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:
tommy0103
2026-06-13 03:42:01 +08:00
parent b524339d85
commit 4eec6b38c9
29 changed files with 1370 additions and 314 deletions
+163
View File
@@ -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 };