Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad67099ff3 | ||
|
|
02d44652df | ||
|
|
d227424096 | ||
|
|
1105f7fbb1 | ||
|
|
ef681194e3 | ||
|
|
4971a72620 | ||
|
|
8947d29717 | ||
|
|
dfaeed1ed6 | ||
|
|
443e038dcf | ||
|
|
242d35927a | ||
|
|
708ee1cd09 | ||
|
|
a2c24c2656 | ||
|
|
d91ed8dd23 | ||
|
|
689bb313f7 |
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
@@ -514,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',
|
||||
|
||||
@@ -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': '检查更新',
|
||||
@@ -750,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': '键盘',
|
||||
|
||||
@@ -17,6 +17,7 @@ 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';
|
||||
@@ -39,6 +40,7 @@ const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
? '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);
|
||||
@@ -161,6 +163,10 @@ export const useSettingsState = () => {
|
||||
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) => {
|
||||
@@ -385,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);
|
||||
@@ -446,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 => {
|
||||
@@ -554,6 +573,8 @@ export const useSettingsState = () => {
|
||||
setCustomCSS,
|
||||
sftpDoubleClickBehavior,
|
||||
setSftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
setSftpAutoSync,
|
||||
availableFonts,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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,6 +13,7 @@ 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";
|
||||
|
||||
@@ -133,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>
|
||||
|
||||
@@ -193,6 +200,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
<SettingsSyncTabWithVault />
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("system") && <SettingsSystemTab />}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
186
components/settings/ThemeSelectModal.tsx
Normal file
186
components/settings/ThemeSelectModal.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Theme Select Modal
|
||||
* A modal dialog for selecting terminal themes in settings
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, Palette, X } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
// Memoized theme item component to prevent unnecessary re-renders
|
||||
const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
theme: TerminalThemeConfig;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => onSelect(theme.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all',
|
||||
isSelected
|
||||
? 'bg-primary/15 ring-1 ring-primary'
|
||||
: 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{/* Color swatch preview */}
|
||||
<div
|
||||
className="w-12 h-8 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
|
||||
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
|
||||
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={16} className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
));
|
||||
ThemeItem.displayName = 'ThemeItem';
|
||||
|
||||
interface ThemeSelectModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
selectedThemeId: string;
|
||||
onSelect: (themeId: string) => void;
|
||||
}
|
||||
|
||||
export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
selectedThemeId,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Group themes by type
|
||||
const { darkThemes, lightThemes } = useMemo(() => {
|
||||
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
|
||||
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
|
||||
return { darkThemes: dark, lightThemes: light };
|
||||
}, []);
|
||||
|
||||
// Handle theme selection - select and close
|
||||
const handleThemeSelect = useCallback((themeId: string) => {
|
||||
onSelect(themeId);
|
||||
onClose();
|
||||
}, [onSelect, onClose]);
|
||||
|
||||
// Handle ESC key
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Handle backdrop click
|
||||
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}, [onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const modalTitleId = 'theme-select-modal-title';
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/60"
|
||||
style={{ zIndex: 99999 }}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={modalTitleId}
|
||||
>
|
||||
<div
|
||||
className="w-[480px] max-h-[600px] bg-background border border-border rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 shrink-0 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-primary/10">
|
||||
<Palette size={16} className="text-primary" />
|
||||
</div>
|
||||
<h2 id={modalTitleId} className="text-sm font-semibold text-foreground">{t('settings.terminal.themeModal.title')}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Theme List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||
{/* Dark Themes Section */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('settings.terminal.themeModal.darkThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{darkThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Light Themes Section */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('settings.terminal.themeModal.lightThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{lightThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end px-5 py-3 shrink-0 border-t border-border bg-muted/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Use Portal to render at document root
|
||||
return createPortal(modalContent, document.body);
|
||||
};
|
||||
|
||||
export default ThemeSelectModal;
|
||||
@@ -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')} />
|
||||
|
||||
180
components/settings/tabs/SettingsSystemTab.tsx
Normal file
180
components/settings/tabs/SettingsSystemTab.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Settings System Tab - System information and temp file management
|
||||
*/
|
||||
import { FolderOpen, HardDrive, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Button } from "../../ui/button";
|
||||
|
||||
interface TempDirInfo {
|
||||
path: string;
|
||||
fileCount: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
const SettingsSystemTab: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const [tempDirInfo, setTempDirInfo] = useState<TempDirInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [clearResult, setClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
|
||||
|
||||
const loadTempDirInfo = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getTempDirInfo) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const info = await bridge.getTempDirInfo();
|
||||
setTempDirInfo(info);
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to get temp dir info:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTempDirInfo();
|
||||
}, [loadTempDirInfo]);
|
||||
|
||||
const handleClearTempFiles = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.clearTempDir) return;
|
||||
|
||||
setIsClearing(true);
|
||||
setClearResult(null);
|
||||
try {
|
||||
const result = await bridge.clearTempDir();
|
||||
setClearResult(result);
|
||||
// Refresh info after clearing
|
||||
await loadTempDirInfo();
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to clear temp dir:", err);
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
}, [loadTempDirInfo]);
|
||||
|
||||
const handleOpenTempDir = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!tempDirInfo?.path || !bridge?.openTempDir) return;
|
||||
await bridge.openTempDir();
|
||||
}, [tempDirInfo]);
|
||||
|
||||
return (
|
||||
<TabsContent
|
||||
value="system"
|
||||
className="data-[state=inactive]:hidden h-full flex flex-col"
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-8 py-6">
|
||||
<div className="max-w-2xl space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t("settings.system.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("settings.system.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Temp Directory Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("settings.system.tempDirectory")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||
{/* Path */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-muted-foreground">{t("settings.system.location")}</p>
|
||||
<p className="text-sm font-mono mt-1 break-all">
|
||||
{isLoading ? "..." : (tempDirInfo?.path ?? "-")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={handleOpenTempDir}
|
||||
disabled={!tempDirInfo?.path}
|
||||
title={t("settings.system.openFolder")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settings.system.fileCount")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{isLoading ? "..." : (tempDirInfo?.fileCount ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settings.system.totalSize")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{isLoading ? "..." : formatBytes(tempDirInfo?.totalSize ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadTempDirInfo}
|
||||
disabled={isLoading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
|
||||
{t("settings.system.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearTempFiles}
|
||||
disabled={isClearing || (tempDirInfo?.fileCount ?? 0) === 0}
|
||||
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{isClearing ? t("settings.system.clearing") : t("settings.system.clearTempFiles")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Clear Result */}
|
||||
{clearResult && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.system.clearResult", {
|
||||
deleted: clearResult.deletedCount,
|
||||
failed: clearResult.failedCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.system.tempDirectoryHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsSystemTab;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, 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,
|
||||
@@ -16,58 +16,56 @@ 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;
|
||||
@@ -99,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(() => {
|
||||
@@ -184,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">
|
||||
|
||||
379
electron/bridges/fileWatcherBridge.cjs
Normal file
379
electron/bridges/fileWatcherBridge.cjs
Normal file
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* File Watcher Bridge - Watches local temp files for changes to sync back to remote
|
||||
*
|
||||
* This bridge enables auto-sync functionality for files opened with external applications.
|
||||
* When a file is downloaded to temp and opened with an external app, we watch for changes
|
||||
* and automatically upload them back to the remote server.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
// Map of watchId -> { watcher, localPath, remotePath, sftpId, lastModified, lastSize }
|
||||
const activeWatchers = new Map();
|
||||
|
||||
// Debounce map to prevent multiple rapid syncs
|
||||
const debounceTimers = new Map();
|
||||
|
||||
// Map of sftpId -> Set<localPath> to track temp files even without watching
|
||||
// This allows cleanup when SFTP session closes, regardless of auto-sync setting
|
||||
const tempFilesMap = new Map();
|
||||
|
||||
let sftpClients = null;
|
||||
let electronModule = null;
|
||||
|
||||
/**
|
||||
* Initialize the file watcher bridge with dependencies
|
||||
*/
|
||||
function init(deps) {
|
||||
sftpClients = deps.sftpClients;
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a temp file for cleanup when SFTP session closes
|
||||
* Called regardless of whether auto-sync is enabled
|
||||
*/
|
||||
function registerTempFile(sftpId, localPath) {
|
||||
if (!tempFilesMap.has(sftpId)) {
|
||||
tempFilesMap.set(sftpId, new Set());
|
||||
}
|
||||
tempFilesMap.get(sftpId).add(localPath);
|
||||
console.log(`[FileWatcher] Registered temp file for cleanup: ${localPath} (session: ${sftpId})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a system notification for file sync events
|
||||
* Works on macOS, Windows, and Linux
|
||||
*/
|
||||
function showSystemNotification(title, body) {
|
||||
try {
|
||||
if (!electronModule?.Notification) {
|
||||
console.warn("[FileWatcher] Electron Notification API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const { Notification } = electronModule;
|
||||
|
||||
// Check if notifications are supported
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[FileWatcher] System notifications not supported on this platform");
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = new Notification({
|
||||
title,
|
||||
body,
|
||||
silent: false, // Allow notification sound
|
||||
});
|
||||
|
||||
notification.show();
|
||||
} catch (err) {
|
||||
console.warn("[FileWatcher] Failed to show system notification:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching a local file for changes
|
||||
* Returns a watchId that can be used to stop watching
|
||||
*/
|
||||
async function startWatching(event, { localPath, remotePath, sftpId }) {
|
||||
const watchId = `watch-${crypto.randomUUID()}`;
|
||||
|
||||
console.log(`[FileWatcher] Starting watch: ${localPath} -> ${remotePath}`);
|
||||
|
||||
// Get initial file stats
|
||||
let lastModified;
|
||||
let lastSize;
|
||||
try {
|
||||
const stat = await fs.promises.stat(localPath);
|
||||
lastModified = stat.mtimeMs;
|
||||
lastSize = stat.size;
|
||||
console.log(`[FileWatcher] Initial file stats: mtime=${lastModified}, size=${lastSize}`);
|
||||
} catch (err) {
|
||||
console.error(`[FileWatcher] Failed to stat file ${localPath}:`, err.message);
|
||||
throw new Error(`Cannot watch file: ${err.message}`);
|
||||
}
|
||||
|
||||
// Store webContents reference for later notifications
|
||||
const webContents = event.sender;
|
||||
|
||||
// Use fs.watchFile (polling) instead of fs.watch for better reliability on Windows
|
||||
// fs.watch can miss events when editors use atomic writes (save to temp, then rename)
|
||||
// fs.watchFile polls the file system at regular intervals
|
||||
const pollInterval = 1000; // Check every 1 second
|
||||
|
||||
fs.watchFile(localPath, { persistent: true, interval: pollInterval }, async (curr, prev) => {
|
||||
console.log(`[FileWatcher] File stat change detected for ${localPath}`);
|
||||
console.log(`[FileWatcher] Previous: mtime=${prev.mtimeMs}, size=${prev.size}`);
|
||||
console.log(`[FileWatcher] Current: mtime=${curr.mtimeMs}, size=${curr.size}`);
|
||||
|
||||
// Check if file was deleted
|
||||
if (curr.nlink === 0) {
|
||||
console.log(`[FileWatcher] File ${localPath} was deleted, stopping watch`);
|
||||
stopWatching(null, { watchId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if file was actually modified
|
||||
if (curr.mtimeMs <= prev.mtimeMs && curr.size === prev.size) {
|
||||
console.log(`[FileWatcher] File unchanged, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce rapid changes (e.g., multiple saves in quick succession)
|
||||
const existingTimer = debounceTimers.get(watchId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
debounceTimers.delete(watchId);
|
||||
await handleFileChange(watchId, webContents);
|
||||
}, 500); // 500ms debounce
|
||||
|
||||
debounceTimers.set(watchId, timer);
|
||||
});
|
||||
|
||||
activeWatchers.set(watchId, {
|
||||
watcher: null, // fs.watchFile doesn't return a watcher object
|
||||
localPath,
|
||||
remotePath,
|
||||
sftpId,
|
||||
lastModified,
|
||||
lastSize,
|
||||
webContents,
|
||||
useWatchFile: true, // Flag to indicate we're using fs.watchFile
|
||||
});
|
||||
|
||||
console.log(`[FileWatcher] Watch started with ID: ${watchId} (using fs.watchFile polling every ${pollInterval}ms)`);
|
||||
return { watchId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file change event - sync to remote
|
||||
*/
|
||||
async function handleFileChange(watchId, webContents) {
|
||||
const watchInfo = activeWatchers.get(watchId);
|
||||
if (!watchInfo) return;
|
||||
|
||||
const { localPath, remotePath, sftpId, lastModified: previousModified, lastSize: previousSize } = watchInfo;
|
||||
|
||||
// Extract file name once for notifications and logging
|
||||
const fileName = path.basename(remotePath);
|
||||
|
||||
console.log(`[FileWatcher] File change detected: ${localPath}`);
|
||||
|
||||
try {
|
||||
// Check if file was actually modified (compare mtime and size)
|
||||
const stat = await fs.promises.stat(localPath);
|
||||
|
||||
// Skip if neither mtime nor size changed (prevents spurious events on some platforms)
|
||||
if (stat.mtimeMs <= previousModified && stat.size === previousSize) {
|
||||
console.log(`[FileWatcher] File unchanged (mtime and size same), skipping sync`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update lastModified and lastSize
|
||||
watchInfo.lastModified = stat.mtimeMs;
|
||||
watchInfo.lastSize = stat.size;
|
||||
|
||||
// Get the SFTP client
|
||||
if (!sftpClients) {
|
||||
throw new Error("SFTP clients not initialized");
|
||||
}
|
||||
|
||||
const client = sftpClients.get(sftpId);
|
||||
if (!client) {
|
||||
throw new Error("SFTP session not found or expired");
|
||||
}
|
||||
|
||||
// Read the local file
|
||||
const content = await fs.promises.readFile(localPath);
|
||||
|
||||
console.log(`[FileWatcher] Syncing ${content.length} bytes to ${remotePath}`);
|
||||
|
||||
// Upload to remote
|
||||
await client.put(content, remotePath);
|
||||
|
||||
console.log(`[FileWatcher] Sync complete: ${remotePath}`);
|
||||
|
||||
// Show system notification for successful sync
|
||||
showSystemNotification(
|
||||
"Netcatty",
|
||||
`File synced to remote: ${fileName}`
|
||||
);
|
||||
|
||||
// Notify the renderer about successful sync
|
||||
if (webContents && !webContents.isDestroyed()) {
|
||||
webContents.send("netcatty:filewatch:synced", {
|
||||
watchId,
|
||||
localPath,
|
||||
remotePath,
|
||||
bytesWritten: content.length,
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[FileWatcher] Sync failed for ${localPath}:`, err.message);
|
||||
|
||||
// Show system notification for sync failure
|
||||
showSystemNotification(
|
||||
"Netcatty",
|
||||
`Failed to sync ${fileName}: ${err.message}`
|
||||
);
|
||||
|
||||
// Notify the renderer about sync failure
|
||||
if (webContents && !webContents.isDestroyed()) {
|
||||
webContents.send("netcatty:filewatch:error", {
|
||||
watchId,
|
||||
localPath,
|
||||
remotePath,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching a file and optionally clean up the temp file
|
||||
*/
|
||||
function stopWatching(event, { watchId, cleanupTempFile = false }) {
|
||||
const watchInfo = activeWatchers.get(watchId);
|
||||
if (!watchInfo) {
|
||||
console.log(`[FileWatcher] Watch ID not found: ${watchId}`);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
console.log(`[FileWatcher] Stopping watch: ${watchInfo.localPath}`);
|
||||
|
||||
// Clear debounce timer if any
|
||||
const timer = debounceTimers.get(watchId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
debounceTimers.delete(watchId);
|
||||
}
|
||||
|
||||
// Stop the watcher
|
||||
try {
|
||||
if (watchInfo.useWatchFile) {
|
||||
// Using fs.watchFile - need to use fs.unwatchFile
|
||||
fs.unwatchFile(watchInfo.localPath);
|
||||
} else if (watchInfo.watcher) {
|
||||
// Using fs.watch - close the watcher
|
||||
watchInfo.watcher.close();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[FileWatcher] Error stopping watcher:`, err.message);
|
||||
}
|
||||
|
||||
// Clean up temp file if requested
|
||||
if (cleanupTempFile && watchInfo.localPath) {
|
||||
cleanupTempFileAsync(watchInfo.localPath);
|
||||
}
|
||||
|
||||
activeWatchers.delete(watchId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously delete a temp file, logging success and silently handling failures
|
||||
*/
|
||||
async function cleanupTempFileAsync(filePath) {
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
console.log(`[FileWatcher] Temp file cleaned up: ${filePath}`);
|
||||
} catch (err) {
|
||||
// Silently ignore deletion failures (file may be in use or already deleted)
|
||||
console.log(`[FileWatcher] Could not delete temp file (may be in use): ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all watchers for a specific SFTP session and clean up temp files
|
||||
* Called when SFTP connection is closed
|
||||
*/
|
||||
function stopWatchersForSession(sftpId, cleanupTempFiles = true) {
|
||||
let watcherCount = 0;
|
||||
|
||||
// Stop active watchers
|
||||
for (const [watchId, watchInfo] of activeWatchers.entries()) {
|
||||
if (watchInfo.sftpId === sftpId) {
|
||||
stopWatching(null, { watchId, cleanupTempFile: cleanupTempFiles });
|
||||
watcherCount++;
|
||||
}
|
||||
}
|
||||
if (watcherCount > 0) {
|
||||
console.log(`[FileWatcher] Stopped ${watcherCount} watcher(s) for SFTP session: ${sftpId}`);
|
||||
}
|
||||
|
||||
// Clean up any registered temp files that weren't being watched
|
||||
if (cleanupTempFiles && tempFilesMap.has(sftpId)) {
|
||||
const tempFiles = tempFilesMap.get(sftpId);
|
||||
let cleanedCount = 0;
|
||||
for (const filePath of tempFiles) {
|
||||
cleanupTempFileAsync(filePath);
|
||||
cleanedCount++;
|
||||
}
|
||||
tempFilesMap.delete(sftpId);
|
||||
if (cleanedCount > 0) {
|
||||
console.log(`[FileWatcher] Queued cleanup for ${cleanedCount} temp file(s) for SFTP session: ${sftpId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of active watchers
|
||||
*/
|
||||
function listWatchers() {
|
||||
const watchers = [];
|
||||
for (const [watchId, info] of activeWatchers.entries()) {
|
||||
watchers.push({
|
||||
watchId,
|
||||
localPath: info.localPath,
|
||||
remotePath: info.remotePath,
|
||||
sftpId: info.sftpId,
|
||||
});
|
||||
}
|
||||
return watchers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for file watching operations
|
||||
*/
|
||||
function registerHandlers(ipcMain) {
|
||||
console.log("[FileWatcher] Registering IPC handlers");
|
||||
ipcMain.handle("netcatty:filewatch:start", (event, args) => {
|
||||
console.log("[FileWatcher] IPC netcatty:filewatch:start received", args);
|
||||
return startWatching(event, args);
|
||||
});
|
||||
ipcMain.handle("netcatty:filewatch:stop", stopWatching);
|
||||
ipcMain.handle("netcatty:filewatch:list", listWatchers);
|
||||
ipcMain.handle("netcatty:filewatch:registerTempFile", (_event, { sftpId, localPath }) => {
|
||||
registerTempFile(sftpId, localPath);
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all watchers on shutdown
|
||||
*/
|
||||
function cleanup() {
|
||||
console.log(`[FileWatcher] Cleaning up ${activeWatchers.size} watcher(s)`);
|
||||
for (const [watchId] of activeWatchers.entries()) {
|
||||
stopWatching(null, { watchId });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
startWatching,
|
||||
stopWatching,
|
||||
stopWatchersForSession,
|
||||
listWatchers,
|
||||
registerTempFile,
|
||||
cleanup,
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
183
electron/bridges/tempDirBridge.cjs
Normal file
183
electron/bridges/tempDirBridge.cjs
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Temp Directory Bridge - Manages Netcatty's dedicated temp directory
|
||||
*
|
||||
* All temporary files (SFTP downloads, etc.) are stored in a dedicated
|
||||
* Netcatty folder within the system temp directory for easier cleanup.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
// Netcatty temp directory name
|
||||
const NETCATTY_TEMP_DIR_NAME = "Netcatty";
|
||||
|
||||
// Cached temp directory path
|
||||
let cachedTempDir = null;
|
||||
|
||||
/**
|
||||
* Get the Netcatty temp directory path
|
||||
* Creates the directory if it doesn't exist
|
||||
*/
|
||||
function getTempDir() {
|
||||
if (cachedTempDir) {
|
||||
// Verify it still exists
|
||||
try {
|
||||
if (fs.existsSync(cachedTempDir)) {
|
||||
return cachedTempDir;
|
||||
}
|
||||
} catch {
|
||||
// Directory was deleted, recreate it
|
||||
}
|
||||
}
|
||||
|
||||
const systemTempDir = os.tmpdir();
|
||||
const netcattyTempDir = path.join(systemTempDir, NETCATTY_TEMP_DIR_NAME);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(netcattyTempDir)) {
|
||||
fs.mkdirSync(netcattyTempDir, { recursive: true });
|
||||
console.log(`[TempDir] Created Netcatty temp directory: ${netcattyTempDir}`);
|
||||
}
|
||||
cachedTempDir = netcattyTempDir;
|
||||
return netcattyTempDir;
|
||||
} catch (err) {
|
||||
console.error(`[TempDir] Failed to create temp directory:`, err.message);
|
||||
// Fallback to system temp dir
|
||||
return systemTempDir;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the temp directory exists (call on app startup)
|
||||
*/
|
||||
function ensureTempDir() {
|
||||
const tempDir = getTempDir();
|
||||
console.log(`[TempDir] Netcatty temp directory: ${tempDir}`);
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get temp directory info (path, size, file count)
|
||||
*/
|
||||
async function getTempDirInfo() {
|
||||
const tempDir = getTempDir();
|
||||
|
||||
try {
|
||||
const files = await fs.promises.readdir(tempDir);
|
||||
let totalSize = 0;
|
||||
let fileCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(tempDir, file);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.isFile()) {
|
||||
totalSize += stat.size;
|
||||
fileCount++;
|
||||
}
|
||||
} catch {
|
||||
// Skip files that can't be stat'd
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: tempDir,
|
||||
totalSize,
|
||||
fileCount,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`[TempDir] Failed to get temp dir info:`, err.message);
|
||||
return {
|
||||
path: tempDir,
|
||||
totalSize: 0,
|
||||
fileCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all files in the temp directory
|
||||
* Returns the number of files deleted
|
||||
*/
|
||||
async function clearTempDir() {
|
||||
const tempDir = getTempDir();
|
||||
let deletedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
try {
|
||||
const files = await fs.promises.readdir(tempDir);
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(tempDir, file);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
|
||||
if (stat.isFile()) {
|
||||
await fs.promises.unlink(filePath);
|
||||
deletedCount++;
|
||||
console.log(`[TempDir] Deleted: ${file}`);
|
||||
} else if (stat.isDirectory()) {
|
||||
// Recursively delete subdirectories
|
||||
await fs.promises.rm(filePath, { recursive: true, force: true });
|
||||
deletedCount++;
|
||||
console.log(`[TempDir] Deleted directory: ${file}`);
|
||||
}
|
||||
} catch (err) {
|
||||
failedCount++;
|
||||
console.log(`[TempDir] Could not delete ${file}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[TempDir] Cleanup complete: ${deletedCount} deleted, ${failedCount} failed`);
|
||||
return { deletedCount, failedCount };
|
||||
} catch (err) {
|
||||
console.error(`[TempDir] Failed to clear temp dir:`, err.message);
|
||||
return { deletedCount: 0, failedCount: 0, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique temp file path for a given filename
|
||||
*/
|
||||
function getTempFilePath(fileName) {
|
||||
const tempDir = getTempDir();
|
||||
const timestamp = Date.now();
|
||||
const safeFileName = fileName.replace(/[<>:"/\\|?*]/g, "_");
|
||||
return path.join(tempDir, `${timestamp}_${safeFileName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers
|
||||
*/
|
||||
function registerHandlers(ipcMain, shell) {
|
||||
ipcMain.handle("netcatty:tempdir:getInfo", async () => {
|
||||
return getTempDirInfo();
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:tempdir:clear", async () => {
|
||||
return clearTempDir();
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:tempdir:getPath", () => {
|
||||
return getTempDir();
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:tempdir:open", async () => {
|
||||
const tempDir = getTempDir();
|
||||
if (shell?.openPath) {
|
||||
await shell.openPath(tempDir);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTempDir,
|
||||
ensureTempDir,
|
||||
getTempDirInfo,
|
||||
clearTempDir,
|
||||
getTempFilePath,
|
||||
registerHandlers,
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
17
global.d.ts
vendored
17
global.d.ts
vendored
@@ -417,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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1004,7 +1004,6 @@
|
||||
"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",
|
||||
@@ -1748,6 +1747,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1769,6 +1769,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1785,6 +1786,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1799,6 +1801,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -5652,7 +5655,6 @@
|
||||
"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",
|
||||
@@ -5682,7 +5684,6 @@
|
||||
"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",
|
||||
@@ -5961,8 +5962,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
@@ -5984,7 +5984,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6044,7 +6043,6 @@
|
||||
"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",
|
||||
@@ -6412,7 +6410,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -7137,7 +7134,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -7378,7 +7376,6 @@
|
||||
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.0.12",
|
||||
"builder-util": "26.0.11",
|
||||
@@ -7705,6 +7702,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -7725,6 +7723,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -7949,7 +7948,6 @@
|
||||
"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",
|
||||
@@ -10161,7 +10159,6 @@
|
||||
"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"
|
||||
@@ -10611,7 +10608,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10670,6 +10666,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -10687,6 +10684,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -10794,7 +10792,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -10804,7 +10801,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -11668,6 +11664,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -11731,6 +11728,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -11745,6 +11743,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -11893,7 +11892,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12096,7 +12094,6 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12435,7 +12432,6 @@
|
||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user