@@ -1,6 +1,6 @@
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, relative, sep } from 'node:path'
|
||||
import { listFiles } from './search-service'
|
||||
import { listFiles, rgAvailable } from './search-service'
|
||||
|
||||
export interface FileNode {
|
||||
name: string
|
||||
@@ -40,31 +40,43 @@ function sortTree(node: FileNode): void {
|
||||
for (const c of node.children) sortTree(c)
|
||||
}
|
||||
|
||||
/** Build a nested tree from relative file paths (dirs first, alphabetical). */
|
||||
export function buildTreeFromPaths(rootName: string, paths: string[]): FileNode {
|
||||
/**
|
||||
* 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]])
|
||||
for (const rel of paths) {
|
||||
|
||||
/** 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 isFile = i === parts.length - 1
|
||||
const curPath = parentPath ? `${parentPath}/${parts[i]}` : parts[i]
|
||||
if (isFile) {
|
||||
parent.children!.push({ name: parts[i], type: 'file', path: curPath })
|
||||
} else {
|
||||
let dir = dirs.get(curPath)
|
||||
if (!dir) {
|
||||
dir = { name: parts[i], type: 'dir', path: curPath, open: parts.slice(0, i + 1).length <= 1, children: [] }
|
||||
dirs.set(curPath, dir)
|
||||
parent.children!.push(dir)
|
||||
}
|
||||
parent = dir
|
||||
parentPath = curPath
|
||||
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
|
||||
}
|
||||
@@ -73,14 +85,89 @@ function rootName(root: string): string {
|
||||
return root.split(sep).filter(Boolean).pop() || root
|
||||
}
|
||||
|
||||
/** Project tree. Primary: rg file list (honors gitignore + excludes). Fallback:
|
||||
* a plain recursive walk (when ripgrep is unavailable). */
|
||||
/** 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 = await listFiles(root).catch(() => [] as string[])
|
||||
if (paths.length) return buildTreeFromPaths(rootName(root), paths)
|
||||
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 {
|
||||
@@ -134,6 +221,20 @@ export async function createProjectFile(root: string, rel: string): Promise<void
|
||||
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 })
|
||||
}
|
||||
|
||||
/** 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)
|
||||
|
||||
Reference in New Issue
Block a user