Compare commits
252 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dbda5bec3 | ||
|
|
2da63c0180 | ||
|
|
2af9cfccb3 | ||
|
|
ffb736eeea | ||
|
|
83cd65ef63 | ||
|
|
e46046081a | ||
|
|
7f75fadb31 | ||
|
|
2997ed6b3c | ||
|
|
2b03db1142 | ||
|
|
513309ba7c | ||
|
|
5918f91132 | ||
|
|
7347b04461 | ||
|
|
d8990dd4b1 | ||
|
|
538dd71084 | ||
|
|
c43f485bee | ||
|
|
839cce58ac | ||
|
|
1324bf95cb | ||
|
|
c668525d17 | ||
|
|
a21970a278 | ||
|
|
c07fd505d3 | ||
|
|
3bb47243ce | ||
|
|
d2483c5863 | ||
|
|
e2f7788c13 | ||
|
|
2e417e1dd5 | ||
|
|
b233e9609f | ||
|
|
f754378bea | ||
|
|
72e79bdc9a | ||
|
|
5d25bda560 | ||
|
|
5baff1ee63 | ||
|
|
1d14f1b0ba | ||
|
|
3f2c3e15d6 | ||
|
|
395361b559 | ||
|
|
918d58862e | ||
|
|
fea1ebf274 | ||
|
|
a56ade35a3 | ||
|
|
1b0cb918d8 | ||
|
|
869d30d4dd | ||
|
|
87388b93d9 | ||
|
|
15a269e5d4 | ||
|
|
cf6b33a3eb | ||
|
|
dfa9b109c2 | ||
|
|
55b55d77c9 | ||
|
|
4dccc11041 | ||
|
|
188e6c860a | ||
|
|
f454c56192 | ||
|
|
4480e5dc8d | ||
|
|
8426da1596 | ||
|
|
c472eaada2 | ||
|
|
71433252a1 | ||
|
|
ca42787808 | ||
|
|
c13c330747 | ||
|
|
a27b99cbf7 | ||
|
|
3d6e981758 | ||
|
|
e6d8c1381c | ||
|
|
bc3d73c683 | ||
|
|
dd5f3ddffd | ||
|
|
3959328e24 | ||
|
|
48928254fa | ||
|
|
30962c992f | ||
|
|
02e0fae051 | ||
|
|
6a94716880 | ||
|
|
2fecbb94fb | ||
|
|
6bd3968e04 | ||
|
|
528cda1f70 | ||
|
|
29bde31989 | ||
|
|
b12a2171e7 | ||
|
|
ffcd94e216 | ||
|
|
9b77fc9e3b | ||
|
|
9e57f2eb90 | ||
|
|
8cf6e9243d | ||
|
|
7a32aa0743 | ||
|
|
a1d0ce02fe | ||
|
|
adb2bc9403 | ||
|
|
7a6ed660fb | ||
|
|
035b22b467 | ||
|
|
1bce2c9808 | ||
|
|
ca2d699e55 | ||
|
|
6907fb54c8 | ||
|
|
4bae2517fe | ||
|
|
da4936ff22 | ||
|
|
2223ec34f0 | ||
|
|
ca1423051d | ||
|
|
ca8b36c7d5 | ||
|
|
b96eaf2aca | ||
|
|
663fe88b2e | ||
|
|
42da477425 | ||
|
|
474a13e4f9 | ||
|
|
3c5e12cc8b | ||
|
|
c2a01d83d7 | ||
|
|
dcc3b6fce7 | ||
|
|
fb43b53f33 | ||
|
|
b90c29f56a | ||
|
|
37092826f3 | ||
|
|
30b809a8f6 | ||
|
|
989a1aa3d7 | ||
|
|
9e5c5f826f | ||
|
|
74e0249797 | ||
|
|
d89d6d3959 | ||
|
|
66680d585f | ||
|
|
57dd2fb48b | ||
|
|
6d973f9bc8 | ||
|
|
425647eeda | ||
|
|
9109aec4ab | ||
|
|
6d5283173a | ||
|
|
ad67099ff3 | ||
|
|
02d44652df | ||
|
|
d227424096 | ||
|
|
1105f7fbb1 | ||
|
|
ef681194e3 | ||
|
|
4971a72620 | ||
|
|
8947d29717 | ||
|
|
dfaeed1ed6 | ||
|
|
443e038dcf | ||
|
|
242d35927a | ||
|
|
708ee1cd09 | ||
|
|
a2c24c2656 | ||
|
|
d91ed8dd23 | ||
|
|
689bb313f7 | ||
|
|
4ff05f7dbb | ||
|
|
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 |
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx tsc:*)"
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(npm run build:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -22,6 +22,7 @@ coverage
|
||||
/release
|
||||
/out
|
||||
*.asar
|
||||
/public/monaco
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
111
App.tsx
@@ -1,11 +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';
|
||||
@@ -17,12 +19,16 @@ import { Input } from './components/ui/input';
|
||||
import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { cn } from './lib/utils';
|
||||
import { ConnectionLog, Host, HostProtocol, TerminalTheme } from './types';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
|
||||
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();
|
||||
@@ -145,6 +151,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
|
||||
// Navigation state for VaultView sections
|
||||
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
|
||||
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
|
||||
const {
|
||||
theme,
|
||||
@@ -208,6 +216,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
submitWorkspaceRename,
|
||||
resetWorkspaceRename,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
@@ -279,6 +288,55 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}, [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 })),
|
||||
});
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onKeyboardInteractive) return;
|
||||
|
||||
const unsubscribe = bridge.onKeyboardInteractive((request) => {
|
||||
console.log('[App] Keyboard-interactive request received:', request);
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
name: request.name,
|
||||
instructions: request.instructions,
|
||||
prompts: request.prompts,
|
||||
hostname: request.hostname,
|
||||
savedPassword: request.savedPassword,
|
||||
}]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle keyboard-interactive submit
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondKeyboardInteractive) {
|
||||
void bridge.respondKeyboardInteractive(requestId, responses, false);
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle keyboard-interactive cancel
|
||||
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondKeyboardInteractive) {
|
||||
void bridge.respondKeyboardInteractive(requestId, [], true);
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Debounce ref for moveFocus to prevent double-triggering when focus switches
|
||||
const lastMoveFocusTimeRef = useRef<number>(0);
|
||||
const MOVE_FOCUS_DEBOUNCE_MS = 200;
|
||||
@@ -607,6 +665,25 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => {
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
|
||||
// Handle serial hosts separately
|
||||
if (host.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
addConnectionLog({
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: host.hostname,
|
||||
username: username,
|
||||
protocol: 'serial',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
connectToHost(host);
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
addConnectionLog({
|
||||
@@ -623,6 +700,24 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
connectToHost(host);
|
||||
}, [addConnectionLog, connectToHost, identities, keys]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const portName = config.path.split('/').pop() || config.path;
|
||||
addConnectionLog({
|
||||
hostId: '',
|
||||
hostLabel: `Serial: ${portName}`,
|
||||
hostname: config.path,
|
||||
username: username,
|
||||
protocol: 'serial',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: hostname,
|
||||
saved: false,
|
||||
});
|
||||
createSerialSession(config);
|
||||
}, [addConnectionLog, createSerialSession]);
|
||||
|
||||
// Handle terminal data capture when session exits
|
||||
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
|
||||
@@ -764,6 +859,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
onConnectSerial={handleConnectSerial}
|
||||
onDeleteHost={handleDeleteHost}
|
||||
onConnect={handleConnectToHost}
|
||||
onUpdateHosts={updateHosts}
|
||||
@@ -939,6 +1035,19 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) - processes queue */}
|
||||
<KeyboardInteractiveModal
|
||||
request={keyboardInteractiveQueue[0] || null}
|
||||
onSubmit={handleKeyboardInteractiveSubmit}
|
||||
onCancel={handleKeyboardInteractiveCancel}
|
||||
/>
|
||||
{/* Indicator when more 2FA requests are pending */}
|
||||
{keyboardInteractiveQueue.length > 1 && (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-muted/90 backdrop-blur-sm text-sm px-3 py-1.5 rounded-full border shadow-sm">
|
||||
{keyboardInteractiveQueue.length - 1} more pending
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<h1 align="center">Netcatty</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong>
|
||||
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong><br/>
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -22,16 +23,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
|
||||
<img src="https://img.shields.io/badge/ダウンロード-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="macOS ARM64 をダウンロード">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
|
||||
<img src="https://img.shields.io/badge/ダウンロード-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="macOS Intel をダウンロード">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
|
||||
<img src="https://img.shields.io/badge/ダウンロード-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="Windows をダウンロード">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=最新版をダウンロード&color=success" alt="最新版をダウンロード">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -47,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
[](screenshots/vault_grid_view.png)
|
||||
|
||||
---
|
||||
|
||||
@@ -146,15 +139,15 @@ Vault ビューはすべての SSH 接続を管理するコマンドセンター
|
||||
|
||||
**ダークモード**
|
||||
|
||||

|
||||

|
||||
|
||||
**ライトモード**
|
||||
**ネストされたフォルダと整理**
|
||||
|
||||

|
||||

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

|
||||

|
||||
|
||||
<a name="ターミナル"></a>
|
||||
## ターミナル
|
||||
@@ -163,18 +156,28 @@ WebGL アクセラレーション対応の xterm.js ベースのターミナル
|
||||
|
||||
**分割ウィンドウ**
|
||||
|
||||

|
||||
**ブロードキャストモード**
|
||||
|
||||
**テーマカスタマイズ**
|
||||
一度入力すれば、どこでも実行できます。複数のサーバーを同時にメンテナンスするのに最適です。
|
||||
|
||||

|
||||

|
||||
|
||||
**パフォーマンス情報とカスタマイズ**
|
||||
|
||||
接続の健全性を監視し、ターミナルのあらゆる側面をカスタマイズします。
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
|
||||
デュアルペイン SFTP ブラウザは、ローカルからリモート、リモートからリモートへのファイル転送をサポート。シングルクリックでディレクトリを移動、ペイン間でファイルをドラッグ&ドロップ、転送進捗をリアルタイムで監視。インターフェースにはファイル権限、サイズ、変更日時を表示。複数の転送をキューに入れ、詳細な速度と進捗インジケーターで完了を確認。コンテキストメニューから名前変更、削除、ダウンロード、アップロード操作にすばやくアクセス。
|
||||
|
||||

|
||||

|
||||
|
||||
**転送キュー**
|
||||
|
||||

|
||||
|
||||
<a name="キーチェーン"></a>
|
||||
## キーチェーン
|
||||
@@ -196,6 +199,10 @@ WebGL アクセラレーション対応の xterm.js ベースのターミナル
|
||||
|
||||

|
||||
|
||||
**キー生成**
|
||||
|
||||

|
||||
|
||||
<a name="ポートフォワーディング"></a>
|
||||
## ポートフォワーディング
|
||||
|
||||
@@ -269,11 +276,13 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
|
||||
|
||||
### ダウンロード
|
||||
|
||||
| プラットフォーム | アーキテクチャ | ダウンロード |
|
||||
|------------------|----------------|--------------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
|
||||
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
|
||||
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
|
||||
[GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) からお使いのプラットフォームに対応した最新版をダウンロードしてください。
|
||||
|
||||
| プラットフォーム | アーキテクチャ | ステータス |
|
||||
|------------------|----------------|------------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ サポート |
|
||||
| **macOS** | Intel | ✅ サポート |
|
||||
| **Windows** | x64 | ✅ サポート |
|
||||
|
||||
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
|
||||
|
||||
@@ -371,6 +380,17 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
---
|
||||
|
||||
<a name="コントリビューター"></a>
|
||||
# コントリビューター
|
||||
|
||||
貢献してくれたすべての人々に感謝します!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<a name="ライセンス"></a>
|
||||
# ライセンス
|
||||
|
||||
|
||||
77
README.md
@@ -5,7 +5,8 @@
|
||||
<h1 align="center">Netcatty</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong>
|
||||
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong><br/>
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -16,22 +17,14 @@
|
||||
<p align="center">
|
||||
<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/download/Netcatty-1.0.0-mac-arm64.dmg">
|
||||
<img src="https://img.shields.io/badge/Download-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="Download macOS ARM64">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
|
||||
<img src="https://img.shields.io/badge/Download-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="Download macOS Intel">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
|
||||
<img src="https://img.shields.io/badge/Download-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="Download Windows">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Download%20Latest&color=success" alt="Download Latest Release">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -47,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
[](screenshots/vault_grid_view.png)
|
||||
|
||||
---
|
||||
|
||||
@@ -75,7 +68,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
|
||||
@@ -146,15 +139,15 @@ The Vault view is your command center for managing all SSH connections. Create h
|
||||
|
||||
**Dark Mode**
|
||||
|
||||

|
||||

|
||||
|
||||
**Light Mode**
|
||||
**Nested Folders & Organization**
|
||||
|
||||

|
||||

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

|
||||

|
||||
|
||||
<a name="terminal"></a>
|
||||
## Terminal
|
||||
@@ -163,18 +156,28 @@ Powered by xterm.js with WebGL acceleration, the terminal delivers a smooth, res
|
||||
|
||||
**Split Windows**
|
||||
|
||||

|
||||
**Broadcast Mode**
|
||||
|
||||
**Theme Customization**
|
||||
Type once, execute everywhere. Great for maintaining multiple servers simultaneously.
|
||||
|
||||

|
||||

|
||||
|
||||
**Performance Info & Customization**
|
||||
|
||||
Monitor your connection health and customize every aspect of your terminal.
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
|
||||
The dual-pane SFTP browser supports local-to-remote and remote-to-remote file transfers. Navigate directories with single-click, drag files between panes, and monitor transfer progress in real-time. The interface shows file permissions, sizes, and modification dates. Queue multiple transfers and watch them complete with detailed speed and progress indicators. Context menus provide quick access to rename, delete, download, and upload operations.
|
||||
|
||||

|
||||

|
||||
|
||||
**Transfer Queue**
|
||||
|
||||

|
||||
|
||||
<a name="keychain"></a>
|
||||
## Keychain
|
||||
@@ -196,6 +199,10 @@ The Keychain is your secure vault for SSH credentials. Generate new keys, import
|
||||
|
||||

|
||||
|
||||
**Key Generator**
|
||||
|
||||

|
||||
|
||||
<a name="port-forwarding"></a>
|
||||
## Port Forwarding
|
||||
|
||||
@@ -269,11 +276,13 @@ Netcatty automatically detects and displays OS icons for connected hosts:
|
||||
|
||||
### Download
|
||||
|
||||
| Platform | Architecture | Download |
|
||||
|----------|--------------|----------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
|
||||
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
|
||||
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
|
||||
Download the latest release for your platform from [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest).
|
||||
|
||||
| Platform | Architecture | Status |
|
||||
|----------|--------------|--------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ Supported |
|
||||
| **macOS** | Intel | ✅ Supported |
|
||||
| **Windows** | x64 | ✅ Supported |
|
||||
|
||||
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
|
||||
|
||||
@@ -285,7 +294,7 @@ Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatt
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+ and npm
|
||||
- macOS or Windows 10+
|
||||
- macOS, Windows 10+, or Linux
|
||||
|
||||
### Development
|
||||
|
||||
@@ -335,6 +344,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)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -370,6 +380,17 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
|
||||
|
||||
---
|
||||
|
||||
<a name="contributors"></a>
|
||||
# Contributors
|
||||
|
||||
Thanks to all the people who contribute!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<a name="license"></a>
|
||||
# License
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<h1 align="center">Netcatty</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong>
|
||||
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong><br/>
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -22,16 +23,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
|
||||
<img src="https://img.shields.io/badge/下载-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="下载 macOS ARM64">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
|
||||
<img src="https://img.shields.io/badge/下载-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="下载 macOS Intel">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
|
||||
<img src="https://img.shields.io/badge/下载-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="下载 Windows">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=下载最新版&color=success" alt="下载最新版">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -47,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
[](screenshots/vault_grid_view.png)
|
||||
|
||||
---
|
||||
|
||||
@@ -146,15 +139,15 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
|
||||
|
||||
**深色模式**
|
||||
|
||||

|
||||

|
||||
|
||||
**浅色模式**
|
||||
**层级文件夹与分组**
|
||||
|
||||

|
||||

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

|
||||

|
||||
|
||||
<a name="终端"></a>
|
||||
## 终端
|
||||
@@ -163,18 +156,28 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
|
||||
|
||||
**分屏窗口**
|
||||
|
||||

|
||||
**广播模式**
|
||||
|
||||
**主题定制**
|
||||
一次输入,多处执行。非常适合同时维护这多台服务器。
|
||||
|
||||

|
||||

|
||||
|
||||
**性能信息与定制**
|
||||
|
||||
监控连接健康状况,并自定义终端的方方面面。
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
|
||||
双窗格 SFTP 浏览器支持本地到远程和远程到远程的文件传输。单击导航目录,在窗格之间拖放文件,实时监控传输进度。界面显示文件权限、大小和修改日期。批量传输队列管理,详细的速度和进度指示器。右键菜单快速访问重命名、删除、下载和上传操作。
|
||||
|
||||

|
||||

|
||||
|
||||
**传输队列**
|
||||
|
||||

|
||||
|
||||
<a name="密钥管理"></a>
|
||||
## 密钥管理
|
||||
@@ -196,6 +199,10 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
|
||||
|
||||

|
||||
|
||||
**密钥生成器**
|
||||
|
||||

|
||||
|
||||
<a name="端口转发"></a>
|
||||
## 端口转发
|
||||
|
||||
@@ -269,11 +276,13 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
|
||||
### 下载
|
||||
|
||||
| 平台 | 架构 | 下载链接 |
|
||||
|------|------|----------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
|
||||
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
|
||||
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
|
||||
从 [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) 下载适合您平台的最新版本。
|
||||
|
||||
| 平台 | 架构 | 状态 |
|
||||
|------|------|------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ 支持 |
|
||||
| **macOS** | Intel | ✅ 支持 |
|
||||
| **Windows** | x64 | ✅ 支持 |
|
||||
|
||||
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
|
||||
|
||||
@@ -371,6 +380,17 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
---
|
||||
|
||||
<a name="贡献者"></a>
|
||||
# 贡献者
|
||||
|
||||
感谢所有参与贡献的人!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<a name="开源协议"></a>
|
||||
# 开源协议
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -106,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',
|
||||
@@ -166,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',
|
||||
@@ -359,9 +394,14 @@ const en: Messages = {
|
||||
'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.newFile': 'New File',
|
||||
'sftp.filter': 'Filter',
|
||||
'sftp.filter.placeholder': 'Filter by filename...',
|
||||
'sftp.columns.name': 'Name',
|
||||
'sftp.columns.modified': 'Modified',
|
||||
'sftp.columns.size': 'Size',
|
||||
@@ -385,13 +425,17 @@ 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.fileName': 'File name',
|
||||
'sftp.fileName.placeholder': 'Enter file name',
|
||||
'sftp.prompt.newFolderName': 'New folder name?',
|
||||
'sftp.rename.title': 'Rename',
|
||||
'sftp.rename.newName': 'New name',
|
||||
@@ -404,6 +448,13 @@ const en: Messages = {
|
||||
'sftp.error.uploadFailed': 'Upload failed',
|
||||
'sftp.error.deleteFailed': 'Delete failed',
|
||||
'sftp.error.createFolderFailed': 'Failed to create folder',
|
||||
'sftp.error.createFileFailed': 'Failed to create file',
|
||||
'sftp.error.invalidFileName': 'Filename contains invalid characters: {chars}',
|
||||
'sftp.error.reservedName': 'This filename is reserved by the system',
|
||||
'sftp.overwrite.title': 'File Already Exists',
|
||||
'sftp.overwrite.desc': 'A file named "{name}" already exists. Do you want to replace it?',
|
||||
'sftp.overwrite.confirm': 'Replace',
|
||||
'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...',
|
||||
@@ -417,11 +468,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',
|
||||
@@ -434,6 +490,88 @@ 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}',
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': 'Uploading {current} of {total} files...',
|
||||
'sftp.upload.currentFile': 'Current: {fileName}',
|
||||
'sftp.upload.cancelled': 'Upload cancelled',
|
||||
'sftp.upload.cancel': 'Cancel',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': 'Reconnecting...',
|
||||
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
|
||||
'sftp.reconnected': 'Connection restored',
|
||||
'sftp.error.reconnectFailed': 'Failed to reconnect. Please try again.',
|
||||
'sftp.error.connectionLostManual': 'Connection lost. Please reconnect manually.',
|
||||
'sftp.error.connectionLostReconnecting': 'Connection lost. Reconnecting...',
|
||||
'sftp.error.sessionLost': 'SFTP session lost. Please reconnect.',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.',
|
||||
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.recentConnections': 'Recent connections',
|
||||
@@ -472,6 +610,10 @@ const en: Messages = {
|
||||
'hostDetails.section.address': 'Address',
|
||||
'hostDetails.hostname.placeholder': 'IP or Hostname',
|
||||
'hostDetails.section.general': 'General',
|
||||
'hostDetails.section.sftp': 'SFTP Settings',
|
||||
'hostDetails.sftp.sudo': 'Sudo Mode',
|
||||
'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
|
||||
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
|
||||
'hostDetails.group.placeholder': 'Parent Group',
|
||||
'hostDetails.section.credentials': 'Credentials',
|
||||
@@ -490,16 +632,20 @@ const en: Messages = {
|
||||
'hostDetails.keys.empty': 'No keys available',
|
||||
'hostDetails.certs.search': 'Search certificates...',
|
||||
'hostDetails.certs.empty': 'No certificates available',
|
||||
'hostDetails.agentForwarding': 'Agent Forwarding',
|
||||
'hostDetails.jumpHosts': 'Jump Hosts',
|
||||
'hostDetails.agentForwarding': 'Forward SSH Agent',
|
||||
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not running',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Direct',
|
||||
'hostDetails.jumpHosts.configure': 'Configure Jump Hosts',
|
||||
'hostDetails.proxy': 'Proxy',
|
||||
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
|
||||
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5',
|
||||
'hostDetails.proxy.none': 'None',
|
||||
'hostDetails.proxy.edit': 'Edit Proxy',
|
||||
'hostDetails.proxy.configure': 'Configure Proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
|
||||
'hostDetails.proxyPanel.credentials': 'Credentials',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
|
||||
@@ -540,6 +686,12 @@ const en: Messages = {
|
||||
'hostDetails.telnet.password': 'Telnet Password',
|
||||
'hostDetails.charset.placeholder': 'Charset (e.g. UTF-8)',
|
||||
'hostDetails.telnet.add': 'Add Telnet Protocol',
|
||||
'hostDetails.tags': 'Tags',
|
||||
'hostDetails.group': 'Group',
|
||||
'hostDetails.selectGroup': 'Select Group',
|
||||
'hostDetails.addTag': 'Add a tag...',
|
||||
'hostDetails.createTag': 'Create tag',
|
||||
'hostDetails.createGroup': 'Create group',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': 'Edit Host',
|
||||
@@ -943,6 +1095,61 @@ 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.stopBits15Warning': '1.5 stop bits may not be supported on all Windows devices',
|
||||
'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 or COM1',
|
||||
'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',
|
||||
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
|
||||
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
|
||||
'serial.field.customBaudRate': 'Using custom baud rate',
|
||||
'serial.field.saveConfig': 'Save Configuration',
|
||||
'serial.field.saveConfigDesc': 'Save this serial configuration to hosts for quick access',
|
||||
'serial.field.configLabel': 'Configuration Name',
|
||||
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
|
||||
'serial.connectAndSave': 'Connect & Save',
|
||||
'serial.edit.title': 'Serial Port Settings',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Authentication Required',
|
||||
'keyboard.interactive.desc': 'The server requires additional authentication.',
|
||||
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
|
||||
'keyboard.interactive.response': 'Response',
|
||||
'keyboard.interactive.enterCode': 'Enter verification code',
|
||||
'keyboard.interactive.enterResponse': 'Enter response',
|
||||
'keyboard.interactive.submit': 'Submit',
|
||||
'keyboard.interactive.verifying': 'Verifying...',
|
||||
'keyboard.interactive.fill': 'Fill',
|
||||
'keyboard.interactive.fillSaved': 'Fill with saved password',
|
||||
'keyboard.interactive.useSaved': 'Use saved',
|
||||
'keyboard.interactive.useSavedPassword': 'Use saved password',
|
||||
};
|
||||
|
||||
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': '检查更新',
|
||||
@@ -248,6 +264,9 @@ const zhCN: Messages = {
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': '新建文件夹',
|
||||
'sftp.newFile': '新建文件',
|
||||
'sftp.filter': '筛选',
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.columns.name': '名称',
|
||||
'sftp.columns.modified': '修改时间',
|
||||
'sftp.columns.size': '大小',
|
||||
@@ -271,13 +290,17 @@ 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.fileName': '文件名称',
|
||||
'sftp.fileName.placeholder': '输入文件名称',
|
||||
'sftp.prompt.newFolderName': '新建文件夹名称?',
|
||||
'sftp.rename.title': '重命名',
|
||||
'sftp.rename.newName': '新名称',
|
||||
@@ -290,6 +313,13 @@ const zhCN: Messages = {
|
||||
'sftp.error.uploadFailed': '上传失败',
|
||||
'sftp.error.deleteFailed': '删除失败',
|
||||
'sftp.error.createFolderFailed': '创建文件夹失败',
|
||||
'sftp.error.createFileFailed': '创建文件失败',
|
||||
'sftp.error.invalidFileName': '文件名包含非法字符:{chars}',
|
||||
'sftp.error.reservedName': '此文件名是系统保留名称',
|
||||
'sftp.overwrite.title': '文件已存在',
|
||||
'sftp.overwrite.desc': '名为"{name}"的文件已存在。是否要替换它?',
|
||||
'sftp.overwrite.confirm': '替换',
|
||||
'sftp.error.renameFailed': '重命名失败',
|
||||
'sftp.picker.title': '选择主机',
|
||||
'sftp.picker.desc': '为{side}窗格选择主机',
|
||||
'sftp.picker.searchPlaceholder': '搜索主机...',
|
||||
@@ -298,11 +328,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': '搜索主机或标签页',
|
||||
@@ -338,6 +370,10 @@ const zhCN: Messages = {
|
||||
'hostDetails.section.address': '地址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主机名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.section.sftp': 'SFTP 设置',
|
||||
'hostDetails.sftp.sudo': 'Sudo 提权模式',
|
||||
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密码。请在上方配置密码,或确保服务器允许免密 sudo。',
|
||||
'hostDetails.label.placeholder': '名称(例如:Production Server)',
|
||||
'hostDetails.group.placeholder': '父级 Group',
|
||||
'hostDetails.section.credentials': '凭据',
|
||||
@@ -356,12 +392,16 @@ const zhCN: Messages = {
|
||||
'hostDetails.keys.empty': '暂无密钥',
|
||||
'hostDetails.certs.search': '搜索证书…',
|
||||
'hostDetails.certs.empty': '暂无证书',
|
||||
'hostDetails.agentForwarding': '代理转发',
|
||||
'hostDetails.jumpHosts': '跳板主机',
|
||||
'hostDetails.agentForwarding': '转发 SSH 密钥',
|
||||
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直连',
|
||||
'hostDetails.jumpHosts.configure': '配置跳板主机',
|
||||
'hostDetails.proxy': '代理',
|
||||
'hostDetails.jumpHosts.configure': '配置代理主机',
|
||||
'hostDetails.proxy': '通过 HTTP/SOCKS5 代理',
|
||||
'hostDetails.proxy.none': '无',
|
||||
'hostDetails.proxy.edit': '编辑代理',
|
||||
'hostDetails.proxy.configure': '配置代理',
|
||||
@@ -378,6 +418,12 @@ const zhCN: Messages = {
|
||||
'hostDetails.telnet.password': 'Telnet 密码',
|
||||
'hostDetails.charset.placeholder': '字符集(例如 UTF-8)',
|
||||
'hostDetails.telnet.add': '添加 Telnet 协议',
|
||||
'hostDetails.tags': '标签',
|
||||
'hostDetails.group': '分组',
|
||||
'hostDetails.selectGroup': '选择分组',
|
||||
'hostDetails.addTag': '添加标签...',
|
||||
'hostDetails.createTag': '创建标签',
|
||||
'hostDetails.createGroup': '创建分组',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': '编辑主机',
|
||||
@@ -666,6 +712,8 @@ const zhCN: Messages = {
|
||||
'pf.deleteActive.title': '删除正在运行的端口转发?',
|
||||
'pf.deleteActive.desc': '端口转发规则 "{label}" 当前正在运行。删除前将先关闭转发连接。',
|
||||
'pf.deleteActive.confirm': '关闭并删除',
|
||||
'pf.form.autoStart': '自动启动',
|
||||
'pf.form.autoStartDesc': '应用启动时自动开启此规则',
|
||||
|
||||
// SFTP (pane + conflict)
|
||||
'sftp.pane.local': '本地',
|
||||
@@ -673,6 +721,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': '已存在',
|
||||
@@ -685,8 +736,94 @@ 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}',
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
|
||||
'sftp.upload.currentFile': '当前: {fileName}',
|
||||
'sftp.upload.cancelled': '上传已取消',
|
||||
'sftp.upload.cancel': '取消',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': '正在重连...',
|
||||
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
|
||||
'sftp.reconnected': '连接已恢复',
|
||||
'sftp.error.reconnectFailed': '重连失败,请重试。',
|
||||
'sftp.error.connectionLostManual': '连接已断开,请手动重新连接。',
|
||||
'sftp.error.connectionLostReconnecting': '连接已断开,正在重连...',
|
||||
'sftp.error.sessionLost': 'SFTP 会话已断开,请重新连接。',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性的文件。',
|
||||
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
|
||||
|
||||
// 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': '键盘',
|
||||
@@ -741,6 +878,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': '快捷键方案',
|
||||
@@ -932,6 +1084,61 @@ 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.stopBits15Warning': '1.5 停止位在 Windows 下可能不被所有设备支持',
|
||||
'serial.field.parity': '校验位',
|
||||
'serial.field.flowControl': '流控制',
|
||||
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
|
||||
'serial.field.customPort': '自定义串口路径',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
'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': '连接串口失败',
|
||||
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
|
||||
'serial.field.baudRateEmpty': '输入自定义波特率',
|
||||
'serial.field.customBaudRate': '使用自定义波特率',
|
||||
'serial.field.saveConfig': '保存配置',
|
||||
'serial.field.saveConfigDesc': '将此串口配置保存到主机列表以便快速访问',
|
||||
'serial.field.configLabel': '配置名称',
|
||||
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
|
||||
'serial.connectAndSave': '连接并保存',
|
||||
'serial.edit.title': '串口设置',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': '需要验证',
|
||||
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
|
||||
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
|
||||
'keyboard.interactive.response': '响应',
|
||||
'keyboard.interactive.enterCode': '输入验证码',
|
||||
'keyboard.interactive.enterResponse': '输入响应',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '验证中...',
|
||||
'keyboard.interactive.fill': '填入',
|
||||
'keyboard.interactive.fillSaved': '填入已保存的密码',
|
||||
'keyboard.interactive.useSaved': '使用已保存',
|
||||
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
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();
|
||||
};
|
||||
@@ -7,6 +7,12 @@ export type ApplicationInfo = {
|
||||
platform: string;
|
||||
};
|
||||
|
||||
export type SshAgentStatus = {
|
||||
running: boolean;
|
||||
startupType: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export const useApplicationBackend = () => {
|
||||
const openExternal = useCallback(async (url: string) => {
|
||||
try {
|
||||
@@ -27,6 +33,12 @@ export const useApplicationBackend = () => {
|
||||
return info ?? null;
|
||||
}, []);
|
||||
|
||||
return { openExternal, getApplicationInfo };
|
||||
const checkSshAgent = useCallback(async (): Promise<SshAgentStatus | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const status = await bridge?.checkSshAgent?.();
|
||||
return status ?? null;
|
||||
}, []);
|
||||
|
||||
return { openExternal, getApplicationInfo, checkSshAgent };
|
||||
};
|
||||
|
||||
|
||||
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,7 +53,56 @@ 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) => {
|
||||
// Handle serial hosts specially - use createSerialSession for them
|
||||
if (host.protocol === 'serial') {
|
||||
// Use stored serialConfig or construct from host data
|
||||
const serialConfig: SerialConfig = host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
flowControl: 'none',
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
};
|
||||
|
||||
const sessionId = crypto.randomUUID();
|
||||
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
||||
const newSession: TerminalSession = {
|
||||
id: sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: serialConfig.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const newSession: TerminalSession = {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
@@ -590,6 +639,7 @@ export const useSessionState = () => {
|
||||
submitWorkspaceRename,
|
||||
resetWorkspaceRename,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
|
||||
@@ -16,11 +16,15 @@ 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,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
} 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 +36,13 @@ 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 DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
|
||||
const readStoredString = (key: string): string | null => {
|
||||
const raw = localStorageAdapter.readString(key);
|
||||
@@ -97,13 +104,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 +154,25 @@ 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;
|
||||
});
|
||||
const [sftpShowHiddenFiles, setSftpShowHiddenFiles] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_SHOW_HIDDEN_FILES;
|
||||
});
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
@@ -371,11 +391,31 @@ 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);
|
||||
}
|
||||
}
|
||||
// Sync SFTP show hidden files setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpShowHiddenFiles) {
|
||||
setSftpShowHiddenFiles(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, sftpShowHiddenFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -415,7 +455,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 +466,24 @@ 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]);
|
||||
|
||||
// Persist SFTP show hidden files setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
|
||||
}, [sftpShowHiddenFiles, notifySettingsChanged]);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -484,8 +542,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 +590,12 @@ export const useSettingsState = () => {
|
||||
setIsHotkeyRecording,
|
||||
customCSS,
|
||||
setCustomCSS,
|
||||
sftpDoubleClickBehavior,
|
||||
setSftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
setSftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
setSftpShowHiddenFiles,
|
||||
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
@@ -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
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Server,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Usb,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useMemo } from "react";
|
||||
@@ -63,6 +64,7 @@ interface LogItemProps {
|
||||
const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) => {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const isLocal = log.protocol === "local" || log.hostname === "localhost";
|
||||
const isSerial = log.protocol === "serial";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -92,14 +94,14 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
|
||||
isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
|
||||
isSerial ? "bg-amber-500/10 text-amber-500" : isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
|
||||
)}>
|
||||
{isLocal ? <Terminal size={14} /> : <Server size={14} />}
|
||||
{isSerial ? <Usb size={14} /> : isLocal ? <Terminal size={14} /> : <Server size={14} />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{isLocal ? "local" : `${log.protocol}, ${log.username}`}
|
||||
{isLocal ? "local" : isSerial ? `serial, ${log.hostname}` : `${log.protocol}, ${log.username}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Server } from "lucide-react";
|
||||
import { Server, Usb } from "lucide-react";
|
||||
import React, { memo } from "react";
|
||||
import { normalizeDistroId } from "../domain/host";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -69,6 +69,21 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
const containerClass = sizeClasses[size];
|
||||
const iconSize = iconSizes[size];
|
||||
|
||||
// Show USB icon for serial hosts
|
||||
if (host.protocol === 'serial') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
containerClass,
|
||||
"flex items-center justify-center bg-amber-500/15 text-amber-500",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Usb className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (logo && !errored) {
|
||||
return (
|
||||
<div
|
||||
|
||||
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;
|
||||
@@ -1,20 +1,29 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
FolderLock,
|
||||
FolderPlus,
|
||||
Forward,
|
||||
Globe,
|
||||
Key,
|
||||
KeyRound,
|
||||
Link2,
|
||||
MapPin,
|
||||
Palette,
|
||||
Plus,
|
||||
Settings2,
|
||||
Shield,
|
||||
Tag,
|
||||
TerminalSquare,
|
||||
User,
|
||||
Variable,
|
||||
Wifi,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -28,6 +37,7 @@ import {
|
||||
} from "./ui/aside-panel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { Card } from "./ui/card";
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
|
||||
import { Input } from "./ui/input";
|
||||
@@ -59,6 +69,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,12 +83,14 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
groups,
|
||||
allTags = [],
|
||||
allHosts = [],
|
||||
defaultGroup,
|
||||
onSave,
|
||||
onCancel,
|
||||
onCreateGroup,
|
||||
onCreateTag,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
initialData ||
|
||||
@@ -90,11 +103,11 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
protocol: "ssh",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
agentForwarding: false,
|
||||
authMethod: "password",
|
||||
charset: "UTF-8",
|
||||
theme: "Flexoki Dark",
|
||||
createdAt: Date.now(),
|
||||
group: defaultGroup || undefined, // Pre-fill with current navigation group
|
||||
} as Host),
|
||||
);
|
||||
|
||||
@@ -113,6 +126,22 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupParent, setNewGroupParent] = useState("");
|
||||
|
||||
// SSH Agent status for Windows (only checked when agentForwarding is enabled)
|
||||
const [sshAgentStatus, setSshAgentStatus] = useState<{
|
||||
running: boolean;
|
||||
startupType: string | null;
|
||||
error: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// Check SSH Agent status when agentForwarding is toggled on (Windows only)
|
||||
useEffect(() => {
|
||||
if (form.agentForwarding) {
|
||||
checkSshAgent().then(setSshAgentStatus);
|
||||
} else {
|
||||
setSshAgentStatus(null);
|
||||
}
|
||||
}, [form.agentForwarding, checkSshAgent]);
|
||||
|
||||
// Group input state for inline creation suggestion
|
||||
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
|
||||
|
||||
@@ -286,10 +315,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]);
|
||||
@@ -478,9 +507,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
>
|
||||
<AsidePanelContent>
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.address")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.address")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={form as Host}
|
||||
@@ -501,9 +533,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.general")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.general")}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t("hostDetails.label.placeholder")}
|
||||
value={form.label}
|
||||
@@ -519,12 +554,16 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<Combobox
|
||||
options={groupOptions}
|
||||
value={form.group || ""}
|
||||
onValueChange={(val) => update("group", val)}
|
||||
onValueChange={(val) => {
|
||||
update("group", val);
|
||||
setGroupInputValue(val);
|
||||
}}
|
||||
placeholder={t("hostDetails.group.placeholder")}
|
||||
allowCreate={true}
|
||||
onCreateNew={(val) => {
|
||||
onCreateGroup?.(val);
|
||||
update("group", val);
|
||||
setGroupInputValue(val);
|
||||
}}
|
||||
createText="Create Group"
|
||||
triggerClassName="flex-1 h-10"
|
||||
@@ -550,9 +589,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.portCredentials")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.portCredentials")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
|
||||
<span className="text-xs text-muted-foreground">SSH on</span>
|
||||
@@ -639,10 +681,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 +692,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 +712,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 +744,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 +892,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,16 +955,47 @@ 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">
|
||||
{t("hostDetails.section.appearance")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderLock size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.sftp")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostDetails.sftp.sudo")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.sudo.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.sftpSudo || false}
|
||||
onCheckedChange={(val) => update("sftpSudo", val)}
|
||||
/>
|
||||
</div>
|
||||
{form.sftpSudo && !form.password && !selectedIdentity?.password && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t("hostDetails.sftp.sudo.passwordWarning")}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.appearance")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* SSH Theme Selection */}
|
||||
<button
|
||||
@@ -1009,7 +1082,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.mosh")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wifi size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.mosh")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label="Mosh"
|
||||
enabled={!!form.moshEnabled}
|
||||
@@ -1017,75 +1093,109 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Agent Forwarding */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Forward size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.agentForwarding")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.agentForwarding")}
|
||||
enabled={!!form.agentForwarding}
|
||||
onToggle={() => update("agentForwarding", !form.agentForwarding)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Host Chain Configuration - Only show when Agent Forwarding is enabled */}
|
||||
{form.agentForwarding && (
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.jumpHosts")}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.agentForwarding.desc")}
|
||||
</p>
|
||||
{form.agentForwarding && sshAgentStatus && !sshAgentStatus.running && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">
|
||||
{t("hostDetails.agentForwarding.agentNotRunning")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.agentForwarding.agentNotRunningHint")}
|
||||
</p>
|
||||
</div>
|
||||
{chainedHosts.length > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("hostDetails.jumpHosts.direct")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{chainedHosts.length > 0 && (
|
||||
<button
|
||||
className="w-full flex items-center gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.jumpHosts")}
|
||||
</p>
|
||||
</div>
|
||||
{chainedHosts.length > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<Link2
|
||||
size={14}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span className="text-sm truncate">
|
||||
{chainedHosts
|
||||
.slice(0, 3)
|
||||
.map((h) => h.hostname || h.label)
|
||||
.join(" -> ")}
|
||||
{chainedHosts.length > 3 && "..."}
|
||||
</span>
|
||||
{t("hostDetails.jumpHosts.direct")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{chainedHosts.length > 0 && (
|
||||
<button
|
||||
className="w-full flex flex-col items-start gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Link2
|
||||
size={14}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0 ml-auto"
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearHostChain();
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{chainedHosts.length === 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.jumpHosts.configure")}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full space-y-1 pl-5">
|
||||
{chainedHosts.slice(0, 5).map((h, idx) => (
|
||||
<div key={h.id} className="flex items-center gap-1 text-sm">
|
||||
<span className="text-muted-foreground">{idx + 1}.</span>
|
||||
<span className="truncate">
|
||||
{h.label !== h.hostname ? `${h.hostname} (${h.label})` : h.hostname}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{chainedHosts.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
+{chainedHosts.length - 5} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{chainedHosts.length === 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.jumpHosts.configure")}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Proxy Configuration */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Switch } from "./ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -257,6 +258,29 @@ const HostForm: React.FC<HostFormProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between space-x-2 border rounded-md p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sftp-sudo" className="text-base">
|
||||
{t("hostDetails.sftp.sudo")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.sudo.desc")}
|
||||
</p>
|
||||
{formData.sftpSudo && authType === "key" && (
|
||||
<p className="text-xs text-amber-500 mt-1">
|
||||
{t("hostDetails.sftp.sudo.passwordWarning")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
id="sftp-sudo"
|
||||
checked={formData.sftpSudo || false}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, sftpSudo: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Label>{t("hostForm.auth.method")}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
|
||||
200
components/KeyboardInteractiveModal.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Keyboard Interactive Authentication Modal
|
||||
* Global modal for handling SSH keyboard-interactive authentication (2FA/MFA)
|
||||
* This modal displays prompts from the SSH server and collects user responses.
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export interface KeyboardInteractivePrompt {
|
||||
prompt: string;
|
||||
echo: boolean;
|
||||
}
|
||||
|
||||
export interface KeyboardInteractiveRequest {
|
||||
requestId: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: KeyboardInteractivePrompt[];
|
||||
hostname?: string;
|
||||
savedPassword?: string | null;
|
||||
}
|
||||
|
||||
interface KeyboardInteractiveModalProps {
|
||||
request: KeyboardInteractiveRequest | null;
|
||||
onSubmit: (requestId: string, responses: string[]) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
}
|
||||
|
||||
export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [responses, setResponses] = useState<string[]>([]);
|
||||
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset state when request changes
|
||||
useEffect(() => {
|
||||
if (request) {
|
||||
setResponses(request.prompts.map(() => ""));
|
||||
setShowPasswords(request.prompts.map(() => false));
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const handleResponseChange = useCallback((index: number, value: string) => {
|
||||
setResponses((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = value;
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleShowPassword = useCallback((index: number) => {
|
||||
setShowPasswords((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = !updated[index];
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!request || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
onSubmit(request.requestId, responses);
|
||||
}, [request, responses, onSubmit, isSubmitting]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (!request) return;
|
||||
onCancel(request.requestId);
|
||||
}, [request, onCancel]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isSubmitting) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit, isSubmitting]
|
||||
);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const title = request.name?.trim() || t("keyboard.interactive.title");
|
||||
const description =
|
||||
request.instructions?.trim() ||
|
||||
(request.hostname
|
||||
? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
|
||||
: t("keyboard.interactive.desc"));
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{request.prompts.map((prompt, index) => {
|
||||
const isPassword = !prompt.echo;
|
||||
const showPassword = showPasswords[index];
|
||||
// Clean up prompt text (remove trailing colon and whitespace)
|
||||
const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-2">
|
||||
<Label htmlFor={`ki-prompt-${index}`}>
|
||||
{promptLabel || t("keyboard.interactive.response")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`ki-prompt-${index}`}
|
||||
type={isPassword && !showPassword ? "password" : "text"}
|
||||
value={responses[index] || ""}
|
||||
onChange={(e) => handleResponseChange(index, e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
className={isPassword ? "pr-10" : undefined}
|
||||
autoFocus={index === 0}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
|
||||
onClick={() => toggleShowPassword(index)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Use saved password button - shown below input, right-aligned */}
|
||||
{isPassword && request.savedPassword && !responses[index] && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 disabled:opacity-50"
|
||||
onClick={() => handleResponseChange(index, request.savedPassword!)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<KeyRound size={12} />
|
||||
<span>{t("keyboard.interactive.useSavedPassword")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("keyboard.interactive.verifying")}
|
||||
</>
|
||||
) : (
|
||||
t("keyboard.interactive.submit")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyboardInteractiveModal;
|
||||
@@ -138,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) {
|
||||
|
||||
428
components/SerialConnectModal.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Serial Port Connect Modal
|
||||
* Allows users to configure and connect to a serial port
|
||||
*/
|
||||
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Save, 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 { Host, 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 { Input } from './ui/input';
|
||||
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;
|
||||
onSaveHost?: (host: Host) => 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,
|
||||
onSaveHost,
|
||||
}) => {
|
||||
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);
|
||||
|
||||
// Save configuration state
|
||||
const [saveConfig, setSaveConfig] = useState(false);
|
||||
const [configLabel, setConfigLabel] = useState('');
|
||||
|
||||
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
|
||||
|
||||
// Generate a default label when port is selected
|
||||
useEffect(() => {
|
||||
if (selectedPort && !configLabel) {
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
setConfigLabel(`Serial: ${portName}`);
|
||||
}
|
||||
}, [selectedPort, configLabel]);
|
||||
|
||||
const handleConnect = () => {
|
||||
if (!selectedPort) return;
|
||||
|
||||
const config: SerialConfig = {
|
||||
path: selectedPort,
|
||||
baudRate,
|
||||
dataBits,
|
||||
stopBits,
|
||||
parity,
|
||||
flowControl,
|
||||
localEcho,
|
||||
lineMode,
|
||||
};
|
||||
|
||||
// Save as host if checkbox is checked and onSaveHost is provided
|
||||
if (saveConfig && onSaveHost) {
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
const host: Host = {
|
||||
id: `serial-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
label: configLabel.trim() || `Serial: ${portName}`,
|
||||
hostname: selectedPort,
|
||||
// For serial hosts, port field stores baud rate as a numeric identifier.
|
||||
// The full configuration is stored in serialConfig for actual connection.
|
||||
port: baudRate,
|
||||
username: '',
|
||||
os: 'linux',
|
||||
tags: ['serial'],
|
||||
protocol: 'serial',
|
||||
createdAt: Date.now(),
|
||||
serialConfig: config, // Store full serial configuration for connection
|
||||
};
|
||||
onSaveHost(host);
|
||||
}
|
||||
|
||||
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/ (Unix/macOS) or COM/\\.\COM (Windows)
|
||||
const trimmedPort = selectedPort.trim();
|
||||
const isPortValid =
|
||||
trimmedPort.startsWith('/dev/') ||
|
||||
/^COM\d+$/i.test(trimmedPort) ||
|
||||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
|
||||
// Allow custom baud rates as long as they are positive integers
|
||||
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
|
||||
|
||||
// Check if using 1.5 stop bits (limited Windows support)
|
||||
const isStopBits15 = stopBits === 1.5;
|
||||
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>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Save Configuration */}
|
||||
{onSaveHost && (
|
||||
<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="save-config" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.saveConfig')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.saveConfigDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="save-config"
|
||||
checked={saveConfig}
|
||||
onChange={(e) => setSaveConfig(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
{saveConfig && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="config-label">{t('serial.field.configLabel')}</Label>
|
||||
<Input
|
||||
id="config-label"
|
||||
value={configLabel}
|
||||
onChange={(e) => setConfigLabel(e.target.value)}
|
||||
placeholder={t('serial.field.configLabelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConnect} disabled={!isValid}>
|
||||
{saveConfig ? (
|
||||
<Save size={14} className="mr-2" />
|
||||
) : (
|
||||
<Cpu size={14} className="mr-2" />
|
||||
)}
|
||||
{saveConfig ? t('serial.connectAndSave') : t('common.connect')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SerialConnectModal;
|
||||
415
components/SerialHostDetailsPanel.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Serial Host Details Panel
|
||||
* A dedicated editor for serial port hosts (distinct from SSH HostDetailsPanel)
|
||||
*/
|
||||
import { ChevronDown, ChevronUp, Save, Tag, 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 { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
} from './ui/aside-panel';
|
||||
|
||||
interface SerialPort {
|
||||
path: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
vendorId: string;
|
||||
productId: string;
|
||||
pnpId: string;
|
||||
type?: 'hardware' | 'pseudo' | 'custom';
|
||||
}
|
||||
|
||||
interface SerialHostDetailsPanelProps {
|
||||
initialData: Host;
|
||||
allTags?: string[];
|
||||
groups?: string[];
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => 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 SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
initialData,
|
||||
allTags = [],
|
||||
groups = [],
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const [ports, setPorts] = useState<SerialPort[]>([]);
|
||||
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [label, setLabel] = useState(initialData.label);
|
||||
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
|
||||
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
|
||||
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
|
||||
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(initialData.serialConfig?.stopBits || 1);
|
||||
const [parity, setParity] = useState<SerialParity>(initialData.serialConfig?.parity || 'none');
|
||||
const [flowControl, setFlowControl] = useState<SerialFlowControl>(initialData.serialConfig?.flowControl || 'none');
|
||||
const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false);
|
||||
const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false);
|
||||
const [tags, setTags] = useState<string[]>(initialData.tags || []);
|
||||
const [group, setGroup] = useState(initialData.group || '');
|
||||
|
||||
const loadPorts = useCallback(async () => {
|
||||
setIsLoadingPorts(true);
|
||||
try {
|
||||
const result = await terminalBackend.listSerialPorts();
|
||||
setPorts(result);
|
||||
} catch (err) {
|
||||
console.error('[Serial] Failed to list ports:', err);
|
||||
} finally {
|
||||
setIsLoadingPorts(false);
|
||||
}
|
||||
}, [terminalBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPorts();
|
||||
}, [loadPorts]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!selectedPort) return;
|
||||
|
||||
const config: SerialConfig = {
|
||||
path: selectedPort,
|
||||
baudRate,
|
||||
dataBits,
|
||||
stopBits,
|
||||
parity,
|
||||
flowControl,
|
||||
localEcho,
|
||||
lineMode,
|
||||
};
|
||||
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
const updatedHost: Host = {
|
||||
...initialData,
|
||||
label: label.trim() || `Serial: ${portName}`,
|
||||
hostname: selectedPort,
|
||||
port: baudRate,
|
||||
tags,
|
||||
group,
|
||||
serialConfig: config,
|
||||
};
|
||||
|
||||
onSave(updatedHost);
|
||||
};
|
||||
|
||||
// Convert ports to Combobox options
|
||||
const portOptions: ComboboxOption[] = useMemo(() => {
|
||||
return ports.map((port) => ({
|
||||
value: port.path,
|
||||
label: port.path,
|
||||
sublabel: port.manufacturer || undefined,
|
||||
}));
|
||||
}, [ports]);
|
||||
|
||||
// Tag options for MultiCombobox
|
||||
const tagOptions: ComboboxOption[] = useMemo(() => {
|
||||
const allUniqueTags = new Set([...allTags, ...tags]);
|
||||
return Array.from(allUniqueTags).map((tag) => ({
|
||||
value: tag,
|
||||
label: tag,
|
||||
}));
|
||||
}, [allTags, tags]);
|
||||
|
||||
// Group options for Combobox
|
||||
const groupOptions: ComboboxOption[] = useMemo(() => {
|
||||
const allGroups = new Set(groups);
|
||||
if (group && !allGroups.has(group)) {
|
||||
allGroups.add(group);
|
||||
}
|
||||
return Array.from(allGroups).map((g) => ({
|
||||
value: g,
|
||||
label: g,
|
||||
}));
|
||||
}, [groups, group]);
|
||||
|
||||
// Validation
|
||||
const trimmedPort = selectedPort.trim();
|
||||
const isPortValid =
|
||||
trimmedPort.startsWith('/dev/') ||
|
||||
/^COM\d+$/i.test(trimmedPort) ||
|
||||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
|
||||
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
|
||||
// Check if using 1.5 stop bits (limited Windows support)
|
||||
const isStopBits15 = stopBits === 1.5;
|
||||
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
title={t('serial.edit.title')}
|
||||
subtitle={initialData.label}
|
||||
className="z-40"
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{/* Label */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="serial-label">{t('serial.field.configLabel')}</Label>
|
||||
<Input
|
||||
id="serial-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t('serial.field.configLabelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Serial Port */}
|
||||
<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"
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Tag size={14} />
|
||||
{t('hostDetails.tags')}
|
||||
</Label>
|
||||
<MultiCombobox
|
||||
options={tagOptions}
|
||||
values={tags}
|
||||
onValuesChange={setTags}
|
||||
placeholder={t('hostDetails.addTag')}
|
||||
allowCreate
|
||||
createText={t('hostDetails.createTag')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('hostDetails.group')}</Label>
|
||||
<Combobox
|
||||
options={groupOptions}
|
||||
value={group}
|
||||
onValueChange={setGroup}
|
||||
placeholder={t('hostDetails.selectGroup')}
|
||||
allowCreate
|
||||
createText={t('hostDetails.createGroup')}
|
||||
/>
|
||||
</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>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
</AsidePanelContent>
|
||||
|
||||
<AsidePanelFooter>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onCancel} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!isValid} className="flex-1">
|
||||
<Save size={14} className="mr-2" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default SerialHostDetailsPanel;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Maximize2, Radio } from "lucide-react";
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
Host,
|
||||
Identity,
|
||||
KnownHost,
|
||||
SerialConfig,
|
||||
SSHKey,
|
||||
Snippet,
|
||||
TerminalSession,
|
||||
@@ -25,7 +27,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 +60,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 +106,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalSettings,
|
||||
sessionId,
|
||||
startupCommand,
|
||||
serialConfig,
|
||||
onUpdateTerminalThemeId,
|
||||
onUpdateTerminalFontFamilyId,
|
||||
onUpdateTerminalFontSize,
|
||||
@@ -124,8 +128,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onToggleBroadcast,
|
||||
onBroadcastInput,
|
||||
}) => {
|
||||
const CONNECTION_TIMEOUT = 12000;
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
const CONNECTION_TIMEOUT = 120000;
|
||||
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 +144,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 +172,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);
|
||||
@@ -174,6 +183,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [showSFTP, setShowSFTP] = useState(false);
|
||||
const [sftpInitialPath, setSftpInitialPath] = useState<string | undefined>(undefined);
|
||||
const [progressValue, setProgressValue] = useState(15);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
|
||||
@@ -280,6 +290,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
startupCommand,
|
||||
terminalSettings,
|
||||
terminalBackend,
|
||||
serialConfig,
|
||||
sessionRef,
|
||||
hasConnectedRef,
|
||||
hasRunStartupCommandRef,
|
||||
@@ -336,6 +347,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onCommandExecuted,
|
||||
commandBufferRef,
|
||||
setIsSearchOpen,
|
||||
// Serial-specific options
|
||||
serialLocalEcho: serialConfig?.localEcho,
|
||||
serialLineMode: serialConfig?.lineMode,
|
||||
serialLineBufferRef,
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -346,7 +361,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 +426,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 +471,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 +555,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 +565,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) {
|
||||
@@ -709,6 +735,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current?.writeln("\r\n[No active SSH session]");
|
||||
};
|
||||
|
||||
const handleOpenSFTP = async () => {
|
||||
// If SFTP is already open, toggle it off
|
||||
if (showSFTP) {
|
||||
setShowSFTP(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get the current working directory from the terminal session
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail and open SFTP without initial path
|
||||
}
|
||||
}
|
||||
|
||||
// Use flushSync to ensure initialPath state is committed before opening SFTP modal
|
||||
// This prevents React's batching from causing the modal to open with stale/undefined initialPath
|
||||
flushSync(() => {
|
||||
setSftpInitialPath(initialPath);
|
||||
});
|
||||
setShowSFTP(true);
|
||||
};
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
setIsCancelling(true);
|
||||
auth.setNeedsAuth(false);
|
||||
@@ -787,7 +841,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
|
||||
isScriptsOpen={isScriptsOpen}
|
||||
setIsScriptsOpen={setIsScriptsOpen}
|
||||
onOpenSFTP={() => setShowSFTP((v) => !v)}
|
||||
onOpenSFTP={handleOpenSFTP}
|
||||
onSnippetClick={handleSnippetClick}
|
||||
onUpdateHost={onUpdateHost}
|
||||
showClose={opts?.showClose}
|
||||
@@ -822,11 +876,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 +901,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 +920,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 +961,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 +1026,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,10 +1078,14 @@ 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,
|
||||
sftpSudo: host.sftpSudo,
|
||||
};
|
||||
})()}
|
||||
open={showSFTP && status === "connected"}
|
||||
onClose={() => setShowSFTP(false)}
|
||||
initialPath={sftpInitialPath}
|
||||
/>
|
||||
</div>
|
||||
</TerminalContextMenu>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
@@ -47,6 +49,8 @@ import KnownHostsManager from "./KnownHostsManager";
|
||||
import PortForwarding from "./PortForwardingNew";
|
||||
import QuickConnectWizard from "./QuickConnectWizard";
|
||||
import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../domain/quickConnect";
|
||||
import SerialConnectModal from "./SerialConnectModal";
|
||||
import SerialHostDetailsPanel from "./SerialHostDetailsPanel";
|
||||
import SnippetsManager from "./SnippetsManager";
|
||||
import { ImportVaultDialog } from "./vault/ImportVaultDialog";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -90,6 +94,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 +129,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onOpenSettings,
|
||||
onOpenQuickSwitcher,
|
||||
onCreateLocalTerminal,
|
||||
onConnectSerial,
|
||||
onDeleteHost,
|
||||
onConnect,
|
||||
onUpdateHosts,
|
||||
@@ -156,6 +162,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [renameGroupName, setRenameGroupName] = useState("");
|
||||
const [renameGroupError, setRenameGroupError] = useState<string | null>(null);
|
||||
const [isImportOpen, setIsImportOpen] = useState(false);
|
||||
const [isSerialModalOpen, setIsSerialModalOpen] = useState(false);
|
||||
|
||||
// Handle external navigation requests
|
||||
useEffect(() => {
|
||||
@@ -960,6 +967,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>
|
||||
)}
|
||||
@@ -1343,7 +1362,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Host Details Panel - positioned at VaultView root level for correct top alignment */}
|
||||
{currentSection === "hosts" && isHostPanelOpen && (
|
||||
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol !== 'serial' && (
|
||||
<HostDetailsPanel
|
||||
initialData={editingHost}
|
||||
availableKeys={keys}
|
||||
@@ -1356,6 +1375,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
)}
|
||||
allTags={allTags}
|
||||
allHosts={hosts}
|
||||
defaultGroup={editingHost ? undefined : selectedGroupPath}
|
||||
onSave={(host) => {
|
||||
onUpdateHosts(
|
||||
editingHost
|
||||
@@ -1377,6 +1397,31 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Serial Host Details Panel - for editing serial port hosts */}
|
||||
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol === 'serial' && (
|
||||
<SerialHostDetailsPanel
|
||||
initialData={editingHost}
|
||||
allTags={allTags}
|
||||
groups={Array.from(
|
||||
new Set([
|
||||
...customGroups,
|
||||
...hosts.map((h) => h.group || "General"),
|
||||
]),
|
||||
)}
|
||||
onSave={(host) => {
|
||||
onUpdateHosts(
|
||||
hosts.map((h) => (h.id === host.id ? host : h)),
|
||||
);
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={isNewFolderOpen} onOpenChange={(open) => {
|
||||
setIsNewFolderOpen(open);
|
||||
if (!open) {
|
||||
@@ -1503,6 +1548,20 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Serial Connect Modal */}
|
||||
<SerialConnectModal
|
||||
open={isSerialModalOpen}
|
||||
onClose={() => setIsSerialModalOpen(false)}
|
||||
onConnect={(config) => {
|
||||
if (onConnectSerial) {
|
||||
onConnectSerial(config);
|
||||
}
|
||||
}}
|
||||
onSaveHost={(host) => {
|
||||
onUpdateHosts([...hosts, host]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -84,7 +84,6 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pem,.key,.pub,.ppk,*"
|
||||
className="hidden"
|
||||
onChange={handleFileImport}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
290
components/settings/tabs/SettingsFileAssociationsTab.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* 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, sftpShowHiddenFiles, setSftpShowHiddenFiles } = 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>
|
||||
|
||||
{/* Show hidden files section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.showHiddenFiles')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.showHiddenFiles.desc')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSftpShowHiddenFiles(!sftpShowHiddenFiles)}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpShowHiddenFiles
|
||||
? "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",
|
||||
sftpShowHiddenFiles
|
||||
? "border-primary bg-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpShowHiddenFiles && (
|
||||
<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.showHiddenFiles.enable')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.showHiddenFiles.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
@@ -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';
|
||||
|
||||
174
components/sftp/SftpContext.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 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>;
|
||||
onCreateFile: (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
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void; // Download to local filesystem
|
||||
// External file upload (supports folders via DataTransfer)
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer) => Promise<void>;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Settings
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// Hook to get showHiddenFiles setting
|
||||
export const useSftpShowHiddenFiles = (): boolean => {
|
||||
const context = useSftpContext();
|
||||
return context.showHiddenFiles;
|
||||
};
|
||||
|
||||
interface SftpContextProviderProps {
|
||||
hosts: Host[];
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
showHiddenFiles: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
hosts,
|
||||
draggedFiles,
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
showHiddenFiles,
|
||||
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,
|
||||
showHiddenFiles,
|
||||
}),
|
||||
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -178,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
@@ -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,31 @@
|
||||
// Utilities
|
||||
export {
|
||||
formatBytes,formatDate,
|
||||
formatSpeed,formatTransferBytes,getFileIcon,type ColumnWidths,type SortField,
|
||||
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,isWindowsHiddenFile,filterHiddenFiles,type ColumnWidths,type SortField,
|
||||
type SortOrder
|
||||
} from './utils';
|
||||
|
||||
// Context
|
||||
export {
|
||||
SftpContextProvider,
|
||||
useSftpContext,
|
||||
useSftpPaneCallbacks,
|
||||
useSftpDrag,
|
||||
useSftpHosts,
|
||||
useSftpShowHiddenFiles,
|
||||
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,41 @@ 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');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a file is hidden on Windows
|
||||
* Only applies to local Windows filesystem where the hidden attribute is set
|
||||
* The ".." parent directory entry is never considered hidden
|
||||
*
|
||||
* Note: On Unix/Linux, there's no system-level hidden file concept.
|
||||
* Dotfiles are just a convention, not actual hidden files, so we don't filter them.
|
||||
*/
|
||||
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean => {
|
||||
if (file.name === "..") return false;
|
||||
return file.hidden === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter files based on Windows hidden file visibility setting
|
||||
* Only filters files with the Windows hidden attribute set
|
||||
* Always preserves ".." parent directory entry
|
||||
*
|
||||
* This setting only affects local Windows filesystem browsing.
|
||||
* On Unix/Linux systems and remote SFTP connections, all files are shown
|
||||
* because there's no system-level hidden file concept (dotfiles are just a convention).
|
||||
*/
|
||||
export const filterHiddenFiles = <T extends { name: string; hidden?: boolean }>(
|
||||
files: T[],
|
||||
showHiddenFiles: boolean
|
||||
): T[] => {
|
||||
if (showHiddenFiles) return files;
|
||||
return files.filter((f) => !isWindowsHiddenFile(f));
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useCallback } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { normalizeLineEndings } from "../../../lib/utils";
|
||||
|
||||
type TerminalBackendWriteApi = {
|
||||
writeToSession: (sessionId: string, data: string) => void;
|
||||
@@ -32,7 +33,7 @@ export const useTerminalContextActions = ({
|
||||
if (!term) return;
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && sessionRef.current) terminalBackend.writeToSession(sessionRef.current, text);
|
||||
if (text && sessionRef.current) terminalBackend.writeToSession(sessionRef.current, normalizeLineEndings(text));
|
||||
} catch (err) {
|
||||
logger.warn("Failed to paste from clipboard", err);
|
||||
}
|
||||
|
||||
@@ -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?.();
|
||||
@@ -204,12 +218,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
identities: ctx.identities,
|
||||
override: pendingAuth
|
||||
? {
|
||||
authMethod: pendingAuth.authMethod,
|
||||
username: pendingAuth.username,
|
||||
password: pendingAuth.password,
|
||||
keyId: pendingAuth.keyId,
|
||||
passphrase: pendingAuth.passphrase,
|
||||
}
|
||||
authMethod: pendingAuth.authMethod,
|
||||
username: pendingAuth.username,
|
||||
password: pendingAuth.password,
|
||||
keyId: pendingAuth.keyId,
|
||||
passphrase: pendingAuth.passphrase,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
@@ -233,12 +247,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
|
||||
const proxyConfig = ctx.host.proxyConfig
|
||||
? {
|
||||
type: ctx.host.proxyConfig.type,
|
||||
host: ctx.host.proxyConfig.host,
|
||||
port: ctx.host.proxyConfig.port,
|
||||
username: ctx.host.proxyConfig.username,
|
||||
password: ctx.host.proxyConfig.password,
|
||||
}
|
||||
type: ctx.host.proxyConfig.type,
|
||||
host: ctx.host.proxyConfig.host,
|
||||
port: ctx.host.proxyConfig.port,
|
||||
username: ctx.host.proxyConfig.username,
|
||||
password: ctx.host.proxyConfig.password,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
|
||||
@@ -329,13 +343,17 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
env: termEnv,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
|
||||
});
|
||||
};
|
||||
|
||||
let id: string;
|
||||
const hasKeyMaterial = !!key?.privateKey;
|
||||
// Respect explicit auth method selection - don't use key if password auth was explicitly selected
|
||||
const authMethod = resolvedAuth.authMethod;
|
||||
const hasKeyMaterial = !!key?.privateKey && authMethod !== 'password';
|
||||
const hasPassword = !!effectivePassword;
|
||||
|
||||
|
||||
if (hasKeyMaterial) {
|
||||
try {
|
||||
id = await startAttempt({ key });
|
||||
@@ -521,10 +539,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 +608,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,13 +10,14 @@ 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,
|
||||
resolveXTermPerformanceConfig,
|
||||
} from "../../../infrastructure/config/xtermPerformance";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { normalizeLineEndings } from "../../../lib/utils";
|
||||
import type {
|
||||
Host,
|
||||
KeyBinding,
|
||||
@@ -38,6 +39,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 +74,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 => {
|
||||
@@ -96,7 +107,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const platform = detectPlatform();
|
||||
const deviceMemoryGb =
|
||||
typeof navigator !== "undefined" &&
|
||||
typeof (navigator as { deviceMemory?: number }).deviceMemory === "number"
|
||||
typeof (navigator as { deviceMemory?: number }).deviceMemory === "number"
|
||||
? (navigator as { deviceMemory?: number }).deviceMemory
|
||||
: undefined;
|
||||
|
||||
@@ -106,7 +117,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;
|
||||
@@ -347,7 +359,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
case "paste": {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) ctx.terminalBackend.writeToSession(id, text);
|
||||
if (id) ctx.terminalBackend.writeToSession(id, normalizeLineEndings(text));
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -379,7 +391,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && ctx.sessionRef.current) {
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, text);
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, normalizeLineEndings(text));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("[Terminal] Failed to paste from clipboard:", err);
|
||||
@@ -397,7 +409,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 +492,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 +567,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>
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ const OPTIONS: ImportOption[] = [
|
||||
format: "ssh_config",
|
||||
label: "ssh_config",
|
||||
iconSrc: "/import/file.png",
|
||||
accept: ".conf,.config,.txt",
|
||||
accept: "*",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -73,6 +88,10 @@ export interface Host {
|
||||
telnetEnabled?: boolean; // Is Telnet enabled for this host
|
||||
telnetUsername?: string; // Telnet-specific username
|
||||
telnetPassword?: string; // Telnet-specific password
|
||||
// Serial-specific configuration (for protocol='serial' hosts)
|
||||
serialConfig?: SerialConfig;
|
||||
// SFTP specific configuration
|
||||
sftpSudo?: boolean; // Use sudo for SFTP operations (requires password)
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
@@ -359,6 +378,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[] = [
|
||||
@@ -394,6 +420,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 {
|
||||
@@ -434,16 +463,21 @@ 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"
|
||||
hidden?: boolean; // Windows hidden attribute (only set for local Windows filesystem)
|
||||
}
|
||||
|
||||
export type WorkspaceNode =
|
||||
@@ -482,6 +516,8 @@ export interface SftpFileEntry {
|
||||
permissions?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
|
||||
hidden?: boolean; // Windows hidden attribute (only set for local Windows filesystem)
|
||||
}
|
||||
|
||||
export interface SftpConnection {
|
||||
@@ -546,6 +582,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;
|
||||
@@ -583,7 +621,7 @@ export interface ConnectionLog {
|
||||
hostLabel: string; // Display label (e.g., 'Local Terminal' or host label)
|
||||
hostname: string; // Target hostname or 'localhost'
|
||||
username: string; // SSH username or system username
|
||||
protocol: 'ssh' | 'telnet' | 'local' | 'mosh';
|
||||
protocol: 'ssh' | 'telnet' | 'local' | 'mosh' | 'serial';
|
||||
startTime: number; // Connection start timestamp
|
||||
endTime?: number; // Connection end timestamp (undefined if still active)
|
||||
localUsername: string; // System username of the local user
|
||||
|
||||
@@ -57,11 +57,12 @@ export const resolveHostAuth = (args: {
|
||||
host.username?.trim() ||
|
||||
"";
|
||||
|
||||
const keyId =
|
||||
override?.keyId ||
|
||||
identity?.keyId ||
|
||||
host.identityFileId ||
|
||||
undefined;
|
||||
// Don't load key when explicit password auth is requested
|
||||
// This ensures user's auth method selection is strictly respected
|
||||
const keyId = override?.authMethod === 'password'
|
||||
? undefined
|
||||
: (override?.keyId || identity?.keyId || host.identityFileId || undefined);
|
||||
|
||||
|
||||
const key = keyId ? keys.find((k) => k.id === keyId) : undefined;
|
||||
|
||||
|
||||
@@ -1,6 +1,82 @@
|
||||
import { Host, HostProtocol } from "./models";
|
||||
import { Host, HostChainConfig, HostProtocol } from "./models";
|
||||
import { parseQuickConnectInput } from "./quickConnect";
|
||||
|
||||
interface ParsedJumpHost {
|
||||
hostname: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
const parseJumpHostSpec = (spec: string): ParsedJumpHost | null => {
|
||||
const trimmed = spec.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === "none") return null;
|
||||
|
||||
if (trimmed.startsWith("ssh://")) {
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
return {
|
||||
hostname: url.hostname,
|
||||
username: url.username || undefined,
|
||||
port: url.port ? parseInt(url.port, 10) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let username: string | undefined;
|
||||
let hostname: string;
|
||||
let port: number | undefined;
|
||||
let rest = trimmed;
|
||||
|
||||
const atIndex = rest.indexOf("@");
|
||||
if (atIndex !== -1) {
|
||||
username = rest.slice(0, atIndex);
|
||||
rest = rest.slice(atIndex + 1);
|
||||
}
|
||||
|
||||
if (rest.startsWith("[")) {
|
||||
const bracketEnd = rest.indexOf("]");
|
||||
if (bracketEnd !== -1) {
|
||||
hostname = rest.slice(1, bracketEnd);
|
||||
const portPart = rest.slice(bracketEnd + 1);
|
||||
if (portPart.startsWith(":")) {
|
||||
const p = parseInt(portPart.slice(1), 10);
|
||||
if (Number.isFinite(p) && p >= 1 && p <= 65535) port = p;
|
||||
}
|
||||
} else {
|
||||
hostname = rest;
|
||||
}
|
||||
} else {
|
||||
const colonIndex = rest.lastIndexOf(":");
|
||||
if (colonIndex !== -1) {
|
||||
const portStr = rest.slice(colonIndex + 1);
|
||||
const p = parseInt(portStr, 10);
|
||||
if (Number.isFinite(p) && p >= 1 && p <= 65535) {
|
||||
port = p;
|
||||
hostname = rest.slice(0, colonIndex);
|
||||
} else {
|
||||
hostname = rest;
|
||||
}
|
||||
} else {
|
||||
hostname = rest;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hostname) return null;
|
||||
return { hostname, username, port };
|
||||
};
|
||||
|
||||
const parseProxyJump = (value: string): ParsedJumpHost[] => {
|
||||
if (!value || value.toLowerCase() === "none") return [];
|
||||
return value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map(parseJumpHostSpec)
|
||||
.filter((h): h is ParsedJumpHost => h !== null);
|
||||
};
|
||||
|
||||
export type VaultImportFormat =
|
||||
| "putty"
|
||||
| "mobaxterm"
|
||||
@@ -442,6 +518,7 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
hostname?: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
proxyJump?: string;
|
||||
};
|
||||
|
||||
const blocks: Block[] = [];
|
||||
@@ -479,16 +556,23 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
if (keyword === "hostname") current.hostname = value;
|
||||
else if (keyword === "user") current.username = value;
|
||||
else if (keyword === "port") current.port = parsePort(value);
|
||||
else if (keyword === "proxyjump") current.proxyJump = value;
|
||||
}
|
||||
|
||||
flush();
|
||||
|
||||
const parsedHosts: Host[] = [];
|
||||
// Use hostname+port as key instead of host.id to survive deduplication
|
||||
const hostProxyJumpMap = new Map<string, string>();
|
||||
let parsed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const isWildcardPattern = (p: string) => /[*?]/.test(p) || p === "!" || p.startsWith("!");
|
||||
|
||||
// Helper to create a stable key for ProxyJump mapping
|
||||
const makeHostKey = (hostname: string, port?: number) =>
|
||||
`${hostname.toLowerCase()}:${port ?? 22}`;
|
||||
|
||||
for (const block of blocks) {
|
||||
const patterns = block.patterns.filter((p) => p && !isWildcardPattern(p));
|
||||
if (patterns.length === 0) continue;
|
||||
@@ -505,24 +589,146 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsedHosts.push(
|
||||
createHost({
|
||||
label: pat,
|
||||
hostname,
|
||||
username: block.username,
|
||||
port: block.port,
|
||||
protocol: "ssh",
|
||||
}),
|
||||
);
|
||||
const host = createHost({
|
||||
label: pat,
|
||||
hostname,
|
||||
username: block.username,
|
||||
port: block.port,
|
||||
protocol: "ssh",
|
||||
});
|
||||
|
||||
parsedHosts.push(host);
|
||||
|
||||
// Store ProxyJump using hostname key (survives deduplication)
|
||||
if (block.proxyJump && block.proxyJump.toLowerCase() !== "none") {
|
||||
const hostKey = makeHostKey(hostname, block.port);
|
||||
hostProxyJumpMap.set(hostKey, block.proxyJump);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { hosts: dedupedHosts, duplicates } = dedupeHosts(parsedHosts);
|
||||
|
||||
const hostnameToId = new Map<string, string>();
|
||||
const labelToId = new Map<string, string>();
|
||||
for (const host of dedupedHosts) {
|
||||
hostnameToId.set(host.hostname.toLowerCase(), host.id);
|
||||
labelToId.set(host.label.toLowerCase(), host.id);
|
||||
}
|
||||
|
||||
const resolveJumpHostToId = (jumpHost: ParsedJumpHost): string | null => {
|
||||
const hostnameKey = jumpHost.hostname.toLowerCase();
|
||||
if (labelToId.has(hostnameKey)) return labelToId.get(hostnameKey)!;
|
||||
if (hostnameToId.has(hostnameKey)) return hostnameToId.get(hostnameKey)!;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Collect inline hosts separately to avoid modifying array during iteration
|
||||
const inlineHosts: Host[] = [];
|
||||
|
||||
// Process ProxyJump for each host (iterate over a copy to avoid issues)
|
||||
const hostsToProcess = [...dedupedHosts];
|
||||
for (const host of hostsToProcess) {
|
||||
const hostKey = makeHostKey(host.hostname, host.port);
|
||||
const proxyJumpValue = hostProxyJumpMap.get(hostKey);
|
||||
if (!proxyJumpValue) continue;
|
||||
|
||||
const jumpHosts = parseProxyJump(proxyJumpValue);
|
||||
if (jumpHosts.length === 0) continue;
|
||||
|
||||
const resolvedIds: string[] = [];
|
||||
const unresolvedJumps: string[] = [];
|
||||
|
||||
for (const jumpHost of jumpHosts) {
|
||||
const existingId = resolveJumpHostToId(jumpHost);
|
||||
if (existingId) {
|
||||
// Avoid duplicate IDs in the chain
|
||||
if (!resolvedIds.includes(existingId)) {
|
||||
resolvedIds.push(existingId);
|
||||
}
|
||||
} else {
|
||||
// Check if we already created an inline host for this
|
||||
const inlineKey = jumpHost.hostname.toLowerCase();
|
||||
let inlineId = hostnameToId.get(inlineKey);
|
||||
|
||||
if (!inlineId) {
|
||||
const inlineHost = createHost({
|
||||
label: jumpHost.hostname,
|
||||
hostname: jumpHost.hostname,
|
||||
username: jumpHost.username,
|
||||
port: jumpHost.port,
|
||||
protocol: "ssh",
|
||||
});
|
||||
inlineHosts.push(inlineHost);
|
||||
hostnameToId.set(inlineHost.hostname.toLowerCase(), inlineHost.id);
|
||||
labelToId.set(inlineHost.label.toLowerCase(), inlineHost.id);
|
||||
inlineId = inlineHost.id;
|
||||
unresolvedJumps.push(jumpHost.hostname);
|
||||
}
|
||||
|
||||
if (!resolvedIds.includes(inlineId)) {
|
||||
resolvedIds.push(inlineId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedIds.length > 0) {
|
||||
// Cycle detection: check if this host appears in its own chain
|
||||
if (resolvedIds.includes(host.id)) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `ssh_config: detected circular reference in ProxyJump for "${host.label}", skipping chain.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostChain: HostChainConfig = { hostIds: resolvedIds };
|
||||
host.hostChain = hostChain;
|
||||
}
|
||||
|
||||
if (unresolvedJumps.length > 0) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `ssh_config: created inline jump host(s) for "${host.label}": ${unresolvedJumps.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add inline hosts to the final result
|
||||
const allHosts = [...dedupedHosts, ...inlineHosts];
|
||||
|
||||
// Deep cycle detection: check for indirect cycles (A -> B -> C -> A)
|
||||
const detectCycle = (hostId: string, visited: Set<string>): boolean => {
|
||||
if (visited.has(hostId)) return true;
|
||||
visited.add(hostId);
|
||||
const host = allHosts.find(h => h.id === hostId);
|
||||
if (host?.hostChain?.hostIds) {
|
||||
for (const chainId of host.hostChain.hostIds) {
|
||||
if (detectCycle(chainId, visited)) return true;
|
||||
}
|
||||
}
|
||||
visited.delete(hostId);
|
||||
return false;
|
||||
};
|
||||
|
||||
// Remove chains that form cycles
|
||||
for (const host of allHosts) {
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
if (detectCycle(host.id, new Set())) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `ssh_config: detected circular dependency in jump chain for "${host.label}", removing chain.`,
|
||||
});
|
||||
delete host.hostChain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { hosts, duplicates } = dedupeHosts(parsedHosts);
|
||||
return {
|
||||
hosts,
|
||||
hosts: allHosts,
|
||||
groups: [],
|
||||
issues,
|
||||
stats: { parsed, imported: hosts.length, skipped, duplicates },
|
||||
stats: { parsed, imported: allHosts.length, skipped, duplicates },
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
110
electron-builder.config.cjs
Normal file
@@ -0,0 +1,110 @@
|
||||
/* global __dirname */
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* @type {import('electron-builder').Configuration}
|
||||
*/
|
||||
module.exports = {
|
||||
appId: 'com.netcatty.app',
|
||||
productName: 'Netcatty',
|
||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||
icon: 'public/icon.png',
|
||||
directories: {
|
||||
buildResources: 'build',
|
||||
output: 'release'
|
||||
},
|
||||
files: [
|
||||
'dist/**/*',
|
||||
'electron/**/*',
|
||||
'!electron/.dev-config.json',
|
||||
'public/**/*',
|
||||
'node_modules/**/*'
|
||||
],
|
||||
asarUnpack: [
|
||||
'node_modules/node-pty/**/*',
|
||||
'node_modules/ssh2/**/*',
|
||||
'node_modules/cpu-features/**/*'
|
||||
],
|
||||
mac: {
|
||||
target: [
|
||||
{
|
||||
target: 'dmg',
|
||||
arch: ['arm64', 'x64']
|
||||
},
|
||||
{
|
||||
target: 'zip',
|
||||
arch: ['arm64', 'x64']
|
||||
}
|
||||
],
|
||||
category: 'public.app-category.developer-tools',
|
||||
hardenedRuntime: false,
|
||||
gatekeeperAssess: false,
|
||||
entitlements: 'electron/entitlements.mac.plist',
|
||||
entitlementsInherit: 'electron/entitlements.mac.plist',
|
||||
extendInfo: {
|
||||
NSCameraUsageDescription: 'Netcatty may use the camera for video calls',
|
||||
NSMicrophoneUsageDescription: 'Netcatty may use the microphone for audio',
|
||||
NSLocalNetworkUsageDescription: 'Netcatty needs local network access for SSH connections'
|
||||
}
|
||||
},
|
||||
dmg: {
|
||||
title: '${productName}',
|
||||
background: 'public/dmg-background.jpg',
|
||||
iconSize: 100,
|
||||
iconTextSize: 12,
|
||||
window: {
|
||||
width: 672,
|
||||
height: 500
|
||||
},
|
||||
contents: [
|
||||
{ x: 150, y: 158 },
|
||||
{ x: 550, y: 158, type: 'link', path: '/Applications' },
|
||||
{
|
||||
x: 350,
|
||||
y: 330,
|
||||
type: 'file',
|
||||
// Use absolute path resolved at build time
|
||||
path: path.resolve(__dirname, 'scripts/FixQuarantine.app'),
|
||||
name: '已损坏修复.app'
|
||||
}
|
||||
]
|
||||
},
|
||||
win: {
|
||||
target: [
|
||||
{
|
||||
target: 'nsis',
|
||||
arch: ['x64']
|
||||
},
|
||||
{
|
||||
target: 'dir',
|
||||
arch: ['x64']
|
||||
}
|
||||
]
|
||||
},
|
||||
nsis: {
|
||||
oneClick: false,
|
||||
perMachine: false,
|
||||
allowElevation: true,
|
||||
allowToChangeInstallationDirectory: true,
|
||||
createDesktopShortcut: true,
|
||||
createStartMenuShortcut: true,
|
||||
shortcutName: 'Netcatty'
|
||||
},
|
||||
linux: {
|
||||
target: [
|
||||
{
|
||||
target: 'AppImage',
|
||||
arch: ['x64', 'arm64']
|
||||
},
|
||||
{
|
||||
target: 'deb',
|
||||
arch: ['x64', 'arm64']
|
||||
},
|
||||
{
|
||||
target: 'rpm',
|
||||
arch: ['x64', 'arm64']
|
||||
}
|
||||
],
|
||||
category: 'Development'
|
||||
}
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
||||
"appId": "com.netcatty.app",
|
||||
"productName": "Netcatty",
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "public/icon.png",
|
||||
"directories": {
|
||||
"buildResources": "build",
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*",
|
||||
"!electron/.dev-config.json",
|
||||
"public/**/*",
|
||||
"node_modules/**/*"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/node-pty/**/*",
|
||||
"node_modules/ssh2/**/*",
|
||||
"node_modules/cpu-features/**/*"
|
||||
],
|
||||
"mac": {
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["arm64", "x64"]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": ["arm64", "x64"]
|
||||
}
|
||||
],
|
||||
"category": "public.app-category.developer-tools",
|
||||
"hardenedRuntime": false,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||
"extendInfo": {
|
||||
"NSCameraUsageDescription": "Netcatty may use the camera for video calls",
|
||||
"NSMicrophoneUsageDescription": "Netcatty may use the microphone for audio",
|
||||
"NSLocalNetworkUsageDescription": "Netcatty needs local network access for SSH connections"
|
||||
}
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"target": "dir",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowElevation": true,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "Netcatty"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"target": "dir",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
],
|
||||
"category": "Development"
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
104
electron/bridges/keyboardInteractiveHandler.cjs
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Keyboard Interactive Handler - Shared state for keyboard-interactive authentication
|
||||
* This module provides a centralized storage for keyboard-interactive auth requests
|
||||
* used by SSH, SFTP, and Port Forwarding bridges.
|
||||
*/
|
||||
|
||||
// Keyboard-interactive authentication pending requests
|
||||
// Map of requestId -> { finishCallback, webContentsId, sessionId, createdAt, timeoutId }
|
||||
const keyboardInteractiveRequests = new Map();
|
||||
|
||||
// TTL for abandoned requests (5 minutes)
|
||||
const REQUEST_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Generate a unique request ID for keyboard-interactive requests
|
||||
*/
|
||||
function generateRequestId(prefix = 'ki') {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a keyboard-interactive request with TTL cleanup
|
||||
*/
|
||||
function storeRequest(requestId, finishCallback, webContentsId, sessionId) {
|
||||
// Set up TTL timeout to clean up abandoned requests
|
||||
const timeoutId = setTimeout(() => {
|
||||
const pending = keyboardInteractiveRequests.get(requestId);
|
||||
if (pending) {
|
||||
console.warn(`[KeyboardInteractive] Request ${requestId} timed out after ${REQUEST_TTL_MS / 1000}s, cleaning up`);
|
||||
keyboardInteractiveRequests.delete(requestId);
|
||||
// Call finish with empty responses to abort the authentication
|
||||
try {
|
||||
pending.finishCallback([]);
|
||||
} catch (err) {
|
||||
console.warn(`[KeyboardInteractive] Failed to call finishCallback for timed out request:`, err.message);
|
||||
}
|
||||
}
|
||||
}, REQUEST_TTL_MS);
|
||||
|
||||
keyboardInteractiveRequests.set(requestId, {
|
||||
finishCallback,
|
||||
webContentsId,
|
||||
sessionId,
|
||||
createdAt: Date.now(),
|
||||
timeoutId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard-interactive authentication response from renderer
|
||||
*/
|
||||
function handleResponse(_event, payload) {
|
||||
console.log(`[KeyboardInteractive] handleResponse called with payload:`, JSON.stringify(payload));
|
||||
|
||||
const { requestId, responses, cancelled } = payload;
|
||||
const pending = keyboardInteractiveRequests.get(requestId);
|
||||
|
||||
console.log(`[KeyboardInteractive] Looking for request ${requestId}, found:`, !!pending);
|
||||
console.log(`[KeyboardInteractive] Current pending requests:`, Array.from(keyboardInteractiveRequests.keys()));
|
||||
|
||||
if (!pending) {
|
||||
console.warn(`[KeyboardInteractive] No pending request for ${requestId}`);
|
||||
return { success: false, error: 'Request not found' };
|
||||
}
|
||||
|
||||
// Clear the TTL timeout since we received a response
|
||||
if (pending.timeoutId) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
}
|
||||
|
||||
keyboardInteractiveRequests.delete(requestId);
|
||||
|
||||
if (cancelled) {
|
||||
console.log(`[KeyboardInteractive] Auth cancelled for ${requestId}`);
|
||||
pending.finishCallback([]); // Empty responses to cancel
|
||||
} else {
|
||||
console.log(`[KeyboardInteractive] Auth response received for ${requestId}, responses count:`, responses?.length);
|
||||
pending.finishCallback(responses);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the requests map (for debugging/testing)
|
||||
*/
|
||||
function getRequests() {
|
||||
return keyboardInteractiveRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handler for keyboard-interactive responses
|
||||
*/
|
||||
function registerHandler(ipcMain) {
|
||||
ipcMain.handle("netcatty:keyboard-interactive:respond", handleResponse);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateRequestId,
|
||||
storeRequest,
|
||||
handleResponse,
|
||||
getRequests,
|
||||
registerHandler,
|
||||
};
|
||||
@@ -6,13 +6,35 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
/**
|
||||
* Check if a file is hidden on Windows using the attrib command
|
||||
* Returns true if the file has the hidden attribute set
|
||||
*/
|
||||
function isWindowsHiddenFile(filePath) {
|
||||
if (process.platform !== "win32") return false;
|
||||
try {
|
||||
const output = execSync(`attrib "${filePath}"`, { encoding: "utf8" });
|
||||
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
|
||||
// The attributes appear in the first ~10 characters before the path
|
||||
const attrPart = output.substring(0, output.indexOf(filePath)).toUpperCase();
|
||||
return attrPart.includes("H");
|
||||
} catch (err) {
|
||||
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a local directory
|
||||
* Properly handles symlinks by resolving their target type
|
||||
* On Windows, also detects hidden files using the hidden attribute
|
||||
*/
|
||||
async function listLocalDir(event, payload) {
|
||||
const dirPath = payload.path;
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// Stat entries in parallel with a small concurrency limit.
|
||||
// Serial stats can be very slow on Windows for large dirs.
|
||||
@@ -27,19 +49,59 @@ 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";
|
||||
}
|
||||
|
||||
// Check for Windows hidden attribute
|
||||
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
|
||||
|
||||
result[i] = {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory()
|
||||
? "directory"
|
||||
: entry.isSymbolicLink()
|
||||
? "symlink"
|
||||
: "file",
|
||||
type,
|
||||
linkTarget,
|
||||
size: `${stat.size} bytes`,
|
||||
lastModified: stat.mtime.toISOString(),
|
||||
hidden,
|
||||
};
|
||||
} 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
|
||||
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
|
||||
result[i] = {
|
||||
name: brokenEntry.name,
|
||||
type: "symlink",
|
||||
linkTarget: null, // Broken link - target unknown
|
||||
size: `${lstat.size} bytes`,
|
||||
lastModified: lstat.mtime.toISOString(),
|
||||
hidden,
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,31 @@
|
||||
|
||||
const net = require("node:net");
|
||||
const { Client: SSHClient } = require("ssh2");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
|
||||
// Active port forwarding tunnels
|
||||
const portForwardingTunnels = new Map();
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a port forwarding tunnel
|
||||
*/
|
||||
async function startPortForward(event, payload) {
|
||||
const {
|
||||
tunnelId,
|
||||
const {
|
||||
tunnelId,
|
||||
type, // 'local' | 'remote' | 'dynamic'
|
||||
localPort,
|
||||
localPort,
|
||||
bindAddress = '127.0.0.1',
|
||||
remoteHost,
|
||||
remotePort,
|
||||
@@ -26,34 +39,88 @@ async function startPortForward(event, payload) {
|
||||
password,
|
||||
privateKey,
|
||||
} = payload;
|
||||
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new SSHClient();
|
||||
const sender = event.sender;
|
||||
|
||||
|
||||
const sendStatus = (status, error = null) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send("netcatty:portforward:status", { tunnelId, status, error });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const connectOpts = {
|
||||
host: hostname,
|
||||
port: port,
|
||||
username: username || 'root',
|
||||
readyTimeout: 30000,
|
||||
readyTimeout: 120000, // 2 minutes for 2FA input
|
||||
keepaliveInterval: 10000,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
};
|
||||
|
||||
|
||||
if (privateKey) {
|
||||
connectOpts.privateKey = privateKey;
|
||||
} else if (password) {
|
||||
}
|
||||
if (password) {
|
||||
connectOpts.password = password;
|
||||
}
|
||||
|
||||
|
||||
// Build auth handler with keyboard-interactive support
|
||||
const authMethods = [];
|
||||
if (privateKey) authMethods.push("publickey");
|
||||
if (password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
connectOpts.authHandler = authMethods;
|
||||
|
||||
// Handle keyboard-interactive authentication (2FA/MFA)
|
||||
conn.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`[PortForward] ${hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`[PortForward] No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('pf');
|
||||
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`[PortForward] Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, tunnelId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId: tunnelId,
|
||||
name: name || "",
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: hostname,
|
||||
savedPassword: password || null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
conn.on('ready', () => {
|
||||
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);
|
||||
|
||||
|
||||
if (type === 'local') {
|
||||
// LOCAL FORWARDING: Listen on local port, forward to remote
|
||||
const server = net.createServer((socket) => {
|
||||
@@ -69,13 +136,13 @@ async function startPortForward(event, payload) {
|
||||
return;
|
||||
}
|
||||
socket.pipe(stream).pipe(socket);
|
||||
|
||||
|
||||
socket.on('error', (e) => console.warn('[PortForward] Socket error:', e.message));
|
||||
stream.on('error', (e) => console.warn('[PortForward] Stream error:', e.message));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error(`[PortForward] Server error:`, err.message);
|
||||
sendStatus('error', err.message);
|
||||
@@ -83,19 +150,19 @@ async function startPortForward(event, payload) {
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
|
||||
server.listen(localPort, bindAddress, () => {
|
||||
console.log(`[PortForward] Local forwarding active: ${bindAddress}:${localPort} -> ${remoteHost}:${remotePort}`);
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'local',
|
||||
conn,
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'local',
|
||||
conn,
|
||||
server,
|
||||
webContentsId: sender.id
|
||||
webContentsId: sender.id
|
||||
});
|
||||
sendStatus('active');
|
||||
resolve({ tunnelId, success: true });
|
||||
});
|
||||
|
||||
|
||||
} else if (type === 'remote') {
|
||||
// REMOTE FORWARDING: Listen on remote port, forward to local
|
||||
conn.forwardIn(bindAddress, localPort, (err) => {
|
||||
@@ -106,24 +173,24 @@ async function startPortForward(event, payload) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
console.log(`[PortForward] Remote forwarding active: remote ${bindAddress}:${localPort} -> local ${remoteHost}:${remotePort}`);
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'remote',
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'remote',
|
||||
conn,
|
||||
webContentsId: sender.id
|
||||
webContentsId: sender.id
|
||||
});
|
||||
sendStatus('active');
|
||||
resolve({ tunnelId, success: true });
|
||||
});
|
||||
|
||||
|
||||
// Handle incoming connections from remote
|
||||
conn.on('tcp connection', (info, accept, rejectConn) => {
|
||||
const stream = accept();
|
||||
const socket = net.connect(remotePort, remoteHost || '127.0.0.1', () => {
|
||||
stream.pipe(socket).pipe(stream);
|
||||
});
|
||||
|
||||
|
||||
socket.on('error', (e) => {
|
||||
console.warn('[PortForward] Local socket error:', e.message);
|
||||
stream.end();
|
||||
@@ -133,7 +200,7 @@ async function startPortForward(event, payload) {
|
||||
socket.end();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
} else if (type === 'dynamic') {
|
||||
// DYNAMIC FORWARDING (SOCKS5 Proxy)
|
||||
const server = net.createServer((socket) => {
|
||||
@@ -143,10 +210,10 @@ async function startPortForward(event, payload) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Reply: version, no auth required
|
||||
socket.write(Buffer.from([0x05, 0x00]));
|
||||
|
||||
|
||||
// Wait for connection request
|
||||
socket.once('data', (request) => {
|
||||
if (request[0] !== 0x05 || request[1] !== 0x01) {
|
||||
@@ -154,10 +221,10 @@ async function startPortForward(event, payload) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
let targetHost, targetPort;
|
||||
const addressType = request[3];
|
||||
|
||||
|
||||
if (addressType === 0x01) {
|
||||
// IPv4
|
||||
targetHost = `${request[4]}.${request[5]}.${request[6]}.${request[7]}`;
|
||||
@@ -177,7 +244,7 @@ async function startPortForward(event, payload) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Forward through SSH tunnel
|
||||
conn.forwardOut(
|
||||
bindAddress,
|
||||
@@ -190,7 +257,7 @@ async function startPortForward(event, payload) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Success reply
|
||||
const reply = Buffer.alloc(10);
|
||||
reply[0] = 0x05;
|
||||
@@ -199,9 +266,9 @@ async function startPortForward(event, payload) {
|
||||
reply[3] = 0x01;
|
||||
reply.writeUInt16BE(0, 8);
|
||||
socket.write(reply);
|
||||
|
||||
|
||||
socket.pipe(stream).pipe(socket);
|
||||
|
||||
|
||||
socket.on('error', () => stream.end());
|
||||
stream.on('error', () => socket.end());
|
||||
}
|
||||
@@ -209,7 +276,7 @@ async function startPortForward(event, payload) {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error(`[PortForward] SOCKS server error:`, err.message);
|
||||
sendStatus('error', err.message);
|
||||
@@ -217,14 +284,14 @@ async function startPortForward(event, payload) {
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
|
||||
server.listen(localPort, bindAddress, () => {
|
||||
console.log(`[PortForward] Dynamic SOCKS5 proxy active on ${bindAddress}:${localPort}`);
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'dynamic',
|
||||
conn,
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'dynamic',
|
||||
conn,
|
||||
server,
|
||||
webContentsId: sender.id
|
||||
webContentsId: sender.id
|
||||
});
|
||||
sendStatus('active');
|
||||
resolve({ tunnelId, success: true });
|
||||
@@ -233,26 +300,26 @@ async function startPortForward(event, payload) {
|
||||
reject(new Error(`Unknown forwarding type: ${type}`));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
conn.on('error', (err) => {
|
||||
console.error(`[PortForward] SSH error:`, err.message);
|
||||
sendStatus('error', err.message);
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
|
||||
conn.on('close', () => {
|
||||
console.log(`[PortForward] SSH connection closed for tunnel ${tunnelId}`);
|
||||
const tunnel = portForwardingTunnels.get(tunnelId);
|
||||
if (tunnel) {
|
||||
if (tunnel.server) {
|
||||
try { tunnel.server.close(); } catch {}
|
||||
try { tunnel.server.close(); } catch { }
|
||||
}
|
||||
sendStatus('inactive');
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
sendStatus('connecting');
|
||||
conn.connect(connectOpts);
|
||||
});
|
||||
@@ -264,11 +331,11 @@ async function startPortForward(event, payload) {
|
||||
async function stopPortForward(event, payload) {
|
||||
const { tunnelId } = payload;
|
||||
const tunnel = portForwardingTunnels.get(tunnelId);
|
||||
|
||||
|
||||
if (!tunnel) {
|
||||
return { tunnelId, success: false, error: 'Tunnel not found' };
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if (tunnel.server) {
|
||||
tunnel.server.close();
|
||||
@@ -277,7 +344,7 @@ async function stopPortForward(event, payload) {
|
||||
tunnel.conn.end();
|
||||
}
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
|
||||
|
||||
return { tunnelId, success: true };
|
||||
} catch (err) {
|
||||
return { tunnelId, success: false, error: err.message };
|
||||
@@ -290,11 +357,11 @@ async function stopPortForward(event, payload) {
|
||||
async function getPortForwardStatus(event, payload) {
|
||||
const { tunnelId } = payload;
|
||||
const tunnel = portForwardingTunnels.get(tunnelId);
|
||||
|
||||
|
||||
if (!tunnel) {
|
||||
return { tunnelId, status: 'inactive' };
|
||||
}
|
||||
|
||||
|
||||
return { tunnelId, status: 'active', type: tunnel.type };
|
||||
}
|
||||
|
||||
@@ -313,6 +380,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 +418,5 @@ module.exports = {
|
||||
stopPortForward,
|
||||
getPortForwardStatus,
|
||||
listPortForwards,
|
||||
stopAllPortForwards,
|
||||
};
|
||||
|
||||
135
electron/bridges/proxyUtils.cjs
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Proxy Utilities - Shared proxy socket creation for SSH connections
|
||||
* Extracted from sshBridge.cjs and sftpBridge.cjs to eliminate code duplication
|
||||
*/
|
||||
|
||||
const net = require("node:net");
|
||||
|
||||
/**
|
||||
* Create a socket through a proxy (HTTP CONNECT or SOCKS5)
|
||||
* @param {Object} proxy - Proxy configuration
|
||||
* @param {string} proxy.type - 'http' or 'socks5'
|
||||
* @param {string} proxy.host - Proxy host
|
||||
* @param {number} proxy.port - Proxy port
|
||||
* @param {string} [proxy.username] - Optional username for auth
|
||||
* @param {string} [proxy.password] - Optional password for auth
|
||||
* @param {string} targetHost - Target host to connect through proxy
|
||||
* @param {number} targetPort - Target port to connect through proxy
|
||||
* @returns {Promise<net.Socket>} Connected socket through proxy
|
||||
*/
|
||||
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}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createProxySocket,
|
||||
};
|
||||
@@ -6,13 +6,44 @@
|
||||
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");
|
||||
let SFTPWrapper;
|
||||
try {
|
||||
// Try to load SFTPWrapper from ssh2 internals for sudo support
|
||||
const sftpModule = require("ssh2/lib/protocol/SFTP");
|
||||
SFTPWrapper = sftpModule.SFTP || sftpModule;
|
||||
} catch (e) {
|
||||
console.warn("[SFTP] Failed to load SFTPWrapper from ssh2, sudo mode will not work:", e.message);
|
||||
}
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.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 }
|
||||
|
||||
// Storage for active SFTP uploads that can be cancelled
|
||||
const activeSftpUploads = new Map(); // transferId -> { cancelled: boolean, stream: Readable }
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the SFTP bridge with dependencies
|
||||
*/
|
||||
@@ -21,18 +52,429 @@ function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
|
||||
kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'],
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish an SFTP connection using sudo
|
||||
* @param {SSHClient} client - Connected SSH client
|
||||
* @param {string} password - User password for sudo
|
||||
*/
|
||||
async function connectSudoSftp(client, password) {
|
||||
if (!SFTPWrapper) {
|
||||
throw new Error("SFTP sudo mode is not available on this platform. Please disable sudo mode in host settings.");
|
||||
}
|
||||
|
||||
// Known sftp-server paths to try
|
||||
const sftpPaths = [
|
||||
"/usr/lib/openssh/sftp-server",
|
||||
"/usr/libexec/openssh/sftp-server",
|
||||
"/usr/lib/ssh/sftp-server",
|
||||
"/usr/libexec/sftp-server",
|
||||
"/usr/local/libexec/sftp-server",
|
||||
"/usr/local/lib/sftp-server"
|
||||
];
|
||||
|
||||
console.log("[SFTP] Probing sftp-server path for sudo mode...");
|
||||
|
||||
let serverPath = null;
|
||||
// Try to find the path
|
||||
for (const p of sftpPaths) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
client.exec(`test -x ${p}`, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
stream.on('exit', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error('Not found'));
|
||||
});
|
||||
});
|
||||
});
|
||||
serverPath = p;
|
||||
break;
|
||||
} catch (e) {
|
||||
// Continue probing
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverPath) {
|
||||
// Fallback: try to find it in path or assume standard location
|
||||
console.warn("[SFTP] Could not probe sftp-server, trying default /usr/lib/openssh/sftp-server");
|
||||
serverPath = "/usr/lib/openssh/sftp-server";
|
||||
} else {
|
||||
console.log(`[SFTP] Found sftp-server at ${serverPath}`);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Use sudo -S to read password from stdin
|
||||
// Use -p '' to set a specific prompt we can detect
|
||||
// Use sh -c 'printf SFTPREADY; exec ...' to synchronize the start of sftp-server
|
||||
// We use printf instead of echo to avoid trailing newline which could confuse SFTPWrapper
|
||||
const prompt = "SUDOPASSWORD:";
|
||||
const readyMarker = "SFTPREADY";
|
||||
const readyMarkerBuffer = Buffer.from(readyMarker);
|
||||
// Add -e to sftp-server to log to stderr for debugging
|
||||
const cmd = `sudo -S -p '${prompt}' sh -c 'printf ${readyMarker}; exec ${serverPath} -e'`;
|
||||
|
||||
console.log(`[SFTP] Executing sudo command: ${cmd}`);
|
||||
|
||||
// Disable pty to ensure clean binary stream for SFTP
|
||||
client.exec(cmd, { pty: false }, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// Add stream lifecycle logging
|
||||
stream.on('close', () => console.log("[SFTP] Stream closed"));
|
||||
stream.on('end', () => console.log("[SFTP] Stream ended"));
|
||||
stream.on('error', (e) => console.error("[SFTP] Stream error:", e.message));
|
||||
|
||||
let sftpInitialized = false;
|
||||
let sftp = null;
|
||||
let settled = false;
|
||||
let stdoutBuffer = Buffer.alloc(0);
|
||||
let stderrBuffer = "";
|
||||
let pendingAfterMarker = null;
|
||||
let sftpCreated = false;
|
||||
const timeoutMs = 20000;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (sftpInitialized || settled) return;
|
||||
settled = true;
|
||||
stream.stderr?.removeListener('data', onStderr);
|
||||
stream.removeListener('data', onStdout);
|
||||
const error = new Error("SFTP sudo handshake timed out. This may happen if: (1) the password is incorrect, (2) sudo requires a TTY, or (3) the user does not have sudo privileges.");
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
|
||||
const finalize = (err, result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
stream.stderr?.removeListener('data', onStderr);
|
||||
stream.removeListener('data', onStdout);
|
||||
if (err) reject(err);
|
||||
else resolve(result);
|
||||
};
|
||||
|
||||
const createSftp = () => {
|
||||
if (sftpCreated) return;
|
||||
sftpCreated = true;
|
||||
try {
|
||||
const chanInfo = {
|
||||
type: 'sftp',
|
||||
incoming: stream.incoming,
|
||||
outgoing: stream.outgoing
|
||||
};
|
||||
sftp = new SFTPWrapper(client, chanInfo, {
|
||||
// debug: (str) => console.log(`[SFTP DEBUG] ${str}`)
|
||||
});
|
||||
|
||||
// Route any remaining channel data directly into the SFTP parser
|
||||
if (client._chanMgr && typeof stream.incoming?.id === "number") {
|
||||
client._chanMgr.update(stream.incoming.id, sftp);
|
||||
}
|
||||
|
||||
sftp.on('ready', () => {
|
||||
sftpInitialized = true;
|
||||
console.log("[SFTP] Protocol ready");
|
||||
finalize(null, sftp);
|
||||
});
|
||||
|
||||
sftp.on('error', (err) => {
|
||||
console.error("[SFTP] Protocol error:", err.message);
|
||||
if (!sftpInitialized) {
|
||||
finalize(err);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
try { sftp.push(null); } catch { }
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[SFTP] Initialization failed:", e.message);
|
||||
finalize(e);
|
||||
}
|
||||
};
|
||||
|
||||
const initSftp = () => {
|
||||
if (sftpInitialized) return;
|
||||
console.log("[SFTP] Sudo success, initializing SFTP protocol...");
|
||||
if (!sftpCreated) createSftp();
|
||||
try {
|
||||
// Start the handshake
|
||||
console.log("[SFTP] Sending INIT packet...");
|
||||
sftp._init();
|
||||
if (pendingAfterMarker && pendingAfterMarker.length > 0) {
|
||||
try {
|
||||
sftp.push(pendingAfterMarker);
|
||||
} catch (pushErr) {
|
||||
console.warn("[SFTP] Failed to push buffered data:", pushErr.message);
|
||||
}
|
||||
pendingAfterMarker = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[SFTP] Initialization failed:", e.message);
|
||||
finalize(e);
|
||||
}
|
||||
};
|
||||
|
||||
const onStdout = (data) => {
|
||||
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
stdoutBuffer = stdoutBuffer.length > 0 ? Buffer.concat([stdoutBuffer, chunk]) : chunk;
|
||||
const markerIndex = stdoutBuffer.indexOf(readyMarkerBuffer);
|
||||
if (markerIndex !== -1) {
|
||||
const afterMarkerIndex = markerIndex + readyMarkerBuffer.length;
|
||||
if (afterMarkerIndex < stdoutBuffer.length) {
|
||||
pendingAfterMarker = stdoutBuffer.subarray(afterMarkerIndex);
|
||||
}
|
||||
// Found marker, stop listening to stdout here so SFTPWrapper can take over
|
||||
stream.removeListener('data', onStdout);
|
||||
stdoutBuffer = Buffer.alloc(0);
|
||||
|
||||
console.log("[SFTP] SFTPREADY detected, waiting for stream to stabilize...");
|
||||
|
||||
// Delay SFTP initialization to ensure sftp-server is fully started and stream is clean
|
||||
// Increased timeout to 1000ms to be safe
|
||||
setTimeout(() => {
|
||||
initSftp();
|
||||
}, 1000);
|
||||
} else if (stdoutBuffer.length > 256) {
|
||||
stdoutBuffer = stdoutBuffer.subarray(stdoutBuffer.length - 256);
|
||||
}
|
||||
};
|
||||
|
||||
const onStderr = (data) => {
|
||||
const chunk = data.toString();
|
||||
// Only log that we received stderr data, not the content (may contain sensitive prompts)
|
||||
stderrBuffer += chunk;
|
||||
if (stderrBuffer.includes(prompt)) {
|
||||
console.log("[SFTP] Sudo requested password, sending...");
|
||||
// Send password
|
||||
if (password) {
|
||||
stream.write(password + '\n');
|
||||
} else {
|
||||
console.warn('[SFTP] sudo requested password but none provided');
|
||||
stream.write('\n');
|
||||
}
|
||||
stderrBuffer = "";
|
||||
} else if (stderrBuffer.length > 256) {
|
||||
stderrBuffer = stderrBuffer.slice(-256);
|
||||
}
|
||||
};
|
||||
|
||||
stream.on('data', onStdout);
|
||||
stream.stderr.on('data', onStderr);
|
||||
|
||||
// Error handling
|
||||
stream.on('exit', (code) => {
|
||||
console.log(`[SFTP] Stream exited with code ${code}`);
|
||||
if (!sftpInitialized && code !== 0) {
|
||||
let errorMsg = `SFTP sudo failed with exit code ${code}.`;
|
||||
if (code === 1) {
|
||||
errorMsg += " The password may be incorrect or sudo privileges are denied.";
|
||||
} else if (code === 127) {
|
||||
errorMsg += " sftp-server was not found on the remote system.";
|
||||
}
|
||||
const error = new Error(errorMsg);
|
||||
finalize(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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",
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 120000, // 2 minutes for 2FA input
|
||||
};
|
||||
|
||||
|
||||
// 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;
|
||||
@@ -59,27 +501,207 @@ async function openSftp(event, options) {
|
||||
const order = ["agent"];
|
||||
if (connectOpts.password) order.push("password");
|
||||
connectOpts.authHandler = order;
|
||||
} else if (options.privateKey && connectOpts.password) {
|
||||
// Prefer key auth when both key and password are present (password still needed for sudo)
|
||||
connectOpts.authHandler = ["publickey", "password"];
|
||||
}
|
||||
|
||||
// Add keyboard-interactive authentication support
|
||||
// ssh2-sftp-client exposes the underlying ssh2 Client through its `on` method
|
||||
const kiHandler = (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`[SFTP] ${options.hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`[SFTP] No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('sftp');
|
||||
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`[SFTP] Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, event.sender.id, connId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(event.sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId: connId,
|
||||
name: name || "",
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: options.hostname,
|
||||
savedPassword: options.password || null,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Add keyboard-interactive listener BEFORE connecting
|
||||
client.on("keyboard-interactive", kiHandler);
|
||||
|
||||
// Enable keyboard-interactive authentication in authHandler
|
||||
if (connectOpts.authHandler) {
|
||||
// Add keyboard-interactive after the existing methods
|
||||
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
|
||||
connectOpts.authHandler.push("keyboard-interactive");
|
||||
}
|
||||
} else {
|
||||
// Create authHandler with keyboard-interactive support
|
||||
const authMethods = [];
|
||||
if (connectOpts.privateKey) authMethods.push("publickey");
|
||||
if (connectOpts.password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
connectOpts.authHandler = authMethods;
|
||||
}
|
||||
|
||||
// Increase timeout to allow for keyboard-interactive auth
|
||||
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
|
||||
|
||||
try {
|
||||
if (options.sudo) {
|
||||
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
|
||||
const sshClient = client.client;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
// Set up error handler for initial connection
|
||||
const onConnectError = (err) => reject(err);
|
||||
sshClient.once('error', onConnectError);
|
||||
|
||||
sshClient.once('ready', async () => {
|
||||
sshClient.removeListener('error', onConnectError);
|
||||
try {
|
||||
// Use provided password or try empty if using key auth (and hope for nopasswd sudo)
|
||||
const sudoPass = options.password || "";
|
||||
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
|
||||
|
||||
// Inject into sftp-client
|
||||
client.sftp = sftpWrapper;
|
||||
|
||||
// Important: attach cleanup listener expected by sftp-client
|
||||
client.sftp.on('close', () => client.end());
|
||||
|
||||
resolve();
|
||||
} catch (e) {
|
||||
sshClient.end();
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
sshClient.connect(connectOpts);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
|
||||
await client.connect(connectOpts);
|
||||
sftpClients.set(connId, client);
|
||||
return { sftpId: connId };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,45 +710,77 @@ async function listSftp(event, payload) {
|
||||
async function readSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
const buffer = await client.get(payload.path);
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file as binary (returns ArrayBuffer for binary files like images)
|
||||
*/
|
||||
async function readSftpBinary(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
const buffer = await client.get(payload.path);
|
||||
// Convert Node.js Buffer to ArrayBuffer
|
||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write file content
|
||||
*/
|
||||
async function writeSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
await client.put(Buffer.from(payload.content, "utf-8"), payload.path);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write binary data with progress callback
|
||||
* Supports cancellation via activeSftpUploads map
|
||||
* Optimized for performance with throttled progress updates
|
||||
*/
|
||||
async function writeSftpBinaryWithProgress(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
const { sftpId, path: remotePath, content, transferId } = payload;
|
||||
const buffer = Buffer.from(content);
|
||||
|
||||
// Optimize: Use Buffer.isBuffer to avoid unnecessary copy if already a Buffer
|
||||
// For ArrayBuffer from renderer, we still need to convert but use a more efficient method
|
||||
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
||||
const totalBytes = buffer.length;
|
||||
let transferredBytes = 0;
|
||||
let lastProgressTime = Date.now();
|
||||
let lastTransferredBytes = 0;
|
||||
|
||||
let lastProgressSentTime = 0;
|
||||
|
||||
// Throttle settings: send progress at most every 100ms or every 1MB
|
||||
const PROGRESS_THROTTLE_MS = 100;
|
||||
const PROGRESS_THROTTLE_BYTES = 1024 * 1024; // 1MB
|
||||
let lastProgressSentBytes = 0;
|
||||
|
||||
const { Readable } = require("stream");
|
||||
const readableStream = new Readable({
|
||||
read() {
|
||||
const chunkSize = 65536;
|
||||
// Check for cancellation
|
||||
const uploadState = activeSftpUploads.get(transferId);
|
||||
if (uploadState?.cancelled) {
|
||||
this.destroy(new Error("Upload cancelled"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Use larger chunk size for better performance (256KB instead of 64KB)
|
||||
const chunkSize = 262144;
|
||||
if (transferredBytes < totalBytes) {
|
||||
const end = Math.min(transferredBytes + chunkSize, totalBytes);
|
||||
const chunk = buffer.slice(transferredBytes, end);
|
||||
// Use subarray instead of slice to avoid copying
|
||||
const chunk = buffer.subarray(transferredBytes, end);
|
||||
transferredBytes = end;
|
||||
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = (now - lastProgressTime) / 1000;
|
||||
let speed = 0;
|
||||
@@ -135,49 +789,110 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
lastProgressTime = now;
|
||||
lastTransferredBytes = transferredBytes;
|
||||
}
|
||||
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:progress", {
|
||||
transferId,
|
||||
transferred: transferredBytes,
|
||||
totalBytes,
|
||||
speed,
|
||||
});
|
||||
|
||||
|
||||
// Throttle IPC progress events: only send if enough time or bytes have passed
|
||||
const timeSinceLastProgress = now - lastProgressSentTime;
|
||||
const bytesSinceLastProgress = transferredBytes - lastProgressSentBytes;
|
||||
const isComplete = transferredBytes >= totalBytes;
|
||||
|
||||
if (isComplete || timeSinceLastProgress >= PROGRESS_THROTTLE_MS || bytesSinceLastProgress >= PROGRESS_THROTTLE_BYTES) {
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:progress", {
|
||||
transferId,
|
||||
transferred: transferredBytes,
|
||||
totalBytes,
|
||||
speed,
|
||||
});
|
||||
lastProgressSentTime = now;
|
||||
lastProgressSentBytes = transferredBytes;
|
||||
}
|
||||
|
||||
this.push(chunk);
|
||||
} else {
|
||||
this.push(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Register this upload for potential cancellation
|
||||
activeSftpUploads.set(transferId, { cancelled: false, stream: readableStream });
|
||||
|
||||
try {
|
||||
await client.put(readableStream, remotePath);
|
||||
|
||||
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:complete", { transferId });
|
||||
|
||||
|
||||
return { success: true, transferId };
|
||||
} catch (err) {
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:error", { transferId, error: err.message });
|
||||
throw err;
|
||||
// Only send error if it's not a cancellation
|
||||
if (err.message !== "Upload cancelled") {
|
||||
contents?.send("netcatty:upload:error", { transferId, error: err.message });
|
||||
throw err;
|
||||
}
|
||||
contents?.send("netcatty:upload:cancelled", { transferId });
|
||||
return { success: false, transferId, cancelled: true };
|
||||
} finally {
|
||||
// Cleanup
|
||||
activeSftpUploads.delete(transferId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an in-progress SFTP upload
|
||||
* Note: We only set the cancelled flag and destroy the stream here.
|
||||
* The cleanup (deleting from activeSftpUploads) is handled by writeSftpBinaryWithProgress's finally block
|
||||
* to avoid race conditions.
|
||||
*/
|
||||
async function cancelSftpUpload(event, payload) {
|
||||
const { transferId } = payload;
|
||||
const uploadState = activeSftpUploads.get(transferId);
|
||||
if (uploadState) {
|
||||
uploadState.cancelled = true;
|
||||
try {
|
||||
uploadState.stream?.destroy();
|
||||
} catch (err) {
|
||||
// Log but continue - stream may already be destroyed
|
||||
console.warn("[SFTP] Error destroying upload stream:", err.message);
|
||||
}
|
||||
// Don't delete here - let the finally block in writeSftpBinaryWithProgress handle cleanup
|
||||
// This avoids race conditions where the upload might still be in progress
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,7 +901,7 @@ async function closeSftp(event, payload) {
|
||||
async function mkdirSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
await client.mkdir(payload.path, true);
|
||||
return true;
|
||||
}
|
||||
@@ -197,7 +912,7 @@ async function mkdirSftp(event, payload) {
|
||||
async function deleteSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
const stat = await client.stat(payload.path);
|
||||
if (stat.isDirectory) {
|
||||
await client.rmdir(payload.path, true);
|
||||
@@ -213,7 +928,7 @@ async function deleteSftp(event, payload) {
|
||||
async function renameSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
await client.rename(payload.oldPath, payload.newPath);
|
||||
return true;
|
||||
}
|
||||
@@ -224,7 +939,7 @@ async function renameSftp(event, payload) {
|
||||
async function statSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
const stat = await client.stat(payload.path);
|
||||
return {
|
||||
name: path.basename(payload.path),
|
||||
@@ -241,7 +956,7 @@ async function statSftp(event, payload) {
|
||||
async function chmodSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
await client.chmod(payload.path, parseInt(payload.mode, 8));
|
||||
return true;
|
||||
}
|
||||
@@ -253,8 +968,10 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:sftp:open", openSftp);
|
||||
ipcMain.handle("netcatty:sftp:list", listSftp);
|
||||
ipcMain.handle("netcatty:sftp:read", readSftp);
|
||||
ipcMain.handle("netcatty:sftp:readBinary", readSftpBinary);
|
||||
ipcMain.handle("netcatty:sftp:write", writeSftp);
|
||||
ipcMain.handle("netcatty:sftp:writeBinaryWithProgress", writeSftpBinaryWithProgress);
|
||||
ipcMain.handle("netcatty:sftp:cancelUpload", cancelSftpUpload);
|
||||
ipcMain.handle("netcatty:sftp:close", closeSftp);
|
||||
ipcMain.handle("netcatty:sftp:mkdir", mkdirSftp);
|
||||
ipcMain.handle("netcatty:sftp:delete", deleteSftp);
|
||||
@@ -263,14 +980,24 @@ 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,
|
||||
readSftpBinary,
|
||||
writeSftp,
|
||||
writeSftpBinaryWithProgress,
|
||||
cancelSftpUpload,
|
||||
closeSftp,
|
||||
mkdirSftp,
|
||||
deleteSftp,
|
||||
|
||||
@@ -6,14 +6,124 @@
|
||||
const net = require("node:net");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { exec } = require("node:child_process");
|
||||
const { Client: SSHClient, utils: sshUtils } = require("ssh2");
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
* @param {string} keyContent - The content of the private key file
|
||||
* @returns {boolean} - True if the key is encrypted
|
||||
*/
|
||||
function isKeyEncrypted(keyContent) {
|
||||
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
|
||||
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for legacy PEM format encryption (e.g., RSA PRIVATE KEY with encryption)
|
||||
if (keyContent.includes("Proc-Type:") && keyContent.includes("ENCRYPTED")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for OpenSSH format keys
|
||||
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
||||
try {
|
||||
// Extract the base64 content between the markers
|
||||
const base64Match = keyContent.match(
|
||||
/-----BEGIN OPENSSH PRIVATE KEY-----\s*([\s\S]*?)\s*-----END OPENSSH PRIVATE KEY-----/
|
||||
);
|
||||
if (base64Match) {
|
||||
const base64Content = base64Match[1].replace(/\s/g, "");
|
||||
const keyBuffer = Buffer.from(base64Content, "base64");
|
||||
|
||||
// OpenSSH key format: "openssh-key-v1\0" followed by cipher name
|
||||
// If ciphername is "none", the key is not encrypted
|
||||
const authMagic = "openssh-key-v1\0";
|
||||
if (keyBuffer.toString("ascii", 0, authMagic.length) === authMagic) {
|
||||
// After magic, read ciphername (length-prefixed string)
|
||||
let offset = authMagic.length;
|
||||
const cipherNameLen = keyBuffer.readUInt32BE(offset);
|
||||
offset += 4;
|
||||
const cipherName = keyBuffer.toString("ascii", offset, offset + cipherNameLen);
|
||||
return cipherName !== "none";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, assume it might be encrypted to be safe
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find default SSH private key from user's ~/.ssh directory
|
||||
* Skips encrypted keys that require a passphrase to allow password/keyboard-interactive auth
|
||||
* @returns {{ privateKey: string, keyPath: string, keyName: string } | null}
|
||||
*/
|
||||
function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
try {
|
||||
const privateKey = fs.readFileSync(keyPath, "utf8");
|
||||
// Skip encrypted keys - they require a passphrase and would abort
|
||||
// authentication before password/keyboard-interactive can be tried
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
log("Skipping encrypted default key", { keyPath, keyName: name });
|
||||
continue;
|
||||
}
|
||||
log("Found default key", { keyPath, keyName: name });
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch (e) {
|
||||
log("Failed to read default key", { keyPath, error: e.message });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Windows SSH Agent service is running
|
||||
* @returns {Promise<{ running: boolean, startupType: string | null, error: string | null }>}
|
||||
*/
|
||||
function checkWindowsSshAgent() {
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform !== "win32") {
|
||||
resolve({ running: true, startupType: null, error: null });
|
||||
return;
|
||||
}
|
||||
exec("sc query ssh-agent", (err, stdout) => {
|
||||
if (err) {
|
||||
resolve({ running: false, startupType: null, error: "SSH Agent service not found" });
|
||||
return;
|
||||
}
|
||||
const running = stdout.includes("RUNNING");
|
||||
const stopped = stdout.includes("STOPPED");
|
||||
resolve({
|
||||
running,
|
||||
startupType: stopped ? "stopped" : (running ? "running" : "unknown"),
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Simple file logger for debugging
|
||||
const logFile = path.join(require("os").tmpdir(), "netcatty-ssh.log");
|
||||
const log = (msg, data) => {
|
||||
const line = `[${new Date().toISOString()}] ${msg} ${data ? JSON.stringify(data) : ""}\n`;
|
||||
try { fs.appendFileSync(logFile, line); } catch {}
|
||||
try { fs.appendFileSync(logFile, line); } catch { }
|
||||
console.log("[SSH]", msg, data || "");
|
||||
};
|
||||
|
||||
@@ -32,6 +142,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
|
||||
*/
|
||||
@@ -40,122 +159,6 @@ function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a socket through a proxy (HTTP CONNECT or SOCKS5)
|
||||
*/
|
||||
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
|
||||
*/
|
||||
@@ -163,35 +166,39 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
const sender = event.sender;
|
||||
const connections = [];
|
||||
let currentSocket = null;
|
||||
|
||||
|
||||
const sendProgress = (hop, total, label, status) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send("netcatty:chain:progress", { hop, total, label, status });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
try {
|
||||
const totalHops = jumpHosts.length;
|
||||
|
||||
|
||||
// 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}`;
|
||||
|
||||
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'connecting');
|
||||
|
||||
|
||||
const conn = new SSHClient();
|
||||
|
||||
|
||||
// Build connection options
|
||||
const connOpts = {
|
||||
host: jump.hostname,
|
||||
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,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
|
||||
@@ -200,7 +207,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
compress: ['none'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Auth - support agent (certificate), key, and password fallback
|
||||
const hasCertificate =
|
||||
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
|
||||
@@ -230,7 +237,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
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);
|
||||
@@ -243,7 +250,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
delete connOpts.host;
|
||||
delete connOpts.port;
|
||||
}
|
||||
|
||||
|
||||
// Connect this hop
|
||||
await new Promise((resolve, reject) => {
|
||||
conn.on('ready', () => {
|
||||
@@ -263,9 +270,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
|
||||
|
||||
connections.push(conn);
|
||||
|
||||
|
||||
// Determine next target
|
||||
let nextHost, nextPort;
|
||||
if (isLast) {
|
||||
@@ -278,7 +285,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
nextHost = nextJump.hostname;
|
||||
nextPort = nextJump.port || 22;
|
||||
}
|
||||
|
||||
|
||||
// Create forward stream to next hop
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Forwarding from ${hopLabel} to ${nextHost}:${nextPort}...`);
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'forwarding');
|
||||
@@ -294,17 +301,17 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Return the final forwarded stream and all connections for cleanup
|
||||
return {
|
||||
socket: currentSocket,
|
||||
return {
|
||||
socket: currentSocket,
|
||||
connections,
|
||||
sendProgress
|
||||
sendProgress
|
||||
};
|
||||
} catch (err) {
|
||||
// Cleanup on error
|
||||
for (const conn of connections) {
|
||||
try { conn.end(); } catch {}
|
||||
try { conn.end(); } catch { }
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -321,7 +328,7 @@ async function startSSHSession(event, options) {
|
||||
const cols = options.cols || 80;
|
||||
const rows = options.rows || 24;
|
||||
const sender = event.sender;
|
||||
|
||||
|
||||
const sendProgress = (hop, total, label, status) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send("netcatty:chain:progress", { hop, total, label, status });
|
||||
@@ -332,13 +339,13 @@ async function startSSHSession(event, options) {
|
||||
const conn = new SSHClient();
|
||||
let chainConnections = [];
|
||||
let connectionSocket = null;
|
||||
|
||||
|
||||
// Determine if we have jump hosts
|
||||
const jumpHosts = options.jumpHosts || [];
|
||||
const hasJumpHosts = jumpHosts.length > 0;
|
||||
const hasProxy = !!options.proxy;
|
||||
const totalHops = jumpHosts.length + 1; // +1 for final target
|
||||
|
||||
|
||||
// Build base connection options for final target
|
||||
const connectOpts = {
|
||||
host: options.hostname,
|
||||
@@ -346,8 +353,12 @@ 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,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
|
||||
@@ -365,13 +376,16 @@ async function startSSHSession(event, options) {
|
||||
hasCertificate,
|
||||
keySource: options.keySource,
|
||||
hasPublicKey: !!options.publicKey,
|
||||
hasPrivateKey: !!options.privateKey,
|
||||
hasPassword: !!options.password,
|
||||
hasEffectivePassphrase: !!effectivePassphrase,
|
||||
});
|
||||
|
||||
|
||||
log("Auth configuration", {
|
||||
hasCertificate,
|
||||
keySource: options.keySource,
|
||||
hasPublicKey: !!options.publicKey,
|
||||
hasPrivateKey: !!options.privateKey,
|
||||
});
|
||||
|
||||
let authAgent = null;
|
||||
@@ -398,6 +412,17 @@ async function startSSHSession(event, options) {
|
||||
connectOpts.password = options.password;
|
||||
}
|
||||
|
||||
// Fallback to default SSH key if no authentication method is configured
|
||||
let usedDefaultKey = null;
|
||||
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
|
||||
const defaultKey = findDefaultPrivateKey();
|
||||
if (defaultKey) {
|
||||
log("Using default SSH key as fallback", { keyPath: defaultKey.keyPath });
|
||||
connectOpts.privateKey = defaultKey.privateKey;
|
||||
usedDefaultKey = defaultKey;
|
||||
}
|
||||
}
|
||||
|
||||
// Agent forwarding
|
||||
if (options.agentForwarding) {
|
||||
connectOpts.agentForward = true;
|
||||
@@ -421,25 +446,25 @@ async function startSSHSession(event, options) {
|
||||
// Handle chain/proxy connections
|
||||
if (hasJumpHosts) {
|
||||
const chainResult = await connectThroughChain(
|
||||
event,
|
||||
options,
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
event,
|
||||
options,
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
options.port || 22
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
|
||||
|
||||
connectOpts.sock = connectionSocket;
|
||||
delete connectOpts.host;
|
||||
delete connectOpts.port;
|
||||
|
||||
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'connecting');
|
||||
} else if (hasProxy) {
|
||||
sendProgress(1, 1, options.hostname, 'connecting');
|
||||
connectionSocket = await createProxySocket(
|
||||
options.proxy,
|
||||
options.hostname,
|
||||
options.proxy,
|
||||
options.hostname,
|
||||
options.port || 22
|
||||
);
|
||||
connectOpts.sock = connectionSocket;
|
||||
@@ -448,12 +473,13 @@ 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');
|
||||
}
|
||||
|
||||
|
||||
conn.shell(
|
||||
{
|
||||
term: "xterm-256color",
|
||||
@@ -461,7 +487,7 @@ async function startSSHSession(event, options) {
|
||||
rows,
|
||||
},
|
||||
{
|
||||
env: {
|
||||
env: {
|
||||
LANG: resolveLangFromCharset(options.charset),
|
||||
COLORTERM: "truecolor",
|
||||
...(options.env || {}),
|
||||
@@ -471,7 +497,7 @@ async function startSSHSession(event, options) {
|
||||
if (err) {
|
||||
conn.end();
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
reject(err);
|
||||
return;
|
||||
@@ -490,16 +516,16 @@ async function startSSHSession(event, options) {
|
||||
let flushTimeout = null;
|
||||
const FLUSH_INTERVAL = 8; // ms - flush every 8ms for ~120fps equivalent
|
||||
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer gets too large
|
||||
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
const bufferData = (data) => {
|
||||
dataBuffer += data;
|
||||
// Immediate flush for large chunks
|
||||
@@ -529,12 +555,12 @@ 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) {
|
||||
try { c.end(); } catch {}
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -551,58 +577,123 @@ 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';
|
||||
|
||||
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", {
|
||||
sessionId,
|
||||
console.log(`${logPrefix} ${options.hostname} auth failed:`, err.message);
|
||||
safeSend(contents, "netcatty:auth:failed", {
|
||||
sessionId,
|
||||
error: err.message,
|
||||
hostname: options.hostname
|
||||
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 {}
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
|
||||
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 {}
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
|
||||
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 {}
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[Chain] Connecting to final target ${options.hostname}...`);
|
||||
// Handle keyboard-interactive authentication (2FA/MFA)
|
||||
conn.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`${logPrefix} ${options.hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, sessionId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId,
|
||||
name: name || "",
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: options.hostname,
|
||||
savedPassword: options.password || null, // Pass saved password for optional fill button
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Enable keyboard-interactive authentication in authHandler
|
||||
if (connectOpts.authHandler) {
|
||||
// Add keyboard-interactive after the existing methods
|
||||
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
|
||||
connectOpts.authHandler.push("keyboard-interactive");
|
||||
}
|
||||
} else {
|
||||
// Create authHandler with keyboard-interactive support
|
||||
const authMethods = [];
|
||||
if (connectOpts.privateKey) authMethods.push("publickey");
|
||||
if (connectOpts.password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
connectOpts.authHandler = authMethods;
|
||||
}
|
||||
|
||||
// Increase timeout to allow for keyboard-interactive auth
|
||||
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -711,11 +802,11 @@ async function execCommand(event, payload) {
|
||||
*/
|
||||
async function generateKeyPair(event, options) {
|
||||
const { type, bits, comment } = options;
|
||||
|
||||
|
||||
try {
|
||||
let keyType;
|
||||
let keyBits = bits;
|
||||
|
||||
|
||||
switch (type) {
|
||||
case 'ED25519':
|
||||
keyType = 'ed25519';
|
||||
@@ -731,15 +822,15 @@ async function generateKeyPair(event, options) {
|
||||
keyBits = bits || 4096;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
const result = sshUtils.generateKeyPairSync(keyType, {
|
||||
bits: keyBits,
|
||||
comment: comment || 'netcatty-generated-key',
|
||||
});
|
||||
|
||||
|
||||
const privateKey = result.private;
|
||||
const publicKey = result.public;
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
privateKey,
|
||||
@@ -754,13 +845,127 @@ 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 existing shell stream and captures the output
|
||||
* using unique markers to identify the command output boundaries
|
||||
*/
|
||||
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 stream = session.stream;
|
||||
const marker = `__PWD_${Date.now()}__`;
|
||||
const timeout = setTimeout(() => {
|
||||
stream.removeListener('data', onData);
|
||||
resolve({ success: false, error: 'Timeout getting pwd' });
|
||||
}, 3000);
|
||||
|
||||
let buffer = '';
|
||||
|
||||
const onData = (data) => {
|
||||
const str = data.toString();
|
||||
buffer += str;
|
||||
|
||||
// We need to find the ACTUAL output markers, not the command echo
|
||||
// The command echo looks like: echo '__PWD_xxx__S' && pwd && echo '__PWD_xxx__E'
|
||||
// The actual output looks like: __PWD_xxx__S\n/path/to/dir\n__PWD_xxx__E
|
||||
//
|
||||
// We look for the marker at the START of a line (after newline) to avoid the echo
|
||||
const startMarkerRegex = new RegExp(`(?:^|[\\r\\n])${marker}S[\\r\\n]+`);
|
||||
const endMarkerRegex = new RegExp(`[\\r\\n]${marker}E(?:[\\r\\n]|$)`);
|
||||
|
||||
const startMatch = buffer.match(startMarkerRegex);
|
||||
const endMatch = buffer.match(endMarkerRegex);
|
||||
|
||||
if (startMatch && endMatch) {
|
||||
const startIdx = buffer.indexOf(startMatch[0]) + startMatch[0].length;
|
||||
const endIdx = buffer.indexOf(endMatch[0]);
|
||||
|
||||
if (startIdx <= endIdx) {
|
||||
clearTimeout(timeout);
|
||||
stream.removeListener('data', onData);
|
||||
|
||||
const pwdOutput = buffer.slice(startIdx, endIdx).trim();
|
||||
console.log('[getSessionPwd] pwdOutput:', JSON.stringify(pwdOutput));
|
||||
|
||||
// The pwd output should be a valid absolute path
|
||||
if (pwdOutput && pwdOutput.startsWith('/')) {
|
||||
console.log('[getSessionPwd] Success, cwd:', pwdOutput);
|
||||
resolve({ success: true, cwd: pwdOutput });
|
||||
} else {
|
||||
console.log('[getSessionPwd] Failed - invalid path:', pwdOutput);
|
||||
resolve({ success: false, error: 'Invalid pwd output' });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
stream.on('data', onData);
|
||||
|
||||
// Send pwd command with short unique markers
|
||||
// Using 'S' and 'E' as suffixes to make markers shorter
|
||||
// After the command, send ANSI escape sequences to clear the output lines:
|
||||
// \x1b[1A = move cursor up 1 line, \x1b[2K = clear entire line
|
||||
// Clear 4 lines: the command echo, START marker, pwd output, and END marker
|
||||
const clearLines = '\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K';
|
||||
stream.write(` echo '${marker}S' && pwd && echo '${marker}E' && printf '${clearLines}'\n`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
ipcMain.handle("netcatty:ssh:check-agent", async () => {
|
||||
return await checkWindowsSshAgent();
|
||||
});
|
||||
ipcMain.handle("netcatty:ssh:get-default-keys", async () => {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
const keys = [];
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
keys.push({ name, path: keyPath });
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
// Register the shared keyboard-interactive response handler
|
||||
keyboardInteractiveHandler.registerHandler(ipcMain);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -769,5 +974,8 @@ module.exports = {
|
||||
createProxySocket,
|
||||
startSSHSession,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
generateKeyPair,
|
||||
checkWindowsSshAgent,
|
||||
findDefaultPrivateKey,
|
||||
};
|
||||
|
||||
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,5 +1,5 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
@@ -107,10 +108,30 @@ function startLocalSession(event, payload) {
|
||||
COLORTERM: "truecolor",
|
||||
});
|
||||
|
||||
// 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 = {
|
||||
@@ -423,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
|
||||
*/
|
||||
@@ -437,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') {
|
||||
@@ -491,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) {
|
||||
@@ -510,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
|
||||
*/
|
||||
@@ -534,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) {
|
||||
@@ -554,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
|
||||
|
||||
@@ -9,6 +9,7 @@ const chainProgressListeners = new Map();
|
||||
const authFailedListeners = new Map();
|
||||
const languageChangeListeners = new Set();
|
||||
const fullscreenChangeListeners = new Set();
|
||||
const keyboardInteractiveListeners = new Set();
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
@@ -86,6 +87,17 @@ ipcRenderer.on("netcatty:auth:failed", (_event, payload) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard-interactive authentication events (2FA/MFA)
|
||||
ipcRenderer.on("netcatty:keyboard-interactive", (_event, payload) => {
|
||||
keyboardInteractiveListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Keyboard-interactive callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Transfer progress events
|
||||
ipcRenderer.on("netcatty:transfer:progress", (_event, payload) => {
|
||||
const cb = transferProgressListeners.get(payload.transferId);
|
||||
@@ -198,6 +210,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,15 +251,37 @@ 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);
|
||||
},
|
||||
checkSshAgent: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ssh:check-agent");
|
||||
},
|
||||
getDefaultKeys: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ssh:get-default-keys");
|
||||
},
|
||||
resizeSession: (sessionId, cols, rows) => {
|
||||
ipcRenderer.send("netcatty:resize", { sessionId, cols, rows });
|
||||
},
|
||||
@@ -245,6 +303,18 @@ const api = {
|
||||
authFailedListeners.get(sessionId).add(cb);
|
||||
return () => authFailedListeners.get(sessionId)?.delete(cb);
|
||||
},
|
||||
// Keyboard-interactive authentication (2FA/MFA)
|
||||
onKeyboardInteractive: (cb) => {
|
||||
keyboardInteractiveListeners.add(cb);
|
||||
return () => keyboardInteractiveListeners.delete(cb);
|
||||
},
|
||||
respondKeyboardInteractive: async (requestId, responses, cancelled = false) => {
|
||||
return ipcRenderer.invoke("netcatty:keyboard-interactive:respond", {
|
||||
requestId,
|
||||
responses,
|
||||
cancelled,
|
||||
});
|
||||
},
|
||||
openSftp: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:sftp:open", options);
|
||||
return result.sftpId;
|
||||
@@ -255,6 +325,9 @@ const api = {
|
||||
readSftp: async (sftpId, path) => {
|
||||
return ipcRenderer.invoke("netcatty:sftp:read", { sftpId, path });
|
||||
},
|
||||
readSftpBinary: async (sftpId, path) => {
|
||||
return ipcRenderer.invoke("netcatty:sftp:readBinary", { sftpId, path });
|
||||
},
|
||||
writeSftp: async (sftpId, path, content) => {
|
||||
return ipcRenderer.invoke("netcatty:sftp:write", { sftpId, path, content });
|
||||
},
|
||||
@@ -290,6 +363,14 @@ const api = {
|
||||
transferId
|
||||
});
|
||||
},
|
||||
// Cancel an in-progress SFTP upload
|
||||
cancelSftpUpload: async (transferId) => {
|
||||
// Cleanup listeners
|
||||
uploadProgressListeners.delete(transferId);
|
||||
uploadCompleteListeners.delete(transferId);
|
||||
uploadErrorListeners.delete(transferId);
|
||||
return ipcRenderer.invoke("netcatty:sftp:cancelUpload", { transferId });
|
||||
},
|
||||
// Local filesystem operations
|
||||
listLocalDir: async (path) => {
|
||||
return ipcRenderer.invoke("netcatty:local:list", { path });
|
||||
@@ -488,6 +569,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
|
||||
|
||||
@@ -7,7 +7,7 @@ import reactHooks from "eslint-plugin-react-hooks";
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**"],
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
824
global.d.ts
vendored
@@ -2,398 +2,468 @@ import type { RemoteFile } from "./types";
|
||||
import type { S3Config, SMBConfig, SyncedFile, WebDAVConfig } from "./domain/sync";
|
||||
|
||||
declare global {
|
||||
// Proxy configuration for SSH connections
|
||||
interface NetcattyProxyConfig {
|
||||
type: 'http' | 'socks5';
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// Jump host configuration for SSH tunneling
|
||||
interface NetcattyJumpHost {
|
||||
hostname: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
label?: string; // Display label for UI
|
||||
}
|
||||
|
||||
// Host key information for verification
|
||||
// Reserved for future host key verification UI feature
|
||||
interface _NetcattyHostKeyInfo {
|
||||
hostname: string;
|
||||
port: number;
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
publicKey?: string;
|
||||
}
|
||||
|
||||
interface NetcattySSHOptions {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
// Optional OpenSSH user certificate
|
||||
certificate?: string;
|
||||
publicKey?: string; // OpenSSH public key line
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
agentForwarding?: boolean;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
extraArgs?: string[];
|
||||
startupCommand?: string;
|
||||
passphrase?: string;
|
||||
// Environment variables to set in the remote shell
|
||||
env?: Record<string, string>;
|
||||
// Proxy configuration
|
||||
proxy?: NetcattyProxyConfig;
|
||||
// Jump hosts (bastion chain)
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
}
|
||||
|
||||
interface SftpStatResult {
|
||||
name: string;
|
||||
type: 'file' | 'directory' | 'symlink';
|
||||
size: number;
|
||||
lastModified: number; // timestamp
|
||||
permissions?: string; // e.g., "rwxr-xr-x"
|
||||
owner?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface SftpTransferProgress {
|
||||
transferId: string;
|
||||
bytesTransferred: number;
|
||||
totalBytes: number;
|
||||
speed: number; // bytes per second
|
||||
}
|
||||
|
||||
// Port Forwarding Types
|
||||
interface PortForwardOptions {
|
||||
tunnelId: string;
|
||||
type: 'local' | 'remote' | 'dynamic';
|
||||
localPort: number;
|
||||
bindAddress?: string;
|
||||
remoteHost?: string;
|
||||
remotePort?: number;
|
||||
// SSH connection details
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
}
|
||||
|
||||
interface PortForwardResult {
|
||||
tunnelId: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PortForwardStatusResult {
|
||||
tunnelId: string;
|
||||
status: 'inactive' | 'connecting' | 'active' | 'error';
|
||||
type?: 'local' | 'remote' | 'dynamic';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type PortForwardStatusCallback = (status: 'inactive' | 'connecting' | 'active' | 'error', error?: string) => void;
|
||||
|
||||
interface NetcattyBridge {
|
||||
startSSHSession(options: NetcattySSHOptions): Promise<string>;
|
||||
startTelnetSession?(options: {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<string>;
|
||||
startMoshSession?(options: {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
// Proxy configuration for SSH connections
|
||||
interface NetcattyProxyConfig {
|
||||
type: 'http' | 'socks5';
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
port?: number;
|
||||
moshServerPath?: string;
|
||||
agentForwarding?: boolean;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<string>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; env?: Record<string, string> }): Promise<string>;
|
||||
generateKeyPair?(options: {
|
||||
type: 'RSA' | 'ECDSA' | 'ED25519';
|
||||
bits?: number;
|
||||
comment?: string;
|
||||
}): Promise<{ success: boolean; privateKey?: string; publicKey?: string; error?: string }>;
|
||||
execCommand(options: {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// Jump host configuration for SSH tunneling
|
||||
interface NetcattyJumpHost {
|
||||
hostname: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
label?: string; // Display label for UI
|
||||
}
|
||||
|
||||
// Host key information for verification
|
||||
// Reserved for future host key verification UI feature
|
||||
interface _NetcattyHostKeyInfo {
|
||||
hostname: string;
|
||||
port: number;
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
publicKey?: string;
|
||||
}
|
||||
|
||||
interface NetcattySSHOptions {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
|
||||
writeToSession(sessionId: string, data: string): void;
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void;
|
||||
closeSession(sessionId: string): void;
|
||||
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
cb: (evt: { exitCode?: number; signal?: number }) => void
|
||||
): () => void;
|
||||
onAuthFailed?(
|
||||
sessionId: string,
|
||||
cb: (evt: { sessionId: string; error: string; hostname: string }) => void
|
||||
): () => void;
|
||||
|
||||
// SFTP operations
|
||||
openSftp(options: NetcattySSHOptions): Promise<string>;
|
||||
listSftp(sftpId: string, path: string): Promise<RemoteFile[]>;
|
||||
readSftp(sftpId: string, path: string): Promise<string>;
|
||||
readSftpBinary?(sftpId: string, path: string): Promise<ArrayBuffer>;
|
||||
writeSftp(sftpId: string, path: string, content: string): Promise<void>;
|
||||
writeSftpBinary?(sftpId: string, path: string, content: ArrayBuffer): Promise<void>;
|
||||
closeSftp(sftpId: string): Promise<void>;
|
||||
mkdirSftp(sftpId: string, path: string): Promise<void>;
|
||||
deleteSftp?(sftpId: string, path: string): Promise<void>;
|
||||
renameSftp?(sftpId: string, oldPath: string, newPath: string): Promise<void>;
|
||||
statSftp?(sftpId: string, path: string): Promise<SftpStatResult>;
|
||||
chmodSftp?(sftpId: string, path: string, mode: string): Promise<void>;
|
||||
|
||||
// Write binary with real-time progress callback
|
||||
writeSftpBinaryWithProgress?(
|
||||
sftpId: string,
|
||||
path: string,
|
||||
content: ArrayBuffer,
|
||||
transferId: string,
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): Promise<{ success: boolean; transferId: string }>;
|
||||
|
||||
// Transfer with progress
|
||||
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
|
||||
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
|
||||
cancelTransfer?(transferId: string): Promise<void>;
|
||||
onTransferProgress?(transferId: string, cb: (progress: SftpTransferProgress) => void): () => void;
|
||||
|
||||
// Streaming transfer with real progress and cancellation
|
||||
startStreamTransfer?(
|
||||
options: {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sourceType: 'local' | 'sftp';
|
||||
targetType: 'local' | 'sftp';
|
||||
sourceSftpId?: string;
|
||||
targetSftpId?: string;
|
||||
totalBytes?: number;
|
||||
},
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): Promise<{ transferId: string; totalBytes?: number; error?: string }>;
|
||||
|
||||
// Local filesystem operations
|
||||
listLocalDir?(path: string): Promise<RemoteFile[]>;
|
||||
readLocalFile?(path: string): Promise<ArrayBuffer>;
|
||||
writeLocalFile?(path: string, content: ArrayBuffer): Promise<void>;
|
||||
deleteLocalFile?(path: string): Promise<void>;
|
||||
renameLocalFile?(oldPath: string, newPath: string): Promise<void>;
|
||||
mkdirLocal?(path: string): Promise<void>;
|
||||
statLocal?(path: string): Promise<SftpStatResult>;
|
||||
getHomeDir?(): Promise<string>;
|
||||
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
|
||||
|
||||
setTheme?(theme: 'light' | 'dark'): Promise<boolean>;
|
||||
setBackgroundColor?(color: string): Promise<boolean>;
|
||||
setLanguage?(language: string): Promise<boolean>;
|
||||
// Window controls for custom title bar (Windows/Linux)
|
||||
windowMinimize?(): Promise<void>;
|
||||
windowMaximize?(): Promise<boolean>;
|
||||
windowClose?(): Promise<void>;
|
||||
windowIsMaximized?(): Promise<boolean>;
|
||||
windowIsFullscreen?(): Promise<boolean>;
|
||||
onWindowFullScreenChanged?(cb: (isFullscreen: boolean) => void): () => void;
|
||||
|
||||
// Settings window
|
||||
openSettingsWindow?(): Promise<boolean>;
|
||||
closeSettingsWindow?(): Promise<void>;
|
||||
// Optional OpenSSH user certificate
|
||||
certificate?: string;
|
||||
publicKey?: string; // OpenSSH public key line
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
agentForwarding?: boolean;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
extraArgs?: string[];
|
||||
startupCommand?: string;
|
||||
passphrase?: string;
|
||||
// Environment variables to set in the remote shell
|
||||
env?: Record<string, string>;
|
||||
// Proxy configuration
|
||||
proxy?: NetcattyProxyConfig;
|
||||
// Jump hosts (bastion chain)
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
// SSH-level keepalive interval in seconds (0 = disabled)
|
||||
keepaliveInterval?: number;
|
||||
// Use sudo for SFTP server
|
||||
sudo?: boolean;
|
||||
}
|
||||
|
||||
// Cross-window settings sync
|
||||
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
|
||||
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
|
||||
interface SftpStatResult {
|
||||
name: string;
|
||||
type: 'file' | 'directory' | 'symlink';
|
||||
size: number;
|
||||
lastModified: number; // timestamp
|
||||
permissions?: string; // e.g., "rwxr-xr-x"
|
||||
owner?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
// Cloud sync master password (stored in-memory + persisted via Electron safeStorage)
|
||||
cloudSyncSetSessionPassword?(password: string): Promise<boolean>;
|
||||
cloudSyncGetSessionPassword?(): Promise<string | null>;
|
||||
cloudSyncClearSessionPassword?(): Promise<boolean>;
|
||||
interface SftpTransferProgress {
|
||||
transferId: string;
|
||||
bytesTransferred: number;
|
||||
totalBytes: number;
|
||||
speed: number; // bytes per second
|
||||
}
|
||||
|
||||
// Cloud sync network operations (proxied via main process)
|
||||
cloudSyncWebdavInitialize?(config: WebDAVConfig): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncWebdavUpload?(
|
||||
config: WebDAVConfig,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncWebdavDownload?(config: WebDAVConfig): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncWebdavDelete?(config: WebDAVConfig): Promise<{ ok: true }>;
|
||||
// Port Forwarding Types
|
||||
interface PortForwardOptions {
|
||||
tunnelId: string;
|
||||
type: 'local' | 'remote' | 'dynamic';
|
||||
localPort: number;
|
||||
bindAddress?: string;
|
||||
remoteHost?: string;
|
||||
remotePort?: number;
|
||||
// SSH connection details
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
}
|
||||
|
||||
cloudSyncS3Initialize?(config: S3Config): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncS3Upload?(
|
||||
config: S3Config,
|
||||
syncedFile: SyncedFile
|
||||
): 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>;
|
||||
stopPortForward?(tunnelId: string): Promise<PortForwardResult>;
|
||||
getPortForwardStatus?(tunnelId: string): Promise<PortForwardStatusResult>;
|
||||
listPortForwards?(): Promise<{ tunnelId: string; type: string; status: string }[]>;
|
||||
onPortForwardStatus?(tunnelId: string, cb: PortForwardStatusCallback): () => void;
|
||||
|
||||
// Known Hosts
|
||||
readKnownHosts?(): Promise<string | null>;
|
||||
|
||||
// Open URL in default browser
|
||||
openExternal?(url: string): Promise<void>;
|
||||
|
||||
// App info (name/version/platform) for About screens
|
||||
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
|
||||
|
||||
// Notify main process the renderer has mounted/painted (used to avoid initial blank screen).
|
||||
rendererReady?(): void;
|
||||
|
||||
onLanguageChanged?(cb: (language: string) => void): () => void;
|
||||
|
||||
// Chain progress listener for jump host connections
|
||||
// Callback receives: (currentHop: number, totalHops: number, hostLabel: string, status: string)
|
||||
onChainProgress?(cb: (hop: number, total: number, label: string, status: string) => void): () => void;
|
||||
|
||||
// OAuth callback server for cloud sync
|
||||
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
|
||||
cancelOAuthCallback?(): Promise<void>;
|
||||
|
||||
// GitHub Device Flow (cloud sync)
|
||||
githubStartDeviceFlow?(options?: { clientId?: string; scope?: string }): Promise<{
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
expiresAt: number;
|
||||
interval: number;
|
||||
}>;
|
||||
githubPollDeviceFlowToken?(options: { clientId?: string; deviceCode: string }): Promise<{
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
scope?: string;
|
||||
interface PortForwardResult {
|
||||
tunnelId: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Google OAuth (cloud sync) - proxied via main process to avoid CORS
|
||||
googleExchangeCodeForTokens?(options: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
googleRefreshAccessToken?(options: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
refreshToken: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
googleGetUserInfo?(options: { accessToken: string }): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
picture?: string;
|
||||
}>;
|
||||
interface PortForwardStatusResult {
|
||||
tunnelId: string;
|
||||
status: 'inactive' | 'connecting' | 'active' | 'error';
|
||||
type?: 'local' | 'remote' | 'dynamic';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Google Drive API (cloud sync) - proxied via main process to avoid CORS/COEP issues
|
||||
googleDriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
|
||||
googleDriveCreateSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string }>;
|
||||
googleDriveUpdateSyncFile?(options: { accessToken: string; fileId: string; syncedFile: unknown }): Promise<{ ok: true }>;
|
||||
googleDriveDownloadSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ syncedFile: unknown | null }>;
|
||||
googleDriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
|
||||
type PortForwardStatusCallback = (status: 'inactive' | 'connecting' | 'active' | 'error', error?: string) => void;
|
||||
|
||||
// OneDrive OAuth + Graph (cloud sync) - proxied via main process to avoid CORS
|
||||
onedriveExchangeCodeForTokens?(options: {
|
||||
clientId: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
scope?: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
onedriveRefreshAccessToken?(options: {
|
||||
clientId: string;
|
||||
refreshToken: string;
|
||||
scope?: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
onedriveGetUserInfo?(options: { accessToken: string }): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarDataUrl?: string;
|
||||
}>;
|
||||
onedriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
|
||||
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 }>;
|
||||
}
|
||||
interface NetcattyBridge {
|
||||
startSSHSession(options: NetcattySSHOptions): Promise<string>;
|
||||
startTelnetSession?(options: {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<string>;
|
||||
startMoshSession?(options: {
|
||||
sessionId?: string;
|
||||
hostname: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
moshServerPath?: string;
|
||||
agentForwarding?: boolean;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: 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;
|
||||
comment?: string;
|
||||
}): Promise<{ success: boolean; privateKey?: string; publicKey?: string; error?: string }>;
|
||||
checkSshAgent?(): Promise<{ running: boolean; startupType: string | null; error: string | null }>;
|
||||
getDefaultKeys?(): Promise<Array<{ name: string; path: string }>>;
|
||||
execCommand(options: {
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
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;
|
||||
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
cb: (evt: { exitCode?: number; signal?: number }) => void
|
||||
): () => void;
|
||||
onAuthFailed?(
|
||||
sessionId: string,
|
||||
cb: (evt: { sessionId: string; error: string; hostname: string }) => void
|
||||
): () => void;
|
||||
|
||||
interface Window {
|
||||
netcatty?: NetcattyBridge;
|
||||
}
|
||||
// Keyboard-interactive authentication (2FA/MFA)
|
||||
onKeyboardInteractive?(
|
||||
cb: (request: {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: Array<{ prompt: string; echo: boolean }>;
|
||||
hostname: string;
|
||||
savedPassword?: string | null;
|
||||
}) => void
|
||||
): () => void;
|
||||
respondKeyboardInteractive?(
|
||||
requestId: string,
|
||||
responses: string[],
|
||||
cancelled?: boolean
|
||||
): Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
// SFTP operations
|
||||
openSftp(options: NetcattySSHOptions): Promise<string>;
|
||||
listSftp(sftpId: string, path: string): Promise<RemoteFile[]>;
|
||||
readSftp(sftpId: string, path: string): Promise<string>;
|
||||
readSftpBinary?(sftpId: string, path: string): Promise<ArrayBuffer>;
|
||||
writeSftp(sftpId: string, path: string, content: string): Promise<void>;
|
||||
writeSftpBinary?(sftpId: string, path: string, content: ArrayBuffer): Promise<void>;
|
||||
closeSftp(sftpId: string): Promise<void>;
|
||||
mkdirSftp(sftpId: string, path: string): Promise<void>;
|
||||
deleteSftp?(sftpId: string, path: string): Promise<void>;
|
||||
renameSftp?(sftpId: string, oldPath: string, newPath: string): Promise<void>;
|
||||
statSftp?(sftpId: string, path: string): Promise<SftpStatResult>;
|
||||
chmodSftp?(sftpId: string, path: string, mode: string): Promise<void>;
|
||||
|
||||
// Write binary with real-time progress callback
|
||||
writeSftpBinaryWithProgress?(
|
||||
sftpId: string,
|
||||
path: string,
|
||||
content: ArrayBuffer,
|
||||
transferId: string,
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): Promise<{ success: boolean; transferId: string; cancelled?: boolean }>;
|
||||
|
||||
// Cancel an in-progress SFTP upload
|
||||
cancelSftpUpload?(transferId: string): Promise<{ success: boolean }>;
|
||||
|
||||
// Transfer with progress
|
||||
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
|
||||
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
|
||||
cancelTransfer?(transferId: string): Promise<void>;
|
||||
onTransferProgress?(transferId: string, cb: (progress: SftpTransferProgress) => void): () => void;
|
||||
|
||||
// Streaming transfer with real progress and cancellation
|
||||
startStreamTransfer?(
|
||||
options: {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sourceType: 'local' | 'sftp';
|
||||
targetType: 'local' | 'sftp';
|
||||
sourceSftpId?: string;
|
||||
targetSftpId?: string;
|
||||
totalBytes?: number;
|
||||
},
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): Promise<{ transferId: string; totalBytes?: number; error?: string }>;
|
||||
|
||||
// Local filesystem operations
|
||||
listLocalDir?(path: string): Promise<RemoteFile[]>;
|
||||
readLocalFile?(path: string): Promise<ArrayBuffer>;
|
||||
writeLocalFile?(path: string, content: ArrayBuffer): Promise<void>;
|
||||
deleteLocalFile?(path: string): Promise<void>;
|
||||
renameLocalFile?(oldPath: string, newPath: string): Promise<void>;
|
||||
mkdirLocal?(path: string): Promise<void>;
|
||||
statLocal?(path: string): Promise<SftpStatResult>;
|
||||
getHomeDir?(): Promise<string>;
|
||||
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
|
||||
|
||||
setTheme?(theme: 'light' | 'dark'): Promise<boolean>;
|
||||
setBackgroundColor?(color: string): Promise<boolean>;
|
||||
setLanguage?(language: string): Promise<boolean>;
|
||||
// Window controls for custom title bar (Windows/Linux)
|
||||
windowMinimize?(): Promise<void>;
|
||||
windowMaximize?(): Promise<boolean>;
|
||||
windowClose?(): Promise<void>;
|
||||
windowIsMaximized?(): Promise<boolean>;
|
||||
windowIsFullscreen?(): Promise<boolean>;
|
||||
onWindowFullScreenChanged?(cb: (isFullscreen: boolean) => void): () => void;
|
||||
|
||||
// Settings window
|
||||
openSettingsWindow?(): Promise<boolean>;
|
||||
closeSettingsWindow?(): Promise<void>;
|
||||
|
||||
// Cross-window settings sync
|
||||
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
|
||||
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
|
||||
|
||||
// Cloud sync master password (stored in-memory + persisted via Electron safeStorage)
|
||||
cloudSyncSetSessionPassword?(password: string): Promise<boolean>;
|
||||
cloudSyncGetSessionPassword?(): Promise<string | null>;
|
||||
cloudSyncClearSessionPassword?(): Promise<boolean>;
|
||||
|
||||
// Cloud sync network operations (proxied via main process)
|
||||
cloudSyncWebdavInitialize?(config: WebDAVConfig): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncWebdavUpload?(
|
||||
config: WebDAVConfig,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncWebdavDownload?(config: WebDAVConfig): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncWebdavDelete?(config: WebDAVConfig): Promise<{ ok: true }>;
|
||||
|
||||
cloudSyncS3Initialize?(config: S3Config): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncS3Upload?(
|
||||
config: S3Config,
|
||||
syncedFile: SyncedFile
|
||||
): 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>;
|
||||
stopPortForward?(tunnelId: string): Promise<PortForwardResult>;
|
||||
getPortForwardStatus?(tunnelId: string): Promise<PortForwardStatusResult>;
|
||||
listPortForwards?(): Promise<{ tunnelId: string; type: string; status: string }[]>;
|
||||
onPortForwardStatus?(tunnelId: string, cb: PortForwardStatusCallback): () => void;
|
||||
|
||||
// Known Hosts
|
||||
readKnownHosts?(): Promise<string | null>;
|
||||
|
||||
// Open URL in default browser
|
||||
openExternal?(url: string): Promise<void>;
|
||||
|
||||
// App info (name/version/platform) for About screens
|
||||
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
|
||||
|
||||
// Notify main process the renderer has mounted/painted (used to avoid initial blank screen).
|
||||
rendererReady?(): void;
|
||||
|
||||
onLanguageChanged?(cb: (language: string) => void): () => void;
|
||||
|
||||
// Chain progress listener for jump host connections
|
||||
// Callback receives: (currentHop: number, totalHops: number, hostLabel: string, status: string)
|
||||
onChainProgress?(cb: (hop: number, total: number, label: string, status: string) => void): () => void;
|
||||
|
||||
// OAuth callback server for cloud sync
|
||||
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
|
||||
cancelOAuthCallback?(): Promise<void>;
|
||||
|
||||
// GitHub Device Flow (cloud sync)
|
||||
githubStartDeviceFlow?(options?: { clientId?: string; scope?: string }): Promise<{
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
expiresAt: number;
|
||||
interval: number;
|
||||
}>;
|
||||
githubPollDeviceFlowToken?(options: { clientId?: string; deviceCode: string }): Promise<{
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
scope?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}>;
|
||||
|
||||
// Google OAuth (cloud sync) - proxied via main process to avoid CORS
|
||||
googleExchangeCodeForTokens?(options: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
googleRefreshAccessToken?(options: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
refreshToken: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
googleGetUserInfo?(options: { accessToken: string }): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
picture?: string;
|
||||
}>;
|
||||
|
||||
// Google Drive API (cloud sync) - proxied via main process to avoid CORS/COEP issues
|
||||
googleDriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
|
||||
googleDriveCreateSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string }>;
|
||||
googleDriveUpdateSyncFile?(options: { accessToken: string; fileId: string; syncedFile: unknown }): Promise<{ ok: true }>;
|
||||
googleDriveDownloadSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ syncedFile: unknown | null }>;
|
||||
googleDriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
|
||||
|
||||
// OneDrive OAuth + Graph (cloud sync) - proxied via main process to avoid CORS
|
||||
onedriveExchangeCodeForTokens?(options: {
|
||||
clientId: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
scope?: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
onedriveRefreshAccessToken?(options: {
|
||||
clientId: string;
|
||||
refreshToken: string;
|
||||
scope?: string;
|
||||
}): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt?: number;
|
||||
tokenType: string;
|
||||
scope?: string;
|
||||
}>;
|
||||
onedriveGetUserInfo?(options: { accessToken: string }): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarDataUrl?: string;
|
||||
}>;
|
||||
onedriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
|
||||
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 {
|
||||
netcatty?: NetcattyBridge;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -216,6 +216,13 @@ const BASE_TERMINAL_FONTS: TerminalFont[] = [
|
||||
description: 'Highly customizable monospace font',
|
||||
category: 'monospace',
|
||||
},
|
||||
{
|
||||
id: 'ioskeley-mono',
|
||||
name: 'Ioskeley Mono',
|
||||
family: '"Ioskeley Mono", monospace',
|
||||
description: 'Iosevka variant mimicking Berkeley Mono style',
|
||||
category: 'monospace',
|
||||
},
|
||||
{
|
||||
id: 'mononoki',
|
||||
name: 'Mononoki',
|
||||
|
||||
@@ -35,5 +35,13 @@ export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hos
|
||||
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';
|
||||
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
593
lib/sftpFileUtils.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a file or directory entry from drag-and-drop
|
||||
* This includes the relative path for nested files in folders
|
||||
*/
|
||||
export interface DropEntry {
|
||||
file: File | null; // null for directory entries
|
||||
relativePath: string; // Path relative to the root of the drop (e.g., "folder/subfolder/file.txt")
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read entries from a FileSystemDirectoryEntry recursively
|
||||
* Uses the webkitGetAsEntry API to access folder contents
|
||||
*/
|
||||
function readDirectoryEntries(
|
||||
directoryReader: FileSystemDirectoryReader
|
||||
): Promise<FileSystemEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const allEntries: FileSystemEntry[] = [];
|
||||
|
||||
const readBatch = () => {
|
||||
directoryReader.readEntries(
|
||||
(entries) => {
|
||||
if (entries.length === 0) {
|
||||
resolve(allEntries);
|
||||
} else {
|
||||
allEntries.push(...entries);
|
||||
// Continue reading (readEntries may not return all entries at once)
|
||||
readBatch();
|
||||
}
|
||||
},
|
||||
(error) => reject(error)
|
||||
);
|
||||
};
|
||||
|
||||
readBatch();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a FileSystemEntry to a File
|
||||
*/
|
||||
function entryToFile(entry: FileSystemFileEntry): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
entry.file(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively process a FileSystemEntry and collect all files
|
||||
* Optimized with parallel processing for faster folder traversal
|
||||
* @param entry - The file system entry to process
|
||||
* @param basePath - The base path (folder name) to prepend
|
||||
* @returns Array of DropEntry objects with files and their relative paths
|
||||
*/
|
||||
async function processEntry(
|
||||
entry: FileSystemEntry,
|
||||
basePath: string = ""
|
||||
): Promise<DropEntry[]> {
|
||||
const results: DropEntry[] = [];
|
||||
|
||||
if (entry.isFile) {
|
||||
const fileEntry = entry as FileSystemFileEntry;
|
||||
try {
|
||||
const file = await entryToFile(fileEntry);
|
||||
results.push({
|
||||
file,
|
||||
relativePath: basePath ? `${basePath}/${entry.name}` : entry.name,
|
||||
isDirectory: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read file entry: ${entry.name}`, error);
|
||||
}
|
||||
} else if (entry.isDirectory) {
|
||||
const dirEntry = entry as FileSystemDirectoryEntry;
|
||||
const currentPath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
||||
|
||||
// Add a placeholder for the directory itself (to ensure it gets created)
|
||||
results.push({
|
||||
file: null, // Directories don't have file content
|
||||
relativePath: currentPath,
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const reader = dirEntry.createReader();
|
||||
const entries = await readDirectoryEntries(reader);
|
||||
|
||||
// Process entries in parallel batches for better performance
|
||||
// Use a concurrency limit to avoid overwhelming the browser
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
||||
const batch = entries.slice(i, i + BATCH_SIZE);
|
||||
// Process batch in parallel
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(childEntry => processEntry(childEntry, currentPath))
|
||||
);
|
||||
// Flatten and add results
|
||||
for (const childResults of batchResults) {
|
||||
results.push(...childResults);
|
||||
}
|
||||
|
||||
// Yield to main thread between batches to keep UI responsive
|
||||
if (i + BATCH_SIZE < entries.length) {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read directory: ${entry.name}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all files and directories from a DataTransfer object
|
||||
* Supports both regular files and folders dropped from the OS
|
||||
*
|
||||
* Uses the webkitGetAsEntry() API for folder access, with fallback
|
||||
* to regular FileList for browsers that don't support it.
|
||||
*
|
||||
* @param dataTransfer - The DataTransfer object from a drop event
|
||||
* @returns Array of DropEntry objects with files and relative paths
|
||||
*/
|
||||
export async function extractDropEntries(
|
||||
dataTransfer: DataTransfer
|
||||
): Promise<DropEntry[]> {
|
||||
const items = dataTransfer.items;
|
||||
const results: DropEntry[] = [];
|
||||
|
||||
// Check if webkitGetAsEntry is supported (for folder access)
|
||||
if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === 'function') {
|
||||
// Collect all entries first (getAsEntry must be called synchronously)
|
||||
const entries: FileSystemEntry[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now process entries asynchronously
|
||||
for (const entry of entries) {
|
||||
const entryResults = await processEntry(entry);
|
||||
results.push(...entryResults);
|
||||
}
|
||||
} else {
|
||||
// Fallback: use regular FileList (no folder support)
|
||||
const files = dataTransfer.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
results.push({
|
||||
file,
|
||||
relativePath: file.name,
|
||||
isDirectory: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
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;
|
||||
}
|
||||
11
lib/utils.ts
@@ -1,6 +1,15 @@
|
||||
import { type ClassValue,clsx } from "clsx"
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize line endings to LF (Unix style).
|
||||
* Converts CRLF (Windows) and standalone CR (old Mac) to LF.
|
||||
* Used for clipboard paste operations in terminal to avoid extra blank lines.
|
||||
*/
|
||||
export function normalizeLineEndings(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
4456
package-lock.json
generated
19
package.json
@@ -1,23 +1,25 @@
|
||||
{
|
||||
"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",
|
||||
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --publish=never",
|
||||
"pack:dir": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --dir --publish=never",
|
||||
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --win --publish=never",
|
||||
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --mac --publish=never",
|
||||
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --linux --publish=never",
|
||||
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --publish=never",
|
||||
"pack:dir": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --dir --publish=never",
|
||||
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --publish=never",
|
||||
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --mac --publish=never",
|
||||
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"rebuild": "electron-builder install-app-deps",
|
||||
"lint": "eslint .",
|
||||
@@ -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",
|
||||
@@ -72,4 +77,4 @@
|
||||
"vite": "^7.2.7",
|
||||
"wait-on": "^9.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
public/dmg-background.jpg
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
public/dmg-fix-icon.png
Normal file
|
After Width: | Height: | Size: 727 KiB |
BIN
public/icon.png
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 26 KiB |
BIN
screenshots/broadcast_mode.png
Normal file
|
After Width: | Height: | Size: 995 KiB |
BIN
screenshots/host_config_advanced.png
Normal file
|
After Width: | Height: | Size: 849 KiB |
BIN
screenshots/host_config_general.png
Normal file
|
After Width: | Height: | Size: 849 KiB |
BIN
screenshots/hybrid_taxonomy.png
Normal file
|
After Width: | Height: | Size: 871 KiB |
BIN
screenshots/key_generator_ui.png
Normal file
|
After Width: | Height: | Size: 657 KiB |
BIN
screenshots/keychain_overview.png
Normal file
|
After Width: | Height: | Size: 671 KiB |