taildir
Version:
A simple directory watcher that recursively monitors file changes using only native Node.js libraries
170 lines (141 loc) • 5.02 kB
JavaScript
const fs = require('fs');
const path = require('path');
class DirectoryWatcher {
constructor(directory, options = {}) {
this.directory = path.resolve(directory);
this.watchers = new Map();
this.debounceTimers = new Map();
this.fileSizes = new Map();
this.extensions = options.extensions || [];
}
watch() {
console.log(`Starting to watch: ${this.directory}`);
this.watchDirectory(this.directory);
}
watchDirectory(dir) {
try {
const files = fs.readdirSync(dir, { withFileTypes: true });
files.forEach(file => {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
this.watchDirectory(fullPath);
} else if (!this.shouldIgnoreFile(fullPath)) {
const stats = fs.statSync(fullPath);
this.fileSizes.set(fullPath, stats.size);
}
});
if (!this.watchers.has(dir)) {
const watcher = fs.watch(dir, { recursive: false }, (eventType, filename) => {
if (filename) {
const filePath = path.join(dir, filename);
this.handleFileChange(eventType, filePath);
}
});
watcher.on('error', (error) => {
console.error(`Error watching ${dir}:`, error.message);
this.watchers.delete(dir);
});
this.watchers.set(dir, watcher);
}
} catch (error) {
console.error(`Error accessing directory ${dir}:`, error.message);
}
}
shouldIgnoreFile(filePath) {
const basename = path.basename(filePath);
// Ignore hidden files and common temporary files
if (basename.startsWith('.')) return true;
if (basename.endsWith('~')) return true;
if (basename.endsWith('.tmp')) return true;
// If extensions filter is set, only watch files with those extensions
if (this.extensions.length > 0) {
const ext = path.extname(filePath).toLowerCase().slice(1); // Remove the dot
if (!this.extensions.includes(ext)) return true;
}
return false;
}
handleFileChange(eventType, filePath) {
if (this.shouldIgnoreFile(filePath)) return;
const key = `${eventType}-${filePath}`;
if (this.debounceTimers.has(key)) {
clearTimeout(this.debounceTimers.get(key));
}
const timer = setTimeout(() => {
this.debounceTimers.delete(key);
fs.stat(filePath, (err, stats) => {
const timestamp = new Date().toISOString();
if (err) {
if (err.code === 'ENOENT') {
this.fileSizes.delete(filePath);
if (this.watchers.has(filePath)) {
this.watchers.get(filePath).close();
this.watchers.delete(filePath);
}
}
} else {
if (stats.isDirectory()) {
if (eventType === 'rename' && !this.watchers.has(filePath)) {
this.watchDirectory(filePath);
}
} else {
if (eventType === 'rename') {
// New file created
this.fileSizes.set(filePath, stats.size);
if (stats.size > 0) {
this.outputFileContent(filePath, 0, stats.size);
} else {
// Empty file created, just show the name
console.log(`\n==> ${filePath} <==`);
}
} else {
const previousSize = this.fileSizes.get(filePath);
const currentSize = stats.size;
if (previousSize === undefined) {
// First time seeing this file, just track its size
this.fileSizes.set(filePath, currentSize);
} else if (currentSize > previousSize) {
this.outputFileContent(filePath, previousSize, currentSize);
this.fileSizes.set(filePath, currentSize);
} else if (currentSize < previousSize) {
this.fileSizes.set(filePath, currentSize);
}
}
}
}
});
}, 100);
this.debounceTimers.set(key, timer);
}
outputFileContent(filePath, startPosition, endPosition) {
const stream = fs.createReadStream(filePath, {
start: startPosition,
end: endPosition - 1
});
let content = '';
stream.on('data', (chunk) => {
content += chunk.toString();
});
stream.on('end', () => {
const trimmedContent = content.trim();
if (trimmedContent) {
console.log(`\n==> ${filePath} <==`);
console.log(trimmedContent);
}
});
stream.on('error', (error) => {
console.error(`Error reading file ${filePath}: ${error.message}`);
});
}
stop() {
for (const [path, watcher] of this.watchers) {
watcher.close();
}
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer);
}
this.watchers.clear();
this.debounceTimers.clear();
this.fileSizes.clear();
}
}
module.exports = DirectoryWatcher;