diff --git a/src/__tests__/main/utils/remote-fs.test.ts b/src/__tests__/main/utils/remote-fs.test.ts new file mode 100644 index 00000000..fee16fbb --- /dev/null +++ b/src/__tests__/main/utils/remote-fs.test.ts @@ -0,0 +1,662 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + readDirRemote, + readFileRemote, + statRemote, + directorySizeRemote, + writeFileRemote, + existsRemote, + mkdirRemote, + type RemoteFsDeps, +} from '../../../main/utils/remote-fs'; +import type { SshRemoteConfig } from '../../../shared/types'; +import type { ExecResult } from '../../../main/utils/execFile'; + +describe('remote-fs', () => { + // Base SSH config for testing + const baseConfig: SshRemoteConfig = { + id: 'test-remote-1', + name: 'Test Remote', + host: 'dev.example.com', + port: 22, + username: 'testuser', + privateKeyPath: '~/.ssh/id_ed25519', + enabled: true, + }; + + // Create mock dependencies + function createMockDeps(execResult: ExecResult): RemoteFsDeps { + return { + execSsh: vi.fn().mockResolvedValue(execResult), + buildSshArgs: vi.fn().mockReturnValue([ + '-i', '/home/user/.ssh/id_ed25519', + '-o', 'BatchMode=yes', + '-p', '22', + 'testuser@dev.example.com', + ]), + }; + } + + describe('readDirRemote', () => { + it('parses ls output correctly for regular files and directories', async () => { + const deps = createMockDeps({ + stdout: 'file1.txt\nfile2.js\nsrc/\nnode_modules/\n', + stderr: '', + exitCode: 0, + }); + + const result = await readDirRemote('/home/user/project', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toEqual([ + { name: 'file1.txt', isDirectory: false, isSymlink: false }, + { name: 'file2.js', isDirectory: false, isSymlink: false }, + { name: 'src', isDirectory: true, isSymlink: false }, + { name: 'node_modules', isDirectory: true, isSymlink: false }, + ]); + }); + + it('identifies symbolic links from ls -F output', async () => { + const deps = createMockDeps({ + stdout: 'link-to-dir@\nlink-to-file@\nregular.txt\n', + stderr: '', + exitCode: 0, + }); + + const result = await readDirRemote('/home/user', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toEqual([ + { name: 'link-to-dir', isDirectory: false, isSymlink: true }, + { name: 'link-to-file', isDirectory: false, isSymlink: true }, + { name: 'regular.txt', isDirectory: false, isSymlink: false }, + ]); + }); + + it('handles hidden files (from -A flag)', async () => { + const deps = createMockDeps({ + stdout: '.gitignore\n.env\npackage.json\nsrc/\n', + stderr: '', + exitCode: 0, + }); + + const result = await readDirRemote('/project', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data?.map(e => e.name)).toContain('.gitignore'); + expect(result.data?.map(e => e.name)).toContain('.env'); + }); + + it('strips executable indicator (*) from files', async () => { + const deps = createMockDeps({ + stdout: 'run.sh*\nscript.py*\ndata.txt\n', + stderr: '', + exitCode: 0, + }); + + const result = await readDirRemote('/scripts', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toEqual([ + { name: 'run.sh', isDirectory: false, isSymlink: false }, + { name: 'script.py', isDirectory: false, isSymlink: false }, + { name: 'data.txt', isDirectory: false, isSymlink: false }, + ]); + }); + + it('returns error when directory does not exist', async () => { + const deps = createMockDeps({ + stdout: '__LS_ERROR__\n', + stderr: '', + exitCode: 0, + }); + + const result = await readDirRemote('/nonexistent', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('not found or not accessible'); + }); + + it('returns error on SSH failure', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: 'Permission denied', + exitCode: 1, + }); + + const result = await readDirRemote('/protected', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Permission denied'); + }); + + it('handles empty directory', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 0, + }); + + const result = await readDirRemote('/empty-dir', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toEqual([]); + }); + + it('builds correct SSH command with escaped path', async () => { + const deps = createMockDeps({ + stdout: 'file.txt\n', + stderr: '', + exitCode: 0, + }); + + await readDirRemote("/path/with spaces/and'quotes", baseConfig, deps); + + expect(deps.execSsh).toHaveBeenCalledWith('ssh', expect.any(Array)); + const call = (deps.execSsh as any).mock.calls[0][1]; + const remoteCommand = call[call.length - 1]; + // Path should be properly escaped in the command + expect(remoteCommand).toContain("'/path/with spaces/and'\\''quotes'"); + }); + }); + + describe('readFileRemote', () => { + it('returns file contents successfully', async () => { + const deps = createMockDeps({ + stdout: '# README\n\nThis is my project.\n', + stderr: '', + exitCode: 0, + }); + + const result = await readFileRemote('/project/README.md', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toBe('# README\n\nThis is my project.\n'); + }); + + it('handles file not found error', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: 'cat: /missing.txt: No such file or directory', + exitCode: 1, + }); + + const result = await readFileRemote('/missing.txt', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('File not found'); + }); + + it('handles permission denied error', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: 'cat: /etc/shadow: Permission denied', + exitCode: 1, + }); + + const result = await readFileRemote('/etc/shadow', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Permission denied'); + }); + + it('handles reading directory error', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: 'cat: /etc/: Is a directory', + exitCode: 1, + }); + + const result = await readFileRemote('/etc/', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('is a directory'); + }); + + it('handles empty file', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 0, + }); + + const result = await readFileRemote('/empty.txt', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toBe(''); + }); + + it('preserves binary-safe content (within UTF-8)', async () => { + const deps = createMockDeps({ + stdout: 'Line 1\nLine 2\r\nLine 3\tTabbed', + stderr: '', + exitCode: 0, + }); + + const result = await readFileRemote('/file.txt', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toBe('Line 1\nLine 2\r\nLine 3\tTabbed'); + }); + }); + + describe('statRemote', () => { + it('parses GNU stat output for regular file', async () => { + const deps = createMockDeps({ + stdout: '1234\nregular file\n1703836800\n', + stderr: '', + exitCode: 0, + }); + + const result = await statRemote('/project/package.json', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ + size: 1234, + isDirectory: false, + mtime: 1703836800000, // Converted to milliseconds + }); + }); + + it('parses GNU stat output for directory', async () => { + const deps = createMockDeps({ + stdout: '4096\ndirectory\n1703836800\n', + stderr: '', + exitCode: 0, + }); + + const result = await statRemote('/project/src', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data?.isDirectory).toBe(true); + }); + + it('parses BSD stat output format', async () => { + // BSD stat -f '%z\n%HT\n%m' format + const deps = createMockDeps({ + stdout: '5678\nRegular File\n1703836800\n', + stderr: '', + exitCode: 0, + }); + + const result = await statRemote('/project/file.txt', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ + size: 5678, + isDirectory: false, + mtime: 1703836800000, + }); + }); + + it('handles file not found', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: "stat: cannot stat '/missing': No such file or directory", + exitCode: 1, + }); + + const result = await statRemote('/missing', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('not found'); + }); + + it('handles permission denied', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: "stat: cannot stat '/protected': Permission denied", + exitCode: 1, + }); + + const result = await statRemote('/protected', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Permission denied'); + }); + + it('handles invalid output format', async () => { + const deps = createMockDeps({ + stdout: 'invalid\n', + stderr: '', + exitCode: 0, + }); + + const result = await statRemote('/file', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid stat output'); + }); + + it('handles non-numeric values in output', async () => { + const deps = createMockDeps({ + stdout: 'notanumber\nregular file\nalsonotanumber\n', + stderr: '', + exitCode: 0, + }); + + const result = await statRemote('/file', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to parse stat output'); + }); + }); + + describe('directorySizeRemote', () => { + it('parses du -sb output (GNU)', async () => { + const deps = createMockDeps({ + stdout: '123456789\t/project\n', + stderr: '', + exitCode: 0, + }); + + const result = await directorySizeRemote('/project', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toBe(123456789); + }); + + it('parses awk-processed du -sk output (BSD fallback)', async () => { + const deps = createMockDeps({ + stdout: '1234567890\n', // Awk output (size * 1024) + stderr: '', + exitCode: 0, + }); + + const result = await directorySizeRemote('/project', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toBe(1234567890); + }); + + it('handles directory not found', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: "du: cannot access '/missing': No such file or directory", + exitCode: 1, + }); + + const result = await directorySizeRemote('/missing', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('not found'); + }); + + it('handles permission denied', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: "du: cannot read directory '/protected': Permission denied", + exitCode: 1, + }); + + const result = await directorySizeRemote('/protected', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Permission denied'); + }); + + it('handles invalid output format', async () => { + const deps = createMockDeps({ + stdout: 'invalid output\n', + stderr: '', + exitCode: 0, + }); + + const result = await directorySizeRemote('/dir', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to parse du output'); + }); + }); + + describe('writeFileRemote', () => { + it('writes content successfully using base64 encoding', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 0, + }); + + const result = await writeFileRemote('/output.txt', 'Hello, World!', baseConfig, deps); + + expect(result.success).toBe(true); + // Verify the SSH command includes base64-encoded content + const call = (deps.execSsh as any).mock.calls[0][1]; + const remoteCommand = call[call.length - 1]; + expect(remoteCommand).toContain('base64 -d'); + }); + + it('handles content with special characters', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 0, + }); + + const content = "Line 1\nLine 2 with 'quotes' and $variables"; + const result = await writeFileRemote('/output.txt', content, baseConfig, deps); + + expect(result.success).toBe(true); + // Verify base64 encoding is used (safe for special chars) + const call = (deps.execSsh as any).mock.calls[0][1]; + const remoteCommand = call[call.length - 1]; + expect(remoteCommand).toContain(Buffer.from(content, 'utf-8').toString('base64')); + }); + + it('handles permission denied on write', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '/etc/test.txt: Permission denied', + exitCode: 1, + }); + + const result = await writeFileRemote('/etc/test.txt', 'test', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Permission denied'); + }); + + it('handles parent directory not found', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '/nonexistent/file.txt: No such file or directory', + exitCode: 1, + }); + + const result = await writeFileRemote('/nonexistent/file.txt', 'test', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Parent directory not found'); + }); + }); + + describe('existsRemote', () => { + it('returns true when path exists', async () => { + const deps = createMockDeps({ + stdout: 'EXISTS\n', + stderr: '', + exitCode: 0, + }); + + const result = await existsRemote('/home/user/file.txt', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toBe(true); + }); + + it('returns false when path does not exist', async () => { + const deps = createMockDeps({ + stdout: 'NOT_EXISTS\n', + stderr: '', + exitCode: 0, + }); + + const result = await existsRemote('/nonexistent', baseConfig, deps); + + expect(result.success).toBe(true); + expect(result.data).toBe(false); + }); + + it('handles SSH error', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: 'Connection refused', + exitCode: 1, + }); + + const result = await existsRemote('/path', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe('mkdirRemote', () => { + it('creates directory successfully', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 0, + }); + + const result = await mkdirRemote('/home/user/newdir', baseConfig, true, deps); + + expect(result.success).toBe(true); + }); + + it('uses -p flag for recursive creation', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 0, + }); + + await mkdirRemote('/home/user/a/b/c', baseConfig, true, deps); + + const call = (deps.execSsh as any).mock.calls[0][1]; + const remoteCommand = call[call.length - 1]; + expect(remoteCommand).toContain('mkdir -p'); + }); + + it('omits -p flag when recursive is false', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 0, + }); + + await mkdirRemote('/home/user/newdir', baseConfig, false, deps); + + const call = (deps.execSsh as any).mock.calls[0][1]; + const remoteCommand = call[call.length - 1]; + expect(remoteCommand).toContain('mkdir '); + expect(remoteCommand).not.toContain('-p'); + }); + + it('handles permission denied', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: "mkdir: cannot create directory '/etc/test': Permission denied", + exitCode: 1, + }); + + const result = await mkdirRemote('/etc/test', baseConfig, true, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('Permission denied'); + }); + + it('handles directory already exists', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: "mkdir: cannot create directory '/home': File exists", + exitCode: 1, + }); + + const result = await mkdirRemote('/home', baseConfig, false, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('already exists'); + }); + }); + + describe('SSH context integration', () => { + it('passes correct SSH remote config to buildSshArgs', async () => { + const customConfig: SshRemoteConfig = { + ...baseConfig, + host: 'custom.host.com', + port: 2222, + username: 'customuser', + }; + + const deps = createMockDeps({ + stdout: 'file.txt\n', + stderr: '', + exitCode: 0, + }); + + await readDirRemote('/path', customConfig, deps); + + expect(deps.buildSshArgs).toHaveBeenCalledWith(customConfig); + }); + + it('handles useSshConfig mode correctly', async () => { + const sshConfigMode: SshRemoteConfig = { + ...baseConfig, + useSshConfig: true, + privateKeyPath: '', + username: '', + }; + + const deps = createMockDeps({ + stdout: 'EXISTS\n', + stderr: '', + exitCode: 0, + }); + + await existsRemote('/test', sshConfigMode, deps); + + expect(deps.buildSshArgs).toHaveBeenCalledWith(sshConfigMode); + }); + }); + + describe('error handling edge cases', () => { + it('handles network timeout', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: 'Connection timed out', + exitCode: 255, + }); + + const result = await readFileRemote('/file.txt', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('timed out'); + }); + + it('handles SSH authentication failure', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: 'Permission denied (publickey)', + exitCode: 255, + }); + + const result = await statRemote('/file', baseConfig, deps); + + expect(result.success).toBe(false); + }); + + it('handles empty response with non-zero exit code', async () => { + const deps = createMockDeps({ + stdout: '', + stderr: '', + exitCode: 1, + }); + + const result = await readFileRemote('/file', baseConfig, deps); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); +}); diff --git a/src/__tests__/renderer/components/AutoRun.test.tsx b/src/__tests__/renderer/components/AutoRun.test.tsx index f46c61a1..c397635b 100644 --- a/src/__tests__/renderer/components/AutoRun.test.tsx +++ b/src/__tests__/renderer/components/AutoRun.test.tsx @@ -360,7 +360,8 @@ describe('AutoRun', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith( '/test/folder', 'test-doc.md', - 'Updated content' + 'Updated content', + undefined // sshRemoteId (undefined for local sessions) ); }); @@ -663,7 +664,8 @@ describe('AutoRun', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith( '/test/folder', 'test-doc.md', - 'new content' + 'new content', + undefined // sshRemoteId (undefined for local sessions) ); expect(onOpenBatchRunner).toHaveBeenCalled(); }); diff --git a/src/__tests__/renderer/components/AutoRunBlurSaveTiming.test.tsx b/src/__tests__/renderer/components/AutoRunBlurSaveTiming.test.tsx index 42a9db5e..e3ab47c2 100644 --- a/src/__tests__/renderer/components/AutoRunBlurSaveTiming.test.tsx +++ b/src/__tests__/renderer/components/AutoRunBlurSaveTiming.test.tsx @@ -216,7 +216,8 @@ describe('AutoRun Save Path Correctness', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith( '/projects/alpha/docs', 'Phase-1.md', - 'Modified content for alpha' + 'Modified content for alpha', + undefined // sshRemoteId (undefined for local sessions) ); }); @@ -240,7 +241,8 @@ describe('AutoRun Save Path Correctness', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith( '/projects/beta/auto-run', 'Tasks.md', - 'Updated tasks list' + 'Updated tasks list', + undefined // sshRemoteId (undefined for local sessions) ); }); @@ -263,7 +265,8 @@ describe('AutoRun Save Path Correctness', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith( '/home/user/gamma-project/docs', 'README.md', - 'Updated readme content' + 'Updated readme content', + undefined // sshRemoteId (undefined for local sessions) ); }); @@ -286,7 +289,8 @@ describe('AutoRun Save Path Correctness', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenLastCalledWith( '/delta/path', 'doc1.md', - 'Version 2' + 'Version 2', + undefined // sshRemoteId (undefined for local sessions) ); // Simulate saved content update (file watcher triggers contentVersion change) @@ -299,7 +303,8 @@ describe('AutoRun Save Path Correctness', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenLastCalledWith( '/delta/path', 'doc1.md', - 'Version 3' + 'Version 3', + undefined // sshRemoteId (undefined for local sessions) ); expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledTimes(2); @@ -609,7 +614,8 @@ describe('AutoRun Save Path Correctness', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith( '/rapid/test', 'rapid-doc.md', - 'Final typed content' + 'Final typed content', + undefined // sshRemoteId (undefined for local sessions) ); }); @@ -630,7 +636,8 @@ describe('AutoRun Save Path Correctness', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith( '/empty/test', 'empty-doc.md', - '' + '', + undefined // sshRemoteId (undefined for local sessions) ); }); @@ -651,7 +658,8 @@ describe('AutoRun Save Path Correctness', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith( '/whitespace/test', 'ws-doc.md', - ' \n\n ' + ' \n\n ', + undefined // sshRemoteId (undefined for local sessions) ); }); @@ -673,7 +681,8 @@ describe('AutoRun Save Path Correctness', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith( '/special/test', 'special-doc.md', - specialContent + specialContent, + undefined // sshRemoteId (undefined for local sessions) ); }); @@ -696,7 +705,8 @@ describe('AutoRun Save Path Correctness', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith( '/long/test', 'long-doc.md', - longContent + longContent, + undefined // sshRemoteId (undefined for local sessions) ); }); }); diff --git a/src/__tests__/renderer/components/AutoRunContentSync.test.tsx b/src/__tests__/renderer/components/AutoRunContentSync.test.tsx index 565153f6..3b6728e4 100644 --- a/src/__tests__/renderer/components/AutoRunContentSync.test.tsx +++ b/src/__tests__/renderer/components/AutoRunContentSync.test.tsx @@ -506,7 +506,8 @@ describe('AutoRun Content Synchronization Race Conditions', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith( '/test/path', 'my-doc.md', - 'New content to save' + 'New content to save', + undefined // sshRemoteId (undefined for local sessions) ); }); diff --git a/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx b/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx index 8605b4f1..d7f7324b 100644 --- a/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx +++ b/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx @@ -411,7 +411,8 @@ describe('AutoRun Session Isolation', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith( '/session-a-folder', 'my-doc.md', - 'New content' + 'New content', + undefined // sshRemoteId (undefined for local sessions) ); // Should NOT be called with any other path @@ -605,7 +606,8 @@ describe('AutoRun Session Isolation', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith( '/folder-a', 'doc-a.md', - 'Modified' + 'Modified', + undefined // sshRemoteId (undefined for local sessions) ); }); }); @@ -667,7 +669,8 @@ describe('AutoRun Folder Path Isolation', () => { expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith( '/unique/session/path', 'unique-doc.md', - 'Changed' + 'Changed', + undefined // sshRemoteId (undefined for local sessions) ); }); }); diff --git a/src/__tests__/renderer/hooks/useBatchProcessor.test.ts b/src/__tests__/renderer/hooks/useBatchProcessor.test.ts index 3ff48bef..fc21a604 100644 --- a/src/__tests__/renderer/hooks/useBatchProcessor.test.ts +++ b/src/__tests__/renderer/hooks/useBatchProcessor.test.ts @@ -1057,7 +1057,8 @@ describe('useBatchProcessor hook', () => { expect(mockWorktreeSetup).toHaveBeenCalledWith( '/test/path', '/test/worktree', - 'feature/test' + 'feature/test', + undefined // sshRemoteId (undefined for local sessions) ); }); @@ -1138,7 +1139,8 @@ describe('useBatchProcessor hook', () => { expect(mockWorktreeCheckout).toHaveBeenCalledWith( '/test/worktree', 'feature/test', - true + true, + undefined // sshRemoteId (undefined for local sessions) ); }); }); @@ -2853,7 +2855,8 @@ describe('useBatchProcessor hook', () => { expect(mockWorktreeCheckout).toHaveBeenCalledWith( '/test/worktree', 'feature-branch', - true + true, + undefined // sshRemoteId (undefined for local sessions) ); // Should have spawned agent with worktree path diff --git a/src/__tests__/renderer/hooks/useFileTreeManagement.test.ts b/src/__tests__/renderer/hooks/useFileTreeManagement.test.ts index 27cf0467..7fefe018 100644 --- a/src/__tests__/renderer/hooks/useFileTreeManagement.test.ts +++ b/src/__tests__/renderer/hooks/useFileTreeManagement.test.ts @@ -138,7 +138,9 @@ describe('useFileTreeManagement', () => { returnedChanges = await result.current.refreshFileTree(state.getSessions()[0].id); }); - expect(loadFileTree).toHaveBeenCalledWith('/test/project'); + // loadFileTree is now called with (path, maxDepth, currentDepth, sshContext) + // For local sessions (no sshRemoteId), sshContext is undefined + expect(loadFileTree).toHaveBeenCalledWith('/test/project', 10, 0, undefined); expect(compareFileTrees).toHaveBeenCalledWith(initialTree, nextTree); expect(returnedChanges).toEqual(changes); expect(state.getSessions()[0].fileTree).toEqual(nextTree); @@ -187,7 +189,8 @@ describe('useFileTreeManagement', () => { await result.current.refreshGitFileState(session.id); }); - expect(loadFileTree).toHaveBeenCalledWith('/test/shell'); + // loadFileTree is now called with (path, maxDepth, currentDepth, sshContext) + expect(loadFileTree).toHaveBeenCalledWith('/test/shell', 10, 0, undefined); expect(gitService.isRepo).toHaveBeenCalledWith('/test/shell'); expect(gitService.getBranches).toHaveBeenCalledWith('/test/shell'); expect(gitService.getTags).toHaveBeenCalledWith('/test/shell'); @@ -243,8 +246,37 @@ describe('useFileTreeManagement', () => { renderHook(() => useFileTreeManagement(deps)); await waitFor(() => { - expect(loadFileTree).toHaveBeenCalledWith('/test/project'); + // loadFileTree is now called with (path, maxDepth, currentDepth, sshContext) + expect(loadFileTree).toHaveBeenCalledWith('/test/project', 10, 0, undefined); expect(state.getSessions()[0].fileTree).toEqual(nextTree); }); }); + + it('passes SSH context when session has sshRemoteId', async () => { + const nextTree: FileNode[] = [{ name: 'remote-file.txt', type: 'file' }]; + const changes = { totalChanges: 0, newFiles: 0, newFolders: 0, removedFiles: 0, removedFolders: 0 }; + + vi.mocked(loadFileTree).mockResolvedValue(nextTree); + vi.mocked(compareFileTrees).mockReturnValue(changes); + + // Create session with SSH context + const sshSession = createMockSession({ + fileTree: [], + sshRemoteId: 'my-ssh-remote', + remoteCwd: '/remote/project', + }); + const state = createSessionsState([sshSession]); + const deps = createDeps(state); + const { result } = renderHook(() => useFileTreeManagement(deps)); + + await act(async () => { + await result.current.refreshFileTree(sshSession.id); + }); + + // Verify SSH context is passed to loadFileTree + expect(loadFileTree).toHaveBeenCalledWith('/test/project', 10, 0, { + sshRemoteId: 'my-ssh-remote', + remoteCwd: '/remote/project', + }); + }); }); diff --git a/src/__tests__/renderer/hooks/useWorktreeValidation.test.ts b/src/__tests__/renderer/hooks/useWorktreeValidation.test.ts index 9e44b8e1..64cf1a13 100644 --- a/src/__tests__/renderer/hooks/useWorktreeValidation.test.ts +++ b/src/__tests__/renderer/hooks/useWorktreeValidation.test.ts @@ -198,7 +198,8 @@ describe('useWorktreeValidation', () => { expect(result.current.validation.branchMismatch).toBe(true); expect(result.current.validation.hasUncommittedChanges).toBe(true); - expect(mockGit.status).toHaveBeenCalledWith('/path/to/worktree'); + // sshRemoteId is undefined when not provided in deps + expect(mockGit.status).toHaveBeenCalledWith('/path/to/worktree', undefined); }); it('does not check uncommitted changes when repos differ', async () => { diff --git a/src/__tests__/renderer/services/git.test.ts b/src/__tests__/renderer/services/git.test.ts index b7f49ed0..aa64efe7 100644 --- a/src/__tests__/renderer/services/git.test.ts +++ b/src/__tests__/renderer/services/git.test.ts @@ -40,7 +40,7 @@ describe('gitService', () => { const result = await gitService.isRepo('/path/to/repo'); expect(result).toBe(true); - expect(mockGit.isRepo).toHaveBeenCalledWith('/path/to/repo'); + expect(mockGit.isRepo).toHaveBeenCalledWith('/path/to/repo', undefined); }); test('returns false when directory is not a git repository', async () => { @@ -49,7 +49,16 @@ describe('gitService', () => { const result = await gitService.isRepo('/path/to/non-repo'); expect(result).toBe(false); - expect(mockGit.isRepo).toHaveBeenCalledWith('/path/to/non-repo'); + expect(mockGit.isRepo).toHaveBeenCalledWith('/path/to/non-repo', undefined); + }); + + test('passes sshRemoteId for remote repository check', async () => { + mockGit.isRepo.mockResolvedValue(true); + + const result = await gitService.isRepo('/remote/path', 'ssh-remote-123'); + + expect(result).toBe(true); + expect(mockGit.isRepo).toHaveBeenCalledWith('/remote/path', 'ssh-remote-123'); }); test('returns false and logs error when IPC call fails', async () => { @@ -167,7 +176,8 @@ UU both-changed-in-merge.ts`; const result = await gitService.getDiff('/path/to/repo'); expect(result.diff).toBe(diffOutput); - expect(mockGit.diff).toHaveBeenCalledWith('/path/to/repo'); + // When no files are specified, sshRemoteId defaults to undefined + expect(mockGit.diff).toHaveBeenCalledWith('/path/to/repo', undefined, undefined); }); test('returns diff for all files when empty array specified', async () => { @@ -177,7 +187,8 @@ UU both-changed-in-merge.ts`; const result = await gitService.getDiff('/path/to/repo', []); expect(result.diff).toBe(diffOutput); - expect(mockGit.diff).toHaveBeenCalledWith('/path/to/repo'); + // sshRemoteId defaults to undefined + expect(mockGit.diff).toHaveBeenCalledWith('/path/to/repo', undefined, undefined); }); test('returns diff for specific files when files array provided', async () => { @@ -190,8 +201,18 @@ UU both-changed-in-merge.ts`; const result = await gitService.getDiff('/path/to/repo', ['file1.ts', 'file2.ts']); expect(result).toEqual({ diff: `${diffOutput1}\n${diffOutput2}` }); - expect(mockGit.diff).toHaveBeenNthCalledWith(1, '/path/to/repo', 'file1.ts'); - expect(mockGit.diff).toHaveBeenNthCalledWith(2, '/path/to/repo', 'file2.ts'); + // sshRemoteId defaults to undefined + expect(mockGit.diff).toHaveBeenNthCalledWith(1, '/path/to/repo', 'file1.ts', undefined); + expect(mockGit.diff).toHaveBeenNthCalledWith(2, '/path/to/repo', 'file2.ts', undefined); + }); + + test('passes sshRemoteId for remote diff', async () => { + mockGit.diff.mockResolvedValue({ stdout: 'remote diff' }); + + const result = await gitService.getDiff('/remote/path', undefined, 'ssh-remote-123'); + + expect(result.diff).toBe('remote diff'); + expect(mockGit.diff).toHaveBeenCalledWith('/remote/path', undefined, 'ssh-remote-123'); }); test('returns empty diff string on error', async () => { @@ -375,7 +396,16 @@ invalid_line`; const result = await gitService.getBranches('/path/to/repo'); expect(result).toEqual(['main', 'develop', 'feature/test']); - expect(mockGit.branches).toHaveBeenCalledWith('/path/to/repo'); + expect(mockGit.branches).toHaveBeenCalledWith('/path/to/repo', undefined); + }); + + test('passes sshRemoteId for remote branches', async () => { + mockGit.branches.mockResolvedValue({ branches: ['main'] }); + + const result = await gitService.getBranches('/remote/path', 'ssh-remote-123'); + + expect(result).toEqual(['main']); + expect(mockGit.branches).toHaveBeenCalledWith('/remote/path', 'ssh-remote-123'); }); test('returns empty array when result.branches is undefined', async () => { @@ -403,7 +433,16 @@ invalid_line`; const result = await gitService.getTags('/path/to/repo'); expect(result).toEqual(['v1.0.0', 'v1.1.0', 'v2.0.0']); - expect(mockGit.tags).toHaveBeenCalledWith('/path/to/repo'); + expect(mockGit.tags).toHaveBeenCalledWith('/path/to/repo', undefined); + }); + + test('passes sshRemoteId for remote tags', async () => { + mockGit.tags.mockResolvedValue({ tags: ['v1.0.0'] }); + + const result = await gitService.getTags('/remote/path', 'ssh-remote-123'); + + expect(result).toEqual(['v1.0.0']); + expect(mockGit.tags).toHaveBeenCalledWith('/remote/path', 'ssh-remote-123'); }); test('returns empty array when result.tags is undefined', async () => { diff --git a/src/__tests__/renderer/utils/fileExplorer.test.ts b/src/__tests__/renderer/utils/fileExplorer.test.ts index 6d4346ba..0bcb7c50 100644 --- a/src/__tests__/renderer/utils/fileExplorer.test.ts +++ b/src/__tests__/renderer/utils/fileExplorer.test.ts @@ -209,7 +209,8 @@ describe('fileExplorer utils', () => { const result = await loadFileTree('/project'); - expect(window.maestro.fs.readDir).toHaveBeenCalledWith('/project'); + // Should pass undefined for sshRemoteId when no SSH context is provided + expect(window.maestro.fs.readDir).toHaveBeenCalledWith('/project', undefined); expect(result).toHaveLength(3); expect(result[0]).toEqual({ name: 'src', @@ -366,6 +367,33 @@ describe('fileExplorer utils', () => { expect(result).toHaveLength(1); expect(result[0].name).toBe('regular.txt'); }); + + it('passes SSH context to readDir for remote file operations', async () => { + vi.mocked(window.maestro.fs.readDir) + .mockResolvedValueOnce([ + { name: 'src', isFile: false, isDirectory: true }, + { name: 'README.md', isFile: true, isDirectory: false }, + ]) + .mockResolvedValue([]); // Empty children for src folder + + const sshContext = { sshRemoteId: 'remote-1', remoteCwd: '/home/user' }; + const result = await loadFileTree('/project', 10, 0, sshContext); + + // Verify SSH remote ID is passed to all readDir calls + expect(window.maestro.fs.readDir).toHaveBeenCalledWith('/project', 'remote-1'); + expect(window.maestro.fs.readDir).toHaveBeenCalledWith('/project/src', 'remote-1'); + expect(result).toHaveLength(2); + }); + + it('passes undefined to readDir when no SSH context is provided', async () => { + vi.mocked(window.maestro.fs.readDir).mockResolvedValueOnce([ + { name: 'file.txt', isFile: true, isDirectory: false }, + ]); + + await loadFileTree('/project'); + + expect(window.maestro.fs.readDir).toHaveBeenCalledWith('/project', undefined); + }); }); // ============================================================================ diff --git a/src/main/index.ts b/src/main/index.ts index 2b8910d8..4aebd209 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -23,6 +23,7 @@ import { initializeOutputParsers, getOutputParser } from './parsers'; import { DEMO_MODE, DEMO_DATA_PATH } from './constants'; import type { SshRemoteConfig } from '../shared/types'; import { initAutoUpdater } from './auto-updater'; +import { readDirRemote, readFileRemote, statRemote, directorySizeRemote } from './utils/remote-fs'; // ============================================================================ // Custom Storage Location Configuration @@ -278,6 +279,15 @@ const agentSessionOriginsStore = new Store({ }, }); +/** + * Get SSH remote configuration by ID. + * Returns undefined if not found. + */ +function getSshRemoteById(sshRemoteId: string): SshRemoteConfig | undefined { + const sshRemotes = store.get('sshRemotes', []) as SshRemoteConfig[]; + return sshRemotes.find((r) => r.id === sshRemoteId); +} + let mainWindow: BrowserWindow | null = null; let processManager: ProcessManager | null = null; let webServer: WebServer | null = null; @@ -994,6 +1004,7 @@ function setupIpcHandlers() { mainWindow, getMainWindow: () => mainWindow, app, + settingsStore: store, }); // Playbook operations - extracted to src/main/ipc/handlers/playbooks.ts @@ -1149,7 +1160,26 @@ function setupIpcHandlers() { return os.homedir(); }); - ipcMain.handle('fs:readDir', async (_, dirPath: string) => { + ipcMain.handle('fs:readDir', async (_, dirPath: string, sshRemoteId?: string) => { + // SSH remote: dispatch to remote fs operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + const result = await readDirRemote(dirPath, sshConfig); + if (!result.success) { + throw new Error(result.error || 'Failed to read remote directory'); + } + // Map remote entries to match local format (isFile derived from !isDirectory && !isSymlink) + return result.data!.map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory, + isFile: !entry.isDirectory && !entry.isSymlink, + })); + } + + // Local: use standard fs operations const entries = await fs.readdir(dirPath, { withFileTypes: true }); // Convert Dirent objects to plain objects for IPC serialization return entries.map((entry: any) => ({ @@ -1159,8 +1189,33 @@ function setupIpcHandlers() { })); }); - ipcMain.handle('fs:readFile', async (_, filePath: string) => { + ipcMain.handle('fs:readFile', async (_, filePath: string, sshRemoteId?: string) => { try { + // SSH remote: dispatch to remote fs operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + const result = await readFileRemote(filePath, sshConfig); + if (!result.success) { + throw new Error(result.error || 'Failed to read remote file'); + } + // For images over SSH, we'd need to base64 encode on remote and decode here + // For now, return raw content (text files work, binary images may have issues) + const ext = filePath.split('.').pop()?.toLowerCase(); + const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']; + const isImage = imageExtensions.includes(ext || ''); + if (isImage) { + // The remote readFile returns raw bytes as string - convert to base64 data URL + const mimeType = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`; + const base64 = Buffer.from(result.data!, 'binary').toString('base64'); + return `data:${mimeType};base64,${base64}`; + } + return result.data!; + } + + // Local: use standard fs operations // Check if file is an image const ext = filePath.split('.').pop()?.toLowerCase(); const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']; @@ -1182,8 +1237,31 @@ function setupIpcHandlers() { } }); - ipcMain.handle('fs:stat', async (_, filePath: string) => { + ipcMain.handle('fs:stat', async (_, filePath: string, sshRemoteId?: string) => { try { + // SSH remote: dispatch to remote fs operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + const result = await statRemote(filePath, sshConfig); + if (!result.success) { + throw new Error(result.error || 'Failed to get remote file stats'); + } + // Map remote stat result to match local format + // Note: remote stat doesn't provide createdAt (birthtime), use mtime as fallback + const mtimeDate = new Date(result.data!.mtime); + return { + size: result.data!.size, + createdAt: mtimeDate.toISOString(), // Fallback: use mtime for createdAt + modifiedAt: mtimeDate.toISOString(), + isDirectory: result.data!.isDirectory, + isFile: !result.data!.isDirectory, + }; + } + + // Local: use standard fs operations const stats = await fs.stat(filePath); return { size: stats.size, @@ -1199,7 +1277,27 @@ function setupIpcHandlers() { // Calculate total size of a directory recursively // Respects the same ignore patterns as loadFileTree (node_modules, __pycache__) - ipcMain.handle('fs:directorySize', async (_, dirPath: string) => { + ipcMain.handle('fs:directorySize', async (_, dirPath: string, sshRemoteId?: string) => { + // SSH remote: dispatch to remote fs operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + const result = await directorySizeRemote(dirPath, sshConfig); + if (!result.success) { + throw new Error(result.error || 'Failed to get remote directory size'); + } + // Remote directorySizeRemote only returns totalSize (via du -sb) + // File/folder counts are not available without recursive listing + return { + totalSize: result.data!, + fileCount: 0, // Not available from remote du command + folderCount: 0, // Not available from remote du command + }; + } + + // Local: use standard fs operations let totalSize = 0; let fileCount = 0; let folderCount = 0; diff --git a/src/main/ipc/handlers/autorun.ts b/src/main/ipc/handlers/autorun.ts index 2c335155..56b6d9e2 100644 --- a/src/main/ipc/handlers/autorun.ts +++ b/src/main/ipc/handlers/autorun.ts @@ -2,8 +2,18 @@ import { ipcMain, BrowserWindow, App } from 'electron'; import fs from 'fs/promises'; import path from 'path'; import chokidar, { FSWatcher } from 'chokidar'; +import Store from 'electron-store'; import { logger } from '../../utils/logger'; import { createIpcHandler, CreateHandlerOptions } from '../../utils/ipcHandler'; +import { SshRemoteConfig } from '../../../shared/types'; +import { MaestroSettings } from './persistence'; +import { + readDirRemote, + readFileRemote, + writeFileRemote, + existsRemote, + mkdirRemote, +} from '../../utils/remote-fs'; const LOG_CONTEXT = '[AutoRun]'; @@ -14,6 +24,31 @@ const handlerOpts = (operation: string, logSuccess = true): CreateHandlerOptions logSuccess, }); +/** + * Dependencies required for Auto Run handler registration. + * Optional for backward compatibility - SSH remote support requires settingsStore. + */ +export interface AutorunHandlerDependencies { + /** The settings store (MaestroSettings) - required for SSH remote lookup */ + settingsStore?: Store; +} + +/** + * Get SSH remote configuration by ID from the settings store. + * Returns undefined if not found or store not provided. + */ +function getSshRemoteById( + store: Store | undefined, + sshRemoteId: string +): SshRemoteConfig | undefined { + if (!store) { + logger.warn(`${LOG_CONTEXT} Settings store not available for SSH remote lookup`, LOG_CONTEXT); + return undefined; + } + const sshRemotes = store.get('sshRemotes', []) as SshRemoteConfig[]; + return sshRemotes.find((r) => r.id === sshRemoteId); +} + // State managed by this module const autoRunWatchers = new Map(); let autoRunWatchDebounceTimer: NodeJS.Timeout | null = null; @@ -79,6 +114,61 @@ async function scanDirectory(dirPath: string, relativePath: string = ''): Promis return nodes; } +/** + * Recursively scan directory for markdown files on a remote host via SSH. + * This is the SSH version of scanDirectory. + */ +async function scanDirectoryRemote( + dirPath: string, + sshRemote: SshRemoteConfig, + relativePath: string = '' +): Promise { + const result = await readDirRemote(dirPath, sshRemote); + if (!result.success || !result.data) { + logger.warn(`${LOG_CONTEXT} Failed to read remote directory: ${result.error}`, LOG_CONTEXT); + return []; + } + + const nodes: TreeNode[] = []; + + // Sort entries: folders first, then files, both alphabetically + const sortedEntries = result.data + .filter((entry) => !entry.name.startsWith('.')) + .sort((a, b) => { + if (a.isDirectory && !b.isDirectory) return -1; + if (!a.isDirectory && b.isDirectory) return 1; + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + for (const entry of sortedEntries) { + const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name; + + if (entry.isDirectory) { + // Recursively scan subdirectory + // Use forward slashes for remote paths (Unix style) + const children = await scanDirectoryRemote(`${dirPath}/${entry.name}`, sshRemote, entryRelativePath); + // Only include folders that contain .md files (directly or in subfolders) + if (children.length > 0) { + nodes.push({ + name: entry.name, + type: 'folder', + path: entryRelativePath, + children, + }); + } + } else if (!entry.isDirectory && !entry.isSymlink && entry.name.toLowerCase().endsWith('.md')) { + // Add .md file (without extension in name, but keep in path) + nodes.push({ + name: entry.name.slice(0, -3), + type: 'file', + path: entryRelativePath.slice(0, -3), // Remove .md from path too + }); + } + } + + return nodes; +} + /** * Flatten tree structure to flat list of paths. * @@ -119,19 +209,37 @@ function validatePathWithinFolder(filePath: string, folderPath: string): boolean * - Image management (save, delete, list) * - Folder watching for external changes * - Folder deletion (wizard "start fresh" feature) + * + * SSH remote support: Handlers accept optional sshRemoteId parameter for remote file operations. */ export function registerAutorunHandlers(deps: { mainWindow: BrowserWindow | null; getMainWindow: () => BrowserWindow | null; app: App; -}): void { - const { getMainWindow, app } = deps; +} & AutorunHandlerDependencies): void { + const { getMainWindow, app, settingsStore } = deps; // List markdown files in a directory for Auto Run (with recursive subfolder support) + // Supports SSH remote execution via optional sshRemoteId parameter ipcMain.handle( 'autorun:listDocs', - createIpcHandler(handlerOpts('listDocs'), async (folderPath: string) => { - // Validate the folder path exists + createIpcHandler(handlerOpts('listDocs'), async (folderPath: string, sshRemoteId?: string) => { + // SSH remote: dispatch to remote operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(settingsStore, sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + logger.debug(`${LOG_CONTEXT} listDocs via SSH: ${folderPath}`, LOG_CONTEXT); + + const tree = await scanDirectoryRemote(folderPath, sshConfig); + const files = flattenTree(tree); + + logger.info(`Listed ${files.length} remote markdown files in ${folderPath} (with subfolders)`, LOG_CONTEXT); + return { files, tree }; + } + + // Local: Validate the folder path exists const folderStat = await fs.stat(folderPath); if (!folderStat.isDirectory()) { throw new Error('Path is not a directory'); @@ -146,9 +254,10 @@ export function registerAutorunHandlers(deps: { ); // Read a markdown document for Auto Run (supports subdirectories) + // Supports SSH remote execution via optional sshRemoteId parameter ipcMain.handle( 'autorun:readDoc', - createIpcHandler(handlerOpts('readDoc'), async (folderPath: string, filename: string) => { + createIpcHandler(handlerOpts('readDoc'), async (folderPath: string, filename: string, sshRemoteId?: string) => { // Reject obvious traversal attempts if (filename.includes('..')) { throw new Error('Invalid filename'); @@ -157,6 +266,27 @@ export function registerAutorunHandlers(deps: { // Ensure filename has .md extension const fullFilename = filename.endsWith('.md') ? filename : `${filename}.md`; + // SSH remote: dispatch to remote operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(settingsStore, sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + + // Construct remote path (use forward slashes) + const remotePath = `${folderPath}/${fullFilename}`; + logger.debug(`${LOG_CONTEXT} readDoc via SSH: ${remotePath}`, LOG_CONTEXT); + + const result = await readFileRemote(remotePath, sshConfig); + if (!result.success || result.data === undefined) { + throw new Error(result.error || 'Failed to read remote file'); + } + + logger.info(`Read remote Auto Run doc: ${fullFilename}`, LOG_CONTEXT); + return { content: result.data }; + } + + // Local: Validate and read const filePath = path.join(folderPath, fullFilename); // Validate the file is within the folder path (prevent traversal) @@ -180,9 +310,10 @@ export function registerAutorunHandlers(deps: { ); // Write a markdown document for Auto Run (supports subdirectories) + // Supports SSH remote execution via optional sshRemoteId parameter ipcMain.handle( 'autorun:writeDoc', - createIpcHandler(handlerOpts('writeDoc'), async (folderPath: string, filename: string, content: string) => { + createIpcHandler(handlerOpts('writeDoc'), async (folderPath: string, filename: string, content: string, sshRemoteId?: string) => { // DEBUG: Log all write attempts to trace cross-session contamination logger.info( `[DEBUG] writeDoc called: folder=${folderPath}, file=${filename}, content.length=${content.length}, content.slice(0,50)="${content.slice(0, 50).replace(/\n/g, '\\n')}"`, @@ -198,6 +329,40 @@ export function registerAutorunHandlers(deps: { // Ensure filename has .md extension const fullFilename = filename.endsWith('.md') ? filename : `${filename}.md`; + // SSH remote: dispatch to remote operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(settingsStore, sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + + // Construct remote path (use forward slashes) + const remotePath = `${folderPath}/${fullFilename}`; + + // Ensure parent directory exists on remote + const remoteParentDir = remotePath.substring(0, remotePath.lastIndexOf('/')); + if (remoteParentDir && remoteParentDir !== folderPath) { + const parentExists = await existsRemote(remoteParentDir, sshConfig); + if (!parentExists.success || !parentExists.data) { + const mkdirResult = await mkdirRemote(remoteParentDir, sshConfig, true); + if (!mkdirResult.success) { + throw new Error(mkdirResult.error || 'Failed to create remote parent directory'); + } + } + } + + logger.debug(`${LOG_CONTEXT} writeDoc via SSH: ${remotePath}`, LOG_CONTEXT); + + const result = await writeFileRemote(remotePath, content, sshConfig); + if (!result.success) { + throw new Error(result.error || 'Failed to write remote file'); + } + + logger.info(`Wrote remote Auto Run doc: ${fullFilename}`, LOG_CONTEXT); + return {}; + } + + // Local: Validate and write const filePath = path.join(folderPath, fullFilename); // Validate the file is within the folder path (prevent traversal) @@ -402,10 +567,35 @@ export function registerAutorunHandlers(deps: { ); // Start watching an Auto Run folder for changes + // Supports SSH remote execution via optional sshRemoteId parameter + // For remote sessions, file watching is not supported (chokidar can't watch remote directories) + // Returns isRemote: true to indicate the UI should poll using listDocs instead ipcMain.handle( 'autorun:watchFolder', - createIpcHandler(handlerOpts('watchFolder'), async (folderPath: string) => { - // Stop any existing watcher for this folder + createIpcHandler(handlerOpts('watchFolder'), async (folderPath: string, sshRemoteId?: string) => { + // SSH remote: Cannot use chokidar for remote directories + // Return success with isRemote flag so UI can fall back to polling + if (sshRemoteId) { + const sshConfig = getSshRemoteById(settingsStore, sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + + // Ensure remote folder exists (create if not) + const folderExists = await existsRemote(folderPath, sshConfig); + if (!folderExists.success || !folderExists.data) { + const mkdirResult = await mkdirRemote(folderPath, sshConfig, true); + if (!mkdirResult.success) { + throw new Error(mkdirResult.error || 'Failed to create remote Auto Run folder'); + } + logger.info(`Created remote Auto Run folder: ${folderPath}`, LOG_CONTEXT); + } + + logger.info(`Remote Auto Run folder ready (polling mode): ${folderPath}`, LOG_CONTEXT); + return { isRemote: true, message: 'File watching not available for remote sessions. Use polling.' }; + } + + // Local: Stop any existing watcher for this folder if (autoRunWatchers.has(folderPath)) { autoRunWatchers.get(folderPath)?.close(); autoRunWatchers.delete(folderPath); diff --git a/src/main/ipc/handlers/git.ts b/src/main/ipc/handlers/git.ts index e511f1d9..9f7488e9 100644 --- a/src/main/ipc/handlers/git.ts +++ b/src/main/ipc/handlers/git.ts @@ -16,6 +16,13 @@ import { getImageMimeType, } from '../../../shared/gitUtils'; import { SshRemoteConfig } from '../../../shared/types'; +import { + worktreeInfoRemote, + worktreeSetupRemote, + worktreeCheckoutRemote, + listWorktreesRemote, + getRepoRootRemote, +} from '../../utils/remote-git'; const LOG_CONTEXT = '[Git]'; @@ -68,7 +75,7 @@ const handlerOpts = (operation: string, logSuccess = false): CreateHandlerOption * - Basic operations: status, diff, branch, remote, tags * - Advanced queries: log, info, commitCount * - File operations: show, showFile - * - Worktree management: worktreeInfo, worktreeSetup, worktreeCheckout + * - Worktree management: worktreeInfo, worktreeSetup, worktreeCheckout (with SSH support) * - GitHub CLI integration: checkGhCli, createPR, getDefaultBranch * * @param deps Dependencies including settingsStore for SSH remote configuration lookup @@ -314,9 +321,25 @@ export function registerGitHandlers(deps: GitHandlerDependencies): void { // Git worktree operations for Auto Run parallelization // Get information about a worktree at a given path + // Supports SSH remote execution via optional sshRemoteId parameter ipcMain.handle('git:worktreeInfo', createIpcHandler( handlerOpts('worktreeInfo'), - async (worktreePath: string) => { + async (worktreePath: string, sshRemoteId?: string) => { + // SSH remote: dispatch to remote git operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + logger.debug(`${LOG_CONTEXT} worktreeInfo via SSH: ${worktreePath}`, LOG_CONTEXT); + const result = await worktreeInfoRemote(worktreePath, sshConfig); + if (!result.success || !result.data) { + throw new Error(result.error || 'Remote worktreeInfo failed'); + } + return result.data; + } + + // Local execution (existing code) // Check if the path exists try { await fs.access(worktreePath); @@ -375,9 +398,25 @@ export function registerGitHandlers(deps: GitHandlerDependencies): void { )); // Get the root directory of the git repository + // Supports SSH remote execution via optional sshRemoteId parameter ipcMain.handle('git:getRepoRoot', createIpcHandler( handlerOpts('getRepoRoot'), - async (cwd: string) => { + async (cwd: string, sshRemoteId?: string) => { + // SSH remote: dispatch to remote git operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + logger.debug(`${LOG_CONTEXT} getRepoRoot via SSH: ${cwd}`, LOG_CONTEXT); + const result = await getRepoRootRemote(cwd, sshConfig); + if (!result.success) { + throw new Error(result.error || 'Not a git repository'); + } + return { root: result.data }; + } + + // Local execution const result = await execFileNoThrow('git', ['rev-parse', '--show-toplevel'], cwd); if (result.exitCode !== 0) { throw new Error(result.stderr || 'Not a git repository'); @@ -387,9 +426,25 @@ export function registerGitHandlers(deps: GitHandlerDependencies): void { )); // Create or reuse a worktree + // Supports SSH remote execution via optional sshRemoteId parameter ipcMain.handle('git:worktreeSetup', withIpcErrorLogging( handlerOpts('worktreeSetup'), - async (mainRepoCwd: string, worktreePath: string, branchName: string) => { + async (mainRepoCwd: string, worktreePath: string, branchName: string, sshRemoteId?: string) => { + // SSH remote: dispatch to remote git operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + logger.debug(`${LOG_CONTEXT} worktreeSetup via SSH: ${JSON.stringify({ mainRepoCwd, worktreePath, branchName })}`, LOG_CONTEXT); + const result = await worktreeSetupRemote(mainRepoCwd, worktreePath, branchName, sshConfig); + if (!result.success) { + throw new Error(result.error || 'Remote worktreeSetup failed'); + } + return result.data; + } + + // Local execution (existing code) logger.debug(`worktreeSetup called with: ${JSON.stringify({ mainRepoCwd, worktreePath, branchName })}`, LOG_CONTEXT); // Resolve paths to absolute for proper comparison @@ -497,9 +552,25 @@ export function registerGitHandlers(deps: GitHandlerDependencies): void { )); // Checkout a branch in a worktree (with uncommitted changes check) + // Supports SSH remote execution via optional sshRemoteId parameter ipcMain.handle('git:worktreeCheckout', withIpcErrorLogging( handlerOpts('worktreeCheckout'), - async (worktreePath: string, branchName: string, createIfMissing: boolean) => { + async (worktreePath: string, branchName: string, createIfMissing: boolean, sshRemoteId?: string) => { + // SSH remote: dispatch to remote git operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + logger.debug(`${LOG_CONTEXT} worktreeCheckout via SSH: ${JSON.stringify({ worktreePath, branchName, createIfMissing })}`, LOG_CONTEXT); + const result = await worktreeCheckoutRemote(worktreePath, branchName, createIfMissing, sshConfig); + if (!result.success) { + throw new Error(result.error || 'Remote worktreeCheckout failed'); + } + return result.data; + } + + // Local execution (existing code) // Check for uncommitted changes const statusResult = await execFileNoThrow('git', ['status', '--porcelain'], worktreePath); if (statusResult.exitCode !== 0) { @@ -646,9 +717,25 @@ export function registerGitHandlers(deps: GitHandlerDependencies): void { )); // List all worktrees for a git repository + // Supports SSH remote execution via optional sshRemoteId parameter ipcMain.handle('git:listWorktrees', createIpcHandler( handlerOpts('listWorktrees'), - async (cwd: string) => { + async (cwd: string, sshRemoteId?: string) => { + // SSH remote: dispatch to remote git operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + logger.debug(`${LOG_CONTEXT} listWorktrees via SSH: ${cwd}`, LOG_CONTEXT); + const result = await listWorktreesRemote(cwd, sshConfig); + if (!result.success) { + throw new Error(result.error || 'Remote listWorktrees failed'); + } + return { worktrees: result.data }; + } + + // Local execution (existing code) // Run git worktree list --porcelain for machine-readable output const result = await execFileNoThrow('git', ['worktree', 'list', '--porcelain'], cwd); if (result.exitCode !== 0) { @@ -781,9 +868,19 @@ export function registerGitHandlers(deps: GitHandlerDependencies): void { )); // Watch a worktree directory for new worktrees + // Note: File watching is not supported for SSH remote sessions. + // Remote sessions will get success: true but isRemote: true flag indicating + // watching is not active. The UI should periodically poll listWorktrees instead. ipcMain.handle('git:watchWorktreeDirectory', createIpcHandler( handlerOpts('watchWorktreeDirectory'), - async (sessionId: string, worktreePath: string) => { + async (sessionId: string, worktreePath: string, sshRemoteId?: string) => { + // SSH remote: file watching is not supported + // Return success with isRemote flag so UI knows to poll instead + if (sshRemoteId) { + logger.debug(`${LOG_CONTEXT} Worktree watching not supported for SSH remote sessions. Session ${sessionId} should poll instead.`, LOG_CONTEXT); + return { success: true, isRemote: true, message: 'File watching not available for remote sessions. Use polling instead.' }; + } + // Stop existing watcher if any const existingWatcher = worktreeWatchers.get(sessionId); if (existingWatcher) { diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index 77987afc..bb147247 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -9,7 +9,7 @@ import { BrowserWindow, App } from 'electron'; import Store from 'electron-store'; -import { registerGitHandlers } from './git'; +import { registerGitHandlers, GitHandlerDependencies } from './git'; import { registerAutorunHandlers } from './autorun'; import { registerPlaybooksHandlers } from './playbooks'; import { registerHistoryHandlers } from './history'; @@ -69,6 +69,7 @@ export type { ContextHandlerDependencies }; export type { StatsHandlerDependencies }; export type { DocumentGraphHandlerDependencies }; export type { SshRemoteHandlerDependencies }; +export type { GitHandlerDependencies }; export type { MaestroSettings, SessionsData, GroupsData }; /** diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts index 7c1fa45d..bb3f85f3 100644 --- a/src/main/ipc/handlers/process.ts +++ b/src/main/ipc/handlers/process.ts @@ -300,6 +300,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void id: sshRemoteUsed.id, name: sshRemoteUsed.name, host: sshRemoteUsed.host, + remoteWorkingDir: sshRemoteUsed.remoteWorkingDir, } : null; mainWindow.webContents.send('process:ssh-remote', config.sessionId, sshRemoteInfo); } @@ -311,6 +312,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void id: sshRemoteUsed.id, name: sshRemoteUsed.name, host: sshRemoteUsed.host, + remoteWorkingDir: sshRemoteUsed.remoteWorkingDir, } : undefined, }; }) diff --git a/src/main/preload.ts b/src/main/preload.ts index 1be14c85..10c8d7c5 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -149,8 +149,9 @@ contextBridge.exposeInMainWorld('maestro', { }, // SSH remote execution status // Emitted when a process starts executing via SSH on a remote host - onSshRemote: (callback: (sessionId: string, sshRemote: { id: string; name: string; host: string } | null) => void) => { - const handler = (_: any, sessionId: string, sshRemote: { id: string; name: string; host: string } | null) => callback(sessionId, sshRemote); + // Includes remoteWorkingDir for session-wide SSH context (file explorer, git, auto run, etc.) + onSshRemote: (callback: (sessionId: string, sshRemote: { id: string; name: string; host: string; remoteWorkingDir?: string } | null) => void) => { + const handler = (_: any, sessionId: string, sshRemote: { id: string; name: string; host: string; remoteWorkingDir?: string } | null) => callback(sessionId, sshRemote); ipcRenderer.on('process:ssh-remote', handler); return () => ipcRenderer.removeListener('process:ssh-remote', handler); }, @@ -384,8 +385,9 @@ contextBridge.exposeInMainWorld('maestro', { showFile: (cwd: string, ref: string, filePath: string) => ipcRenderer.invoke('git:showFile', cwd, ref, filePath) as Promise<{ content?: string; error?: string }>, // Git worktree operations for Auto Run parallelization - worktreeInfo: (worktreePath: string) => - ipcRenderer.invoke('git:worktreeInfo', worktreePath) as Promise<{ + // All worktree operations support SSH remote execution via optional sshRemoteId parameter + worktreeInfo: (worktreePath: string, sshRemoteId?: string) => + ipcRenderer.invoke('git:worktreeInfo', worktreePath, sshRemoteId) as Promise<{ success: boolean; exists?: boolean; isWorktree?: boolean; @@ -393,14 +395,14 @@ contextBridge.exposeInMainWorld('maestro', { repoRoot?: string; error?: string; }>, - getRepoRoot: (cwd: string) => - ipcRenderer.invoke('git:getRepoRoot', cwd) as Promise<{ + getRepoRoot: (cwd: string, sshRemoteId?: string) => + ipcRenderer.invoke('git:getRepoRoot', cwd, sshRemoteId) as Promise<{ success: boolean; root?: string; error?: string; }>, - worktreeSetup: (mainRepoCwd: string, worktreePath: string, branchName: string) => - ipcRenderer.invoke('git:worktreeSetup', mainRepoCwd, worktreePath, branchName) as Promise<{ + worktreeSetup: (mainRepoCwd: string, worktreePath: string, branchName: string, sshRemoteId?: string) => + ipcRenderer.invoke('git:worktreeSetup', mainRepoCwd, worktreePath, branchName, sshRemoteId) as Promise<{ success: boolean; created?: boolean; currentBranch?: string; @@ -408,8 +410,8 @@ contextBridge.exposeInMainWorld('maestro', { branchMismatch?: boolean; error?: string; }>, - worktreeCheckout: (worktreePath: string, branchName: string, createIfMissing: boolean) => - ipcRenderer.invoke('git:worktreeCheckout', worktreePath, branchName, createIfMissing) as Promise<{ + worktreeCheckout: (worktreePath: string, branchName: string, createIfMissing: boolean, sshRemoteId?: string) => + ipcRenderer.invoke('git:worktreeCheckout', worktreePath, branchName, createIfMissing, sshRemoteId) as Promise<{ success: boolean; hasUncommittedChanges: boolean; error?: string; @@ -439,8 +441,9 @@ contextBridge.exposeInMainWorld('maestro', { error?: string; }>, // List all worktrees for a git repository - listWorktrees: (cwd: string) => - ipcRenderer.invoke('git:listWorktrees', cwd) as Promise<{ + // Supports SSH remote execution via optional sshRemoteId parameter + listWorktrees: (cwd: string, sshRemoteId?: string) => + ipcRenderer.invoke('git:listWorktrees', cwd, sshRemoteId) as Promise<{ worktrees: Array<{ path: string; head: string; @@ -460,10 +463,14 @@ contextBridge.exposeInMainWorld('maestro', { }>; }>, // Watch a worktree directory for new worktrees - watchWorktreeDirectory: (sessionId: string, worktreePath: string) => - ipcRenderer.invoke('git:watchWorktreeDirectory', sessionId, worktreePath) as Promise<{ + // Note: File watching is not available for SSH remote sessions. + // For remote sessions, returns isRemote: true indicating polling should be used instead. + watchWorktreeDirectory: (sessionId: string, worktreePath: string, sshRemoteId?: string) => + ipcRenderer.invoke('git:watchWorktreeDirectory', sessionId, worktreePath, sshRemoteId) as Promise<{ success: boolean; error?: string; + isRemote?: boolean; + message?: string; }>, // Stop watching a worktree directory unwatchWorktreeDirectory: (sessionId: string) => @@ -488,13 +495,16 @@ contextBridge.exposeInMainWorld('maestro', { // File System API fs: { homeDir: () => ipcRenderer.invoke('fs:homeDir') as Promise, - readDir: (dirPath: string) => ipcRenderer.invoke('fs:readDir', dirPath), - readFile: (filePath: string) => ipcRenderer.invoke('fs:readFile', filePath), + readDir: (dirPath: string, sshRemoteId?: string) => + ipcRenderer.invoke('fs:readDir', dirPath, sshRemoteId), + readFile: (filePath: string, sshRemoteId?: string) => + ipcRenderer.invoke('fs:readFile', filePath, sshRemoteId), writeFile: (filePath: string, content: string) => ipcRenderer.invoke('fs:writeFile', filePath, content) as Promise<{ success: boolean }>, - stat: (filePath: string) => ipcRenderer.invoke('fs:stat', filePath), - directorySize: (dirPath: string) => - ipcRenderer.invoke('fs:directorySize', dirPath) as Promise<{ + stat: (filePath: string, sshRemoteId?: string) => + ipcRenderer.invoke('fs:stat', filePath, sshRemoteId), + directorySize: (dirPath: string, sshRemoteId?: string) => + ipcRenderer.invoke('fs:directorySize', dirPath, sshRemoteId) as Promise<{ totalSize: number; fileCount: number; folderCount: number; @@ -1165,13 +1175,14 @@ contextBridge.exposeInMainWorld('maestro', { }, // Auto Run API (file-system-based document runner) + // SSH remote support: Core operations accept optional sshRemoteId for remote file operations autorun: { - listDocs: (folderPath: string) => - ipcRenderer.invoke('autorun:listDocs', folderPath), - readDoc: (folderPath: string, filename: string) => - ipcRenderer.invoke('autorun:readDoc', folderPath, filename), - writeDoc: (folderPath: string, filename: string, content: string) => - ipcRenderer.invoke('autorun:writeDoc', folderPath, filename, content), + listDocs: (folderPath: string, sshRemoteId?: string) => + ipcRenderer.invoke('autorun:listDocs', folderPath, sshRemoteId), + readDoc: (folderPath: string, filename: string, sshRemoteId?: string) => + ipcRenderer.invoke('autorun:readDoc', folderPath, filename, sshRemoteId), + writeDoc: (folderPath: string, filename: string, content: string, sshRemoteId?: string) => + ipcRenderer.invoke('autorun:writeDoc', folderPath, filename, content, sshRemoteId), saveImage: ( folderPath: string, docName: string, @@ -1192,8 +1203,9 @@ contextBridge.exposeInMainWorld('maestro', { deleteFolder: (projectPath: string) => ipcRenderer.invoke('autorun:deleteFolder', projectPath), // File watching for live updates - watchFolder: (folderPath: string) => - ipcRenderer.invoke('autorun:watchFolder', folderPath), + // For remote sessions (sshRemoteId provided), returns isRemote: true indicating polling should be used + watchFolder: (folderPath: string, sshRemoteId?: string): Promise<{ isRemote?: boolean; message?: string }> => + ipcRenderer.invoke('autorun:watchFolder', folderPath, sshRemoteId), unwatchFolder: (folderPath: string) => ipcRenderer.invoke('autorun:unwatchFolder', folderPath), onFileChanged: (handler: (data: { folderPath: string; filename: string; eventType: string }) => void) => { @@ -1708,7 +1720,7 @@ export interface MaestroAPI { onSlashCommands: (callback: (sessionId: string, slashCommands: string[]) => void) => () => void; onThinkingChunk: (callback: (sessionId: string, content: string) => void) => () => void; onToolExecution: (callback: (sessionId: string, toolEvent: { toolName: string; state?: unknown; timestamp: number }) => void) => () => void; - onSshRemote: (callback: (sessionId: string, sshRemote: { id: string; name: string; host: string } | null) => void) => () => void; + onSshRemote: (callback: (sessionId: string, sshRemote: { id: string; name: string; host: string; remoteWorkingDir?: string } | null) => void) => () => void; onRemoteCommand: (callback: (sessionId: string, command: string) => void) => () => void; onRemoteSwitchMode: (callback: (sessionId: string, mode: 'ai' | 'terminal') => void) => () => void; onRemoteInterrupt: (callback: (sessionId: string) => void) => () => void; @@ -1839,17 +1851,17 @@ export interface MaestroAPI { }; fs: { homeDir: () => Promise; - readDir: (dirPath: string) => Promise; - readFile: (filePath: string) => Promise; + readDir: (dirPath: string, sshRemoteId?: string) => Promise; + readFile: (filePath: string, sshRemoteId?: string) => Promise; writeFile: (filePath: string, content: string) => Promise<{ success: boolean }>; - stat: (filePath: string) => Promise<{ + stat: (filePath: string, sshRemoteId?: string) => Promise<{ size: number; createdAt: string; modifiedAt: string; isDirectory: boolean; isFile: boolean; }>; - directorySize: (dirPath: string) => Promise<{ + directorySize: (dirPath: string, sshRemoteId?: string) => Promise<{ totalSize: number; fileCount: number; folderCount: number; diff --git a/src/main/utils/remote-fs.ts b/src/main/utils/remote-fs.ts new file mode 100644 index 00000000..3460d871 --- /dev/null +++ b/src/main/utils/remote-fs.ts @@ -0,0 +1,483 @@ +/** + * Remote File System utilities for SSH remote execution. + * + * Provides functions to perform file system operations on remote hosts via SSH. + * These utilities enable File Explorer, Auto Run, and other features to work + * when a session is running on a remote host. + * + * All functions accept a SshRemoteConfig and execute the corresponding + * Unix commands (ls, cat, stat, du) via SSH, parsing their output. + */ + +import { SshRemoteConfig } from '../../shared/types'; +import { execFileNoThrow, ExecResult } from './execFile'; +import { shellEscape } from './shell-escape'; +import { sshRemoteManager } from '../ssh-remote-manager'; + +/** + * File or directory entry returned from readDir operations. + */ +export interface RemoteDirEntry { + /** File or directory name */ + name: string; + /** Whether this entry is a directory */ + isDirectory: boolean; + /** Whether this entry is a symbolic link */ + isSymlink: boolean; +} + +/** + * File stat information returned from stat operations. + */ +export interface RemoteStatResult { + /** File size in bytes */ + size: number; + /** Whether this is a directory */ + isDirectory: boolean; + /** Modification time as Unix timestamp (milliseconds) */ + mtime: number; +} + +/** + * Result wrapper for remote fs operations. + * Includes success/failure status and optional error message. + */ +export interface RemoteFsResult { + /** Whether the operation succeeded */ + success: boolean; + /** The result data (if success is true) */ + data?: T; + /** Error message (if success is false) */ + error?: string; +} + +/** + * Dependencies that can be injected for testing. + */ +export interface RemoteFsDeps { + /** Function to execute SSH commands */ + execSsh: (command: string, args: string[]) => Promise; + /** Function to build SSH args from config */ + buildSshArgs: (config: SshRemoteConfig) => string[]; +} + +/** + * Default dependencies using real implementations. + */ +const defaultDeps: RemoteFsDeps = { + execSsh: (command: string, args: string[]): Promise => { + return execFileNoThrow(command, args); + }, + buildSshArgs: (config: SshRemoteConfig): string[] => { + return sshRemoteManager.buildSshArgs(config); + }, +}; + +/** + * Execute a command on a remote host via SSH. + * + * @param config SSH remote configuration + * @param remoteCommand The shell command to execute on the remote + * @param deps Optional dependencies for testing + * @returns ExecResult with stdout, stderr, and exitCode + */ +async function execRemoteCommand( + config: SshRemoteConfig, + remoteCommand: string, + deps: RemoteFsDeps = defaultDeps +): Promise { + const sshArgs = deps.buildSshArgs(config); + sshArgs.push(remoteCommand); + return deps.execSsh('ssh', sshArgs); +} + +/** + * Read directory contents from a remote host via SSH. + * + * Executes `ls -la` on the remote and parses the output to extract + * file names, types (directory, file, symlink), and other metadata. + * + * @param dirPath Path to the directory on the remote host + * @param sshRemote SSH remote configuration + * @param deps Optional dependencies for testing + * @returns Array of directory entries + * + * @example + * const entries = await readDirRemote('/home/user/project', sshConfig); + * // => [{ name: 'src', isDirectory: true, isSymlink: false }, ...] + */ +export async function readDirRemote( + dirPath: string, + sshRemote: SshRemoteConfig, + deps: RemoteFsDeps = defaultDeps +): Promise> { + // Use ls with specific options: + // -1: One entry per line + // -A: Show hidden files except . and .. + // -F: Append indicator (/ for dirs, @ for symlinks, * for executables) + // --color=never: Disable color codes in output + // We avoid -l because parsing long format is complex and locale-dependent + const escapedPath = shellEscape(dirPath); + const remoteCommand = `ls -1AF --color=never ${escapedPath} 2>/dev/null || echo "__LS_ERROR__"`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + if (result.exitCode !== 0 && !result.stdout.includes('__LS_ERROR__')) { + return { + success: false, + error: result.stderr || `ls failed with exit code ${result.exitCode}`, + }; + } + + // Check for our error marker + if (result.stdout.trim() === '__LS_ERROR__') { + return { + success: false, + error: `Directory not found or not accessible: ${dirPath}`, + }; + } + + const entries: RemoteDirEntry[] = []; + const lines = result.stdout.trim().split('\n').filter(Boolean); + + for (const line of lines) { + if (!line || line === '__LS_ERROR__') continue; + + let name = line; + let isDirectory = false; + let isSymlink = false; + + // Parse the indicator suffix from -F flag + if (name.endsWith('/')) { + name = name.slice(0, -1); + isDirectory = true; + } else if (name.endsWith('@')) { + name = name.slice(0, -1); + isSymlink = true; + } else if (name.endsWith('*')) { + // Executable file - remove the indicator + name = name.slice(0, -1); + } else if (name.endsWith('|')) { + // Named pipe - remove the indicator + name = name.slice(0, -1); + } else if (name.endsWith('=')) { + // Socket - remove the indicator + name = name.slice(0, -1); + } + + // Skip empty names (shouldn't happen, but be safe) + if (!name) continue; + + entries.push({ name, isDirectory, isSymlink }); + } + + return { + success: true, + data: entries, + }; +} + +/** + * Read file contents from a remote host via SSH. + * + * Executes `cat` on the remote to read the file contents. + * For binary files or very large files, consider using different approaches. + * + * @param filePath Path to the file on the remote host + * @param sshRemote SSH remote configuration + * @param deps Optional dependencies for testing + * @returns File contents as a string + * + * @example + * const content = await readFileRemote('/home/user/project/README.md', sshConfig); + * // => '# My Project\n...' + */ +export async function readFileRemote( + filePath: string, + sshRemote: SshRemoteConfig, + deps: RemoteFsDeps = defaultDeps +): Promise> { + const escapedPath = shellEscape(filePath); + // Use cat with explicit error handling + const remoteCommand = `cat ${escapedPath}`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + if (result.exitCode !== 0) { + const error = result.stderr || `Failed to read file: ${filePath}`; + return { + success: false, + error: error.includes('No such file') + ? `File not found: ${filePath}` + : error.includes('Is a directory') + ? `Path is a directory: ${filePath}` + : error.includes('Permission denied') + ? `Permission denied: ${filePath}` + : error, + }; + } + + return { + success: true, + data: result.stdout, + }; +} + +/** + * Get file/directory stat information from a remote host via SSH. + * + * Executes `stat` on the remote with a specific format string to get + * size, type, and modification time. + * + * @param filePath Path to the file or directory on the remote host + * @param sshRemote SSH remote configuration + * @param deps Optional dependencies for testing + * @returns Stat information (size, isDirectory, mtime) + * + * @example + * const stats = await statRemote('/home/user/project/package.json', sshConfig); + * // => { size: 1234, isDirectory: false, mtime: 1703836800000 } + */ +export async function statRemote( + filePath: string, + sshRemote: SshRemoteConfig, + deps: RemoteFsDeps = defaultDeps +): Promise> { + const escapedPath = shellEscape(filePath); + // Use stat with format string: + // %s = size in bytes + // %F = file type (regular file, directory, symbolic link, etc.) + // %Y = modification time as Unix timestamp (seconds) + // Note: GNU stat vs BSD stat have different format specifiers + // We try GNU format first (Linux), then BSD format (macOS) + const remoteCommand = `stat --printf='%s\\n%F\\n%Y' ${escapedPath} 2>/dev/null || stat -f '%z\\n%HT\\n%m' ${escapedPath}`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + if (result.exitCode !== 0) { + const error = result.stderr || `Failed to stat: ${filePath}`; + return { + success: false, + error: error.includes('No such file') + ? `Path not found: ${filePath}` + : error.includes('Permission denied') + ? `Permission denied: ${filePath}` + : error, + }; + } + + const lines = result.stdout.trim().split('\n'); + if (lines.length < 3) { + return { + success: false, + error: `Invalid stat output for: ${filePath}`, + }; + } + + const size = parseInt(lines[0], 10); + const fileType = lines[1].toLowerCase(); + const mtimeSeconds = parseInt(lines[2], 10); + + if (isNaN(size) || isNaN(mtimeSeconds)) { + return { + success: false, + error: `Failed to parse stat output for: ${filePath}`, + }; + } + + // Determine if it's a directory from the file type string + // GNU stat returns: "regular file", "directory", "symbolic link" + // BSD stat returns: "Regular File", "Directory", "Symbolic Link" + const isDirectory = fileType.includes('directory'); + + return { + success: true, + data: { + size, + isDirectory, + mtime: mtimeSeconds * 1000, // Convert to milliseconds + }, + }; +} + +/** + * Get total size of a directory from a remote host via SSH. + * + * Executes `du -sb` on the remote to calculate the total size + * of all files in the directory. + * + * @param dirPath Path to the directory on the remote host + * @param sshRemote SSH remote configuration + * @param deps Optional dependencies for testing + * @returns Total size in bytes + * + * @example + * const size = await directorySizeRemote('/home/user/project', sshConfig); + * // => 1234567890 + */ +export async function directorySizeRemote( + dirPath: string, + sshRemote: SshRemoteConfig, + deps: RemoteFsDeps = defaultDeps +): Promise> { + const escapedPath = shellEscape(dirPath); + // Use du with: + // -s: summarize (total only) + // -b: apparent size in bytes (GNU) + // If -b not available (BSD), use -k and multiply by 1024 + const remoteCommand = `du -sb ${escapedPath} 2>/dev/null || du -sk ${escapedPath} 2>/dev/null | awk '{print $1 * 1024}'`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + if (result.exitCode !== 0) { + const error = result.stderr || `Failed to get directory size: ${dirPath}`; + return { + success: false, + error: error.includes('No such file') + ? `Directory not found: ${dirPath}` + : error.includes('Permission denied') + ? `Permission denied: ${dirPath}` + : error, + }; + } + + // Parse the size from the output (first field) + const output = result.stdout.trim(); + const match = output.match(/^(\d+)/); + + if (!match) { + return { + success: false, + error: `Failed to parse du output for: ${dirPath}`, + }; + } + + const size = parseInt(match[1], 10); + + if (isNaN(size)) { + return { + success: false, + error: `Invalid size value for: ${dirPath}`, + }; + } + + return { + success: true, + data: size, + }; +} + +/** + * Write file contents to a remote host via SSH. + * + * Uses cat with a heredoc to safely write content to a file on the remote. + * This is safe for text content but not recommended for binary files. + * + * @param filePath Path to the file on the remote host + * @param content Content to write + * @param sshRemote SSH remote configuration + * @param deps Optional dependencies for testing + * @returns Success/failure result + * + * @example + * const result = await writeFileRemote('/home/user/project/output.txt', 'Hello!', sshConfig); + * // => { success: true } + */ +export async function writeFileRemote( + filePath: string, + content: string, + sshRemote: SshRemoteConfig, + deps: RemoteFsDeps = defaultDeps +): Promise> { + const escapedPath = shellEscape(filePath); + + // Use base64 encoding to safely transfer the content + // This avoids issues with special characters, quotes, and newlines + const base64Content = Buffer.from(content, 'utf-8').toString('base64'); + + // Decode base64 on remote and write to file + const remoteCommand = `echo '${base64Content}' | base64 -d > ${escapedPath}`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + if (result.exitCode !== 0) { + const error = result.stderr || `Failed to write file: ${filePath}`; + return { + success: false, + error: error.includes('Permission denied') + ? `Permission denied: ${filePath}` + : error.includes('No such file') + ? `Parent directory not found: ${filePath}` + : error, + }; + } + + return { success: true }; +} + +/** + * Check if a path exists on a remote host via SSH. + * + * @param remotePath Path to check + * @param sshRemote SSH remote configuration + * @param deps Optional dependencies for testing + * @returns Whether the path exists + */ +export async function existsRemote( + remotePath: string, + sshRemote: SshRemoteConfig, + deps: RemoteFsDeps = defaultDeps +): Promise> { + const escapedPath = shellEscape(remotePath); + const remoteCommand = `test -e ${escapedPath} && echo "EXISTS" || echo "NOT_EXISTS"`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + if (result.exitCode !== 0) { + return { + success: false, + error: result.stderr || 'Failed to check path existence', + }; + } + + return { + success: true, + data: result.stdout.trim() === 'EXISTS', + }; +} + +/** + * Create a directory on a remote host via SSH. + * + * @param dirPath Directory path to create + * @param sshRemote SSH remote configuration + * @param recursive Whether to create parent directories (mkdir -p) + * @param deps Optional dependencies for testing + * @returns Success/failure result + */ +export async function mkdirRemote( + dirPath: string, + sshRemote: SshRemoteConfig, + recursive: boolean = true, + deps: RemoteFsDeps = defaultDeps +): Promise> { + const escapedPath = shellEscape(dirPath); + const mkdirFlag = recursive ? '-p' : ''; + const remoteCommand = `mkdir ${mkdirFlag} ${escapedPath}`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + if (result.exitCode !== 0) { + const error = result.stderr || `Failed to create directory: ${dirPath}`; + return { + success: false, + error: error.includes('Permission denied') + ? `Permission denied: ${dirPath}` + : error.includes('File exists') + ? `Directory already exists: ${dirPath}` + : error, + }; + } + + return { success: true }; +} diff --git a/src/main/utils/remote-git.ts b/src/main/utils/remote-git.ts index bed3a8d7..f42ad771 100644 --- a/src/main/utils/remote-git.ts +++ b/src/main/utils/remote-git.ts @@ -3,6 +3,9 @@ * * Provides functionality to execute git commands on remote hosts via SSH * when a session is configured for remote execution. + * + * These utilities enable worktree management and other git operations + * when a session is running on a remote host. */ import { SshRemoteConfig } from '../../shared/types'; @@ -22,6 +25,19 @@ export interface RemoteGitOptions { remoteCwd?: string; } +/** + * Result wrapper for remote git operations. + * Includes success/failure status and optional error message. + */ +export interface RemoteGitResult { + /** Whether the operation succeeded */ + success: boolean; + /** The result data (if success is true) */ + data?: T; + /** Error message (if success is false) */ + error?: string; +} + /** * Execute a git command on a remote host via SSH. * @@ -99,3 +115,551 @@ export async function execGit( // Local execution return execFileNoThrow('git', args, localCwd); } + +/** + * Execute a shell command on a remote host via SSH. + * + * @param shellCommand The shell command to execute on the remote + * @param sshRemote SSH remote configuration + * @returns Execution result + */ +async function execRemoteShellCommand( + shellCommand: string, + sshRemote: SshRemoteConfig +): Promise { + const remoteOptions: RemoteCommandOptions = { + command: 'sh', + args: ['-c', shellCommand], + env: sshRemote.remoteEnv, + }; + + const sshCommand = buildSshCommand(sshRemote, remoteOptions); + return execFileNoThrow(sshCommand.command, sshCommand.args); +} + +/** + * Worktree info result from remote host. + */ +export interface RemoteWorktreeInfo extends Record { + exists: boolean; + isWorktree: boolean; + currentBranch?: string; + repoRoot?: string; +} + +/** + * Get information about a worktree at a given path on a remote host. + * + * @param worktreePath Path to the worktree on the remote host + * @param sshRemote SSH remote configuration + * @returns Worktree information + */ +export async function worktreeInfoRemote( + worktreePath: string, + sshRemote: SshRemoteConfig +): Promise> { + // Check if path exists + const existsResult = await execRemoteShellCommand( + `test -d '${worktreePath}' && echo "EXISTS" || echo "NOT_EXISTS"`, + sshRemote + ); + + if (existsResult.exitCode !== 0) { + return { + success: false, + error: existsResult.stderr || 'Failed to check path existence', + }; + } + + if (existsResult.stdout.trim() === 'NOT_EXISTS') { + return { + success: true, + data: { exists: false, isWorktree: false }, + }; + } + + // Check if it's a git directory + const isInsideWorkTree = await execGitRemote( + ['rev-parse', '--is-inside-work-tree'], + { sshRemote, remoteCwd: worktreePath } + ); + + if (isInsideWorkTree.exitCode !== 0) { + return { + success: true, + data: { exists: true, isWorktree: false }, + }; + } + + // Get git-dir and git-common-dir to determine if it's a worktree + const gitDirResult = await execGitRemote( + ['rev-parse', '--git-dir'], + { sshRemote, remoteCwd: worktreePath } + ); + + if (gitDirResult.exitCode !== 0) { + return { + success: false, + error: 'Failed to get git directory', + }; + } + + const gitDir = gitDirResult.stdout.trim(); + + const gitCommonDirResult = await execGitRemote( + ['rev-parse', '--git-common-dir'], + { sshRemote, remoteCwd: worktreePath } + ); + + const gitCommonDir = gitCommonDirResult.exitCode === 0 + ? gitCommonDirResult.stdout.trim() + : gitDir; + + // If git-dir and git-common-dir are different, this is a worktree + const isWorktree = gitDir !== gitCommonDir; + + // Get current branch + const branchResult = await execGitRemote( + ['rev-parse', '--abbrev-ref', 'HEAD'], + { sshRemote, remoteCwd: worktreePath } + ); + + const currentBranch = branchResult.exitCode === 0 + ? branchResult.stdout.trim() + : undefined; + + // Get repository root + let repoRoot: string | undefined; + + if (isWorktree && gitCommonDir) { + // For worktrees, find main repo root from common dir + // Use dirname on the remote to get parent of .git folder + const repoRootResult = await execRemoteShellCommand( + `cd '${worktreePath}' && dirname $(cd '${gitCommonDir}' && pwd)`, + sshRemote + ); + + if (repoRootResult.exitCode === 0) { + repoRoot = repoRootResult.stdout.trim(); + } + } else { + const repoRootResult = await execGitRemote( + ['rev-parse', '--show-toplevel'], + { sshRemote, remoteCwd: worktreePath } + ); + + if (repoRootResult.exitCode === 0) { + repoRoot = repoRootResult.stdout.trim(); + } + } + + return { + success: true, + data: { + exists: true, + isWorktree, + currentBranch, + repoRoot, + }, + }; +} + +/** + * Worktree setup result. + */ +export interface RemoteWorktreeSetupResult extends Record { + success: boolean; + error?: string; + created?: boolean; + currentBranch?: string; + requestedBranch?: string; + branchMismatch?: boolean; +} + +/** + * Create or reuse a worktree on a remote host. + * + * @param mainRepoCwd Path to the main repository on the remote + * @param worktreePath Path where the worktree should be created + * @param branchName Branch name for the worktree + * @param sshRemote SSH remote configuration + * @returns Setup result with success/failure and branch info + */ +export async function worktreeSetupRemote( + mainRepoCwd: string, + worktreePath: string, + branchName: string, + sshRemote: SshRemoteConfig +): Promise> { + // Check if worktree path is inside the main repo (nested worktree) + const checkNestedResult = await execRemoteShellCommand( + `realpath '${mainRepoCwd}' && realpath --canonicalize-missing '${worktreePath}'`, + sshRemote + ); + + if (checkNestedResult.exitCode === 0) { + const lines = checkNestedResult.stdout.trim().split('\n'); + if (lines.length >= 2) { + const resolvedMainRepo = lines[0]; + const resolvedWorktree = lines[1]; + if (resolvedWorktree.startsWith(resolvedMainRepo + '/')) { + return { + success: true, + data: { + success: false, + error: 'Worktree path cannot be inside the main repository. Please use a sibling directory.', + }, + }; + } + } + } + + // Check if worktree path already exists + const existsResult = await execRemoteShellCommand( + `test -d '${worktreePath}' && echo "EXISTS" || echo "NOT_EXISTS"`, + sshRemote + ); + + if (existsResult.exitCode !== 0) { + return { + success: false, + error: existsResult.stderr || 'Failed to check path existence', + }; + } + + let pathExists = existsResult.stdout.trim() === 'EXISTS'; + + if (pathExists) { + // Check if it's already a worktree of this repo + const worktreeInfo = await execGitRemote( + ['rev-parse', '--is-inside-work-tree'], + { sshRemote, remoteCwd: worktreePath } + ); + + if (worktreeInfo.exitCode !== 0) { + // Path exists but isn't a git repo - check if empty + const lsResult = await execRemoteShellCommand( + `ls -A '${worktreePath}' 2>/dev/null | head -1`, + sshRemote + ); + + if (lsResult.exitCode === 0 && lsResult.stdout.trim() === '') { + // Empty directory - remove it + await execRemoteShellCommand(`rmdir '${worktreePath}'`, sshRemote); + pathExists = false; + } else { + return { + success: true, + data: { + success: false, + error: 'Path exists but is not a git worktree or repository (and is not empty)', + }, + }; + } + } + } + + if (pathExists) { + // Verify it belongs to the same repo + const gitCommonDirResult = await execGitRemote( + ['rev-parse', '--git-common-dir'], + { sshRemote, remoteCwd: worktreePath } + ); + + const mainGitDirResult = await execGitRemote( + ['rev-parse', '--git-dir'], + { sshRemote, remoteCwd: mainRepoCwd } + ); + + if (gitCommonDirResult.exitCode === 0 && mainGitDirResult.exitCode === 0) { + // Compare normalized paths on remote + const compareResult = await execRemoteShellCommand( + `test "$(cd '${worktreePath}' && cd '${gitCommonDirResult.stdout.trim()}' && pwd)" = "$(cd '${mainRepoCwd}' && cd '${mainGitDirResult.stdout.trim()}' && pwd)" && echo "SAME" || echo "DIFFERENT"`, + sshRemote + ); + + if (compareResult.stdout.trim() === 'DIFFERENT') { + return { + success: true, + data: { + success: false, + error: 'Worktree path belongs to a different repository', + }, + }; + } + } + + // Get current branch in existing worktree + const currentBranchResult = await execGitRemote( + ['rev-parse', '--abbrev-ref', 'HEAD'], + { sshRemote, remoteCwd: worktreePath } + ); + + const currentBranch = currentBranchResult.exitCode === 0 + ? currentBranchResult.stdout.trim() + : ''; + + return { + success: true, + data: { + success: true, + created: false, + currentBranch, + requestedBranch: branchName, + branchMismatch: currentBranch !== branchName && branchName !== '', + }, + }; + } + + // Worktree doesn't exist, create it + // First check if branch exists + const branchExistsResult = await execGitRemote( + ['rev-parse', '--verify', branchName], + { sshRemote, remoteCwd: mainRepoCwd } + ); + + const branchExists = branchExistsResult.exitCode === 0; + + let createResult: ExecResult; + if (branchExists) { + createResult = await execGitRemote( + ['worktree', 'add', worktreePath, branchName], + { sshRemote, remoteCwd: mainRepoCwd } + ); + } else { + createResult = await execGitRemote( + ['worktree', 'add', '-b', branchName, worktreePath], + { sshRemote, remoteCwd: mainRepoCwd } + ); + } + + if (createResult.exitCode !== 0) { + return { + success: true, + data: { + success: false, + error: createResult.stderr || 'Failed to create worktree', + }, + }; + } + + return { + success: true, + data: { + success: true, + created: true, + currentBranch: branchName, + requestedBranch: branchName, + branchMismatch: false, + }, + }; +} + +/** + * Worktree checkout result. + */ +export interface RemoteWorktreeCheckoutResult extends Record { + success: boolean; + hasUncommittedChanges: boolean; + error?: string; +} + +/** + * Checkout a branch in a worktree on a remote host. + * + * @param worktreePath Path to the worktree on the remote + * @param branchName Branch to checkout + * @param createIfMissing Whether to create the branch if it doesn't exist + * @param sshRemote SSH remote configuration + * @returns Checkout result + */ +export async function worktreeCheckoutRemote( + worktreePath: string, + branchName: string, + createIfMissing: boolean, + sshRemote: SshRemoteConfig +): Promise> { + // Check for uncommitted changes + const statusResult = await execGitRemote( + ['status', '--porcelain'], + { sshRemote, remoteCwd: worktreePath } + ); + + if (statusResult.exitCode !== 0) { + return { + success: true, + data: { + success: false, + hasUncommittedChanges: false, + error: 'Failed to check git status', + }, + }; + } + + if (statusResult.stdout.trim().length > 0) { + return { + success: true, + data: { + success: false, + hasUncommittedChanges: true, + error: 'Worktree has uncommitted changes. Please commit or stash them first.', + }, + }; + } + + // Check if branch exists + const branchExistsResult = await execGitRemote( + ['rev-parse', '--verify', branchName], + { sshRemote, remoteCwd: worktreePath } + ); + + const branchExists = branchExistsResult.exitCode === 0; + + let checkoutResult: ExecResult; + if (branchExists) { + checkoutResult = await execGitRemote( + ['checkout', branchName], + { sshRemote, remoteCwd: worktreePath } + ); + } else if (createIfMissing) { + checkoutResult = await execGitRemote( + ['checkout', '-b', branchName], + { sshRemote, remoteCwd: worktreePath } + ); + } else { + return { + success: true, + data: { + success: false, + hasUncommittedChanges: false, + error: `Branch '${branchName}' does not exist`, + }, + }; + } + + if (checkoutResult.exitCode !== 0) { + return { + success: true, + data: { + success: false, + hasUncommittedChanges: false, + error: checkoutResult.stderr || 'Checkout failed', + }, + }; + } + + return { + success: true, + data: { + success: true, + hasUncommittedChanges: false, + }, + }; +} + +/** + * Worktree entry from list. + */ +export interface RemoteWorktreeEntry extends Record { + path: string; + head: string; + branch: string | null; + isBare: boolean; +} + +/** + * List all worktrees for a git repository on a remote host. + * + * @param cwd Path to the repository on the remote + * @param sshRemote SSH remote configuration + * @returns Array of worktree entries + */ +export async function listWorktreesRemote( + cwd: string, + sshRemote: SshRemoteConfig +): Promise> { + const result = await execGitRemote( + ['worktree', 'list', '--porcelain'], + { sshRemote, remoteCwd: cwd } + ); + + if (result.exitCode !== 0) { + // Not a git repo or no worktree support + return { + success: true, + data: [], + }; + } + + // Parse porcelain output + const worktrees: RemoteWorktreeEntry[] = []; + const lines = result.stdout.split('\n'); + let current: { path?: string; head?: string; branch?: string | null; isBare?: boolean } = {}; + + for (const line of lines) { + if (line.startsWith('worktree ')) { + current.path = line.substring(9); + } else if (line.startsWith('HEAD ')) { + current.head = line.substring(5); + } else if (line.startsWith('branch ')) { + const branchRef = line.substring(7); + current.branch = branchRef.replace('refs/heads/', ''); + } else if (line === 'bare') { + current.isBare = true; + } else if (line === 'detached') { + current.branch = null; + } else if (line === '' && current.path) { + worktrees.push({ + path: current.path, + head: current.head || '', + branch: current.branch ?? null, + isBare: current.isBare || false, + }); + current = {}; + } + } + + // Handle last entry if no trailing newline + if (current.path) { + worktrees.push({ + path: current.path, + head: current.head || '', + branch: current.branch ?? null, + isBare: current.isBare || false, + }); + } + + return { + success: true, + data: worktrees, + }; +} + +/** + * Get the repository root on a remote host. + * + * @param cwd Path to check on the remote + * @param sshRemote SSH remote configuration + * @returns Repository root path + */ +export async function getRepoRootRemote( + cwd: string, + sshRemote: SshRemoteConfig +): Promise> { + const result = await execGitRemote( + ['rev-parse', '--show-toplevel'], + { sshRemote, remoteCwd: cwd } + ); + + if (result.exitCode !== 0) { + return { + success: false, + error: result.stderr || 'Not a git repository', + }; + } + + return { + success: true, + data: result.stdout.trim(), + }; +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 304a6adb..154a467f 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2348,7 +2348,8 @@ function MaestroConsoleInner() { }); // Handle SSH remote status events - tracks when sessions are executing on remote hosts - const unsubscribeSshRemote = window.maestro.process.onSshRemote?.((sessionId: string, sshRemote: { id: string; name: string; host: string } | null) => { + // Also populates session-wide SSH context (sshRemoteId, remoteCwd) for file explorer, git, auto run, etc. + const unsubscribeSshRemote = window.maestro.process.onSshRemote?.((sessionId: string, sshRemote: { id: string; name: string; host: string; remoteWorkingDir?: string } | null) => { // Parse sessionId to get actual session ID (format: {id}-ai-{tabId} or {id}-terminal) let actualSessionId: string; const aiTabMatch = sessionId.match(/^(.+)-ai-(.+)$/); @@ -2360,14 +2361,20 @@ function MaestroConsoleInner() { actualSessionId = sessionId; } - // Update session with SSH remote info + // Update session with SSH remote info and session-wide SSH context setSessions(prev => prev.map(s => { if (s.id !== actualSessionId) return s; // Only update if the value actually changed (avoid unnecessary re-renders) const currentRemoteId = s.sshRemote?.id; const newRemoteId = sshRemote?.id; if (currentRemoteId === newRemoteId) return s; - return { ...s, sshRemote: sshRemote ?? undefined }; + return { + ...s, + sshRemote: sshRemote ?? undefined, + // Populate session-wide SSH context for all operations (file explorer, git, auto run, etc.) + sshRemoteId: sshRemote?.id, + remoteCwd: sshRemote?.remoteWorkingDir, + }; })); }); @@ -8773,6 +8780,7 @@ function MaestroConsoleInner() { defaultShowExternalLinks={documentGraphShowExternalLinks} onExternalLinksChange={settings.setDocumentGraphShowExternalLinks} defaultMaxNodes={documentGraphMaxNodes} + sshRemoteId={activeSession?.sshRemoteId} /> {/* NOTE: All modals are now rendered via the unified component above */} diff --git a/src/renderer/components/AutoRun.tsx b/src/renderer/components/AutoRun.tsx index 6fad0a8f..7903f9b3 100644 --- a/src/renderer/components/AutoRun.tsx +++ b/src/renderer/components/AutoRun.tsx @@ -22,6 +22,9 @@ interface AutoRunProps { theme: Theme; sessionId: string; // Maestro session ID for per-session attachment storage + // SSH Remote context (for remote sessions) + sshRemoteId?: string; // SSH remote config ID - when set, all fs/autorun operations use SSH + // Folder & document state folderPath: string | null; selectedFile: string | null; @@ -103,12 +106,14 @@ function AttachmentImage({ src, alt, folderPath, + sshRemoteId, theme, onImageClick }: { src?: string; alt?: string; folderPath: string | null; + sshRemoteId?: string; // SSH remote ID for loading images from remote sessions theme: any; onImageClick?: (filename: string) => void; }) { @@ -141,7 +146,7 @@ function AttachmentImage({ // Load from folder using absolute path const absolutePath = `${folderPath}/${decodedSrc}`; - window.maestro.fs.readFile(absolutePath) + window.maestro.fs.readFile(absolutePath, sshRemoteId) .then((result) => { if (result.startsWith('data:')) { imageCache.set(cacheKey, result); @@ -168,7 +173,7 @@ function AttachmentImage({ } else if (src.startsWith('/')) { // Absolute file path - load via IPC setFilename(src.split('/').pop() || null); - window.maestro.fs.readFile(src) + window.maestro.fs.readFile(src, sshRemoteId) .then((result) => { if (result.startsWith('data:')) { setDataUrl(result); @@ -185,7 +190,7 @@ function AttachmentImage({ // Other relative path - try to load as file from folderPath if available setFilename(src.split('/').pop() || null); const pathToLoad = folderPath ? `${folderPath}/${src}` : src; - window.maestro.fs.readFile(pathToLoad) + window.maestro.fs.readFile(pathToLoad, sshRemoteId) .then((result) => { if (result.startsWith('data:')) { setDataUrl(result); @@ -199,7 +204,7 @@ function AttachmentImage({ setLoading(false); }); } - }, [src, folderPath]); + }, [src, folderPath, sshRemoteId]); if (loading) { return ( @@ -317,6 +322,7 @@ function ImagePreview({ const AutoRunInner = forwardRef(function AutoRunInner({ theme, sessionId, + sshRemoteId, folderPath, selectedFile, documentList, @@ -487,12 +493,12 @@ const AutoRunInner = forwardRef(function AutoRunInn if (!folderPath || !selectedFile || !isDirty) return; try { - await window.maestro.autorun.writeDoc(folderPath, selectedFile + '.md', localContent); + await window.maestro.autorun.writeDoc(folderPath, selectedFile + '.md', localContent, sshRemoteId); setSavedContent(localContent); } catch (err) { console.error('Failed to save:', err); } - }, [folderPath, selectedFile, localContent, isDirty, setSavedContent]); + }, [folderPath, selectedFile, localContent, isDirty, setSavedContent, sshRemoteId]); // Revert function - discard changes const handleRevert = useCallback(() => { @@ -569,12 +575,12 @@ const AutoRunInner = forwardRef(function AutoRunInn // Auto-save the reset content try { - await window.maestro.autorun.writeDoc(folderPath, selectedFile + '.md', resetContent); + await window.maestro.autorun.writeDoc(folderPath, selectedFile + '.md', resetContent, sshRemoteId); setSavedContent(resetContent); } catch (err) { console.error('Failed to save after reset:', err); } - }, [folderPath, selectedFile, localContent, setLocalContent, setSavedContent, pushUndoState, lastUndoSnapshotRef]); + }, [folderPath, selectedFile, localContent, setLocalContent, setSavedContent, pushUndoState, lastUndoSnapshotRef, sshRemoteId]); // Image handling hook (attachments, paste, upload, lightbox) const { @@ -1225,13 +1231,14 @@ const AutoRunInner = forwardRef(function AutoRunInn src={src} alt={alt} folderPath={folderPath} + sshRemoteId={sshRemoteId} theme={theme} onImageClick={openLightboxByFilename} {...props} /> ), }; - }, [theme, folderPath, openLightboxByFilename, handleFileClick]); + }, [theme, folderPath, sshRemoteId, openLightboxByFilename, handleFileClick]); // Search-highlighted components - only used in preview mode with active search // This allows the base components to remain stable during editing @@ -1263,13 +1270,14 @@ const AutoRunInner = forwardRef(function AutoRunInn src={src} alt={alt} folderPath={folderPath} + sshRemoteId={sshRemoteId} theme={theme} onImageClick={openLightboxByFilename} {...props} /> ), }; - }, [theme, folderPath, openLightboxByFilename, handleFileClick, searchOpen, searchQuery, totalMatches, currentMatchIndex, handleMatchRendered]); + }, [theme, folderPath, sshRemoteId, openLightboxByFilename, handleFileClick, searchOpen, searchQuery, totalMatches, currentMatchIndex, handleMatchRendered]); // Use search-highlighted components when available, otherwise use base components const markdownComponents = searchHighlightedComponents || baseMarkdownComponents; diff --git a/src/renderer/components/AutoRunExpandedModal.tsx b/src/renderer/components/AutoRunExpandedModal.tsx index e591d94c..70f56628 100644 --- a/src/renderer/components/AutoRunExpandedModal.tsx +++ b/src/renderer/components/AutoRunExpandedModal.tsx @@ -13,6 +13,7 @@ interface AutoRunExpandedModalProps { onClose: () => void; // Pass through all AutoRun props sessionId: string; + sshRemoteId?: string; // SSH remote config ID - when set, all fs/autorun operations use SSH folderPath: string | null; selectedFile: string | null; documentList: string[]; diff --git a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx index 788683e1..164f366b 100644 --- a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx +++ b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx @@ -76,6 +76,8 @@ export interface DocumentGraphViewProps { defaultNeighborDepth?: number; /** Callback to persist neighbor depth changes */ onNeighborDepthChange?: (depth: number) => void; + /** Optional SSH remote ID - if provided, shows unavailable message (can't scan remote filesystem) */ + sshRemoteId?: string; } /** @@ -95,6 +97,7 @@ export function DocumentGraphView({ defaultMaxNodes = DEFAULT_MAX_NODES, defaultNeighborDepth = 2, onNeighborDepthChange, + sshRemoteId, }: DocumentGraphViewProps) { // Graph data state const [nodes, setNodes] = useState([]); @@ -494,6 +497,96 @@ export function DocumentGraphView({ if (!isOpen) return null; + // Show unavailable message for remote sessions - Document Graph cannot scan remote filesystems + if (sshRemoteId) { + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ +

+ Document Graph +

+
+ +
+ + {/* Content */} +
+
+ +
+
+

+ Not Available for Remote Sessions +

+

+ Document Graph requires local filesystem access to scan and visualize markdown file relationships. + This feature is not available when connected to a remote host via SSH. +

+
+
+ + {/* Footer */} +
+ +
+
+
+ ); + } + const documentCount = nodes.filter(n => n.nodeType === 'document').length; const externalCount = nodes.filter(n => n.nodeType === 'external').length; diff --git a/src/renderer/components/RightPanel.tsx b/src/renderer/components/RightPanel.tsx index 7e2cbecd..c2518d80 100644 --- a/src/renderer/components/RightPanel.tsx +++ b/src/renderer/components/RightPanel.tsx @@ -265,6 +265,7 @@ export const RightPanel = memo(forwardRef(fun const autoRunSharedProps = { theme, sessionId: session.id, + sshRemoteId: session.sshRemoteId, folderPath: session.autoRunFolderPath || null, selectedFile: session.autoRunSelectedFile || null, documentList: autoRunDocumentList, diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 5badaf4a..87b708fb 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -291,7 +291,8 @@ interface MaestroAPI { error?: string; }>; // Git worktree operations for Auto Run parallelization - worktreeInfo: (worktreePath: string) => Promise<{ + // All worktree operations support SSH remote execution via optional sshRemoteId parameter + worktreeInfo: (worktreePath: string, sshRemoteId?: string) => Promise<{ success: boolean; exists?: boolean; isWorktree?: boolean; @@ -299,12 +300,12 @@ interface MaestroAPI { repoRoot?: string; error?: string; }>; - getRepoRoot: (cwd: string) => Promise<{ + getRepoRoot: (cwd: string, sshRemoteId?: string) => Promise<{ success: boolean; root?: string; error?: string; }>; - worktreeSetup: (mainRepoCwd: string, worktreePath: string, branchName: string) => Promise<{ + worktreeSetup: (mainRepoCwd: string, worktreePath: string, branchName: string, sshRemoteId?: string) => Promise<{ success: boolean; created?: boolean; currentBranch?: string; @@ -312,7 +313,7 @@ interface MaestroAPI { branchMismatch?: boolean; error?: string; }>; - worktreeCheckout: (worktreePath: string, branchName: string, createIfMissing: boolean) => Promise<{ + worktreeCheckout: (worktreePath: string, branchName: string, createIfMissing: boolean, sshRemoteId?: string) => Promise<{ success: boolean; hasUncommittedChanges: boolean; error?: string; @@ -331,7 +332,8 @@ interface MaestroAPI { installed: boolean; authenticated: boolean; }>; - listWorktrees: (cwd: string) => Promise<{ + // Supports SSH remote execution via optional sshRemoteId parameter + listWorktrees: (cwd: string, sshRemoteId?: string) => Promise<{ worktrees: Array<{ path: string; head: string; @@ -348,9 +350,13 @@ interface MaestroAPI { repoRoot: string | null; }>; }>; - watchWorktreeDirectory: (sessionId: string, worktreePath: string) => Promise<{ + // File watching is not available for SSH remote sessions. + // For remote sessions, returns isRemote: true indicating polling should be used instead. + watchWorktreeDirectory: (sessionId: string, worktreePath: string, sshRemoteId?: string) => Promise<{ success: boolean; error?: string; + isRemote?: boolean; + message?: string; }>; unwatchWorktreeDirectory: (sessionId: string) => Promise<{ success: boolean; @@ -364,17 +370,17 @@ interface MaestroAPI { }; fs: { homeDir: () => Promise; - readDir: (dirPath: string) => Promise; - readFile: (filePath: string) => Promise; + readDir: (dirPath: string, sshRemoteId?: string) => Promise; + readFile: (filePath: string, sshRemoteId?: string) => Promise; writeFile: (filePath: string, content: string) => Promise<{ success: boolean }>; - stat: (filePath: string) => Promise<{ + stat: (filePath: string, sshRemoteId?: string) => Promise<{ size: number; createdAt: string; modifiedAt: string; isDirectory: boolean; isFile: boolean; }>; - directorySize: (dirPath: string) => Promise<{ + directorySize: (dirPath: string, sshRemoteId?: string) => Promise<{ totalSize: number; fileCount: number; folderCount: number; @@ -866,21 +872,23 @@ interface MaestroAPI { getPath: (sessionId: string) => Promise<{ success: boolean; path: string }>; }; // Auto Run file operations + // SSH remote support: Core operations accept optional sshRemoteId for remote file operations autorun: { - listDocs: (folderPath: string) => Promise<{ + listDocs: (folderPath: string, sshRemoteId?: string) => Promise<{ success: boolean; files: string[]; tree?: AutoRunTreeNode[]; error?: string; }>; - readDoc: (folderPath: string, filename: string) => Promise<{ success: boolean; content?: string; error?: string }>; - writeDoc: (folderPath: string, filename: string, content: string) => Promise<{ success: boolean; error?: string }>; + readDoc: (folderPath: string, filename: string, sshRemoteId?: string) => Promise<{ success: boolean; content?: string; error?: string }>; + writeDoc: (folderPath: string, filename: string, content: string, sshRemoteId?: string) => Promise<{ success: boolean; error?: string }>; saveImage: (folderPath: string, docName: string, base64Data: string, extension: string) => Promise<{ success: boolean; relativePath?: string; error?: string }>; deleteImage: (folderPath: string, relativePath: string) => Promise<{ success: boolean; error?: string }>; listImages: (folderPath: string, docName: string) => Promise<{ success: boolean; images?: Array<{ filename: string; relativePath: string }>; error?: string }>; deleteFolder: (projectPath: string) => Promise<{ success: boolean; error?: string }>; // File watching for live updates - watchFolder: (folderPath: string) => Promise<{ success: boolean; error?: string }>; + // For remote sessions (sshRemoteId provided), returns isRemote: true indicating polling should be used + watchFolder: (folderPath: string, sshRemoteId?: string) => Promise<{ success: boolean; isRemote?: boolean; message?: string; error?: string }>; unwatchFolder: (folderPath: string) => Promise<{ success: boolean; error?: string }>; onFileChanged: (handler: (data: { folderPath: string; filename: string; eventType: string }) => void) => () => void; // Backup operations for reset-on-completion documents (legacy) diff --git a/src/renderer/hooks/batch/useBatchProcessor.ts b/src/renderer/hooks/batch/useBatchProcessor.ts index 068318e4..16e4d090 100644 --- a/src/renderer/hooks/batch/useBatchProcessor.ts +++ b/src/renderer/hooks/batch/useBatchProcessor.ts @@ -401,7 +401,9 @@ export function useBatchProcessor({ delete errorResolutionRefs.current[sessionId]; // Set up worktree if enabled using extracted hook - const worktreeResult = await worktreeManager.setupWorktree(session.cwd, worktree); + // Inject sshRemoteId from session into worktree config for remote worktree operations + const worktreeWithSsh = worktree ? { ...worktree, sshRemoteId: session.sshRemoteId } : undefined; + const worktreeResult = await worktreeManager.setupWorktree(session.cwd, worktreeWithSsh); if (!worktreeResult.success) { window.maestro.logger.log('error', 'Worktree setup failed', 'BatchProcessor', { sessionId, error: worktreeResult.error }); return; diff --git a/src/renderer/hooks/batch/useWorktreeManager.ts b/src/renderer/hooks/batch/useWorktreeManager.ts index 0dd733a7..dabb87d8 100644 --- a/src/renderer/hooks/batch/useWorktreeManager.ts +++ b/src/renderer/hooks/batch/useWorktreeManager.ts @@ -26,6 +26,8 @@ export interface WorktreeConfig { prTargetBranch?: string; /** Path to gh CLI binary (if not in PATH) */ ghPath?: string; + /** SSH remote ID for remote sessions (optional) */ + sshRemoteId?: string; } /** @@ -167,7 +169,8 @@ ${docList} const setupResult = await window.maestro.git.worktreeSetup( sessionCwd, worktree.path, - worktree.branchName + worktree.branchName, + worktree.sshRemoteId ); window.maestro.logger.log('info', 'worktreeSetup result', 'WorktreeManager', { @@ -205,7 +208,8 @@ ${docList} const checkoutResult = await window.maestro.git.worktreeCheckout( worktree.path, worktree.branchName, - true // createIfMissing + true, // createIfMissing + worktree.sshRemoteId ); window.maestro.logger.log('info', 'worktreeCheckout result', 'WorktreeManager', { diff --git a/src/renderer/hooks/batch/useWorktreeValidation.ts b/src/renderer/hooks/batch/useWorktreeValidation.ts index 6f657cae..d4d0017b 100644 --- a/src/renderer/hooks/batch/useWorktreeValidation.ts +++ b/src/renderer/hooks/batch/useWorktreeValidation.ts @@ -38,6 +38,8 @@ export interface UseWorktreeValidationDeps { worktreeEnabled: boolean; /** The session's current working directory (main repo) */ sessionCwd: string; + /** SSH remote ID for remote sessions (optional) */ + sshRemoteId?: string; } /** @@ -90,6 +92,7 @@ export function useWorktreeValidation({ branchName, worktreeEnabled, sessionCwd, + sshRemoteId, }: UseWorktreeValidationDeps): UseWorktreeValidationReturn { const [validation, setValidation] = useState(INITIAL_VALIDATION_STATE); @@ -108,7 +111,7 @@ export function useWorktreeValidation({ const timeoutId = setTimeout(async () => { try { // Check if the path exists and get worktree info - const worktreeInfoResult = await window.maestro.git.worktreeInfo(worktreePath); + const worktreeInfoResult = await window.maestro.git.worktreeInfo(worktreePath, sshRemoteId); if (!worktreeInfoResult.success) { setValidation({ @@ -138,7 +141,7 @@ export function useWorktreeValidation({ // Path exists - check if it's part of the same repo // If there's no repoRoot, the directory exists but isn't a git repo - that's fine for a new worktree - const mainRepoRootResult = await window.maestro.git.getRepoRoot(sessionCwd); + const mainRepoRootResult = await window.maestro.git.getRepoRoot(sessionCwd, sshRemoteId); const sameRepo = !worktreeInfoResult.repoRoot || (mainRepoRootResult.success && worktreeInfoResult.repoRoot === mainRepoRootResult.root); @@ -156,7 +159,8 @@ export function useWorktreeValidation({ if (branchMismatch && sameRepo) { try { // Use git status to check for uncommitted changes in the worktree - const statusResult = await window.maestro.git.status(worktreePath); + // Pass sshRemoteId to support remote worktree validation + const statusResult = await window.maestro.git.status(worktreePath, sshRemoteId); hasChanges = hasUncommittedChanges(statusResult.stdout); } catch { // If we can't check, assume no uncommitted changes @@ -190,7 +194,7 @@ export function useWorktreeValidation({ // Cleanup timeout on unmount or when dependencies change return () => clearTimeout(timeoutId); - }, [worktreePath, branchName, worktreeEnabled, sessionCwd]); + }, [worktreePath, branchName, worktreeEnabled, sessionCwd, sshRemoteId]); return { validation }; } diff --git a/src/renderer/hooks/git/useFileTreeManagement.ts b/src/renderer/hooks/git/useFileTreeManagement.ts index a67cf8ca..6ae9daf3 100644 --- a/src/renderer/hooks/git/useFileTreeManagement.ts +++ b/src/renderer/hooks/git/useFileTreeManagement.ts @@ -2,11 +2,26 @@ import { useCallback, useEffect, useMemo } from 'react'; import type { RightPanelHandle } from '../../components/RightPanel'; import type { Session } from '../../types'; import type { FileNode } from '../../types/fileTree'; -import { loadFileTree, compareFileTrees, type FileTreeChanges } from '../../utils/fileExplorer'; +import { loadFileTree, compareFileTrees, type FileTreeChanges, type SshContext } from '../../utils/fileExplorer'; import { fuzzyMatch } from '../../utils/search'; import { gitService } from '../../services/git'; +/** + * Extract SSH context from session for remote file operations. + * Returns undefined if no SSH remote is configured. + */ +function getSshContext(session: Session): SshContext | undefined { + if (!session.sshRemoteId) { + return undefined; + } + return { + sshRemoteId: session.sshRemoteId, + remoteCwd: session.remoteCwd, + }; +} + export type { RightPanelHandle } from '../../components/RightPanel'; +export type { SshContext } from '../../utils/fileExplorer'; /** * Dependencies for the useFileTreeManagement hook. @@ -68,17 +83,22 @@ export function useFileTreeManagement( /** * Refresh file tree for a session and return the changes detected. * Uses sessionsRef to avoid dependency on sessions state (prevents timer reset on every session change). + * Passes SSH context for remote sessions to enable remote file operations (Phase 2+). */ const refreshFileTree = useCallback(async (sessionId: string): Promise => { // Use sessionsRef to avoid dependency on sessions state (prevents timer reset on every session change) const session = sessionsRef.current.find(s => s.id === sessionId); if (!session) return undefined; + // Extract SSH context for remote file operations + const sshContext = getSshContext(session); + try { // Fetch tree and stats in parallel + // Pass SSH context for remote file operations const [newTree, stats] = await Promise.all([ - loadFileTree(session.cwd), - window.maestro.fs.directorySize(session.cwd) + loadFileTree(session.cwd, 10, 0, sshContext), + window.maestro.fs.directorySize(session.cwd, sshContext?.sshRemoteId) ]); const oldTree = session.fileTree || []; const changes = compareFileTrees(oldTree, newTree); @@ -115,6 +135,7 @@ export function useFileTreeManagement( /** * Refresh both file tree and git state for a session. * Loads file tree, checks git repo status, and fetches branches/tags if applicable. + * Passes SSH context for remote sessions to enable remote operations (Phase 2+). */ const refreshGitFileState = useCallback(async (sessionId: string) => { const session = sessions.find(s => s.id === sessionId); @@ -122,11 +143,15 @@ export function useFileTreeManagement( const cwd = session.inputMode === 'terminal' ? (session.shellCwd || session.cwd) : session.cwd; + // Extract SSH context for remote file/git operations + const sshContext = getSshContext(session); + try { // Refresh file tree, stats, git repo status, branches, and tags in parallel + // Pass SSH context for remote file operations const [tree, stats, isGitRepo] = await Promise.all([ - loadFileTree(cwd), - window.maestro.fs.directorySize(cwd), + loadFileTree(cwd, 10, 0, sshContext), + window.maestro.fs.directorySize(cwd, sshContext?.sshRemoteId), gitService.isRepo(cwd) ]); @@ -179,6 +204,7 @@ export function useFileTreeManagement( /** * Load file tree when active session changes. * Only loads if file tree is empty. + * Passes SSH context for remote sessions to enable remote operations (Phase 2+). */ useEffect(() => { const session = sessions.find(s => s.id === activeSessionId); @@ -186,9 +212,12 @@ export function useFileTreeManagement( // Only load if file tree is empty if (!session.fileTree || session.fileTree.length === 0) { + // Extract SSH context for remote file operations + const sshContext = getSshContext(session); + Promise.all([ - loadFileTree(session.cwd), - window.maestro.fs.directorySize(session.cwd) + loadFileTree(session.cwd, 10, 0, sshContext), + window.maestro.fs.directorySize(session.cwd, sshContext?.sshRemoteId) ]).then(([tree, stats]) => { setSessions(prev => prev.map(s => s.id === activeSessionId ? { diff --git a/src/renderer/hooks/git/useGitStatusPolling.ts b/src/renderer/hooks/git/useGitStatusPolling.ts index 624efff2..28014dca 100644 --- a/src/renderer/hooks/git/useGitStatusPolling.ts +++ b/src/renderer/hooks/git/useGitStatusPolling.ts @@ -167,9 +167,12 @@ export function useGitStatusPolling( const isActiveSession = session.id === currentActiveSessionId; + // Get SSH remote ID from session for remote git operations + const sshRemoteId = session.sshRemoteId; + // For non-active sessions, just get basic status (file count) if (!isActiveSession) { - const status = await gitService.getStatus(cwd); + const status = await gitService.getStatus(cwd, sshRemoteId); const statusData: GitStatusData = { fileCount: status.files.length, branch: status.branch, @@ -187,9 +190,9 @@ export function useGitStatusPolling( // Use git:info for branch/remote/ahead/behind (single IPC call, 4 parallel git commands) // Plus get detailed file changes with numstat const [gitInfo, status, numstat] = await Promise.all([ - window.maestro.git.info(cwd), - gitService.getStatus(cwd), - gitService.getNumstat(cwd), + window.maestro.git.info(cwd, sshRemoteId), + gitService.getStatus(cwd, sshRemoteId), + gitService.getNumstat(cwd, sshRemoteId), ]); // Create a map of path -> numstat data diff --git a/src/renderer/services/git.ts b/src/renderer/services/git.ts index 552eac7e..89c8a650 100644 --- a/src/renderer/services/git.ts +++ b/src/renderer/services/git.ts @@ -26,13 +26,19 @@ export interface GitNumstat { }>; } +/** + * All git service methods support SSH remote execution via optional sshRemoteId parameter. + * When sshRemoteId is provided, operations execute on the remote host via SSH. + */ export const gitService = { /** * Check if a directory is a git repository + * @param cwd Working directory path + * @param sshRemoteId Optional SSH remote ID for remote execution */ - async isRepo(cwd: string): Promise { + async isRepo(cwd: string, sshRemoteId?: string): Promise { return createIpcMethod({ - call: () => window.maestro.git.isRepo(cwd), + call: () => window.maestro.git.isRepo(cwd, sshRemoteId), errorContext: 'Git isRepo', defaultValue: false, }); @@ -40,13 +46,15 @@ export const gitService = { /** * Get git status (porcelain format) and current branch + * @param cwd Working directory path + * @param sshRemoteId Optional SSH remote ID for remote execution */ - async getStatus(cwd: string): Promise { + async getStatus(cwd: string, sshRemoteId?: string): Promise { return createIpcMethod({ call: async () => { const [statusResult, branchResult] = await Promise.all([ - window.maestro.git.status(cwd), - window.maestro.git.branch(cwd), + window.maestro.git.status(cwd, sshRemoteId), + window.maestro.git.branch(cwd, sshRemoteId), ]); const files = parseGitStatusPorcelain(statusResult.stdout || ''); @@ -61,18 +69,21 @@ export const gitService = { /** * Get git diff for specific files or all changes + * @param cwd Working directory path + * @param files Optional list of files to get diff for + * @param sshRemoteId Optional SSH remote ID for remote execution */ - async getDiff(cwd: string, files?: string[]): Promise { + async getDiff(cwd: string, files?: string[], sshRemoteId?: string): Promise { return createIpcMethod({ call: async () => { // If no files specified, get full diff if (!files || files.length === 0) { - const result = await window.maestro.git.diff(cwd); + const result = await window.maestro.git.diff(cwd, undefined, sshRemoteId); return { diff: result.stdout }; } // Otherwise get diff for specific files const results = await Promise.all( - files.map(file => window.maestro.git.diff(cwd, file)) + files.map(file => window.maestro.git.diff(cwd, file, sshRemoteId)) ); return { diff: results.map(result => result.stdout).join('\n') }; }, @@ -83,11 +94,13 @@ export const gitService = { /** * Get line-level statistics for all changes + * @param cwd Working directory path + * @param sshRemoteId Optional SSH remote ID for remote execution */ - async getNumstat(cwd: string): Promise { + async getNumstat(cwd: string, sshRemoteId?: string): Promise { return createIpcMethod({ call: async () => { - const result = await window.maestro.git.numstat(cwd); + const result = await window.maestro.git.numstat(cwd, sshRemoteId); const files = parseGitNumstat(result.stdout || ''); return { files }; }, @@ -99,11 +112,13 @@ export const gitService = { /** * Get the browser-friendly URL for the remote repository * Returns null if no remote or URL cannot be parsed + * @param cwd Working directory path + * @param sshRemoteId Optional SSH remote ID for remote execution */ - async getRemoteBrowserUrl(cwd: string): Promise { + async getRemoteBrowserUrl(cwd: string, sshRemoteId?: string): Promise { return createIpcMethod({ call: async () => { - const result = await window.maestro.git.remote(cwd); + const result = await window.maestro.git.remote(cwd, sshRemoteId); return result.stdout ? remoteUrlToBrowserUrl(result.stdout) : null; }, errorContext: 'Git remote', @@ -113,11 +128,13 @@ export const gitService = { /** * Get all branches (local and remote, deduplicated) + * @param cwd Working directory path + * @param sshRemoteId Optional SSH remote ID for remote execution */ - async getBranches(cwd: string): Promise { + async getBranches(cwd: string, sshRemoteId?: string): Promise { return createIpcMethod({ call: async () => { - const result = await window.maestro.git.branches(cwd); + const result = await window.maestro.git.branches(cwd, sshRemoteId); return result.branches || []; }, errorContext: 'Git branches', @@ -127,11 +144,13 @@ export const gitService = { /** * Get all tags + * @param cwd Working directory path + * @param sshRemoteId Optional SSH remote ID for remote execution */ - async getTags(cwd: string): Promise { + async getTags(cwd: string, sshRemoteId?: string): Promise { return createIpcMethod({ call: async () => { - const result = await window.maestro.git.tags(cwd); + const result = await window.maestro.git.tags(cwd, sshRemoteId); return result.tags || []; }, errorContext: 'Git tags', diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 6d56b96a..5053270f 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -477,6 +477,10 @@ export interface Session { host: string; // Remote host for tooltip }; + // SSH Remote context (session-wide, for all operations - file explorer, git, auto run, etc.) + sshRemoteId?: string; // ID of SSH remote config being used (flattened from sshRemote.id) + remoteCwd?: string; // Current working directory on remote host + // Per-session agent configuration overrides // These override the global agent-level settings for this specific session customPath?: string; // Custom path to agent binary (overrides agent-level) diff --git a/src/renderer/utils/fileExplorer.ts b/src/renderer/utils/fileExplorer.ts index 2b518811..4224acd8 100644 --- a/src/renderer/utils/fileExplorer.ts +++ b/src/renderer/utils/fileExplorer.ts @@ -50,18 +50,33 @@ export interface FileTreeNode { children?: FileTreeNode[]; } +/** + * SSH context for remote file operations + */ +export interface SshContext { + /** SSH remote config ID */ + sshRemoteId?: string; + /** Remote working directory */ + remoteCwd?: string; +} + /** * Load file tree from directory recursively + * @param dirPath - The directory path to load + * @param maxDepth - Maximum recursion depth (default: 10) + * @param currentDepth - Current recursion depth (internal use) + * @param sshContext - Optional SSH context for remote file operations */ export async function loadFileTree( dirPath: string, maxDepth = 10, - currentDepth = 0 + currentDepth = 0, + sshContext?: SshContext ): Promise { if (currentDepth >= maxDepth) return []; try { - const entries = await window.maestro.fs.readDir(dirPath); + const entries = await window.maestro.fs.readDir(dirPath, sshContext?.sshRemoteId); const tree: FileTreeNode[] = []; for (const entry of entries) { @@ -71,7 +86,7 @@ export async function loadFileTree( } if (entry.isDirectory) { - const children = await loadFileTree(`${dirPath}/${entry.name}`, maxDepth, currentDepth + 1); + const children = await loadFileTree(`${dirPath}/${entry.name}`, maxDepth, currentDepth + 1, sshContext); tree.push({ name: entry.name, type: 'folder',