mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 00:21:21 +00:00
Merge pull request #121 from pedramamini/1-ssh-tunnel-agents
ssh tunneling enhancements
This commit is contained in:
662
src/__tests__/main/utils/remote-fs.test.ts
Normal file
662
src/__tests__/main/utils/remote-fs.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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<AgentSessionOriginsData>({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -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<MaestroSettings>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SSH remote configuration by ID from the settings store.
|
||||
* Returns undefined if not found or store not provided.
|
||||
*/
|
||||
function getSshRemoteById(
|
||||
store: Store<MaestroSettings> | 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<string, FSWatcher>();
|
||||
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<TreeNode[]> {
|
||||
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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -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<string>,
|
||||
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<string>;
|
||||
readDir: (dirPath: string) => Promise<DirectoryEntry[]>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
readDir: (dirPath: string, sshRemoteId?: string) => Promise<DirectoryEntry[]>;
|
||||
readFile: (filePath: string, sshRemoteId?: string) => Promise<string>;
|
||||
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;
|
||||
|
||||
483
src/main/utils/remote-fs.ts
Normal file
483
src/main/utils/remote-fs.ts
Normal file
@@ -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<T> {
|
||||
/** 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<ExecResult>;
|
||||
/** 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<ExecResult> => {
|
||||
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<ExecResult> {
|
||||
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<RemoteFsResult<RemoteDirEntry[]>> {
|
||||
// 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<RemoteFsResult<string>> {
|
||||
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<RemoteFsResult<RemoteStatResult>> {
|
||||
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<RemoteFsResult<number>> {
|
||||
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<RemoteFsResult<void>> {
|
||||
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<RemoteFsResult<boolean>> {
|
||||
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<RemoteFsResult<void>> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<T> {
|
||||
/** 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<ExecResult> {
|
||||
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<string, unknown> {
|
||||
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<RemoteGitResult<RemoteWorktreeInfo>> {
|
||||
// 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<string, unknown> {
|
||||
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<RemoteGitResult<RemoteWorktreeSetupResult>> {
|
||||
// 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<string, unknown> {
|
||||
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<RemoteGitResult<RemoteWorktreeCheckoutResult>> {
|
||||
// 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<string, unknown> {
|
||||
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<RemoteGitResult<RemoteWorktreeEntry[]>> {
|
||||
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<RemoteGitResult<string>> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 <AppModals /> component above */}
|
||||
|
||||
@@ -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<AutoRunHandle, AutoRunProps>(function AutoRunInner({
|
||||
theme,
|
||||
sessionId,
|
||||
sshRemoteId,
|
||||
folderPath,
|
||||
selectedFile,
|
||||
documentList,
|
||||
@@ -487,12 +493,12 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(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<AutoRunHandle, AutoRunProps>(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<AutoRunHandle, AutoRunProps>(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<AutoRunHandle, AutoRunProps>(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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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<ForceGraphNode[]>([]);
|
||||
@@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 modal-overlay flex items-center justify-center z-[9999] animate-in fade-in duration-100"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Document Graph - Not Available"
|
||||
className="rounded-xl shadow-2xl border overflow-hidden flex flex-col outline-none"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
borderColor: theme.colors.border,
|
||||
width: 480,
|
||||
maxWidth: '90vw',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-6 py-4 border-b flex items-center justify-between flex-shrink-0"
|
||||
style={{ borderColor: theme.colors.border }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Network className="w-5 h-5" style={{ color: theme.colors.textDim }} />
|
||||
<h2 className="text-lg font-semibold" style={{ color: theme.colors.textMain }}>
|
||||
Document Graph
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded transition-colors"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = `${theme.colors.accent}20`)}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-8 flex flex-col items-center gap-4">
|
||||
<div
|
||||
className="p-4 rounded-full"
|
||||
style={{ backgroundColor: `${theme.colors.accent}20` }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="w-8 h-8"
|
||||
style={{ color: theme.colors.accent }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-base font-medium mb-2" style={{ color: theme.colors.textMain }}>
|
||||
Not Available for Remote Sessions
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: theme.colors.textDim }}>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="px-6 py-4 border-t flex justify-end"
|
||||
style={{ borderColor: theme.colors.border }}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded text-sm font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: theme.colors.accent,
|
||||
color: theme.colors.bgMain,
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.opacity = '0.85')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.opacity = '1')}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const documentCount = nodes.filter(n => n.nodeType === 'document').length;
|
||||
const externalCount = nodes.filter(n => n.nodeType === 'external').length;
|
||||
|
||||
|
||||
@@ -265,6 +265,7 @@ export const RightPanel = memo(forwardRef<RightPanelHandle, RightPanelProps>(fun
|
||||
const autoRunSharedProps = {
|
||||
theme,
|
||||
sessionId: session.id,
|
||||
sshRemoteId: session.sshRemoteId,
|
||||
folderPath: session.autoRunFolderPath || null,
|
||||
selectedFile: session.autoRunSelectedFile || null,
|
||||
documentList: autoRunDocumentList,
|
||||
|
||||
36
src/renderer/global.d.ts
vendored
36
src/renderer/global.d.ts
vendored
@@ -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<string>;
|
||||
readDir: (dirPath: string) => Promise<DirectoryEntry[]>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
readDir: (dirPath: string, sshRemoteId?: string) => Promise<DirectoryEntry[]>;
|
||||
readFile: (filePath: string, sshRemoteId?: string) => Promise<string>;
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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<WorktreeValidationState>(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 };
|
||||
}
|
||||
|
||||
@@ -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<FileTreeChanges | undefined> => {
|
||||
// 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 ? {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<boolean> {
|
||||
async isRepo(cwd: string, sshRemoteId?: string): Promise<boolean> {
|
||||
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<GitStatus> {
|
||||
async getStatus(cwd: string, sshRemoteId?: string): Promise<GitStatus> {
|
||||
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<GitDiff> {
|
||||
async getDiff(cwd: string, files?: string[], sshRemoteId?: string): Promise<GitDiff> {
|
||||
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<GitNumstat> {
|
||||
async getNumstat(cwd: string, sshRemoteId?: string): Promise<GitNumstat> {
|
||||
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<string | null> {
|
||||
async getRemoteBrowserUrl(cwd: string, sshRemoteId?: string): Promise<string | null> {
|
||||
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<string[]> {
|
||||
async getBranches(cwd: string, sshRemoteId?: string): Promise<string[]> {
|
||||
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<string[]> {
|
||||
async getTags(cwd: string, sshRemoteId?: string): Promise<string[]> {
|
||||
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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<FileTreeNode[]> {
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user