Compare commits

...

34 Commits

Author SHA1 Message Date
TachibanaLolo
ec04334a21 Merge branch 'binaricat:main' into main
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-01-09 22:03:02 +08:00
TachibanaLolo
57e3641ec5 docs: add Netcatty feature todo list 2026-01-09 22:02:34 +08:00
陈大猫
ad67099ff3 Merge pull request #58 from binaricat:copilot/refactor-theme-modal-interface
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Move terminal theme selection to modal in Settings
2026-01-09 18:15:11 +08:00
copilot-swe-agent[bot]
02d44652df Address code review: remove unused translations and add accessibility
- Remove unused 'currentTheme' translation keys from English and Chinese
- Add role="dialog", aria-modal="true", aria-labelledby to ThemeSelectModal
- Add aria-label for close button accessibility

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 10:01:06 +00:00
copilot-swe-agent[bot]
d227424096 Move terminal theme selection to modal in SettingsTerminalTab
- Create new ThemeSelectModal component for theme selection
- Replace inline theme grid with a preview button that opens modal
- Group themes by type (dark/light) in modal
- Add i18n translations for modal (English and Chinese)

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 09:58:06 +00:00
copilot-swe-agent[bot]
1105f7fbb1 Initial plan 2026-01-09 09:49:59 +00:00
陈大猫
ef681194e3 Merge pull request #57 from binaricat/copilot/fix-sftp-editor-save-issue
feat(sftp): add auto-sync for files opened with external apps
2026-01-09 17:47:03 +08:00
LAPTOP-O016UC3M\Qi Chen
4971a72620 fix: import shell from electron module 2026-01-09 17:44:33 +08:00
LAPTOP-O016UC3M\Qi Chen
8947d29717 feat(sftp): add temp directory management with System settings page
- Create dedicated Netcatty temp directory in system temp folder
- Add tempDirBridge for temp directory management
- Add System tab in Settings with temp dir info and cleanup
- Register temp files for cleanup when SFTP session closes
- Add i18n translations for System settings (en/zh-CN)
- Update agents.md with temp file guidelines
2026-01-09 17:42:39 +08:00
copilot-swe-agent[bot]
dfaeed1ed6 feat(sftp): add comprehensive logging for third-party app debugging
Add detailed console logging throughout the file open/watch flow:
- Log file download details (sftpId, paths, temp path)
- Log app launch command and platform
- Log file watch start/stop events
- Log auto-sync check status
- Add error handling for app spawn failures

This will help diagnose issues with third-party apps not triggering
file sync on Windows and other platforms.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 08:52:45 +00:00
copilot-swe-agent[bot]
443e038dcf fix(sftp): fix Ctrl+S stale closure and improve file watcher events
- Fix built-in editor Ctrl+S stale closure bug that caused saving the
  initial content instead of the current content
- Use a ref to always capture the latest handleSave function
- Improve file watcher to handle both 'change' and 'rename' events
  (some editors use atomic writes which trigger 'rename' instead)

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 08:17:04 +00:00
copilot-swe-agent[bot]
242d35927a fix(sftp): improve file watcher reliability
- Change fs.watch to use persistent: true for more reliable file watching
- Store webContents reference in watchInfo for later use
- Add debug logging for file system events

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 08:00:48 +00:00
copilot-swe-agent[bot]
708ee1cd09 feat(sftp): add system-level notifications for file sync
Use Electron's Notification API to show macOS/Windows/Linux system
notifications when files are synced to or fail to sync to remote.
These notifications are visible even when the app is not focused.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 06:52:49 +00:00
copilot-swe-agent[bot]
a2c24c2656 fix: use crypto.randomUUID for watchId and add file size validation
Address code review comments:
- Use crypto.randomUUID() instead of Math.random() for watchId generation
- Add file size checking in addition to mtime to prevent spurious sync events

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 06:33:35 +00:00
copilot-swe-agent[bot]
d91ed8dd23 feat(sftp): add auto-sync feature for files opened with external apps
Implements file watching to automatically sync changes back to remote:
- Add fileWatcherBridge in Electron for watching local temp files
- Add startFileWatch/stopFileWatch APIs in preload
- Add sftpAutoSync setting with UI toggle in settings
- Update downloadToTempAndOpen to support file watching
- Add i18n translations for auto-sync UI
- Show toast notifications when files are synced to remote

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 06:29:29 +00:00
copilot-swe-agent[bot]
689bb313f7 Initial plan 2026-01-09 06:12:22 +00:00
陈大猫
4ff05f7dbb Merge pull request #53 from AkarinServer/feature/linux-build-support
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat: add linux build support (x64/arm64)
2026-01-08 23:34:28 +08:00
TachibanaLolo
8258ad6e95 Merge pull request #1 from AkarinServer/feature/linux-build-support
feat: add linux build support (x64/arm64)
2026-01-08 23:22:16 +08:00
TachibanaLolo
f930e80dab feat: add linux build support (x64/arm64) 2026-01-08 23:21:05 +08:00
陈大猫
e19b68db12 Merge pull request #52 from binaricat:copilot/add-auto-start-reconnect-port-forwarding
feat: add auto-start and auto-reconnect for port forwarding rules
2026-01-08 20:13:10 +08:00
copilot-swe-agent[bot]
f6e67b6edb fix: move auto-start to app level and remove indicator icon
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-08 12:10:31 +00:00
copilot-swe-agent[bot]
a86c74e509 refactor: extract reconnect logic into helper function and add a11y attributes
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-08 11:43:43 +00:00
copilot-swe-agent[bot]
bedcaddea7 feat: add auto-start and auto-reconnect for port forwarding rules
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-08 11:39:36 +00:00
copilot-swe-agent[bot]
78aaa6840b Initial plan 2026-01-08 11:26:17 +00:00
陈大猫
dff869a89d Merge pull request #51 from binaricat/copilot/add-keepalive-feature
Add SSH Keepalive Interval setting
2026-01-08 19:09:52 +08:00
copilot-swe-agent[bot]
78d7b417fc Address code review feedback for keepalive interval
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-08 10:36:17 +00:00
copilot-swe-agent[bot]
27fcc4e493 Add keepaliveInterval setting for SSH connections
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-08 10:33:41 +00:00
copilot-swe-agent[bot]
b7216e9427 Initial plan 2026-01-08 10:26:20 +00:00
陈大猫
be4da72b21 Merge pull request #50 from qi-xmu/add_local_fonts
Add  local mono fonts support
2026-01-08 18:09:28 +08:00
LAPTOP-O016UC3M\Qi Chen
7b903c44b0 Removes unused import of terminal fonts list
Streamlines imports by removing a redundant font list import,
reducing unnecessary dependencies and improving code clarity.
2026-01-08 18:07:52 +08:00
LAPTOP-O016UC3M\Qi Chen
c3c23d042f refactor: replace useFontState hook with global fontStore singleton
- Create fontStore.ts with useSyncExternalStore pattern (matches project style)
- Eliminate props drilling (availableFonts through 5+ layers)
- Improve font detection with KNOWN_MONOSPACE_FONTS (50+ fonts)
- Add eager initialization at app startup
- Remove unused useFontState.ts
- Components now use useAvailableFonts()/useFontById() directly
2026-01-08 18:05:15 +08:00
LAPTOP-O016UC3M\Qi Chen
3263676996 fix: Address PR review comments for local font support
- Improve font filtering logic with word boundary matching to avoid false positives
- Rename snake_case variable to camelCase (mono_fonts -> monoFonts)
- Translate Chinese comments to English
- Add TypeScript type definitions for Font Access API
- Check API availability before calling queryLocalFonts
- Apply CJK fallback fonts to local fonts
- Add 'local-' prefix for local font IDs to avoid collisions
- Handle permission denied gracefully
- Use TERMINAL_FONTS as default for availableFonts
- Add safer fallbacks for empty availableFonts in all components
- Export withCjkFallback function for reuse
2026-01-08 17:53:46 +08:00
陈大猫
7c6a14afda Update lib/localFonts.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-08 17:37:20 +08:00
qi-xmu
6a76287bf7 Add local mono fonts support 2026-01-08 15:38:20 +08:00
43 changed files with 2330 additions and 146 deletions

View File

@@ -18,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 }}
@@ -58,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:

11
App.tsx
View File

@@ -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';
@@ -23,6 +25,9 @@ import { LogView as LogViewType } from './application/state/useSessionState';
import type { SftpView as SftpViewComponent } from './components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
// Initialize fonts eagerly at app startup
initializeFonts();
// Visibility container for VaultView - isolates isActive subscription
const VaultViewContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const isActive = useIsVaultActive();
@@ -280,6 +285,12 @@ 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 })),
});
// Debounce ref for moveFocus to prevent double-triggering when focus switches
const lastMoveFocusTimeRef = useRef<number>(0);
const MOVE_FOCUS_DEBOUNCE_MS = 200;

View File

@@ -16,7 +16,7 @@
<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>
&nbsp;
<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>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
@@ -67,7 +67,7 @@
<a name="what-is-netcatty"></a>
# What is Netcatty
**Netcatty** is a modern SSH client and terminal manager for macOS and Windows, designed for developers, sysadmins, and DevOps engineers who need to manage multiple remote servers efficiently.
**Netcatty** is a modern SSH client and terminal manager for macOS, Windows, and Linux, designed for developers, sysadmins, and DevOps engineers who need to manage multiple remote servers efficiently.
- **Netcatty is** an alternative to PuTTY, Termius, SecureCRT, and macOS Terminal.app for SSH connections
- **Netcatty is** a powerful SFTP client with dual-pane file browser
@@ -279,7 +279,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
@@ -329,6 +329,7 @@ npm run pack
# Package for specific platforms
npm run pack:mac # macOS (DMG + ZIP)
npm run pack:win # Windows (NSIS installer)
npm run pack:linux # Linux (AppImage + DEB + RPM)
```
---

View File

@@ -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.

View File

@@ -61,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',
@@ -107,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',
@@ -179,6 +198,9 @@ const en: Messages = {
'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',
@@ -372,6 +394,8 @@ 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',
@@ -509,6 +533,14 @@ const en: Messages = {
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Auto-sync to remote',
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
'settings.sftp.autoSync.enable': 'Enable auto-sync',
'settings.sftp.autoSync.enableDesc': 'When you save a file in an external application, changes will be automatically uploaded to the remote server',
'sftp.autoSync.success': 'File synced to remote: {fileName}',
'sftp.autoSync.error': 'Failed to sync file: {error}',
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',

View File

@@ -49,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': '检查更新',
@@ -674,6 +689,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': '本地',
@@ -748,9 +765,21 @@ const zhCN: Messages = {
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': '自动同步到远程',
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
'settings.sftp.autoSync.enable': '启用自动同步',
'settings.sftp.autoSync.enableDesc': '在外部应用程序中保存文件时,更改将自动上传到远程服务器',
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
'sftp.autoSync.error': '同步文件失败:{error}',
// Settings > Terminal
'settings.terminal.section.theme': '终端主题',
'settings.terminal.themeModal.title': '选择主题',
'settings.terminal.themeModal.darkThemes': '深色主题',
'settings.terminal.themeModal.lightThemes': '浅色主题',
'settings.terminal.theme.selectButton': '选择主题',
'settings.terminal.section.font': '字体',
'settings.terminal.section.cursor': '光标',
'settings.terminal.section.keyboard': '键盘',
@@ -817,6 +846,9 @@ const zhCN: Messages = {
'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': '快捷键方案',

View 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();
};

View 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]);
};

View File

@@ -7,6 +7,7 @@ import {
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
clearReconnectTimer,
getActiveConnection,
getActiveRuleIds,
startPortForward,
@@ -51,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,
@@ -212,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],
);
@@ -226,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);

View File

@@ -17,11 +17,13 @@ STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { TERMINAL_FONTS, DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
import { useAvailableFonts } from './fontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -33,11 +35,12 @@ const DEFAULT_CUSTOM_ACCENT = '221.2 83.2% 53.3%';
const DEFAULT_TERMINAL_THEME = 'netcatty-dark';
const DEFAULT_FONT_FAMILY = 'menlo';
// Auto-detect default hotkey scheme based on platform
const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
? 'mac'
const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
? 'mac'
: 'pc';
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
const DEFAULT_SFTP_AUTO_SYNC = false;
const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
@@ -99,13 +102,14 @@ const applyThemeTokens = (
root.style.setProperty('--border', tokens.border);
root.style.setProperty('--input', tokens.input);
root.style.setProperty('--ring', accentToken);
// Sync with native window title bar (Electron)
netcattyBridge.get()?.setTheme?.(theme);
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
};
export const useSettingsState = () => {
const availableFonts = useAvailableFonts();
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
const stored = readStoredString(STORAGE_KEY_THEME);
return stored && isValidTheme(stored) ? stored : DEFAULT_THEME;
@@ -148,17 +152,21 @@ export const useSettingsState = () => {
}
return DEFAULT_HOTKEY_SCHEME;
});
const [customKeyBindings, setCustomKeyBindings] = useState<CustomKeyBindings>(() =>
const [customKeyBindings, setCustomKeyBindings] = useState<CustomKeyBindings>(() =>
localStorageAdapter.read<CustomKeyBindings>(STORAGE_KEY_CUSTOM_KEY_BINDINGS) || {}
);
const [isHotkeyRecording, setIsHotkeyRecordingState] = useState(false);
const [customCSS, setCustomCSS] = useState<string>(() =>
const [customCSS, setCustomCSS] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS) || ''
);
const [sftpDoubleClickBehavior, setSftpDoubleClickBehavior] = useState<'open' | 'transfer'>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
return (stored === 'open' || stored === 'transfer') ? stored : DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR;
});
const [sftpAutoSync, setSftpAutoSync] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_SYNC;
});
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
@@ -383,11 +391,18 @@ export const useSettingsState = () => {
setSftpDoubleClickBehavior(e.newValue);
}
}
// Sync SFTP auto-sync setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpAutoSync) {
setSftpAutoSync(newValue);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -427,7 +442,7 @@ export const useSettingsState = () => {
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
// Apply custom CSS to document
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
if (!styleEl) {
@@ -444,6 +459,12 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
}, [sftpDoubleClickBehavior, notifySettingsChanged]);
// Persist SFTP auto-sync setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
}, [sftpAutoSync, notifySettingsChanged]);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -502,8 +523,8 @@ export const useSettingsState = () => {
);
const currentTerminalFont = useMemo(
() => TERMINAL_FONTS.find(f => f.id === terminalFontFamilyId) || TERMINAL_FONTS[0],
[terminalFontFamilyId]
() => availableFonts.find(f => f.id === terminalFontFamilyId) || availableFonts[0],
[terminalFontFamilyId, availableFonts]
);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
@@ -552,5 +573,8 @@ export const useSettingsState = () => {
setCustomCSS,
sftpDoubleClickBehavior,
setSftpDoubleClickBehavior,
sftpAutoSync,
setSftpAutoSync,
availableFonts,
};
};

View File

@@ -184,16 +184,51 @@ export const useSftpBackend = () => {
sftpId: string,
remotePath: string,
fileName: string,
appPath: 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 {

View File

@@ -143,7 +143,32 @@ const createEmptyPane = (id?: string): SftpPane => ({
filter: "",
});
export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity[]) => {
// File watch event types
export interface FileWatchSyncedEvent {
watchId: string;
localPath: string;
remotePath: string;
bytesWritten: number;
}
export interface FileWatchErrorEvent {
watchId: string;
localPath: string;
remotePath: string;
error: string;
}
export interface SftpStateOptions {
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
onFileWatchError?: (event: FileWatchErrorEvent) => void;
}
export const useSftpState = (
hosts: Host[],
keys: SSHKey[],
identities: Identity[],
options?: SftpStateOptions
) => {
// Multi-tab state: left and right sides each have multiple tabs
const [leftTabs, setLeftTabs] = useState<SftpSideTabs>({
tabs: [],
@@ -540,6 +565,29 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
};
}, []);
// Listen for file watch events (auto-sync feature)
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFileWatchSynced || !bridge?.onFileWatchError) return;
const unsubscribeSynced = bridge.onFileWatchSynced((payload: FileWatchSyncedEvent) => {
options?.onFileWatchSynced?.(payload);
});
const unsubscribeError = bridge.onFileWatchError((payload: FileWatchErrorEvent) => {
options?.onFileWatchError?.(payload);
});
return () => {
try {
unsubscribeSynced?.();
unsubscribeError?.();
} catch {
// ignore cleanup errors
}
};
}, [options]);
// Track if initial auto-connect has been done
const initialConnectDoneRef = useRef(false);
@@ -2604,8 +2652,16 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
);
// Download file to temp directory and open with external application
// If enableWatch is true and the file is remote, starts watching the temp file for changes
// Returns { localTempPath, watchId } if watch was started, otherwise just { localTempPath }
const downloadToTempAndOpen = useCallback(
async (side: "left" | "right", remotePath: string, fileName: string, appPath: string): Promise<void> => {
async (
side: "left" | "right",
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
): Promise<{ localTempPath: string; watchId?: string }> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
@@ -2617,9 +2673,9 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
}
if (pane.connection.isLocal) {
// For local files, just open directly
// For local files, just open directly (no watching needed)
await bridge.openWithApplication(remotePath, appPath);
return;
return { localTempPath: remotePath };
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
@@ -2628,10 +2684,42 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
}
// Download to temp directory
console.log("[SFTP] Downloading file to temp", { sftpId, remotePath, fileName });
const localTempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName);
console.log("[SFTP] File downloaded to temp", { localTempPath });
// Register temp file for cleanup when SFTP session closes (regardless of auto-sync setting)
if (bridge.registerTempFile) {
try {
await bridge.registerTempFile(sftpId, localTempPath);
} catch (err) {
console.warn("[SFTP] Failed to register temp file for cleanup:", err);
}
}
// Open with the selected application
console.log("[SFTP] Opening with application", { localTempPath, appPath });
await bridge.openWithApplication(localTempPath, appPath);
console.log("[SFTP] Application launched");
// Start file watching if enabled
let watchId: string | undefined;
console.log("[SFTP] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
if (options?.enableWatch && bridge.startFileWatch) {
try {
console.log("[SFTP] Starting file watch", { localTempPath, remotePath, sftpId });
const result = await bridge.startFileWatch(localTempPath, remotePath, sftpId);
watchId = result.watchId;
console.log("[SFTP] File watch started successfully", { watchId, localTempPath, remotePath });
} catch (err) {
console.warn("[SFTP] Failed to start file watch:", err);
// Don't fail the operation if watching fails
}
} else {
console.log("[SFTP] File watching not enabled or not available");
}
return { localTempPath, watchId };
},
[getActivePane],
);

View File

@@ -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) {

View File

@@ -43,6 +43,7 @@ import React, {
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { useSettingsState } from "../application/state/useSettingsState";
import { logger } from "../lib/logger";
import { getFileExtension, isKnownBinaryFile, FileOpenerType, SystemAppInfo } from "../lib/sftpFileUtils";
import { cn } from "../lib/utils";
@@ -303,6 +304,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
downloadSftpToTempAndOpen,
} = useSftpBackend();
const { t, resolvedLocale } = useI18n();
const { sftpAutoSync } = useSettingsState();
const isLocalSession = host.protocol === "local";
const [currentPath, setCurrentPath] = useState("/");
const [files, setFiles] = useState<RemoteFile[]>([]);
@@ -1163,7 +1165,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
}
} else {
const sftpId = await ensureSftp();
await downloadSftpToTempAndOpen(sftpId, fullPath, file.name, savedOpener.systemApp.path);
await downloadSftpToTempAndOpen(sftpId, fullPath, file.name, savedOpener.systemApp.path, { enableWatch: sftpAutoSync });
}
} catch (e) {
toast.error(
@@ -1176,7 +1178,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
// Show opener dialog
openFileOpenerDialog(file);
}
}, [getOpenerForFile, handleEditFile, openFileOpenerDialog, joinPath, currentPath, isLocalSession, ensureSftp, downloadSftpToTempAndOpen, t]);
}, [getOpenerForFile, handleEditFile, openFileOpenerDialog, joinPath, currentPath, isLocalSession, ensureSftp, downloadSftpToTempAndOpen, sftpAutoSync, t]);
const handleFileOpenerSelect = useCallback(async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
if (!fileOpenerTarget) return;
@@ -1203,7 +1205,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
}
} else {
const sftpId = await ensureSftp();
await downloadSftpToTempAndOpen(sftpId, fullPath, fileOpenerTarget.name, systemApp.path);
await downloadSftpToTempAndOpen(sftpId, fullPath, fileOpenerTarget.name, systemApp.path, { enableWatch: sftpAutoSync });
}
} catch (e) {
toast.error(
@@ -1214,7 +1216,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
}
setFileOpenerTarget(null);
}, [fileOpenerTarget, setOpenerForExtension, handleEditFile, joinPath, currentPath, isLocalSession, ensureSftp, downloadSftpToTempAndOpen, t]);
}, [fileOpenerTarget, setOpenerForExtension, handleEditFile, joinPath, currentPath, isLocalSession, ensureSftp, downloadSftpToTempAndOpen, sftpAutoSync, t]);
// Callback for FileOpenerDialog to select a system application
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {

View File

@@ -2,7 +2,7 @@
* Settings Page - Standalone settings window content
* This component is rendered in a separate Electron window
*/
import { AppWindow, Cloud, FileType, 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";
@@ -13,11 +13,15 @@ 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"));
@@ -130,6 +134,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
>
<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>
@@ -165,6 +175,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
availableFonts={settings.availableFonts}
/>
)}
@@ -189,6 +200,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
<SettingsSyncTabWithVault />
</React.Suspense>
)}
{mountedTabs.has("system") && <SettingsSystemTab />}
</div>
</Tabs>
</div>

View File

@@ -1480,8 +1480,22 @@ interface SftpViewProps {
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
const sftp = useSftpState(hosts, keys, identities);
const { sftpDoubleClickBehavior } = useSettingsState();
const { sftpDoubleClickBehavior, sftpAutoSync } = useSettingsState();
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
toast.success(t('sftp.autoSync.success', { fileName }));
logger.info("[SFTP] File auto-synced to remote", payload);
},
onFileWatchError: (payload: { error: string }) => {
toast.error(t('sftp.autoSync.error', { error: payload.error }));
logger.error("[SFTP] File auto-sync failed", payload);
},
}), [t]);
const sftp = useSftpState(hosts, keys, identities, fileWatchHandlers);
// Store sftp in a ref so callbacks can access the latest instance
// without needing to re-create when sftp changes
@@ -1491,6 +1505,10 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
// Store behavior setting in ref for stable callbacks
const behaviorRef = useRef(sftpDoubleClickBehavior);
behaviorRef.current = sftpDoubleClickBehavior;
// Store auto-sync setting in ref for stable callbacks
const autoSyncRef = useRef(sftpAutoSync);
autoSyncRef.current = sftpAutoSync;
// Sync activeTabId to external store (allows child components to subscribe without parent re-render)
// Using useLayoutEffect to sync before paint
@@ -1743,7 +1761,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
side,
fullPath,
file.name,
savedOpener.systemApp.path
savedOpener.systemApp.path,
{ enableWatch: autoSyncRef.current }
);
} catch (e) {
toast.error(
@@ -1785,7 +1804,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
fileOpenerTarget.side,
fileOpenerTarget.fullPath,
fileOpenerTarget.file.name,
systemApp.path
systemApp.path,
{ enableWatch: autoSyncRef.current }
);
} catch (e) {
toast.error(

View File

@@ -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>
) : (

View File

@@ -26,7 +26,7 @@ import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
import SFTPModal from "./SFTPModal";
import { Button } from "./ui/button";
import { toast } from "./ui/toast";
import { TERMINAL_FONTS } from "../infrastructure/config/fonts";
import { useAvailableFonts } from "../application/state/fontStore";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
@@ -129,6 +129,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}) => {
const CONNECTION_TIMEOUT = 12000;
const { t } = useI18n();
const availableFonts = useAvailableFonts();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
@@ -551,7 +552,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current.options.fontSize = effectiveFontSize;
const hostFontId = host.fontFamily || fontFamilyId || "menlo";
const fontObj = TERMINAL_FONTS.find((f) => f.id === hostFontId) || TERMINAL_FONTS[0];
const fontObj = availableFonts.find((f) => f.id === hostFontId) || availableFonts[0];
termRef.current.options.fontFamily = fontObj.family;
termRef.current.options.theme = {
@@ -561,7 +562,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setTimeout(() => safeFit(), 50);
}
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme]);
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
useEffect(() => {
if (isVisible && fitAddonRef.current) {

View File

@@ -95,6 +95,9 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
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(() =>
@@ -140,6 +143,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
}
}, [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'));
@@ -155,9 +163,9 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
editorRef.current = editor;
// Add save shortcut
// Add save shortcut - use ref to avoid stale closure
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSave();
handleSaveRef.current();
});
// Add find shortcut (Ctrl+F / Cmd+F)
@@ -165,7 +173,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
// Trigger Monaco's built-in find widget
editor.trigger('keyboard', 'actions.find', null);
});
}, [handleSave]);
}, []);
// Trigger search dialog
const handleSearch = useCallback(() => {

View File

@@ -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

View File

@@ -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

View 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;

View File

@@ -29,7 +29,7 @@ const getOpenerLabel = (
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior } = useSettingsState();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync } = useSettingsState();
const associations = getAllAssociations();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
@@ -133,6 +133,46 @@ export default function SettingsFileAssociationsTab() {
</div>
</div>
{/* Auto-sync section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.autoSync')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.autoSync.desc')}
</p>
<button
onClick={() => setSftpAutoSync(!sftpAutoSync)}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpAutoSync
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpAutoSync
? "border-primary bg-primary"
: "border-muted-foreground/30"
)}>
{sftpAutoSync && (
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.autoSync.enable')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.autoSync.enableDesc')}
</p>
</div>
</div>
</button>
</div>
{/* File associations section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftpFileAssociations.title')} />

View 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;

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from "react";
import { AlertCircle, 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,6 +89,7 @@ export default function SettingsTerminalTab(props: {
setTerminalFontSize,
terminalSettings,
updateTerminalSetting,
availableFonts,
} = props;
const { t } = useI18n();
@@ -97,6 +97,12 @@ export default function SettingsTerminalTab(props: {
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(() => {
@@ -182,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">
@@ -201,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"
/>
@@ -578,6 +586,28 @@ export default function SettingsTerminalTab(props: {
</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>
);
}

View File

@@ -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}

View File

@@ -343,6 +343,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
env: termEnv,
proxy: proxyConfig,
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
});
};

View File

@@ -10,7 +10,7 @@ import {
getAppLevelActions,
getTerminalPassthroughActions,
} from "../../../application/state/useGlobalHotkeys";
import { TERMINAL_FONTS } from "../../../infrastructure/config/fonts";
import { fontStore } from "../../../application/state/fontStore";
import {
XTERM_PERFORMANCE_CONFIG,
type XTermPlatform,
@@ -73,7 +73,7 @@ export type CreateXTermRuntimeContext = {
) => void;
commandBufferRef: RefObject<string>;
setIsSearchOpen: Dispatch<SetStateAction<boolean>>;
// Serial-specific options
serialLocalEcho?: boolean;
serialLineMode?: boolean;
@@ -116,7 +116,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
});
const hostFontId = ctx.host.fontFamily || ctx.fontFamilyId || "menlo";
const fontObj = TERMINAL_FONTS.find((f) => f.id === hostFontId) || TERMINAL_FONTS[0];
// Use fontStore for font lookup - guarantees non-empty result
const fontObj = fontStore.getFontById(hostFontId);
const fontFamily = fontObj.family;
const effectiveFontSize = ctx.host.fontSize || ctx.fontSize;

View File

@@ -378,6 +378,9 @@ export interface TerminalSettings {
// 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[] = [
@@ -415,6 +418,7 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
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 {
@@ -572,6 +576,8 @@ export interface PortForwardingRule {
remotePort?: number;
// Host to tunnel through
hostId?: string;
// Auto-start: if true, this rule will automatically start when the app launches
autoStart?: boolean;
// Runtime state
status: PortForwardingStatus;
error?: string;

View File

@@ -67,15 +67,15 @@
"target": [
{
"target": "AppImage",
"arch": ["x64"]
"arch": ["x64", "arm64"]
},
{
"target": "deb",
"arch": ["x64"]
"arch": ["x64", "arm64"]
},
{
"target": "dir",
"arch": ["x64"]
"target": "rpm",
"arch": ["x64", "arm64"]
}
],
"category": "Development"

View 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,
};

View File

@@ -10,6 +10,7 @@ const net = require("node:net");
const SftpClient = require("ssh2-sftp-client");
const { Client: SSHClient } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
// SFTP clients storage - shared reference passed from main
let sftpClients = null;
@@ -544,12 +545,19 @@ async function writeSftpBinaryWithProgress(event, payload) {
/**
* Close an SFTP connection
* Also cleans up any jump host connections if present
* 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) {

View File

@@ -199,7 +199,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
port: jump.port || 22,
username: jump.username || 'root',
readyTimeout: 20000, // Reduced from 60s for faster failure detection
keepaliveInterval: 10000,
// Use user-configured keepalive interval from options (in seconds -> convert to ms)
// If 0 or not provided, use 10000ms as default
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
keepaliveCountMax: 3,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
@@ -355,7 +357,9 @@ async function startSSHSession(event, options) {
username: options.username || "root",
// `readyTimeout` covers the entire connection + authentication flow in ssh2.
readyTimeout: 20000, // Fast failure for non-interactive auth
keepaliveInterval: 10000,
// Use user-configured keepalive interval (in seconds -> convert to ms)
// If 0 or not provided, use 10000ms as default
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
keepaliveCountMax: 3,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)

View 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,
};

View File

@@ -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 () => {
@@ -470,33 +478,94 @@ const registerBridges = (win) => {
// Open a file with a specific application
ipcMain.handle("netcatty:openWithApplication", async (_event, { filePath, appPath }) => {
const { shell, spawn } = electronModule;
const { spawn: cpSpawn } = require("node:child_process");
if (process.platform === "darwin") {
// On macOS, use 'open' command with -a flag for specific app
cpSpawn("open", ["-a", appPath, filePath], { detached: true, stdio: "ignore" }).unref();
} else if (process.platform === "win32") {
// On Windows, just spawn the exe with the file as argument
cpSpawn(appPath, [filePath], { detached: true, stdio: "ignore", shell: true }).unref();
} else {
// On Linux, spawn the app with the file
cpSpawn(appPath, [filePath], { detached: true, stdio: "ignore" }).unref();
}
console.log(`[Main] Opening file with application:`);
console.log(`[Main] File: ${filePath}`);
console.log(`[Main] App: ${appPath}`);
console.log(`[Main] Platform: ${process.platform}`);
return true;
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");
const tempDir = os.tmpdir();
const tempFileName = `netcatty_${Date.now()}_${fileName}`;
const localPath = path.join(tempDir, tempFileName);
// 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") {
@@ -504,18 +573,42 @@ const registerBridges = (win) => {
} 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');
};

View File

@@ -198,6 +198,30 @@ ipcRenderer.on("netcatty:portforward:status", (_event, payload) => {
}
});
// File watcher listeners (for auto-sync feature)
const fileWatchSyncedListeners = new Set();
const fileWatchErrorListeners = new Set();
ipcRenderer.on("netcatty:filewatch:synced", (_event, payload) => {
fileWatchSyncedListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("File watch synced callback failed", err);
}
});
});
ipcRenderer.on("netcatty:filewatch:error", (_event, payload) => {
fileWatchErrorListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("File watch error callback failed", err);
}
});
});
const api = {
startSSHSession: async (options) => {
const result = await ipcRenderer.invoke("netcatty:start", options);
@@ -512,6 +536,38 @@ const api = {
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

19
global.d.ts vendored
View File

@@ -61,6 +61,8 @@ interface NetcattySSHOptions {
proxy?: NetcattyProxyConfig;
// Jump hosts (bastion chain)
jumpHosts?: NetcattyJumpHost[];
// SSH-level keepalive interval in seconds (0 = disabled)
keepaliveInterval?: number;
}
interface SftpStatResult {
@@ -415,6 +417,23 @@ interface NetcattyBridge {
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 {

View File

@@ -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.

View File

@@ -40,6 +40,7 @@ export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associatio
// SFTP Settings
export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_click_behavior_v1';
export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
// Archived legacy key records that are no longer supported by the app (e.g. biometric/WebAuthn/FIDO2 experiments).
export const STORAGE_KEY_LEGACY_KEYS = 'netcatty_legacy_keys_v1';

View File

@@ -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
*/
@@ -106,15 +179,20 @@ export const syncWithBackend = async (): Promise<void> => {
/**
* 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...');
@@ -141,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');
@@ -170,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 };
@@ -196,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 };
@@ -249,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();
};
@@ -299,4 +413,6 @@ export default {
getPortForwardStatus,
isBackendAvailable,
stopAllPortForwards,
setReconnectCallback,
clearReconnectTimer,
};

127
lib/localFonts.ts Normal file
View 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 [];
}
}

View File

@@ -1,10 +1,11 @@
{
"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": {

162
to-do.md Normal file
View File

@@ -0,0 +1,162 @@
# Netcatty Feature TODO List
项目地址: https://github.com/binaricat/Netcatty
## 功能需求清单
### 1. GB18030编码支持 🔤
**优先级**: 高
**需求描述**:
- 支持操作文件名为GB18030编码的文件
- 实现动态编码切换,无需断开重连即可生效
- 解决目前市面上工具需要重新连接才能应用编码设置的问题
**技术要点**:
- SFTP文件列表的编码转换
- 文件名编码自动检测/手动切换
- 保持连接状态下的编码切换
---
### 2. SFTP的sudo提权支持 🔐
**优先级**: 高
**需求描述**:
- 普通用户通过SFTP操作文件时支持sudo提权
- 两种可选实现方式:
- **方式A (WinSCP式)**: 要求服务器端配置sudo免密码
- **方式B (HexHub式)**: 使用保存的密码自动完成sudo鉴权 ⭐ 推荐
**技术要点**:
- 研究HexHub的实现原理
- 密码安全存储
- sudo命令的SFTP封装
- 权限提升的UI交互设计
---
### 3. trzsz协议支持 📁
**优先级**: 中
**需求描述**:
- 集成trzsz文件传输协议
- 参考项目: https://github.com/trzsz/trzsz
- 解决electerm和tabby现有实现中的稳定性问题
**已知问题**:
- electerm和tabby支持trzsz但偶尔无法正常收发文件
- 具体bug现象待补充
**技术要点**:
- trzsz协议完整实现
- 文件传输的错误处理和重试机制
- 传输进度显示
- 大文件传输稳定性测试
---
### 4. 终端性能优化 ⚡
**优先级**: 高
**需求描述**:
- 解决基于xtermjs的终端在大量滚屏时的性能问题
- 确保高速输出场景下键盘输入的实时响应
**核心问题**:
- 大量刷屏时`Ctrl+C`信号发不出去
- tmux切换窗口命令无响应
- 输入延迟严重
**技术要点**:
- 终端渲染性能优化
- 输入处理与渲染分离
- 虚拟滚动/缓冲区管理
- 输入队列优先级处理
- 压力测试场景设计
---
### 5. X11 Forwarding支持 🖥️
**优先级**: 中
**需求描述**:
- 支持X11图形界面转发
- 能够在SSH连接中运行远程图形应用程序
**技术要点**:
- X11转发的SSH配置
- 本地X Server集成或推荐
- 跨平台兼容性Windows/macOS/Linux
- 连接配置UI
---
### 6. Terminal到SFTP目录定位 🎯
**优先级**: 中
**需求描述**:
- 在Terminal界面时点击右上角按钮
- 自动切换到SFTP视图并定位到当前工作目录
- 实现Terminal和SFTP之间的上下文联动
**已知问题**:
- 之前尝试实现但未成功
**技术要点**:
- 获取当前shell的工作目录`pwd`命令)
- Terminal和SFTP视图的状态同步
- 异步目录切换的UI反馈
- 处理特殊路径(软链接、权限不足等)
**实现思路**:
1. 通过发送`pwd`命令获取当前目录
2. 解析命令输出结果
3. 触发SFTP视图切换
4. 异步加载目标目录内容
---
## 开发注意事项 ⚠️
### 质量要求
- 充分的单元测试和集成测试
- 避免"按下葫芦起了瓢"的问题
- 每个功能都要有完整的测试用例
### 性能考虑
- 避免频繁的AI token消耗
- 代码review和人工测试相结合
- 建立性能基准测试
### 用户体验
- 这些都是"可以没有但有了方便很多"的功能
- 注重细节和边界情况处理
- 提供清晰的错误提示和操作引导
---
## 实现优先级建议
### Phase 1 - 核心功能完善
- [ ] GB18030编码支持
- [ ] 终端性能优化
- [ ] Terminal到SFTP目录定位
### Phase 2 - 高级特性
- [ ] SFTP的sudo提权支持
- [ ] trzsz协议支持
### Phase 3 - 扩展功能
- [ ] X11 Forwarding支持
---
## 参考资料
- trzsz项目: https://github.com/trzsz/trzsz
- 竞品分析: WinSCP, HexHub, electerm, tabby
- 技术栈: xtermjs (需要性能优化方案)
---
**最后更新**: 2026-01-09