'use strict'; /** * Directory listing helpers — .gitignore-aware listing with pagination. */ import fs from 'fs'; import path from 'path'; import ignore from 'ignore'; import { ROOT_DIR } from './config.js'; /** * Build an `ignore` filter that respects all .gitignore files found between * ROOT_DIR and `dir` (inclusive), plus a hardcoded node_modules rule. * Paths fed to ig.ignores() must be relative to ROOT_DIR and use forward slashes. */ export function buildIgnoreFilter(dir) { const ig = ignore().add('node_modules/'); // Collect .gitignore files from ROOT_DIR down to dir const rel = path.relative(ROOT_DIR, dir); const parts = rel === '' ? [] : rel.split(path.sep); const ancestors = [ROOT_DIR]; for (let i = 0; i < parts.length; i++) { ancestors.push(path.join(ROOT_DIR, ...parts.slice(0, i + 1))); } for (const ancestor of ancestors) { const gi = path.join(ancestor, '.gitignore'); if (fs.existsSync(gi)) { try { const rules = fs.readFileSync(gi, 'utf8'); // Rules in a nested .gitignore are relative to that directory; // prefix them so they work relative to ROOT_DIR. const prefix = path.relative(ROOT_DIR, ancestor).replace(/\\/g, '/'); const prefixed = rules .split('\n') .filter(l => l.trim() && !l.startsWith('#')) .map(l => (prefix ? `${prefix}/${l.trim()}` : l.trim())); ig.add(prefixed); } catch { /* ignore read errors */ } } } return ig; } /** * List entries of a directory, optionally recursive, respecting .gitignore. * Returns { entries: Array<{path,type,size}>, total, truncated } * - type: 'd' for directory, 'f' for file * - size: file size in bytes (null for directories) * Dot-directories (names starting with '.', e.g. .git) are skipped unless * `includeDotDirs` is true. * All paths are relative to ROOT_DIR and use forward slashes. */ export function listDir(dir, { seek = 0, limit = 50, recursive = false, includeDotDirs = false } = {}) { const ig = buildIgnoreFilter(dir); const allEntries = []; const collect = (current) => { let entries; try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; } for (const e of entries) { // Skip dot-directories (e.g. .git) by default. if (e.isDirectory() && e.name.startsWith('.') && !includeDotDirs) continue; const full = path.join(current, e.name); const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/'); if (ig.ignores(e.isDirectory() ? rel + '/' : rel)) continue; if (e.isDirectory()) { allEntries.push({ path: rel + '/', type: 'd', size: null }); if (recursive) collect(full); } else { let size = null; try { size = fs.statSync(full).size; } catch { /* skip */ } allEntries.push({ path: rel, type: 'f', size }); } } }; collect(dir); const total = allEntries.length; const sliced = allEntries.slice(seek, seek + limit); const truncated = total - (seek + sliced.length); return { entries: sliced, total, truncated: Math.max(0, truncated) }; } /** * Recursively walk the *files* under `dir`, honoring .gitignore rules * (including nested .gitignore files) plus the hardcoded node_modules rule. * Returns an array of { abs, rel } where `rel` is relative to ROOT_DIR using * forward slashes. Symlinks are skipped to avoid loops. */ export function walkFiles(dir) { const results = []; const walk = (current) => { // Rebuild per directory so nested .gitignore files apply to their subtree. const ig = buildIgnoreFilter(current); let entries; try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; } for (const e of entries) { const full = path.join(current, e.name); const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/'); if (ig.ignores(e.isDirectory() ? rel + '/' : rel)) continue; if (e.isDirectory()) { walk(full); } else if (e.isFile()) { results.push({ abs: full, rel }); } } }; walk(dir); return results; }