feat(app): embed indexer in Electron with file-watching service and UI refinements
Extract schema DDL into scripts/schema.sql shared between CLI and app. Add an in-process chokidar-based indexer-service that watches ~/.claude/projects for JSONL changes, debounces, and triggers background rebuilds via a worker thread. Rename Usage view to Activity, flesh out MemoryDetail and SubagentDetail views, and refine App.vue layout/routing. The main process now starts/stops the indexer lifecycle and notifies renderer windows on index updates.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const DEFAULT_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
|
||||
const DEFAULT_DEBOUNCE_MS = 2000;
|
||||
const DEFAULT_STABILITY_MS = 500;
|
||||
const DEFAULT_HEARTBEAT_MS = 30000;
|
||||
const DEFAULT_WATCH_RETRY_MS = 5000;
|
||||
|
||||
function createIndexerService({
|
||||
projectsDir = DEFAULT_PROJECTS_DIR,
|
||||
debounceMs = DEFAULT_DEBOUNCE_MS,
|
||||
stabilityMs = DEFAULT_STABILITY_MS,
|
||||
heartbeatMs = DEFAULT_HEARTBEAT_MS,
|
||||
watchRetryMs = DEFAULT_WATCH_RETRY_MS,
|
||||
buildIndex,
|
||||
writeHeartbeat = () => {},
|
||||
watchProjects,
|
||||
chokidar,
|
||||
timers = {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
},
|
||||
logger = console,
|
||||
} = {}) {
|
||||
if (typeof buildIndex !== 'function') throw new Error('createIndexerService() requires buildIndex');
|
||||
const watch = watchProjects || ((onChange) => {
|
||||
if (!fs.existsSync(projectsDir)) return null;
|
||||
const watcher = (chokidar || require('chokidar')).watch(projectsDir, {
|
||||
cwd: projectsDir,
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: Math.max(stabilityMs, 500),
|
||||
pollInterval: 100,
|
||||
},
|
||||
ignored: (targetPath, stats) => {
|
||||
if (stats?.isDirectory()) return false;
|
||||
if (!stats) return false;
|
||||
return !String(targetPath).endsWith('.jsonl') && !String(targetPath).endsWith('.json');
|
||||
},
|
||||
});
|
||||
const onFileChange = (filename) => {
|
||||
const name = filename ? String(filename) : '';
|
||||
if (!name || name.endsWith('.jsonl') || name.endsWith('.json')) onChange(name);
|
||||
};
|
||||
return watcher
|
||||
.on('add', onFileChange)
|
||||
.on('change', onFileChange)
|
||||
.on('unlink', onFileChange)
|
||||
.on('error', (error) => {
|
||||
logger.warn?.(`Obelisk watcher failed: ${error.message}`);
|
||||
});
|
||||
});
|
||||
|
||||
let buildTimer = null;
|
||||
let stabilityTimer = null;
|
||||
let heartbeatTimer = null;
|
||||
let watchRetryTimer = null;
|
||||
let watcher = null;
|
||||
let stopped = false;
|
||||
let running = false;
|
||||
let pending = false;
|
||||
let lastReason = null;
|
||||
let idlePromise = Promise.resolve();
|
||||
|
||||
const runBuildNow = (reason = 'manual') => {
|
||||
if (stopped) return idlePromise;
|
||||
if (running) {
|
||||
pending = true;
|
||||
return idlePromise;
|
||||
}
|
||||
running = true;
|
||||
pending = false;
|
||||
idlePromise = (async () => {
|
||||
await buildIndex({ reason });
|
||||
writeHeartbeat();
|
||||
})()
|
||||
.catch((error) => {
|
||||
logger.warn?.(`Obelisk index build failed: ${error.message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
running = false;
|
||||
if (pending && !stopped) {
|
||||
pending = false;
|
||||
runBuildNow('pending');
|
||||
}
|
||||
});
|
||||
return idlePromise;
|
||||
};
|
||||
|
||||
const scheduleBuild = (reason = 'change') => {
|
||||
if (stopped) return;
|
||||
lastReason = reason;
|
||||
if (running) pending = true;
|
||||
if (buildTimer) timers.clearTimeout(buildTimer);
|
||||
if (stabilityTimer) timers.clearTimeout(stabilityTimer);
|
||||
buildTimer = timers.setTimeout(() => {
|
||||
buildTimer = null;
|
||||
if (stabilityMs <= 0) {
|
||||
runBuildNow(lastReason || reason);
|
||||
return;
|
||||
}
|
||||
stabilityTimer = timers.setTimeout(() => {
|
||||
stabilityTimer = null;
|
||||
runBuildNow(lastReason || reason);
|
||||
}, stabilityMs);
|
||||
}, debounceMs);
|
||||
};
|
||||
|
||||
const startWatching = () => {
|
||||
if (stopped || watcher) return;
|
||||
watcher = watch(() => scheduleBuild('watch'));
|
||||
if (!watcher) {
|
||||
watchRetryTimer = timers.setTimeout(() => {
|
||||
watchRetryTimer = null;
|
||||
startWatching();
|
||||
}, watchRetryMs);
|
||||
}
|
||||
};
|
||||
|
||||
const start = ({ buildOnStart = true } = {}) => {
|
||||
stopped = false;
|
||||
if (buildOnStart) scheduleBuild('startup');
|
||||
startWatching();
|
||||
if (typeof timers.setInterval === 'function') {
|
||||
heartbeatTimer = timers.setInterval(() => {
|
||||
try {
|
||||
writeHeartbeat();
|
||||
} catch (error) {
|
||||
logger.warn?.(`Obelisk heartbeat failed: ${error.message}`);
|
||||
}
|
||||
}, heartbeatMs);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
pending = false;
|
||||
if (buildTimer) timers.clearTimeout(buildTimer);
|
||||
buildTimer = null;
|
||||
if (stabilityTimer) timers.clearTimeout(stabilityTimer);
|
||||
stabilityTimer = null;
|
||||
if (watchRetryTimer) timers.clearTimeout(watchRetryTimer);
|
||||
watchRetryTimer = null;
|
||||
if (heartbeatTimer && typeof timers.clearInterval === 'function') timers.clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
if (watcher?.close) watcher.close();
|
||||
watcher = null;
|
||||
};
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
scheduleBuild,
|
||||
runBuildNow,
|
||||
idle: () => idlePromise,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createIndexerService };
|
||||
Reference in New Issue
Block a user