mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
feat(save-markdown): add SSH remote filesystem support
Enable saving markdown content directly to remote filesystems when connected via SSH. The fs:writeFile IPC handler now accepts an optional sshRemoteId parameter and uses writeFileRemote for SSH transport.
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
* - Basic rendering
|
||||
* - Form validation
|
||||
* - Save functionality
|
||||
* - SSH remote session behavior (folder browse button visibility)
|
||||
* - SSH remote session behavior (folder browse button visibility, remote saving)
|
||||
* - Keyboard interaction (Enter to save)
|
||||
* - Error handling
|
||||
*/
|
||||
@@ -141,6 +141,51 @@ describe('SaveMarkdownModal', () => {
|
||||
fireEvent.change(folderInput, { target: { value: '/remote/path' } });
|
||||
expect(folderInput).toHaveValue('/remote/path');
|
||||
});
|
||||
|
||||
it('passes sshRemoteId to writeFile when saving to remote', async () => {
|
||||
mockWriteFile.mockResolvedValue({ success: true });
|
||||
render(
|
||||
<SaveMarkdownModal
|
||||
{...defaultProps}
|
||||
isRemoteSession={true}
|
||||
sshRemoteId="ssh-remote-123"
|
||||
defaultFolder="/home/user"
|
||||
/>
|
||||
);
|
||||
|
||||
const filenameInput = screen.getByPlaceholderText('document.md');
|
||||
fireEvent.change(filenameInput, { target: { value: 'remote-doc.md' } });
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save' });
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
'/home/user/remote-doc.md',
|
||||
'# Test Markdown\n\nThis is test content.',
|
||||
'ssh-remote-123'
|
||||
);
|
||||
});
|
||||
|
||||
it('saves without sshRemoteId when not provided', async () => {
|
||||
mockWriteFile.mockResolvedValue({ success: true });
|
||||
render(<SaveMarkdownModal {...defaultProps} isRemoteSession={false} />);
|
||||
|
||||
const filenameInput = screen.getByPlaceholderText('document.md');
|
||||
fireEvent.change(filenameInput, { target: { value: 'local-doc.md' } });
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save' });
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
'/test/folder/local-doc.md',
|
||||
expect.any(String),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('folder browser', () => {
|
||||
@@ -252,7 +297,8 @@ describe('SaveMarkdownModal', () => {
|
||||
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
'/test/folder/test.md',
|
||||
'# Test Markdown\n\nThis is test content.'
|
||||
'# Test Markdown\n\nThis is test content.',
|
||||
undefined // No SSH remote ID for local save
|
||||
);
|
||||
});
|
||||
|
||||
@@ -268,7 +314,7 @@ describe('SaveMarkdownModal', () => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('/test/folder/test.md', expect.any(String));
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('/test/folder/test.md', expect.any(String), undefined);
|
||||
});
|
||||
|
||||
it('does not duplicate .md extension', async () => {
|
||||
@@ -283,7 +329,7 @@ describe('SaveMarkdownModal', () => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('/test/folder/test.md', expect.any(String));
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('/test/folder/test.md', expect.any(String), undefined);
|
||||
});
|
||||
|
||||
it('handles .MD extension case-insensitively', async () => {
|
||||
@@ -299,7 +345,7 @@ describe('SaveMarkdownModal', () => {
|
||||
});
|
||||
|
||||
// Should not add another .md
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('/test/folder/test.MD', expect.any(String));
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('/test/folder/test.MD', expect.any(String), undefined);
|
||||
});
|
||||
|
||||
it('calls onClose after successful save', async () => {
|
||||
@@ -384,7 +430,7 @@ describe('SaveMarkdownModal', () => {
|
||||
});
|
||||
|
||||
// Should not have double slash
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('/test/folder/test.md', expect.any(String));
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('/test/folder/test.md', expect.any(String), undefined);
|
||||
});
|
||||
|
||||
it('handles Windows-style paths', async () => {
|
||||
@@ -402,7 +448,7 @@ describe('SaveMarkdownModal', () => {
|
||||
});
|
||||
|
||||
// Should use backslash for Windows paths
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(`${windowsPath}\\test.md`, expect.any(String));
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(`${windowsPath}\\test.md`, expect.any(String), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - readFile: Read file contents with image base64 encoding (local & SSH remote)
|
||||
* - stat: Get file/directory statistics (local & SSH remote)
|
||||
* - directorySize: Calculate directory size recursively (local & SSH remote)
|
||||
* - writeFile: Write content to file (local only)
|
||||
* - writeFile: Write content to file (local & SSH remote)
|
||||
* - rename: Rename file/directory (local & SSH remote)
|
||||
* - delete: Delete file/directory (local & SSH remote)
|
||||
* - countItems: Count files and folders recursively (local & SSH remote)
|
||||
@@ -25,6 +25,7 @@ import { logger } from '../../utils/logger';
|
||||
import {
|
||||
readDirRemote,
|
||||
readFileRemote,
|
||||
writeFileRemote,
|
||||
statRemote,
|
||||
directorySizeRemote,
|
||||
renameRemote,
|
||||
@@ -239,9 +240,23 @@ export function registerFilesystemHandlers(): void {
|
||||
};
|
||||
});
|
||||
|
||||
// Write content to file (local only)
|
||||
ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string) => {
|
||||
// Write content to file (supports SSH remote)
|
||||
ipcMain.handle('fs:writeFile', async (_, filePath: string, content: 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 writeFileRemote(filePath, content, sshConfig);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to write remote file');
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Local: use standard fs operations
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -72,8 +72,8 @@ export function createFsApi() {
|
||||
/**
|
||||
* Write file contents
|
||||
*/
|
||||
writeFile: (filePath: string, content: string): Promise<{ success: boolean }> =>
|
||||
ipcRenderer.invoke('fs:writeFile', filePath, content),
|
||||
writeFile: (filePath: string, content: string, sshRemoteId?: string): Promise<{ success: boolean }> =>
|
||||
ipcRenderer.invoke('fs:writeFile', filePath, content, sshRemoteId),
|
||||
|
||||
/**
|
||||
* Get file/directory stats
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface SaveMarkdownModalProps {
|
||||
defaultFolder?: string;
|
||||
/** Whether the session is running over SSH (hides folder browser button) */
|
||||
isRemoteSession?: boolean;
|
||||
/** SSH remote ID for saving to remote filesystem */
|
||||
sshRemoteId?: string;
|
||||
}
|
||||
|
||||
export function SaveMarkdownModal({
|
||||
@@ -30,6 +32,7 @@ export function SaveMarkdownModal({
|
||||
onClose,
|
||||
defaultFolder = '',
|
||||
isRemoteSession = false,
|
||||
sshRemoteId,
|
||||
}: SaveMarkdownModalProps) {
|
||||
const [folder, setFolder] = useState(defaultFolder);
|
||||
const [filename, setFilename] = useState('');
|
||||
@@ -80,8 +83,8 @@ export function SaveMarkdownModal({
|
||||
const separator = folder.includes('\\') ? '\\' : '/';
|
||||
const fullPath = `${folder}${folder.endsWith(separator) ? '' : separator}${finalFilename}`;
|
||||
|
||||
// Write the file
|
||||
const result = await window.maestro.fs.writeFile(fullPath, content);
|
||||
// Write the file (local or remote via SSH)
|
||||
const result = await window.maestro.fs.writeFile(fullPath, content, sshRemoteId);
|
||||
if (result.success) {
|
||||
onClose();
|
||||
} else {
|
||||
|
||||
@@ -1709,6 +1709,11 @@ export const TerminalOutput = memo(
|
||||
session.sessionSshRemoteConfig?.enabled &&
|
||||
!!session.sessionSshRemoteConfig?.remoteId
|
||||
}
|
||||
sshRemoteId={
|
||||
session.sessionSshRemoteConfig?.enabled
|
||||
? session.sessionSshRemoteConfig?.remoteId ?? undefined
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
2
src/renderer/global.d.ts
vendored
2
src/renderer/global.d.ts
vendored
@@ -539,7 +539,7 @@ interface MaestroAPI {
|
||||
homeDir: () => Promise<string>;
|
||||
readDir: (dirPath: string, sshRemoteId?: string) => Promise<DirectoryEntry[]>;
|
||||
readFile: (filePath: string, sshRemoteId?: string) => Promise<string>;
|
||||
writeFile: (filePath: string, content: string) => Promise<{ success: boolean }>;
|
||||
writeFile: (filePath: string, content: string, sshRemoteId?: string) => Promise<{ success: boolean }>;
|
||||
stat: (
|
||||
filePath: string,
|
||||
sshRemoteId?: string
|
||||
|
||||
Reference in New Issue
Block a user