Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
986f552779 | ||
|
|
42647e3572 | ||
|
|
5930d1601a | ||
|
|
df3d507e2b | ||
|
|
f8c7a9081b | ||
|
|
d8cfb0f1d9 | ||
|
|
269d790f28 | ||
|
|
0f12eab680 | ||
|
|
139fa43c43 | ||
|
|
eb30e6580e | ||
|
|
104a0c73d2 | ||
|
|
fc16739e99 | ||
|
|
dd386f218f | ||
|
|
254558771c | ||
|
|
9c9d01f372 | ||
|
|
a75b981630 | ||
|
|
2b706b7b4e | ||
|
|
8276f63c65 | ||
|
|
cac621413c | ||
|
|
897ddaddbf | ||
|
|
d51c0f526c | ||
|
|
7acd9b3b8d | ||
|
|
05345d1ac7 | ||
|
|
1f1ec8f7a6 | ||
|
|
8abba4bc7d | ||
|
|
ccf707df5a | ||
|
|
48d7a63d2e | ||
|
|
ad7f523ec2 | ||
|
|
a905b3e092 | ||
|
|
23148e88b1 | ||
|
|
23c6c55968 | ||
|
|
a53264013c | ||
|
|
7f58e039a2 | ||
|
|
4999a6884b | ||
|
|
eb8b565a77 | ||
|
|
cf103d7421 | ||
|
|
88b8cfb4da | ||
|
|
24f7a5a805 | ||
|
|
37d289be50 | ||
|
|
74f99e65d9 | ||
|
|
937608e7f3 | ||
|
|
3e1b72b869 | ||
|
|
9d04ae86f4 | ||
|
|
7beb9c1444 | ||
|
|
dd2f23b672 | ||
|
|
eac1007764 | ||
|
|
62625214a0 | ||
|
|
a6ae160932 | ||
|
|
6f1431e623 | ||
|
|
bebd161a98 | ||
|
|
3eaac53515 | ||
|
|
3a6949862d | ||
|
|
3c843c448a | ||
|
|
ff6fa55829 | ||
|
|
a9fabf6677 | ||
|
|
aa42468ccd | ||
|
|
242c420961 | ||
|
|
abdac05db6 | ||
|
|
84809b37a7 | ||
|
|
2cdd83d6f1 | ||
|
|
0ecb51ea17 | ||
|
|
326e613e82 | ||
|
|
71aaeba17b | ||
|
|
9d2e19a034 | ||
|
|
fcdf5bce32 | ||
|
|
ea655d95a3 | ||
|
|
be5110f306 |
2
App.tsx
2
App.tsx
@@ -208,6 +208,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
submitWorkspaceRename,
|
||||
resetWorkspaceRename,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
@@ -764,6 +765,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
onConnectSerial={createSerialSession}
|
||||
onDeleteHost={handleDeleteHost}
|
||||
onConnect={handleConnectToHost}
|
||||
onUpdateHosts={updateHosts}
|
||||
|
||||
@@ -22,16 +22,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
|
||||
<img src="https://img.shields.io/badge/ダウンロード-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="macOS ARM64 をダウンロード">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
|
||||
<img src="https://img.shields.io/badge/ダウンロード-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="macOS Intel をダウンロード">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
|
||||
<img src="https://img.shields.io/badge/ダウンロード-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="Windows をダウンロード">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=最新版をダウンロード&color=success" alt="最新版をダウンロード">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -269,11 +261,13 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
|
||||
|
||||
### ダウンロード
|
||||
|
||||
| プラットフォーム | アーキテクチャ | ダウンロード |
|
||||
|------------------|----------------|--------------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
|
||||
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
|
||||
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
|
||||
[GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) からお使いのプラットフォームに対応した最新版をダウンロードしてください。
|
||||
|
||||
| プラットフォーム | アーキテクチャ | ステータス |
|
||||
|------------------|----------------|------------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ サポート |
|
||||
| **macOS** | Intel | ✅ サポート |
|
||||
| **Windows** | x64 | ✅ サポート |
|
||||
|
||||
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
|
||||
|
||||
|
||||
24
README.md
24
README.md
@@ -22,16 +22,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
|
||||
<img src="https://img.shields.io/badge/Download-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="Download macOS ARM64">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
|
||||
<img src="https://img.shields.io/badge/Download-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="Download macOS Intel">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
|
||||
<img src="https://img.shields.io/badge/Download-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="Download Windows">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Download%20Latest&color=success" alt="Download Latest Release">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -269,11 +261,13 @@ Netcatty automatically detects and displays OS icons for connected hosts:
|
||||
|
||||
### Download
|
||||
|
||||
| Platform | Architecture | Download |
|
||||
|----------|--------------|----------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
|
||||
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
|
||||
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
|
||||
Download the latest release for your platform from [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest).
|
||||
|
||||
| Platform | Architecture | Status |
|
||||
|----------|--------------|--------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ Supported |
|
||||
| **macOS** | Intel | ✅ Supported |
|
||||
| **Windows** | x64 | ✅ Supported |
|
||||
|
||||
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
|
||||
|
||||
|
||||
@@ -22,16 +22,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
|
||||
<img src="https://img.shields.io/badge/下载-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="下载 macOS ARM64">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
|
||||
<img src="https://img.shields.io/badge/下载-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="下载 macOS Intel">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
|
||||
<img src="https://img.shields.io/badge/下载-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="下载 Windows">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=下载最新版&color=success" alt="下载最新版">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -269,11 +261,13 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
|
||||
### 下载
|
||||
|
||||
| 平台 | 架构 | 下载链接 |
|
||||
|------|------|----------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
|
||||
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
|
||||
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
|
||||
从 [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) 下载适合您平台的最新版本。
|
||||
|
||||
| 平台 | 架构 | 状态 |
|
||||
|------|------|------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ 支持 |
|
||||
| **macOS** | Intel | ✅ 支持 |
|
||||
| **Windows** | x64 | ✅ 支持 |
|
||||
|
||||
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const en: Messages = {
|
||||
'common.noResultsFound': 'No results found',
|
||||
'common.back': 'Back',
|
||||
'common.apply': 'Apply',
|
||||
'common.use': 'Use',
|
||||
'common.saveChanges': 'Save Changes',
|
||||
'common.advanced': 'Advanced',
|
||||
'common.left': 'Left',
|
||||
@@ -166,6 +167,18 @@ const en: Messages = {
|
||||
'settings.terminal.scrollback.rows': 'Number of rows *',
|
||||
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
|
||||
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
|
||||
'settings.terminal.section.localShell': 'Local Shell',
|
||||
'settings.terminal.localShell.shell': 'Shell executable',
|
||||
'settings.terminal.localShell.shell.desc': 'Path to the shell executable (e.g., /bin/zsh, pwsh.exe). Leave empty for system default.',
|
||||
'settings.terminal.localShell.shell.placeholder': 'System default',
|
||||
'settings.terminal.localShell.shell.detected': 'Detected',
|
||||
'settings.terminal.localShell.shell.notFound': 'Shell executable not found',
|
||||
'settings.terminal.localShell.shell.isDirectory': 'Path is a directory, not an executable',
|
||||
'settings.terminal.localShell.startDir': 'Starting directory',
|
||||
'settings.terminal.localShell.startDir.desc': 'Directory to start in when opening a local terminal. Leave empty for home directory.',
|
||||
'settings.terminal.localShell.startDir.placeholder': 'Home directory',
|
||||
'settings.terminal.localShell.startDir.notFound': 'Directory not found',
|
||||
'settings.terminal.localShell.startDir.isFile': 'Path is a file, not a directory',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
|
||||
@@ -356,6 +369,9 @@ const en: Messages = {
|
||||
'pf.view.list': 'List',
|
||||
'pf.rule.summary.dynamic': 'SOCKS on {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.deleteActive.title': 'Delete Active Port Forwarding?',
|
||||
'pf.deleteActive.desc': 'This port forwarding rule "{label}" is currently active. Deleting it will stop the tunnel first.',
|
||||
'pf.deleteActive.confirm': 'Stop and Delete',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'New Folder',
|
||||
@@ -382,11 +398,13 @@ const en: Messages = {
|
||||
'sftp.itemsCount': '{count} items',
|
||||
'sftp.selectedCount': '{count} selected',
|
||||
'sftp.path.doubleClickToEdit': 'Double-click to edit path',
|
||||
'sftp.showHiddenPaths': 'Hidden paths',
|
||||
'sftp.task.waiting': 'Waiting...',
|
||||
'sftp.status.loading': 'Loading...',
|
||||
'sftp.status.uploading': 'Uploading...',
|
||||
'sftp.status.ready': 'Ready',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goHome': 'Go to home',
|
||||
'sftp.folderName': 'Folder name',
|
||||
'sftp.folderName.placeholder': 'Enter folder name',
|
||||
'sftp.prompt.newFolderName': 'New folder name?',
|
||||
@@ -690,6 +708,20 @@ const en: Messages = {
|
||||
'cloudSync.s3.forcePathStyle': 'Force path-style URLs (for MinIO/R2, etc.)',
|
||||
'cloudSync.s3.showSecret': 'Show secrets',
|
||||
'cloudSync.s3.validation.required': 'Endpoint, region, bucket, access key, and secret are required.',
|
||||
'cloudSync.smb.title': 'SMB Settings',
|
||||
'cloudSync.smb.desc': 'Connect to an SMB/CIFS file share for encrypted sync.',
|
||||
'cloudSync.smb.share': 'Share Path',
|
||||
'cloudSync.smb.username': 'Username',
|
||||
'cloudSync.smb.password': 'Password',
|
||||
'cloudSync.smb.domain': 'Domain (optional)',
|
||||
'cloudSync.smb.domainPlaceholder': 'e.g., WORKGROUP',
|
||||
'cloudSync.smb.port': 'Port (optional)',
|
||||
'cloudSync.smb.showSecret': 'Show password',
|
||||
'cloudSync.smb.validation.share': 'Share path is required.',
|
||||
'cloudSync.smb.validation.port': 'Port must be a number between 1 and 65535.',
|
||||
'cloudSync.connect.smb.success': 'SMB connected successfully',
|
||||
'cloudSync.connect.smb.failedTitle': 'SMB connection failed',
|
||||
'cloudSync.provider.smb': 'SMB Share',
|
||||
'cloudSync.connect.webdav.success': 'WebDAV connected successfully',
|
||||
'cloudSync.connect.webdav.failedTitle': 'WebDAV connection failed',
|
||||
'cloudSync.connect.s3.success': 'S3 connected successfully',
|
||||
@@ -926,6 +958,37 @@ const en: Messages = {
|
||||
'snippets.packageDialog.root': 'Root',
|
||||
'snippets.packageDialog.placeholder': 'e.g. ops/maintenance',
|
||||
'snippets.packageDialog.hint': 'Use "/" to create nested packages.',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Serial',
|
||||
'serial.modal.title': 'Connect to Serial Port',
|
||||
'serial.modal.desc': 'Configure serial port connection settings',
|
||||
'serial.field.port': 'Serial Port',
|
||||
'serial.field.selectPort': 'Select a port...',
|
||||
'serial.field.baudRate': 'Baud Rate',
|
||||
'serial.field.dataBits': 'Data Bits',
|
||||
'serial.field.stopBits': 'Stop Bits',
|
||||
'serial.field.parity': 'Parity',
|
||||
'serial.field.flowControl': 'Flow Control',
|
||||
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
|
||||
'serial.field.customPort': 'Custom Port Path',
|
||||
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudo Terminal',
|
||||
'serial.type.custom': 'Custom',
|
||||
'serial.parity.none': 'None',
|
||||
'serial.parity.even': 'Even',
|
||||
'serial.parity.odd': 'Odd',
|
||||
'serial.parity.mark': 'Mark',
|
||||
'serial.parity.space': 'Space',
|
||||
'serial.flowControl.none': 'None',
|
||||
'serial.flowControl.xon/xoff': 'XON/XOFF (Software)',
|
||||
'serial.flowControl.rts/cts': 'RTS/CTS (Hardware)',
|
||||
'serial.field.localEcho': 'Force Local Echo',
|
||||
'serial.field.localEchoDesc': 'Echo typed characters locally (for devices without remote echo)',
|
||||
'serial.field.lineMode': 'Line Mode',
|
||||
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
|
||||
'serial.connectionError': 'Failed to connect to serial port',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -19,6 +19,7 @@ const zhCN: Messages = {
|
||||
'common.noResultsFound': '没有匹配结果',
|
||||
'common.back': '返回',
|
||||
'common.apply': '应用',
|
||||
'common.use': '使用',
|
||||
'common.left': '左侧',
|
||||
'common.right': '右侧',
|
||||
'common.selectAHost': '选择主机',
|
||||
@@ -271,11 +272,13 @@ const zhCN: Messages = {
|
||||
'sftp.itemsCount': '{count} 个项目',
|
||||
'sftp.selectedCount': '已选 {count} 个',
|
||||
'sftp.path.doubleClickToEdit': '双击编辑路径',
|
||||
'sftp.showHiddenPaths': '隐藏的路径',
|
||||
'sftp.task.waiting': '等待中...',
|
||||
'sftp.status.loading': '加载中...',
|
||||
'sftp.status.uploading': '上传中...',
|
||||
'sftp.status.ready': '就绪',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goHome': '返回主目录',
|
||||
'sftp.folderName': '文件夹名称',
|
||||
'sftp.folderName.placeholder': '输入文件夹名称',
|
||||
'sftp.prompt.newFolderName': '新建文件夹名称?',
|
||||
@@ -530,6 +533,20 @@ const zhCN: Messages = {
|
||||
'cloudSync.s3.forcePathStyle': '强制使用 path-style URL(适用于 MinIO/R2 等)',
|
||||
'cloudSync.s3.showSecret': '显示密钥',
|
||||
'cloudSync.s3.validation.required': '端点、Region、Bucket、Access Key 与 Secret 必填。',
|
||||
'cloudSync.smb.title': 'SMB 设置',
|
||||
'cloudSync.smb.desc': '连接到 SMB/CIFS 文件共享以进行加密同步。',
|
||||
'cloudSync.smb.share': '共享路径',
|
||||
'cloudSync.smb.username': '用户名',
|
||||
'cloudSync.smb.password': '密码',
|
||||
'cloudSync.smb.domain': '域(可选)',
|
||||
'cloudSync.smb.domainPlaceholder': '例如:WORKGROUP',
|
||||
'cloudSync.smb.port': '端口(可选)',
|
||||
'cloudSync.smb.showSecret': '显示密码',
|
||||
'cloudSync.smb.validation.share': '共享路径必填。',
|
||||
'cloudSync.smb.validation.port': '端口必须是 1 到 65535 之间的数字。',
|
||||
'cloudSync.connect.smb.success': 'SMB 已连接',
|
||||
'cloudSync.connect.smb.failedTitle': 'SMB 连接失败',
|
||||
'cloudSync.provider.smb': 'SMB 共享',
|
||||
'cloudSync.connect.webdav.success': 'WebDAV 已连接',
|
||||
'cloudSync.connect.webdav.failedTitle': 'WebDAV 连接失败',
|
||||
'cloudSync.connect.s3.success': 'S3 已连接',
|
||||
@@ -649,6 +666,9 @@ const zhCN: Messages = {
|
||||
'pf.view.list': '列表',
|
||||
'pf.rule.summary.dynamic': 'SOCKS 监听于 {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.deleteActive.title': '删除正在运行的端口转发?',
|
||||
'pf.deleteActive.desc': '端口转发规则 "{label}" 当前正在运行。删除前将先关闭转发连接。',
|
||||
'pf.deleteActive.confirm': '关闭并删除',
|
||||
|
||||
// SFTP (pane + conflict)
|
||||
'sftp.pane.local': '本地',
|
||||
@@ -724,6 +744,18 @@ const zhCN: Messages = {
|
||||
'settings.terminal.scrollback.rows': '行数 *',
|
||||
'settings.terminal.keywordHighlight.title': '关键字高亮',
|
||||
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
|
||||
'settings.terminal.section.localShell': '本地 Shell',
|
||||
'settings.terminal.localShell.shell': 'Shell 可执行文件',
|
||||
'settings.terminal.localShell.shell.desc': 'Shell 可执行文件的路径(例如 /bin/zsh、pwsh.exe)。留空使用系统默认。',
|
||||
'settings.terminal.localShell.shell.placeholder': '系统默认',
|
||||
'settings.terminal.localShell.shell.detected': '检测到',
|
||||
'settings.terminal.localShell.shell.notFound': '未找到 Shell 可执行文件',
|
||||
'settings.terminal.localShell.shell.isDirectory': '路径是目录,不是可执行文件',
|
||||
'settings.terminal.localShell.startDir': '起始目录',
|
||||
'settings.terminal.localShell.startDir.desc': '打开本地终端时的起始目录。留空使用用户主目录。',
|
||||
'settings.terminal.localShell.startDir.placeholder': '用户主目录',
|
||||
'settings.terminal.localShell.startDir.notFound': '目录不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': '快捷键方案',
|
||||
@@ -915,6 +947,37 @@ const zhCN: Messages = {
|
||||
'snippets.packageDialog.root': '根目录',
|
||||
'snippets.packageDialog.placeholder': '例如:ops/maintenance',
|
||||
'snippets.packageDialog.hint': '使用 "/" 创建嵌套代码包。',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': '串口',
|
||||
'serial.modal.title': '连接串口',
|
||||
'serial.modal.desc': '配置串口连接参数',
|
||||
'serial.field.port': '串口',
|
||||
'serial.field.selectPort': '选择串口...',
|
||||
'serial.field.baudRate': '波特率',
|
||||
'serial.field.dataBits': '数据位',
|
||||
'serial.field.stopBits': '停止位',
|
||||
'serial.field.parity': '校验位',
|
||||
'serial.field.flowControl': '流控制',
|
||||
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
|
||||
'serial.field.customPort': '自定义串口路径',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001',
|
||||
'serial.type.hardware': '硬件',
|
||||
'serial.type.pseudo': '虚拟终端',
|
||||
'serial.type.custom': '自定义',
|
||||
'serial.parity.none': '无',
|
||||
'serial.parity.even': '偶校验',
|
||||
'serial.parity.odd': '奇校验',
|
||||
'serial.parity.mark': 'Mark',
|
||||
'serial.parity.space': 'Space',
|
||||
'serial.flowControl.none': '无',
|
||||
'serial.flowControl.xon/xoff': 'XON/XOFF (软件)',
|
||||
'serial.flowControl.rts/cts': 'RTS/CTS (硬件)',
|
||||
'serial.field.localEcho': '强制本地回显',
|
||||
'serial.field.localEchoDesc': '本地回显输入字符(用于没有远程回显的设备)',
|
||||
'serial.field.lineMode': '行模式',
|
||||
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
|
||||
'serial.connectionError': '连接串口失败',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -148,7 +148,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
useEffect(() => {
|
||||
// Compute a simple hash of the master key config to detect changes
|
||||
const currentHash = state.masterKeyConfig
|
||||
? JSON.stringify({ salt: state.masterKeyConfig.salt, iv: state.masterKeyConfig.iv })
|
||||
? JSON.stringify({ salt: state.masterKeyConfig.salt, kdf: state.masterKeyConfig.kdf })
|
||||
: null;
|
||||
|
||||
// If master key config changed (e.g., set up in settings window), reset the attempt flag
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getActiveRuleIds,
|
||||
startPortForward,
|
||||
stopPortForward,
|
||||
syncWithBackend,
|
||||
} from "../../infrastructure/services/portForwardingService";
|
||||
import { useStoredViewMode, ViewMode } from "./useStoredViewMode";
|
||||
|
||||
@@ -78,25 +79,32 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE, prefer);
|
||||
}, []);
|
||||
|
||||
// Load rules from storage on mount
|
||||
// Load rules from storage on mount and sync with backend
|
||||
useEffect(() => {
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (saved && Array.isArray(saved)) {
|
||||
// Sync status with active connections in the service layer
|
||||
const _activeRuleIds = getActiveRuleIds();
|
||||
const withSyncedStatus = saved.map((r) => {
|
||||
const conn = getActiveConnection(r.id);
|
||||
if (conn) {
|
||||
// This rule has an active connection, preserve its status
|
||||
return { ...r, status: conn.status, error: conn.error };
|
||||
}
|
||||
// No active connection, reset to inactive
|
||||
return { ...r, status: "inactive" as const, error: undefined };
|
||||
});
|
||||
setRules(withSyncedStatus);
|
||||
}
|
||||
const loadAndSync = async () => {
|
||||
// First, sync with backend to get any active tunnels
|
||||
await syncWithBackend();
|
||||
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (saved && Array.isArray(saved)) {
|
||||
// Sync status with active connections in the service layer
|
||||
const _activeRuleIds = getActiveRuleIds();
|
||||
const withSyncedStatus = saved.map((r) => {
|
||||
const conn = getActiveConnection(r.id);
|
||||
if (conn) {
|
||||
// This rule has an active connection, preserve its status
|
||||
return { ...r, status: conn.status, error: conn.error };
|
||||
}
|
||||
// No active connection, reset to inactive
|
||||
return { ...r, status: "inactive" as const, error: undefined };
|
||||
});
|
||||
setRules(withSyncedStatus);
|
||||
}
|
||||
};
|
||||
|
||||
void loadAndSync();
|
||||
}, []);
|
||||
|
||||
// Persist rules to storage whenever they change
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MouseEvent,useCallback,useMemo,useState } from 'react';
|
||||
import { ConnectionLog,Host,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
|
||||
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
|
||||
import {
|
||||
collectSessionIds,
|
||||
createWorkspaceFromSessions as createWorkspaceEntity,
|
||||
@@ -53,6 +53,24 @@ export const useSessionState = () => {
|
||||
setActiveTabId(sessionId);
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createSerialSession = useCallback((config: SerialConfig) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const serialHostId = `serial-${sessionId}`;
|
||||
const portName = config.path.split('/').pop() || config.path;
|
||||
const newSession: TerminalSession = {
|
||||
id: sessionId,
|
||||
hostId: serialHostId,
|
||||
hostLabel: `Serial: ${portName}`,
|
||||
hostname: config.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: config,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const connectToHost = useCallback((host: Host) => {
|
||||
const newSession: TerminalSession = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -590,6 +608,7 @@ export const useSessionState = () => {
|
||||
submitWorkspaceRename,
|
||||
resetWorkspaceRename,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
|
||||
@@ -41,6 +41,11 @@ const getFileExtension = (name: string): string => {
|
||||
return ext || "file";
|
||||
};
|
||||
|
||||
// Check if an entry is navigable like a directory (directories or symlinks pointing to directories)
|
||||
const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
|
||||
return entry.type === "directory" || (entry.type === "symlink" && entry.linkTarget === "directory");
|
||||
};
|
||||
|
||||
// Check if path is Windows-style
|
||||
const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
|
||||
|
||||
@@ -293,6 +298,47 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
|
||||
(host: Host): NetcattySSHOptions => {
|
||||
const resolved = resolveHostAuth({ host, keys, identities });
|
||||
const key = resolved.key || null;
|
||||
|
||||
// Build proxy config if present
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: host.proxyConfig.password,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Build jump hosts array if host chain is configured
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds
|
||||
.map((hostId) => hosts.find((h) => h.id === hostId))
|
||||
.filter((h): h is Host => !!h)
|
||||
.map((jumpHost) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpAuth.username || "root",
|
||||
password: jumpAuth.password,
|
||||
privateKey: jumpKey?.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
username: resolved.username,
|
||||
@@ -303,9 +349,11 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
|
||||
publicKey: key?.publicKey,
|
||||
keyId: resolved.keyId,
|
||||
keySource: key?.source,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
};
|
||||
},
|
||||
[identities, keys],
|
||||
[hosts, identities, keys],
|
||||
);
|
||||
|
||||
const getMockLocalFiles = useCallback((path: string): SftpFileEntry[] => {
|
||||
@@ -327,6 +375,7 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
|
||||
sizeFormatted: f.size,
|
||||
lastModified: new Date(f.lastModified).getTime(),
|
||||
lastModifiedFormatted: f.lastModified,
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
}));
|
||||
},
|
||||
[getMockLocalFiles],
|
||||
@@ -344,6 +393,7 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
|
||||
sizeFormatted: f.size,
|
||||
lastModified: new Date(f.lastModified).getTime(),
|
||||
lastModifiedFormatted: f.lastModified,
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
@@ -1295,7 +1345,8 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.type === "directory") {
|
||||
// Navigate into directories, or symlinks that point to directories
|
||||
if (isNavigableDirectory(entry)) {
|
||||
const newPath = joinPath(pane.connection.currentPath, entry.name);
|
||||
await navigateTo(side, newPath);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ export const useTerminalBackend = () => {
|
||||
return !!bridge?.startLocalSession;
|
||||
}, []);
|
||||
|
||||
const serialAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.startSerialSession;
|
||||
}, []);
|
||||
|
||||
const execAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.execCommand;
|
||||
@@ -46,6 +51,12 @@ export const useTerminalBackend = () => {
|
||||
return bridge.startLocalSession(options);
|
||||
}, []);
|
||||
|
||||
const startSerialSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startSerialSession"]>>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startSerialSession) throw new Error("startSerialSession unavailable");
|
||||
return bridge.startSerialSession(options);
|
||||
}, []);
|
||||
|
||||
const execCommand = useCallback(async (options: Parameters<NetcattyBridge["execCommand"]>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.execCommand) throw new Error("execCommand unavailable");
|
||||
@@ -99,17 +110,26 @@ export const useTerminalBackend = () => {
|
||||
return !!bridge?.startSSHSession;
|
||||
}, []);
|
||||
|
||||
const listSerialPorts = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listSerialPorts) return [];
|
||||
return bridge.listSerialPorts();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
backendAvailable,
|
||||
telnetAvailable,
|
||||
moshAvailable,
|
||||
localAvailable,
|
||||
serialAvailable,
|
||||
execAvailable,
|
||||
openExternalAvailable,
|
||||
startSSHSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
execCommand,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
|
||||
@@ -1045,13 +1045,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="github"
|
||||
name="GitHub Gist"
|
||||
icon={<Github size={24} />}
|
||||
isConnected={sync.providers.github.status === 'connected' || sync.providers.github.status === 'syncing'}
|
||||
isSyncing={sync.providers.github.status === 'syncing'}
|
||||
isConnecting={sync.providers.github.status === 'connecting'}
|
||||
account={sync.providers.github.account}
|
||||
lastSync={sync.providers.github.lastSync}
|
||||
error={sync.providers.github.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.github.status !== 'connected' && sync.providers.github.status !== 'syncing'}
|
||||
isConnected={sync.providers.github.status === 'connected' || sync.providers.github.status === 'syncing'}
|
||||
isSyncing={sync.providers.github.status === 'syncing'}
|
||||
isConnecting={sync.providers.github.status === 'connecting'}
|
||||
account={sync.providers.github.account}
|
||||
lastSync={sync.providers.github.lastSync}
|
||||
error={sync.providers.github.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.github.status !== 'connected' && sync.providers.github.status !== 'syncing'}
|
||||
onConnect={handleConnectGitHub}
|
||||
onDisconnect={() => sync.disconnectProvider('github')}
|
||||
onSync={() => handleSync('github')}
|
||||
@@ -1061,13 +1061,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="google"
|
||||
name="Google Drive"
|
||||
icon={<GoogleDriveIcon className="w-6 h-6" />}
|
||||
isConnected={sync.providers.google.status === 'connected' || sync.providers.google.status === 'syncing'}
|
||||
isSyncing={sync.providers.google.status === 'syncing'}
|
||||
isConnecting={sync.providers.google.status === 'connecting'}
|
||||
account={sync.providers.google.account}
|
||||
lastSync={sync.providers.google.lastSync}
|
||||
error={sync.providers.google.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.google.status !== 'connected' && sync.providers.google.status !== 'syncing'}
|
||||
isConnected={sync.providers.google.status === 'connected' || sync.providers.google.status === 'syncing'}
|
||||
isSyncing={sync.providers.google.status === 'syncing'}
|
||||
isConnecting={sync.providers.google.status === 'connecting'}
|
||||
account={sync.providers.google.account}
|
||||
lastSync={sync.providers.google.lastSync}
|
||||
error={sync.providers.google.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.google.status !== 'connected' && sync.providers.google.status !== 'syncing'}
|
||||
onConnect={handleConnectGoogle}
|
||||
onDisconnect={() => sync.disconnectProvider('google')}
|
||||
onSync={() => handleSync('google')}
|
||||
@@ -1077,13 +1077,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="onedrive"
|
||||
name="Microsoft OneDrive"
|
||||
icon={<OneDriveIcon className="w-6 h-6" />}
|
||||
isConnected={sync.providers.onedrive.status === 'connected' || sync.providers.onedrive.status === 'syncing'}
|
||||
isSyncing={sync.providers.onedrive.status === 'syncing'}
|
||||
isConnecting={sync.providers.onedrive.status === 'connecting'}
|
||||
account={sync.providers.onedrive.account}
|
||||
lastSync={sync.providers.onedrive.lastSync}
|
||||
error={sync.providers.onedrive.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.onedrive.status !== 'connected' && sync.providers.onedrive.status !== 'syncing'}
|
||||
isConnected={sync.providers.onedrive.status === 'connected' || sync.providers.onedrive.status === 'syncing'}
|
||||
isSyncing={sync.providers.onedrive.status === 'syncing'}
|
||||
isConnecting={sync.providers.onedrive.status === 'connecting'}
|
||||
account={sync.providers.onedrive.account}
|
||||
lastSync={sync.providers.onedrive.lastSync}
|
||||
error={sync.providers.onedrive.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.onedrive.status !== 'connected' && sync.providers.onedrive.status !== 'syncing'}
|
||||
onConnect={handleConnectOneDrive}
|
||||
onDisconnect={() => sync.disconnectProvider('onedrive')}
|
||||
onSync={() => handleSync('onedrive')}
|
||||
@@ -1093,13 +1093,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="webdav"
|
||||
name={t('cloudSync.provider.webdav')}
|
||||
icon={<Server size={24} />}
|
||||
isConnected={sync.providers.webdav.status === 'connected' || sync.providers.webdav.status === 'syncing'}
|
||||
isSyncing={sync.providers.webdav.status === 'syncing'}
|
||||
isConnecting={sync.providers.webdav.status === 'connecting'}
|
||||
account={sync.providers.webdav.account}
|
||||
lastSync={sync.providers.webdav.lastSync}
|
||||
error={sync.providers.webdav.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.webdav.status !== 'connected' && sync.providers.webdav.status !== 'syncing'}
|
||||
isConnected={sync.providers.webdav.status === 'connected' || sync.providers.webdav.status === 'syncing'}
|
||||
isSyncing={sync.providers.webdav.status === 'syncing'}
|
||||
isConnecting={sync.providers.webdav.status === 'connecting'}
|
||||
account={sync.providers.webdav.account}
|
||||
lastSync={sync.providers.webdav.lastSync}
|
||||
error={sync.providers.webdav.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.webdav.status !== 'connected' && sync.providers.webdav.status !== 'syncing'}
|
||||
onEdit={openWebdavDialog}
|
||||
onConnect={openWebdavDialog}
|
||||
onDisconnect={() => sync.disconnectProvider('webdav')}
|
||||
@@ -1110,13 +1110,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="s3"
|
||||
name={t('cloudSync.provider.s3')}
|
||||
icon={<Database size={24} />}
|
||||
isConnected={sync.providers.s3.status === 'connected' || sync.providers.s3.status === 'syncing'}
|
||||
isSyncing={sync.providers.s3.status === 'syncing'}
|
||||
isConnecting={sync.providers.s3.status === 'connecting'}
|
||||
account={sync.providers.s3.account}
|
||||
lastSync={sync.providers.s3.lastSync}
|
||||
error={sync.providers.s3.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.s3.status !== 'connected' && sync.providers.s3.status !== 'syncing'}
|
||||
isConnected={sync.providers.s3.status === 'connected' || sync.providers.s3.status === 'syncing'}
|
||||
isSyncing={sync.providers.s3.status === 'syncing'}
|
||||
isConnecting={sync.providers.s3.status === 'connecting'}
|
||||
account={sync.providers.s3.account}
|
||||
lastSync={sync.providers.s3.lastSync}
|
||||
error={sync.providers.s3.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.s3.status !== 'connected' && sync.providers.s3.status !== 'syncing'}
|
||||
onEdit={openS3Dialog}
|
||||
onConnect={openS3Dialog}
|
||||
onDisconnect={() => sync.disconnectProvider('s3')}
|
||||
@@ -1714,7 +1714,7 @@ interface CloudSyncSettingsProps {
|
||||
|
||||
export const CloudSyncSettings: React.FC<CloudSyncSettingsProps> = (props) => {
|
||||
const { securityState } = useCloudSync();
|
||||
|
||||
|
||||
// Simplified UX: once a master key is configured, we auto-unlock via safeStorage
|
||||
// so users don't have to manage a separate LOCKED screen.
|
||||
if (securityState === 'NO_KEY') {
|
||||
|
||||
@@ -59,6 +59,7 @@ interface HostDetailsPanelProps {
|
||||
groups: string[];
|
||||
allTags?: string[]; // All available tags for autocomplete
|
||||
allHosts?: Host[]; // All hosts for chain selection
|
||||
defaultGroup?: string | null; // Default group for new hosts (from current navigation)
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
|
||||
@@ -72,6 +73,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
groups,
|
||||
allTags = [],
|
||||
allHosts = [],
|
||||
defaultGroup,
|
||||
onSave,
|
||||
onCancel,
|
||||
onCreateGroup,
|
||||
@@ -95,6 +97,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
charset: "UTF-8",
|
||||
theme: "Flexoki Dark",
|
||||
createdAt: Date.now(),
|
||||
group: defaultGroup || undefined, // Pre-fill with current navigation group
|
||||
} as Host),
|
||||
);
|
||||
|
||||
@@ -286,10 +289,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
const base = identities;
|
||||
const filtered = q
|
||||
? base.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: base;
|
||||
return filtered.slice(0, 6);
|
||||
}, [form.username, identities, selectedIdentity]);
|
||||
@@ -639,10 +642,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
const q = next.toLowerCase().trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
setIdentitySuggestionsOpen(matches.length > 0);
|
||||
}}
|
||||
@@ -650,10 +653,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
const q = (form.username || "").toLowerCase().trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
setIdentitySuggestionsOpen(matches.length > 0);
|
||||
}}
|
||||
@@ -670,10 +673,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
.trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
return matches.length > 0;
|
||||
});
|
||||
@@ -702,8 +705,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{filteredIdentitySuggestions.map((identity) => {
|
||||
const keyLabel = identity.keyId
|
||||
? availableKeys.find(
|
||||
(k) => k.id === identity.keyId,
|
||||
)?.label
|
||||
(k) => k.id === identity.keyId,
|
||||
)?.label
|
||||
: undefined;
|
||||
const methodLabel =
|
||||
identity.authMethod === "certificate"
|
||||
@@ -850,42 +853,42 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "key" &&
|
||||
!form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.key.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ""}`,
|
||||
icon: <Key size={14} className="text-muted-foreground" />,
|
||||
}))}
|
||||
value={form.identityFileId}
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "key");
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.keys.search")}
|
||||
emptyText={t("hostDetails.keys.empty")}
|
||||
icon={<Key size={14} className="text-muted-foreground" />}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.key.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ""}`,
|
||||
icon: <Key size={14} className="text-muted-foreground" />,
|
||||
}))}
|
||||
value={form.identityFileId}
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "key");
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.keys.search")}
|
||||
emptyText={t("hostDetails.keys.empty")}
|
||||
icon={<Key size={14} className="text-muted-foreground" />}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Certificate selection combobox - appears after selecting "Certificate" type */}
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "certificate" &&
|
||||
!form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "certificate" &&
|
||||
!form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.certificate.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
@@ -913,11 +916,11 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Globe,
|
||||
@@ -26,6 +27,14 @@ import {
|
||||
AsidePanelFooter,
|
||||
} from "./ui/aside-panel";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { SortDropdown } from "./ui/sort-dropdown";
|
||||
@@ -207,6 +216,11 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
// New forwarding menu
|
||||
const [showNewMenu, setShowNewMenu] = useState(false);
|
||||
|
||||
// Delete confirmation dialog state for active tunnels
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [ruleToDelete, setRuleToDelete] = useState<PortForwardingRule | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Reset wizard
|
||||
const resetWizard = () => {
|
||||
setWizardStep("type");
|
||||
@@ -358,12 +372,50 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
};
|
||||
|
||||
// Close edit panel
|
||||
const closeEditPanel = () => {
|
||||
const closeEditPanel = useCallback(() => {
|
||||
setShowEditPanel(false);
|
||||
setEditingRule(null);
|
||||
setEditDraft({});
|
||||
setSelectedRuleId(null);
|
||||
};
|
||||
}, [setSelectedRuleId]);
|
||||
|
||||
// Handle delete with confirmation for active tunnels
|
||||
const handleDeleteRule = useCallback(
|
||||
(rule: PortForwardingRule) => {
|
||||
// If tunnel is active or connecting, show confirmation dialog
|
||||
if (rule.status === "active" || rule.status === "connecting") {
|
||||
setRuleToDelete(rule);
|
||||
setShowDeleteConfirm(true);
|
||||
} else {
|
||||
// If inactive, delete directly
|
||||
if (editingRule?.id === rule.id) {
|
||||
closeEditPanel();
|
||||
}
|
||||
deleteRule(rule.id);
|
||||
}
|
||||
},
|
||||
[editingRule, deleteRule, closeEditPanel],
|
||||
);
|
||||
|
||||
// Confirm delete of active tunnel: stop first, then delete
|
||||
const confirmDeleteActiveRule = useCallback(async () => {
|
||||
if (!ruleToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// Stop the tunnel first
|
||||
await stopTunnel(ruleToDelete.id);
|
||||
// Then delete the rule
|
||||
if (editingRule?.id === ruleToDelete.id) {
|
||||
closeEditPanel();
|
||||
}
|
||||
deleteRule(ruleToDelete.id);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
setRuleToDelete(null);
|
||||
}
|
||||
}, [ruleToDelete, stopTunnel, deleteRule, editingRule, closeEditPanel]);
|
||||
|
||||
// Handle wizard navigation
|
||||
// Flow for local: type -> local-config -> destination -> host-selection
|
||||
@@ -654,12 +706,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
}}
|
||||
onEdit={() => startEditRule(rule)}
|
||||
onDuplicate={() => duplicateRule(rule.id)}
|
||||
onDelete={() => {
|
||||
if (editingRule?.id === rule.id) {
|
||||
closeEditPanel();
|
||||
}
|
||||
deleteRule(rule.id);
|
||||
}}
|
||||
onDelete={() => handleDeleteRule(rule)}
|
||||
onStart={() => handleStartTunnel(rule)}
|
||||
onStop={() => handleStopTunnel(rule)}
|
||||
/>
|
||||
@@ -685,10 +732,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
duplicateRule(editingRule.id);
|
||||
closeEditPanel();
|
||||
}}
|
||||
onDelete={() => {
|
||||
deleteRule(editingRule.id);
|
||||
closeEditPanel();
|
||||
}}
|
||||
onDelete={() => handleDeleteRule(editingRule)}
|
||||
onOpenHostSelector={() => setShowHostSelector(true)}
|
||||
/>
|
||||
)}
|
||||
@@ -819,6 +863,45 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
isValid={isNewFormValid()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Active Tunnel Confirmation Dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={(open) => {
|
||||
if (!isDeleting) {
|
||||
setShowDeleteConfirm(open);
|
||||
if (!open) setRuleToDelete(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle size={20} />
|
||||
{t("pf.deleteActive.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("pf.deleteActive.desc", { label: ruleToDelete?.label ?? "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setRuleToDelete(null);
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDeleteActiveRule}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t("pf.deleteActive.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ChevronRight,
|
||||
Database,
|
||||
Download,
|
||||
ExternalLink,
|
||||
File,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
Key,
|
||||
Loader2,
|
||||
Lock,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
@@ -51,7 +53,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
// Comprehensive file icon helper
|
||||
const getFileIcon = (fileName: string, isDirectory: boolean) => {
|
||||
const getFileIcon = (fileName: string, isDirectory: boolean, isSymlink?: boolean) => {
|
||||
if (isDirectory)
|
||||
return (
|
||||
<Folder
|
||||
@@ -62,6 +64,11 @@ const getFileIcon = (fileName: string, isDirectory: boolean) => {
|
||||
/>
|
||||
);
|
||||
|
||||
// For symlink files (not directories), show a special symlink icon
|
||||
if (isSymlink) {
|
||||
return <ExternalLink size={18} className="text-cyan-500" />;
|
||||
}
|
||||
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
const iconClass = "text-muted-foreground";
|
||||
|
||||
@@ -233,6 +240,8 @@ interface SFTPModalProps {
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
};
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -322,6 +331,9 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
const [isEditingPath, setIsEditingPath] = useState(false);
|
||||
const [editingPathValue, setEditingPathValue] = useState("");
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Breadcrumb truncation constant
|
||||
const MAX_VISIBLE_BREADCRUMB_PARTS = 4;
|
||||
|
||||
const isWindowsPath = useCallback((path: string): boolean => {
|
||||
return /^[A-Za-z]:/.test(path);
|
||||
@@ -445,6 +457,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
publicKey: credentials.publicKey,
|
||||
keyId: credentials.keyId,
|
||||
keySource: credentials.keySource,
|
||||
proxy: credentials.proxy,
|
||||
jumpHosts: credentials.jumpHosts,
|
||||
});
|
||||
sftpIdRef.current = sftpId;
|
||||
return sftpId;
|
||||
@@ -461,6 +475,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
credentials.publicKey,
|
||||
credentials.keyId,
|
||||
credentials.keySource,
|
||||
credentials.proxy,
|
||||
credentials.jumpHosts,
|
||||
openSftp,
|
||||
]);
|
||||
|
||||
@@ -743,14 +759,14 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
return true;
|
||||
} catch (e) {
|
||||
setUploadTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === taskId
|
||||
prev.map((task) =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...t,
|
||||
...task,
|
||||
status: "failed" as const,
|
||||
error: e instanceof Error ? e.message : t("sftp.error.uploadFailed"),
|
||||
}
|
||||
: t,
|
||||
: task,
|
||||
),
|
||||
);
|
||||
return false;
|
||||
@@ -865,9 +881,11 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
// Sorted files
|
||||
const sortedFiles = useMemo(() => {
|
||||
return [...files].sort((a, b) => {
|
||||
// Directories always first
|
||||
if (a.type === "directory" && b.type !== "directory") return -1;
|
||||
if (a.type !== "directory" && b.type === "directory") return 1;
|
||||
// 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) {
|
||||
@@ -968,6 +986,35 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
|
||||
// Breadcrumbs
|
||||
const breadcrumbs = getBreadcrumbs(currentPath);
|
||||
|
||||
// Compute visible/hidden breadcrumbs for truncation (always truncate, no expansion)
|
||||
const { visibleBreadcrumbs, hiddenBreadcrumbs, needsBreadcrumbTruncation } = useMemo(() => {
|
||||
if (breadcrumbs.length <= MAX_VISIBLE_BREADCRUMB_PARTS) {
|
||||
return {
|
||||
visibleBreadcrumbs: breadcrumbs.map((part, idx) => ({ part, originalIndex: idx })),
|
||||
hiddenBreadcrumbs: [] as { part: string; originalIndex: number }[],
|
||||
needsBreadcrumbTruncation: false
|
||||
};
|
||||
}
|
||||
|
||||
// Show first part + ellipsis + last (MAX_VISIBLE_BREADCRUMB_PARTS - 1) parts
|
||||
const firstPart = [{ part: breadcrumbs[0], originalIndex: 0 }];
|
||||
const lastPartsCount = MAX_VISIBLE_BREADCRUMB_PARTS - 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]);
|
||||
|
||||
const handleFileClick = (
|
||||
file: RemoteFile,
|
||||
@@ -1031,7 +1078,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
};
|
||||
|
||||
const handleFileDoubleClick = (file: RemoteFile) => {
|
||||
if (file.type === "directory") {
|
||||
// Navigate into directories, or symlinks that point to directories
|
||||
if (file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) {
|
||||
handleNavigate(joinPath(currentPath, file.name));
|
||||
} else {
|
||||
handleDownload(file);
|
||||
@@ -1143,32 +1191,55 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
<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={t("sftp.path.doubleClickToEdit")}
|
||||
title={currentPath}
|
||||
>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground px-1"
|
||||
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
|
||||
onClick={() => setCurrentPath(getRootPath(currentPath))}
|
||||
>
|
||||
{isLocalSession && isWindowsPath(currentPath)
|
||||
? getWindowsDrive(currentPath) ?? "C:"
|
||||
: "/"}
|
||||
</button>
|
||||
{breadcrumbs.map((part, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground truncate px-1"
|
||||
onClick={() =>
|
||||
setCurrentPath(breadcrumbPathAt(breadcrumbs, idx))
|
||||
}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{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={() =>
|
||||
setCurrentPath(breadcrumbPathAt(breadcrumbs, originalIndex))
|
||||
}
|
||||
title={part}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1298,7 +1369,12 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="divide-y divide-border/30">
|
||||
{sortedFiles.map((file, idx) => (
|
||||
{sortedFiles.map((file, idx) => {
|
||||
// Check if this entry is navigable like a directory
|
||||
const isNavigableDirectory = file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory");
|
||||
const isDownloadableFile = file.type === "file" || (file.type === "symlink" && file.linkTarget === "file");
|
||||
|
||||
return (
|
||||
<ContextMenu key={idx}>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
@@ -1315,18 +1391,24 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="shrink-0">
|
||||
{getFileIcon(file.name, file.type === "directory")}
|
||||
{getFileIcon(file.name, isNavigableDirectory, file.type === "symlink" && !isNavigableDirectory)}
|
||||
</div>
|
||||
<span className="truncate font-medium">{file.name}</span>
|
||||
<span className={cn("truncate font-medium", file.type === "symlink" && "italic")}>
|
||||
{file.name}
|
||||
{file.type === "symlink" && <span className="sr-only"> (symbolic link)</span>}
|
||||
</span>
|
||||
{file.type === "symlink" && (
|
||||
<span className="text-xs text-muted-foreground shrink-0" aria-hidden="true">→</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{file.type === "directory" ? "--" : formatBytes(file.size)}
|
||||
{isNavigableDirectory ? "--" : formatBytes(file.size)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{formatDate(file.lastModified, resolvedLocale)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{file.type === "file" && (
|
||||
{isDownloadableFile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -1356,7 +1438,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{file.type === "directory" && (
|
||||
{(file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) && (
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
handleNavigate(
|
||||
@@ -1369,7 +1451,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{file.type === "file" && (
|
||||
{(file.type === "file" || (file.type === "symlink" && file.linkTarget === "file")) && (
|
||||
<ContextMenuItem onClick={() => handleDownload(file)}>
|
||||
<Download size={14} className="mr-2" /> {t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
@@ -1382,7 +1464,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
|
||||
332
components/SerialConnectModal.tsx
Normal file
332
components/SerialConnectModal.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Serial Port Connect Modal
|
||||
* Allows users to configure and connect to a serial port
|
||||
*/
|
||||
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Usb } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import type { SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, type ComboboxOption } from './ui/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Label } from './ui/label';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
|
||||
interface SerialPort {
|
||||
path: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
vendorId: string;
|
||||
productId: string;
|
||||
pnpId: string;
|
||||
type?: 'hardware' | 'pseudo' | 'custom';
|
||||
}
|
||||
|
||||
interface SerialConnectModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConnect: (config: SerialConfig) => void;
|
||||
}
|
||||
|
||||
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
|
||||
const DATA_BITS: Array<5 | 6 | 7 | 8> = [5, 6, 7, 8];
|
||||
const STOP_BITS: Array<1 | 1.5 | 2> = [1, 1.5, 2];
|
||||
const PARITY_OPTIONS: SerialParity[] = ['none', 'even', 'odd', 'mark', 'space'];
|
||||
const FLOW_CONTROL_OPTIONS: SerialFlowControl[] = ['none', 'xon/xoff', 'rts/cts'];
|
||||
|
||||
export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConnect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [ports, setPorts] = useState<SerialPort[]>([]);
|
||||
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [selectedPort, setSelectedPort] = useState('');
|
||||
const [baudRate, setBaudRate] = useState(115200);
|
||||
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(8);
|
||||
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(1);
|
||||
const [parity, setParity] = useState<SerialParity>('none');
|
||||
const [flowControl, setFlowControl] = useState<SerialFlowControl>('none');
|
||||
const [localEcho, setLocalEcho] = useState(false);
|
||||
const [lineMode, setLineMode] = useState(false);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
|
||||
const loadPorts = useCallback(async () => {
|
||||
setIsLoadingPorts(true);
|
||||
try {
|
||||
const result = await terminalBackend.listSerialPorts();
|
||||
setPorts(result);
|
||||
// Auto-select first port if available and no port is selected
|
||||
if (result.length > 0) {
|
||||
setSelectedPort((prev) => prev || result[0].path);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Serial] Failed to list ports:', err);
|
||||
} finally {
|
||||
setIsLoadingPorts(false);
|
||||
}
|
||||
}, [terminalBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadPorts();
|
||||
}
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleConnect = () => {
|
||||
if (!selectedPort) return;
|
||||
|
||||
const config: SerialConfig = {
|
||||
path: selectedPort,
|
||||
baudRate,
|
||||
dataBits,
|
||||
stopBits,
|
||||
parity,
|
||||
flowControl,
|
||||
localEcho,
|
||||
lineMode,
|
||||
};
|
||||
|
||||
onConnect(config);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Convert ports to Combobox options
|
||||
const portOptions: ComboboxOption[] = useMemo(() => {
|
||||
return ports.map((port) => ({
|
||||
value: port.path,
|
||||
label: port.path,
|
||||
sublabel: port.manufacturer || undefined,
|
||||
}));
|
||||
}, [ports]);
|
||||
|
||||
// Validate: port path must start with /dev/
|
||||
const isPortValid = selectedPort.trim().startsWith('/dev/');
|
||||
const isBaudRateValid = BAUD_RATES.includes(baudRate);
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Usb size={18} />
|
||||
{t('serial.modal.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('serial.modal.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Serial Port Selection */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="serial-port">{t('serial.field.port')}</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadPorts}
|
||||
disabled={isLoadingPorts}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<RefreshCw size={12} className={cn("mr-1", isLoadingPorts && "animate-spin")} />
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Combobox for port selection with manual input support */}
|
||||
<Combobox
|
||||
options={portOptions}
|
||||
value={selectedPort}
|
||||
onValueChange={setSelectedPort}
|
||||
placeholder={t('serial.field.selectPort')}
|
||||
emptyText={t('serial.noPorts')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
icon={<Usb size={14} className="text-muted-foreground" />}
|
||||
/>
|
||||
|
||||
{!isPortValid && selectedPort && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('serial.field.customPortPlaceholder')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Baud Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<select
|
||||
id="baud-rate"
|
||||
value={baudRate}
|
||||
onChange={(e) => setBaudRate(parseInt(e.target.value, 10))}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{BAUD_RATES.map((rate) => (
|
||||
<option key={rate} value={rate}>
|
||||
{rate}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-9 px-0 hover:bg-transparent"
|
||||
>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t('common.advanced')}
|
||||
</span>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp size={14} className="text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* Data Bits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<select
|
||||
id="data-bits"
|
||||
value={dataBits}
|
||||
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<select
|
||||
id="stop-bits"
|
||||
value={stopBits}
|
||||
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<select
|
||||
id="parity"
|
||||
value={parity}
|
||||
onChange={(e) => setParity(e.target.value as SerialParity)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<select
|
||||
id="flow-control"
|
||||
value={flowControl}
|
||||
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Terminal Options */}
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="local-echo" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.localEcho')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.localEchoDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="local-echo"
|
||||
checked={localEcho}
|
||||
onChange={(e) => setLocalEcho(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="line-mode" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.lineMode')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.lineModeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="line-mode"
|
||||
checked={lineMode}
|
||||
onChange={(e) => setLineMode(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConnect} disabled={!isValid}>
|
||||
<Cpu size={14} className="mr-2" />
|
||||
{t('common.connect')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SerialConnectModal;
|
||||
@@ -42,6 +42,7 @@ import { Label } from "./ui/label";
|
||||
// Import extracted components
|
||||
import {
|
||||
ColumnWidths,
|
||||
isNavigableDirectory,
|
||||
SftpBreadcrumb,
|
||||
SftpConflictDialog,
|
||||
SftpFileRow,
|
||||
@@ -181,8 +182,9 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
const currentValue = editingPathValue.trim().toLowerCase();
|
||||
const suggestions: { path: string; type: "folder" | "history" }[] = [];
|
||||
|
||||
// Include both directories and symlinks pointing to directories
|
||||
const folders = filteredFiles.filter(
|
||||
(f) => f.type === "directory" && f.name !== "..",
|
||||
(f) => isNavigableDirectory(f) && f.name !== "..",
|
||||
);
|
||||
folders.forEach((f) => {
|
||||
const fullPath =
|
||||
@@ -455,10 +457,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
.filter((f) => selectedNames.includes(f.name))
|
||||
.map((f) => ({
|
||||
name: f.name,
|
||||
isDirectory: f.type === "directory",
|
||||
isDirectory: isNavigableDirectory(f),
|
||||
side,
|
||||
}))
|
||||
: [{ name: entry.name, isDirectory: entry.type === "directory", side }];
|
||||
: [{ name: entry.name, isDirectory: isNavigableDirectory(entry), side }];
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData("text/plain", files.map((f) => f.name).join("\n"));
|
||||
onDragStart(files, side);
|
||||
@@ -466,7 +468,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
|
||||
const handleEntryDragOver = (entry: SftpFileEntry, e: React.DragEvent) => {
|
||||
if (!draggedFiles || draggedFiles[0]?.side === side) return;
|
||||
if (entry.type === "directory" && entry.name !== "..") {
|
||||
// Allow drag over for directories and symlinks pointing to directories
|
||||
if (isNavigableDirectory(entry) && entry.name !== "..") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverEntry(entry.name);
|
||||
@@ -475,7 +478,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
|
||||
const handleEntryDrop = (entry: SftpFileEntry, e: React.DragEvent) => {
|
||||
if (!draggedFiles || draggedFiles[0]?.side === side) return;
|
||||
if (entry.type === "directory" && entry.name !== "..") {
|
||||
// Allow drop on directories and symlinks pointing to directories
|
||||
if (isNavigableDirectory(entry) && entry.name !== "..") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverEntry(null);
|
||||
@@ -851,7 +855,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
{entry.name !== ".." && (
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onOpenEntry(entry)}>
|
||||
{entry.type === "directory"
|
||||
{isNavigableDirectory(entry)
|
||||
? t("sftp.context.open")
|
||||
: t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
@@ -867,7 +871,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
);
|
||||
return {
|
||||
name,
|
||||
isDirectory: file?.type === "directory" || false,
|
||||
isDirectory: file ? isNavigableDirectory(file) : false,
|
||||
};
|
||||
});
|
||||
onCopyToOtherPane(fileData);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Host,
|
||||
Identity,
|
||||
KnownHost,
|
||||
SerialConfig,
|
||||
SSHKey,
|
||||
Snippet,
|
||||
TerminalSession,
|
||||
@@ -58,6 +59,7 @@ interface TerminalProps {
|
||||
terminalSettings?: TerminalSettings;
|
||||
sessionId: string;
|
||||
startupCommand?: string;
|
||||
serialConfig?: SerialConfig;
|
||||
onUpdateTerminalThemeId?: (themeId: string) => void;
|
||||
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
|
||||
onUpdateTerminalFontSize?: (fontSize: number) => void;
|
||||
@@ -103,6 +105,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalSettings,
|
||||
sessionId,
|
||||
startupCommand,
|
||||
serialConfig,
|
||||
onUpdateTerminalThemeId,
|
||||
onUpdateTerminalFontFamilyId,
|
||||
onUpdateTerminalFontSize,
|
||||
@@ -138,6 +141,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hasConnectedRef = useRef(false);
|
||||
const hasRunStartupCommandRef = useRef(false);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
const serialLineBufferRef = useRef<string>("");
|
||||
|
||||
const terminalSettingsRef = useRef(terminalSettings);
|
||||
terminalSettingsRef.current = terminalSettings;
|
||||
@@ -280,6 +284,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
startupCommand,
|
||||
terminalSettings,
|
||||
terminalBackend,
|
||||
serialConfig,
|
||||
sessionRef,
|
||||
hasConnectedRef,
|
||||
hasRunStartupCommandRef,
|
||||
@@ -336,6 +341,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onCommandExecuted,
|
||||
commandBufferRef,
|
||||
setIsSearchOpen,
|
||||
// Serial-specific options
|
||||
serialLocalEcho: serialConfig?.localEcho,
|
||||
serialLineMode: serialConfig?.lineMode,
|
||||
serialLineBufferRef,
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -346,7 +355,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const term = runtime.term;
|
||||
|
||||
if (host.protocol === "local" || host.hostname === "localhost") {
|
||||
if (host.protocol === "serial") {
|
||||
setStatus("connecting");
|
||||
setProgressLogs(["Initializing serial connection..."]);
|
||||
await sessionStarters.startSerial(term);
|
||||
} else if (host.protocol === "local" || host.hostname === "localhost") {
|
||||
setStatus("connecting");
|
||||
setProgressLogs(["Initializing local shell..."]);
|
||||
await sessionStarters.startLocal(term);
|
||||
@@ -407,21 +420,28 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Connection timeline and timeout visuals
|
||||
useEffect(() => {
|
||||
if (status !== "connecting" || auth.needsAuth) return;
|
||||
const scripted = [
|
||||
"Resolving host and keys...",
|
||||
"Negotiating ciphers...",
|
||||
"Exchanging keys...",
|
||||
"Authenticating user...",
|
||||
"Waiting for server greeting...",
|
||||
];
|
||||
let idx = 0;
|
||||
const stepTimer = setInterval(() => {
|
||||
setProgressLogs((prev) => {
|
||||
if (idx >= scripted.length) return prev;
|
||||
const next = scripted[idx++];
|
||||
return prev.includes(next) ? prev : [...prev, next];
|
||||
});
|
||||
}, 900);
|
||||
|
||||
// Only show SSH-specific scripted logs for SSH connections
|
||||
const isSSH = host.protocol !== "serial" && host.protocol !== "local" && host.protocol !== "telnet" && host.hostname !== "localhost";
|
||||
|
||||
let stepTimer: ReturnType<typeof setInterval> | undefined;
|
||||
if (isSSH) {
|
||||
const scripted = [
|
||||
"Resolving host and keys...",
|
||||
"Negotiating ciphers...",
|
||||
"Exchanging keys...",
|
||||
"Authenticating user...",
|
||||
"Waiting for server greeting...",
|
||||
];
|
||||
let idx = 0;
|
||||
stepTimer = setInterval(() => {
|
||||
setProgressLogs((prev) => {
|
||||
if (idx >= scripted.length) return prev;
|
||||
const next = scripted[idx++];
|
||||
return prev.includes(next) ? prev : [...prev, next];
|
||||
});
|
||||
}, 900);
|
||||
}
|
||||
|
||||
setTimeLeft(CONNECTION_TIMEOUT / 1000);
|
||||
const countdown = setInterval(() => {
|
||||
@@ -445,13 +465,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
clearInterval(stepTimer);
|
||||
if (stepTimer) clearInterval(stepTimer);
|
||||
clearInterval(countdown);
|
||||
clearTimeout(timeout);
|
||||
clearInterval(prog);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateStatus is a stable internal helper
|
||||
}, [status, auth.needsAuth]);
|
||||
}, [status, auth.needsAuth, host.protocol, host.hostname]);
|
||||
|
||||
const safeFit = () => {
|
||||
const fitAddon = fitAddonRef.current;
|
||||
@@ -822,11 +842,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
>
|
||||
<div className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]">
|
||||
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
|
||||
style={{
|
||||
backgroundColor: effectiveTheme.colors.background,
|
||||
color: effectiveTheme.colors.foreground,
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
|
||||
style={{
|
||||
backgroundColor: effectiveTheme.colors.background,
|
||||
color: effectiveTheme.colors.foreground,
|
||||
borderColor: `color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%)`,
|
||||
['--terminal-toolbar-fg' as never]: effectiveTheme.colors.foreground,
|
||||
['--terminal-toolbar-bg' as never]: effectiveTheme.colors.background,
|
||||
@@ -847,14 +867,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{inWorkspace && onToggleBroadcast && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
|
||||
"bg-transparent hover:bg-transparent",
|
||||
isBroadcastEnabled && "text-green-500",
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
|
||||
"bg-transparent hover:bg-transparent",
|
||||
isBroadcastEnabled && "text-green-500",
|
||||
)}
|
||||
onClick={onToggleBroadcast}
|
||||
title={
|
||||
isBroadcastEnabled
|
||||
@@ -866,22 +886,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
? t("terminal.toolbar.broadcastDisable")
|
||||
: t("terminal.toolbar.broadcastEnable")
|
||||
}
|
||||
>
|
||||
<Radio size={12} />
|
||||
</Button>
|
||||
)}
|
||||
{inWorkspace && !isFocusMode && onExpandToFocus && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
|
||||
onClick={onExpandToFocus}
|
||||
title={t("terminal.toolbar.focusMode")}
|
||||
aria-label={t("terminal.toolbar.focusMode")}
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<Radio size={12} />
|
||||
</Button>
|
||||
)}
|
||||
{inWorkspace && !isFocusMode && onExpandToFocus && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
|
||||
onClick={onExpandToFocus}
|
||||
title={t("terminal.toolbar.focusMode")}
|
||||
aria-label={t("terminal.toolbar.focusMode")}
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
{renderControls({ showClose: inWorkspace })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -972,6 +992,47 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
host={host}
|
||||
credentials={(() => {
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
|
||||
// Build proxy config if present
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: host.proxyConfig.password,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Build jump hosts array if host chain is configured
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds
|
||||
.map((hostId) => allHosts.find((h) => h.id === hostId))
|
||||
.filter((h): h is Host => !!h)
|
||||
.map((jumpHost) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpAuth.username || "root",
|
||||
password: jumpAuth.password,
|
||||
privateKey: jumpKey?.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
username: resolvedAuth.username,
|
||||
hostname: host.hostname,
|
||||
@@ -983,6 +1044,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
publicKey: resolvedAuth.key?.publicKey,
|
||||
keyId: resolvedAuth.keyId,
|
||||
keySource: resolvedAuth.key?.source,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
};
|
||||
})()}
|
||||
open={showSFTP && status === "connected"}
|
||||
|
||||
@@ -683,6 +683,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
terminalSettings={terminalSettings}
|
||||
sessionId={session.id}
|
||||
startupCommand={session.startupCommand}
|
||||
serialConfig={session.serialConfig}
|
||||
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
|
||||
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
TerminalSquare,
|
||||
Trash2,
|
||||
Upload,
|
||||
Usb,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
HostProtocol,
|
||||
Identity,
|
||||
KnownHost,
|
||||
SerialConfig,
|
||||
SSHKey,
|
||||
ShellHistoryEntry,
|
||||
Snippet,
|
||||
@@ -47,6 +49,7 @@ import KnownHostsManager from "./KnownHostsManager";
|
||||
import PortForwarding from "./PortForwardingNew";
|
||||
import QuickConnectWizard from "./QuickConnectWizard";
|
||||
import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../domain/quickConnect";
|
||||
import SerialConnectModal from "./SerialConnectModal";
|
||||
import SnippetsManager from "./SnippetsManager";
|
||||
import { ImportVaultDialog } from "./vault/ImportVaultDialog";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -90,6 +93,7 @@ interface VaultViewProps {
|
||||
onOpenSettings: () => void;
|
||||
onOpenQuickSwitcher: () => void;
|
||||
onCreateLocalTerminal: () => void;
|
||||
onConnectSerial?: (config: SerialConfig) => void;
|
||||
onDeleteHost: (id: string) => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onUpdateHosts: (hosts: Host[]) => void;
|
||||
@@ -124,6 +128,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onOpenSettings,
|
||||
onOpenQuickSwitcher,
|
||||
onCreateLocalTerminal,
|
||||
onConnectSerial,
|
||||
onDeleteHost,
|
||||
onConnect,
|
||||
onUpdateHosts,
|
||||
@@ -156,6 +161,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [renameGroupName, setRenameGroupName] = useState("");
|
||||
const [renameGroupError, setRenameGroupError] = useState<string | null>(null);
|
||||
const [isImportOpen, setIsImportOpen] = useState(false);
|
||||
const [isSerialModalOpen, setIsSerialModalOpen] = useState(false);
|
||||
|
||||
// Handle external navigation requests
|
||||
useEffect(() => {
|
||||
@@ -960,6 +966,18 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-10 px-3 app-no-drag",
|
||||
currentSection === "hosts" &&
|
||||
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
|
||||
)}
|
||||
onClick={() => setIsSerialModalOpen(true)}
|
||||
>
|
||||
<Usb size={14} className="mr-2" /> {t("serial.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
@@ -1356,6 +1374,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
)}
|
||||
allTags={allTags}
|
||||
allHosts={hosts}
|
||||
defaultGroup={editingHost ? undefined : selectedGroupPath}
|
||||
onSave={(host) => {
|
||||
onUpdateHosts(
|
||||
editingHost
|
||||
@@ -1503,6 +1522,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Serial Connect Modal */}
|
||||
<SerialConnectModal
|
||||
open={isSerialModalOpen}
|
||||
onClose={() => setIsSerialModalOpen(false)}
|
||||
onConnect={(config) => {
|
||||
if (onConnectSerial) {
|
||||
onConnectSerial(config);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Check, Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { AlertCircle, Check, Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import type {
|
||||
CursorShape,
|
||||
LinkModifier,
|
||||
@@ -93,6 +93,87 @@ export default function SettingsTerminalTab(props: {
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
|
||||
// Local shell settings state
|
||||
const [defaultShell, setDefaultShell] = useState<string>("");
|
||||
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
|
||||
// Fetch default shell on mount
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (bridge?.getDefaultShell) {
|
||||
bridge.getDefaultShell().then((shell) => {
|
||||
setDefaultShell(shell);
|
||||
}).catch(() => {
|
||||
// Ignore errors - might not be in Electron
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Validate shell path when it changes
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
const shellPath = terminalSettings.localShell;
|
||||
|
||||
if (!shellPath) {
|
||||
setShellValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bridge?.validatePath) {
|
||||
setShellValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
bridge.validatePath(shellPath, 'file').then((result) => {
|
||||
if (result.exists && result.isFile) {
|
||||
setShellValidation({ valid: true });
|
||||
} else if (result.exists && result.isDirectory) {
|
||||
setShellValidation({ valid: false, message: t("settings.terminal.localShell.shell.isDirectory") });
|
||||
} else {
|
||||
setShellValidation({ valid: false, message: t("settings.terminal.localShell.shell.notFound") });
|
||||
}
|
||||
}).catch(() => {
|
||||
setShellValidation(null);
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [terminalSettings.localShell, t]);
|
||||
|
||||
// Validate directory path when it changes
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
const dirPath = terminalSettings.localStartDir;
|
||||
|
||||
if (!dirPath) {
|
||||
setDirValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bridge?.validatePath) {
|
||||
setDirValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
bridge.validatePath(dirPath, 'directory').then((result) => {
|
||||
if (result.exists && result.isDirectory) {
|
||||
setDirValidation({ valid: true });
|
||||
} else if (result.exists && result.isFile) {
|
||||
setDirValidation({ valid: false, message: t("settings.terminal.localShell.startDir.isFile") });
|
||||
} else {
|
||||
setDirValidation({ valid: false, message: t("settings.terminal.localShell.startDir.notFound") });
|
||||
}
|
||||
}).catch(() => {
|
||||
setDirValidation(null);
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [terminalSettings.localStartDir, t]);
|
||||
|
||||
const clampFontSize = useCallback((next: number) => {
|
||||
const safe = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, next));
|
||||
setTerminalFontSize(safe);
|
||||
@@ -443,6 +524,60 @@ export default function SettingsTerminalTab(props: {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.localShell")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.localShell.shell")}
|
||||
description={t("settings.terminal.localShell.shell.desc")}
|
||||
>
|
||||
<div className="flex flex-col gap-1 items-end">
|
||||
<Input
|
||||
value={terminalSettings.localShell}
|
||||
placeholder={t("settings.terminal.localShell.shell.placeholder")}
|
||||
onChange={(e) => updateTerminalSetting("localShell", e.target.value)}
|
||||
className={cn(
|
||||
"w-48",
|
||||
shellValidation && !shellValidation.valid && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
/>
|
||||
{defaultShell && !terminalSettings.localShell && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.localShell.shell.detected")}: {defaultShell}
|
||||
</span>
|
||||
)}
|
||||
{shellValidation && !shellValidation.valid && shellValidation.message && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
{shellValidation.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.localShell.startDir")}
|
||||
description={t("settings.terminal.localShell.startDir.desc")}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Input
|
||||
value={terminalSettings.localStartDir}
|
||||
placeholder={t("settings.terminal.localShell.startDir.placeholder")}
|
||||
onChange={(e) => updateTerminalSetting("localStartDir", e.target.value)}
|
||||
className={cn(
|
||||
"w-48",
|
||||
dirValidation && !dirValidation.valid && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
/>
|
||||
{dirValidation && !dirValidation.valid && dirValidation.message && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
{dirValidation.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,27 @@
|
||||
* SFTP Breadcrumb navigation component
|
||||
*/
|
||||
|
||||
import { ChevronRight,Home } from 'lucide-react';
|
||||
import React,{ memo } from 'react';
|
||||
import { ChevronRight, Home, MoreHorizontal } from 'lucide-react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface SftpBreadcrumbProps {
|
||||
path: string;
|
||||
onNavigate: (path: string) => void;
|
||||
onHome: () => void;
|
||||
/** Maximum number of visible path segments before truncation (default: 4) */
|
||||
maxVisibleParts?: number;
|
||||
}
|
||||
|
||||
const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({ path, onNavigate, onHome }) => {
|
||||
const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
|
||||
path,
|
||||
onNavigate,
|
||||
onHome,
|
||||
maxVisibleParts = 4
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Handle both Windows (C:\path) and Unix (/path) style paths
|
||||
const isWindowsPath = /^[A-Za-z]:/.test(path);
|
||||
const separator = isWindowsPath ? /[\\/]/ : /\//;
|
||||
@@ -26,27 +36,73 @@ const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({ path, onNavigate,
|
||||
return '/' + parts.slice(0, index + 1).join('/');
|
||||
};
|
||||
|
||||
// Determine which parts to show (always truncate, no expansion)
|
||||
const { visibleParts, hiddenParts, needsTruncation } = useMemo(() => {
|
||||
if (parts.length <= maxVisibleParts) {
|
||||
return {
|
||||
visibleParts: parts.map((part, idx) => ({ part, originalIndex: idx })),
|
||||
hiddenParts: [] as { part: string; originalIndex: number }[],
|
||||
needsTruncation: false
|
||||
};
|
||||
}
|
||||
|
||||
// Show first part + ellipsis + last (maxVisibleParts - 1) parts
|
||||
const firstPart = [{ part: parts[0], originalIndex: 0 }];
|
||||
const lastPartsCount = maxVisibleParts - 1;
|
||||
const lastParts = parts.slice(-lastPartsCount).map((part, idx) => ({
|
||||
part,
|
||||
originalIndex: parts.length - lastPartsCount + idx
|
||||
}));
|
||||
const hidden = parts.slice(1, -lastPartsCount).map((part, idx) => ({
|
||||
part,
|
||||
originalIndex: idx + 1
|
||||
}));
|
||||
|
||||
return {
|
||||
visibleParts: [...firstPart, ...lastParts],
|
||||
hiddenParts: hidden,
|
||||
needsTruncation: true
|
||||
};
|
||||
}, [parts, maxVisibleParts]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground overflow-x-auto scrollbar-none">
|
||||
<div
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground overflow-hidden"
|
||||
title={path}
|
||||
>
|
||||
<button
|
||||
onClick={onHome}
|
||||
className="hover:text-foreground p-1 rounded hover:bg-secondary/60 shrink-0"
|
||||
title="Go to home"
|
||||
title={t("sftp.goHome")}
|
||||
>
|
||||
<Home size={12} />
|
||||
</button>
|
||||
<ChevronRight size={12} className="opacity-40 shrink-0" />
|
||||
{parts.map((part, idx) => {
|
||||
const partPath = buildPath(idx);
|
||||
const isLast = idx === parts.length - 1;
|
||||
{visibleParts.map(({ part, originalIndex }, displayIdx) => {
|
||||
const partPath = buildPath(originalIndex);
|
||||
const isLast = originalIndex === parts.length - 1;
|
||||
const showEllipsisBefore = needsTruncation && displayIdx === 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={partPath}>
|
||||
{showEllipsisBefore && (
|
||||
<>
|
||||
<span
|
||||
className="px-1 py-0.5 shrink-0 flex items-center text-muted-foreground cursor-default"
|
||||
title={`${t("sftp.showHiddenPaths")}: ${hiddenParts.map(h => h.part).join(' > ')}`}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</span>
|
||||
<ChevronRight size={12} className="opacity-40 shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onNavigate(partPath)}
|
||||
className={cn(
|
||||
"hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 truncate max-w-[120px]",
|
||||
"hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 truncate max-w-[120px] shrink-0",
|
||||
isLast && "text-foreground font-medium"
|
||||
)}
|
||||
title={part}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* SFTP File row component for file list
|
||||
*/
|
||||
|
||||
import { Folder } from 'lucide-react';
|
||||
import { Folder, Link } from 'lucide-react';
|
||||
import React,{ memo } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { SftpFileEntry } from '../../types';
|
||||
import { ColumnWidths,formatBytes,formatDate,getFileIcon } from './utils';
|
||||
import { ColumnWidths,formatBytes,formatDate,getFileIcon,isNavigableDirectory } from './utils';
|
||||
|
||||
interface SftpFileRowProps {
|
||||
entry: SftpFileEntry;
|
||||
@@ -36,6 +36,9 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
onDrop,
|
||||
}) => {
|
||||
const isParentDir = entry.name === '..';
|
||||
// A symlink pointing to a directory behaves like a directory (navigable, accepts drops)
|
||||
const isNavDir = isNavigableDirectory(entry);
|
||||
const isSymlinkToDirectory = entry.type === 'symlink' && entry.linkTarget === 'directory';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -50,25 +53,32 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
className={cn(
|
||||
"px-4 py-2 items-center cursor-pointer text-sm transition-colors",
|
||||
isSelected ? "bg-primary/15 text-foreground" : "hover:bg-secondary/40",
|
||||
isDragOver && entry.type === 'directory' && "bg-primary/25 ring-1 ring-primary/50"
|
||||
isDragOver && isNavDir && "bg-primary/25 ring-1 ring-primary/50"
|
||||
)}
|
||||
style={{ display: 'grid', gridTemplateColumns: `${columnWidths.name}% ${columnWidths.modified}% ${columnWidths.size}% ${columnWidths.type}%` }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={cn(
|
||||
"h-7 w-7 rounded flex items-center justify-center shrink-0",
|
||||
entry.type === 'directory' ? "bg-primary/10 text-primary" : "bg-secondary/60 text-muted-foreground"
|
||||
"h-7 w-7 rounded flex items-center justify-center shrink-0 relative",
|
||||
isNavDir ? "bg-primary/10 text-primary" : "bg-secondary/60 text-muted-foreground"
|
||||
)}>
|
||||
{entry.type === 'directory' ? <Folder size={14} /> : getFileIcon(entry)}
|
||||
{isNavDir ? <Folder size={14} /> : getFileIcon(entry)}
|
||||
{/* Show link indicator for symlinks */}
|
||||
{entry.type === 'symlink' && (
|
||||
<Link size={8} className="absolute -bottom-0.5 -right-0.5 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate">{entry.name}</span>
|
||||
<span className={cn("truncate", entry.type === 'symlink' && "italic")}>
|
||||
{entry.name}
|
||||
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate">{formatDate(entry.lastModified)}</span>
|
||||
<span className="text-xs text-muted-foreground truncate text-right">
|
||||
{entry.type === 'directory' ? '--' : formatBytes(entry.size)}
|
||||
{isNavDir ? '--' : formatBytes(entry.size)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate capitalize text-right">
|
||||
{entry.type === 'directory' ? 'folder' : entry.name.split('.').pop()?.toLowerCase() || 'file'}
|
||||
{isSymlinkToDirectory ? 'link → folder' : entry.type === 'directory' ? 'folder' : entry.type === 'symlink' ? 'link' : entry.name.split('.').pop()?.toLowerCase() || 'file'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -44,10 +44,13 @@ export const SftpHostPicker: React.FC<SftpHostPickerProps> = ({
|
||||
).sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [hosts, hostSearch]);
|
||||
const sideLabel = side === 'left' ? t('common.left') : t('common.right');
|
||||
const items = useMemo(() => {
|
||||
return [{ type: 'local' as const, id: 'local' }].concat(
|
||||
filteredHosts.map((host) => ({ type: 'host' as const, id: host.id, host }))
|
||||
);
|
||||
|
||||
type PickerItem = { type: 'local'; id: string } | { type: 'host'; id: string; host: Host };
|
||||
|
||||
const items = useMemo<PickerItem[]>(() => {
|
||||
const localItem: PickerItem = { type: 'local', id: 'local' };
|
||||
const hostItems: PickerItem[] = filteredHosts.map((host) => ({ type: 'host', id: host.id, host }));
|
||||
return [localItem, ...hostItems];
|
||||
}, [filteredHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,7 +65,7 @@ export const SftpHostPicker: React.FC<SftpHostPickerProps> = ({
|
||||
setSelectedIndex(0);
|
||||
}, [hostSearch, open]);
|
||||
|
||||
const handleSelect = (item: typeof items[number]) => {
|
||||
const handleSelect = (item: PickerItem) => {
|
||||
if (item.type === 'local') {
|
||||
onSelectLocal();
|
||||
} else {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Utilities
|
||||
export {
|
||||
formatBytes,formatDate,
|
||||
formatSpeed,formatTransferBytes,getFileIcon,type ColumnWidths,type SortField,
|
||||
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,type ColumnWidths,type SortField,
|
||||
type SortOrder
|
||||
} from './utils';
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import {
|
||||
Database,
|
||||
ExternalLink,
|
||||
File,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
@@ -73,6 +74,11 @@ export const formatSpeed = (bytesPerSecond: number): string => {
|
||||
*/
|
||||
export const getFileIcon = (entry: SftpFileEntry): React.ReactElement => {
|
||||
if (entry.type === 'directory') return React.createElement(Folder, { size: 14 });
|
||||
|
||||
// For symlink files (not directories), show a special symlink icon
|
||||
if (entry.type === 'symlink' && entry.linkTarget !== 'directory') {
|
||||
return React.createElement(ExternalLink, { size: 14, className: "text-cyan-500" });
|
||||
}
|
||||
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
@@ -173,3 +179,11 @@ export interface ColumnWidths {
|
||||
size: number;
|
||||
type: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an entry is navigable like a directory
|
||||
* This includes regular directories and symlinks that point to directories
|
||||
*/
|
||||
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
|
||||
return entry.type === 'directory' || (entry.type === 'symlink' && entry.linkTarget === 'directory');
|
||||
};
|
||||
|
||||
@@ -58,6 +58,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
const buttonBase = "h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent";
|
||||
|
||||
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
|
||||
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
|
||||
const hidesSftp = isLocalTerminal || isSerialTerminal;
|
||||
|
||||
const currentThemeId = host?.theme || defaultThemeId;
|
||||
const currentFontFamilyId = host?.fontFamily || defaultFontFamilyId;
|
||||
@@ -95,17 +97,19 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
disabled={status !== 'connected'}
|
||||
title={status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
|
||||
aria-label={t("terminal.toolbar.openSftp")}
|
||||
onClick={onOpenSFTP}
|
||||
>
|
||||
<FolderInput size={12} />
|
||||
</Button>
|
||||
{!hidesSftp && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
disabled={status !== 'connected'}
|
||||
title={status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
|
||||
aria-label={t("terminal.toolbar.openSftp")}
|
||||
onClick={onOpenSFTP}
|
||||
>
|
||||
<FolderInput size={12} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Popover open={isScriptsOpen} onOpenChange={setIsScriptsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { Dispatch, RefObject, SetStateAction } from "react";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import type { Host, Identity, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
|
||||
import type { Host, Identity, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
|
||||
import { resolveHostAuth } from "../../../domain/sshAuth";
|
||||
|
||||
type TerminalBackendApi = {
|
||||
@@ -11,6 +11,7 @@ type TerminalBackendApi = {
|
||||
telnetAvailable: () => boolean;
|
||||
moshAvailable: () => boolean;
|
||||
localAvailable: () => boolean;
|
||||
serialAvailable: () => boolean;
|
||||
execAvailable: () => boolean;
|
||||
startSSHSession: (options: NetcattySSHOptions) => Promise<string>;
|
||||
startTelnetSession: (
|
||||
@@ -22,6 +23,9 @@ type TerminalBackendApi = {
|
||||
startLocalSession: (
|
||||
options: Parameters<NonNullable<NetcattyBridge["startLocalSession"]>>[0],
|
||||
) => Promise<string>;
|
||||
startSerialSession: (
|
||||
options: Parameters<NonNullable<NetcattyBridge["startSerialSession"]>>[0],
|
||||
) => Promise<string>;
|
||||
execCommand: (options: Parameters<NetcattyBridge["execCommand"]>[0]) => Promise<{
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
@@ -61,6 +65,7 @@ export type TerminalSessionStartersContext = {
|
||||
startupCommand?: string;
|
||||
terminalSettings?: TerminalSettings;
|
||||
terminalBackend: TerminalBackendApi;
|
||||
serialConfig?: SerialConfig;
|
||||
|
||||
sessionRef: RefObject<string | null>;
|
||||
hasConnectedRef: RefObject<boolean>;
|
||||
@@ -114,12 +119,21 @@ const attachSessionToTerminal = (
|
||||
opts?: {
|
||||
onExitMessage?: (evt: { exitCode?: number; signal?: number }) => string;
|
||||
onConnected?: () => void;
|
||||
// For serial: convert lone LF to CRLF to avoid "staircase effect"
|
||||
convertLfToCrlf?: boolean;
|
||||
},
|
||||
) => {
|
||||
ctx.sessionRef.current = id;
|
||||
|
||||
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
|
||||
term.write(ctx.highlightProcessorRef.current(chunk));
|
||||
let data = chunk;
|
||||
// Convert lone LF (\n) to CRLF (\r\n) for proper terminal display
|
||||
// This prevents the "staircase effect" common in serial terminals
|
||||
if (opts?.convertLfToCrlf) {
|
||||
// Replace \n that is not preceded by \r with \r\n
|
||||
data = data.replace(/(?<!\r)\n/g, "\r\n");
|
||||
}
|
||||
term.write(ctx.highlightProcessorRef.current(data));
|
||||
if (!ctx.hasConnectedRef.current) {
|
||||
ctx.updateStatus("connected");
|
||||
opts?.onConnected?.();
|
||||
@@ -521,10 +535,16 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}
|
||||
|
||||
try {
|
||||
// Get local shell configuration from terminal settings
|
||||
const localShell = ctx.terminalSettings?.localShell;
|
||||
const localStartDir = ctx.terminalSettings?.localStartDir;
|
||||
|
||||
const id = await ctx.terminalBackend.startLocalSession({
|
||||
sessionId: ctx.sessionId,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
shell: localShell,
|
||||
cwd: localStartDir,
|
||||
env: {
|
||||
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
|
||||
},
|
||||
@@ -584,5 +604,50 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}
|
||||
};
|
||||
|
||||
return { startSSH, startTelnet, startMosh, startLocal };
|
||||
// Start Serial session
|
||||
const startSerial = async (term: XTerm) => {
|
||||
if (!ctx.serialConfig) {
|
||||
ctx.setError("No serial configuration provided");
|
||||
term.writeln("\r\n[Error: No serial configuration provided]");
|
||||
ctx.updateStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info("[Serial] Starting serial session", {
|
||||
port: ctx.serialConfig.path,
|
||||
baudRate: ctx.serialConfig.baudRate,
|
||||
});
|
||||
|
||||
const id = await ctx.terminalBackend.startSerialSession({
|
||||
sessionId: ctx.sessionId,
|
||||
path: ctx.serialConfig.path,
|
||||
baudRate: ctx.serialConfig.baudRate,
|
||||
dataBits: ctx.serialConfig.dataBits,
|
||||
stopBits: ctx.serialConfig.stopBits,
|
||||
parity: ctx.serialConfig.parity,
|
||||
flowControl: ctx.serialConfig.flowControl,
|
||||
});
|
||||
|
||||
// Serial connection is established immediately when session starts
|
||||
// Update status right away since serial ports don't require handshake
|
||||
ctx.updateStatus("connected");
|
||||
ctx.setProgressValue(100);
|
||||
term.writeln(`[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
|
||||
|
||||
attachSessionToTerminal(ctx, term, id, {
|
||||
onExitMessage: (evt) =>
|
||||
`\r\n[serial port closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||||
// Convert lone LF to CRLF to prevent "staircase effect" in serial terminals
|
||||
convertLfToCrlf: true,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ctx.setError(message);
|
||||
term.writeln(`\r\n[Failed to connect to serial port: ${message}]`);
|
||||
ctx.updateStatus("disconnected");
|
||||
}
|
||||
};
|
||||
|
||||
return { startSSH, startTelnet, startMosh, startLocal, startSerial };
|
||||
};
|
||||
|
||||
@@ -71,6 +71,11 @@ export type CreateXTermRuntimeContext = {
|
||||
) => void;
|
||||
commandBufferRef: RefObject<string>;
|
||||
setIsSearchOpen: Dispatch<SetStateAction<boolean>>;
|
||||
|
||||
// Serial-specific options
|
||||
serialLocalEcho?: boolean;
|
||||
serialLineMode?: boolean;
|
||||
serialLineBufferRef?: RefObject<string>;
|
||||
};
|
||||
|
||||
const detectPlatform = (): XTermPlatform => {
|
||||
@@ -397,7 +402,64 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
term.onData((data) => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) {
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
// Serial line mode: buffer input and send on Enter
|
||||
if (ctx.host.protocol === "serial" && ctx.serialLineMode && ctx.serialLineBufferRef) {
|
||||
if (data === "\r") {
|
||||
// Enter key: send buffered line + CR
|
||||
const line = ctx.serialLineBufferRef.current + "\r";
|
||||
ctx.terminalBackend.writeToSession(id, line);
|
||||
ctx.serialLineBufferRef.current = "";
|
||||
// Local echo newline if enabled
|
||||
if (ctx.serialLocalEcho) {
|
||||
term.write("\r\n");
|
||||
}
|
||||
} else if (data === "\x7f" || data === "\b") {
|
||||
// Backspace: remove last character from buffer
|
||||
if (ctx.serialLineBufferRef.current.length > 0) {
|
||||
ctx.serialLineBufferRef.current = ctx.serialLineBufferRef.current.slice(0, -1);
|
||||
if (ctx.serialLocalEcho) {
|
||||
term.write("\b \b");
|
||||
}
|
||||
}
|
||||
} else if (data === "\x03") {
|
||||
// Ctrl+C: clear buffer and send Ctrl+C
|
||||
ctx.serialLineBufferRef.current = "";
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
if (ctx.serialLocalEcho) {
|
||||
term.write("^C\r\n");
|
||||
}
|
||||
} else if (data === "\x15") {
|
||||
// Ctrl+U: clear line buffer
|
||||
if (ctx.serialLocalEcho && ctx.serialLineBufferRef.current.length > 0) {
|
||||
// Erase the displayed line
|
||||
const len = ctx.serialLineBufferRef.current.length;
|
||||
term.write("\b \b".repeat(len));
|
||||
}
|
||||
ctx.serialLineBufferRef.current = "";
|
||||
} else if (data.charCodeAt(0) >= 32 || data.length > 1) {
|
||||
// Regular characters: add to buffer
|
||||
ctx.serialLineBufferRef.current += data;
|
||||
if (ctx.serialLocalEcho) {
|
||||
term.write(data);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Character mode (default): send immediately
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
|
||||
// Local echo for serial connections only when explicitly enabled
|
||||
if (ctx.host.protocol === "serial" && ctx.serialLocalEcho) {
|
||||
if (data === "\r") {
|
||||
term.write("\r\n");
|
||||
} else if (data === "\x7f" || data === "\b") {
|
||||
term.write("\b \b");
|
||||
} else if (data === "\x03") {
|
||||
term.write("^C");
|
||||
} else if (data.charCodeAt(0) >= 32 || data.length > 1) {
|
||||
term.write(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||||
ctx.onBroadcastInputRef.current(data, ctx.sessionId);
|
||||
|
||||
@@ -23,7 +23,22 @@ export interface EnvVar {
|
||||
}
|
||||
|
||||
// Protocol type for connections
|
||||
export type HostProtocol = 'ssh' | 'telnet' | 'mosh' | 'local';
|
||||
export type HostProtocol = 'ssh' | 'telnet' | 'mosh' | 'local' | 'serial';
|
||||
|
||||
// Serial port configuration
|
||||
export type SerialParity = 'none' | 'even' | 'odd' | 'mark' | 'space';
|
||||
export type SerialFlowControl = 'none' | 'xon/xoff' | 'rts/cts';
|
||||
|
||||
export interface SerialConfig {
|
||||
path: string; // Serial port path (e.g., /dev/ttyUSB0, COM1)
|
||||
baudRate: number; // Baud rate (e.g., 9600, 115200)
|
||||
dataBits?: 5 | 6 | 7 | 8; // Data bits (default: 8)
|
||||
stopBits?: 1 | 1.5 | 2; // Stop bits (default: 1)
|
||||
parity?: SerialParity; // Parity (default: 'none')
|
||||
flowControl?: SerialFlowControl; // Flow control (default: 'none')
|
||||
localEcho?: boolean; // Force local echo (default: false, rely on remote echo)
|
||||
lineMode?: boolean; // Line mode - buffer input and send on Enter (default: false)
|
||||
}
|
||||
|
||||
// Per-protocol configuration
|
||||
export interface ProtocolConfig {
|
||||
@@ -48,7 +63,7 @@ export interface Host {
|
||||
tags: string[];
|
||||
os: 'linux' | 'windows' | 'macos';
|
||||
identityFileId?: string; // Reference to SSHKey
|
||||
protocol?: 'ssh' | 'telnet' | 'local'; // Default/primary protocol
|
||||
protocol?: 'ssh' | 'telnet' | 'local' | 'serial'; // Default/primary protocol
|
||||
password?: string;
|
||||
authMethod?: 'password' | 'key' | 'certificate';
|
||||
agentForwarding?: boolean;
|
||||
@@ -345,6 +360,9 @@ export interface TerminalSettings {
|
||||
// Keyboard
|
||||
altAsMeta: boolean; // Use ⌥ as the Meta key
|
||||
scrollOnInput: boolean; // Scroll terminal to bottom on input
|
||||
scrollOnOutput: boolean; // Scroll terminal to bottom on output
|
||||
scrollOnKeyPress: boolean; // Scroll terminal to bottom on key press
|
||||
scrollOnPaste: boolean; // Scroll terminal to bottom on paste
|
||||
|
||||
// Mouse
|
||||
rightClickBehavior: RightClickBehavior;
|
||||
@@ -356,6 +374,10 @@ export interface TerminalSettings {
|
||||
// Keyword Highlighting
|
||||
keywordHighlightEnabled: boolean;
|
||||
keywordHighlightRules: KeywordHighlightRule[];
|
||||
|
||||
// Local Shell Configuration
|
||||
localShell: string; // Path to shell executable (empty = system default)
|
||||
localStartDir: string; // Starting directory for local terminal (empty = home directory)
|
||||
}
|
||||
|
||||
export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
|
||||
@@ -381,6 +403,9 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
minimumContrastRatio: 1,
|
||||
altAsMeta: false,
|
||||
scrollOnInput: true,
|
||||
scrollOnOutput: false,
|
||||
scrollOnKeyPress: false,
|
||||
scrollOnPaste: true,
|
||||
rightClickBehavior: 'context-menu',
|
||||
copyOnSelect: false,
|
||||
middleClickPaste: true,
|
||||
@@ -388,6 +413,8 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
linkModifier: 'none',
|
||||
keywordHighlightEnabled: true,
|
||||
keywordHighlightRules: DEFAULT_KEYWORD_HIGHLIGHT_RULES,
|
||||
localShell: '', // Empty = use system default
|
||||
localStartDir: '', // Empty = use home directory
|
||||
};
|
||||
|
||||
export interface TerminalTheme {
|
||||
@@ -428,16 +455,19 @@ export interface TerminalSession {
|
||||
workspaceId?: string;
|
||||
startupCommand?: string; // Command to run after connection (for snippet runner)
|
||||
// Connection-time protocol overrides (used instead of looking up from hosts)
|
||||
protocol?: 'ssh' | 'telnet' | 'local';
|
||||
protocol?: 'ssh' | 'telnet' | 'local' | 'serial';
|
||||
port?: number;
|
||||
moshEnabled?: boolean;
|
||||
// Serial-specific connection settings
|
||||
serialConfig?: SerialConfig;
|
||||
}
|
||||
|
||||
export interface RemoteFile {
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
type: 'file' | 'directory' | 'symlink';
|
||||
size: string;
|
||||
lastModified: string;
|
||||
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
|
||||
}
|
||||
|
||||
export type WorkspaceNode =
|
||||
@@ -476,6 +506,7 @@ export interface SftpFileEntry {
|
||||
permissions?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
|
||||
}
|
||||
|
||||
export interface SftpConnection {
|
||||
|
||||
@@ -373,6 +373,7 @@ export const SYNC_STORAGE_KEYS = {
|
||||
PROVIDER_ONEDRIVE: 'netcatty_provider_onedrive_v1',
|
||||
PROVIDER_WEBDAV: 'netcatty_provider_webdav_v1',
|
||||
PROVIDER_S3: 'netcatty_provider_s3_v1',
|
||||
PROVIDER_SMB: 'netcatty_provider_smb_v1',
|
||||
LOCAL_SYNC_META: 'netcatty_local_sync_meta_v1',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const os = require("node:os");
|
||||
|
||||
/**
|
||||
* List files in a local directory
|
||||
* Properly handles symlinks by resolving their target type
|
||||
*/
|
||||
async function listLocalDir(event, payload) {
|
||||
const dirPath = payload.path;
|
||||
@@ -27,19 +28,53 @@ async function listLocalDir(event, payload) {
|
||||
const entry = entries[i];
|
||||
try {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
// fs.promises.stat follows symlinks, so we get the target's stats
|
||||
const stat = await fs.promises.stat(fullPath);
|
||||
|
||||
let type;
|
||||
let linkTarget = null;
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
// This is a symlink - mark it as such and record the target type
|
||||
type = "symlink";
|
||||
// stat follows symlinks, so stat.isDirectory() tells us if target is a directory
|
||||
linkTarget = stat.isDirectory() ? "directory" : "file";
|
||||
} else if (entry.isDirectory()) {
|
||||
type = "directory";
|
||||
} else {
|
||||
type = "file";
|
||||
}
|
||||
|
||||
result[i] = {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory()
|
||||
? "directory"
|
||||
: entry.isSymbolicLink()
|
||||
? "symlink"
|
||||
: "file",
|
||||
type,
|
||||
linkTarget,
|
||||
size: `${stat.size} bytes`,
|
||||
lastModified: stat.mtime.toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn(`Could not stat ${entry.name}:`, err.message);
|
||||
// Handle broken symlinks - lstat doesn't follow symlinks
|
||||
if (err.code === 'ENOENT' || err.code === 'ELOOP') {
|
||||
const brokenEntry = entries[i];
|
||||
try {
|
||||
const fullPath = path.join(dirPath, brokenEntry.name);
|
||||
const lstat = await fs.promises.lstat(fullPath);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
// Broken symlink
|
||||
result[i] = {
|
||||
name: brokenEntry.name,
|
||||
type: "symlink",
|
||||
linkTarget: null, // Broken link - target unknown
|
||||
size: `${lstat.size} bytes`,
|
||||
lastModified: lstat.mtime.toISOString(),
|
||||
};
|
||||
return;
|
||||
}
|
||||
} catch (lstatErr) {
|
||||
console.warn(`Could not lstat ${brokenEntry.name}:`, lstatErr.message);
|
||||
}
|
||||
}
|
||||
console.warn(`Could not stat ${entries[i].name}:`, err.message);
|
||||
result[i] = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,6 +313,28 @@ async function listPortForwards() {
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all active port forwards (cleanup on app quit)
|
||||
*/
|
||||
function stopAllPortForwards() {
|
||||
console.log(`[PortForward] Stopping all ${portForwardingTunnels.size} active tunnels...`);
|
||||
for (const [tunnelId, tunnel] of portForwardingTunnels) {
|
||||
try {
|
||||
if (tunnel.server) {
|
||||
tunnel.server.close();
|
||||
}
|
||||
if (tunnel.conn) {
|
||||
tunnel.conn.end();
|
||||
}
|
||||
console.log(`[PortForward] Stopped tunnel ${tunnelId}`);
|
||||
} catch (err) {
|
||||
console.warn(`[PortForward] Failed to stop tunnel ${tunnelId}:`, err.message);
|
||||
}
|
||||
}
|
||||
portForwardingTunnels.clear();
|
||||
console.log('[PortForward] All tunnels stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for port forwarding operations
|
||||
*/
|
||||
@@ -329,4 +351,5 @@ module.exports = {
|
||||
stopPortForward,
|
||||
getPortForwardStatus,
|
||||
listPortForwards,
|
||||
stopAllPortForwards,
|
||||
};
|
||||
|
||||
@@ -6,13 +6,18 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const net = require("node:net");
|
||||
const SftpClient = require("ssh2-sftp-client");
|
||||
const { Client: SSHClient } = require("ssh2");
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
|
||||
// SFTP clients storage - shared reference passed from main
|
||||
let sftpClients = null;
|
||||
let electronModule = null;
|
||||
|
||||
// Storage for jump host connections that need to be cleaned up
|
||||
const jumpConnectionsMap = new Map(); // connId -> { connections: SSHClient[], socket: stream }
|
||||
|
||||
/**
|
||||
* Initialize the SFTP bridge with dependencies
|
||||
*/
|
||||
@@ -21,18 +26,312 @@ function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a socket through a proxy (HTTP CONNECT or SOCKS5)
|
||||
* Reused from sshBridge.cjs
|
||||
*/
|
||||
function createProxySocket(proxy, targetHost, targetPort) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (proxy.type === 'http') {
|
||||
// HTTP CONNECT proxy
|
||||
const socket = net.connect(proxy.port, proxy.host, () => {
|
||||
let authHeader = '';
|
||||
if (proxy.username && proxy.password) {
|
||||
const auth = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64');
|
||||
authHeader = `Proxy-Authorization: Basic ${auth}\r\n`;
|
||||
}
|
||||
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`;
|
||||
socket.write(connectRequest);
|
||||
|
||||
let response = '';
|
||||
const onData = (data) => {
|
||||
response += data.toString();
|
||||
if (response.includes('\r\n\r\n')) {
|
||||
socket.removeListener('data', onData);
|
||||
if (response.startsWith('HTTP/1.1 200') || response.startsWith('HTTP/1.0 200')) {
|
||||
resolve(socket);
|
||||
} else {
|
||||
socket.destroy();
|
||||
reject(new Error(`HTTP proxy error: ${response.split('\r\n')[0]}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
socket.on('data', onData);
|
||||
});
|
||||
socket.on('error', reject);
|
||||
} else if (proxy.type === 'socks5') {
|
||||
// SOCKS5 proxy
|
||||
const socket = net.connect(proxy.port, proxy.host, () => {
|
||||
// SOCKS5 greeting
|
||||
const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00];
|
||||
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]));
|
||||
|
||||
let step = 'greeting';
|
||||
const onData = (data) => {
|
||||
if (step === 'greeting') {
|
||||
if (data[0] !== 0x05) {
|
||||
socket.destroy();
|
||||
reject(new Error('Invalid SOCKS5 response'));
|
||||
return;
|
||||
}
|
||||
const method = data[1];
|
||||
if (method === 0x02 && proxy.username && proxy.password) {
|
||||
// Username/password auth
|
||||
step = 'auth';
|
||||
const userBuf = Buffer.from(proxy.username);
|
||||
const passBuf = Buffer.from(proxy.password);
|
||||
socket.write(Buffer.concat([
|
||||
Buffer.from([0x01, userBuf.length]),
|
||||
userBuf,
|
||||
Buffer.from([passBuf.length]),
|
||||
passBuf
|
||||
]));
|
||||
} else if (method === 0x00) {
|
||||
// No auth, proceed to connect
|
||||
step = 'connect';
|
||||
sendConnectRequest();
|
||||
} else {
|
||||
socket.destroy();
|
||||
reject(new Error('SOCKS5 authentication method not supported'));
|
||||
}
|
||||
} else if (step === 'auth') {
|
||||
if (data[1] !== 0x00) {
|
||||
socket.destroy();
|
||||
reject(new Error('SOCKS5 authentication failed'));
|
||||
return;
|
||||
}
|
||||
step = 'connect';
|
||||
sendConnectRequest();
|
||||
} else if (step === 'connect') {
|
||||
socket.removeListener('data', onData);
|
||||
if (data[1] === 0x00) {
|
||||
resolve(socket);
|
||||
} else {
|
||||
const errors = {
|
||||
0x01: 'General failure',
|
||||
0x02: 'Connection not allowed',
|
||||
0x03: 'Network unreachable',
|
||||
0x04: 'Host unreachable',
|
||||
0x05: 'Connection refused',
|
||||
0x06: 'TTL expired',
|
||||
0x07: 'Command not supported',
|
||||
0x08: 'Address type not supported',
|
||||
};
|
||||
socket.destroy();
|
||||
reject(new Error(`SOCKS5 error: ${errors[data[1]] || 'Unknown'}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sendConnectRequest = () => {
|
||||
// SOCKS5 connect request
|
||||
const hostBuf = Buffer.from(targetHost);
|
||||
const request = Buffer.concat([
|
||||
Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
|
||||
hostBuf,
|
||||
Buffer.from([(targetPort >> 8) & 0xff, targetPort & 0xff])
|
||||
]);
|
||||
socket.write(request);
|
||||
};
|
||||
|
||||
socket.on('data', onData);
|
||||
});
|
||||
socket.on('error', reject);
|
||||
} else {
|
||||
reject(new Error(`Unknown proxy type: ${proxy.type}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect through a chain of jump hosts for SFTP
|
||||
*/
|
||||
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort) {
|
||||
const connections = [];
|
||||
let currentSocket = null;
|
||||
|
||||
try {
|
||||
// Connect through each jump host
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
const jump = jumpHosts[i];
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === jumpHosts.length - 1;
|
||||
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
|
||||
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`);
|
||||
|
||||
const conn = new SSHClient();
|
||||
|
||||
// Build connection options
|
||||
const connOpts = {
|
||||
host: jump.hostname,
|
||||
port: jump.port || 22,
|
||||
username: jump.username || 'root',
|
||||
readyTimeout: 20000,
|
||||
keepaliveInterval: 10000,
|
||||
keepaliveCountMax: 3,
|
||||
algorithms: {
|
||||
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
|
||||
kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'],
|
||||
compress: ['none'],
|
||||
},
|
||||
};
|
||||
|
||||
// Auth - support agent (certificate), key, and password fallback
|
||||
const hasCertificate =
|
||||
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
|
||||
|
||||
let authAgent = null;
|
||||
if (hasCertificate) {
|
||||
authAgent = new NetcattyAgent({
|
||||
mode: "certificate",
|
||||
webContents: event.sender,
|
||||
meta: {
|
||||
label: jump.keyId || jump.username || "",
|
||||
certificate: jump.certificate,
|
||||
privateKey: jump.privateKey,
|
||||
passphrase: jump.passphrase,
|
||||
},
|
||||
});
|
||||
connOpts.agent = authAgent;
|
||||
} else if (jump.privateKey) {
|
||||
connOpts.privateKey = jump.privateKey;
|
||||
if (jump.passphrase) connOpts.passphrase = jump.passphrase;
|
||||
}
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
|
||||
if (authAgent) {
|
||||
const order = ["agent"];
|
||||
if (connOpts.password) order.push("password");
|
||||
connOpts.authHandler = order;
|
||||
}
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
|
||||
connOpts.sock = currentSocket;
|
||||
delete connOpts.host;
|
||||
delete connOpts.port;
|
||||
} else if (!isFirst && currentSocket) {
|
||||
// Tunnel through previous hop
|
||||
connOpts.sock = currentSocket;
|
||||
delete connOpts.host;
|
||||
delete connOpts.port;
|
||||
}
|
||||
|
||||
// Connect this hop
|
||||
await new Promise((resolve, reject) => {
|
||||
conn.on('ready', () => {
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} connected`);
|
||||
resolve();
|
||||
});
|
||||
conn.on('error', (err) => {
|
||||
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} error:`, err.message);
|
||||
reject(err);
|
||||
});
|
||||
conn.on('timeout', () => {
|
||||
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} timeout`);
|
||||
reject(new Error(`Connection timeout to ${hopLabel}`));
|
||||
});
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
|
||||
connections.push(conn);
|
||||
|
||||
// Determine next target
|
||||
let nextHost, nextPort;
|
||||
if (isLast) {
|
||||
// Last jump host, forward to final target
|
||||
nextHost = targetHost;
|
||||
nextPort = targetPort;
|
||||
} else {
|
||||
// Forward to next jump host
|
||||
const nextJump = jumpHosts[i + 1];
|
||||
nextHost = nextJump.hostname;
|
||||
nextPort = nextJump.port || 22;
|
||||
}
|
||||
|
||||
// Create forward stream to next hop
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Forwarding to ${nextHost}:${nextPort}...`);
|
||||
currentSocket = await new Promise((resolve, reject) => {
|
||||
conn.forwardOut('127.0.0.1', 0, nextHost, nextPort, (err, stream) => {
|
||||
if (err) {
|
||||
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: forwardOut failed:`, err.message);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: forwardOut success`);
|
||||
resolve(stream);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Return the final forwarded stream and all connections for cleanup
|
||||
return {
|
||||
socket: currentSocket,
|
||||
connections
|
||||
};
|
||||
} catch (err) {
|
||||
// Cleanup on error
|
||||
for (const conn of connections) {
|
||||
try { conn.end(); } catch (cleanupErr) { console.warn('[SFTP Chain] Cleanup error:', cleanupErr.message); }
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a new SFTP connection
|
||||
* Supports jump host connections when options.jumpHosts is provided
|
||||
*/
|
||||
async function openSftp(event, options) {
|
||||
const client = new SftpClient();
|
||||
const connId = options.sessionId || `${Date.now()}-sftp-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
// Check if we need to connect through jump hosts
|
||||
const jumpHosts = options.jumpHosts || [];
|
||||
const hasJumpHosts = jumpHosts.length > 0;
|
||||
const hasProxy = !!options.proxy;
|
||||
|
||||
let chainConnections = [];
|
||||
let connectionSocket = null;
|
||||
|
||||
// Handle chain/proxy connections
|
||||
if (hasJumpHosts) {
|
||||
console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`);
|
||||
const chainResult = await connectThroughChainForSftp(
|
||||
event,
|
||||
options,
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
options.port || 22
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
} else if (hasProxy) {
|
||||
console.log(`[SFTP] Opening connection through proxy to ${options.hostname}:${options.port || 22}`);
|
||||
connectionSocket = await createProxySocket(
|
||||
options.proxy,
|
||||
options.hostname,
|
||||
options.port || 22
|
||||
);
|
||||
}
|
||||
|
||||
const connectOpts = {
|
||||
host: options.hostname,
|
||||
port: options.port || 22,
|
||||
username: options.username || "root",
|
||||
};
|
||||
|
||||
// Use the tunneled socket if we have one
|
||||
if (connectionSocket) {
|
||||
connectOpts.sock = connectionSocket;
|
||||
// When using sock, we should not set host/port as the connection is already established
|
||||
delete connectOpts.host;
|
||||
delete connectOpts.port;
|
||||
}
|
||||
|
||||
const hasCertificate = typeof options.certificate === "string" && options.certificate.trim().length > 0;
|
||||
|
||||
let authAgent = null;
|
||||
@@ -61,25 +360,78 @@ async function openSftp(event, options) {
|
||||
connectOpts.authHandler = order;
|
||||
}
|
||||
|
||||
await client.connect(connectOpts);
|
||||
sftpClients.set(connId, client);
|
||||
return { sftpId: connId };
|
||||
try {
|
||||
await client.connect(connectOpts);
|
||||
sftpClients.set(connId, client);
|
||||
|
||||
// Store jump connections for cleanup when SFTP is closed
|
||||
if (chainConnections.length > 0) {
|
||||
jumpConnectionsMap.set(connId, {
|
||||
connections: chainConnections,
|
||||
socket: connectionSocket
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[SFTP] Connection established: ${connId}`);
|
||||
return { sftpId: connId };
|
||||
} catch (err) {
|
||||
// Cleanup jump connections on error
|
||||
for (const conn of chainConnections) {
|
||||
try { conn.end(); } catch (cleanupErr) { console.warn('[SFTP] Cleanup error on connect failure:', cleanupErr.message); }
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a directory
|
||||
* Properly handles symlinks by resolving their target type
|
||||
*/
|
||||
async function listSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
const list = await client.list(payload.path || ".");
|
||||
return list.map((item) => ({
|
||||
name: item.name,
|
||||
type: item.type === "d" ? "directory" : "file",
|
||||
size: `${item.size} bytes`,
|
||||
lastModified: new Date(item.modifyTime || Date.now()).toISOString(),
|
||||
const basePath = payload.path || ".";
|
||||
|
||||
// Process items and resolve symlinks
|
||||
const results = await Promise.all(list.map(async (item) => {
|
||||
let type;
|
||||
let linkTarget = null;
|
||||
|
||||
if (item.type === "d") {
|
||||
type = "directory";
|
||||
} else if (item.type === "l") {
|
||||
// This is a symlink - try to resolve its target type
|
||||
type = "symlink";
|
||||
try {
|
||||
// Use path.posix.join to properly construct the path and avoid double slashes
|
||||
const fullPath = path.posix.join(basePath === "." ? "/" : basePath, item.name);
|
||||
const stat = await client.stat(fullPath);
|
||||
// stat follows symlinks, so we get the target's type
|
||||
if (stat.isDirectory) {
|
||||
linkTarget = "directory";
|
||||
} else {
|
||||
linkTarget = "file";
|
||||
}
|
||||
} catch (err) {
|
||||
// If we can't stat the symlink target (broken link), keep it as symlink
|
||||
console.warn(`Could not resolve symlink target for ${item.name}:`, err.message);
|
||||
}
|
||||
} else {
|
||||
type = "file";
|
||||
}
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
type,
|
||||
linkTarget,
|
||||
size: `${item.size} bytes`,
|
||||
lastModified: new Date(item.modifyTime || Date.now()).toISOString(),
|
||||
};
|
||||
}));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,6 +519,7 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
|
||||
/**
|
||||
* Close an SFTP connection
|
||||
* Also cleans up any jump host connections if present
|
||||
*/
|
||||
async function closeSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
@@ -178,6 +531,16 @@ async function closeSftp(event, payload) {
|
||||
console.warn("SFTP close failed", err);
|
||||
}
|
||||
sftpClients.delete(payload.sftpId);
|
||||
|
||||
// Clean up jump connections if any
|
||||
const jumpData = jumpConnectionsMap.get(payload.sftpId);
|
||||
if (jumpData) {
|
||||
for (const conn of jumpData.connections) {
|
||||
try { conn.end(); } catch (cleanupErr) { console.warn('[SFTP] Cleanup error on close:', cleanupErr.message); }
|
||||
}
|
||||
jumpConnectionsMap.delete(payload.sftpId);
|
||||
console.log(`[SFTP] Cleaned up ${jumpData.connections.length} jump connection(s) for ${payload.sftpId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
/**
|
||||
* Terminal Bridge - Handles local shell and telnet/mosh sessions
|
||||
* Terminal Bridge - Handles local shell, telnet/mosh, and serial port sessions
|
||||
* Extracted from main.cjs for single responsibility
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
const pty = require("node-pty");
|
||||
const { SerialPort } = require("serialport");
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
let electronModule = null;
|
||||
|
||||
const DEFAULT_UTF8_LOCALE = "en_US.UTF-8";
|
||||
const LOGIN_SHELLS = new Set(["bash", "zsh", "fish", "ksh"]);
|
||||
|
||||
const getLoginShellArgs = (shellPath) => {
|
||||
if (!shellPath || process.platform === "win32") return [];
|
||||
const shellName = path.basename(shellPath);
|
||||
return LOGIN_SHELLS.has(shellName) ? ["-l"] : [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the terminal bridge with dependencies
|
||||
@@ -91,6 +100,7 @@ function startLocalSession(event, payload) {
|
||||
? findExecutable("powershell") || "powershell.exe"
|
||||
: process.env.SHELL || "/bin/bash";
|
||||
const shell = payload?.shell || defaultShell;
|
||||
const shellArgs = getLoginShellArgs(shell);
|
||||
const env = applyLocaleDefaults({
|
||||
...process.env,
|
||||
...(payload?.env || {}),
|
||||
@@ -98,10 +108,30 @@ function startLocalSession(event, payload) {
|
||||
COLORTERM: "truecolor",
|
||||
});
|
||||
|
||||
const proc = pty.spawn(shell, [], {
|
||||
// Determine the starting directory
|
||||
// Default to home directory if not specified or if specified path is invalid
|
||||
const defaultCwd = os.homedir();
|
||||
let cwd = defaultCwd;
|
||||
|
||||
if (payload?.cwd) {
|
||||
try {
|
||||
// Resolve to absolute path and check if it exists and is a directory
|
||||
const resolvedPath = path.resolve(payload.cwd);
|
||||
if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) {
|
||||
cwd = resolvedPath;
|
||||
} else {
|
||||
console.warn(`[Terminal] Specified cwd "${payload.cwd}" is not a valid directory, using home directory`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[Terminal] Error validating cwd "${payload.cwd}":`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const proc = pty.spawn(shell, shellArgs, {
|
||||
cols: payload?.cols || 80,
|
||||
rows: payload?.rows || 24,
|
||||
env,
|
||||
cwd,
|
||||
});
|
||||
|
||||
const session = {
|
||||
@@ -414,6 +444,103 @@ async function startMoshSession(event, options) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available serial ports (hardware only)
|
||||
*/
|
||||
async function listSerialPorts() {
|
||||
try {
|
||||
const ports = await SerialPort.list();
|
||||
return ports.map(port => ({
|
||||
path: port.path,
|
||||
manufacturer: port.manufacturer || '',
|
||||
serialNumber: port.serialNumber || '',
|
||||
vendorId: port.vendorId || '',
|
||||
productId: port.productId || '',
|
||||
pnpId: port.pnpId || '',
|
||||
type: 'hardware',
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("[Serial] Failed to list ports:", err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a serial port session (supports both hardware serial ports and PTY devices)
|
||||
* Note: SerialPort library can open PTY devices directly, they just won't appear in list()
|
||||
*/
|
||||
async function startSerialSession(event, options) {
|
||||
const sessionId =
|
||||
options.sessionId ||
|
||||
`serial-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const portPath = options.path;
|
||||
const baudRate = options.baudRate || 115200;
|
||||
const dataBits = options.dataBits || 8;
|
||||
const stopBits = options.stopBits || 1;
|
||||
const parity = options.parity || 'none';
|
||||
const flowControl = options.flowControl || 'none';
|
||||
|
||||
console.log(`[Serial] Starting connection to ${portPath} at ${baudRate} baud`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const serialPort = new SerialPort({
|
||||
path: portPath,
|
||||
baudRate: baudRate,
|
||||
dataBits: dataBits,
|
||||
stopBits: stopBits,
|
||||
parity: parity,
|
||||
rtscts: flowControl === 'rts/cts',
|
||||
xon: flowControl === 'xon/xoff',
|
||||
xoff: flowControl === 'xon/xoff',
|
||||
autoOpen: false,
|
||||
});
|
||||
|
||||
serialPort.open((err) => {
|
||||
if (err) {
|
||||
console.error(`[Serial] Failed to open port ${portPath}:`, err.message);
|
||||
reject(new Error(`Failed to open serial port: ${err.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Serial] Connected to ${portPath}`);
|
||||
|
||||
const session = {
|
||||
serialPort,
|
||||
type: 'serial',
|
||||
webContentsId: event.sender.id,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
serialPort.on('data', (data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: data.toString('binary') });
|
||||
});
|
||||
|
||||
serialPort.on('error', (err) => {
|
||||
console.error(`[Serial] Port error: ${err.message}`);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
serialPort.on('close', () => {
|
||||
console.log(`[Serial] Port closed`);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 0 });
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
resolve({ sessionId });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[Serial] Failed to start serial session:", err.message);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write data to a session
|
||||
*/
|
||||
@@ -428,6 +555,8 @@ function writeToSession(event, payload) {
|
||||
session.proc.write(payload.data);
|
||||
} else if (session.socket) {
|
||||
session.socket.write(payload.data);
|
||||
} else if (session.serialPort) {
|
||||
session.serialPort.write(payload.data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') {
|
||||
@@ -482,6 +611,8 @@ function closeSession(event, payload) {
|
||||
session.proc.kill();
|
||||
} else if (session.socket) {
|
||||
session.socket.destroy();
|
||||
} else if (session.serialPort) {
|
||||
session.serialPort.close();
|
||||
}
|
||||
if (session.chainConnections) {
|
||||
for (const c of session.chainConnections) {
|
||||
@@ -501,11 +632,90 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:local:start", startLocalSession);
|
||||
ipcMain.handle("netcatty:telnet:start", startTelnetSession);
|
||||
ipcMain.handle("netcatty:mosh:start", startMoshSession);
|
||||
ipcMain.handle("netcatty:serial:start", startSerialSession);
|
||||
ipcMain.handle("netcatty:serial:list", listSerialPorts);
|
||||
ipcMain.handle("netcatty:local:defaultShell", getDefaultShell);
|
||||
ipcMain.handle("netcatty:local:validatePath", validatePath);
|
||||
ipcMain.on("netcatty:write", writeToSession);
|
||||
ipcMain.on("netcatty:resize", resizeSession);
|
||||
ipcMain.on("netcatty:close", closeSession);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default shell for the current platform
|
||||
*/
|
||||
function getDefaultShell() {
|
||||
if (process.platform === "win32") {
|
||||
return findExecutable("powershell") || "powershell.exe";
|
||||
}
|
||||
return process.env.SHELL || "/bin/bash";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a path - check if it exists and whether it's a file or directory
|
||||
* @param {object} event - IPC event
|
||||
* @param {object} payload - Contains { path: string, type?: 'file' | 'directory' | 'any' }
|
||||
* @returns {{ exists: boolean, isFile: boolean, isDirectory: boolean }}
|
||||
*/
|
||||
function validatePath(event, payload) {
|
||||
const targetPath = payload?.path;
|
||||
const type = payload?.type || 'any';
|
||||
if (!targetPath) {
|
||||
return { exists: false, isFile: false, isDirectory: false };
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
resolvedPath = path.resolve(resolvedPath);
|
||||
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
const stat = fs.statSync(resolvedPath);
|
||||
return {
|
||||
exists: true,
|
||||
isFile: stat.isFile(),
|
||||
isDirectory: stat.isDirectory(),
|
||||
};
|
||||
}
|
||||
|
||||
// If type is 'file' and path doesn't exist, try to resolve via PATH (for executables like cmd.exe, powershell.exe)
|
||||
if (type === 'file') {
|
||||
const resolvedExecutable = findExecutable(targetPath);
|
||||
// findExecutable returns the original name if not found, so check if it actually resolves to a real path
|
||||
if (resolvedExecutable !== targetPath && fs.existsSync(resolvedExecutable)) {
|
||||
const stat = fs.statSync(resolvedExecutable);
|
||||
return {
|
||||
exists: true,
|
||||
isFile: stat.isFile(),
|
||||
isDirectory: stat.isDirectory(),
|
||||
};
|
||||
}
|
||||
// Also try with .exe extension on Windows if not already present
|
||||
if (process.platform === 'win32' && !targetPath.toLowerCase().endsWith('.exe')) {
|
||||
const withExe = findExecutable(targetPath + '.exe');
|
||||
if (withExe !== targetPath + '.exe' && fs.existsSync(withExe)) {
|
||||
const stat = fs.statSync(withExe);
|
||||
return {
|
||||
exists: true,
|
||||
isFile: stat.isFile(),
|
||||
isDirectory: stat.isDirectory(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { exists: false, isFile: false, isDirectory: false };
|
||||
} catch (err) {
|
||||
console.warn(`[Terminal] Error validating path "${targetPath}":`, err.message);
|
||||
return { exists: false, isFile: false, isDirectory: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all sessions - call before app quit
|
||||
*/
|
||||
@@ -525,6 +735,12 @@ function cleanupAllSessions() {
|
||||
}
|
||||
} else if (session.socket) {
|
||||
session.socket.destroy();
|
||||
} else if (session.serialPort) {
|
||||
try {
|
||||
session.serialPort.close();
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
if (session.chainConnections) {
|
||||
for (const c of session.chainConnections) {
|
||||
@@ -545,8 +761,12 @@ module.exports = {
|
||||
startLocalSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
closeSession,
|
||||
cleanupAllSessions,
|
||||
getDefaultShell,
|
||||
validatePath,
|
||||
};
|
||||
|
||||
@@ -27,11 +27,15 @@ let currentTheme = "light";
|
||||
let currentLanguage = "en";
|
||||
let handlersRegistered = false; // Prevent duplicate IPC handler registration
|
||||
let menuDeps = null;
|
||||
let electronApp = null; // Reference to Electron app for userData path
|
||||
const rendererReadyCallbacksByWebContentsId = new Map();
|
||||
const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
|
||||
const OAUTH_DEFAULT_WIDTH = 600;
|
||||
const OAUTH_DEFAULT_HEIGHT = 700;
|
||||
const OAUTH_OVERLAY_ID = "__netcatty_oauth_loading__";
|
||||
const WINDOW_STATE_FILE = "window-state.json";
|
||||
const DEFAULT_WINDOW_WIDTH = 1400;
|
||||
const DEFAULT_WINDOW_HEIGHT = 900;
|
||||
|
||||
function debugLog(...args) {
|
||||
if (!DEBUG_WINDOWS) return;
|
||||
@@ -43,6 +47,78 @@ function debugLog(...args) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the window state file
|
||||
*/
|
||||
function getWindowStatePath() {
|
||||
try {
|
||||
if (!electronApp) return null;
|
||||
return path.join(electronApp.getPath("userData"), WINDOW_STATE_FILE);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load saved window state from disk
|
||||
*/
|
||||
function loadWindowState() {
|
||||
try {
|
||||
const statePath = getWindowStatePath();
|
||||
if (!statePath || !fs.existsSync(statePath)) {
|
||||
return null;
|
||||
}
|
||||
const data = fs.readFileSync(statePath, "utf8");
|
||||
const state = JSON.parse(data);
|
||||
// Validate the loaded state has required properties
|
||||
if (
|
||||
typeof state.width === "number" &&
|
||||
typeof state.height === "number" &&
|
||||
state.width > 0 &&
|
||||
state.height > 0
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
debugLog("Failed to load window state:", err?.message || err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save window state to disk
|
||||
*/
|
||||
function saveWindowState(state) {
|
||||
try {
|
||||
const statePath = getWindowStatePath();
|
||||
if (!statePath) return false;
|
||||
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||||
return true;
|
||||
} catch (err) {
|
||||
debugLog("Failed to save window state:", err?.message || err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current window bounds state for saving
|
||||
* @param {BrowserWindow} win - The window to get bounds from
|
||||
* @param {Object} overrideBounds - Optional bounds to use instead of current window bounds (for normal bounds tracking)
|
||||
*/
|
||||
function getWindowBoundsState(win, overrideBounds) {
|
||||
if (!win || win.isDestroyed()) return null;
|
||||
const bounds = overrideBounds || win.getBounds();
|
||||
return {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
isMaximized: win.isMaximized(),
|
||||
isFullScreen: win.isFullScreen(),
|
||||
};
|
||||
}
|
||||
|
||||
const MENU_LABELS = {
|
||||
en: { edit: "Edit", view: "View", window: "Window" },
|
||||
"zh-CN": { edit: "编辑", view: "视图", window: "窗口" },
|
||||
@@ -420,18 +496,58 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
|
||||
* Create the main application window
|
||||
*/
|
||||
async function createWindow(electronModule, options) {
|
||||
const { BrowserWindow, nativeTheme } = electronModule;
|
||||
const { BrowserWindow, nativeTheme, app, screen } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir } = options;
|
||||
|
||||
// Store app reference for window state persistence
|
||||
electronApp = app;
|
||||
|
||||
const osTheme = nativeTheme?.shouldUseDarkColors ? "dark" : "light";
|
||||
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
|
||||
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
|
||||
const backgroundColor = frontendBackground || "#1a1a1a";
|
||||
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
|
||||
|
||||
// Load saved window state
|
||||
const savedState = loadWindowState();
|
||||
let windowBounds = {
|
||||
width: DEFAULT_WINDOW_WIDTH,
|
||||
height: DEFAULT_WINDOW_HEIGHT,
|
||||
};
|
||||
|
||||
if (savedState) {
|
||||
// Use saved dimensions
|
||||
windowBounds.width = savedState.width;
|
||||
windowBounds.height = savedState.height;
|
||||
|
||||
// Only use saved position if the screen is available at that location
|
||||
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
|
||||
try {
|
||||
// Check if the saved position is within any available display
|
||||
const displays = screen?.getAllDisplays?.() || [];
|
||||
const isPositionVisible = displays.some((display) => {
|
||||
const { x, y, width, height } = display.bounds;
|
||||
// Check if at least part of the window would be visible on this display
|
||||
return (
|
||||
savedState.x < x + width &&
|
||||
savedState.x + savedState.width > x &&
|
||||
savedState.y < y + height &&
|
||||
savedState.y + savedState.height > y
|
||||
);
|
||||
});
|
||||
|
||||
if (isPositionVisible) {
|
||||
windowBounds.x = savedState.x;
|
||||
windowBounds.y = savedState.y;
|
||||
}
|
||||
} catch {
|
||||
// Ignore screen check errors, just don't set position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
...windowBounds,
|
||||
backgroundColor,
|
||||
icon: appIcon,
|
||||
show: false,
|
||||
@@ -448,12 +564,70 @@ async function createWindow(electronModule, options) {
|
||||
|
||||
mainWindow = win;
|
||||
|
||||
// Restore maximized state if it was saved
|
||||
if (savedState?.isMaximized && !savedState?.isFullScreen) {
|
||||
win.once("ready-to-show", () => {
|
||||
try {
|
||||
win.maximize();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track window bounds for saving (use last non-maximized/non-fullscreen bounds)
|
||||
let lastNormalBounds = null;
|
||||
let saveStateTimer = null;
|
||||
|
||||
const updateNormalBounds = () => {
|
||||
if (!win.isDestroyed() && !win.isMaximized() && !win.isFullScreen()) {
|
||||
lastNormalBounds = win.getBounds();
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleSaveState = () => {
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
saveStateTimer = setTimeout(() => {
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (state) saveWindowState(state);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Update normal bounds on resize/move when not maximized/fullscreen
|
||||
win.on("resize", () => {
|
||||
updateNormalBounds();
|
||||
scheduleSaveState();
|
||||
});
|
||||
|
||||
win.on("move", () => {
|
||||
updateNormalBounds();
|
||||
scheduleSaveState();
|
||||
});
|
||||
|
||||
win.on("maximize", scheduleSaveState);
|
||||
win.on("unmaximize", () => {
|
||||
updateNormalBounds();
|
||||
scheduleSaveState();
|
||||
});
|
||||
|
||||
// Save state when window is about to close
|
||||
win.on("close", () => {
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (state) saveWindowState(state);
|
||||
// Close settings window when main window closes
|
||||
closeSettingsWindow();
|
||||
});
|
||||
|
||||
win.on("enter-full-screen", () => {
|
||||
win.webContents?.send("netcatty:window:fullscreen-changed", true);
|
||||
scheduleSaveState();
|
||||
});
|
||||
|
||||
win.on("leave-full-screen", () => {
|
||||
win.webContents?.send("netcatty:window:fullscreen-changed", false);
|
||||
updateNormalBounds();
|
||||
scheduleSaveState();
|
||||
});
|
||||
|
||||
// Ensure native background matches frontend background, even before first paint.
|
||||
@@ -559,9 +733,10 @@ async function openSettingsWindow(electronModule, options) {
|
||||
backgroundColor,
|
||||
icon: appIcon,
|
||||
fullscreenable: !isMac,
|
||||
// NOTE: Do NOT set parent on Windows - it can cause the main window to close
|
||||
// when the settings window is closed in some edge cases.
|
||||
parent: isMac ? mainWindow : undefined,
|
||||
// NOTE: Do NOT set parent - on macOS this causes rendering issues when dragging
|
||||
// the window to a different screen (the window becomes invisible while still
|
||||
// appearing in "Show All Windows" in the Dock). On Windows it can cause the
|
||||
// main window to close when the settings window is closed.
|
||||
modal: false,
|
||||
show: false,
|
||||
frame: isMac,
|
||||
|
||||
@@ -539,13 +539,18 @@ app.on("window-all-closed", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup all PTY sessions before quitting to prevent node-pty assertion errors
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
app.on("will-quit", () => {
|
||||
try {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
} catch (err) {
|
||||
console.warn("Error during terminal cleanup:", err);
|
||||
}
|
||||
try {
|
||||
portForwardingBridge.stopAllPortForwards();
|
||||
} catch (err) {
|
||||
console.warn("Error during port forwarding cleanup:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Export for testing
|
||||
|
||||
@@ -215,6 +215,19 @@ const api = {
|
||||
const result = await ipcRenderer.invoke("netcatty:local:start", options || {});
|
||||
return result.sessionId;
|
||||
},
|
||||
startSerialSession: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:serial:start", options);
|
||||
return result.sessionId;
|
||||
},
|
||||
listSerialPorts: async () => {
|
||||
return ipcRenderer.invoke("netcatty:serial:list");
|
||||
},
|
||||
getDefaultShell: async () => {
|
||||
return ipcRenderer.invoke("netcatty:local:defaultShell");
|
||||
},
|
||||
validatePath: async (path, type) => {
|
||||
return ipcRenderer.invoke("netcatty:local:validatePath", { path, type });
|
||||
},
|
||||
writeToSession: (sessionId, data) => {
|
||||
ipcRenderer.send("netcatty:write", { sessionId, data });
|
||||
},
|
||||
|
||||
31
global.d.ts
vendored
31
global.d.ts
vendored
@@ -1,5 +1,5 @@
|
||||
import type { RemoteFile } from "./types";
|
||||
import type { S3Config, SyncedFile, WebDAVConfig } from "./domain/sync";
|
||||
import type { S3Config, SMBConfig, SyncedFile, WebDAVConfig } from "./domain/sync";
|
||||
|
||||
declare global {
|
||||
// Proxy configuration for SSH connections
|
||||
@@ -134,7 +134,26 @@ interface NetcattyBridge {
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<string>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; env?: Record<string, string> }): Promise<string>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string> }): Promise<string>;
|
||||
startSerialSession?(options: {
|
||||
sessionId?: string;
|
||||
path: string;
|
||||
baudRate?: number;
|
||||
dataBits?: 5 | 6 | 7 | 8;
|
||||
stopBits?: 1 | 1.5 | 2;
|
||||
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
|
||||
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
|
||||
}): Promise<string>;
|
||||
listSerialPorts?(): Promise<Array<{
|
||||
path: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
vendorId: string;
|
||||
productId: string;
|
||||
pnpId: string;
|
||||
}>>;
|
||||
getDefaultShell?(): Promise<string>;
|
||||
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
|
||||
generateKeyPair?(options: {
|
||||
type: 'RSA' | 'ECDSA' | 'ED25519';
|
||||
bits?: number;
|
||||
@@ -261,6 +280,14 @@ interface NetcattyBridge {
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncS3Download?(config: S3Config): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncS3Delete?(config: S3Config): Promise<{ ok: true }>;
|
||||
|
||||
cloudSyncSmbInitialize?(config: SMBConfig): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncSmbUpload?(
|
||||
config: SMBConfig,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncSmbDownload?(config: SMBConfig): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncSmbDelete?(config: SMBConfig): Promise<{ ok: true }>;
|
||||
|
||||
// Port Forwarding
|
||||
startPortForward?(options: PortForwardOptions): Promise<PortForwardResult>;
|
||||
|
||||
@@ -35,6 +35,75 @@ export const getActiveRuleIds = (): string[] => {
|
||||
.map(([ruleId]) => ruleId);
|
||||
};
|
||||
|
||||
// Tunnel ID prefix and UUID regex pattern for parsing
|
||||
const TUNNEL_ID_PREFIX = 'pf-';
|
||||
// UUID format: 8-4-4-4-12 hexadecimal characters
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Parse rule ID from tunnel ID
|
||||
* Tunnel ID format is "pf-{ruleId}-{timestamp}" where ruleId is a UUID
|
||||
*/
|
||||
const parseRuleIdFromTunnelId = (tunnelId: string): string | null => {
|
||||
if (!tunnelId.startsWith(TUNNEL_ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove prefix and split remaining parts
|
||||
const withoutPrefix = tunnelId.slice(TUNNEL_ID_PREFIX.length);
|
||||
const parts = withoutPrefix.split('-');
|
||||
|
||||
// UUID has 5 parts (8-4-4-4-12), so we need at least 6 parts (5 UUID + timestamp)
|
||||
if (parts.length < 6) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reconstruct the UUID from first 5 parts
|
||||
const ruleId = parts.slice(0, 5).join('-');
|
||||
|
||||
// Validate it's a proper UUID format
|
||||
if (!UUID_REGEX.test(ruleId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ruleId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync active connections with backend
|
||||
* Called on app startup to restore state of tunnels that may still be running
|
||||
* This updates the local activeConnections map to match the backend state.
|
||||
*/
|
||||
export const syncWithBackend = async (): Promise<void> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
if (!bridge?.listPortForwards) {
|
||||
logger.warn('[PortForwardingService] Backend not available for sync');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeTunnels = await bridge.listPortForwards();
|
||||
logger.info(`[PortForwardingService] Backend reports ${activeTunnels.length} active tunnels`);
|
||||
|
||||
for (const tunnel of activeTunnels) {
|
||||
const ruleId = parseRuleIdFromTunnelId(tunnel.tunnelId);
|
||||
if (ruleId) {
|
||||
// Update local connection tracking
|
||||
activeConnections.set(ruleId, {
|
||||
ruleId,
|
||||
tunnelId: tunnel.tunnelId,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
logger.info(`[PortForwardingService] Synced active tunnel for rule ${ruleId}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[PortForwardingService] Failed to sync with backend:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a port forwarding tunnel
|
||||
*/
|
||||
|
||||
3974
package-lock.json
generated
3974
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@
|
||||
"node-pty": "1.1.0-beta19",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"serialport": "^13.0.0",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"uuid": "^13.0.0",
|
||||
|
||||
BIN
public/icon.png
BIN
public/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 26 KiB |
Reference in New Issue
Block a user