Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
3876e8c479 | ||
|
|
7143bce61e | ||
|
|
7c232335ef | ||
|
|
cc61974744 | ||
|
|
86de2b2b60 | ||
|
|
27301f3ecd | ||
|
|
7f30d7c662 | ||
|
|
456755a2ec | ||
|
|
1eee141bed | ||
|
|
e808a2c51c | ||
|
|
15ee1fd020 | ||
|
|
c3cbf4fbdf |
25
App.tsx
25
App.tsx
@@ -3,6 +3,7 @@ import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisi
|
||||
import { useAutoSync } from './application/state/useAutoSync';
|
||||
import { useSessionState } from './application/state/useSessionState';
|
||||
import { useSettingsState } from './application/state/useSettingsState';
|
||||
import { useUpdateCheck } from './application/state/useUpdateCheck';
|
||||
import { useVaultState } from './application/state/useVaultState';
|
||||
import { useWindowControls } from './application/state/useWindowControls';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
@@ -207,6 +208,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
submitWorkspaceRename,
|
||||
resetWorkspaceRename,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
@@ -256,6 +258,28 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return handleSyncNow({ trigger: 'manual' });
|
||||
}, [handleSyncNow]);
|
||||
|
||||
// Update check hook - checks for new versions on startup
|
||||
const { updateState, openReleasePage, dismissUpdate } = useUpdateCheck();
|
||||
|
||||
// Show toast notification when update is available
|
||||
useEffect(() => {
|
||||
if (updateState.hasUpdate && updateState.latestRelease) {
|
||||
const version = updateState.latestRelease.version;
|
||||
toast.info(
|
||||
t('update.available.message', { version }),
|
||||
{
|
||||
title: t('update.available.title'),
|
||||
duration: 8000, // Show longer for update notifications
|
||||
onClick: () => {
|
||||
openReleasePage();
|
||||
dismissUpdate();
|
||||
},
|
||||
actionLabel: t('update.downloadNow'),
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [updateState.hasUpdate, updateState.latestRelease, t, openReleasePage, dismissUpdate]);
|
||||
|
||||
// Debounce ref for moveFocus to prevent double-triggering when focus switches
|
||||
const lastMoveFocusTimeRef = useRef<number>(0);
|
||||
const MOVE_FOCUS_DEBOUNCE_MS = 200;
|
||||
@@ -741,6 +765,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
onConnectSerial={createSerialSession}
|
||||
onDeleteHost={handleDeleteHost}
|
||||
onConnect={handleConnectToHost}
|
||||
onUpdateHosts={updateHosts}
|
||||
|
||||
@@ -14,13 +14,19 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/user/netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/user/netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
|
||||
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://ko-fi.com/binaricat">
|
||||
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="Ko-fi でサポート">
|
||||
@@ -130,20 +136,30 @@
|
||||
|
||||
Vault ビューはすべての SSH 接続を管理するコマンドセンターです。右クリックメニューで階層的なグループを作成し、グループ間でホストをドラッグ、パンくずナビゲーションでホストツリーを素早く移動できます。各ホストは接続状態、OS アイコン、クイック接続ボタンを表示。グリッドとリストビューを切り替え、強力な検索で名前、ホスト名、タグ、グループでフィルタリングできます。
|
||||
|
||||
| ダークモード | ライトモード | リストビュー |
|
||||
|------------|------------|------------|
|
||||
|  |  |  |
|
||||
**ダークモード**
|
||||
|
||||

|
||||
|
||||
**ライトモード**
|
||||
|
||||

|
||||
|
||||
**リストビュー**
|
||||
|
||||

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

|
||||

|
||||
|
||||
**テーマカスタマイズ**
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
@@ -245,7 +261,15 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
|
||||
|
||||
### ダウンロード
|
||||
|
||||
[GitHub Releases](https://github.com/user/netcatty/releases/latest) から最新版をダウンロードしてください。
|
||||
[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) ですべてのリリースを参照してください。
|
||||
|
||||
> **⚠️ macOS ユーザーへ:** アプリはコード署名されていないため、macOS Gatekeeper によってブロックされます。ダウンロード後、以下のコマンドを実行して隔離属性を削除してください:
|
||||
> ```bash
|
||||
@@ -261,8 +285,8 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
|
||||
|
||||
```bash
|
||||
# リポジトリをクローン
|
||||
git clone https://github.com/user/netcatty.git
|
||||
cd netcatty
|
||||
git clone https://github.com/binaricat/Netcatty.git
|
||||
cd Netcatty
|
||||
|
||||
# 依存関係をインストール
|
||||
npm install
|
||||
|
||||
46
README.md
46
README.md
@@ -14,13 +14,19 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/user/netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/user/netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
|
||||
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://ko-fi.com/binaricat">
|
||||
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="Support on Ko-fi">
|
||||
@@ -130,20 +136,30 @@
|
||||
|
||||
The Vault view is your command center for managing all SSH connections. Create hierarchical groups with right-click context menus, drag hosts between groups, and use breadcrumb navigation to quickly traverse your host tree. Each host displays its connection status, OS icon, and quick-connect button. Switch between grid and list views based on your preference, and use the powerful search to filter hosts by name, hostname, tags, or group.
|
||||
|
||||
| Dark Mode | Light Mode | List View |
|
||||
|-----------|------------|-----------|
|
||||
|  |  |  |
|
||||
**Dark Mode**
|
||||
|
||||

|
||||
|
||||
**Light Mode**
|
||||
|
||||

|
||||
|
||||
**List View**
|
||||
|
||||

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

|
||||

|
||||
|
||||
**Theme Customization**
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
@@ -245,7 +261,15 @@ Netcatty automatically detects and displays OS icons for connected hosts:
|
||||
|
||||
### Download
|
||||
|
||||
Download the latest release from [GitHub Releases](https://github.com/user/netcatty/releases/latest).
|
||||
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).
|
||||
|
||||
> **⚠️ macOS Users:** Since the app is not code-signed, macOS Gatekeeper will block it. After downloading, run this command to remove the quarantine attribute:
|
||||
> ```bash
|
||||
@@ -261,8 +285,8 @@ Download the latest release from [GitHub Releases](https://github.com/user/netca
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/user/netcatty.git
|
||||
cd netcatty
|
||||
git clone https://github.com/binaricat/Netcatty.git
|
||||
cd Netcatty
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
@@ -14,13 +14,19 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/user/netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/user/netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
|
||||
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://ko-fi.com/binaricat">
|
||||
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="在 Ko-fi 上支持我">
|
||||
@@ -130,20 +136,30 @@
|
||||
|
||||
Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建层级分组,在分组间拖拽主机,使用面包屑导航快速遍历主机树。每个主机显示连接状态、操作系统图标和快速连接按钮。根据偏好在网格和列表视图之间切换,使用强大的搜索按名称、主机名、标签或分组过滤主机。
|
||||
|
||||
| 深色模式 | 浅色模式 | 列表视图 |
|
||||
|---------|---------|---------|
|
||||
|  |  |  |
|
||||
**深色模式**
|
||||
|
||||

|
||||
|
||||
**浅色模式**
|
||||
|
||||

|
||||
|
||||
**列表视图**
|
||||
|
||||

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

|
||||

|
||||
|
||||
**主题定制**
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
@@ -245,7 +261,15 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
|
||||
### 下载
|
||||
|
||||
从 [GitHub Releases](https://github.com/user/netcatty/releases/latest) 下载最新版本。
|
||||
从 [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) 浏览所有版本。
|
||||
|
||||
> **⚠️ macOS 用户注意:** 由于应用未经代码签名,macOS Gatekeeper 会阻止运行。下载后,请在终端运行以下命令移除隔离属性:
|
||||
> ```bash
|
||||
@@ -261,8 +285,8 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/user/netcatty.git
|
||||
cd netcatty
|
||||
git clone https://github.com/binaricat/Netcatty.git
|
||||
cd Netcatty
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
@@ -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',
|
||||
@@ -72,6 +73,17 @@ const en: Messages = {
|
||||
'settings.application.whatsNew': "What's new",
|
||||
'settings.application.whatsNew.subtitle': 'Show release notes',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Update Available',
|
||||
'update.available.message': 'A new version {version} is available. Click to download.',
|
||||
'update.checking': 'Checking for updates...',
|
||||
'update.upToDate.title': 'Up to Date',
|
||||
'update.upToDate.message': 'You are running the latest version ({version}).',
|
||||
'update.error': 'Failed to check for updates',
|
||||
'update.downloadNow': 'Download Now',
|
||||
'update.remindLater': 'Remind Later',
|
||||
'update.skipVersion': 'Skip This Version',
|
||||
|
||||
// Settings > Appearance
|
||||
'settings.appearance.uiTheme': 'UI Theme',
|
||||
'settings.appearance.darkMode': 'Dark Mode',
|
||||
@@ -155,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',
|
||||
@@ -246,7 +270,7 @@ const en: Messages = {
|
||||
'vault.hosts.header.live': '{count} live',
|
||||
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname...',
|
||||
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname / ssh -p 2222 user@hostname...',
|
||||
'vault.hosts.connect': 'Connect',
|
||||
'vault.view.grid': 'Grid',
|
||||
'vault.view.list': 'List',
|
||||
@@ -345,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',
|
||||
@@ -371,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?',
|
||||
@@ -431,6 +460,9 @@ const en: Messages = {
|
||||
// Select Host panel
|
||||
'selectHost.title': 'Select Host',
|
||||
'selectHost.noHostsFound': 'No hosts found',
|
||||
'selectHost.newHost': 'New Host',
|
||||
'selectHost.continue': 'Continue',
|
||||
'selectHost.continueWithCount': 'Continue ({count} selected)',
|
||||
|
||||
// Quick Connect
|
||||
'quickConnect.knownHost.title': 'Are you sure you want to connect?',
|
||||
@@ -439,6 +471,7 @@ const en: Messages = {
|
||||
'quickConnect.knownHost.addQuestion': 'Do you want to add it to the list of known hosts?',
|
||||
'quickConnect.knownHost.addAndContinue': 'Add and continue',
|
||||
'quickConnect.addKey': 'Add key',
|
||||
'quickConnect.warning.unparsedOptions': 'Some SSH arguments were ignored: {options}',
|
||||
|
||||
// Terminal
|
||||
'terminal.connectionErrorTitle': 'Connection Error',
|
||||
@@ -675,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',
|
||||
@@ -911,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': '选择主机',
|
||||
@@ -60,6 +61,17 @@ const zhCN: Messages = {
|
||||
'settings.application.whatsNew': '更新内容',
|
||||
'settings.application.whatsNew.subtitle': '查看发布说明',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '发现新版本',
|
||||
'update.available.message': '新版本 {version} 已发布,点击前往下载。',
|
||||
'update.checking': '正在检查更新...',
|
||||
'update.upToDate.title': '已是最新版本',
|
||||
'update.upToDate.message': '当前版本 ({version}) 已是最新。',
|
||||
'update.error': '检查更新失败',
|
||||
'update.downloadNow': '立即下载',
|
||||
'update.remindLater': '稍后提醒',
|
||||
'update.skipVersion': '跳过此版本',
|
||||
|
||||
// Settings > Appearance
|
||||
'settings.appearance.uiTheme': '界面主题',
|
||||
'settings.appearance.darkMode': '深色模式',
|
||||
@@ -148,7 +160,7 @@ const zhCN: Messages = {
|
||||
'vault.hosts.header.live': '{count} 个在线',
|
||||
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': '查找主机或 ssh user@hostname…',
|
||||
'vault.hosts.search.placeholder': '查找主机或 ssh user@hostname / ssh -p 2222 user@hostname…',
|
||||
'vault.hosts.connect': '连接',
|
||||
'vault.view.grid': '网格',
|
||||
'vault.view.list': '列表',
|
||||
@@ -260,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': '新建文件夹名称?',
|
||||
@@ -304,6 +318,9 @@ const zhCN: Messages = {
|
||||
// Select Host panel
|
||||
'selectHost.title': '选择主机',
|
||||
'selectHost.noHostsFound': '未找到主机',
|
||||
'selectHost.newHost': '新建主机',
|
||||
'selectHost.continue': '继续',
|
||||
'selectHost.continueWithCount': '继续(已选 {count} 个)',
|
||||
|
||||
// Quick Connect
|
||||
'quickConnect.knownHost.title': '确认要连接吗?',
|
||||
@@ -312,6 +329,7 @@ const zhCN: Messages = {
|
||||
'quickConnect.knownHost.addQuestion': '是否将它加入 Known Hosts?',
|
||||
'quickConnect.knownHost.addAndContinue': '加入并继续',
|
||||
'quickConnect.addKey': '添加 key',
|
||||
'quickConnect.warning.unparsedOptions': '部分 SSH 参数已被忽略: {options}',
|
||||
|
||||
// Protocol select dialog
|
||||
'protocolSelect.chooseProtocol': '选择协议',
|
||||
@@ -515,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 已连接',
|
||||
@@ -634,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': '本地',
|
||||
@@ -709,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': '快捷键方案',
|
||||
@@ -900,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;
|
||||
|
||||
@@ -142,8 +142,21 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
|
||||
// and unlock silently so users don't have to manage a LOCKED state in the UI.
|
||||
// Track the master key config hash to detect when a new master key is set up in another window.
|
||||
const lastMasterKeyHashRef = useRef<string | null>(null);
|
||||
const attemptedAutoUnlockRef = useRef(false);
|
||||
useEffect(() => {
|
||||
// Compute a simple hash of the master key config to detect changes
|
||||
const currentHash = state.masterKeyConfig
|
||||
? 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
|
||||
if (currentHash !== lastMasterKeyHashRef.current) {
|
||||
lastMasterKeyHashRef.current = currentHash;
|
||||
attemptedAutoUnlockRef.current = false;
|
||||
}
|
||||
|
||||
if (attemptedAutoUnlockRef.current) return;
|
||||
if (state.securityState !== 'LOCKED') return;
|
||||
attemptedAutoUnlockRef.current = true;
|
||||
@@ -162,7 +175,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
// Ignore auto-unlock errors; manual actions will surface them.
|
||||
}
|
||||
})();
|
||||
}, [state.securityState]);
|
||||
}, [state.securityState, state.masterKeyConfig]);
|
||||
|
||||
// ========== Computed Values ==========
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
265
application/state/useUpdateCheck.ts
Normal file
265
application/state/useUpdateCheck.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK } from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// Check for updates at most once per hour
|
||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
// Delay startup check to avoid slowing down app launch
|
||||
const STARTUP_CHECK_DELAY_MS = 5000;
|
||||
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
|
||||
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
|
||||
// Debug logging for update checks
|
||||
const debugLog = (...args: unknown[]) => {
|
||||
if (IS_UPDATE_DEMO_MODE || (typeof window !== 'undefined' && window.localStorage?.getItem('debug.updateCheck') === '1')) {
|
||||
console.log('[UpdateCheck]', ...args);
|
||||
}
|
||||
};
|
||||
|
||||
export interface UpdateState {
|
||||
isChecking: boolean;
|
||||
hasUpdate: boolean;
|
||||
currentVersion: string;
|
||||
latestRelease: ReleaseInfo | null;
|
||||
error: string | null;
|
||||
lastCheckedAt: number | null;
|
||||
}
|
||||
|
||||
export interface UseUpdateCheckResult {
|
||||
updateState: UpdateState;
|
||||
checkNow: () => Promise<UpdateCheckResult | null>;
|
||||
dismissUpdate: () => void;
|
||||
openReleasePage: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing update checks
|
||||
* - Automatically checks for updates on startup (with delay)
|
||||
* - Respects dismissed version to avoid nagging
|
||||
* - Provides manual check capability
|
||||
*/
|
||||
export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
const [updateState, setUpdateState] = useState<UpdateState>({
|
||||
isChecking: false,
|
||||
hasUpdate: false,
|
||||
currentVersion: '',
|
||||
latestRelease: null,
|
||||
error: null,
|
||||
lastCheckedAt: null,
|
||||
});
|
||||
|
||||
const hasCheckedOnStartupRef = useRef(false);
|
||||
const isCheckingRef = useRef(false);
|
||||
const startupCheckTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Get current app version
|
||||
useEffect(() => {
|
||||
const loadVersion = async () => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
const info = await bridge?.getAppInfo?.();
|
||||
if (info?.version) {
|
||||
setUpdateState((prev) => ({ ...prev, currentVersion: info.version }));
|
||||
}
|
||||
} catch {
|
||||
// Ignore - running without Electron bridge
|
||||
}
|
||||
};
|
||||
void loadVersion();
|
||||
}, []);
|
||||
|
||||
const performCheck = useCallback(async (currentVersion: string): Promise<UpdateCheckResult | null> => {
|
||||
debugLog('performCheck called', { currentVersion, IS_UPDATE_DEMO_MODE });
|
||||
|
||||
// In demo mode, use a fake version to allow checking
|
||||
const effectiveVersion = IS_UPDATE_DEMO_MODE ? '0.0.1' : currentVersion;
|
||||
|
||||
if (!effectiveVersion || effectiveVersion === '0.0.0') {
|
||||
debugLog('Skipping check - invalid version:', effectiveVersion);
|
||||
// Skip check for dev builds
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isCheckingRef.current) {
|
||||
debugLog('Already checking, skipping');
|
||||
return null;
|
||||
}
|
||||
|
||||
isCheckingRef.current = true;
|
||||
setUpdateState((prev) => ({ ...prev, isChecking: true, error: null }));
|
||||
|
||||
try {
|
||||
let result: UpdateCheckResult;
|
||||
|
||||
if (IS_UPDATE_DEMO_MODE) {
|
||||
debugLog('Demo mode: creating fake update result');
|
||||
// Simulate a short delay like a real API call
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
// In demo mode, create a fake update result
|
||||
result = {
|
||||
hasUpdate: true,
|
||||
currentVersion: '0.0.1',
|
||||
latestRelease: {
|
||||
version: '1.0.0',
|
||||
tagName: 'v1.0.0',
|
||||
name: 'Netcatty v1.0.0',
|
||||
body: 'Demo release for testing update notification',
|
||||
htmlUrl: 'https://github.com/binaricat/Netcatty/releases',
|
||||
publishedAt: new Date().toISOString(),
|
||||
assets: [],
|
||||
},
|
||||
};
|
||||
} else {
|
||||
result = await checkForUpdates(currentVersion);
|
||||
}
|
||||
debugLog('Check result:', result);
|
||||
debugLog('Latest release version:', result.latestRelease?.version);
|
||||
const now = Date.now();
|
||||
|
||||
// Save last check time
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
|
||||
|
||||
// Check if this version was dismissed
|
||||
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
||||
const showUpdate = result.hasUpdate &&
|
||||
result.latestRelease?.version !== dismissedVersion;
|
||||
|
||||
debugLog('Show update:', showUpdate, 'dismissed version:', dismissedVersion);
|
||||
debugLog('Setting state with hasUpdate:', showUpdate);
|
||||
|
||||
setUpdateState((prev) => {
|
||||
debugLog('State updated:', { ...prev, hasUpdate: showUpdate, latestRelease: result.latestRelease });
|
||||
return {
|
||||
...prev,
|
||||
isChecking: false,
|
||||
hasUpdate: showUpdate,
|
||||
latestRelease: result.latestRelease,
|
||||
error: result.error || null,
|
||||
lastCheckedAt: now,
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
isChecking: false,
|
||||
error: errorMsg,
|
||||
}));
|
||||
return null;
|
||||
} finally {
|
||||
isCheckingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkNow = useCallback(async () => {
|
||||
// In demo mode, use fake version to allow checking
|
||||
const version = IS_UPDATE_DEMO_MODE ? '0.0.1' : updateState.currentVersion;
|
||||
return performCheck(version);
|
||||
}, [performCheck, updateState.currentVersion]);
|
||||
|
||||
const dismissUpdate = useCallback(() => {
|
||||
if (updateState.latestRelease?.version) {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_UPDATE_DISMISSED_VERSION,
|
||||
updateState.latestRelease.version
|
||||
);
|
||||
}
|
||||
setUpdateState((prev) => ({ ...prev, hasUpdate: false }));
|
||||
}, [updateState.latestRelease?.version]);
|
||||
|
||||
const openReleasePage = useCallback(async () => {
|
||||
const url = updateState.latestRelease
|
||||
? getReleaseUrl(updateState.latestRelease.version)
|
||||
: getReleaseUrl();
|
||||
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.openExternal) {
|
||||
await bridge.openExternal(url);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fallback to window.open
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}, [updateState.latestRelease]);
|
||||
|
||||
// Startup check with delay - runs once on mount
|
||||
useEffect(() => {
|
||||
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
|
||||
|
||||
// In demo mode, trigger check immediately after a short delay
|
||||
if (IS_UPDATE_DEMO_MODE) {
|
||||
debugLog('Demo mode: scheduling update check in', STARTUP_CHECK_DELAY_MS, 'ms');
|
||||
|
||||
startupCheckTimeoutRef.current = setTimeout(() => {
|
||||
debugLog('=== Demo mode: Triggering update check ===');
|
||||
void performCheck('0.0.1');
|
||||
}, STARTUP_CHECK_DELAY_MS);
|
||||
|
||||
return () => {
|
||||
if (startupCheckTimeoutRef.current) {
|
||||
clearTimeout(startupCheckTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Normal mode: wait for version to be loaded, then check
|
||||
// This is handled by the version-dependent effect below
|
||||
}, [performCheck]);
|
||||
|
||||
// Normal mode startup check - depends on currentVersion
|
||||
useEffect(() => {
|
||||
// Skip in demo mode (handled above)
|
||||
if (IS_UPDATE_DEMO_MODE) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('Version check effect', {
|
||||
hasChecked: hasCheckedOnStartupRef.current,
|
||||
currentVersion: updateState.currentVersion
|
||||
});
|
||||
|
||||
if (hasCheckedOnStartupRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updateState.currentVersion || updateState.currentVersion === '0.0.0') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've checked recently
|
||||
const lastCheck = localStorageAdapter.readNumber(STORAGE_KEY_UPDATE_LAST_CHECK);
|
||||
const now = Date.now();
|
||||
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
|
||||
hasCheckedOnStartupRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
hasCheckedOnStartupRef.current = true;
|
||||
debugLog('Starting delayed update check for version:', updateState.currentVersion);
|
||||
|
||||
startupCheckTimeoutRef.current = setTimeout(() => {
|
||||
debugLog('=== Delayed check triggered ===');
|
||||
void performCheck(updateState.currentVersion);
|
||||
}, STARTUP_CHECK_DELAY_MS);
|
||||
|
||||
return () => {
|
||||
if (startupCheckTimeoutRef.current) {
|
||||
clearTimeout(startupCheckTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [updateState.currentVersion, performCheck]);
|
||||
|
||||
return {
|
||||
updateState,
|
||||
checkNow,
|
||||
dismissUpdate,
|
||||
openReleasePage,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -53,6 +62,7 @@ type WizardStep =
|
||||
interface PortForwardingProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities?: import('../domain/models').Identity[];
|
||||
customGroups: string[];
|
||||
onNewHost?: () => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
@@ -62,6 +72,7 @@ interface PortForwardingProps {
|
||||
const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities = [],
|
||||
customGroups: _customGroups,
|
||||
onNewHost: _onNewHost,
|
||||
onSaveHost,
|
||||
@@ -205,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");
|
||||
@@ -356,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
|
||||
@@ -652,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)}
|
||||
/>
|
||||
@@ -683,10 +732,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
duplicateRule(editingRule.id);
|
||||
closeEditPanel();
|
||||
}}
|
||||
onDelete={() => {
|
||||
deleteRule(editingRule.id);
|
||||
closeEditPanel();
|
||||
}}
|
||||
onDelete={() => handleDeleteRule(editingRule)}
|
||||
onOpenHostSelector={() => setShowHostSelector(true)}
|
||||
/>
|
||||
)}
|
||||
@@ -796,9 +842,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
onBack={() => setShowHostSelector(false)}
|
||||
onContinue={() => setShowHostSelector(false)}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={_onCreateGroup}
|
||||
title="Select Host"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -817,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ interface QuickConnectWizardProps {
|
||||
target: QuickConnectTarget;
|
||||
keys: SSHKey[];
|
||||
knownHosts: KnownHost[];
|
||||
warnings?: string[];
|
||||
onConnect: (host: Host) => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onAddKey?: () => void;
|
||||
@@ -42,6 +43,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
target,
|
||||
keys,
|
||||
knownHosts,
|
||||
warnings,
|
||||
onConnect,
|
||||
onSaveHost,
|
||||
onAddKey,
|
||||
@@ -644,6 +646,16 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
{/* Progress indicator */}
|
||||
<div className="px-6">{renderProgressIndicator()}</div>
|
||||
|
||||
{warnings && warnings.length > 0 && (
|
||||
<div className="px-6 pb-2">
|
||||
<div className="text-xs text-amber-600 bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-2">
|
||||
{t("quickConnect.warning.unparsedOptions", {
|
||||
options: warnings.join(", "),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4">
|
||||
{step === "protocol" && renderProtocolStep()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -30,6 +30,7 @@ interface SelectHostPanelProps {
|
||||
onNewHost?: () => void;
|
||||
// Props for inline host creation
|
||||
availableKeys?: SSHKey[];
|
||||
identities?: import('../domain/models').Identity[];
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
title?: string;
|
||||
@@ -47,6 +48,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
onContinue,
|
||||
onNewHost,
|
||||
availableKeys = [],
|
||||
identities = [],
|
||||
onSaveHost,
|
||||
onCreateGroup,
|
||||
title,
|
||||
@@ -203,7 +205,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-30 flex flex-col app-no-drag",
|
||||
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-40 flex flex-col app-no-drag",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -247,7 +249,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
NEW HOST
|
||||
{t('selectHost.newHost')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
@@ -256,7 +258,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search"
|
||||
placeholder={t('common.searchPlaceholder')}
|
||||
className="h-8 pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
@@ -393,8 +395,8 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
}}
|
||||
>
|
||||
{multiSelect
|
||||
? `Continue (${selectedHostIds.length} selected)`
|
||||
: "Continue"}
|
||||
? t('selectHost.continueWithCount', { count: selectedHostIds.length })
|
||||
: t('selectHost.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -403,6 +405,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
<HostDetailsPanel
|
||||
initialData={null}
|
||||
availableKeys={availableKeys}
|
||||
identities={identities}
|
||||
groups={customGroups}
|
||||
allHosts={hosts}
|
||||
onSave={(host) => {
|
||||
|
||||
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;
|
||||
@@ -1,91 +1,145 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Bug, Github, MessageCircle, Newspaper, RefreshCcw } from "lucide-react";
|
||||
import { ArrowUpCircle, Bug, Check, Github, Loader2, MessageCircle, Newspaper, RefreshCcw } from "lucide-react";
|
||||
import AppLogo from "./AppLogo";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { SettingsTabContent } from "./settings/settings-ui";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
type AppInfo = {
|
||||
name: string;
|
||||
version: string;
|
||||
platform?: string;
|
||||
name: string;
|
||||
version: string;
|
||||
platform?: string;
|
||||
};
|
||||
|
||||
const REPO_URL = "https://github.com/binaricat/Netcatty";
|
||||
|
||||
const buildIssueUrl = (appInfo: AppInfo) => {
|
||||
const title = "Bug: ";
|
||||
const bodyLines = [
|
||||
"## Describe the problem",
|
||||
"",
|
||||
"## Steps to reproduce",
|
||||
"1.",
|
||||
"",
|
||||
"## Expected behavior",
|
||||
"",
|
||||
"## Actual behavior",
|
||||
"",
|
||||
"## Environment",
|
||||
`- App: ${appInfo.name} ${appInfo.version}`,
|
||||
`- Platform: ${appInfo.platform || "unknown"}`,
|
||||
`- UA: ${typeof navigator !== "undefined" ? navigator.userAgent : "unknown"}`,
|
||||
];
|
||||
const params = new URLSearchParams({
|
||||
title,
|
||||
body: bodyLines.join("\n"),
|
||||
});
|
||||
return `${REPO_URL}/issues/new?${params.toString()}`;
|
||||
const title = "Bug: ";
|
||||
const bodyLines = [
|
||||
"## Describe the problem",
|
||||
"",
|
||||
"## Steps to reproduce",
|
||||
"1.",
|
||||
"",
|
||||
"## Expected behavior",
|
||||
"",
|
||||
"## Actual behavior",
|
||||
"",
|
||||
"## Environment",
|
||||
`- App: ${appInfo.name} ${appInfo.version}`,
|
||||
`- Platform: ${appInfo.platform || "unknown"}`,
|
||||
`- UA: ${typeof navigator !== "undefined" ? navigator.userAgent : "unknown"}`,
|
||||
];
|
||||
const params = new URLSearchParams({
|
||||
title,
|
||||
body: bodyLines.join("\n"),
|
||||
});
|
||||
return `${REPO_URL}/issues/new?${params.toString()}`;
|
||||
};
|
||||
|
||||
const ActionRow: React.FC<{
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onClick: () => void;
|
||||
}> = ({ icon, title, subtitle, onClick }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 rounded-lg px-3 py-3 text-left",
|
||||
"hover:bg-muted/50 transition-colors"
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 text-muted-foreground">{icon}</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium leading-tight">{title}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 truncate">{subtitle}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 rounded-lg px-3 py-3 text-left",
|
||||
"hover:bg-muted/50 transition-colors"
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 text-muted-foreground">{icon}</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium leading-tight">{title}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 truncate">{subtitle}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
export default function SettingsApplicationTab() {
|
||||
const { t } = useI18n();
|
||||
const { openExternal, getApplicationInfo } = useApplicationBackend();
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
|
||||
const { t } = useI18n();
|
||||
const { openExternal, getApplicationInfo } = useApplicationBackend();
|
||||
const { updateState, checkNow, openReleasePage } = useUpdateCheck();
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
|
||||
const [lastCheckResult, setLastCheckResult] = useState<'none' | 'available' | 'upToDate'>('none');
|
||||
const [hasAutoChecked, setHasAutoChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const info = await getApplicationInfo();
|
||||
if (!cancelled && info?.name && typeof info?.version === "string") {
|
||||
setAppInfo(info);
|
||||
}
|
||||
} catch {
|
||||
// Ignore: running in browser/dev without Electron bridge
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [getApplicationInfo]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const info = await getApplicationInfo();
|
||||
if (!cancelled && info?.name && typeof info?.version === "string") {
|
||||
setAppInfo(info);
|
||||
}
|
||||
} catch {
|
||||
// Ignore: running in browser/dev without Electron bridge
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [getApplicationInfo]);
|
||||
|
||||
const issueUrl = useMemo(() => buildIssueUrl(appInfo), [appInfo]);
|
||||
const releasesUrl = `${REPO_URL}/releases`;
|
||||
// Check if demo mode is enabled for development testing
|
||||
const isUpdateDemoMode = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
|
||||
// Auto check for updates when entering this page
|
||||
useEffect(() => {
|
||||
if (hasAutoChecked) return;
|
||||
if (updateState.isChecking) return;
|
||||
|
||||
// In demo mode or when we have a valid version, auto-check
|
||||
const canCheck = isUpdateDemoMode || (appInfo.version && appInfo.version !== '0.0.0');
|
||||
if (!canCheck) return;
|
||||
|
||||
setHasAutoChecked(true);
|
||||
void checkNow();
|
||||
}, [hasAutoChecked, updateState.isChecking, isUpdateDemoMode, appInfo.version, checkNow]);
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
// In demo mode, allow checking even for dev builds
|
||||
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
|
||||
// Dev build - just open releases page
|
||||
openReleasePage();
|
||||
return;
|
||||
}
|
||||
|
||||
setLastCheckResult('none');
|
||||
|
||||
const result = await checkNow();
|
||||
|
||||
if (result?.hasUpdate && result.latestRelease) {
|
||||
setLastCheckResult('available');
|
||||
toast.info(
|
||||
t('update.available.message', { version: result.latestRelease.version }),
|
||||
t('update.available.title')
|
||||
);
|
||||
// Open the release page
|
||||
openReleasePage();
|
||||
} else if (result) {
|
||||
setLastCheckResult('upToDate');
|
||||
toast.success(
|
||||
t('update.upToDate.message', { version: appInfo.version }),
|
||||
t('update.upToDate.title')
|
||||
);
|
||||
}
|
||||
|
||||
// Reset the result after 3 seconds
|
||||
setTimeout(() => setLastCheckResult('none'), 3000);
|
||||
};
|
||||
|
||||
const issueUrl = useMemo(() => buildIssueUrl(appInfo), [appInfo]);
|
||||
const releasesUrl = `${REPO_URL}/releases`;
|
||||
const discussionsUrl = `${REPO_URL}/discussions`;
|
||||
|
||||
return (
|
||||
@@ -96,16 +150,46 @@ export default function SettingsApplicationTab() {
|
||||
<AppLogo className="w-16 h-16" />
|
||||
<div>
|
||||
<div className="text-3xl font-semibold leading-none">{appInfo.name}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{appInfo.version ? appInfo.version : " "}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{appInfo.version ? appInfo.version : " "}
|
||||
</span>
|
||||
{/* Update available badge - inline with version */}
|
||||
{updateState.hasUpdate && updateState.latestRelease && (
|
||||
<button
|
||||
onClick={() => void openReleasePage()}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
"bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300",
|
||||
"hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<ArrowUpCircle size={12} />
|
||||
v{updateState.latestRelease.version} {t('update.downloadNow')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button variant="secondary" className="gap-2" onClick={() => void openExternal(releasesUrl)}>
|
||||
<RefreshCcw size={16} />
|
||||
{t("settings.application.checkUpdates")}
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
onClick={() => void handleCheckForUpdates()}
|
||||
disabled={updateState.isChecking}
|
||||
>
|
||||
{updateState.isChecking ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : lastCheckResult === 'upToDate' ? (
|
||||
<Check size={16} />
|
||||
) : (
|
||||
<RefreshCcw size={16} />
|
||||
)}
|
||||
{updateState.isChecking
|
||||
? t("update.checking")
|
||||
: t("settings.application.checkUpdates")
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
@@ -46,7 +48,8 @@ import KeychainManager from "./KeychainManager";
|
||||
import KnownHostsManager from "./KnownHostsManager";
|
||||
import PortForwarding from "./PortForwardingNew";
|
||||
import QuickConnectWizard from "./QuickConnectWizard";
|
||||
import { isQuickConnectInput, parseQuickConnectInput } from "../domain/quickConnect";
|
||||
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(() => {
|
||||
@@ -184,6 +190,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
port?: number;
|
||||
} | null>(null);
|
||||
const [isQuickConnectOpen, setIsQuickConnectOpen] = useState(false);
|
||||
const [quickConnectWarnings, setQuickConnectWarnings] = useState<string[]>([]);
|
||||
|
||||
// Protocol select state (for hosts with multiple protocols)
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(
|
||||
@@ -198,9 +205,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
// Handle connect button click - detect quick connect or regular search
|
||||
const handleConnectClick = useCallback(() => {
|
||||
if (isSearchQuickConnect) {
|
||||
const target = parseQuickConnectInput(search);
|
||||
if (target) {
|
||||
setQuickConnectTarget(target);
|
||||
const parsed = parseQuickConnectInputWithWarnings(search);
|
||||
if (parsed.target) {
|
||||
setQuickConnectTarget(parsed.target);
|
||||
setQuickConnectWarnings(parsed.warnings);
|
||||
setIsQuickConnectOpen(true);
|
||||
}
|
||||
} else {
|
||||
@@ -268,6 +276,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onConnect(host);
|
||||
setIsQuickConnectOpen(false);
|
||||
setQuickConnectTarget(null);
|
||||
setQuickConnectWarnings([]);
|
||||
setSearch("");
|
||||
},
|
||||
[onConnect],
|
||||
@@ -957,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>
|
||||
)}
|
||||
@@ -1308,6 +1329,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<PortForwarding
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
customGroups={customGroups}
|
||||
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
|
||||
onCreateGroup={(groupPath) =>
|
||||
@@ -1352,6 +1374,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
)}
|
||||
allTags={allTags}
|
||||
allHosts={hosts}
|
||||
defaultGroup={editingHost ? undefined : selectedGroupPath}
|
||||
onSave={(host) => {
|
||||
onUpdateHosts(
|
||||
editingHost
|
||||
@@ -1483,7 +1506,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onClose={() => {
|
||||
setIsQuickConnectOpen(false);
|
||||
setQuickConnectTarget(null);
|
||||
setQuickConnectWarnings([]);
|
||||
}}
|
||||
warnings={quickConnectWarnings}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1497,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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AlertCircle,AlertTriangle,CheckCircle,Info,X } from 'lucide-react';
|
||||
import React,{ createContext,useCallback,useContext,useEffect,useState } from 'react';
|
||||
import { AlertCircle, AlertTriangle, CheckCircle, Info, X } from 'lucide-react';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
@@ -10,6 +10,8 @@ export interface Toast {
|
||||
title?: string;
|
||||
message: string;
|
||||
duration?: number;
|
||||
onClick?: () => void;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
@@ -31,18 +33,29 @@ export const useToast = () => {
|
||||
// Simple hook for components that may not be inside ToastProvider
|
||||
let globalShowToast: ((toast: Omit<Toast, 'id'>) => void) | null = null;
|
||||
|
||||
export interface ToastOptions {
|
||||
title?: string;
|
||||
duration?: number;
|
||||
onClick?: () => void;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (message: string, title?: string) => {
|
||||
globalShowToast?.({ type: 'success', message, title, duration: 3000 });
|
||||
success: (message: string, titleOrOptions?: string | ToastOptions) => {
|
||||
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
|
||||
globalShowToast?.({ type: 'success', message, duration: 3000, ...options });
|
||||
},
|
||||
error: (message: string, title?: string) => {
|
||||
globalShowToast?.({ type: 'error', message, title, duration: 5000 });
|
||||
error: (message: string, titleOrOptions?: string | ToastOptions) => {
|
||||
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
|
||||
globalShowToast?.({ type: 'error', message, duration: 5000, ...options });
|
||||
},
|
||||
warning: (message: string, title?: string) => {
|
||||
globalShowToast?.({ type: 'warning', message, title, duration: 4000 });
|
||||
warning: (message: string, titleOrOptions?: string | ToastOptions) => {
|
||||
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
|
||||
globalShowToast?.({ type: 'warning', message, duration: 4000, ...options });
|
||||
},
|
||||
info: (message: string, title?: string) => {
|
||||
globalShowToast?.({ type: 'info', message, title, duration: 3000 });
|
||||
info: (message: string, titleOrOptions?: string | ToastOptions) => {
|
||||
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
|
||||
globalShowToast?.({ type: 'info', message, duration: 3000, ...options });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -99,6 +112,13 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
const ToastContainer: React.FC<{ toasts: Toast[]; onDismiss: (id: string) => void }> = ({ toasts, onDismiss }) => {
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
const handleToastClick = (t: Toast) => {
|
||||
if (t.onClick) {
|
||||
t.onClick();
|
||||
onDismiss(t.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm">
|
||||
{toasts.map(t => (
|
||||
@@ -107,8 +127,12 @@ const ToastContainer: React.FC<{ toasts: Toast[]; onDismiss: (id: string) => voi
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-3 rounded-lg border shadow-lg",
|
||||
"bg-card animate-in slide-in-from-right-5 fade-in duration-200",
|
||||
TOAST_STYLES[t.type]
|
||||
TOAST_STYLES[t.type],
|
||||
t.onClick && "cursor-pointer hover:opacity-90 transition-opacity"
|
||||
)}
|
||||
onClick={() => handleToastClick(t)}
|
||||
role={t.onClick ? "button" : undefined}
|
||||
tabIndex={t.onClick ? 0 : undefined}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{TOAST_ICONS[t.type]}
|
||||
@@ -118,9 +142,12 @@ const ToastContainer: React.FC<{ toasts: Toast[]; onDismiss: (id: string) => voi
|
||||
<div className="text-sm font-medium text-foreground">{t.title}</div>
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground break-words">{t.message}</div>
|
||||
{t.actionLabel && t.onClick && (
|
||||
<div className="text-xs font-medium text-primary mt-1">{t.actionLabel} →</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDismiss(t.id)}
|
||||
onClick={(e) => { e.stopPropagation(); onDismiss(t.id); }}
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,10 +4,12 @@ export interface QuickConnectTarget {
|
||||
port?: number;
|
||||
}
|
||||
|
||||
// Parse user@host:port format
|
||||
export function parseQuickConnectInput(
|
||||
input: string,
|
||||
): QuickConnectTarget | null {
|
||||
export interface QuickConnectParseResult {
|
||||
target: QuickConnectTarget | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const parseDirectTarget = (input: string): QuickConnectTarget | null => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
@@ -43,10 +45,230 @@ export function parseQuickConnectInput(
|
||||
username: username || undefined,
|
||||
port,
|
||||
};
|
||||
};
|
||||
|
||||
const sshArgOptions = new Set([
|
||||
"-b",
|
||||
"-c",
|
||||
"-D",
|
||||
"-E",
|
||||
"-F",
|
||||
"-i",
|
||||
"-I",
|
||||
"-J",
|
||||
"-L",
|
||||
"-m",
|
||||
"-O",
|
||||
"-P",
|
||||
"-R",
|
||||
"-S",
|
||||
"-W",
|
||||
"-w",
|
||||
]);
|
||||
|
||||
const parseSshOption = (
|
||||
raw: string,
|
||||
nextToken?: string,
|
||||
): { key: string; value: string; consumedNext: boolean } | null => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const parts = trimmed.split("=");
|
||||
if (parts.length >= 2) {
|
||||
const key = parts[0]?.trim();
|
||||
const value = parts.slice(1).join("=").trim();
|
||||
if (key && value) {
|
||||
return { key, value, consumedNext: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (nextToken && !nextToken.startsWith("-")) {
|
||||
return { key: trimmed, value: nextToken, consumedNext: true };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseSshCommand = (input: string): QuickConnectParseResult | null => {
|
||||
const trimmed = input.trim();
|
||||
if (!/^ssh(\s|$)/i.test(trimmed)) return null;
|
||||
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
if (tokens.length < 2) return null;
|
||||
|
||||
const warnings: string[] = [];
|
||||
let username: string | undefined;
|
||||
let optionUsername: string | undefined;
|
||||
let port: number | undefined;
|
||||
let optionPort: number | undefined;
|
||||
let portInvalid = false;
|
||||
let optionHostname: string | undefined;
|
||||
let hostToken: string | undefined;
|
||||
|
||||
for (let i = 1; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (!token) continue;
|
||||
|
||||
if (token === "-p") {
|
||||
const value = tokens[i + 1];
|
||||
if (value) {
|
||||
port = parseInt(value, 10);
|
||||
if (Number.isNaN(port)) portInvalid = true;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("-p") && token.length > 2) {
|
||||
const value = token.replace(/^-p=?/, "");
|
||||
if (value) {
|
||||
port = parseInt(value, 10);
|
||||
if (Number.isNaN(port)) portInvalid = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === "-l") {
|
||||
const value = tokens[i + 1];
|
||||
if (value) {
|
||||
username = value;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("-l") && token.length > 2) {
|
||||
const value = token.replace(/^-l=?/, "");
|
||||
if (value) username = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === "-o") {
|
||||
const optionToken = tokens[i + 1];
|
||||
if (optionToken) {
|
||||
const nextToken = tokens[i + 2];
|
||||
const parsed = parseSshOption(optionToken, nextToken);
|
||||
if (parsed) {
|
||||
const key = parsed.key.toLowerCase();
|
||||
if (key === "port") {
|
||||
const parsedPort = parseInt(parsed.value, 10);
|
||||
if (Number.isNaN(parsedPort)) {
|
||||
portInvalid = true;
|
||||
} else {
|
||||
optionPort = parsedPort;
|
||||
}
|
||||
} else if (key === "user") {
|
||||
optionUsername = parsed.value;
|
||||
} else if (key === "hostname") {
|
||||
optionHostname = parsed.value;
|
||||
} else {
|
||||
warnings.push(`-o ${parsed.key}`);
|
||||
}
|
||||
i += parsed.consumedNext ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
warnings.push("-o");
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("-o") && token.length > 2) {
|
||||
const parsed = parseSshOption(token.slice(2), tokens[i + 1]);
|
||||
if (parsed) {
|
||||
const key = parsed.key.toLowerCase();
|
||||
if (key === "port") {
|
||||
const parsedPort = parseInt(parsed.value, 10);
|
||||
if (Number.isNaN(parsedPort)) {
|
||||
portInvalid = true;
|
||||
} else {
|
||||
optionPort = parsedPort;
|
||||
}
|
||||
} else if (key === "user") {
|
||||
optionUsername = parsed.value;
|
||||
} else if (key === "hostname") {
|
||||
optionHostname = parsed.value;
|
||||
} else {
|
||||
warnings.push(`-o ${parsed.key}`);
|
||||
}
|
||||
if (parsed.consumedNext) i++;
|
||||
continue;
|
||||
}
|
||||
warnings.push("-o");
|
||||
}
|
||||
|
||||
if (sshArgOptions.has(token)) {
|
||||
warnings.push(token);
|
||||
const next = tokens[i + 1];
|
||||
if (next) i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("-")) {
|
||||
warnings.push(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hostToken) {
|
||||
hostToken = token;
|
||||
} else {
|
||||
warnings.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hostToken) return null;
|
||||
|
||||
const base = optionHostname
|
||||
? parseDirectTarget(optionHostname)
|
||||
: parseDirectTarget(hostToken);
|
||||
if (!base) return null;
|
||||
|
||||
if (portInvalid) return null;
|
||||
|
||||
const resolvedPort =
|
||||
port !== undefined && !Number.isNaN(port)
|
||||
? port
|
||||
: optionPort !== undefined && !Number.isNaN(optionPort)
|
||||
? optionPort
|
||||
: base.port;
|
||||
if (
|
||||
resolvedPort !== undefined &&
|
||||
(Number.isNaN(resolvedPort) || resolvedPort < 1 || resolvedPort > 65535)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
target: {
|
||||
hostname: base.hostname,
|
||||
username: optionUsername || username || base.username,
|
||||
port: resolvedPort,
|
||||
},
|
||||
warnings: Array.from(new Set(warnings)),
|
||||
};
|
||||
};
|
||||
|
||||
// Parse user@host:port or ssh command formats with warning details
|
||||
export function parseQuickConnectInputWithWarnings(
|
||||
input: string,
|
||||
): QuickConnectParseResult {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { target: null, warnings: [] };
|
||||
|
||||
const sshTarget = parseSshCommand(trimmed);
|
||||
if (sshTarget) return sshTarget;
|
||||
|
||||
return { target: parseDirectTarget(trimmed), warnings: [] };
|
||||
}
|
||||
|
||||
// Parse user@host:port or ssh command formats
|
||||
export function parseQuickConnectInput(
|
||||
input: string,
|
||||
): QuickConnectTarget | null {
|
||||
return parseQuickConnectInputWithWarnings(input).target;
|
||||
}
|
||||
|
||||
// Check if input looks like a quick connect address
|
||||
export function isQuickConnectInput(input: string): boolean {
|
||||
return parseQuickConnectInput(input) !== null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,17 +1,28 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -52,6 +63,32 @@ function findExecutable(name) {
|
||||
return name;
|
||||
}
|
||||
|
||||
const isUtf8Locale = (value) => typeof value === "string" && /utf-?8/i.test(value);
|
||||
|
||||
const isEmptyLocale = (value) => {
|
||||
if (value === undefined || value === null) return true;
|
||||
const trimmed = String(value).trim();
|
||||
if (!trimmed) return true;
|
||||
return trimmed === "C" || trimmed === "POSIX";
|
||||
};
|
||||
|
||||
const applyLocaleDefaults = (env) => {
|
||||
const hasUtf8 =
|
||||
isUtf8Locale(env.LC_ALL) || isUtf8Locale(env.LC_CTYPE) || isUtf8Locale(env.LANG);
|
||||
if (hasUtf8) return env;
|
||||
|
||||
const hasAnyLocale =
|
||||
!isEmptyLocale(env.LC_ALL) || !isEmptyLocale(env.LC_CTYPE) || !isEmptyLocale(env.LANG);
|
||||
if (hasAnyLocale) return env;
|
||||
|
||||
return {
|
||||
...env,
|
||||
LANG: DEFAULT_UTF8_LOCALE,
|
||||
LC_CTYPE: DEFAULT_UTF8_LOCALE,
|
||||
LC_ALL: DEFAULT_UTF8_LOCALE,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a local terminal session
|
||||
*/
|
||||
@@ -63,17 +100,38 @@ function startLocalSession(event, payload) {
|
||||
? findExecutable("powershell") || "powershell.exe"
|
||||
: process.env.SHELL || "/bin/bash";
|
||||
const shell = payload?.shell || defaultShell;
|
||||
const env = {
|
||||
const shellArgs = getLoginShellArgs(shell);
|
||||
const env = applyLocaleDefaults({
|
||||
...process.env,
|
||||
...(payload?.env || {}),
|
||||
TERM: "xterm-256color",
|
||||
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 = {
|
||||
@@ -386,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
|
||||
*/
|
||||
@@ -400,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') {
|
||||
@@ -454,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) {
|
||||
@@ -473,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
|
||||
*/
|
||||
@@ -497,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) {
|
||||
@@ -517,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,68 @@ 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);
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -31,5 +31,9 @@ export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v
|
||||
export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';
|
||||
|
||||
// Update check
|
||||
export const STORAGE_KEY_UPDATE_LAST_CHECK = 'netcatty_update_last_check_v1';
|
||||
export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_version_v1';
|
||||
|
||||
// Archived legacy key records that are no longer supported by the app (e.g. biometric/WebAuthn/FIDO2 experiments).
|
||||
export const STORAGE_KEY_LEGACY_KEYS = 'netcatty_legacy_keys_v1';
|
||||
|
||||
@@ -217,6 +217,26 @@ export class CloudSyncManager {
|
||||
const key = event.key;
|
||||
if (!key) return;
|
||||
|
||||
// Handle master key config changes (e.g., when set up in settings window)
|
||||
if (key === SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG) {
|
||||
const nextConfig = this.safeJsonParse<MasterKeyConfig>(event.newValue);
|
||||
|
||||
if (nextConfig && !this.state.masterKeyConfig) {
|
||||
// Master key was set up in another window - update our state
|
||||
this.state.masterKeyConfig = nextConfig;
|
||||
this.state.securityState = 'LOCKED';
|
||||
this.notifyStateChange();
|
||||
} else if (!nextConfig && this.state.masterKeyConfig) {
|
||||
// Master key was removed in another window
|
||||
this.state.masterKeyConfig = null;
|
||||
this.state.securityState = 'NO_KEY';
|
||||
this.state.unlockedKey = null;
|
||||
this.masterPassword = null;
|
||||
this.notifyStateChange();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync versions + auto-sync settings
|
||||
if (key === SYNC_STORAGE_KEYS.SYNC_CONFIG) {
|
||||
const next = this.safeJsonParse<{
|
||||
|
||||
166
infrastructure/services/updateService.ts
Normal file
166
infrastructure/services/updateService.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Update Service - Checks GitHub releases for new versions
|
||||
*/
|
||||
|
||||
const GITHUB_API_URL = 'https://api.github.com/repos/binaricat/Netcatty/releases/latest';
|
||||
const RELEASES_PAGE_URL = 'https://github.com/binaricat/Netcatty/releases';
|
||||
|
||||
export interface ReleaseInfo {
|
||||
version: string; // e.g. "1.0.0" (without 'v' prefix)
|
||||
tagName: string; // e.g. "v1.0.0"
|
||||
name: string; // Release title
|
||||
body: string; // Release notes (markdown)
|
||||
htmlUrl: string; // URL to the release page
|
||||
publishedAt: string; // ISO date string
|
||||
assets: ReleaseAsset[];
|
||||
}
|
||||
|
||||
export interface ReleaseAsset {
|
||||
name: string;
|
||||
browserDownloadUrl: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
hasUpdate: boolean;
|
||||
currentVersion: string;
|
||||
latestRelease: ReleaseInfo | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse version string to comparable array
|
||||
* e.g. "1.2.3" -> [1, 2, 3]
|
||||
*/
|
||||
function parseVersion(version: string): number[] {
|
||||
// Remove 'v' prefix if present
|
||||
const clean = version.replace(/^v/i, '');
|
||||
return clean.split('.').map((part) => {
|
||||
const num = parseInt(part, 10);
|
||||
return isNaN(num) ? 0 : num;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two version strings
|
||||
* Returns: 1 if a > b, -1 if a < b, 0 if equal
|
||||
*/
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const partsA = parseVersion(a);
|
||||
const partsB = parseVersion(b);
|
||||
const maxLen = Math.max(partsA.length, partsB.length);
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const numA = partsA[i] ?? 0;
|
||||
const numB = partsB[i] ?? 0;
|
||||
if (numA > numB) return 1;
|
||||
if (numA < numB) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest release info from GitHub
|
||||
*/
|
||||
export async function fetchLatestRelease(): Promise<ReleaseInfo | null> {
|
||||
try {
|
||||
const response = await fetch(GITHUB_API_URL, {
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
// Using anonymous access - rate limited to 60 requests/hour
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
// No releases yet
|
||||
return null;
|
||||
}
|
||||
throw new Error(`GitHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
version: data.tag_name?.replace(/^v/i, '') || '0.0.0',
|
||||
tagName: data.tag_name || '',
|
||||
name: data.name || data.tag_name || '',
|
||||
body: data.body || '',
|
||||
htmlUrl: data.html_url || RELEASES_PAGE_URL,
|
||||
publishedAt: data.published_at || '',
|
||||
assets: (data.assets || []).map((asset: { name?: string; browser_download_url?: string; size?: number }) => ({
|
||||
name: asset.name || '',
|
||||
browserDownloadUrl: asset.browser_download_url || '',
|
||||
size: asset.size || 0,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[UpdateService] Failed to fetch latest release:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for updates
|
||||
*/
|
||||
export async function checkForUpdates(currentVersion: string): Promise<UpdateCheckResult> {
|
||||
const result: UpdateCheckResult = {
|
||||
hasUpdate: false,
|
||||
currentVersion,
|
||||
latestRelease: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const release = await fetchLatestRelease();
|
||||
if (!release) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.latestRelease = release;
|
||||
result.hasUpdate = compareVersions(release.version, currentVersion) > 0;
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : 'Unknown error';
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get release page URL for a specific version
|
||||
*/
|
||||
export function getReleaseUrl(version?: string): string {
|
||||
if (version) {
|
||||
return `${RELEASES_PAGE_URL}/tag/v${version.replace(/^v/i, '')}`;
|
||||
}
|
||||
return RELEASES_PAGE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download URL for current platform
|
||||
*/
|
||||
export function getDownloadUrlForPlatform(
|
||||
release: ReleaseInfo,
|
||||
platform: string
|
||||
): string | null {
|
||||
const assets = release.assets;
|
||||
|
||||
// Platform-specific file patterns
|
||||
const patterns: Record<string, RegExp[]> = {
|
||||
win32: [/\.exe$/i, /win.*\.zip$/i, /windows/i],
|
||||
darwin: [/\.dmg$/i, /mac.*\.zip$/i, /darwin/i],
|
||||
linux: [/\.AppImage$/i, /\.deb$/i, /linux/i],
|
||||
};
|
||||
|
||||
const platformPatterns = patterns[platform] || [];
|
||||
|
||||
for (const pattern of platformPatterns) {
|
||||
const asset = assets.find((a) => pattern.test(a.name));
|
||||
if (asset) {
|
||||
return asset.browserDownloadUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to release page
|
||||
return null;
|
||||
}
|
||||
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 |
Binary file not shown.
|
Before Width: | Height: | Size: 785 KiB After Width: | Height: | Size: 875 KiB |
Reference in New Issue
Block a user