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:
Pedram Amini
2026-02-01 23:48:10 -06:00
parent 7c19aec1ce
commit bf86974290
6 changed files with 84 additions and 15 deletions

View File

@@ -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);
});
});

View File

@@ -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) {

View File

@@ -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

View File

@@ -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 {

View File

@@ -1709,6 +1709,11 @@ export const TerminalOutput = memo(
session.sessionSshRemoteConfig?.enabled &&
!!session.sessionSshRemoteConfig?.remoteId
}
sshRemoteId={
session.sessionSshRemoteConfig?.enabled
? session.sessionSshRemoteConfig?.remoteId ?? undefined
: undefined
}
/>
)}
</div>

View File

@@ -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