diff --git a/src/main/files.ts b/src/main/files.ts new file mode 100644 index 000000000..e7da1280d --- /dev/null +++ b/src/main/files.ts @@ -0,0 +1,256 @@ +import { ipcMain } from "electron"; +import { + existsSync, + lstatSync, + readFileSync, + realpathSync, + readdirSync, + statSync, +} from "fs"; +import { dirname, isAbsolute, join, relative, resolve } from "path"; +import { HERMES_HOME } from "./installer"; +import { safeWriteFile } from "./utils"; +import { isRemoteOnlyMode } from "./hermes"; + +const ROOT_FILE = join(HERMES_HOME, "desktop", "files-workspace-root.txt"); +const MAX_READ_BYTES = 1024 * 1024; +const MAX_WRITE_BYTES = 1024 * 1024; + +interface FileEntry { + name: string; + isDir: boolean; + path: string; + error?: string; +} + +type FilesFailure = { + success: false; + error: string; + unsupportedMode?: boolean; +}; + +type FilesSuccess = { + success: true; + data?: T; +}; + +type FilesResult = FilesSuccess | FilesFailure; + +function isFilesFailure( + result: { realRoot: string; realTarget: string } | { realRoot: string; target: string } | FilesResult, +): result is FilesResult { + return "success" in result && result.success === false; +} + +interface ListResult { + root: string | null; + cwd: string | null; + entries: FileEntry[]; +} + +function unsupported(): FilesFailure { + return { + success: false, + unsupportedMode: true, + error: "Files is only available in local or SSH tunnel modes.", + }; +} + +function fail(error: string): FilesFailure { + return { success: false, error }; +} + +function readWorkspaceRoot(): string | null { + try { + if (!existsSync(ROOT_FILE)) return null; + const raw = readFileSync(ROOT_FILE, "utf-8").trim(); + if (!raw) return null; + return realpathSync(raw); + } catch { + return null; + } +} + +function writeWorkspaceRoot(root: string): void { + safeWriteFile(ROOT_FILE, root); +} + +export function isPathUnderRoot(realTarget: string, realRoot: string): boolean { + const rel = relative(realRoot, realTarget); + return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel)); +} + +function realpathParentOrThrow(parent: string): string { + try { + if (!existsSync(parent)) throw new Error("Path not allowed"); + return realpathSync(parent); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error("Path not allowed"); + } + throw err; + } +} + +/** Resolve a path and ensure it (or its parent for new files) stays under workspace roots. */ +export function assertPathAllowed(target: string, workspaceRoots: string[]): string { + if (workspaceRoots.length === 0) { + throw new Error("Path not allowed"); + } + + let resolved: string; + try { + resolved = existsSync(target) ? realpathSync(target) : resolve(target); + } catch { + throw new Error("Path not allowed"); + } + + const parentReal = realpathParentOrThrow(dirname(resolved)); + const allowed = workspaceRoots.some((root) => { + try { + const realRoot = realpathSync(root); + if (isPathUnderRoot(parentReal, realRoot)) return true; + if (existsSync(resolved)) { + return isPathUnderRoot(realpathSync(target), realRoot); + } + return false; + } catch { + return false; + } + }); + + if (!allowed) throw new Error("Path not allowed"); + return resolved; +} + +function validateRoot(): string | FilesFailure { + const root = readWorkspaceRoot(); + if (!root) return fail("Choose a workspace folder first."); + return root; +} + +function validateExistingTarget( + path: unknown, +): { realRoot: string; realTarget: string } | FilesFailure { + if (typeof path !== "string") return fail("Invalid path."); + const realRoot = validateRoot(); + if (typeof realRoot !== "string") return realRoot; + try { + const realTarget = assertPathAllowed(path || realRoot, [realRoot]); + return { realRoot, realTarget }; + } catch (err) { + if (err instanceof Error && err.message === "Path not allowed") { + return fail("Path is outside the workspace."); + } + return fail(err instanceof Error ? err.message : String(err)); + } +} + +function validateWriteTarget(path: unknown): { realRoot: string; target: string } | FilesFailure { + if (typeof path !== "string" || !path.trim()) return fail("Invalid path."); + const realRoot = validateRoot(); + if (typeof realRoot !== "string") return realRoot; + const target = resolve(path); + try { + assertPathAllowed(target, [realRoot]); + if (existsSync(target)) { + const realTarget = realpathSync(target); + if (statSync(realTarget).isDirectory()) return fail("Cannot write to a directory."); + } + return { realRoot, target }; + } catch (err) { + if (err instanceof Error && err.message === "Path not allowed") { + return fail("Write target is outside the workspace."); + } + return fail(err instanceof Error ? err.message : String(err)); + } +} + +function listEntry(parent: string, name: string, realRoot: string): FileEntry { + const path = join(parent, name); + try { + const lst = lstatSync(path); + if (lst.isSymbolicLink()) { + const real = realpathSync(path); + if (!isPathUnderRoot(real, realRoot)) { + return { name, isDir: false, path, error: "Outside workspace" }; + } + return { name, isDir: statSync(real).isDirectory(), path }; + } + return { name, isDir: lst.isDirectory(), path }; + } catch (err) { + return { + name, + isDir: false, + path, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +function containsBinaryBytes(buffer: Buffer): boolean { + return buffer.includes(0); +} + +export function registerFilesHandlers(): void { + ipcMain.handle("files-get-workspace-root", (): FilesResult<{ root: string | null }> => { + if (isRemoteOnlyMode()) return unsupported(); + return { success: true, data: { root: readWorkspaceRoot() } }; + }); + + ipcMain.handle("files-set-workspace-root", (_event, dir: string): FilesResult<{ root: string }> => { + if (isRemoteOnlyMode()) return unsupported(); + if (typeof dir !== "string" || !dir.trim()) return fail("Invalid workspace folder."); + try { + const realRoot = realpathSync(dir); + if (!statSync(realRoot).isDirectory()) return fail("Workspace root must be a directory."); + writeWorkspaceRoot(realRoot); + return { success: true, data: { root: realRoot } }; + } catch (err) { + return fail(err instanceof Error ? err.message : String(err)); + } + }); + + ipcMain.handle("files-list-dir", (_event, dir: string): FilesResult => { + if (isRemoteOnlyMode()) return unsupported(); + const target = validateExistingTarget(dir); + if (isFilesFailure(target)) return target; + if (!statSync(target.realTarget).isDirectory()) return fail("Path is not a directory."); + + const entries = readdirSync(target.realTarget) + .map((name) => listEntry(target.realTarget, name, target.realRoot)) + .sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return { + success: true, + data: { root: target.realRoot, cwd: target.realTarget, entries }, + }; + }); + + ipcMain.handle("files-read", (_event, path: string): FilesResult<{ text: string }> => { + if (isRemoteOnlyMode()) return unsupported(); + const target = validateExistingTarget(path); + if (isFilesFailure(target)) return target; + const st = statSync(target.realTarget); + if (!st.isFile()) return fail("Path is not a file."); + if (st.size > MAX_READ_BYTES) return fail("File is too large to open."); + const raw = readFileSync(target.realTarget); + if (containsBinaryBytes(raw)) return fail("Binary files are not supported."); + return { success: true, data: { text: raw.toString("utf-8") } }; + }); + + ipcMain.handle("files-write", (_event, path: string, content: string): FilesResult => { + if (isRemoteOnlyMode()) return unsupported(); + if (typeof content !== "string") return fail("File content must be text."); + if (Buffer.byteLength(content, "utf-8") > MAX_WRITE_BYTES) { + return fail("File is too large to save."); + } + const target = validateWriteTarget(path); + if (isFilesFailure(target)) return target; + safeWriteFile(target.target, content); + return { success: 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..b982d2827 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -770,6 +770,34 @@ interface HermesAPI { logFile?: string, lines?: number, ) => Promise<{ content: string; path: string }>; + + filesGetWorkspaceRoot: () => Promise<{ + success: boolean; + data?: { root: string | null }; + error?: string; + }>; + filesSetWorkspaceRoot: ( + dir: string, + ) => Promise<{ success: boolean; data?: { root: string }; error?: string }>; + filesListDir: ( + dir: string, + ) => Promise<{ + success: boolean; + data?: { + root: string | null; + cwd: string | null; + entries: Array<{ name: string; isDir: boolean; path: string; error?: string }>; + }; + error?: string; + unsupportedMode?: boolean; + }>; + filesRead: ( + path: string, + ) => Promise<{ success: boolean; data?: { text: string }; error?: string }>; + filesWrite: ( + path: string, + content: string, + ) => Promise<{ success: boolean; error?: string }>; } declare global { diff --git a/src/preload/index.ts b/src/preload/index.ts index 9f9e0efb3..fbed11ac7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -952,6 +952,23 @@ const hermesAPI = { lines?: number, ): Promise<{ content: string; path: string }> => ipcRenderer.invoke("read-logs", logFile, lines), + + // File browser + filesGetWorkspaceRoot: (): Promise<{ success: boolean; data?: { root: string | null }; error?: string }> => + ipcRenderer.invoke("files-get-workspace-root"), + filesSetWorkspaceRoot: (dir: string): Promise<{ success: boolean; data?: { root: string }; error?: string }> => + ipcRenderer.invoke("files-set-workspace-root", dir), + filesListDir: (dir: string): Promise<{ + success: boolean; + data?: { root: string | null; cwd: string | null; entries: Array<{ name: string; isDir: boolean; path: string; error?: string }> }; + error?: string; + unsupportedMode?: boolean; + }> => + ipcRenderer.invoke("files-list-dir", dir), + filesRead: (path: string): Promise<{ success: boolean; data?: { text: string }; error?: string }> => + ipcRenderer.invoke("files-read", path), + filesWrite: (path: string, content: string): Promise<{ success: boolean; error?: string }> => + 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..c056f2930 --- /dev/null +++ b/src/renderer/src/screens/Files/Files.tsx @@ -0,0 +1,134 @@ +import { useState, useEffect } from "react"; + +interface FileEntry { + name: string; + isDir: boolean; + path: string; + error?: string; +} + +function Files(): React.JSX.Element { + const [rootInput, setRootInput] = useState(""); + const [root, setRoot] = useState(null); + const [cwd, setCwd] = useState(""); + const [entries, setEntries] = useState([]); + const [selected, setSelected] = useState(null); + const [content, setContent] = useState(""); + const [dirty, setDirty] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + window.hermesAPI.filesGetWorkspaceRoot().then((res) => { + if (res.success && res.data?.root) { + setRoot(res.data.root); + setRootInput(res.data.root); + setCwd(res.data.root); + } else if (res.error) { + setError(res.error); + } + }); + }, []); + + useEffect(() => { + if (!cwd) return; + window.hermesAPI.filesListDir(cwd).then((res) => { + if (!res.success || !res.data) { + setError(res.error || "Unable to list folder"); + setEntries([]); + return; + } + setError(""); + setRoot(res.data.root); + setCwd(res.data.cwd || ""); + setEntries(res.data.entries); + }); + }, [cwd]); + + async function openFile(path: string): Promise { + const res = await window.hermesAPI.filesRead(path); + if (!res.success || !res.data) { + setError(res.error || "Unable to open file"); + return; + } + setSelected(path); + setContent(res.data.text); + setDirty(false); + setError(""); + } + + async function save(): Promise { + if (!selected) return; + const res = await window.hermesAPI.filesWrite(selected, content); + if (!res.success) { + setError(res.error || "Unable to save file"); + return; + } + setDirty(false); + setError(""); + } + + async function setWorkspaceRoot(): Promise { + const res = await window.hermesAPI.filesSetWorkspaceRoot(rootInput); + if (!res.success || !res.data) { + setError(res.error || "Unable to use workspace folder"); + return; + } + setRoot(res.data.root); + setCwd(res.data.root); + setSelected(null); + setContent(""); + setDirty(false); + setError(""); + } + + return ( +
+
+

Files

+
+ setRootInput(e.target.value)} + placeholder="Workspace root" + /> + + {selected && ( + + )} +
+
+ {error &&
{error}
} +
+ +
+ {selected ? ( + <> +
{selected}
+