Files
fidoca/src/dir-listing.js
2026-09-22 19:21:14 +02:00

120 lines
4.4 KiB
JavaScript

'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;
}