This commit is contained in:
seb
2026-09-22 18:53:38 +02:00
parent dd197d8b0f
commit 09365539e5
12 changed files with 763 additions and 0 deletions

83
src/dir-listing.js Normal file
View File

@@ -0,0 +1,83 @@
'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)
* All paths are relative to ROOT_DIR and use forward slashes.
*/
export function listDir(dir, { seek = 0, limit = 50, recursive = 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) {
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) };
}