Compare commits

...

27 Commits

Author SHA1 Message Date
陈大猫
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
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
LAPTOP-O016UC3M\Qi Chen
5317a4b81b Removes unnecessary whitespace in state initialization
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Cleans up formatting in the state initialization expression for improved code consistency. No functional changes introduced.
2026-01-08 11:15:16 +08:00
LAPTOP-O016UC3M\Qi Chen
2574d6d5e4 Syncs editor theme with app by observing HTML class
Replaces use of global settings state for theme with a MutationObserver
that tracks the presence of the 'dark' class on the root HTML element.
Ensures the editor theme stays consistent with the actual app theme,
even if changed via side effects outside React state.
2026-01-08 11:15:11 +08:00
LAPTOP-O016UC3M\Qi Chen
f04b1220ed Syncs editor theme with user settings
Updates the editor to use the app's current theme preference,
ensuring consistency with user-selected light or dark modes.
Also improves the loading UI to better match the surrounding style.
2026-01-08 11:09:41 +08:00
LAPTOP-O016UC3M\Qi Chen
ce4d156c2c Remove trailing whitespace for code style consistency
Cleans up unnecessary trailing whitespace to maintain consistent code formatting and improve readability. No functional changes introduced.
2026-01-08 11:05:46 +08:00
LAPTOP-O016UC3M\Qi Chen
ca46c9c924 Adds toggleable filter bar to SFTP pane toolbar
Introduces a dedicated, toggleable filter bar for searching SFTP files, improving discoverability and usability over the previous inline filter input. Updates translations to support the new filter UI in English and Chinese.

Enhances user experience by making filtering more accessible and visually distinct from other toolbar actions.
2026-01-08 11:05:41 +08:00
陈大猫
f0d2c5c60d Merge pull request #48 from binaricat/copilot/add-human-readable-file-size
Display human-readable file sizes in SftpView
2026-01-08 10:58:18 +08:00
copilot-swe-agent[bot]
6cdf33a29d Fix file sizes to display in human-readable format (KB, MB, GB) in SftpView
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-08 02:51:18 +00:00
copilot-swe-agent[bot]
9b0b7c0eb7 Initial plan 2026-01-08 02:46:44 +00:00
LAPTOP-O016UC3M\Qi Chen
5954359995 Allow manual build trigger to publish releases
Enables publishing a GitHub Release when the workflow is manually
triggered and the corresponding input is set, providing more
flexibility for release management beyond tag-based automation.
2026-01-08 10:40:00 +08:00
LAPTOP-O016UC3M\Qi Chen
044165319e Updates Monaco editor path handling for production builds
Configures the editor to load Monaco assets from a local directory in production, improving reliability and performance by avoiding CDN usage.
Adds prebuild script to copy Monaco files, and updates ignore rules to exclude copied assets from version control.
2026-01-08 10:34:49 +08:00
33 changed files with 943 additions and 233 deletions

View File

@@ -2,6 +2,11 @@ name: build-packages
on:
workflow_dispatch:
inputs:
publish_release:
description: "Publish GitHub Release after build"
type: boolean
default: false
push:
tags:
- "v*"
@@ -13,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
os: [macos-latest, windows-latest, ubuntu-latest]
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
@@ -53,6 +58,12 @@ jobs:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:win
- name: Build package (Linux)
if: matrix.os == 'ubuntu-latest'
env:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -74,7 +85,7 @@ jobs:
name: release
runs-on: ubuntu-latest
needs: build
if: startsWith(github.ref, 'refs/tags/')
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
permissions:
contents: write
steps:

1
.gitignore vendored
View File

@@ -22,6 +22,7 @@ coverage
/release
/out
*.asar
/public/monaco
# Editor directories and files
.vscode/*

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

@@ -179,6 +179,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,9 +375,13 @@ const en: Messages = {
'pf.deleteActive.title': 'Delete Active Port Forwarding?',
'pf.deleteActive.desc': 'This port forwarding rule "{label}" is currently active. Deleting it will stop the tunnel first.',
'pf.deleteActive.confirm': 'Stop and Delete',
'pf.form.autoStart': 'Auto Start',
'pf.form.autoStartDesc': 'Automatically start this rule when the app launches',
// SFTP
'sftp.newFolder': 'New Folder',
'sftp.filter': 'Filter',
'sftp.filter.placeholder': 'Filter by filename...',
'sftp.columns.name': 'Name',
'sftp.columns.modified': 'Modified',
'sftp.columns.size': 'Size',

View File

@@ -249,6 +249,8 @@ const zhCN: Messages = {
// SFTP
'sftp.newFolder': '新建文件夹',
'sftp.filter': '筛选',
'sftp.filter.placeholder': '按文件名筛选...',
'sftp.columns.name': '名称',
'sftp.columns.modified': '修改时间',
'sftp.columns.size': '大小',
@@ -672,6 +674,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': '本地',
@@ -815,6 +819,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

@@ -20,8 +20,9 @@ STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
} 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,9 +34,9 @@ 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';
@@ -99,13 +100,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,11 +150,11 @@ 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'>(() => {
@@ -427,7 +429,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) {
@@ -502,8 +504,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 +554,6 @@ export const useSettingsState = () => {
setCustomCSS,
sftpDoubleClickBehavior,
setSftpDoubleClickBehavior,
availableFonts,
};
};

View File

@@ -618,15 +618,18 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
return getMockLocalFiles(path);
}
return rawFiles.map((f) => ({
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size: parseInt(f.size) || 0,
sizeFormatted: f.size,
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
}));
return rawFiles.map((f) => {
const size = parseInt(f.size) || 0;
return {
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size,
sizeFormatted: formatFileSize(size),
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
};
});
},
[getMockLocalFiles],
);
@@ -636,15 +639,18 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
const rawFiles = await netcattyBridge.get()?.listSftp(sftpId, path);
if (!rawFiles) return [];
return rawFiles.map((f) => ({
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size: parseInt(f.size) || 0,
sizeFormatted: f.size,
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
}));
return rawFiles.map((f) => {
const size = parseInt(f.size) || 0;
return {
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size,
sizeFormatted: formatFileSize(size),
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
};
});
},
[],
);

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

@@ -14,10 +14,13 @@ import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociation
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
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"));
@@ -165,6 +168,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
availableFonts={settings.availableFonts}
/>
)}

View File

@@ -243,6 +243,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const [showPathSuggestions, setShowPathSuggestions] = useState(false);
const [pathSuggestionIndex, setPathSuggestionIndex] = useState(-1);
const pathInputRef = useRef<HTMLInputElement>(null);
// Inline search/filter bar state
const [showFilterBar, setShowFilterBar] = useState(false);
const filterInputRef = useRef<HTMLInputElement>(null);
const pathDropdownRef = useRef<HTMLDivElement>(null);
const [rowHeight, setRowHeight] = useState(0);
const [scrollTop, setScrollTop] = useState(0);
@@ -1003,161 +1007,176 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onDragLeave={handlePaneDragLeave}
onDrop={handlePaneDrop}
>
{/* Header - compact version - only show when showHeader is true */}
{showHeader && (
<div className="h-8 px-3 border-b border-border/60 flex items-center gap-2">
<div className="flex items-center gap-1.5 text-xs font-medium">
{pane.connection.isLocal ? (
<Monitor size={12} />
) : (
<HardDrive size={12} />
)}
<span>{pane.connection.hostLabel}</span>
{(pane.connection.status === "connecting" || pane.reconnecting) && (
<Loader2 size={10} className="animate-spin text-muted-foreground" />
)}
{pane.reconnecting && (
<span className="text-[10px] text-muted-foreground">
Reconnecting...
</span>
)}
{pane.connection.status === "error" && !pane.reconnecting && (
<AlertCircle size={10} className="text-destructive" />
{/* Toolbar - always visible when connected */}
<div className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={onNavigateUp}
title={t("sftp.goUp")}
>
<ChevronLeft size={12} />
</Button>
{/* Editable Breadcrumb with autocomplete */}
{isEditingPath ? (
<div className="relative flex-1">
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => {
setEditingPathValue(e.target.value);
setShowPathSuggestions(true);
setPathSuggestionIndex(-1);
}}
onBlur={handlePathBlur}
onKeyDown={handlePathKeyDown}
onFocus={() => setShowPathSuggestions(true)}
className="h-5 w-full text-[10px] bg-background"
autoFocus
/>
{showPathSuggestions && pathSuggestions.length > 0 && (
<div
ref={pathDropdownRef}
className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-48 overflow-auto"
>
{pathSuggestions.map((suggestion, idx) => (
<button
key={suggestion.path}
type="button"
className={cn(
"w-full px-3 py-2 text-left text-xs flex items-center gap-2 hover:bg-secondary/60 transition-colors",
idx === pathSuggestionIndex && "bg-secondary/80",
)}
onMouseDown={(e) => {
e.preventDefault();
handlePathSubmit(suggestion.path);
}}
>
{suggestion.type === "folder" ? (
<Folder size={12} className="text-primary shrink-0" />
) : (
<Home
size={12}
className="text-muted-foreground shrink-0"
/>
)}
<span className="truncate font-mono">
{suggestion.path}
</span>
</button>
))}
</div>
)}
</div>
<div className="flex items-center gap-1 ml-auto">
<div className="relative">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={pane.filter}
onChange={(e) =>
startTransition(() => onSetFilter(e.target.value))
}
placeholder="Filter..."
className="h-6 w-28 pl-6 pr-5 text-[10px] bg-secondary/40"
/>
{pane.filter && (
<button
onClick={() => startTransition(() => onSetFilter(""))}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={10} />
</button>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onRefresh}
title={t("common.refresh")}
>
<RefreshCw
size={12}
className={
pane.loading || pane.reconnecting ? "animate-spin" : ""
}
/>
</Button>
) : (
<div
className="flex-1 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={t("sftp.path.doubleClickToEdit")}
>
<SftpBreadcrumb
path={pane.connection.currentPath}
onNavigate={onNavigateTo}
onHome={() =>
pane.connection?.homeDir &&
onNavigateTo(pane.connection.homeDir)
}
/>
</div>
</div>
)}
)}
{/* Toolbar - compact - only show when showHeader is true */}
{showHeader && (
<div className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20">
<div className="ml-auto flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={onNavigateUp}
title={t("sftp.goUp")}
className="h-6 w-6"
onClick={() => setShowNewFolderDialog(true)}
title={t("sftp.newFolder")}
>
<ChevronLeft size={12} />
<FolderPlus size={14} />
</Button>
<Button
variant={showFilterBar || pane.filter ? "secondary" : "ghost"}
size="icon"
className={cn("h-6 w-6", pane.filter && "text-primary")}
onClick={() => {
setShowFilterBar(!showFilterBar);
if (!showFilterBar) {
setTimeout(() => filterInputRef.current?.focus(), 0);
}
}}
title={t("sftp.filter")}
>
<Search size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onRefresh}
title={t("common.refresh")}
>
<RefreshCw
size={14}
className={
pane.loading || pane.reconnecting ? "animate-spin" : ""
}
/>
</Button>
</div>
</div>
{/* Editable Breadcrumb with autocomplete */}
{isEditingPath ? (
<div className="relative flex-1">
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => {
setEditingPathValue(e.target.value);
setShowPathSuggestions(true);
setPathSuggestionIndex(-1);
}}
onBlur={handlePathBlur}
onKeyDown={handlePathKeyDown}
onFocus={() => setShowPathSuggestions(true)}
className="h-5 w-full text-[10px] bg-background"
autoFocus
/>
{showPathSuggestions && pathSuggestions.length > 0 && (
<div
ref={pathDropdownRef}
className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-48 overflow-auto"
>
{pathSuggestions.map((suggestion, idx) => (
<button
key={suggestion.path}
type="button"
className={cn(
"w-full px-3 py-2 text-left text-xs flex items-center gap-2 hover:bg-secondary/60 transition-colors",
idx === pathSuggestionIndex && "bg-secondary/80",
)}
onMouseDown={(e) => {
e.preventDefault();
handlePathSubmit(suggestion.path);
}}
>
{suggestion.type === "folder" ? (
<Folder size={12} className="text-primary shrink-0" />
) : (
<Home
size={12}
className="text-muted-foreground shrink-0"
/>
)}
<span className="truncate font-mono">
{suggestion.path}
</span>
</button>
))}
</div>
)}
</div>
) : (
<div
className="flex-1 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={t("sftp.path.doubleClickToEdit")}
>
<SftpBreadcrumb
path={pane.connection.currentPath}
onNavigate={onNavigateTo}
onHome={() =>
pane.connection?.homeDir &&
onNavigateTo(pane.connection.homeDir)
{/* Inline filter bar - appears below toolbar when search is active */}
{showFilterBar && (
<div className="h-8 px-3 flex items-center gap-2 border-b border-border/40 bg-secondary/10">
<div className="relative flex-1">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
ref={filterInputRef}
value={pane.filter}
onChange={(e) =>
startTransition(() => onSetFilter(e.target.value))
}
placeholder={t("sftp.filter.placeholder")}
className="h-6 w-full pl-7 pr-7 text-xs bg-background"
onKeyDown={(e) => {
if (e.key === "Escape") {
if (pane.filter) {
startTransition(() => onSetFilter(""));
} else {
setShowFilterBar(false);
}
}
/>
</div>
)}
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setShowNewFolderDialog(true)}
>
<FolderPlus size={12} className="mr-1" /> {t("sftp.newFolder")}
</Button>
}}
/>
{pane.filter && (
<button
onClick={() => startTransition(() => onSetFilter(""))}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={12} />
</button>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => {
startTransition(() => onSetFilter(""));
setShowFilterBar(false);
}}
title={t("common.close")}
>
<X size={14} />
</Button>
</div>
)}

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

@@ -12,7 +12,10 @@ import type * as Monaco from 'monaco-editor';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// Configure Monaco to use local files instead of CDN
loader.config({ paths: { vs: './node_modules/monaco-editor/min/vs' } });
const monacoBasePath = import.meta.env.DEV
? './node_modules/monaco-editor/min/vs'
: `${import.meta.env.BASE_URL}monaco/vs`;
loader.config({ paths: { vs: monacoBasePath } });
import { useI18n } from '../application/i18n/I18nProvider';
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
@@ -93,6 +96,21 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
// Track theme from document.documentElement class (syncs with app theme)
const [isDarkTheme, setIsDarkTheme] = useState(() =>
document.documentElement.classList.contains('dark')
);
// Listen for theme changes via MutationObserver on <html> class
useEffect(() => {
const root = document.documentElement;
const observer = new MutationObserver(() => {
setIsDarkTheme(root.classList.contains('dark'));
});
observer.observe(root, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
// Reset content when file changes
useEffect(() => {
setContent(initialContent);
@@ -159,6 +177,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
const monacoTheme = isDarkTheme ? 'vs-dark' : 'light';
const languageOptions = useMemo(
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
[supportedLanguages],
@@ -238,9 +257,9 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
value={content}
onChange={handleEditorChange}
onMount={handleEditorMount}
theme="vs-dark"
theme={monacoTheme}
loading={
<div className="flex items-center justify-center h-full bg-[#1e1e1e]">
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Loader2 size={32} className="animate-spin text-muted-foreground" />
</div>
}

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

@@ -9,7 +9,7 @@ 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";
@@ -80,6 +80,7 @@ export default function SettingsTerminalTab(props: {
key: K,
value: TerminalSettings[K],
) => void;
availableFonts: TerminalFont[];
}) {
const {
terminalThemeId,
@@ -90,6 +91,7 @@ export default function SettingsTerminalTab(props: {
setTerminalFontSize,
terminalSettings,
updateTerminalSetting,
availableFonts,
} = props;
const { t } = useI18n();
@@ -201,7 +203,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 +580,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

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

2
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 {

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

@@ -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 [];
}
}

32
package-lock.json generated
View File

@@ -1004,6 +1004,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1747,7 +1748,6 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1769,7 +1769,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1786,7 +1785,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1801,7 +1799,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -5655,6 +5652,7 @@
"integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.51.0",
@@ -5684,6 +5682,7 @@
"integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.51.0",
"@typescript-eslint/types": "8.51.0",
@@ -5962,7 +5961,8 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/7zip-bin": {
"version": "5.2.0",
@@ -5984,6 +5984,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6043,6 +6044,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -6410,6 +6412,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -7134,8 +7137,7 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/cross-env": {
"version": "10.1.0",
@@ -7376,6 +7378,7 @@
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.0.12",
"builder-util": "26.0.11",
@@ -7702,7 +7705,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -7723,7 +7725,6 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -7948,6 +7949,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10159,6 +10161,7 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
@@ -10608,6 +10611,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -10666,7 +10670,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -10684,7 +10687,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -10792,6 +10794,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -10801,6 +10804,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -11664,7 +11668,6 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -11728,7 +11731,6 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -11743,7 +11745,6 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -11892,6 +11893,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12094,6 +12096,7 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -12432,6 +12435,7 @@
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1,15 +1,17 @@
{
"name": "netcatty",
"description": "Netcatty is a modern SSH manager and terminal app with host grouping, SFTP, keychain, port forwarding, and a rich UI.",
"homepage": "https://github.com/binaricat/Netcatty",
"private": true,
"version": "0.0.0",
"type": "module",
"author": "binaricat",
"author": "binaricat <support@netcatty.com>",
"license": "GPL-3.0-or-later",
"main": "electron/main.cjs",
"scripts": {
"dev": "npm run lint && concurrently -k \"vite\" \"npm:dev:electron\"",
"dev:electron": "wait-on http-get://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 node electron/launch.cjs",
"prebuild": "node scripts/copy-monaco.cjs",
"build": "vite build",
"preview": "vite preview",
"start": "node electron/launch.cjs",

16
scripts/copy-monaco.cjs Normal file
View File

@@ -0,0 +1,16 @@
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const source = path.join(repoRoot, 'node_modules', 'monaco-editor', 'min', 'vs');
const target = path.join(repoRoot, 'public', 'monaco', 'vs');
if (!fs.existsSync(source)) {
console.error('[copy-monaco] Source not found:', source);
process.exit(1);
}
fs.rmSync(target, { recursive: true, force: true });
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.cpSync(source, target, { recursive: true });
console.log('[copy-monaco] Copied Monaco VS assets to', target);