From 497991d87e4647b22994423222d4f65eb71bb9e8 Mon Sep 17 00:00:00 2001 From: 0ROBR0 <0r0b0r01188@gmail.com> Date: Fri, 29 May 2026 13:28:34 +0530 Subject: [PATCH 1/2] feat(files): add workspace file browser with path sandbox Expose list/read/write IPC for files under homedir and cwd, with a split-pane Files screen for browsing and editing text files. Co-authored-by: Cursor --- src/main/files.ts | 47 ++++++++++++++ src/main/index.ts | 3 + src/preload/index.d.ts | 6 ++ src/preload/index.ts | 7 +++ src/renderer/src/assets/icons/index.tsx | 1 + src/renderer/src/assets/main.css | 42 +++++++++++++ src/renderer/src/screens/Files/Files.tsx | 73 ++++++++++++++++++++++ src/renderer/src/screens/Layout/Layout.tsx | 10 +++ src/shared/i18n/locales/en/navigation.ts | 1 + tests/files-path.test.ts | 37 +++++++++++ tests/ipc-handlers.test.ts | 9 ++- tests/preload-api-surface.test.ts | 7 +++ 12 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 src/main/files.ts create mode 100644 src/renderer/src/screens/Files/Files.tsx create mode 100644 tests/files-path.test.ts diff --git a/src/main/files.ts b/src/main/files.ts new file mode 100644 index 000000000..d52e03b17 --- /dev/null +++ b/src/main/files.ts @@ -0,0 +1,47 @@ +import { ipcMain } from "electron"; +import { readdirSync, readFileSync, statSync, existsSync } from "fs"; +import { join, resolve, normalize, sep } from "path"; +import { homedir } from "os"; +import { safeWriteFile } from "./utils"; + +const ALLOWED_ROOTS = [homedir(), process.cwd()]; + +function isPathUnderRoot(resolvedTarget: string, root: string): boolean { + const resolvedRoot = normalize(resolve(root)); + if (resolvedTarget === resolvedRoot) return true; + const rootPrefix = resolvedRoot.endsWith(sep) ? resolvedRoot : `${resolvedRoot}${sep}`; + return resolvedTarget.startsWith(rootPrefix); +} + +function isPathAllowed(target: string): boolean { + const resolved = normalize(resolve(target)); + return ALLOWED_ROOTS.some((root) => isPathUnderRoot(resolved, root)); +} + +export function registerFilesHandlers(): void { + ipcMain.handle("files-list-dir", (_event, dir: string) => { + const target = dir || homedir(); + if (!isPathAllowed(target)) throw new Error("Path not allowed"); + if (!existsSync(target)) return []; + + return readdirSync(target).map((name) => { + const path = join(target, name); + const st = statSync(path); + return { name, isDir: st.isDirectory(), path }; + }).sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + }); + + ipcMain.handle("files-read", (_event, path: string) => { + if (!isPathAllowed(path)) throw new Error("Path not allowed"); + return readFileSync(path, "utf-8"); + }); + + ipcMain.handle("files-write", (_event, path: string, content: string) => { + if (!isPathAllowed(path)) throw new Error("Path not allowed"); + safeWriteFile(path, content); + return true; + }); +} diff --git a/src/main/index.ts b/src/main/index.ts index 392f8a85c..9694f9fee 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -118,6 +118,7 @@ import { deleteProfile, setActiveProfile, } from "./profiles"; +import { registerFilesHandlers } from "./files"; import { readMemory, addMemoryEntry, @@ -1543,6 +1544,8 @@ function setupIPC(): void { return sshReadLogs(conn.ssh, logFile, lines); return readLogs(logFile, lines); }); + + registerFilesHandlers(); } function buildMenu(): void { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index ea4d82e74..4caebc69f 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -770,6 +770,12 @@ interface HermesAPI { logFile?: string, lines?: number, ) => Promise<{ content: string; path: string }>; + + filesListDir: ( + dir: string, + ) => Promise>; + filesRead: (path: string) => Promise; + filesWrite: (path: string, content: string) => Promise; } declare global { diff --git a/src/preload/index.ts b/src/preload/index.ts index 9f9e0efb3..8fcded697 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -952,6 +952,13 @@ const hermesAPI = { lines?: number, ): Promise<{ content: string; path: string }> => ipcRenderer.invoke("read-logs", logFile, lines), + + filesListDir: (dir: string): Promise> => + ipcRenderer.invoke("files-list-dir", dir), + filesRead: (path: string): Promise => + ipcRenderer.invoke("files-read", path), + filesWrite: (path: string, content: string): Promise => + ipcRenderer.invoke("files-write", path, content), }; if (process.contextIsolated) { diff --git a/src/renderer/src/assets/icons/index.tsx b/src/renderer/src/assets/icons/index.tsx index 52386a771..574db2201 100644 --- a/src/renderer/src/assets/icons/index.tsx +++ b/src/renderer/src/assets/icons/index.tsx @@ -37,3 +37,4 @@ export { Ban } from "lucide-react"; export { RotateCcw } from "lucide-react"; export { Loader2 as Spinner } from "lucide-react"; export { Columns3 as Kanban } from "lucide-react"; +export { FolderOpen } from "lucide-react"; diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 4f89794ca..2358e7560 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -5959,3 +5959,45 @@ body { font-size: 11px; color: var(--text-muted); } + +/* ── Files browser ───────────────────────────────────────────────────── */ + +.screen-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.screen-title { + font-size: 18px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.btn-primary { + padding: 8px 14px; + border-radius: 6px; + font-size: 13px; + cursor: pointer; + border: none; + background: var(--accent); + color: #fff; +} + +.files-screen { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.files-split { display: flex; flex: 1; overflow: hidden; } +.files-tree { width: 240px; border-right: 1px solid var(--border); overflow-y: auto; padding: 8px; } +.files-entry { display: block; width: 100%; text-align: left; padding: 6px 8px; border: none; background: transparent; color: var(--text-primary); cursor: pointer; font-size: 13px; } +.files-editor { flex: 1; display: flex; flex-direction: column; } +.files-textarea { flex: 1; border: none; padding: 12px; font-family: monospace; font-size: 13px; background: var(--bg-primary); color: var(--text-primary); resize: none; } +.files-path { padding: 8px 12px; font-size: 12px; color: var(--text-muted); border-bottom: 1px solid var(--border); } diff --git a/src/renderer/src/screens/Files/Files.tsx b/src/renderer/src/screens/Files/Files.tsx new file mode 100644 index 000000000..5898499c1 --- /dev/null +++ b/src/renderer/src/screens/Files/Files.tsx @@ -0,0 +1,73 @@ +import { useState, useEffect } from "react"; + +interface FileEntry { + name: string; + isDir: boolean; + path: string; +} + +function Files(): React.JSX.Element { + const [cwd, setCwd] = useState(""); + const [entries, setEntries] = useState([]); + const [selected, setSelected] = useState(null); + const [content, setContent] = useState(""); + const [dirty, setDirty] = useState(false); + + useEffect(() => { + window.hermesAPI.filesListDir("").then((list) => { + if (list.length > 0) { + setCwd(list[0].path.split("/").slice(0, -1).join("/") || list[0].path); + } + }); + }, []); + + useEffect(() => { + if (!cwd) return; + window.hermesAPI.filesListDir(cwd).then(setEntries); + }, [cwd]); + + async function openFile(path: string): Promise { + const text = await window.hermesAPI.filesRead(path); + setSelected(path); + setContent(text); + setDirty(false); + } + + async function save(): Promise { + if (!selected) return; + await window.hermesAPI.filesWrite(selected, content); + setDirty(false); + } + + return ( +
+
+

Files

+ {selected && ( + + )} +
+
+ +
+ {selected ? ( + <> +
{selected}
+