diff --git a/apps/emdash-desktop/src/main/core/fs/controller.ts b/apps/emdash-desktop/src/main/core/fs/controller.ts index 1d888a47fa..5c73b08a3e 100644 --- a/apps/emdash-desktop/src/main/core/fs/controller.ts +++ b/apps/emdash-desktop/src/main/core/fs/controller.ts @@ -104,7 +104,12 @@ export const filesController = createRPCController({ } }, - removeFile: async (projectId: string, workspaceId: string, filePath: string) => { + removeFile: async ( + projectId: string, + workspaceId: string, + filePath: string, + options?: { recursive?: boolean } + ) => { const env = resolveWorkspace(projectId, workspaceId); if (!env) return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); @@ -117,7 +122,7 @@ export const filesController = createRPCController({ } try { - const result = await env.fs.remove(filePath); + const result = await env.fs.remove(filePath, options); return ok(result); } catch (e) { if ( diff --git a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts b/apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts index 1f6709bf4f..fb9a7fddab 100644 --- a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts +++ b/apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts @@ -386,6 +386,16 @@ describe('LocalFileSystem', () => { expect(result.error).toContain('directory'); }); + it('should remove directory recursively when requested', async () => { + fs.mkdirSync(path.join(tempDir, 'subdir/nested'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'subdir/nested/delete.txt'), 'content'); + + const result = await fsService.remove('subdir', { recursive: true }); + + expect(result.success).toBe(true); + expect(fs.existsSync(path.join(tempDir, 'subdir'))).toBe(false); + }); + it('should retry with chmod on permission error', async () => { if (process.platform !== 'win32') { const filePath = path.join(tempDir, 'readonly.txt'); diff --git a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts b/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts index db4299bbe0..4f542677df 100644 --- a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts +++ b/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts @@ -1,5 +1,6 @@ +import { EventEmitter } from 'node:events'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { FileEntry, FileListResult } from '../types'; +import { FileSystemErrorCodes, type FileEntry, type FileListResult } from '../types'; import { SshFileSystem } from './ssh-fs'; type SftpMkdirError = Error & { code?: number }; @@ -39,6 +40,43 @@ function makeMkdirFs(errors: Array) { }; } +function makeRemoveFs() { + const execCommands: string[] = []; + const sftp = { + on: vi.fn(), + stat: vi.fn((_path: string, callback: (error: Error | undefined, stats?: unknown) => void) => { + callback(undefined, { + isDirectory: () => true, + size: 0, + mtime: 0, + atime: 0, + mode: 0o040755, + }); + }), + }; + const proxy = { + sftp: vi.fn((callback: (error: Error | undefined, sftp: unknown) => void) => { + callback(undefined, sftp); + }), + getRemoteShellProfile: vi.fn(async () => ({ shell: '/bin/sh', env: {} })), + exec: vi.fn( + (command: string, callback: (error: Error | undefined, stream: EventEmitter) => void) => { + execCommands.push(command); + const stream = new EventEmitter() as EventEmitter & { stderr: EventEmitter }; + stream.stderr = new EventEmitter(); + callback(undefined, stream); + setImmediate(() => stream.emit('close', 0)); + } + ), + }; + + return { + fs: new SshFileSystem(proxy as never, '/repo'), + execCommands, + proxy, + }; +} + describe('SshFileSystem.mkdir', () => { afterEach(() => { vi.restoreAllMocks(); @@ -70,6 +108,33 @@ describe('SshFileSystem.mkdir', () => { }); }); +describe('SshFileSystem.remove', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('rejects traversal before recursive directory removal can reach SSH', async () => { + const { fs, proxy } = makeRemoveFs(); + + await expect(fs.remove('subdir/../../../outside', { recursive: true })).rejects.toMatchObject({ + code: FileSystemErrorCodes.PATH_ESCAPE, + }); + + expect(proxy.sftp).not.toHaveBeenCalled(); + expect(proxy.exec).not.toHaveBeenCalled(); + }); + + it('removes directories recursively inside the workspace', async () => { + const { fs, execCommands } = makeRemoveFs(); + + await expect(fs.remove('subdir', { recursive: true })).resolves.toEqual({ success: true }); + + expect(execCommands).toHaveLength(1); + expect(execCommands[0]).toContain('rm -rf'); + expect(execCommands[0]).toContain('/repo/subdir'); + }); +}); + describe('SshFileSystem.watch', () => { afterEach(() => { vi.useRealTimers(); diff --git a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts b/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts index 3a3f42529a..b5f4ed59ec 100644 --- a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts +++ b/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts @@ -3,6 +3,7 @@ * Uses SFTP over SSH for remote filesystem operations */ +import { posix as pathPosix } from 'node:path'; import type { SFTPWrapper } from 'ssh2'; import { buildRemoteShellCommand } from '@main/core/ssh/lifecycle/remote-shell-profile'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; @@ -821,16 +822,9 @@ export class SshFileSystem implements FileSystemProvider { return fullPath; } - /** Remove single-dot segments from a POSIX path (e.g. /a/./b → /a/b). */ + /** Normalize POSIX path segments the same way the remote shell resolves them. */ private normalizePosixPath(p: string): string { - const parts = p.split('/'); - const out: string[] = []; - for (const seg of parts) { - if (seg === '.') continue; - out.push(seg); - } - // Re-join and collapse any double slashes introduced by the filter - return out.join('/').replace(/\/+/g, '/') || '/'; + return pathPosix.normalize(p.replace(/\/+/g, '/')); } /** diff --git a/apps/emdash-desktop/src/renderer/features/tasks/editor/editor-file-tree.tsx b/apps/emdash-desktop/src/renderer/features/tasks/editor/editor-file-tree.tsx index 42660fc0f9..cc7b351d3c 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/editor/editor-file-tree.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/editor/editor-file-tree.tsx @@ -1,5 +1,13 @@ import { useVirtualizer } from '@tanstack/react-virtual'; -import { ChevronDown, ChevronRight, Copy, FileText, Folder, FolderOpen } from 'lucide-react'; +import { + ChevronDown, + ChevronRight, + Copy, + FileText, + Folder, + FolderOpen, + Trash2, +} from 'lucide-react'; import { runInAction } from 'mobx'; import { observer } from 'mobx-react-lite'; import React, { useEffect, useRef, useState } from 'react'; @@ -30,11 +38,13 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, ContextMenuTrigger, } from '@renderer/lib/ui/context-menu'; import { cn } from '@renderer/utils/utils'; import { basenameFromAnyPath } from '@shared/path-name'; import { activeFilePath as getActiveFilePath } from './pane-selectors'; +import type { FileTabStore } from './stores/file-tab-store'; const MAX_COPY_FILE_BYTES = 10 * 1024 * 1024; @@ -61,6 +71,16 @@ function joinRelPath(dir: string, name: string): string { return dir ? `${dir}/${name}` : name; } +function isPathWithinDeletedItem( + path: string, + deletedPath: string, + deletedType: 'file' | 'directory' +) { + return deletedType === 'file' + ? path === deletedPath + : path === deletedPath || path.startsWith(`${deletedPath}/`); +} + async function importLocalFiles(args: { files: FilesStore; projectId: string; @@ -245,6 +265,52 @@ const FileTreeRow = observer(function FileTreeRow({ } }; + const closeDeletedFileTabs = () => { + for (const { pane } of taskView.paneLayout.groups) { + for (const tab of pane.entriesOfKind('file')) { + if (isPathWithinDeletedItem(tab.path, node.path, node.type)) { + pane.closeTab(tab.tabId); + } + } + } + }; + + const deleteItem = async () => { + try { + const result = await rpc.workspace.fs.removeFile(projectId, workspaceId, node.path, { + recursive: node.type === 'directory', + }); + if (!result.success) throw new Error(resultErrorMessage(result.error)); + if (!result.data.success) throw new Error(result.data.error ?? 'Delete failed.'); + + closeDeletedFileTabs(); + workspace.files.removeNode(node.path); + toast({ title: node.type === 'directory' ? 'Folder deleted' : 'File deleted' }); + } catch (error) { + await workspace.files.loadDir(node.parentPath ?? '', true); + toast({ + title: 'Delete failed', + description: error instanceof Error ? error.message : 'The item could not be deleted.', + variant: 'destructive', + }); + } + }; + + const confirmDelete = () => { + showModal('confirmActionModal', { + title: node.type === 'directory' ? 'Delete folder?' : 'Delete file?', + description: + node.type === 'directory' + ? `"${node.path}" and all of its contents will be deleted from the workspace.` + : `"${node.path}" will be deleted from the workspace.`, + confirmLabel: 'Delete', + variant: 'destructive', + onSuccess: () => { + void deleteItem(); + }, + }); + }; + const [isDropTarget, setIsDropTarget] = useState(false); const handleDragStart = (event: React.DragEvent) => { @@ -376,6 +442,11 @@ const FileTreeRow = observer(function FileTreeRow({ Copy relative path + + + + Delete + );