Skip to content

Commit ec04334

Browse files
authored
Merge branch 'binaricat:main' into main
2 parents 57e3641 + ad67099 commit ec04334

22 files changed

Lines changed: 1493 additions & 110 deletions

agents.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ This project is wired around three layers: domain (pure logic), application stat
3131
## Data & Storage
3232
- Persisted keys: see `storageKeys.ts`. Use `localStorageAdapter` for all reads/writes.
3333
- Seed data: `config/defaultData.ts`; terminal themes: `config/terminalThemes.ts`.
34+
- **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.
3435

3536
## Testing & Safety
3637
- Favor unit tests for domain helpers (e.g., `workspace.ts`, `host.ts`) and hook-level tests for application state.

application/i18n/locales/en.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,21 @@ const en: Messages = {
6161
'settings.tab.terminal': 'Terminal',
6262
'settings.tab.shortcuts': 'Shortcuts',
6363
'settings.tab.syncCloud': 'Sync & Cloud',
64+
'settings.tab.system': 'System',
65+
66+
// Settings > System
67+
'settings.system.title': 'System',
68+
'settings.system.description': 'System information and temporary file management.',
69+
'settings.system.tempDirectory': 'Temporary Files',
70+
'settings.system.location': 'Location',
71+
'settings.system.fileCount': 'Files',
72+
'settings.system.totalSize': 'Size',
73+
'settings.system.openFolder': 'Open folder',
74+
'settings.system.refresh': 'Refresh',
75+
'settings.system.clearTempFiles': 'Clear temp files',
76+
'settings.system.clearing': 'Clearing...',
77+
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
78+
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
6479

6580
// Settings > Application
6681
'settings.application.checkUpdates': 'Check for updates',
@@ -107,6 +122,10 @@ const en: Messages = {
107122

108123
// Settings > Terminal
109124
'settings.terminal.section.theme': 'Terminal Theme',
125+
'settings.terminal.themeModal.title': 'Select Theme',
126+
'settings.terminal.themeModal.darkThemes': 'Dark Themes',
127+
'settings.terminal.themeModal.lightThemes': 'Light Themes',
128+
'settings.terminal.theme.selectButton': 'Select Theme',
110129
'settings.terminal.section.font': 'Font',
111130
'settings.terminal.section.cursor': 'Cursor',
112131
'settings.terminal.section.keyboard': 'Keyboard',
@@ -514,6 +533,14 @@ const en: Messages = {
514533
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
515534
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
516535
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
536+
537+
// Settings > SFTP Auto Sync
538+
'settings.sftp.autoSync': 'Auto-sync to remote',
539+
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
540+
'settings.sftp.autoSync.enable': 'Enable auto-sync',
541+
'settings.sftp.autoSync.enableDesc': 'When you save a file in an external application, changes will be automatically uploaded to the remote server',
542+
'sftp.autoSync.success': 'File synced to remote: {fileName}',
543+
'sftp.autoSync.error': 'Failed to sync file: {error}',
517544

518545
// Quick Switcher
519546
'qs.search.placeholder': 'Search hosts or tabs',

application/i18n/locales/zh-CN.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,21 @@ const zhCN: Messages = {
4949
'settings.tab.terminal': '终端',
5050
'settings.tab.shortcuts': '快捷键',
5151
'settings.tab.syncCloud': '同步与云',
52+
'settings.tab.system': '系统',
53+
54+
// Settings > System
55+
'settings.system.title': '系统',
56+
'settings.system.description': '系统信息与临时文件管理。',
57+
'settings.system.tempDirectory': '临时文件',
58+
'settings.system.location': '位置',
59+
'settings.system.fileCount': '文件数量',
60+
'settings.system.totalSize': '占用空间',
61+
'settings.system.openFolder': '打开文件夹',
62+
'settings.system.refresh': '刷新',
63+
'settings.system.clearTempFiles': '清理临时文件',
64+
'settings.system.clearing': '清理中...',
65+
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
66+
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
5267

5368
// Settings > Application
5469
'settings.application.checkUpdates': '检查更新',
@@ -750,9 +765,21 @@ const zhCN: Messages = {
750765
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
751766
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
752767
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
768+
769+
// Settings > SFTP Auto Sync
770+
'settings.sftp.autoSync': '自动同步到远程',
771+
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
772+
'settings.sftp.autoSync.enable': '启用自动同步',
773+
'settings.sftp.autoSync.enableDesc': '在外部应用程序中保存文件时,更改将自动上传到远程服务器',
774+
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
775+
'sftp.autoSync.error': '同步文件失败:{error}',
753776

754777
// Settings > Terminal
755778
'settings.terminal.section.theme': '终端主题',
779+
'settings.terminal.themeModal.title': '选择主题',
780+
'settings.terminal.themeModal.darkThemes': '深色主题',
781+
'settings.terminal.themeModal.lightThemes': '浅色主题',
782+
'settings.terminal.theme.selectButton': '选择主题',
756783
'settings.terminal.section.font': '字体',
757784
'settings.terminal.section.cursor': '光标',
758785
'settings.terminal.section.keyboard': '键盘',

application/state/useSettingsState.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ STORAGE_KEY_ACCENT_MODE,
1717
STORAGE_KEY_UI_THEME_LIGHT,
1818
STORAGE_KEY_UI_THEME_DARK,
1919
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
20+
STORAGE_KEY_SFTP_AUTO_SYNC,
2021
} from '../../infrastructure/config/storageKeys';
2122
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
2223
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
@@ -39,6 +40,7 @@ const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
3940
? 'mac'
4041
: 'pc';
4142
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
43+
const DEFAULT_SFTP_AUTO_SYNC = false;
4244

4345
const readStoredString = (key: string): string | null => {
4446
const raw = localStorageAdapter.readString(key);
@@ -161,6 +163,10 @@ export const useSettingsState = () => {
161163
const stored = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
162164
return (stored === 'open' || stored === 'transfer') ? stored : DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR;
163165
});
166+
const [sftpAutoSync, setSftpAutoSync] = useState<boolean>(() => {
167+
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
168+
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_SYNC;
169+
});
164170

165171
// Helper to notify other windows about settings changes via IPC
166172
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
@@ -385,11 +391,18 @@ export const useSettingsState = () => {
385391
setSftpDoubleClickBehavior(e.newValue);
386392
}
387393
}
394+
// Sync SFTP auto-sync setting from other windows
395+
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
396+
const newValue = e.newValue === 'true';
397+
if (newValue !== sftpAutoSync) {
398+
setSftpAutoSync(newValue);
399+
}
400+
}
388401
};
389402

390403
window.addEventListener('storage', handleStorageChange);
391404
return () => window.removeEventListener('storage', handleStorageChange);
392-
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior]);
405+
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync]);
393406

394407
useEffect(() => {
395408
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -446,6 +459,12 @@ export const useSettingsState = () => {
446459
notifySettingsChanged(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
447460
}, [sftpDoubleClickBehavior, notifySettingsChanged]);
448461

462+
// Persist SFTP auto-sync setting
463+
useEffect(() => {
464+
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync ? 'true' : 'false');
465+
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
466+
}, [sftpAutoSync, notifySettingsChanged]);
467+
449468
// Get merged key bindings (defaults + custom overrides)
450469
const keyBindings = useMemo((): KeyBinding[] => {
451470
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -554,6 +573,8 @@ export const useSettingsState = () => {
554573
setCustomCSS,
555574
sftpDoubleClickBehavior,
556575
setSftpDoubleClickBehavior,
576+
sftpAutoSync,
577+
setSftpAutoSync,
557578
availableFonts,
558579
};
559580
};

application/state/useSftpBackend.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,16 +184,51 @@ export const useSftpBackend = () => {
184184
sftpId: string,
185185
remotePath: string,
186186
fileName: string,
187-
appPath: string
188-
) => {
187+
appPath: string,
188+
options?: { enableWatch?: boolean }
189+
): Promise<{ localTempPath: string; watchId?: string }> => {
189190
const bridge = netcattyBridge.get();
190191
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
191192
throw new Error("Download to temp / open with unavailable");
192193
}
194+
193195
// Download the file to temp
196+
console.log("[SFTPBackend] Downloading file to temp", { sftpId, remotePath, fileName });
194197
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName);
198+
console.log("[SFTPBackend] File downloaded to temp", { tempPath });
199+
200+
// Register temp file for cleanup when SFTP session closes (regardless of auto-sync setting)
201+
if (bridge.registerTempFile) {
202+
try {
203+
await bridge.registerTempFile(sftpId, tempPath);
204+
} catch (err) {
205+
console.warn("[SFTPBackend] Failed to register temp file for cleanup:", err);
206+
}
207+
}
208+
195209
// Open with the selected application
210+
console.log("[SFTPBackend] Opening with application", { tempPath, appPath });
196211
await bridge.openWithApplication(tempPath, appPath);
212+
console.log("[SFTPBackend] Application launched");
213+
214+
// Start file watching if enabled
215+
let watchId: string | undefined;
216+
console.log("[SFTPBackend] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
217+
if (options?.enableWatch && bridge.startFileWatch) {
218+
try {
219+
console.log("[SFTPBackend] Starting file watch", { tempPath, remotePath, sftpId });
220+
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId);
221+
watchId = result.watchId;
222+
console.log("[SFTPBackend] File watch started successfully", { watchId, tempPath, remotePath });
223+
} catch (err) {
224+
console.warn("[SFTPBackend] Failed to start file watch:", err);
225+
// Don't fail the operation if watching fails
226+
}
227+
} else {
228+
console.log("[SFTPBackend] File watching not enabled or not available");
229+
}
230+
231+
return { localTempPath: tempPath, watchId };
197232
}, []);
198233

199234
return {

application/state/useSftpState.ts

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,32 @@ const createEmptyPane = (id?: string): SftpPane => ({
143143
filter: "",
144144
});
145145

146-
export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity[]) => {
146+
// File watch event types
147+
export interface FileWatchSyncedEvent {
148+
watchId: string;
149+
localPath: string;
150+
remotePath: string;
151+
bytesWritten: number;
152+
}
153+
154+
export interface FileWatchErrorEvent {
155+
watchId: string;
156+
localPath: string;
157+
remotePath: string;
158+
error: string;
159+
}
160+
161+
export interface SftpStateOptions {
162+
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
163+
onFileWatchError?: (event: FileWatchErrorEvent) => void;
164+
}
165+
166+
export const useSftpState = (
167+
hosts: Host[],
168+
keys: SSHKey[],
169+
identities: Identity[],
170+
options?: SftpStateOptions
171+
) => {
147172
// Multi-tab state: left and right sides each have multiple tabs
148173
const [leftTabs, setLeftTabs] = useState<SftpSideTabs>({
149174
tabs: [],
@@ -540,6 +565,29 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
540565
};
541566
}, []);
542567

568+
// Listen for file watch events (auto-sync feature)
569+
useEffect(() => {
570+
const bridge = netcattyBridge.get();
571+
if (!bridge?.onFileWatchSynced || !bridge?.onFileWatchError) return;
572+
573+
const unsubscribeSynced = bridge.onFileWatchSynced((payload: FileWatchSyncedEvent) => {
574+
options?.onFileWatchSynced?.(payload);
575+
});
576+
577+
const unsubscribeError = bridge.onFileWatchError((payload: FileWatchErrorEvent) => {
578+
options?.onFileWatchError?.(payload);
579+
});
580+
581+
return () => {
582+
try {
583+
unsubscribeSynced?.();
584+
unsubscribeError?.();
585+
} catch {
586+
// ignore cleanup errors
587+
}
588+
};
589+
}, [options]);
590+
543591
// Track if initial auto-connect has been done
544592
const initialConnectDoneRef = useRef(false);
545593

@@ -2604,8 +2652,16 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
26042652
);
26052653

26062654
// Download file to temp directory and open with external application
2655+
// If enableWatch is true and the file is remote, starts watching the temp file for changes
2656+
// Returns { localTempPath, watchId } if watch was started, otherwise just { localTempPath }
26072657
const downloadToTempAndOpen = useCallback(
2608-
async (side: "left" | "right", remotePath: string, fileName: string, appPath: string): Promise<void> => {
2658+
async (
2659+
side: "left" | "right",
2660+
remotePath: string,
2661+
fileName: string,
2662+
appPath: string,
2663+
options?: { enableWatch?: boolean }
2664+
): Promise<{ localTempPath: string; watchId?: string }> => {
26092665
const pane = getActivePane(side);
26102666
if (!pane?.connection) {
26112667
throw new Error("No connection available");
@@ -2617,9 +2673,9 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
26172673
}
26182674

26192675
if (pane.connection.isLocal) {
2620-
// For local files, just open directly
2676+
// For local files, just open directly (no watching needed)
26212677
await bridge.openWithApplication(remotePath, appPath);
2622-
return;
2678+
return { localTempPath: remotePath };
26232679
}
26242680

26252681
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
@@ -2628,10 +2684,42 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
26282684
}
26292685

26302686
// Download to temp directory
2687+
console.log("[SFTP] Downloading file to temp", { sftpId, remotePath, fileName });
26312688
const localTempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName);
2689+
console.log("[SFTP] File downloaded to temp", { localTempPath });
2690+
2691+
// Register temp file for cleanup when SFTP session closes (regardless of auto-sync setting)
2692+
if (bridge.registerTempFile) {
2693+
try {
2694+
await bridge.registerTempFile(sftpId, localTempPath);
2695+
} catch (err) {
2696+
console.warn("[SFTP] Failed to register temp file for cleanup:", err);
2697+
}
2698+
}
26322699

26332700
// Open with the selected application
2701+
console.log("[SFTP] Opening with application", { localTempPath, appPath });
26342702
await bridge.openWithApplication(localTempPath, appPath);
2703+
console.log("[SFTP] Application launched");
2704+
2705+
// Start file watching if enabled
2706+
let watchId: string | undefined;
2707+
console.log("[SFTP] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
2708+
if (options?.enableWatch && bridge.startFileWatch) {
2709+
try {
2710+
console.log("[SFTP] Starting file watch", { localTempPath, remotePath, sftpId });
2711+
const result = await bridge.startFileWatch(localTempPath, remotePath, sftpId);
2712+
watchId = result.watchId;
2713+
console.log("[SFTP] File watch started successfully", { watchId, localTempPath, remotePath });
2714+
} catch (err) {
2715+
console.warn("[SFTP] Failed to start file watch:", err);
2716+
// Don't fail the operation if watching fails
2717+
}
2718+
} else {
2719+
console.log("[SFTP] File watching not enabled or not available");
2720+
}
2721+
2722+
return { localTempPath, watchId };
26352723
},
26362724
[getActivePane],
26372725
);

0 commit comments

Comments
 (0)