diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index ef6f230..82071f4 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -28,6 +28,7 @@ import * as path from 'path'; import * as fs from 'fs'; import { spawn } from 'child_process'; import { getCodeGraphDir, findNearestCodeGraphRoot, isInitialized } from '../directory'; +import { createShimmerProgress } from '../ui/shimmer-progress'; // Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast. async function loadCodeGraph(): Promise { @@ -43,8 +44,6 @@ async function loadCodeGraph(): Promise { } } -type IndexProgress = import('../index').IndexProgress; - // Dynamic import helper — tsc compiles import() to require() in CJS mode, // which fails for ESM-only packages. This bypasses the transformation. // eslint-disable-next-line @typescript-eslint/no-implied-eval @@ -174,141 +173,8 @@ function formatDuration(ms: number): string { return `${minutes}m ${remainingSeconds.toFixed(0)}s`; } -// ============================================================================= -// Shimmer Progress Renderer (devpit-style animation) -// ============================================================================= - -const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽']; -const ANIM_INTERVAL = 150; -const FRAMES_PER_GLYPH = 3; - -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\x1b[1m`; -} - -const PHASE_NAMES: Record = { - scanning: 'Scanning files', - parsing: 'Parsing code', - storing: 'Storing data', - resolving: 'Resolving refs', -}; - -interface ShimmerProgress { - onProgress: (progress: IndexProgress) => void; - stop: () => void; -} - -function createShimmerProgress(): ShimmerProgress { - const startTime = Date.now(); - let interval: ReturnType | null = null; - let currentMessage = ''; - let currentPercent = -1; - let currentCount = 0; - let lastPhase = ''; - - function animFrame(): number { - return Math.floor((Date.now() - startTime) / ANIM_INTERVAL); - } - - function spinnerGlyph(): string { - const idx = Math.floor(animFrame() / FRAMES_PER_GLYPH) % SPINNER_GLYPHS.length; - return SPINNER_GLYPHS[idx] ?? '·'; - } - - function renderBar(filled: number, empty: number): string { - if (filled === 0) return `${colors.dim}${'░'.repeat(empty)}${colors.reset}`; - // Shimmer sweeps left-to-right across the filled portion - const cycleFrames = 24; - const shimmerPos = ((animFrame() % 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\x1b[1m█`; - } - bar += `${colors.reset}${colors.dim}${'░'.repeat(empty)}${colors.reset}`; - return bar; - } - - function render() { - const glyph = spinnerGlyph(); - const frame = animFrame(); - const color = shimmerColor(frame); - const rst = colors.reset; - const dm = colors.dim; - - 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(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() { - 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(`${colors.dim}│${colors.reset} ${colors.green}◆${colors.reset} ${currentMessage}${detail}\n`); - } - - // Start animation loop - interval = setInterval(render, ANIM_INTERVAL); - - return { - onProgress(progress: IndexProgress) { - const phaseName = PHASE_NAMES[progress.phase] || progress.phase; - - if (progress.phase !== lastPhase && lastPhase) { - finishPhase(); - } - lastPhase = progress.phase; - currentMessage = phaseName; - - if (progress.total > 0) { - currentPercent = Math.round((progress.current / progress.total) * 100); - currentCount = 0; - } else if (progress.current > 0) { - currentPercent = -1; - currentCount = progress.current; - } else { - currentPercent = -1; - currentCount = 0; - } - - // Render immediately — scanning is synchronous so setInterval can't fire - render(); - }, - stop() { - if (interval) { - clearInterval(interval); - interval = null; - } - finishPhase(); - }, - }; -} +// Shimmer progress renderer (runs in a worker thread for smooth animation) +// Imported at top of file from '../ui/shimmer-progress' /** * Print success message @@ -490,7 +356,7 @@ program onProgress: progress.onProgress, }); - progress.stop(); + await progress.stop(); printIndexResult(clack, result, projectPath); } else { clack.log.info('Run "codegraph index" to index the project'); @@ -594,7 +460,7 @@ program onProgress: progress.onProgress, }); - progress.stop(); + await progress.stop(); printIndexResult(clack, result, projectPath); if (!result.success) { @@ -646,7 +512,7 @@ program onProgress: progress.onProgress, }); - progress.stop(); + await progress.stop(); const totalChanges = result.filesAdded + result.filesModified + result.filesRemoved; diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 426108a..a311b10 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -18,7 +18,7 @@ import { } from '../types'; import { QueryBuilder } from '../db/queries'; import { extractFromSource } from './tree-sitter'; -import { detectLanguage, isLanguageSupported, initGrammars, loadGrammarsForLanguages, resetParser } from './grammars'; +import { detectLanguage, isLanguageSupported, initGrammars, loadGrammarsForLanguages } from './grammars'; import { logDebug, logWarn } from '../errors'; import { validatePathWithinRoot, normalizePath } from '../utils'; import picomatch from 'picomatch'; @@ -29,11 +29,7 @@ import picomatch from 'picomatch'; */ const FILE_IO_BATCH_SIZE = 10; -/** - * Reset tree-sitter parser after this many parses per language to reclaim - * WASM heap memory and prevent "memory access out of bounds" crashes. - */ -const PARSER_RESET_INTERVAL = 5000; +// PARSER_RESET_INTERVAL moved to parse-worker.ts (runs in worker thread) /** * Progress callback for indexing operations @@ -249,6 +245,36 @@ export function scanDirectory( return scanDirectoryWalk(rootDir, config, onProgress); } +/** + * Async variant of scanDirectory that yields to the event loop periodically, + * allowing worker threads to receive and render progress messages. + */ +export async function scanDirectoryAsync( + rootDir: string, + config: CodeGraphConfig, + onProgress?: (current: number, file: string) => void +): Promise { + const gitFiles = getGitVisibleFiles(rootDir); + if (gitFiles) { + const files: string[] = []; + let count = 0; + for (const filePath of gitFiles) { + if (shouldIncludeFile(filePath, config)) { + files.push(filePath); + count++; + onProgress?.(count, filePath); + // Yield every 100 files so worker threads can render progress + if (count % 100 === 0) { + await new Promise(r => setImmediate(r)); + } + } + } + return files; + } + + return scanDirectoryWalk(rootDir, config, onProgress); +} + /** * Filesystem walk fallback for non-git projects. */ @@ -387,7 +413,7 @@ export class ExtractionOrchestrator { total: 0, }); - const files = scanDirectory(this.rootDir, this.config, (current, file) => { + const files = await scanDirectoryAsync(this.rootDir, this.config, (current, file) => { onProgress?.({ phase: 'scanning', current, @@ -409,19 +435,69 @@ export class ExtractionOrchestrator { }; } - // Load only the grammars needed for languages actually present in the project. - // This avoids compiling all 16+ WASM grammar modules upfront, which can cause - // V8 WASM Zone OOM on large codebases (see issue #54). - const neededLanguages = [...new Set(files.map((f) => detectLanguage(f)))]; - await loadGrammarsForLanguages(neededLanguages); - - // Phase 2: Parse files (read in parallel batches, parse/store sequentially) + // Phase 2: Parse files in a worker thread (keeps main thread unblocked for UI) const total = files.length; let processed = 0; - const parseCounts = new Map(); // track parses per language for WASM reset + + // Detect needed languages and load grammars in the parse worker + const neededLanguages = [...new Set(files.map((f) => detectLanguage(f)))]; + + // Try to use a worker thread for parsing (keeps main thread unblocked for UI). + // Falls back to in-process parsing if the compiled worker is unavailable (e.g. tests). + const parseWorkerPath = path.join(__dirname, 'parse-worker.js'); + const useWorker = fs.existsSync(parseWorkerPath); + let parseWorker: import('worker_threads').Worker | null = null; + + if (useWorker) { + const { Worker } = await import('worker_threads'); + parseWorker = new Worker(parseWorkerPath); + } else { + // In-process fallback: load grammars locally + await loadGrammarsForLanguages(neededLanguages); + } + + // Set up worker-based or in-process parsing + let nextId = 0; + const pendingParses = new Map void; + }>(); + + if (parseWorker) { + // Wait for grammars to load in the worker + await new Promise((resolve, reject) => { + parseWorker!.once('message', (msg: { type: string }) => { + if (msg.type === 'grammars-loaded') resolve(); + else reject(new Error(`Unexpected message: ${msg.type}`)); + }); + parseWorker!.postMessage({ type: 'load-grammars', languages: neededLanguages }); + }); + + parseWorker.on('message', (msg: { type: string; id?: number; result?: ExtractionResult }) => { + if (msg.type === 'parse-result' && msg.id !== undefined) { + const pending = pendingParses.get(msg.id); + if (pending) { + pendingParses.delete(msg.id); + pending.resolve(msg.result!); + } + } + }); + } + + function requestParse(filePath: string, content: string): Promise { + if (parseWorker) { + return new Promise((resolve) => { + const id = nextId++; + pendingParses.set(id, { resolve }); + parseWorker!.postMessage({ type: 'parse', id, filePath, content }); + }); + } + // In-process fallback + return Promise.resolve(extractFromSource(filePath, content, detectLanguage(filePath))); + } for (let i = 0; i < files.length; i += FILE_IO_BATCH_SIZE) { if (signal?.aborted) { + if (parseWorker) await parseWorker.terminate(); return { success: false, filesIndexed, @@ -454,9 +530,10 @@ export class ExtractionOrchestrator { }) ); - // Parse and store sequentially + // Send to worker for parsing, store results on main thread for (const { filePath, content, stats, error } of fileContents) { if (signal?.aborted) { + if (parseWorker) await parseWorker.terminate(); return { success: false, filesIndexed, @@ -488,20 +565,16 @@ export class ExtractionOrchestrator { continue; } - const result = await this.indexFileWithContent(filePath, content, stats); + // Parse in worker thread (main thread stays unblocked) + const result = await requestParse(filePath, content); - // Periodically reset the parser to reclaim WASM heap memory. - // Without this, tree-sitter's WASM runtime fragments its heap - // across thousands of parses and eventually crashes. - const lang = detectLanguage(filePath); - const count = (parseCounts.get(lang) ?? 0) + 1; - parseCounts.set(lang, count); - if (count % PARSER_RESET_INTERVAL === 0) { - resetParser(lang); + // Store in database on main thread (SQLite is not thread-safe) + if (result.nodes.length > 0 || result.errors.length === 0) { + const language = detectLanguage(filePath); + this.storeExtractionResult(filePath, content, language, stats, result); } if (result.errors.length > 0) { - // Annotate errors with file path if not already set for (const err of result.errors) { if (!err.filePath) err.filePath = filePath; } @@ -520,6 +593,9 @@ export class ExtractionOrchestrator { } } + // Shut down parse worker + if (parseWorker) await parseWorker.terminate(); + // Phase 3: Resolve references onProgress?.({ phase: 'resolving', diff --git a/src/extraction/parse-worker.ts b/src/extraction/parse-worker.ts new file mode 100644 index 0000000..f8e7dec --- /dev/null +++ b/src/extraction/parse-worker.ts @@ -0,0 +1,51 @@ +/** + * Parse Worker + * + * Runs tree-sitter parsing in a separate thread so the main thread + * stays unblocked and the UI animation renders smoothly. + */ + +import { parentPort } from 'worker_threads'; +import { extractFromSource } from './tree-sitter'; +import { detectLanguage, loadGrammarsForLanguages, resetParser } from './grammars'; +import type { Language, ExtractionResult } from '../types'; + +const PARSER_RESET_INTERVAL = 5000; +const parseCounts = new Map(); + +parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: string; content?: string; languages?: Language[] }) => { + if (msg.type === 'load-grammars') { + await loadGrammarsForLanguages(msg.languages!); + parentPort!.postMessage({ type: 'grammars-loaded' }); + } else if (msg.type === 'parse') { + const { id, filePath, content } = msg; + try { + const language = detectLanguage(filePath!); + const result: ExtractionResult = extractFromSource(filePath!, content!, language); + + // Periodic parser reset to reclaim WASM heap memory + const count = (parseCounts.get(language) ?? 0) + 1; + parseCounts.set(language, count); + if (count % PARSER_RESET_INTERVAL === 0) { + resetParser(language); + } + + parentPort!.postMessage({ type: 'parse-result', id, result }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + parentPort!.postMessage({ + type: 'parse-result', + id, + result: { + nodes: [], + edges: [], + unresolvedReferences: [], + errors: [{ message: `Parse worker error: ${message}`, filePath: filePath!, severity: 'error', code: 'parse_error' }], + durationMs: 0, + } satisfies ExtractionResult, + }); + } + } else if (msg.type === 'shutdown') { + parentPort!.postMessage({ type: 'shutdown-ack' }); + } +}); diff --git a/src/installer/index.ts b/src/installer/index.ts index 5a23121..0e58d21 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -184,92 +184,16 @@ async function initializeLocalProject(clack: typeof import('@clack/prompts')): P const cg = await CodeGraph.init(projectPath); clack.log.success('Created .codegraph/ directory'); - // Index the project with shimmer progress - const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽']; - const ANIM_INTERVAL = 150; - const FRAMES_PER_GLYPH = 3; - const _lerp = (a: number, b: number, t: number) => Math.round(a + (b - a) * t); - const _shimmerColor = (frame: number) => { - const t = (Math.sin(frame * 2 * Math.PI / 13) + 1) / 2; - return `\x1b[38;2;${_lerp(160, 251, t)};${_lerp(100, 191, t)};${_lerp(9, 36, t)}m\x1b[1m`; - }; - const rst = '\x1b[0m'; - const dm = '\x1b[2m'; - const grn = '\x1b[32m'; - const phaseNames: Record = { - scanning: 'Scanning files', - parsing: 'Parsing code', - storing: 'Storing data', - resolving: 'Resolving refs', - }; - - const _startTime = Date.now(); - const _animFrame = () => Math.floor((Date.now() - _startTime) / ANIM_INTERVAL); - let curMsg = ''; - let curPercent = -1; - let curCount = 0; - let lastPhase = ''; - - const renderBar = (filled: number, empty: number): string => { - if (filled === 0) return `${dm}${'░'.repeat(empty)}${rst}`; - const cycleFrames = 24; - const shimmerPos = ((_animFrame() % 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\x1b[1m█`; - } - return bar + `${rst}${dm}${'░'.repeat(empty)}${rst}`; - }; - - const renderTick = () => { - const frame = _animFrame(); - const glyph = SPINNER_GLYPHS[Math.floor(frame / FRAMES_PER_GLYPH) % SPINNER_GLYPHS.length]; - const color = _shimmerColor(frame); - let line: string; - if (curPercent >= 0) { - const barW = 25, filled = Math.round(barW * curPercent / 100), empty = barW - filled; - line = `${dm}│${rst} ${color}${glyph}${rst} ${curMsg} ${renderBar(filled, empty)} ${curPercent}%`; - } else if (curCount > 0) { - line = `${dm}│${rst} ${color}${glyph}${rst} ${curMsg}... ${formatNumber(curCount)} found`; - } else { - line = `${dm}│${rst} ${color}${glyph}${rst} ${curMsg}...`; - } - process.stdout.write(`\r\x1b[K${line}`); - }; - - const finishPhase = () => { - if (!curMsg) return; - process.stdout.write(`\r\x1b[K`); - let detail = ''; - if (curPercent >= 0) detail = ' — done'; - else if (curCount > 0) detail = ` — ${formatNumber(curCount)} found`; - process.stdout.write(`${dm}│${rst} ${grn}◆${rst} ${curMsg}${detail}\n`); - }; - - process.stdout.write(`${dm}│${rst}\n`); - const ticker = setInterval(renderTick, ANIM_INTERVAL); + // Index the project with shimmer progress (worker thread for smooth animation) + const { createShimmerProgress } = await import('../ui/shimmer-progress'); + process.stdout.write(`\x1b[2m│\x1b[0m\n`); + const progress = createShimmerProgress(); const result = await cg.indexAll({ - onProgress: (progress) => { - const phaseName = phaseNames[progress.phase] || progress.phase; - if (progress.phase !== lastPhase && lastPhase) finishPhase(); - lastPhase = progress.phase; - curMsg = phaseName; - if (progress.total > 0) { curPercent = Math.round((progress.current / progress.total) * 100); curCount = 0; } - else if (progress.current > 0) { curPercent = -1; curCount = progress.current; } - else { curPercent = -1; curCount = 0; } - renderTick(); - }, + onProgress: progress.onProgress, }); - clearInterval(ticker); - finishPhase(); + await progress.stop(); if (result.filesErrored > 0) { clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} failed, ${formatNumber(result.nodesCreated)} symbols)`); diff --git a/src/ui/shimmer-progress.ts b/src/ui/shimmer-progress.ts new file mode 100644 index 0000000..2535902 --- /dev/null +++ b/src/ui/shimmer-progress.ts @@ -0,0 +1,73 @@ +import { Worker } from 'worker_threads'; +import * as path from 'path'; + +const PHASE_NAMES: Record = { + 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; +} + +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((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' }); + }); + }, + }; +} diff --git a/src/ui/shimmer-worker.ts b/src/ui/shimmer-worker.ts new file mode 100644 index 0000000..2644c47 --- /dev/null +++ b/src/ui/shimmer-worker.ts @@ -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' }); + } +}); diff --git a/src/ui/types.ts b/src/ui/types.ts new file mode 100644 index 0000000..6236072 --- /dev/null +++ b/src/ui/types.ts @@ -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' };