add ssh suport
This commit is contained in:
parent
e2670a3a02
commit
4b56186f7c
32 changed files with 1817 additions and 121 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
): 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');
|
||||
}
|
||||
|
|
|
|||
107
src/main/ipc/ssh.ts
Normal file
107
src/main/ipc/ssh.ts
Normal file
|
|
@ -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<void>) | 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>
|
||||
): 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<SshConnectionStatus> => {
|
||||
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);
|
||||
}
|
||||
|
|
@ -137,7 +137,7 @@ async function handleReadClaudeMdFiles(
|
|||
projectRoot: string
|
||||
): Promise<Record<string, ClaudeMdFileInfo>> {
|
||||
try {
|
||||
const result = readAllClaudeMdFiles(projectRoot);
|
||||
const result = await readAllClaudeMdFiles(projectRoot);
|
||||
// Convert Map to object for IPC serialization
|
||||
const files: Record<string, ClaudeMdFileInfo> = {};
|
||||
result.files.forEach((info, key) => {
|
||||
|
|
@ -160,7 +160,7 @@ async function handleReadDirectoryClaudeMd(
|
|||
dirPath: string
|
||||
): Promise<ClaudeMdFileInfo> {
|
||||
try {
|
||||
const info = readDirectoryClaudeMd(dirPath);
|
||||
const info = await readDirectoryClaudeMd(dirPath);
|
||||
return info;
|
||||
} catch (error) {
|
||||
logger.error(`Error in read-directory-claude-md:`, error);
|
||||
|
|
|
|||
|
|
@ -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<string, string>();
|
||||
|
||||
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<string[]> {
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -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<Project[]> {
|
||||
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<Project[]> {
|
||||
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<Session> {
|
||||
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<Session | null> {
|
||||
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<boolean> {
|
||||
return this.fsProvider.exists(this.projectsDir);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
|
@ -803,13 +808,16 @@ export class ProjectScanner {
|
|||
*/
|
||||
private async hasDisplayableContent(filePath: string, mtimeMs?: number): Promise<boolean> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -21,13 +21,17 @@
|
|||
* - synthetic assistant messages (model='<synthetic>')
|
||||
*/
|
||||
|
||||
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<boolean> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
static async hasNonNoiseMessages(
|
||||
filePath: string,
|
||||
fsProvider: FileSystemProvider = defaultProvider
|
||||
): Promise<boolean> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<SearchResult[]> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
75
src/main/services/infrastructure/FileSystemProvider.ts
Normal file
75
src/main/services/infrastructure/FileSystemProvider.ts
Normal file
|
|
@ -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<boolean>;
|
||||
|
||||
/** Read a file's contents as a string */
|
||||
readFile(filePath: string, encoding?: BufferEncoding): Promise<string>;
|
||||
|
||||
/** Get file/directory stats */
|
||||
stat(filePath: string): Promise<FsStatResult>;
|
||||
|
||||
/** Read directory entries */
|
||||
readdir(dirPath: string): Promise<FsDirent[]>;
|
||||
|
||||
/** Create a readable stream for a file */
|
||||
createReadStream(filePath: string, opts?: ReadStreamOptions): Readable;
|
||||
|
||||
/** Cleanup resources */
|
||||
dispose(): void;
|
||||
}
|
||||
|
|
@ -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<string, NodeJS.Timeout>();
|
||||
|
|
@ -66,16 +70,28 @@ export class FileWatcher extends EventEmitter {
|
|||
private activeSessionFiles = new Map<string, ActiveSessionFile>();
|
||||
/** 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<string, number>();
|
||||
/** Files currently being processed (concurrency guard) */
|
||||
private processingInProgress = new Set<string>();
|
||||
/** Files that need reprocessing after current processing completes */
|
||||
private pendingReprocess = new Set<string>();
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<AppendedParseResult> {
|
||||
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<void> {
|
||||
// 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) {
|
||||
|
|
|
|||
63
src/main/services/infrastructure/LocalFileSystemProvider.ts
Normal file
63
src/main/services/infrastructure/LocalFileSystemProvider.ts
Normal file
|
|
@ -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<boolean> {
|
||||
try {
|
||||
await fs.promises.access(filePath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
|
||||
return fs.promises.readFile(filePath, encoding);
|
||||
}
|
||||
|
||||
async stat(filePath: string): Promise<FsStatResult> {
|
||||
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<FsDirent[]> {
|
||||
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
|
||||
}
|
||||
}
|
||||
328
src/main/services/infrastructure/SshConnectionManager.ts
Normal file
328
src/main/services/infrastructure/SshConnectionManager.ts
Normal file
|
|
@ -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<void> {
|
||||
// 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<void>((resolve, reject) => {
|
||||
client.on('ready', () => resolve());
|
||||
client.on('error', (err) => reject(err));
|
||||
client.connect(connectConfig);
|
||||
});
|
||||
|
||||
// Open SFTP channel
|
||||
const sftp = await new Promise<ReturnType<Client['sftp']> 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<void>((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<void>((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<ConnectConfig> {
|
||||
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<string> {
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
129
src/main/services/infrastructure/SshFileSystemProvider.ts
Normal file
129
src/main/services/infrastructure/SshFileSystemProvider.ts
Normal file
|
|
@ -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<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
this.sftp.stat(filePath, (err) => {
|
||||
resolve(!err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
|
||||
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<FsStatResult> {
|
||||
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<FsDirent[]> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<ClaudeMdFileInfo> {
|
||||
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<ClaudeMdFileInfo> {
|
||||
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<string[]> {
|
||||
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<ClaudeMdFileInfo> {
|
||||
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<ClaudeMdReadResult> {
|
||||
const files = new Map<string, ClaudeMdFileInfo>();
|
||||
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/<encoded>/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<ClaudeMdFileInfo> {
|
||||
const expandedDirPath = expandTilde(dirPath);
|
||||
const claudeMdPath = path.join(expandedDirPath, 'CLAUDE.md');
|
||||
return readClaudeMdFile(claudeMdPath);
|
||||
return readClaudeMdFile(claudeMdPath, fsProvider);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ParsedMessage[]> {
|
||||
export async function parseJsonlFile(
|
||||
filePath: string,
|
||||
fsProvider: FileSystemProvider = defaultProvider
|
||||
): Promise<ParsedMessage[]> {
|
||||
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<SessionFileMetadata> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
export async function analyzeSessionFileMetadata(
|
||||
filePath: string,
|
||||
fsProvider: FileSystemProvider = defaultProvider
|
||||
): Promise<SessionFileMetadata> {
|
||||
if (!(await fsProvider.exists(filePath))) {
|
||||
return {
|
||||
firstUserMessage: null,
|
||||
messageCount: 0,
|
||||
|
|
@ -309,7 +319,7 @@ export async function analyzeSessionFileMetadata(filePath: string): Promise<Sess
|
|||
};
|
||||
}
|
||||
|
||||
const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity,
|
||||
|
|
|
|||
|
|
@ -3,23 +3,30 @@
|
|||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs';
|
||||
import * as readline from 'readline';
|
||||
|
||||
import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider';
|
||||
import { type ChatHistoryEntry } from '../types';
|
||||
|
||||
import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider';
|
||||
|
||||
const logger = createLogger('Util:metadataExtraction');
|
||||
|
||||
const defaultProvider = new LocalFileSystemProvider();
|
||||
|
||||
/**
|
||||
* Extract CWD (current working directory) from the first entry.
|
||||
* Used to get the actual project path from encoded directory names.
|
||||
*/
|
||||
export async function extractCwd(filePath: string): Promise<string | null> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
export async function extractCwd(
|
||||
filePath: string,
|
||||
fsProvider: FileSystemProvider = defaultProvider
|
||||
): Promise<string | null> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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<SshConnectionStatus> => {
|
||||
return invokeIpcWithResult<SshConnectionStatus>(SSH_CONNECT, config);
|
||||
},
|
||||
disconnect: async (): Promise<SshConnectionStatus> => {
|
||||
return invokeIpcWithResult<SshConnectionStatus>(SSH_DISCONNECT);
|
||||
},
|
||||
getState: async (): Promise<SshConnectionStatus> => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-3 py-1 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-sidebar)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
{connectionState === 'connecting' && (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin text-yellow-400" />
|
||||
<span>Connecting to {connectedHost}...</span>
|
||||
</>
|
||||
)}
|
||||
{connectionState === 'connected' && (
|
||||
<>
|
||||
<Wifi className="size-3 text-green-400" />
|
||||
<span className="text-green-400">SSH: {connectedHost}</span>
|
||||
</>
|
||||
)}
|
||||
{connectionState === 'error' && (
|
||||
<>
|
||||
<div className="size-2 rounded-full bg-red-400" />
|
||||
<span className="text-red-400">SSH Error</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TabbedLayout = (): React.JSX.Element => {
|
||||
// Enable keyboard shortcuts
|
||||
useKeyboardShortcuts();
|
||||
|
|
@ -31,6 +74,7 @@ export const TabbedLayout = (): React.JSX.Element => {
|
|||
}
|
||||
>
|
||||
<UpdateBanner />
|
||||
<SshConnectionIndicator />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Command Palette (Cmd+K) */}
|
||||
<CommandPalette />
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' && <ConnectionSection />}
|
||||
|
||||
{activeSection === 'notifications' && (
|
||||
<NotificationsSection
|
||||
safeConfig={safeConfig}
|
||||
|
|
|
|||
308
src/renderer/components/settings/sections/ConnectionSection.tsx
Normal file
308
src/renderer/components/settings/sections/ConnectionSection.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
/**
|
||||
* ConnectionSection - Settings section for SSH connection management.
|
||||
*
|
||||
* Provides UI for:
|
||||
* - Toggling between local and SSH modes
|
||||
* - Configuring SSH connection (host, port, username, auth)
|
||||
* - Testing and connecting to remote hosts
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
|
||||
|
||||
import { SettingRow } from '../components/SettingRow';
|
||||
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
||||
|
||||
import type { SshAuthMethod, SshConnectionConfig } from '@shared/types';
|
||||
|
||||
export const ConnectionSection = (): React.JSX.Element => {
|
||||
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<SshAuthMethod>('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<void> => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
const result = await testConnection(buildConfig());
|
||||
setTestResult(result);
|
||||
setTesting(false);
|
||||
};
|
||||
|
||||
const handleConnect = async (): Promise<void> => {
|
||||
await connectSsh(buildConfig());
|
||||
};
|
||||
|
||||
const handleDisconnect = async (): Promise<void> => {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<SettingsSectionHeader title="Remote Connection" />
|
||||
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Connect to a remote machine to view Claude Code sessions running there
|
||||
</p>
|
||||
|
||||
{/* Connection Status */}
|
||||
{isConnected && (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-md border px-4 py-3"
|
||||
style={{
|
||||
borderColor: 'rgba(34, 197, 94, 0.3)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.05)',
|
||||
}}
|
||||
>
|
||||
<Wifi className="size-4 text-green-400" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
Connected to {connectedHost}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Viewing remote sessions via SSH
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleDisconnect()}
|
||||
className="rounded-md px-3 py-1.5 text-sm transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectionError && (
|
||||
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-4 py-3">
|
||||
<p className="text-sm text-red-400">{connectionError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mode indicator */}
|
||||
{!isConnected && (
|
||||
<SettingRow label="Current Mode" description="Data source for session files">
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<Monitor className="size-4" />
|
||||
<span>Local (~/.claude/)</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{/* SSH Connection Form */}
|
||||
{!isConnected && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
SSH Connection
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={host}
|
||||
onChange={(e) => 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)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Port
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
placeholder="22"
|
||||
className={inputClass}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="user"
|
||||
className={inputClass}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Authentication
|
||||
</label>
|
||||
<select
|
||||
value={authMethod}
|
||||
onChange={(e) => setAuthMethod(e.target.value as SshAuthMethod)}
|
||||
className="w-full rounded-md border px-3 py-1.5 text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
<option value="agent">SSH Agent</option>
|
||||
<option value="privateKey">Private Key</option>
|
||||
<option value="password">Password</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{authMethod === 'privateKey' && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Private Key Path
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={privateKeyPath}
|
||||
onChange={(e) => setPrivateKeyPath(e.target.value)}
|
||||
placeholder="~/.ssh/id_rsa"
|
||||
className={inputClass}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authMethod === 'password' && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={inputClass}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div
|
||||
className={`rounded-md border px-3 py-2 text-sm ${
|
||||
testResult.success
|
||||
? 'border-green-500/20 bg-green-500/10 text-green-400'
|
||||
: 'border-red-500/20 bg-red-500/10 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{testResult.success
|
||||
? 'Connection successful'
|
||||
: `Connection failed: ${testResult.error}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => void handleTest()}
|
||||
disabled={!host || !username || testing || isConnecting}
|
||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{testing ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Testing...
|
||||
</span>
|
||||
) : (
|
||||
'Test Connection'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => void handleConnect()}
|
||||
disabled={!host || !username || isConnecting}
|
||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Connecting...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<WifiOff className="size-3" />
|
||||
Connect
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,5 +3,6 @@
|
|||
*/
|
||||
|
||||
export { AdvancedSection } from './AdvancedSection';
|
||||
export { ConnectionSection } from './ConnectionSection';
|
||||
export { GeneralSection } from './GeneralSection';
|
||||
export { NotificationsSection } from './NotificationsSection';
|
||||
|
|
|
|||
|
|
@ -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<AppState>()((...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()) {
|
||||
|
|
|
|||
121
src/renderer/store/slices/connectionSlice.ts
Normal file
121
src/renderer/store/slices/connectionSlice.ts
Normal file
|
|
@ -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<void>;
|
||||
disconnectSsh: () => Promise<void>;
|
||||
testConnection: (config: SshConnectionConfig) => Promise<{ success: boolean; error?: string }>;
|
||||
setConnectionStatus: (
|
||||
state: SshConnectionState,
|
||||
host: string | null,
|
||||
error: string | null
|
||||
) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Slice Creator
|
||||
// =============================================================================
|
||||
|
||||
export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSlice> = (
|
||||
set,
|
||||
get
|
||||
) => ({
|
||||
// Initial state
|
||||
connectionMode: 'local',
|
||||
connectionState: 'disconnected',
|
||||
connectedHost: null,
|
||||
connectionError: null,
|
||||
|
||||
// Actions
|
||||
connectSsh: async (config: SshConnectionConfig): Promise<void> => {
|
||||
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<void> => {
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<SshConnectionStatus>;
|
||||
disconnect: () => Promise<SshConnectionStatus>;
|
||||
getState: () => Promise<SshConnectionStatus>;
|
||||
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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue