import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { dirname, join, relative, sep } from 'node:path' import { listFiles, rgAvailable } from './search-service' export interface FileNode { name: string type: 'dir' | 'file' path: string open?: boolean children?: FileNode[] } /** Directories never walked by the fallback (rg already honors these as globs). */ const IGNORE_DIRS = new Set([ 'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt', '.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo', ]) const MAX_FILE_BYTES = 300_000 const MAX_INDEXED_FILES = 6000 function ignored(name: string): boolean { return IGNORE_DIRS.has(name) || name === '.DS_Store' } function looksBinary(buf: Buffer): boolean { const n = Math.min(buf.length, 8000) for (let i = 0; i < n; i++) if (buf[i] === 0) return true return false } // ---- tree from a flat path list (the rg-backed primary path) ---------------- function sortTree(node: FileNode): void { if (!node.children) return node.children.sort((a, b) => { if (a.type !== b.type) return a.type === 'dir' ? -1 : 1 return a.name.localeCompare(b.name) }) for (const c of node.children) sortTree(c) } /** * Build a nested tree from relative file paths (dirs first, alphabetical). * `dirPaths` are directories to force into the tree even when they hold no * files — empty folders that `rg --files` can never emit (see `listEmptyDirs`). */ export function buildTreeFromPaths(rootName: string, paths: string[], dirPaths: string[] = []): FileNode { const root: FileNode = { name: rootName, type: 'dir', path: '', open: true, children: [] } const dirs = new Map([['', root]]) /** Ensure a directory node (and all its ancestors) exist; return the node. */ function ensureDir(rel: string): FileNode { const existing = dirs.get(rel) if (existing) return existing const parts = rel.split('/').filter(Boolean) let parentPath = '' let parent = root for (let i = 0; i < parts.length; i++) { const curPath = parentPath ? `${parentPath}/${parts[i]}` : parts[i] let dir = dirs.get(curPath) if (!dir) { dir = { name: parts[i], type: 'dir', path: curPath, open: i === 0, children: [] } dirs.set(curPath, dir) parent.children!.push(dir) } parent = dir parentPath = curPath } return parent } for (const rel of paths) { const parts = rel.split('/').filter(Boolean) if (!parts.length) continue const parent = ensureDir(parts.slice(0, -1).join('/')) parent.children!.push({ name: parts[parts.length - 1], type: 'file', path: rel }) } for (const rel of dirPaths) if (rel) ensureDir(rel) sortTree(root) return root } function rootName(root: string): string { return root.split(sep).filter(Boolean).pop() || root } /** Project tree. Primary: rg file list (honors gitignore + excludes) plus the * empty folders rg can't emit. Fallback: a plain recursive walk (when ripgrep * is unavailable) — that already lists empty dirs. */ export async function readTree(root: string): Promise { const [paths, emptyDirs] = await Promise.all([ listFiles(root).catch(() => [] as string[]), listEmptyDirs(root).catch(() => [] as string[]), ]) if (paths.length || emptyDirs.length) return buildTreeFromPaths(rootName(root), paths, emptyDirs) return { name: rootName(root), type: 'dir', path: '', open: true, children: await readDir(root, root, 0) } } /** * Relative paths of directories whose entire subtree holds no files * ("file-empty" folders). `rg --files` lists files only, so an empty folder has * nothing for it to emit and the Explorer would never show it until its first * file lands (and not even after a restart). We inject these alongside the rg * list so a freshly created folder shows up immediately. * * Only *file-empty* dirs are injected, never a dir that contains files: a dir * with files is already represented by those files (gitignore-filtered by rg), * so this can never resurrect a gitignored content directory. Traversal honors * the same IGNORE_DIRS as the fallback walk. `scope` (an absolute dir inside * root) restricts the walk; returned paths stay relative to root. */ export async function listEmptyDirs(root: string, scope?: string): Promise { const out: string[] = [] /** Walk `abs`; return whether its subtree contains at least one file. */ async function walk(abs: string, depth: number): Promise { let entries: import('node:fs').Dirent[] try { entries = await readdir(abs, { withFileTypes: true }) } catch { return false } let hasFile = false const subdirs: string[] = [] for (const e of entries) { if (ignored(e.name)) continue if (e.isFile()) hasFile = true else if (e.isDirectory()) subdirs.push(join(abs, e.name)) } for (const childAbs of subdirs) { const childHasFile = depth < 12 ? await walk(childAbs, depth + 1) : false if (childHasFile) hasFile = true else out.push(relative(root, childAbs).split(sep).join('/')) } return hasFile } await walk(scope || root, 0) return out } /** Find the node at a root-relative path inside a tree ('' is the root). */ function findNode(tree: FileNode, rel: string): FileNode | null { if (rel === '') return tree let node: FileNode | null = tree for (const part of rel.split('/').filter(Boolean)) { node = node?.children?.find((c) => c.name === part) ?? null if (!node) return null } return node } /** * Fresh children for a single directory (root-relative path; '' = root). Used to * re-read a folder on expand/collapse so newly added/removed files show up * without a full tree walk. Stays consistent with the initial tree: rg-backed * (honors gitignore + excludes), with the recursive-walk fallback only when * ripgrep is unavailable. */ export async function readDirChildren(root: string, rel: string): Promise { const abs = rel ? join(root, rel) : root if (await rgAvailable()) { const [paths, emptyDirs] = await Promise.all([ listFiles(root, abs).catch(() => [] as string[]), listEmptyDirs(root, abs).catch(() => [] as string[]), ]) return findNode(buildTreeFromPaths(rootName(root), paths, emptyDirs), rel)?.children ?? [] } return readDir(abs, root, 0) } async function readDir(abs: string, root: string, depth: number): Promise { let entries: import('node:fs').Dirent[] try { entries = await readdir(abs, { withFileTypes: true }) } catch { return [] } const dirs: FileNode[] = [] const files: FileNode[] = [] for (const e of entries) { if (ignored(e.name)) continue const childAbs = join(abs, e.name) const rel = relative(root, childAbs).split(sep).join('/') if (e.isDirectory()) { dirs.push({ name: e.name, type: 'dir', path: rel, open: depth < 1, children: depth < 12 ? await readDir(childAbs, root, depth + 1) : [], }) } else if (e.isFile()) { files.push({ name: e.name, type: 'file', path: rel }) } } dirs.sort((a, b) => a.name.localeCompare(b.name)) files.sort((a, b) => a.name.localeCompare(b.name)) return [...dirs, ...files] } /** Read a single text file (relative path) → string. */ export async function readProjectFile(root: string, rel: string): Promise { const buf = await readFile(join(root, rel)) if (looksBinary(buf)) return '' return buf.toString('utf8') } const IMAGE_MIME: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', jfif: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon', avif: 'image/avif', apng: 'image/apng', } const MAX_IMAGE_BYTES = 25_000_000 /** Read an image file as a `data:` URL for the viewer's — the renderer * can't touch the filesystem, and a data URL sidesteps file:// path/escaping * concerns entirely. Returns '' for a non-image extension, a path escaping the * root, or an oversized/unreadable file. */ export async function readImageDataUrl(root: string, rel: string): Promise { const ext = rel.split('.').pop()?.toLowerCase() ?? '' const mime = IMAGE_MIME[ext] if (!mime) return '' const target = join(root, rel) if (relative(root, target).startsWith('..')) return '' try { const buf = await readFile(target) if (buf.length > MAX_IMAGE_BYTES) return '' return `data:${mime};base64,${buf.toString('base64')}` } catch { return '' } } /** Write a text file (relative path). Used by the editable buffer's save. */ export async function writeProjectFile(root: string, rel: string, content: string): Promise { await writeFile(join(root, rel), content, 'utf8') } /** * Create a new, empty text file (relative path). Creates parent folders as * needed, refuses to escape the project root, and throws if the file already * exists so an accidental name collision never clobbers existing content. */ export async function createProjectFile(root: string, rel: string): Promise { const target = join(root, rel) if (relative(root, target).startsWith('..')) throw new Error('outside project root') const existing = await stat(target).catch(() => null) if (existing) throw new Error('file already exists') await mkdir(dirname(target), { recursive: true }) await writeFile(target, '', { encoding: 'utf8', flag: 'wx' }) } /** * Create a new, empty folder (relative path). Creates parent folders as needed, * refuses to escape the project root, and throws if the folder already exists so * a name collision is surfaced rather than silently swallowed. The empty folder * shows up in the tree immediately (see `listEmptyDirs`). */ export async function createProjectDir(root: string, rel: string): Promise { const target = join(root, rel) if (relative(root, target).startsWith('..')) throw new Error('outside project root') const existing = await stat(target).catch(() => null) if (existing) throw new Error('folder already exists') await mkdir(target, { recursive: true }) } /** * Rename a project file or folder. `rel` is the current relative path, `name` * the new basename (no slashes — a rename stays in the same folder). Returns the * new relative path. Refuses to escape the project root and throws if the target * name is already taken, so a rename never clobbers an existing file. */ export async function renameProjectEntry(root: string, rel: string, name: string): Promise { const clean = name.trim().replace(/\/+$/, '') if (!clean || clean.includes('/') || clean === '.' || clean === '..') throw new Error('invalid name') const from = join(root, rel) const parent = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '' const next = parent ? `${parent}/${clean}` : clean const to = join(root, next) if (relative(root, from).startsWith('..') || relative(root, to).startsWith('..')) throw new Error('outside project root') if (from === to) return rel // Case-only renames (foo.md → Foo.md) hit an existing path on macOS' case // insensitive filesystem, so only guard when the name really differs. const sameName = from.toLowerCase() === to.toLowerCase() if (!sameName && await stat(to).catch(() => null)) throw new Error('name already exists') await rename(from, to) return next } /** Delete a project file or folder (relative path). Stays inside the project root. */ export async function deleteProjectFile(root: string, rel: string): Promise { const target = join(root, rel) if (relative(root, target).startsWith('..')) return // guard against escaping the root await rm(target, { recursive: true, force: true }) } /** * In-memory content index of all (small, text) files — powers content viewing. * Primary: read the rg file list; fallback: walk. Capped for large repos. */ export async function readAll(root: string): Promise> { const paths = await listFiles(root).catch(() => [] as string[]) if (paths.length) return readListed(root, paths) return readAllWalk(root) } async function readListed(root: string, paths: string[]): Promise> { const out: Record = {} let count = 0 for (const rel of paths) { if (count >= MAX_INDEXED_FILES) break try { const abs = join(root, rel) const s = await stat(abs) if (s.size > MAX_FILE_BYTES) continue const buf = await readFile(abs) if (looksBinary(buf)) continue out[rel] = buf.toString('utf8') count++ } catch { /* skip unreadable */ } } return out } async function readAllWalk(root: string): Promise> { const out: Record = {} let count = 0 async function walk(abs: string): Promise { if (count >= MAX_INDEXED_FILES) return let entries: import('node:fs').Dirent[] try { entries = await readdir(abs, { withFileTypes: true }) } catch { return } for (const e of entries) { if (count >= MAX_INDEXED_FILES) return if (ignored(e.name)) continue const childAbs = join(abs, e.name) if (e.isDirectory()) { await walk(childAbs) } else if (e.isFile()) { try { const s = await stat(childAbs) if (s.size > MAX_FILE_BYTES) continue const buf = await readFile(childAbs) if (looksBinary(buf)) continue out[relative(root, childAbs).split(sep).join('/')] = buf.toString('utf8') count++ } catch { /* skip unreadable */ } } } } await walk(root) return out }