cntx-ui
Version:
Autonomous Repository Intelligence engine with web UI and MCP server. Unified semantic code understanding, local RAG, and agent working memory.
306 lines (305 loc) • 10.9 kB
JavaScript
/**
* File System Manager for cntx-ui
* Handles file operations, pattern matching, and directory traversal
*/
import { readdirSync, readFileSync, statSync, existsSync, watch } from 'fs';
import { join, relative, extname, basename } from 'path';
export default class FileSystemManager {
CWD;
verbose;
watchers;
ignorePatterns;
constructor(cwd = process.cwd(), options = {}) {
this.CWD = cwd;
this.verbose = options.verbose || false;
this.watchers = [];
this.ignorePatterns = [];
this.loadIgnorePatterns();
}
loadIgnorePatterns() {
const ignorePath = join(this.CWD, '.cntxignore');
if (existsSync(ignorePath)) {
try {
const content = readFileSync(ignorePath, 'utf8');
const patterns = content.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
this.ignorePatterns = patterns;
if (this.verbose)
console.log(`📋 Loaded ${patterns.length} patterns from .cntxignore`);
}
catch (e) {
console.error('Failed to load .cntxignore:', e.message);
}
}
}
// === File Traversal ===
getAllFiles(dir = this.CWD, files = []) {
try {
const items = readdirSync(dir);
for (const item of items) {
const fullPath = join(dir, item);
// Skip if should be ignored
if (this.shouldIgnoreAnything(item, fullPath)) {
continue;
}
try {
const stat = statSync(fullPath);
if (stat.isDirectory()) {
this.getAllFiles(fullPath, files);
}
else if (stat.isFile()) {
if (!this.shouldIgnoreFile(fullPath)) {
files.push(fullPath);
}
}
}
catch (error) {
// Skip files we can't stat (permission issues, broken symlinks, etc.)
continue;
}
}
}
catch (error) {
if (this.verbose) {
console.warn(`Cannot read directory ${dir}: ${error.message}`);
}
}
return files;
}
getFileTree() {
const files = this.getAllFiles();
return files.map(file => {
const stats = this.getFileStats(file);
const relativePath = relative(this.CWD, file);
return {
path: relativePath,
fullPath: file,
size: stats.size,
modified: stats.mtime.toISOString(),
type: this.getFileType(file)
};
});
}
// === Pattern Matching ===
matchesPattern(path, pattern) {
// Convert glob pattern to regex
let regexPattern = pattern
.replace(/\./g, '\\.') // Escape dots
.replace(/\*\*/g, '___GLOBSTAR___') // Temporary replace **
.replace(/\*/g, '[^/]*') // * matches anything except /
.replace(/___GLOBSTAR___/g, '.*') // ** matches anything including /
.replace(/\?/g, '[^/]'); // ? matches single char except /
// Ensure pattern matches from start or after a directory separator
if (!regexPattern.startsWith('.*') && !regexPattern.startsWith('[^/]*')) {
regexPattern = '(^|/)' + regexPattern;
}
// Ensure pattern matches to end or before a directory separator
if (!regexPattern.endsWith('.*') && !regexPattern.endsWith('[^/]*')) {
regexPattern = regexPattern + '($|/)';
}
const regex = new RegExp(regexPattern);
const relativePath = relative(this.CWD, path);
return regex.test(relativePath) || regex.test(path);
}
shouldIgnoreFile(filePath) {
const relativePath = relative(this.CWD, filePath);
return this.ignorePatterns.some(pattern => this.matchesPattern(filePath, pattern) ||
this.matchesPattern(relativePath, pattern));
}
shouldIgnoreAnything(itemName, fullPath) {
// Hardcoded bad directories and files to always ignore
const badDirs = [
'node_modules', '.git', '.svn', '.hg', '.bzr', '_darcs',
'CVS', '.cvs', 'RCS', 'SCCS', '{arch}', '.arch-ids',
'.monotone', '_MTN', '.fslckout', '_FOSSIL_',
'.fos', 'BitKeeper', 'ChangeSet', '.teamcity',
'.idea', '.vscode', '.vs', '.gradle', '.settings',
'target', 'build', 'dist', 'out', 'bin', 'obj',
'.next', '.nuxt', '.vite', '.tmp', '.temp',
'__pycache__', '.pytest_cache', '.coverage',
'.nyc_output', 'coverage', 'lcov-report'
];
const badExtensions = [
'.log', '.tmp', '.temp', '.cache', '.pid', '.lock',
'.swp', '.swo', '.DS_Store', 'Thumbs.db', '.env',
'.min.js', '.min.css', '.map', '.pyc', '.pyo',
'.class', '.jar', '.exe', '.dll', '.so', '.dylib',
'.o', '.a', '.obj', '.lib', '.pdb'
];
const badFiles = [
'.gitignore', '.gitkeep', '.gitattributes',
'.eslintcache', '.prettierignore',
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
'npm-debug.log', 'yarn-debug.log', 'yarn-error.log'
];
// Check bad directories
if (badDirs.includes(itemName)) {
return true;
}
// Check bad extensions
const ext = extname(itemName);
if (badExtensions.includes(ext)) {
return true;
}
// Check bad files
if (badFiles.includes(itemName)) {
return true;
}
// Check if it's a hidden file/directory (starts with .)
if (itemName.startsWith('.') && itemName !== '.cntx') {
return true;
}
// Check ignore patterns if loaded
if (this.ignorePatterns.length > 0) {
const relativePath = relative(this.CWD, fullPath);
if (this.ignorePatterns.some(pattern => this.matchesPattern(fullPath, pattern) ||
this.matchesPattern(relativePath, pattern))) {
return true;
}
}
return false;
}
// === File Metadata ===
getFileStats(filePath) {
try {
return statSync(filePath);
}
catch (error) {
// Return a mock stats object for missing files
return {
size: 0,
mtime: new Date(0),
ctime: new Date(0),
isDirectory: () => false,
isFile: () => false
};
}
}
getFileType(filePath) {
const ext = extname(filePath).toLowerCase();
const fileName = basename(filePath).toLowerCase();
// Programming languages
if (ext.match(/\.(js|jsx|mjs|cjs)$/))
return 'javascript';
if (ext.match(/\.(ts|tsx)$/))
return 'typescript';
if (ext.match(/\.(py|pyw)$/))
return 'python';
if (ext.match(/\.(java|class)$/))
return 'java';
if (ext.match(/\.(c|h)$/))
return 'c';
if (ext.match(/\.(cpp|cxx|cc|hpp|hxx)$/))
return 'cpp';
if (ext.match(/\.(cs)$/))
return 'csharp';
if (ext.match(/\.(go)$/))
return 'go';
if (ext.match(/\.(rs)$/))
return 'rust';
if (ext.match(/\.(php)$/))
return 'php';
if (ext.match(/\.(rb)$/))
return 'ruby';
// Web technologies
if (ext.match(/\.(html|htm)$/))
return 'html';
if (ext.match(/\.(css|scss|sass|less|styl)$/))
return 'stylesheet';
if (ext.match(/\.(vue)$/))
return 'vue';
// Data formats
if (ext.match(/\.(json)$/))
return 'json';
if (ext.match(/\.(xml)$/))
return 'xml';
if (ext.match(/\.(yaml|yml)$/))
return 'yaml';
if (ext.match(/\.(toml)$/))
return 'toml';
if (ext.match(/\.(ini)$/))
return 'ini';
if (ext.match(/\.(csv)$/))
return 'csv';
// Documentation
if (ext.match(/\.(md|markdown)$/))
return 'markdown';
if (ext.match(/\.(txt)$/))
return 'text';
if (ext.match(/\.(rst)$/))
return 'restructuredtext';
// Media
if (ext.match(/\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/))
return 'image';
if (ext.match(/\.(mp4|avi|mov|wmv|flv|webm)$/))
return 'video';
if (ext.match(/\.(mp3|wav|flac|aac|ogg)$/))
return 'audio';
// Archives
if (ext.match(/\.(zip|tar|gz|bz2|xz|7z|rar)$/))
return 'archive';
// Configuration
if (fileName.includes('config') || fileName.includes('setup'))
return 'configuration';
return 'unknown';
}
// === File Watching ===
startWatching(onFileChange) {
try {
// Watch the current working directory recursively
const watcher = watch(this.CWD, { recursive: true }, (eventType, filename) => {
if (filename && !this.shouldIgnoreAnything(basename(filename), join(this.CWD, filename))) {
onFileChange?.(eventType, filename);
}
});
this.watchers.push(watcher);
if (this.verbose) {
console.log('📁 File watcher started');
}
}
catch (error) {
if (this.verbose) {
console.error('Failed to start file watcher:', error.message);
}
}
}
stopWatching() {
this.watchers.forEach(watcher => {
try {
watcher.close();
}
catch (error) {
if (this.verbose) {
console.error('Failed to close watcher:', error.message);
}
}
});
this.watchers = [];
if (this.verbose) {
console.log('📁 File watchers stopped');
}
}
// === Utilities ===
setIgnorePatterns(patterns) {
this.ignorePatterns = patterns;
}
relativePath(filePath) {
return relative(this.CWD, filePath);
}
fullPath(relativePath) {
return join(this.CWD, relativePath);
}
isValidPath(filePath) {
try {
return existsSync(filePath);
}
catch (error) {
return false;
}
}
// === Cleanup ===
destroy() {
this.stopWatching();
}
}