Files
helder/src/main/fs-service.ts
2026-08-17 10:43:38 +02:00

358 lines
14 KiB
TypeScript

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<string, FileNode>([['', 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<FileNode> {
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<string[]> {
const out: string[] = []
/** Walk `abs`; return whether its subtree contains at least one file. */
async function walk(abs: string, depth: number): Promise<boolean> {
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<FileNode[]> {
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<FileNode[]> {
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<string> {
const buf = await readFile(join(root, rel))
if (looksBinary(buf)) return ''
return buf.toString('utf8')
}
const IMAGE_MIME: Record<string, string> = {
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 <img> — 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<string> {
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<void> {
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<void> {
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<void> {
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<string> {
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<void> {
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<Record<string, string>> {
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<Record<string, string>> {
const out: Record<string, string> = {}
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<Record<string, string>> {
const out: Record<string, string> = {}
let count = 0
async function walk(abs: string): Promise<void> {
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
}