diff --git a/.github/workflows/_link-check.yml b/.github/workflows/_link-check.yml index bad3f47..28b3ead 100644 --- a/.github/workflows/_link-check.yml +++ b/.github/workflows/_link-check.yml @@ -32,5 +32,7 @@ jobs: --exclude 'https?://localhost' --exclude 'file://' --exclude 'mailto:' + --exclude-path './CHANGELOG.md' + --exclude 'github.com/dgtalbug/dextree/issues' './**/*.md' fail: true diff --git a/packages/core/src/extractors/frameworks/detector.ts b/packages/core/src/extractors/frameworks/detector.ts index e6a8056..a7b4c35 100644 --- a/packages/core/src/extractors/frameworks/detector.ts +++ b/packages/core/src/extractors/frameworks/detector.ts @@ -22,10 +22,11 @@ export const detectFrameworks: DetectFrameworksFn = async ( try { const hit = await runMatchersFor(def, params); if (hit !== null) results.push(hit); - } catch { - // FR-007: a single framework's matcher failing must not stop others. - // Failures are intentionally silent at the detector layer; the indexer - // wrapper logs the failure with the framework name for observability. + } catch (err) { + params.logger?.warn("Framework matcher failed", { + framework: def.name, + error: err instanceof Error ? err.message : String(err), + }); } } diff --git a/packages/core/src/extractors/frameworks/fsIO.ts b/packages/core/src/extractors/frameworks/fsIO.ts index 12c4c45..2ee891a 100644 --- a/packages/core/src/extractors/frameworks/fsIO.ts +++ b/packages/core/src/extractors/frameworks/fsIO.ts @@ -4,6 +4,7 @@ import picomatch from "picomatch"; const ALWAYS_EXCLUDE = new Set(["node_modules", "dist", "build", "out", ".git"]); +import type { Logger } from "../../types.js"; import type { DetectFrameworksParams } from "./types.js"; /** @@ -11,9 +12,10 @@ import type { DetectFrameworksParams } from "./types.js"; * Skips heavy dirs (node_modules/dist/build/out/.git) and reads files * lazily. Errors during read are surfaced as `null` so matchers stay silent. */ -export function createNodeFsIO(workspaceRoot: string): DetectFrameworksParams { +export function createNodeFsIO(workspaceRoot: string, logger?: Logger): DetectFrameworksParams { return { workspaceRoot, + ...(logger === undefined ? {} : { logger }), async readFile(rel: string) { try { return await readFile(join(workspaceRoot, rel), "utf8"); diff --git a/packages/core/src/extractors/frameworks/types.ts b/packages/core/src/extractors/frameworks/types.ts index c73cbdf..11b94ed 100644 --- a/packages/core/src/extractors/frameworks/types.ts +++ b/packages/core/src/extractors/frameworks/types.ts @@ -7,7 +7,7 @@ * source of truth for the spec, and this file is the runtime declaration. * Keep both in sync. */ -import type { FrameworkDetectionSource } from "../../types.js"; +import type { FrameworkDetectionSource, Logger } from "../../types.js"; export interface ManifestKeyPathPresent { kind: "present"; @@ -62,6 +62,7 @@ export interface DetectFrameworksParams { workspaceRoot: string; readFile: (relativePath: string) => Promise; listFiles: (glob: string) => Promise; + logger?: Logger; } export type DetectFrameworksFn = ( diff --git a/packages/core/src/extractors/index.ts b/packages/core/src/extractors/index.ts index d473cd1..5a5fa9a 100644 --- a/packages/core/src/extractors/index.ts +++ b/packages/core/src/extractors/index.ts @@ -5,6 +5,7 @@ import { ImplementsExtractor } from "./ImplementsExtractor.js"; import { NaiveCallExtractor } from "./NaiveCallExtractor.js"; import { createExtractorRegistry } from "./registry.js"; import type { ExtractorRegistry } from "./types.js"; +import type { Logger } from "../types.js"; /** * Builds the default registry with all first-party extractors registered in the @@ -18,8 +19,8 @@ import type { ExtractorRegistry } from "./types.js"; * Tests that want isolation should call `createExtractorRegistry()` directly and * register only what they need. */ -export function createDefaultExtractorRegistry(): ExtractorRegistry { - const registry = createExtractorRegistry(); +export function createDefaultExtractorRegistry(logger?: Logger): ExtractorRegistry { + const registry = createExtractorRegistry(logger); registry.register(new BaselineTsJsExtractor()); registry.register(new NaiveCallExtractor()); registry.register(new ClassRelationExtractor()); diff --git a/packages/core/src/extractors/registry.test.ts b/packages/core/src/extractors/registry.test.ts index d00cabf..936ee27 100644 --- a/packages/core/src/extractors/registry.test.ts +++ b/packages/core/src/extractors/registry.test.ts @@ -99,8 +99,9 @@ describe("ExtractorRegistry", () => { }); it("isolates per-extractor failures and continues with surviving extractors", async () => { - const registry = createExtractorRegistry(); - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const warn = vi.fn(); + const logger = { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() }; + const registry = createExtractorRegistry(logger); const broken: Extractor = { name: "broken", @@ -125,10 +126,13 @@ describe("ExtractorRegistry", () => { const result = await registry.run(makeInput()); expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith("Extractor failed", { + extractor: "broken", + file: "/workspace/src/x.ts", + error: "boom", + }); expect(result.edges).toHaveLength(1); expect(result.edges[0]?.kind).toBe("OK"); - - warn.mockRestore(); }); it("throws when two extractors both populate `file`", async () => { diff --git a/packages/core/src/extractors/registry.ts b/packages/core/src/extractors/registry.ts index 585ba00..5bd2d5a 100644 --- a/packages/core/src/extractors/registry.ts +++ b/packages/core/src/extractors/registry.ts @@ -1,4 +1,4 @@ -import type { ExtractedImportRef, StoredSymbol } from "../types.js"; +import type { ExtractedImportRef, Logger, StoredSymbol } from "../types.js"; import type { EdgeRow, Extractor, @@ -25,6 +25,11 @@ function toKnownSymbol(s: StoredSymbol): KnownSymbol { class InMemoryExtractorRegistry implements ExtractorRegistry { private readonly extractors: Extractor[] = []; + private logger: Logger | undefined; + + constructor(logger?: Logger) { + this.logger = logger; + } register(extractor: Extractor): void { if (this.extractors.some((existing) => existing.name === extractor.name)) { @@ -55,13 +60,12 @@ class InMemoryExtractorRegistry implements ExtractorRegistry { let result: ExtractionResult; try { result = await extractor.extract(enrichedInput); - } catch (error) { + } catch (err) { // Per FR-007 / contract: failure isolation. Log and continue. - console.warn({ + this.logger?.warn("Extractor failed", { extractor: extractor.name, - version: extractor.version, file: input.absolutePath, - error, + error: err instanceof Error ? err.message : String(err), }); continue; } @@ -96,8 +100,8 @@ class InMemoryExtractorRegistry implements ExtractorRegistry { } } -export function createExtractorRegistry(): ExtractorRegistry { - return new InMemoryExtractorRegistry(); +export function createExtractorRegistry(logger?: Logger): ExtractorRegistry { + return new InMemoryExtractorRegistry(logger); } export type { Extractor, ExtractorRegistry } from "./types.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bec2fd0..3965b2e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -36,6 +36,8 @@ import { type FrameworkInfo, type IndexResult, type Indexer, + type IndexerFactoryOptions, + type Logger, type SessionSummary, type StoredFile, type StoredSymbol, @@ -60,8 +62,10 @@ export type { GraphEdgeKind, GraphNode, GraphNodeType, + IndexerFactoryOptions, IndexResult, Indexer, + Logger, SessionSummary, StoredFile, StoredSymbol, @@ -122,13 +126,19 @@ const TS_LIKE_LANGUAGES = new Set([ class DuckTreeIndexer implements Indexer { private databaseHandle: DatabaseHandle | null = null; private initializationPromise: Promise | null = null; - private readonly registry: ExtractorRegistry = createDefaultExtractorRegistry(); + private readonly registry: ExtractorRegistry; private readonly frameworkCache = new Map(); + private readonly logger: Logger | undefined; + private readonly indexFileInFlight = new Map>(); constructor( private readonly dbPath: string, private readonly wasmDir: string, - ) {} + options?: IndexerFactoryOptions, + ) { + this.logger = options?.logger; + this.registry = createDefaultExtractorRegistry(this.logger); + } async initialize(): Promise { if (this.initializationPromise !== null) { @@ -136,16 +146,20 @@ class DuckTreeIndexer implements Indexer { } this.initializationPromise = (async () => { + this.logger?.debug("Initializing DuckDB", { dbPath: this.dbPath }); + if (this.dbPath !== ":memory:") { await mkdir(dirname(this.dbPath), { recursive: true }); } this.databaseHandle = await openDatabase(this.dbPath); await initializeSchema(this.databaseHandle.connection); - const migrationResult = await applyMigrations(this.databaseHandle.connection); + const migrationResult = await applyMigrations(this.databaseHandle.connection, this.logger); if (migrationResult.status === "failed") { throw new SchemaError(migrationResult.reason); } + + this.logger?.debug("Initialized DuckDB", { dbPath: this.dbPath }); })(); await this.initializationPromise; @@ -155,6 +169,25 @@ class DuckTreeIndexer implements Indexer { absolutePath: string, workspaceRoot: string, cacheIdentity?: WorkspaceCacheIdentity, + ): Promise { + const existing = this.indexFileInFlight.get(absolutePath); + if (existing !== undefined) { + return existing; + } + + const promise = this.doIndexFile(absolutePath, workspaceRoot, cacheIdentity); + this.indexFileInFlight.set(absolutePath, promise); + try { + return await promise; + } finally { + this.indexFileInFlight.delete(absolutePath); + } + } + + private async doIndexFile( + absolutePath: string, + workspaceRoot: string, + cacheIdentity?: WorkspaceCacheIdentity, ): Promise { const startedAt = Date.now(); await this.initialize(); @@ -169,6 +202,10 @@ class DuckTreeIndexer implements Indexer { : null; try { + this.logger?.debug("indexFile start", { + relativePath: absolutePath.split("/").pop() ?? absolutePath, + }); + const result = await this.registry.run({ absolutePath, workspaceRoot, @@ -268,11 +305,18 @@ class DuckTreeIndexer implements Indexer { const symbols = await getSymbolsForFile(database.connection, extracted.file.relativePath); + const elapsedMs = Date.now() - startedAt; + this.logger?.debug("indexFile complete", { + relativePath: extracted.file.relativePath, + symbolCount: symbols.length, + elapsedMs, + }); + return { relativePath: extracted.file.relativePath, symbolCount: symbols.length, symbols, - elapsedMs: Date.now() - startedAt, + elapsedMs, }; } finally { tree?.delete(); @@ -282,7 +326,7 @@ class DuckTreeIndexer implements Indexer { async detectWorkspaceFrameworks(workspaceRoot: string): Promise { await this.initialize(); const database = this.requireDatabaseHandle(); - const detected = await detectFrameworks(createNodeFsIO(workspaceRoot)); + const detected = await detectFrameworks(createNodeFsIO(workspaceRoot, this.logger)); this.frameworkCache.set(workspaceRoot, detected); await replaceWorkspaceFrameworks( database.connection, @@ -303,6 +347,7 @@ class DuckTreeIndexer implements Indexer { await this.initialize(); const database = this.requireDatabaseHandle(); await resolveWorkspaceCrossFileEdges(database.connection, workspaceRoot); + this.logger?.info("Finalized workspace cross-file edges", { workspaceRoot }); } async validateWorkspaceCache(identity: WorkspaceCacheIdentity) { @@ -378,6 +423,10 @@ class DuckTreeIndexer implements Indexer { } } -export function createIndexer(dbPath: string, wasmDir: string): Indexer { - return new DuckTreeIndexer(dbPath, wasmDir); +export function createIndexer( + dbPath: string, + wasmDir: string, + options?: IndexerFactoryOptions, +): Indexer { + return new DuckTreeIndexer(dbPath, wasmDir, options); } diff --git a/packages/core/src/storage/db.ts b/packages/core/src/storage/db.ts index 1a65cea..93ca9fe 100644 --- a/packages/core/src/storage/db.ts +++ b/packages/core/src/storage/db.ts @@ -1,11 +1,15 @@ import type { DuckDBConnection, DuckDBInstance, DuckDBMaterializedResult } from "@duckdb/node-api"; +import type { Logger } from "../types.js"; + export interface DatabaseHandle { instance: DuckDBInstance; connection: DuckDBConnection; close(): void; } +let inTransaction = false; + export async function openDatabase(dbPath: string): Promise { const { DuckDBInstance } = await import("@duckdb/node-api"); const instance = await DuckDBInstance.create(dbPath); @@ -32,6 +36,9 @@ export async function openDatabase(dbPath: string): Promise { * "no active transaction" (normal) or "aborted state cleared" (desired). * - On operation failure, ROLLBACK is attempted and any error it throws is * swallowed so the original error always propagates to the caller. + * - A runtime guard throws `Error("Nested runInTransaction detected")` if + * called while another `runInTransaction` is already in-flight. This + * prevents silent rollback of the outer transaction. * * NOTE: `runInTransaction` must never be called from inside another * `runInTransaction` block. The defensive ROLLBACK would silently undo the @@ -40,7 +47,13 @@ export async function openDatabase(dbPath: string): Promise { export async function runInTransaction( connection: DuckDBConnection, operation: () => Promise, + logger?: Logger, ): Promise { + // Nesting guard — throws synchronously before any SQL is issued. + if (inTransaction) { + throw new Error("Nested runInTransaction detected"); + } + // Pre-flight: clear any lingering aborted transaction from a previous call. // "No active transaction" is expected on a clean connection and not an error. try { @@ -49,13 +62,17 @@ export async function runInTransaction( // Normal path — no active transaction to roll back. } - await connection.run("BEGIN TRANSACTION"); - try { + inTransaction = true; + logger?.debug("BEGIN TRANSACTION"); + await connection.run("BEGIN TRANSACTION"); + const result = await operation(); + logger?.debug("COMMIT"); await connection.run("COMMIT"); return result; } catch (error) { + logger?.error("ROLLBACK", error, {}); // Wrap ROLLBACK so a failing rollback doesn't replace the original error. try { await connection.run("ROLLBACK"); @@ -63,6 +80,8 @@ export async function runInTransaction( // Best-effort only — the pre-flight on the next call will clean this up. } throw error; + } finally { + inTransaction = false; } } diff --git a/packages/core/src/storage/migrations/runner.ts b/packages/core/src/storage/migrations/runner.ts index f190dae..fae1e08 100644 --- a/packages/core/src/storage/migrations/runner.ts +++ b/packages/core/src/storage/migrations/runner.ts @@ -1,6 +1,7 @@ import type { DuckDBConnection } from "@duckdb/node-api"; import { SCHEMA_VERSION } from "../../types.js"; +import type { Logger } from "../../types.js"; /** * Migrations are inlined as string constants rather than loaded from disk because @@ -422,7 +423,10 @@ async function readCurrentVersion(connection: DuckDBConnection): Promise * The runner does NOT throw on migration failure — callers (currently * `DuckTreeIndexer.initialize`) decide whether failure is fatal. */ -export async function applyMigrations(connection: DuckDBConnection): Promise { +export async function applyMigrations( + connection: DuckDBConnection, + logger?: Logger, +): Promise { const applied: string[] = []; let currentVersion: number; try { @@ -455,6 +459,7 @@ export async function applyMigrations(connection: DuckDBConnection): Promise): void; + info(message: string, context?: Record): void; + warn(message: string, context?: Record): void; + error(message: string, error?: unknown, context?: Record): void; +} + +export interface IndexerFactoryOptions { + logger?: Logger; +} + export interface WorkspaceCacheIdentity { cacheKey: string; workspaceRoot: string; diff --git a/packages/exporters/src/mermaid/preview.ts b/packages/exporters/src/mermaid/preview.ts index 91b8dc7..2f8d7c2 100644 --- a/packages/exporters/src/mermaid/preview.ts +++ b/packages/exporters/src/mermaid/preview.ts @@ -1,4 +1,4 @@ -import type { WorkspaceSubgraph } from "@dextree/core"; +import type { Logger, WorkspaceSubgraph } from "@dextree/core"; import { serializeToScopedMermaid } from "./scopedSerializer.js"; import type { MermaidDirection, MermaidGranularity, MermaidScope } from "./scopedSerializer.js"; @@ -82,6 +82,7 @@ function titleForOptions(options: MermaidPreviewOptions): string { export function generateMermaidPreview( subgraph: WorkspaceSubgraph, options: MermaidPreviewOptions, + logger?: Logger, ): MermaidPreviewResult { if (options.diagram === "sequenceDiagram") { return { @@ -106,6 +107,11 @@ export function generateMermaidPreview( title: titleForOptions(options), }; } catch (err) { + logger?.error("generateMermaidPreview failed", err, { + options: options as unknown as Record, + nodeCount: subgraph.nodes.length, + edgeCount: subgraph.edges.length, + }); const reason = err instanceof Error ? err.message : String(err); const status = classifyFailure(reason); return { status, options, reason }; diff --git a/packages/extension/src/commands/indexFile.test.ts b/packages/extension/src/commands/indexFile.test.ts index c8a4586..8b4ed7c 100644 --- a/packages/extension/src/commands/indexFile.test.ts +++ b/packages/extension/src/commands/indexFile.test.ts @@ -66,6 +66,8 @@ function createContext() { function createLogger() { return { debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), error: vi.fn(), dispose: vi.fn(), }; diff --git a/packages/extension/src/commands/indexWorkspace.test.ts b/packages/extension/src/commands/indexWorkspace.test.ts index 700bb5f..29aa9dc 100644 --- a/packages/extension/src/commands/indexWorkspace.test.ts +++ b/packages/extension/src/commands/indexWorkspace.test.ts @@ -62,6 +62,8 @@ function createMockIndexer() { function createLogger() { return { debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), error: vi.fn(), dispose: vi.fn(), }; diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 9055849..8fe6eaa 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -154,8 +154,13 @@ export async function activate(context: ActivationContext): Promise { void (async () => { try { await pushCurrentGraph(); - } catch { - // Non-critical — panel will still show previous state + } catch (err) { + logger.error("Failed to push current graph", err); + if (WebviewPanelManager.isOpen()) { + void vscode.window.showWarningMessage( + "Dextree: Could not load graph. See output for details.", + ); + } } })(); }; @@ -238,7 +243,7 @@ export async function activate(context: ActivationContext): Promise { } const indexer = await getIndexer(); const subgraph = await indexer.getWorkspaceSubgraph(workspaceRoot); - return generateMermaidPreview(subgraph, options); + return generateMermaidPreview(subgraph, options, logger); }); const recordSuccessfulIndex = async (): Promise => { @@ -261,7 +266,10 @@ export async function activate(context: ActivationContext): Promise { join(storageUri.fsPath, "dextree.db"), ); } catch { - // Registry is best-effort — failure must not block indexing or graph push. + logger.warn("Failed to register workspace in global registry", { workspaceRoot }); + void vscode.window.showWarningMessage( + "Dextree: Workspace indexed but not registered in the workspace switcher.", + ); } }; @@ -346,8 +354,13 @@ export async function activate(context: ActivationContext): Promise { if (WebviewPanelManager.isOpen()) { try { await pushCurrentGraph(); - } catch { - // Non-critical — panel will still clear the overlay + } catch (err) { + logger.error("Failed to push current graph after indexing", err); + if (WebviewPanelManager.isOpen()) { + void vscode.window.showWarningMessage( + "Dextree: Indexing completed but graph could not be loaded. See output for details.", + ); + } } } pushIndexing("finished", update); diff --git a/packages/extension/src/logger.ts b/packages/extension/src/logger.ts index f25caff..8763861 100644 --- a/packages/extension/src/logger.ts +++ b/packages/extension/src/logger.ts @@ -1,21 +1,40 @@ import type { OutputChannel } from "vscode"; export interface Logger { - debug(message: string): void; - error(message: string, error?: unknown): void; + debug(message: string, context?: Record): void; + info(message: string, context?: Record): void; + warn(message: string, context?: Record): void; + error(message: string, error?: unknown, context?: Record): void; dispose(): void; } class OutputChannelLogger implements Logger { constructor(private readonly outputChannel: OutputChannel) {} - debug(message: string): void { - this.outputChannel.appendLine(`[Dextree] ${message}`); + debug(message: string, context?: Record): void { + const suffix = context ? ` ${JSON.stringify(context)}` : ""; + this.outputChannel.appendLine(`[Dextree] ${message}${suffix}`); } - error(message: string, error?: unknown): void { - const suffix = error instanceof Error ? `: ${error.message}` : ""; - this.outputChannel.appendLine(`[Dextree] ERROR ${message}${suffix}`); + info(message: string, context?: Record): void { + const suffix = context ? ` ${JSON.stringify(context)}` : ""; + this.outputChannel.appendLine(`[Dextree] ${message}${suffix}`); + } + + warn(message: string, context?: Record): void { + const suffix = context ? ` ${JSON.stringify(context)}` : ""; + this.outputChannel.appendLine(`[Dextree] WARN ${message}${suffix}`); + } + + error(message: string, error?: unknown, context?: Record): void { + const contextSuffix = context ? ` ${JSON.stringify(context)}` : ""; + const suffix = + error instanceof Error + ? `: ${error.message}${error.stack ? `\n ${error.stack.split("\n").slice(0, 3).join("\n ")}` : ""}` + : error !== undefined + ? `: ${String(error)}` + : ""; + this.outputChannel.appendLine(`[Dextree] ERROR ${message}${suffix}${contextSuffix}`); } dispose(): void { diff --git a/packages/extension/src/tree/SymbolsTreeProvider.test.ts b/packages/extension/src/tree/SymbolsTreeProvider.test.ts index cda6665..acde20c 100644 --- a/packages/extension/src/tree/SymbolsTreeProvider.test.ts +++ b/packages/extension/src/tree/SymbolsTreeProvider.test.ts @@ -79,6 +79,8 @@ function makeIndexer() { function makeLogger() { return { debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), error: vi.fn(), dispose: vi.fn(), }; diff --git a/packages/extension/src/watcher/workspaceWatcher.test.ts b/packages/extension/src/watcher/workspaceWatcher.test.ts index f008bd5..4347f25 100644 --- a/packages/extension/src/watcher/workspaceWatcher.test.ts +++ b/packages/extension/src/watcher/workspaceWatcher.test.ts @@ -12,7 +12,13 @@ const mockIsIgnored = vi.fn().mockReturnValue(false); const mockReadFile = vi.fn(); const mockCreateFileSystemWatcher = vi.fn(); const mockGetConfiguration = vi.fn(); -const mockLogger = { debug: vi.fn(), error: vi.fn(), dispose: vi.fn() }; +const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + dispose: vi.fn(), +}; type EventHandler = (uri: { fsPath: string }) => void; let onChangeHandler: EventHandler = () => {}; diff --git a/packages/extension/src/watcher/workspaceWatcher.ts b/packages/extension/src/watcher/workspaceWatcher.ts index fd42628..c125120 100644 --- a/packages/extension/src/watcher/workspaceWatcher.ts +++ b/packages/extension/src/watcher/workspaceWatcher.ts @@ -10,16 +10,12 @@ import type { WorkspaceWatcherDependencies, WatcherEvent } from "./types.js"; export type { WorkspaceWatcherDependencies, WatcherEvent } from "./types.js"; const DEBOUNCE_MS = 500; -const MAX_QUEUE_SIZE = 50; +const MAX_QUEUE_SIZE = 200; function hashContent(content: string): string { return createHash("sha256").update(content, "utf8").digest("hex"); } -function isVerbose(): boolean { - return vscode.workspace.getConfiguration("dextree").get("watcher.verbose") ?? false; -} - export function createWorkspaceWatcher( deps: WorkspaceWatcherDependencies, ): vscode.Disposable & { drainQueue(): Promise } { @@ -27,6 +23,7 @@ export function createWorkspaceWatcher( const debounceTimers = new Map>(); const eventQueue: WatcherEvent[] = []; + let droppedEventCount = 0; // Lazily-initialized ignore helper — createWorkspaceIgnore reads .gitignore files. const ignorePromise = createWorkspaceIgnore(workspaceRoot); @@ -59,6 +56,9 @@ export function createWorkspaceWatcher( if (isWorkspaceIndexing()) { if (eventQueue.length < MAX_QUEUE_SIZE) { eventQueue.push(event); + } else { + droppedEventCount++; + logger?.warn(`[watcher] event dropped (queue full)`, { file: event.uri.fsPath }); } return; } @@ -114,9 +114,7 @@ export function createWorkspaceWatcher( const allFiles = await indexer.getAllFiles(); const stored = allFiles.find((f) => f.path === filePath); if (stored !== undefined && stored.hash === currentHash) { - if (isVerbose()) { - logger.debug(`[watcher] skipped (unchanged): ${fileName}`); - } + logger.debug(`[watcher] skipped (unchanged): ${fileName}`); return; } @@ -150,6 +148,13 @@ export function createWorkspaceWatcher( for (const event of queued) { await dispatchEvent(event); } + if (droppedEventCount > 0) { + const count = droppedEventCount; + droppedEventCount = 0; + void vscode.window.showInformationMessage( + `Dextree: ${count} file change(s) were skipped during indexing. Re-index to ensure consistency.`, + ); + } } function dispose(): void { diff --git a/packages/extension/src/webview/components/GraphView.tsx b/packages/extension/src/webview/components/GraphView.tsx index 21f9d8b..541d04f 100644 --- a/packages/extension/src/webview/components/GraphView.tsx +++ b/packages/extension/src/webview/components/GraphView.tsx @@ -1157,6 +1157,7 @@ export function GraphView({ const reducedMotion = useReducedMotionPreference(); const [error, setError] = useState(null); + const [retryCount, setRetryCount] = useState(0); const [fallbackGraph, setFallbackGraph] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(null); const [overlaySegments, setOverlaySegments] = useState([]); @@ -1554,6 +1555,7 @@ export function GraphView({ const colors = readThemeColors(); const graph = buildGraph(nodes, edges, colors); + const canceledRef = { current: false }; // Degree-based size boost: hub nodes (high connectivity) render larger so // important call-sites and widely-imported files stand out visually. @@ -1652,6 +1654,8 @@ export function GraphView({ if (!canUseWebGL()) { sigmaRef.current = null; + if (canceledRef.current) return; + if (canceledRef.current) return; setError(null); setFallbackGraph(buildFallbackGraph()); return () => { @@ -1835,6 +1839,11 @@ export function GraphView({ sigmaRef.current = sigma; applyTheme(graph, sigma, container); + if (canceledRef.current) { + sigma.kill(); + sigmaRef.current = null; + return; + } setFallbackGraph(null); setOverlaySegments([]); @@ -1943,11 +1952,13 @@ export function GraphView({ } catch (err) { console.error("Dextree graph renderer failed", err); sigmaRef.current = null; + if (canceledRef.current) return; setError(err instanceof Error ? err.message : "Could not initialize graph renderer."); setFallbackGraph(buildFallbackGraph()); } return () => { + canceledRef.current = true; if (clickTimeoutRef.current !== null) { window.clearTimeout(clickTimeoutRef.current); clickTimeoutRef.current = null; @@ -1961,7 +1972,7 @@ export function GraphView({ sigmaRef.current = null; graphRef.current = null; }; - }, [edges, nodes, onNavigate, reducedMotion]); + }, [edges, nodes, onNavigate, reducedMotion, retryCount]); useEffect(() => { const graph = graphRef.current; @@ -2097,10 +2108,37 @@ export function GraphView({ } if (error !== null) { + const handleRetry = (): void => { + setError(null); + setFallbackGraph(null); + setRetryCount((prev) => prev + 1); + }; + const handleShowFallback = (): void => { + if (graphRef.current !== null) { + setFallbackGraph(snapshotGraph(graphRef.current)); + } + }; + return (
); } diff --git a/packages/extension/src/webview/components/InspectorPanel.tsx b/packages/extension/src/webview/components/InspectorPanel.tsx index 467e616..4b0a4ed 100644 --- a/packages/extension/src/webview/components/InspectorPanel.tsx +++ b/packages/extension/src/webview/components/InspectorPanel.tsx @@ -66,11 +66,17 @@ function badgeFor(key: InspectorBadgeKey, node: GraphNode): BadgeRendering { case "framework": return renderFramework(node); case "layer": + return { text: capitalize(node.archLayer ?? PLACEHOLDER) }; case "entry": - return { text: PLACEHOLDER }; + return { text: capitalize(node.entryKind ?? PLACEHOLDER) }; } } +function capitalize(value: string): string { + if (value.length === 0) return value; + return value.charAt(0).toUpperCase() + value.slice(1); +} + function EmptyState(): React.ReactElement { return (
diff --git a/packages/extension/src/webview/components/MermaidPreviewPanel.tsx b/packages/extension/src/webview/components/MermaidPreviewPanel.tsx index cea5618..39530cc 100644 --- a/packages/extension/src/webview/components/MermaidPreviewPanel.tsx +++ b/packages/extension/src/webview/components/MermaidPreviewPanel.tsx @@ -121,24 +121,14 @@ export function MermaidPreviewPanel({ const [exportStatus, setExportStatus] = useState< | { type: "idle" } | { type: "working"; message: string } - | { type: "error"; message: string } - | { type: "success"; message: string } + | { type: "error"; message: string; timestamp: number } + | { type: "success"; message: string; timestamp: number } >({ type: "idle" }); const okPreview = useMemo(() => (preview?.status === "ok" ? preview : null), [preview]); const renderedSvg = renderState.status === "ok" ? renderState.svg : null; const exportResetTimerRef = useRef | null>(null); - function scheduleExportStatusReset(): void { - if (exportResetTimerRef.current !== null) { - clearTimeout(exportResetTimerRef.current); - } - exportResetTimerRef.current = setTimeout(() => { - setExportStatus({ type: "idle" }); - exportResetTimerRef.current = null; - }, 3000); - } - useEffect( () => () => { if (exportResetTimerRef.current !== null) { @@ -201,11 +191,15 @@ export function MermaidPreviewPanel({ setExportStatus({ type: "success", message: `${format.toUpperCase()} ready — choose where to save.`, + timestamp: Date.now(), }); - scheduleExportStatusReset(); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); - setExportStatus({ type: "error", message: `Export failed: ${message}` }); + setExportStatus({ + type: "error", + message: `Export failed: ${message}`, + timestamp: Date.now(), + }); } } @@ -214,11 +208,14 @@ export function MermaidPreviewPanel({ setExportStatus({ type: "working", message: "Copying image…" }); try { await copyClipboardImage(renderedSvg, writeClipboardImage); - setExportStatus({ type: "success", message: "Image copied to clipboard." }); - scheduleExportStatusReset(); + setExportStatus({ + type: "success", + message: "Image copied to clipboard.", + timestamp: Date.now(), + }); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); - setExportStatus({ type: "error", message: `Copy failed: ${message}` }); + setExportStatus({ type: "error", message: `Copy failed: ${message}`, timestamp: Date.now() }); } } @@ -229,14 +226,17 @@ export function MermaidPreviewPanel({ const snippet = buildMarkdownSnippet(okPreview.source); if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(snippet); - setExportStatus({ type: "success", message: "Markdown snippet copied." }); - scheduleExportStatusReset(); + setExportStatus({ + type: "success", + message: "Markdown snippet copied.", + timestamp: Date.now(), + }); } else { throw new Error("Clipboard text API unavailable"); } } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); - setExportStatus({ type: "error", message: `Copy failed: ${message}` }); + setExportStatus({ type: "error", message: `Copy failed: ${message}`, timestamp: Date.now() }); } } @@ -418,6 +418,14 @@ function ControlBar({ options, onOptionsChange }: ControlBarProps) { emitIfChanged({ ...options, direction: next }); } + function handleScopeChange(event: ChangeEvent): void { + const next = event.target.value; + if (next === "workspace") { + emitIfChanged({ ...options, scope: { kind: "workspace" } }); + } + // "file" is listed but disabled — scope stays workspace for now + } + return (