feat: Move parsing to worker threads for smooth progress animation
Offloads tree-sitter parsing to a dedicated worker thread, keeping the main thread unblocked so shimmer progress animations render smoothly during indexing. Refactors shimmer progress renderer into separate worker for consistent 50ms animation updates. Falls back to in-process parsing when worker compilation unavailable (e.g., tests).
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { Worker } from 'worker_threads';
|
||||
import * as path from 'path';
|
||||
|
||||
const PHASE_NAMES: Record<string, string> = {
|
||||
scanning: 'Scanning files',
|
||||
parsing: 'Parsing code',
|
||||
storing: 'Storing data',
|
||||
resolving: 'Resolving refs',
|
||||
};
|
||||
|
||||
export interface IndexProgress {
|
||||
phase: string;
|
||||
current: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ShimmerProgress {
|
||||
onProgress: (progress: IndexProgress) => void;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createShimmerProgress(): ShimmerProgress {
|
||||
let lastPhase = '';
|
||||
|
||||
const workerPath = path.join(__dirname, 'shimmer-worker.js');
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: { startTime: Date.now() },
|
||||
});
|
||||
|
||||
return {
|
||||
onProgress(progress: IndexProgress) {
|
||||
const phaseName = PHASE_NAMES[progress.phase] || progress.phase;
|
||||
|
||||
if (progress.phase !== lastPhase && lastPhase) {
|
||||
worker.postMessage({ type: 'finish-phase' });
|
||||
}
|
||||
lastPhase = progress.phase;
|
||||
|
||||
let percent = -1;
|
||||
let count = 0;
|
||||
if (progress.total > 0) {
|
||||
percent = Math.round((progress.current / progress.total) * 100);
|
||||
} else if (progress.current > 0) {
|
||||
count = progress.current;
|
||||
}
|
||||
|
||||
worker.postMessage({
|
||||
type: 'update',
|
||||
phase: progress.phase,
|
||||
phaseName,
|
||||
percent,
|
||||
count,
|
||||
});
|
||||
},
|
||||
|
||||
stop() {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
worker.terminate().then(() => resolve());
|
||||
}, 2000);
|
||||
|
||||
worker.on('message', (msg: { type: string }) => {
|
||||
if (msg.type === 'stopped') {
|
||||
clearTimeout(timeout);
|
||||
worker.terminate().then(() => resolve());
|
||||
}
|
||||
});
|
||||
|
||||
worker.postMessage({ type: 'stop' });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { parentPort, workerData } from 'worker_threads';
|
||||
import type { ShimmerWorkerMessage } from './types';
|
||||
|
||||
const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽'];
|
||||
const ANIM_INTERVAL = 150;
|
||||
const FRAMES_PER_GLYPH = 3;
|
||||
|
||||
const RST = '\x1b[0m';
|
||||
const DM = '\x1b[2m';
|
||||
const GRN = '\x1b[32m';
|
||||
const BOLD = '\x1b[1m';
|
||||
|
||||
const startTime: number = workerData.startTime;
|
||||
|
||||
function animFrame(): number {
|
||||
return Math.floor((Date.now() - startTime) / ANIM_INTERVAL);
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return Math.round(a + (b - a) * t);
|
||||
}
|
||||
|
||||
function shimmerColor(frame: number): string {
|
||||
const t = (Math.sin(frame * 2 * Math.PI / 13) + 1) / 2;
|
||||
const r = lerp(160, 251, t);
|
||||
const g = lerp(100, 191, t);
|
||||
const b = lerp(9, 36, t);
|
||||
return `\x1b[38;2;${r};${g};${b}m${BOLD}`;
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function renderBar(frame: number, filled: number, empty: number): string {
|
||||
if (filled === 0) return `${DM}${'░'.repeat(empty)}${RST}`;
|
||||
const cycleFrames = 24;
|
||||
const shimmerPos = ((frame % cycleFrames) / cycleFrames) * (filled + 6) - 3;
|
||||
const shimmerWidth = 3;
|
||||
let bar = '';
|
||||
for (let i = 0; i < filled; i++) {
|
||||
const dist = Math.abs(i - shimmerPos);
|
||||
const t = Math.max(0, 1 - dist / shimmerWidth);
|
||||
const r = lerp(160, 251, t);
|
||||
const g = lerp(100, 191, t);
|
||||
const b = lerp(9, 36, t);
|
||||
bar += `\x1b[38;2;${r};${g};${b}m${BOLD}█`;
|
||||
}
|
||||
bar += `${RST}${DM}${'░'.repeat(empty)}${RST}`;
|
||||
return bar;
|
||||
}
|
||||
|
||||
// Mutable state
|
||||
let currentMessage = '';
|
||||
let currentPercent = -1;
|
||||
let currentCount = 0;
|
||||
|
||||
function render(): void {
|
||||
if (!currentMessage) return;
|
||||
const frame = animFrame();
|
||||
const glyphIdx = Math.floor(frame / FRAMES_PER_GLYPH) % SPINNER_GLYPHS.length;
|
||||
const glyph = SPINNER_GLYPHS[glyphIdx] ?? '·';
|
||||
const color = shimmerColor(frame);
|
||||
|
||||
let line: string;
|
||||
if (currentPercent >= 0) {
|
||||
const barWidth = 25;
|
||||
const filled = Math.round(barWidth * currentPercent / 100);
|
||||
const empty = barWidth - filled;
|
||||
line = `${DM}│${RST} ${color}${glyph}${RST} ${currentMessage} ${renderBar(frame, filled, empty)} ${currentPercent}%`;
|
||||
} else if (currentCount > 0) {
|
||||
line = `${DM}│${RST} ${color}${glyph}${RST} ${currentMessage}... ${formatNumber(currentCount)} found`;
|
||||
} else {
|
||||
line = `${DM}│${RST} ${color}${glyph}${RST} ${currentMessage}...`;
|
||||
}
|
||||
|
||||
process.stdout.write(`\r\x1b[K${line}`);
|
||||
}
|
||||
|
||||
function finishPhase(): void {
|
||||
if (!currentMessage) return;
|
||||
process.stdout.write(`\r\x1b[K`);
|
||||
let detail = '';
|
||||
if (currentPercent >= 0) detail = ' — done';
|
||||
else if (currentCount > 0) detail = ` — ${formatNumber(currentCount)} found`;
|
||||
process.stdout.write(`${DM}│${RST} ${GRN}◆${RST} ${currentMessage}${detail}\n`);
|
||||
currentMessage = '';
|
||||
currentPercent = -1;
|
||||
currentCount = 0;
|
||||
}
|
||||
|
||||
// Render loop — independent of main thread
|
||||
const tickInterval = setInterval(render, 50);
|
||||
|
||||
parentPort!.on('message', (msg: ShimmerWorkerMessage) => {
|
||||
if (msg.type === 'update') {
|
||||
currentMessage = msg.phaseName;
|
||||
currentPercent = msg.percent;
|
||||
currentCount = msg.count;
|
||||
} else if (msg.type === 'finish-phase') {
|
||||
finishPhase();
|
||||
} else if (msg.type === 'stop') {
|
||||
clearInterval(tickInterval);
|
||||
finishPhase();
|
||||
parentPort!.postMessage({ type: 'stopped' });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Messages from main thread to worker */
|
||||
export type ShimmerWorkerMessage =
|
||||
| { type: 'update'; phase: string; phaseName: string; percent: number; count: number }
|
||||
| { type: 'finish-phase' }
|
||||
| { type: 'stop' };
|
||||
|
||||
/** Messages from worker to main thread */
|
||||
export type ShimmerMainMessage =
|
||||
| { type: 'stopped' };
|
||||
Reference in New Issue
Block a user