From 4b56186f7cd60cd8ab2add7a5cc76195b9c39d20 Mon Sep 17 00:00:00 2001 From: matt Date: Wed, 11 Feb 2026 13:34:12 +0000 Subject: [PATCH] add ssh suport --- package.json | 2 + pnpm-lock.yaml | 79 +++++ src/main/index.ts | 64 +++- src/main/ipc/handlers.ts | 14 +- src/main/ipc/ssh.ts | 107 ++++++ src/main/ipc/utility.ts | 4 +- .../services/discovery/ProjectPathResolver.ts | 16 +- src/main/services/discovery/ProjectScanner.ts | 66 ++-- .../discovery/SessionContentFilter.ts | 15 +- .../services/discovery/SessionSearcher.ts | 18 +- .../services/discovery/SubagentLocator.ts | 58 +++- .../services/discovery/WorktreeGrouper.ts | 9 +- .../infrastructure/FileSystemProvider.ts | 75 ++++ .../services/infrastructure/FileWatcher.ts | 123 ++++++- .../infrastructure/LocalFileSystemProvider.ts | 63 ++++ .../infrastructure/SshConnectionManager.ts | 328 ++++++++++++++++++ .../infrastructure/SshFileSystemProvider.ts | 129 +++++++ src/main/services/infrastructure/index.ts | 8 + src/main/services/parsing/ClaudeMdReader.ts | 86 +++-- src/main/utils/jsonl.ts | 28 +- src/main/utils/metadataExtraction.ts | 15 +- src/preload/constants/ipcChannels.ts | 19 + src/preload/index.ts | 35 ++ .../components/layout/TabbedLayout.tsx | 44 +++ .../components/settings/SettingsTabs.tsx | 5 +- .../components/settings/SettingsView.tsx | 9 +- .../settings/sections/ConnectionSection.tsx | 308 ++++++++++++++++ .../components/settings/sections/index.ts | 1 + src/renderer/store/index.ts | 24 ++ src/renderer/store/slices/connectionSlice.ts | 121 +++++++ src/renderer/store/types.ts | 2 + src/shared/types/api.ts | 63 ++++ 32 files changed, 1817 insertions(+), 121 deletions(-) create mode 100644 src/main/ipc/ssh.ts create mode 100644 src/main/services/infrastructure/FileSystemProvider.ts create mode 100644 src/main/services/infrastructure/LocalFileSystemProvider.ts create mode 100644 src/main/services/infrastructure/SshConnectionManager.ts create mode 100644 src/main/services/infrastructure/SshFileSystemProvider.ts create mode 100644 src/renderer/components/settings/sections/ConnectionSection.tsx create mode 100644 src/renderer/store/slices/connectionSlice.ts diff --git a/package.json b/package.json index 87336ea5..484ab850 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", + "ssh2": "^1.17.0", "unified": "^11.0.5", "zustand": "^4.5.0" }, @@ -64,6 +65,7 @@ "@types/node": "^25.0.7", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/ssh2": "^1.15.5", "@vitejs/plugin-react": "^4.3.1", "@vitest/coverage-v8": "^3.1.4", "autoprefixer": "^10.4.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db1b2c35..0b3c15c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: remark-parse: specifier: ^11.0.0 version: 11.0.0 + ssh2: + specifier: ^1.17.0 + version: 1.17.0 unified: specifier: ^11.0.5 version: 11.0.5 @@ -81,6 +84,9 @@ importers: '@types/react-dom': specifier: ^18.3.0 version: 18.3.7(@types/react@18.3.27) + '@types/ssh2': + specifier: ^1.15.5 + version: 1.15.5 '@vitejs/plugin-react': specifier: ^4.3.1 version: 4.7.0(vite@5.4.21(@types/node@25.0.7)) @@ -1072,6 +1078,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@24.10.12': resolution: {integrity: sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==} @@ -1095,6 +1104,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1421,6 +1433,9 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -1489,6 +1504,9 @@ packages: resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1534,6 +1552,10 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + builder-util-runtime@9.2.4: resolution: {integrity: sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==} engines: {node: '>=12.0.0'} @@ -1686,6 +1708,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -2967,6 +2993,9 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nan@2.25.0: + resolution: {integrity: sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3522,6 +3551,10 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + stable-hash-x@0.2.0: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} @@ -3719,6 +3752,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3764,6 +3800,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -4771,6 +4810,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@24.10.12': dependencies: undici-types: 7.16.0 @@ -4800,6 +4843,10 @@ snapshots: dependencies: '@types/node': 25.0.7 + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.130 + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -5224,6 +5271,10 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assert-plus@1.0.0: optional: true @@ -5275,6 +5326,10 @@ snapshots: baseline-browser-mapping@2.9.14: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + binary-extensions@2.3.0: {} bl@4.1.0: @@ -5324,6 +5379,9 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buildcheck@0.0.7: + optional: true + builder-util-runtime@9.2.4: dependencies: debug: 4.4.3 @@ -5498,6 +5556,12 @@ snapshots: core-util-is@1.0.3: {} + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.25.0 + optional: true + crc-32@1.2.2: {} crc32-stream@4.0.3: @@ -7265,6 +7329,9 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nan@2.25.0: + optional: true + nanoid@3.3.11: {} napi-postinstall@0.3.4: {} @@ -7864,6 +7931,14 @@ snapshots: sprintf-js@1.1.3: optional: true + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.25.0 + stable-hash-x@0.2.0: {} stackback@0.0.2: {} @@ -8121,6 +8196,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -8184,6 +8261,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@5.26.5: {} + undici-types@7.16.0: {} unified@11.0.5: diff --git a/src/main/index.ts b/src/main/index.ts index 6e64a4f6..2490cbac 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -35,6 +35,8 @@ const getIconPath = (): string => { }; const logger = createLogger('App'); +import { SSH_STATUS } from '@preload/constants/ipcChannels'; + import { ChunkBuilder, configManager, @@ -62,6 +64,9 @@ let dataCache: DataCache; let fileWatcher: FileWatcher; let notificationManager: NotificationManager; let updaterService: UpdaterService; +let sshConnectionManager: InstanceType< + typeof import('./services/infrastructure/SshConnectionManager').SshConnectionManager +>; let cleanupInterval: NodeJS.Timeout | null = null; /** @@ -70,6 +75,13 @@ let cleanupInterval: NodeJS.Timeout | null = null; function initializeServices(): void { logger.info('Initializing services...'); + // Initialize SSH connection manager + const { SshConnectionManager: SshConnMgr } = + require('./services/infrastructure/SshConnectionManager') as { + SshConnectionManager: typeof import('./services/infrastructure/SshConnectionManager').SshConnectionManager; + }; + sshConnectionManager = new SshConnMgr(); + // Initialize services (paths are set automatically from environment) projectScanner = new ProjectScanner(); sessionParser = new SessionParser(projectScanner); @@ -81,16 +93,59 @@ function initializeServices(): void { logger.info(`Projects directory: ${projectScanner.getProjectsDir()}`); - // Initialize IPC handlers + // Mode switch callback: recreates services with new provider when switching local↔SSH + const handleModeSwitch = async (mode: 'local' | 'ssh'): Promise => { + logger.info(`Switching to ${mode} mode`); + + // Stop file watcher + fileWatcher.stop(); + + // Clear data cache + dataCache.clear(); + + // Get provider and projects path from connection manager + const provider = sshConnectionManager.getProvider(); + const projectsDir = + mode === 'ssh' ? (sshConnectionManager.getRemoteProjectsPath() ?? undefined) : undefined; + + // Recreate services with new provider + projectScanner = new ProjectScanner(projectsDir, undefined, provider); + sessionParser = new SessionParser(projectScanner); + subagentResolver = new SubagentResolver(projectScanner); + + // Update file watcher provider + fileWatcher.setFileSystemProvider(provider); + + // Restart file watcher + fileWatcher.start(); + + // Notify renderer to re-fetch all data + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(SSH_STATUS, sshConnectionManager.getStatus()); + } + + logger.info(`Mode switch to ${mode} complete`); + }; + + // Initialize IPC handlers (including SSH) initializeIpcHandlers( projectScanner, sessionParser, subagentResolver, chunkBuilder, dataCache, - updaterService + updaterService, + sshConnectionManager, + handleModeSwitch ); + // Forward SSH state changes to renderer + sshConnectionManager.on('state-change', (status: unknown) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(SSH_STATUS, status); + } + }); + // Initialize notification manager using singleton pattern // This ensures IPC handlers and FileWatcher use the same instance // Note: mainWindow will be set later via setMainWindow() when window is created @@ -138,6 +193,11 @@ function shutdownServices(): void { cleanupInterval = null; } + // Dispose SSH connection manager + if (sshConnectionManager) { + sshConnectionManager.dispose(); + } + // Remove IPC handlers removeIpcHandlers(); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index e0f59226..9807d7ce 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -10,6 +10,7 @@ * - utility.ts: Shell operations and file reading * - notifications.ts: Notification management * - config.ts: App configuration + * - ssh.ts: SSH connection management */ import { createLogger } from '@shared/utils/logger'; @@ -30,6 +31,7 @@ import { registerSessionHandlers, removeSessionHandlers, } from './sessions'; +import { initializeSshHandlers, registerSshHandlers, removeSshHandlers } from './ssh'; import { initializeSubagentHandlers, registerSubagentHandlers, @@ -48,6 +50,7 @@ import type { DataCache, ProjectScanner, SessionParser, + SshConnectionManager, SubagentResolver, UpdaterService, } from '../services'; @@ -61,7 +64,9 @@ export function initializeIpcHandlers( resolver: SubagentResolver, builder: ChunkBuilder, cache: DataCache, - updater: UpdaterService + updater: UpdaterService, + sshManager?: SshConnectionManager, + sshModeSwitchCallback?: (mode: 'local' | 'ssh') => Promise ): void { // Initialize domain handlers with their required services initializeProjectHandlers(scanner); @@ -69,6 +74,9 @@ export function initializeIpcHandlers( initializeSearchHandlers(scanner); initializeSubagentHandlers(builder, cache, parser, resolver); initializeUpdaterHandlers(updater); + if (sshManager && sshModeSwitchCallback) { + initializeSshHandlers(sshManager, sshModeSwitchCallback); + } // Register all handlers registerProjectHandlers(ipcMain); @@ -80,6 +88,9 @@ export function initializeIpcHandlers( registerNotificationHandlers(ipcMain); registerConfigHandlers(ipcMain); registerUpdaterHandlers(ipcMain); + if (sshManager) { + registerSshHandlers(ipcMain); + } logger.info('All handlers registered'); } @@ -98,6 +109,7 @@ export function removeIpcHandlers(): void { removeNotificationHandlers(ipcMain); removeConfigHandlers(ipcMain); removeUpdaterHandlers(ipcMain); + removeSshHandlers(ipcMain); logger.info('All handlers removed'); } diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts new file mode 100644 index 00000000..27c84e8c --- /dev/null +++ b/src/main/ipc/ssh.ts @@ -0,0 +1,107 @@ +/** + * SSH IPC Handlers - Manages SSH connection lifecycle from renderer requests. + * + * Channels: + * - ssh:connect - Connect to SSH host, switch to remote mode + * - ssh:disconnect - Disconnect and switch back to local mode + * - ssh:getState - Get current connection state + * - ssh:test - Test connection without switching + */ + +import { + SSH_CONNECT, + SSH_DISCONNECT, + SSH_GET_STATE, + SSH_TEST, +} from '@preload/constants/ipcChannels'; +import { createLogger } from '@shared/utils/logger'; + +import type { + SshConnectionConfig, + SshConnectionManager, + SshConnectionStatus, +} from '../services/infrastructure/SshConnectionManager'; +import type { IpcMain } from 'electron'; + +const logger = createLogger('IPC:ssh'); + +// ============================================================================= +// Module State +// ============================================================================= + +let connectionManager: SshConnectionManager; +let onModeSwitch: ((mode: 'local' | 'ssh') => Promise) | null = null; + +// ============================================================================= +// Initialization +// ============================================================================= + +/** + * Initialize SSH handlers with required services. + * @param manager - The SSH connection manager instance + * @param modeSwitchCallback - Called when switching between local/SSH mode + */ +export function initializeSshHandlers( + manager: SshConnectionManager, + modeSwitchCallback: (mode: 'local' | 'ssh') => Promise +): void { + connectionManager = manager; + onModeSwitch = modeSwitchCallback; +} + +// ============================================================================= +// Handler Registration +// ============================================================================= + +export function registerSshHandlers(ipcMain: IpcMain): void { + ipcMain.handle(SSH_CONNECT, async (_event, config: SshConnectionConfig) => { + try { + await connectionManager.connect(config); + if (onModeSwitch) { + await onModeSwitch('ssh'); + } + return { success: true, data: connectionManager.getStatus() }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error('SSH connect failed:', message); + return { success: false, error: message }; + } + }); + + ipcMain.handle(SSH_DISCONNECT, async () => { + try { + connectionManager.disconnect(); + if (onModeSwitch) { + await onModeSwitch('local'); + } + return { success: true, data: connectionManager.getStatus() }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error('SSH disconnect failed:', message); + return { success: false, error: message }; + } + }); + + ipcMain.handle(SSH_GET_STATE, async (): Promise => { + return connectionManager.getStatus(); + }); + + ipcMain.handle(SSH_TEST, async (_event, config: SshConnectionConfig) => { + try { + const result = await connectionManager.testConnection(config); + return { success: true, data: result }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: message }; + } + }); + + logger.info('SSH handlers registered'); +} + +export function removeSshHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler(SSH_CONNECT); + ipcMain.removeHandler(SSH_DISCONNECT); + ipcMain.removeHandler(SSH_GET_STATE); + ipcMain.removeHandler(SSH_TEST); +} diff --git a/src/main/ipc/utility.ts b/src/main/ipc/utility.ts index c13dd394..c6e69a6f 100644 --- a/src/main/ipc/utility.ts +++ b/src/main/ipc/utility.ts @@ -137,7 +137,7 @@ async function handleReadClaudeMdFiles( projectRoot: string ): Promise> { try { - const result = readAllClaudeMdFiles(projectRoot); + const result = await readAllClaudeMdFiles(projectRoot); // Convert Map to object for IPC serialization const files: Record = {}; result.files.forEach((info, key) => { @@ -160,7 +160,7 @@ async function handleReadDirectoryClaudeMd( dirPath: string ): Promise { try { - const info = readDirectoryClaudeMd(dirPath); + const info = await readDirectoryClaudeMd(dirPath); return info; } catch (error) { logger.error(`Error in read-directory-claude-md:`, error); diff --git a/src/main/services/discovery/ProjectPathResolver.ts b/src/main/services/discovery/ProjectPathResolver.ts index b1861197..5ea65792 100644 --- a/src/main/services/discovery/ProjectPathResolver.ts +++ b/src/main/services/discovery/ProjectPathResolver.ts @@ -9,14 +9,16 @@ * Results are memoized per projectId and can be invalidated by file watcher events. */ +import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; import { extractCwd } from '@main/utils/jsonl'; import { decodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; -import * as fs from 'fs'; import * as path from 'path'; import { subprojectRegistry } from './SubprojectRegistry'; +import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider'; + const logger = createLogger('Discovery:ProjectPathResolver'); interface ResolveProjectPathOptions { @@ -27,10 +29,12 @@ interface ResolveProjectPathOptions { export class ProjectPathResolver { private readonly projectsDir: string; + private readonly fsProvider: FileSystemProvider; private readonly projectPathCache = new Map(); - constructor(projectsDir?: string) { + constructor(projectsDir?: string, fsProvider?: FileSystemProvider) { this.projectsDir = projectsDir ?? getProjectsBasePath(); + this.fsProvider = fsProvider ?? new LocalFileSystemProvider(); } /** @@ -64,7 +68,7 @@ export class ProjectPathResolver { const sessionPaths = opts.sessionPaths?.length ? opts.sessionPaths - : this.listSessionPaths(projectId); + : await this.listSessionPaths(projectId); for (const sessionPath of sessionPaths) { try { @@ -97,14 +101,14 @@ export class ProjectPathResolver { this.projectPathCache.clear(); } - private listSessionPaths(projectId: string): string[] { + private async listSessionPaths(projectId: string): Promise { const projectDir = path.join(this.projectsDir, extractBaseDir(projectId)); - if (!fs.existsSync(projectDir)) { + if (!(await this.fsProvider.exists(projectDir))) { return []; } try { - const entries = fs.readdirSync(projectDir, { withFileTypes: true }); + const entries = await this.fsProvider.readdir(projectDir); return entries .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) .map((entry) => path.join(projectDir, entry.name)); diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index e142dbe7..e118dd7b 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -37,12 +37,15 @@ import { isValidEncodedPath, } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; -import * as fs from 'fs'; import * as path from 'path'; +import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider'; + import { SessionContentFilter } from './SessionContentFilter'; import { subprojectRegistry } from './SubprojectRegistry'; +import type { FileSystemProvider } from '../infrastructure/FileSystemProvider'; + const logger = createLogger('Discovery:ProjectScanner'); import { ProjectPathResolver } from './ProjectPathResolver'; import { SessionSearcher } from './SessionSearcher'; @@ -65,22 +68,24 @@ export class ProjectScanner { >(); // Delegated services + private readonly fsProvider: FileSystemProvider; private readonly sessionContentFilter: typeof SessionContentFilter; private readonly worktreeGrouper: WorktreeGrouper; private readonly subagentLocator: SubagentLocator; private readonly sessionSearcher: SessionSearcher; private readonly projectPathResolver: ProjectPathResolver; - constructor(projectsDir?: string, todosDir?: string) { + constructor(projectsDir?: string, todosDir?: string, fsProvider?: FileSystemProvider) { this.projectsDir = projectsDir ?? getProjectsBasePath(); this.todosDir = todosDir ?? getTodosBasePath(); + this.fsProvider = fsProvider ?? new LocalFileSystemProvider(); // Initialize delegated services this.sessionContentFilter = SessionContentFilter; - this.worktreeGrouper = new WorktreeGrouper(this.projectsDir); - this.subagentLocator = new SubagentLocator(this.projectsDir); - this.sessionSearcher = new SessionSearcher(this.projectsDir); - this.projectPathResolver = new ProjectPathResolver(this.projectsDir); + this.worktreeGrouper = new WorktreeGrouper(this.projectsDir, this.fsProvider); + this.subagentLocator = new SubagentLocator(this.projectsDir, this.fsProvider); + this.sessionSearcher = new SessionSearcher(this.projectsDir, this.fsProvider); + this.projectPathResolver = new ProjectPathResolver(this.projectsDir, this.fsProvider); } // =========================================================================== @@ -93,7 +98,7 @@ export class ProjectScanner { */ async scan(): Promise { try { - if (!fs.existsSync(this.projectsDir)) { + if (!(await this.fsProvider.exists(this.projectsDir))) { logger.warn(`Projects directory does not exist: ${this.projectsDir}`); return []; } @@ -101,7 +106,7 @@ export class ProjectScanner { // Clear the subproject registry on full re-scan subprojectRegistry.clear(); - const entries = fs.readdirSync(this.projectsDir, { withFileTypes: true }); + const entries = await this.fsProvider.readdir(this.projectsDir); // Filter to only directories with valid encoding pattern const projectDirs = entries.filter( @@ -176,7 +181,7 @@ export class ProjectScanner { private async scanProject(encodedName: string): Promise { try { const projectPath = path.join(this.projectsDir, encodedName); - const entries = fs.readdirSync(projectPath, { withFileTypes: true }); + const entries = await this.fsProvider.readdir(projectPath); // Get session files (.jsonl at root level) const sessionFiles = entries.filter( @@ -199,10 +204,10 @@ export class ProjectScanner { const sessionInfos: SessionInfo[] = await Promise.all( sessionFiles.map(async (file) => { const filePath = path.join(projectPath, file.name); - const stats = fs.statSync(filePath); + const stats = await this.fsProvider.stat(filePath); let cwd: string | null = null; try { - cwd = await extractCwd(filePath); + cwd = await extractCwd(filePath, this.fsProvider); } catch { // Ignore unreadable files } @@ -328,7 +333,7 @@ export class ProjectScanner { const baseDir = extractBaseDir(projectId); const projectPath = path.join(this.projectsDir, baseDir); - if (!fs.existsSync(projectPath)) { + if (!(await this.fsProvider.exists(projectPath))) { return null; } @@ -356,11 +361,11 @@ export class ProjectScanner { const projectPath = path.join(this.projectsDir, baseDir); const sessionFilter = subprojectRegistry.getSessionFilter(projectId); - if (!fs.existsSync(projectPath)) { + if (!(await this.fsProvider.exists(projectPath))) { return []; } - const entries = fs.readdirSync(projectPath, { withFileTypes: true }); + const entries = await this.fsProvider.readdir(projectPath); let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')); // Filter to only sessions belonging to this subproject @@ -421,12 +426,12 @@ export class ProjectScanner { const projectPath = path.join(this.projectsDir, baseDir); const sessionFilter = subprojectRegistry.getSessionFilter(projectId); - if (!fs.existsSync(projectPath)) { + if (!(await this.fsProvider.exists(projectPath))) { return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 }; } // Step 1: Get all session files with their timestamps (lightweight stat calls) - const entries = fs.readdirSync(projectPath, { withFileTypes: true }); + const entries = await this.fsProvider.readdir(projectPath); let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')); // Filter to only sessions belonging to this subproject @@ -447,7 +452,7 @@ export class ProjectScanner { for (const file of sessionFiles) { const filePath = path.join(projectPath, file.name); try { - const stats = fs.statSync(filePath); + const stats = await this.fsProvider.stat(filePath); fileInfos.push({ name: file.name, sessionId: extractSessionId(file.name), @@ -590,18 +595,18 @@ export class ProjectScanner { filePath: string, projectPath: string ): Promise { - const stats = fs.statSync(filePath); + const stats = await this.fsProvider.stat(filePath); const cachedMetadata = this.sessionMetadataCache.get(filePath); const metadata = cachedMetadata?.mtimeMs === stats.mtimeMs ? cachedMetadata.metadata - : await analyzeSessionFileMetadata(filePath); + : await analyzeSessionFileMetadata(filePath, this.fsProvider); if (cachedMetadata?.mtimeMs !== stats.mtimeMs) { this.sessionMetadataCache.set(filePath, { mtimeMs: stats.mtimeMs, metadata }); } // Check for subagents (delegated to SubagentLocator) - const hasSubagents = this.subagentLocator.hasSubagentsSync(projectId, sessionId); + const hasSubagents = await this.subagentLocator.hasSubagents(projectId, sessionId); // Load task list data if exists const todoData = await this.loadTodoData(sessionId); @@ -627,7 +632,7 @@ export class ProjectScanner { async getSession(projectId: string, sessionId: string): Promise { const filePath = this.getSessionPath(projectId, sessionId); - if (!fs.existsSync(filePath)) { + if (!(await this.fsProvider.exists(filePath))) { return null; } @@ -646,11 +651,11 @@ export class ProjectScanner { try { const todoPath = buildTodoPath(path.dirname(this.projectsDir), sessionId); - if (!fs.existsSync(todoPath)) { + if (!(await this.fsProvider.exists(todoPath))) { return undefined; } - const content = fs.readFileSync(todoPath, 'utf8'); + const content = await this.fsProvider.readFile(todoPath); return JSON.parse(content) as unknown; } catch (error) { // Log but continue - task list data is non-critical @@ -686,11 +691,11 @@ export class ProjectScanner { const projectPath = path.join(this.projectsDir, baseDir); const sessionFilter = subprojectRegistry.getSessionFilter(projectId); - if (!fs.existsSync(projectPath)) { + if (!(await this.fsProvider.exists(projectPath))) { return []; } - const entries = fs.readdirSync(projectPath, { withFileTypes: true }); + const entries = await this.fsProvider.readdir(projectPath); let files = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')); @@ -754,8 +759,8 @@ export class ProjectScanner { /** * Checks if the projects directory exists. */ - projectsDirExists(): boolean { - return fs.existsSync(this.projectsDir); + async projectsDirExists(): Promise { + return this.fsProvider.exists(this.projectsDir); } // =========================================================================== @@ -803,13 +808,16 @@ export class ProjectScanner { */ private async hasDisplayableContent(filePath: string, mtimeMs?: number): Promise { try { - const effectiveMtime = mtimeMs ?? fs.statSync(filePath).mtimeMs; + const effectiveMtime = mtimeMs ?? (await this.fsProvider.stat(filePath)).mtimeMs; const cached = this.contentPresenceCache.get(filePath); if (cached?.mtimeMs === effectiveMtime) { return cached.hasContent; } - const hasContent = await this.sessionContentFilter.hasNonNoiseMessages(filePath); + const hasContent = await this.sessionContentFilter.hasNonNoiseMessages( + filePath, + this.fsProvider + ); this.contentPresenceCache.set(filePath, { mtimeMs: effectiveMtime, hasContent }); return hasContent; } catch { diff --git a/src/main/services/discovery/SessionContentFilter.ts b/src/main/services/discovery/SessionContentFilter.ts index f146ff20..b57c7f5b 100644 --- a/src/main/services/discovery/SessionContentFilter.ts +++ b/src/main/services/discovery/SessionContentFilter.ts @@ -21,13 +21,17 @@ * - synthetic assistant messages (model='') */ +import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; import { type ChatHistoryEntry, type ContentBlock } from '@main/types'; import { createLogger } from '@shared/utils/logger'; -import * as fs from 'fs'; import * as readline from 'readline'; +import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider'; + const logger = createLogger('Service:SessionContentFilter'); +const defaultProvider = new LocalFileSystemProvider(); + /** * Hard noise tags - user messages with ONLY these tags are filtered out. */ @@ -54,12 +58,15 @@ export class SessionContentFilter { * @param filePath - Path to the session JSONL file * @returns Promise resolving to true if session has displayable content */ - static async hasNonNoiseMessages(filePath: string): Promise { - if (!fs.existsSync(filePath)) { + static async hasNonNoiseMessages( + filePath: string, + fsProvider: FileSystemProvider = defaultProvider + ): Promise { + if (!(await fsProvider.exists(filePath))) { return false; } - const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, diff --git a/src/main/services/discovery/SessionSearcher.ts b/src/main/services/discovery/SessionSearcher.ts index a25b2284..9bfee7bc 100644 --- a/src/main/services/discovery/SessionSearcher.ts +++ b/src/main/services/discovery/SessionSearcher.ts @@ -9,6 +9,7 @@ */ import { ChunkBuilder } from '@main/services/analysis/ChunkBuilder'; +import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; import { isEnhancedAIChunk, isUserChunk, @@ -25,11 +26,12 @@ import { extractMarkdownPlainText, findMarkdownSearchMatches, } from '@shared/utils/markdownTextSearch'; -import * as fs from 'fs'; import * as path from 'path'; import { subprojectRegistry } from './SubprojectRegistry'; +import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider'; + const logger = createLogger('Discovery:SessionSearcher'); interface SearchableEntry { @@ -47,10 +49,12 @@ interface SearchableEntry { export class SessionSearcher { private readonly projectsDir: string; private readonly chunkBuilder: ChunkBuilder; + private readonly fsProvider: FileSystemProvider; - constructor(projectsDir: string) { + constructor(projectsDir: string, fsProvider?: FileSystemProvider) { this.projectsDir = projectsDir; this.chunkBuilder = new ChunkBuilder(); + this.fsProvider = fsProvider ?? new LocalFileSystemProvider(); } /** @@ -81,14 +85,12 @@ export class SessionSearcher { const projectPath = path.join(this.projectsDir, baseDir); const sessionFilter = subprojectRegistry.getSessionFilter(projectId); - try { - await fs.promises.access(projectPath, fs.constants.R_OK); - } catch { + if (!(await this.fsProvider.exists(projectPath))) { return { results: [], totalMatches: 0, sessionsSearched: 0, query }; } // Get all session files - const entries = await fs.promises.readdir(projectPath, { withFileTypes: true }); + const entries = await this.fsProvider.readdir(projectPath); const sessionFilesWithTime = await Promise.all( entries .filter((entry) => { @@ -103,7 +105,7 @@ export class SessionSearcher { .map(async (entry) => { const filePath = path.join(projectPath, entry.name); try { - const stats = await fs.promises.stat(filePath); + const stats = await this.fsProvider.stat(filePath); return { name: entry.name, filePath, mtimeMs: stats.mtimeMs }; } catch { return null; @@ -168,7 +170,7 @@ export class SessionSearcher { ): Promise { const results: SearchResult[] = []; let sessionTitle: string | undefined; - const messages = await parseJsonlFile(filePath); + const messages = await parseJsonlFile(filePath, this.fsProvider); const chunks = this.chunkBuilder.buildChunks(messages, []); for (const chunk of chunks) { diff --git a/src/main/services/discovery/SubagentLocator.ts b/src/main/services/discovery/SubagentLocator.ts index 94100497..c6d030f3 100644 --- a/src/main/services/discovery/SubagentLocator.ts +++ b/src/main/services/discovery/SubagentLocator.ts @@ -10,11 +10,14 @@ * - Determine subagent ownership for OLD structure */ +import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; import { buildSubagentsPath, extractBaseDir } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; +import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider'; + const logger = createLogger('Discovery:SubagentLocator'); /** @@ -22,20 +25,55 @@ const logger = createLogger('Discovery:SubagentLocator'); */ export class SubagentLocator { private readonly projectsDir: string; + private readonly fsProvider: FileSystemProvider; - constructor(projectsDir: string) { + constructor(projectsDir: string, fsProvider?: FileSystemProvider) { this.projectsDir = projectsDir; + this.fsProvider = fsProvider ?? new LocalFileSystemProvider(); } /** * Checks if a session has subagent files (async). + * Uses the FileSystemProvider for filesystem access. * * @param projectId - The project ID * @param sessionId - The session ID * @returns Promise resolving to true if subagents exist */ async hasSubagents(projectId: string, sessionId: string): Promise { - return this.hasSubagentsSync(projectId, sessionId); + // Check NEW structure: {projectId}/{sessionId}/subagents/ + const newSubagentsPath = this.getSubagentsPath(projectId, sessionId); + if (await this.fsProvider.exists(newSubagentsPath)) { + try { + const entries = await this.fsProvider.readdir(newSubagentsPath); + const subagentFiles = entries.filter( + (entry) => entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl') + ); + + // Check if at least one subagent file has content (not empty) + for (const entry of subagentFiles) { + const filePath = path.join(newSubagentsPath, entry.name); + try { + const stats = await this.fsProvider.stat(filePath); + // File must have size > 0 and contain at least one line + if (stats.size > 0) { + const content = await this.fsProvider.readFile(filePath); + if (content.trim().length > 0) { + return true; + } + } + } catch (error) { + // Skip this file if we can't read it - log for debugging + logger.debug(`SubagentLocator: Could not read file ${filePath}:`, error); + continue; + } + } + } catch { + // Ignore errors + } + } + + return false; } /** @@ -97,8 +135,8 @@ export class SubagentLocator { try { // Scan NEW structure: {projectId}/{sessionId}/subagents/agent-*.jsonl const newSubagentsPath = this.getSubagentsPath(projectId, sessionId); - if (fs.existsSync(newSubagentsPath)) { - const entries = fs.readdirSync(newSubagentsPath, { withFileTypes: true }); + if (await this.fsProvider.exists(newSubagentsPath)) { + const entries = await this.fsProvider.readdir(newSubagentsPath); const newFiles = entries .filter( (entry) => @@ -138,14 +176,14 @@ export class SubagentLocator { try { const projectPath = path.join(this.projectsDir, extractBaseDir(projectId)); - if (!fs.existsSync(projectPath)) { + if (!(await this.fsProvider.exists(projectPath))) { return []; } - const files = fs.readdirSync(projectPath); - const agentFiles = files - .filter((f) => f.startsWith('agent-') && f.endsWith('.jsonl')) - .map((f) => path.join(projectPath, f)); + const entries = await this.fsProvider.readdir(projectPath); + const agentFiles = entries + .filter((entry) => entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl')) + .map((entry) => path.join(projectPath, entry.name)); // Filter files by checking if their sessionId matches const matchingFiles: string[] = []; @@ -173,7 +211,7 @@ export class SubagentLocator { async subagentBelongsToSession(filePath: string, sessionId: string): Promise { try { // Read just the first line to check sessionId - const content = fs.readFileSync(filePath, 'utf-8'); + const content = await this.fsProvider.readFile(filePath); const firstNewline = content.indexOf('\n'); const firstLine = firstNewline > 0 ? content.slice(0, firstNewline) : content; diff --git a/src/main/services/discovery/WorktreeGrouper.ts b/src/main/services/discovery/WorktreeGrouper.ts index 95bd0840..f5fb0d9e 100644 --- a/src/main/services/discovery/WorktreeGrouper.ts +++ b/src/main/services/discovery/WorktreeGrouper.ts @@ -17,19 +17,24 @@ import { import { extractBaseDir } from '@main/utils/pathDecoder'; import * as path from 'path'; +import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider'; import { gitIdentityResolver } from '../parsing/GitIdentityResolver'; import { SessionContentFilter } from './SessionContentFilter'; import { subprojectRegistry } from './SubprojectRegistry'; +import type { FileSystemProvider } from '../infrastructure/FileSystemProvider'; + /** * WorktreeGrouper provides methods for grouping projects by git repository. */ export class WorktreeGrouper { private readonly projectsDir: string; + private readonly fsProvider: FileSystemProvider; - constructor(projectsDir: string) { + constructor(projectsDir: string, fsProvider?: FileSystemProvider) { this.projectsDir = projectsDir; + this.fsProvider = fsProvider ?? new LocalFileSystemProvider(); } /** @@ -79,7 +84,7 @@ export class WorktreeGrouper { continue; } const sessionPath = path.join(projectPath, `${sessionId}.jsonl`); - if (await SessionContentFilter.hasNonNoiseMessages(sessionPath)) { + if (await SessionContentFilter.hasNonNoiseMessages(sessionPath, this.fsProvider)) { filteredSessions.push(sessionId); } } diff --git a/src/main/services/infrastructure/FileSystemProvider.ts b/src/main/services/infrastructure/FileSystemProvider.ts new file mode 100644 index 00000000..fc23d2c9 --- /dev/null +++ b/src/main/services/infrastructure/FileSystemProvider.ts @@ -0,0 +1,75 @@ +/** + * FileSystemProvider - Abstract interface for filesystem operations. + * + * Enables the app to read session data from either: + * - Local filesystem (default) + * - Remote SSH/SFTP connections + * + * Only covers read operations needed by session-data services. + * Write operations (ConfigManager, NotificationManager) always stay local. + */ + +import type { Readable } from 'stream'; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Simplified stat result matching the subset of fs.Stats used by services. + */ +export interface FsStatResult { + size: number; + mtimeMs: number; + birthtimeMs: number; + isFile(): boolean; + isDirectory(): boolean; +} + +/** + * Simplified directory entry matching the subset of fs.Dirent used by services. + */ +export interface FsDirent { + name: string; + isFile(): boolean; + isDirectory(): boolean; +} + +/** + * Options for createReadStream, matching the subset used by services. + */ +export interface ReadStreamOptions { + start?: number; + encoding?: BufferEncoding; +} + +// ============================================================================= +// Provider Interface +// ============================================================================= + +/** + * Abstract filesystem provider interface. + * All session-data services use this instead of direct `fs` calls. + */ +export interface FileSystemProvider { + /** Provider type identifier */ + readonly type: 'local' | 'ssh'; + + /** Check if a file or directory exists */ + exists(filePath: string): Promise; + + /** Read a file's contents as a string */ + readFile(filePath: string, encoding?: BufferEncoding): Promise; + + /** Get file/directory stats */ + stat(filePath: string): Promise; + + /** Read directory entries */ + readdir(dirPath: string): Promise; + + /** Create a readable stream for a file */ + createReadStream(filePath: string, opts?: ReadStreamOptions): Readable; + + /** Cleanup resources */ + dispose(): void; +} diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 3263db84..7d8c5cc7 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -23,8 +23,11 @@ import { errorDetector } from '../error/ErrorDetector'; import { ConfigManager } from './ConfigManager'; import { type DataCache } from './DataCache'; +import { LocalFileSystemProvider } from './LocalFileSystemProvider'; import { type NotificationManager } from './NotificationManager'; +import type { FileSystemProvider } from './FileSystemProvider'; + const logger = createLogger('Service:FileWatcher'); /** Debounce window for file change events */ @@ -55,6 +58,7 @@ export class FileWatcher extends EventEmitter { private projectsPath: string; private todosPath: string; private dataCache: DataCache; + private fsProvider: FileSystemProvider; private notificationManager: NotificationManager | null = null; private isWatching: boolean = false; private debounceTimers = new Map(); @@ -66,16 +70,28 @@ export class FileWatcher extends EventEmitter { private activeSessionFiles = new Map(); /** Timer for periodic catch-up scan */ private catchUpTimer: NodeJS.Timeout | null = null; + /** Timer for SSH polling mode (replaces fs.watch) */ + private pollingTimer: NodeJS.Timeout | null = null; + /** Polling interval for SSH mode */ + private static readonly SSH_POLL_INTERVAL_MS = 5000; + /** Track file sizes for SSH polling change detection */ + private polledFileSizes = new Map(); /** Files currently being processed (concurrency guard) */ private processingInProgress = new Set(); /** Files that need reprocessing after current processing completes */ private pendingReprocess = new Set(); - constructor(dataCache: DataCache, projectsPath?: string, todosPath?: string) { + constructor( + dataCache: DataCache, + projectsPath?: string, + todosPath?: string, + fsProvider?: FileSystemProvider + ) { super(); this.projectsPath = projectsPath ?? getProjectsBasePath(); this.todosPath = todosPath ?? getTodosBasePath(); this.dataCache = dataCache; + this.fsProvider = fsProvider ?? new LocalFileSystemProvider(); } /** @@ -86,6 +102,13 @@ export class FileWatcher extends EventEmitter { this.notificationManager = manager; } + /** + * Sets the filesystem provider. Used when switching between local and SSH modes. + */ + setFileSystemProvider(provider: FileSystemProvider): void { + this.fsProvider = provider; + } + // =========================================================================== // Watcher Control // =========================================================================== @@ -100,7 +123,11 @@ export class FileWatcher extends EventEmitter { } this.isWatching = true; - this.ensureWatchers(); + if (this.fsProvider.type === 'ssh') { + this.startPollingMode(); + } else { + this.ensureWatchers(); + } this.startCatchUpTimer(); } @@ -137,6 +164,13 @@ export class FileWatcher extends EventEmitter { this.catchUpTimer = null; } + // Clear SSH polling timer + if (this.pollingTimer) { + clearInterval(this.pollingTimer); + this.pollingTimer = null; + } + this.polledFileSizes.clear(); + // Clear error detection tracking this.lastProcessedLineCount.clear(); this.lastProcessedSize.clear(); @@ -212,7 +246,7 @@ export class FileWatcher extends EventEmitter { } private ensureWatchers(): void { - if (!this.isWatching) { + if (!this.isWatching || this.fsProvider.type === 'ssh') { return; } @@ -259,6 +293,70 @@ export class FileWatcher extends EventEmitter { }); } + // =========================================================================== + // SSH Polling Mode + // =========================================================================== + + /** + * Starts polling mode for SSH connections. + * Polls the projects directory for file changes instead of using fs.watch(). + */ + private startPollingMode(): void { + if (this.pollingTimer) return; + + logger.info('FileWatcher: Starting SSH polling mode'); + this.pollingTimer = setInterval(() => { + this.pollForChanges().catch((err) => { + logger.error('Error during SSH polling:', err); + }); + }, FileWatcher.SSH_POLL_INTERVAL_MS); + } + + /** + * Polls the projects directory for file changes in SSH mode. + */ + private async pollForChanges(): Promise { + try { + if (!(await this.fsProvider.exists(this.projectsPath))) return; + + const projectDirs = await this.fsProvider.readdir(this.projectsPath); + for (const dir of projectDirs) { + if (!dir.isDirectory()) continue; + + const projectPath = path.join(this.projectsPath, dir.name); + let entries: import('./FileSystemProvider').FsDirent[]; + try { + entries = await this.fsProvider.readdir(projectPath); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; + + const fullPath = path.join(projectPath, entry.name); + try { + const stats = await this.fsProvider.stat(fullPath); + const lastSize = this.polledFileSizes.get(fullPath); + + if (lastSize === undefined) { + // First time seeing this file + this.polledFileSizes.set(fullPath, stats.size); + } else if (stats.size !== lastSize) { + // File changed + this.polledFileSizes.set(fullPath, stats.size); + this.handleProjectsChange('change', path.join(dir.name, entry.name)); + } + } catch { + continue; + } + } + } + } catch (err) { + logger.error('Error polling for changes:', err); + } + } + // =========================================================================== // Event Handling // =========================================================================== @@ -283,14 +381,14 @@ export class FileWatcher extends EventEmitter { /** * Process a debounced projects change. */ - private processProjectsChange(eventType: string, filename: string): void { + private async processProjectsChange(eventType: string, filename: string): Promise { const parts = filename.split(path.sep); const projectId = parts[0]; if (!projectId) return; const fullPath = path.join(this.projectsPath, filename); - const fileExists = fs.existsSync(fullPath); + const fileExists = await this.fsProvider.exists(fullPath); // Determine change type let changeType: FileChangeEvent['type']; @@ -390,7 +488,7 @@ export class FileWatcher extends EventEmitter { // Get the last processed line count for this file const lastLineCount = this.lastProcessedLineCount.get(filePath) ?? 0; const lastSize = this.lastProcessedSize.get(filePath) ?? 0; - const fileStats = await fs.promises.stat(filePath); + const fileStats = await this.fsProvider.stat(filePath); const currentSize = fileStats.size; // Fast path: no size change means no new data @@ -414,7 +512,7 @@ export class FileWatcher extends EventEmitter { currentLineCount = messages.length; newMessages = messages.slice(lastLineCount); // Re-stat after full parse to capture bytes written during the parse - const postParseStats = await fs.promises.stat(filePath); + const postParseStats = await this.fsProvider.stat(filePath); processedSize = postParseStats.size; } @@ -492,7 +590,10 @@ export class FileWatcher extends EventEmitter { startOffset: number ): Promise { const parsedMessages: ParsedMessage[] = []; - const stream = fs.createReadStream(filePath, { start: startOffset, encoding: 'utf8' }); + const stream = this.fsProvider.createReadStream(filePath, { + start: startOffset, + encoding: 'utf8', + }); let buffer = ''; let consumedBytes = 0; @@ -561,11 +662,11 @@ export class FileWatcher extends EventEmitter { /** * Process a debounced todos change. */ - private processTodosChange(eventType: string, filename: string): void { + private async processTodosChange(eventType: string, filename: string): Promise { // Session ID is the filename without extension const sessionId = path.basename(filename, '.json'); const fullPath = path.join(this.todosPath, filename); - const fileExists = fs.existsSync(fullPath); + const fileExists = await this.fsProvider.exists(fullPath); // Determine change type let changeType: FileChangeEvent['type']; @@ -621,7 +722,7 @@ export class FileWatcher extends EventEmitter { for (const [filePath, info] of this.activeSessionFiles) { try { - const stats = await fs.promises.stat(filePath); + const stats = await this.fsProvider.stat(filePath); // Skip files not modified recently if (now - stats.mtimeMs > CATCH_UP_MAX_AGE_MS) { diff --git a/src/main/services/infrastructure/LocalFileSystemProvider.ts b/src/main/services/infrastructure/LocalFileSystemProvider.ts new file mode 100644 index 00000000..9d7ee499 --- /dev/null +++ b/src/main/services/infrastructure/LocalFileSystemProvider.ts @@ -0,0 +1,63 @@ +/** + * LocalFileSystemProvider - FileSystemProvider backed by Node's fs module. + * + * Thin wrapper around Node.js filesystem APIs. + * This is the default provider used when operating in local mode. + */ + +import * as fs from 'fs'; + +import type { + FileSystemProvider, + FsDirent, + FsStatResult, + ReadStreamOptions, +} from './FileSystemProvider'; + +export class LocalFileSystemProvider implements FileSystemProvider { + readonly type = 'local' as const; + + async exists(filePath: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.F_OK); + return true; + } catch { + return false; + } + } + + async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise { + return fs.promises.readFile(filePath, encoding); + } + + async stat(filePath: string): Promise { + const stats = await fs.promises.stat(filePath); + return { + size: stats.size, + mtimeMs: stats.mtimeMs, + birthtimeMs: stats.birthtimeMs, + isFile: () => stats.isFile(), + isDirectory: () => stats.isDirectory(), + }; + } + + async readdir(dirPath: string): Promise { + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); + return entries.map((entry) => ({ + name: entry.name, + isFile: () => entry.isFile(), + isDirectory: () => entry.isDirectory(), + })); + } + + createReadStream(filePath: string, opts?: ReadStreamOptions): fs.ReadStream { + return fs.createReadStream(filePath, { + start: opts?.start, + encoding: opts?.encoding, + }); + } + + dispose(): void { + // No resources to clean up for local fs + } +} diff --git a/src/main/services/infrastructure/SshConnectionManager.ts b/src/main/services/infrastructure/SshConnectionManager.ts new file mode 100644 index 00000000..5071c956 --- /dev/null +++ b/src/main/services/infrastructure/SshConnectionManager.ts @@ -0,0 +1,328 @@ +/** + * SshConnectionManager - Manages SSH connection lifecycle. + * + * Responsibilities: + * - Connect/disconnect SSH sessions + * - Manage SFTP channel + * - Provide FileSystemProvider (local or SSH) to services + * - Emit connection state events for UI updates + * - Handle reconnection on errors + */ + +import { createLogger } from '@shared/utils/logger'; +import { EventEmitter } from 'events'; +import * as os from 'os'; +import * as path from 'path'; +import { Client, type ConnectConfig } from 'ssh2'; + +import { LocalFileSystemProvider } from './LocalFileSystemProvider'; +import { SshFileSystemProvider } from './SshFileSystemProvider'; + +import type { FileSystemProvider } from './FileSystemProvider'; + +const logger = createLogger('Infrastructure:SshConnectionManager'); + +// ============================================================================= +// Types +// ============================================================================= + +export type SshConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; + +export type SshAuthMethod = 'password' | 'privateKey' | 'agent'; + +export interface SshConnectionConfig { + host: string; + port: number; + username: string; + authMethod: SshAuthMethod; + password?: string; + privateKeyPath?: string; +} + +export interface SshConnectionProfile { + id: string; + name: string; + host: string; + port: number; + username: string; + authMethod: SshAuthMethod; + privateKeyPath?: string; +} + +export interface SshConnectionStatus { + state: SshConnectionState; + host: string | null; + error: string | null; + remoteProjectsPath: string | null; +} + +// ============================================================================= +// Connection Manager +// ============================================================================= + +export class SshConnectionManager extends EventEmitter { + private client: Client | null = null; + private provider: FileSystemProvider; + private localProvider: LocalFileSystemProvider; + private state: SshConnectionState = 'disconnected'; + private connectedHost: string | null = null; + private lastError: string | null = null; + private remoteProjectsPath: string | null = null; + + constructor() { + super(); + this.localProvider = new LocalFileSystemProvider(); + this.provider = this.localProvider; + } + + /** + * Returns the current FileSystemProvider (local or SSH). + */ + getProvider(): FileSystemProvider { + return this.provider; + } + + /** + * Returns the current connection status. + */ + getStatus(): SshConnectionStatus { + return { + state: this.state, + host: this.connectedHost, + error: this.lastError, + remoteProjectsPath: this.remoteProjectsPath, + }; + } + + /** + * Returns the remote projects directory path. + * Used by services to know where to scan on the remote machine. + */ + getRemoteProjectsPath(): string | null { + return this.remoteProjectsPath; + } + + /** + * Returns whether we're in SSH mode. + */ + isRemote(): boolean { + return this.state === 'connected' && this.provider.type === 'ssh'; + } + + /** + * Connect to a remote SSH host. + */ + async connect(config: SshConnectionConfig): Promise { + // Disconnect existing connection first + if (this.client) { + this.disconnect(); + } + + this.setState('connecting'); + this.connectedHost = config.host; + + try { + const client = new Client(); + this.client = client; + + const connectConfig = await this.buildConnectConfig(config); + + await new Promise((resolve, reject) => { + client.on('ready', () => resolve()); + client.on('error', (err) => reject(err)); + client.connect(connectConfig); + }); + + // Open SFTP channel + const sftp = await new Promise extends void ? never : never>( + (resolve, reject) => { + client.sftp((err, sftp) => { + if (err) { + reject(err); + return; + } + resolve(sftp as never); + }); + } + ); + + // Create SSH provider + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.provider = new SshFileSystemProvider(sftp as any); + + // Resolve remote ~/.claude/projects/ path + this.remoteProjectsPath = await this.resolveRemoteProjectsPath(config.username); + + // Set up disconnect handler + client.on('end', () => { + logger.info('SSH connection ended'); + this.handleDisconnect(); + }); + + client.on('close', () => { + logger.info('SSH connection closed'); + this.handleDisconnect(); + }); + + client.on('error', (err) => { + logger.error('SSH connection error:', err); + this.lastError = err.message; + this.setState('error'); + }); + + this.setState('connected'); + logger.info(`SSH connected to ${config.host}:${config.port}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`SSH connection failed: ${message}`); + this.lastError = message; + this.setState('error'); + this.cleanup(); + throw err; + } + } + + /** + * Test a connection without switching to SSH mode. + */ + async testConnection(config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> { + const testClient = new Client(); + + try { + const connectConfig = await this.buildConnectConfig(config); + + await new Promise((resolve, reject) => { + testClient.on('ready', () => resolve()); + testClient.on('error', (err) => reject(err)); + testClient.connect(connectConfig); + }); + + // Try to open SFTP to verify full access + await new Promise((resolve, reject) => { + testClient.sftp((err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + + testClient.end(); + return { success: true }; + } catch (err) { + testClient.end(); + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: message }; + } + } + + /** + * Disconnect and switch back to local mode. + */ + disconnect(): void { + this.cleanup(); + this.provider = this.localProvider; + this.connectedHost = null; + this.lastError = null; + this.remoteProjectsPath = null; + this.setState('disconnected'); + logger.info('Switched to local mode'); + } + + /** + * Dispose of all resources. + */ + dispose(): void { + this.cleanup(); + this.localProvider.dispose(); + this.removeAllListeners(); + } + + // =========================================================================== + // Private Methods + // =========================================================================== + + private async buildConnectConfig(config: SshConnectionConfig): Promise { + const connectConfig: ConnectConfig = { + host: config.host, + port: config.port, + username: config.username, + readyTimeout: 10000, + }; + + switch (config.authMethod) { + case 'password': + connectConfig.password = config.password; + break; + + case 'privateKey': { + const keyPath = config.privateKeyPath ?? path.join(os.homedir(), '.ssh', 'id_rsa'); + const { promises: fsPromises } = await import('fs'); + try { + const keyData = await fsPromises.readFile(keyPath, 'utf8'); + connectConfig.privateKey = keyData; + } catch (err) { + throw new Error(`Cannot read private key at ${keyPath}: ${(err as Error).message}`); + } + break; + } + + case 'agent': + connectConfig.agent = process.env.SSH_AUTH_SOCK; + if (!connectConfig.agent) { + throw new Error('SSH_AUTH_SOCK environment variable is not set'); + } + break; + } + + return connectConfig; + } + + private async resolveRemoteProjectsPath(username: string): Promise { + // Try to resolve the remote home directory + // SFTP doesn't have a direct "get home dir" call, so we try common paths + const candidates = [ + `/home/${username}/.claude/projects`, + `/Users/${username}/.claude/projects`, + `/root/.claude/projects`, + ]; + + for (const candidate of candidates) { + if (await this.provider.exists(candidate)) { + return candidate; + } + } + + // Fallback: try to read from environment via realpath of ~ + // Default to Linux convention + return `/home/${username}/.claude/projects`; + } + + private handleDisconnect(): void { + if (this.state === 'disconnected') return; + + this.provider = this.localProvider; + this.remoteProjectsPath = null; + this.setState('disconnected'); + } + + private cleanup(): void { + if (this.provider.type === 'ssh') { + this.provider.dispose(); + } + if (this.client) { + try { + this.client.end(); + } catch { + // Ignore cleanup errors + } + this.client = null; + } + } + + private setState(state: SshConnectionState): void { + this.state = state; + this.emit('state-change', this.getStatus()); + } +} diff --git a/src/main/services/infrastructure/SshFileSystemProvider.ts b/src/main/services/infrastructure/SshFileSystemProvider.ts new file mode 100644 index 00000000..76408b50 --- /dev/null +++ b/src/main/services/infrastructure/SshFileSystemProvider.ts @@ -0,0 +1,129 @@ +/** + * SshFileSystemProvider - FileSystemProvider backed by SSH2 SFTP. + * + * Wraps an ssh2 SFTPWrapper to provide the same filesystem interface + * used by session-data services, enabling remote file access. + */ + +import { createLogger } from '@shared/utils/logger'; +import { PassThrough, type Readable } from 'stream'; + +import type { + FileSystemProvider, + FsDirent, + FsStatResult, + ReadStreamOptions, +} from './FileSystemProvider'; +import type { SFTPWrapper } from 'ssh2'; + +const logger = createLogger('Infrastructure:SshFileSystemProvider'); + +export class SshFileSystemProvider implements FileSystemProvider { + readonly type = 'ssh' as const; + private sftp: SFTPWrapper; + + constructor(sftp: SFTPWrapper) { + this.sftp = sftp; + } + + async exists(filePath: string): Promise { + return new Promise((resolve) => { + this.sftp.stat(filePath, (err) => { + resolve(!err); + }); + }); + } + + async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise { + return new Promise((resolve, reject) => { + this.sftp.readFile(filePath, { encoding }, (err, data) => { + if (err) { + reject(err); + return; + } + resolve(data as unknown as string); + }); + }); + } + + async stat(filePath: string): Promise { + return new Promise((resolve, reject) => { + this.sftp.stat(filePath, (err, stats) => { + if (err) { + reject(err); + return; + } + // SFTP stats use mode bitmask for file type detection + const S_IFMT = 0o170000; + const S_IFREG = 0o100000; + const S_IFDIR = 0o040000; + const mode = stats.mode; + + resolve({ + size: stats.size, + mtimeMs: (stats.mtime ?? 0) * 1000, + // SFTP doesn't provide birth time, use mtime as fallback + birthtimeMs: (stats.mtime ?? 0) * 1000, + isFile: () => (mode & S_IFMT) === S_IFREG, + isDirectory: () => (mode & S_IFMT) === S_IFDIR, + }); + }); + }); + } + + async readdir(dirPath: string): Promise { + return new Promise((resolve, reject) => { + this.sftp.readdir(dirPath, (err, list) => { + if (err) { + reject(err); + return; + } + const S_IFMT = 0o170000; + const S_IFREG = 0o100000; + const S_IFDIR = 0o040000; + + const entries: FsDirent[] = list.map((item) => { + const mode = item.attrs.mode; + return { + name: item.filename, + isFile: () => (mode & S_IFMT) === S_IFREG, + isDirectory: () => (mode & S_IFMT) === S_IFDIR, + }; + }); + resolve(entries); + }); + }); + } + + createReadStream(filePath: string, opts?: ReadStreamOptions): Readable { + try { + const sftpStream = this.sftp.createReadStream(filePath, { + start: opts?.start, + encoding: opts?.encoding ?? undefined, + }); + + // Wrap in PassThrough to ensure Node Readable compatibility + const passthrough = new PassThrough(); + sftpStream.pipe(passthrough); + sftpStream.on('error', (err: Error) => { + passthrough.destroy(err); + }); + + return passthrough; + } catch (err) { + logger.error(`Error creating read stream for ${filePath}:`, err); + // Return an errored stream + const errStream = new PassThrough(); + process.nextTick(() => errStream.destroy(err as Error)); + return errStream; + } + } + + dispose(): void { + try { + this.sftp.end(); + } catch { + // Ignore errors during cleanup + } + } +} diff --git a/src/main/services/infrastructure/index.ts b/src/main/services/infrastructure/index.ts index 78cb02b0..e8bc3eba 100644 --- a/src/main/services/infrastructure/index.ts +++ b/src/main/services/infrastructure/index.ts @@ -7,11 +7,19 @@ * - ConfigManager: App configuration management * - TriggerManager: Notification trigger management (used internally by ConfigManager) * - NotificationManager: Notification handling and persistence + * - FileSystemProvider: Abstract filesystem interface + * - LocalFileSystemProvider: Local fs implementation + * - SshFileSystemProvider: SSH/SFTP implementation + * - SshConnectionManager: SSH connection lifecycle */ export * from './ConfigManager'; export * from './DataCache'; +export type * from './FileSystemProvider'; export * from './FileWatcher'; +export * from './LocalFileSystemProvider'; export * from './NotificationManager'; +export * from './SshConnectionManager'; +export * from './SshFileSystemProvider'; export * from './TriggerManager'; export * from './UpdaterService'; diff --git a/src/main/services/parsing/ClaudeMdReader.ts b/src/main/services/parsing/ClaudeMdReader.ts index 6c0ca859..34871cc7 100644 --- a/src/main/services/parsing/ClaudeMdReader.ts +++ b/src/main/services/parsing/ClaudeMdReader.ts @@ -12,11 +12,16 @@ import { encodePath } from '@main/utils/pathDecoder'; import { countTokens } from '@main/utils/tokenizer'; import { createLogger } from '@shared/utils/logger'; import { app } from 'electron'; -import * as fs from 'fs'; import * as path from 'path'; +import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider'; + +import type { FileSystemProvider } from '../infrastructure/FileSystemProvider'; + const logger = createLogger('Service:ClaudeMdReader'); +const defaultProvider = new LocalFileSystemProvider(); + // =========================================================================== // Types // =========================================================================== @@ -56,13 +61,17 @@ function expandTilde(filePath: string): string { /** * Reads a single CLAUDE.md file and returns its info. * @param filePath - Path to the CLAUDE.md file (supports ~ expansion) + * @param fsProvider - Optional filesystem provider (defaults to local) * @returns ClaudeMdFileInfo with file details */ -function readClaudeMdFile(filePath: string): ClaudeMdFileInfo { +async function readClaudeMdFile( + filePath: string, + fsProvider: FileSystemProvider = defaultProvider +): Promise { const expandedPath = expandTilde(filePath); try { - if (!fs.existsSync(expandedPath)) { + if (!(await fsProvider.exists(expandedPath))) { return { path: expandedPath, exists: false, @@ -71,7 +80,7 @@ function readClaudeMdFile(filePath: string): ClaudeMdFileInfo { }; } - const content = fs.readFileSync(expandedPath, 'utf8'); + const content = await fsProvider.readFile(expandedPath); const charCount = content.length; const estimatedTokens = countTokens(content); @@ -97,13 +106,17 @@ function readClaudeMdFile(filePath: string): ClaudeMdFileInfo { * Reads all .md files in a directory and returns combined info. * Used for project rules directory. * @param dirPath - Path to the directory (supports ~ expansion) + * @param fsProvider - Optional filesystem provider (defaults to local) * @returns ClaudeMdFileInfo with combined stats from all .md files */ -function readDirectoryMdFiles(dirPath: string): ClaudeMdFileInfo { +async function readDirectoryMdFiles( + dirPath: string, + fsProvider: FileSystemProvider = defaultProvider +): Promise { const expandedPath = expandTilde(dirPath); try { - if (!fs.existsSync(expandedPath)) { + if (!(await fsProvider.exists(expandedPath))) { return { path: expandedPath, exists: false, @@ -112,7 +125,7 @@ function readDirectoryMdFiles(dirPath: string): ClaudeMdFileInfo { }; } - const stats = fs.statSync(expandedPath); + const stats = await fsProvider.stat(expandedPath); if (!stats.isDirectory()) { return { path: expandedPath, @@ -122,7 +135,7 @@ function readDirectoryMdFiles(dirPath: string): ClaudeMdFileInfo { }; } - const mdFiles = collectMdFiles(expandedPath); + const mdFiles = await collectMdFiles(expandedPath, fsProvider); if (mdFiles.length === 0) { return { @@ -138,7 +151,7 @@ function readDirectoryMdFiles(dirPath: string): ClaudeMdFileInfo { for (const filePath of mdFiles) { try { - const content = fs.readFileSync(filePath, 'utf8'); + const content = await fsProvider.readFile(filePath); totalCharCount += content.length; allContent.push(content); } catch { @@ -170,18 +183,20 @@ function readDirectoryMdFiles(dirPath: string): ClaudeMdFileInfo { /** * Recursively collect all .md files in a directory tree. */ -function collectMdFiles(dir: string): string[] { +async function collectMdFiles( + dir: string, + fsProvider: FileSystemProvider = defaultProvider +): Promise { const mdFiles: string[] = []; try { - const entries = fs.readdirSync(dir); + const entries = await fsProvider.readdir(dir); for (const entry of entries) { - const fullPath = path.join(dir, entry); + const fullPath = path.join(dir, entry.name); try { - const stats = fs.statSync(fullPath); - if (stats.isFile() && entry.endsWith('.md')) { + if (entry.isFile() && entry.name.endsWith('.md')) { mdFiles.push(fullPath); - } else if (stats.isDirectory()) { - mdFiles.push(...collectMdFiles(fullPath)); + } else if (entry.isDirectory()) { + mdFiles.push(...(await collectMdFiles(fullPath, fsProvider))); } } catch { continue; @@ -211,18 +226,21 @@ function getEnterprisePath(): string { * Reads auto memory MEMORY.md file for a project. * Only reads the first 200 lines, matching Claude Code behavior. */ -function readAutoMemoryFile(projectRoot: string): ClaudeMdFileInfo { +async function readAutoMemoryFile( + projectRoot: string, + fsProvider: FileSystemProvider = defaultProvider +): Promise { const expandedRoot = expandTilde(projectRoot); const encoded = encodePath(expandedRoot); const homeDir = app.getPath('home'); const memoryPath = path.join(homeDir, '.claude', 'projects', encoded, 'memory', 'MEMORY.md'); try { - if (!fs.existsSync(memoryPath)) { + if (!(await fsProvider.exists(memoryPath))) { return { path: memoryPath, exists: false, charCount: 0, estimatedTokens: 0 }; } - const content = fs.readFileSync(memoryPath, 'utf8'); + const content = await fsProvider.readFile(memoryPath); // Only first 200 lines, matching Claude Code behavior const lines = content.split('\n'); const truncated = lines.slice(0, 200).join('\n'); @@ -239,43 +257,47 @@ function readAutoMemoryFile(projectRoot: string): ClaudeMdFileInfo { /** * Reads all potential CLAUDE.md locations for a project. * @param projectRoot - The root directory of the project + * @param fsProvider - Optional filesystem provider (defaults to local) * @returns ClaudeMdReadResult with Map of path -> ClaudeMdFileInfo */ -export function readAllClaudeMdFiles(projectRoot: string): ClaudeMdReadResult { +export async function readAllClaudeMdFiles( + projectRoot: string, + fsProvider: FileSystemProvider = defaultProvider +): Promise { const files = new Map(); const expandedProjectRoot = expandTilde(projectRoot); // 1. Enterprise CLAUDE.md (platform-specific path) const enterprisePath = getEnterprisePath(); - files.set('enterprise', readClaudeMdFile(enterprisePath)); + files.set('enterprise', await readClaudeMdFile(enterprisePath, fsProvider)); // 2. User memory: ~/.claude/CLAUDE.md const userMemoryPath = '~/.claude/CLAUDE.md'; - files.set('user', readClaudeMdFile(userMemoryPath)); + files.set('user', await readClaudeMdFile(userMemoryPath, fsProvider)); // 3. Project memory: ${projectRoot}/CLAUDE.md const projectMemoryPath = path.join(expandedProjectRoot, 'CLAUDE.md'); - files.set('project', readClaudeMdFile(projectMemoryPath)); + files.set('project', await readClaudeMdFile(projectMemoryPath, fsProvider)); // 4. Project memory alt: ${projectRoot}/.claude/CLAUDE.md const projectMemoryAltPath = path.join(expandedProjectRoot, '.claude', 'CLAUDE.md'); - files.set('project-alt', readClaudeMdFile(projectMemoryAltPath)); + files.set('project-alt', await readClaudeMdFile(projectMemoryAltPath, fsProvider)); // 5. Project rules: ${projectRoot}/.claude/rules/*.md const projectRulesPath = path.join(expandedProjectRoot, '.claude', 'rules'); - files.set('project-rules', readDirectoryMdFiles(projectRulesPath)); + files.set('project-rules', await readDirectoryMdFiles(projectRulesPath, fsProvider)); // 6. Project local: ${projectRoot}/CLAUDE.local.md const projectLocalPath = path.join(expandedProjectRoot, 'CLAUDE.local.md'); - files.set('project-local', readClaudeMdFile(projectLocalPath)); + files.set('project-local', await readClaudeMdFile(projectLocalPath, fsProvider)); // 7. User rules: ~/.claude/rules/**/*.md const homeDir = app.getPath('home'); const userRulesPath = path.join(homeDir, '.claude', 'rules'); - files.set('user-rules', readDirectoryMdFiles(userRulesPath)); + files.set('user-rules', await readDirectoryMdFiles(userRulesPath, fsProvider)); // 8. Auto memory: ~/.claude/projects//memory/MEMORY.md - files.set('auto-memory', readAutoMemoryFile(projectRoot)); + files.set('auto-memory', await readAutoMemoryFile(projectRoot, fsProvider)); return { files }; } @@ -284,10 +306,14 @@ export function readAllClaudeMdFiles(projectRoot: string): ClaudeMdReadResult { * Reads a specific directory's CLAUDE.md file. * Used for directory-specific CLAUDE.md detected from file reads. * @param dirPath - Path to the directory (supports ~ expansion) + * @param fsProvider - Optional filesystem provider (defaults to local) * @returns ClaudeMdFileInfo for the CLAUDE.md file in that directory */ -export function readDirectoryClaudeMd(dirPath: string): ClaudeMdFileInfo { +export async function readDirectoryClaudeMd( + dirPath: string, + fsProvider: FileSystemProvider = defaultProvider +): Promise { const expandedDirPath = expandTilde(dirPath); const claudeMdPath = path.join(expandedDirPath, 'CLAUDE.md'); - return readClaudeMdFile(claudeMdPath); + return readClaudeMdFile(claudeMdPath, fsProvider); } diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 1bf314cd..fe4e880c 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -9,11 +9,9 @@ import { isCommandOutputContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; import { createLogger } from '@shared/utils/logger'; -import * as fs from 'fs'; import * as readline from 'readline'; -const logger = createLogger('Util:jsonl'); - +import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider'; import { type ChatHistoryEntry, type ContentBlock, @@ -31,6 +29,12 @@ import { // Import from extracted modules import { extractToolCalls, extractToolResults } from './toolExtraction'; +import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider'; + +const logger = createLogger('Util:jsonl'); + +const defaultProvider = new LocalFileSystemProvider(); + // Re-export for backwards compatibility export { extractCwd } from './metadataExtraction'; export { checkMessagesOngoing } from './sessionStateDetection'; @@ -43,14 +47,17 @@ export { checkMessagesOngoing } from './sessionStateDetection'; * Parse a JSONL file line by line using streaming. * This avoids loading the entire file into memory. */ -export async function parseJsonlFile(filePath: string): Promise { +export async function parseJsonlFile( + filePath: string, + fsProvider: FileSystemProvider = defaultProvider +): Promise { const messages: ParsedMessage[] = []; - if (!fs.existsSync(filePath)) { + if (!(await fsProvider.exists(filePath))) { return messages; } - const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, @@ -299,8 +306,11 @@ export interface SessionFileMetadata { * Analyze key session metadata in a single streaming pass. * This avoids multiple file scans when listing sessions. */ -export async function analyzeSessionFileMetadata(filePath: string): Promise { - if (!fs.existsSync(filePath)) { +export async function analyzeSessionFileMetadata( + filePath: string, + fsProvider: FileSystemProvider = defaultProvider +): Promise { + if (!(await fsProvider.exists(filePath))) { return { firstUserMessage: null, messageCount: 0, @@ -309,7 +319,7 @@ export async function analyzeSessionFileMetadata(filePath: string): Promise { - if (!fs.existsSync(filePath)) { +export async function extractCwd( + filePath: string, + fsProvider: FileSystemProvider = defaultProvider +): Promise { + if (!(await fsProvider.exists(filePath))) { return null; } - const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 6e373e1c..ace58729 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -59,6 +59,25 @@ export const CONFIG_PIN_SESSION = 'config:pinSession'; /** Unpin a session */ export const CONFIG_UNPIN_SESSION = 'config:unpinSession'; +// ============================================================================= +// SSH API Channels +// ============================================================================= + +/** Connect to SSH host */ +export const SSH_CONNECT = 'ssh:connect'; + +/** Disconnect SSH and switch to local */ +export const SSH_DISCONNECT = 'ssh:disconnect'; + +/** Get current SSH connection state */ +export const SSH_GET_STATE = 'ssh:getState'; + +/** Test SSH connection without switching */ +export const SSH_TEST = 'ssh:test'; + +/** SSH status event channel (main -> renderer) */ +export const SSH_STATUS = 'ssh:status'; + // ============================================================================= // Updater API Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index 1f114471..d06e9036 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,6 +2,11 @@ import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants'; import { contextBridge, ipcRenderer } from 'electron'; import { + SSH_CONNECT, + SSH_DISCONNECT, + SSH_GET_STATE, + SSH_STATUS, + SSH_TEST, UPDATER_CHECK, UPDATER_DOWNLOAD, UPDATER_INSTALL, @@ -32,6 +37,8 @@ import type { ElectronAPI, NotificationTrigger, SessionsPaginationOptions, + SshConnectionConfig, + SshConnectionStatus, TriggerTestResult, } from '@shared/types'; @@ -310,6 +317,34 @@ const electronAPI: ElectronAPI = { }; }, }, + + // SSH API + ssh: { + connect: async (config: SshConnectionConfig): Promise => { + return invokeIpcWithResult(SSH_CONNECT, config); + }, + disconnect: async (): Promise => { + return invokeIpcWithResult(SSH_DISCONNECT); + }, + getState: async (): Promise => { + return ipcRenderer.invoke(SSH_GET_STATE); + }, + test: async (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> => { + return invokeIpcWithResult<{ success: boolean; error?: string }>(SSH_TEST, config); + }, + onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void): (() => void) => { + ipcRenderer.on( + SSH_STATUS, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + SSH_STATUS, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, + }, }; // Use contextBridge to securely expose the API to the renderer process diff --git a/src/renderer/components/layout/TabbedLayout.tsx b/src/renderer/components/layout/TabbedLayout.tsx index 3a71aef7..a7373dff 100644 --- a/src/renderer/components/layout/TabbedLayout.tsx +++ b/src/renderer/components/layout/TabbedLayout.tsx @@ -9,6 +9,8 @@ import { getTrafficLightPaddingForZoom } from '@renderer/constants/layout'; import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts'; import { useZoomFactor } from '@renderer/hooks/useZoomFactor'; +import { useStore } from '@renderer/store'; +import { Loader2, Wifi } from 'lucide-react'; import { UpdateBanner } from '../common/UpdateBanner'; import { UpdateDialog } from '../common/UpdateDialog'; @@ -17,6 +19,47 @@ import { CommandPalette } from '../search/CommandPalette'; import { PaneContainer } from './PaneContainer'; import { Sidebar } from './Sidebar'; +/** + * SshConnectionIndicator - Shows SSH connection status in the layout. + * Only visible when in SSH mode or connecting. + */ +const SshConnectionIndicator = (): React.JSX.Element | null => { + const connectionState = useStore((s) => s.connectionState); + const connectedHost = useStore((s) => s.connectedHost); + + if (connectionState === 'disconnected') return null; + + return ( +
+ {connectionState === 'connecting' && ( + <> + + Connecting to {connectedHost}... + + )} + {connectionState === 'connected' && ( + <> + + SSH: {connectedHost} + + )} + {connectionState === 'error' && ( + <> +
+ SSH Error + + )} +
+ ); +}; + export const TabbedLayout = (): React.JSX.Element => { // Enable keyboard shortcuts useKeyboardShortcuts(); @@ -31,6 +74,7 @@ export const TabbedLayout = (): React.JSX.Element => { } > +
{/* Command Palette (Cmd+K) */} diff --git a/src/renderer/components/settings/SettingsTabs.tsx b/src/renderer/components/settings/SettingsTabs.tsx index 1efcd72f..fdedb1b7 100644 --- a/src/renderer/components/settings/SettingsTabs.tsx +++ b/src/renderer/components/settings/SettingsTabs.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; -import { Bell, Settings, Wrench } from 'lucide-react'; +import { Bell, Server, Settings, Wrench } from 'lucide-react'; -export type SettingsSection = 'general' | 'notifications' | 'advanced'; +export type SettingsSection = 'general' | 'connection' | 'notifications' | 'advanced'; interface SettingsTabsProps { activeSection: SettingsSection; @@ -17,6 +17,7 @@ interface TabConfig { const tabs: TabConfig[] = [ { id: 'general', label: 'General', icon: Settings }, + { id: 'connection', label: 'Connection', icon: Server }, { id: 'notifications', label: 'Notifications', icon: Bell }, { id: 'advanced', label: 'Advanced', icon: Wrench }, ]; diff --git a/src/renderer/components/settings/SettingsView.tsx b/src/renderer/components/settings/SettingsView.tsx index 3263500d..b961619a 100644 --- a/src/renderer/components/settings/SettingsView.tsx +++ b/src/renderer/components/settings/SettingsView.tsx @@ -8,7 +8,12 @@ import { useState } from 'react'; import { Loader2 } from 'lucide-react'; import { useSettingsConfig, useSettingsHandlers } from './hooks'; -import { AdvancedSection, GeneralSection, NotificationsSection } from './sections'; +import { + AdvancedSection, + ConnectionSection, + GeneralSection, + NotificationsSection, +} from './sections'; import { type SettingsSection, SettingsTabs } from './SettingsTabs'; export const SettingsView = (): React.JSX.Element | null => { @@ -112,6 +117,8 @@ export const SettingsView = (): React.JSX.Element | null => { /> )} + {activeSection === 'connection' && } + {activeSection === 'notifications' && ( { + const connectionState = useStore((s) => s.connectionState); + const connectedHost = useStore((s) => s.connectedHost); + const connectionError = useStore((s) => s.connectionError); + const connectSsh = useStore((s) => s.connectSsh); + const disconnectSsh = useStore((s) => s.disconnectSsh); + const testConnection = useStore((s) => s.testConnection); + + // Form state + const [host, setHost] = useState(''); + const [port, setPort] = useState('22'); + const [username, setUsername] = useState(''); + const [authMethod, setAuthMethod] = useState('agent'); + const [password, setPassword] = useState(''); + const [privateKeyPath, setPrivateKeyPath] = useState('~/.ssh/id_rsa'); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; error?: string } | null>(null); + + const buildConfig = (): SshConnectionConfig => ({ + host, + port: parseInt(port, 10) || 22, + username, + authMethod, + password: authMethod === 'password' ? password : undefined, + privateKeyPath: authMethod === 'privateKey' ? privateKeyPath : undefined, + }); + + const handleTest = async (): Promise => { + setTesting(true); + setTestResult(null); + const result = await testConnection(buildConfig()); + setTestResult(result); + setTesting(false); + }; + + const handleConnect = async (): Promise => { + await connectSsh(buildConfig()); + }; + + const handleDisconnect = async (): Promise => { + await disconnectSsh(); + }; + + const isConnecting = connectionState === 'connecting'; + const isConnected = connectionState === 'connected'; + + const inputClass = 'w-full rounded-md border px-3 py-1.5 text-sm focus:outline-none focus:ring-1'; + + return ( +
+ +

+ Connect to a remote machine to view Claude Code sessions running there +

+ + {/* Connection Status */} + {isConnected && ( +
+ +
+

+ Connected to {connectedHost} +

+

+ Viewing remote sessions via SSH +

+
+ +
+ )} + + {connectionError && ( +
+

{connectionError}

+
+ )} + + {/* Mode indicator */} + {!isConnected && ( + +
+ + Local (~/.claude/) +
+
+ )} + + {/* SSH Connection Form */} + {!isConnected && ( +
+

+ SSH Connection +

+ +
+
+ + setHost(e.target.value)} + placeholder="192.168.1.100" + className={inputClass} + style={{ + backgroundColor: 'var(--color-surface-raised)', + borderColor: 'var(--color-border)', + color: 'var(--color-text)', + }} + /> +
+
+ + setPort(e.target.value)} + placeholder="22" + className={inputClass} + style={{ + backgroundColor: 'var(--color-surface-raised)', + borderColor: 'var(--color-border)', + color: 'var(--color-text)', + }} + /> +
+
+ +
+ + setUsername(e.target.value)} + placeholder="user" + className={inputClass} + style={{ + backgroundColor: 'var(--color-surface-raised)', + borderColor: 'var(--color-border)', + color: 'var(--color-text)', + }} + /> +
+ +
+ + +
+ + {authMethod === 'privateKey' && ( +
+ + setPrivateKeyPath(e.target.value)} + placeholder="~/.ssh/id_rsa" + className={inputClass} + style={{ + backgroundColor: 'var(--color-surface-raised)', + borderColor: 'var(--color-border)', + color: 'var(--color-text)', + }} + /> +
+ )} + + {authMethod === 'password' && ( +
+ + setPassword(e.target.value)} + className={inputClass} + style={{ + backgroundColor: 'var(--color-surface-raised)', + borderColor: 'var(--color-border)', + color: 'var(--color-text)', + }} + /> +
+ )} + + {/* Test result */} + {testResult && ( +
+ {testResult.success + ? 'Connection successful' + : `Connection failed: ${testResult.error}`} +
+ )} + + {/* Action buttons */} +
+ + + +
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/settings/sections/index.ts b/src/renderer/components/settings/sections/index.ts index d5579c28..62adf323 100644 --- a/src/renderer/components/settings/sections/index.ts +++ b/src/renderer/components/settings/sections/index.ts @@ -3,5 +3,6 @@ */ export { AdvancedSection } from './AdvancedSection'; +export { ConnectionSection } from './ConnectionSection'; export { GeneralSection } from './GeneralSection'; export { NotificationsSection } from './NotificationsSection'; diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index cb0c286d..557a4ff5 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -5,6 +5,7 @@ import { create } from 'zustand'; import { createConfigSlice } from './slices/configSlice'; +import { createConnectionSlice } from './slices/connectionSlice'; import { createConversationSlice } from './slices/conversationSlice'; import { createNotificationSlice } from './slices/notificationSlice'; import { createPaneSlice } from './slices/paneSlice'; @@ -39,6 +40,7 @@ export const useStore = create()((...args) => ({ ...createUISlice(...args), ...createNotificationSlice(...args), ...createConfigSlice(...args), + ...createConnectionSlice(...args), ...createUpdateSlice(...args), })); @@ -274,6 +276,28 @@ export function initializeNotificationListeners(): () => void { } } + // Listen for SSH connection status changes from main process + if (window.electronAPI.ssh?.onStatus) { + const cleanup = window.electronAPI.ssh.onStatus((_event: unknown, status: unknown) => { + const s = status as { state: string; host: string | null; error: string | null }; + useStore + .getState() + .setConnectionStatus( + s.state as 'disconnected' | 'connecting' | 'connected' | 'error', + s.host, + s.error + ); + + // Re-fetch data when connection state changes to connected or disconnected + if (s.state === 'connected' || s.state === 'disconnected') { + void useStore.getState().fetchProjects(); + } + }); + if (typeof cleanup === 'function') { + cleanupFns.push(cleanup); + } + } + // Return cleanup function return () => { for (const timer of pendingSessionRefreshTimers.values()) { diff --git a/src/renderer/store/slices/connectionSlice.ts b/src/renderer/store/slices/connectionSlice.ts new file mode 100644 index 00000000..76173f08 --- /dev/null +++ b/src/renderer/store/slices/connectionSlice.ts @@ -0,0 +1,121 @@ +/** + * Connection Slice - Manages SSH connection state. + * + * Tracks connection mode (local/ssh), connection state, + * and provides actions for connecting/disconnecting. + */ + +import type { AppState } from '../types'; +import type { SshConnectionConfig, SshConnectionState } from '@shared/types'; +import type { StateCreator } from 'zustand'; + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface ConnectionSlice { + // State + connectionMode: 'local' | 'ssh'; + connectionState: SshConnectionState; + connectedHost: string | null; + connectionError: string | null; + + // Actions + connectSsh: (config: SshConnectionConfig) => Promise; + disconnectSsh: () => Promise; + testConnection: (config: SshConnectionConfig) => Promise<{ success: boolean; error?: string }>; + setConnectionStatus: ( + state: SshConnectionState, + host: string | null, + error: string | null + ) => void; +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createConnectionSlice: StateCreator = ( + set, + get +) => ({ + // Initial state + connectionMode: 'local', + connectionState: 'disconnected', + connectedHost: null, + connectionError: null, + + // Actions + connectSsh: async (config: SshConnectionConfig): Promise => { + set({ + connectionState: 'connecting', + connectedHost: config.host, + connectionError: null, + }); + + try { + const status = await window.electronAPI.ssh.connect(config); + set({ + connectionMode: status.state === 'connected' ? 'ssh' : 'local', + connectionState: status.state, + connectedHost: status.host, + connectionError: status.error, + }); + + // Re-fetch all data when connected + if (status.state === 'connected') { + const state = get(); + void state.fetchProjects(); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + set({ + connectionState: 'error', + connectionError: message, + }); + } + }, + + disconnectSsh: async (): Promise => { + try { + const status = await window.electronAPI.ssh.disconnect(); + set({ + connectionMode: 'local', + connectionState: status.state, + connectedHost: null, + connectionError: null, + }); + + // Re-fetch local data + const state = get(); + void state.fetchProjects(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + set({ connectionError: message }); + } + }, + + testConnection: async ( + config: SshConnectionConfig + ): Promise<{ success: boolean; error?: string }> => { + try { + return await window.electronAPI.ssh.test(config); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: message }; + } + }, + + setConnectionStatus: ( + state: SshConnectionState, + host: string | null, + error: string | null + ): void => { + set({ + connectionState: state, + connectionMode: state === 'connected' ? 'ssh' : 'local', + connectedHost: host, + connectionError: error, + }); + }, +}); diff --git a/src/renderer/store/types.ts b/src/renderer/store/types.ts index 6b1c2ce1..22a33e9b 100644 --- a/src/renderer/store/types.ts +++ b/src/renderer/store/types.ts @@ -4,6 +4,7 @@ */ import type { ConfigSlice } from './slices/configSlice'; +import type { ConnectionSlice } from './slices/connectionSlice'; import type { ConversationSlice } from './slices/conversationSlice'; import type { NotificationSlice } from './slices/notificationSlice'; import type { PaneSlice } from './slices/paneSlice'; @@ -86,4 +87,5 @@ export type AppState = ProjectSlice & UISlice & NotificationSlice & ConfigSlice & + ConnectionSlice & UpdateSlice; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 4748f54b..ec77f0b7 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -144,6 +144,66 @@ export interface UpdaterAPI { onStatus: (callback: (event: unknown, status: unknown) => void) => () => void; } +// ============================================================================= +// SSH API +// ============================================================================= + +/** + * SSH connection state. + */ +export type SshConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; + +/** + * SSH authentication method. + */ +export type SshAuthMethod = 'password' | 'privateKey' | 'agent'; + +/** + * SSH connection configuration sent from renderer. + */ +export interface SshConnectionConfig { + host: string; + port: number; + username: string; + authMethod: SshAuthMethod; + password?: string; + privateKeyPath?: string; +} + +/** + * Saved SSH connection profile (no password stored). + */ +export interface SshConnectionProfile { + id: string; + name: string; + host: string; + port: number; + username: string; + authMethod: SshAuthMethod; + privateKeyPath?: string; +} + +/** + * SSH connection status returned from main process. + */ +export interface SshConnectionStatus { + state: SshConnectionState; + host: string | null; + error: string | null; + remoteProjectsPath: string | null; +} + +/** + * SSH API exposed via preload. + */ +export interface SshAPI { + connect: (config: SshConnectionConfig) => Promise; + disconnect: () => Promise; + getState: () => Promise; + test: (config: SshConnectionConfig) => Promise<{ success: boolean; error?: string }>; + onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void) => () => void; +} + // ============================================================================= // Main Electron API // ============================================================================= @@ -225,6 +285,9 @@ export interface ElectronAPI { // Updater API updater: UpdaterAPI; + + // SSH API + ssh: SshAPI; } // =============================================================================