Compare commits
177 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec04334a21 | ||
|
|
57e3641ec5 | ||
|
|
ad67099ff3 | ||
|
|
02d44652df | ||
|
|
d227424096 | ||
|
|
1105f7fbb1 | ||
|
|
ef681194e3 | ||
|
|
4971a72620 | ||
|
|
8947d29717 | ||
|
|
dfaeed1ed6 | ||
|
|
443e038dcf | ||
|
|
242d35927a | ||
|
|
708ee1cd09 | ||
|
|
a2c24c2656 | ||
|
|
d91ed8dd23 | ||
|
|
689bb313f7 | ||
|
|
4ff05f7dbb | ||
|
|
8258ad6e95 | ||
|
|
f930e80dab | ||
|
|
e19b68db12 | ||
|
|
f6e67b6edb | ||
|
|
a86c74e509 | ||
|
|
bedcaddea7 | ||
|
|
78aaa6840b | ||
|
|
dff869a89d | ||
|
|
78d7b417fc | ||
|
|
27fcc4e493 | ||
|
|
b7216e9427 | ||
|
|
be4da72b21 | ||
|
|
7b903c44b0 | ||
|
|
c3c23d042f | ||
|
|
3263676996 | ||
|
|
7c6a14afda | ||
|
|
6a76287bf7 | ||
|
|
5317a4b81b | ||
|
|
2574d6d5e4 | ||
|
|
f04b1220ed | ||
|
|
ce4d156c2c | ||
|
|
ca46c9c924 | ||
|
|
f0d2c5c60d | ||
|
|
6cdf33a29d | ||
|
|
9b0b7c0eb7 | ||
|
|
5954359995 | ||
|
|
044165319e | ||
|
|
131553128a | ||
|
|
4aae4b19fc | ||
|
|
7b5fb46fd7 | ||
|
|
5bfb1f01c2 | ||
|
|
12188e11ef | ||
|
|
c0756e9981 | ||
|
|
b600aedc6f | ||
|
|
9fe915c65e | ||
|
|
1aa634a6c2 | ||
|
|
bfbab88ac2 | ||
|
|
faa7fd6dad | ||
|
|
9c6c653931 | ||
|
|
d46b63398e | ||
|
|
72bc03573c | ||
|
|
66c543cb97 | ||
|
|
fc61647c34 | ||
|
|
b2390da5b6 | ||
|
|
a3e0d4d5c1 | ||
|
|
45af36fd28 | ||
|
|
00784a6b0e | ||
|
|
de6acf0347 | ||
|
|
7c067964ee | ||
|
|
6b4cecf94f | ||
|
|
6b83f6c494 | ||
|
|
d2b58e69b0 | ||
|
|
7ffc9d427e | ||
|
|
d6db6c5db1 | ||
|
|
a528ade563 | ||
|
|
3e2edbec5e | ||
|
|
f7464f1d45 | ||
|
|
3bbe5f5fc4 | ||
|
|
e515e3d981 | ||
|
|
41ecef675c | ||
|
|
ac6f61b8cf | ||
|
|
0990c26cb2 | ||
|
|
753ce0480c | ||
|
|
974506415e | ||
|
|
51330c0443 | ||
|
|
ba761004e0 | ||
|
|
4278188292 | ||
|
|
cccb17c919 | ||
|
|
ab0e5e95b3 | ||
|
|
4102a45810 | ||
|
|
dc14255983 | ||
|
|
771eef0af9 | ||
|
|
45e9960d6b | ||
|
|
8ced017474 | ||
|
|
4a07c00a71 | ||
|
|
33cacfcd3d | ||
|
|
35b72b0992 | ||
|
|
fd77431847 | ||
|
|
c5f7540c6e | ||
|
|
b7428d0cbb | ||
|
|
02c4d97934 | ||
|
|
986f552779 | ||
|
|
42647e3572 | ||
|
|
5930d1601a | ||
|
|
df3d507e2b | ||
|
|
f8c7a9081b | ||
|
|
d8cfb0f1d9 | ||
|
|
269d790f28 | ||
|
|
0f12eab680 | ||
|
|
139fa43c43 | ||
|
|
eb30e6580e | ||
|
|
104a0c73d2 | ||
|
|
fc16739e99 | ||
|
|
dd386f218f | ||
|
|
254558771c | ||
|
|
9c9d01f372 | ||
|
|
a75b981630 | ||
|
|
2b706b7b4e | ||
|
|
8276f63c65 | ||
|
|
cac621413c | ||
|
|
897ddaddbf | ||
|
|
d51c0f526c | ||
|
|
7acd9b3b8d | ||
|
|
05345d1ac7 | ||
|
|
1f1ec8f7a6 | ||
|
|
8abba4bc7d | ||
|
|
ccf707df5a | ||
|
|
48d7a63d2e | ||
|
|
ad7f523ec2 | ||
|
|
a905b3e092 | ||
|
|
23148e88b1 | ||
|
|
23c6c55968 | ||
|
|
a53264013c | ||
|
|
7f58e039a2 | ||
|
|
4999a6884b | ||
|
|
eb8b565a77 | ||
|
|
cf103d7421 | ||
|
|
88b8cfb4da | ||
|
|
24f7a5a805 | ||
|
|
37d289be50 | ||
|
|
74f99e65d9 | ||
|
|
937608e7f3 | ||
|
|
3e1b72b869 | ||
|
|
9d04ae86f4 | ||
|
|
7beb9c1444 | ||
|
|
dd2f23b672 | ||
|
|
eac1007764 | ||
|
|
62625214a0 | ||
|
|
a6ae160932 | ||
|
|
6f1431e623 | ||
|
|
bebd161a98 | ||
|
|
3eaac53515 | ||
|
|
3a6949862d | ||
|
|
3c843c448a | ||
|
|
ff6fa55829 | ||
|
|
a9fabf6677 | ||
|
|
aa42468ccd | ||
|
|
242c420961 | ||
|
|
abdac05db6 | ||
|
|
84809b37a7 | ||
|
|
2cdd83d6f1 | ||
|
|
0ecb51ea17 | ||
|
|
326e613e82 | ||
|
|
71aaeba17b | ||
|
|
9d2e19a034 | ||
|
|
fcdf5bce32 | ||
|
|
ea655d95a3 | ||
|
|
be5110f306 | ||
|
|
3876e8c479 | ||
|
|
7143bce61e | ||
|
|
7c232335ef | ||
|
|
cc61974744 | ||
|
|
86de2b2b60 | ||
|
|
27301f3ecd | ||
|
|
7f30d7c662 | ||
|
|
456755a2ec | ||
|
|
1eee141bed | ||
|
|
e808a2c51c | ||
|
|
15ee1fd020 | ||
|
|
c3cbf4fbdf |
15
.github/workflows/build.yml
vendored
15
.github/workflows/build.yml
vendored
@@ -2,6 +2,11 @@ name: build-packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_release:
|
||||
description: "Publish GitHub Release after build"
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
@@ -13,7 +18,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest]
|
||||
os: [macos-latest, windows-latest, ubuntu-latest]
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
@@ -53,6 +58,12 @@ jobs:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:win
|
||||
|
||||
- name: Build package (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
env:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -74,7 +85,7 @@ jobs:
|
||||
name: release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,6 +22,7 @@ coverage
|
||||
/release
|
||||
/out
|
||||
*.asar
|
||||
/public/monaco
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
36
App.tsx
36
App.tsx
@@ -1,10 +1,13 @@
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
|
||||
import { useAutoSync } from './application/state/useAutoSync';
|
||||
import { usePortForwardingAutoStart } from './application/state/usePortForwardingAutoStart';
|
||||
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 { initializeFonts } from './application/state/fontStore';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
@@ -22,6 +25,9 @@ import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
import type { SftpView as SftpViewComponent } from './components/SftpView';
|
||||
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
|
||||
|
||||
// Initialize fonts eagerly at app startup
|
||||
initializeFonts();
|
||||
|
||||
// Visibility container for VaultView - isolates isActive subscription
|
||||
const VaultViewContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const isActive = useIsVaultActive();
|
||||
@@ -207,6 +213,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
submitWorkspaceRename,
|
||||
resetWorkspaceRename,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
@@ -256,6 +263,34 @@ 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]);
|
||||
|
||||
// Auto-start port forwarding rules on app launch
|
||||
usePortForwardingAutoStart({
|
||||
hosts,
|
||||
keys: keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
|
||||
});
|
||||
|
||||
// Debounce ref for moveFocus to prevent double-triggering when focus switches
|
||||
const lastMoveFocusTimeRef = useRef<number>(0);
|
||||
const MOVE_FOCUS_DEBOUNCE_MS = 200;
|
||||
@@ -741,6 +776,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
|
||||
|
||||
53
README.md
53
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="#"><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="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">
|
||||
@@ -61,7 +67,7 @@
|
||||
<a name="what-is-netcatty"></a>
|
||||
# What is Netcatty
|
||||
|
||||
**Netcatty** is a modern SSH client and terminal manager for macOS and Windows, designed for developers, sysadmins, and DevOps engineers who need to manage multiple remote servers efficiently.
|
||||
**Netcatty** is a modern SSH client and terminal manager for macOS, Windows, and Linux, designed for developers, sysadmins, and DevOps engineers who need to manage multiple remote servers efficiently.
|
||||
|
||||
- **Netcatty is** an alternative to PuTTY, Termius, SecureCRT, and macOS Terminal.app for SSH connections
|
||||
- **Netcatty is** a powerful SFTP client with dual-pane file browser
|
||||
@@ -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
|
||||
@@ -255,14 +279,14 @@ Download the latest release from [GitHub Releases](https://github.com/user/netca
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+ and npm
|
||||
- macOS or Windows 10+
|
||||
- macOS, Windows 10+, or Linux
|
||||
|
||||
### Development
|
||||
|
||||
```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
|
||||
@@ -305,6 +329,7 @@ npm run pack
|
||||
# Package for specific platforms
|
||||
npm run pack:mac # macOS (DMG + ZIP)
|
||||
npm run pack:win # Windows (NSIS installer)
|
||||
npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,7 @@ This project is wired around three layers: domain (pure logic), application stat
|
||||
## Data & Storage
|
||||
- Persisted keys: see `storageKeys.ts`. Use `localStorageAdapter` for all reads/writes.
|
||||
- Seed data: `config/defaultData.ts`; terminal themes: `config/terminalThemes.ts`.
|
||||
- **Temporary files**: All temporary files (e.g., SFTP downloaded files for external editing) must be written to Netcatty's dedicated temp directory via `tempDirBridge.getTempFilePath(fileName)`. Do not write directly to `os.tmpdir()`. This ensures proper cleanup and user visibility in Settings > System.
|
||||
|
||||
## Testing & Safety
|
||||
- Favor unit tests for domain helpers (e.g., `workspace.ts`, `host.ts`) and hook-level tests for application state.
|
||||
|
||||
@@ -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',
|
||||
@@ -60,6 +61,21 @@ const en: Messages = {
|
||||
'settings.tab.terminal': 'Terminal',
|
||||
'settings.tab.shortcuts': 'Shortcuts',
|
||||
'settings.tab.syncCloud': 'Sync & Cloud',
|
||||
'settings.tab.system': 'System',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': 'System',
|
||||
'settings.system.description': 'System information and temporary file management.',
|
||||
'settings.system.tempDirectory': 'Temporary Files',
|
||||
'settings.system.location': 'Location',
|
||||
'settings.system.fileCount': 'Files',
|
||||
'settings.system.totalSize': 'Size',
|
||||
'settings.system.openFolder': 'Open folder',
|
||||
'settings.system.refresh': 'Refresh',
|
||||
'settings.system.clearTempFiles': 'Clear temp files',
|
||||
'settings.system.clearing': 'Clearing...',
|
||||
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
|
||||
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': 'Check for updates',
|
||||
@@ -72,6 +88,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',
|
||||
@@ -95,6 +122,10 @@ const en: Messages = {
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': 'Terminal Theme',
|
||||
'settings.terminal.themeModal.title': 'Select Theme',
|
||||
'settings.terminal.themeModal.darkThemes': 'Dark Themes',
|
||||
'settings.terminal.themeModal.lightThemes': 'Light Themes',
|
||||
'settings.terminal.theme.selectButton': 'Select Theme',
|
||||
'settings.terminal.section.font': 'Font',
|
||||
'settings.terminal.section.cursor': 'Cursor',
|
||||
'settings.terminal.section.keyboard': 'Keyboard',
|
||||
@@ -155,6 +186,21 @@ 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.terminal.section.connection': 'Connection',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets to server. Set to 0 to disable.',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
|
||||
@@ -246,7 +292,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,9 +391,16 @@ 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',
|
||||
'pf.form.autoStart': 'Auto Start',
|
||||
'pf.form.autoStartDesc': 'Automatically start this rule when the app launches',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'New Folder',
|
||||
'sftp.filter': 'Filter',
|
||||
'sftp.filter.placeholder': 'Filter by filename...',
|
||||
'sftp.columns.name': 'Name',
|
||||
'sftp.columns.modified': 'Modified',
|
||||
'sftp.columns.size': 'Size',
|
||||
@@ -371,11 +424,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?',
|
||||
@@ -390,6 +445,7 @@ const en: Messages = {
|
||||
'sftp.error.uploadFailed': 'Upload failed',
|
||||
'sftp.error.deleteFailed': 'Delete failed',
|
||||
'sftp.error.createFolderFailed': 'Failed to create folder',
|
||||
'sftp.error.renameFailed': 'Failed to rename',
|
||||
'sftp.picker.title': 'Select Host',
|
||||
'sftp.picker.desc': 'Pick a host for the {side} pane',
|
||||
'sftp.picker.searchPlaceholder': 'Search hosts...',
|
||||
@@ -403,11 +459,16 @@ const en: Messages = {
|
||||
'sftp.permissions.others': 'Others',
|
||||
'sftp.permissions.octal': 'Octal',
|
||||
'sftp.permissions.symbolic': 'Symbolic',
|
||||
'sftp.permissions.success': 'Permissions updated successfully',
|
||||
'sftp.permissions.failed': 'Failed to update permissions',
|
||||
'sftp.pane.local': 'Local',
|
||||
'sftp.pane.remote': 'Remote',
|
||||
'sftp.pane.selectHost': 'Select host',
|
||||
'sftp.pane.selectHostToStart': 'Select a host to start',
|
||||
'sftp.pane.chooseFilesystem': 'Choose a local or remote filesystem to browse',
|
||||
'sftp.tabs.addTab': 'Add new tab',
|
||||
'sftp.tabs.closeTab': 'Close tab',
|
||||
'sftp.tabs.newTab': 'New Tab',
|
||||
'sftp.conflict.title': 'File Conflict',
|
||||
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'already exists',
|
||||
@@ -420,6 +481,67 @@ const en: Messages = {
|
||||
'sftp.conflict.action.keepBoth': 'Keep Both',
|
||||
'sftp.conflict.action.replace': 'Replace',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.openWith': 'Open with...',
|
||||
'sftp.context.edit': 'Edit',
|
||||
'sftp.context.preview': 'Preview',
|
||||
'sftp.opener.title': 'Open with',
|
||||
'sftp.opener.desc': 'Choose an application to open this file',
|
||||
'sftp.opener.builtInEditor': 'Built-in Editor',
|
||||
'sftp.opener.editDescription': 'Edit text files',
|
||||
'sftp.opener.builtInImageViewer': 'Built-in Image Viewer',
|
||||
'sftp.opener.previewDescription': 'Preview images',
|
||||
'sftp.opener.systemApp': 'Choose Application...',
|
||||
'sftp.opener.systemAppDescription': 'Select an application from your computer',
|
||||
'sftp.opener.onlySystemApp': 'This file can only be opened with an external application',
|
||||
'sftp.opener.noAppsAvailable': 'No applications available',
|
||||
'sftp.opener.noExtension': 'files without extension',
|
||||
'sftp.opener.setDefault': 'Always use this for {ext} files',
|
||||
'sftp.opener.confirmTitle': 'Set as Default?',
|
||||
'sftp.opener.confirmDescription': 'Do you want to always use {app} for {ext} files?',
|
||||
'sftp.opener.yesRemember': 'Yes, remember this choice',
|
||||
'sftp.opener.justOnce': 'Just this once',
|
||||
'sftp.opener.confirm.title': 'Set Default Application',
|
||||
'sftp.opener.confirm.desc': 'Do you want to always open .{ext} files with this application?',
|
||||
'sftp.editor.title': 'Text Editor',
|
||||
'sftp.editor.save': 'Save to Remote',
|
||||
'sftp.editor.saving': 'Saving...',
|
||||
'sftp.editor.saved': 'Saved successfully',
|
||||
'sftp.editor.saveFailed': 'Failed to save file',
|
||||
'sftp.editor.unsavedChanges': 'You have unsaved changes. Close anyway?',
|
||||
'sftp.editor.syntaxHighlight': 'Syntax Highlighting',
|
||||
'sftp.preview.title': 'Image Preview',
|
||||
'sftp.preview.zoomIn': 'Zoom In',
|
||||
'sftp.preview.zoomOut': 'Zoom Out',
|
||||
'sftp.preview.resetZoom': 'Reset Zoom',
|
||||
'sftp.preview.fitToWindow': 'Fit to Window',
|
||||
|
||||
// Settings > SFTP File Associations
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftpFileAssociations.title': 'SFTP File Associations',
|
||||
'settings.sftpFileAssociations.desc': 'Configure default applications for opening files by extension',
|
||||
'settings.sftpFileAssociations.extension': 'Extension',
|
||||
'settings.sftpFileAssociations.application': 'Application',
|
||||
'settings.sftpFileAssociations.noAssociations': 'No file associations configured',
|
||||
'settings.sftpFileAssociations.remove': 'Remove',
|
||||
'settings.sftpFileAssociations.removeConfirm': 'Remove association for .{ext}?',
|
||||
|
||||
// Settings > SFTP Behavior
|
||||
'settings.sftp.doubleClickBehavior': 'Double-click behavior',
|
||||
'settings.sftp.doubleClickBehavior.desc': 'Choose the action when double-clicking a file in SFTP View',
|
||||
'settings.sftp.doubleClickBehavior.open': 'Open file',
|
||||
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Auto-sync to remote',
|
||||
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
|
||||
'settings.sftp.autoSync.enable': 'Enable auto-sync',
|
||||
'settings.sftp.autoSync.enableDesc': 'When you save a file in an external application, changes will be automatically uploaded to the remote server',
|
||||
'sftp.autoSync.success': 'File synced to remote: {fileName}',
|
||||
'sftp.autoSync.error': 'Failed to sync file: {error}',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.recentConnections': 'Recent connections',
|
||||
@@ -431,6 +553,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 +564,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 +801,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 +1051,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': '选择主机',
|
||||
@@ -48,6 +49,21 @@ const zhCN: Messages = {
|
||||
'settings.tab.terminal': '终端',
|
||||
'settings.tab.shortcuts': '快捷键',
|
||||
'settings.tab.syncCloud': '同步与云',
|
||||
'settings.tab.system': '系统',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': '系统',
|
||||
'settings.system.description': '系统信息与临时文件管理。',
|
||||
'settings.system.tempDirectory': '临时文件',
|
||||
'settings.system.location': '位置',
|
||||
'settings.system.fileCount': '文件数量',
|
||||
'settings.system.totalSize': '占用空间',
|
||||
'settings.system.openFolder': '打开文件夹',
|
||||
'settings.system.refresh': '刷新',
|
||||
'settings.system.clearTempFiles': '清理临时文件',
|
||||
'settings.system.clearing': '清理中...',
|
||||
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
|
||||
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': '检查更新',
|
||||
@@ -60,6 +76,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 +175,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': '列表',
|
||||
@@ -237,6 +264,8 @@ const zhCN: Messages = {
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': '新建文件夹',
|
||||
'sftp.filter': '筛选',
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.columns.name': '名称',
|
||||
'sftp.columns.modified': '修改时间',
|
||||
'sftp.columns.size': '大小',
|
||||
@@ -260,11 +289,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': '新建文件夹名称?',
|
||||
@@ -279,6 +310,7 @@ const zhCN: Messages = {
|
||||
'sftp.error.uploadFailed': '上传失败',
|
||||
'sftp.error.deleteFailed': '删除失败',
|
||||
'sftp.error.createFolderFailed': '创建文件夹失败',
|
||||
'sftp.error.renameFailed': '重命名失败',
|
||||
'sftp.picker.title': '选择主机',
|
||||
'sftp.picker.desc': '为{side}窗格选择主机',
|
||||
'sftp.picker.searchPlaceholder': '搜索主机...',
|
||||
@@ -287,11 +319,13 @@ const zhCN: Messages = {
|
||||
'sftp.picker.local.badge': '本地',
|
||||
'sftp.picker.noMatch': '没有匹配的主机',
|
||||
'sftp.permissions.title': '编辑权限',
|
||||
'sftp.permissions.owner': 'Owner',
|
||||
'sftp.permissions.group': 'Group',
|
||||
'sftp.permissions.others': 'Others',
|
||||
'sftp.permissions.octal': 'Octal',
|
||||
'sftp.permissions.symbolic': 'Symbolic',
|
||||
'sftp.permissions.owner': '所有者',
|
||||
'sftp.permissions.group': '群组',
|
||||
'sftp.permissions.others': '其他',
|
||||
'sftp.permissions.octal': '八进制',
|
||||
'sftp.permissions.symbolic': '符号',
|
||||
'sftp.permissions.success': '权限已更新',
|
||||
'sftp.permissions.failed': '权限更新失败',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜索主机或标签页',
|
||||
@@ -304,6 +338,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 +349,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 +553,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 +686,11 @@ 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': '关闭并删除',
|
||||
'pf.form.autoStart': '自动启动',
|
||||
'pf.form.autoStartDesc': '应用启动时自动开启此规则',
|
||||
|
||||
// SFTP (pane + conflict)
|
||||
'sftp.pane.local': '本地',
|
||||
@@ -641,6 +698,9 @@ const zhCN: Messages = {
|
||||
'sftp.pane.selectHost': '选择主机',
|
||||
'sftp.pane.selectHostToStart': '先选择一个主机',
|
||||
'sftp.pane.chooseFilesystem': '选择要浏览的本地或远端文件系统',
|
||||
'sftp.tabs.addTab': '新建标签页',
|
||||
'sftp.tabs.closeTab': '关闭标签页',
|
||||
'sftp.tabs.newTab': '新标签页',
|
||||
'sftp.conflict.title': '文件冲突',
|
||||
'sftp.conflict.desc': '目标位置已存在同名文件',
|
||||
'sftp.conflict.alreadyExistsSuffix': '已存在',
|
||||
@@ -653,8 +713,73 @@ const zhCN: Messages = {
|
||||
'sftp.conflict.action.keepBoth': '保留两者',
|
||||
'sftp.conflict.action.replace': '替换',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
'sftp.context.edit': '编辑',
|
||||
'sftp.context.preview': '预览',
|
||||
'sftp.opener.title': '打开方式',
|
||||
'sftp.opener.desc': '选择一个应用程序来打开此文件',
|
||||
'sftp.opener.builtInEditor': '内置编辑器',
|
||||
'sftp.opener.editDescription': '编辑文本文件',
|
||||
'sftp.opener.builtInImageViewer': '内置图片预览',
|
||||
'sftp.opener.previewDescription': '预览图片',
|
||||
'sftp.opener.systemApp': '选择应用程序...',
|
||||
'sftp.opener.systemAppDescription': '从本地选择一个应用程序',
|
||||
'sftp.opener.onlySystemApp': '此文件只能用外部应用程序打开',
|
||||
'sftp.opener.noAppsAvailable': '无可用应用程序',
|
||||
'sftp.opener.noExtension': '无扩展名文件',
|
||||
'sftp.opener.setDefault': '始终使用此方式打开 {ext} 文件',
|
||||
'sftp.opener.confirmTitle': '设为默认?',
|
||||
'sftp.opener.confirmDescription': '是否始终使用 {app} 打开 {ext} 文件?',
|
||||
'sftp.opener.yesRemember': '是,记住此选择',
|
||||
'sftp.opener.justOnce': '仅此一次',
|
||||
'sftp.opener.confirm.title': '设置默认应用程序',
|
||||
'sftp.opener.confirm.desc': '是否始终使用此应用程序打开 .{ext} 文件?',
|
||||
'sftp.editor.title': '文本编辑器',
|
||||
'sftp.editor.save': '保存到远程',
|
||||
'sftp.editor.saving': '保存中...',
|
||||
'sftp.editor.saved': '保存成功',
|
||||
'sftp.editor.saveFailed': '保存文件失败',
|
||||
'sftp.editor.unsavedChanges': '您有未保存的更改。确定要关闭吗?',
|
||||
'sftp.editor.syntaxHighlight': '语法高亮',
|
||||
'sftp.preview.title': '图片预览',
|
||||
'sftp.preview.zoomIn': '放大',
|
||||
'sftp.preview.zoomOut': '缩小',
|
||||
'sftp.preview.resetZoom': '重置缩放',
|
||||
'sftp.preview.fitToWindow': '适应窗口',
|
||||
|
||||
// Settings > SFTP File Associations
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftpFileAssociations.title': 'SFTP 文件关联',
|
||||
'settings.sftpFileAssociations.desc': '配置按扩展名打开文件的默认应用程序',
|
||||
'settings.sftpFileAssociations.extension': '扩展名',
|
||||
'settings.sftpFileAssociations.application': '应用程序',
|
||||
'settings.sftpFileAssociations.noAssociations': '未配置文件关联',
|
||||
'settings.sftpFileAssociations.remove': '移除',
|
||||
'settings.sftpFileAssociations.removeConfirm': '确定移除 .{ext} 的关联吗?',
|
||||
|
||||
// Settings > SFTP Behavior
|
||||
'settings.sftp.doubleClickBehavior': '双击行为',
|
||||
'settings.sftp.doubleClickBehavior.desc': '选择在 SFTP 视图中双击文件时的操作',
|
||||
'settings.sftp.doubleClickBehavior.open': '打开文件',
|
||||
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': '自动同步到远程',
|
||||
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
|
||||
'settings.sftp.autoSync.enable': '启用自动同步',
|
||||
'settings.sftp.autoSync.enableDesc': '在外部应用程序中保存文件时,更改将自动上传到远程服务器',
|
||||
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
|
||||
'sftp.autoSync.error': '同步文件失败:{error}',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': '终端主题',
|
||||
'settings.terminal.themeModal.title': '选择主题',
|
||||
'settings.terminal.themeModal.darkThemes': '深色主题',
|
||||
'settings.terminal.themeModal.lightThemes': '浅色主题',
|
||||
'settings.terminal.theme.selectButton': '选择主题',
|
||||
'settings.terminal.section.font': '字体',
|
||||
'settings.terminal.section.cursor': '光标',
|
||||
'settings.terminal.section.keyboard': '键盘',
|
||||
@@ -709,6 +834,21 @@ 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.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': '快捷键方案',
|
||||
@@ -900,6 +1040,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;
|
||||
|
||||
146
application/state/fontStore.ts
Normal file
146
application/state/fontStore.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { TERMINAL_FONTS, type TerminalFont } from '../../infrastructure/config/fonts';
|
||||
import { getMonospaceFonts } from '../../lib/localFonts';
|
||||
|
||||
/**
|
||||
* Global font store - singleton pattern using useSyncExternalStore
|
||||
* Ensures fonts are loaded only once and shared across all components
|
||||
*/
|
||||
type Listener = () => void;
|
||||
|
||||
interface FontStoreState {
|
||||
availableFonts: TerminalFont[];
|
||||
isLoading: boolean;
|
||||
isLoaded: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
class FontStore {
|
||||
private state: FontStoreState = {
|
||||
availableFonts: TERMINAL_FONTS,
|
||||
isLoading: false,
|
||||
isLoaded: false,
|
||||
error: null,
|
||||
};
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
// Getters for individual state slices
|
||||
getAvailableFonts = (): TerminalFont[] => this.state.availableFonts;
|
||||
getIsLoading = (): boolean => this.state.isLoading;
|
||||
getIsLoaded = (): boolean => this.state.isLoaded;
|
||||
getError = (): string | null => this.state.error;
|
||||
|
||||
private notify = () => {
|
||||
// Defer listener notification to avoid "setState during render"
|
||||
Promise.resolve().then(() => {
|
||||
this.listeners.forEach(listener => listener());
|
||||
});
|
||||
};
|
||||
|
||||
private setState = (partial: Partial<FontStoreState>) => {
|
||||
this.state = { ...this.state, ...partial };
|
||||
this.notify();
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize font loading - safe to call multiple times,
|
||||
* will only load once
|
||||
*/
|
||||
initialize = async (): Promise<void> => {
|
||||
// Already loaded or currently loading
|
||||
if (this.state.isLoaded || this.state.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const localFonts = await getMonospaceFonts();
|
||||
|
||||
// Combine default fonts with local fonts, deduplicate by id
|
||||
const fontMap = new Map<string, TerminalFont>();
|
||||
|
||||
// Add default fonts first
|
||||
TERMINAL_FONTS.forEach(font => fontMap.set(font.id, font));
|
||||
|
||||
// Add local fonts with a distinct ID namespace to avoid collisions
|
||||
localFonts.forEach(font => {
|
||||
const localId = font.id.startsWith('local-') ? font.id : `local-${font.id}`;
|
||||
fontMap.set(localId, { ...font, id: localId });
|
||||
});
|
||||
|
||||
this.setState({
|
||||
availableFonts: Array.from(fontMap.values()),
|
||||
isLoading: false,
|
||||
isLoaded: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to load local fonts';
|
||||
console.warn('Failed to fetch local fonts, using defaults:', error);
|
||||
this.setState({
|
||||
availableFonts: TERMINAL_FONTS,
|
||||
isLoading: false,
|
||||
isLoaded: true,
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find a font by ID with fallback
|
||||
*/
|
||||
getFontById = (fontId: string): TerminalFont => {
|
||||
const fonts = this.state.availableFonts;
|
||||
return fonts.find(f => f.id === fontId) || fonts[0] || TERMINAL_FONTS[0];
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const fontStore = new FontStore();
|
||||
|
||||
// ============== Hooks ==============
|
||||
|
||||
/**
|
||||
* Get available fonts - triggers initialization on first use
|
||||
*/
|
||||
export const useAvailableFonts = (): TerminalFont[] => {
|
||||
// Trigger initialization on first use
|
||||
if (!fontStore.getIsLoaded() && !fontStore.getIsLoading()) {
|
||||
fontStore.initialize();
|
||||
}
|
||||
|
||||
return useSyncExternalStore(
|
||||
fontStore.subscribe,
|
||||
fontStore.getAvailableFonts
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get font loading state
|
||||
*/
|
||||
export const useFontsLoading = (): boolean => {
|
||||
return useSyncExternalStore(
|
||||
fontStore.subscribe,
|
||||
fontStore.getIsLoading
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get font by ID with fallback - useful for components that need a specific font
|
||||
*/
|
||||
export const useFontById = (fontId: string): TerminalFont => {
|
||||
const fonts = useAvailableFonts();
|
||||
return fonts.find(f => f.id === fontId) || fonts[0] || TERMINAL_FONTS[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize fonts eagerly (call at app startup)
|
||||
*/
|
||||
export const initializeFonts = (): void => {
|
||||
fontStore.initialize();
|
||||
};
|
||||
@@ -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 ==========
|
||||
|
||||
|
||||
137
application/state/usePortForwardingAutoStart.ts
Normal file
137
application/state/usePortForwardingAutoStart.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Hook for auto-starting port forwarding rules on app launch.
|
||||
* This should be used at the App level to ensure auto-start happens
|
||||
* when the application starts, not when the user navigates to the port forwarding page.
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Host, PortForwardingRule } from "../../domain/models";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
getActiveConnection,
|
||||
setReconnectCallback,
|
||||
startPortForward,
|
||||
syncWithBackend,
|
||||
} from "../../infrastructure/services/portForwardingService";
|
||||
import { logger } from "../../lib/logger";
|
||||
|
||||
export interface UsePortForwardingAutoStartOptions {
|
||||
hosts: Host[];
|
||||
keys: { id: string; privateKey: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-starts port forwarding rules that have autoStart enabled.
|
||||
* This hook should be called at the App level to run on app launch.
|
||||
*/
|
||||
export const usePortForwardingAutoStart = ({
|
||||
hosts,
|
||||
keys,
|
||||
}: UsePortForwardingAutoStartOptions): void => {
|
||||
const autoStartExecutedRef = useRef(false);
|
||||
const hostsRef = useRef<Host[]>(hosts);
|
||||
const keysRef = useRef<{ id: string; privateKey: string }[]>(keys);
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => {
|
||||
hostsRef.current = hosts;
|
||||
}, [hosts]);
|
||||
|
||||
useEffect(() => {
|
||||
keysRef.current = keys;
|
||||
}, [keys]);
|
||||
|
||||
// Set up the reconnect callback
|
||||
useEffect(() => {
|
||||
const handleReconnect = async (
|
||||
ruleId: string,
|
||||
onStatusChange: (status: PortForwardingRule["status"], error?: string) => void,
|
||||
) => {
|
||||
// Load the current rules from storage
|
||||
const rules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
|
||||
const rule = rules.find((r) => r.id === ruleId);
|
||||
if (!rule || !rule.hostId) {
|
||||
return { success: false, error: "Rule or host not found" };
|
||||
}
|
||||
|
||||
const host = hostsRef.current.find((h) => h.id === rule.hostId);
|
||||
if (!host) {
|
||||
return { success: false, error: "Host not found" };
|
||||
}
|
||||
|
||||
return startPortForward(rule, host, keysRef.current, onStatusChange, true);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect);
|
||||
return () => {
|
||||
setReconnectCallback(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-start rules on app launch
|
||||
useEffect(() => {
|
||||
if (autoStartExecutedRef.current) return;
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
const runAutoStart = async () => {
|
||||
// First sync with backend to get any active tunnels
|
||||
await syncWithBackend();
|
||||
|
||||
// Load rules from storage
|
||||
const rules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
|
||||
// Only start rules that are not already active
|
||||
const autoStartRules = rules.filter((r) => {
|
||||
if (!r.autoStart || !r.hostId) return false;
|
||||
// Check if there's an active connection for this rule
|
||||
const conn = getActiveConnection(r.id);
|
||||
// Only start if not already connecting or active
|
||||
return !conn || conn.status === 'inactive' || conn.status === 'error';
|
||||
});
|
||||
|
||||
if (autoStartRules.length === 0) return;
|
||||
|
||||
autoStartExecutedRef.current = true;
|
||||
logger.info(`[PortForwardingAutoStart] Starting ${autoStartRules.length} auto-start rules`);
|
||||
|
||||
// Start each auto-start rule
|
||||
for (const rule of autoStartRules) {
|
||||
const host = hosts.find((h) => h.id === rule.hostId);
|
||||
if (host) {
|
||||
void startPortForward(
|
||||
rule,
|
||||
host,
|
||||
keys,
|
||||
(status, error) => {
|
||||
// Update the rule status in storage
|
||||
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
|
||||
const updatedRules = currentRules.map((r) =>
|
||||
r.id === rule.id
|
||||
? {
|
||||
...r,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
|
||||
}
|
||||
: r,
|
||||
);
|
||||
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
|
||||
},
|
||||
true, // Enable reconnect for auto-start rules
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void runAutoStart();
|
||||
}, [hosts, keys]);
|
||||
};
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
clearReconnectTimer,
|
||||
getActiveConnection,
|
||||
getActiveRuleIds,
|
||||
startPortForward,
|
||||
stopPortForward,
|
||||
syncWithBackend,
|
||||
} from "../../infrastructure/services/portForwardingService";
|
||||
import { useStoredViewMode, ViewMode } from "./useStoredViewMode";
|
||||
|
||||
@@ -50,6 +52,7 @@ export interface UsePortForwardingStateResult {
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string }[],
|
||||
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
|
||||
enableReconnect?: boolean,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
stopTunnel: (
|
||||
ruleId: string,
|
||||
@@ -78,25 +81,32 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE, prefer);
|
||||
}, []);
|
||||
|
||||
// Load rules from storage on mount
|
||||
// Load rules from storage on mount and sync with backend
|
||||
useEffect(() => {
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (saved && Array.isArray(saved)) {
|
||||
// Sync status with active connections in the service layer
|
||||
const _activeRuleIds = getActiveRuleIds();
|
||||
const withSyncedStatus = saved.map((r) => {
|
||||
const conn = getActiveConnection(r.id);
|
||||
if (conn) {
|
||||
// This rule has an active connection, preserve its status
|
||||
return { ...r, status: conn.status, error: conn.error };
|
||||
}
|
||||
// No active connection, reset to inactive
|
||||
return { ...r, status: "inactive" as const, error: undefined };
|
||||
});
|
||||
setRules(withSyncedStatus);
|
||||
}
|
||||
const loadAndSync = async () => {
|
||||
// First, sync with backend to get any active tunnels
|
||||
await syncWithBackend();
|
||||
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (saved && Array.isArray(saved)) {
|
||||
// Sync status with active connections in the service layer
|
||||
const _activeRuleIds = getActiveRuleIds();
|
||||
const withSyncedStatus = saved.map((r) => {
|
||||
const conn = getActiveConnection(r.id);
|
||||
if (conn) {
|
||||
// This rule has an active connection, preserve its status
|
||||
return { ...r, status: conn.status, error: conn.error };
|
||||
}
|
||||
// No active connection, reset to inactive
|
||||
return { ...r, status: "inactive" as const, error: undefined };
|
||||
});
|
||||
setRules(withSyncedStatus);
|
||||
}
|
||||
};
|
||||
|
||||
void loadAndSync();
|
||||
}, []);
|
||||
|
||||
// Persist rules to storage whenever they change
|
||||
@@ -204,11 +214,12 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
status: PortForwardingRule["status"],
|
||||
error?: string,
|
||||
) => void,
|
||||
enableReconnect = false,
|
||||
) => {
|
||||
return startPortForward(rule, host, keys, (status, error) => {
|
||||
setRuleStatus(rule.id, status, error);
|
||||
onStatusChange?.(status, error ?? undefined);
|
||||
});
|
||||
}, enableReconnect);
|
||||
},
|
||||
[setRuleStatus],
|
||||
);
|
||||
@@ -218,6 +229,8 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
ruleId: string,
|
||||
onStatusChange?: (status: PortForwardingRule["status"]) => void,
|
||||
) => {
|
||||
// Clear any pending reconnect timer when manually stopping
|
||||
clearReconnectTimer(ruleId);
|
||||
return stopPortForward(ruleId, (status) => {
|
||||
setRuleStatus(ruleId, status);
|
||||
onStatusChange?.(status);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,11 +16,14 @@ STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { TERMINAL_FONTS, DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { useAvailableFonts } from './fontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
@@ -32,10 +35,12 @@ const DEFAULT_CUSTOM_ACCENT = '221.2 83.2% 53.3%';
|
||||
const DEFAULT_TERMINAL_THEME = 'netcatty-dark';
|
||||
const DEFAULT_FONT_FAMILY = 'menlo';
|
||||
// Auto-detect default hotkey scheme based on platform
|
||||
const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
|
||||
? 'mac'
|
||||
const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
|
||||
? 'mac'
|
||||
: 'pc';
|
||||
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
|
||||
const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
|
||||
const readStoredString = (key: string): string | null => {
|
||||
const raw = localStorageAdapter.readString(key);
|
||||
@@ -97,13 +102,14 @@ const applyThemeTokens = (
|
||||
root.style.setProperty('--border', tokens.border);
|
||||
root.style.setProperty('--input', tokens.input);
|
||||
root.style.setProperty('--ring', accentToken);
|
||||
|
||||
|
||||
// Sync with native window title bar (Electron)
|
||||
netcattyBridge.get()?.setTheme?.(theme);
|
||||
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
|
||||
};
|
||||
|
||||
export const useSettingsState = () => {
|
||||
const availableFonts = useAvailableFonts();
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_THEME);
|
||||
return stored && isValidTheme(stored) ? stored : DEFAULT_THEME;
|
||||
@@ -146,13 +152,21 @@ export const useSettingsState = () => {
|
||||
}
|
||||
return DEFAULT_HOTKEY_SCHEME;
|
||||
});
|
||||
const [customKeyBindings, setCustomKeyBindings] = useState<CustomKeyBindings>(() =>
|
||||
const [customKeyBindings, setCustomKeyBindings] = useState<CustomKeyBindings>(() =>
|
||||
localStorageAdapter.read<CustomKeyBindings>(STORAGE_KEY_CUSTOM_KEY_BINDINGS) || {}
|
||||
);
|
||||
const [isHotkeyRecording, setIsHotkeyRecordingState] = useState(false);
|
||||
const [customCSS, setCustomCSS] = useState<string>(() =>
|
||||
const [customCSS, setCustomCSS] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS) || ''
|
||||
);
|
||||
const [sftpDoubleClickBehavior, setSftpDoubleClickBehavior] = useState<'open' | 'transfer'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
return (stored === 'open' || stored === 'transfer') ? stored : DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR;
|
||||
});
|
||||
const [sftpAutoSync, setSftpAutoSync] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_SYNC;
|
||||
});
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
@@ -371,11 +385,24 @@ export const useSettingsState = () => {
|
||||
setTerminalFontSize(newSize);
|
||||
}
|
||||
}
|
||||
// Sync SFTP double-click behavior from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
|
||||
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== sftpDoubleClickBehavior) {
|
||||
setSftpDoubleClickBehavior(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-sync setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpAutoSync) {
|
||||
setSftpAutoSync(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -415,7 +442,7 @@ export const useSettingsState = () => {
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
|
||||
|
||||
// Apply custom CSS to document
|
||||
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
@@ -426,6 +453,18 @@ export const useSettingsState = () => {
|
||||
styleEl.textContent = customCSS;
|
||||
}, [customCSS, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP double-click behavior
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
|
||||
}, [sftpDoubleClickBehavior, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP auto-sync setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
|
||||
}, [sftpAutoSync, notifySettingsChanged]);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -484,8 +523,8 @@ export const useSettingsState = () => {
|
||||
);
|
||||
|
||||
const currentTerminalFont = useMemo(
|
||||
() => TERMINAL_FONTS.find(f => f.id === terminalFontFamilyId) || TERMINAL_FONTS[0],
|
||||
[terminalFontFamilyId]
|
||||
() => availableFonts.find(f => f.id === terminalFontFamilyId) || availableFonts[0],
|
||||
[terminalFontFamilyId, availableFonts]
|
||||
);
|
||||
|
||||
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
|
||||
@@ -532,5 +571,10 @@ export const useSettingsState = () => {
|
||||
setIsHotkeyRecording,
|
||||
customCSS,
|
||||
setCustomCSS,
|
||||
sftpDoubleClickBehavior,
|
||||
setSftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
setSftpAutoSync,
|
||||
availableFonts,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -174,6 +174,63 @@ export const useSftpBackend = () => {
|
||||
return bridge.onTransferProgress(transferId, cb);
|
||||
}, []);
|
||||
|
||||
const selectApplication = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectApplication) return undefined;
|
||||
return bridge.selectApplication();
|
||||
}, []);
|
||||
|
||||
const downloadSftpToTempAndOpen = useCallback(async (
|
||||
sftpId: string,
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
): Promise<{ localTempPath: string; watchId?: string }> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
|
||||
throw new Error("Download to temp / open with unavailable");
|
||||
}
|
||||
|
||||
// Download the file to temp
|
||||
console.log("[SFTPBackend] Downloading file to temp", { sftpId, remotePath, fileName });
|
||||
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName);
|
||||
console.log("[SFTPBackend] File downloaded to temp", { tempPath });
|
||||
|
||||
// Register temp file for cleanup when SFTP session closes (regardless of auto-sync setting)
|
||||
if (bridge.registerTempFile) {
|
||||
try {
|
||||
await bridge.registerTempFile(sftpId, tempPath);
|
||||
} catch (err) {
|
||||
console.warn("[SFTPBackend] Failed to register temp file for cleanup:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Open with the selected application
|
||||
console.log("[SFTPBackend] Opening with application", { tempPath, appPath });
|
||||
await bridge.openWithApplication(tempPath, appPath);
|
||||
console.log("[SFTPBackend] Application launched");
|
||||
|
||||
// Start file watching if enabled
|
||||
let watchId: string | undefined;
|
||||
console.log("[SFTPBackend] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
|
||||
if (options?.enableWatch && bridge.startFileWatch) {
|
||||
try {
|
||||
console.log("[SFTPBackend] Starting file watch", { tempPath, remotePath, sftpId });
|
||||
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId);
|
||||
watchId = result.watchId;
|
||||
console.log("[SFTPBackend] File watch started successfully", { watchId, tempPath, remotePath });
|
||||
} catch (err) {
|
||||
console.warn("[SFTPBackend] Failed to start file watch:", err);
|
||||
// Don't fail the operation if watching fails
|
||||
}
|
||||
} else {
|
||||
console.log("[SFTPBackend] File watching not enabled or not available");
|
||||
}
|
||||
|
||||
return { localTempPath: tempPath, watchId };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
openSftp,
|
||||
closeSftp,
|
||||
@@ -201,6 +258,8 @@ export const useSftpBackend = () => {
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
onTransferProgress,
|
||||
selectApplication,
|
||||
downloadSftpToTempAndOpen,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
149
application/state/useSftpFileAssociations.ts
Normal file
149
application/state/useSftpFileAssociations.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* useSftpFileAssociations - Hook for managing SFTP file opener associations
|
||||
* Uses a shared state pattern to sync across components
|
||||
*/
|
||||
import { useCallback, useEffect, useSyncExternalStore } from 'react';
|
||||
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import type { FileAssociation, FileOpenerType, SystemAppInfo } from '../../lib/sftpFileUtils';
|
||||
import { getFileExtension } from '../../lib/sftpFileUtils';
|
||||
|
||||
export interface FileAssociationEntry {
|
||||
openerType: FileOpenerType;
|
||||
systemApp?: SystemAppInfo;
|
||||
}
|
||||
|
||||
export interface FileAssociationsMap {
|
||||
[extension: string]: FileAssociationEntry;
|
||||
}
|
||||
|
||||
// Shared state and subscribers for cross-component synchronization
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
// Use a wrapper object so we can update the reference for useSyncExternalStore
|
||||
let snapshotRef: { associations: FileAssociationsMap } = { associations: {} };
|
||||
|
||||
function loadFromStorage(): FileAssociationsMap {
|
||||
const stored = localStorageAdapter.read<FileAssociationsMap>(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
|
||||
console.log('[SftpFileAssociations] Loading from storage:', stored);
|
||||
if (stored) {
|
||||
const migrated: FileAssociationsMap = {};
|
||||
for (const [ext, value] of Object.entries(stored)) {
|
||||
if (typeof value === 'string') {
|
||||
migrated[ext] = { openerType: value as FileOpenerType };
|
||||
} else {
|
||||
migrated[ext] = value as FileAssociationEntry;
|
||||
}
|
||||
}
|
||||
console.log('[SftpFileAssociations] Migrated associations:', migrated);
|
||||
return migrated;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Initialize from storage
|
||||
snapshotRef = { associations: loadFromStorage() };
|
||||
|
||||
function saveToStorage(associations: FileAssociationsMap) {
|
||||
console.log('[SftpFileAssociations] saveToStorage called with:', associations);
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, associations);
|
||||
// Verify it was saved
|
||||
const verify = localStorageAdapter.read(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
|
||||
console.log('[SftpFileAssociations] Verification read from storage:', verify);
|
||||
}
|
||||
|
||||
function updateAssociations(newAssociations: FileAssociationsMap) {
|
||||
console.log('[SftpFileAssociations] Updating associations:', newAssociations);
|
||||
// Create new reference so useSyncExternalStore detects change
|
||||
snapshotRef = { associations: newAssociations };
|
||||
saveToStorage(newAssociations);
|
||||
console.log('[SftpFileAssociations] Notifying', subscribers.size, 'subscribers');
|
||||
subscribers.forEach(callback => callback());
|
||||
}
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
subscribers.add(callback);
|
||||
return () => subscribers.delete(callback);
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return snapshotRef;
|
||||
}
|
||||
|
||||
export function useSftpFileAssociations() {
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const associations = snapshot.associations;
|
||||
|
||||
// Listen for storage events from other tabs/windows
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY_SFTP_FILE_ASSOCIATIONS) {
|
||||
updateAssociations(loadFromStorage());
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get the opener entry for a file based on its extension
|
||||
*/
|
||||
const getOpenerForFile = useCallback((fileName: string): FileAssociationEntry | null => {
|
||||
const ext = getFileExtension(fileName);
|
||||
return associations[ext] || null;
|
||||
}, [associations]);
|
||||
|
||||
/**
|
||||
* Set the opener type for a specific extension
|
||||
*/
|
||||
const setOpenerForExtension = useCallback((
|
||||
extension: string,
|
||||
openerType: FileOpenerType,
|
||||
systemApp?: SystemAppInfo
|
||||
) => {
|
||||
console.log('[SftpFileAssociations] setOpenerForExtension called with:', { extension, openerType, systemApp });
|
||||
console.log('[SftpFileAssociations] Current associations before update:', snapshotRef.associations);
|
||||
updateAssociations({
|
||||
...snapshotRef.associations,
|
||||
[extension.toLowerCase()]: { openerType, systemApp },
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Remove the association for a specific extension
|
||||
*/
|
||||
const removeAssociation = useCallback((extension: string) => {
|
||||
const next = { ...snapshotRef.associations };
|
||||
delete next[extension.toLowerCase()];
|
||||
updateAssociations(next);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get all associations as an array
|
||||
*/
|
||||
const getAllAssociations = useCallback((): FileAssociation[] => {
|
||||
const result = Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
|
||||
extension,
|
||||
openerType: entry.openerType,
|
||||
systemApp: entry.systemApp,
|
||||
}));
|
||||
console.log('[SftpFileAssociations] getAllAssociations called, returning', result.length, 'items:', result);
|
||||
return result;
|
||||
}, [associations]);
|
||||
|
||||
/**
|
||||
* Clear all associations
|
||||
*/
|
||||
const clearAllAssociations = useCallback(() => {
|
||||
updateAssociations({});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
associations,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
removeAssociation,
|
||||
getAllAssociations,
|
||||
clearAllAssociations,
|
||||
};
|
||||
}
|
||||
184
application/state/useSftpFileOperations.ts
Normal file
184
application/state/useSftpFileOperations.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* useSftpFileOperations - Shared file operations for SFTP components
|
||||
*
|
||||
* This hook provides common file operations like open, edit, preview
|
||||
* that can be shared between SFTPModal and SftpView components.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { getFileExtension, isTextFile, FileOpenerType } from "../../lib/sftpFileUtils";
|
||||
import { toast } from "../../components/ui/toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useSftpFileAssociations } from "./useSftpFileAssociations";
|
||||
|
||||
export interface FileOperationsState {
|
||||
// Text editor state
|
||||
showTextEditor: boolean;
|
||||
textEditorTarget: { name: string; fullPath: string } | null;
|
||||
textEditorContent: string;
|
||||
loadingTextContent: boolean;
|
||||
|
||||
// File opener dialog state
|
||||
showFileOpenerDialog: boolean;
|
||||
fileOpenerTarget: { name: string; fullPath: string } | null;
|
||||
}
|
||||
|
||||
export interface FileOperationsActions {
|
||||
// Open file based on type/association
|
||||
openFile: (fileName: string, fullPath: string) => void;
|
||||
|
||||
// Edit text file
|
||||
editFile: (
|
||||
fileName: string,
|
||||
fullPath: string,
|
||||
readContent: () => Promise<string>
|
||||
) => Promise<void>;
|
||||
|
||||
// Save text file
|
||||
saveTextFile: (
|
||||
content: string,
|
||||
writeContent: (path: string, content: string) => Promise<void>
|
||||
) => Promise<void>;
|
||||
|
||||
// Handle file opener selection
|
||||
handleFileOpenerSelect: (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
readTextContent: () => Promise<string>,
|
||||
readImageData: () => Promise<ArrayBuffer>
|
||||
) => Promise<void>;
|
||||
|
||||
// Close modals
|
||||
closeTextEditor: () => void;
|
||||
closeFileOpenerDialog: () => void;
|
||||
|
||||
// Check if file can be edited
|
||||
canEditFile: (fileName: string) => boolean;
|
||||
}
|
||||
|
||||
export interface UseSftpFileOperationsResult {
|
||||
state: FileOperationsState;
|
||||
actions: FileOperationsActions;
|
||||
}
|
||||
|
||||
export function useSftpFileOperations(): UseSftpFileOperationsResult {
|
||||
const { t } = useI18n();
|
||||
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
|
||||
|
||||
// Text editor state
|
||||
const [showTextEditor, setShowTextEditor] = useState(false);
|
||||
const [textEditorTarget, setTextEditorTarget] = useState<{ name: string; fullPath: string } | null>(null);
|
||||
const [textEditorContent, setTextEditorContent] = useState("");
|
||||
const [loadingTextContent, setLoadingTextContent] = useState(false);
|
||||
|
||||
// File opener dialog state
|
||||
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
|
||||
const [fileOpenerTarget, setFileOpenerTarget] = useState<{ name: string; fullPath: string } | null>(null);
|
||||
|
||||
const canEditFile = useCallback((fileName: string) => {
|
||||
return isTextFile(fileName);
|
||||
}, []);
|
||||
|
||||
const closeTextEditor = useCallback(() => {
|
||||
setShowTextEditor(false);
|
||||
setTextEditorTarget(null);
|
||||
setTextEditorContent("");
|
||||
}, []);
|
||||
|
||||
const closeFileOpenerDialog = useCallback(() => {
|
||||
setShowFileOpenerDialog(false);
|
||||
setFileOpenerTarget(null);
|
||||
}, []);
|
||||
|
||||
const editFile = useCallback(async (
|
||||
fileName: string,
|
||||
fullPath: string,
|
||||
readContent: () => Promise<string>
|
||||
) => {
|
||||
try {
|
||||
setLoadingTextContent(true);
|
||||
setTextEditorTarget({ name: fileName, fullPath });
|
||||
const content = await readContent();
|
||||
setTextEditorContent(content);
|
||||
setShowTextEditor(true);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoadingTextContent(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const saveTextFile = useCallback(async (
|
||||
content: string,
|
||||
writeContent: (path: string, content: string) => Promise<void>
|
||||
) => {
|
||||
if (!textEditorTarget) return;
|
||||
await writeContent(textEditorTarget.fullPath, content);
|
||||
}, [textEditorTarget]);
|
||||
|
||||
const openFile = useCallback((fileName: string, fullPath: string) => {
|
||||
const savedOpener = getOpenerForFile(fileName);
|
||||
|
||||
if (savedOpener) {
|
||||
// User has saved an opener for this file type
|
||||
// We'll just set the target and let the caller handle it
|
||||
setFileOpenerTarget({ name: fileName, fullPath });
|
||||
|
||||
// Return the opener type so caller knows which operation to perform
|
||||
if (savedOpener === 'builtin-editor' && canEditFile(fileName)) {
|
||||
// Don't show dialog, caller should call editFile
|
||||
return 'edit' as const;
|
||||
}
|
||||
}
|
||||
|
||||
// No saved opener, show the dialog
|
||||
setFileOpenerTarget({ name: fileName, fullPath });
|
||||
setShowFileOpenerDialog(true);
|
||||
return 'dialog' as const;
|
||||
}, [getOpenerForFile, canEditFile]);
|
||||
|
||||
const handleFileOpenerSelect = useCallback(async (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
readTextContent: () => Promise<string>,
|
||||
_readImageData: () => Promise<ArrayBuffer>
|
||||
) => {
|
||||
if (!fileOpenerTarget) return;
|
||||
|
||||
if (setAsDefault) {
|
||||
const ext = getFileExtension(fileOpenerTarget.name);
|
||||
if (ext !== 'file') {
|
||||
setOpenerForExtension(ext, openerType);
|
||||
}
|
||||
}
|
||||
|
||||
setShowFileOpenerDialog(false);
|
||||
|
||||
if (openerType === 'builtin-editor') {
|
||||
await editFile(fileOpenerTarget.name, fileOpenerTarget.fullPath, readTextContent);
|
||||
}
|
||||
}, [fileOpenerTarget, setOpenerForExtension, editFile]);
|
||||
|
||||
return {
|
||||
state: {
|
||||
showTextEditor,
|
||||
textEditorTarget,
|
||||
textEditorContent,
|
||||
loadingTextContent,
|
||||
showFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
},
|
||||
actions: {
|
||||
openFile,
|
||||
editFile,
|
||||
saveTextFile,
|
||||
handleFileOpenerSelect,
|
||||
closeTextEditor,
|
||||
closeFileOpenerDialog,
|
||||
canEditFile,
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,18 +110,34 @@ export const useTerminalBackend = () => {
|
||||
return !!bridge?.startSSHSession;
|
||||
}, []);
|
||||
|
||||
const listSerialPorts = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listSerialPorts) return [];
|
||||
return bridge.listSerialPorts();
|
||||
}, []);
|
||||
|
||||
const getSessionPwd = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
|
||||
return bridge.getSessionPwd(sessionId);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
backendAvailable,
|
||||
telnetAvailable,
|
||||
moshAvailable,
|
||||
localAvailable,
|
||||
serialAvailable,
|
||||
execAvailable,
|
||||
openExternalAvailable,
|
||||
startSSHSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
closeSession,
|
||||
|
||||
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') {
|
||||
|
||||
132
components/FileOpenerDialog.tsx
Normal file
132
components/FileOpenerDialog.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* FileOpenerDialog - Dialog for choosing how to open a file
|
||||
*/
|
||||
import { Edit2, FolderOpen } from 'lucide-react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { FileOpenerType, SystemAppInfo } from '../lib/sftpFileUtils';
|
||||
import { getFileExtension, isKnownBinaryFile } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
interface FileOpenerDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
fileName: string;
|
||||
onSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
|
||||
onSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
}
|
||||
|
||||
export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fileName,
|
||||
onSelect,
|
||||
onSelectSystemApp,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isSelectingApp, setIsSelectingApp] = useState(false);
|
||||
const [rememberChoice, setRememberChoice] = useState(true);
|
||||
|
||||
const extension = getFileExtension(fileName);
|
||||
// Show edit option for files that are not known binary formats
|
||||
const canEdit = !isKnownBinaryFile(fileName);
|
||||
// For files without extension, we use 'file' as virtual extension
|
||||
// So we always allow setting default (hasExtension is always true)
|
||||
const displayExtension = extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`;
|
||||
|
||||
const handleSelectBuiltIn = useCallback((openerType: FileOpenerType) => {
|
||||
onSelect(openerType, rememberChoice);
|
||||
onClose();
|
||||
}, [rememberChoice, onSelect, onClose]);
|
||||
|
||||
const handleSelectSystemApp = useCallback(async () => {
|
||||
setIsSelectingApp(true);
|
||||
try {
|
||||
const result = await onSelectSystemApp();
|
||||
if (result) {
|
||||
console.log('[FileOpenerDialog] Calling onSelect with rememberChoice:', rememberChoice, 'result:', result);
|
||||
onSelect('system-app', rememberChoice, result);
|
||||
onClose();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to select application:', e);
|
||||
} finally {
|
||||
setIsSelectingApp(false);
|
||||
}
|
||||
}, [onSelectSystemApp, rememberChoice, onSelect, onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
// Don't close while selecting system app
|
||||
if (!isOpen && !isSelectingApp) {
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader className="min-w-0">
|
||||
<DialogTitle>{t('sftp.opener.title')}</DialogTitle>
|
||||
<DialogDescription className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{fileName}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-2">
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-12"
|
||||
onClick={() => handleSelectBuiltIn('builtin-editor')}
|
||||
>
|
||||
<Edit2 size={18} className="text-primary" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-sm">{t('sftp.opener.builtInEditor')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('sftp.opener.editDescription')}</div>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* System application option */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-12"
|
||||
onClick={handleSelectSystemApp}
|
||||
disabled={isSelectingApp}
|
||||
>
|
||||
<FolderOpen size={18} className="text-primary" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-sm">{t('sftp.opener.systemApp')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('sftp.opener.systemAppDescription')}</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Remember choice checkbox - always show, use 'file' for no extension */}
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember-choice"
|
||||
checked={rememberChoice}
|
||||
onChange={(e) => setRememberChoice(e.target.checked)}
|
||||
className="rounded border-border h-4 w-4 accent-primary"
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember-choice"
|
||||
className="text-sm text-muted-foreground cursor-pointer select-none"
|
||||
>
|
||||
{t('sftp.opener.setDefault', { ext: displayExtension })}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileOpenerDialog;
|
||||
@@ -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,
|
||||
@@ -127,6 +138,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
);
|
||||
}
|
||||
},
|
||||
rule.autoStart, // Enable reconnect for auto-start rules
|
||||
);
|
||||
// Show error from result only if not already shown
|
||||
if (!result.success && result.error && !errorShown) {
|
||||
@@ -205,6 +217,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 +373,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 +707,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 +733,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
duplicateRule(editingRule.id);
|
||||
closeEditPanel();
|
||||
}}
|
||||
onDelete={() => {
|
||||
deleteRule(editingRule.id);
|
||||
closeEditPanel();
|
||||
}}
|
||||
onDelete={() => handleDeleteRule(editingRule)}
|
||||
onOpenHostSelector={() => setShowHostSelector(true)}
|
||||
/>
|
||||
)}
|
||||
@@ -796,9 +843,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 +864,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()}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Settings Page - Standalone settings window content
|
||||
* This component is rendered in a separate Electron window
|
||||
*/
|
||||
import { AppWindow, Cloud, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
@@ -10,13 +10,18 @@ import { useWindowControls } from "../application/state/useWindowControls";
|
||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||
import SettingsApplicationTab from "./SettingsApplicationTab";
|
||||
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
|
||||
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
|
||||
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
|
||||
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
|
||||
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import type { TerminalFont } from "../infrastructure/config/fonts";
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
type SettingsState = ReturnType<typeof useSettingsState> & {
|
||||
availableFonts: TerminalFont[];
|
||||
};
|
||||
|
||||
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
||||
|
||||
@@ -117,12 +122,24 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
>
|
||||
<Keyboard size={14} /> {t("settings.tab.shortcuts")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="file-associations"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
>
|
||||
<FileType size={14} /> {t("settings.tab.sftpFileAssociations")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sync"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
>
|
||||
<Cloud size={14} /> {t("settings.tab.syncCloud")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="system"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
>
|
||||
<HardDrive size={14} /> {t("settings.tab.system")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -158,6 +175,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
availableFonts={settings.availableFonts}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -173,11 +191,17 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
/>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("file-associations") && (
|
||||
<SettingsFileAssociationsTab />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("sync") && (
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsSyncTabWithVault />
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("system") && <SettingsSystemTab />}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* SyncStatusButton - Cloud Sync Status Indicator for Top Bar
|
||||
*
|
||||
*
|
||||
* Shows current sync state with cloud icon and colored indicators:
|
||||
* - Green dot: All synced
|
||||
* - Blue dot + spin: Syncing in progress
|
||||
* - Blue dot + spin: Syncing in progress
|
||||
* - Red dot: Error
|
||||
* - Gray dot: No providers connected
|
||||
*
|
||||
*
|
||||
* Clicking opens a popover with sync status details and history.
|
||||
*/
|
||||
|
||||
@@ -239,7 +239,7 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
<CloudOff size={32} className="mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm font-medium mb-1">{t('sync.notConfigured')}</p>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Connect a cloud provider to sync your data across devices.
|
||||
{t('sync.autoSync.noProvider')}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -249,7 +249,7 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
onOpenSettings?.();
|
||||
}}
|
||||
>
|
||||
Configure Cloud Sync
|
||||
{t('sync.settings')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Host,
|
||||
Identity,
|
||||
KnownHost,
|
||||
SerialConfig,
|
||||
SSHKey,
|
||||
Snippet,
|
||||
TerminalSession,
|
||||
@@ -25,7 +26,7 @@ import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
|
||||
import SFTPModal from "./SFTPModal";
|
||||
import { Button } from "./ui/button";
|
||||
import { toast } from "./ui/toast";
|
||||
import { TERMINAL_FONTS } from "../infrastructure/config/fonts";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
|
||||
import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
|
||||
@@ -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,
|
||||
@@ -126,6 +129,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}) => {
|
||||
const CONNECTION_TIMEOUT = 12000;
|
||||
const { t } = useI18n();
|
||||
const availableFonts = useAvailableFonts();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
@@ -138,6 +142,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;
|
||||
@@ -165,6 +170,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession } = terminalBackend;
|
||||
|
||||
|
||||
|
||||
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
|
||||
const [status, setStatus] = useState<TerminalSession["status"]>("connecting");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -280,6 +287,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
startupCommand,
|
||||
terminalSettings,
|
||||
terminalBackend,
|
||||
serialConfig,
|
||||
sessionRef,
|
||||
hasConnectedRef,
|
||||
hasRunStartupCommandRef,
|
||||
@@ -336,6 +344,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onCommandExecuted,
|
||||
commandBufferRef,
|
||||
setIsSearchOpen,
|
||||
// Serial-specific options
|
||||
serialLocalEcho: serialConfig?.localEcho,
|
||||
serialLineMode: serialConfig?.lineMode,
|
||||
serialLineBufferRef,
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -346,7 +358,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 +423,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 +468,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;
|
||||
@@ -529,7 +552,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
|
||||
const hostFontId = host.fontFamily || fontFamilyId || "menlo";
|
||||
const fontObj = TERMINAL_FONTS.find((f) => f.id === hostFontId) || TERMINAL_FONTS[0];
|
||||
const fontObj = availableFonts.find((f) => f.id === hostFontId) || availableFonts[0];
|
||||
termRef.current.options.fontFamily = fontObj.family;
|
||||
|
||||
termRef.current.options.theme = {
|
||||
@@ -539,7 +562,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
setTimeout(() => safeFit(), 50);
|
||||
}
|
||||
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme]);
|
||||
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
@@ -822,11 +845,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 +870,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 +889,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>
|
||||
@@ -907,7 +930,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
ref={containerRef}
|
||||
className="absolute inset-x-0 bottom-0"
|
||||
style={{
|
||||
top: isSearchOpen ? "64px" : "40px",
|
||||
top: isSearchOpen ? "64px" : "30px",
|
||||
paddingLeft: 6,
|
||||
backgroundColor: effectiveTheme.colors.background,
|
||||
}}
|
||||
@@ -972,6 +995,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 +1047,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}
|
||||
|
||||
310
components/TextEditorModal.tsx
Normal file
310
components/TextEditorModal.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* TextEditorModal - Modal for editing text files in SFTP with syntax highlighting
|
||||
*/
|
||||
import {
|
||||
CloudUpload,
|
||||
Loader2,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Editor, { type OnMount, loader } from '@monaco-editor/react';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
// Configure Monaco to use local files instead of CDN
|
||||
const monacoBasePath = import.meta.env.DEV
|
||||
? './node_modules/monaco-editor/min/vs'
|
||||
: `${import.meta.env.BASE_URL}monaco/vs`;
|
||||
loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Combobox } from './ui/combobox';
|
||||
import { toast } from './ui/toast';
|
||||
|
||||
interface TextEditorModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
fileName: string;
|
||||
initialContent: string;
|
||||
onSave: (content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
const languageIdToMonaco = (langId: string): string => {
|
||||
const mapping: Record<string, string> = {
|
||||
'javascript': 'javascript',
|
||||
'typescript': 'typescript',
|
||||
'python': 'python',
|
||||
'shell': 'shell',
|
||||
'batch': 'bat',
|
||||
'powershell': 'powershell',
|
||||
'c': 'c',
|
||||
'cpp': 'cpp',
|
||||
'java': 'java',
|
||||
'kotlin': 'kotlin',
|
||||
'go': 'go',
|
||||
'rust': 'rust',
|
||||
'ruby': 'ruby',
|
||||
'php': 'php',
|
||||
'perl': 'perl',
|
||||
'lua': 'lua',
|
||||
'r': 'r',
|
||||
'swift': 'swift',
|
||||
'dart': 'dart',
|
||||
'csharp': 'csharp',
|
||||
'fsharp': 'fsharp',
|
||||
'vb': 'vb',
|
||||
'html': 'html',
|
||||
'css': 'css',
|
||||
'scss': 'scss',
|
||||
'sass': 'sass',
|
||||
'less': 'less',
|
||||
'json': 'json',
|
||||
'jsonc': 'json',
|
||||
'json5': 'json',
|
||||
'xml': 'xml',
|
||||
'yaml': 'yaml',
|
||||
'toml': 'ini',
|
||||
'ini': 'ini',
|
||||
'sql': 'sql',
|
||||
'graphql': 'graphql',
|
||||
'markdown': 'markdown',
|
||||
'plaintext': 'plaintext',
|
||||
'vue': 'html',
|
||||
'svelte': 'html',
|
||||
'dockerfile': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
'diff': 'diff',
|
||||
};
|
||||
return mapping[langId] || 'plaintext';
|
||||
};
|
||||
|
||||
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fileName,
|
||||
initialContent,
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
|
||||
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
// Listen for theme changes via MutationObserver on <html> class
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkTheme(root.classList.contains('dark'));
|
||||
});
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['class'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Reset content when file changes
|
||||
useEffect(() => {
|
||||
setContent(initialContent);
|
||||
setHasChanges(false);
|
||||
setLanguageId(getLanguageId(fileName));
|
||||
}, [initialContent, fileName]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
setHasChanges(content !== initialContent);
|
||||
}, [content, initialContent]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(content);
|
||||
setHasChanges(false);
|
||||
toast.success(t('sftp.editor.saved'), 'SFTP');
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t('sftp.editor.saveFailed'),
|
||||
'SFTP'
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [content, onSave, saving, t]);
|
||||
|
||||
// Keep the ref updated with the latest handleSave function
|
||||
useEffect(() => {
|
||||
handleSaveRef.current = handleSave;
|
||||
}, [handleSave]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasChanges) {
|
||||
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
onClose();
|
||||
}, [hasChanges, onClose, t]);
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
setContent(value || '');
|
||||
}, []);
|
||||
|
||||
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
// Add save shortcut - use ref to avoid stale closure
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
handleSaveRef.current();
|
||||
});
|
||||
|
||||
// Add find shortcut (Ctrl+F / Cmd+F)
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
|
||||
// Trigger Monaco's built-in find widget
|
||||
editor.trigger('keyboard', 'actions.find', null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Trigger search dialog
|
||||
const handleSearch = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.trigger('keyboard', 'actions.find', null);
|
||||
editorRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
|
||||
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
|
||||
const monacoTheme = isDarkTheme ? 'vs-dark' : 'light';
|
||||
const languageOptions = useMemo(
|
||||
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
|
||||
[supportedLanguages],
|
||||
);
|
||||
|
||||
const handleLanguageChange = useCallback((nextValue: string) => {
|
||||
setLanguageId(nextValue || 'plaintext');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0" hideCloseButton>
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<DialogTitle className="text-sm font-semibold truncate">
|
||||
{fileName}
|
||||
{hasChanges && <span className="text-primary ml-1">*</span>}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Search button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleSearch}
|
||||
title={t('common.search')}
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Language selector */}
|
||||
<Combobox
|
||||
options={languageOptions}
|
||||
value={languageId}
|
||||
onValueChange={handleLanguageChange}
|
||||
placeholder={t('sftp.editor.syntaxHighlight')}
|
||||
triggerClassName="h-7 max-w-[180px] min-w-[120px] text-xs"
|
||||
/>
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={14} className="mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<CloudUpload size={14} className="mr-1.5" />
|
||||
)}
|
||||
{saving ? t('sftp.editor.saving') : t('sftp.editor.save')}
|
||||
</Button>
|
||||
|
||||
{/* Close button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Monaco Editor */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<Editor
|
||||
height="100%"
|
||||
language={monacoLanguage}
|
||||
value={content}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorMount}
|
||||
theme={monacoTheme}
|
||||
loading={
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||||
<Loader2 size={32} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: false,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
wordWrap: 'off',
|
||||
folding: true,
|
||||
renderWhitespace: 'selection',
|
||||
bracketPairColorization: { enabled: true },
|
||||
find: {
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'selection',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
|
||||
<span>
|
||||
{getLanguageName(languageId)}
|
||||
</span>
|
||||
<span>
|
||||
{content.split('\n').length} lines • {content.length} characters
|
||||
</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextEditorModal;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AsideActionMenu,AsideActionMenuItem,AsidePanel,AsidePanelContent,AsideP
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Switch } from '../ui/switch';
|
||||
|
||||
export interface EditPanelProps {
|
||||
rule: PortForwardingRule;
|
||||
@@ -152,6 +153,18 @@ export const EditPanel: React.FC<EditPanelProps> = ({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Auto Start Toggle */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">{t('pf.form.autoStart')}</Label>
|
||||
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoStartDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.autoStart ?? false}
|
||||
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
|
||||
/>
|
||||
</div>
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter className="space-y-2">
|
||||
<Button
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { getTypeLabel } from './utils';
|
||||
|
||||
export interface NewFormPanelProps {
|
||||
@@ -153,6 +154,18 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Auto Start Toggle */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">{t('pf.form.autoStart')}</Label>
|
||||
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoStartDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.autoStart ?? false}
|
||||
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
|
||||
/>
|
||||
</div>
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter className="space-y-2">
|
||||
<Button
|
||||
|
||||
186
components/settings/ThemeSelectModal.tsx
Normal file
186
components/settings/ThemeSelectModal.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Theme Select Modal
|
||||
* A modal dialog for selecting terminal themes in settings
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, Palette, X } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
// Memoized theme item component to prevent unnecessary re-renders
|
||||
const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
theme: TerminalThemeConfig;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => onSelect(theme.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all',
|
||||
isSelected
|
||||
? 'bg-primary/15 ring-1 ring-primary'
|
||||
: 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{/* Color swatch preview */}
|
||||
<div
|
||||
className="w-12 h-8 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
|
||||
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
|
||||
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={16} className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
));
|
||||
ThemeItem.displayName = 'ThemeItem';
|
||||
|
||||
interface ThemeSelectModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
selectedThemeId: string;
|
||||
onSelect: (themeId: string) => void;
|
||||
}
|
||||
|
||||
export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
selectedThemeId,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Group themes by type
|
||||
const { darkThemes, lightThemes } = useMemo(() => {
|
||||
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
|
||||
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
|
||||
return { darkThemes: dark, lightThemes: light };
|
||||
}, []);
|
||||
|
||||
// Handle theme selection - select and close
|
||||
const handleThemeSelect = useCallback((themeId: string) => {
|
||||
onSelect(themeId);
|
||||
onClose();
|
||||
}, [onSelect, onClose]);
|
||||
|
||||
// Handle ESC key
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Handle backdrop click
|
||||
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}, [onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const modalTitleId = 'theme-select-modal-title';
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/60"
|
||||
style={{ zIndex: 99999 }}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={modalTitleId}
|
||||
>
|
||||
<div
|
||||
className="w-[480px] max-h-[600px] bg-background border border-border rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 shrink-0 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-primary/10">
|
||||
<Palette size={16} className="text-primary" />
|
||||
</div>
|
||||
<h2 id={modalTitleId} className="text-sm font-semibold text-foreground">{t('settings.terminal.themeModal.title')}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Theme List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||
{/* Dark Themes Section */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('settings.terminal.themeModal.darkThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{darkThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Light Themes Section */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('settings.terminal.themeModal.lightThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{lightThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end px-5 py-3 shrink-0 border-t border-border bg-muted/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Use Portal to render at document root
|
||||
return createPortal(modalContent, document.body);
|
||||
};
|
||||
|
||||
export default ThemeSelectModal;
|
||||
250
components/settings/tabs/SettingsFileAssociationsTab.tsx
Normal file
250
components/settings/tabs/SettingsFileAssociationsTab.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* SettingsFileAssociationsTab - Manage SFTP file opener associations and behavior
|
||||
*/
|
||||
import { FileType, Pencil, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { useSftpFileAssociations } from "../../../application/state/useSftpFileAssociations";
|
||||
import { useSettingsState } from "../../../application/state/useSettingsState";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Label } from "../../ui/label";
|
||||
import { SectionHeader, SettingsTabContent } from "../settings-ui";
|
||||
|
||||
const getOpenerLabel = (
|
||||
openerType: FileOpenerType,
|
||||
systemApp: SystemAppInfo | undefined,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
if (openerType === 'builtin-editor') {
|
||||
return t('sftp.opener.builtInEditor');
|
||||
} else if (openerType === 'system-app' && systemApp) {
|
||||
return systemApp.name;
|
||||
}
|
||||
return openerType;
|
||||
};
|
||||
|
||||
export default function SettingsFileAssociationsTab() {
|
||||
const { t } = useI18n();
|
||||
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync } = useSettingsState();
|
||||
const associations = getAllAssociations();
|
||||
const [editingExtension, setEditingExtension] = useState<string | null>(null);
|
||||
|
||||
// Debug log for Settings page
|
||||
console.log('[SettingsFileAssociationsTab] Rendering with', associations.length, 'associations:', associations);
|
||||
|
||||
const handleRemove = useCallback((extension: string) => {
|
||||
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
|
||||
removeAssociation(extension);
|
||||
}
|
||||
}, [removeAssociation, t]);
|
||||
|
||||
const handleEdit = useCallback(async (extension: string) => {
|
||||
setEditingExtension(extension);
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectApplication) {
|
||||
return;
|
||||
}
|
||||
const result = await bridge.selectApplication();
|
||||
if (result) {
|
||||
setOpenerForExtension(extension, 'system-app', { path: result.path, name: result.name });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to select application:', e);
|
||||
} finally {
|
||||
setEditingExtension(null);
|
||||
}
|
||||
}, [setOpenerForExtension]);
|
||||
|
||||
return (
|
||||
<SettingsTabContent value="file-associations">
|
||||
<div className="space-y-8">
|
||||
{/* Double-click behavior section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.doubleClickBehavior')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.doubleClickBehavior.desc')}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setSftpDoubleClickBehavior('open')}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpDoubleClickBehavior === 'open'
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpDoubleClickBehavior === 'open'
|
||||
? "border-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpDoubleClickBehavior === 'open' && (
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.doubleClickBehavior.open')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.doubleClickBehavior.openDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSftpDoubleClickBehavior('transfer')}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpDoubleClickBehavior === 'transfer'
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpDoubleClickBehavior === 'transfer'
|
||||
? "border-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpDoubleClickBehavior === 'transfer' && (
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.doubleClickBehavior.transfer')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.doubleClickBehavior.transferDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-sync section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.autoSync')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.autoSync.desc')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSftpAutoSync(!sftpAutoSync)}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpAutoSync
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpAutoSync
|
||||
? "border-primary bg-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpAutoSync && (
|
||||
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.autoSync.enable')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.autoSync.enableDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File associations section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftpFileAssociations.title')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftpFileAssociations.desc')}
|
||||
</p>
|
||||
|
||||
{associations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<FileType size={48} strokeWidth={1} className="mb-4 opacity-50" />
|
||||
<p className="text-sm">{t('settings.sftpFileAssociations.noAssociations')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b border-border">
|
||||
<th className="text-left px-4 py-2 font-medium">
|
||||
{t('settings.sftpFileAssociations.extension')}
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 font-medium">
|
||||
{t('settings.sftpFileAssociations.application')}
|
||||
</th>
|
||||
<th className="text-right px-4 py-2 font-medium w-28">
|
||||
{/* Actions */}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{associations.map(({ extension, openerType, systemApp }) => (
|
||||
<tr key={extension} className="border-b border-border last:border-b-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{openerType === 'system-app' && systemApp ? (
|
||||
<span title={systemApp.path}>{systemApp.name}</span>
|
||||
) : (
|
||||
getOpenerLabel(openerType, systemApp, t)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEdit(extension)}
|
||||
disabled={editingExtension === extension}
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRemove(extension)}
|
||||
title={t('settings.sftpFileAssociations.remove')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
180
components/settings/tabs/SettingsSystemTab.tsx
Normal file
180
components/settings/tabs/SettingsSystemTab.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Settings System Tab - System information and temp file management
|
||||
*/
|
||||
import { FolderOpen, HardDrive, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Button } from "../../ui/button";
|
||||
|
||||
interface TempDirInfo {
|
||||
path: string;
|
||||
fileCount: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
const SettingsSystemTab: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const [tempDirInfo, setTempDirInfo] = useState<TempDirInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [clearResult, setClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
|
||||
|
||||
const loadTempDirInfo = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getTempDirInfo) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const info = await bridge.getTempDirInfo();
|
||||
setTempDirInfo(info);
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to get temp dir info:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTempDirInfo();
|
||||
}, [loadTempDirInfo]);
|
||||
|
||||
const handleClearTempFiles = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.clearTempDir) return;
|
||||
|
||||
setIsClearing(true);
|
||||
setClearResult(null);
|
||||
try {
|
||||
const result = await bridge.clearTempDir();
|
||||
setClearResult(result);
|
||||
// Refresh info after clearing
|
||||
await loadTempDirInfo();
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to clear temp dir:", err);
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
}, [loadTempDirInfo]);
|
||||
|
||||
const handleOpenTempDir = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!tempDirInfo?.path || !bridge?.openTempDir) return;
|
||||
await bridge.openTempDir();
|
||||
}, [tempDirInfo]);
|
||||
|
||||
return (
|
||||
<TabsContent
|
||||
value="system"
|
||||
className="data-[state=inactive]:hidden h-full flex flex-col"
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-8 py-6">
|
||||
<div className="max-w-2xl space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t("settings.system.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("settings.system.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Temp Directory Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("settings.system.tempDirectory")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||
{/* Path */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-muted-foreground">{t("settings.system.location")}</p>
|
||||
<p className="text-sm font-mono mt-1 break-all">
|
||||
{isLoading ? "..." : (tempDirInfo?.path ?? "-")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={handleOpenTempDir}
|
||||
disabled={!tempDirInfo?.path}
|
||||
title={t("settings.system.openFolder")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settings.system.fileCount")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{isLoading ? "..." : (tempDirInfo?.fileCount ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settings.system.totalSize")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{isLoading ? "..." : formatBytes(tempDirInfo?.totalSize ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadTempDirInfo}
|
||||
disabled={isLoading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
|
||||
{t("settings.system.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearTempFiles}
|
||||
disabled={isClearing || (tempDirInfo?.fileCount ?? 0) === 0}
|
||||
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{isClearing ? t("settings.system.clearing") : t("settings.system.clearTempFiles")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Clear Result */}
|
||||
{clearResult && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.system.clearResult", {
|
||||
deleted: clearResult.deletedCount,
|
||||
failed: clearResult.failedCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.system.tempDirectoryHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsSystemTab;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Check, Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import { AlertCircle, ChevronRight, Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import type {
|
||||
CursorShape,
|
||||
LinkModifier,
|
||||
@@ -9,65 +9,63 @@ import type {
|
||||
} from "../../../domain/models";
|
||||
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../../domain/models";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { TERMINAL_FONTS, MAX_FONT_SIZE, MIN_FONT_SIZE } from "../../../infrastructure/config/fonts";
|
||||
import { MAX_FONT_SIZE, MIN_FONT_SIZE, type TerminalFont } from "../../../infrastructure/config/fonts";
|
||||
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Input } from "../../ui/input";
|
||||
import { Label } from "../../ui/label";
|
||||
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
||||
import { ThemeSelectModal } from "../ThemeSelectModal";
|
||||
|
||||
// Helper: render terminal preview
|
||||
const renderTerminalPreview = (theme: (typeof TERMINAL_THEMES)[0]) => {
|
||||
// Theme preview button component
|
||||
const ThemePreviewButton: React.FC<{
|
||||
theme: (typeof TERMINAL_THEMES)[0];
|
||||
onClick: () => void;
|
||||
buttonLabel: string;
|
||||
}> = ({ theme, onClick, buttonLabel }) => {
|
||||
const c = theme.colors;
|
||||
const lines = [
|
||||
{ prompt: "~", cmd: "ssh prod-server", color: c.foreground },
|
||||
{ prompt: "prod", cmd: "ls -la", color: c.green },
|
||||
{ prompt: "prod", cmd: "cat config.json", color: c.cyan },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
className="font-mono text-[9px] leading-tight p-1.5 rounded overflow-hidden h-full"
|
||||
style={{ backgroundColor: c.background, color: c.foreground }}
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-4 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-all text-left",
|
||||
)}
|
||||
>
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} className="flex gap-1 truncate">
|
||||
<span style={{ color: c.blue }}>{l.prompt}</span>
|
||||
<span style={{ color: c.magenta }}>$</span>
|
||||
<span style={{ color: l.color }}>{l.cmd}</span>
|
||||
{/* Theme preview swatch */}
|
||||
<div
|
||||
className="w-20 h-14 rounded-lg flex-shrink-0 flex flex-col justify-center items-start pl-2 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: c.background }}
|
||||
>
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="font-mono text-[8px]" style={{ color: c.green }}>$</span>
|
||||
<span className="font-mono text-[8px]" style={{ color: c.blue }}>ls</span>
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: c.cyan }} />
|
||||
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: c.magenta }} />
|
||||
</div>
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="font-mono text-[8px]" style={{ color: c.green }}>$</span>
|
||||
<span className="inline-block w-1.5 h-2 animate-pulse" style={{ backgroundColor: c.cursor }} />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-1">
|
||||
<span style={{ color: c.blue }}>~</span>
|
||||
<span style={{ color: c.magenta }}>$</span>
|
||||
<span className="inline-block w-1.5 h-2.5 animate-pulse" style={{ backgroundColor: c.cursor }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{theme.name}</div>
|
||||
<div className="text-xs text-muted-foreground capitalize">{theme.type}</div>
|
||||
</div>
|
||||
|
||||
{/* Action button area */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-xs">{buttonLabel}</span>
|
||||
<ChevronRight size={16} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const TerminalThemeCard: React.FC<{
|
||||
theme: (typeof TERMINAL_THEMES)[0];
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}> = ({ theme, active, onClick }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-lg border-2 transition-all overflow-hidden text-left",
|
||||
active ? "border-primary ring-2 ring-primary/20" : "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
<div className="h-16">{renderTerminalPreview(theme)}</div>
|
||||
<div className="px-2 py-1.5 text-xs font-medium border-t bg-card">{theme.name}</div>
|
||||
{active && (
|
||||
<div className="absolute top-1 right-1 w-4 h-4 bg-primary rounded-full flex items-center justify-center">
|
||||
<Check size={10} className="text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
export default function SettingsTerminalTab(props: {
|
||||
terminalThemeId: string;
|
||||
setTerminalThemeId: (id: string) => void;
|
||||
@@ -80,6 +78,7 @@ export default function SettingsTerminalTab(props: {
|
||||
key: K,
|
||||
value: TerminalSettings[K],
|
||||
) => void;
|
||||
availableFonts: TerminalFont[];
|
||||
}) {
|
||||
const {
|
||||
terminalThemeId,
|
||||
@@ -90,9 +89,97 @@ export default function SettingsTerminalTab(props: {
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
updateTerminalSetting,
|
||||
availableFonts,
|
||||
} = 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);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
|
||||
// Get current selected theme
|
||||
const currentTheme = useMemo(() => {
|
||||
return TERMINAL_THEMES.find(t => t.id === terminalThemeId) || TERMINAL_THEMES[0];
|
||||
}, [terminalThemeId]);
|
||||
|
||||
// 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);
|
||||
@@ -101,16 +188,18 @@ export default function SettingsTerminalTab(props: {
|
||||
return (
|
||||
<SettingsTabContent value="terminal">
|
||||
<SectionHeader title={t("settings.terminal.section.theme")} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{TERMINAL_THEMES.map((t) => (
|
||||
<TerminalThemeCard
|
||||
key={t.id}
|
||||
theme={t}
|
||||
active={terminalThemeId === t.id}
|
||||
onClick={() => setTerminalThemeId(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ThemePreviewButton
|
||||
theme={currentTheme}
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
buttonLabel={t("settings.terminal.theme.selectButton")}
|
||||
/>
|
||||
|
||||
<ThemeSelectModal
|
||||
open={themeModalOpen}
|
||||
onClose={() => setThemeModalOpen(false)}
|
||||
selectedThemeId={terminalThemeId}
|
||||
onSelect={setTerminalThemeId}
|
||||
/>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.font")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
@@ -120,7 +209,7 @@ export default function SettingsTerminalTab(props: {
|
||||
>
|
||||
<Select
|
||||
value={terminalFontFamilyId}
|
||||
options={TERMINAL_FONTS.map((f) => ({ value: f.id, label: f.name }))}
|
||||
options={availableFonts.map((f) => ({ value: f.id, label: f.name }))}
|
||||
onChange={(id) => setTerminalFontFamilyId(id)}
|
||||
className="w-40"
|
||||
/>
|
||||
@@ -443,6 +532,82 @@ 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>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.connection")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.connection.keepaliveInterval")}
|
||||
description={t("settings.terminal.connection.keepaliveInterval.desc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3600}
|
||||
value={terminalSettings.keepaliveInterval}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
if (val >= 0 && val <= 3600) {
|
||||
updateTerminalSetting("keepaliveInterval", val);
|
||||
}
|
||||
}}
|
||||
className="w-24"
|
||||
/>
|
||||
</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 ? /[\\/]/ : /\//;
|
||||
@@ -21,32 +31,83 @@ const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({ path, onNavigate,
|
||||
// For Windows, first part might be drive letter like "C:"
|
||||
const buildPath = (index: number) => {
|
||||
if (isWindowsPath) {
|
||||
return parts.slice(0, index + 1).join('\\');
|
||||
const builtPath = parts.slice(0, index + 1).join('\\');
|
||||
// If this is just a drive letter (e.g., "C:"), add trailing backslash
|
||||
if (/^[A-Za-z]:$/.test(builtPath)) {
|
||||
return builtPath + '\\';
|
||||
}
|
||||
return builtPath;
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import React,{ useState } from 'react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog,DialogContent,DialogDescription,DialogFooter,DialogHeader,DialogTitle } from '../ui/dialog';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
|
||||
interface ConflictItem {
|
||||
transferId: string;
|
||||
@@ -25,7 +25,7 @@ interface SftpConflictDialogProps {
|
||||
formatFileSize: (size: number) => string;
|
||||
}
|
||||
|
||||
export const SftpConflictDialog: React.FC<SftpConflictDialogProps> = ({ conflicts, onResolve, formatFileSize }) => {
|
||||
const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts, onResolve, formatFileSize }) => {
|
||||
const { t } = useI18n();
|
||||
const [applyToAll, setApplyToAll] = useState(false);
|
||||
const conflict = conflicts[0]; // Handle first conflict
|
||||
@@ -135,3 +135,6 @@ export const SftpConflictDialog: React.FC<SftpConflictDialogProps> = ({ conflict
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const SftpConflictDialog = memo(SftpConflictDialogInner);
|
||||
SftpConflictDialog.displayName = 'SftpConflictDialog';
|
||||
|
||||
158
components/sftp/SftpContext.tsx
Normal file
158
components/sftp/SftpContext.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* SftpContext - Provides stable callback references to SFTP components
|
||||
*
|
||||
* This context eliminates props drilling of callback functions through
|
||||
* the component tree, significantly reducing re-renders caused by
|
||||
* callback reference changes.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
|
||||
import { Host, SftpFileEntry } from "../../types";
|
||||
|
||||
// Types for the context
|
||||
export interface SftpPaneCallbacks {
|
||||
onConnect: (host: Host | "local") => void;
|
||||
onDisconnect: () => void;
|
||||
onNavigateTo: (path: string) => void;
|
||||
onNavigateUp: () => void;
|
||||
onRefresh: () => void;
|
||||
onOpenEntry: (entry: SftpFileEntry) => void;
|
||||
onToggleSelection: (fileName: string, multiSelect: boolean) => void;
|
||||
onRangeSelect: (fileNames: string[]) => void;
|
||||
onClearSelection: () => void;
|
||||
onSetFilter: (filter: string) => void;
|
||||
onCreateDirectory: (name: string) => Promise<void>;
|
||||
onDeleteFiles: (fileNames: string[]) => Promise<void>;
|
||||
onRenameFile: (oldName: string, newName: string) => Promise<void>;
|
||||
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
onReceiveFromOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
onEditPermissions?: (file: SftpFileEntry) => void;
|
||||
// File operations
|
||||
onEditFile?: (entry: SftpFileEntry) => void;
|
||||
onOpenFile?: (entry: SftpFileEntry) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry) => void; // Always show opener dialog
|
||||
}
|
||||
|
||||
export interface SftpDragCallbacks {
|
||||
onDragStart: (files: { name: string; isDirectory: boolean }[], side: "left" | "right") => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
// Store for activeTabId - allows subscription without re-rendering parent
|
||||
type ActiveTabStore = {
|
||||
left: string | null;
|
||||
right: string | null;
|
||||
};
|
||||
|
||||
type ActiveTabListener = () => void;
|
||||
|
||||
let activeTabState: ActiveTabStore = { left: null, right: null };
|
||||
const activeTabListeners = new Set<ActiveTabListener>();
|
||||
|
||||
export const activeTabStore = {
|
||||
getSnapshot: () => activeTabState,
|
||||
getLeftActiveTabId: () => activeTabState.left,
|
||||
getRightActiveTabId: () => activeTabState.right,
|
||||
setActiveTabId: (side: "left" | "right", tabId: string | null) => {
|
||||
if (activeTabState[side] !== tabId) {
|
||||
activeTabState = { ...activeTabState, [side]: tabId };
|
||||
activeTabListeners.forEach((listener) => listener());
|
||||
}
|
||||
},
|
||||
subscribe: (listener: ActiveTabListener) => {
|
||||
activeTabListeners.add(listener);
|
||||
return () => activeTabListeners.delete(listener);
|
||||
},
|
||||
};
|
||||
|
||||
// Hook to subscribe to active tab changes for a specific side
|
||||
export const useActiveTabId = (side: "left" | "right"): string | null => {
|
||||
return useSyncExternalStore(
|
||||
activeTabStore.subscribe,
|
||||
() => (side === "left" ? activeTabStore.getLeftActiveTabId() : activeTabStore.getRightActiveTabId()),
|
||||
() => (side === "left" ? activeTabStore.getLeftActiveTabId() : activeTabStore.getRightActiveTabId()),
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to check if a specific pane is active (for CSS control)
|
||||
export const useIsPaneActive = (side: "left" | "right", paneId: string): boolean => {
|
||||
const activeTabId = useActiveTabId(side);
|
||||
return activeTabId === paneId || (activeTabId === null && paneId !== null);
|
||||
};
|
||||
|
||||
export interface SftpContextValue {
|
||||
// Hosts list for connection picker
|
||||
hosts: Host[];
|
||||
|
||||
// Drag state (shared between panes)
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
|
||||
// Callbacks for each side
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
}
|
||||
|
||||
const SftpContext = createContext<SftpContextValue | null>(null);
|
||||
|
||||
export const useSftpContext = () => {
|
||||
const context = useContext(SftpContext);
|
||||
if (!context) {
|
||||
throw new Error("useSftpContext must be used within SftpContextProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Hook to get callbacks for a specific side
|
||||
export const useSftpPaneCallbacks = (side: "left" | "right"): SftpPaneCallbacks => {
|
||||
const context = useSftpContext();
|
||||
return side === "left" ? context.leftCallbacks : context.rightCallbacks;
|
||||
};
|
||||
|
||||
// Hook to get drag-related values
|
||||
export const useSftpDrag = () => {
|
||||
const context = useSftpContext();
|
||||
return {
|
||||
draggedFiles: context.draggedFiles,
|
||||
...context.dragCallbacks,
|
||||
};
|
||||
};
|
||||
|
||||
// Hook to get hosts
|
||||
export const useSftpHosts = () => {
|
||||
const context = useSftpContext();
|
||||
return context.hosts;
|
||||
};
|
||||
|
||||
interface SftpContextProviderProps {
|
||||
hosts: Host[];
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
hosts,
|
||||
draggedFiles,
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
children,
|
||||
}) => {
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
// Note: The callbacks objects should be stable (created with useMemo in parent)
|
||||
const value = useMemo<SftpContextValue>(
|
||||
() => ({
|
||||
hosts,
|
||||
draggedFiles,
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
}),
|
||||
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks],
|
||||
);
|
||||
|
||||
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;
|
||||
};
|
||||
@@ -2,28 +2,30 @@
|
||||
* SFTP File row component for file list
|
||||
*/
|
||||
|
||||
import { Folder } from 'lucide-react';
|
||||
import React,{ memo } from 'react';
|
||||
import { Folder, Link } from 'lucide-react';
|
||||
import React, { memo, useCallback } 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;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
isDragOver: boolean;
|
||||
columnWidths: ColumnWidths;
|
||||
onSelect: (e: React.MouseEvent) => void;
|
||||
onOpen: () => void;
|
||||
onDragStart: (e: React.DragEvent) => void;
|
||||
onSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
|
||||
onOpen: (entry: SftpFileEntry) => void;
|
||||
onDragStart: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
onDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
entry,
|
||||
index,
|
||||
isSelected,
|
||||
isDragOver,
|
||||
columnWidths,
|
||||
@@ -36,43 +38,96 @@ 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';
|
||||
const modifiedLabel = entry.lastModifiedFormatted || formatDate(entry.lastModified);
|
||||
const sizeLabel = entry.sizeFormatted || formatBytes(entry.size);
|
||||
const handleSelect = useCallback((e: React.MouseEvent) => {
|
||||
onSelect(entry, index, e);
|
||||
}, [entry, index, onSelect]);
|
||||
const handleOpen = useCallback(() => {
|
||||
console.log("[SftpFileRow] handleOpen called", { entryName: entry.name, entryType: entry.type });
|
||||
onOpen(entry);
|
||||
}, [entry, onOpen]);
|
||||
const handleDragStart = useCallback((e: React.DragEvent) => {
|
||||
onDragStart(entry, e);
|
||||
}, [entry, onDragStart]);
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
onDragOver(entry, e);
|
||||
}, [entry, onDragOver]);
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
onDrop(entry, e);
|
||||
}, [entry, onDrop]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-sftp-row="true"
|
||||
draggable={!isParentDir}
|
||||
onDragStart={onDragStart}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={onSelect}
|
||||
onDoubleClick={onOpen}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleSelect}
|
||||
onDoubleClick={handleOpen}
|
||||
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 pr-1")}>
|
||||
{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">{modifiedLabel}</span>
|
||||
<span className="text-xs text-muted-foreground truncate text-right">
|
||||
{entry.type === 'directory' ? '--' : formatBytes(entry.size)}
|
||||
{isNavDir ? '--' : sizeLabel}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export const SftpFileRow = memo(SftpFileRowInner);
|
||||
const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
|
||||
if (prev.index !== next.index) return false;
|
||||
if (prev.isSelected !== next.isSelected) return false;
|
||||
if (prev.isDragOver !== next.isDragOver) return false;
|
||||
if (prev.columnWidths.name !== next.columnWidths.name) return false;
|
||||
if (prev.columnWidths.modified !== next.columnWidths.modified) return false;
|
||||
if (prev.columnWidths.size !== next.columnWidths.size) return false;
|
||||
if (prev.columnWidths.type !== next.columnWidths.type) return false;
|
||||
// Compare callbacks - important for ".." entry which has static properties
|
||||
if (prev.onOpen !== next.onOpen) return false;
|
||||
if (prev.onSelect !== next.onSelect) return false;
|
||||
const prevEntry = prev.entry;
|
||||
const nextEntry = next.entry;
|
||||
return (
|
||||
prevEntry.name === nextEntry.name &&
|
||||
prevEntry.type === nextEntry.type &&
|
||||
prevEntry.size === nextEntry.size &&
|
||||
prevEntry.lastModified === nextEntry.lastModified &&
|
||||
prevEntry.linkTarget === nextEntry.linkTarget &&
|
||||
prevEntry.sizeFormatted === nextEntry.sizeFormatted &&
|
||||
prevEntry.lastModifiedFormatted === nextEntry.lastModifiedFormatted
|
||||
);
|
||||
};
|
||||
|
||||
export const SftpFileRow = memo(SftpFileRowInner, areEqual);
|
||||
SftpFileRow.displayName = 'SftpFileRow';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { Monitor, Search } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host } from '../../types';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
@@ -22,7 +22,7 @@ interface SftpHostPickerProps {
|
||||
onSelectHost: (host: Host) => void;
|
||||
}
|
||||
|
||||
export const SftpHostPicker: React.FC<SftpHostPickerProps> = ({
|
||||
const SftpHostPickerInner: React.FC<SftpHostPickerProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
hosts,
|
||||
@@ -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 {
|
||||
@@ -175,3 +178,6 @@ export const SftpHostPicker: React.FC<SftpHostPickerProps> = ({
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const SftpHostPicker = memo(SftpHostPickerInner);
|
||||
SftpHostPicker.displayName = 'SftpHostPicker';
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* SFTP Permissions Editor Dialog
|
||||
*/
|
||||
|
||||
import React,{ useEffect,useState } from 'react';
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { SftpFileEntry } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog,DialogContent,DialogDescription,DialogFooter,DialogHeader,DialogTitle } from '../ui/dialog';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
|
||||
interface SftpPermissionsDialogProps {
|
||||
open: boolean;
|
||||
@@ -15,7 +15,7 @@ interface SftpPermissionsDialogProps {
|
||||
onSave: (file: SftpFileEntry, permissions: string) => void;
|
||||
}
|
||||
|
||||
export const SftpPermissionsDialog: React.FC<SftpPermissionsDialogProps> = ({ open, onOpenChange, file, onSave }) => {
|
||||
const SftpPermissionsDialogInner: React.FC<SftpPermissionsDialogProps> = ({ open, onOpenChange, file, onSave }) => {
|
||||
const { t } = useI18n();
|
||||
const [permissions, setPermissions] = useState({
|
||||
owner: { read: false, write: false, execute: false },
|
||||
@@ -24,10 +24,38 @@ export const SftpPermissionsDialog: React.FC<SftpPermissionsDialogProps> = ({ op
|
||||
});
|
||||
|
||||
// Parse permissions from file
|
||||
// Supports both symbolic format (rwxr-xr-x) and octal format (755)
|
||||
useEffect(() => {
|
||||
if (file?.permissions) {
|
||||
const perms = file.permissions;
|
||||
// Parse rwxrwxrwx format (skip first char for type)
|
||||
|
||||
// Check if it's octal format (e.g., "755", "644")
|
||||
if (/^[0-7]{3,4}$/.test(perms)) {
|
||||
const octal = perms.length === 4 ? perms.slice(1) : perms;
|
||||
const ownerBits = parseInt(octal[0], 10);
|
||||
const groupBits = parseInt(octal[1], 10);
|
||||
const othersBits = parseInt(octal[2], 10);
|
||||
setPermissions({
|
||||
owner: {
|
||||
read: (ownerBits & 4) !== 0,
|
||||
write: (ownerBits & 2) !== 0,
|
||||
execute: (ownerBits & 1) !== 0,
|
||||
},
|
||||
group: {
|
||||
read: (groupBits & 4) !== 0,
|
||||
write: (groupBits & 2) !== 0,
|
||||
execute: (groupBits & 1) !== 0,
|
||||
},
|
||||
others: {
|
||||
read: (othersBits & 4) !== 0,
|
||||
write: (othersBits & 2) !== 0,
|
||||
execute: (othersBits & 1) !== 0,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse symbolic rwxrwxrwx format (skip first char for type)
|
||||
const pStr = perms.length === 10 ? perms.slice(1) : perms;
|
||||
if (pStr.length >= 9) {
|
||||
setPermissions({
|
||||
@@ -139,3 +167,6 @@ export const SftpPermissionsDialog: React.FC<SftpPermissionsDialogProps> = ({ op
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const SftpPermissionsDialog = memo(SftpPermissionsDialogInner);
|
||||
SftpPermissionsDialog.displayName = 'SftpPermissionsDialog';
|
||||
|
||||
420
components/sftp/SftpTabBar.tsx
Normal file
420
components/sftp/SftpTabBar.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* SFTP Tab Bar Component
|
||||
*
|
||||
* A tab bar for managing multiple SFTP connections in a single pane.
|
||||
* Features:
|
||||
* - Tab items with close button
|
||||
* - Add button (+) to open HostSelectModal
|
||||
* - Scrollable when many tabs are open
|
||||
* - Drag-and-drop reordering of tabs
|
||||
*/
|
||||
|
||||
import { HardDrive, Monitor, Plus, X } from "lucide-react";
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { useRenderTracker } from "../../lib/useRenderTracker";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useActiveTabId } from "./SftpContext";
|
||||
|
||||
export interface SftpTab {
|
||||
id: string;
|
||||
label: string;
|
||||
isLocal: boolean;
|
||||
hostId: string | null;
|
||||
}
|
||||
|
||||
interface SftpTabBarProps {
|
||||
tabs: SftpTab[];
|
||||
side: "left" | "right";
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => void;
|
||||
onAddTab: () => void;
|
||||
onReorderTabs: (
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: "before" | "after",
|
||||
) => void;
|
||||
/** Called when a tab is dragged to the other side */
|
||||
onMoveTabToOtherSide?: (tabId: string) => void;
|
||||
}
|
||||
|
||||
const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
tabs,
|
||||
side,
|
||||
onSelectTab,
|
||||
onCloseTab,
|
||||
onAddTab,
|
||||
onReorderTabs,
|
||||
onMoveTabToOtherSide,
|
||||
}) => {
|
||||
// Subscribe to activeTabId from store (isolated subscription)
|
||||
const activeTabId = useActiveTabId(side);
|
||||
|
||||
// 渲染追踪 - 追踪所有 props 包括回调函数
|
||||
useRenderTracker(`SftpTabBar[${side}]`, {
|
||||
side,
|
||||
tabsCount: tabs.length,
|
||||
activeTabId,
|
||||
// 追踪回调函数引用是否变化
|
||||
onSelectTab,
|
||||
onCloseTab,
|
||||
onAddTab,
|
||||
onReorderTabs,
|
||||
onMoveTabToOtherSide,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Refs for scrollable tab container
|
||||
const tabsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
// Drag state
|
||||
const [dropIndicator, setDropIndicator] = useState<{
|
||||
tabId: string;
|
||||
position: "before" | "after";
|
||||
} | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isCrossPaneDragOver, setIsCrossPaneDragOver] = useState(false);
|
||||
const draggedTabIdRef = useRef<string | null>(null);
|
||||
|
||||
// Global dragend listener to ensure state is reset even if the dragged element is removed
|
||||
useEffect(() => {
|
||||
const handleGlobalDragEnd = () => {
|
||||
if (draggedTabIdRef.current) {
|
||||
draggedTabIdRef.current = null;
|
||||
setDropIndicator(null);
|
||||
setIsDragging(false);
|
||||
setIsCrossPaneDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("dragend", handleGlobalDragEnd);
|
||||
return () => document.removeEventListener("dragend", handleGlobalDragEnd);
|
||||
}, []);
|
||||
|
||||
// Check scroll state
|
||||
const updateScrollState = useCallback(() => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (container) {
|
||||
setCanScrollLeft(container.scrollLeft > 0);
|
||||
setCanScrollRight(
|
||||
container.scrollLeft < container.scrollWidth - container.clientWidth - 1,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update scroll state on mount and resize
|
||||
useEffect(() => {
|
||||
updateScrollState();
|
||||
const container = tabsContainerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener("scroll", updateScrollState);
|
||||
const resizeObserver = new ResizeObserver(updateScrollState);
|
||||
resizeObserver.observe(container);
|
||||
return () => {
|
||||
container.removeEventListener("scroll", updateScrollState);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
}, [updateScrollState, tabs]);
|
||||
|
||||
// Scroll to active tab when it changes
|
||||
useLayoutEffect(() => {
|
||||
if (!activeTabId) return;
|
||||
const container = tabsContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const activeTabElement = container.querySelector(
|
||||
`[data-tab-id="${activeTabId}"]`,
|
||||
) as HTMLElement | null;
|
||||
if (activeTabElement) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const tabRect = activeTabElement.getBoundingClientRect();
|
||||
|
||||
if (tabRect.left < containerRect.left) {
|
||||
container.scrollLeft -= containerRect.left - tabRect.left + 8;
|
||||
} else if (tabRect.right > containerRect.right) {
|
||||
container.scrollLeft += tabRect.right - containerRect.right + 8;
|
||||
}
|
||||
}
|
||||
setTimeout(updateScrollState, 100);
|
||||
}, [activeTabId, updateScrollState]);
|
||||
|
||||
// Drag handlers
|
||||
const handleTabDragStart = useCallback(
|
||||
(e: React.DragEvent, tabId: string) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("sftp-tab-id", tabId);
|
||||
e.dataTransfer.setData("sftp-tab-side", side);
|
||||
draggedTabIdRef.current = tabId;
|
||||
setTimeout(() => {
|
||||
setIsDragging(true);
|
||||
}, 0);
|
||||
},
|
||||
[side],
|
||||
);
|
||||
|
||||
const handleTabDragEnd = useCallback(() => {
|
||||
draggedTabIdRef.current = null;
|
||||
setDropIndicator(null);
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleTabDragOver = useCallback(
|
||||
(e: React.DragEvent, tabId: string) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
|
||||
if (!draggedTabIdRef.current || draggedTabIdRef.current === tabId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const midpoint = rect.left + rect.width / 2;
|
||||
const position: "before" | "after" =
|
||||
e.clientX < midpoint ? "before" : "after";
|
||||
|
||||
setDropIndicator({ tabId, position });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleTabDrop = useCallback(
|
||||
(e: React.DragEvent, targetTabId: string) => {
|
||||
e.preventDefault();
|
||||
const draggedId =
|
||||
e.dataTransfer.getData("sftp-tab-id") || draggedTabIdRef.current;
|
||||
|
||||
if (draggedId && draggedId !== targetTabId && dropIndicator) {
|
||||
onReorderTabs(draggedId, targetTabId, dropIndicator.position);
|
||||
}
|
||||
|
||||
setDropIndicator(null);
|
||||
setIsDragging(false);
|
||||
},
|
||||
[dropIndicator, onReorderTabs],
|
||||
);
|
||||
|
||||
const handleCloseTab = useCallback(
|
||||
(e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation();
|
||||
onCloseTab(tabId);
|
||||
},
|
||||
[onCloseTab],
|
||||
);
|
||||
|
||||
// Cross-pane drag handlers
|
||||
const handleCrossPaneDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
const draggedFromSide = e.dataTransfer.types.includes("sftp-tab-side");
|
||||
if (!draggedFromSide) return;
|
||||
|
||||
// Check if this is from the other side (we can't read the data during dragover due to browser security)
|
||||
// We'll set the indicator and validate on drop
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setIsCrossPaneDragOver(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCrossPaneDragLeave = useCallback(() => {
|
||||
setIsCrossPaneDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleCrossPaneDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsCrossPaneDragOver(false);
|
||||
|
||||
const draggedId = e.dataTransfer.getData("sftp-tab-id");
|
||||
const draggedFromSide = e.dataTransfer.getData("sftp-tab-side");
|
||||
|
||||
// Only accept drops from the other side
|
||||
if (draggedId && draggedFromSide && draggedFromSide !== side && onMoveTabToOtherSide) {
|
||||
logger.info("[SftpTabBar] Cross-pane drop", {
|
||||
tabId: draggedId,
|
||||
fromSide: draggedFromSide,
|
||||
toSide: side,
|
||||
});
|
||||
onMoveTabToOtherSide(draggedId);
|
||||
}
|
||||
|
||||
// Always reset drag state on drop
|
||||
draggedTabIdRef.current = null;
|
||||
setDropIndicator(null);
|
||||
setIsDragging(false);
|
||||
},
|
||||
[side, onMoveTabToOtherSide],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-stretch h-8 bg-secondary/30 border-b border-border/40 transition-colors",
|
||||
isCrossPaneDragOver && "bg-primary/10 ring-1 ring-inset ring-primary/40",
|
||||
)}
|
||||
onDragOver={handleCrossPaneDragOver}
|
||||
onDragLeave={handleCrossPaneDragLeave}
|
||||
onDrop={handleCrossPaneDrop}
|
||||
>
|
||||
{/* Scrollable tabs container */}
|
||||
<div className="relative flex-1 min-w-0 flex">
|
||||
{/* Left fade mask */}
|
||||
{canScrollLeft && (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-6 pointer-events-none z-10"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, hsl(var(--secondary) / 0.9), transparent)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={tabsContainerRef}
|
||||
className="flex items-stretch overflow-x-auto scrollbar-none max-w-full"
|
||||
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTabId === tab.id;
|
||||
const isBeingDragged =
|
||||
isDragging && draggedTabIdRef.current === tab.id;
|
||||
const showDropIndicatorBefore =
|
||||
dropIndicator?.tabId === tab.id &&
|
||||
dropIndicator.position === "before";
|
||||
const showDropIndicatorAfter =
|
||||
dropIndicator?.tabId === tab.id &&
|
||||
dropIndicator.position === "after";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
data-tab-id={tab.id}
|
||||
onClick={() => onSelectTab(tab.id)}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, tab.id)}
|
||||
onDragEnd={handleTabDragEnd}
|
||||
onDragOver={(e) => handleTabDragOver(e, tab.id)}
|
||||
onDrop={(e) => handleTabDrop(e, tab.id)}
|
||||
className={cn(
|
||||
"relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
|
||||
"transition-[color,opacity,transform] duration-100 ease-out",
|
||||
isActive
|
||||
? "text-foreground border-b-2"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
isBeingDragged && "opacity-50",
|
||||
)}
|
||||
style={
|
||||
isActive
|
||||
? { borderBottomColor: "hsl(var(--accent))" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDragging && (
|
||||
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
{/* Drop indicator line - after */}
|
||||
{showDropIndicatorAfter && isDragging && (
|
||||
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
{tab.isLocal ? (
|
||||
<Monitor
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<HardDrive
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => handleCloseTab(e, tab.id)}
|
||||
className="p-0.5 hover:bg-destructive/10 hover:text-destructive transition-colors shrink-0"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right fade mask */}
|
||||
{canScrollRight && (
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-6 pointer-events-none z-10"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to left, hsl(var(--secondary) / 0.9), transparent)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add tab button */}
|
||||
<button
|
||||
className="px-2 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-[linear-gradient(135deg,_hsl(var(--accent)_/_0.18),_hsl(var(--primary)_/_0.18))] transition-all duration-150 border-l border-border/40 cursor-pointer"
|
||||
onClick={onAddTab}
|
||||
title={t("sftp.tabs.addTab")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom comparison - only re-render when data props change, ignore callback refs
|
||||
// Note: activeTabId is now subscribed internally, not passed as prop
|
||||
const sftpTabBarAreEqual = (
|
||||
prev: SftpTabBarProps,
|
||||
next: SftpTabBarProps,
|
||||
): boolean => {
|
||||
// Compare data props only
|
||||
if (prev.side !== next.side) return false;
|
||||
if (prev.tabs.length !== next.tabs.length) return false;
|
||||
|
||||
// Deep compare tabs array
|
||||
for (let i = 0; i < prev.tabs.length; i++) {
|
||||
const prevTab = prev.tabs[i];
|
||||
const nextTab = next.tabs[i];
|
||||
if (
|
||||
prevTab.id !== nextTab.id ||
|
||||
prevTab.label !== nextTab.label ||
|
||||
prevTab.isLocal !== nextTab.isLocal ||
|
||||
prevTab.hostId !== nextTab.hostId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore callback function refs - they may change but behavior is stable
|
||||
return true;
|
||||
};
|
||||
|
||||
export const SftpTabBar = memo(SftpTabBarInner, sftpTabBarAreEqual);
|
||||
SftpTabBar.displayName = "SftpTabBar";
|
||||
|
||||
@@ -7,14 +7,30 @@
|
||||
// Utilities
|
||||
export {
|
||||
formatBytes,formatDate,
|
||||
formatSpeed,formatTransferBytes,getFileIcon,type ColumnWidths,type SortField,
|
||||
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,type ColumnWidths,type SortField,
|
||||
type SortOrder
|
||||
} from './utils';
|
||||
|
||||
// Context
|
||||
export {
|
||||
SftpContextProvider,
|
||||
useSftpContext,
|
||||
useSftpPaneCallbacks,
|
||||
useSftpDrag,
|
||||
useSftpHosts,
|
||||
useActiveTabId,
|
||||
useIsPaneActive,
|
||||
activeTabStore,
|
||||
type SftpPaneCallbacks,
|
||||
type SftpDragCallbacks,
|
||||
type SftpContextValue,
|
||||
} from './SftpContext';
|
||||
|
||||
// Components
|
||||
export { SftpBreadcrumb } from './SftpBreadcrumb';
|
||||
export { SftpConflictDialog } from './SftpConflictDialog';
|
||||
export { SftpFileRow } from './SftpFileRow';
|
||||
export { SftpHostPicker } from './SftpHostPicker';
|
||||
export { SftpPermissionsDialog } from './SftpPermissionsDialog';
|
||||
export { SftpTabBar, type SftpTab } from './SftpTabBar';
|
||||
export { SftpTransferItem } from './SftpTransferItem';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Terminal Theme Customize Modal
|
||||
* Left-right split design: list on left, large preview on right
|
||||
* Uses React Portal to render at document root for proper z-index
|
||||
*
|
||||
*
|
||||
* Features:
|
||||
* - Real-time preview: changes are applied immediately to the terminal
|
||||
* - Save: persists the current settings
|
||||
@@ -13,8 +13,9 @@ import React, { useEffect, useMemo, useState, useCallback, useRef, memo } from '
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, Minus, Palette, Plus, Type, X } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useAvailableFonts } from '../../application/state/fontStore';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
|
||||
import { TERMINAL_FONTS, DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
|
||||
import { DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
@@ -265,6 +266,7 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const availableFonts = useAvailableFonts();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('theme');
|
||||
const [selectedTheme, setSelectedTheme] = useState(currentThemeId);
|
||||
const [selectedFont, setSelectedFont] = useState(currentFontFamilyId);
|
||||
@@ -294,8 +296,8 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
}, [open, currentThemeId, currentFontFamilyId, currentFontSize]);
|
||||
|
||||
const currentFont = useMemo(
|
||||
() => TERMINAL_FONTS.find(f => f.id === selectedFont) || TERMINAL_FONTS[0],
|
||||
[selectedFont]
|
||||
(): TerminalFont => availableFonts.find(f => f.id === selectedFont) || availableFonts[0],
|
||||
[selectedFont, availableFonts]
|
||||
);
|
||||
const currentTheme = useMemo(
|
||||
() => TERMINAL_THEMES.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0],
|
||||
@@ -430,7 +432,7 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
)}
|
||||
{activeTab === 'font' && (
|
||||
<div className="space-y-1">
|
||||
{TERMINAL_FONTS.map(font => (
|
||||
{availableFonts.map(font => (
|
||||
<FontItem
|
||||
key={font.id}
|
||||
font={font}
|
||||
|
||||
@@ -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?.();
|
||||
@@ -329,6 +343,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
env: termEnv,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -521,10 +536,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 +605,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 };
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getAppLevelActions,
|
||||
getTerminalPassthroughActions,
|
||||
} from "../../../application/state/useGlobalHotkeys";
|
||||
import { TERMINAL_FONTS } from "../../../infrastructure/config/fonts";
|
||||
import { fontStore } from "../../../application/state/fontStore";
|
||||
import {
|
||||
XTERM_PERFORMANCE_CONFIG,
|
||||
type XTermPlatform,
|
||||
@@ -38,6 +38,8 @@ export type XTermRuntime = {
|
||||
serializeAddon: SerializeAddon;
|
||||
searchAddon: SearchAddon;
|
||||
dispose: () => void;
|
||||
/** Current working directory detected via OSC 7 */
|
||||
currentCwd: string | undefined;
|
||||
};
|
||||
|
||||
export type CreateXTermRuntimeContext = {
|
||||
@@ -71,6 +73,14 @@ export type CreateXTermRuntimeContext = {
|
||||
) => void;
|
||||
commandBufferRef: RefObject<string>;
|
||||
setIsSearchOpen: Dispatch<SetStateAction<boolean>>;
|
||||
|
||||
// Serial-specific options
|
||||
serialLocalEcho?: boolean;
|
||||
serialLineMode?: boolean;
|
||||
serialLineBufferRef?: RefObject<string>;
|
||||
|
||||
// Callback when shell reports CWD change via OSC 7
|
||||
onCwdChange?: (cwd: string) => void;
|
||||
};
|
||||
|
||||
const detectPlatform = (): XTermPlatform => {
|
||||
@@ -106,7 +116,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
});
|
||||
|
||||
const hostFontId = ctx.host.fontFamily || ctx.fontFamilyId || "menlo";
|
||||
const fontObj = TERMINAL_FONTS.find((f) => f.id === hostFontId) || TERMINAL_FONTS[0];
|
||||
// Use fontStore for font lookup - guarantees non-empty result
|
||||
const fontObj = fontStore.getFontById(hostFontId);
|
||||
const fontFamily = fontObj.family;
|
||||
|
||||
const effectiveFontSize = ctx.host.fontSize || ctx.fontSize;
|
||||
@@ -397,7 +408,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);
|
||||
@@ -423,6 +491,36 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
});
|
||||
|
||||
// Track current working directory via OSC 7 escape sequences
|
||||
// OSC 7 format: \x1b]7;file://hostname/path\x07 or \x1b]7;file://hostname/path\x1b\\
|
||||
let currentCwd: string | undefined = undefined;
|
||||
|
||||
// Register OSC 7 handler using xterm.js parser
|
||||
// OSC 7 is the standard way for shells to report the current working directory
|
||||
term.parser.registerOscHandler(7, (data) => {
|
||||
try {
|
||||
// data is the content after "7;" - typically "file://hostname/path"
|
||||
if (data.startsWith('file://')) {
|
||||
// Extract path from file:// URL
|
||||
const url = new URL(data);
|
||||
const path = decodeURIComponent(url.pathname);
|
||||
if (path && path.length > 0) {
|
||||
currentCwd = path;
|
||||
ctx.onCwdChange?.(path);
|
||||
logger.debug('[XTerm] OSC 7 CWD update:', path);
|
||||
}
|
||||
} else if (data.startsWith('/')) {
|
||||
// Some shells send just the path without file:// prefix
|
||||
currentCwd = data;
|
||||
ctx.onCwdChange?.(data);
|
||||
logger.debug('[XTerm] OSC 7 CWD update (raw path):', data);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[XTerm] Failed to parse OSC 7:', err);
|
||||
}
|
||||
return true; // Indicate we handled the sequence
|
||||
});
|
||||
|
||||
let resizeTimeout: NodeJS.Timeout | null = null;
|
||||
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
|
||||
term.onResize(({ cols, rows }) => {
|
||||
@@ -468,5 +566,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
logger.warn("[XTerm] webglAddon dispose failed", err);
|
||||
}
|
||||
},
|
||||
get currentCwd() {
|
||||
return currentCwd;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -115,7 +115,7 @@ export function Combobox({
|
||||
<PopoverTrigger asChild disabled={disabled}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center rounded-md border border-input bg-background text-sm",
|
||||
"flex h-10 w-full items-center rounded-md border border-input bg-background text-sm min-w-0 overflow-hidden",
|
||||
"hover:bg-secondary/50 transition-colors",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
triggerClassName
|
||||
@@ -129,7 +129,7 @@ export function Combobox({
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 h-full px-3 bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
className="flex-1 min-w-0 h-full px-3 bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
disabled={disabled}
|
||||
/>
|
||||
{inputValue && (
|
||||
|
||||
@@ -30,8 +30,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideCloseButton?: boolean }
|
||||
>(({ className, children, hideCloseButton, ...props }, ref) => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
@@ -47,10 +47,12 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
|
||||
@@ -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,13 @@ 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)
|
||||
|
||||
// SSH Connection
|
||||
keepaliveInterval: number; // Seconds between SSH-level keepalive packets (0 = disabled)
|
||||
}
|
||||
|
||||
export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
|
||||
@@ -381,6 +406,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 +416,9 @@ 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
|
||||
keepaliveInterval: 0, // 0 = disabled (use SSH library defaults)
|
||||
};
|
||||
|
||||
export interface TerminalTheme {
|
||||
@@ -428,16 +459,20 @@ 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
|
||||
permissions?: string; // rwx format for owner/group/others e.g. "rwxr-xr-x"
|
||||
}
|
||||
|
||||
export type WorkspaceNode =
|
||||
@@ -476,6 +511,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 {
|
||||
@@ -540,6 +576,8 @@ export interface PortForwardingRule {
|
||||
remotePort?: number;
|
||||
// Host to tunnel through
|
||||
hostId?: string;
|
||||
// Auto-start: if true, this rule will automatically start when the app launches
|
||||
autoStart?: boolean;
|
||||
// Runtime state
|
||||
status: PortForwardingStatus;
|
||||
error?: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -67,15 +67,15 @@
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": ["x64"]
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": ["x64"]
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "dir",
|
||||
"arch": ["x64"]
|
||||
"target": "rpm",
|
||||
"arch": ["x64", "arm64"]
|
||||
}
|
||||
],
|
||||
"category": "Development"
|
||||
|
||||
379
electron/bridges/fileWatcherBridge.cjs
Normal file
379
electron/bridges/fileWatcherBridge.cjs
Normal file
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* File Watcher Bridge - Watches local temp files for changes to sync back to remote
|
||||
*
|
||||
* This bridge enables auto-sync functionality for files opened with external applications.
|
||||
* When a file is downloaded to temp and opened with an external app, we watch for changes
|
||||
* and automatically upload them back to the remote server.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
// Map of watchId -> { watcher, localPath, remotePath, sftpId, lastModified, lastSize }
|
||||
const activeWatchers = new Map();
|
||||
|
||||
// Debounce map to prevent multiple rapid syncs
|
||||
const debounceTimers = new Map();
|
||||
|
||||
// Map of sftpId -> Set<localPath> to track temp files even without watching
|
||||
// This allows cleanup when SFTP session closes, regardless of auto-sync setting
|
||||
const tempFilesMap = new Map();
|
||||
|
||||
let sftpClients = null;
|
||||
let electronModule = null;
|
||||
|
||||
/**
|
||||
* Initialize the file watcher bridge with dependencies
|
||||
*/
|
||||
function init(deps) {
|
||||
sftpClients = deps.sftpClients;
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a temp file for cleanup when SFTP session closes
|
||||
* Called regardless of whether auto-sync is enabled
|
||||
*/
|
||||
function registerTempFile(sftpId, localPath) {
|
||||
if (!tempFilesMap.has(sftpId)) {
|
||||
tempFilesMap.set(sftpId, new Set());
|
||||
}
|
||||
tempFilesMap.get(sftpId).add(localPath);
|
||||
console.log(`[FileWatcher] Registered temp file for cleanup: ${localPath} (session: ${sftpId})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a system notification for file sync events
|
||||
* Works on macOS, Windows, and Linux
|
||||
*/
|
||||
function showSystemNotification(title, body) {
|
||||
try {
|
||||
if (!electronModule?.Notification) {
|
||||
console.warn("[FileWatcher] Electron Notification API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const { Notification } = electronModule;
|
||||
|
||||
// Check if notifications are supported
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[FileWatcher] System notifications not supported on this platform");
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = new Notification({
|
||||
title,
|
||||
body,
|
||||
silent: false, // Allow notification sound
|
||||
});
|
||||
|
||||
notification.show();
|
||||
} catch (err) {
|
||||
console.warn("[FileWatcher] Failed to show system notification:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching a local file for changes
|
||||
* Returns a watchId that can be used to stop watching
|
||||
*/
|
||||
async function startWatching(event, { localPath, remotePath, sftpId }) {
|
||||
const watchId = `watch-${crypto.randomUUID()}`;
|
||||
|
||||
console.log(`[FileWatcher] Starting watch: ${localPath} -> ${remotePath}`);
|
||||
|
||||
// Get initial file stats
|
||||
let lastModified;
|
||||
let lastSize;
|
||||
try {
|
||||
const stat = await fs.promises.stat(localPath);
|
||||
lastModified = stat.mtimeMs;
|
||||
lastSize = stat.size;
|
||||
console.log(`[FileWatcher] Initial file stats: mtime=${lastModified}, size=${lastSize}`);
|
||||
} catch (err) {
|
||||
console.error(`[FileWatcher] Failed to stat file ${localPath}:`, err.message);
|
||||
throw new Error(`Cannot watch file: ${err.message}`);
|
||||
}
|
||||
|
||||
// Store webContents reference for later notifications
|
||||
const webContents = event.sender;
|
||||
|
||||
// Use fs.watchFile (polling) instead of fs.watch for better reliability on Windows
|
||||
// fs.watch can miss events when editors use atomic writes (save to temp, then rename)
|
||||
// fs.watchFile polls the file system at regular intervals
|
||||
const pollInterval = 1000; // Check every 1 second
|
||||
|
||||
fs.watchFile(localPath, { persistent: true, interval: pollInterval }, async (curr, prev) => {
|
||||
console.log(`[FileWatcher] File stat change detected for ${localPath}`);
|
||||
console.log(`[FileWatcher] Previous: mtime=${prev.mtimeMs}, size=${prev.size}`);
|
||||
console.log(`[FileWatcher] Current: mtime=${curr.mtimeMs}, size=${curr.size}`);
|
||||
|
||||
// Check if file was deleted
|
||||
if (curr.nlink === 0) {
|
||||
console.log(`[FileWatcher] File ${localPath} was deleted, stopping watch`);
|
||||
stopWatching(null, { watchId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if file was actually modified
|
||||
if (curr.mtimeMs <= prev.mtimeMs && curr.size === prev.size) {
|
||||
console.log(`[FileWatcher] File unchanged, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce rapid changes (e.g., multiple saves in quick succession)
|
||||
const existingTimer = debounceTimers.get(watchId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
debounceTimers.delete(watchId);
|
||||
await handleFileChange(watchId, webContents);
|
||||
}, 500); // 500ms debounce
|
||||
|
||||
debounceTimers.set(watchId, timer);
|
||||
});
|
||||
|
||||
activeWatchers.set(watchId, {
|
||||
watcher: null, // fs.watchFile doesn't return a watcher object
|
||||
localPath,
|
||||
remotePath,
|
||||
sftpId,
|
||||
lastModified,
|
||||
lastSize,
|
||||
webContents,
|
||||
useWatchFile: true, // Flag to indicate we're using fs.watchFile
|
||||
});
|
||||
|
||||
console.log(`[FileWatcher] Watch started with ID: ${watchId} (using fs.watchFile polling every ${pollInterval}ms)`);
|
||||
return { watchId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file change event - sync to remote
|
||||
*/
|
||||
async function handleFileChange(watchId, webContents) {
|
||||
const watchInfo = activeWatchers.get(watchId);
|
||||
if (!watchInfo) return;
|
||||
|
||||
const { localPath, remotePath, sftpId, lastModified: previousModified, lastSize: previousSize } = watchInfo;
|
||||
|
||||
// Extract file name once for notifications and logging
|
||||
const fileName = path.basename(remotePath);
|
||||
|
||||
console.log(`[FileWatcher] File change detected: ${localPath}`);
|
||||
|
||||
try {
|
||||
// Check if file was actually modified (compare mtime and size)
|
||||
const stat = await fs.promises.stat(localPath);
|
||||
|
||||
// Skip if neither mtime nor size changed (prevents spurious events on some platforms)
|
||||
if (stat.mtimeMs <= previousModified && stat.size === previousSize) {
|
||||
console.log(`[FileWatcher] File unchanged (mtime and size same), skipping sync`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update lastModified and lastSize
|
||||
watchInfo.lastModified = stat.mtimeMs;
|
||||
watchInfo.lastSize = stat.size;
|
||||
|
||||
// Get the SFTP client
|
||||
if (!sftpClients) {
|
||||
throw new Error("SFTP clients not initialized");
|
||||
}
|
||||
|
||||
const client = sftpClients.get(sftpId);
|
||||
if (!client) {
|
||||
throw new Error("SFTP session not found or expired");
|
||||
}
|
||||
|
||||
// Read the local file
|
||||
const content = await fs.promises.readFile(localPath);
|
||||
|
||||
console.log(`[FileWatcher] Syncing ${content.length} bytes to ${remotePath}`);
|
||||
|
||||
// Upload to remote
|
||||
await client.put(content, remotePath);
|
||||
|
||||
console.log(`[FileWatcher] Sync complete: ${remotePath}`);
|
||||
|
||||
// Show system notification for successful sync
|
||||
showSystemNotification(
|
||||
"Netcatty",
|
||||
`File synced to remote: ${fileName}`
|
||||
);
|
||||
|
||||
// Notify the renderer about successful sync
|
||||
if (webContents && !webContents.isDestroyed()) {
|
||||
webContents.send("netcatty:filewatch:synced", {
|
||||
watchId,
|
||||
localPath,
|
||||
remotePath,
|
||||
bytesWritten: content.length,
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[FileWatcher] Sync failed for ${localPath}:`, err.message);
|
||||
|
||||
// Show system notification for sync failure
|
||||
showSystemNotification(
|
||||
"Netcatty",
|
||||
`Failed to sync ${fileName}: ${err.message}`
|
||||
);
|
||||
|
||||
// Notify the renderer about sync failure
|
||||
if (webContents && !webContents.isDestroyed()) {
|
||||
webContents.send("netcatty:filewatch:error", {
|
||||
watchId,
|
||||
localPath,
|
||||
remotePath,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching a file and optionally clean up the temp file
|
||||
*/
|
||||
function stopWatching(event, { watchId, cleanupTempFile = false }) {
|
||||
const watchInfo = activeWatchers.get(watchId);
|
||||
if (!watchInfo) {
|
||||
console.log(`[FileWatcher] Watch ID not found: ${watchId}`);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
console.log(`[FileWatcher] Stopping watch: ${watchInfo.localPath}`);
|
||||
|
||||
// Clear debounce timer if any
|
||||
const timer = debounceTimers.get(watchId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
debounceTimers.delete(watchId);
|
||||
}
|
||||
|
||||
// Stop the watcher
|
||||
try {
|
||||
if (watchInfo.useWatchFile) {
|
||||
// Using fs.watchFile - need to use fs.unwatchFile
|
||||
fs.unwatchFile(watchInfo.localPath);
|
||||
} else if (watchInfo.watcher) {
|
||||
// Using fs.watch - close the watcher
|
||||
watchInfo.watcher.close();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[FileWatcher] Error stopping watcher:`, err.message);
|
||||
}
|
||||
|
||||
// Clean up temp file if requested
|
||||
if (cleanupTempFile && watchInfo.localPath) {
|
||||
cleanupTempFileAsync(watchInfo.localPath);
|
||||
}
|
||||
|
||||
activeWatchers.delete(watchId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously delete a temp file, logging success and silently handling failures
|
||||
*/
|
||||
async function cleanupTempFileAsync(filePath) {
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
console.log(`[FileWatcher] Temp file cleaned up: ${filePath}`);
|
||||
} catch (err) {
|
||||
// Silently ignore deletion failures (file may be in use or already deleted)
|
||||
console.log(`[FileWatcher] Could not delete temp file (may be in use): ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all watchers for a specific SFTP session and clean up temp files
|
||||
* Called when SFTP connection is closed
|
||||
*/
|
||||
function stopWatchersForSession(sftpId, cleanupTempFiles = true) {
|
||||
let watcherCount = 0;
|
||||
|
||||
// Stop active watchers
|
||||
for (const [watchId, watchInfo] of activeWatchers.entries()) {
|
||||
if (watchInfo.sftpId === sftpId) {
|
||||
stopWatching(null, { watchId, cleanupTempFile: cleanupTempFiles });
|
||||
watcherCount++;
|
||||
}
|
||||
}
|
||||
if (watcherCount > 0) {
|
||||
console.log(`[FileWatcher] Stopped ${watcherCount} watcher(s) for SFTP session: ${sftpId}`);
|
||||
}
|
||||
|
||||
// Clean up any registered temp files that weren't being watched
|
||||
if (cleanupTempFiles && tempFilesMap.has(sftpId)) {
|
||||
const tempFiles = tempFilesMap.get(sftpId);
|
||||
let cleanedCount = 0;
|
||||
for (const filePath of tempFiles) {
|
||||
cleanupTempFileAsync(filePath);
|
||||
cleanedCount++;
|
||||
}
|
||||
tempFilesMap.delete(sftpId);
|
||||
if (cleanedCount > 0) {
|
||||
console.log(`[FileWatcher] Queued cleanup for ${cleanedCount} temp file(s) for SFTP session: ${sftpId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of active watchers
|
||||
*/
|
||||
function listWatchers() {
|
||||
const watchers = [];
|
||||
for (const [watchId, info] of activeWatchers.entries()) {
|
||||
watchers.push({
|
||||
watchId,
|
||||
localPath: info.localPath,
|
||||
remotePath: info.remotePath,
|
||||
sftpId: info.sftpId,
|
||||
});
|
||||
}
|
||||
return watchers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for file watching operations
|
||||
*/
|
||||
function registerHandlers(ipcMain) {
|
||||
console.log("[FileWatcher] Registering IPC handlers");
|
||||
ipcMain.handle("netcatty:filewatch:start", (event, args) => {
|
||||
console.log("[FileWatcher] IPC netcatty:filewatch:start received", args);
|
||||
return startWatching(event, args);
|
||||
});
|
||||
ipcMain.handle("netcatty:filewatch:stop", stopWatching);
|
||||
ipcMain.handle("netcatty:filewatch:list", listWatchers);
|
||||
ipcMain.handle("netcatty:filewatch:registerTempFile", (_event, { sftpId, localPath }) => {
|
||||
registerTempFile(sftpId, localPath);
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all watchers on shutdown
|
||||
*/
|
||||
function cleanup() {
|
||||
console.log(`[FileWatcher] Cleaning up ${activeWatchers.size} watcher(s)`);
|
||||
for (const [watchId] of activeWatchers.entries()) {
|
||||
stopWatching(null, { watchId });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
startWatching,
|
||||
stopWatching,
|
||||
stopWatchersForSession,
|
||||
listWatchers,
|
||||
registerTempFile,
|
||||
cleanup,
|
||||
};
|
||||
@@ -9,6 +9,7 @@ const os = require("node:os");
|
||||
|
||||
/**
|
||||
* List files in a local directory
|
||||
* Properly handles symlinks by resolving their target type
|
||||
*/
|
||||
async function listLocalDir(event, payload) {
|
||||
const dirPath = payload.path;
|
||||
@@ -27,19 +28,53 @@ async function listLocalDir(event, payload) {
|
||||
const entry = entries[i];
|
||||
try {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
// fs.promises.stat follows symlinks, so we get the target's stats
|
||||
const stat = await fs.promises.stat(fullPath);
|
||||
|
||||
let type;
|
||||
let linkTarget = null;
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
// This is a symlink - mark it as such and record the target type
|
||||
type = "symlink";
|
||||
// stat follows symlinks, so stat.isDirectory() tells us if target is a directory
|
||||
linkTarget = stat.isDirectory() ? "directory" : "file";
|
||||
} else if (entry.isDirectory()) {
|
||||
type = "directory";
|
||||
} else {
|
||||
type = "file";
|
||||
}
|
||||
|
||||
result[i] = {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory()
|
||||
? "directory"
|
||||
: entry.isSymbolicLink()
|
||||
? "symlink"
|
||||
: "file",
|
||||
type,
|
||||
linkTarget,
|
||||
size: `${stat.size} bytes`,
|
||||
lastModified: stat.mtime.toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn(`Could not stat ${entry.name}:`, err.message);
|
||||
// Handle broken symlinks - lstat doesn't follow symlinks
|
||||
if (err.code === 'ENOENT' || err.code === 'ELOOP') {
|
||||
const brokenEntry = entries[i];
|
||||
try {
|
||||
const fullPath = path.join(dirPath, brokenEntry.name);
|
||||
const lstat = await fs.promises.lstat(fullPath);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
// Broken symlink
|
||||
result[i] = {
|
||||
name: brokenEntry.name,
|
||||
type: "symlink",
|
||||
linkTarget: null, // Broken link - target unknown
|
||||
size: `${lstat.size} bytes`,
|
||||
lastModified: lstat.mtime.toISOString(),
|
||||
};
|
||||
return;
|
||||
}
|
||||
} catch (lstatErr) {
|
||||
console.warn(`Could not lstat ${brokenEntry.name}:`, lstatErr.message);
|
||||
}
|
||||
}
|
||||
console.warn(`Could not stat ${entries[i].name}:`, err.message);
|
||||
result[i] = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,6 +313,28 @@ async function listPortForwards() {
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all active port forwards (cleanup on app quit)
|
||||
*/
|
||||
function stopAllPortForwards() {
|
||||
console.log(`[PortForward] Stopping all ${portForwardingTunnels.size} active tunnels...`);
|
||||
for (const [tunnelId, tunnel] of portForwardingTunnels) {
|
||||
try {
|
||||
if (tunnel.server) {
|
||||
tunnel.server.close();
|
||||
}
|
||||
if (tunnel.conn) {
|
||||
tunnel.conn.end();
|
||||
}
|
||||
console.log(`[PortForward] Stopped tunnel ${tunnelId}`);
|
||||
} catch (err) {
|
||||
console.warn(`[PortForward] Failed to stop tunnel ${tunnelId}:`, err.message);
|
||||
}
|
||||
}
|
||||
portForwardingTunnels.clear();
|
||||
console.log('[PortForward] All tunnels stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for port forwarding operations
|
||||
*/
|
||||
@@ -329,4 +351,5 @@ module.exports = {
|
||||
stopPortForward,
|
||||
getPortForwardStatus,
|
||||
listPortForwards,
|
||||
stopAllPortForwards,
|
||||
};
|
||||
|
||||
@@ -6,13 +6,19 @@
|
||||
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");
|
||||
const fileWatcherBridge = require("./fileWatcherBridge.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 +27,315 @@ 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();
|
||||
// Increase max listeners to prevent Node.js warning
|
||||
// Set to 0 (unlimited) since complex operations add many temp listeners
|
||||
conn.setMaxListeners(0);
|
||||
|
||||
// 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 +364,100 @@ async function openSftp(event, options) {
|
||||
connectOpts.authHandler = order;
|
||||
}
|
||||
|
||||
await client.connect(connectOpts);
|
||||
sftpClients.set(connId, client);
|
||||
return { sftpId: connId };
|
||||
try {
|
||||
await client.connect(connectOpts);
|
||||
|
||||
// Increase max listeners AFTER connect, when the internal ssh2 Client exists
|
||||
// This prevents Node.js MaxListenersExceededWarning when performing many operations
|
||||
// ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit
|
||||
if (client.client && typeof client.client.setMaxListeners === 'function') {
|
||||
client.client.setMaxListeners(0); // 0 means unlimited
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
// Extract permissions from longname or rights
|
||||
let permissions = undefined;
|
||||
if (item.rights) {
|
||||
// ssh2-sftp-client returns rights object with user/group/other
|
||||
permissions = `${item.rights.user || '---'}${item.rights.group || '---'}${item.rights.other || '---'}`;
|
||||
} else if (item.longname) {
|
||||
// Fallback: parse from longname (e.g., "-rwxr-xr-x 1 root root ...")
|
||||
const match = item.longname.match(/^[dlsbc-]([rwxsStT-]{9})/);
|
||||
if (match) {
|
||||
permissions = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
type,
|
||||
linkTarget,
|
||||
size: `${item.size} bytes`,
|
||||
lastModified: new Date(item.modifyTime || Date.now()).toISOString(),
|
||||
permissions,
|
||||
};
|
||||
}));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,17 +545,35 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
|
||||
/**
|
||||
* Close an SFTP connection
|
||||
* Also cleans up any jump host connections and file watchers if present
|
||||
*/
|
||||
async function closeSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) return;
|
||||
|
||||
// Stop file watchers and clean up temp files for this SFTP session
|
||||
try {
|
||||
fileWatcherBridge.stopWatchersForSession(payload.sftpId, true);
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Error stopping file watchers:", err.message);
|
||||
}
|
||||
|
||||
try {
|
||||
await client.end();
|
||||
} catch (err) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,9 +659,17 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:sftp:chmod", chmodSftp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SFTP clients map (for external access)
|
||||
*/
|
||||
function getSftpClients() {
|
||||
return sftpClients;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
getSftpClients,
|
||||
openSftp,
|
||||
listSftp,
|
||||
readSftp,
|
||||
|
||||
@@ -32,6 +32,15 @@ function resolveLangFromCharset(charset) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the SSH bridge with dependencies
|
||||
*/
|
||||
@@ -190,7 +199,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
port: jump.port || 22,
|
||||
username: jump.username || 'root',
|
||||
readyTimeout: 20000, // Reduced from 60s for faster failure detection
|
||||
keepaliveInterval: 10000,
|
||||
// Use user-configured keepalive interval from options (in seconds -> convert to ms)
|
||||
// If 0 or not provided, use 10000ms as default
|
||||
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
|
||||
keepaliveCountMax: 3,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
@@ -346,7 +357,9 @@ async function startSSHSession(event, options) {
|
||||
username: options.username || "root",
|
||||
// `readyTimeout` covers the entire connection + authentication flow in ssh2.
|
||||
readyTimeout: 20000, // Fast failure for non-interactive auth
|
||||
keepaliveInterval: 10000,
|
||||
// Use user-configured keepalive interval (in seconds -> convert to ms)
|
||||
// If 0 or not provided, use 10000ms as default
|
||||
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
|
||||
keepaliveCountMax: 3,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
@@ -365,6 +378,8 @@ async function startSSHSession(event, options) {
|
||||
hasCertificate,
|
||||
keySource: options.keySource,
|
||||
hasPublicKey: !!options.publicKey,
|
||||
hasPrivateKey: !!options.privateKey,
|
||||
hasPassword: !!options.password,
|
||||
hasEffectivePassphrase: !!effectivePassphrase,
|
||||
});
|
||||
|
||||
@@ -372,6 +387,7 @@ async function startSSHSession(event, options) {
|
||||
hasCertificate,
|
||||
keySource: options.keySource,
|
||||
hasPublicKey: !!options.publicKey,
|
||||
hasPrivateKey: !!options.privateKey,
|
||||
});
|
||||
|
||||
let authAgent = null;
|
||||
@@ -448,8 +464,9 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const logPrefix = hasJumpHosts ? '[Chain]' : '[SSH]';
|
||||
conn.on("ready", () => {
|
||||
console.log(`[Chain] Final target ${options.hostname} ready`);
|
||||
console.log(`${logPrefix} ${options.hostname} ready`);
|
||||
if (hasJumpHosts || hasProxy) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'connected');
|
||||
}
|
||||
@@ -493,8 +510,8 @@ async function startSSHSession(event, options) {
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (dataBuffer.length > 0) {
|
||||
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
|
||||
contents?.send("netcatty:data", { sessionId, data: dataBuffer });
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:data", { sessionId, data: dataBuffer });
|
||||
dataBuffer = '';
|
||||
}
|
||||
flushTimeout = null;
|
||||
@@ -529,8 +546,8 @@ async function startSSHSession(event, options) {
|
||||
clearTimeout(flushTimeout);
|
||||
}
|
||||
flushBuffer();
|
||||
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 0 });
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
|
||||
sessions.delete(sessionId);
|
||||
conn.end();
|
||||
for (const c of chainConnections) {
|
||||
@@ -551,23 +568,26 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
console.error(`[Chain] Final target ${options.hostname} error:`, err.message);
|
||||
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
|
||||
const contents = event.sender;
|
||||
|
||||
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
|
||||
err.message?.toLowerCase().includes('auth') ||
|
||||
err.message?.toLowerCase().includes('password') ||
|
||||
err.level === 'client-authentication';
|
||||
|
||||
// Use log instead of error for auth failures (normal fallback scenario)
|
||||
if (isAuthError) {
|
||||
contents?.send("netcatty:auth:failed", {
|
||||
console.log(`${logPrefix} ${options.hostname} auth failed:`, err.message);
|
||||
safeSend(contents, "netcatty:auth:failed", {
|
||||
sessionId,
|
||||
error: err.message,
|
||||
hostname: options.hostname
|
||||
});
|
||||
} else {
|
||||
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
|
||||
}
|
||||
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
sessions.delete(sessionId);
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
@@ -576,10 +596,10 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
|
||||
conn.on("timeout", () => {
|
||||
console.error(`[Chain] Final target ${options.hostname} connection timeout`);
|
||||
console.error(`${logPrefix} ${options.hostname} connection timeout`);
|
||||
const err = new Error(`Connection timeout to ${options.hostname}`);
|
||||
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
sessions.delete(sessionId);
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
@@ -588,21 +608,21 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
|
||||
conn.on("close", () => {
|
||||
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 0 });
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
|
||||
sessions.delete(sessionId);
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[Chain] Connecting to final target ${options.hostname}...`);
|
||||
console.log(`${logPrefix} Connecting to ${options.hostname}...`);
|
||||
conn.connect(connectOpts);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[Chain] SSH chain connection error:", err.message);
|
||||
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -754,12 +774,86 @@ async function generateKeyPair(event, options) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for SSH session handler to suppress noisy auth error stack traces
|
||||
* Auth failures are expected when fallback to password is available
|
||||
*/
|
||||
async function startSSHSessionWrapper(event, options) {
|
||||
try {
|
||||
return await startSSHSession(event, options);
|
||||
} catch (err) {
|
||||
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
|
||||
err.message?.toLowerCase().includes('auth') ||
|
||||
err.level === 'client-authentication';
|
||||
|
||||
if (isAuthError) {
|
||||
// Re-throw with a clean error to avoid Electron printing full stack trace
|
||||
// The frontend will handle this as a normal auth failure for fallback
|
||||
const authError = new Error(err.message);
|
||||
authError.level = 'client-authentication';
|
||||
authError.isAuthError = true;
|
||||
throw authError;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current working directory from an active SSH session
|
||||
* This sends 'pwd' to the shell and captures the output
|
||||
*/
|
||||
async function getSessionPwd(event, payload) {
|
||||
const { sessionId } = payload;
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
if (!session || !session.stream || !session.conn) {
|
||||
return { success: false, error: 'Session not found or not connected' };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const conn = session.conn;
|
||||
const timeout = setTimeout(() => {
|
||||
resolve({ success: false, error: 'Timeout getting pwd' });
|
||||
}, 3000);
|
||||
|
||||
// Use exec on the existing connection to run pwd
|
||||
conn.exec('pwd', (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
resolve({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
stream.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
const cwd = stdout.trim().split(/\r?\n/).pop()?.trim();
|
||||
if (cwd && cwd.startsWith('/')) {
|
||||
resolve({ success: true, cwd });
|
||||
} else {
|
||||
resolve({ success: false, error: 'Invalid pwd output' });
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ success: false, error: err.message });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for SSH operations
|
||||
*/
|
||||
function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:start", startSSHSession);
|
||||
ipcMain.handle("netcatty:start", startSSHSessionWrapper);
|
||||
ipcMain.handle("netcatty:ssh:exec", execCommand);
|
||||
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
|
||||
ipcMain.handle("netcatty:key:generate", generateKeyPair);
|
||||
}
|
||||
|
||||
@@ -769,5 +863,6 @@ module.exports = {
|
||||
createProxySocket,
|
||||
startSSHSession,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
generateKeyPair,
|
||||
};
|
||||
|
||||
183
electron/bridges/tempDirBridge.cjs
Normal file
183
electron/bridges/tempDirBridge.cjs
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Temp Directory Bridge - Manages Netcatty's dedicated temp directory
|
||||
*
|
||||
* All temporary files (SFTP downloads, etc.) are stored in a dedicated
|
||||
* Netcatty folder within the system temp directory for easier cleanup.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
// Netcatty temp directory name
|
||||
const NETCATTY_TEMP_DIR_NAME = "Netcatty";
|
||||
|
||||
// Cached temp directory path
|
||||
let cachedTempDir = null;
|
||||
|
||||
/**
|
||||
* Get the Netcatty temp directory path
|
||||
* Creates the directory if it doesn't exist
|
||||
*/
|
||||
function getTempDir() {
|
||||
if (cachedTempDir) {
|
||||
// Verify it still exists
|
||||
try {
|
||||
if (fs.existsSync(cachedTempDir)) {
|
||||
return cachedTempDir;
|
||||
}
|
||||
} catch {
|
||||
// Directory was deleted, recreate it
|
||||
}
|
||||
}
|
||||
|
||||
const systemTempDir = os.tmpdir();
|
||||
const netcattyTempDir = path.join(systemTempDir, NETCATTY_TEMP_DIR_NAME);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(netcattyTempDir)) {
|
||||
fs.mkdirSync(netcattyTempDir, { recursive: true });
|
||||
console.log(`[TempDir] Created Netcatty temp directory: ${netcattyTempDir}`);
|
||||
}
|
||||
cachedTempDir = netcattyTempDir;
|
||||
return netcattyTempDir;
|
||||
} catch (err) {
|
||||
console.error(`[TempDir] Failed to create temp directory:`, err.message);
|
||||
// Fallback to system temp dir
|
||||
return systemTempDir;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the temp directory exists (call on app startup)
|
||||
*/
|
||||
function ensureTempDir() {
|
||||
const tempDir = getTempDir();
|
||||
console.log(`[TempDir] Netcatty temp directory: ${tempDir}`);
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get temp directory info (path, size, file count)
|
||||
*/
|
||||
async function getTempDirInfo() {
|
||||
const tempDir = getTempDir();
|
||||
|
||||
try {
|
||||
const files = await fs.promises.readdir(tempDir);
|
||||
let totalSize = 0;
|
||||
let fileCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(tempDir, file);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.isFile()) {
|
||||
totalSize += stat.size;
|
||||
fileCount++;
|
||||
}
|
||||
} catch {
|
||||
// Skip files that can't be stat'd
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: tempDir,
|
||||
totalSize,
|
||||
fileCount,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`[TempDir] Failed to get temp dir info:`, err.message);
|
||||
return {
|
||||
path: tempDir,
|
||||
totalSize: 0,
|
||||
fileCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all files in the temp directory
|
||||
* Returns the number of files deleted
|
||||
*/
|
||||
async function clearTempDir() {
|
||||
const tempDir = getTempDir();
|
||||
let deletedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
try {
|
||||
const files = await fs.promises.readdir(tempDir);
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(tempDir, file);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
|
||||
if (stat.isFile()) {
|
||||
await fs.promises.unlink(filePath);
|
||||
deletedCount++;
|
||||
console.log(`[TempDir] Deleted: ${file}`);
|
||||
} else if (stat.isDirectory()) {
|
||||
// Recursively delete subdirectories
|
||||
await fs.promises.rm(filePath, { recursive: true, force: true });
|
||||
deletedCount++;
|
||||
console.log(`[TempDir] Deleted directory: ${file}`);
|
||||
}
|
||||
} catch (err) {
|
||||
failedCount++;
|
||||
console.log(`[TempDir] Could not delete ${file}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[TempDir] Cleanup complete: ${deletedCount} deleted, ${failedCount} failed`);
|
||||
return { deletedCount, failedCount };
|
||||
} catch (err) {
|
||||
console.error(`[TempDir] Failed to clear temp dir:`, err.message);
|
||||
return { deletedCount: 0, failedCount: 0, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique temp file path for a given filename
|
||||
*/
|
||||
function getTempFilePath(fileName) {
|
||||
const tempDir = getTempDir();
|
||||
const timestamp = Date.now();
|
||||
const safeFileName = fileName.replace(/[<>:"/\\|?*]/g, "_");
|
||||
return path.join(tempDir, `${timestamp}_${safeFileName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers
|
||||
*/
|
||||
function registerHandlers(ipcMain, shell) {
|
||||
ipcMain.handle("netcatty:tempdir:getInfo", async () => {
|
||||
return getTempDirInfo();
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:tempdir:clear", async () => {
|
||||
return clearTempDir();
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:tempdir:getPath", () => {
|
||||
return getTempDir();
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:tempdir:open", async () => {
|
||||
const tempDir = getTempDir();
|
||||
if (shell?.openPath) {
|
||||
await shell.openPath(tempDir);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTempDir,
|
||||
ensureTempDir,
|
||||
getTempDirInfo,
|
||||
clearTempDir,
|
||||
getTempFilePath,
|
||||
registerHandlers,
|
||||
};
|
||||
@@ -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,70 @@ async function createWindow(electronModule, options) {
|
||||
|
||||
mainWindow = win;
|
||||
|
||||
// Restore maximized state if it was saved
|
||||
if (savedState?.isMaximized && !savedState?.isFullScreen) {
|
||||
win.once("ready-to-show", () => {
|
||||
try {
|
||||
win.maximize();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track window bounds for saving (use last non-maximized/non-fullscreen bounds)
|
||||
let lastNormalBounds = null;
|
||||
let saveStateTimer = null;
|
||||
|
||||
const updateNormalBounds = () => {
|
||||
if (!win.isDestroyed() && !win.isMaximized() && !win.isFullScreen()) {
|
||||
lastNormalBounds = win.getBounds();
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleSaveState = () => {
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
saveStateTimer = setTimeout(() => {
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (state) saveWindowState(state);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Update normal bounds on resize/move when not maximized/fullscreen
|
||||
win.on("resize", () => {
|
||||
updateNormalBounds();
|
||||
scheduleSaveState();
|
||||
});
|
||||
|
||||
win.on("move", () => {
|
||||
updateNormalBounds();
|
||||
scheduleSaveState();
|
||||
});
|
||||
|
||||
win.on("maximize", scheduleSaveState);
|
||||
win.on("unmaximize", () => {
|
||||
updateNormalBounds();
|
||||
scheduleSaveState();
|
||||
});
|
||||
|
||||
// Save state when window is about to close
|
||||
win.on("close", () => {
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (state) saveWindowState(state);
|
||||
// Close settings window when main window closes
|
||||
closeSettingsWindow();
|
||||
});
|
||||
|
||||
win.on("enter-full-screen", () => {
|
||||
win.webContents?.send("netcatty:window:fullscreen-changed", true);
|
||||
scheduleSaveState();
|
||||
});
|
||||
|
||||
win.on("leave-full-screen", () => {
|
||||
win.webContents?.send("netcatty:window:fullscreen-changed", false);
|
||||
updateNormalBounds();
|
||||
scheduleSaveState();
|
||||
});
|
||||
|
||||
// Ensure native background matches frontend background, even before first paint.
|
||||
@@ -559,9 +733,10 @@ async function openSettingsWindow(electronModule, options) {
|
||||
backgroundColor,
|
||||
icon: appIcon,
|
||||
fullscreenable: !isMac,
|
||||
// NOTE: Do NOT set parent on Windows - it can cause the main window to close
|
||||
// when the settings window is closed in some edge cases.
|
||||
parent: isMac ? mainWindow : undefined,
|
||||
// NOTE: Do NOT set parent - on macOS this causes rendering issues when dragging
|
||||
// the window to a different screen (the window becomes invisible while still
|
||||
// appearing in "Show All Windows" in the Dock). On Windows it can cause the
|
||||
// main window to close when the settings window is closed.
|
||||
modal: false,
|
||||
show: false,
|
||||
frame: isMac,
|
||||
|
||||
@@ -36,7 +36,7 @@ try {
|
||||
electronModule = require("electron");
|
||||
}
|
||||
|
||||
const { app, BrowserWindow, Menu, protocol } = electronModule || {};
|
||||
const { app, BrowserWindow, Menu, protocol, shell } = electronModule || {};
|
||||
if (!app || !BrowserWindow) {
|
||||
throw new Error("Failed to load Electron runtime. Ensure the app is launched with the Electron binary.");
|
||||
}
|
||||
@@ -76,6 +76,8 @@ const githubAuthBridge = require("./bridges/githubAuthBridge.cjs");
|
||||
const googleAuthBridge = require("./bridges/googleAuthBridge.cjs");
|
||||
const onedriveAuthBridge = require("./bridges/onedriveAuthBridge.cjs");
|
||||
const cloudSyncBridge = require("./bridges/cloudSyncBridge.cjs");
|
||||
const fileWatcherBridge = require("./bridges/fileWatcherBridge.cjs");
|
||||
const tempDirBridge = require("./bridges/tempDirBridge.cjs");
|
||||
const windowManager = require("./bridges/windowManager.cjs");
|
||||
|
||||
// GPU settings
|
||||
@@ -359,6 +361,10 @@ const registerBridges = (win) => {
|
||||
sftpBridge.init(deps);
|
||||
transferBridge.init(deps);
|
||||
terminalBridge.init(deps);
|
||||
fileWatcherBridge.init(deps);
|
||||
|
||||
// Initialize temp directory (synchronously)
|
||||
tempDirBridge.ensureTempDir();
|
||||
|
||||
// Register all IPC handlers
|
||||
sshBridge.registerHandlers(ipcMain);
|
||||
@@ -372,6 +378,8 @@ const registerBridges = (win) => {
|
||||
googleAuthBridge.registerHandlers(ipcMain, electronModule);
|
||||
onedriveAuthBridge.registerHandlers(ipcMain, electronModule);
|
||||
cloudSyncBridge.registerHandlers(ipcMain);
|
||||
fileWatcherBridge.registerHandlers(ipcMain);
|
||||
tempDirBridge.registerHandlers(ipcMain, shell);
|
||||
|
||||
// Settings window handler
|
||||
ipcMain.handle("netcatty:settings:open", async () => {
|
||||
@@ -432,6 +440,175 @@ const registerBridges = (win) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Select an application from system file picker
|
||||
ipcMain.handle("netcatty:selectApplication", async () => {
|
||||
const { dialog } = electronModule;
|
||||
|
||||
let filters = [];
|
||||
let defaultPath;
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
filters = [{ name: "Applications", extensions: ["app"] }];
|
||||
defaultPath = "/Applications";
|
||||
} else if (process.platform === "win32") {
|
||||
filters = [{ name: "Executables", extensions: ["exe", "com", "bat", "cmd"] }];
|
||||
defaultPath = "C:\\Program Files";
|
||||
} else {
|
||||
// Linux - no specific filter, user can pick any executable
|
||||
filters = [{ name: "All Files", extensions: ["*"] }];
|
||||
defaultPath = "/usr/bin";
|
||||
}
|
||||
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: "Select Application",
|
||||
defaultPath,
|
||||
filters,
|
||||
properties: ["openFile"],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appPath = result.filePaths[0];
|
||||
const appName = path.basename(appPath).replace(/\.[^.]+$/, "");
|
||||
|
||||
return { path: appPath, name: appName };
|
||||
});
|
||||
|
||||
// Open a file with a specific application
|
||||
ipcMain.handle("netcatty:openWithApplication", async (_event, { filePath, appPath }) => {
|
||||
const { spawn: cpSpawn } = require("node:child_process");
|
||||
|
||||
console.log(`[Main] Opening file with application:`);
|
||||
console.log(`[Main] File: ${filePath}`);
|
||||
console.log(`[Main] App: ${appPath}`);
|
||||
console.log(`[Main] Platform: ${process.platform}`);
|
||||
|
||||
try {
|
||||
let child;
|
||||
if (process.platform === "darwin") {
|
||||
// On macOS, use 'open' command with -a flag for specific app
|
||||
const args = ["-a", appPath, filePath];
|
||||
console.log(`[Main] Command: open ${args.join(' ')}`);
|
||||
child = cpSpawn("open", args, { detached: true, stdio: "pipe" });
|
||||
} else if (process.platform === "win32") {
|
||||
// On Windows, use cmd /c start to properly handle paths with spaces
|
||||
// The empty string "" as window title is required when the first arg has quotes
|
||||
const args = ["/c", "start", "\"\"", `"${appPath}"`, `"${filePath}"`];
|
||||
console.log(`[Main] Command: cmd ${args.join(' ')}`);
|
||||
child = cpSpawn("cmd", args, { detached: true, stdio: "pipe", windowsVerbatimArguments: true });
|
||||
} else {
|
||||
// On Linux, spawn the app with the file
|
||||
console.log(`[Main] Command: ${appPath} ${filePath}`);
|
||||
child = cpSpawn(appPath, [filePath], { detached: true, stdio: "pipe" });
|
||||
}
|
||||
|
||||
// Log any errors from the child process
|
||||
child.on("error", (err) => {
|
||||
console.error(`[Main] Failed to start application:`, err.message);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
// On Windows, stderr may be encoded in GBK/CP936, try to decode
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
// Try decoding as GBK (code page 936) for Chinese Windows
|
||||
const { TextDecoder } = require("node:util");
|
||||
const decoder = new TextDecoder("gbk");
|
||||
const decoded = decoder.decode(data);
|
||||
console.log(`[Main] Application stderr: ${decoded}`);
|
||||
} catch {
|
||||
// Fallback to hex dump if decoding fails
|
||||
console.log(`[Main] Application stderr (hex): ${data.toString("hex")}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`[Main] Application stderr:`, data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
// On Windows, many apps (like Notepad++) pass the file to an existing instance
|
||||
// and immediately exit with code 1, this is normal behavior
|
||||
if (code !== 0 && code !== null) {
|
||||
if (process.platform === "win32") {
|
||||
console.log(`[Main] Application exited with code: ${code}, signal: ${signal} (this may be normal for single-instance apps)`);
|
||||
} else {
|
||||
console.warn(`[Main] Application exited with code: ${code}, signal: ${signal}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[Main] Application started successfully`);
|
||||
}
|
||||
});
|
||||
|
||||
child.unref();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[Main] Error opening file with application:`, err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// Download SFTP file to temp and return local path
|
||||
ipcMain.handle("netcatty:sftp:downloadToTemp", async (_event, { sftpId, remotePath, fileName }) => {
|
||||
console.log(`[Main] Downloading SFTP file to temp:`);
|
||||
console.log(`[Main] SFTP ID: ${sftpId}`);
|
||||
console.log(`[Main] Remote path: ${remotePath}`);
|
||||
console.log(`[Main] File name: ${fileName}`);
|
||||
|
||||
const client = require("./bridges/sftpBridge.cjs");
|
||||
// Use tempDirBridge for dedicated Netcatty temp directory
|
||||
const localPath = await tempDirBridge.getTempFilePath(fileName);
|
||||
|
||||
console.log(`[Main] Local temp path: ${localPath}`);
|
||||
|
||||
// Get the sftp client and download file
|
||||
const sftpClients = client.getSftpClients ? client.getSftpClients() : null;
|
||||
if (!sftpClients) {
|
||||
console.log(`[Main] Using fallback readSftp method`);
|
||||
// Fallback: use readSftp and write to temp file
|
||||
const content = await client.readSftp(null, { sftpId, path: remotePath });
|
||||
if (typeof content === "string") {
|
||||
await fs.promises.writeFile(localPath, content, "utf-8");
|
||||
} else {
|
||||
await fs.promises.writeFile(localPath, content);
|
||||
}
|
||||
console.log(`[Main] File downloaded successfully (fallback)`);
|
||||
return localPath;
|
||||
}
|
||||
|
||||
const sftpClient = sftpClients.get(sftpId);
|
||||
if (!sftpClient) {
|
||||
console.error(`[Main] SFTP session not found: ${sftpId}`);
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
await sftpClient.fastGet(remotePath, localPath);
|
||||
console.log(`[Main] File downloaded successfully`);
|
||||
return localPath;
|
||||
});
|
||||
|
||||
// Delete a temp file (for cleanup when editors close)
|
||||
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
|
||||
try {
|
||||
// Only allow deleting files in Netcatty temp directory for security
|
||||
const netcattyTempDir = tempDirBridge.getTempDir();
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
if (!resolvedPath.startsWith(netcattyTempDir)) {
|
||||
console.warn(`[Main] Refused to delete file outside Netcatty temp dir: ${filePath}`);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
await fs.promises.unlink(resolvedPath);
|
||||
console.log(`[Main] Temp file deleted: ${filePath}`);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Silently handle failures (file may be in use or already deleted)
|
||||
console.log(`[Main] Could not delete temp file: ${filePath} (${err.message})`);
|
||||
return { success: false };
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Main] All bridges registered successfully');
|
||||
};
|
||||
|
||||
@@ -539,13 +716,18 @@ app.on("window-all-closed", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup all PTY sessions before quitting to prevent node-pty assertion errors
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
app.on("will-quit", () => {
|
||||
try {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
} catch (err) {
|
||||
console.warn("Error during terminal cleanup:", err);
|
||||
}
|
||||
try {
|
||||
portForwardingBridge.stopAllPortForwards();
|
||||
} catch (err) {
|
||||
console.warn("Error during port forwarding cleanup:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Export for testing
|
||||
|
||||
@@ -198,6 +198,30 @@ ipcRenderer.on("netcatty:portforward:status", (_event, payload) => {
|
||||
}
|
||||
});
|
||||
|
||||
// File watcher listeners (for auto-sync feature)
|
||||
const fileWatchSyncedListeners = new Set();
|
||||
const fileWatchErrorListeners = new Set();
|
||||
|
||||
ipcRenderer.on("netcatty:filewatch:synced", (_event, payload) => {
|
||||
fileWatchSyncedListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("File watch synced callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:filewatch:error", (_event, payload) => {
|
||||
fileWatchErrorListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("File watch error callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const api = {
|
||||
startSSHSession: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:start", options);
|
||||
@@ -215,12 +239,28 @@ 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 });
|
||||
},
|
||||
execCommand: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:ssh:exec", options);
|
||||
},
|
||||
getSessionPwd: async (sessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ssh:pwd", { sessionId });
|
||||
},
|
||||
generateKeyPair: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:key:generate", options);
|
||||
},
|
||||
@@ -488,6 +528,46 @@ const api = {
|
||||
ipcRenderer.invoke("netcatty:onedrive:drive:downloadSyncFile", options),
|
||||
onedriveDeleteSyncFile: (options) =>
|
||||
ipcRenderer.invoke("netcatty:onedrive:drive:deleteSyncFile", options),
|
||||
|
||||
// File opener helpers (for "Open With" feature)
|
||||
selectApplication: () =>
|
||||
ipcRenderer.invoke("netcatty:selectApplication"),
|
||||
openWithApplication: (filePath, appPath) =>
|
||||
ipcRenderer.invoke("netcatty:openWithApplication", { filePath, appPath }),
|
||||
downloadSftpToTemp: (sftpId, remotePath, fileName) =>
|
||||
ipcRenderer.invoke("netcatty:sftp:downloadToTemp", { sftpId, remotePath, fileName }),
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch: (localPath, remotePath, sftpId) =>
|
||||
ipcRenderer.invoke("netcatty:filewatch:start", { localPath, remotePath, sftpId }),
|
||||
stopFileWatch: (watchId, cleanupTempFile = false) =>
|
||||
ipcRenderer.invoke("netcatty:filewatch:stop", { watchId, cleanupTempFile }),
|
||||
listFileWatches: () =>
|
||||
ipcRenderer.invoke("netcatty:filewatch:list"),
|
||||
registerTempFile: (sftpId, localPath) =>
|
||||
ipcRenderer.invoke("netcatty:filewatch:registerTempFile", { sftpId, localPath }),
|
||||
onFileWatchSynced: (cb) => {
|
||||
fileWatchSyncedListeners.add(cb);
|
||||
return () => fileWatchSyncedListeners.delete(cb);
|
||||
},
|
||||
onFileWatchError: (cb) => {
|
||||
fileWatchErrorListeners.add(cb);
|
||||
return () => fileWatchErrorListeners.delete(cb);
|
||||
},
|
||||
|
||||
// Temp file cleanup
|
||||
deleteTempFile: (filePath) =>
|
||||
ipcRenderer.invoke("netcatty:deleteTempFile", { filePath }),
|
||||
|
||||
// Temp directory management
|
||||
getTempDirInfo: () =>
|
||||
ipcRenderer.invoke("netcatty:tempdir:getInfo"),
|
||||
clearTempDir: () =>
|
||||
ipcRenderer.invoke("netcatty:tempdir:clear"),
|
||||
getTempDirPath: () =>
|
||||
ipcRenderer.invoke("netcatty:tempdir:getPath"),
|
||||
openTempDir: () =>
|
||||
ipcRenderer.invoke("netcatty:tempdir:open"),
|
||||
};
|
||||
|
||||
// Merge with existing netcatty (if any) to avoid stale objects on hot reload
|
||||
|
||||
57
global.d.ts
vendored
57
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
|
||||
@@ -61,6 +61,8 @@ interface NetcattySSHOptions {
|
||||
proxy?: NetcattyProxyConfig;
|
||||
// Jump hosts (bastion chain)
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
// SSH-level keepalive interval in seconds (0 = disabled)
|
||||
keepaliveInterval?: number;
|
||||
}
|
||||
|
||||
interface SftpStatResult {
|
||||
@@ -134,7 +136,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;
|
||||
@@ -149,6 +170,8 @@ interface NetcattyBridge {
|
||||
command: string;
|
||||
timeout?: number;
|
||||
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
|
||||
/** Get current working directory from an active SSH session */
|
||||
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
|
||||
writeToSession(sessionId: string, data: string): void;
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void;
|
||||
closeSession(sessionId: string): void;
|
||||
@@ -261,6 +284,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>;
|
||||
@@ -381,6 +412,28 @@ interface NetcattyBridge {
|
||||
onedriveUploadSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string | null }>;
|
||||
onedriveDownloadSyncFile?(options: { accessToken: string; fileId?: string; fileName?: string }): Promise<{ syncedFile: unknown | null }>;
|
||||
onedriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
|
||||
|
||||
// File opener helpers (for "Open With" feature)
|
||||
selectApplication?(): Promise<{ path: string; name: string } | null>;
|
||||
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
|
||||
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string): Promise<string>;
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch?(localPath: string, remotePath: string, sftpId: string): Promise<{ watchId: string }>;
|
||||
stopFileWatch?(watchId: string, cleanupTempFile?: boolean): Promise<{ success: boolean }>;
|
||||
listFileWatches?(): Promise<Array<{ watchId: string; localPath: string; remotePath: string; sftpId: string }>>;
|
||||
registerTempFile?(sftpId: string, localPath: string): Promise<{ success: boolean }>;
|
||||
onFileWatchSynced?(cb: (payload: { watchId: string; localPath: string; remotePath: string; bytesWritten: number }) => void): () => void;
|
||||
onFileWatchError?(cb: (payload: { watchId: string; localPath: string; remotePath: string; error: string }) => void): () => void;
|
||||
|
||||
// Temp file cleanup
|
||||
deleteTempFile?(filePath: string): Promise<{ success: boolean }>;
|
||||
|
||||
// Temp directory management
|
||||
getTempDirInfo?(): Promise<{ path: string; fileCount: number; totalSize: number }>;
|
||||
clearTempDir?(): Promise<{ deletedCount: number; failedCount: number; error?: string }>;
|
||||
getTempDirPath?(): Promise<string>;
|
||||
openTempDir?(): Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' data: blob: ws: wss: https:; img-src 'self' data: https:;" />
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' data: blob: ws: wss: https:; img-src 'self' data: https:;" />
|
||||
<title>netcatty SSH</title>
|
||||
<style>
|
||||
/* Load extended Unicode ranges for terminal box drawing characters */
|
||||
@@ -206,4 +206,4 @@
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
@@ -30,7 +30,7 @@ const CJK_FALLBACK_FONTS = [
|
||||
|
||||
const CJK_FALLBACK_STACK = CJK_FALLBACK_FONTS.join(', ');
|
||||
|
||||
const withCjkFallback = (family: string) => {
|
||||
export const withCjkFallback = (family: string) => {
|
||||
const trimmed = family.trim();
|
||||
if (!CJK_FALLBACK_STACK) return trimmed;
|
||||
// Avoid double-appending if a custom stack already includes one of these fonts.
|
||||
|
||||
@@ -31,5 +31,16 @@ 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';
|
||||
|
||||
// SFTP File Opener Associations
|
||||
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';
|
||||
|
||||
// SFTP Settings
|
||||
export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_click_behavior_v1';
|
||||
export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_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<{
|
||||
|
||||
@@ -14,11 +14,84 @@ export interface PortForwardingConnection {
|
||||
status: 'inactive' | 'connecting' | 'active' | 'error';
|
||||
error?: string;
|
||||
unsubscribe?: () => void;
|
||||
// Reconnect state
|
||||
reconnectAttempts?: number;
|
||||
reconnectTimeoutId?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
// Map to track active connections
|
||||
const activeConnections = new Map<string, PortForwardingConnection>();
|
||||
|
||||
// Reconnect configuration
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
const RECONNECT_DELAY_MS = 3000; // 3 seconds between reconnection attempts
|
||||
|
||||
// Callbacks for auto-reconnect - will be set by the state hook
|
||||
let reconnectCallback: ((
|
||||
ruleId: string,
|
||||
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void
|
||||
) => Promise<{ success: boolean; error?: string }>) | null = null;
|
||||
|
||||
/**
|
||||
* Set the reconnect callback (called by state hook to enable auto-reconnect)
|
||||
*/
|
||||
export const setReconnectCallback = (
|
||||
callback: typeof reconnectCallback
|
||||
): void => {
|
||||
reconnectCallback = callback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear any pending reconnect for a rule
|
||||
*/
|
||||
export const clearReconnectTimer = (ruleId: string): void => {
|
||||
const conn = activeConnections.get(ruleId);
|
||||
if (conn?.reconnectTimeoutId) {
|
||||
clearTimeout(conn.reconnectTimeoutId);
|
||||
conn.reconnectTimeoutId = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to schedule a reconnection attempt
|
||||
* Returns true if a reconnect was scheduled, false otherwise
|
||||
*/
|
||||
const scheduleReconnectIfNeeded = (
|
||||
ruleId: string,
|
||||
enableReconnect: boolean,
|
||||
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void,
|
||||
): boolean => {
|
||||
if (!enableReconnect || !reconnectCallback) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentConn = activeConnections.get(ruleId);
|
||||
const attempts = (currentConn?.reconnectAttempts ?? 0) + 1;
|
||||
|
||||
if (attempts <= MAX_RECONNECT_ATTEMPTS) {
|
||||
logger.info(`[PortForwardingService] Scheduling reconnect ${attempts}/${MAX_RECONNECT_ATTEMPTS}`);
|
||||
|
||||
if (currentConn) {
|
||||
currentConn.reconnectAttempts = attempts;
|
||||
currentConn.reconnectTimeoutId = setTimeout(() => {
|
||||
if (reconnectCallback) {
|
||||
reconnectCallback(ruleId, onStatusChange);
|
||||
}
|
||||
}, RECONNECT_DELAY_MS);
|
||||
}
|
||||
|
||||
onStatusChange('connecting', `Reconnecting (${attempts}/${MAX_RECONNECT_ATTEMPTS})...`);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.warn(`[PortForwardingService] Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached for rule ${ruleId}`);
|
||||
// Reset reconnect attempts
|
||||
if (currentConn) {
|
||||
currentConn.reconnectAttempts = 0;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get active connection info for a rule
|
||||
*/
|
||||
@@ -35,17 +108,91 @@ export const getActiveRuleIds = (): string[] => {
|
||||
.map(([ruleId]) => ruleId);
|
||||
};
|
||||
|
||||
// Tunnel ID prefix and UUID regex pattern for parsing
|
||||
const TUNNEL_ID_PREFIX = 'pf-';
|
||||
// UUID format: 8-4-4-4-12 hexadecimal characters
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Parse rule ID from tunnel ID
|
||||
* Tunnel ID format is "pf-{ruleId}-{timestamp}" where ruleId is a UUID
|
||||
*/
|
||||
const parseRuleIdFromTunnelId = (tunnelId: string): string | null => {
|
||||
if (!tunnelId.startsWith(TUNNEL_ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove prefix and split remaining parts
|
||||
const withoutPrefix = tunnelId.slice(TUNNEL_ID_PREFIX.length);
|
||||
const parts = withoutPrefix.split('-');
|
||||
|
||||
// UUID has 5 parts (8-4-4-4-12), so we need at least 6 parts (5 UUID + timestamp)
|
||||
if (parts.length < 6) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reconstruct the UUID from first 5 parts
|
||||
const ruleId = parts.slice(0, 5).join('-');
|
||||
|
||||
// Validate it's a proper UUID format
|
||||
if (!UUID_REGEX.test(ruleId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ruleId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync active connections with backend
|
||||
* Called on app startup to restore state of tunnels that may still be running
|
||||
* This updates the local activeConnections map to match the backend state.
|
||||
*/
|
||||
export const syncWithBackend = async (): Promise<void> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
if (!bridge?.listPortForwards) {
|
||||
logger.warn('[PortForwardingService] Backend not available for sync');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeTunnels = await bridge.listPortForwards();
|
||||
logger.info(`[PortForwardingService] Backend reports ${activeTunnels.length} active tunnels`);
|
||||
|
||||
for (const tunnel of activeTunnels) {
|
||||
const ruleId = parseRuleIdFromTunnelId(tunnel.tunnelId);
|
||||
if (ruleId) {
|
||||
// Update local connection tracking
|
||||
activeConnections.set(ruleId, {
|
||||
ruleId,
|
||||
tunnelId: tunnel.tunnelId,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
logger.info(`[PortForwardingService] Synced active tunnel for rule ${ruleId}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[PortForwardingService] Failed to sync with backend:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a port forwarding tunnel
|
||||
* @param enableReconnect - If true, will automatically attempt to reconnect on disconnect
|
||||
*/
|
||||
export const startPortForward = async (
|
||||
rule: PortForwardingRule,
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string }[],
|
||||
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void
|
||||
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void,
|
||||
enableReconnect = false
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
// Clear any existing reconnect timer
|
||||
clearReconnectTimer(rule.id);
|
||||
|
||||
if (!bridge?.startPortForward) {
|
||||
// Fallback for browser/dev mode - simulate the connection
|
||||
logger.warn('[PortForwardingService] Backend not available, simulating connection...');
|
||||
@@ -72,15 +219,26 @@ export const startPortForward = async (
|
||||
conn.status = status;
|
||||
conn.error = error;
|
||||
}
|
||||
|
||||
// Handle auto-reconnect on error/disconnect
|
||||
if (status === 'error') {
|
||||
const reconnectScheduled = scheduleReconnectIfNeeded(rule.id, enableReconnect, onStatusChange);
|
||||
if (reconnectScheduled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onStatusChange(status, error ?? undefined);
|
||||
});
|
||||
|
||||
// Store connection info
|
||||
// Store connection info (preserve reconnect attempts if this is a reconnect)
|
||||
const existingConn = activeConnections.get(rule.id);
|
||||
activeConnections.set(rule.id, {
|
||||
ruleId: rule.id,
|
||||
tunnelId,
|
||||
status: 'connecting',
|
||||
unsubscribe,
|
||||
reconnectAttempts: existingConn?.reconnectAttempts ?? 0,
|
||||
});
|
||||
|
||||
onStatusChange('connecting');
|
||||
@@ -101,16 +259,35 @@ export const startPortForward = async (
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
// Check if we should attempt reconnect
|
||||
const reconnectScheduled = scheduleReconnectIfNeeded(rule.id, enableReconnect, onStatusChange);
|
||||
if (reconnectScheduled) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
activeConnections.delete(rule.id);
|
||||
unsubscribe?.();
|
||||
onStatusChange('error', result.error);
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
// Reset reconnect attempts on successful connection
|
||||
const conn = activeConnections.get(rule.id);
|
||||
if (conn) {
|
||||
conn.reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : 'Unknown error';
|
||||
|
||||
// Check if we should attempt reconnect
|
||||
const reconnectScheduled = scheduleReconnectIfNeeded(rule.id, enableReconnect, onStatusChange);
|
||||
if (reconnectScheduled) {
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
onStatusChange('error', error);
|
||||
activeConnections.delete(rule.id);
|
||||
return { success: false, error };
|
||||
@@ -127,6 +304,9 @@ export const stopPortForward = async (
|
||||
const bridge = netcattyBridge.get();
|
||||
const conn = activeConnections.get(ruleId);
|
||||
|
||||
// Clear any pending reconnect timer
|
||||
clearReconnectTimer(ruleId);
|
||||
|
||||
if (!conn) {
|
||||
onStatusChange('inactive');
|
||||
return { success: true };
|
||||
@@ -180,16 +360,19 @@ export const isBackendAvailable = (): boolean => {
|
||||
export const stopAllPortForwards = async (): Promise<void> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
for (const [_ruleId, conn] of activeConnections) {
|
||||
try {
|
||||
if (bridge?.stopPortForward) {
|
||||
await bridge.stopPortForward(conn.tunnelId);
|
||||
}
|
||||
conn.unsubscribe?.();
|
||||
} catch (err) {
|
||||
logger.warn(`[PortForwardingService] Failed to stop tunnel ${conn.tunnelId}:`, err);
|
||||
}
|
||||
}
|
||||
for (const [ruleId, conn] of activeConnections) {
|
||||
// Clear any pending reconnect timer
|
||||
clearReconnectTimer(ruleId);
|
||||
|
||||
try {
|
||||
if (bridge?.stopPortForward) {
|
||||
await bridge.stopPortForward(conn.tunnelId);
|
||||
}
|
||||
conn.unsubscribe?.();
|
||||
} catch (err) {
|
||||
logger.warn(`[PortForwardingService] Failed to stop tunnel ${conn.tunnelId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
activeConnections.clear();
|
||||
};
|
||||
@@ -230,4 +413,6 @@ export default {
|
||||
getPortForwardStatus,
|
||||
isBackendAvailable,
|
||||
stopAllPortForwards,
|
||||
setReconnectCallback,
|
||||
clearReconnectTimer,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
127
lib/localFonts.ts
Normal file
127
lib/localFonts.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { TerminalFont, withCjkFallback } from "../infrastructure/config/fonts"
|
||||
|
||||
/**
|
||||
* Type definition for Local Font Access API
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Local_Font_Access_API
|
||||
*/
|
||||
interface LocalFontData {
|
||||
family: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Known monospace font families that don't follow naming conventions.
|
||||
* These are popular programming/terminal fonts that should be included.
|
||||
*/
|
||||
const KNOWN_MONOSPACE_FONTS = new Set([
|
||||
// Popular programming fonts
|
||||
'iosevka',
|
||||
'hack',
|
||||
'consolas',
|
||||
'menlo',
|
||||
'monaco',
|
||||
'inconsolata',
|
||||
'mononoki',
|
||||
'fantasque sans mono',
|
||||
'anonymous pro',
|
||||
'liberation mono',
|
||||
'dejavu sans mono',
|
||||
'droid sans mono',
|
||||
'ubuntu mono',
|
||||
'roboto mono',
|
||||
'source code pro',
|
||||
'fira code',
|
||||
'fira mono',
|
||||
'jetbrains mono',
|
||||
'cascadia code',
|
||||
'cascadia mono',
|
||||
'victor mono',
|
||||
'ibm plex mono',
|
||||
'sf mono',
|
||||
'operator mono',
|
||||
'input mono',
|
||||
'pragmata pro',
|
||||
'berkeley mono',
|
||||
'monaspace',
|
||||
'geist mono',
|
||||
'comic mono',
|
||||
'courier',
|
||||
'courier new',
|
||||
'lucida console',
|
||||
'pt mono',
|
||||
'overpass mono',
|
||||
'space mono',
|
||||
'go mono',
|
||||
'noto sans mono',
|
||||
'sarasa mono',
|
||||
'maple mono',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Suffix indicators that suggest a font is monospace
|
||||
*/
|
||||
const MONO_SUFFIX_INDICATORS = ['mono', 'monospace', 'code', 'terminal', 'console'];
|
||||
|
||||
/**
|
||||
* Checks if a font family name indicates a monospace font.
|
||||
* Uses both known font list and suffix matching for comprehensive detection.
|
||||
*/
|
||||
function isMonospaceFont(familyName: string): boolean {
|
||||
const familyLower = familyName.toLowerCase().trim();
|
||||
|
||||
// Check against known monospace fonts (exact or partial match)
|
||||
for (const knownFont of KNOWN_MONOSPACE_FONTS) {
|
||||
if (familyLower === knownFont || familyLower.startsWith(knownFont + ' ')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check suffix indicators with word boundary
|
||||
return MONO_SUFFIX_INDICATORS.some(indicator => {
|
||||
return (
|
||||
familyLower === indicator ||
|
||||
familyLower.endsWith(' ' + indicator) ||
|
||||
familyLower.endsWith('-' + indicator) ||
|
||||
familyLower.includes(' ' + indicator + ' ')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries local monospace fonts from the system using the Font Access API.
|
||||
* Returns an empty array if the API is not available or permission is denied.
|
||||
*/
|
||||
export async function getMonospaceFonts(): Promise<TerminalFont[]> {
|
||||
// Check if the Font Access API is available
|
||||
if (typeof window === "undefined" || !("queryLocalFonts" in window)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const queryLocalFonts = (window as unknown as { queryLocalFonts: () => Promise<LocalFontData[]> }).queryLocalFonts;
|
||||
const fonts = await queryLocalFonts();
|
||||
|
||||
// Filter monospace fonts using robust word boundary matching
|
||||
const monoFonts = fonts.filter(f => isMonospaceFont(f.family));
|
||||
|
||||
// Deduplicate by family name (API may return multiple entries per family)
|
||||
const uniqueFamilies = new Set<string>();
|
||||
const dedupedFonts = monoFonts.filter(f => {
|
||||
if (uniqueFamilies.has(f.family)) return false;
|
||||
uniqueFamilies.add(f.family);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Map to TerminalFont structure with CJK fallback applied
|
||||
return dedupedFonts.map(f => ({
|
||||
id: f.family,
|
||||
name: f.family,
|
||||
family: withCjkFallback(f.family + ', monospace'),
|
||||
description: `Local font: ${f.family}`,
|
||||
category: 'monospace' as const,
|
||||
}));
|
||||
} catch (error) {
|
||||
// Handle permission denied or other errors gracefully
|
||||
console.warn('Failed to query local fonts:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
426
lib/sftpFileUtils.ts
Normal file
426
lib/sftpFileUtils.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* SFTP File Utilities
|
||||
* Helper functions for file type detection and extension handling
|
||||
*/
|
||||
|
||||
// Common text file extensions
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
// Code/Scripts
|
||||
'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'vue', 'svelte',
|
||||
'py', 'pyw', 'pyi',
|
||||
'sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1', 'psm1',
|
||||
'c', 'cpp', 'h', 'hpp', 'cc', 'cxx', 'hh', 'hxx',
|
||||
'java', 'scala', 'kt', 'kts', 'groovy', 'gradle',
|
||||
'go', 'rs', 'rb', 'php', 'pl', 'pm', 'lua', 'r', 'R',
|
||||
'swift', 'dart', 'cs', 'fs', 'vb',
|
||||
'ex', 'exs', 'erl', 'hrl', 'clj', 'cljs', 'cljc',
|
||||
'hs', 'lhs', 'elm', 'ml', 'mli', 'nim',
|
||||
// Web
|
||||
'html', 'htm', 'xhtml', 'css', 'scss', 'sass', 'less', 'styl',
|
||||
// Config/Data
|
||||
'json', 'json5', 'jsonc', 'xml', 'xsl', 'xslt', 'xsd',
|
||||
'yml', 'yaml', 'toml', 'ini', 'conf', 'cfg', 'config', 'properties',
|
||||
'env', 'gitignore', 'gitattributes', 'editorconfig', 'eslintrc', 'prettierrc',
|
||||
'sql', 'graphql', 'gql',
|
||||
// Text/Docs
|
||||
'md', 'markdown', 'mdx', 'txt', 'text', 'log', 'rst', 'adoc', 'asciidoc',
|
||||
'tex', 'latex', 'bib',
|
||||
// Data formats
|
||||
'csv', 'tsv', 'psv',
|
||||
// System
|
||||
'rc', 'bashrc', 'zshrc', 'profile', 'vimrc', 'tmux', 'nanorc',
|
||||
'dockerfile', 'containerfile', 'makefile', 'cmake', 'mak',
|
||||
// Version control & Git
|
||||
'gitconfig', 'gitmodules', 'gitkeep',
|
||||
// Other common text formats
|
||||
'diff', 'patch', 'htaccess', 'lock', 'sum',
|
||||
// Service/System files
|
||||
'service', 'socket', 'timer', 'mount', 'automount', 'target',
|
||||
// Shell history and data
|
||||
'history', 'zsh_history', 'bash_history',
|
||||
]);
|
||||
|
||||
// Additional filenames (no extension) that are always text
|
||||
const TEXT_FILENAMES = new Set([
|
||||
'readme', 'license', 'licence', 'changelog', 'authors', 'contributors',
|
||||
'copying', 'install', 'news', 'todo', 'history', 'makefile', 'dockerfile',
|
||||
'gemfile', 'rakefile', 'brewfile', 'procfile', 'vagrantfile',
|
||||
'cmakelists.txt', 'cmakelists',
|
||||
]);
|
||||
|
||||
// Common image file extensions
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg',
|
||||
'ico', 'tiff', 'tif', 'heic', 'heif', 'avif', 'jfif',
|
||||
]);
|
||||
|
||||
// Known binary file extensions - files that should never be opened as text
|
||||
const BINARY_EXTENSIONS = new Set([
|
||||
// Images
|
||||
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'tiff', 'tif',
|
||||
'heic', 'heif', 'avif', 'jfif', 'psd', 'ai', 'eps', 'raw', 'cr2', 'nef',
|
||||
// Audio
|
||||
'mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', 'm4a', 'aiff', 'opus',
|
||||
// Video
|
||||
'mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'webm', 'm4v', '3gp', 'mpeg', 'mpg',
|
||||
// Archives
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'lz', 'lzma', 'zst',
|
||||
'tgz', 'tbz2', 'txz', 'cab', 'iso', 'dmg',
|
||||
// Executables
|
||||
'exe', 'dll', 'so', 'dylib', 'bin', 'app', 'msi', 'deb', 'rpm',
|
||||
'apk', 'ipa', 'jar', 'war', 'ear',
|
||||
// Documents (binary formats)
|
||||
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
|
||||
// Fonts
|
||||
'ttf', 'otf', 'woff', 'woff2', 'eot',
|
||||
// Database
|
||||
'db', 'sqlite', 'sqlite3', 'mdb', 'accdb',
|
||||
// Object files
|
||||
'o', 'obj', 'pyc', 'pyo', 'class', 'beam',
|
||||
// Other binary
|
||||
'swf', 'fla', 'blend', 'unity3d', 'unitypackage',
|
||||
]);
|
||||
|
||||
// MIME types for images (for creating blob URLs)
|
||||
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
jfif: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
bmp: 'image/bmp',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
ico: 'image/x-icon',
|
||||
tiff: 'image/tiff',
|
||||
tif: 'image/tiff',
|
||||
heic: 'image/heic',
|
||||
heif: 'image/heif',
|
||||
avif: 'image/avif',
|
||||
};
|
||||
|
||||
// Language IDs for syntax highlighting
|
||||
const EXTENSION_TO_LANGUAGE: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
jsx: 'javascript',
|
||||
mjs: 'javascript',
|
||||
cjs: 'javascript',
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
py: 'python',
|
||||
pyw: 'python',
|
||||
pyi: 'python',
|
||||
sh: 'shell',
|
||||
bash: 'shell',
|
||||
zsh: 'shell',
|
||||
fish: 'shell',
|
||||
bat: 'batch',
|
||||
cmd: 'batch',
|
||||
ps1: 'powershell',
|
||||
psm1: 'powershell',
|
||||
c: 'c',
|
||||
cpp: 'cpp',
|
||||
h: 'c',
|
||||
hpp: 'cpp',
|
||||
cc: 'cpp',
|
||||
cxx: 'cpp',
|
||||
java: 'java',
|
||||
kt: 'kotlin',
|
||||
kts: 'kotlin',
|
||||
go: 'go',
|
||||
rs: 'rust',
|
||||
rb: 'ruby',
|
||||
php: 'php',
|
||||
pl: 'perl',
|
||||
lua: 'lua',
|
||||
r: 'r',
|
||||
R: 'r',
|
||||
swift: 'swift',
|
||||
dart: 'dart',
|
||||
cs: 'csharp',
|
||||
fs: 'fsharp',
|
||||
vb: 'vb',
|
||||
html: 'html',
|
||||
htm: 'html',
|
||||
xhtml: 'html',
|
||||
css: 'css',
|
||||
scss: 'scss',
|
||||
sass: 'sass',
|
||||
less: 'less',
|
||||
json: 'json',
|
||||
jsonc: 'jsonc',
|
||||
json5: 'json5',
|
||||
xml: 'xml',
|
||||
xsl: 'xml',
|
||||
xslt: 'xml',
|
||||
yml: 'yaml',
|
||||
yaml: 'yaml',
|
||||
toml: 'toml',
|
||||
ini: 'ini',
|
||||
conf: 'ini',
|
||||
cfg: 'ini',
|
||||
sql: 'sql',
|
||||
graphql: 'graphql',
|
||||
gql: 'graphql',
|
||||
md: 'markdown',
|
||||
markdown: 'markdown',
|
||||
mdx: 'markdown',
|
||||
txt: 'plaintext',
|
||||
log: 'plaintext',
|
||||
vue: 'vue',
|
||||
svelte: 'svelte',
|
||||
dockerfile: 'dockerfile',
|
||||
makefile: 'makefile',
|
||||
diff: 'diff',
|
||||
patch: 'diff',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the file extension from a filename
|
||||
* For files without extension, returns 'file'
|
||||
*/
|
||||
export function getFileExtension(fileName: string): string {
|
||||
const lastDot = fileName.lastIndexOf('.');
|
||||
if (lastDot === -1 || lastDot === 0) {
|
||||
return 'file'; // No extension or hidden file without extension
|
||||
}
|
||||
return fileName.slice(lastDot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a text file based on its extension and name
|
||||
*/
|
||||
export function isTextFile(fileName: string): boolean {
|
||||
const ext = getFileExtension(fileName);
|
||||
|
||||
// Check known text extensions
|
||||
if (TEXT_EXTENSIONS.has(ext)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check common filenames that are text but have no extension
|
||||
const baseName = fileName.toLowerCase().split('/').pop() || '';
|
||||
const nameWithoutExt = baseName.replace(/\.[^.]+$/, '');
|
||||
|
||||
// Check exact filename matches
|
||||
if (TEXT_FILENAMES.has(baseName) || TEXT_FILENAMES.has(nameWithoutExt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check dot-files that are typically text config files
|
||||
if (baseName.startsWith('.')) {
|
||||
const dotConfigPatterns = [
|
||||
/^\.(git|npm|yarn|docker|eslint|prettier|babel|env)/,
|
||||
/^\.(nvmrc|ruby-version|python-version|node-version)$/,
|
||||
/rc$/, // Files ending with 'rc' like .bashrc, .vimrc
|
||||
];
|
||||
if (dotConfigPatterns.some(pattern => pattern.test(baseName))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if binary data appears to be text by analyzing byte patterns
|
||||
* This provides a more accurate detection than extension-only checking
|
||||
*
|
||||
* @param data - First chunk of file data (ArrayBuffer or Uint8Array)
|
||||
* @param maxBytes - Maximum bytes to check (default 512)
|
||||
* @returns true if data appears to be text
|
||||
*/
|
||||
export function isTextData(data: ArrayBuffer | Uint8Array, maxBytes: number = 512): boolean {
|
||||
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
||||
const checkLength = Math.min(bytes.length, maxBytes);
|
||||
|
||||
if (checkLength === 0) return true; // Empty file is considered text
|
||||
|
||||
let controlChars = 0;
|
||||
let nullBytes = 0;
|
||||
let highBytes = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
for (let i = 0; i < checkLength; i++) {
|
||||
const byte = bytes[i];
|
||||
totalBytes++;
|
||||
|
||||
// Null bytes are strong indicators of binary files
|
||||
if (byte === 0) {
|
||||
nullBytes++;
|
||||
if (nullBytes > 0) return false; // Even one null byte suggests binary
|
||||
}
|
||||
|
||||
// Control characters (except common ones like \t, \n, \r)
|
||||
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
|
||||
controlChars++;
|
||||
}
|
||||
|
||||
// High-bit characters (non-ASCII) - some are OK for UTF-8
|
||||
if (byte > 127) {
|
||||
highBytes++;
|
||||
}
|
||||
}
|
||||
|
||||
// If more than 30% are control chars or more than 95% are high-bit chars, likely binary
|
||||
const controlRatio = controlChars / totalBytes;
|
||||
const highRatio = highBytes / totalBytes;
|
||||
|
||||
if (controlRatio > 0.3) return false;
|
||||
if (highRatio > 0.95) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced text file detection combining extension and content analysis
|
||||
* Use this when you have access to file data for better accuracy
|
||||
*/
|
||||
export function isTextFileEnhanced(fileName: string, data?: ArrayBuffer | Uint8Array): boolean {
|
||||
// First check by extension
|
||||
const extCheck = isTextFile(fileName);
|
||||
|
||||
// If we have data, verify it's actually text
|
||||
if (data && data.byteLength > 0) {
|
||||
return extCheck && isTextData(data);
|
||||
}
|
||||
|
||||
// Fall back to extension-only check
|
||||
return extCheck;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is definitely a binary file based on its extension.
|
||||
* Used to exclude files from "Edit" option in context menu.
|
||||
*/
|
||||
export function isKnownBinaryFile(fileName: string): boolean {
|
||||
const ext = getFileExtension(fileName);
|
||||
return BINARY_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file could potentially be opened as text.
|
||||
* This is more permissive than isTextFile - it returns true for any file
|
||||
* that is not a known binary file. Used for showing "Edit" in context menu.
|
||||
* Actual text detection should be done by reading file content.
|
||||
*/
|
||||
export function couldBeTextFile(fileName: string): boolean {
|
||||
// If it's a known binary file, definitely not text
|
||||
if (isKnownBinaryFile(fileName)) {
|
||||
return false;
|
||||
}
|
||||
// Otherwise, it could be text - we'll verify when actually opening
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is an image file based on its extension
|
||||
*/
|
||||
export function isImageFile(fileName: string): boolean {
|
||||
const ext = getFileExtension(fileName);
|
||||
return IMAGE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for an image file
|
||||
*/
|
||||
export function getImageMimeType(fileName: string): string {
|
||||
const ext = getFileExtension(fileName);
|
||||
return IMAGE_MIME_TYPES[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language ID for syntax highlighting
|
||||
*/
|
||||
export function getLanguageId(fileName: string): string {
|
||||
const ext = getFileExtension(fileName);
|
||||
return EXTENSION_TO_LANGUAGE[ext] || 'plaintext';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly name for a language
|
||||
*/
|
||||
export function getLanguageName(languageId: string): string {
|
||||
const names: Record<string, string> = {
|
||||
javascript: 'JavaScript',
|
||||
typescript: 'TypeScript',
|
||||
python: 'Python',
|
||||
shell: 'Shell',
|
||||
batch: 'Batch',
|
||||
powershell: 'PowerShell',
|
||||
c: 'C',
|
||||
cpp: 'C++',
|
||||
java: 'Java',
|
||||
kotlin: 'Kotlin',
|
||||
go: 'Go',
|
||||
rust: 'Rust',
|
||||
ruby: 'Ruby',
|
||||
php: 'PHP',
|
||||
perl: 'Perl',
|
||||
lua: 'Lua',
|
||||
r: 'R',
|
||||
swift: 'Swift',
|
||||
dart: 'Dart',
|
||||
csharp: 'C#',
|
||||
fsharp: 'F#',
|
||||
vb: 'Visual Basic',
|
||||
html: 'HTML',
|
||||
css: 'CSS',
|
||||
scss: 'SCSS',
|
||||
sass: 'Sass',
|
||||
less: 'Less',
|
||||
json: 'JSON',
|
||||
jsonc: 'JSON with Comments',
|
||||
json5: 'JSON5',
|
||||
xml: 'XML',
|
||||
yaml: 'YAML',
|
||||
toml: 'TOML',
|
||||
ini: 'INI',
|
||||
sql: 'SQL',
|
||||
graphql: 'GraphQL',
|
||||
markdown: 'Markdown',
|
||||
plaintext: 'Plain Text',
|
||||
vue: 'Vue',
|
||||
svelte: 'Svelte',
|
||||
dockerfile: 'Dockerfile',
|
||||
makefile: 'Makefile',
|
||||
diff: 'Diff',
|
||||
};
|
||||
return names[languageId] || languageId.charAt(0).toUpperCase() + languageId.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* File opener application types
|
||||
* - 'builtin-editor': Built-in text editor (Monaco)
|
||||
* - 'system-app': External system application (stores path)
|
||||
*/
|
||||
export type FileOpenerType = 'builtin-editor' | 'system-app';
|
||||
|
||||
/**
|
||||
* System application info for file associations
|
||||
*/
|
||||
export interface SystemAppInfo {
|
||||
path: string; // Path to the executable/app
|
||||
name: string; // Display name
|
||||
}
|
||||
|
||||
/**
|
||||
* File association record
|
||||
*/
|
||||
export interface FileAssociation {
|
||||
extension: string;
|
||||
openerType: FileOpenerType;
|
||||
systemApp?: SystemAppInfo; // Only set when openerType is 'system-app'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all supported language IDs for syntax highlighting dropdown
|
||||
*/
|
||||
export function getSupportedLanguages(): { id: string; name: string }[] {
|
||||
const languageIds = new Set(Object.values(EXTENSION_TO_LANGUAGE));
|
||||
languageIds.add('plaintext');
|
||||
|
||||
return Array.from(languageIds)
|
||||
.map(id => ({ id, name: getLanguageName(id) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
90
lib/useRenderTracker.ts
Normal file
90
lib/useRenderTracker.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useRef } from "react";
|
||||
import { logger } from "./logger";
|
||||
|
||||
/**
|
||||
* 追踪组件渲染次数和原因
|
||||
* 在开发环境下帮助识别不必要的重渲染
|
||||
*
|
||||
* @param componentName 组件名称
|
||||
* @param props 当前 props(用于比较变化)
|
||||
* @param enabled 是否启用追踪,默认 true
|
||||
*/
|
||||
export function useRenderTracker(
|
||||
componentName: string,
|
||||
props: Record<string, unknown>,
|
||||
enabled: boolean = true
|
||||
): void {
|
||||
const renderCountRef = useRef(0);
|
||||
const prevPropsRef = useRef<Record<string, unknown>>({});
|
||||
|
||||
renderCountRef.current += 1;
|
||||
|
||||
if (!enabled) return;
|
||||
|
||||
const renderCount = renderCountRef.current;
|
||||
const prevProps = prevPropsRef.current;
|
||||
|
||||
// 找出变化的 props
|
||||
const changedProps: string[] = [];
|
||||
const allKeys = new Set([...Object.keys(props), ...Object.keys(prevProps)]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (prevProps[key] !== props[key]) {
|
||||
changedProps.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 只在有变化时打印(减少日志噪音)
|
||||
if (renderCount === 1) {
|
||||
logger.info(`[Render] ${componentName} - 首次渲染`);
|
||||
} else if (changedProps.length > 0) {
|
||||
logger.info(`[Render] ${componentName} - 第${renderCount}次渲染`, {
|
||||
changedProps,
|
||||
details: changedProps.reduce((acc, key) => {
|
||||
acc[key] = {
|
||||
prev: summarizeValue(prevProps[key]),
|
||||
curr: summarizeValue(props[key]),
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, { prev: string; curr: string }>),
|
||||
});
|
||||
}
|
||||
// 不再打印 "props未变化" 的警告 - 这是正常的 React 行为
|
||||
|
||||
// 更新 prevProps
|
||||
prevPropsRef.current = { ...props };
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化值的显示,避免日志过长
|
||||
*/
|
||||
function summarizeValue(value: unknown): string {
|
||||
if (value === undefined) return "undefined";
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "function") return `fn:${value.name || "anonymous"}`;
|
||||
if (typeof value === "object") {
|
||||
if (Array.isArray(value)) return `Array(${value.length})`;
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length <= 3) {
|
||||
return `{${keys.join(", ")}}`;
|
||||
}
|
||||
return `Object(${keys.length} keys)`;
|
||||
}
|
||||
if (typeof value === "string" && value.length > 30) {
|
||||
return `"${value.slice(0, 30)}..."`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的渲染计数器,只记录渲染次数不做详细分析
|
||||
*/
|
||||
export function useRenderCount(componentName: string): number {
|
||||
const renderCountRef = useRef(0);
|
||||
renderCountRef.current += 1;
|
||||
|
||||
// 每次渲染都打印
|
||||
logger.info(`[Render] ${componentName} - 第${renderCountRef.current}次渲染`);
|
||||
|
||||
return renderCountRef.current;
|
||||
}
|
||||
4043
package-lock.json
generated
4043
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "netcatty",
|
||||
"description": "Netcatty is a modern SSH manager and terminal app with host grouping, SFTP, keychain, port forwarding, and a rich UI.",
|
||||
"homepage": "https://github.com/binaricat/Netcatty",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"author": "binaricat",
|
||||
"author": "binaricat <support@netcatty.com>",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "npm run lint && concurrently -k \"vite\" \"npm:dev:electron\"",
|
||||
"dev:electron": "wait-on http-get://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 node electron/launch.cjs",
|
||||
"prebuild": "node scripts/copy-monaco.cjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node electron/launch.cjs",
|
||||
@@ -28,6 +30,7 @@
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/space-grotesk": "^5.2.10",
|
||||
"@google/genai": "1.33.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-context-menu": "2.2.16",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
@@ -44,9 +47,11 @@
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"clsx": "2.1.1",
|
||||
"lucide-react": "0.560.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"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 |
16
scripts/copy-monaco.cjs
Normal file
16
scripts/copy-monaco.cjs
Normal file
@@ -0,0 +1,16 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const source = path.join(repoRoot, 'node_modules', 'monaco-editor', 'min', 'vs');
|
||||
const target = path.join(repoRoot, 'public', 'monaco', 'vs');
|
||||
|
||||
if (!fs.existsSync(source)) {
|
||||
console.error('[copy-monaco] Source not found:', source);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.cpSync(source, target, { recursive: true });
|
||||
console.log('[copy-monaco] Copied Monaco VS assets to', target);
|
||||
162
to-do.md
Normal file
162
to-do.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Netcatty Feature TODO List
|
||||
|
||||
项目地址: https://github.com/binaricat/Netcatty
|
||||
|
||||
## 功能需求清单
|
||||
|
||||
### 1. GB18030编码支持 🔤
|
||||
**优先级**: 高
|
||||
|
||||
**需求描述**:
|
||||
- 支持操作文件名为GB18030编码的文件
|
||||
- 实现动态编码切换,无需断开重连即可生效
|
||||
- 解决目前市面上工具需要重新连接才能应用编码设置的问题
|
||||
|
||||
**技术要点**:
|
||||
- SFTP文件列表的编码转换
|
||||
- 文件名编码自动检测/手动切换
|
||||
- 保持连接状态下的编码切换
|
||||
|
||||
---
|
||||
|
||||
### 2. SFTP的sudo提权支持 🔐
|
||||
**优先级**: 高
|
||||
|
||||
**需求描述**:
|
||||
- 普通用户通过SFTP操作文件时支持sudo提权
|
||||
- 两种可选实现方式:
|
||||
- **方式A (WinSCP式)**: 要求服务器端配置sudo免密码
|
||||
- **方式B (HexHub式)**: 使用保存的密码自动完成sudo鉴权 ⭐ 推荐
|
||||
|
||||
**技术要点**:
|
||||
- 研究HexHub的实现原理
|
||||
- 密码安全存储
|
||||
- sudo命令的SFTP封装
|
||||
- 权限提升的UI交互设计
|
||||
|
||||
---
|
||||
|
||||
### 3. trzsz协议支持 📁
|
||||
**优先级**: 中
|
||||
|
||||
**需求描述**:
|
||||
- 集成trzsz文件传输协议
|
||||
- 参考项目: https://github.com/trzsz/trzsz
|
||||
- 解决electerm和tabby现有实现中的稳定性问题
|
||||
|
||||
**已知问题**:
|
||||
- electerm和tabby支持trzsz但偶尔无法正常收发文件
|
||||
- 具体bug现象待补充
|
||||
|
||||
**技术要点**:
|
||||
- trzsz协议完整实现
|
||||
- 文件传输的错误处理和重试机制
|
||||
- 传输进度显示
|
||||
- 大文件传输稳定性测试
|
||||
|
||||
---
|
||||
|
||||
### 4. 终端性能优化 ⚡
|
||||
**优先级**: 高
|
||||
|
||||
**需求描述**:
|
||||
- 解决基于xtermjs的终端在大量滚屏时的性能问题
|
||||
- 确保高速输出场景下键盘输入的实时响应
|
||||
|
||||
**核心问题**:
|
||||
- 大量刷屏时`Ctrl+C`信号发不出去
|
||||
- tmux切换窗口命令无响应
|
||||
- 输入延迟严重
|
||||
|
||||
**技术要点**:
|
||||
- 终端渲染性能优化
|
||||
- 输入处理与渲染分离
|
||||
- 虚拟滚动/缓冲区管理
|
||||
- 输入队列优先级处理
|
||||
- 压力测试场景设计
|
||||
|
||||
---
|
||||
|
||||
### 5. X11 Forwarding支持 🖥️
|
||||
**优先级**: 中
|
||||
|
||||
**需求描述**:
|
||||
- 支持X11图形界面转发
|
||||
- 能够在SSH连接中运行远程图形应用程序
|
||||
|
||||
**技术要点**:
|
||||
- X11转发的SSH配置
|
||||
- 本地X Server集成或推荐
|
||||
- 跨平台兼容性(Windows/macOS/Linux)
|
||||
- 连接配置UI
|
||||
|
||||
---
|
||||
|
||||
### 6. Terminal到SFTP目录定位 🎯
|
||||
**优先级**: 中
|
||||
|
||||
**需求描述**:
|
||||
- 在Terminal界面时,点击右上角按钮
|
||||
- 自动切换到SFTP视图并定位到当前工作目录
|
||||
- 实现Terminal和SFTP之间的上下文联动
|
||||
|
||||
**已知问题**:
|
||||
- 之前尝试实现但未成功
|
||||
|
||||
**技术要点**:
|
||||
- 获取当前shell的工作目录(`pwd`命令)
|
||||
- Terminal和SFTP视图的状态同步
|
||||
- 异步目录切换的UI反馈
|
||||
- 处理特殊路径(软链接、权限不足等)
|
||||
|
||||
**实现思路**:
|
||||
1. 通过发送`pwd`命令获取当前目录
|
||||
2. 解析命令输出结果
|
||||
3. 触发SFTP视图切换
|
||||
4. 异步加载目标目录内容
|
||||
|
||||
---
|
||||
|
||||
## 开发注意事项 ⚠️
|
||||
|
||||
### 质量要求
|
||||
- 充分的单元测试和集成测试
|
||||
- 避免"按下葫芦起了瓢"的问题
|
||||
- 每个功能都要有完整的测试用例
|
||||
|
||||
### 性能考虑
|
||||
- 避免频繁的AI token消耗
|
||||
- 代码review和人工测试相结合
|
||||
- 建立性能基准测试
|
||||
|
||||
### 用户体验
|
||||
- 这些都是"可以没有但有了方便很多"的功能
|
||||
- 注重细节和边界情况处理
|
||||
- 提供清晰的错误提示和操作引导
|
||||
|
||||
---
|
||||
|
||||
## 实现优先级建议
|
||||
|
||||
### Phase 1 - 核心功能完善
|
||||
- [ ] GB18030编码支持
|
||||
- [ ] 终端性能优化
|
||||
- [ ] Terminal到SFTP目录定位
|
||||
|
||||
### Phase 2 - 高级特性
|
||||
- [ ] SFTP的sudo提权支持
|
||||
- [ ] trzsz协议支持
|
||||
|
||||
### Phase 3 - 扩展功能
|
||||
- [ ] X11 Forwarding支持
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
- trzsz项目: https://github.com/trzsz/trzsz
|
||||
- 竞品分析: WinSCP, HexHub, electerm, tabby
|
||||
- 技术栈: xtermjs (需要性能优化方案)
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-01-09
|
||||
@@ -3,6 +3,21 @@ import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Custom plugin to suppress monaco-editor source map warnings
|
||||
const suppressMonacoSourcemapWarning = () => ({
|
||||
name: 'suppress-monaco-sourcemap-warning',
|
||||
apply: 'serve' as const,
|
||||
configResolved(config: { logger: { warn: (msg: string, options?: { timestamp?: boolean }) => void } }) {
|
||||
const originalWarn = config.logger.warn;
|
||||
config.logger.warn = (msg: string, options?: { timestamp?: boolean }) => {
|
||||
// Suppress monaco-editor source map warnings
|
||||
if (msg.includes('monaco-editor') && msg.includes('source map')) return;
|
||||
if (msg.includes('loader.js.map')) return;
|
||||
originalWarn(msg, options);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
base: "./",
|
||||
@@ -16,10 +31,14 @@ export default defineConfig(() => {
|
||||
// while still enabling crossOriginIsolated.
|
||||
'Cross-Origin-Embedder-Policy': 'credentialless',
|
||||
},
|
||||
hmr: {
|
||||
overlay: true,
|
||||
},
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 3000,
|
||||
target: 'esnext', // Required for top-level await in WASM modules
|
||||
sourcemap: false, // Disable source maps to avoid missing map file warnings
|
||||
// Optimize chunk splitting for faster initial load
|
||||
rollupOptions: {
|
||||
output: {
|
||||
@@ -48,7 +67,7 @@ export default defineConfig(() => {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [tailwindcss(), react()],
|
||||
plugins: [suppressMonacoSourcemapWarning(), tailwindcss(), react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
|
||||
Reference in New Issue
Block a user