Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27829d7a4b | ||
|
|
4d09227bed | ||
|
|
16415299ae | ||
|
|
dfc9a4efdd | ||
|
|
254c6da4ca | ||
|
|
81063419de | ||
|
|
fee7da5aad | ||
|
|
66b4908686 | ||
|
|
9e6e9eab87 | ||
|
|
41606eacf0 | ||
|
|
795970b524 | ||
|
|
5b52413d97 | ||
|
|
3c17476809 | ||
|
|
874a2b19df | ||
|
|
a9c862fe96 | ||
|
|
cbd53ed2a3 | ||
|
|
c2b94ea3bd | ||
|
|
6189c31af2 | ||
|
|
a0dce5d4a6 | ||
|
|
dcaf25ae57 | ||
|
|
3fd5e1128b | ||
|
|
cb8c06e152 | ||
|
|
cabc82e1df | ||
|
|
91191d6603 | ||
|
|
17e98090ad | ||
|
|
ab371a53be | ||
|
|
67706e4db3 | ||
|
|
53aaf06d6c | ||
|
|
ac8e9c0dfc | ||
|
|
f4bbe62a1d | ||
|
|
57e131a16e | ||
|
|
ea6f9e138c | ||
|
|
5177ce2028 | ||
|
|
9f44112479 | ||
|
|
6999f362a3 | ||
|
|
fc546c2430 | ||
|
|
f7e4953038 | ||
|
|
922376fa06 | ||
|
|
3d4ca46c9b | ||
|
|
1d8f203f5b | ||
|
|
41d079a806 | ||
|
|
93c95959d3 | ||
|
|
e7300429f8 | ||
|
|
c7743d082a | ||
|
|
56a4fe905d | ||
|
|
b17775307f |
18
.github/workflows/build.yml
vendored
18
.github/workflows/build.yml
vendored
@@ -121,11 +121,17 @@ jobs:
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Prepare node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh prepare x64
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-x64
|
||||
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify x64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -155,7 +161,9 @@ jobs:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y curl build-essential python3 git libfuse2 file rpm
|
||||
apt-get install -y curl build-essential python3 git libfuse2 file rpm \
|
||||
libglib2.0-0 libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 \
|
||||
libatk-bridge2.0-0 libdrm2 libgbm1 libx11-xcb1 libxcb-dri3-0
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y nodejs
|
||||
|
||||
@@ -176,12 +184,20 @@ jobs:
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Prepare node-pty Linux runtime
|
||||
env:
|
||||
npm_config_arch: arm64
|
||||
run: bash scripts/ensure-node-pty-linux.sh prepare arm64
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: arm64
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-arm64
|
||||
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify arm64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
35
App.tsx
35
App.tsx
@@ -28,6 +28,7 @@ import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
import { classifyLocalShellType } from './lib/localShell';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
|
||||
import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
import type { SftpView as SftpViewComponent } from './components/SftpView';
|
||||
@@ -657,6 +658,24 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const addConnectionLogRef = useRef(addConnectionLog);
|
||||
addConnectionLogRef.current = addConnectionLog;
|
||||
|
||||
const createLocalTerminalWithCurrentShell = useCallback(() => {
|
||||
return createLocalTerminal({
|
||||
shellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
|
||||
});
|
||||
}, [createLocalTerminal, terminalSettings.localShell]);
|
||||
|
||||
const splitSessionWithCurrentShell = useCallback((sessionId: string, direction: 'horizontal' | 'vertical') => {
|
||||
return splitSession(sessionId, direction, {
|
||||
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
|
||||
});
|
||||
}, [splitSession, terminalSettings.localShell]);
|
||||
|
||||
const copySessionWithCurrentShell = useCallback((sessionId: string) => {
|
||||
return copySession(sessionId, {
|
||||
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
|
||||
});
|
||||
}, [copySession, terminalSettings.localShell]);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
switch (action) {
|
||||
@@ -728,7 +747,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: systemInfoRef.current.hostname,
|
||||
saved: false,
|
||||
});
|
||||
createLocalTerminal();
|
||||
createLocalTerminalWithCurrentShell();
|
||||
break;
|
||||
case 'openHosts':
|
||||
setActiveTabId('vault');
|
||||
@@ -767,7 +786,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeSession && !activeSession.workspaceId) {
|
||||
// Standalone session - split it
|
||||
splitSession(activeSession.id, 'horizontal');
|
||||
splitSessionWithCurrentShell(activeSession.id, 'horizontal');
|
||||
} else if (activeWs) {
|
||||
// In a workspace - need to determine focused session
|
||||
// For now, we'll need the terminal to handle this via context menu
|
||||
@@ -782,7 +801,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeSession && !activeSession.workspaceId) {
|
||||
// Standalone session - split it
|
||||
splitSession(activeSession.id, 'vertical');
|
||||
splitSessionWithCurrentShell(activeSession.id, 'vertical');
|
||||
} else if (activeWs) {
|
||||
// In a workspace - need to determine focused session
|
||||
if (IS_DEV) console.log('[Hotkey] Split vertical in workspace - use context menu on specific terminal');
|
||||
@@ -822,7 +841,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminal, splitSession, moveFocusInWorkspace, toggleBroadcast]);
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast]);
|
||||
|
||||
// Callback for terminal to invoke app-level hotkey actions
|
||||
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
@@ -968,7 +987,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Wrapper to create local terminal with logging
|
||||
const handleCreateLocalTerminal = useCallback(() => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const sessionId = createLocalTerminal();
|
||||
const sessionId = createLocalTerminalWithCurrentShell();
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: '',
|
||||
@@ -981,7 +1000,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: hostname,
|
||||
saved: false,
|
||||
});
|
||||
}, [addConnectionLog, createLocalTerminal]);
|
||||
}, [addConnectionLog, createLocalTerminalWithCurrentShell]);
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => {
|
||||
@@ -1204,7 +1223,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
isMacClient={isMacClient}
|
||||
onCloseSession={closeSession}
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySession}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
@@ -1298,7 +1317,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||
onSplitSession={splitSession}
|
||||
onSplitSession={splitSessionWithCurrentShell}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
updateHosts={updateHosts}
|
||||
|
||||
46
README.md
46
README.md
@@ -5,13 +5,13 @@
|
||||
<h1 align="center">Netcatty</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong><br/>
|
||||
<strong>🔥 AI-Powered SSH Client, SFTP Browser & Terminal Manager 🚀</strong><br/>
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
|
||||
Split terminals, Vault views, SFTP workflows, custom themes, and keyword highlighting — all in one.
|
||||
🔥 Built-in AI Agent · Split terminals · Vault views · SFTP workflows · Custom themes — all in one.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -42,10 +42,52 @@
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
|
||||
---
|
||||
|
||||
<a name="catty-agent"></a>
|
||||
# 🔥 Catty Agent — Your IT Ops AI Partner
|
||||
|
||||
> 🚀 **Boost your IT ops daily work with AI power.** Catty Agent is the built-in AI assistant that understands your servers, executes commands, and handles complex multi-host operations — all through natural conversation.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/ai-feature.png" alt="Catty Agent Interface" width="800">
|
||||
</p>
|
||||
|
||||
### 🔥 What can Catty Agent do?
|
||||
|
||||
- 🚀 **Natural language server management** — just tell it what you need, no more memorizing commands
|
||||
- 🔥 **Real-time server diagnostics** — check status, inspect logs, monitor resources through conversation
|
||||
- 🚀 **Multi-host orchestration** — coordinate tasks across multiple servers simultaneously
|
||||
- 🔥 **Intelligent context awareness** — understands your server environment and provides tailored responses
|
||||
- 🚀 **One-click complex operations** — set up clusters, deploy services, and more with simple instructions
|
||||
|
||||
### 🎬 AI in Action
|
||||
|
||||
#### 🔥 Single Host — Intelligent Server Diagnostics
|
||||
|
||||
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
|
||||
|
||||
|
||||
|
||||
|
||||
#### 🚀 Multi-Host — Docker Swarm Cluster Setup
|
||||
|
||||
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
|
||||
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/282027aa-5c9e-4bb1-b2c3-5eea9df2b203
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Contents <!-- omit in toc -->
|
||||
|
||||
- [🔥 Catty Agent — AI Partner](#catty-agent)
|
||||
- [What is Netcatty](#what-is-netcatty)
|
||||
- [Why Netcatty](#why-netcatty)
|
||||
- [Features](#features)
|
||||
|
||||
@@ -5,6 +5,9 @@ const en: Messages = {
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.close': 'Close',
|
||||
'common.reset': 'Reset',
|
||||
'common.zoomIn': 'Zoom in',
|
||||
'common.zoomOut': 'Zoom out',
|
||||
'common.settings': 'Settings',
|
||||
'common.search': 'Search',
|
||||
'common.searchPlaceholder': 'Search...',
|
||||
@@ -30,6 +33,7 @@ const en: Messages = {
|
||||
'common.back': 'Back',
|
||||
'common.apply': 'Apply',
|
||||
'common.use': 'Use',
|
||||
'common.useGlobal': 'Use global',
|
||||
'common.saveChanges': 'Save Changes',
|
||||
'common.advanced': 'Advanced',
|
||||
'common.left': 'Left',
|
||||
@@ -615,6 +619,7 @@ const en: Messages = {
|
||||
'sftp.transfers': 'Transfers',
|
||||
'sftp.transfers.active': '{count} active',
|
||||
'sftp.transfers.clearCompleted': 'Clear completed',
|
||||
'sftp.transfers.calculatingTotal': 'Calculating total size...',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.encoding.label': 'Filename Encoding',
|
||||
@@ -839,6 +844,29 @@ const en: Messages = {
|
||||
'hostDetails.section.credentials': 'Credentials',
|
||||
'hostDetails.section.portCredentials': 'Port & Credentials',
|
||||
'hostDetails.section.appearance': 'Appearance',
|
||||
'hostDetails.distro.title': 'Linux Distribution',
|
||||
'hostDetails.distro.desc': 'Auto-detect on connect, or override the distro icon manually.',
|
||||
'hostDetails.distro.mode': 'Source',
|
||||
'hostDetails.distro.mode.auto': 'Auto-detect',
|
||||
'hostDetails.distro.mode.manual': 'Manual override',
|
||||
'hostDetails.distro.detectedLabel': 'Current',
|
||||
'hostDetails.distro.manualLabel': 'Override',
|
||||
'hostDetails.distro.pending': 'Detect after first connection',
|
||||
'hostDetails.distro.unknown': 'Unknown',
|
||||
'hostDetails.distro.option.linux': 'Generic Linux',
|
||||
'hostDetails.distro.option.ubuntu': 'Ubuntu',
|
||||
'hostDetails.distro.option.debian': 'Debian',
|
||||
'hostDetails.distro.option.centos': 'CentOS',
|
||||
'hostDetails.distro.option.rocky': 'Rocky Linux',
|
||||
'hostDetails.distro.option.fedora': 'Fedora',
|
||||
'hostDetails.distro.option.arch': 'Arch Linux',
|
||||
'hostDetails.distro.option.alpine': 'Alpine',
|
||||
'hostDetails.distro.option.amazon': 'Amazon Linux',
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.username.placeholder': 'Username',
|
||||
'hostDetails.password.placeholder': 'Password',
|
||||
@@ -1055,6 +1083,7 @@ const en: Messages = {
|
||||
'terminal.progress.disconnected': 'Disconnected',
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
'terminal.progress.startOver': 'Start over',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Dismiss disconnected notice',
|
||||
'terminal.connection.chainOf': 'Chain {current} of {total}',
|
||||
'terminal.connection.showLogs': 'Show logs',
|
||||
'terminal.connection.hideLogs': 'Hide logs',
|
||||
@@ -1067,6 +1096,8 @@ const en: Messages = {
|
||||
'terminal.themeModal.tab.theme': 'Theme',
|
||||
'terminal.themeModal.tab.font': 'Font',
|
||||
'terminal.themeModal.tab.custom': 'Custom',
|
||||
'terminal.themeModal.globalTheme': 'Global Theme',
|
||||
'terminal.themeModal.globalFont': 'Global Font',
|
||||
'terminal.themeModal.fontSize': 'Font Size',
|
||||
'terminal.themeModal.livePreview': 'Live Preview',
|
||||
'terminal.themeModal.themeType': '{type} theme',
|
||||
@@ -1555,7 +1586,7 @@ const en: Messages = {
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-code-acp for ACP protocol streaming.",
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-agent-acp for ACP protocol streaming.",
|
||||
'ai.claude.detecting': 'Detecting...',
|
||||
'ai.claude.detected': 'Detected',
|
||||
'ai.claude.notFound': 'Not found',
|
||||
@@ -1572,7 +1603,6 @@ const en: Messages = {
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
|
||||
'ai.chat.toolDenied': 'Action was rejected by the user.',
|
||||
'ai.chat.toolApprovalTitle': 'Permission Required',
|
||||
'ai.chat.toolApproved': 'Approved',
|
||||
'ai.chat.toolApprovalHint': 'Press Enter to approve, Escape to reject',
|
||||
'ai.chat.approve': 'Approve',
|
||||
|
||||
@@ -5,6 +5,9 @@ const zhCN: Messages = {
|
||||
'common.save': '保存',
|
||||
'common.cancel': '取消',
|
||||
'common.close': '关闭',
|
||||
'common.reset': '重置',
|
||||
'common.zoomIn': '放大',
|
||||
'common.zoomOut': '缩小',
|
||||
'common.settings': '设置',
|
||||
'common.search': '搜索',
|
||||
'common.connect': '连接',
|
||||
@@ -20,6 +23,7 @@ const zhCN: Messages = {
|
||||
'common.back': '返回',
|
||||
'common.apply': '应用',
|
||||
'common.use': '使用',
|
||||
'common.useGlobal': '跟随全局',
|
||||
'common.left': '左侧',
|
||||
'common.right': '右侧',
|
||||
'common.more': '更多',
|
||||
@@ -442,6 +446,7 @@ const zhCN: Messages = {
|
||||
'sftp.transfers': '传输',
|
||||
'sftp.transfers.active': '{count} 个进行中',
|
||||
'sftp.transfers.clearCompleted': '清除已完成',
|
||||
'sftp.transfers.calculatingTotal': '正在统计总大小...',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.encoding.label': '文件名编码',
|
||||
@@ -531,6 +536,29 @@ const zhCN: Messages = {
|
||||
'hostDetails.section.credentials': '凭据',
|
||||
'hostDetails.section.portCredentials': '端口与凭据',
|
||||
'hostDetails.section.appearance': '外观',
|
||||
'hostDetails.distro.title': 'Linux 发行版',
|
||||
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
|
||||
'hostDetails.distro.mode': '来源',
|
||||
'hostDetails.distro.mode.auto': '自动探测',
|
||||
'hostDetails.distro.mode.manual': '手动覆盖',
|
||||
'hostDetails.distro.detectedLabel': '当前值',
|
||||
'hostDetails.distro.manualLabel': '手动指定',
|
||||
'hostDetails.distro.pending': '首次连接后自动探测',
|
||||
'hostDetails.distro.unknown': '未知',
|
||||
'hostDetails.distro.option.linux': '通用 Linux',
|
||||
'hostDetails.distro.option.ubuntu': 'Ubuntu',
|
||||
'hostDetails.distro.option.debian': 'Debian',
|
||||
'hostDetails.distro.option.centos': 'CentOS',
|
||||
'hostDetails.distro.option.rocky': 'Rocky Linux',
|
||||
'hostDetails.distro.option.fedora': 'Fedora',
|
||||
'hostDetails.distro.option.arch': 'Arch Linux',
|
||||
'hostDetails.distro.option.alpine': 'Alpine',
|
||||
'hostDetails.distro.option.amazon': 'Amazon Linux',
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.username.placeholder': '用户名',
|
||||
'hostDetails.password.placeholder': '密码',
|
||||
@@ -719,6 +747,7 @@ const zhCN: Messages = {
|
||||
'terminal.progress.disconnected': '已断开',
|
||||
'terminal.progress.cancelling': '正在取消...',
|
||||
'terminal.progress.startOver': '重新开始',
|
||||
'terminal.connection.dismissDisconnectedDialog': '关闭断连提示',
|
||||
'terminal.connection.chainOf': 'Chain {current} / {total}',
|
||||
'terminal.connection.showLogs': '显示日志',
|
||||
'terminal.connection.hideLogs': '隐藏日志',
|
||||
@@ -731,6 +760,8 @@ const zhCN: Messages = {
|
||||
'terminal.themeModal.tab.theme': '主题',
|
||||
'terminal.themeModal.tab.font': '字体',
|
||||
'terminal.themeModal.tab.custom': '自定义',
|
||||
'terminal.themeModal.globalTheme': '全局主题',
|
||||
'terminal.themeModal.globalFont': '全局字体',
|
||||
'terminal.themeModal.fontSize': '字体大小',
|
||||
'terminal.themeModal.livePreview': '实时预览',
|
||||
'terminal.themeModal.themeType': '{type} 主题',
|
||||
@@ -1570,7 +1601,7 @@ const zhCN: Messages = {
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-code-acp 进行 ACP 协议流式传输。',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-agent-acp 进行 ACP 协议流式传输。',
|
||||
'ai.claude.detecting': '检测中...',
|
||||
'ai.claude.detected': '已检测到',
|
||||
'ai.claude.notFound': '未找到',
|
||||
@@ -1587,7 +1618,6 @@ const zhCN: Messages = {
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
'ai.chat.toolDenied': '操作已被用户拒绝。',
|
||||
'ai.chat.toolApprovalTitle': '需要权限确认',
|
||||
'ai.chat.toolApproved': '已批准',
|
||||
'ai.chat.toolApprovalHint': '按回车批准,按 Esc 拒绝',
|
||||
'ai.chat.approve': '批准',
|
||||
|
||||
@@ -38,7 +38,9 @@ export const useSessionState = () => {
|
||||
// Log views: stores open log replay tabs
|
||||
const [logViews, setLogViews] = useState<LogView[]>([]);
|
||||
|
||||
const createLocalTerminal = useCallback(() => {
|
||||
const createLocalTerminal = useCallback((options?: {
|
||||
shellType?: TerminalSession['shellType'];
|
||||
}) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const localHostId = `local-${sessionId}`;
|
||||
const newSession: TerminalSession = {
|
||||
@@ -48,6 +50,8 @@ export const useSessionState = () => {
|
||||
hostname: 'localhost',
|
||||
username: 'local',
|
||||
status: 'connecting',
|
||||
protocol: 'local',
|
||||
shellType: options?.shellType,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
@@ -414,11 +418,17 @@ export const useSessionState = () => {
|
||||
// direction: 'horizontal' = split top/bottom, 'vertical' = split left/right
|
||||
const splitSession = useCallback((
|
||||
sessionId: string,
|
||||
direction: SplitDirection
|
||||
direction: SplitDirection,
|
||||
options?: {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
},
|
||||
) => {
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
if (!session) return prevSessions;
|
||||
const nextShellType = session.protocol === 'local'
|
||||
? options?.localShellType
|
||||
: session.shellType;
|
||||
|
||||
// If session is already in a workspace, split within that workspace
|
||||
if (session.workspaceId) {
|
||||
@@ -434,6 +444,7 @@ export const useSessionState = () => {
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
};
|
||||
|
||||
// Add pane to existing workspace
|
||||
@@ -464,6 +475,7 @@ export const useSessionState = () => {
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
};
|
||||
|
||||
const hint: SplitHint = {
|
||||
@@ -615,10 +627,15 @@ export const useSessionState = () => {
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Copy a session - creates a new session with the same host connection
|
||||
const copySession = useCallback((sessionId: string) => {
|
||||
const copySession = useCallback((sessionId: string, options?: {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
}) => {
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
if (!session) return prevSessions;
|
||||
const nextShellType = session.protocol === 'local'
|
||||
? options?.localShellType
|
||||
: session.shellType;
|
||||
|
||||
// Create a new session with the same connection info
|
||||
const newSession: TerminalSession = {
|
||||
@@ -631,6 +648,7 @@ export const useSessionState = () => {
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
serialConfig: session.serialConfig,
|
||||
};
|
||||
|
||||
|
||||
@@ -197,6 +197,12 @@ export const useSftpBackend = () => {
|
||||
return bridge.showSaveDialog(defaultPath, filters);
|
||||
}, []);
|
||||
|
||||
const selectDirectory = async (title?: string, defaultPath?: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectDirectory) return null;
|
||||
return bridge.selectDirectory(title, defaultPath);
|
||||
};
|
||||
|
||||
const downloadSftpToTempAndOpen = useCallback(async (
|
||||
sftpId: string,
|
||||
remotePath: string,
|
||||
@@ -278,6 +284,7 @@ export const useSftpBackend = () => {
|
||||
onTransferProgress,
|
||||
selectApplication,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
downloadSftpToTempAndOpen,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*
|
||||
* Core logic is decomposed into focused hooks:
|
||||
* - useAIChatStreaming: stream processing, abort management, agent sub-flows
|
||||
* - useToolApproval: tool approval workflow, timeouts, resume logic
|
||||
* - useConversationExport: export formats & object URL lifecycle
|
||||
*/
|
||||
|
||||
@@ -40,7 +39,7 @@ import ChatInput from './ai/ChatInput';
|
||||
import ChatMessageList from './ai/ChatMessageList';
|
||||
import ConversationExport from './ai/ConversationExport';
|
||||
import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming';
|
||||
import { useToolApproval } from './ai/hooks/useToolApproval';
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
|
||||
@@ -102,6 +101,8 @@ interface AIChatSidePanelProps {
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
resolveExecutorContext?: (scope: {
|
||||
@@ -214,7 +215,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
streamingSessionIds,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
sendToCattyAgent,
|
||||
sendToExternalAgent,
|
||||
reportStreamError,
|
||||
@@ -225,20 +225,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
updateMessageById,
|
||||
});
|
||||
|
||||
// ── Tool approval hook ──
|
||||
const {
|
||||
pendingApprovalContextRef,
|
||||
setPendingApproval,
|
||||
handleApprovalResponse,
|
||||
} = useToolApproval({
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
t,
|
||||
});
|
||||
|
||||
// Per-scope active session ID
|
||||
const activeSessionId = activeSessionIdMap[scopeKey] ?? null;
|
||||
@@ -260,7 +246,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
// Proactively sync terminal session metadata to main process whenever scope or sessions change
|
||||
useEffect(() => {
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiMcpUpdateSessions && terminalSessions.length > 0) {
|
||||
if (bridge?.aiMcpUpdateSessions) {
|
||||
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
|
||||
}
|
||||
}, [terminalSessions, scopeKey, activeSessionId]);
|
||||
@@ -530,7 +516,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
setPendingApproval,
|
||||
autoTitleSession,
|
||||
}, attachments.length > 0 ? attachments : undefined);
|
||||
}
|
||||
@@ -541,7 +526,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setStreamingForScope, setInputValue, clearFiles,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope, setPendingApproval,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
|
||||
]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
@@ -556,11 +541,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'cancelled' : msg.executionStatus,
|
||||
}));
|
||||
// Also clear any pending approval (clears timeout too via setPendingApproval)
|
||||
if (pendingApprovalContextRef.current?.sessionId === activeSessionId) {
|
||||
setPendingApproval(null);
|
||||
}
|
||||
}, [activeSessionId, setStreamingForScope, updateLastMessage, setPendingApproval, abortControllersRef, pendingApprovalContextRef]);
|
||||
// Clear pending approvals for this session (so tool execute functions don't hang)
|
||||
clearAllPendingApprovals(activeSessionId);
|
||||
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
@@ -653,16 +636,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
onApprove={(messageId) => void handleApprovalResponse(messageId, true, {
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
webSearchConfig,
|
||||
})}
|
||||
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
webSearchConfig,
|
||||
})}
|
||||
activeSessionId={activeSessionId}
|
||||
/>
|
||||
|
||||
{/* Recent sessions (Zed-style, shown when no messages) */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Server, Usb } from "lucide-react";
|
||||
import React, { memo } from "react";
|
||||
import { normalizeDistroId } from "../domain/host";
|
||||
import { getEffectiveHostDistro } from "../domain/host";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host } from "../types";
|
||||
|
||||
@@ -58,8 +58,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
className,
|
||||
size = "md",
|
||||
}) => {
|
||||
const distro =
|
||||
normalizeDistroId(host.distro) || (host.distro || "").toLowerCase();
|
||||
const distro = getEffectiveHostDistro(host);
|
||||
const logo = DISTRO_LOGOS[distro];
|
||||
const [errored, setErrored] = React.useState(false);
|
||||
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
|
||||
@@ -106,7 +105,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
>
|
||||
<img
|
||||
src={logo}
|
||||
alt={host.distro || host.os}
|
||||
alt={distro || host.os}
|
||||
className={cn("object-contain invert brightness-0", iconSize)}
|
||||
onError={() => setErrored(true)}
|
||||
/>
|
||||
|
||||
@@ -28,10 +28,20 @@ import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { getEffectiveHostDistro, LINUX_DISTRO_OPTIONS } from "../domain/host";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import {
|
||||
clearHostFontSizeOverride,
|
||||
clearHostThemeOverride,
|
||||
hasHostFontSizeOverride,
|
||||
hasHostThemeOverride,
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { cn } from "../lib/utils";
|
||||
import { EnvVar, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
|
||||
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
import {
|
||||
@@ -69,6 +79,8 @@ type SubPanel =
|
||||
| "theme-select"
|
||||
| "telnet-theme-select";
|
||||
|
||||
const LINUX_DISTRO_OPTION_IDS = [...LINUX_DISTRO_OPTIONS];
|
||||
|
||||
interface HostDetailsPanelProps {
|
||||
initialData?: Host | null;
|
||||
availableKeys: SSHKey[];
|
||||
@@ -117,6 +129,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
charset: "UTF-8",
|
||||
theme: terminalThemeId,
|
||||
fontSize: terminalFontSize,
|
||||
distroMode: "auto",
|
||||
createdAt: Date.now(),
|
||||
group: defaultGroup || undefined, // Pre-fill with current navigation group
|
||||
} as Host),
|
||||
@@ -179,6 +192,56 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const effectiveThemeId = useMemo(
|
||||
() => resolveHostTerminalThemeId(form, terminalThemeId),
|
||||
[form, terminalThemeId],
|
||||
);
|
||||
const effectiveFontSize = useMemo(
|
||||
() => resolveHostTerminalFontSize(form, terminalFontSize),
|
||||
[form, terminalFontSize],
|
||||
);
|
||||
const hasEffectiveThemeOverride = useMemo(
|
||||
() => hasHostThemeOverride(form),
|
||||
[form],
|
||||
);
|
||||
const hasEffectiveFontSizeOverride = useMemo(
|
||||
() => hasHostFontSizeOverride(form),
|
||||
[form],
|
||||
);
|
||||
const effectiveTelnetThemeId =
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || effectiveThemeId;
|
||||
const distroOptions = useMemo(
|
||||
() =>
|
||||
LINUX_DISTRO_OPTION_IDS.map((value) => ({
|
||||
value,
|
||||
label: t(`hostDetails.distro.option.${value}`),
|
||||
icon: DISTRO_LOGOS[value],
|
||||
bgClass: DISTRO_COLORS[value] || DISTRO_COLORS.default,
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
const getDistroOptionLabel = useCallback(
|
||||
(value?: string) =>
|
||||
distroOptions.find((option) => option.value === value)?.label ||
|
||||
value ||
|
||||
t("hostDetails.distro.pending"),
|
||||
[distroOptions, t],
|
||||
);
|
||||
|
||||
const effectiveFormDistro = getEffectiveHostDistro(form);
|
||||
|
||||
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
distroMode: mode,
|
||||
manualDistro:
|
||||
mode === "manual"
|
||||
? prev.manualDistro || getEffectiveHostDistro(prev) || "linux"
|
||||
: prev.manualDistro,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateProxyConfig = useCallback(
|
||||
(field: keyof ProxyConfig, value: string | number) => {
|
||||
setForm((prev) => ({
|
||||
@@ -298,6 +361,27 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
password: form.savePassword === false ? undefined : form.password,
|
||||
managedSourceId: finalManagedSourceId,
|
||||
};
|
||||
const preserveLegacyTheme = initialData?.theme != null && cleaned.themeOverride !== false;
|
||||
const preserveLegacyFontFamily = initialData?.fontFamily != null && cleaned.fontFamilyOverride !== false;
|
||||
const preserveLegacyFontSize = initialData?.fontSize != null && cleaned.fontSizeOverride !== false;
|
||||
|
||||
if (cleaned.themeOverride === false) {
|
||||
delete cleaned.theme;
|
||||
} else if (preserveLegacyTheme && cleaned.theme == null) {
|
||||
cleaned.theme = initialData?.theme;
|
||||
}
|
||||
|
||||
if (cleaned.fontFamilyOverride === false) {
|
||||
delete cleaned.fontFamily;
|
||||
} else if (preserveLegacyFontFamily && cleaned.fontFamily == null) {
|
||||
cleaned.fontFamily = initialData?.fontFamily;
|
||||
}
|
||||
|
||||
if (cleaned.fontSizeOverride === false) {
|
||||
delete cleaned.fontSize;
|
||||
} else if (preserveLegacyFontSize && cleaned.fontSize == null) {
|
||||
cleaned.fontSize = initialData?.fontSize;
|
||||
}
|
||||
onSave(cleaned);
|
||||
};
|
||||
|
||||
@@ -478,9 +562,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
return (
|
||||
<ThemeSelectPanel
|
||||
open={true}
|
||||
selectedThemeId={form.theme || "flexoki-dark"}
|
||||
selectedThemeId={effectiveThemeId}
|
||||
onSelect={(themeId) => {
|
||||
update("theme", themeId);
|
||||
setForm((prev) => ({ ...prev, theme: themeId, themeOverride: true }));
|
||||
setActiveSubPanel("none");
|
||||
}}
|
||||
onClose={onCancel}
|
||||
@@ -495,11 +579,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
return (
|
||||
<ThemeSelectPanel
|
||||
open={true}
|
||||
selectedThemeId={
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme ||
|
||||
form.theme ||
|
||||
"flexoki-dark"
|
||||
}
|
||||
selectedThemeId={effectiveTelnetThemeId}
|
||||
onSelect={(themeId) => {
|
||||
// Update telnet protocol theme
|
||||
const telnetConfig = form.protocols?.find(
|
||||
@@ -1103,6 +1183,113 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.os === "linux" && (
|
||||
<div className="space-y-2 rounded-lg border border-border/70 bg-secondary/30 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Globe size={14} className="mt-0.5 text-muted-foreground" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
|
||||
<Select
|
||||
value={form.distroMode || "auto"}
|
||||
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
|
||||
<span className="truncate whitespace-nowrap pr-2 text-left">
|
||||
{form.distroMode === "manual"
|
||||
? t("hostDetails.distro.mode.manual")
|
||||
: t("hostDetails.distro.mode.auto")}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
|
||||
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.distroMode === "manual" ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
|
||||
<Select
|
||||
value={form.manualDistro}
|
||||
onValueChange={(val) => update("manualDistro", val)}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
|
||||
{(() => {
|
||||
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
|
||||
return selectedOption ? (
|
||||
<div className="flex min-w-0 items-center gap-2 pr-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
selectedOption.bgClass,
|
||||
)}
|
||||
>
|
||||
{selectedOption.icon ? (
|
||||
<img
|
||||
src={selectedOption.icon}
|
||||
alt={selectedOption.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
|
||||
);
|
||||
})()}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[14rem]">
|
||||
{distroOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
option.bgClass,
|
||||
)}
|
||||
>
|
||||
{option.icon ? (
|
||||
<img
|
||||
src={option.icon}
|
||||
alt={option.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
|
||||
{effectiveFormDistro
|
||||
? getDistroOptionLabel(effectiveFormDistro)
|
||||
: t("hostDetails.distro.unknown")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSH Theme Selection */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1113,15 +1300,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
|
||||
style={{
|
||||
backgroundColor:
|
||||
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.background || "#100F0F",
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
|
||||
color:
|
||||
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.foreground || "#CECDC3",
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
|
||||
}}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<div
|
||||
style={{
|
||||
color: customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.green,
|
||||
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
|
||||
}}
|
||||
>
|
||||
$
|
||||
@@ -1129,9 +1316,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm flex-1">
|
||||
{customThemeStore.getThemeById(form.theme || "flexoki-dark")?.name || "Flexoki Dark"}
|
||||
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
|
||||
</span>
|
||||
</button>
|
||||
{hasEffectiveThemeOverride && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-primary"
|
||||
onClick={() => setForm((prev) => clearHostThemeOverride(prev))}
|
||||
>
|
||||
{t("common.useGlobal")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Font Size */}
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1140,11 +1337,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if ((form.fontSize || 14) > MIN_FONT_SIZE) {
|
||||
update("fontSize", (form.fontSize || 14) - 1);
|
||||
if (effectiveFontSize > MIN_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: effectiveFontSize - 1,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={(form.fontSize || 14) <= MIN_FONT_SIZE}
|
||||
disabled={effectiveFontSize <= MIN_FONT_SIZE}
|
||||
className="px-2 h-8"
|
||||
>
|
||||
-
|
||||
@@ -1153,25 +1354,43 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
type="number"
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
value={form.fontSize || 14}
|
||||
value={effectiveFontSize}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (val >= MIN_FONT_SIZE && val <= MAX_FONT_SIZE) {
|
||||
update("fontSize", val);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: val,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className="w-16 text-center h-8"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">pt</span>
|
||||
{hasEffectiveFontSizeOverride && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-8 text-primary"
|
||||
onClick={() => setForm((prev) => clearHostFontSizeOverride(prev))}
|
||||
>
|
||||
{t("common.useGlobal")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if ((form.fontSize || 14) < MAX_FONT_SIZE) {
|
||||
update("fontSize", (form.fontSize || 14) + 1);
|
||||
if (effectiveFontSize < MAX_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: effectiveFontSize + 1,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={(form.fontSize || 14) >= MAX_FONT_SIZE}
|
||||
disabled={effectiveFontSize >= MAX_FONT_SIZE}
|
||||
className="px-2 h-8"
|
||||
>
|
||||
+
|
||||
@@ -1494,21 +1713,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
|
||||
style={{
|
||||
backgroundColor:
|
||||
customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.colors.background || "#100F0F",
|
||||
customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.background || "#100F0F",
|
||||
color:
|
||||
customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.colors.foreground || "#CECDC3",
|
||||
customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.foreground || "#CECDC3",
|
||||
}}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<div
|
||||
style={{
|
||||
color: customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.colors.green,
|
||||
color: customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.green,
|
||||
}}
|
||||
>
|
||||
$
|
||||
@@ -1516,9 +1729,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm flex-1">
|
||||
{customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.name || "Flexoki Dark"}
|
||||
{customThemeStore.getThemeById(effectiveTelnetThemeId)?.name || "Flexoki Dark"}
|
||||
</span>
|
||||
</button>
|
||||
</Card>
|
||||
|
||||
@@ -1,817 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useSftpModalTransfers } from "./sftp-modal/hooks/useSftpModalTransfers";
|
||||
import { Host, RemoteFile, SftpFilenameEncoding } from "../types";
|
||||
import { filterHiddenFiles } from "./sftp";
|
||||
import { DropEntry } from "../lib/sftpFileUtils";
|
||||
import FileOpenerDialog from "./FileOpenerDialog";
|
||||
import TextEditorModal from "./TextEditorModal";
|
||||
import { SftpModalFileList } from "./sftp-modal/SftpModalFileList";
|
||||
import { SftpModalDialogs } from "./sftp-modal/SftpModalDialogs";
|
||||
import { SftpModalFooter } from "./sftp-modal/SftpModalFooter";
|
||||
import { SftpModalHeader } from "./sftp-modal/SftpModalHeader";
|
||||
import { SftpModalUploadTasks } from "./sftp-modal/SftpModalUploadTasks";
|
||||
import { formatBytes, formatDate } from "./sftp-modal/utils";
|
||||
import { useSftpModalSorting } from "./sftp-modal/hooks/useSftpModalSorting";
|
||||
import { useSftpModalVirtualList } from "./sftp-modal/hooks/useSftpModalVirtualList";
|
||||
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";
|
||||
|
||||
interface SFTPModalProps {
|
||||
host: Host;
|
||||
credentials: {
|
||||
username?: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
};
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Initial path to open in SFTP. If not accessible, falls back to home directory. */
|
||||
initialPath?: string;
|
||||
/** Initial entries to upload when SFTP modal opens. Used for drag-and-drop to terminal. */
|
||||
initialEntriesToUpload?: DropEntry[];
|
||||
/** Callback to update the host (e.g. for bookmark persistence). */
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
}
|
||||
|
||||
const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
host,
|
||||
credentials,
|
||||
open,
|
||||
onClose,
|
||||
initialPath,
|
||||
initialEntriesToUpload,
|
||||
onUpdateHost,
|
||||
}) => {
|
||||
const {
|
||||
openSftp,
|
||||
closeSftp: closeSftpBackend,
|
||||
listSftp,
|
||||
readSftp,
|
||||
writeSftpBinaryWithProgress,
|
||||
writeSftpBinary,
|
||||
writeSftp,
|
||||
deleteSftp,
|
||||
mkdirSftp,
|
||||
renameSftp,
|
||||
chmodSftp,
|
||||
statSftp,
|
||||
listLocalDir,
|
||||
readLocalFile,
|
||||
writeLocalFile,
|
||||
deleteLocalFile,
|
||||
mkdirLocal,
|
||||
getHomeDir,
|
||||
selectApplication,
|
||||
downloadSftpToTempAndOpen,
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
} = useSftpBackend();
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
} = useSettingsState();
|
||||
const isLocalSession = host.protocol === "local";
|
||||
const [filenameEncoding, setFilenameEncoding] = useState<SftpFilenameEncoding>(
|
||||
host.sftpEncoding ?? "auto"
|
||||
);
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const navigatingRef = useRef(false);
|
||||
const clearSelection = useCallback(() => setSelectedFiles(new Set()), []);
|
||||
|
||||
// Update filenameEncoding when host changes
|
||||
useEffect(() => {
|
||||
setFilenameEncoding(host.sftpEncoding ?? "auto");
|
||||
}, [host.id, host.sftpEncoding]);
|
||||
|
||||
const listSftpWithEncoding = useCallback(
|
||||
(sftpId: string, path: string) => listSftp(sftpId, path, filenameEncoding),
|
||||
[listSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const readSftpWithEncoding = useCallback(
|
||||
(sftpId: string, path: string) => readSftp(sftpId, path, filenameEncoding),
|
||||
[readSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const writeSftpWithEncoding = useCallback(
|
||||
(sftpId: string, path: string, data: string) =>
|
||||
writeSftp(sftpId, path, data, filenameEncoding),
|
||||
[writeSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const writeSftpBinaryWithEncoding = useCallback(
|
||||
(sftpId: string, path: string, data: ArrayBuffer) =>
|
||||
writeSftpBinary(sftpId, path, data, filenameEncoding),
|
||||
[writeSftpBinary, filenameEncoding],
|
||||
);
|
||||
|
||||
const writeSftpBinaryWithProgressWithEncoding = useCallback(
|
||||
(
|
||||
sftpId: string,
|
||||
path: string,
|
||||
data: ArrayBuffer,
|
||||
transferId: string,
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void,
|
||||
) =>
|
||||
writeSftpBinaryWithProgress(
|
||||
sftpId,
|
||||
path,
|
||||
data,
|
||||
transferId,
|
||||
filenameEncoding,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
),
|
||||
[writeSftpBinaryWithProgress, filenameEncoding],
|
||||
);
|
||||
|
||||
const deleteSftpWithEncoding = useCallback(
|
||||
(sftpId: string, path: string) => deleteSftp(sftpId, path, filenameEncoding),
|
||||
[deleteSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const mkdirSftpWithEncoding = useCallback(
|
||||
(sftpId: string, path: string) => mkdirSftp(sftpId, path, filenameEncoding),
|
||||
[mkdirSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const renameSftpWithEncoding = useCallback(
|
||||
(sftpId: string, oldPath: string, newPath: string) =>
|
||||
renameSftp(sftpId, oldPath, newPath, filenameEncoding),
|
||||
[renameSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const chmodSftpWithEncoding = useCallback(
|
||||
(sftpId: string, path: string, mode: string) =>
|
||||
chmodSftp(sftpId, path, mode, filenameEncoding),
|
||||
[chmodSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const statSftpWithEncoding = useCallback(
|
||||
(sftpId: string, path: string) => statSftp(sftpId, path, filenameEncoding),
|
||||
[statSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const downloadSftpToTempAndOpenWithEncoding = useCallback(
|
||||
(
|
||||
sftpId: string,
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean },
|
||||
) =>
|
||||
downloadSftpToTempAndOpen(sftpId, remotePath, fileName, appPath, {
|
||||
...options,
|
||||
encoding: filenameEncoding,
|
||||
}),
|
||||
[downloadSftpToTempAndOpen, filenameEncoding],
|
||||
);
|
||||
|
||||
const {
|
||||
currentPath,
|
||||
setCurrentPath,
|
||||
currentPathRef,
|
||||
files,
|
||||
loading,
|
||||
setLoading,
|
||||
reconnecting,
|
||||
sessionVersion,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
closeSftpSession,
|
||||
localHomeRef,
|
||||
} = useSftpModalSession({
|
||||
open,
|
||||
host,
|
||||
credentials,
|
||||
initialPath,
|
||||
isLocalSession,
|
||||
t,
|
||||
openSftp,
|
||||
closeSftp: closeSftpBackend,
|
||||
listSftp: listSftpWithEncoding,
|
||||
listLocalDir,
|
||||
getHomeDir,
|
||||
onClearSelection: clearSelection,
|
||||
});
|
||||
|
||||
// Track previous encoding to detect changes
|
||||
const prevEncodingRef = useRef(filenameEncoding);
|
||||
|
||||
// Force reload only when filenameEncoding changes (not on every path change)
|
||||
useEffect(() => {
|
||||
if (!open || isLocalSession) return;
|
||||
// Only force reload if encoding actually changed
|
||||
if (prevEncodingRef.current !== filenameEncoding) {
|
||||
prevEncodingRef.current = filenameEncoding;
|
||||
loadFiles(currentPath, { force: true });
|
||||
}
|
||||
}, [currentPath, filenameEncoding, isLocalSession, loadFiles, open]);
|
||||
|
||||
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
|
||||
|
||||
const { sortField, sortOrder, columnWidths, handleSort, handleResizeStart } =
|
||||
useSftpModalSorting();
|
||||
|
||||
const joinPathForSession = useCallback(
|
||||
(base: string, name: string) => joinPath(base, name, isLocalSession),
|
||||
[isLocalSession],
|
||||
);
|
||||
const isRootPathForSession = useCallback(
|
||||
(path: string) => isRootPath(path, isLocalSession),
|
||||
[isLocalSession],
|
||||
);
|
||||
const getParentPathForSession = useCallback(
|
||||
(path: string) => getParentPath(path, isLocalSession),
|
||||
[isLocalSession],
|
||||
);
|
||||
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
// Prevent double navigation (e.g., from double-click race condition)
|
||||
if (navigatingRef.current) return;
|
||||
navigatingRef.current = true;
|
||||
setCurrentPath(path);
|
||||
// Reset lock after a short delay
|
||||
setTimeout(() => {
|
||||
navigatingRef.current = false;
|
||||
}, 300);
|
||||
}, [navigatingRef, setCurrentPath]);
|
||||
|
||||
const handleUp = () => {
|
||||
if (isRootPathForSession(currentPath)) return;
|
||||
setCurrentPath(getParentPathForSession(currentPath));
|
||||
};
|
||||
|
||||
const {
|
||||
isEditingPath,
|
||||
editingPathValue,
|
||||
setEditingPathValue,
|
||||
pathInputRef,
|
||||
handlePathDoubleClick,
|
||||
handlePathSubmit,
|
||||
handlePathKeyDown,
|
||||
breadcrumbs,
|
||||
visibleBreadcrumbs,
|
||||
hiddenBreadcrumbs,
|
||||
needsBreadcrumbTruncation,
|
||||
breadcrumbPathAtForIndex,
|
||||
rootLabel,
|
||||
rootPath,
|
||||
} = useSftpModalPath({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
localHomePath: localHomeRef.current,
|
||||
onNavigate: handleNavigate,
|
||||
});
|
||||
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
isRenaming,
|
||||
openRenameDialog,
|
||||
handleRename,
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
isChangingPermissions,
|
||||
openPermissionsDialog,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
openFileOpenerDialog,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
handleEditFile,
|
||||
handleSaveTextFile,
|
||||
handleOpenFile,
|
||||
} = useSftpModalFileActions({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath: joinPathForSession,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
readLocalFile,
|
||||
readSftp: readSftpWithEncoding,
|
||||
writeLocalFile,
|
||||
writeSftp: writeSftpWithEncoding,
|
||||
writeSftpBinary: writeSftpBinaryWithEncoding,
|
||||
deleteLocalFile,
|
||||
deleteSftp: deleteSftpWithEncoding,
|
||||
mkdirLocal,
|
||||
mkdirSftp: mkdirSftpWithEncoding,
|
||||
renameSftp: renameSftpWithEncoding,
|
||||
chmodSftp: chmodSftpWithEncoding,
|
||||
statSftp: statSftpWithEncoding,
|
||||
t,
|
||||
sftpAutoSync,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
downloadSftpToTempAndOpen: downloadSftpToTempAndOpenWithEncoding,
|
||||
selectApplication,
|
||||
});
|
||||
|
||||
const {
|
||||
uploading,
|
||||
uploadTasks,
|
||||
dragActive,
|
||||
handleDownload,
|
||||
handleUploadEntries,
|
||||
handleFileSelect,
|
||||
handleFolderSelect,
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
cancelUpload,
|
||||
cancelTask,
|
||||
dismissTask,
|
||||
} = useSftpModalTransfers({
|
||||
currentPath,
|
||||
currentPathRef,
|
||||
isLocalSession,
|
||||
joinPath: joinPathForSession,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
readLocalFile,
|
||||
readSftp: readSftpWithEncoding,
|
||||
writeLocalFile,
|
||||
writeSftpBinaryWithProgress: writeSftpBinaryWithProgressWithEncoding,
|
||||
writeSftpBinary: writeSftpBinaryWithEncoding,
|
||||
writeSftp: writeSftpWithEncoding,
|
||||
mkdirLocal,
|
||||
mkdirSftp: mkdirSftpWithEncoding,
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
listSftp: listSftpWithEncoding,
|
||||
deleteLocalFile,
|
||||
});
|
||||
const hasEverOpenedRef = useRef(false);
|
||||
|
||||
const hasActiveTransferTasks = useMemo(
|
||||
() =>
|
||||
uploadTasks.some(
|
||||
(task) =>
|
||||
task.status === "pending" ||
|
||||
task.status === "uploading" ||
|
||||
task.status === "downloading",
|
||||
),
|
||||
[uploadTasks],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
hasEverOpenedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasEverOpenedRef.current) return;
|
||||
if (uploading || hasActiveTransferTasks) return;
|
||||
|
||||
void closeSftpSession();
|
||||
}, [closeSftpSession, hasActiveTransferTasks, open, sessionVersion, uploading]);
|
||||
|
||||
const handleClose = async () => {
|
||||
if (uploading || hasActiveTransferTasks) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
await closeSftpSession();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Handle initial entries to upload (from drag-and-drop to terminal)
|
||||
const initialUploadTriggeredRef = useRef(false);
|
||||
const prevLoadingRef = useRef(loading);
|
||||
const prevEntriesRef = useRef<DropEntry[] | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
// Detect when loading transitions from true to false (initial load complete)
|
||||
const wasLoading = prevLoadingRef.current;
|
||||
prevLoadingRef.current = loading;
|
||||
const justFinishedLoading = wasLoading && !loading;
|
||||
|
||||
// Reset the flag when initialEntriesToUpload is cleared
|
||||
if (!initialEntriesToUpload || initialEntriesToUpload.length === 0) {
|
||||
initialUploadTriggeredRef.current = false;
|
||||
prevEntriesRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the flag when new entries arrive (different reference = new drop)
|
||||
if (initialEntriesToUpload !== prevEntriesRef.current) {
|
||||
initialUploadTriggeredRef.current = false;
|
||||
prevEntriesRef.current = initialEntriesToUpload;
|
||||
}
|
||||
|
||||
// Prevent duplicate uploads
|
||||
if (initialUploadTriggeredRef.current) return;
|
||||
|
||||
// Wait for SFTP connection to be established
|
||||
// Trigger when: modal is open AND loading just finished (works for empty directories too)
|
||||
if (!open || loading) return;
|
||||
if (!justFinishedLoading) return;
|
||||
|
||||
initialUploadTriggeredRef.current = true;
|
||||
|
||||
// Trigger upload with full DropEntry data (preserves directory structure)
|
||||
void handleUploadEntries(initialEntriesToUpload);
|
||||
}, [handleUploadEntries, initialEntriesToUpload, loading, open]);
|
||||
|
||||
// Display files with parent entry (like SftpView)
|
||||
const displayFiles = useMemo(() => {
|
||||
// Filter hidden files using utility function
|
||||
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles);
|
||||
|
||||
// Check if we're at root
|
||||
const atRoot = isRootPathForSession(currentPath);
|
||||
if (atRoot) return visibleFiles;
|
||||
|
||||
// Add ".." parent directory entry at the top (only if not at root)
|
||||
const parentEntry: RemoteFile = {
|
||||
name: "..",
|
||||
type: "directory",
|
||||
size: "--",
|
||||
lastModified: undefined,
|
||||
};
|
||||
return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")];
|
||||
}, [files, currentPath, isRootPathForSession, sftpShowHiddenFiles]);
|
||||
|
||||
// Sorted files
|
||||
const sortedFiles = useMemo(() => {
|
||||
if (!displayFiles.length) return displayFiles;
|
||||
|
||||
// Keep ".." at the top, sort the rest
|
||||
const parentEntry = displayFiles.find((f) => f.name === "..");
|
||||
const otherFiles = displayFiles.filter((f) => f.name !== "..");
|
||||
|
||||
const sorted = [...otherFiles].sort((a, b) => {
|
||||
// Directories and symlinks pointing to directories come first
|
||||
const aIsDir = a.type === "directory" || (a.type === "symlink" && a.linkTarget === "directory");
|
||||
const bIsDir = b.type === "directory" || (b.type === "symlink" && b.linkTarget === "directory");
|
||||
if (aIsDir && !bIsDir) return -1;
|
||||
if (!aIsDir && bIsDir) return 1;
|
||||
|
||||
let cmp = 0;
|
||||
switch (sortField) {
|
||||
case "name":
|
||||
cmp = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case "size": {
|
||||
const sizeA =
|
||||
typeof a.size === "number"
|
||||
? a.size
|
||||
: parseInt(String(a.size), 10) || 0;
|
||||
const sizeB =
|
||||
typeof b.size === "number"
|
||||
? b.size
|
||||
: parseInt(String(b.size), 10) || 0;
|
||||
cmp = sizeA - sizeB;
|
||||
break;
|
||||
}
|
||||
case "modified": {
|
||||
const dateA = new Date(a.lastModified || 0).getTime();
|
||||
const dateB = new Date(b.lastModified || 0).getTime();
|
||||
cmp = dateA - dateB;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return sortOrder === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
return parentEntry ? [parentEntry, ...sorted] : sorted;
|
||||
}, [displayFiles, sortField, sortOrder]);
|
||||
const hasFiles = files.length > 0;
|
||||
const hasDisplayFiles = sortedFiles.length > 0;
|
||||
const {
|
||||
fileListRef,
|
||||
handleFileListScroll,
|
||||
shouldVirtualize,
|
||||
totalHeight,
|
||||
visibleRows,
|
||||
} = useSftpModalVirtualList({ open, sortedFiles });
|
||||
|
||||
|
||||
const { handleFileClick, handleFileDoubleClick } = useSftpModalSelection({
|
||||
files,
|
||||
setSelectedFiles,
|
||||
currentPath,
|
||||
joinPath: joinPathForSession,
|
||||
onNavigate: handleNavigate,
|
||||
onOpenFile: handleOpenFile,
|
||||
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);
|
||||
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadSelected = async () => {
|
||||
if (selectedFiles.size === 0) return;
|
||||
for (const fileName of selectedFiles) {
|
||||
const file = files.find((f) => f.name === fileName);
|
||||
if (file && file.type === "file") {
|
||||
await handleDownload(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full flex flex-col bg-background border-r border-border/60 overflow-hidden">
|
||||
<SftpModalHeader
|
||||
onClose={handleClose}
|
||||
t={t}
|
||||
host={host}
|
||||
credentials={credentials}
|
||||
showEncoding={!isLocalSession}
|
||||
filenameEncoding={filenameEncoding}
|
||||
onFilenameEncodingChange={setFilenameEncoding}
|
||||
currentPath={currentPath}
|
||||
isEditingPath={isEditingPath}
|
||||
editingPathValue={editingPathValue}
|
||||
setEditingPathValue={setEditingPathValue}
|
||||
handlePathSubmit={handlePathSubmit}
|
||||
handlePathKeyDown={handlePathKeyDown}
|
||||
handlePathDoubleClick={handlePathDoubleClick}
|
||||
isAtRoot={isRootPathForSession(currentPath)}
|
||||
rootLabel={rootLabel}
|
||||
isRefreshing={loading || reconnecting}
|
||||
onUp={handleUp}
|
||||
onHome={() =>
|
||||
setCurrentPath((isLocalSession && localHomeRef.current) || rootPath)
|
||||
}
|
||||
onRefresh={() => loadFiles(currentPath, { force: true })}
|
||||
visibleBreadcrumbs={visibleBreadcrumbs}
|
||||
hiddenBreadcrumbs={hiddenBreadcrumbs}
|
||||
needsBreadcrumbTruncation={needsBreadcrumbTruncation}
|
||||
breadcrumbs={breadcrumbs}
|
||||
onBreadcrumbSelect={(index) => setCurrentPath(breadcrumbPathAtForIndex(index))}
|
||||
onRootSelect={() => setCurrentPath(rootPath)}
|
||||
inputRef={inputRef}
|
||||
folderInputRef={folderInputRef}
|
||||
pathInputRef={pathInputRef}
|
||||
uploading={uploading}
|
||||
onTriggerUpload={() => inputRef.current?.click()}
|
||||
onTriggerFolderUpload={() => folderInputRef.current?.click()}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
onCreateFile={handleCreateFile}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
showHiddenFiles={sftpShowHiddenFiles}
|
||||
onToggleShowHiddenFiles={() =>
|
||||
setSftpShowHiddenFiles(!sftpShowHiddenFiles)
|
||||
}
|
||||
onUpdateHost={onUpdateHost}
|
||||
onNavigateToBookmark={(path) => setCurrentPath(path)}
|
||||
/>
|
||||
|
||||
<SftpModalFileList
|
||||
t={t}
|
||||
currentPath={currentPath}
|
||||
isLocalSession={isLocalSession}
|
||||
hasFiles={hasFiles}
|
||||
hasDisplayFiles={hasDisplayFiles}
|
||||
selectedFiles={selectedFiles}
|
||||
dragActive={dragActive}
|
||||
loading={loading}
|
||||
loadingTextContent={loadingTextContent}
|
||||
reconnecting={reconnecting}
|
||||
columnWidths={columnWidths}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
shouldVirtualize={shouldVirtualize}
|
||||
totalHeight={totalHeight}
|
||||
visibleRows={visibleRows}
|
||||
fileListRef={fileListRef}
|
||||
inputRef={inputRef}
|
||||
folderInputRef={folderInputRef}
|
||||
handleSort={handleSort}
|
||||
handleResizeStart={handleResizeStart}
|
||||
handleFileListScroll={handleFileListScroll}
|
||||
handleDrag={handleDrag}
|
||||
handleDrop={handleDrop}
|
||||
handleFileClick={handleFileClick}
|
||||
handleFileDoubleClick={handleFileDoubleClick}
|
||||
handleDownload={handleDownload}
|
||||
handleDelete={handleDelete}
|
||||
handleOpenFile={handleOpenFile}
|
||||
openFileOpenerDialog={openFileOpenerDialog}
|
||||
handleEditFile={handleEditFile}
|
||||
openRenameDialog={openRenameDialog}
|
||||
openPermissionsDialog={openPermissionsDialog}
|
||||
handleNavigate={handleNavigate}
|
||||
handleCreateFolder={handleCreateFolder}
|
||||
handleCreateFile={handleCreateFile}
|
||||
handleDownloadSelected={handleDownloadSelected}
|
||||
handleDeleteSelected={handleDeleteSelected}
|
||||
loadFiles={loadFiles}
|
||||
formatBytes={formatBytes}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
|
||||
<SftpModalUploadTasks tasks={uploadTasks} t={t} onCancel={cancelUpload} onCancelTask={cancelTask} onDismiss={dismissTask} />
|
||||
|
||||
<SftpModalFooter
|
||||
t={t}
|
||||
files={files}
|
||||
selectedFiles={selectedFiles}
|
||||
loading={loading}
|
||||
uploading={uploading}
|
||||
onDownloadSelected={handleDownloadSelected}
|
||||
onDeleteSelected={handleDeleteSelected}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SftpModalDialogs
|
||||
t={t}
|
||||
showRenameDialog={showRenameDialog}
|
||||
setShowRenameDialog={setShowRenameDialog}
|
||||
renameTarget={renameTarget}
|
||||
renameName={renameName}
|
||||
setRenameName={setRenameName}
|
||||
handleRename={handleRename}
|
||||
isRenaming={isRenaming}
|
||||
showPermissionsDialog={showPermissionsDialog}
|
||||
setShowPermissionsDialog={setShowPermissionsDialog}
|
||||
permissionsTarget={permissionsTarget}
|
||||
permissions={permissions}
|
||||
togglePermission={togglePermission}
|
||||
getOctalPermissions={getOctalPermissions}
|
||||
getSymbolicPermissions={getSymbolicPermissions}
|
||||
handleSavePermissions={handleSavePermissions}
|
||||
isChangingPermissions={isChangingPermissions}
|
||||
showCreateDialog={showCreateDialog}
|
||||
setShowCreateDialog={setShowCreateDialog}
|
||||
createType={createType}
|
||||
createName={createName}
|
||||
setCreateName={setCreateName}
|
||||
isCreating={isCreating}
|
||||
handleCreateSubmit={handleCreateSubmit}
|
||||
/>
|
||||
|
||||
{/* File Opener Dialog */}
|
||||
<FileOpenerDialog
|
||||
open={showFileOpenerDialog}
|
||||
onClose={() => {
|
||||
setShowFileOpenerDialog(false);
|
||||
setFileOpenerTarget(null);
|
||||
}}
|
||||
fileName={fileOpenerTarget?.name || ""}
|
||||
onSelect={handleFileOpenerSelect}
|
||||
onSelectSystemApp={handleSelectSystemApp}
|
||||
/>
|
||||
|
||||
{/* Text Editor Modal */}
|
||||
<TextEditorModal
|
||||
open={showTextEditor}
|
||||
onClose={() => {
|
||||
setShowTextEditor(false);
|
||||
setTextEditorTarget(null);
|
||||
setTextEditorContent("");
|
||||
}}
|
||||
fileName={textEditorTarget?.name || ""}
|
||||
initialContent={textEditorContent}
|
||||
onSave={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SFTPModal;
|
||||
@@ -101,7 +101,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
|
||||
const {
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
} = useSftpBackend();
|
||||
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
@@ -153,7 +160,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
});
|
||||
|
||||
@@ -86,8 +86,15 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
|
||||
// Get stream transfer functions for optimized downloads
|
||||
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
|
||||
// Get backend helpers for file downloads and local filesystem writes.
|
||||
const {
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
} = useSftpBackend();
|
||||
|
||||
// Store sftp in a ref so callbacks can access the latest instance
|
||||
// without needing to re-create when sftp changes
|
||||
@@ -176,7 +183,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
});
|
||||
|
||||
@@ -25,6 +25,11 @@ import {
|
||||
shouldEnableNativeUserInputAutoScroll,
|
||||
shouldScrollOnTerminalInput,
|
||||
} from "../domain/terminalScroll";
|
||||
import {
|
||||
resolveHostTerminalFontFamilyId,
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
|
||||
@@ -302,6 +307,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [showSFTP, setShowSFTP] = useState(false);
|
||||
const [progressValue, setProgressValue] = useState(15);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
|
||||
|
||||
const statusRef = useRef<TerminalSession["status"]>(status);
|
||||
statusRef.current = status;
|
||||
@@ -402,13 +408,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
const effectiveTheme = useMemo(() => {
|
||||
if (host.theme) {
|
||||
const hostTheme = TERMINAL_THEMES.find((t) => t.id === host.theme)
|
||||
|| customThemes.find((t) => t.id === host.theme);
|
||||
const themeId = resolveHostTerminalThemeId(host, terminalTheme.id);
|
||||
if (themeId) {
|
||||
const hostTheme = TERMINAL_THEMES.find((t) => t.id === themeId)
|
||||
|| customThemes.find((t) => t.id === themeId);
|
||||
if (hostTheme) return hostTheme;
|
||||
}
|
||||
return terminalTheme;
|
||||
}, [host.theme, terminalTheme, customThemes]);
|
||||
}, [host, terminalTheme, customThemes]);
|
||||
|
||||
const resolvedChainHosts =
|
||||
(host.hostChain?.hostIds
|
||||
@@ -502,6 +509,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setProgressLogs([]);
|
||||
setShowLogs(false);
|
||||
setIsCancelling(false);
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
|
||||
const boot = async () => {
|
||||
try {
|
||||
@@ -679,6 +687,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateStatus is a stable internal helper
|
||||
}, [status, auth.needsAuth, host.protocol, host.hostname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "connecting") {
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
|
||||
const fitAddon = fitAddonRef.current;
|
||||
if (!fitAddon) return;
|
||||
@@ -725,7 +739,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
const effectiveFontSize = host.fontSize || fontSize;
|
||||
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
|
||||
termRef.current.options.theme = {
|
||||
@@ -782,14 +796,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
setTimeout(() => safeFit({ force: true }), 50);
|
||||
}
|
||||
}, [fontSize, effectiveTheme, terminalSettings, host.fontSize]);
|
||||
}, [fontSize, effectiveTheme, terminalSettings, host]);
|
||||
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
const effectiveFontSize = host.fontSize || fontSize;
|
||||
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
|
||||
const hostFontId = host.fontFamily || fontFamilyId || "menlo";
|
||||
const hostFontId = resolveHostTerminalFontFamilyId(host, fontFamilyId) || "menlo";
|
||||
const fontObj = availableFonts.find((f) => f.id === hostFontId) || availableFonts[0];
|
||||
termRef.current.options.fontFamily = fontObj.family;
|
||||
|
||||
@@ -800,7 +814,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
setTimeout(() => safeFit({ force: true }), 50);
|
||||
}
|
||||
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
|
||||
}, [host, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
@@ -848,7 +862,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
if (terminalSettings && termRef.current) {
|
||||
const fontFamily = termRef.current.options?.fontFamily || "";
|
||||
const effectiveFontSize = host.fontSize || fontSize;
|
||||
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
|
||||
if (typeof document !== "undefined" && document.fonts?.check) {
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
const resolvedBold = document.fonts.check(weightSpec)
|
||||
@@ -884,7 +898,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [host.id, host.fontFamily, host.fontSize, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
|
||||
}, [host, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
|
||||
@@ -1110,6 +1124,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onCloseSession?.(sessionId);
|
||||
};
|
||||
|
||||
const handleDismissDisconnectedDialog = () => {
|
||||
setIsDisconnectedDialogDismissed(true);
|
||||
};
|
||||
|
||||
const handleCloseDisconnectedSession = () => {
|
||||
onCloseSession?.(sessionId);
|
||||
};
|
||||
|
||||
const handleHostKeyClose = () => {
|
||||
setNeedsHostKeyVerification(false);
|
||||
setPendingHostKeyInfo(null);
|
||||
@@ -1150,17 +1172,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
cleanupSession();
|
||||
auth.resetForRetry();
|
||||
hasRunStartupCommandRef.current = false;
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
setStatus("connecting");
|
||||
setError(null);
|
||||
setProgressLogs(["Retrying secure channel..."]);
|
||||
setShowLogs(true);
|
||||
if (host.protocol === "local" || host.hostname === "localhost") {
|
||||
if (host.protocol === "serial") {
|
||||
sessionStarters.startSerial(termRef.current);
|
||||
} else if (host.protocol === "local" || host.hostname === "localhost") {
|
||||
sessionStarters.startLocal(termRef.current);
|
||||
} else if (host.protocol === "telnet") {
|
||||
sessionStarters.startTelnet(termRef.current);
|
||||
} else if (host.moshEnabled) {
|
||||
sessionStarters.startMosh(termRef.current);
|
||||
} else {
|
||||
sessionStarters.startSSH(termRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowConnectionDialog = status !== "connected"
|
||||
&& !needsHostKeyVerification
|
||||
&& !((isLocalConnection || isSerialConnection) && status === "connecting")
|
||||
&& !(status === "disconnected" && isDisconnectedDialogDismissed);
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -1734,9 +1768,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
)}
|
||||
|
||||
{/* Connection dialog: skip for local/serial during connecting phase, but show on error */}
|
||||
{status !== "connected" && !needsHostKeyVerification && !(
|
||||
(isLocalConnection || isSerialConnection) && status === "connecting"
|
||||
) && (
|
||||
{shouldShowConnectionDialog && (
|
||||
<TerminalConnectionDialog
|
||||
host={host}
|
||||
status={status}
|
||||
@@ -1747,6 +1779,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
showLogs={showLogs}
|
||||
_setShowLogs={setShowLogs}
|
||||
keys={keys}
|
||||
onDismissDisconnected={handleDismissDisconnectedDialog}
|
||||
authProps={{
|
||||
authMethod: auth.authMethod,
|
||||
setAuthMethod: auth.setAuthMethod,
|
||||
@@ -1772,7 +1805,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
timeLeft,
|
||||
isCancelling,
|
||||
progressLogs,
|
||||
onCancel: handleCancelConnect,
|
||||
onCancelConnect: handleCancelConnect,
|
||||
onCloseSession: handleCloseDisconnectedSession,
|
||||
onRetry: handleRetry,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,19 @@ import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import { collectSessionIds } from '../domain/workspace';
|
||||
import { SplitDirection } from '../domain/workspace';
|
||||
import { KeyBinding, TerminalSettings } from '../domain/models';
|
||||
import {
|
||||
clearHostFontFamilyOverride,
|
||||
clearHostFontSizeOverride,
|
||||
clearHostThemeOverride,
|
||||
hasHostFontFamilyOverride,
|
||||
hasHostFontSizeOverride,
|
||||
hasHostThemeOverride,
|
||||
resolveHostTerminalFontFamilyId,
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalThemeId,
|
||||
} from '../domain/terminalAppearance';
|
||||
import { cn } from '../lib/utils';
|
||||
import { detectLocalOs } from '../lib/localShell';
|
||||
import { useStoredString } from '../application/state/useStoredString';
|
||||
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
|
||||
import type { DropEntry } from '../lib/sftpFileUtils';
|
||||
@@ -22,6 +34,7 @@ import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
|
||||
|
||||
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
|
||||
|
||||
@@ -65,6 +78,38 @@ const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<s
|
||||
return changed ? next : source;
|
||||
};
|
||||
|
||||
type AITerminalSessionInfo = {
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
const buildAITerminalSessionInfo = (
|
||||
session: TerminalSession | undefined,
|
||||
host: Host | undefined,
|
||||
localOs: 'linux' | 'macos' | 'windows',
|
||||
): AITerminalSessionInfo => {
|
||||
const protocol = session?.protocol || host?.protocol;
|
||||
const isLocalSession = protocol === 'local' || session?.hostId?.startsWith('local-');
|
||||
return {
|
||||
sessionId: session?.id || '',
|
||||
hostId: session?.hostId || '',
|
||||
hostname: host?.hostname || session?.hostname || '',
|
||||
label: host?.label || session?.hostLabel || '',
|
||||
os: host?.os || (isLocalSession ? localOs : undefined),
|
||||
username: host?.username || session?.username,
|
||||
protocol,
|
||||
shellType: session?.shellType && session.shellType !== 'unknown' ? session.shellType : undefined,
|
||||
connected: session?.status === 'connected',
|
||||
};
|
||||
};
|
||||
|
||||
interface TerminalLayerProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
@@ -966,34 +1011,52 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return;
|
||||
}
|
||||
if (focusedHost) {
|
||||
onUpdateHost({ ...focusedHost, theme: themeId });
|
||||
onUpdateHost({ ...focusedHost, theme: themeId, themeOverride: true });
|
||||
}
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalThemeId, onUpdateHost]);
|
||||
|
||||
const handleThemeResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
onUpdateHost(clearHostThemeOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
|
||||
|
||||
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
|
||||
if (isFocusedHostLocal) {
|
||||
onUpdateTerminalFontFamilyId?.(fontFamilyId);
|
||||
return;
|
||||
}
|
||||
if (focusedHost) {
|
||||
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId });
|
||||
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
|
||||
}
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalFontFamilyId, onUpdateHost]);
|
||||
|
||||
const handleFontFamilyResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
|
||||
|
||||
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
|
||||
if (isFocusedHostLocal) {
|
||||
onUpdateTerminalFontSize?.(newFontSize);
|
||||
return;
|
||||
}
|
||||
if (focusedHost) {
|
||||
onUpdateHost({ ...focusedHost, fontSize: newFontSize });
|
||||
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
|
||||
}
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalFontSize, onUpdateHost]);
|
||||
|
||||
const handleFontSizeResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
onUpdateHost(clearHostFontSizeOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
|
||||
|
||||
// Current theme/font/size for the focused session (for ThemeSidePanel)
|
||||
const focusedThemeId = focusedHost?.theme ?? terminalTheme.id;
|
||||
const focusedFontFamilyId = focusedHost?.fontFamily ?? terminalFontFamilyId;
|
||||
const focusedFontSize = focusedHost?.fontSize ?? fontSize;
|
||||
const focusedThemeId = resolveHostTerminalThemeId(focusedHost, terminalTheme.id);
|
||||
const focusedFontFamilyId = resolveHostTerminalFontFamilyId(focusedHost, terminalFontFamilyId);
|
||||
const focusedFontSize = resolveHostTerminalFontSize(focusedHost, fontSize);
|
||||
const focusedThemeOverridden = hasHostThemeOverride(focusedHost);
|
||||
const focusedFontFamilyOverridden = hasHostFontFamilyOverride(focusedHost);
|
||||
const focusedFontSizeOverridden = hasHostFontSizeOverride(focusedHost);
|
||||
|
||||
// AI Chat state
|
||||
const aiState = useAIState();
|
||||
@@ -1006,8 +1069,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
cleanupOrphanedSessions(activeIds);
|
||||
}, [sessions, workspaces, cleanupOrphanedSessions]);
|
||||
|
||||
// Keep MCP/ACP approval IPC listener alive for the entire terminal lifecycle.
|
||||
// Must live here (TerminalLayer), NOT in AIChatSidePanel (unmounts on tab switch)
|
||||
// or ChatMessageList (unmounts on panel hide).
|
||||
useEffect(() => {
|
||||
return setupMcpApprovalBridge();
|
||||
}, []);
|
||||
|
||||
// Build terminal session context for the AI chat panel
|
||||
const aiTerminalSessions = useMemo(() => {
|
||||
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
|
||||
const sessionIds = activeWorkspace?.root
|
||||
? collectSessionIds(activeWorkspace.root)
|
||||
: activeSession ? [activeSession.id] : [];
|
||||
@@ -1015,15 +1086,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const result = sessionIds.map(sid => {
|
||||
const s = sessions.find(s => s.id === sid);
|
||||
const host = s?.hostId ? hosts.find(h => h.id === s.hostId) : undefined;
|
||||
return {
|
||||
sessionId: sid,
|
||||
hostId: s?.hostId || '',
|
||||
hostname: host?.hostname || '',
|
||||
label: host?.label || s?.hostLabel || '',
|
||||
os: host?.os,
|
||||
username: host?.username,
|
||||
connected: s?.status === 'connected',
|
||||
};
|
||||
return buildAITerminalSessionInfo(s, host, localOs);
|
||||
});
|
||||
return result;
|
||||
}, [sessions, hosts, activeWorkspace, activeSession]);
|
||||
@@ -1036,6 +1099,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const latestWorkspaces = workspacesRef.current;
|
||||
const latestSessions = sessionsRef.current;
|
||||
const latestHosts = hostsRef.current;
|
||||
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
|
||||
const sessionIds = scope.type === 'workspace'
|
||||
? (() => {
|
||||
const workspace = scope.targetId ? latestWorkspaces.find((w) => w.id === scope.targetId) : undefined;
|
||||
@@ -1051,15 +1115,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessions: sessionIds.map((sid) => {
|
||||
const session = latestSessions.find((s) => s.id === sid);
|
||||
const host = session?.hostId ? latestHosts.find((h) => h.id === session.hostId) : undefined;
|
||||
return {
|
||||
sessionId: sid,
|
||||
hostId: session?.hostId || '',
|
||||
hostname: host?.hostname || '',
|
||||
label: host?.label || session?.hostLabel || '',
|
||||
os: host?.os,
|
||||
username: host?.username,
|
||||
connected: session?.status === 'connected',
|
||||
};
|
||||
return buildAITerminalSessionInfo(session, host, localOs);
|
||||
}),
|
||||
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
|
||||
workspaceName,
|
||||
@@ -1414,11 +1470,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<div className="absolute inset-0 z-10">
|
||||
<ThemeSidePanel
|
||||
currentThemeId={focusedThemeId}
|
||||
globalThemeId={terminalTheme.id}
|
||||
currentFontFamilyId={focusedFontFamilyId}
|
||||
globalFontFamilyId={terminalFontFamilyId}
|
||||
currentFontSize={focusedFontSize}
|
||||
canResetTheme={focusedThemeOverridden}
|
||||
canResetFontFamily={focusedFontFamilyOverridden}
|
||||
canResetFontSize={focusedFontSizeOverridden}
|
||||
onThemeChange={handleThemeChangeForFocusedSession}
|
||||
onThemeReset={handleThemeResetForFocusedSession}
|
||||
onFontFamilyChange={handleFontFamilyChangeForFocusedSession}
|
||||
onFontFamilyReset={handleFontFamilyResetForFocusedSession}
|
||||
onFontSizeChange={handleFontSizeChangeForFocusedSession}
|
||||
onFontSizeReset={handleFontSizeResetForFocusedSession}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { activeTabStore, useActiveTabId } from '../application/state/activeTabSt
|
||||
import { LogView } from '../application/state/useSessionState';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { normalizeDistroId } from '../domain/host';
|
||||
import { getEffectiveHostDistro } from '../domain/host';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
@@ -89,7 +89,7 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
|
||||
|
||||
// Try distro logo with brand background color
|
||||
if (host) {
|
||||
const distro = normalizeDistroId(host.distro) || (host.distro || '').toLowerCase();
|
||||
const distro = getEffectiveHostDistro(host);
|
||||
const logo = DISTRO_LOGOS[distro];
|
||||
if (logo) {
|
||||
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
|
||||
@@ -97,7 +97,7 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
|
||||
<div className={cn(boxBase, bg)}>
|
||||
<img
|
||||
src={logo}
|
||||
alt={host.distro || host.os}
|
||||
alt={distro || host.os}
|
||||
className={cn(iconSize, "object-contain invert brightness-0")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { sanitizeHost } from "../domain/host";
|
||||
import { getEffectiveHostDistro, sanitizeHost } from "../domain/host";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED } from "../infrastructure/config/storageKeys";
|
||||
@@ -1918,9 +1918,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
{group.hosts.map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const effectiveDistro = getEffectiveHostDistro(safeHost);
|
||||
const distroBadge = {
|
||||
text: (safeHost.os || "L")[0].toUpperCase(),
|
||||
label: safeHost.distro || safeHost.os || "Linux",
|
||||
label: effectiveDistro || safeHost.os || "Linux",
|
||||
};
|
||||
return (
|
||||
<ContextMenu key={host.id}>
|
||||
@@ -2056,9 +2057,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
{displayedHosts.map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const effectiveDistro = getEffectiveHostDistro(safeHost);
|
||||
const distroBadge = {
|
||||
text: (safeHost.os || "L")[0].toUpperCase(),
|
||||
label: safeHost.distro || safeHost.os || "Linux",
|
||||
label: effectiveDistro || safeHost.os || "Linux",
|
||||
};
|
||||
return (
|
||||
<ContextMenu key={host.id}>
|
||||
|
||||
@@ -9,7 +9,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-hidden', className)}
|
||||
initial="smooth"
|
||||
initial="instant"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ChevronDown, ChevronRight, CheckCircle2, Loader2, XCircle, Slash } from 'lucide-react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
|
||||
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
name: string;
|
||||
@@ -10,12 +12,73 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
isError?: boolean;
|
||||
isLoading?: boolean;
|
||||
isInterrupted?: boolean;
|
||||
/** Approval state for this tool call (from the approval gate). */
|
||||
approvalStatus?: 'pending' | 'approved' | 'denied';
|
||||
/** Called when user approves this tool call. */
|
||||
onApprove?: () => void;
|
||||
/** Called when user rejects this tool call. */
|
||||
onReject?: () => void;
|
||||
}
|
||||
|
||||
export const ToolCall = ({ name, args, result, isError, isLoading, isInterrupted, className, ...props }: ToolCallProps) => {
|
||||
export const ToolCall = ({
|
||||
name, args, result, isError, isLoading, isInterrupted,
|
||||
approvalStatus, onApprove, onReject,
|
||||
className, ...props
|
||||
}: ToolCallProps) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const approveBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const [responded, setResponded] = useState(false);
|
||||
|
||||
const statusIcon = isLoading ? (
|
||||
const isPendingApproval = approvalStatus === 'pending' && !responded;
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
if (!isPendingApproval) return;
|
||||
setResponded(true);
|
||||
onApprove?.();
|
||||
}, [isPendingApproval, onApprove]);
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
if (!isPendingApproval) return;
|
||||
setResponded(true);
|
||||
onReject?.();
|
||||
}, [isPendingApproval, onReject]);
|
||||
|
||||
// Keyboard: Enter = approve, Escape = reject (when pending)
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!isPendingApproval) return;
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleApprove(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); handleReject(); }
|
||||
}, [isPendingApproval, handleApprove, handleReject]);
|
||||
|
||||
// Auto-focus and auto-scroll when approval is pending
|
||||
useEffect(() => {
|
||||
if (isPendingApproval && cardRef.current) {
|
||||
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
// Small delay to let the UI render, then expand and focus
|
||||
setExpanded(true);
|
||||
setTimeout(() => approveBtnRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isPendingApproval]);
|
||||
|
||||
// Reset responded state when approvalStatus changes (e.g. new approval)
|
||||
useEffect(() => {
|
||||
if (approvalStatus === 'pending') setResponded(false);
|
||||
}, [approvalStatus]);
|
||||
|
||||
// Border/bg color based on approval status
|
||||
const borderClass = approvalStatus === 'pending'
|
||||
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
|
||||
: approvalStatus === 'approved'
|
||||
? 'border-green-500/20 bg-green-500/[0.03]'
|
||||
: approvalStatus === 'denied'
|
||||
? 'border-red-500/20 bg-red-500/[0.03]'
|
||||
: 'border-border/25 bg-muted/10';
|
||||
|
||||
const statusIcon = approvalStatus === 'pending' ? (
|
||||
<ShieldAlert size={12} className="text-yellow-500/70 shrink-0" />
|
||||
) : isLoading ? (
|
||||
<Loader2 size={12} className="animate-spin text-blue-400/70" />
|
||||
) : isInterrupted ? (
|
||||
<Slash size={12} className="text-muted-foreground/55" />
|
||||
@@ -26,7 +89,13 @@ export const ToolCall = ({ name, args, result, isError, isLoading, isInterrupted
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-md border border-border/25 bg-muted/10 overflow-hidden text-[12px]', className)} {...props}>
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={isPendingApproval ? 0 : undefined}
|
||||
onKeyDown={isPendingApproval ? handleKeyDown : undefined}
|
||||
className={cn('rounded-md border overflow-hidden text-[12px] outline-none', borderClass, className)}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
@@ -38,8 +107,20 @@ export const ToolCall = ({ name, args, result, isError, isLoading, isInterrupted
|
||||
}
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
<span className="flex-1" />
|
||||
{/* Approval badge for resolved approvals */}
|
||||
{approvalStatus === 'approved' && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 bg-green-600/20 text-green-400 border-green-600/30">
|
||||
{t('ai.chat.toolApproved')}
|
||||
</Badge>
|
||||
)}
|
||||
{approvalStatus === 'denied' && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 bg-red-600/20 text-red-400 border-red-600/30">
|
||||
{t('ai.chat.toolDenied')}
|
||||
</Badge>
|
||||
)}
|
||||
{statusIcon}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-border/20">
|
||||
{args && Object.keys(args).length > 0 && (
|
||||
@@ -50,6 +131,38 @@ export const ToolCall = ({ name, args, result, isError, isLoading, isInterrupted
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline approval buttons */}
|
||||
{isPendingApproval && (
|
||||
<div className="px-3 py-2 border-t border-border/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground/30">
|
||||
{t('ai.chat.toolApprovalHint')}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[11px] border-red-500/20 text-red-400/80 hover:bg-red-500/10 hover:text-red-400"
|
||||
onClick={handleReject}
|
||||
>
|
||||
<X size={11} className="mr-0.5" />
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
<Button
|
||||
ref={approveBtnRef}
|
||||
size="sm"
|
||||
className="h-6 px-2.5 text-[11px] bg-green-600/80 hover:bg-green-600 text-white"
|
||||
onClick={handleApprove}
|
||||
>
|
||||
<Check size={11} className="mr-0.5" />
|
||||
{t('ai.chat.approve')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result !== undefined && (
|
||||
<div className="px-3 py-2 border-t border-border/20">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Result</div>
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
* No avatars. Thinking blocks are collapsible.
|
||||
*/
|
||||
|
||||
import { AlertCircle, FileText } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { AlertCircle, FileText, RotateCcw, X, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { ChatMessage } from '../../infrastructure/ai/types';
|
||||
import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
@@ -17,17 +18,124 @@ import {
|
||||
} from '../ai-elements/conversation';
|
||||
import { Message, MessageContent, MessageResponse } from '../ai-elements/message';
|
||||
import { ToolCall } from '../ai-elements/tool-call';
|
||||
import { InlineApprovalCard } from './InlineApprovalCard';
|
||||
import ThinkingBlock from './ThinkingBlock';
|
||||
import {
|
||||
onApprovalRequest,
|
||||
onApprovalCleared,
|
||||
replayPendingApprovals,
|
||||
resolveApproval,
|
||||
type ApprovalRequest,
|
||||
} from '../../infrastructure/ai/shared/approvalGate';
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
isStreaming?: boolean;
|
||||
onApprove?: (messageId: string) => void;
|
||||
onReject?: (messageId: string) => void;
|
||||
/** Active chat session ID — used to filter standalone MCP approval blocks */
|
||||
activeSessionId?: string | null;
|
||||
}
|
||||
|
||||
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, onApprove, onReject }) => {
|
||||
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, activeSessionId }) => {
|
||||
// Track pending approvals from the approval gate
|
||||
const [pendingApprovals, setPendingApprovals] = useState<Map<string, ApprovalRequest>>(new Map());
|
||||
const [resolvedApprovals, setResolvedApprovals] = useState<Map<string, boolean>>(new Map());
|
||||
|
||||
// Subscribe to approval gate events (SDK + MCP tool calls)
|
||||
useEffect(() => {
|
||||
const handler = (request: ApprovalRequest) => {
|
||||
setPendingApprovals(prev => new Map(prev).set(request.toolCallId, request));
|
||||
};
|
||||
const unsub = onApprovalRequest(handler);
|
||||
// Replay any approvals that fired while this component was unmounted
|
||||
replayPendingApprovals(handler);
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// Subscribe to approval cleared/removed events (fired on session stop or timeout)
|
||||
useEffect(() => {
|
||||
return onApprovalCleared((clearedIds) => {
|
||||
setPendingApprovals(prev => {
|
||||
const m = new Map(prev);
|
||||
for (const id of clearedIds) m.delete(id);
|
||||
return m;
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleApprove = useCallback((toolCallId: string) => {
|
||||
resolveApproval(toolCallId, true);
|
||||
setPendingApprovals(prev => { const m = new Map(prev); m.delete(toolCallId); return m; });
|
||||
setResolvedApprovals(prev => new Map(prev).set(toolCallId, true));
|
||||
}, []);
|
||||
|
||||
const handleReject = useCallback((toolCallId: string) => {
|
||||
resolveApproval(toolCallId, false);
|
||||
setPendingApprovals(prev => { const m = new Map(prev); m.delete(toolCallId); return m; });
|
||||
setResolvedApprovals(prev => new Map(prev).set(toolCallId, false));
|
||||
}, []);
|
||||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||
const [zoom, setZoom] = useState(100);
|
||||
const [dragged, setDragged] = useState(false);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const dragPos = useRef({ x: 0, y: 0 });
|
||||
const dragStart = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
|
||||
const applyTransform = useCallback((z: number, x: number, y: number, animate: boolean) => {
|
||||
if (!imgRef.current) return;
|
||||
imgRef.current.style.transition = animate ? 'transform 0.25s ease' : 'none';
|
||||
imgRef.current.style.transform = `scale(${z / 100}) translate(${x / (z / 100)}px, ${y / (z / 100)}px)`;
|
||||
}, []);
|
||||
|
||||
const zoomRef = useRef(100);
|
||||
const setZoomAndRef = useCallback((fn: (z: number) => number) => {
|
||||
setZoom(z => { const nz = fn(z); zoomRef.current = nz; return nz; });
|
||||
}, []);
|
||||
const zoomIn = useCallback(() => setZoomAndRef(z => { const nz = Math.min(z + 25, 200); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]);
|
||||
const zoomOut = useCallback(() => setZoomAndRef(z => { const nz = Math.max(z - 25, 25); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]);
|
||||
|
||||
const onWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -10 : 10;
|
||||
setZoomAndRef(z => {
|
||||
const nz = Math.max(25, Math.min(200, z + delta));
|
||||
applyTransform(nz, dragPos.current.x, dragPos.current.y, false);
|
||||
return nz;
|
||||
});
|
||||
}, [applyTransform, setZoomAndRef]);
|
||||
const openPreview = useCallback((src: string, name: string) => {
|
||||
setZoom(100); zoomRef.current = 100;
|
||||
setDragged(false);
|
||||
dragPos.current = { x: 0, y: 0 };
|
||||
setPreview({ src, name });
|
||||
}, []);
|
||||
|
||||
const resetPreview = useCallback(() => {
|
||||
setZoom(100); zoomRef.current = 100;
|
||||
setDragged(false);
|
||||
dragPos.current = { x: 0, y: 0 };
|
||||
applyTransform(100, 0, 0, true);
|
||||
}, [applyTransform]);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
dragStart.current = { startX: e.clientX, startY: e.clientY, origX: dragPos.current.x, origY: dragPos.current.y };
|
||||
}, []);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragStart.current) return;
|
||||
if ((e.buttons & 1) === 0) { dragStart.current = null; return; }
|
||||
const x = dragStart.current.origX + (e.clientX - dragStart.current.startX);
|
||||
const y = dragStart.current.origY + (e.clientY - dragStart.current.startY);
|
||||
dragPos.current = { x, y };
|
||||
applyTransform(zoomRef.current, x, y, false);
|
||||
}, [applyTransform]);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
if (dragStart.current && (dragPos.current.x !== 0 || dragPos.current.y !== 0)) {
|
||||
setDragged(true);
|
||||
}
|
||||
dragStart.current = null;
|
||||
}, []);
|
||||
const { t } = useI18n();
|
||||
const visibleMessages = messages.filter(m => m.role !== 'system');
|
||||
const resolvedToolCallIds = new Set(
|
||||
@@ -59,6 +167,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
const lastAssistantMessage = visibleMessages.findLast(m => m.role === 'assistant');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent className="gap-1.5 px-4 py-2">
|
||||
{visibleMessages.map((message) => {
|
||||
@@ -102,7 +211,8 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
|
||||
src={`data:${att.mediaType};base64,${att.base64Data}`}
|
||||
alt={att.filename || 'image'}
|
||||
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20"
|
||||
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => openPreview(`data:${att.mediaType};base64,${att.base64Data}`, att.filename || 'image')}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -126,26 +236,30 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
)}
|
||||
|
||||
{/* Tool calls */}
|
||||
{message.toolCalls?.map((tc) => (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isThisStreaming && message.executionStatus === 'running'}
|
||||
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
|
||||
/>
|
||||
))}
|
||||
{message.toolCalls?.map((tc) => {
|
||||
const isPending = pendingApprovals.has(tc.id);
|
||||
const resolved = resolvedApprovals.get(tc.id);
|
||||
const approvalStatus = isPending
|
||||
? 'pending' as const
|
||||
: resolved === true
|
||||
? 'approved' as const
|
||||
: resolved === false
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
|
||||
{/* Inline approval card */}
|
||||
{message.pendingApproval && (
|
||||
<InlineApprovalCard
|
||||
toolName={message.pendingApproval.toolName}
|
||||
toolArgs={message.pendingApproval.toolArgs}
|
||||
status={message.pendingApproval.status}
|
||||
onApprove={() => onApprove?.(message.id)}
|
||||
onReject={() => onReject?.(message.id)}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isThisStreaming && message.executionStatus === 'running' && !isPending}
|
||||
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Status text with shimmer */}
|
||||
{message.statusText && (
|
||||
@@ -173,6 +287,24 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
|
||||
{Array.from(pendingApprovals.entries())
|
||||
.filter((entry) => entry[0].startsWith('mcp_approval_') && (!activeSessionId || entry[1].chatSessionId === activeSessionId))
|
||||
.map((entry) => {
|
||||
const [id, req] = entry;
|
||||
return (
|
||||
<ToolCall
|
||||
key={id}
|
||||
name={req.toolName}
|
||||
args={req.args}
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus={'pending'}
|
||||
onApprove={() => handleApprove(id)}
|
||||
onReject={() => handleReject(id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Streaming indicator — only when no content and no thinking yet */}
|
||||
{isStreaming && !lastAssistantMessage?.content && !lastAssistantMessage?.thinking && (
|
||||
<div className="flex items-center gap-1 py-2">
|
||||
@@ -184,13 +316,89 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
{/* Image preview lightbox */}
|
||||
<Dialog open={!!preview} onOpenChange={(open) => { if (!open) setPreview(null); }}>
|
||||
<DialogContent
|
||||
hideCloseButton
|
||||
className="max-w-[min(90vw,800px)] max-h-[min(90vh,700px)] min-w-[280px] min-h-[200px] w-fit p-0 gap-0 focus:outline-none shadow-2xl"
|
||||
>
|
||||
{/* Title bar: filename | zoom controls | close — all in one flex row */}
|
||||
<div className="flex items-center h-10 px-3 border-b border-border/40 gap-2 shrink-0">
|
||||
<DialogTitle className="text-sm font-medium truncate flex-1">{preview?.name}</DialogTitle>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={resetPreview}
|
||||
disabled={zoom === 100 && !dragged}
|
||||
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
|
||||
aria-label={t('common.reset')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<div className="w-px h-3.5 bg-border/40 mx-0.5" />
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
disabled={zoom <= 25}
|
||||
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
|
||||
aria-label={t('common.zoomOut')}
|
||||
>
|
||||
<ZoomOut size={14} />
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-9 text-center select-none">{zoom}%</span>
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
disabled={zoom >= 200}
|
||||
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
|
||||
aria-label={t('common.zoomIn')}
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPreview(null)}
|
||||
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground shrink-0"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Image area with drag support */}
|
||||
{preview && (
|
||||
<div
|
||||
className="overflow-hidden flex items-center justify-center"
|
||||
style={{
|
||||
height: 'calc(min(90vh, 700px) - 40px)',
|
||||
cursor: 'grab',
|
||||
// Clamp aspect ratio: if image is extremely tall/wide, the container
|
||||
// constrains it; object-contain handles the rest.
|
||||
aspectRatio: 'auto',
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onWheel={onWheel}
|
||||
onLostPointerCapture={endDrag}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={preview.src}
|
||||
alt={preview.name}
|
||||
draggable={false}
|
||||
className="select-none max-w-full max-h-full object-contain"
|
||||
style={{ transition: 'transform 0.25s ease' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps): boolean {
|
||||
if (prev.isStreaming !== next.isStreaming) return false;
|
||||
if (prev.onApprove !== next.onApprove) return false;
|
||||
if (prev.onReject !== next.onReject) return false;
|
||||
if (prev.activeSessionId !== next.activeSessionId) return false;
|
||||
if (prev.messages.length !== next.messages.length) return false;
|
||||
if (prev.messages === next.messages) return true;
|
||||
|
||||
@@ -208,7 +416,6 @@ function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps
|
||||
p.role !== n.role ||
|
||||
p.statusText !== n.statusText ||
|
||||
p.executionStatus !== n.executionStatus ||
|
||||
p.pendingApproval !== n.pendingApproval ||
|
||||
p.errorInfo !== n.errorInfo ||
|
||||
p.toolCalls !== n.toolCalls ||
|
||||
p.toolResults !== n.toolResults
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
/**
|
||||
* InlineApprovalCard - Inline tool approval card rendered within chat messages.
|
||||
*
|
||||
* Replaces the modal PermissionDialog. Shows tool name, arguments, and
|
||||
* approve/reject buttons. Keyboard shortcuts: Enter to approve, Escape to reject.
|
||||
*/
|
||||
|
||||
import { Check, ShieldAlert, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
interface InlineApprovalCardProps {
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
status: 'pending' | 'approved' | 'denied';
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
}
|
||||
|
||||
const InlineApprovalCard: React.FC<InlineApprovalCardProps> = ({
|
||||
toolName,
|
||||
toolArgs,
|
||||
status,
|
||||
onApprove,
|
||||
onReject,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const approveBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const isPending = status === 'pending';
|
||||
const [responded, setResponded] = useState(false);
|
||||
|
||||
// Use refs to always access the latest callbacks without re-registering the listener
|
||||
const onApproveRef = useRef(onApprove);
|
||||
const onRejectRef = useRef(onReject);
|
||||
onApproveRef.current = onApprove;
|
||||
onRejectRef.current = onReject;
|
||||
|
||||
const isDisabled = !isPending || responded;
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
if (isDisabled) return;
|
||||
setResponded(true);
|
||||
onApproveRef.current();
|
||||
}, [isDisabled]);
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
if (isDisabled) return;
|
||||
setResponded(true);
|
||||
onRejectRef.current();
|
||||
}, [isDisabled]);
|
||||
|
||||
// Keyboard shortcuts: handled via local onKeyDown on the focusable card element
|
||||
// to avoid conflicts when multiple InlineApprovalCard instances exist simultaneously.
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (isDisabled) return;
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleApprove();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleReject();
|
||||
}
|
||||
}, [isDisabled, handleApprove, handleReject]);
|
||||
|
||||
// Auto-focus approve button and auto-scroll into view when mounted as pending
|
||||
useEffect(() => {
|
||||
if (isPending && cardRef.current) {
|
||||
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
approveBtnRef.current?.focus();
|
||||
}
|
||||
}, [isPending]);
|
||||
|
||||
let formattedArgs: string;
|
||||
try {
|
||||
formattedArgs = JSON.stringify(toolArgs, null, 2);
|
||||
} catch {
|
||||
formattedArgs = String(toolArgs);
|
||||
}
|
||||
|
||||
// Extract target session info if present
|
||||
const sessionId = toolArgs?.sessionId as string | undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={0}
|
||||
role="alertdialog"
|
||||
aria-label="Tool execution approval required"
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`rounded-md border overflow-hidden text-[12px] mt-1.5 outline-none ${
|
||||
isPending
|
||||
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
|
||||
: status === 'approved'
|
||||
? 'border-green-500/20 bg-green-500/[0.03]'
|
||||
: 'border-red-500/20 bg-red-500/[0.03]'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5">
|
||||
<ShieldAlert
|
||||
size={13}
|
||||
className={
|
||||
isPending
|
||||
? 'text-yellow-500/70 shrink-0'
|
||||
: status === 'approved'
|
||||
? 'text-green-400/70 shrink-0'
|
||||
: 'text-red-400/70 shrink-0'
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-foreground/70">
|
||||
{t('ai.chat.toolApprovalTitle')}
|
||||
</span>
|
||||
{!isPending && (
|
||||
<Badge
|
||||
className={`ml-auto text-[10px] px-1.5 py-0 ${
|
||||
status === 'approved'
|
||||
? 'bg-green-600/20 text-green-400 border-green-600/30'
|
||||
: 'bg-red-600/20 text-red-400 border-red-600/30'
|
||||
}`}
|
||||
>
|
||||
{status === 'approved' ? t('ai.chat.toolApproved') : t('ai.chat.toolDenied')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool info */}
|
||||
<div className="px-3 pb-2 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.toolLabel')}</span>
|
||||
<code className="text-[11px] font-mono text-muted-foreground/70 bg-muted/30 px-1.5 py-0.5 rounded">
|
||||
{toolName}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{sessionId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.targetLabel')}</span>
|
||||
<code className="text-[11px] font-mono text-muted-foreground/50 bg-muted/30 px-1.5 py-0.5 rounded">
|
||||
{sessionId}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arguments */}
|
||||
<div className="rounded border border-border/20 bg-muted/10 p-2 max-h-32 overflow-auto">
|
||||
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all text-muted-foreground/50">
|
||||
{formattedArgs}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Actions or hint */}
|
||||
{isPending && (
|
||||
<div className="flex items-center justify-between pt-0.5">
|
||||
<span className="text-[10px] text-muted-foreground/30">
|
||||
{t('ai.chat.toolApprovalHint')}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={responded}
|
||||
className={`h-6 px-2 text-[11px] border-red-500/20 text-red-400/80 hover:bg-red-500/10 hover:text-red-400 ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={handleReject}
|
||||
>
|
||||
<X size={11} className="mr-0.5" />
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
<Button
|
||||
ref={approveBtnRef}
|
||||
size="sm"
|
||||
disabled={responded}
|
||||
className={`h-6 px-2.5 text-[11px] bg-green-600/80 hover:bg-green-600 text-white ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={handleApprove}
|
||||
>
|
||||
<Check size={11} className="mr-0.5" />
|
||||
{t('ai.chat.approve')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
InlineApprovalCard.displayName = 'InlineApprovalCard';
|
||||
|
||||
export default InlineApprovalCard;
|
||||
export { InlineApprovalCard };
|
||||
export type { InlineApprovalCardProps };
|
||||
@@ -64,16 +64,30 @@ interface ToolResultChunk {
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a tool-approval-request chunk from the Vercel AI SDK fullStream. */
|
||||
interface ToolApprovalRequestChunk {
|
||||
type: 'tool-approval-request';
|
||||
approvalId: string;
|
||||
toolCall: {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args?: Record<string, unknown>;
|
||||
input?: Record<string, unknown>;
|
||||
};
|
||||
/** Detect tool results that represent errors/denials (e.g. `{ error: "..." }` or `{ ok: false }`) */
|
||||
function isToolResultError(output: unknown): boolean {
|
||||
if (output == null) return false;
|
||||
|
||||
if (typeof output === 'object') {
|
||||
const obj = output as Record<string, unknown>;
|
||||
// Check for explicit error objects
|
||||
if ('error' in obj && typeof obj.error === 'string') return true;
|
||||
if ('ok' in obj && obj.ok === false) return true;
|
||||
}
|
||||
|
||||
// Check stringified JSON (common for tool result wrapping)
|
||||
if (typeof output === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(output);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const parsedObj = parsed as Record<string, unknown>;
|
||||
if ('error' in parsedObj && typeof parsedObj.error === 'string') return true;
|
||||
if ('ok' in parsedObj && parsedObj.ok === false) return true;
|
||||
}
|
||||
} catch { /* not JSON, not an error */ }
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Shape of an error chunk from the Vercel AI SDK fullStream. */
|
||||
@@ -88,9 +102,8 @@ type StreamChunk =
|
||||
| ReasoningChunk
|
||||
| ToolCallChunk
|
||||
| ToolResultChunk
|
||||
| ToolApprovalRequestChunk
|
||||
| ErrorChunk
|
||||
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' };
|
||||
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' | 'tool-approval-request' };
|
||||
|
||||
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
|
||||
export interface PanelBridge extends NetcattyBridge {
|
||||
@@ -110,6 +123,8 @@ export interface TerminalSessionInfo {
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
@@ -119,27 +134,8 @@ export function getNetcattyBridge(): PanelBridge | undefined {
|
||||
return (window as any).netcatty as PanelBridge | undefined;
|
||||
}
|
||||
|
||||
/** Approval info returned by processCattyStream when a tool-approval-request is received. */
|
||||
export interface ApprovalInfo {
|
||||
approvalId: string;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Pending approval context stored between approval request and user response. */
|
||||
export interface PendingApprovalContext {
|
||||
sessionId: string;
|
||||
scopeKey: string;
|
||||
sdkMessages: Array<ModelMessage>;
|
||||
approvalInfo: ApprovalInfo;
|
||||
model: ReturnType<typeof createModelFromConfig>;
|
||||
systemPrompt: string;
|
||||
tools: ReturnType<typeof createCattyTools>;
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeLabel?: string;
|
||||
getExecutorContext: () => ExecutorContext;
|
||||
}
|
||||
// ApprovalInfo and PendingApprovalContext removed — approval is now handled
|
||||
// inside the tool's execute function via the approvalGate module.
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -181,7 +177,7 @@ export interface UseAIChatStreamingReturn {
|
||||
setStreamingForScope: (key: string, val: boolean) => void;
|
||||
/** Ref to per-session abort controllers. */
|
||||
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
|
||||
/** Process a Catty agent stream, returning approval info if one is requested. */
|
||||
/** Process a Catty agent stream. */
|
||||
processCattyStream: (
|
||||
streamSessionId: string,
|
||||
model: ReturnType<typeof createModelFromConfig>,
|
||||
@@ -190,7 +186,7 @@ export interface UseAIChatStreamingReturn {
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
) => Promise<ApprovalInfo | null>;
|
||||
) => Promise<void>;
|
||||
/** Send a message to the Catty agent (built-in). */
|
||||
sendToCattyAgent: (
|
||||
sessionId: string,
|
||||
@@ -227,7 +223,6 @@ export interface SendToCattyContext {
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
getExecutorContext?: () => ExecutorContext;
|
||||
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
|
||||
autoTitleSession: (sessionId: string, text: string) => void;
|
||||
}
|
||||
|
||||
@@ -325,7 +320,7 @@ export function useAIChatStreaming({
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
): Promise<ApprovalInfo | null> => {
|
||||
): Promise<void> => {
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: sdkMessages,
|
||||
@@ -339,7 +334,6 @@ export function useAIChatStreaming({
|
||||
let activeMsgId = currentAssistantMsgId;
|
||||
let lastAddedRole: 'assistant' | 'tool' = 'assistant';
|
||||
const reader = result.fullStream.getReader();
|
||||
let pendingApprovalInfo: ApprovalInfo | null = null;
|
||||
|
||||
// -- Text-delta batching: accumulate deltas and flush periodically --
|
||||
let pendingText = '';
|
||||
@@ -457,6 +451,7 @@ export function useAIChatStreaming({
|
||||
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
|
||||
);
|
||||
const toolOutput = typedChunk.output ?? typedChunk.result;
|
||||
const toolError = isToolResultError(toolOutput);
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: generateId(),
|
||||
role: 'tool',
|
||||
@@ -466,7 +461,7 @@ export function useAIChatStreaming({
|
||||
content: typeof toolOutput === 'string'
|
||||
? toolOutput
|
||||
: JSON.stringify(toolOutput),
|
||||
isError: false,
|
||||
isError: toolError,
|
||||
}],
|
||||
timestamp: Date.now(),
|
||||
executionStatus: 'completed',
|
||||
@@ -474,25 +469,9 @@ export function useAIChatStreaming({
|
||||
lastAddedRole = 'tool';
|
||||
break;
|
||||
}
|
||||
case 'tool-approval-request': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ToolApprovalRequestChunk;
|
||||
pendingApprovalInfo = {
|
||||
approvalId: typedChunk.approvalId,
|
||||
toolCallId: typedChunk.toolCall.toolCallId,
|
||||
toolName: typedChunk.toolCall.toolName,
|
||||
toolArgs: typedChunk.toolCall.args ?? typedChunk.toolCall.input ?? {},
|
||||
};
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
pendingApproval: {
|
||||
...pendingApprovalInfo!,
|
||||
status: 'pending' as const,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
}
|
||||
// tool-approval-request is no longer handled here — approval is now
|
||||
// inside the tool's execute function via the approvalGate module.
|
||||
// The SDK may still emit this chunk type but we simply ignore it.
|
||||
case 'error': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
@@ -524,7 +503,7 @@ export function useAIChatStreaming({
|
||||
flushText();
|
||||
reader.releaseLock();
|
||||
}
|
||||
return pendingApprovalInfo;
|
||||
return;
|
||||
}, [maxIterations, addMessageToSession, updateMessageById]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -613,9 +592,10 @@ export function useAIChatStreaming({
|
||||
: msg.toolCalls;
|
||||
return { ...msg, toolCalls: updatedToolCalls, executionStatus: 'completed', statusText: undefined };
|
||||
});
|
||||
const toolError = isToolResultError(result);
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'tool', content: '',
|
||||
toolResults: [{ toolCallId, content: result, isError: false }],
|
||||
toolResults: [{ toolCallId, content: result, isError: toolError }],
|
||||
timestamp: Date.now(), executionStatus: 'completed',
|
||||
});
|
||||
needsNewAssistantMsg = true;
|
||||
@@ -689,13 +669,18 @@ export function useAIChatStreaming({
|
||||
context.commandBlocklist,
|
||||
context.globalPermissionMode,
|
||||
context.webSearchConfig ?? undefined,
|
||||
sessionId,
|
||||
);
|
||||
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
scopeType: context.scopeType, scopeLabel: context.scopeLabel,
|
||||
hosts: context.terminalSessions.map(s => ({
|
||||
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
|
||||
os: s.os, username: s.username, connected: s.connected,
|
||||
os: s.os,
|
||||
username: s.username,
|
||||
protocol: s.protocol,
|
||||
shellType: s.shellType,
|
||||
connected: s.connected,
|
||||
})),
|
||||
permissionMode: context.globalPermissionMode,
|
||||
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
|
||||
@@ -819,23 +804,7 @@ export function useAIChatStreaming({
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
}
|
||||
|
||||
const approvalInfo = await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
|
||||
|
||||
if (approvalInfo) {
|
||||
context.setPendingApproval({
|
||||
sessionId,
|
||||
scopeKey: sendScopeKey,
|
||||
sdkMessages,
|
||||
approvalInfo,
|
||||
model,
|
||||
systemPrompt,
|
||||
tools,
|
||||
scopeType: context.scopeType,
|
||||
scopeLabel: context.scopeLabel,
|
||||
getExecutorContext,
|
||||
});
|
||||
return; // Keep streaming flag — waiting for user approval
|
||||
}
|
||||
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
|
||||
} catch (err) {
|
||||
console.error('[Catty] streamText error:', err);
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
/**
|
||||
* useToolApproval — Encapsulates the tool approval workflow for the AI chat panel.
|
||||
*
|
||||
* Handles:
|
||||
* - Pending approval context management
|
||||
* - Approval timeout (auto-clear after 5 minutes)
|
||||
* - handleApprovalResponse (approve/reject from InlineApprovalCard)
|
||||
* - Resuming the Catty stream after approval
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
ChatMessage,
|
||||
WebSearchConfig,
|
||||
} from '../../../infrastructure/ai/types';
|
||||
import { isWebSearchReady } from '../../../infrastructure/ai/types';
|
||||
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
|
||||
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
import type {
|
||||
ApprovalInfo,
|
||||
PendingApprovalContext,
|
||||
} from './useAIChatStreaming';
|
||||
import { getNetcattyBridge } from './useAIChatStreaming';
|
||||
import type { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
let sharedPendingApprovalContext: PendingApprovalContext | null = null;
|
||||
let sharedPendingApprovalTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook parameters
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseToolApprovalParams {
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
setStreamingForScope: (key: string, val: boolean) => void;
|
||||
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
|
||||
processCattyStream: (
|
||||
streamSessionId: string,
|
||||
model: ReturnType<typeof createModelFromConfig>,
|
||||
systemPrompt: string,
|
||||
tools: ReturnType<typeof createCattyTools>,
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
) => Promise<ApprovalInfo | null>;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook return type
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseToolApprovalReturn {
|
||||
/** Ref to the current pending approval context (null when none). */
|
||||
pendingApprovalContextRef: React.MutableRefObject<PendingApprovalContext | null>;
|
||||
/** Set or clear the pending approval context (manages timeout). */
|
||||
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
|
||||
/** Handle a user's approve/reject response from InlineApprovalCard. */
|
||||
handleApprovalResponse: (
|
||||
messageId: string,
|
||||
approved: boolean,
|
||||
approvalContext: ToolApprovalContext,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Context values needed by handleApprovalResponse that change frequently. */
|
||||
export interface ToolApprovalContext {
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook implementation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export function useToolApproval({
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
t,
|
||||
}: UseToolApprovalParams): UseToolApprovalReturn {
|
||||
// Pending approval context — stores SDK state needed to resume after user approves/rejects
|
||||
const pendingApprovalContextRef = useRef<PendingApprovalContext | null>(sharedPendingApprovalContext);
|
||||
pendingApprovalContextRef.current = sharedPendingApprovalContext;
|
||||
|
||||
/** Set pending approval context with a 5-minute auto-clear timeout. */
|
||||
const setPendingApproval = useCallback((ctx: PendingApprovalContext | null) => {
|
||||
// Clear any existing timeout
|
||||
if (sharedPendingApprovalTimeout) {
|
||||
clearTimeout(sharedPendingApprovalTimeout);
|
||||
sharedPendingApprovalTimeout = null;
|
||||
}
|
||||
sharedPendingApprovalContext = ctx;
|
||||
pendingApprovalContextRef.current = ctx;
|
||||
if (ctx) {
|
||||
sharedPendingApprovalTimeout = setTimeout(() => {
|
||||
// Auto-clear after 5 minutes if user never responds
|
||||
if (sharedPendingApprovalContext?.sessionId === ctx.sessionId) {
|
||||
sharedPendingApprovalContext = null;
|
||||
pendingApprovalContextRef.current = null;
|
||||
setStreamingForScope(ctx.sessionId, false);
|
||||
abortControllersRef.current.get(ctx.sessionId)?.abort();
|
||||
abortControllersRef.current.delete(ctx.sessionId);
|
||||
// Notify the user that the approval timed out
|
||||
updateLastMessage(ctx.sessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
addMessageToSession(ctx.sessionId, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: t('ai.chat.approvalTimeout'),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
sharedPendingApprovalTimeout = null;
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
}
|
||||
}, [setStreamingForScope, abortControllersRef, updateLastMessage, addMessageToSession, t]);
|
||||
|
||||
// Handle inline approval response (approve/reject from InlineApprovalCard)
|
||||
const handleApprovalResponse = useCallback(async (
|
||||
messageId: string,
|
||||
approved: boolean,
|
||||
approvalContext: ToolApprovalContext,
|
||||
) => {
|
||||
const ctx = pendingApprovalContextRef.current;
|
||||
if (!ctx) return;
|
||||
// Destructure all needed values BEFORE clearing the ref to avoid race conditions
|
||||
const {
|
||||
sessionId: sid,
|
||||
scopeKey: sk,
|
||||
sdkMessages,
|
||||
approvalInfo,
|
||||
model: ctxModel,
|
||||
scopeType,
|
||||
scopeLabel,
|
||||
getExecutorContext,
|
||||
} = ctx;
|
||||
// Clear pending approval (and its timeout) via setPendingApproval
|
||||
setPendingApproval(null);
|
||||
|
||||
// Update the message's pendingApproval status using message ID
|
||||
updateMessageById(sid, messageId, msg => ({
|
||||
...msg,
|
||||
pendingApproval: msg.pendingApproval
|
||||
? { ...msg.pendingApproval, status: approved ? 'approved' as const : 'denied' as const }
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
if (!approved) {
|
||||
// User rejected — add denial text and stop
|
||||
updateMessageById(sid, messageId, msg => ({
|
||||
...msg,
|
||||
content: msg.content + (msg.content ? '\n\n' : '') + t('ai.chat.toolDenied'),
|
||||
statusText: '',
|
||||
executionStatus: 'completed',
|
||||
}));
|
||||
setStreamingForScope(sid, false);
|
||||
abortControllersRef.current.delete(sid);
|
||||
return;
|
||||
}
|
||||
|
||||
// User approved — construct SDK messages with approval response and resume
|
||||
const resumeMessages: Array<Record<string, unknown>> = [
|
||||
...sdkMessages,
|
||||
// The assistant message that contained the tool call + approval request
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: approvalInfo.toolCallId,
|
||||
toolName: approvalInfo.toolName,
|
||||
input: approvalInfo.toolArgs,
|
||||
},
|
||||
{
|
||||
type: 'tool-approval-request',
|
||||
approvalId: approvalInfo.approvalId,
|
||||
toolCallId: approvalInfo.toolCallId,
|
||||
},
|
||||
],
|
||||
},
|
||||
// The user's approval response
|
||||
{
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-approval-response',
|
||||
approvalId: approvalInfo.approvalId,
|
||||
approved: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Create a new assistant message placeholder for the continuation
|
||||
const newAssistantMsgId = generateId();
|
||||
addMessageToSession(sid, {
|
||||
id: newAssistantMsgId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllersRef.current.set(sid, abortController);
|
||||
|
||||
try {
|
||||
// Rebuild tools and system prompt with the latest permission mode to prevent
|
||||
// stale settings, while keeping the original AI scope pinned to its workspace/session.
|
||||
const bridge = getNetcattyBridge();
|
||||
const freshExecutorContext = getExecutorContext();
|
||||
const freshTools = createCattyTools(
|
||||
bridge,
|
||||
getExecutorContext,
|
||||
approvalContext.commandBlocklist,
|
||||
approvalContext.globalPermissionMode,
|
||||
approvalContext.webSearchConfig ?? undefined,
|
||||
);
|
||||
const freshSystemPrompt = buildSystemPrompt({
|
||||
scopeType,
|
||||
scopeLabel,
|
||||
hosts: freshExecutorContext.sessions.map(s => ({
|
||||
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
|
||||
os: s.os, username: s.username, connected: s.connected,
|
||||
})),
|
||||
permissionMode: approvalContext.globalPermissionMode,
|
||||
webSearchEnabled: isWebSearchReady(approvalContext.webSearchConfig),
|
||||
});
|
||||
const newApprovalInfo = await processCattyStream(sid, ctxModel, freshSystemPrompt, freshTools, resumeMessages as unknown as ModelMessage[], abortController.signal, newAssistantMsgId);
|
||||
|
||||
if (newApprovalInfo) {
|
||||
// Another approval needed — save context for the next round (with timeout)
|
||||
setPendingApproval({
|
||||
sessionId: sid,
|
||||
scopeKey: sk,
|
||||
sdkMessages: resumeMessages,
|
||||
approvalInfo: newApprovalInfo,
|
||||
model: ctxModel,
|
||||
systemPrompt: freshSystemPrompt,
|
||||
tools: freshTools,
|
||||
scopeType,
|
||||
scopeLabel,
|
||||
getExecutorContext,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Catty resume] streamText error:', err);
|
||||
if (!abortController.signal.aborted) {
|
||||
const errorStr = err instanceof Error ? err.message : String(err);
|
||||
updateMessageById(sid, newAssistantMsgId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
addMessageToSession(sid, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo: classifyError(errorStr),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!pendingApprovalContextRef.current || pendingApprovalContextRef.current.sessionId !== sid) {
|
||||
// Clear any lingering statusText when the resumed stream finishes
|
||||
updateLastMessage(sid, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sid, false);
|
||||
abortControllersRef.current.delete(sid);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
processCattyStream, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope, abortControllersRef, t, setPendingApproval,
|
||||
]);
|
||||
|
||||
return {
|
||||
pendingApprovalContextRef,
|
||||
setPendingApproval,
|
||||
handleApprovalResponse,
|
||||
};
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
|
||||
name: "Claude Code",
|
||||
args: ["-p", "--output-format", "text", "{prompt}"],
|
||||
icon: "claude",
|
||||
acpCommand: "claude-code-acp",
|
||||
acpCommand: "claude-agent-acp",
|
||||
acpArgs: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import type { RemoteFile } from "../../types";
|
||||
|
||||
interface PermissionsState {
|
||||
owner: { read: boolean; write: boolean; execute: boolean };
|
||||
group: { read: boolean; write: boolean; execute: boolean };
|
||||
others: { read: boolean; write: boolean; execute: boolean };
|
||||
}
|
||||
|
||||
interface SftpModalDialogsProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
showRenameDialog: boolean;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
renameTarget: RemoteFile | null;
|
||||
renameName: string;
|
||||
setRenameName: (value: string) => void;
|
||||
handleRename: () => void;
|
||||
isRenaming: boolean;
|
||||
showPermissionsDialog: boolean;
|
||||
setShowPermissionsDialog: (open: boolean) => void;
|
||||
permissionsTarget: RemoteFile | null;
|
||||
permissions: PermissionsState;
|
||||
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
|
||||
getOctalPermissions: () => string;
|
||||
getSymbolicPermissions: () => string;
|
||||
handleSavePermissions: () => void;
|
||||
isChangingPermissions: boolean;
|
||||
showCreateDialog: boolean;
|
||||
setShowCreateDialog: (open: boolean) => void;
|
||||
createType: "file" | "folder";
|
||||
createName: string;
|
||||
setCreateName: (value: string) => void;
|
||||
isCreating: boolean;
|
||||
handleCreateSubmit: () => void;
|
||||
}
|
||||
|
||||
export const SftpModalDialogs: React.FC<SftpModalDialogsProps> = ({
|
||||
t,
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
handleRename,
|
||||
isRenaming,
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
isChangingPermissions,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
}) => (
|
||||
<>
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
|
||||
<DialogDescription className="truncate">
|
||||
{renameTarget?.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
placeholder={t("sftp.rename.placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRename();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowRenameDialog(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleRename} disabled={isRenaming || !renameName.trim()}>
|
||||
{isRenaming ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
|
||||
{t("common.apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showPermissionsDialog} onOpenChange={setShowPermissionsDialog}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.permissions.title")}</DialogTitle>
|
||||
<DialogDescription className="truncate">
|
||||
{permissionsTarget?.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-3">
|
||||
{(["owner", "group", "others"] as const).map((role) => (
|
||||
<div key={role} className="flex items-center gap-4">
|
||||
<div className="w-16 text-sm font-medium">
|
||||
{t(`sftp.permissions.${role}`)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{(["read", "write", "execute"] as const).map((perm) => (
|
||||
<label key={perm} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permissions[role][perm]}
|
||||
onChange={() => togglePermission(role, perm)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{perm === "read" ? "R" : perm === "write" ? "W" : "X"}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border/60">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sftp.permissions.octal")}: <span className="font-mono text-foreground">{getOctalPermissions()}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sftp.permissions.symbolic")}: <span className="font-mono text-foreground">{getSymbolicPermissions()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowPermissionsDialog(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSavePermissions} disabled={isChangingPermissions}>
|
||||
{isChangingPermissions ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
|
||||
{t("common.apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(createType === "folder" ? "sftp.newFolder" : "sftp.newFile")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(createType === "folder" ? "sftp.prompt.newFolderName" : "sftp.fileName.placeholder")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
placeholder={t(createType === "folder" ? "sftp.prompt.newFolderName" : "sftp.fileName.placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreateSubmit();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCreateSubmit} disabled={isCreating || !createName.trim()}>
|
||||
{isCreating ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
|
||||
{t("common.apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
@@ -1,438 +0,0 @@
|
||||
import React from "react";
|
||||
import { Download, Edit2, Folder, FolderOpen, FolderUp, Link, Loader2, MoreHorizontal, Plus, RefreshCw, Shield, Trash2, Upload } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { RemoteFile } from "../../types";
|
||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "../ui/context-menu";
|
||||
import { Button } from "../ui/button";
|
||||
import { getFileIcon } from "./fileIcons";
|
||||
|
||||
interface VisibleRow {
|
||||
file: RemoteFile;
|
||||
index: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
interface SftpModalFileListProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
hasFiles: boolean;
|
||||
hasDisplayFiles: boolean;
|
||||
selectedFiles: Set<string>;
|
||||
dragActive: boolean;
|
||||
loading: boolean;
|
||||
loadingTextContent: boolean;
|
||||
reconnecting: boolean;
|
||||
columnWidths: { name: number; size: number; modified: number; actions: number };
|
||||
sortField: "name" | "size" | "modified";
|
||||
sortOrder: "asc" | "desc";
|
||||
shouldVirtualize: boolean;
|
||||
totalHeight: number;
|
||||
visibleRows: VisibleRow[];
|
||||
fileListRef: React.RefObject<HTMLDivElement>;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
folderInputRef: React.RefObject<HTMLInputElement>;
|
||||
handleSort: (field: "name" | "size" | "modified") => void;
|
||||
handleResizeStart: (field: string, e: React.MouseEvent) => void;
|
||||
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||
handleDrag: (e: React.DragEvent) => void;
|
||||
handleDrop: (e: React.DragEvent) => void;
|
||||
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
|
||||
handleFileDoubleClick: (file: RemoteFile) => void;
|
||||
handleDownload: (file: RemoteFile) => void;
|
||||
handleDelete: (file: RemoteFile) => void;
|
||||
handleOpenFile: (file: RemoteFile) => void;
|
||||
openFileOpenerDialog: (file: RemoteFile) => void;
|
||||
handleEditFile: (file: RemoteFile) => void;
|
||||
openRenameDialog: (file: RemoteFile) => void;
|
||||
openPermissionsDialog: (file: RemoteFile) => void;
|
||||
handleNavigate: (path: string) => void;
|
||||
handleCreateFolder: () => void;
|
||||
handleCreateFile: () => void;
|
||||
handleDownloadSelected: () => void;
|
||||
handleDeleteSelected: () => void;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => void;
|
||||
formatBytes: (bytes: number | string) => string;
|
||||
formatDate: (dateStr: string | number | undefined) => string;
|
||||
}
|
||||
|
||||
export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
t,
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
hasFiles,
|
||||
hasDisplayFiles,
|
||||
selectedFiles,
|
||||
dragActive,
|
||||
loading,
|
||||
loadingTextContent,
|
||||
reconnecting,
|
||||
columnWidths,
|
||||
sortField,
|
||||
sortOrder,
|
||||
shouldVirtualize,
|
||||
totalHeight,
|
||||
visibleRows,
|
||||
fileListRef,
|
||||
inputRef,
|
||||
folderInputRef,
|
||||
handleSort,
|
||||
handleResizeStart,
|
||||
handleFileListScroll,
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
handleFileClick,
|
||||
handleFileDoubleClick,
|
||||
handleDownload,
|
||||
handleDelete,
|
||||
handleOpenFile,
|
||||
openFileOpenerDialog,
|
||||
handleEditFile,
|
||||
openRenameDialog,
|
||||
openPermissionsDialog,
|
||||
handleNavigate,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
handleDownloadSelected,
|
||||
handleDeleteSelected,
|
||||
loadFiles,
|
||||
formatBytes,
|
||||
formatDate,
|
||||
}) => (
|
||||
<>
|
||||
<div
|
||||
className="shrink-0 bg-muted/80 backdrop-blur-sm border-b border-border/60 px-4 py-2 flex items-center text-xs font-medium text-muted-foreground select-none"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
<span>{t("sftp.columns.name")}</span>
|
||||
{sortField === "name" && (
|
||||
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
|
||||
)}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("name", e)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
onClick={() => handleSort("size")}
|
||||
>
|
||||
<span>{t("sftp.columns.size")}</span>
|
||||
{sortField === "size" && (
|
||||
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
|
||||
)}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("size", e)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
onClick={() => handleSort("modified")}
|
||||
>
|
||||
<span>{t("sftp.columns.modified")}</span>
|
||||
{sortField === "modified" && (
|
||||
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
|
||||
)}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("modified", e)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right">{t("sftp.columns.actions")}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={fileListRef}
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto relative",
|
||||
dragActive && "bg-primary/5 ring-2 ring-inset ring-primary",
|
||||
)}
|
||||
onScroll={handleFileListScroll}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{dragActive && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="bg-background/95 p-6 rounded-xl shadow-lg border-2 border-dashed border-primary text-primary font-medium flex flex-col items-center gap-2">
|
||||
<Upload size={32} />
|
||||
<span>{t("sftp.dropFilesHere")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !hasFiles && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingTextContent && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-20">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("sftp.status.loading")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t("sftp.reconnecting.desc")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasDisplayFiles && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={48} className="mb-3 opacity-50" />
|
||||
<div className="text-sm font-medium">{t("sftp.emptyDirectory")}</div>
|
||||
<div className="text-xs mt-1">{t("sftp.dragDropToUpload")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={shouldVirtualize ? "relative" : "divide-y divide-border/30"}
|
||||
style={shouldVirtualize ? { height: totalHeight } : undefined}
|
||||
>
|
||||
{visibleRows.map(({ file, index: idx, top }) => {
|
||||
const isNavigableDirectory =
|
||||
file.type === "directory" ||
|
||||
(file.type === "symlink" && file.linkTarget === "directory");
|
||||
const isDownloadableFile =
|
||||
file.type === "file" ||
|
||||
(file.type === "symlink" && file.linkTarget === "file");
|
||||
const isParentEntry = file.name === "..";
|
||||
|
||||
return (
|
||||
<ContextMenu key={file.name}>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
data-sftp-modal-row="true"
|
||||
className={cn(
|
||||
"px-4 py-2.5 items-center hover:bg-muted/50 cursor-pointer transition-colors text-sm",
|
||||
selectedFiles.has(file.name) && !isParentEntry && "bg-primary/10",
|
||||
shouldVirtualize ? "absolute left-0 right-0 border-b border-border/30" : "",
|
||||
)}
|
||||
style={
|
||||
shouldVirtualize
|
||||
? {
|
||||
top,
|
||||
display: "grid",
|
||||
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
|
||||
}
|
||||
: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
|
||||
}
|
||||
}
|
||||
onClick={(e) => handleFileClick(file, idx, e)}
|
||||
onDoubleClick={() => handleFileDoubleClick(file)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="relative shrink-0 h-7 w-7 flex items-center justify-center">
|
||||
{getFileIcon(
|
||||
file.name,
|
||||
isNavigableDirectory,
|
||||
file.type === "symlink" && !isNavigableDirectory,
|
||||
)}
|
||||
{file.type === "symlink" && (
|
||||
<Link
|
||||
size={10}
|
||||
className="absolute -bottom-0.5 -right-0.5 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate font-medium",
|
||||
file.type === "symlink" && "italic pr-1",
|
||||
)}
|
||||
>
|
||||
{file.name}
|
||||
{file.type === "symlink" && (
|
||||
<span className="sr-only"> (symbolic link)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isNavigableDirectory ? "--" : formatBytes(file.size)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{formatDate(file.lastModified)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isDownloadableFile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(file);
|
||||
}}
|
||||
title={t("sftp.context.download")}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
)}
|
||||
{!isParentEntry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(file);
|
||||
}}
|
||||
title={t("sftp.context.delete")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{isParentEntry ? (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const segments = currentPath.split("/").filter(Boolean);
|
||||
segments.pop();
|
||||
const parentPath =
|
||||
segments.length === 0 ? "/" : `/${segments.join("/")}`;
|
||||
handleNavigate(parentPath);
|
||||
}}
|
||||
>
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
) : (
|
||||
<>
|
||||
{isNavigableDirectory && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
handleNavigate(
|
||||
currentPath === "/"
|
||||
? `/${file.name}`
|
||||
: `${currentPath}/${file.name}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
{!isLocalSession && (
|
||||
<ContextMenuItem onClick={() => handleDownload(file)}>
|
||||
<Download size={14} className="mr-2" />
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isDownloadableFile && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => handleOpenFile(file)}>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openFileOpenerDialog(file)}>
|
||||
<MoreHorizontal size={14} className="mr-2" />
|
||||
{t("sftp.context.openWith")}
|
||||
</ContextMenuItem>
|
||||
{!isKnownBinaryFile(file.name) && (
|
||||
<ContextMenuItem onClick={() => handleEditFile(file)}>
|
||||
<Edit2 size={14} className="mr-2" />
|
||||
{t("sftp.context.edit")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => handleDownload(file)}>
|
||||
<Download size={14} className="mr-2" />
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => openRenameDialog(file)}>
|
||||
<Edit2 size={14} className="mr-2" />
|
||||
{t("sftp.context.rename")}
|
||||
</ContextMenuItem>
|
||||
{!isLocalSession && (
|
||||
<ContextMenuItem onClick={() => openPermissionsDialog(file)}>
|
||||
<Shield size={14} className="mr-2" />
|
||||
{t("sftp.context.permissions")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDelete(file)}
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t("sftp.context.delete")}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={handleCreateFolder}>
|
||||
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleCreateFile}>
|
||||
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFile")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => inputRef.current?.click()}>
|
||||
<Upload className="h-4 w-4 mr-2" /> {t("sftp.uploadFiles")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => folderInputRef.current?.click()}>
|
||||
<FolderUp className="h-4 w-4 mr-2" /> {t("sftp.uploadFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => loadFiles(currentPath, { force: true })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> {t("sftp.context.refresh")}
|
||||
</ContextMenuItem>
|
||||
{selectedFiles.size > 0 && (
|
||||
<>
|
||||
<ContextMenuItem onClick={handleDownloadSelected}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{t("sftp.context.downloadSelected", { count: selectedFiles.size })}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t("sftp.context.deleteSelected", { count: selectedFiles.size })}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -1,61 +0,0 @@
|
||||
import React from "react";
|
||||
import { Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import type { RemoteFile } from "../../types";
|
||||
|
||||
interface SftpModalFooterProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
files: RemoteFile[];
|
||||
selectedFiles: Set<string>;
|
||||
loading: boolean;
|
||||
uploading: boolean;
|
||||
onDownloadSelected: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
}
|
||||
|
||||
export const SftpModalFooter: React.FC<SftpModalFooterProps> = ({
|
||||
t,
|
||||
files,
|
||||
selectedFiles,
|
||||
loading,
|
||||
uploading,
|
||||
onDownloadSelected,
|
||||
onDeleteSelected,
|
||||
}) => (
|
||||
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
|
||||
<span>
|
||||
{t("sftp.itemsCount", { count: files.length })}
|
||||
{selectedFiles.size > 0 && (
|
||||
<>
|
||||
<span className="mx-2">|</span>
|
||||
<span className="text-primary">
|
||||
{t("sftp.selectedCount", { count: selectedFiles.size })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-2 ml-2 text-xs text-primary hover:text-primary"
|
||||
onClick={onDownloadSelected}
|
||||
>
|
||||
<Download size={10} className="mr-1" /> {t("sftp.context.download")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-2 text-xs text-destructive hover:text-destructive"
|
||||
onClick={onDeleteSelected}
|
||||
>
|
||||
<Trash2 size={10} className="mr-1" /> {t("sftp.context.delete")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{loading
|
||||
? t("sftp.status.loading")
|
||||
: uploading
|
||||
? t("sftp.status.uploading")
|
||||
: t("sftp.status.ready")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1,480 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ArrowUp, Bookmark, Check, ChevronRight, Eye, EyeOff, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload, X } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { Host, SftpFilenameEncoding } from "../../types";
|
||||
import { useSftpBookmarks } from "../sftp/hooks/useSftpBookmarks";
|
||||
import { DistroAvatar } from "../DistroAvatar";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
interface BreadcrumbPart {
|
||||
part: string;
|
||||
originalIndex: number;
|
||||
}
|
||||
|
||||
interface SftpModalHeaderProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
host: Host;
|
||||
credentials: { username?: string; hostname: string; port?: number };
|
||||
showEncoding: boolean;
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
onFilenameEncodingChange: (encoding: SftpFilenameEncoding) => void;
|
||||
currentPath: string;
|
||||
isEditingPath: boolean;
|
||||
editingPathValue: string;
|
||||
setEditingPathValue: (value: string) => void;
|
||||
handlePathSubmit: () => void;
|
||||
handlePathKeyDown: (e: React.KeyboardEvent) => void;
|
||||
handlePathDoubleClick: () => void;
|
||||
isAtRoot: boolean;
|
||||
rootLabel: string;
|
||||
isRefreshing: boolean;
|
||||
onUp: () => void;
|
||||
onHome: () => void;
|
||||
onRefresh: () => void;
|
||||
visibleBreadcrumbs: BreadcrumbPart[];
|
||||
hiddenBreadcrumbs: BreadcrumbPart[];
|
||||
needsBreadcrumbTruncation: boolean;
|
||||
breadcrumbs: string[];
|
||||
onBreadcrumbSelect: (index: number) => void;
|
||||
onRootSelect: () => void;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
folderInputRef: React.RefObject<HTMLInputElement>;
|
||||
pathInputRef: React.RefObject<HTMLInputElement>;
|
||||
uploading: boolean;
|
||||
onTriggerUpload: () => void;
|
||||
onTriggerFolderUpload: () => void;
|
||||
onCreateFolder: () => void;
|
||||
onCreateFile: () => void;
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onFolderSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
showHiddenFiles: boolean;
|
||||
onToggleShowHiddenFiles: () => void;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
onNavigateToBookmark?: (path: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
t,
|
||||
host,
|
||||
credentials,
|
||||
showEncoding,
|
||||
filenameEncoding,
|
||||
onFilenameEncodingChange,
|
||||
currentPath,
|
||||
isEditingPath,
|
||||
editingPathValue,
|
||||
setEditingPathValue,
|
||||
handlePathSubmit,
|
||||
handlePathKeyDown,
|
||||
handlePathDoubleClick,
|
||||
isAtRoot,
|
||||
rootLabel,
|
||||
isRefreshing,
|
||||
onUp,
|
||||
onHome,
|
||||
onRefresh,
|
||||
visibleBreadcrumbs,
|
||||
hiddenBreadcrumbs,
|
||||
needsBreadcrumbTruncation,
|
||||
breadcrumbs,
|
||||
onBreadcrumbSelect,
|
||||
onRootSelect,
|
||||
inputRef,
|
||||
folderInputRef,
|
||||
pathInputRef,
|
||||
uploading,
|
||||
onTriggerUpload,
|
||||
onTriggerFolderUpload,
|
||||
onCreateFolder,
|
||||
onCreateFile,
|
||||
onFileSelect,
|
||||
onFolderSelect,
|
||||
showHiddenFiles,
|
||||
onToggleShowHiddenFiles,
|
||||
onUpdateHost,
|
||||
onNavigateToBookmark,
|
||||
onClose,
|
||||
}) => {
|
||||
// Delay tooltip activation to prevent flickering when modal opens
|
||||
const [tooltipsReady, setTooltipsReady] = useState(false);
|
||||
const [openTooltip, setOpenTooltip] = useState<string | null>(null);
|
||||
|
||||
// Bookmarks
|
||||
const {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
} = useSftpBookmarks({
|
||||
host,
|
||||
currentPath,
|
||||
onUpdateHost,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setTooltipsReady(true), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleTooltipOpenChange = (id: string) => (open: boolean) => {
|
||||
if (!tooltipsReady) return;
|
||||
setOpenTooltip(open ? id : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
className="h-8 w-8"
|
||||
size="sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold">
|
||||
{host.label}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{credentials.username || "root"}@{credentials.hostname}:
|
||||
{credentials.port || 22}
|
||||
</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={800} disableHoverableContent>
|
||||
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
|
||||
<Tooltip open={openTooltip === 'up'} onOpenChange={handleTooltipOpenChange('up')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onUp}
|
||||
disabled={isAtRoot}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.up")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'home'} onOpenChange={handleTooltipOpenChange('home')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onHome}
|
||||
>
|
||||
<Home size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.home")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'refresh'} onOpenChange={handleTooltipOpenChange('refresh')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn(isRefreshing && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Bookmark button */}
|
||||
{onUpdateHost && (
|
||||
<Popover>
|
||||
<Tooltip open={openTooltip === 'bookmark'} onOpenChange={handleTooltipOpenChange('bookmark')}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Bookmark
|
||||
size={14}
|
||||
className={cn(
|
||||
isCurrentPathBookmarked && "fill-yellow-500 text-yellow-500"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-56 p-1" align="start">
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors"
|
||||
onClick={toggleBookmark}
|
||||
>
|
||||
<Bookmark
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isCurrentPathBookmarked && "fill-yellow-500 text-yellow-500"
|
||||
)}
|
||||
/>
|
||||
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
|
||||
</button>
|
||||
{/* Divider + list */}
|
||||
{bookmarks.length > 0 && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
{bookmarks.map((bm) => (
|
||||
<div
|
||||
key={bm.id}
|
||||
className="group flex items-center gap-1 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => onNavigateToBookmark?.(bm.path)}
|
||||
title={bm.path}
|
||||
>
|
||||
<Bookmark size={10} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{bm.label}</span>
|
||||
<span className="flex-1 truncate text-muted-foreground text-[10px]">{bm.path}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteBookmark(bm.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{bookmarks.length === 0 && (
|
||||
<div className="p-2 text-xs text-muted-foreground text-center">
|
||||
{t("sftp.bookmark.empty")}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
{showEncoding && (
|
||||
<Popover>
|
||||
<Tooltip open={openTooltip === 'encoding'} onOpenChange={handleTooltipOpenChange('encoding')}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Languages size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
|
||||
<PopoverClose asChild key={encoding}>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-sm hover:bg-secondary transition-colors",
|
||||
filenameEncoding === encoding && "bg-secondary"
|
||||
)}
|
||||
onClick={() => onFilenameEncodingChange(encoding)}
|
||||
>
|
||||
<Check
|
||||
size={14}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
filenameEncoding === encoding ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<Tooltip
|
||||
open={openTooltip === 'showHiddenFiles'}
|
||||
onOpenChange={handleTooltipOpenChange('showHiddenFiles')}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={showHiddenFiles ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", showHiddenFiles && "text-primary")}
|
||||
onClick={onToggleShowHiddenFiles}
|
||||
>
|
||||
{showHiddenFiles ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.sftp.showHiddenFiles")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
|
||||
{isEditingPath ? (
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={editingPathValue}
|
||||
onChange={(e) => setEditingPathValue(e.target.value)}
|
||||
onBlur={handlePathSubmit}
|
||||
onKeyDown={handlePathKeyDown}
|
||||
className="h-7 text-sm bg-background"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
|
||||
onDoubleClick={handlePathDoubleClick}
|
||||
title={currentPath}
|
||||
>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
|
||||
onClick={onRootSelect}
|
||||
>
|
||||
{rootLabel}
|
||||
</button>
|
||||
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
|
||||
const isLast = originalIndex === breadcrumbs.length - 1;
|
||||
const showEllipsisBefore =
|
||||
needsBreadcrumbTruncation && displayIdx === 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={originalIndex}>
|
||||
{showEllipsisBefore && (
|
||||
<>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
className="text-muted-foreground px-1 shrink-0 flex items-center cursor-default"
|
||||
title={`${t("sftp.showHiddenPaths")}: ${hiddenBreadcrumbs
|
||||
.map((h) => h.part)
|
||||
.join(" > ")}`}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
|
||||
isLast && "text-foreground font-medium",
|
||||
)}
|
||||
onClick={() => onBreadcrumbSelect(originalIndex)}
|
||||
title={part}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Tooltip open={openTooltip === 'upload'} onOpenChange={handleTooltipOpenChange('upload')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onTriggerUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Upload size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.upload")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'uploadFolder'} onOpenChange={handleTooltipOpenChange('uploadFolder')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onTriggerFolderUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<FolderUp size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.uploadFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'newFolder'} onOpenChange={handleTooltipOpenChange('newFolder')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onCreateFolder}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'newFile'} onOpenChange={handleTooltipOpenChange('newFile')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onCreateFile}
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={inputRef}
|
||||
onChange={onFileSelect}
|
||||
multiple
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={folderInputRef}
|
||||
onChange={onFolderSelect}
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,228 +0,0 @@
|
||||
import React from "react";
|
||||
import { Download, Loader2, Upload, X, XCircle } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface TransferTask {
|
||||
id: string;
|
||||
fileName: string;
|
||||
totalBytes: number;
|
||||
transferredBytes: number;
|
||||
progress: number;
|
||||
speed: number;
|
||||
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
|
||||
error?: string;
|
||||
direction: "upload" | "download";
|
||||
targetPath?: string;
|
||||
}
|
||||
|
||||
interface SftpModalUploadTasksProps {
|
||||
tasks: TransferTask[];
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
onCancel?: () => void;
|
||||
onCancelTask?: (taskId: string) => void;
|
||||
onDismiss?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onCancelTask, onDismiss }) => {
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
// Helper function to get localized display name for compressed uploads
|
||||
const getDisplayName = (task: TransferTask) => {
|
||||
// Check for explicit phase marker format: "folderName|phase"
|
||||
// This is the format sent by uploadService.ts for compressed uploads
|
||||
if (task.fileName.includes('|')) {
|
||||
const pipeIndex = task.fileName.lastIndexOf('|');
|
||||
const baseName = task.fileName.substring(0, pipeIndex);
|
||||
const phase = task.fileName.substring(pipeIndex + 1);
|
||||
|
||||
if (phase === 'compressing' || phase === 'extracting' || phase === 'uploading' || phase === 'compressed') {
|
||||
const phaseLabel = t(`sftp.upload.phase.${phase}`);
|
||||
return `${baseName} (${phaseLabel})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for exact matches of phase status strings (legacy support)
|
||||
if (task.fileName === t('sftp.upload.compressing') || task.fileName === 'Compressing...' || task.fileName === 'Compressing') {
|
||||
return t('sftp.upload.compressing');
|
||||
}
|
||||
if (task.fileName === t('sftp.upload.extracting') || task.fileName === 'Extracting...' || task.fileName === 'Extracting') {
|
||||
return t('sftp.upload.extracting');
|
||||
}
|
||||
if (task.fileName === t('sftp.upload.scanning') || task.fileName === 'Scanning files...' || task.fileName === 'Scanning files') {
|
||||
return t('sftp.upload.scanning');
|
||||
}
|
||||
|
||||
// Check if this is a compressed upload task (legacy format)
|
||||
if (task.fileName.includes('(compressed)')) {
|
||||
const baseName = task.fileName.replace(' (compressed)', '');
|
||||
return `${baseName} (${t('sftp.upload.compressed')})`;
|
||||
}
|
||||
|
||||
return task.fileName;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/60 bg-secondary/50 flex-shrink-0">
|
||||
<div className="max-h-40 overflow-y-auto overflow-x-hidden">
|
||||
{[...tasks].reverse().map((task) => {
|
||||
const formatSpeed = (bytesPerSec: number) => {
|
||||
if (bytesPerSec <= 0) return "";
|
||||
if (bytesPerSec >= 1024 * 1024)
|
||||
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
if (bytesPerSec >= 1024)
|
||||
return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
|
||||
return `${Math.round(bytesPerSec)} B/s`;
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (bytes >= 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
};
|
||||
|
||||
const remainingBytes = task.totalBytes - task.transferredBytes;
|
||||
const effectiveSpeed = task.speed > 0 ? task.speed : 0;
|
||||
const remainingTime =
|
||||
effectiveSpeed > 0 ? Math.ceil(remainingBytes / effectiveSpeed) : 0;
|
||||
const remainingStr =
|
||||
remainingTime > 60
|
||||
? `~${Math.ceil(remainingTime / 60)}m left`
|
||||
: remainingTime > 0
|
||||
? `~${remainingTime}s left`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="px-4 py-2.5 flex items-center gap-3 border-b border-border/30 last:border-b-0"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{(task.status === "uploading" || task.status === "downloading") && (
|
||||
<Loader2 size={14} className="animate-spin text-primary" />
|
||||
)}
|
||||
{task.status === "pending" && (
|
||||
task.direction === "download"
|
||||
? <Download size={14} className="text-muted-foreground animate-pulse" />
|
||||
: <Upload size={14} className="text-muted-foreground animate-pulse" />
|
||||
)}
|
||||
{task.status === "completed" && (
|
||||
task.direction === "download"
|
||||
? <Download size={14} className="text-green-500" />
|
||||
: <Upload size={14} className="text-green-500" />
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
<XCircle size={14} className="text-destructive" />
|
||||
)}
|
||||
{task.status === "cancelled" && (
|
||||
<XCircle size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium truncate">
|
||||
{getDisplayName(task)}
|
||||
</span>
|
||||
{(task.status === "uploading" || task.status === "downloading") && effectiveSpeed > 0 && (
|
||||
<span className="text-[10px] text-primary font-mono shrink-0">
|
||||
{formatSpeed(effectiveSpeed)}
|
||||
</span>
|
||||
)}
|
||||
{(task.status === "uploading" || task.status === "downloading") && remainingStr && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{remainingStr}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-150",
|
||||
task.status === "pending"
|
||||
? "bg-muted-foreground/50 animate-pulse w-full"
|
||||
: "bg-primary",
|
||||
)}
|
||||
style={{
|
||||
width:
|
||||
task.status === "uploading" || task.status === "downloading"
|
||||
? `${task.progress}%`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono shrink-0 w-8 text-right">
|
||||
{task.status === "uploading" || task.status === "downloading" ? `${Math.round(task.progress)}%` : "..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(task.status === "uploading" || task.status === "downloading") && task.totalBytes > 0 && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
|
||||
{formatBytes(task.transferredBytes)} / {formatBytes(task.totalBytes)}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "completed" && (
|
||||
<div className="text-[10px] text-green-600 mt-0.5">
|
||||
{t(task.direction === "download" ? "sftp.download.completed" : "sftp.upload.completed")} - {formatBytes(task.totalBytes)}
|
||||
{task.targetPath && (
|
||||
<span className="text-muted-foreground ml-1">→ {task.targetPath}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "cancelled" && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{t(task.direction === "download" ? "sftp.download.cancelled" : "sftp.upload.cancelled")}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "failed" && task.error && (
|
||||
<div className="text-[10px] text-destructive truncate mt-0.5">
|
||||
{task.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-1">
|
||||
{task.status === "pending" && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("sftp.task.waiting")}
|
||||
</span>
|
||||
)}
|
||||
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (onCancelTask || onCancel) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
// For download tasks or when onCancelTask is available, use task-specific cancel
|
||||
if (onCancelTask) {
|
||||
onCancelTask(task.id);
|
||||
} else if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
title={t("sftp.action.cancel")}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
{(task.status === "completed" || task.status === "failed" || task.status === "cancelled") && onDismiss && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onDismiss(task.id)}
|
||||
title={t("sftp.action.dismiss")}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,149 +0,0 @@
|
||||
import {
|
||||
Database,
|
||||
ExternalLink,
|
||||
File,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileType,
|
||||
FileVideo,
|
||||
Folder,
|
||||
Globe,
|
||||
Lock,
|
||||
Settings,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
export const getFileIcon = (fileName: string, isDirectory: boolean, isSymlink?: boolean) => {
|
||||
if (isDirectory)
|
||||
return (
|
||||
<Folder
|
||||
size={18}
|
||||
fill="currentColor"
|
||||
fillOpacity={0.2}
|
||||
className="text-blue-400"
|
||||
/>
|
||||
);
|
||||
|
||||
if (isSymlink) {
|
||||
return <ExternalLink size={18} className="text-cyan-500" />;
|
||||
}
|
||||
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
if (["doc", "docx", "rtf", "odt"].includes(ext))
|
||||
return <FileText size={18} className="text-blue-500" />;
|
||||
if (["xls", "xlsx", "csv", "ods"].includes(ext))
|
||||
return <FileSpreadsheet size={18} className="text-green-500" />;
|
||||
if (["ppt", "pptx", "odp"].includes(ext))
|
||||
return <FileType size={18} className="text-orange-500" />;
|
||||
if (["pdf"].includes(ext))
|
||||
return <FileText size={18} className="text-red-500" />;
|
||||
|
||||
if (["js", "jsx", "ts", "tsx", "mjs", "cjs"].includes(ext))
|
||||
return <FileCode size={18} className="text-yellow-500" />;
|
||||
if (["py", "pyc", "pyw"].includes(ext))
|
||||
return <FileCode size={18} className="text-blue-400" />;
|
||||
if (["sh", "bash", "zsh", "fish", "bat", "cmd", "ps1"].includes(ext))
|
||||
return <Terminal size={18} className="text-green-400" />;
|
||||
if (["c", "cpp", "h", "hpp", "cc", "cxx"].includes(ext))
|
||||
return <FileCode size={18} className="text-blue-600" />;
|
||||
if (["java", "class", "jar"].includes(ext))
|
||||
return <FileCode size={18} className="text-orange-600" />;
|
||||
if (["go"].includes(ext))
|
||||
return <FileCode size={18} className="text-cyan-500" />;
|
||||
if (["rs"].includes(ext))
|
||||
return <FileCode size={18} className="text-orange-400" />;
|
||||
if (["rb"].includes(ext))
|
||||
return <FileCode size={18} className="text-red-400" />;
|
||||
if (["php"].includes(ext))
|
||||
return <FileCode size={18} className="text-purple-500" />;
|
||||
if (["html", "htm", "xhtml"].includes(ext))
|
||||
return <Globe size={18} className="text-orange-500" />;
|
||||
if (["css", "scss", "sass", "less"].includes(ext))
|
||||
return <FileCode size={18} className="text-blue-500" />;
|
||||
if (["vue", "svelte"].includes(ext))
|
||||
return <FileCode size={18} className="text-green-500" />;
|
||||
|
||||
if (["json", "json5"].includes(ext))
|
||||
return <FileCode size={18} className="text-yellow-600" />;
|
||||
if (["xml", "xsl", "xslt"].includes(ext))
|
||||
return <FileCode size={18} className="text-orange-400" />;
|
||||
if (["yml", "yaml"].includes(ext))
|
||||
return <Settings size={18} className="text-pink-400" />;
|
||||
if (["toml", "ini", "conf", "cfg", "config"].includes(ext))
|
||||
return <Settings size={18} className="text-gray-400" />;
|
||||
if (["env"].includes(ext))
|
||||
return <Lock size={18} className="text-yellow-500" />;
|
||||
if (["sql", "sqlite", "db"].includes(ext))
|
||||
return <Database size={18} className="text-blue-400" />;
|
||||
|
||||
if (
|
||||
[
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"bmp",
|
||||
"webp",
|
||||
"svg",
|
||||
"ico",
|
||||
"tiff",
|
||||
"tif",
|
||||
"heic",
|
||||
"heif",
|
||||
"avif",
|
||||
].includes(ext)
|
||||
)
|
||||
return <FileImage size={18} className="text-purple-400" />;
|
||||
|
||||
if (
|
||||
[
|
||||
"mp4",
|
||||
"mkv",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"webm",
|
||||
"m4v",
|
||||
"3gp",
|
||||
"mpeg",
|
||||
"mpg",
|
||||
].includes(ext)
|
||||
)
|
||||
return <FileVideo size={18} className="text-pink-500" />;
|
||||
|
||||
if (
|
||||
["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", "opus", "aiff"].includes(
|
||||
ext,
|
||||
)
|
||||
)
|
||||
return <FileAudio size={18} className="text-green-400" />;
|
||||
|
||||
if (
|
||||
[
|
||||
"zip",
|
||||
"rar",
|
||||
"7z",
|
||||
"tar",
|
||||
"gz",
|
||||
"bz2",
|
||||
"xz",
|
||||
"tgz",
|
||||
"tbz2",
|
||||
"lz",
|
||||
"lzma",
|
||||
"cab",
|
||||
"iso",
|
||||
"dmg",
|
||||
].includes(ext)
|
||||
)
|
||||
return <FileArchive size={18} className="text-yellow-600" />;
|
||||
|
||||
return <File size={18} className="text-muted-foreground" />;
|
||||
};
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalCreateDeleteParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
deleteLocalFile: (path: string) => Promise<void>;
|
||||
deleteSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
mkdirLocal: (path: string) => Promise<void>;
|
||||
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface UseSftpModalCreateDeleteResult {
|
||||
handleDelete: (file: RemoteFile) => Promise<void>;
|
||||
handleCreateFolder: () => void;
|
||||
handleCreateFile: () => void;
|
||||
// Create dialog state
|
||||
showCreateDialog: boolean;
|
||||
setShowCreateDialog: (open: boolean) => void;
|
||||
createType: "file" | "folder";
|
||||
createName: string;
|
||||
setCreateName: (value: string) => void;
|
||||
isCreating: boolean;
|
||||
handleCreateSubmit: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalCreateDelete = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
deleteLocalFile,
|
||||
deleteSftp,
|
||||
mkdirLocal,
|
||||
mkdirSftp,
|
||||
writeLocalFile,
|
||||
writeSftpBinary,
|
||||
writeSftp,
|
||||
t,
|
||||
}: UseSftpModalCreateDeleteParams): UseSftpModalCreateDeleteResult => {
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [createType, setCreateType] = useState<"file" | "folder">("folder");
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (file: RemoteFile) => {
|
||||
if (file.name === "..") return;
|
||||
if (!confirm(t("sftp.deleteConfirm.single", { name: file.name }))) return;
|
||||
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
if (isLocalSession) {
|
||||
await deleteLocalFile(fullPath);
|
||||
} else {
|
||||
await deleteSftp(await ensureSftp(), fullPath);
|
||||
}
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
},
|
||||
[currentPath, deleteLocalFile, deleteSftp, ensureSftp, isLocalSession, joinPath, loadFiles, t],
|
||||
);
|
||||
|
||||
const handleCreateFolder = useCallback(() => {
|
||||
setCreateType("folder");
|
||||
setCreateName("");
|
||||
setShowCreateDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateFile = useCallback(() => {
|
||||
setCreateType("file");
|
||||
setCreateName("");
|
||||
setShowCreateDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateSubmit = useCallback(async () => {
|
||||
const name = createName.trim();
|
||||
if (!name || isCreating) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, name);
|
||||
if (createType === "folder") {
|
||||
if (isLocalSession) {
|
||||
await mkdirLocal(fullPath);
|
||||
} else {
|
||||
await mkdirSftp(await ensureSftp(), fullPath);
|
||||
}
|
||||
} else {
|
||||
if (isLocalSession) {
|
||||
await writeLocalFile(fullPath, new ArrayBuffer(0));
|
||||
} else {
|
||||
try {
|
||||
await writeSftpBinary(await ensureSftp(), fullPath, new ArrayBuffer(0));
|
||||
} catch {
|
||||
await writeSftp(await ensureSftp(), fullPath, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
setShowCreateDialog(false);
|
||||
setCreateName("");
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t(createType === "folder" ? "sftp.error.createFolderFailed" : "sftp.error.createFileFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [createName, createType, currentPath, ensureSftp, isCreating, isLocalSession, joinPath, loadFiles, mkdirLocal, mkdirSftp, t, writeLocalFile, writeSftp, writeSftpBinary]);
|
||||
|
||||
return {
|
||||
handleDelete,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
};
|
||||
};
|
||||
@@ -1,277 +0,0 @@
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { useSftpModalCreateDelete } from "./useSftpModalCreateDelete";
|
||||
import { useSftpModalRename } from "./useSftpModalRename";
|
||||
import { useSftpModalPermissions } from "./useSftpModalPermissions";
|
||||
import { useSftpModalTextEditor } from "./useSftpModalTextEditor";
|
||||
import { useSftpModalFileOpener } from "./useSftpModalFileOpener";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
|
||||
interface UseSftpModalFileActionsParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
readLocalFile: (path: string) => Promise<ArrayBuffer>;
|
||||
readSftp: (sftpId: string, path: string) => Promise<string>;
|
||||
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
|
||||
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
|
||||
deleteLocalFile: (path: string) => Promise<void>;
|
||||
deleteSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
mkdirLocal: (path: string) => Promise<void>;
|
||||
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
|
||||
chmodSftp: (sftpId: string, path: string, permissions: string) => Promise<void>;
|
||||
statSftp: (sftpId: string, path: string) => Promise<{ permissions?: string }>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
sftpAutoSync: boolean;
|
||||
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
|
||||
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
|
||||
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
}
|
||||
|
||||
interface UseSftpModalFileActionsResult {
|
||||
handleDelete: (file: RemoteFile) => Promise<void>;
|
||||
handleCreateFolder: () => void;
|
||||
handleCreateFile: () => void;
|
||||
showCreateDialog: boolean;
|
||||
setShowCreateDialog: (open: boolean) => void;
|
||||
createType: "file" | "folder";
|
||||
createName: string;
|
||||
setCreateName: (value: string) => void;
|
||||
isCreating: boolean;
|
||||
handleCreateSubmit: () => Promise<void>;
|
||||
showRenameDialog: boolean;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
renameTarget: RemoteFile | null;
|
||||
renameName: string;
|
||||
setRenameName: (value: string) => void;
|
||||
isRenaming: boolean;
|
||||
openRenameDialog: (file: RemoteFile) => void;
|
||||
handleRename: () => Promise<void>;
|
||||
showPermissionsDialog: boolean;
|
||||
setShowPermissionsDialog: (open: boolean) => void;
|
||||
permissionsTarget: RemoteFile | null;
|
||||
permissions: {
|
||||
owner: { read: boolean; write: boolean; execute: boolean };
|
||||
group: { read: boolean; write: boolean; execute: boolean };
|
||||
others: { read: boolean; write: boolean; execute: boolean };
|
||||
};
|
||||
isChangingPermissions: boolean;
|
||||
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
|
||||
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
|
||||
getOctalPermissions: () => string;
|
||||
getSymbolicPermissions: () => string;
|
||||
handleSavePermissions: () => Promise<void>;
|
||||
showFileOpenerDialog: boolean;
|
||||
setShowFileOpenerDialog: (open: boolean) => void;
|
||||
fileOpenerTarget: RemoteFile | null;
|
||||
setFileOpenerTarget: (target: RemoteFile | null) => void;
|
||||
openFileOpenerDialog: (file: RemoteFile) => void;
|
||||
handleFileOpenerSelect: (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
systemApp?: SystemAppInfo,
|
||||
) => Promise<void>;
|
||||
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
showTextEditor: boolean;
|
||||
setShowTextEditor: (open: boolean) => void;
|
||||
textEditorTarget: RemoteFile | null;
|
||||
setTextEditorTarget: (target: RemoteFile | null) => void;
|
||||
textEditorContent: string;
|
||||
setTextEditorContent: (value: string) => void;
|
||||
loadingTextContent: boolean;
|
||||
handleEditFile: (file: RemoteFile) => Promise<void>;
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
handleOpenFile: (file: RemoteFile) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalFileActions = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftp,
|
||||
writeSftpBinary,
|
||||
deleteLocalFile,
|
||||
deleteSftp,
|
||||
mkdirLocal,
|
||||
mkdirSftp,
|
||||
renameSftp,
|
||||
chmodSftp,
|
||||
statSftp,
|
||||
t,
|
||||
sftpAutoSync,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
downloadSftpToTempAndOpen,
|
||||
selectApplication,
|
||||
}: UseSftpModalFileActionsParams): UseSftpModalFileActionsResult => {
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
} =
|
||||
useSftpModalCreateDelete({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
deleteLocalFile,
|
||||
deleteSftp,
|
||||
mkdirLocal,
|
||||
mkdirSftp,
|
||||
writeLocalFile,
|
||||
writeSftpBinary,
|
||||
writeSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
isRenaming,
|
||||
openRenameDialog,
|
||||
handleRename,
|
||||
} = useSftpModalRename({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
renameSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
isChangingPermissions,
|
||||
openPermissionsDialog,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
} = useSftpModalPermissions({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
chmodSftp,
|
||||
statSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
handleEditFile,
|
||||
handleSaveTextFile,
|
||||
} = useSftpModalTextEditor({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
openFileOpenerDialog,
|
||||
handleOpenFile,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpModalFileOpener({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
sftpAutoSync,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
downloadSftpToTempAndOpen,
|
||||
selectApplication,
|
||||
t,
|
||||
handleEditFile,
|
||||
});
|
||||
|
||||
return {
|
||||
handleDelete,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
isRenaming,
|
||||
openRenameDialog,
|
||||
handleRename,
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
isChangingPermissions,
|
||||
openPermissionsDialog,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
openFileOpenerDialog,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
handleEditFile,
|
||||
handleSaveTextFile,
|
||||
handleOpenFile,
|
||||
};
|
||||
};
|
||||
@@ -1,154 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { getFileExtension, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
|
||||
interface UseSftpModalFileOpenerParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
sftpAutoSync: boolean;
|
||||
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
|
||||
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
|
||||
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
handleEditFile: (file: RemoteFile) => Promise<void>;
|
||||
}
|
||||
|
||||
interface UseSftpModalFileOpenerResult {
|
||||
showFileOpenerDialog: boolean;
|
||||
setShowFileOpenerDialog: (open: boolean) => void;
|
||||
fileOpenerTarget: RemoteFile | null;
|
||||
setFileOpenerTarget: (target: RemoteFile | null) => void;
|
||||
openFileOpenerDialog: (file: RemoteFile) => void;
|
||||
handleOpenFile: (file: RemoteFile) => Promise<void>;
|
||||
handleFileOpenerSelect: (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
systemApp?: SystemAppInfo,
|
||||
) => Promise<void>;
|
||||
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
}
|
||||
|
||||
export const useSftpModalFileOpener = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
sftpAutoSync,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
downloadSftpToTempAndOpen,
|
||||
selectApplication,
|
||||
t,
|
||||
handleEditFile,
|
||||
}: UseSftpModalFileOpenerParams): UseSftpModalFileOpenerResult => {
|
||||
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
|
||||
const [fileOpenerTarget, setFileOpenerTarget] = useState<RemoteFile | null>(null);
|
||||
|
||||
const openFileOpenerDialog = useCallback((file: RemoteFile) => {
|
||||
setFileOpenerTarget(file);
|
||||
setShowFileOpenerDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleOpenFile = useCallback(async (file: RemoteFile) => {
|
||||
const savedOpener = getOpenerForFile(file.name);
|
||||
|
||||
if (savedOpener) {
|
||||
if (savedOpener.openerType === "builtin-editor") {
|
||||
await handleEditFile(file);
|
||||
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
if (isLocalSession) {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (bridge?.openWithApplication) {
|
||||
await bridge.openWithApplication(fullPath, savedOpener.systemApp.path);
|
||||
}
|
||||
} else {
|
||||
const sftpId = await ensureSftp();
|
||||
await downloadSftpToTempAndOpen(
|
||||
sftpId,
|
||||
fullPath,
|
||||
file.name,
|
||||
savedOpener.systemApp.path,
|
||||
{ enableWatch: sftpAutoSync },
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.openFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
openFileOpenerDialog(file);
|
||||
}
|
||||
}, [currentPath, downloadSftpToTempAndOpen, ensureSftp, getOpenerForFile, handleEditFile, isLocalSession, joinPath, openFileOpenerDialog, sftpAutoSync, t]);
|
||||
|
||||
const handleFileOpenerSelect = useCallback(
|
||||
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
|
||||
if (!fileOpenerTarget) return;
|
||||
|
||||
if (setAsDefault) {
|
||||
const ext = getFileExtension(fileOpenerTarget.name);
|
||||
setOpenerForExtension(ext, openerType, systemApp);
|
||||
}
|
||||
|
||||
setShowFileOpenerDialog(false);
|
||||
|
||||
if (openerType === "builtin-editor") {
|
||||
await handleEditFile(fileOpenerTarget);
|
||||
} else if (openerType === "system-app" && systemApp) {
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, fileOpenerTarget.name);
|
||||
if (isLocalSession) {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (bridge?.openWithApplication) {
|
||||
await bridge.openWithApplication(fullPath, systemApp.path);
|
||||
}
|
||||
} else {
|
||||
const sftpId = await ensureSftp();
|
||||
await downloadSftpToTempAndOpen(
|
||||
sftpId,
|
||||
fullPath,
|
||||
fileOpenerTarget.name,
|
||||
systemApp.path,
|
||||
{ enableWatch: sftpAutoSync },
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.openFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setFileOpenerTarget(null);
|
||||
},
|
||||
[currentPath, downloadSftpToTempAndOpen, ensureSftp, fileOpenerTarget, handleEditFile, isLocalSession, joinPath, sftpAutoSync, setOpenerForExtension, t],
|
||||
);
|
||||
|
||||
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
|
||||
const result = await selectApplication();
|
||||
if (result) {
|
||||
return { path: result.path, name: result.name };
|
||||
}
|
||||
return null;
|
||||
}, [selectApplication]);
|
||||
|
||||
return {
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
openFileOpenerDialog,
|
||||
handleOpenFile,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
};
|
||||
};
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
const isEditableTarget =
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable ||
|
||||
!!target.closest?.(".monaco-editor, .monaco-diff-editor, .monaco-inputbox");
|
||||
if (isEditableTarget) {
|
||||
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]);
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { breadcrumbPathAt, getBreadcrumbs, getRootPath, getWindowsDrive, isWindowsPath } from "../pathUtils";
|
||||
|
||||
interface UseSftpModalPathParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
localHomePath: string | null;
|
||||
onNavigate: (path: string) => void;
|
||||
maxVisibleBreadcrumbParts?: number;
|
||||
}
|
||||
|
||||
interface UseSftpModalPathResult {
|
||||
isEditingPath: boolean;
|
||||
editingPathValue: string;
|
||||
setEditingPathValue: (value: string) => void;
|
||||
pathInputRef: React.RefObject<HTMLInputElement>;
|
||||
handlePathDoubleClick: () => void;
|
||||
handlePathSubmit: () => void;
|
||||
handlePathKeyDown: (e: React.KeyboardEvent) => void;
|
||||
breadcrumbs: string[];
|
||||
visibleBreadcrumbs: { part: string; originalIndex: number }[];
|
||||
hiddenBreadcrumbs: { part: string; originalIndex: number }[];
|
||||
needsBreadcrumbTruncation: boolean;
|
||||
breadcrumbPathAtForIndex: (index: number) => string;
|
||||
rootLabel: string;
|
||||
rootPath: string;
|
||||
}
|
||||
|
||||
export const useSftpModalPath = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
localHomePath,
|
||||
onNavigate,
|
||||
maxVisibleBreadcrumbParts = 4,
|
||||
}: UseSftpModalPathParams): UseSftpModalPathResult => {
|
||||
const [isEditingPath, setIsEditingPath] = useState(false);
|
||||
const [editingPathValue, setEditingPathValue] = useState("");
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handlePathDoubleClick = () => {
|
||||
setEditingPathValue(currentPath);
|
||||
setIsEditingPath(true);
|
||||
setTimeout(() => pathInputRef.current?.select(), 0);
|
||||
};
|
||||
|
||||
const handlePathSubmit = () => {
|
||||
const fallbackPath = localHomePath || getRootPath(currentPath, isLocalSession);
|
||||
const newPath = editingPathValue.trim() || fallbackPath;
|
||||
setIsEditingPath(false);
|
||||
if (newPath !== currentPath) {
|
||||
if (isLocalSession) {
|
||||
onNavigate(newPath);
|
||||
} else {
|
||||
onNavigate(newPath.startsWith("/") ? newPath : `/${newPath}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePathKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handlePathSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
setIsEditingPath(false);
|
||||
}
|
||||
};
|
||||
|
||||
const breadcrumbs = useMemo(
|
||||
() => getBreadcrumbs(currentPath, isLocalSession),
|
||||
[currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
const { visibleBreadcrumbs, hiddenBreadcrumbs, needsBreadcrumbTruncation } =
|
||||
useMemo(() => {
|
||||
if (breadcrumbs.length <= maxVisibleBreadcrumbParts) {
|
||||
return {
|
||||
visibleBreadcrumbs: breadcrumbs.map((part, idx) => ({ part, originalIndex: idx })),
|
||||
hiddenBreadcrumbs: [] as { part: string; originalIndex: number }[],
|
||||
needsBreadcrumbTruncation: false,
|
||||
};
|
||||
}
|
||||
|
||||
const firstPart = [{ part: breadcrumbs[0], originalIndex: 0 }];
|
||||
const lastPartsCount = maxVisibleBreadcrumbParts - 1;
|
||||
const lastParts = breadcrumbs.slice(-lastPartsCount).map((part, idx) => ({
|
||||
part,
|
||||
originalIndex: breadcrumbs.length - lastPartsCount + idx,
|
||||
}));
|
||||
const hidden = breadcrumbs.slice(1, -lastPartsCount).map((part, idx) => ({
|
||||
part,
|
||||
originalIndex: idx + 1,
|
||||
}));
|
||||
|
||||
return {
|
||||
visibleBreadcrumbs: [...firstPart, ...lastParts],
|
||||
hiddenBreadcrumbs: hidden,
|
||||
needsBreadcrumbTruncation: true,
|
||||
};
|
||||
}, [breadcrumbs, maxVisibleBreadcrumbParts]);
|
||||
|
||||
const breadcrumbPathAtForIndex = useCallback(
|
||||
(index: number) =>
|
||||
breadcrumbPathAt(breadcrumbs, index, currentPath, isLocalSession),
|
||||
[breadcrumbs, currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
const rootLabel = useMemo(
|
||||
() =>
|
||||
isLocalSession && isWindowsPath(currentPath)
|
||||
? getWindowsDrive(currentPath) ?? "C:"
|
||||
: "/",
|
||||
[currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
const rootPath = useMemo(
|
||||
() => getRootPath(currentPath, isLocalSession),
|
||||
[currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
return {
|
||||
isEditingPath,
|
||||
editingPathValue,
|
||||
setEditingPathValue,
|
||||
pathInputRef,
|
||||
handlePathDoubleClick,
|
||||
handlePathSubmit,
|
||||
handlePathKeyDown,
|
||||
breadcrumbs,
|
||||
visibleBreadcrumbs,
|
||||
hiddenBreadcrumbs,
|
||||
needsBreadcrumbTruncation,
|
||||
breadcrumbPathAtForIndex,
|
||||
rootLabel,
|
||||
rootPath,
|
||||
};
|
||||
};
|
||||
@@ -1,189 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalPermissionsParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
chmodSftp: (sftpId: string, path: string, permissions: string) => Promise<void>;
|
||||
statSftp: (sftpId: string, path: string) => Promise<{ permissions?: string }>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface PermissionsState {
|
||||
owner: { read: boolean; write: boolean; execute: boolean };
|
||||
group: { read: boolean; write: boolean; execute: boolean };
|
||||
others: { read: boolean; write: boolean; execute: boolean };
|
||||
}
|
||||
|
||||
interface UseSftpModalPermissionsResult {
|
||||
showPermissionsDialog: boolean;
|
||||
setShowPermissionsDialog: (open: boolean) => void;
|
||||
permissionsTarget: RemoteFile | null;
|
||||
permissions: PermissionsState;
|
||||
isChangingPermissions: boolean;
|
||||
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
|
||||
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
|
||||
getOctalPermissions: () => string;
|
||||
getSymbolicPermissions: () => string;
|
||||
handleSavePermissions: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalPermissions = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
chmodSftp,
|
||||
statSftp,
|
||||
t,
|
||||
}: UseSftpModalPermissionsParams): UseSftpModalPermissionsResult => {
|
||||
const [showPermissionsDialog, setShowPermissionsDialog] = useState(false);
|
||||
const [permissionsTarget, setPermissionsTarget] = useState<RemoteFile | null>(null);
|
||||
const [permissions, setPermissions] = useState<PermissionsState>({
|
||||
owner: { read: false, write: false, execute: false },
|
||||
group: { read: false, write: false, execute: false },
|
||||
others: { read: false, write: false, execute: false },
|
||||
});
|
||||
const [isChangingPermissions, setIsChangingPermissions] = useState(false);
|
||||
|
||||
const parsePermissions = useCallback((perms: string | undefined) => {
|
||||
const defaultPerms = {
|
||||
owner: { read: false, write: false, execute: false },
|
||||
group: { read: false, write: false, execute: false },
|
||||
others: { read: false, write: false, execute: false },
|
||||
};
|
||||
if (!perms) return defaultPerms;
|
||||
|
||||
if (/^[0-7]{3,4}$/.test(perms)) {
|
||||
const octal = perms.length === 4 ? perms.slice(1) : perms;
|
||||
const ownerBits = parseInt(octal[0], 10);
|
||||
const groupBits = parseInt(octal[1], 10);
|
||||
const othersBits = parseInt(octal[2], 10);
|
||||
return {
|
||||
owner: {
|
||||
read: (ownerBits & 4) !== 0,
|
||||
write: (ownerBits & 2) !== 0,
|
||||
execute: (ownerBits & 1) !== 0,
|
||||
},
|
||||
group: {
|
||||
read: (groupBits & 4) !== 0,
|
||||
write: (groupBits & 2) !== 0,
|
||||
execute: (groupBits & 1) !== 0,
|
||||
},
|
||||
others: {
|
||||
read: (othersBits & 4) !== 0,
|
||||
write: (othersBits & 2) !== 0,
|
||||
execute: (othersBits & 1) !== 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const pStr = perms.length === 10 ? perms.slice(1) : perms;
|
||||
if (pStr.length >= 9) {
|
||||
return {
|
||||
owner: {
|
||||
read: pStr[0] === "r",
|
||||
write: pStr[1] === "w",
|
||||
execute: pStr[2] === "x" || pStr[2] === "s",
|
||||
},
|
||||
group: {
|
||||
read: pStr[3] === "r",
|
||||
write: pStr[4] === "w",
|
||||
execute: pStr[5] === "x" || pStr[5] === "s",
|
||||
},
|
||||
others: {
|
||||
read: pStr[6] === "r",
|
||||
write: pStr[7] === "w",
|
||||
execute: pStr[8] === "x" || pStr[8] === "t",
|
||||
},
|
||||
};
|
||||
}
|
||||
return defaultPerms;
|
||||
}, []);
|
||||
|
||||
const openPermissionsDialog = useCallback(async (file: RemoteFile) => {
|
||||
if (isLocalSession) {
|
||||
toast.error("Permissions not available for local files", "SFTP");
|
||||
return;
|
||||
}
|
||||
setPermissionsTarget(file);
|
||||
|
||||
let permsStr = file.permissions;
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
const stat = await statSftp(await ensureSftp(), fullPath);
|
||||
if (stat.permissions) {
|
||||
permsStr = stat.permissions;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to fetch file permissions:", e);
|
||||
}
|
||||
|
||||
setPermissions(parsePermissions(permsStr));
|
||||
setShowPermissionsDialog(true);
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, parsePermissions, statSftp]);
|
||||
|
||||
const togglePermission = useCallback(
|
||||
(role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => {
|
||||
setPermissions((prev) => ({
|
||||
...prev,
|
||||
[role]: { ...prev[role], [perm]: !prev[role][perm] },
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getOctalPermissions = useCallback(() => {
|
||||
const getNum = (p: { read: boolean; write: boolean; execute: boolean }) =>
|
||||
(p.read ? 4 : 0) + (p.write ? 2 : 0) + (p.execute ? 1 : 0);
|
||||
return `${getNum(permissions.owner)}${getNum(permissions.group)}${getNum(permissions.others)}`;
|
||||
}, [permissions]);
|
||||
|
||||
const getSymbolicPermissions = useCallback(() => {
|
||||
const getSym = (p: { read: boolean; write: boolean; execute: boolean }) =>
|
||||
`${p.read ? "r" : "-"}${p.write ? "w" : "-"}${p.execute ? "x" : "-"}`;
|
||||
return (
|
||||
getSym(permissions.owner) +
|
||||
getSym(permissions.group) +
|
||||
getSym(permissions.others)
|
||||
);
|
||||
}, [permissions]);
|
||||
|
||||
const handleSavePermissions = useCallback(async () => {
|
||||
if (!permissionsTarget || isChangingPermissions) return;
|
||||
setIsChangingPermissions(true);
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, permissionsTarget.name);
|
||||
await chmodSftp(await ensureSftp(), fullPath, getOctalPermissions());
|
||||
setShowPermissionsDialog(false);
|
||||
setPermissionsTarget(null);
|
||||
await loadFiles(currentPath, { force: true });
|
||||
toast.success(t("sftp.permissions.success"), "SFTP");
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.permissions.failed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setIsChangingPermissions(false);
|
||||
}
|
||||
}, [chmodSftp, currentPath, ensureSftp, getOctalPermissions, isChangingPermissions, joinPath, loadFiles, permissionsTarget, t]);
|
||||
|
||||
return {
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
isChangingPermissions,
|
||||
openPermissionsDialog,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
};
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalRenameParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface UseSftpModalRenameResult {
|
||||
showRenameDialog: boolean;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
renameTarget: RemoteFile | null;
|
||||
renameName: string;
|
||||
setRenameName: (value: string) => void;
|
||||
isRenaming: boolean;
|
||||
openRenameDialog: (file: RemoteFile) => void;
|
||||
handleRename: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalRename = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
renameSftp,
|
||||
t,
|
||||
}: UseSftpModalRenameParams): UseSftpModalRenameResult => {
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<RemoteFile | null>(null);
|
||||
const [renameName, setRenameName] = useState("");
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
|
||||
const openRenameDialog = useCallback((file: RemoteFile) => {
|
||||
setRenameTarget(file);
|
||||
setRenameName(file.name);
|
||||
setShowRenameDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleRename = useCallback(async () => {
|
||||
if (!renameTarget || !renameName.trim() || isRenaming) return;
|
||||
if (renameName.trim() === renameTarget.name) {
|
||||
setShowRenameDialog(false);
|
||||
return;
|
||||
}
|
||||
setIsRenaming(true);
|
||||
try {
|
||||
const oldPath = joinPath(currentPath, renameTarget.name);
|
||||
const newPath = joinPath(currentPath, renameName.trim());
|
||||
if (isLocalSession) {
|
||||
toast.error("Local rename not implemented", "SFTP");
|
||||
} else {
|
||||
await renameSftp(await ensureSftp(), oldPath, newPath);
|
||||
}
|
||||
setShowRenameDialog(false);
|
||||
setRenameTarget(null);
|
||||
setRenameName("");
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.renameFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, renameName, renameSftp, renameTarget, t, isRenaming]);
|
||||
|
||||
return {
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
isRenaming,
|
||||
openRenameDialog,
|
||||
handleRename,
|
||||
};
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
|
||||
interface UseSftpModalSelectionParams {
|
||||
files: RemoteFile[];
|
||||
setSelectedFiles: (value: Set<string> | ((prev: Set<string>) => Set<string>)) => void;
|
||||
currentPath: string;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
onNavigate: (path: string) => void;
|
||||
onOpenFile: (file: RemoteFile) => void;
|
||||
onNavigateUp: () => void;
|
||||
}
|
||||
|
||||
interface UseSftpModalSelectionResult {
|
||||
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
|
||||
handleFileDoubleClick: (file: RemoteFile) => void;
|
||||
}
|
||||
|
||||
export const useSftpModalSelection = ({
|
||||
files,
|
||||
setSelectedFiles,
|
||||
currentPath,
|
||||
joinPath,
|
||||
onNavigate,
|
||||
onOpenFile,
|
||||
onNavigateUp,
|
||||
}: UseSftpModalSelectionParams): UseSftpModalSelectionResult => {
|
||||
const lastSelectedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const handleFileClick = useCallback(
|
||||
(file: RemoteFile, index: number, e: React.MouseEvent) => {
|
||||
if (file.name === "..") return;
|
||||
|
||||
if (file.type === "directory") {
|
||||
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
|
||||
const start = Math.min(lastSelectedIndexRef.current, index);
|
||||
const end = Math.max(lastSelectedIndexRef.current, index);
|
||||
const newSelection = new Set<string>();
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (files[i] && files[i].type !== "directory") {
|
||||
newSelection.add(files[i].name);
|
||||
}
|
||||
}
|
||||
setSelectedFiles(newSelection);
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
|
||||
const start = Math.min(lastSelectedIndexRef.current, index);
|
||||
const end = Math.max(lastSelectedIndexRef.current, index);
|
||||
const newSelection = new Set<string>();
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (files[i] && files[i].type !== "directory") {
|
||||
newSelection.add(files[i].name);
|
||||
}
|
||||
}
|
||||
setSelectedFiles(newSelection);
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(file.name)) {
|
||||
next.delete(file.name);
|
||||
} else {
|
||||
next.add(file.name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
lastSelectedIndexRef.current = index;
|
||||
} else {
|
||||
setSelectedFiles(new Set([file.name]));
|
||||
lastSelectedIndexRef.current = index;
|
||||
}
|
||||
},
|
||||
[files, setSelectedFiles],
|
||||
);
|
||||
|
||||
const handleFileDoubleClick = useCallback(
|
||||
(file: RemoteFile) => {
|
||||
if (file.name === "..") {
|
||||
onNavigateUp();
|
||||
return;
|
||||
}
|
||||
if (file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) {
|
||||
onNavigate(joinPath(currentPath, file.name));
|
||||
} else {
|
||||
onOpenFile(file);
|
||||
}
|
||||
},
|
||||
[currentPath, joinPath, onNavigate, onNavigateUp, onOpenFile],
|
||||
);
|
||||
|
||||
return { handleFileClick, handleFileDoubleClick };
|
||||
};
|
||||
@@ -1,462 +0,0 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { Host, RemoteFile } from "../../../types";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { isSessionError } from "../../../application/state/sftp/errors";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalSessionParams {
|
||||
open: boolean;
|
||||
host: Host;
|
||||
credentials: {
|
||||
username?: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: "generated" | "imported";
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
};
|
||||
initialPath?: string;
|
||||
isLocalSession: boolean;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
openSftp: (params: {
|
||||
sessionId: string;
|
||||
hostname: string;
|
||||
username: string;
|
||||
port: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: "generated" | "imported";
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
}) => Promise<string>;
|
||||
closeSftp: (sftpId: string) => Promise<void>;
|
||||
listSftp: (sftpId: string, path: string) => Promise<RemoteFile[]>;
|
||||
listLocalDir: (path: string) => Promise<RemoteFile[]>;
|
||||
getHomeDir: () => Promise<string | null>;
|
||||
onClearSelection: () => void;
|
||||
}
|
||||
|
||||
interface UseSftpModalSessionResult {
|
||||
currentPath: string;
|
||||
setCurrentPath: (path: string) => void;
|
||||
currentPathRef: React.MutableRefObject<string>;
|
||||
files: RemoteFile[];
|
||||
setFiles: (files: RemoteFile[]) => void;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
reconnecting: boolean;
|
||||
sessionVersion: number;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
closeSftpSession: () => Promise<void>;
|
||||
localHomeRef: React.MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
export const useSftpModalSession = ({
|
||||
open,
|
||||
host,
|
||||
credentials,
|
||||
initialPath,
|
||||
isLocalSession,
|
||||
t,
|
||||
openSftp,
|
||||
closeSftp,
|
||||
listSftp,
|
||||
listLocalDir,
|
||||
getHomeDir,
|
||||
onClearSelection,
|
||||
}: UseSftpModalSessionParams): UseSftpModalSessionResult => {
|
||||
const [currentPath, setCurrentPathState] = useState("/");
|
||||
const [files, setFiles] = useState<RemoteFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
const [sessionVersion, setSessionVersion] = useState(0);
|
||||
const currentPathRef = useRef(currentPath);
|
||||
const sftpIdRef = useRef<string | null>(null);
|
||||
const closingPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
const initializingRef = useRef(false);
|
||||
const lastInitialPathRef = useRef<string | undefined>(undefined);
|
||||
const localHomeRef = useRef<string | null>(null);
|
||||
|
||||
const reconnectingRef = useRef(false);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const MAX_RECONNECT_ATTEMPTS = 3;
|
||||
|
||||
const DIR_CACHE_TTL_MS = 10_000;
|
||||
const dirCacheRef = useRef<
|
||||
Map<string, { files: RemoteFile[]; timestamp: number }>
|
||||
>(new Map());
|
||||
const loadSeqRef = useRef(0);
|
||||
const setCurrentPath = useCallback((path: string) => {
|
||||
currentPathRef.current = path;
|
||||
setCurrentPathState(path);
|
||||
}, []);
|
||||
const bumpSessionVersion = useCallback(() => {
|
||||
setSessionVersion((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
const ensureSftp = useCallback(async () => {
|
||||
if (isLocalSession) throw new Error("Local session does not use SFTP");
|
||||
if (closingPromiseRef.current) {
|
||||
await closingPromiseRef.current;
|
||||
}
|
||||
if (sftpIdRef.current) return sftpIdRef.current;
|
||||
const sftpId = await openSftp({
|
||||
sessionId: `sftp-modal-${host.id}`,
|
||||
hostname: credentials.hostname,
|
||||
username: credentials.username || "root",
|
||||
port: credentials.port || 22,
|
||||
password: credentials.password,
|
||||
privateKey: credentials.privateKey,
|
||||
certificate: credentials.certificate,
|
||||
passphrase: credentials.passphrase,
|
||||
publicKey: credentials.publicKey,
|
||||
keyId: credentials.keyId,
|
||||
keySource: credentials.keySource,
|
||||
proxy: credentials.proxy,
|
||||
jumpHosts: credentials.jumpHosts,
|
||||
sudo: credentials.sftpSudo,
|
||||
legacyAlgorithms: credentials.legacyAlgorithms,
|
||||
});
|
||||
if (sftpIdRef.current !== sftpId) {
|
||||
sftpIdRef.current = sftpId;
|
||||
bumpSessionVersion();
|
||||
}
|
||||
return sftpId;
|
||||
}, [
|
||||
isLocalSession,
|
||||
host.id,
|
||||
credentials.hostname,
|
||||
credentials.username,
|
||||
credentials.port,
|
||||
credentials.password,
|
||||
credentials.privateKey,
|
||||
credentials.certificate,
|
||||
credentials.passphrase,
|
||||
credentials.publicKey,
|
||||
credentials.keyId,
|
||||
credentials.keySource,
|
||||
credentials.proxy,
|
||||
credentials.jumpHosts,
|
||||
credentials.sftpSudo,
|
||||
credentials.legacyAlgorithms,
|
||||
bumpSessionVersion,
|
||||
openSftp,
|
||||
]);
|
||||
|
||||
const closeSftpSession = useCallback(async () => {
|
||||
if (isLocalSession) {
|
||||
if (sftpIdRef.current !== null) {
|
||||
sftpIdRef.current = null;
|
||||
bumpSessionVersion();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear ref before awaiting backend close to avoid handing out a stale ID
|
||||
// if the modal is reopened while close is still in flight.
|
||||
const sftpIdToClose = sftpIdRef.current;
|
||||
if (sftpIdToClose !== null) {
|
||||
sftpIdRef.current = null;
|
||||
bumpSessionVersion();
|
||||
}
|
||||
if (!sftpIdToClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentClosePromise = (async () => {
|
||||
try {
|
||||
await closeSftp(sftpIdToClose);
|
||||
} catch {
|
||||
// Silently ignore close errors - connection may already be closed
|
||||
} finally {
|
||||
if (closingPromiseRef.current === currentClosePromise) {
|
||||
closingPromiseRef.current = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
closingPromiseRef.current = currentClosePromise;
|
||||
await currentClosePromise;
|
||||
}, [bumpSessionVersion, closeSftp, isLocalSession]);
|
||||
|
||||
// Use shared session-error classifier from errors.ts
|
||||
|
||||
const handleSessionError = useCallback(async () => {
|
||||
if (reconnectingRef.current) return;
|
||||
reconnectingRef.current = true;
|
||||
setReconnecting(true);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
||||
while (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
|
||||
try {
|
||||
reconnectAttemptsRef.current += 1;
|
||||
await closeSftpSession();
|
||||
const newSftpId = await ensureSftp();
|
||||
reconnectingRef.current = false;
|
||||
setReconnecting(false);
|
||||
|
||||
// Auto-reload current directory after successful reconnect
|
||||
try {
|
||||
const reloadPath = currentPathRef.current;
|
||||
const reloadRequestId = loadSeqRef.current;
|
||||
const list = await listSftp(newSftpId, reloadPath);
|
||||
if (
|
||||
reloadRequestId !== loadSeqRef.current ||
|
||||
currentPathRef.current !== reloadPath
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClearSelection();
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${reloadPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// Reload failed — UI still shows old data, user can manually refresh
|
||||
}
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[SFTP] Reconnect attempt ${reconnectAttemptsRef.current} failed`,
|
||||
err,
|
||||
);
|
||||
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectingRef.current = false;
|
||||
setReconnecting(false);
|
||||
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}, [closeSftpSession, ensureSftp, listSftp, host.id, onClearSelection, t]);
|
||||
|
||||
const loadFiles = useCallback(
|
||||
async (path: string, options?: { force?: boolean }) => {
|
||||
const requestId = ++loadSeqRef.current;
|
||||
setLoading(true);
|
||||
onClearSelection();
|
||||
|
||||
try {
|
||||
if (isLocalSession) {
|
||||
const list = await listLocalDir(path);
|
||||
if (requestId === loadSeqRef.current) {
|
||||
setFiles(list);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `${host.id}::${path}`;
|
||||
const cached = dirCacheRef.current.get(cacheKey);
|
||||
const isFresh =
|
||||
cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
|
||||
if (cached && isFresh && !options?.force) {
|
||||
setFiles(cached.files);
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, path);
|
||||
if (requestId !== loadSeqRef.current) return;
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(cacheKey, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isLocalSession && isSessionError(e) && files.length > 0) {
|
||||
logger.info("[SFTP] Session lost, attempting to reconnect...");
|
||||
handleSessionError();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error("Failed to load files", e);
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
if (loadSeqRef.current === requestId) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, handleSessionError, files.length, onClearSelection],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const cacheKey = `${host.id}::${currentPath}`;
|
||||
const cached = dirCacheRef.current.get(cacheKey);
|
||||
const isFresh = cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
|
||||
if (!isFresh) {
|
||||
setFiles([]);
|
||||
onClearSelection();
|
||||
}
|
||||
}, [currentPath, host.id, onClearSelection, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (!initializedRef.current || lastInitialPathRef.current !== initialPath) {
|
||||
initializedRef.current = true;
|
||||
initializingRef.current = true;
|
||||
lastInitialPathRef.current = initialPath;
|
||||
onClearSelection();
|
||||
setLoading(true);
|
||||
|
||||
if (isLocalSession) {
|
||||
(async () => {
|
||||
try {
|
||||
const homePath = await getHomeDir();
|
||||
localHomeRef.current = homePath ?? null;
|
||||
const startPath = initialPath || homePath || "/";
|
||||
try {
|
||||
const list = await listLocalDir(startPath);
|
||||
setCurrentPath(startPath);
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${startPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} finally {
|
||||
initializingRef.current = false;
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const homePath = await getHomeDir();
|
||||
localHomeRef.current = homePath ?? null;
|
||||
if (initialPath) {
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, initialPath);
|
||||
setCurrentPath(initialPath);
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
} catch {
|
||||
logger.warn(
|
||||
`[SFTP] Initial path ${initialPath} not accessible, falling back to home`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, homePath || "/");
|
||||
setCurrentPath(homePath || "/");
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${homePath || "/"}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setLoading(false);
|
||||
} catch {
|
||||
logger.warn(`[SFTP] Home ${homePath} not accessible, using /`);
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, "/");
|
||||
setCurrentPath("/");
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::/`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("[SFTP] Failed to load root directory", e);
|
||||
toast.error(t("sftp.error.loadFailed"), "SFTP");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
initializingRef.current = false;
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
// Skip redundant loadFiles while async initialization is still in flight.
|
||||
// Without this guard, dependency changes (e.g. loadFiles recreation from
|
||||
// files.length change) can re-trigger this effect and call loadFiles with
|
||||
// the stale currentPath before the initialization IIFE has resolved and
|
||||
// updated currentPathRef — causing uploads to target the wrong directory.
|
||||
if (!initializingRef.current) {
|
||||
void loadFiles(currentPath);
|
||||
}
|
||||
} else {
|
||||
loadSeqRef.current += 1;
|
||||
initializedRef.current = false;
|
||||
initializingRef.current = false;
|
||||
}
|
||||
}, [
|
||||
closeSftpSession,
|
||||
currentPath,
|
||||
ensureSftp,
|
||||
getHomeDir,
|
||||
host.id,
|
||||
initialPath,
|
||||
isLocalSession,
|
||||
listLocalDir,
|
||||
listSftp,
|
||||
loadFiles,
|
||||
onClearSelection,
|
||||
open,
|
||||
setCurrentPath,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
void closeSftpSession();
|
||||
};
|
||||
}, [closeSftpSession]);
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
setCurrentPath,
|
||||
currentPathRef,
|
||||
files,
|
||||
setFiles,
|
||||
loading,
|
||||
setLoading,
|
||||
reconnecting,
|
||||
sessionVersion,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
closeSftpSession,
|
||||
localHomeRef,
|
||||
};
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
|
||||
export type SortField = "name" | "size" | "modified";
|
||||
export type SortOrder = "asc" | "desc";
|
||||
|
||||
interface UseSftpModalSortingResult {
|
||||
sortField: SortField;
|
||||
sortOrder: SortOrder;
|
||||
columnWidths: { name: number; size: number; modified: number; actions: number };
|
||||
handleSort: (field: SortField) => void;
|
||||
handleResizeStart: (field: string, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const useSftpModalSorting = (): UseSftpModalSortingResult => {
|
||||
const [sortField, setSortField] = useState<SortField>("name");
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
|
||||
const [columnWidths, setColumnWidths] = useState({
|
||||
name: 45,
|
||||
size: 15,
|
||||
modified: 25,
|
||||
actions: 15,
|
||||
});
|
||||
|
||||
const resizingRef = useRef<{
|
||||
field: string;
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
||||
if (!resizingRef.current) return;
|
||||
const diff = e.clientX - resizingRef.current.startX;
|
||||
const newWidth = Math.max(
|
||||
10,
|
||||
Math.min(60, resizingRef.current.startWidth + diff / 5),
|
||||
);
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[resizingRef.current!.field]: newWidth,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
resizingRef.current = null;
|
||||
document.removeEventListener("mousemove", handleResizeMove);
|
||||
document.removeEventListener("mouseup", handleResizeEnd);
|
||||
}, [handleResizeMove]);
|
||||
|
||||
const handleResizeStart = (field: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
resizingRef.current = {
|
||||
field,
|
||||
startX: e.clientX,
|
||||
startWidth: columnWidths[field as keyof typeof columnWidths],
|
||||
};
|
||||
document.addEventListener("mousemove", handleResizeMove);
|
||||
document.addEventListener("mouseup", handleResizeEnd);
|
||||
};
|
||||
|
||||
return {
|
||||
sortField,
|
||||
sortOrder,
|
||||
columnWidths,
|
||||
handleSort,
|
||||
handleResizeStart,
|
||||
};
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalTextEditorParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
readLocalFile: (path: string) => Promise<ArrayBuffer>;
|
||||
readSftp: (sftpId: string, path: string) => Promise<string>;
|
||||
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface UseSftpModalTextEditorResult {
|
||||
showTextEditor: boolean;
|
||||
setShowTextEditor: (open: boolean) => void;
|
||||
textEditorTarget: RemoteFile | null;
|
||||
setTextEditorTarget: (target: RemoteFile | null) => void;
|
||||
textEditorContent: string;
|
||||
setTextEditorContent: (value: string) => void;
|
||||
loadingTextContent: boolean;
|
||||
handleEditFile: (file: RemoteFile) => Promise<void>;
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalTextEditor = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftp,
|
||||
t,
|
||||
}: UseSftpModalTextEditorParams): UseSftpModalTextEditorResult => {
|
||||
const [showTextEditor, setShowTextEditor] = useState(false);
|
||||
const [textEditorTarget, setTextEditorTarget] = useState<RemoteFile | null>(null);
|
||||
const [textEditorContent, setTextEditorContent] = useState("");
|
||||
const [loadingTextContent, setLoadingTextContent] = useState(false);
|
||||
|
||||
const handleEditFile = useCallback(async (file: RemoteFile) => {
|
||||
try {
|
||||
setLoadingTextContent(true);
|
||||
setTextEditorTarget(file);
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
const content = isLocalSession
|
||||
? await readLocalFile(fullPath).then((buf) => new TextDecoder().decode(buf))
|
||||
: await readSftp(await ensureSftp(), fullPath);
|
||||
setTextEditorContent(content);
|
||||
setShowTextEditor(true);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoadingTextContent(false);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, t]);
|
||||
|
||||
const handleSaveTextFile = useCallback(async (content: string) => {
|
||||
if (!textEditorTarget) return;
|
||||
const fullPath = joinPath(currentPath, textEditorTarget.name);
|
||||
if (isLocalSession) {
|
||||
const encoder = new TextEncoder();
|
||||
await writeLocalFile(fullPath, encoder.encode(content).buffer);
|
||||
} else {
|
||||
await writeSftp(await ensureSftp(), fullPath, content);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, textEditorTarget, writeLocalFile, writeSftp]);
|
||||
|
||||
return {
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
handleEditFile,
|
||||
handleSaveTextFile,
|
||||
};
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,123 +0,0 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
|
||||
interface UseSftpModalVirtualListParams {
|
||||
open: boolean;
|
||||
sortedFiles: RemoteFile[];
|
||||
}
|
||||
|
||||
interface UseSftpModalVirtualListResult {
|
||||
fileListRef: React.RefObject<HTMLDivElement>;
|
||||
rowHeight: number;
|
||||
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||
shouldVirtualize: boolean;
|
||||
totalHeight: number;
|
||||
visibleRows: { file: RemoteFile; index: number; top: number }[];
|
||||
}
|
||||
|
||||
export const useSftpModalVirtualList = ({
|
||||
open,
|
||||
sortedFiles,
|
||||
}: UseSftpModalVirtualListParams): UseSftpModalVirtualListResult => {
|
||||
const fileListRef = useRef<HTMLDivElement>(null);
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const [rowHeight, setRowHeight] = useState(40);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = fileListRef.current;
|
||||
if (!container || !open) return;
|
||||
const update = () => setViewportHeight(container.clientHeight);
|
||||
update();
|
||||
const raf = window.requestAnimationFrame(update);
|
||||
const resizeObserver = new ResizeObserver(update);
|
||||
resizeObserver.observe(container);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [open, sortedFiles.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = fileListRef.current;
|
||||
if (!container || !open || sortedFiles.length === 0) return;
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
const rowElement = container.querySelector(
|
||||
'[data-sftp-modal-row="true"]',
|
||||
) as HTMLElement | null;
|
||||
if (!rowElement) return;
|
||||
const nextHeight = Math.round(rowElement.getBoundingClientRect().height);
|
||||
if (nextHeight && Math.abs(nextHeight - rowHeight) > 1) {
|
||||
setRowHeight(nextHeight);
|
||||
}
|
||||
});
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [open, rowHeight, sortedFiles.length]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(scrollFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFileListScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>) => {
|
||||
const nextTop = e.currentTarget.scrollTop;
|
||||
if (scrollFrameRef.current !== null) return;
|
||||
scrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
scrollFrameRef.current = null;
|
||||
setScrollTop(nextTop);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
|
||||
const overscan = 6;
|
||||
const canVirtualize = open && viewportHeight > 0 && rowHeight > 0;
|
||||
const shouldVirtualizeLocal = canVirtualize && sortedFiles.length > 50;
|
||||
const totalHeightLocal = shouldVirtualizeLocal
|
||||
? sortedFiles.length * rowHeight
|
||||
: 0;
|
||||
const startIndex = shouldVirtualizeLocal
|
||||
? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
|
||||
: 0;
|
||||
const endIndex = shouldVirtualizeLocal
|
||||
? Math.min(
|
||||
sortedFiles.length - 1,
|
||||
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
|
||||
)
|
||||
: sortedFiles.length - 1;
|
||||
const visibleRowsLocal = shouldVirtualizeLocal
|
||||
? sortedFiles
|
||||
.slice(startIndex, endIndex + 1)
|
||||
.map((file, idx) => ({
|
||||
file,
|
||||
index: startIndex + idx,
|
||||
top: (startIndex + idx) * rowHeight,
|
||||
}))
|
||||
: sortedFiles.map((file, index) => ({
|
||||
file,
|
||||
index,
|
||||
top: 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
shouldVirtualize: shouldVirtualizeLocal,
|
||||
totalHeight: totalHeightLocal,
|
||||
visibleRows: visibleRowsLocal,
|
||||
};
|
||||
}, [open, rowHeight, scrollTop, sortedFiles, viewportHeight]);
|
||||
|
||||
return {
|
||||
fileListRef,
|
||||
rowHeight,
|
||||
handleFileListScroll,
|
||||
shouldVirtualize,
|
||||
totalHeight,
|
||||
visibleRows,
|
||||
};
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
export const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
|
||||
|
||||
export const normalizeWindowsRoot = (path: string): string => {
|
||||
const normalized = path.replace(/\//g, "\\");
|
||||
if (/^[A-Za-z]:\\$/.test(normalized)) return normalized;
|
||||
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const joinPath = (base: string, name: string, isLocalSession: boolean): string => {
|
||||
if (isLocalSession && isWindowsPath(base)) {
|
||||
const normalizedBase = normalizeWindowsRoot(base).replace(/[\\/]+$/, "");
|
||||
return `${normalizedBase}\\${name}`;
|
||||
}
|
||||
if (base === "/") return `/${name}`;
|
||||
return `${base}/${name}`;
|
||||
};
|
||||
|
||||
export const isRootPath = (path: string, isLocalSession: boolean): boolean => {
|
||||
if (isLocalSession && isWindowsPath(path)) {
|
||||
return /^[A-Za-z]:\\?$/.test(path.replace(/\//g, "\\"));
|
||||
}
|
||||
return path === "/";
|
||||
};
|
||||
|
||||
export const getParentPath = (path: string, isLocalSession: boolean): string => {
|
||||
if (isLocalSession && isWindowsPath(path)) {
|
||||
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
|
||||
const drive = normalized.slice(0, 2);
|
||||
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
|
||||
return `${drive}\\`;
|
||||
}
|
||||
const rest = normalized.slice(2).replace(/^[\\]+/, "");
|
||||
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
|
||||
if (parts.length <= 1) return `${drive}\\`;
|
||||
parts.pop();
|
||||
return `${drive}\\${parts.join("\\")}`;
|
||||
}
|
||||
if (path === "/") return "/";
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
parts.pop();
|
||||
return parts.length ? `/${parts.join("/")}` : "/";
|
||||
};
|
||||
|
||||
export const getRootPath = (path: string, isLocalSession: boolean): string => {
|
||||
if (isLocalSession && isWindowsPath(path)) {
|
||||
const drive = path.replace(/\//g, "\\").slice(0, 2);
|
||||
return `${drive}\\`;
|
||||
}
|
||||
return "/";
|
||||
};
|
||||
|
||||
export const getWindowsDrive = (path: string): string | null => {
|
||||
if (!isWindowsPath(path)) return null;
|
||||
const normalized = path.replace(/\//g, "\\");
|
||||
return /^[A-Za-z]:/.test(normalized) ? normalized.slice(0, 2) : null;
|
||||
};
|
||||
|
||||
export const getBreadcrumbs = (path: string, isLocalSession: boolean): string[] => {
|
||||
if (isLocalSession && isWindowsPath(path)) {
|
||||
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
|
||||
const rest = normalized.slice(2).replace(/^[\\]+/, "");
|
||||
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
|
||||
return parts;
|
||||
}
|
||||
return path === "/" ? [] : path.split("/").filter(Boolean);
|
||||
};
|
||||
|
||||
export const breadcrumbPathAt = (
|
||||
breadcrumbs: string[],
|
||||
idx: number,
|
||||
currentPath: string,
|
||||
isLocalSession: boolean,
|
||||
): string => {
|
||||
if (isLocalSession) {
|
||||
const drive = getWindowsDrive(currentPath);
|
||||
if (drive) {
|
||||
const rest = breadcrumbs.slice(0, idx + 1).join("\\");
|
||||
return rest ? `${drive}\\${rest}` : `${drive}\\`;
|
||||
}
|
||||
}
|
||||
return "/" + breadcrumbs.slice(0, idx + 1).join("/");
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
export const formatBytes = (bytes: number | string): string => {
|
||||
const numBytes = typeof bytes === "string" ? parseInt(bytes, 10) : bytes;
|
||||
if (isNaN(numBytes) || numBytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(numBytes) / Math.log(1024));
|
||||
const size = numBytes / Math.pow(1024, i);
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
};
|
||||
|
||||
export const formatDate = (dateStr: string | number | undefined): string => {
|
||||
if (!dateStr) return "--";
|
||||
const date = typeof dateStr === "number" ? new Date(dateStr) : new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return String(dateStr);
|
||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
};
|
||||
@@ -151,7 +151,8 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
{t("sftp.context.edit")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isNavigableDirectory(entry) && onDownloadFile && (
|
||||
{onDownloadFile &&
|
||||
(!isNavigableDirectory(entry) || !pane.connection?.isLocal) && (
|
||||
<ContextMenuItem onClick={() => onDownloadFile(entry)}>
|
||||
<Download size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.download")}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import React, { memo } from 'react';
|
||||
import { getParentPath } from '../../application/state/sftp/utils';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { TransferTask } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -35,6 +36,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
canRevealTarget = false,
|
||||
onRevealTarget,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasKnownTotal = task.totalBytes > 0;
|
||||
const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0;
|
||||
|
||||
// Calculate remaining time from backend-reported sliding-window speed
|
||||
@@ -42,7 +45,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
const effectiveSpeed = task.status === 'transferring'
|
||||
? (Number.isFinite(task.speed) && task.speed > 0 ? task.speed : 0)
|
||||
: 0;
|
||||
const remainingTime = effectiveSpeed > 0
|
||||
const remainingTime = hasKnownTotal && effectiveSpeed > 0
|
||||
? Math.ceil(remainingBytes / effectiveSpeed)
|
||||
: 0;
|
||||
const remainingFormatted = remainingTime > 60
|
||||
@@ -54,6 +57,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
// Format bytes transferred / total
|
||||
const bytesDisplay = task.status === 'transferring' && task.totalBytes > 0
|
||||
? `${formatTransferBytes(task.transferredBytes)} / ${formatTransferBytes(task.totalBytes)}`
|
||||
: task.status === 'transferring'
|
||||
? formatTransferBytes(task.transferredBytes)
|
||||
: task.status === 'completed' && task.totalBytes > 0
|
||||
? formatTransferBytes(task.totalBytes)
|
||||
: '';
|
||||
@@ -99,12 +104,14 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full relative overflow-hidden",
|
||||
task.status === 'pending'
|
||||
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
|
||||
? "bg-muted-foreground/50 animate-pulse"
|
||||
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
|
||||
)}
|
||||
style={{
|
||||
width: task.status === 'pending' ? '100%' : `${progress}%`,
|
||||
width: task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
|
||||
? '100%'
|
||||
: `${progress}%`,
|
||||
transition: 'width 150ms ease-out'
|
||||
}}
|
||||
>
|
||||
@@ -121,7 +128,11 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0 min-w-[34px] text-right font-mono">
|
||||
{task.status === 'pending' ? 'waiting...' : `${Math.round(progress)}%`}
|
||||
{task.status === 'pending'
|
||||
? 'waiting...'
|
||||
: hasKnownTotal
|
||||
? `${Math.round(progress)}%`
|
||||
: '...'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -130,6 +141,11 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
{bytesDisplay}
|
||||
</div>
|
||||
)}
|
||||
{task.status === 'transferring' && !hasKnownTotal && (
|
||||
<div className="text-[9px] text-muted-foreground mt-0.5">
|
||||
{t('sftp.transfers.calculatingTotal')}
|
||||
</div>
|
||||
)}
|
||||
{task.status === 'completed' && bytesDisplay && (
|
||||
<div className="text-[9px] text-green-600 mt-0.5">
|
||||
Completed - {bytesDisplay}
|
||||
@@ -196,10 +212,13 @@ const arePropsEqual = (
|
||||
// Always re-render on fileName change
|
||||
if (prev.fileName !== next.fileName) return false;
|
||||
if (prev.targetPath !== next.targetPath) return false;
|
||||
if (prev.totalBytes !== next.totalBytes) return false;
|
||||
if ((prevProps.canRevealTarget ?? false) !== (nextProps.canRevealTarget ?? false)) return false;
|
||||
|
||||
// For transferring status, allow frequent re-renders for smooth progress bar
|
||||
if (next.status === 'transferring') {
|
||||
if (next.totalBytes <= 0 && prev.transferredBytes !== next.transferredBytes) return false;
|
||||
|
||||
// Re-render on any meaningful progress change (0.1% for smooth bar animation)
|
||||
const prevProgress = prev.totalBytes > 0 ? (prev.transferredBytes / prev.totalBytes) * 100 : 0;
|
||||
const nextProgress = next.totalBytes > 0 ? (next.transferredBytes / next.totalBytes) * 100 : 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { RemoteFile, SftpFileEntry, SftpFilenameEncoding } from "../../../types";
|
||||
import { joinPath as joinFsPath } from "../../../application/state/sftp/utils";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { toast } from "../../ui/toast";
|
||||
@@ -20,7 +21,11 @@ interface UseSftpViewFileOpsParams {
|
||||
systemApp?: SystemAppInfo,
|
||||
) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
|
||||
mkdirLocal?: (path: string) => Promise<unknown>;
|
||||
deleteLocalFile?: (path: string) => Promise<unknown>;
|
||||
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
|
||||
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
|
||||
startStreamTransfer?: (
|
||||
options: {
|
||||
transferId: string;
|
||||
@@ -31,6 +36,8 @@ interface UseSftpViewFileOpsParams {
|
||||
sourceSftpId?: string;
|
||||
targetSftpId?: string;
|
||||
totalBytes?: number;
|
||||
sourceEncoding?: SftpFilenameEncoding;
|
||||
targetEncoding?: SftpFilenameEncoding;
|
||||
},
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
@@ -105,7 +112,11 @@ export const useSftpViewFileOps = ({
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection,
|
||||
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
|
||||
@@ -363,10 +374,16 @@ export const useSftpViewFileOps = ({
|
||||
if (!pane.connection) return;
|
||||
|
||||
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const isDirectory = isNavigableDirectory(file);
|
||||
|
||||
try {
|
||||
// For local files, use blob download
|
||||
// For local files, use blob download.
|
||||
if (pane.connection.isLocal) {
|
||||
if (isDirectory) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await sftpRef.current.readBinaryFile(side, fullPath);
|
||||
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
@@ -383,7 +400,7 @@ export const useSftpViewFileOps = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// For remote SFTP files, use streaming download with save dialog
|
||||
// For remote SFTP files/directories, use streaming download with save dialog.
|
||||
if (!showSaveDialog || !startStreamTransfer || !getSftpIdForConnection) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
@@ -394,6 +411,413 @@ export const useSftpViewFileOps = ({
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
if (!listSftp || !mkdirLocal || !selectDirectory) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
|
||||
if (!selectedDirectory) return;
|
||||
|
||||
const targetPath = joinFsPath(selectedDirectory, file.name);
|
||||
|
||||
const transferId = `download-dir-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
let completedBytes = 0;
|
||||
const MAX_SYMLINK_DEPTH = 32;
|
||||
const DIRECTORY_DOWNLOAD_MAX_CONCURRENCY = 10;
|
||||
const activeChildTransferIds = new Set<string>();
|
||||
const activeFileProgress = new Map<string, { transferred: number; speed: number }>();
|
||||
const activeFileSizes = new Map<string, number>();
|
||||
const visitedPaths = new Set<string>();
|
||||
const directoryTaskQueue: Array<{
|
||||
type: "directory";
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
symlinkDepth: number;
|
||||
}> = [];
|
||||
const fileTaskQueue: Array<{
|
||||
type: "file";
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
size: number;
|
||||
}> = [];
|
||||
let pendingDirectoryTasks = 0;
|
||||
let discoveredTotalBytes = 0;
|
||||
let estimatedTotalBytes = 0;
|
||||
let activeQueueTasks = 0;
|
||||
|
||||
const isTaskCancelled = () =>
|
||||
sftpRef.current.transfers.some(
|
||||
(task) => task.id === transferId && task.status === "cancelled",
|
||||
);
|
||||
|
||||
const updateAggregateProgress = () => {
|
||||
let activeTransferredBytes = 0;
|
||||
let activeSpeed = 0;
|
||||
|
||||
for (const progress of activeFileProgress.values()) {
|
||||
activeTransferredBytes += progress.transferred;
|
||||
activeSpeed += progress.speed;
|
||||
}
|
||||
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
fileName: pendingDirectoryTasks > 0 ? `${file.name} (${t("sftp.upload.scanning")})` : file.name,
|
||||
transferredBytes: completedBytes + activeTransferredBytes,
|
||||
totalBytes: estimatedTotalBytes > 0 ? estimatedTotalBytes : 0,
|
||||
speed: activeSpeed,
|
||||
});
|
||||
};
|
||||
|
||||
const cancelActiveChildTransfers = async () => {
|
||||
await Promise.all(
|
||||
Array.from(activeChildTransferIds).map((childTransferId) =>
|
||||
sftpRef.current.cancelTransfer(childTransferId).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const maybeFinalizeDiscovery = () => {
|
||||
if (pendingDirectoryTasks === 0) {
|
||||
estimatedTotalBytes = discoveredTotalBytes;
|
||||
updateAggregateProgress();
|
||||
}
|
||||
};
|
||||
|
||||
const getDynamicConcurrencyLimit = () => {
|
||||
let largeFiles = 0;
|
||||
let mediumFiles = 0;
|
||||
|
||||
for (const size of activeFileSizes.values()) {
|
||||
if (size >= 32 * 1024 * 1024) largeFiles += 1;
|
||||
else if (size >= 1 * 1024 * 1024) mediumFiles += 1;
|
||||
}
|
||||
|
||||
if (largeFiles > 0) return 2;
|
||||
if (mediumFiles >= 2) return 4;
|
||||
if (mediumFiles === 1) return 5;
|
||||
return DIRECTORY_DOWNLOAD_MAX_CONCURRENCY;
|
||||
};
|
||||
|
||||
const enqueueDirectoryTask = (task: {
|
||||
type: "directory";
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
symlinkDepth: number;
|
||||
}) => {
|
||||
directoryTaskQueue.push(task);
|
||||
};
|
||||
|
||||
const enqueueFileTask = (task: {
|
||||
type: "file";
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
size: number;
|
||||
}) => {
|
||||
const insertIndex = fileTaskQueue.findIndex((queuedTask) => queuedTask.size > task.size);
|
||||
if (insertIndex === -1) {
|
||||
fileTaskQueue.push(task);
|
||||
} else {
|
||||
fileTaskQueue.splice(insertIndex, 0, task);
|
||||
}
|
||||
};
|
||||
|
||||
const dequeueTask = () => {
|
||||
if (pendingDirectoryTasks > 0 && directoryTaskQueue.length > 0) {
|
||||
return directoryTaskQueue.shift() ?? null;
|
||||
}
|
||||
if (fileTaskQueue.length > 0) return fileTaskQueue.shift() ?? null;
|
||||
if (directoryTaskQueue.length > 0) return directoryTaskQueue.shift() ?? null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const processFileTask = async (task: {
|
||||
type: "file";
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
size: number;
|
||||
}) => {
|
||||
const childTransferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
activeChildTransferIds.add(childTransferId);
|
||||
activeFileSizes.set(childTransferId, task.size);
|
||||
activeFileProgress.set(childTransferId, { transferred: 0, speed: 0 });
|
||||
updateAggregateProgress();
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
startStreamTransfer(
|
||||
{
|
||||
transferId: childTransferId,
|
||||
sourcePath: task.remotePath,
|
||||
targetPath: task.localPath,
|
||||
sourceType: "sftp",
|
||||
targetType: "local",
|
||||
sourceSftpId: sftpId,
|
||||
totalBytes: task.size,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
},
|
||||
(transferred, _total, speed) => {
|
||||
if (isTaskCancelled()) {
|
||||
sftpRef.current.cancelTransfer(childTransferId).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
activeFileProgress.set(childTransferId, {
|
||||
transferred,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
|
||||
});
|
||||
updateAggregateProgress();
|
||||
},
|
||||
() => {
|
||||
completedBytes += task.size;
|
||||
activeChildTransferIds.delete(childTransferId);
|
||||
activeFileSizes.delete(childTransferId);
|
||||
activeFileProgress.delete(childTransferId);
|
||||
updateAggregateProgress();
|
||||
resolve();
|
||||
},
|
||||
(error) => {
|
||||
activeChildTransferIds.delete(childTransferId);
|
||||
activeFileSizes.delete(childTransferId);
|
||||
activeFileProgress.delete(childTransferId);
|
||||
updateAggregateProgress();
|
||||
reject(new Error(error));
|
||||
},
|
||||
)
|
||||
.then((result) => {
|
||||
if (result === undefined) {
|
||||
activeChildTransferIds.delete(childTransferId);
|
||||
activeFileSizes.delete(childTransferId);
|
||||
activeFileProgress.delete(childTransferId);
|
||||
updateAggregateProgress();
|
||||
reject(new Error("Stream transfer unavailable"));
|
||||
} else if (result.error) {
|
||||
activeChildTransferIds.delete(childTransferId);
|
||||
activeFileSizes.delete(childTransferId);
|
||||
activeFileProgress.delete(childTransferId);
|
||||
updateAggregateProgress();
|
||||
reject(new Error(result.error));
|
||||
}
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
} finally {
|
||||
activeChildTransferIds.delete(childTransferId);
|
||||
activeFileSizes.delete(childTransferId);
|
||||
activeFileProgress.delete(childTransferId);
|
||||
}
|
||||
};
|
||||
|
||||
const processDirectoryTask = async (task: {
|
||||
type: "directory";
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
symlinkDepth: number;
|
||||
}) => {
|
||||
if (visitedPaths.has(task.remotePath)) {
|
||||
pendingDirectoryTasks -= 1;
|
||||
maybeFinalizeDiscovery();
|
||||
return;
|
||||
}
|
||||
|
||||
visitedPaths.add(task.remotePath);
|
||||
|
||||
if (isTaskCancelled()) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const entries = await listSftp(sftpId, task.remotePath, pane.filenameEncoding);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name === ".." || entry.name === ".") continue;
|
||||
|
||||
if (isTaskCancelled()) {
|
||||
await cancelActiveChildTransfers();
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const remoteEntryPath = sftpRef.current.joinPath(task.remotePath, entry.name);
|
||||
const localEntryPath = joinFsPath(task.localPath, entry.name);
|
||||
const isRealDir = entry.type === "directory";
|
||||
const isSymlinkDir =
|
||||
entry.type === "symlink" && entry.linkTarget === "directory";
|
||||
|
||||
if (isRealDir || isSymlinkDir) {
|
||||
if (isSymlinkDir && task.symlinkDepth >= MAX_SYMLINK_DEPTH) {
|
||||
throw new Error(
|
||||
"Maximum symlink directory depth exceeded (possible symlink cycle)",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdirLocal(localEntryPath);
|
||||
} catch (mkdirErr: unknown) {
|
||||
const isEEXIST =
|
||||
mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
|
||||
if (!isEEXIST) throw mkdirErr;
|
||||
}
|
||||
|
||||
pendingDirectoryTasks += 1;
|
||||
enqueueDirectoryTask({
|
||||
type: "directory",
|
||||
remotePath: remoteEntryPath,
|
||||
localPath: localEntryPath,
|
||||
symlinkDepth: isSymlinkDir ? task.symlinkDepth + 1 : task.symlinkDepth,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const entrySize =
|
||||
typeof entry.size === "string"
|
||||
? parseInt(String(entry.size), 10) || 0
|
||||
: entry.size || 0;
|
||||
discoveredTotalBytes += entrySize;
|
||||
enqueueFileTask({
|
||||
type: "file",
|
||||
remotePath: remoteEntryPath,
|
||||
localPath: localEntryPath,
|
||||
size: entrySize,
|
||||
});
|
||||
}
|
||||
|
||||
pendingDirectoryTasks -= 1;
|
||||
maybeFinalizeDiscovery();
|
||||
};
|
||||
|
||||
const runQueue = async () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const pump = () => {
|
||||
if (settled) return;
|
||||
|
||||
if (isTaskCancelled()) {
|
||||
settled = true;
|
||||
void cancelActiveChildTransfers().finally(() =>
|
||||
reject(new Error("Transfer cancelled")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
while (
|
||||
activeQueueTasks < getDynamicConcurrencyLimit()
|
||||
) {
|
||||
const nextTask = dequeueTask();
|
||||
if (!nextTask) break;
|
||||
|
||||
activeQueueTasks += 1;
|
||||
Promise.resolve(
|
||||
nextTask.type === "directory"
|
||||
? processDirectoryTask(nextTask)
|
||||
: processFileTask(nextTask),
|
||||
)
|
||||
.then(() => {
|
||||
activeQueueTasks -= 1;
|
||||
if (
|
||||
!settled &&
|
||||
fileTaskQueue.length === 0 &&
|
||||
directoryTaskQueue.length === 0 &&
|
||||
activeQueueTasks === 0 &&
|
||||
pendingDirectoryTasks === 0
|
||||
) {
|
||||
settled = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
pump();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
void cancelActiveChildTransfers().finally(() => reject(error));
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!settled &&
|
||||
fileTaskQueue.length === 0 &&
|
||||
directoryTaskQueue.length === 0 &&
|
||||
activeQueueTasks === 0 &&
|
||||
pendingDirectoryTasks === 0
|
||||
) {
|
||||
settled = true;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
pump();
|
||||
});
|
||||
|
||||
sftpRef.current.addExternalUpload({
|
||||
id: transferId,
|
||||
fileName: `${file.name} (${t("sftp.upload.scanning")})`,
|
||||
sourcePath: fullPath,
|
||||
targetPath,
|
||||
sourceConnectionId: pane.connection.id,
|
||||
targetConnectionId: "local",
|
||||
direction: "download",
|
||||
status: "transferring",
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
retryable: false,
|
||||
});
|
||||
|
||||
try {
|
||||
try {
|
||||
await mkdirLocal(targetPath);
|
||||
} catch (mkdirErr: unknown) {
|
||||
const isEEXIST =
|
||||
mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
|
||||
if (isEEXIST && deleteLocalFile) {
|
||||
await deleteLocalFile(targetPath);
|
||||
await mkdirLocal(targetPath);
|
||||
} else {
|
||||
throw mkdirErr;
|
||||
}
|
||||
}
|
||||
|
||||
pendingDirectoryTasks = 1;
|
||||
enqueueDirectoryTask({
|
||||
type: "directory",
|
||||
remotePath: fullPath,
|
||||
localPath: targetPath,
|
||||
symlinkDepth: 0,
|
||||
});
|
||||
await runQueue();
|
||||
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: "completed",
|
||||
fileName: file.name,
|
||||
transferredBytes: completedBytes,
|
||||
totalBytes: estimatedTotalBytes > 0 ? estimatedTotalBytes : completedBytes,
|
||||
speed: 0,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t("sftp.error.downloadFailed");
|
||||
const isCancelled =
|
||||
errorMessage.includes("cancelled") || errorMessage.includes("canceled");
|
||||
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: isCancelled ? "cancelled" : "failed",
|
||||
error: isCancelled ? undefined : errorMessage,
|
||||
speed: 0,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
|
||||
if (!isCancelled) {
|
||||
toast.error(errorMessage, "SFTP");
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Show save dialog to get target path
|
||||
const targetPath = await showSaveDialog(file.name);
|
||||
if (!targetPath) {
|
||||
@@ -433,6 +857,7 @@ export const useSftpViewFileOps = ({
|
||||
targetType: 'local',
|
||||
sourceSftpId: sftpId,
|
||||
totalBytes: fileSize,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
},
|
||||
(transferred, total, speed) => {
|
||||
// Update transfer progress in the queue
|
||||
@@ -497,7 +922,17 @@ export const useSftpViewFileOps = ({
|
||||
);
|
||||
}
|
||||
},
|
||||
[sftpRef, t, showSaveDialog, startStreamTransfer, getSftpIdForConnection],
|
||||
[
|
||||
sftpRef,
|
||||
t,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection,
|
||||
],
|
||||
);
|
||||
|
||||
const onDownloadFileLeft = useCallback(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import type { RemoteFile, SftpFilenameEncoding } from "../../../types";
|
||||
import type { SftpPaneCallbacks } from "../SftpContext";
|
||||
import { useSftpViewPaneActions } from "./useSftpViewPaneActions";
|
||||
import { useSftpViewFileOps } from "./useSftpViewFileOps";
|
||||
@@ -19,7 +20,11 @@ interface UseSftpViewPaneCallbacksParams {
|
||||
systemApp?: SystemAppInfo,
|
||||
) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
|
||||
mkdirLocal?: (path: string) => Promise<unknown>;
|
||||
deleteLocalFile?: (path: string) => Promise<unknown>;
|
||||
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
|
||||
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
|
||||
startStreamTransfer?: (
|
||||
options: {
|
||||
transferId: string;
|
||||
@@ -30,6 +35,8 @@ interface UseSftpViewPaneCallbacksParams {
|
||||
sourceSftpId?: string;
|
||||
targetSftpId?: string;
|
||||
totalBytes?: number;
|
||||
sourceEncoding?: SftpFilenameEncoding;
|
||||
targetEncoding?: SftpFilenameEncoding;
|
||||
},
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
@@ -45,7 +52,11 @@ export const useSftpViewPaneCallbacks = ({
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection,
|
||||
}: UseSftpViewPaneCallbacksParams) => {
|
||||
@@ -57,7 +68,11 @@ export const useSftpViewPaneCallbacks = ({
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Terminal Connection Dialog
|
||||
* Full connection overlay with host info, progress indicator, and auth/progress content
|
||||
*/
|
||||
import { Loader2, TerminalSquare, User } from 'lucide-react';
|
||||
import { Loader2, Plug, TerminalSquare, X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -30,6 +30,7 @@ export interface TerminalConnectionDialogProps {
|
||||
// Auth dialog props
|
||||
authProps: Omit<TerminalAuthDialogProps, 'keys'>;
|
||||
keys: SSHKey[];
|
||||
onDismissDisconnected?: () => void;
|
||||
// Progress props
|
||||
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs'>;
|
||||
}
|
||||
@@ -68,11 +69,13 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
_setShowLogs: setShowLogs, // Rename back to setShowLogs for internal use
|
||||
authProps,
|
||||
keys,
|
||||
onDismissDisconnected,
|
||||
progressProps,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasError = Boolean(error);
|
||||
const isConnecting = status === 'connecting';
|
||||
const canDismissDisconnected = status === 'disconnected' && !needsAuth && !!onDismissDisconnected;
|
||||
const protocolInfo = getProtocolInfo(host);
|
||||
|
||||
return (
|
||||
@@ -80,12 +83,11 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
"absolute inset-0 z-20 flex items-center justify-center",
|
||||
needsAuth ? "bg-black" : "bg-black/30"
|
||||
)}>
|
||||
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-2xl shadow-xl p-6 space-y-4">
|
||||
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-xl shadow-xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10" />
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg" />
|
||||
<div>
|
||||
{/* Show chain progress if available */}
|
||||
{chainProgress ? (
|
||||
<>
|
||||
<div className="text-sm font-semibold">
|
||||
@@ -104,7 +106,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-semibold">{host.label}</div>
|
||||
<div className="text-lg font-semibold">{host.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono">
|
||||
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
|
||||
</div>
|
||||
@@ -112,32 +114,56 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!needsAuth && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
>
|
||||
{showLogs ? t('terminal.connection.hideLogs') : t('terminal.connection.showLogs')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{!needsAuth && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
>
|
||||
{showLogs ? t('terminal.connection.hideLogs') : t('terminal.connection.showLogs')}
|
||||
</Button>
|
||||
)}
|
||||
{status === 'connecting' && !needsAuth && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs"
|
||||
onClick={progressProps.onCancelConnect}
|
||||
disabled={progressProps.isCancelling}
|
||||
>
|
||||
{progressProps.isCancelling ? t('terminal.progress.cancelling') : t('common.close')}
|
||||
</Button>
|
||||
)}
|
||||
{canDismissDisconnected && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
aria-label={t('terminal.connection.dismissDisconnectedDialog')}
|
||||
title={t('terminal.connection.dismissDisconnectedDialog')}
|
||||
onClick={onDismissDisconnected}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator - icons with progress bar below */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"h-8 w-8 rounded-full flex items-center justify-center flex-shrink-0",
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center flex-shrink-0",
|
||||
needsAuth
|
||||
? "bg-primary text-primary-foreground"
|
||||
: hasError
|
||||
? "bg-destructive/20 text-destructive"
|
||||
: isConnecting
|
||||
: isConnecting
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
<User size={14} />
|
||||
<Plug size={14} />
|
||||
</div>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-border/60 overflow-hidden relative">
|
||||
<div
|
||||
@@ -151,7 +177,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"h-8 w-8 rounded-full flex items-center justify-center flex-shrink-0",
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center flex-shrink-0",
|
||||
hasError ? "bg-destructive/20 text-destructive" : "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{isConnecting ? (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Terminal Connection Progress
|
||||
* Displays connection progress with logs and timeout
|
||||
*/
|
||||
import { AlertCircle, Clock, Play, ShieldCheck } from 'lucide-react';
|
||||
import { Loader2, Play } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -15,7 +15,8 @@ export interface TerminalConnectionProgressProps {
|
||||
isCancelling: boolean;
|
||||
showLogs: boolean;
|
||||
progressLogs: string[];
|
||||
onCancel: () => void;
|
||||
onCancelConnect: () => void;
|
||||
onCloseSession: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
@@ -23,71 +24,70 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
|
||||
status,
|
||||
error,
|
||||
timeLeft,
|
||||
isCancelling,
|
||||
isCancelling: _isCancelling,
|
||||
showLogs,
|
||||
progressLogs,
|
||||
onCancel,
|
||||
onCancelConnect: _onCancelConnect,
|
||||
onCloseSession,
|
||||
onRetry,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
{status === 'connecting'
|
||||
? t('terminal.progress.timeoutIn', { seconds: timeLeft })
|
||||
: error || t('terminal.progress.disconnected')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-start justify-between gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
{status === 'connecting' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onCancel}
|
||||
disabled={isCancelling}
|
||||
>
|
||||
{isCancelling ? t('terminal.progress.cancelling') : t('common.close')}
|
||||
</Button>
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 mt-0.5 flex-shrink-0 animate-spin" />
|
||||
<span className="min-w-0 whitespace-pre-wrap break-words leading-5">
|
||||
{t('terminal.progress.timeoutIn', { seconds: timeLeft })}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={onCancel}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
<Button size="sm" className="h-8" onClick={onRetry}>
|
||||
<Play className="h-3 w-3 mr-2" /> {t('terminal.progress.startOver')}
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-destructive" />
|
||||
<span className="min-w-0 whitespace-pre-wrap break-words leading-5 text-destructive">
|
||||
{error || t('terminal.progress.disconnected')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLogs && (
|
||||
<div className="rounded-xl border border-border/60 bg-background/70 shadow-inner">
|
||||
<div className="rounded-md border border-border/35 bg-background/40">
|
||||
<ScrollArea className="max-h-52 p-3">
|
||||
<div className="space-y-2 text-sm text-foreground/90">
|
||||
<div className="space-y-1 text-sm text-foreground/90">
|
||||
{progressLogs.map((line, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2">
|
||||
<div className="mt-0.5">
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-primary" />
|
||||
</div>
|
||||
<div>{line}</div>
|
||||
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-500" />
|
||||
<div className="min-w-0 break-words leading-5">{line}</div>
|
||||
</div>
|
||||
))}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 text-destructive">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5" />
|
||||
<div>{error}</div>
|
||||
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-destructive" />
|
||||
<div className="min-w-0 break-words leading-5">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{status !== 'connecting' && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={onCloseSession}>
|
||||
{t('terminal.toolbar.closeSession')}
|
||||
</Button>
|
||||
<Button size="sm" className="h-8" onClick={onRetry}>
|
||||
<Play className="h-3 w-3 mr-2" /> {t('terminal.progress.startOver')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -118,21 +118,37 @@ FontItem.displayName = 'FontItem';
|
||||
|
||||
interface ThemeSidePanelProps {
|
||||
currentThemeId: string;
|
||||
globalThemeId: string;
|
||||
currentFontFamilyId: string;
|
||||
globalFontFamilyId: string;
|
||||
currentFontSize: number;
|
||||
canResetTheme?: boolean;
|
||||
canResetFontFamily?: boolean;
|
||||
canResetFontSize?: boolean;
|
||||
onThemeChange: (themeId: string) => void;
|
||||
onThemeReset?: () => void;
|
||||
onFontFamilyChange: (fontFamilyId: string) => void;
|
||||
onFontFamilyReset?: () => void;
|
||||
onFontSizeChange: (fontSize: number) => void;
|
||||
onFontSizeReset?: () => void;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
currentThemeId,
|
||||
globalThemeId,
|
||||
currentFontFamilyId,
|
||||
globalFontFamilyId,
|
||||
currentFontSize,
|
||||
canResetTheme = false,
|
||||
canResetFontFamily = false,
|
||||
canResetFontSize = false,
|
||||
onThemeChange,
|
||||
onThemeReset,
|
||||
onFontFamilyChange,
|
||||
onFontFamilyReset,
|
||||
onFontSizeChange,
|
||||
onFontSizeReset,
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -149,6 +165,14 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
() => [...TERMINAL_THEMES, ...customThemes],
|
||||
[customThemes]
|
||||
);
|
||||
const globalTheme = useMemo(
|
||||
() => allThemes.find((theme) => theme.id === globalThemeId) || TERMINAL_THEMES[0],
|
||||
[allThemes, globalThemeId],
|
||||
);
|
||||
const globalFont = useMemo(
|
||||
() => availableFonts.find((font) => font.id === globalFontFamilyId) || availableFonts[0],
|
||||
[availableFonts, globalFontFamilyId],
|
||||
);
|
||||
|
||||
const handleThemeSelect = useCallback((themeId: string) => {
|
||||
setEditingTheme(null);
|
||||
@@ -294,6 +318,18 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{canResetTheme && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
|
||||
{t('terminal.themeModal.globalTheme')}
|
||||
</div>
|
||||
<ThemeItem
|
||||
theme={globalTheme}
|
||||
isSelected={!canResetTheme}
|
||||
onSelect={() => onThemeReset?.()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'font' && (
|
||||
@@ -306,6 +342,18 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
onSelect={handleFontSelect}
|
||||
/>
|
||||
))}
|
||||
{canResetFontFamily && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
|
||||
{t('terminal.themeModal.globalFont')}
|
||||
</div>
|
||||
<FontItem
|
||||
font={globalFont}
|
||||
isSelected={!canResetFontFamily}
|
||||
onSelect={() => onFontFamilyReset?.()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'custom' && !editingTheme && (
|
||||
@@ -365,8 +413,18 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
{/* Font Size Control (only in font tab) */}
|
||||
{activeTab === 'font' && (
|
||||
<div className="p-2.5 border-t border-border/50 shrink-0">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1.5 font-semibold">
|
||||
{t('terminal.themeModal.fontSize')}
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold">
|
||||
{t('terminal.themeModal.fontSize')}
|
||||
</div>
|
||||
{canResetFontSize && (
|
||||
<button
|
||||
onClick={onFontSizeReset}
|
||||
className="text-[10px] font-medium text-primary hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{t('common.useGlobal')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 bg-muted/30 rounded-lg p-1.5">
|
||||
<button
|
||||
|
||||
@@ -442,6 +442,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}): Promise<string> => {
|
||||
return ctx.terminalBackend.startSSHSession({
|
||||
sessionId: ctx.sessionId,
|
||||
hostLabel: ctx.host.label,
|
||||
hostname: ctx.host.hostname,
|
||||
username: effectiveUsername,
|
||||
port: ctx.host.port || 22,
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
shouldScrollOnTerminalInput,
|
||||
shouldScrollOnTerminalPaste,
|
||||
} from "../../../domain/terminalScroll";
|
||||
import {
|
||||
resolveHostTerminalFontFamilyId,
|
||||
resolveHostTerminalFontSize,
|
||||
} from "../../../domain/terminalAppearance";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
@@ -141,12 +145,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
rendererType,
|
||||
});
|
||||
|
||||
const hostFontId = ctx.host.fontFamily || ctx.fontFamilyId || "menlo";
|
||||
const hostFontId = resolveHostTerminalFontFamilyId(ctx.host, ctx.fontFamilyId) || "menlo";
|
||||
// Use fontStore for font lookup - guarantees non-empty result
|
||||
const fontObj = fontStore.getFontById(hostFontId);
|
||||
const fontFamily = fontObj.family;
|
||||
|
||||
const effectiveFontSize = ctx.host.fontSize || ctx.fontSize;
|
||||
const effectiveFontSize = resolveHostTerminalFontSize(ctx.host, ctx.fontSize);
|
||||
|
||||
const cursorStyle = settings?.cursorShape ?? "block";
|
||||
const cursorBlink = settings?.cursorBlink ?? true;
|
||||
|
||||
@@ -30,13 +30,13 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideCloseButton?: boolean }
|
||||
>(({ className, children, hideCloseButton, ...props }, ref) => {
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideCloseButton?: boolean; overlayClassName?: string }
|
||||
>(({ className, children, hideCloseButton, overlayClassName, ...props }, ref) => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import { Host } from './models';
|
||||
|
||||
export const LINUX_DISTRO_OPTIONS = [
|
||||
'linux',
|
||||
'ubuntu',
|
||||
'debian',
|
||||
'centos',
|
||||
'rocky',
|
||||
'fedora',
|
||||
'arch',
|
||||
'alpine',
|
||||
'amazon',
|
||||
'opensuse',
|
||||
'redhat',
|
||||
'almalinux',
|
||||
'oracle',
|
||||
'kali',
|
||||
] as const;
|
||||
|
||||
export const normalizeDistroId = (value?: string) => {
|
||||
const v = (value || '').toLowerCase().trim();
|
||||
if (!v) return '';
|
||||
@@ -16,11 +33,36 @@ export const normalizeDistroId = (value?: string) => {
|
||||
if (v.includes('almalinux')) return 'almalinux';
|
||||
if (v.includes('oracle')) return 'oracle';
|
||||
if (v.includes('kali')) return 'kali';
|
||||
if (v === 'linux' || v.includes('linux')) return 'linux';
|
||||
return '';
|
||||
};
|
||||
|
||||
export const getEffectiveHostDistro = (
|
||||
host?: Pick<Host, 'distro' | 'manualDistro' | 'distroMode'> | null,
|
||||
) => {
|
||||
if (!host) return '';
|
||||
const detected = normalizeDistroId(host.distro);
|
||||
const manual = normalizeDistroId(host.manualDistro);
|
||||
if (host.distroMode === 'manual') return manual || detected;
|
||||
if (host.distroMode === 'auto') return detected;
|
||||
return detected;
|
||||
};
|
||||
|
||||
export const sanitizeHost = (host: Host): Host => {
|
||||
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
|
||||
const cleanDistro = normalizeDistroId(host.distro);
|
||||
return { ...host, hostname: cleanHostname, distro: cleanDistro };
|
||||
const cleanManualDistro = normalizeDistroId(host.manualDistro);
|
||||
const cleanDistroMode =
|
||||
host.distroMode === 'manual'
|
||||
? 'manual'
|
||||
: host.distroMode === 'auto'
|
||||
? 'auto'
|
||||
: undefined;
|
||||
return {
|
||||
...host,
|
||||
hostname: cleanHostname,
|
||||
distro: cleanDistro,
|
||||
distroMode: cleanDistroMode,
|
||||
manualDistro: cleanManualDistro || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -86,9 +86,14 @@ export interface Host {
|
||||
moshEnabled?: boolean;
|
||||
moshServerPath?: string; // Custom mosh-server path (e.g., /usr/local/bin/mosh-server)
|
||||
theme?: string;
|
||||
themeOverride?: boolean; // Explicitly override the global terminal theme for this host
|
||||
fontFamily?: string; // Terminal font family for this host
|
||||
fontFamilyOverride?: boolean; // Explicitly override the global terminal font family for this host
|
||||
fontSize?: number; // Terminal font size for this host (pt)
|
||||
fontSizeOverride?: boolean; // Explicitly override the global terminal font size for this host
|
||||
distro?: string; // detected distro id (e.g., ubuntu, debian)
|
||||
distroMode?: 'auto' | 'manual'; // whether distro icon comes from detection or manual override
|
||||
manualDistro?: string; // manually selected distro id when distroMode='manual'
|
||||
// Multi-protocol support
|
||||
protocols?: ProtocolConfig[]; // Multiple protocol configurations
|
||||
telnetPort?: number; // Telnet-specific port (for quick access)
|
||||
@@ -594,6 +599,7 @@ export interface TerminalSession {
|
||||
protocol?: 'ssh' | 'telnet' | 'local' | 'serial';
|
||||
port?: number;
|
||||
moshEnabled?: boolean;
|
||||
shellType?: 'posix' | 'fish' | 'powershell' | 'cmd' | 'unknown';
|
||||
// Serial-specific connection settings
|
||||
serialConfig?: SerialConfig;
|
||||
}
|
||||
|
||||
66
domain/terminalAppearance.ts
Normal file
66
domain/terminalAppearance.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Host } from './models';
|
||||
|
||||
type TerminalAppearanceDefaults = {
|
||||
themeId: string;
|
||||
fontFamilyId: string;
|
||||
fontSize: number;
|
||||
};
|
||||
|
||||
const hasLegacyStringValue = (value: string | undefined): boolean =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
|
||||
const hasLegacyNumberValue = (value: number | undefined): boolean =>
|
||||
typeof value === 'number' && !Number.isNaN(value);
|
||||
|
||||
const hasEffectiveOverride = (
|
||||
explicitOverride: boolean | undefined,
|
||||
legacyValuePresent: boolean,
|
||||
): boolean => explicitOverride === true || (explicitOverride === undefined && legacyValuePresent);
|
||||
|
||||
export const hasHostThemeOverride = (host?: Pick<Host, 'themeOverride' | 'theme'> | null): boolean =>
|
||||
hasEffectiveOverride(host?.themeOverride, hasLegacyStringValue(host?.theme));
|
||||
|
||||
export const hasHostFontFamilyOverride = (host?: Pick<Host, 'fontFamilyOverride' | 'fontFamily'> | null): boolean =>
|
||||
hasEffectiveOverride(host?.fontFamilyOverride, hasLegacyStringValue(host?.fontFamily));
|
||||
|
||||
export const hasHostFontSizeOverride = (host?: Pick<Host, 'fontSizeOverride' | 'fontSize'> | null): boolean =>
|
||||
hasEffectiveOverride(host?.fontSizeOverride, hasLegacyNumberValue(host?.fontSize));
|
||||
|
||||
export const clearHostThemeOverride = (host: Host): Host => ({
|
||||
...host,
|
||||
theme: undefined,
|
||||
themeOverride: false,
|
||||
});
|
||||
|
||||
export const clearHostFontFamilyOverride = (host: Host): Host => ({
|
||||
...host,
|
||||
fontFamily: undefined,
|
||||
fontFamilyOverride: false,
|
||||
});
|
||||
|
||||
export const clearHostFontSizeOverride = (host: Host): Host => ({
|
||||
...host,
|
||||
fontSize: undefined,
|
||||
fontSizeOverride: false,
|
||||
});
|
||||
|
||||
export const resolveHostTerminalThemeId = (host: Host | null | undefined, defaultThemeId: string): string =>
|
||||
hasHostThemeOverride(host) && host?.theme ? host.theme : defaultThemeId;
|
||||
|
||||
export const resolveHostTerminalFontFamilyId = (host: Host | null | undefined, defaultFontFamilyId: string): string =>
|
||||
hasHostFontFamilyOverride(host) && host?.fontFamily ? host.fontFamily : defaultFontFamilyId;
|
||||
|
||||
export const resolveHostTerminalFontSize = (host: Host | null | undefined, defaultFontSize: number): number =>
|
||||
hasHostFontSizeOverride(host) && host?.fontSize != null ? host.fontSize : defaultFontSize;
|
||||
|
||||
export const resolveHostTerminalAppearance = (
|
||||
host: Host | null | undefined,
|
||||
defaults: TerminalAppearanceDefaults,
|
||||
) => ({
|
||||
themeId: resolveHostTerminalThemeId(host, defaults.themeId),
|
||||
fontFamilyId: resolveHostTerminalFontFamilyId(host, defaults.fontFamilyId),
|
||||
fontSize: resolveHostTerminalFontSize(host, defaults.fontSize),
|
||||
hasThemeOverride: hasHostThemeOverride(host),
|
||||
hasFontFamilyOverride: hasHostFontFamilyOverride(host),
|
||||
hasFontSizeOverride: hasHostFontSizeOverride(host),
|
||||
});
|
||||
@@ -13,6 +13,7 @@ module.exports = {
|
||||
files: [
|
||||
'dist/**/*',
|
||||
'electron/**/*',
|
||||
'lib/**/*.cjs',
|
||||
'!electron/.dev-config.json',
|
||||
'public/**/*',
|
||||
'node_modules/**/*'
|
||||
@@ -21,6 +22,9 @@ module.exports = {
|
||||
'node_modules/node-pty/**/*',
|
||||
'node_modules/ssh2/**/*',
|
||||
'node_modules/cpu-features/**/*',
|
||||
'node_modules/@zed-industries/claude-agent-acp/**/*',
|
||||
'node_modules/@agentclientprotocol/sdk/**/*',
|
||||
'node_modules/@anthropic-ai/claude-agent-sdk/**/*',
|
||||
'node_modules/@zed-industries/codex-acp/**/*',
|
||||
'node_modules/@zed-industries/codex-acp-*/**/*',
|
||||
'node_modules/@modelcontextprotocol/sdk/**/*',
|
||||
|
||||
@@ -11,6 +11,89 @@
|
||||
|
||||
const crypto = require("crypto");
|
||||
const { stripAnsi } = require("./shellUtils.cjs");
|
||||
const { classifyLocalShellType } = require("../../../lib/localShell.cjs");
|
||||
|
||||
function detectShellKind(shellPath, platform = process.platform) {
|
||||
return classifyLocalShellType(shellPath, platform);
|
||||
}
|
||||
|
||||
function subscribeToPtyData(ptyStream, onData) {
|
||||
if (typeof ptyStream?.onData === "function") {
|
||||
const disposable = ptyStream.onData((data) => onData(data));
|
||||
return () => {
|
||||
try {
|
||||
disposable?.dispose?.();
|
||||
} catch {
|
||||
// Ignore cleanup failures
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof ptyStream?.on === "function" && typeof ptyStream?.removeListener === "function") {
|
||||
ptyStream.on("data", onData);
|
||||
return () => {
|
||||
try {
|
||||
ptyStream.removeListener("data", onData);
|
||||
} catch {
|
||||
// Ignore cleanup failures
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("PTY stream does not support data subscriptions");
|
||||
}
|
||||
|
||||
function buildWrappedCommand(command, shellKind, marker) {
|
||||
switch (shellKind) {
|
||||
case "powershell": {
|
||||
// Combine into 2 PTY lines (like posix) to minimise prompt echo duplication:
|
||||
// Line 1: start marker + pager env + user command
|
||||
// Line 2: capture exit code + end marker
|
||||
const psPager = "$env:PAGER='cat'; $env:SYSTEMD_PAGER=''; $env:GIT_PAGER='cat'; $env:LESS=''; ";
|
||||
return (
|
||||
`Write-Output '${marker}_S'; ${psPager}${command}\r\n` +
|
||||
`Write-Output "${marker}_E:$LASTEXITCODE"\r\n`
|
||||
);
|
||||
}
|
||||
|
||||
case "cmd":
|
||||
return [
|
||||
'set "PAGER=cat"',
|
||||
'set "SYSTEMD_PAGER="',
|
||||
'set "GIT_PAGER=cat"',
|
||||
'set "LESS="',
|
||||
`echo ${marker}_S`,
|
||||
command,
|
||||
`echo ${marker}_E:%errorlevel%`,
|
||||
"",
|
||||
].join("\r\n");
|
||||
|
||||
case "fish":
|
||||
return [
|
||||
"set -gx PAGER cat",
|
||||
"set -gx SYSTEMD_PAGER ''",
|
||||
"set -gx GIT_PAGER cat",
|
||||
"set -gx LESS ''",
|
||||
`printf '%s\\n' '${marker}_S'`,
|
||||
command,
|
||||
"set __NCMCP_rc $status",
|
||||
`printf '%s\\n' '${marker}_E:'$__NCMCP_rc`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
case "posix":
|
||||
default: {
|
||||
// Combine into 2 PTY lines to minimise prompt echo duplication:
|
||||
// Line 1: start marker + pager env + user command
|
||||
// Line 2: capture exit code + end marker + restore exit code
|
||||
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
|
||||
return (
|
||||
`printf '%s\\n' '${marker}_S';${noPager}${command}\n` +
|
||||
`__NCMCP_rc=$?;printf '%s\\n' '${marker}_E:'"$__NCMCP_rc";(exit $__NCMCP_rc)\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute command through a terminal PTY stream.
|
||||
@@ -29,15 +112,18 @@ function execViaPty(ptyStream, command, options) {
|
||||
stripMarkers = false,
|
||||
trackForCancellation = null,
|
||||
timeoutMs = 60000,
|
||||
shellKind,
|
||||
} = options || {};
|
||||
|
||||
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
|
||||
const resolvedShellKind = shellKind || "posix";
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let output = "";
|
||||
let foundStart = false;
|
||||
let timeoutId = null;
|
||||
let finished = false;
|
||||
let unsubscribe = null;
|
||||
|
||||
const onData = (data) => {
|
||||
const text = data.toString();
|
||||
@@ -97,14 +183,14 @@ function execViaPty(ptyStream, command, options) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeoutId);
|
||||
ptyStream.removeListener("data", onData);
|
||||
unsubscribe?.();
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(marker);
|
||||
}
|
||||
|
||||
let cleaned = stripAnsi(stdout || "").trim();
|
||||
if (stripMarkers) {
|
||||
cleaned = cleaned.replace(/__NCMCP_[^\r\n]*[\r\n]*/g, "").trim();
|
||||
cleaned = cleaned.replace(/^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm, "").trim();
|
||||
}
|
||||
resolve({
|
||||
ok: exitCode === 0 || exitCode === null,
|
||||
@@ -117,7 +203,7 @@ function execViaPty(ptyStream, command, options) {
|
||||
timeoutId = setTimeout(() => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
ptyStream.removeListener("data", onData);
|
||||
unsubscribe?.();
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(marker);
|
||||
}
|
||||
@@ -128,22 +214,21 @@ function execViaPty(ptyStream, command, options) {
|
||||
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
|
||||
}, timeoutMs);
|
||||
|
||||
ptyStream.on("data", onData);
|
||||
unsubscribe = subscribeToPtyData(ptyStream, onData);
|
||||
|
||||
// Register for cancellation if tracking map provided
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.set(marker, {
|
||||
ptyStream,
|
||||
cleanup: () => { clearTimeout(timeoutId); ptyStream.removeListener("data", onData); },
|
||||
cleanup: () => {
|
||||
clearTimeout(timeoutId);
|
||||
unsubscribe?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Markers are filtered from terminal display by preload.cjs (MCP_MARKER_RE).
|
||||
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
|
||||
ptyStream.write(
|
||||
`printf '${marker}_S\\n';${noPager}${command}\n` +
|
||||
`__nc=$?;printf '${marker}_E:'$__nc'\\n';(exit $__nc)\n`
|
||||
);
|
||||
ptyStream.write(buildWrappedCommand(command, resolvedShellKind, marker));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -214,5 +299,6 @@ function execViaChannel(sshClient, command, options) {
|
||||
module.exports = {
|
||||
execViaPty,
|
||||
execViaChannel,
|
||||
detectShellKind,
|
||||
stripAnsi,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ const path = require("node:path");
|
||||
const ANSI_ESCAPE_REGEX = /\u001B\[[0-?]*[ -/]*[@-~]/g;
|
||||
const ANSI_OSC_REGEX = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g;
|
||||
const URL_CANDIDATE_REGEX = /https?:\/\/[^\s]+/g;
|
||||
const WINDOWS_RUNNABLE_EXTENSIONS = [".exe", ".cmd", ".bat", ".com"];
|
||||
|
||||
// ── ANSI stripping ──
|
||||
|
||||
@@ -56,6 +57,37 @@ function extractFirstNonLocalhostUrl(output) {
|
||||
|
||||
// ── CLI / path helpers ──
|
||||
|
||||
function normalizeCliPathForPlatform(filePath) {
|
||||
const normalized = String(filePath || "").trim();
|
||||
if (!normalized) return null;
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
return existsSync(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
const ext = path.extname(normalized).toLowerCase();
|
||||
if (ext) {
|
||||
return existsSync(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
// Windows npm globals often contain both a POSIX shim (`codex`) and the
|
||||
// actual runnable wrapper (`codex.cmd`). Prefer the wrapper when present.
|
||||
for (const suffix of WINDOWS_RUNNABLE_EXTENSIONS) {
|
||||
const candidate = `${normalized}${suffix}`;
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return existsSync(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function shouldUseShellForCommand(command) {
|
||||
if (process.platform !== "win32") return false;
|
||||
const normalized = String(command || "").trim().toLowerCase();
|
||||
return normalized.endsWith(".cmd") || normalized.endsWith(".bat");
|
||||
}
|
||||
|
||||
function resolveCliFromPath(command, shellEnv) {
|
||||
// Validate command: only allow valid binary names (alphanumeric, hyphens, underscores, dots)
|
||||
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
|
||||
@@ -70,8 +102,11 @@ function resolveCliFromPath(command, shellEnv) {
|
||||
timeout: 3000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: shellEnv,
|
||||
}).trim().split("\n")[0].trim();
|
||||
if (resolved && existsSync(resolved)) return resolved;
|
||||
}).trim();
|
||||
for (const candidate of resolved.split(/\r?\n/)) {
|
||||
const normalized = normalizeCliPathForPlatform(candidate);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
} catch {
|
||||
// Not found on PATH
|
||||
}
|
||||
@@ -141,6 +176,48 @@ async function getShellEnv() {
|
||||
return _cachedShellEnv;
|
||||
}
|
||||
|
||||
// ── Claude Code ACP binary resolution ──
|
||||
|
||||
/**
|
||||
* Resolve the Claude ACP binary, returning { command, prependArgs }.
|
||||
*
|
||||
* On macOS/Linux a shebang-based .js script can be spawned directly, but on
|
||||
* Windows `child_process.spawn` does not interpret shebangs — so when the
|
||||
* resolved path is a JS file we invoke it via the system Node runtime.
|
||||
*/
|
||||
function resolveClaudeAcpBinaryPath(shellEnv, electronModule) {
|
||||
const binaryName = "claude-agent-acp";
|
||||
|
||||
// Dev mode: prefer system PATH (npm creates platform-appropriate wrappers)
|
||||
const isPackaged = electronModule?.app?.isPackaged;
|
||||
if (!isPackaged && shellEnv) {
|
||||
const systemPath = resolveCliFromPath(binaryName, shellEnv);
|
||||
if (systemPath) return { command: systemPath, prependArgs: [] };
|
||||
}
|
||||
|
||||
// Packaged build (or dev fallback): use npm-bundled binary
|
||||
try {
|
||||
const resolved = require.resolve("@zed-industries/claude-agent-acp/dist/index.js");
|
||||
const scriptPath = toUnpackedAsarPath(resolved);
|
||||
|
||||
// On Windows, .js files cannot be spawned directly (no shebang support) —
|
||||
// invoke via Node. In packaged Electron builds process.execPath is the
|
||||
// app binary (e.g. Netcatty.exe), not a Node runtime, so we must resolve
|
||||
// the real `node` from PATH. If Node is not installed, fall back to the
|
||||
// bare command name and let the system find the npm-generated .cmd wrapper.
|
||||
if (process.platform === "win32") {
|
||||
const nodePath = resolveCliFromPath("node", shellEnv);
|
||||
if (nodePath) {
|
||||
return { command: nodePath, prependArgs: [scriptPath] };
|
||||
}
|
||||
return { command: binaryName, prependArgs: [] };
|
||||
}
|
||||
return { command: scriptPath, prependArgs: [] };
|
||||
} catch {
|
||||
return { command: binaryName, prependArgs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stream chunk serialization ──
|
||||
|
||||
function serializeStreamChunk(chunk) {
|
||||
@@ -196,7 +273,10 @@ module.exports = {
|
||||
stripAnsi,
|
||||
isLocalhostHostname,
|
||||
extractFirstNonLocalhostUrl,
|
||||
normalizeCliPathForPlatform,
|
||||
shouldUseShellForCommand,
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
toUnpackedAsarPath,
|
||||
getShellEnv,
|
||||
serializeStreamChunk,
|
||||
|
||||
@@ -17,7 +17,10 @@ const mcpServerBridge = require("./mcpServerBridge.cjs");
|
||||
// ── Extracted modules ──
|
||||
const {
|
||||
stripAnsi,
|
||||
normalizeCliPathForPlatform,
|
||||
shouldUseShellForCommand,
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
getShellEnv,
|
||||
serializeStreamChunk,
|
||||
} = require("./ai/shellUtils.cjs");
|
||||
@@ -235,6 +238,17 @@ function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
mcpServerBridge.init({ sessions, sftpClients });
|
||||
|
||||
// Wire up main window getter for MCP approval IPC
|
||||
mcpServerBridge.setMainWindowGetter(() => {
|
||||
try {
|
||||
const windowManager = require("./windowManager.cjs");
|
||||
const mainWin = windowManager.getMainWindow?.();
|
||||
return (mainWin && !mainWin.isDestroyed()) ? mainWin : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Store main window webContents ID for IPC sender validation (Issue #17)
|
||||
try {
|
||||
const windowManager = require("./windowManager.cjs");
|
||||
@@ -888,11 +902,22 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
|
||||
try {
|
||||
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "AI execution is not supported for this local shell executable. Configure the local terminal to use bash/zsh/sh, fish, PowerShell/pwsh, or cmd.exe.",
|
||||
};
|
||||
}
|
||||
|
||||
// Prefer PTY stream (visible in terminal)
|
||||
const ptyStream = session.stream || session.pty;
|
||||
const ptyStream = session.stream || session.pty || session.proc;
|
||||
if (ptyStream && typeof ptyStream.write === "function") {
|
||||
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return execViaPty(ptyStream, command, { stripMarkers: true, timeoutMs });
|
||||
return execViaPty(ptyStream, command, {
|
||||
stripMarkers: true,
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: SSH exec channel (invisible to terminal)
|
||||
@@ -938,6 +963,10 @@ function registerHandlers(ipcMain) {
|
||||
session.pty.write(data);
|
||||
return { ok: true };
|
||||
}
|
||||
if (session.proc) {
|
||||
session.proc.write(data);
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, error: "No writable stream for session" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
@@ -950,6 +979,7 @@ function registerHandlers(ipcMain) {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
cwd: options?.cwd || undefined,
|
||||
env: options?.env || process.env,
|
||||
shell: shouldUseShellForCommand(command),
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
@@ -1176,7 +1206,7 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
}
|
||||
|
||||
// Discover external agents from PATH, plus the bundled Codex CLI if present.
|
||||
// Discover external agents from PATH, plus bundled ACP binaries if present.
|
||||
ipcMain.handle("netcatty:ai:agents:discover", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const agents = [];
|
||||
@@ -1186,9 +1216,10 @@ function registerHandlers(ipcMain) {
|
||||
name: "Claude Code",
|
||||
icon: "claude",
|
||||
description: "Anthropic's agentic coding assistant",
|
||||
acpCommand: "claude-code-acp",
|
||||
acpCommand: "claude-agent-acp",
|
||||
acpArgs: [],
|
||||
args: ["-p", "--output-format", "text", "{prompt}"],
|
||||
resolveAcp: resolveClaudeAcpBinaryPath,
|
||||
},
|
||||
{
|
||||
command: "codex",
|
||||
@@ -1198,6 +1229,7 @@ function registerHandlers(ipcMain) {
|
||||
acpCommand: "codex-acp",
|
||||
acpArgs: [],
|
||||
args: ["exec", "--full-auto", "--json", "{prompt}"],
|
||||
resolveAcp: resolveCodexAcpBinaryPath,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1205,21 +1237,35 @@ function registerHandlers(ipcMain) {
|
||||
const seenPaths = new Set();
|
||||
|
||||
for (const agent of knownAgents) {
|
||||
let resolvedPath = null;
|
||||
let resolvedPath = resolveCliFromPath(agent.command, shellEnv);
|
||||
|
||||
try {
|
||||
const whichCmd = process.platform === "win32" ? "where" : "which";
|
||||
const result = execFileSync(whichCmd, [agent.command], {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: shellEnv,
|
||||
}).trim();
|
||||
if (result) {
|
||||
resolvedPath = result.split("\n")[0].trim();
|
||||
// If the base command is not on PATH, check whether the bundled ACP
|
||||
// binary is available — the agent can still work via ACP without the
|
||||
// standalone CLI installed.
|
||||
// resolveClaudeAcpBinaryPath returns { command, prependArgs },
|
||||
// resolveCodexAcpBinaryPath returns a plain string.
|
||||
let versionCommand = null;
|
||||
let versionPrependArgs = [];
|
||||
if (!resolvedPath && agent.resolveAcp) {
|
||||
const result = agent.resolveAcp(shellEnv, electronModule);
|
||||
if (typeof result === "string") {
|
||||
if (result && result !== agent.acpCommand && existsSync(result)) {
|
||||
resolvedPath = result;
|
||||
}
|
||||
} else if (result?.command) {
|
||||
// On Windows the command may be `node` with the script in prependArgs.
|
||||
// Use the script path for display/dedup so the UI shows the actual
|
||||
// agent rather than the Node binary.
|
||||
const scriptPath = result.prependArgs?.[0];
|
||||
const displayPath = scriptPath || result.command;
|
||||
if (displayPath !== agent.acpCommand && existsSync(displayPath)) {
|
||||
resolvedPath = displayPath;
|
||||
if (scriptPath) {
|
||||
versionCommand = result.command;
|
||||
versionPrependArgs = result.prependArgs;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
resolvedPath = null;
|
||||
}
|
||||
|
||||
if (!resolvedPath || seenPaths.has(resolvedPath)) {
|
||||
@@ -1228,14 +1274,19 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
let version = "";
|
||||
try {
|
||||
const result = await runCommand(resolvedPath, ["--version"], { env: shellEnv });
|
||||
// When the agent is invoked via Node (Windows), probe version with
|
||||
// the full command (e.g. `node /path/to/dist/index.js --version`).
|
||||
const probeCmd = versionCommand || resolvedPath;
|
||||
const probeArgs = [...versionPrependArgs, "--version"];
|
||||
const result = await runCommand(probeCmd, probeArgs, { env: shellEnv });
|
||||
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
|
||||
} catch {
|
||||
version = "";
|
||||
}
|
||||
|
||||
const { resolveAcp: _unused, ...agentInfo } = agent;
|
||||
agents.push({
|
||||
...agent,
|
||||
...agentInfo,
|
||||
path: resolvedPath,
|
||||
version,
|
||||
available: true,
|
||||
@@ -1253,10 +1304,8 @@ function registerHandlers(ipcMain) {
|
||||
let resolvedPath = null;
|
||||
|
||||
if (customPath) {
|
||||
// User provided a custom path – validate it exists
|
||||
if (existsSync(customPath)) {
|
||||
resolvedPath = customPath;
|
||||
}
|
||||
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
|
||||
resolvedPath = normalizeCliPathForPlatform(customPath);
|
||||
} else {
|
||||
resolvedPath = resolveCliFromPath(command, shellEnv);
|
||||
}
|
||||
@@ -1341,6 +1390,7 @@ function registerHandlers(ipcMain) {
|
||||
const child = spawn(codexCliPath, ["login"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: shellEnv,
|
||||
shell: shouldUseShellForCommand(codexCliPath),
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
@@ -1447,7 +1497,7 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
// Known agent command names (must match knownAgents in discover handler)
|
||||
const ALLOWED_AGENT_COMMANDS = new Set([
|
||||
"claude", "claude-code-acp",
|
||||
"claude", "claude-agent-acp",
|
||||
"codex", "codex-acp",
|
||||
]);
|
||||
|
||||
@@ -1649,6 +1699,13 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// ── MCP Approval response (renderer → main) ──
|
||||
ipcMain.handle("netcatty:ai:mcp:approval-response", async (event, { approvalId, approved }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
mcpServerBridge.resolveApprovalFromRenderer(approvalId, approved);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// ── ACP (Agent Client Protocol) streaming ──
|
||||
|
||||
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images }) => {
|
||||
@@ -1665,6 +1722,7 @@ function registerHandlers(ipcMain) {
|
||||
const shellEnv = await getShellEnv();
|
||||
const sessionCwd = cwd || process.cwd();
|
||||
const isCodexAgent = acpCommand === "codex-acp";
|
||||
const isClaudeAgent = acpCommand === "claude-agent-acp";
|
||||
|
||||
// Resolve API key from providerId (decrypted in main process only)
|
||||
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
||||
@@ -1695,7 +1753,7 @@ function registerHandlers(ipcMain) {
|
||||
? await resolveCodexMcpSnapshot(sessionCwd)
|
||||
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
|
||||
|
||||
// Inject Netcatty MCP server for remote host access (scoped to this chat session)
|
||||
// Inject Netcatty MCP server for scoped terminal-session access
|
||||
try {
|
||||
const mcpPort = await mcpServerBridge.getOrCreateHost();
|
||||
const scopedIds = mcpServerBridge.getScopedSessionIds(chatSessionId);
|
||||
@@ -1742,13 +1800,19 @@ function registerHandlers(ipcMain) {
|
||||
agentEnv.CODEX_API_KEY = apiKey;
|
||||
}
|
||||
|
||||
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
|
||||
const resolvedCommand = isCodexAgent
|
||||
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
|
||||
: acpCommand;
|
||||
: claudeAcp
|
||||
? claudeAcp.command
|
||||
: acpCommand;
|
||||
const resolvedArgs = claudeAcp
|
||||
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [];
|
||||
|
||||
const provider = createACPProvider({
|
||||
command: resolvedCommand,
|
||||
args: acpArgs || [],
|
||||
args: resolvedArgs,
|
||||
env: agentEnv,
|
||||
session: {
|
||||
cwd: sessionCwd,
|
||||
@@ -1785,11 +1849,16 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
|
||||
const fallbackClaudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
|
||||
const fallbackProvider = createACPProvider({
|
||||
command: isCodexAgent
|
||||
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
|
||||
: acpCommand,
|
||||
args: acpArgs || [],
|
||||
: fallbackClaudeAcp
|
||||
? fallbackClaudeAcp.command
|
||||
: acpCommand,
|
||||
args: fallbackClaudeAcp
|
||||
? [...fallbackClaudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [],
|
||||
env: apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
|
||||
session: {
|
||||
cwd: sessionCwd,
|
||||
@@ -1827,15 +1896,15 @@ function registerHandlers(ipcMain) {
|
||||
acpRequestSessions.set(requestId, chatSessionId);
|
||||
acpChatRuns.set(chatSessionId, { requestId, cancelRequested: false });
|
||||
|
||||
// Prepend context hint so the agent uses MCP tools for remote hosts
|
||||
// Prepend context hint so the agent uses Netcatty MCP tools for the scoped sessions
|
||||
const contextualPrompt =
|
||||
`[Context: You are inside Netcatty, a multi-host SSH terminal manager. ` +
|
||||
`The user is managing REMOTE servers, not the local machine. ` +
|
||||
`Use the "netcatty-remote-hosts" MCP tools to operate on the remote hosts. ` +
|
||||
`Call get_environment first to discover available hosts and their session IDs. ` +
|
||||
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
|
||||
`Use the "netcatty-remote-hosts" MCP tools to operate only on the terminal sessions exposed by Netcatty. ` +
|
||||
`Those sessions may be remote hosts, a local terminal, or Mosh-backed shells. ` +
|
||||
`Call get_environment first to discover available sessions and their IDs. ` +
|
||||
`For normal shell commands, use terminal_execute so you receive command output. ` +
|
||||
`Use terminal_send_input only to respond to an interactive prompt that is already running; it does not read back the updated terminal output. ` +
|
||||
`Do NOT use local shell execution.]\n\n${prompt}`;
|
||||
`SFTP file tools only work for remote SSH sessions, not local terminals.]\n\n${prompt}`;
|
||||
|
||||
// Build message content: text + optional attachments
|
||||
// ACP provider only supports image/* and audio/* inline via `type: "file"`.
|
||||
@@ -2004,6 +2073,7 @@ function registerHandlers(ipcMain) {
|
||||
mcpServerBridge.cancelAllPtyExecs();
|
||||
const effectiveChatSessionId = chatSessionId || acpRequestSessions.get(requestId);
|
||||
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
|
||||
mcpServerBridge.clearPendingApprovals(effectiveChatSessionId);
|
||||
const activeRun = effectiveChatSessionId ? acpChatRuns.get(effectiveChatSessionId) : null;
|
||||
if (activeRun && activeRun.requestId === requestId) {
|
||||
activeRun.cancelRequested = true;
|
||||
|
||||
@@ -15,7 +15,7 @@ const { existsSync } = require("node:fs");
|
||||
const { toUnpackedAsarPath } = require("./ai/shellUtils.cjs");
|
||||
const { execViaPty, execViaChannel } = require("./ai/ptyExec.cjs");
|
||||
|
||||
let sessions = null; // Map<sessionId, { sshClient, stream, pty, conn, ... }>
|
||||
let sessions = null; // Map<sessionId, { sshClient, stream, pty, proc, conn, ... }>
|
||||
let sftpClients = null; // Map<sftpId, SFTPWrapper>
|
||||
let tcpServer = null;
|
||||
let tcpPort = null;
|
||||
@@ -57,6 +57,91 @@ let permissionMode = "confirm";
|
||||
const activePtyExecs = new Map(); // marker → { ptyStream, cleanup }
|
||||
const cancelledChatSessions = new Set();
|
||||
|
||||
// ── Approval gate (for confirm mode with ACP/MCP agents) ──
|
||||
let getMainWindowFn = null; // () => BrowserWindow | null
|
||||
const pendingApprovals = new Map(); // approvalId → { resolve, chatSessionId }
|
||||
let approvalIdCounter = 0;
|
||||
|
||||
function setMainWindowGetter(fn) {
|
||||
getMainWindowFn = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request approval from the renderer process.
|
||||
* Sends an IPC event and returns a Promise<boolean> that resolves
|
||||
* when the user approves/rejects in the UI, or auto-denies after timeout.
|
||||
*/
|
||||
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function requestApprovalFromRenderer(toolName, args, chatSessionId) {
|
||||
return new Promise((resolve) => {
|
||||
const mainWin = typeof getMainWindowFn === 'function' ? getMainWindowFn() : null;
|
||||
if (!mainWin || mainWin.isDestroyed()) {
|
||||
// No renderer available — deny to preserve confirm mode safety guarantee
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const approvalId = `mcp_approval_${++approvalIdCounter}_${Date.now()}`;
|
||||
|
||||
// Auto-deny after timeout so ACP/MCP tool calls don't hang indefinitely
|
||||
const timerId = setTimeout(() => {
|
||||
if (pendingApprovals.has(approvalId)) {
|
||||
pendingApprovals.delete(approvalId);
|
||||
resolve(false);
|
||||
// Notify renderer to remove the stale approval card
|
||||
try {
|
||||
const win = typeof getMainWindowFn === 'function' ? getMainWindowFn() : null;
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('netcatty:ai:mcp:approval-cleared', { approvalIds: [approvalId] });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}, APPROVAL_TIMEOUT_MS);
|
||||
|
||||
pendingApprovals.set(approvalId, {
|
||||
resolve: (approved) => {
|
||||
clearTimeout(timerId);
|
||||
resolve(approved);
|
||||
},
|
||||
chatSessionId: chatSessionId || null,
|
||||
});
|
||||
mainWin.webContents.send('netcatty:ai:mcp:approval-request', {
|
||||
approvalId,
|
||||
toolName,
|
||||
args,
|
||||
chatSessionId: chatSessionId || undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveApprovalFromRenderer(approvalId, approved) {
|
||||
const entry = pendingApprovals.get(approvalId);
|
||||
if (entry) {
|
||||
pendingApprovals.delete(approvalId);
|
||||
entry.resolve(approved);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pending MCP approvals, optionally scoped to a specific chatSessionId.
|
||||
* Resolves matched entries with false (denied) to unblock hanging promises.
|
||||
*/
|
||||
function clearPendingApprovals(chatSessionId) {
|
||||
if (!chatSessionId) {
|
||||
for (const [, entry] of pendingApprovals) {
|
||||
entry.resolve(false);
|
||||
}
|
||||
pendingApprovals.clear();
|
||||
return;
|
||||
}
|
||||
for (const [id, entry] of pendingApprovals) {
|
||||
if (entry.chatSessionId === chatSessionId) {
|
||||
pendingApprovals.delete(id);
|
||||
entry.resolve(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAllPtyExecs() {
|
||||
for (const [marker, entry] of activePtyExecs) {
|
||||
try {
|
||||
@@ -134,7 +219,7 @@ function isChatSessionCancelled(chatSessionId) {
|
||||
* Register metadata for terminal sessions (called from renderer via IPC).
|
||||
* Metadata is stored per-scope (chatSessionId) so different AI chat sessions
|
||||
* only see their own hosts.
|
||||
* @param {Array<{sessionId, hostname, label, os, username, connected}>} sessionList
|
||||
* @param {Array<{sessionId, hostname, label, os, username, connected, protocol?, shellType?}>} sessionList
|
||||
* @param {string} [chatSessionId] - AI chat session ID for per-scope isolation
|
||||
*/
|
||||
function updateSessionMetadata(sessionList, chatSessionId) {
|
||||
@@ -146,6 +231,8 @@ function updateSessionMetadata(sessionList, chatSessionId) {
|
||||
label: s.label || "",
|
||||
os: s.os || "",
|
||||
username: s.username || "",
|
||||
protocol: s.protocol || "",
|
||||
shellType: s.shellType || "",
|
||||
connected: s.connected !== false,
|
||||
});
|
||||
}
|
||||
@@ -189,6 +276,20 @@ function getSessionMeta(sessionId, chatSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function sessionSupportsSftp(session) {
|
||||
const sshClient = session?.conn || session?.sshClient;
|
||||
return !!(sshClient && typeof sshClient.exec === "function");
|
||||
}
|
||||
|
||||
function scopeHasSftpSessions(sessionIds) {
|
||||
if (!Array.isArray(sessionIds) || sessionIds.length === 0) return false;
|
||||
for (const sessionId of sessionIds) {
|
||||
const session = sessions?.get(sessionId);
|
||||
if (sessionSupportsSftp(session)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an array of async task factories with a concurrency limit.
|
||||
*/
|
||||
@@ -354,6 +455,15 @@ async function dispatch(method, params) {
|
||||
return { ok: false, error: "Operation cancelled: the ACP session was stopped." };
|
||||
}
|
||||
|
||||
// Confirm mode: request user approval for write operations
|
||||
if (permissionMode === "confirm" && WRITE_METHODS.has(method)) {
|
||||
const { chatSessionId, ...toolArgs } = params || {};
|
||||
const approved = await requestApprovalFromRenderer(method, toolArgs, chatSessionId);
|
||||
if (!approved) {
|
||||
return { ok: false, error: "Operation denied by user." };
|
||||
}
|
||||
}
|
||||
|
||||
// Scope validation for session-targeted operations
|
||||
if (method !== "netcatty/getContext" && params?.sessionId) {
|
||||
const scopeErr = validateSessionScope(params.sessionId, params?.chatSessionId);
|
||||
@@ -422,9 +532,11 @@ function handleGetContext(params) {
|
||||
}
|
||||
for (const [sessionId, session] of sessions.entries()) {
|
||||
if (scopedIds && !scopedIds.has(sessionId)) continue;
|
||||
// Only include SSH sessions (skip local terminal sessions)
|
||||
const ptyStream = session.stream || session.pty || session.proc;
|
||||
const sshClient = session.conn || session.sshClient;
|
||||
if (!sshClient || typeof sshClient.exec !== "function") continue;
|
||||
const hasCommandablePty = ptyStream && typeof ptyStream.write === "function";
|
||||
const hasSshExec = sshClient && typeof sshClient.exec === "function";
|
||||
if (!hasCommandablePty && !hasSshExec) continue;
|
||||
|
||||
// Look up metadata scoped to this chat session
|
||||
const meta = getSessionMeta(sessionId, chatSessionId) || {};
|
||||
@@ -434,15 +546,19 @@ function handleGetContext(params) {
|
||||
label: meta.label || session.label || "",
|
||||
os: meta.os || "",
|
||||
username: meta.username || session.username || "",
|
||||
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn),
|
||||
protocol: meta.protocol || session.protocol || session.type || "",
|
||||
shellType: meta.shellType || session.shellKind || "",
|
||||
supportsSftp: sessionSupportsSftp(session),
|
||||
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn || ptyStream),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
environment: "netcatty-terminal",
|
||||
description: "You are operating inside Netcatty, a multi-host SSH terminal manager. " +
|
||||
"The user is managing remote servers. Use the provided tools to execute commands, " +
|
||||
"read/write files, and manage hosts on the remote machines. " +
|
||||
description: "You are operating inside Netcatty, a multi-session terminal manager. " +
|
||||
"The available sessions may be remote hosts, local terminals, or Mosh-backed shells. " +
|
||||
"Use the provided tools to execute commands through the sessions exposed by Netcatty. " +
|
||||
"SFTP tools only work for remote SSH sessions. " +
|
||||
"Always prefer these tools over suggesting the user to do things manually.",
|
||||
hosts,
|
||||
hostCount: hosts.length,
|
||||
@@ -466,26 +582,36 @@ function handleExec(params) {
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session) return { ok: false, error: "Session not found" };
|
||||
|
||||
const sshClient = session.conn || session.sshClient;
|
||||
if (!sshClient || typeof sshClient.exec !== "function") {
|
||||
return { ok: false, error: "Not an SSH session" };
|
||||
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "AI execution is not supported for this local shell executable. Configure the local terminal to use bash/zsh/sh, fish, PowerShell/pwsh, or cmd.exe.",
|
||||
};
|
||||
}
|
||||
|
||||
const ptyStream = session.stream;
|
||||
const sshClient = session.conn || session.sshClient;
|
||||
const ptyStream = session.stream || session.pty || session.proc;
|
||||
|
||||
// Prefer the interactive PTY so the user sees command/output in-session.
|
||||
if (ptyStream && typeof ptyStream.write === "function") {
|
||||
return execViaPty(ptyStream, command, {
|
||||
trackForCancellation: activePtyExecs,
|
||||
timeoutMs: commandTimeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
});
|
||||
}
|
||||
|
||||
// If no PTY stream, fall back to exec channel for SSH sessions only.
|
||||
if (!sshClient || typeof sshClient.exec !== "function") {
|
||||
return { ok: false, error: "Session does not support command execution" };
|
||||
}
|
||||
|
||||
// If no PTY stream, fall back to exec channel (invisible to terminal)
|
||||
if (!ptyStream || typeof ptyStream.write !== "function") {
|
||||
return execViaChannel(sshClient, command, {
|
||||
timeoutMs: commandTimeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
});
|
||||
}
|
||||
|
||||
// Execute via PTY stream so user sees the command in the terminal
|
||||
return execViaPty(ptyStream, command, {
|
||||
trackForCancellation: activePtyExecs,
|
||||
timeoutMs: commandTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Handler: terminalWrite ──
|
||||
@@ -511,6 +637,10 @@ function handleTerminalWrite(params) {
|
||||
session.pty.write(input);
|
||||
return { ok: true };
|
||||
}
|
||||
if (session.proc) {
|
||||
session.proc.write(input);
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, error: "No writable stream" };
|
||||
}
|
||||
|
||||
@@ -740,7 +870,7 @@ async function handleMultiExec(params) {
|
||||
} else {
|
||||
// Parallel execution with concurrency limit
|
||||
const tasks = sessionIds.map((sid) => () => {
|
||||
return handleExec({ sessionId: sid, command }).then(result => ({
|
||||
return Promise.resolve(handleExec({ sessionId: sid, command })).then(result => ({
|
||||
sid,
|
||||
ok: result.ok,
|
||||
output: result.ok ? (result.stdout || "(no output)") : `Error: ${result.error || result.stderr || "Failed"}`,
|
||||
@@ -786,6 +916,11 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
|
||||
env.push({ name: "NETCATTY_MCP_CHAT_SESSION_ID", value: chatSessionId });
|
||||
}
|
||||
|
||||
env.push({
|
||||
name: "NETCATTY_MCP_ENABLE_SFTP",
|
||||
value: scopeHasSftpSessions(effectiveIds) ? "1" : "0",
|
||||
});
|
||||
|
||||
// Pass permission mode so MCP server can enforce it locally (defense-in-depth)
|
||||
env.push({ name: "NETCATTY_MCP_PERMISSION_MODE", value: permissionMode });
|
||||
|
||||
@@ -834,4 +969,7 @@ module.exports = {
|
||||
cancelAllPtyExecs,
|
||||
cleanupScopedMetadata,
|
||||
cleanup,
|
||||
setMainWindowGetter,
|
||||
resolveApprovalFromRenderer,
|
||||
clearPendingApprovals,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { stripAnsi, terminalDataToHtml } = require("./sessionLogsBridge.cjs");
|
||||
const { toLocalISOString, stripAnsi, terminalDataToHtml } = require("./sessionLogsBridge.cjs");
|
||||
|
||||
// Active log streams keyed by sessionId
|
||||
const activeStreams = new Map();
|
||||
@@ -41,7 +41,7 @@ function startStream(sessionId, opts) {
|
||||
fs.mkdirSync(hostDir, { recursive: true });
|
||||
|
||||
const date = new Date(startTime || Date.now());
|
||||
const dateStr = date.toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const dateStr = toLocalISOString(date);
|
||||
// For html format, write raw data to a temp file during streaming,
|
||||
// then convert on stopStream.
|
||||
const isHtml = format === "html";
|
||||
|
||||
@@ -7,13 +7,36 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { dialog } = require("electron");
|
||||
|
||||
/**
|
||||
* Get current Date to a local ISO-like string (YYYY-MM-DDTHH-MM-SS)
|
||||
*/
|
||||
function toLocalISOString(date = new Date()) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = pad(date.getMonth() + 1);
|
||||
const day = pad(date.getDate());
|
||||
const hours = pad(date.getHours());
|
||||
const minutes = pad(date.getMinutes());
|
||||
const seconds = pad(date.getSeconds());
|
||||
|
||||
return `${year}-${month}-${day}T${hours}-${minutes}-${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escape codes from text
|
||||
* Used for plain text export format
|
||||
*/
|
||||
function stripAnsi(str) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
|
||||
return str
|
||||
// OSC: ESC ] ... BEL or ESC ] ... ESC \
|
||||
.replace(/\x1B\][\s\S]*?(?:\x07|\x1B\\)/g, '')
|
||||
// ANSI CSI / ESC sequences
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "")
|
||||
// Remove remaining control chars except \n \r \t
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,7 +171,7 @@ async function exportSessionLog(event, payload) {
|
||||
|
||||
// Generate default filename
|
||||
const date = new Date(startTime);
|
||||
const dateStr = date.toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const dateStr = toLocalISOString(date);
|
||||
const safeHostLabel = (hostLabel || hostname || "session").replace(/[^a-zA-Z0-9-_]/g, "_");
|
||||
const ext = format === "html" ? "html" : format === "raw" ? "log" : "txt";
|
||||
const defaultPath = `${safeHostLabel}_${dateStr}.${ext}`;
|
||||
@@ -223,7 +246,7 @@ async function autoSaveSessionLog(event, payload) {
|
||||
|
||||
// Generate filename with timestamp
|
||||
const date = new Date(startTime);
|
||||
const dateStr = date.toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const dateStr = toLocalISOString(date);
|
||||
const ext = format === "html" ? "html" : format === "raw" ? "log" : "txt";
|
||||
const fileName = `${dateStr}.${ext}`;
|
||||
const filePath = path.join(hostDir, fileName);
|
||||
@@ -285,5 +308,6 @@ module.exports = {
|
||||
autoSaveSessionLog,
|
||||
openSessionLogsDir,
|
||||
stripAnsi,
|
||||
toLocalISOString,
|
||||
terminalDataToHtml,
|
||||
};
|
||||
|
||||
@@ -981,7 +981,7 @@ async function startSSHSession(event, options) {
|
||||
// Start real-time session log stream if configured
|
||||
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
||||
sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: options.label || options.hostname || '',
|
||||
hostLabel: options.hostLabel || options.hostname || '',
|
||||
hostname: options.hostname || '',
|
||||
directory: options.sessionLog.directory,
|
||||
format: options.sessionLog.format || 'txt',
|
||||
|
||||
@@ -12,6 +12,7 @@ const pty = require("node-pty");
|
||||
const { SerialPort } = require("serialport");
|
||||
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { detectShellKind } = require("./ai/ptyExec.cjs");
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
@@ -21,6 +22,22 @@ const DEFAULT_UTF8_LOCALE = "en_US.UTF-8";
|
||||
const LOGIN_SHELLS = new Set(["bash", "zsh", "fish", "ksh"]);
|
||||
const POWERSHELL_SHELLS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
|
||||
|
||||
function expandHomePath(targetPath) {
|
||||
if (!targetPath) return targetPath;
|
||||
if (targetPath === "~") return os.homedir();
|
||||
if (targetPath.startsWith("~/")) return path.join(os.homedir(), targetPath.slice(2));
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
function normalizeExecutablePath(targetPath) {
|
||||
const expanded = expandHomePath(targetPath);
|
||||
if (!expanded) return expanded;
|
||||
if (expanded.includes(path.sep) || expanded.startsWith(".")) {
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
const getLoginShellArgs = (shellPath) => {
|
||||
if (!shellPath || process.platform === "win32") return [];
|
||||
const shellName = path.basename(shellPath);
|
||||
@@ -174,8 +191,9 @@ function startLocalSession(event, payload) {
|
||||
payload?.sessionId ||
|
||||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const defaultShell = getDefaultLocalShell();
|
||||
const shell = payload?.shell || defaultShell;
|
||||
const shell = normalizeExecutablePath(payload?.shell) || defaultShell;
|
||||
const shellArgs = getLocalShellArgs(shell);
|
||||
const shellKind = detectShellKind(shell);
|
||||
const env = applyLocaleDefaults({
|
||||
...process.env,
|
||||
...(payload?.env || {}),
|
||||
@@ -191,7 +209,7 @@ function startLocalSession(event, payload) {
|
||||
if (payload?.cwd) {
|
||||
try {
|
||||
// Resolve to absolute path and check if it exists and is a directory
|
||||
const resolvedPath = path.resolve(payload.cwd);
|
||||
const resolvedPath = path.resolve(expandHomePath(payload.cwd));
|
||||
if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) {
|
||||
cwd = resolvedPath;
|
||||
} else {
|
||||
@@ -212,7 +230,21 @@ function startLocalSession(event, payload) {
|
||||
|
||||
const session = {
|
||||
proc,
|
||||
pty: proc,
|
||||
type: "local",
|
||||
protocol: "local",
|
||||
webContentsId: event.sender.id,
|
||||
hostname: "localhost",
|
||||
username: (() => {
|
||||
try {
|
||||
return os.userInfo().username || "local";
|
||||
} catch {
|
||||
return "local";
|
||||
}
|
||||
})(),
|
||||
label: "Local Terminal",
|
||||
shellExecutable: shell,
|
||||
shellKind,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -543,8 +575,15 @@ async function startMoshSession(event, options) {
|
||||
|
||||
const session = {
|
||||
proc,
|
||||
pty: proc,
|
||||
type: 'mosh',
|
||||
protocol: 'mosh',
|
||||
webContentsId: event.sender.id,
|
||||
hostname: options.hostname || '',
|
||||
username: options.username || '',
|
||||
label: options.label || options.hostname || 'Mosh Session',
|
||||
shellKind: 'posix',
|
||||
shellExecutable: 'remote-shell',
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -818,12 +857,7 @@ function validatePath(event, payload) {
|
||||
|
||||
try {
|
||||
// Resolve path (handle ~, etc.)
|
||||
let resolvedPath = targetPath;
|
||||
if (resolvedPath === "~") {
|
||||
resolvedPath = os.homedir();
|
||||
} else if (resolvedPath.startsWith("~/")) {
|
||||
resolvedPath = path.join(os.homedir(), resolvedPath.slice(2));
|
||||
}
|
||||
let resolvedPath = expandHomePath(targetPath);
|
||||
resolvedPath = path.resolve(resolvedPath);
|
||||
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
|
||||
@@ -34,6 +34,7 @@ const TRANSFER_CONCURRENCY = 64; // 64 parallel SFTP requests
|
||||
// Progress IPC throttle: sending too many IPC messages bogs down the event loop
|
||||
const PROGRESS_THROTTLE_MS = 100; // Send IPC at most every 100ms
|
||||
const PROGRESS_THROTTLE_BYTES = 256 * 1024; // Or every 256KB of progress
|
||||
const ISOLATED_DOWNLOAD_IDLE_TTL_MS = 5000;
|
||||
|
||||
// Speed calculation uses strict sliding-window average:
|
||||
// speed = bytes_delta_in_window / time_delta_in_window
|
||||
@@ -45,6 +46,7 @@ let sftpClients = null;
|
||||
|
||||
// Active transfers storage
|
||||
const activeTransfers = new Map();
|
||||
const isolatedDownloadChannelPools = new WeakMap();
|
||||
|
||||
/**
|
||||
* Initialize the transfer bridge with dependencies
|
||||
@@ -65,6 +67,185 @@ async function openIsolatedSftpChannel(client) {
|
||||
});
|
||||
}
|
||||
|
||||
function getIsolatedDownloadChannelPool(client) {
|
||||
let pool = isolatedDownloadChannelPools.get(client);
|
||||
if (!pool) {
|
||||
pool = {
|
||||
idle: [],
|
||||
idleTimers: new Map(),
|
||||
busy: new Set(),
|
||||
waiters: [],
|
||||
opening: 0,
|
||||
maxChannels: null,
|
||||
warnedCapacity: false,
|
||||
};
|
||||
isolatedDownloadChannelPools.set(client, pool);
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
function isIsolatedChannelOpenFailure(err) {
|
||||
const message = err?.message || String(err || "");
|
||||
return (
|
||||
message.includes("Channel open failure") ||
|
||||
message.includes("open failed")
|
||||
);
|
||||
}
|
||||
|
||||
function waitForIsolatedDownloadChannel(pool, transfer) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const waiter = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const index = pool.waiters.indexOf(waiter);
|
||||
if (index !== -1) {
|
||||
pool.waiters.splice(index, 1);
|
||||
}
|
||||
if (transfer?.wakeWaiter === waiter) {
|
||||
transfer.wakeWaiter = null;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
if (transfer) {
|
||||
transfer.wakeWaiter = waiter;
|
||||
}
|
||||
pool.waiters.push(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
function notifyIsolatedDownloadWaiter(pool) {
|
||||
const waiter = pool.waiters.shift();
|
||||
if (waiter) waiter();
|
||||
}
|
||||
|
||||
function removeIdleIsolatedDownloadChannel(pool, sftp) {
|
||||
const index = pool.idle.indexOf(sftp);
|
||||
if (index !== -1) {
|
||||
pool.idle.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function clearIdleIsolatedDownloadTimer(pool, sftp) {
|
||||
const timer = pool.idleTimers.get(sftp);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
pool.idleTimers.delete(sftp);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleIdleIsolatedDownloadChannel(client, sftp) {
|
||||
const pool = isolatedDownloadChannelPools.get(client);
|
||||
if (!pool) return;
|
||||
|
||||
clearIdleIsolatedDownloadTimer(pool, sftp);
|
||||
const timer = setTimeout(() => {
|
||||
clearIdleIsolatedDownloadTimer(pool, sftp);
|
||||
removeIdleIsolatedDownloadChannel(pool, sftp);
|
||||
try { sftp?.end?.(); } catch { }
|
||||
}, ISOLATED_DOWNLOAD_IDLE_TTL_MS);
|
||||
pool.idleTimers.set(sftp, timer);
|
||||
}
|
||||
|
||||
function releaseIsolatedDownloadChannel(client, sftp, options = {}) {
|
||||
const { dispose = false } = options;
|
||||
const pool = isolatedDownloadChannelPools.get(client);
|
||||
if (!pool) {
|
||||
if (dispose) {
|
||||
try { sftp?.end?.(); } catch { }
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pool.busy.delete(sftp);
|
||||
clearIdleIsolatedDownloadTimer(pool, sftp);
|
||||
|
||||
if (dispose) {
|
||||
try { sftp?.end?.(); } catch { }
|
||||
notifyIsolatedDownloadWaiter(pool);
|
||||
return;
|
||||
}
|
||||
|
||||
pool.idle.push(sftp);
|
||||
scheduleIdleIsolatedDownloadChannel(client, sftp);
|
||||
notifyIsolatedDownloadWaiter(pool);
|
||||
}
|
||||
|
||||
async function acquireIsolatedDownloadChannel(client, transfer) {
|
||||
const pool = getIsolatedDownloadChannelPool(client);
|
||||
|
||||
while (true) {
|
||||
if (transfer?.cancelled) return null;
|
||||
|
||||
const cached = pool.idle.pop();
|
||||
if (cached) {
|
||||
clearIdleIsolatedDownloadTimer(pool, cached);
|
||||
pool.busy.add(cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const knownCapacity = pool.maxChannels;
|
||||
const currentChannelCount = pool.idle.length + pool.busy.size + pool.opening;
|
||||
if (knownCapacity !== null && currentChannelCount >= knownCapacity) {
|
||||
if (pool.opening > 0) {
|
||||
await waitForIsolatedDownloadChannel(pool, transfer);
|
||||
if (transfer?.cancelled) return null;
|
||||
continue;
|
||||
}
|
||||
if (pool.busy.size === 0) {
|
||||
return null;
|
||||
}
|
||||
await waitForIsolatedDownloadChannel(pool, transfer);
|
||||
if (transfer?.cancelled) return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
pool.opening += 1;
|
||||
try {
|
||||
const opened = await openIsolatedSftpChannel(client);
|
||||
pool.opening -= 1;
|
||||
notifyIsolatedDownloadWaiter(pool);
|
||||
if (!opened) return null;
|
||||
pool.busy.add(opened);
|
||||
const knownCapacity = pool.idle.length + pool.busy.size;
|
||||
if (pool.maxChannels !== null) {
|
||||
pool.maxChannels = Math.max(pool.maxChannels, knownCapacity);
|
||||
}
|
||||
return opened;
|
||||
} catch (err) {
|
||||
pool.opening -= 1;
|
||||
notifyIsolatedDownloadWaiter(pool);
|
||||
if (isIsolatedChannelOpenFailure(err)) {
|
||||
if (pool.opening > 0) {
|
||||
await waitForIsolatedDownloadChannel(pool, transfer);
|
||||
if (transfer?.cancelled) return null;
|
||||
continue;
|
||||
}
|
||||
const detectedCapacity = pool.idle.length + pool.busy.size;
|
||||
pool.maxChannels = detectedCapacity;
|
||||
if (!pool.warnedCapacity) {
|
||||
pool.warnedCapacity = true;
|
||||
console.warn(
|
||||
`[transferBridge] Isolated fastGet channel capacity reached; reusing up to ${detectedCapacity} extra channel(s) for this SFTP session.`,
|
||||
);
|
||||
}
|
||||
if (detectedCapacity > 0) {
|
||||
await waitForIsolatedDownloadChannel(pool, transfer);
|
||||
if (transfer?.cancelled) return null;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"[transferBridge] Failed to open isolated SFTP channel for fastGet, falling back to streams:",
|
||||
err.message || String(err),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a local file to SFTP using ssh2's fastPut (parallel SFTP requests).
|
||||
* Falls back to sequential stream piping if fastPut is unavailable.
|
||||
@@ -184,12 +365,7 @@ async function downloadFile(remotePath, localPath, client, fileSize, transfer, s
|
||||
|
||||
// Prefer fastGet on an isolated SFTP channel so cancellation can abort just this transfer.
|
||||
if (!client.__netcattySudoMode) {
|
||||
let fastSftp = null;
|
||||
try {
|
||||
fastSftp = await openIsolatedSftpChannel(client);
|
||||
} catch (err) {
|
||||
console.warn("[transferBridge] Failed to open isolated SFTP channel for fastGet, falling back to streams:", err.message || String(err));
|
||||
}
|
||||
const fastSftp = await acquireIsolatedDownloadChannel(client, transfer);
|
||||
|
||||
if (fastSftp && typeof fastSftp.fastGet === "function") {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -205,7 +381,9 @@ async function downloadFile(remotePath, localPath, client, fileSize, transfer, s
|
||||
try { fastSftp.removeListener("error", onFastSftpError); } catch { }
|
||||
onFastSftpError = null;
|
||||
}
|
||||
try { fastSftp.end(); } catch { }
|
||||
releaseIsolatedDownloadChannel(client, fastSftp, {
|
||||
dispose: !!err || transfer.cancelled,
|
||||
});
|
||||
|
||||
if (transfer.cancelled) reject(new Error("Transfer cancelled"));
|
||||
else if (err) reject(err);
|
||||
@@ -214,7 +392,6 @@ async function downloadFile(remotePath, localPath, client, fileSize, transfer, s
|
||||
const abortFastTransfer = () => {
|
||||
if (settled) return;
|
||||
transfer.cancelled = true;
|
||||
try { fastSftp.end(); } catch { }
|
||||
finish(new Error("Transfer cancelled"));
|
||||
};
|
||||
transfer.abort = abortFastTransfer;
|
||||
@@ -236,10 +413,6 @@ async function downloadFile(remotePath, localPath, client, fileSize, transfer, s
|
||||
}, finish);
|
||||
});
|
||||
}
|
||||
|
||||
if (fastSftp && typeof fastSftp.end === "function") {
|
||||
try { fastSftp.end(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: sequential stream piping
|
||||
@@ -302,7 +475,7 @@ async function startTransfer(event, payload, onProgress) {
|
||||
} = payload;
|
||||
const sender = event.sender;
|
||||
|
||||
const transfer = { cancelled: false, readStream: null, writeStream: null, abort: null };
|
||||
const transfer = { cancelled: false, readStream: null, writeStream: null, abort: null, wakeWaiter: null };
|
||||
activeTransfers.set(transferId, transfer);
|
||||
const transferCreatedAt = Date.now();
|
||||
|
||||
@@ -554,6 +727,9 @@ async function cancelTransfer(event, payload) {
|
||||
const transfer = activeTransfers.get(transferId);
|
||||
if (transfer) {
|
||||
transfer.cancelled = true;
|
||||
if (typeof transfer.wakeWaiter === "function") {
|
||||
try { transfer.wakeWaiter(); } catch { }
|
||||
}
|
||||
|
||||
if (typeof transfer.abort === "function") {
|
||||
try { transfer.abort(); } catch { }
|
||||
|
||||
@@ -606,6 +606,23 @@ const registerBridges = (win) => {
|
||||
return result.filePath;
|
||||
});
|
||||
|
||||
// Select a directory and return the selected path
|
||||
ipcMain.handle("netcatty:selectDirectory", async (_event, { title, defaultPath }) => {
|
||||
const { dialog } = electronModule;
|
||||
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: title || "Select Directory",
|
||||
defaultPath,
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
// Download SFTP file to temp and return local path
|
||||
ipcMain.handle("netcatty:sftp:downloadToTemp", async (_event, { sftpId, remotePath, fileName, encoding }) => {
|
||||
console.log(`[Main] Downloading SFTP file to temp:`);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Spawned by codex-acp (or other ACP agents) as a child process.
|
||||
* Communicates with the Netcatty main process via TCP (JSON-RPC over newline-delimited JSON).
|
||||
* Exposes SSH terminal and SFTP tools so ACP agents can operate on remote hosts.
|
||||
* Exposes Netcatty terminal and SFTP tools so ACP agents can operate on scoped sessions.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
@@ -39,6 +39,7 @@ const CHAT_SESSION_ID = process.env.NETCATTY_MCP_CHAT_SESSION_ID || null;
|
||||
|
||||
// Permission mode: 'observer' | 'confirm' | 'autonomous' (defense-in-depth, TCP bridge also checks)
|
||||
const PERMISSION_MODE = process.env.NETCATTY_MCP_PERMISSION_MODE || "confirm";
|
||||
const ENABLE_SFTP_TOOLS = process.env.NETCATTY_MCP_ENABLE_SFTP !== "0";
|
||||
|
||||
// Default command blocklist (defense-in-depth, TCP bridge also checks)
|
||||
// NOTE: Keep in sync with DEFAULT_COMMAND_BLOCKLIST in infrastructure/ai/types.ts
|
||||
@@ -196,23 +197,31 @@ server.resource(
|
||||
// Tool: get_environment
|
||||
server.tool(
|
||||
"get_environment",
|
||||
"Get information about the current Netcatty workspace: all connected remote hosts, their session IDs, OS, and connection status. Call this first to discover available hosts before executing commands.",
|
||||
"Get information about the current Netcatty scope: all terminal sessions exposed by Netcatty, their session IDs, OS, shell hints, and connection status. Sessions may be remote hosts, a local terminal, or Mosh-backed shells. Call this first before executing commands.",
|
||||
{},
|
||||
async () => {
|
||||
process.stderr.write(`[netcatty-mcp] get_environment called, SCOPED_SESSION_IDS: ${JSON.stringify(SCOPED_SESSION_IDS)}, CHAT_SESSION_ID: ${CHAT_SESSION_ID}\n`);
|
||||
const ctx = await rpcCall("netcatty/getContext", scopeParams);
|
||||
process.stderr.write(`[netcatty-mcp] get_environment result: hostCount=${ctx.hostCount}, hosts=${JSON.stringify(ctx.hosts?.map(h => h.sessionId))}\n`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(ctx, null, 2) }] };
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
...ctx,
|
||||
sftpAvailable: ENABLE_SFTP_TOOLS,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: terminal_execute
|
||||
server.tool(
|
||||
"terminal_execute",
|
||||
"Execute a shell command on a remote host via SSH. The command runs in the host's shell and output (stdout/stderr) is returned when complete.",
|
||||
"Execute a shell command on a Netcatty terminal session. The command runs in that session's shell and output (stdout/stderr) is returned when complete.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID (from get_environment) to execute on."),
|
||||
command: z.string().describe("The shell command to execute on the remote host."),
|
||||
command: z.string().describe("The shell command to execute in the target session."),
|
||||
},
|
||||
async ({ sessionId, command }) => {
|
||||
const guardErr = guardWriteOperation(command);
|
||||
@@ -234,7 +243,7 @@ server.tool(
|
||||
// Tool: terminal_send_input
|
||||
server.tool(
|
||||
"terminal_send_input",
|
||||
"Send raw input to a terminal session on a remote host. Use only for interactive programs that are already running: y/n prompts, passwords, ctrl+c (\\x03), ctrl+d (\\x04), or pressing enter (\\n). This tool does not return the updated terminal output. For normal commands, use terminal_execute.",
|
||||
"Send raw input to a Netcatty terminal session. Use only for interactive programs that are already running: y/n prompts, passwords, ctrl+c (\\x03), ctrl+d (\\x04), or pressing enter (\\n). This tool does not return the updated terminal output. For normal commands, use terminal_execute.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID to send input to."),
|
||||
input: z.string().describe("The raw input string. Use escape sequences for special keys (e.g. \\x03 for ctrl+c, \\n for enter)."),
|
||||
@@ -252,149 +261,151 @@ server.tool(
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_list_directory
|
||||
server.tool(
|
||||
"sftp_list_directory",
|
||||
"List the contents of a directory on the remote host. Returns file names, sizes, types, and modification timestamps.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the remote directory to list."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const result = await rpcCall("netcatty/sftpList", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(result.files || result.output, null, 2) }] };
|
||||
},
|
||||
);
|
||||
if (ENABLE_SFTP_TOOLS) {
|
||||
// Tool: sftp_list_directory
|
||||
server.tool(
|
||||
"sftp_list_directory",
|
||||
"List the contents of a directory on the remote host. Returns file names, sizes, types, and modification timestamps.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the remote directory to list."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const result = await rpcCall("netcatty/sftpList", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(result.files || result.output, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_read_file
|
||||
server.tool(
|
||||
"sftp_read_file",
|
||||
"Read the content of a file on the remote host. Returns file content as text, truncated if the file is large.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the remote file to read."),
|
||||
maxBytes: z.number().optional().default(10000).describe("Maximum bytes to read. Defaults to 10000."),
|
||||
},
|
||||
async ({ sessionId, path, maxBytes }) => {
|
||||
const safeMaxBytes = Math.max(1, Math.min(10 * 1024 * 1024, Number(maxBytes) || 10000));
|
||||
const result = await rpcCall("netcatty/sftpRead", { ...scopeParams, sessionId, path, maxBytes: safeMaxBytes });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: result.content || "(empty file)" }] };
|
||||
},
|
||||
);
|
||||
// Tool: sftp_read_file
|
||||
server.tool(
|
||||
"sftp_read_file",
|
||||
"Read the content of a file on the remote host. Returns file content as text, truncated if the file is large.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the remote file to read."),
|
||||
maxBytes: z.number().optional().default(10000).describe("Maximum bytes to read. Defaults to 10000."),
|
||||
},
|
||||
async ({ sessionId, path, maxBytes }) => {
|
||||
const safeMaxBytes = Math.max(1, Math.min(10 * 1024 * 1024, Number(maxBytes) || 10000));
|
||||
const result = await rpcCall("netcatty/sftpRead", { ...scopeParams, sessionId, path, maxBytes: safeMaxBytes });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: result.content || "(empty file)" }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_write_file
|
||||
server.tool(
|
||||
"sftp_write_file",
|
||||
"Write content to a file on the remote host. Creates the file if it does not exist, or overwrites it.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the remote file to write."),
|
||||
content: z.string().describe("The text content to write to the file."),
|
||||
},
|
||||
async ({ sessionId, path, content }) => {
|
||||
const guardErr = guardWriteOperation(path);
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpWrite", { ...scopeParams, sessionId, path, content });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Written: ${path}` }] };
|
||||
},
|
||||
);
|
||||
// Tool: sftp_write_file
|
||||
server.tool(
|
||||
"sftp_write_file",
|
||||
"Write content to a file on the remote host. Creates the file if it does not exist, or overwrites it.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the remote file to write."),
|
||||
content: z.string().describe("The text content to write to the file."),
|
||||
},
|
||||
async ({ sessionId, path, content }) => {
|
||||
const guardErr = guardWriteOperation(path);
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpWrite", { ...scopeParams, sessionId, path, content });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Written: ${path}` }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_mkdir
|
||||
server.tool(
|
||||
"sftp_mkdir",
|
||||
"Create a directory on the remote host. Creates parent directories if they don't exist.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the directory to create."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const guardErr = guardWriteOperation();
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpMkdir", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Created directory: ${path}` }] };
|
||||
},
|
||||
);
|
||||
// Tool: sftp_mkdir
|
||||
server.tool(
|
||||
"sftp_mkdir",
|
||||
"Create a directory on the remote host. Creates parent directories if they don't exist.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the directory to create."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const guardErr = guardWriteOperation();
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpMkdir", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Created directory: ${path}` }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_remove
|
||||
server.tool(
|
||||
"sftp_remove",
|
||||
"Delete a file or directory on the remote host. Directories are removed recursively.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the file or directory to delete."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const guardErr = guardWriteOperation();
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpRemove", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Removed: ${path}` }] };
|
||||
},
|
||||
);
|
||||
// Tool: sftp_remove
|
||||
server.tool(
|
||||
"sftp_remove",
|
||||
"Delete a file or directory on the remote host. Directories are removed recursively.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the file or directory to delete."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const guardErr = guardWriteOperation();
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpRemove", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Removed: ${path}` }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_rename
|
||||
server.tool(
|
||||
"sftp_rename",
|
||||
"Rename or move a file/directory on the remote host.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
oldPath: z.string().describe("The current absolute path."),
|
||||
newPath: z.string().describe("The new absolute path."),
|
||||
},
|
||||
async ({ sessionId, oldPath, newPath }) => {
|
||||
const guardErr = guardWriteOperation();
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpRename", { ...scopeParams, sessionId, oldPath, newPath });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Renamed: ${oldPath} → ${newPath}` }] };
|
||||
},
|
||||
);
|
||||
// Tool: sftp_rename
|
||||
server.tool(
|
||||
"sftp_rename",
|
||||
"Rename or move a file/directory on the remote host.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
oldPath: z.string().describe("The current absolute path."),
|
||||
newPath: z.string().describe("The new absolute path."),
|
||||
},
|
||||
async ({ sessionId, oldPath, newPath }) => {
|
||||
const guardErr = guardWriteOperation();
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpRename", { ...scopeParams, sessionId, oldPath, newPath });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Renamed: ${oldPath} → ${newPath}` }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_stat
|
||||
server.tool(
|
||||
"sftp_stat",
|
||||
"Get file/directory metadata on the remote host: type, size, permissions, and modification time.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path to stat."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const result = await rpcCall("netcatty/sftpStat", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
||||
},
|
||||
);
|
||||
// Tool: sftp_stat
|
||||
server.tool(
|
||||
"sftp_stat",
|
||||
"Get file/directory metadata on the remote host: type, size, permissions, and modification time.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path to stat."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const result = await rpcCall("netcatty/sftpStat", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Tool: multi_host_execute
|
||||
server.tool(
|
||||
"multi_host_execute",
|
||||
"Execute a command on multiple remote hosts simultaneously or sequentially. Useful for fleet-wide operations like checking status, deploying updates, or maintenance.",
|
||||
"Execute a command on multiple Netcatty terminal sessions simultaneously or sequentially. Useful for fleet-wide operations, or to compare local and remote environments.",
|
||||
{
|
||||
sessionIds: z.array(z.string()).describe("Array of session IDs to execute on."),
|
||||
command: z.string().describe("The shell command to execute on each host."),
|
||||
|
||||
@@ -27,30 +27,75 @@ function cleanupTransferListeners(transferId) {
|
||||
transferCancelledListeners.delete(transferId);
|
||||
}
|
||||
|
||||
// Filter MCP marker artifacts from terminal output:
|
||||
// 1. Marker output lines (standalone): __NCMCP_xxx_S or __NCMCP_xxx_E:0
|
||||
// 2. End marker command echo: __nc=$?;printf '__NCMCP_...'
|
||||
// 3. Start marker printf prefix in echoed command: printf '__NCMCP_...\n';
|
||||
// We keep the actual command part visible.
|
||||
function filterMcpMarkers(data) {
|
||||
return data
|
||||
// Remove standalone marker output lines (printf output)
|
||||
.replace(/^__NCMCP_[^\r\n]*[\r\n]*/gm, "")
|
||||
// Remove end marker command echo lines
|
||||
.replace(/[^\r\n]*__nc=\$\?;printf '[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/g, "")
|
||||
// Remove start marker printf prefix from combined command lines
|
||||
.replace(/printf '__NCMCP_[^']*\\n';/g, "");
|
||||
// ── MCP marker filter with per-session line buffering ──
|
||||
// PTY data arrives in arbitrary chunks; the marker string (__NCMCP_) can be
|
||||
// split across chunk boundaries so a simple data.includes() guard misses it.
|
||||
// We buffer the trailing fragment of each chunk and prepend it to the next
|
||||
// chunk, then filter complete lines that contain the marker.
|
||||
|
||||
const _mcpLineBufs = new Map(); // sessionId -> trailing fragment string
|
||||
|
||||
// Returns true if `s` ends with a non-empty prefix of "__NCMCP_"
|
||||
// (i.e. the next chunk might complete it into a marker-containing line).
|
||||
function _endsWithMarkerPrefix(s) {
|
||||
const p = "__NCMCP_";
|
||||
for (let i = 1; i < p.length; i++) {
|
||||
if (s.endsWith(p.slice(0, i))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function filterMcpChunk(sessionId, chunk) {
|
||||
// Prepend any buffered fragment from the previous chunk
|
||||
const held = _mcpLineBufs.get(sessionId) || "";
|
||||
const data = held + chunk;
|
||||
_mcpLineBufs.delete(sessionId);
|
||||
|
||||
// Fast path: nothing suspicious in the combined data
|
||||
if (!data.includes("__NCMCP_") && !_endsWithMarkerPrefix(data)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Slow path: scan line by line
|
||||
let result = "";
|
||||
let pos = 0;
|
||||
while (pos < data.length) {
|
||||
const nlIdx = data.indexOf("\n", pos);
|
||||
if (nlIdx === -1) {
|
||||
// Incomplete trailing line — no newline yet
|
||||
const tail = data.slice(pos);
|
||||
if (tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
|
||||
// Hold it; next chunk might complete or confirm the marker
|
||||
_mcpLineBufs.set(sessionId, tail);
|
||||
} else {
|
||||
result += tail; // safe to display immediately
|
||||
}
|
||||
break;
|
||||
}
|
||||
const line = data.slice(pos, nlIdx + 1); // includes the \n
|
||||
if (!line.includes("__NCMCP_")) {
|
||||
result += line;
|
||||
}
|
||||
// else: drop it — it's a wrapper marker line (or echo of one)
|
||||
pos = nlIdx + 1;
|
||||
}
|
||||
|
||||
// Also strip Posix pager prefix and Fish env lines that have no __NCMCP_
|
||||
if (result) {
|
||||
result = result
|
||||
.replace(/PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= /g, "")
|
||||
.replace(/^set -gx (?:PAGER|SYSTEMD_PAGER|GIT_PAGER|LESS) [^\r\n]*[\r\n]*/gm, "")
|
||||
.replace(/^set "(?:PAGER|SYSTEMD_PAGER|GIT_PAGER|LESS)=[^"]*"[\r\n]*/gm, "");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
// Filter MCP marker artifacts before they reach xterm.js
|
||||
let data = payload.data;
|
||||
if (data.includes("__NCMCP_")) {
|
||||
data = filterMcpMarkers(data);
|
||||
if (!data) return;
|
||||
}
|
||||
const data = filterMcpChunk(payload.sessionId, payload.data);
|
||||
if (!data) return;
|
||||
set.forEach((cb) => {
|
||||
try {
|
||||
cb(data);
|
||||
@@ -73,6 +118,7 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
||||
}
|
||||
dataListeners.delete(payload.sessionId);
|
||||
exitListeners.delete(payload.sessionId);
|
||||
_mcpLineBufs.delete(payload.sessionId); // clean up any held fragment
|
||||
});
|
||||
|
||||
// Chain progress events (for jump host connections)
|
||||
@@ -827,6 +873,8 @@ const api = {
|
||||
// Save dialog for file downloads
|
||||
showSaveDialog: (defaultPath, filters) =>
|
||||
ipcRenderer.invoke("netcatty:showSaveDialog", { defaultPath, filters }),
|
||||
selectDirectory: (title, defaultPath) =>
|
||||
ipcRenderer.invoke("netcatty:selectDirectory", { title, defaultPath }),
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch: (localPath, remotePath, sftpId, encoding) =>
|
||||
@@ -1064,6 +1112,21 @@ const api = {
|
||||
aiMcpSetPermissionMode: async (mode) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:set-permission-mode", { mode });
|
||||
},
|
||||
// MCP approval gate: renderer receives approval requests from main process
|
||||
onMcpApprovalRequest: (cb) => {
|
||||
const handler = (_event, payload) => cb(payload);
|
||||
ipcRenderer.on("netcatty:ai:mcp:approval-request", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-request", handler);
|
||||
},
|
||||
respondMcpApproval: async (approvalId, approved) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:approval-response", { approvalId, approved });
|
||||
},
|
||||
// MCP approval cleared: main process timed out or cancelled an approval
|
||||
onMcpApprovalCleared: (cb) => {
|
||||
const handler = (_event, payload) => cb(payload);
|
||||
ipcRenderer.on("netcatty:ai:mcp:approval-cleared", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-cleared", handler);
|
||||
},
|
||||
// ACP streaming
|
||||
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images });
|
||||
|
||||
18
global.d.ts
vendored
18
global.d.ts
vendored
@@ -1,6 +1,11 @@
|
||||
import type { RemoteFile, SftpFilenameEncoding } from "./types";
|
||||
import type { S3Config, SMBConfig, SyncedFile, WebDAVConfig } from "./domain/sync";
|
||||
|
||||
declare module "*.cjs" {
|
||||
const value: Record<string, unknown>;
|
||||
export = value;
|
||||
}
|
||||
|
||||
declare global {
|
||||
// Extend HTMLInputElement to support webkitdirectory attribute
|
||||
namespace JSX {
|
||||
@@ -47,6 +52,7 @@ declare global {
|
||||
|
||||
interface NetcattySSHOptions {
|
||||
sessionId?: string;
|
||||
hostLabel?: string;
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
@@ -571,6 +577,7 @@ declare global {
|
||||
|
||||
// Save dialog for file downloads
|
||||
showSaveDialog?(defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>): Promise<string | null>;
|
||||
selectDirectory?(title?: string, defaultPath?: string): Promise<string | null>;
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch?(localPath: string, remotePath: string, sftpId: string, encoding?: SftpFilenameEncoding): Promise<{ watchId: string }>;
|
||||
@@ -689,7 +696,16 @@ declare global {
|
||||
logoutOutput?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
aiMcpUpdateSessions?(sessions: Array<{ sessionId: string; hostname: string; label: string; os?: string; username?: string; connected: boolean }>): Promise<{ ok: boolean }>;
|
||||
aiMcpUpdateSessions?(sessions: Array<{
|
||||
sessionId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
connected: boolean;
|
||||
}>, chatSessionId?: string): Promise<{ ok: boolean }>;
|
||||
aiSpawnAgent?(agentId: string, command: string, args?: string[], env?: Record<string, string>, options?: { closeStdin?: boolean }): Promise<{ ok: boolean; pid?: number; error?: string }>;
|
||||
aiWriteToAgent?(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiCloseAgentStdin?(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface ExecutorContext {
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
// Workspace info
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface SystemPromptContext {
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
permissionMode: 'observer' | 'confirm' | 'autonomous';
|
||||
@@ -20,13 +22,13 @@ export function buildSystemPrompt(context: SystemPromptContext): string {
|
||||
const hostList = buildHostList(hosts);
|
||||
const permissionRules = buildPermissionRules(permissionMode);
|
||||
|
||||
return `You are **Catty Agent**, a terminal automation assistant built into netcatty. You help users manage remote servers by executing commands, reading files, and performing batch operations across multiple hosts.
|
||||
return `You are **Catty Agent**, a terminal automation assistant built into netcatty. You help users operate terminal sessions managed by Netcatty, including remote hosts and the user's local terminal.
|
||||
|
||||
## Current Scope
|
||||
|
||||
${scopeDescription}
|
||||
|
||||
## Available Hosts
|
||||
## Available Sessions
|
||||
|
||||
${hostList}
|
||||
|
||||
@@ -36,21 +38,21 @@ ${permissionRules}
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. **Plan before acting.** When a task involves multiple steps, present a brief numbered plan to the user before executing. Wait for acknowledgment on complex or risky operations.
|
||||
1. **Plan before acting.** When a task involves multiple steps, present a brief numbered plan to the user before executing.
|
||||
|
||||
2. **Use the right tool.** For normal shell commands, use \`terminal_execute\` so you receive the command output. When operating on multiple hosts, call \`terminal_execute\` for each host.
|
||||
2. **Use the right tool.** For normal shell commands, use \`terminal_execute\` so you receive the command output. When operating on multiple sessions, call \`terminal_execute\` for each target session.
|
||||
|
||||
3. **Never execute dangerous commands.** Commands matching the blocklist (e.g. \`rm -rf /\`, \`mkfs\`, \`dd\` to disk devices, \`shutdown\`, fork bombs, recursive chmod 777 on root) are strictly forbidden and will be automatically denied. Do not attempt to bypass these restrictions.
|
||||
|
||||
4. **Explain before executing.** Before running any command, briefly explain what it does and why. This is especially important for commands that modify the system.
|
||||
4. **Explain before executing.** Before running any command, briefly explain what it does and why.
|
||||
|
||||
5. **Handle errors gracefully.** If a command fails, analyze the error output, explain what went wrong, and suggest alternatives or corrective actions. Do not retry the same failing command without modification.
|
||||
|
||||
6. **Stay focused.** Keep responses concise and relevant to terminal and server operations. Avoid unrelated commentary.
|
||||
|
||||
7. **Respect connection status.** Only attempt operations on hosts that are currently connected. If a host is disconnected, inform the user and suggest reconnecting.
|
||||
7. **Respect connection status.** Only attempt operations on sessions that are currently connected. If a session is disconnected, inform the user and suggest reconnecting or reopening it.
|
||||
|
||||
8. **Be careful with file operations.** When writing files via shell commands, confirm the target path with the user if there is any ambiguity. Always prefer appending or targeted edits over full file overwrites when possible.
|
||||
8. **Be careful with file operations.** When writing files via shell commands, prefer appending or targeted edits over full file overwrites when possible.
|
||||
|
||||
9. **Fetch URLs when provided.** When the user shares a URL or asks you to read a webpage, use \`url_fetch\` to retrieve its content.${webSearchEnabled ? `
|
||||
|
||||
@@ -63,11 +65,11 @@ function buildScopeDescription(
|
||||
): string {
|
||||
switch (scopeType) {
|
||||
case 'terminal':
|
||||
return `You are scoped to a single terminal session${scopeLabel ? `: **${scopeLabel}**` : ''}. Focus operations on this specific host.`;
|
||||
return `You are scoped to a single terminal session${scopeLabel ? `: **${scopeLabel}**` : ''}. Focus operations on this specific session.`;
|
||||
case 'workspace':
|
||||
return `You are scoped to workspace${scopeLabel ? ` **${scopeLabel}**` : ''}. You can operate on any host within this workspace.`;
|
||||
return `You are scoped to workspace${scopeLabel ? ` **${scopeLabel}**` : ''}. You can operate on any session within this workspace.`;
|
||||
case 'global':
|
||||
return `You have global scope and can operate on any connected host across all workspaces.`;
|
||||
return `You have global scope and can operate on any connected session across all workspaces.`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +77,7 @@ function buildHostList(
|
||||
hosts: SystemPromptContext['hosts'],
|
||||
): string {
|
||||
if (hosts.length === 0) {
|
||||
return '_No hosts are currently available. The user needs to connect to a host first._';
|
||||
return '_No terminal sessions are currently available. The user needs to open or connect a terminal first._';
|
||||
}
|
||||
|
||||
const lines = hosts.map(host => {
|
||||
@@ -83,8 +85,10 @@ function buildHostList(
|
||||
const details = [
|
||||
`hostname: ${host.hostname}`,
|
||||
`label: ${host.label}`,
|
||||
host.protocol ? `protocol: ${host.protocol}` : null,
|
||||
host.os ? `os: ${host.os}` : null,
|
||||
host.username ? `user: ${host.username}` : null,
|
||||
host.shellType ? `shell: ${host.shellType}` : null,
|
||||
`status: ${status}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -112,10 +116,10 @@ function buildPermissionRules(
|
||||
|
||||
case 'confirm':
|
||||
return [
|
||||
'You are in **confirm** mode. Every write or execute operation requires explicit user approval before it runs:',
|
||||
'- Command execution (`terminal_execute`)',
|
||||
'You are in **confirm** mode. The system will automatically show an approval prompt to the user for write and execute operations:',
|
||||
'- Command execution (`terminal_execute`) will pause and show approval buttons in the UI automatically.',
|
||||
'',
|
||||
'Read-only operations are allowed without confirmation. When proposing a command, clearly state what it will do so the user can make an informed decision.',
|
||||
'You do NOT need to ask the user for confirmation in your text responses. Just call the tool directly — the approval system handles it. Read-only operations are allowed without any approval.',
|
||||
].join('\n');
|
||||
|
||||
case 'autonomous':
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ToolDeps,
|
||||
type ToolExecResult,
|
||||
} from '../shared/toolExecutors';
|
||||
import { requestApproval } from '../shared/approvalGate';
|
||||
|
||||
/** Unwrap a shared ToolExecResult into the shape expected by Vercel AI SDK tool results. */
|
||||
function unwrap<T>(r: ToolExecResult<T>): T | { error: string } {
|
||||
@@ -34,28 +35,35 @@ export function createCattyTools(
|
||||
commandBlocklist?: string[],
|
||||
permissionMode: AIPermissionMode = 'confirm',
|
||||
webSearchConfig?: WebSearchConfig,
|
||||
chatSessionId?: string,
|
||||
) {
|
||||
const writeToolNeedsApproval = permissionMode === 'confirm';
|
||||
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig };
|
||||
|
||||
return {
|
||||
terminal_execute: tool({
|
||||
description:
|
||||
'Execute a shell command on a remote host via the specified terminal session. ' +
|
||||
'Execute a shell command on the specified terminal session. ' +
|
||||
"The command runs in the session's shell and output is returned when complete.",
|
||||
inputSchema: z.object({
|
||||
sessionId: z.string().describe('The terminal session ID to execute the command on.'),
|
||||
command: z.string().describe('The shell command to execute on the remote host.'),
|
||||
command: z.string().describe('The shell command to execute in the target session.'),
|
||||
}),
|
||||
needsApproval: writeToolNeedsApproval,
|
||||
execute: async ({ sessionId, command }) => {
|
||||
// No needsApproval — approval is handled inside execute via the approval gate.
|
||||
execute: async ({ sessionId, command }, { toolCallId }) => {
|
||||
// In confirm mode, await user approval before executing
|
||||
if (permissionMode === 'confirm') {
|
||||
const approved = await requestApproval(toolCallId, 'terminal_execute', { sessionId, command }, chatSessionId);
|
||||
if (!approved) {
|
||||
return { error: 'User denied command execution.' };
|
||||
}
|
||||
}
|
||||
return unwrap(await executeTerminalExecute(deps, { sessionId, command }));
|
||||
},
|
||||
}),
|
||||
|
||||
workspace_get_info: tool({
|
||||
description:
|
||||
'Get information about the current workspace, including all configured hosts ' +
|
||||
'Get information about the current workspace, including all terminal sessions ' +
|
||||
'and their connection status. No parameters required.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
@@ -66,7 +74,7 @@ export function createCattyTools(
|
||||
workspace_get_session_info: tool({
|
||||
description:
|
||||
'Get detailed information about a specific terminal or SFTP session, including ' +
|
||||
'the host it is connected to, connection status, and session metadata.',
|
||||
'its connection status, protocol, shell hints, and session metadata.',
|
||||
inputSchema: z.object({
|
||||
sessionId: z.string().describe('The session ID to get information about.'),
|
||||
}),
|
||||
|
||||
260
infrastructure/ai/shared/approvalGate.ts
Normal file
260
infrastructure/ai/shared/approvalGate.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* approvalGate — Promise-based approval system for tool execution.
|
||||
*
|
||||
* Tools call `requestApproval()` inside their `execute` function. This returns
|
||||
* a Promise that resolves when the user approves/rejects from the UI, or after
|
||||
* a timeout (default 5 minutes) to prevent indefinite hangs.
|
||||
*
|
||||
* Also supports MCP/ACP tool calls from the Electron main process:
|
||||
* the main process sends an IPC approval request, and we route it
|
||||
* through the same listener/UI system. MCP approvals are stored in
|
||||
* the same pendingApprovals map so they survive ChatMessageList
|
||||
* unmount/remount cycles via replayPendingApprovals().
|
||||
*
|
||||
* Approvals are scoped by optional chatSessionId to prevent cross-session
|
||||
* interference when stopping or cancelling sessions.
|
||||
*/
|
||||
|
||||
/** Default timeout for unanswered approval prompts (5 minutes). */
|
||||
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface ApprovalRequest {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
/** Optional chat session scope — used to clear only relevant approvals on stop */
|
||||
chatSessionId?: string;
|
||||
}
|
||||
|
||||
// Pending approval entries keyed by toolCallId.
|
||||
// SDK approvals have a real `resolve` callback; MCP approvals use a no-op
|
||||
// (the real resolution goes via IPC in resolveApproval).
|
||||
const pendingApprovals = new Map<string, {
|
||||
resolve: (approved: boolean) => void;
|
||||
request: ApprovalRequest;
|
||||
}>();
|
||||
|
||||
// Subscribers for approval request events (UI listens here)
|
||||
type ApprovalRequestListener = (request: ApprovalRequest) => void;
|
||||
const listeners = new Set<ApprovalRequestListener>();
|
||||
|
||||
// Subscribers for approval cleared/removed events (UI listens to clean up cards)
|
||||
type ApprovalClearedListener = (toolCallIds: string[]) => void;
|
||||
const clearedListeners = new Set<ApprovalClearedListener>();
|
||||
|
||||
/**
|
||||
* Called from a tool's `execute` function when it needs user approval.
|
||||
* Returns a Promise<boolean> that resolves to `true` (approved) or `false` (denied).
|
||||
* The UI is notified via the listener system to render approval buttons.
|
||||
*
|
||||
* If the user does not respond within `timeoutMs` (default 5 minutes), the
|
||||
* approval is auto-denied to prevent the session from hanging indefinitely.
|
||||
*/
|
||||
export function requestApproval(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
chatSessionId?: string,
|
||||
timeoutMs: number = APPROVAL_TIMEOUT_MS,
|
||||
): Promise<boolean> {
|
||||
const request: ApprovalRequest = { toolCallId, toolName, args, chatSessionId };
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const wrappedResolve = (approved: boolean) => {
|
||||
if (timerId) { clearTimeout(timerId); timerId = null; }
|
||||
resolve(approved);
|
||||
};
|
||||
|
||||
pendingApprovals.set(toolCallId, { resolve: wrappedResolve, request });
|
||||
|
||||
// Auto-deny after timeout so the session doesn't hang indefinitely
|
||||
timerId = setTimeout(() => {
|
||||
if (pendingApprovals.has(toolCallId)) {
|
||||
pendingApprovals.delete(toolCallId);
|
||||
wrappedResolve(false);
|
||||
// Notify UI to remove the stale card
|
||||
for (const cl of clearedListeners) {
|
||||
try { cl([toolCallId]); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
// Notify all UI listeners
|
||||
for (const listener of listeners) {
|
||||
try { listener(request); } catch { /* ignore listener errors */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the UI when the user approves or rejects a tool execution.
|
||||
* Handles both SDK tool calls (local Promise) and MCP tool calls (IPC to main process).
|
||||
*/
|
||||
export function resolveApproval(toolCallId: string, approved: boolean): void {
|
||||
const entry = pendingApprovals.get(toolCallId);
|
||||
if (entry) {
|
||||
pendingApprovals.delete(toolCallId);
|
||||
// SDK tool calls have a real resolve; MCP tool calls have a no-op resolve
|
||||
entry.resolve(approved);
|
||||
}
|
||||
|
||||
// MCP tool call: also forward response to main process via IPC
|
||||
if (toolCallId.startsWith('mcp_approval_')) {
|
||||
const bridge = (window as unknown as { netcatty?: { respondMcpApproval?: (id: string, approved: boolean) => Promise<unknown> } }).netcatty;
|
||||
bridge?.respondMcpApproval?.(toolCallId, approved);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to approval request events. Returns an unsubscribe function.
|
||||
*/
|
||||
export function onApprovalRequest(listener: ApprovalRequestListener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to approval cleared/removed events. Returns an unsubscribe function.
|
||||
* Fired when approvals are cleared (e.g. on session stop) or timed out,
|
||||
* so the UI can remove stale approval cards.
|
||||
*/
|
||||
export function onApprovalCleared(listener: ApprovalClearedListener): () => void {
|
||||
clearedListeners.add(listener);
|
||||
return () => { clearedListeners.delete(listener); };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay all currently pending approval requests to a listener.
|
||||
* Useful when ChatMessageList remounts after being unmounted — without this,
|
||||
* approvals that fired while unmounted would be silently missed and the
|
||||
* corresponding execute Promises would hang indefinitely.
|
||||
*
|
||||
* This covers both SDK and MCP approvals since both are stored in the same map.
|
||||
*/
|
||||
export function replayPendingApprovals(listener: ApprovalRequestListener): void {
|
||||
for (const [, entry] of pendingApprovals) {
|
||||
try { listener(entry.request); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific toolCallId has a pending approval.
|
||||
*/
|
||||
export function hasPendingApproval(toolCallId: string): boolean {
|
||||
return pendingApprovals.has(toolCallId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pending approvals, optionally scoped to a specific chatSessionId.
|
||||
* Resolves matching entries with `false` (denied) so execute functions don't hang.
|
||||
* Also notifies cleared-listeners so the UI can remove stale approval cards.
|
||||
*
|
||||
* When chatSessionId is provided, only approvals belonging to that session
|
||||
* are cleared — preventing cross-session interference in concurrent chats.
|
||||
* When omitted, all pending approvals are cleared (backward-compatible).
|
||||
*/
|
||||
export function clearAllPendingApprovals(chatSessionId?: string): void {
|
||||
const clearedIds: string[] = [];
|
||||
|
||||
if (!chatSessionId) {
|
||||
// Clear everything (legacy / global stop)
|
||||
for (const [id, entry] of pendingApprovals) {
|
||||
entry.resolve(false);
|
||||
clearedIds.push(id);
|
||||
}
|
||||
pendingApprovals.clear();
|
||||
} else {
|
||||
// Scoped clear: only remove approvals for this chatSessionId
|
||||
for (const [id, entry] of pendingApprovals) {
|
||||
if (entry.request.chatSessionId === chatSessionId) {
|
||||
pendingApprovals.delete(id);
|
||||
entry.resolve(false);
|
||||
clearedIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notify UI listeners to remove the cards
|
||||
if (clearedIds.length > 0) {
|
||||
for (const cl of clearedListeners) {
|
||||
try { cl(clearedIds); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a bridge to receive MCP/ACP approval requests from the Electron main process.
|
||||
* Subscribes to IPC events and stores them in the same pendingApprovals map,
|
||||
* so the same ToolCall UI handles both SDK and MCP approvals, and approvals
|
||||
* survive ChatMessageList unmount/remount cycles via replayPendingApprovals().
|
||||
*
|
||||
* IMPORTANT: Call this from a component that stays mounted for the lifetime of
|
||||
* the AI panel (e.g. AIChatSidePanel), NOT from ChatMessageList which unmounts
|
||||
* on tab switches.
|
||||
*
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
export function setupMcpApprovalBridge(): () => void {
|
||||
const bridge = (window as unknown as {
|
||||
netcatty?: {
|
||||
onMcpApprovalRequest?: (cb: (payload: {
|
||||
approvalId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
chatSessionId?: string;
|
||||
}) => void) => () => void;
|
||||
onMcpApprovalCleared?: (cb: (payload: {
|
||||
approvalIds: string[];
|
||||
}) => void) => () => void;
|
||||
};
|
||||
}).netcatty;
|
||||
if (!bridge?.onMcpApprovalRequest) return () => {};
|
||||
|
||||
const unsubRequest = bridge.onMcpApprovalRequest((payload) => {
|
||||
const request: ApprovalRequest = {
|
||||
toolCallId: payload.approvalId,
|
||||
toolName: payload.toolName,
|
||||
args: payload.args,
|
||||
chatSessionId: payload.chatSessionId,
|
||||
};
|
||||
|
||||
// Store in pendingApprovals so it survives unmount/remount
|
||||
// The resolve is a no-op because MCP approval resolution goes through IPC
|
||||
// (handled in resolveApproval when toolCallId starts with 'mcp_approval_')
|
||||
if (!pendingApprovals.has(payload.approvalId)) {
|
||||
pendingApprovals.set(payload.approvalId, {
|
||||
resolve: () => {}, // no-op; real resolution is via IPC
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
// Notify all UI listeners
|
||||
for (const listener of listeners) {
|
||||
try { listener(request); } catch { /* ignore listener errors */ }
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to main-process approval cleared events (timeout, cancel)
|
||||
// so stale approval cards are removed from the renderer UI.
|
||||
const unsubCleared = bridge.onMcpApprovalCleared?.((payload) => {
|
||||
const clearedIds: string[] = [];
|
||||
for (const id of payload.approvalIds) {
|
||||
if (pendingApprovals.has(id)) {
|
||||
pendingApprovals.delete(id);
|
||||
clearedIds.push(id);
|
||||
}
|
||||
}
|
||||
if (clearedIds.length > 0) {
|
||||
for (const cl of clearedListeners) {
|
||||
try { cl(clearedIds); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubRequest();
|
||||
unsubCleared?.();
|
||||
};
|
||||
}
|
||||
@@ -112,6 +112,8 @@ export function executeWorkspaceGetInfo(
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
}> {
|
||||
@@ -127,6 +129,8 @@ export function executeWorkspaceGetInfo(
|
||||
label: s.label,
|
||||
os: s.os,
|
||||
username: s.username,
|
||||
protocol: s.protocol,
|
||||
shellType: s.shellType,
|
||||
connected: s.connected,
|
||||
})),
|
||||
},
|
||||
|
||||
@@ -150,7 +150,7 @@ export interface ExternalAgentConfig {
|
||||
env?: Record<string, string>;
|
||||
icon?: string;
|
||||
enabled: boolean;
|
||||
/** ACP command (e.g. 'codex-acp', 'claude-code-acp', 'gemini --experimental-acp') */
|
||||
/** ACP command (e.g. 'codex-acp', 'claude-agent-acp', 'gemini --experimental-acp') */
|
||||
acpCommand?: string;
|
||||
acpArgs?: string[];
|
||||
}
|
||||
|
||||
38
lib/localShell.cjs
Normal file
38
lib/localShell.cjs
Normal file
@@ -0,0 +1,38 @@
|
||||
"use strict";
|
||||
|
||||
const POWERSHELL_SHELLS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
|
||||
const CMD_SHELLS = new Set(["cmd", "cmd.exe"]);
|
||||
const FISH_SHELLS = new Set(["fish"]);
|
||||
const POSIX_SHELLS = new Set(["sh", "bash", "zsh", "ksh", "dash", "ash"]);
|
||||
|
||||
function getExecutableBaseName(filePath) {
|
||||
const normalized = String(filePath || "").trim();
|
||||
if (!normalized) return "";
|
||||
const parts = normalized.split(/[\\/]/);
|
||||
return (parts[parts.length - 1] || "").toLowerCase();
|
||||
}
|
||||
|
||||
function detectLocalOs(platformLike) {
|
||||
const platform = String(platformLike || "").toLowerCase();
|
||||
if (platform.includes("mac")) return "macos";
|
||||
if (platform.includes("win")) return "windows";
|
||||
if (platform.includes("darwin")) return "macos";
|
||||
return "linux";
|
||||
}
|
||||
|
||||
function classifyLocalShellType(shellPath, platformLike) {
|
||||
const shellName = getExecutableBaseName(shellPath);
|
||||
if (POWERSHELL_SHELLS.has(shellName)) return "powershell";
|
||||
if (CMD_SHELLS.has(shellName)) return "cmd";
|
||||
if (FISH_SHELLS.has(shellName)) return "fish";
|
||||
if (POSIX_SHELLS.has(shellName)) return "posix";
|
||||
if (!shellName) {
|
||||
return detectLocalOs(platformLike) === "windows" ? "powershell" : "posix";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyLocalShellType,
|
||||
detectLocalOs,
|
||||
};
|
||||
36
lib/localShell.ts
Normal file
36
lib/localShell.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type LocalShellType = 'posix' | 'fish' | 'powershell' | 'cmd' | 'unknown';
|
||||
export type LocalOs = 'linux' | 'macos' | 'windows';
|
||||
|
||||
const POWERSHELL_SHELLS = new Set(['powershell', 'powershell.exe', 'pwsh', 'pwsh.exe']);
|
||||
const CMD_SHELLS = new Set(['cmd', 'cmd.exe']);
|
||||
const FISH_SHELLS = new Set(['fish']);
|
||||
const POSIX_SHELLS = new Set(['sh', 'bash', 'zsh', 'ksh', 'dash', 'ash']);
|
||||
|
||||
const getExecutableBaseName = (filePath: string | undefined): string => {
|
||||
const normalized = String(filePath || '').trim();
|
||||
if (!normalized) return '';
|
||||
const parts = normalized.split(/[\\/]/);
|
||||
return (parts[parts.length - 1] || '').toLowerCase();
|
||||
};
|
||||
|
||||
export const detectLocalOs = (platformLike?: string): LocalOs => {
|
||||
const platform = String(platformLike || '').toLowerCase();
|
||||
if (platform.includes('mac') || platform.includes('darwin')) return 'macos';
|
||||
if (platform.includes('win')) return 'windows';
|
||||
return 'linux';
|
||||
};
|
||||
|
||||
export const classifyLocalShellType = (
|
||||
shellPath: string | undefined,
|
||||
platformLike?: string,
|
||||
): LocalShellType => {
|
||||
const shellName = getExecutableBaseName(shellPath);
|
||||
if (POWERSHELL_SHELLS.has(shellName)) return 'powershell';
|
||||
if (CMD_SHELLS.has(shellName)) return 'cmd';
|
||||
if (FISH_SHELLS.has(shellName)) return 'fish';
|
||||
if (POSIX_SHELLS.has(shellName)) return 'posix';
|
||||
if (!shellName) {
|
||||
return detectLocalOs(platformLike) === 'windows' ? 'powershell' : 'posix';
|
||||
}
|
||||
return 'unknown';
|
||||
};
|
||||
@@ -33,4 +33,4 @@ export function isMacPlatform(): boolean {
|
||||
return /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
351
package-lock.json
generated
351
package-lock.json
generated
@@ -37,6 +37,7 @@
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@zed-industries/claude-agent-acp": "0.22.2",
|
||||
"@zed-industries/codex-acp": "0.10.0",
|
||||
"ai": "^6.0.116",
|
||||
"clsx": "2.1.1",
|
||||
@@ -194,6 +195,29 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.2.76",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.76.tgz",
|
||||
"integrity": "sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "^0.34.2",
|
||||
"@img/sharp-darwin-x64": "^0.34.2",
|
||||
"@img/sharp-linux-arm": "^0.34.2",
|
||||
"@img/sharp-linux-arm64": "^0.34.2",
|
||||
"@img/sharp-linux-x64": "^0.34.2",
|
||||
"@img/sharp-linuxmusl-arm64": "^0.34.2",
|
||||
"@img/sharp-linuxmusl-x64": "^0.34.2",
|
||||
"@img/sharp-win32-arm64": "^0.34.2",
|
||||
"@img/sharp-win32-x64": "^0.34.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/crc32": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
|
||||
@@ -2657,6 +2681,310 @@
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
@@ -6347,6 +6675,29 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@zed-industries/claude-agent-acp": {
|
||||
"version": "0.22.2",
|
||||
"resolved": "https://registry.npmjs.org/@zed-industries/claude-agent-acp/-/claude-agent-acp-0.22.2.tgz",
|
||||
"integrity": "sha512-GLiKxy5MBNS9UoiE1XaM9EHVxlEcvk0sXSMCnyDp9JNAQliynt0axZrhptTl5AWe6PXGjVh5hMFdPp+yulw2uQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.76",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"claude-agent-acp": "dist/index.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@zed-industries/claude-agent-acp/node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.16.1.tgz",
|
||||
"integrity": "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zed-industries/codex-acp": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.10.0.tgz",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@zed-industries/claude-agent-acp": "0.22.2",
|
||||
"@zed-industries/codex-acp": "0.10.0",
|
||||
"ai": "^6.0.116",
|
||||
"clsx": "2.1.1",
|
||||
|
||||
BIN
screenshots/ai-feature.png
Normal file
BIN
screenshots/ai-feature.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
screenshots/gifs/ai-feature-muti-hosts.mp4
Normal file
BIN
screenshots/gifs/ai-feature-muti-hosts.mp4
Normal file
Binary file not shown.
BIN
screenshots/gifs/ai-feature.mp4
Normal file
BIN
screenshots/gifs/ai-feature.mp4
Normal file
Binary file not shown.
141
scripts/ensure-node-pty-linux.sh
Normal file
141
scripts/ensure-node-pty-linux.sh
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 <prepare|verify> <x64|arm64>" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
checksum() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$@"
|
||||
else
|
||||
shasum -a 256 "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
electron_bin() {
|
||||
echo "./node_modules/.bin/electron"
|
||||
}
|
||||
|
||||
log_file_info() {
|
||||
local file="$1"
|
||||
echo "[node-pty] file: ${file}"
|
||||
ls -lh "${file}"
|
||||
checksum "${file}"
|
||||
}
|
||||
|
||||
log_optional_spawn_helper() {
|
||||
local file="$1"
|
||||
|
||||
if [[ -f "${file}" ]]; then
|
||||
test -x "${file}"
|
||||
log_file_info "${file}"
|
||||
else
|
||||
echo "[node-pty] spawn-helper not present at ${file} (expected on Linux)"
|
||||
fi
|
||||
}
|
||||
|
||||
log_electron_runtime_info() {
|
||||
ELECTRON_RUN_AS_NODE=1 "$(electron_bin)" -e '
|
||||
console.log(`[node-pty] electron=${process.versions.electron || "unknown"} node=${process.versions.node} modules=${process.versions.modules}`);
|
||||
'
|
||||
}
|
||||
|
||||
assert_loadable_native_module() {
|
||||
local file="$1"
|
||||
echo "[node-pty] loading native module with Electron runtime: ${file}"
|
||||
ELECTRON_RUN_AS_NODE=1 "$(electron_bin)" -e '
|
||||
const path = require("node:path");
|
||||
require(path.resolve(process.argv[1]));
|
||||
console.log("[node-pty] native module loaded successfully");
|
||||
' "${file}"
|
||||
}
|
||||
|
||||
prepare() {
|
||||
local arch="$1"
|
||||
local root="node_modules/node-pty"
|
||||
local release_dir="${root}/build/Release"
|
||||
local prebuild_dir="${root}/prebuilds/linux-${arch}"
|
||||
|
||||
echo "[node-pty] rebuilding native modules for Electron on linux-${arch}"
|
||||
log_electron_runtime_info
|
||||
npx electron-rebuild
|
||||
|
||||
test -f "${release_dir}/pty.node"
|
||||
|
||||
echo "[node-pty] built Linux runtime artifacts:"
|
||||
log_file_info "${release_dir}/pty.node"
|
||||
log_optional_spawn_helper "${release_dir}/spawn-helper"
|
||||
assert_loadable_native_module "${release_dir}/pty.node"
|
||||
|
||||
mkdir -p "${prebuild_dir}"
|
||||
cp "${release_dir}/pty.node" "${prebuild_dir}/pty.node"
|
||||
if [[ -f "${release_dir}/spawn-helper" ]]; then
|
||||
cp "${release_dir}/spawn-helper" "${prebuild_dir}/spawn-helper"
|
||||
fi
|
||||
|
||||
echo "[node-pty] mirrored Linux runtime artifacts into ${prebuild_dir}:"
|
||||
log_file_info "${prebuild_dir}/pty.node"
|
||||
log_optional_spawn_helper "${prebuild_dir}/spawn-helper"
|
||||
}
|
||||
|
||||
verify() {
|
||||
local arch="$1"
|
||||
local release_dir
|
||||
local prebuild_dir
|
||||
|
||||
log_electron_runtime_info
|
||||
|
||||
release_dir="$(find release -type d -path "*/resources/app.asar.unpacked/node_modules/node-pty/build/Release" -print -quit)"
|
||||
prebuild_dir="$(find release -type d -path "*/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/linux-${arch}" -print -quit)"
|
||||
|
||||
if [[ -z "${release_dir}" ]]; then
|
||||
echo "[node-pty] packaged build/Release directory not found under release/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${prebuild_dir}" ]]; then
|
||||
echo "[node-pty] packaged prebuild directory not found for linux-${arch} under release/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "${release_dir}/pty.node"
|
||||
test -f "${prebuild_dir}/pty.node"
|
||||
|
||||
echo "[node-pty] packaged build/Release artifacts:"
|
||||
log_file_info "${release_dir}/pty.node"
|
||||
log_optional_spawn_helper "${release_dir}/spawn-helper"
|
||||
assert_loadable_native_module "${release_dir}/pty.node"
|
||||
|
||||
echo "[node-pty] packaged prebuild artifacts:"
|
||||
log_file_info "${prebuild_dir}/pty.node"
|
||||
log_optional_spawn_helper "${prebuild_dir}/spawn-helper"
|
||||
assert_loadable_native_module "${prebuild_dir}/pty.node"
|
||||
|
||||
echo "[node-pty] packaged artifact locations:"
|
||||
find release -path "*/resources/app.asar.unpacked/node_modules/node-pty/*" \
|
||||
\( -name 'pty.node' -o -name 'spawn-helper' \) \
|
||||
-print | sort
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ $# -ne 2 ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
prepare)
|
||||
prepare "$2"
|
||||
;;
|
||||
verify)
|
||||
verify "$2"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user