diff --git a/.changeset/faster-godot-class-names.md b/.changeset/faster-godot-class-names.md new file mode 100644 index 000000000..cd89e5dde --- /dev/null +++ b/.changeset/faster-godot-class-names.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/plugin-godot": patch +--- + +Godot class-name indexing no longer runs the full GDScript parser for metadata-only `class_name` discovery. On the CodeGraphy monorepo benchmark, the Godot metadata slice helped move cold indexing from 104.67s to 37.27s and file analysis from 87,918ms to 23,352ms. diff --git a/.changeset/faster-graph-cache-and-filtering.md b/.changeset/faster-graph-cache-and-filtering.md new file mode 100644 index 000000000..37831df2c --- /dev/null +++ b/.changeset/faster-graph-cache-and-filtering.md @@ -0,0 +1,10 @@ +--- +"@codegraphy-dev/core": patch +"@codegraphy-dev/extension": patch +--- + +Large CodeGraphy workspaces now index, save, and filter graph data much faster. On the CodeGraphy monorepo benchmark, cold indexing improved from 214.04s to 17.28s, Graph Cache saves improved from 122,757ms to 10,904ms, and the Graph Cache shrank from 64,638,976 bytes to 18,153,472 bytes. + +The same benchmark now projects the current Visible Graph in 12ms instead of 775ms. Folder-node projection improved from 1,369ms to 32ms, import-edge-off projection improved from 153ms to 7ms, and search projection improved from 781ms to 12ms. + +Graph Cache replay also normalizes cached path separators before checking gitignore rules, so ignored files stay filtered across platforms during warm starts. diff --git a/.changeset/faster-graph-view-interactions.md b/.changeset/faster-graph-view-interactions.md new file mode 100644 index 000000000..93dd79831 --- /dev/null +++ b/.changeset/faster-graph-view-interactions.md @@ -0,0 +1,9 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Graph View interactions now stay responsive on large workspaces. In the VS Code benchmark, toggling the Imports Graph Scope row improved from a 2,983ms median to 188ms wall clock, with the browser-visible update path measuring 54ms. + +Warm Graph View startup improved from 9,917ms to 4,614ms. The latest startup split shows CodeGraphy sends the first graph payload at 1,041ms, then spends most remaining first-ready time in VS Code view and webview frame readiness rather than graph work. + +Saved-file updates now stay incremental after the graph has loaded. In the editor-save benchmark, the post-save path measured 39ms from saved-document receipt to request start and 140ms to request completion. diff --git a/.changeset/faster-material-groups.md b/.changeset/faster-material-groups.md new file mode 100644 index 000000000..66dda4826 --- /dev/null +++ b/.changeset/faster-material-groups.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Default Graph View groups from Material Icon Theme rules now resolve faster in large workspaces. The measured group computation improved from 66ms to 38ms, and total group publish time improved from 71ms to 39ms. diff --git a/.changeset/faster-typescript-aliases.md b/.changeset/faster-typescript-aliases.md new file mode 100644 index 000000000..22592fb52 --- /dev/null +++ b/.changeset/faster-typescript-aliases.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/plugin-typescript": patch +--- + +TypeScript alias import analysis now reads `tsconfig` compiler options without enumerating every project file, and it reuses parsed alias configuration until the config changes. On the CodeGraphy monorepo benchmark, this moved cold indexing from 37.27s to 17.28s and file analysis from 23,352ms to 3,697ms. diff --git a/packages/core/src/diagnostics/collector.ts b/packages/core/src/diagnostics/collector.ts new file mode 100644 index 000000000..3cec10f0f --- /dev/null +++ b/packages/core/src/diagnostics/collector.ts @@ -0,0 +1,15 @@ +import type { DiagnosticEvent, DiagnosticEventSink } from './contracts'; + +export function collectDiagnosticEvents(enabled: boolean): DiagnosticEventSink & { readonly events: DiagnosticEvent[] } { + const events: DiagnosticEvent[] = []; + return { + get events(): DiagnosticEvent[] { + return events; + }, + emit(event: DiagnosticEvent): void { + if (enabled) { + events.push(event); + } + }, + }; +} diff --git a/packages/core/src/diagnostics/contracts.ts b/packages/core/src/diagnostics/contracts.ts new file mode 100644 index 000000000..47e0fa07a --- /dev/null +++ b/packages/core/src/diagnostics/contracts.ts @@ -0,0 +1,27 @@ +export type DiagnosticContextValue = + | null + | string + | number + | boolean + | DiagnosticContextValue[] + | { [key: string]: DiagnosticContextValue }; + +export interface DiagnosticEvent { + area: string; + event: string; + context?: Record; +} + +export interface DiagnosticEventInput { + area: string; + event: string; + context?: Record; +} + +export interface DiagnosticEventSink { + emit(event: DiagnosticEvent): void; +} + +export type DiagnosticEventFormatter = ( + context: Record | undefined, +) => string | undefined; diff --git a/packages/core/src/diagnostics/create.ts b/packages/core/src/diagnostics/create.ts new file mode 100644 index 000000000..5a4544ab5 --- /dev/null +++ b/packages/core/src/diagnostics/create.ts @@ -0,0 +1,10 @@ +import type { DiagnosticEvent, DiagnosticEventInput } from './contracts'; +import { normalizeContext } from './normalize/context'; + +export function createDiagnosticEvent(input: DiagnosticEventInput): DiagnosticEvent { + return { + area: input.area, + event: input.event, + ...(input.context ? { context: normalizeContext(input.context) } : {}), + }; +} diff --git a/packages/core/src/diagnostics/events.ts b/packages/core/src/diagnostics/events.ts index 047d4b1d0..62a3289b7 100644 --- a/packages/core/src/diagnostics/events.ts +++ b/packages/core/src/diagnostics/events.ts @@ -1,351 +1,27 @@ -export type DiagnosticContextValue = - | null - | string - | number - | boolean - | DiagnosticContextValue[] - | { [key: string]: DiagnosticContextValue }; - -export interface DiagnosticEvent { - area: string; - event: string; - context?: Record; -} - -export interface DiagnosticEventInput { - area: string; - event: string; - context?: Record; -} - -export interface DiagnosticEventSink { - emit(event: DiagnosticEvent): void; -} - -function normalizeError(error: Error): Record { - return { - name: error.name, - message: error.message, - }; -} - -function normalizeContextValue(value: unknown): DiagnosticContextValue { - if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return value; - } - - if (value instanceof Error) { - return normalizeError(value); - } - - if (Array.isArray(value)) { - return value.map(normalizeContextValue); - } - - if (value instanceof Set) { - return [...value].map(normalizeContextValue); - } - - if (value instanceof Map) { - return [...value.entries()].map(([key, entryValue]) => ({ - key: normalizeContextValue(key), - value: normalizeContextValue(entryValue), - })); - } - - if (typeof value === 'object') { - const normalized: Record = {}; - for (const [key, entryValue] of Object.entries(value)) { - normalized[key] = normalizeContextValue(entryValue); - } - return normalized; - } - - if (typeof value === 'undefined') { - return 'undefined'; - } - - if (typeof value === 'bigint') { - return value.toString(); - } - - if (typeof value === 'symbol') { - return value.description ? `Symbol(${value.description})` : 'Symbol()'; - } - - if (typeof value === 'function') { - return value.name ? `[Function: ${value.name}]` : '[Function]'; - } - - return 'unknown'; -} - -function normalizeContext(context: Record | undefined): Record | undefined { - if (!context) { - return undefined; - } - - const normalized: Record = {}; - for (const [key, value] of Object.entries(context)) { - normalized[key] = normalizeContextValue(value); - } - return normalized; -} +import { collectDiagnosticEvents as collectDiagnosticEventsImpl } from './collector'; +import type { + DiagnosticEvent, + DiagnosticEventInput, + DiagnosticEventSink, +} from './contracts'; +import { createDiagnosticEvent as createDiagnosticEventImpl } from './create'; +import { formatDiagnosticEventLine as formatDiagnosticEventLineImpl } from './format/line'; + +export type { + DiagnosticContextValue, + DiagnosticEvent, + DiagnosticEventInput, + DiagnosticEventSink, +} from './contracts'; export function createDiagnosticEvent(input: DiagnosticEventInput): DiagnosticEvent { - return { - area: input.area, - event: input.event, - ...(input.context ? { context: normalizeContext(input.context) } : {}), - }; + return createDiagnosticEventImpl(input); } export function collectDiagnosticEvents(enabled: boolean): DiagnosticEventSink & { readonly events: DiagnosticEvent[] } { - const events: DiagnosticEvent[] = []; - return { - get events(): DiagnosticEvent[] { - return events; - }, - emit(event: DiagnosticEvent): void { - if (enabled) { - events.push(event); - } - }, - }; -} - -function formatCount(value: DiagnosticContextValue | undefined, noun: string): string | undefined { - if (typeof value !== 'number') { - return undefined; - } - return `${value} ${noun}${value === 1 ? '' : 's'}`; -} - -function formatScalar(value: DiagnosticContextValue | undefined): string | undefined { - if (value === null || typeof value === 'undefined') { - return undefined; - } - - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - - return JSON.stringify(value); -} - -function formatContextDetail( - context: Record | undefined, - key: string, - label: string = key, -): string | undefined { - const value = formatScalar(context?.[key]); - return value ? `${label}=${value}` : undefined; -} - -function joinDetails(details: Array): string { - return details.filter((detail): detail is string => Boolean(detail)).join(', '); -} - -function formatStatusRead(context: Record | undefined): string { - const state = formatScalar(context?.state); - const cacheDescription = state ? `${state} Graph Cache` : 'Graph Cache'; - const details = joinDetails([ - formatContextDetail(context, 'workspaceRoot', 'workspace'), - formatContextDetail(context, 'graphCache', 'cache'), - formatContextDetail(context, 'enabledPluginCount', 'plugins'), - ]); - return details - ? `Workspace status read: ${cacheDescription}, ${details}` - : `Workspace status read: ${cacheDescription}`; -} - -function formatIndexingComplete(context: Record | undefined): string { - const counts = joinDetails([ - formatCount(context?.files, 'file'), - formatCount(context?.nodes, 'node'), - formatCount(context?.edges, 'edge'), - ]); - const details = joinDetails([ - counts, - formatContextDetail(context, 'durationMs', 'durationMs'), - formatContextDetail(context, 'operationId', 'operation'), - formatContextDetail(context, 'graphCache', 'cache'), - ]); - return details ? `Indexing complete: ${details}` : 'Indexing complete'; -} - -function formatCommandEvent( - event: string, - context: Record | undefined, -): string | undefined { - const command = formatScalar(context?.command); - if (event === 'command-started') { - const details = joinDetails([ - command, - formatContextDetail(context, 'action'), - formatContextDetail(context, 'workspacePath', 'workspace'), - ]); - return details ? `Starting command: ${details}` : 'Starting command'; - } - - if (event === 'command-completed') { - const details = joinDetails([ - command, - formatContextDetail(context, 'exitCode'), - ]); - return details ? `Command complete: ${details}` : 'Command complete'; - } - - return undefined; -} - -function formatAnalysisEvent( - event: string, - context: Record | undefined, -): string | undefined { - if (event === 'request-started') { - return `Starting analysis: ${joinDetails([ - formatContextDetail(context, 'requestId', 'request'), - formatContextDetail(context, 'mode'), - formatContextDetail(context, 'filterPatternCount', 'filters'), - formatContextDetail(context, 'disabledPluginCount', 'disabledPlugins'), - ])}`; - } - - if (event === 'request-completed') { - return `Analysis complete: ${joinDetails([ - formatContextDetail(context, 'requestId', 'request'), - formatContextDetail(context, 'mode'), - formatContextDetail(context, 'durationMs', 'durationMs'), - ])}`; - } - - if (event === 'request-failed') { - return `Analysis failed: ${joinDetails([ - formatContextDetail(context, 'requestId', 'request'), - formatContextDetail(context, 'mode'), - formatContextDetail(context, 'durationMs', 'durationMs'), - formatContextDetail(context, 'error'), - ])}`; - } - - if (event === 'load-decision') { - return `Analysis load decision: ${joinDetails([ - formatContextDetail(context, 'route'), - formatContextDetail(context, 'mode'), - formatContextDetail(context, 'shouldDiscover'), - formatContextDetail(context, 'canReplayCache'), - formatContextDetail(context, 'indexFreshness', 'freshness'), - ])}`; - } - - return undefined; -} - -function formatKnownEvent(event: DiagnosticEvent): string | undefined { - const context = event.context; - - if (event.area === 'cli') { - return formatCommandEvent(event.event, context); - } - - if (event.area === 'workspace' && event.event === 'index-started') { - return `Starting indexing: ${joinDetails([ - formatContextDetail(context, 'workspaceRoot', 'workspace'), - formatContextDetail(context, 'operationId', 'operation'), - ])}`; - } - - if (event.area === 'workspace' && event.event === 'status-read') { - return formatStatusRead(context); - } - - if (event.area === 'indexing' && event.event === 'completed') { - return formatIndexingComplete(context); - } - - if (event.area === 'graph-query' && event.event === 'started') { - return `Starting Graph Query: ${joinDetails([ - formatContextDetail(context, 'report'), - formatContextDetail(context, 'operationId', 'operation'), - formatContextDetail(context, 'workspaceRoot', 'workspace'), - ])}`; - } - - if (event.area === 'graph-query' && event.event === 'cache-missing') { - return `Graph Cache missing: ${joinDetails([ - formatContextDetail(context, 'report'), - formatContextDetail(context, 'cacheState'), - formatContextDetail(context, 'operationId', 'operation'), - formatContextDetail(context, 'workspaceRoot', 'workspace'), - ])}`; - } - - if (event.area === 'graph-query' && event.event === 'completed') { - return `Graph Query complete: ${joinDetails([ - formatContextDetail(context, 'report'), - formatCount(context?.nodeCount, 'node'), - formatCount(context?.edgeCount, 'edge'), - formatContextDetail(context, 'durationMs', 'durationMs'), - formatContextDetail(context, 'operationId', 'operation'), - ])}`; - } - - if (event.area === 'extension.lifecycle' && event.event === 'activation-started') { - return `Extension activation started: ${joinDetails([ - formatContextDetail(context, 'workspaceFolders'), - ])}`; - } - - if (event.area === 'extension.lifecycle' && event.event === 'activation-completed') { - return `Extension activation complete: ${joinDetails([ - formatContextDetail(context, 'registeredWebviewProviders'), - ])}`; - } - - if (event.area === 'extension.webview' && event.event === 'ready-replayed') { - return `Webview ready replayed: ${joinDetails([ - formatContextDetail(context, 'hasWorkspace'), - formatContextDetail(context, 'firstAnalysis'), - formatContextDetail(context, 'readyNotified'), - formatContextDetail(context, 'maxFiles'), - ])}`; - } - - if (event.area === 'extension.webview' && event.event === 'bootstrap-completed') { - return `Webview bootstrap complete: ${joinDetails([ - formatContextDetail(context, 'hasWorkspace'), - formatContextDetail(context, 'firstAnalysis'), - formatContextDetail(context, 'readyNotified'), - ])}`; - } - - if (event.area === 'extension.analysis') { - return formatAnalysisEvent(event.event, context); - } - - return undefined; -} - -function humanizeEventName(event: string): string { - return event - .split('-') - .filter(Boolean) - .map((part, index) => index === 0 - ? `${part.charAt(0).toUpperCase()}${part.slice(1)}` - : part) - .join(' '); -} - -function formatFallbackEvent(event: DiagnosticEvent): string { - const details = joinDetails([ - formatContextDetail({ area: event.area }, 'area'), - ...Object.keys(event.context ?? {}).map(key => formatContextDetail(event.context, key)), - ]); - const message = humanizeEventName(event.event); - return details ? `${message}: ${details}` : message; + return collectDiagnosticEventsImpl(enabled); } export function formatDiagnosticEventLine(event: DiagnosticEvent): string { - return `[CodeGraphy] ${formatKnownEvent(event) ?? formatFallbackEvent(event)}`; + return formatDiagnosticEventLineImpl(event); } diff --git a/packages/core/src/diagnostics/format/events/analysis.ts b/packages/core/src/diagnostics/format/events/analysis.ts new file mode 100644 index 000000000..f8a070f84 --- /dev/null +++ b/packages/core/src/diagnostics/format/events/analysis.ts @@ -0,0 +1,42 @@ +import type { + DiagnosticContextValue, + DiagnosticEventFormatter, +} from '../../contracts'; +import { + formatContextDetail, + joinDetails, +} from '../parts'; + +const ANALYSIS_EVENT_FORMATTERS = new Map([ + ['request-started', context => `Starting analysis: ${joinDetails([ + formatContextDetail(context, 'requestId', 'request'), + formatContextDetail(context, 'mode'), + formatContextDetail(context, 'filterPatternCount', 'filters'), + formatContextDetail(context, 'disabledPluginCount', 'disabledPlugins'), + ])}`], + ['request-completed', context => `Analysis complete: ${joinDetails([ + formatContextDetail(context, 'requestId', 'request'), + formatContextDetail(context, 'mode'), + formatContextDetail(context, 'durationMs', 'durationMs'), + ])}`], + ['request-failed', context => `Analysis failed: ${joinDetails([ + formatContextDetail(context, 'requestId', 'request'), + formatContextDetail(context, 'mode'), + formatContextDetail(context, 'durationMs', 'durationMs'), + formatContextDetail(context, 'error'), + ])}`], + ['load-decision', context => `Analysis load decision: ${joinDetails([ + formatContextDetail(context, 'route'), + formatContextDetail(context, 'mode'), + formatContextDetail(context, 'shouldDiscover'), + formatContextDetail(context, 'canReplayCache'), + formatContextDetail(context, 'indexFreshness', 'freshness'), + ])}`], +]); + +export function formatAnalysisEvent( + event: string, + context: Record | undefined, +): string | undefined { + return ANALYSIS_EVENT_FORMATTERS.get(event)?.(context); +} diff --git a/packages/core/src/diagnostics/format/events/command.ts b/packages/core/src/diagnostics/format/events/command.ts new file mode 100644 index 000000000..2e0e4c007 --- /dev/null +++ b/packages/core/src/diagnostics/format/events/command.ts @@ -0,0 +1,31 @@ +import type { DiagnosticContextValue } from '../../contracts'; +import { + formatContextDetail, + formatScalar, + joinDetails, +} from '../parts'; + +export function formatCommandEvent( + event: string, + context: Record | undefined, +): string | undefined { + const command = formatScalar(context?.command); + if (event === 'command-started') { + const details = joinDetails([ + command, + formatContextDetail(context, 'action'), + formatContextDetail(context, 'workspacePath', 'workspace'), + ]); + return details ? `Starting command: ${details}` : 'Starting command'; + } + + if (event === 'command-completed') { + const details = joinDetails([ + command, + formatContextDetail(context, 'exitCode'), + ]); + return details ? `Command complete: ${details}` : 'Command complete'; + } + + return undefined; +} diff --git a/packages/core/src/diagnostics/format/events/extension.ts b/packages/core/src/diagnostics/format/events/extension.ts new file mode 100644 index 000000000..52011cf54 --- /dev/null +++ b/packages/core/src/diagnostics/format/events/extension.ts @@ -0,0 +1,48 @@ +import type { DiagnosticContextValue } from '../../contracts'; +import { + formatContextDetail, + joinDetails, +} from '../parts'; + +export function formatExtensionLifecycleEvent( + event: string, + context: Record | undefined, +): string | undefined { + if (event === 'activation-started') { + return `Extension activation started: ${joinDetails([ + formatContextDetail(context, 'workspaceFolders'), + ])}`; + } + + if (event === 'activation-completed') { + return `Extension activation complete: ${joinDetails([ + formatContextDetail(context, 'registeredWebviewProviders'), + ])}`; + } + + return undefined; +} + +export function formatExtensionWebviewEvent( + event: string, + context: Record | undefined, +): string | undefined { + if (event === 'ready-replayed') { + return `Webview ready replayed: ${joinDetails([ + formatContextDetail(context, 'hasWorkspace'), + formatContextDetail(context, 'firstAnalysis'), + formatContextDetail(context, 'readyNotified'), + formatContextDetail(context, 'maxFiles'), + ])}`; + } + + if (event === 'bootstrap-completed') { + return `Webview bootstrap complete: ${joinDetails([ + formatContextDetail(context, 'hasWorkspace'), + formatContextDetail(context, 'firstAnalysis'), + formatContextDetail(context, 'readyNotified'), + ])}`; + } + + return undefined; +} diff --git a/packages/core/src/diagnostics/format/events/graphQuery.ts b/packages/core/src/diagnostics/format/events/graphQuery.ts new file mode 100644 index 000000000..92d03f1da --- /dev/null +++ b/packages/core/src/diagnostics/format/events/graphQuery.ts @@ -0,0 +1,40 @@ +import type { DiagnosticContextValue } from '../../contracts'; +import { + formatContextDetail, + formatCount, + joinDetails, +} from '../parts'; + +export function formatGraphQueryEvent( + event: string, + context: Record | undefined, +): string | undefined { + if (event === 'started') { + return `Starting Graph Query: ${joinDetails([ + formatContextDetail(context, 'report'), + formatContextDetail(context, 'operationId', 'operation'), + formatContextDetail(context, 'workspaceRoot', 'workspace'), + ])}`; + } + + if (event === 'cache-missing') { + return `Graph Cache missing: ${joinDetails([ + formatContextDetail(context, 'report'), + formatContextDetail(context, 'cacheState'), + formatContextDetail(context, 'operationId', 'operation'), + formatContextDetail(context, 'workspaceRoot', 'workspace'), + ])}`; + } + + if (event === 'completed') { + return `Graph Query complete: ${joinDetails([ + formatContextDetail(context, 'report'), + formatCount(context?.nodeCount, 'node'), + formatCount(context?.edgeCount, 'edge'), + formatContextDetail(context, 'durationMs', 'durationMs'), + formatContextDetail(context, 'operationId', 'operation'), + ])}`; + } + + return undefined; +} diff --git a/packages/core/src/diagnostics/format/events/indexing.ts b/packages/core/src/diagnostics/format/events/indexing.ts new file mode 100644 index 000000000..aa83909fe --- /dev/null +++ b/packages/core/src/diagnostics/format/events/indexing.ts @@ -0,0 +1,46 @@ +import type { DiagnosticContextValue } from '../../contracts'; +import { + formatContextDetail, + formatCount, + joinDetails, +} from '../parts'; + +export function formatIndexingEvent( + event: string, + context: Record | undefined, +): string | undefined { + if (event === 'completed') { + const counts = joinDetails([ + formatCount(context?.files, 'file'), + formatCount(context?.nodes, 'node'), + formatCount(context?.edges, 'edge'), + ]); + const details = joinDetails([ + counts, + formatContextDetail(context, 'durationMs', 'durationMs'), + formatContextDetail(context, 'operationId', 'operation'), + formatContextDetail(context, 'graphCache', 'cache'), + ]); + return details ? `Indexing complete: ${details}` : 'Indexing complete'; + } + + if (event === 'phase-completed') { + const details = joinDetails([ + formatContextDetail(context, 'phase'), + formatContextDetail(context, 'durationMs', 'durationMs'), + formatContextDetail(context, 'files'), + formatContextDetail(context, 'directories'), + formatContextDetail(context, 'totalFound', 'totalFound'), + formatContextDetail(context, 'limitReached', 'limitReached'), + formatContextDetail(context, 'cacheHits', 'cacheHits'), + formatContextDetail(context, 'cacheMisses', 'cacheMisses'), + formatContextDetail(context, 'nodes'), + formatContextDetail(context, 'edges'), + formatContextDetail(context, 'loadedPackagePlugins', 'loadedPackagePlugins'), + formatContextDetail(context, 'registeredPlugins', 'registeredPlugins'), + ]); + return details ? `Indexing phase complete: ${details}` : 'Indexing phase complete'; + } + + return undefined; +} diff --git a/packages/core/src/diagnostics/format/events/workspace.ts b/packages/core/src/diagnostics/format/events/workspace.ts new file mode 100644 index 000000000..ed211c73d --- /dev/null +++ b/packages/core/src/diagnostics/format/events/workspace.ts @@ -0,0 +1,34 @@ +import type { DiagnosticContextValue } from '../../contracts'; +import { + formatContextDetail, + formatScalar, + joinDetails, +} from '../parts'; + +export function formatWorkspaceEvent( + event: string, + context: Record | undefined, +): string | undefined { + if (event === 'index-started') { + const details = joinDetails([ + formatContextDetail(context, 'workspaceRoot', 'workspace'), + formatContextDetail(context, 'operationId', 'operation'), + ]); + return `Starting indexing: ${details}`; + } + + if (event === 'status-read') { + const state = formatScalar(context?.state); + const cacheDescription = state ? `${state} Graph Cache` : 'Graph Cache'; + const details = joinDetails([ + formatContextDetail(context, 'workspaceRoot', 'workspace'), + formatContextDetail(context, 'graphCache', 'cache'), + formatContextDetail(context, 'enabledPluginCount', 'plugins'), + ]); + return details + ? `Workspace status read: ${cacheDescription}, ${details}` + : `Workspace status read: ${cacheDescription}`; + } + + return undefined; +} diff --git a/packages/core/src/diagnostics/format/fallback.ts b/packages/core/src/diagnostics/format/fallback.ts new file mode 100644 index 000000000..814578665 --- /dev/null +++ b/packages/core/src/diagnostics/format/fallback.ts @@ -0,0 +1,19 @@ +import type { DiagnosticEvent } from '../contracts'; +import { + formatContextDetail, + joinDetails, +} from './parts'; +import { humanizeEventName } from './humanize'; + +export function formatFallbackEvent(event: DiagnosticEvent): string { + const details = joinDetails([ + formatContextDetail({ area: event.area }, 'area'), + ...Object.keys(event.context ?? {}).map(key => formatContextDetail(event.context, key)), + ]); + const message = humanizeEventName(event.event); + if (details) { + return `${message}: ${details}`; + } + + return message; +} diff --git a/packages/core/src/diagnostics/format/humanize.ts b/packages/core/src/diagnostics/format/humanize.ts new file mode 100644 index 000000000..2ef514647 --- /dev/null +++ b/packages/core/src/diagnostics/format/humanize.ts @@ -0,0 +1,9 @@ +export function humanizeEventName(event: string): string { + return event + .split('-') + .filter(Boolean) + .map((part, index) => index === 0 + ? `${part.charAt(0).toUpperCase()}${part.slice(1)}` + : part) + .join(' '); +} diff --git a/packages/core/src/diagnostics/format/known.ts b/packages/core/src/diagnostics/format/known.ts new file mode 100644 index 000000000..2b12c8c6e --- /dev/null +++ b/packages/core/src/diagnostics/format/known.ts @@ -0,0 +1,44 @@ +import type { DiagnosticEvent } from '../contracts'; +import { formatAnalysisEvent } from './events/analysis'; +import { formatCommandEvent } from './events/command'; +import { + formatExtensionLifecycleEvent, + formatExtensionWebviewEvent, +} from './events/extension'; +import { formatGraphQueryEvent } from './events/graphQuery'; +import { + formatIndexingEvent, +} from './events/indexing'; +import { formatWorkspaceEvent } from './events/workspace'; + +export function formatKnownEvent(event: DiagnosticEvent): string | undefined { + if (event.area === 'cli') { + return formatCommandEvent(event.event, event.context); + } + + if (event.area === 'extension.analysis') { + return formatAnalysisEvent(event.event, event.context); + } + + if (event.area === 'workspace') { + return formatWorkspaceEvent(event.event, event.context); + } + + if (event.area === 'indexing') { + return formatIndexingEvent(event.event, event.context); + } + + if (event.area === 'graph-query') { + return formatGraphQueryEvent(event.event, event.context); + } + + if (event.area === 'extension.lifecycle') { + return formatExtensionLifecycleEvent(event.event, event.context); + } + + if (event.area === 'extension.webview') { + return formatExtensionWebviewEvent(event.event, event.context); + } + + return undefined; +} diff --git a/packages/core/src/diagnostics/format/line.ts b/packages/core/src/diagnostics/format/line.ts new file mode 100644 index 000000000..27dd2347d --- /dev/null +++ b/packages/core/src/diagnostics/format/line.ts @@ -0,0 +1,7 @@ +import type { DiagnosticEvent } from '../contracts'; +import { formatFallbackEvent } from './fallback'; +import { formatKnownEvent } from './known'; + +export function formatDiagnosticEventLine(event: DiagnosticEvent): string { + return `[CodeGraphy] ${formatKnownEvent(event) ?? formatFallbackEvent(event)}`; +} diff --git a/packages/core/src/diagnostics/format/parts.ts b/packages/core/src/diagnostics/format/parts.ts new file mode 100644 index 000000000..d359657ca --- /dev/null +++ b/packages/core/src/diagnostics/format/parts.ts @@ -0,0 +1,33 @@ +import type { DiagnosticContextValue } from '../contracts'; + +export function formatCount(value: DiagnosticContextValue | undefined, noun: string): string | undefined { + if (typeof value !== 'number') { + return undefined; + } + return `${value} ${noun}${value === 1 ? '' : 's'}`; +} + +export function formatScalar(value: DiagnosticContextValue | undefined): string | undefined { + if (value === null || typeof value === 'undefined') { + return undefined; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + return JSON.stringify(value); +} + +export function formatContextDetail( + context: Record | undefined, + key: string, + label: string = key, +): string | undefined { + const value = formatScalar(context?.[key]); + return value ? `${label}=${value}` : undefined; +} + +export function joinDetails(details: Array): string { + return details.filter((detail): detail is string => Boolean(detail)).join(', '); +} diff --git a/packages/core/src/diagnostics/normalize/collections.ts b/packages/core/src/diagnostics/normalize/collections.ts new file mode 100644 index 000000000..7310ca327 --- /dev/null +++ b/packages/core/src/diagnostics/normalize/collections.ts @@ -0,0 +1,25 @@ +import type { DiagnosticContextValue } from '../contracts'; + +type NormalizeValue = (value: unknown) => DiagnosticContextValue; + +export function normalizeCollectionContextValue( + value: unknown, + normalizeValue: NormalizeValue, +): DiagnosticContextValue | undefined { + if (Array.isArray(value)) { + return value.map(normalizeValue); + } + + if (value instanceof Set) { + return [...value].map(normalizeValue); + } + + if (value instanceof Map) { + return [...value.entries()].map(([key, entryValue]) => ({ + key: normalizeValue(key), + value: normalizeValue(entryValue), + })); + } + + return undefined; +} diff --git a/packages/core/src/diagnostics/normalize/context.ts b/packages/core/src/diagnostics/normalize/context.ts new file mode 100644 index 000000000..e10def8c8 --- /dev/null +++ b/packages/core/src/diagnostics/normalize/context.ts @@ -0,0 +1,35 @@ +import type { DiagnosticContextValue } from '../contracts'; +import { normalizeCollectionContextValue } from './collections'; +import { normalizeObjectContextValue } from './objects'; +import { + isScalarContextValue, + normalizeError, + normalizeNonJsonPrimitiveContextValue, +} from './primitives'; + +function normalizeContextValue(value: unknown): DiagnosticContextValue { + if (isScalarContextValue(value)) { + return value; + } + + if (value instanceof Error) { + return normalizeError(value); + } + + return normalizeCollectionContextValue(value, normalizeContextValue) + ?? normalizeObjectContextValue(value, normalizeContextValue) + ?? normalizeNonJsonPrimitiveContextValue(value) + ?? 'unknown'; +} + +export function normalizeContext(context: Record | undefined): Record | undefined { + if (!context) { + return undefined; + } + + const normalized: Record = {}; + for (const [key, value] of Object.entries(context)) { + normalized[key] = normalizeContextValue(value); + } + return normalized; +} diff --git a/packages/core/src/diagnostics/normalize/objects.ts b/packages/core/src/diagnostics/normalize/objects.ts new file mode 100644 index 000000000..db7b0bd44 --- /dev/null +++ b/packages/core/src/diagnostics/normalize/objects.ts @@ -0,0 +1,18 @@ +import type { DiagnosticContextValue } from '../contracts'; + +type NormalizeValue = (value: unknown) => DiagnosticContextValue; + +export function normalizeObjectContextValue( + value: unknown, + normalizeValue: NormalizeValue, +): DiagnosticContextValue | undefined { + if (value === null || typeof value !== 'object') { + return undefined; + } + + const normalized: Record = {}; + for (const [key, entryValue] of Object.entries(value)) { + normalized[key] = normalizeValue(entryValue); + } + return normalized; +} diff --git a/packages/core/src/diagnostics/normalize/primitives.ts b/packages/core/src/diagnostics/normalize/primitives.ts new file mode 100644 index 000000000..2dcc6aa32 --- /dev/null +++ b/packages/core/src/diagnostics/normalize/primitives.ts @@ -0,0 +1,35 @@ +import type { DiagnosticContextValue } from '../contracts'; + +export function normalizeError(error: Error): Record { + return { + name: error.name, + message: error.message, + }; +} + +export function isScalarContextValue(value: unknown): value is null | string | number | boolean { + return value === null + || typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean'; +} + +export function normalizeNonJsonPrimitiveContextValue(value: unknown): DiagnosticContextValue | undefined { + if (typeof value === 'undefined') { + return 'undefined'; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (typeof value === 'symbol') { + return value.description ? `Symbol(${value.description})` : 'Symbol()'; + } + + if (typeof value === 'function') { + return value.name ? `[Function: ${value.name}]` : '[Function]'; + } + + return undefined; +} diff --git a/packages/core/src/discovery/defaultExcludedPath.ts b/packages/core/src/discovery/defaultExcludedPath.ts new file mode 100644 index 000000000..ca4ceccf5 --- /dev/null +++ b/packages/core/src/discovery/defaultExcludedPath.ts @@ -0,0 +1,33 @@ +import { normalizeDiscoveryPath } from './pathNormalization.js'; + +const DEFAULT_EXCLUDE_SEGMENTS = new Set([ + 'node_modules', + 'dist', + 'build', + 'out', + '.git', + '.codegraphy', + '.turbo', + '.worktrees', + 'coverage', +]); + +const DEFAULT_EXCLUDE_BASENAMES = new Set([ + '.DS_Store', +]); + +const DEFAULT_EXCLUDE_SUFFIXES = [ + '.min.js', + '.bundle.js', + '.map', +]; + +export function isDefaultExcludedPath(relativePath: string): boolean { + const normalizedPath = normalizeDiscoveryPath(relativePath); + const segments = normalizedPath.split('/').filter(Boolean); + const basename = segments.at(-1) ?? normalizedPath; + + return segments.some(segment => DEFAULT_EXCLUDE_SEGMENTS.has(segment)) + || DEFAULT_EXCLUDE_BASENAMES.has(basename) + || DEFAULT_EXCLUDE_SUFFIXES.some(suffix => basename.endsWith(suffix)); +} diff --git a/packages/core/src/discovery/knownDirectory.ts b/packages/core/src/discovery/knownDirectory.ts new file mode 100644 index 000000000..e1b4572cc --- /dev/null +++ b/packages/core/src/discovery/knownDirectory.ts @@ -0,0 +1,12 @@ +import { normalizeDiscoveryPath } from './pathNormalization.js'; + +export function shouldSkipKnownDirectory(relativePath: string): boolean { + const normalizedRelative = normalizeDiscoveryPath(relativePath); + + return normalizedRelative === 'node_modules' + || normalizedRelative === '.git' + || normalizedRelative === '.codegraphy' + || normalizedRelative.startsWith('node_modules/') + || normalizedRelative.startsWith('.git/') + || normalizedRelative.startsWith('.codegraphy/'); +} diff --git a/packages/core/src/discovery/pathExclusions.ts b/packages/core/src/discovery/pathExclusions.ts new file mode 100644 index 000000000..20d9b2681 --- /dev/null +++ b/packages/core/src/discovery/pathExclusions.ts @@ -0,0 +1,17 @@ +export const DEFAULT_EXCLUDE = [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/out/**', + '**/.git/**', + '**/.codegraphy/**', + '**/.turbo', + '**/.turbo/**', + '**/.worktrees', + '**/.worktrees/**', + '**/coverage/**', + '**/.DS_Store', + '**/*.min.js', + '**/*.bundle.js', + '**/*.map', +]; diff --git a/packages/core/src/discovery/pathMatching.ts b/packages/core/src/discovery/pathMatching.ts index 9789fdb38..68f44ad38 100644 --- a/packages/core/src/discovery/pathMatching.ts +++ b/packages/core/src/discovery/pathMatching.ts @@ -1,28 +1,10 @@ -/** - * @fileoverview Path normalization and pattern matching helpers for discovery. - * @module core/discovery/pathMatching - */ - import { minimatch } from 'minimatch'; +import { normalizeDiscoveryPath } from './pathNormalization.js'; -export const DEFAULT_EXCLUDE = [ - '**/node_modules/**', - '**/dist/**', - '**/build/**', - '**/out/**', - '**/.git/**', - '**/.codegraphy/**', - '**/.turbo/**', - '**/coverage/**', - '**/.DS_Store', - '**/*.min.js', - '**/*.bundle.js', - '**/*.map', -]; - -export function normalizeDiscoveryPath(relativePath: string): string { - return relativePath.replace(/\\/g, '/'); -} +export { DEFAULT_EXCLUDE } from './pathExclusions.js'; +export { isDefaultExcludedPath } from './defaultExcludedPath.js'; +export { normalizeDiscoveryPath } from './pathNormalization.js'; +export { shouldSkipKnownDirectory } from './knownDirectory.js'; export function matchesAnyPattern(relativePath: string, patterns: readonly string[]): boolean { const normalizedPath = normalizeDiscoveryPath(relativePath); @@ -31,14 +13,3 @@ export function matchesAnyPattern(relativePath: string, patterns: readonly strin minimatch(normalizedPath, pattern, { dot: true, matchBase: true }) ); } - -export function shouldSkipKnownDirectory(relativePath: string): boolean { - const normalizedRelative = normalizeDiscoveryPath(relativePath); - - return normalizedRelative === 'node_modules' - || normalizedRelative === '.git' - || normalizedRelative === '.codegraphy' - || normalizedRelative.startsWith('node_modules/') - || normalizedRelative.startsWith('.git/') - || normalizedRelative.startsWith('.codegraphy/'); -} diff --git a/packages/core/src/discovery/pathNormalization.ts b/packages/core/src/discovery/pathNormalization.ts new file mode 100644 index 000000000..61f7c5595 --- /dev/null +++ b/packages/core/src/discovery/pathNormalization.ts @@ -0,0 +1,3 @@ +export function normalizeDiscoveryPath(relativePath: string): string { + return relativePath.replace(/\\/g, '/'); +} diff --git a/packages/core/src/globMatch.ts b/packages/core/src/globMatch.ts index 1530b1dcc..ab9a217bd 100644 --- a/packages/core/src/globMatch.ts +++ b/packages/core/src/globMatch.ts @@ -1,44 +1,12 @@ -/** - * Convert a simple glob pattern to a RegExp. - * - * Rules: - * - `**` matches any path segments, including nested `/` - * - `*` matches anything except `/` - * - regex metacharacters are escaped - * - * Patterns are matched against the basename or path suffix, so `src/*` - * works anywhere in the tree while still keeping `*` and `**` semantics. - */ -export function globToRegex(pattern: string): RegExp { - let body = ''; - for (let index = 0; index < pattern.length; index += 1) { - const character = pattern[index]; - const nextCharacter = pattern[index + 1]; - const afterNextCharacter = pattern[index + 2]; +import { globToRegex } from './globRegex.js'; - if (character === '*' && nextCharacter === '*' && afterNextCharacter === '/') { - body += '(?:.*/)?'; - index += 2; - continue; - } +export { globToRegex } from './globRegex.js'; - if (character === '*' && nextCharacter === '*') { - body += '.*'; - index += 1; - continue; - } - - if (character === '*') { - body += '[^/]*'; - continue; - } - - body += character.replace(/([.+^${}()|[\]\\])/g, '\\$1'); - } - - return new RegExp(`(?:^|/)${body}$`); +export function createGlobMatcher(pattern: string): (filePath: string) => boolean { + const regex = globToRegex(pattern); + return (filePath: string): boolean => regex.test(filePath); } export function globMatch(filePath: string, pattern: string): boolean { - return globToRegex(pattern).test(filePath); + return createGlobMatcher(pattern)(filePath); } diff --git a/packages/core/src/globRegex.ts b/packages/core/src/globRegex.ts new file mode 100644 index 000000000..b1d31ef78 --- /dev/null +++ b/packages/core/src/globRegex.ts @@ -0,0 +1,40 @@ +/** + * Convert a simple glob pattern to a RegExp. + * + * Rules: + * - `**` matches any path segments, including nested `/` + * - `*` matches anything except `/` + * - regex metacharacters are escaped + * + * Patterns are matched against the basename or path suffix, so `src/*` + * works anywhere in the tree while still keeping `*` and `**` semantics. + */ +export function globToRegex(pattern: string): RegExp { + let body = ''; + for (let index = 0; index < pattern.length; index += 1) { + const character = pattern[index]; + const nextCharacter = pattern[index + 1]; + const afterNextCharacter = pattern[index + 2]; + + if (character === '*' && nextCharacter === '*' && afterNextCharacter === '/') { + body += '(?:.*/)?'; + index += 2; + continue; + } + + if (character === '*' && nextCharacter === '*') { + body += '.*'; + index += 1; + continue; + } + + if (character === '*') { + body += '[^/]*'; + continue; + } + + body += character.replace(/([.+^${}()|[\]\\])/g, '\\$1'); + } + + return new RegExp(`(?:^|/)${body}$`); +} diff --git a/packages/core/src/graph/edgeTargetCache.ts b/packages/core/src/graph/edgeTargetCache.ts new file mode 100644 index 000000000..5ce59fe15 --- /dev/null +++ b/packages/core/src/graph/edgeTargetCache.ts @@ -0,0 +1,58 @@ +import type { IPlugin } from '@codegraphy-dev/plugin-api'; +import type { IProjectedConnection } from '../analysis/projectedConnection.js'; +import { getConnectionTargetId } from './edgeTargets.js'; + +export type ConnectionTargetResolver = typeof getConnectionTargetId; + +export function createCachedConnectionTargetResolver( + resolveConnectionTargetId: ConnectionTargetResolver, + fileConnections: ReadonlyMap, + workspaceRoot: string, +): (plugin: IPlugin | undefined, connection: IProjectedConnection) => string | null { + const targetIdByPlugin = new Map>(); + + return (plugin, connection) => { + const cacheKey = createTargetCacheKey(connection); + if (!cacheKey) { + return resolveConnectionTargetId( + plugin, + connection, + fileConnections, + workspaceRoot, + ); + } + + const pluginKey = plugin?.id; + const targetIdByKey = targetIdByPlugin.get(pluginKey); + if (targetIdByKey?.has(cacheKey)) { + return targetIdByKey.get(cacheKey) ?? null; + } + + const targetId = resolveConnectionTargetId( + plugin, + connection, + fileConnections, + workspaceRoot, + ); + + const pluginTargetIds = targetIdByKey ?? new Map(); + targetIdByPlugin.set(pluginKey, pluginTargetIds); + pluginTargetIds.set(cacheKey, targetId); + + return targetId; + }; +} + +function createTargetCacheKey( + connection: IProjectedConnection, +): string | undefined { + if (connection.resolvedPath) { + return `resolved\0${connection.resolvedPath}`; + } + + if (connection.specifier) { + return `specifier\0${connection.specifier}`; + } + + return undefined; +} diff --git a/packages/core/src/graph/edges.ts b/packages/core/src/graph/edges.ts index 39f3552c6..78714c6fe 100644 --- a/packages/core/src/graph/edges.ts +++ b/packages/core/src/graph/edges.ts @@ -10,10 +10,15 @@ import type { IGraphEdge } from './contracts'; import { createGraphEdgeId } from './edgeIdentity'; import { createEdgeSource } from './edgeSources'; import { getConnectionTargetId } from './edgeTargets'; +import { + createCachedConnectionTargetResolver, + type ConnectionTargetResolver, +} from './edgeTargetCache.js'; export interface IWorkspaceGraphEdgesOptions { disabledPlugins: ReadonlySet; fileConnections: ReadonlyMap; + getConnectionTargetId?: ConnectionTargetResolver; getPluginForFile: (absolutePath: string) => IPlugin | undefined; workspaceRoot: string; } @@ -43,10 +48,9 @@ function appendConnectionEdge( disabledPlugins: ReadonlySet; edgeMap: Map; edges: IGraphEdge[]; - fileConnections: ReadonlyMap; nodeIds: Set; plugin: IPlugin | undefined; - workspaceRoot: string; + resolveConnectionTargetId: (plugin: IPlugin | undefined, connection: IProjectedConnection) => string | null; }, ): void { const sourcePluginId = connection.pluginId; @@ -54,11 +58,9 @@ function appendConnectionEdge( return; } - const targetId = getConnectionTargetId( + const targetId = options.resolveConnectionTargetId( options.plugin, connection, - options.fileConnections, - options.workspaceRoot, ); if (!targetId) { return; @@ -100,6 +102,7 @@ export function buildWorkspaceGraphEdges( const { disabledPlugins, fileConnections, + getConnectionTargetId: resolveConnectionTargetId = getConnectionTargetId, getPluginForFile, workspaceRoot, } = options; @@ -108,6 +111,11 @@ export function buildWorkspaceGraphEdges( const edgeMap = new Map(); const edges: IGraphEdge[] = []; const nodeIds = new Set(); + const resolveTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + fileConnections, + workspaceRoot, + ); for (const [filePath, connections] of fileConnections) { nodeIds.add(filePath); @@ -120,10 +128,9 @@ export function buildWorkspaceGraphEdges( disabledPlugins, edgeMap, edges, - fileConnections, nodeIds, plugin, - workspaceRoot, + resolveConnectionTargetId: resolveTarget, }); } } diff --git a/packages/core/src/graphCache/database/io/connection.ts b/packages/core/src/graphCache/database/io/connection.ts index 3341dc59d..f3d89edf7 100644 --- a/packages/core/src/graphCache/database/io/connection.ts +++ b/packages/core/src/graphCache/database/io/connection.ts @@ -1,6 +1,7 @@ import { Connection, Database } from '@ladybugdb/core'; import type * as lb from '@ladybugdb/core'; import type { FileAnalysisRow } from '../records/contracts'; +import { ensureSchema, ensureSchemaAsync } from './schema.js'; interface LadybugQueryResultLike { getAll?(): Promise; @@ -20,6 +21,20 @@ function closeQueryResults(result: unknown): void { } } +function firstQueryResult(result: unknown): LadybugQueryResultLike | undefined { + return (Array.isArray(result) ? result[0] : result) as LadybugQueryResultLike | undefined; +} + +function readRowsFromQueryResultSync(queryResult: LadybugQueryResultLike | undefined): FileAnalysisRow[] { + return queryResult?.getAllSync?.() ?? []; +} + +async function readRowsFromQueryResultAsync( + queryResult: LadybugQueryResultLike | undefined, +): Promise { + return queryResult?.getAll?.() ?? []; +} + export function runStatementSync(connection: lb.Connection, statement: string): void { const result = connection.querySync(statement); closeQueryResults(result); @@ -30,12 +45,51 @@ export async function runStatementAsync(connection: lb.Connection, statement: st closeQueryResults(result); } +export function prepareStatementSync( + connection: lb.Connection, + statement: string, +): lb.PreparedStatement { + const preparedStatement = connection.prepareSync(statement); + if (!preparedStatement.isSuccess()) { + throw new Error(preparedStatement.getErrorMessage()); + } + return preparedStatement; +} + +export async function prepareStatementAsync( + connection: lb.Connection, + statement: string, +): Promise { + const preparedStatement = await connection.prepare(statement); + if (!preparedStatement.isSuccess()) { + throw new Error(preparedStatement.getErrorMessage()); + } + return preparedStatement; +} + +export function executeStatementSync( + connection: lb.Connection, + preparedStatement: lb.PreparedStatement, + params: Record, +): void { + const result = connection.executeSync(preparedStatement, params); + closeQueryResults(result); +} + +export async function executeStatementAsync( + connection: lb.Connection, + preparedStatement: lb.PreparedStatement, + params: Record, +): Promise { + const result = await connection.execute(preparedStatement, params); + closeQueryResults(result); +} + export function readRowsSync(connection: lb.Connection, statement: string): FileAnalysisRow[] { const result = connection.querySync(statement); try { - const queryResult = Array.isArray(result) ? result[0] : result; - return (queryResult as LadybugQueryResultLike | undefined)?.getAllSync?.() ?? []; + return readRowsFromQueryResultSync(firstQueryResult(result)); } finally { closeQueryResults(result); } @@ -45,43 +99,12 @@ export async function readRowsAsync(connection: lb.Connection, statement: string const result = await connection.query(statement); try { - const queryResult = Array.isArray(result) ? result[0] : result; - return await ((queryResult as LadybugQueryResultLike | undefined)?.getAll?.() ?? []); + return await readRowsFromQueryResultAsync(firstQueryResult(result)); } finally { closeQueryResults(result); } } -function ensureSchema(connection: lb.Connection): void { - runStatementSync( - connection, - 'CREATE NODE TABLE IF NOT EXISTS FileAnalysis(filePath STRING PRIMARY KEY, mtime INT64, size INT64, analysis STRING)', - ); - runStatementSync( - connection, - 'CREATE NODE TABLE IF NOT EXISTS Symbol(symbolId STRING PRIMARY KEY, filePath STRING, name STRING, kind STRING, signature STRING, rangeJson STRING, metadataJson STRING)', - ); - runStatementSync( - connection, - 'CREATE NODE TABLE IF NOT EXISTS Relation(relationId STRING PRIMARY KEY, filePath STRING, kind STRING, pluginId STRING, sourceId STRING, fromFilePath STRING, toFilePath STRING, fromNodeId STRING, toNodeId STRING, fromSymbolId STRING, toSymbolId STRING, specifier STRING, relationType STRING, variant STRING, resolvedPath STRING, metadataJson STRING)', - ); -} - -async function ensureSchemaAsync(connection: lb.Connection): Promise { - await runStatementAsync( - connection, - 'CREATE NODE TABLE IF NOT EXISTS FileAnalysis(filePath STRING PRIMARY KEY, mtime INT64, size INT64, analysis STRING)', - ); - await runStatementAsync( - connection, - 'CREATE NODE TABLE IF NOT EXISTS Symbol(symbolId STRING PRIMARY KEY, filePath STRING, name STRING, kind STRING, signature STRING, rangeJson STRING, metadataJson STRING)', - ); - await runStatementAsync( - connection, - 'CREATE NODE TABLE IF NOT EXISTS Relation(relationId STRING PRIMARY KEY, filePath STRING, kind STRING, pluginId STRING, sourceId STRING, fromFilePath STRING, toFilePath STRING, fromNodeId STRING, toNodeId STRING, fromSymbolId STRING, toSymbolId STRING, specifier STRING, relationType STRING, variant STRING, resolvedPath STRING, metadataJson STRING)', - ); -} - export function withConnection( databasePath: string, callback: (connection: lb.Connection) => T, diff --git a/packages/core/src/graphCache/database/io/save.ts b/packages/core/src/graphCache/database/io/save.ts index 0cbf68549..b0056b517 100644 --- a/packages/core/src/graphCache/database/io/save.ts +++ b/packages/core/src/graphCache/database/io/save.ts @@ -1,14 +1,20 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { setImmediate as waitForImmediate } from 'node:timers/promises'; import type { IWorkspaceAnalysisCache } from '../../../analysis/cache'; -import { runStatementAsync, runStatementSync, withConnection, withConnectionAsync } from './connection'; +import { runStatementSync, withConnection } from './connection'; import { ensureDatabaseDirectory, getWorkspaceAnalysisDatabasePath } from './paths'; import { + createWorkspaceAnalysisCacheWriter, persistAnalysisEntry, - persistAnalysisEntryAsync, sortedCacheEntries, } from '../query/write'; +import { + cleanupTemporaryDatabase, + createTemporaryDatabasePath, + replaceDatabaseCache, +} from './temporary'; + +export { saveWorkspaceAnalysisDatabaseCacheAsync } from './saveAsync'; export interface WorkspaceAnalysisDatabaseSaveProgress { current: number; @@ -20,20 +26,6 @@ export interface WorkspaceAnalysisDatabaseSaveOptions { yieldEvery?: number; } -function createTemporaryDatabasePath(databasePath: string): string { - return `${databasePath}.${process.pid}.${Date.now()}.tmp`; -} - -function replaceDatabaseCache(tempDatabasePath: string, databasePath: string): void { - fs.renameSync(tempDatabasePath, databasePath); -} - -function cleanupTemporaryDatabase(tempDatabasePath: string): void { - if (fs.existsSync(tempDatabasePath)) { - fs.rmSync(tempDatabasePath, { force: true }); - } -} - export function saveWorkspaceAnalysisDatabaseCache( workspaceRoot: string, cache: IWorkspaceAnalysisCache, @@ -51,54 +43,9 @@ export function saveWorkspaceAnalysisDatabaseCache( runStatementSync(connection, 'MATCH (entry:Symbol) DELETE entry'); runStatementSync(connection, 'MATCH (entry:Relation) DELETE entry'); + const writer = createWorkspaceAnalysisCacheWriter(connection); for (const [filePath, entry] of sortedCacheEntries(cache)) { - persistAnalysisEntry(connection, filePath, entry); - } - }); - replaceDatabaseCache(tempDatabasePath, databasePath); - } catch (error) { - cleanupTemporaryDatabase(tempDatabasePath); - throw error; - } -} - -export async function saveWorkspaceAnalysisDatabaseCacheAsync( - workspaceRoot: string, - cache: IWorkspaceAnalysisCache, - options: WorkspaceAnalysisDatabaseSaveOptions = {}, -): Promise { - ensureDatabaseDirectory(workspaceRoot); - const databasePath = getWorkspaceAnalysisDatabasePath(workspaceRoot); - if (!fs.existsSync(path.dirname(databasePath))) { - return; - } - - const entries = sortedCacheEntries(cache); - const total = entries.length; - const yieldEvery = options.yieldEvery ?? 100; - const tempDatabasePath = createTemporaryDatabasePath(databasePath); - options.onProgress?.({ current: 0, total }); - - try { - await withConnectionAsync(tempDatabasePath, async (connection) => { - await runStatementAsync(connection, 'MATCH (entry:FileAnalysis) DELETE entry'); - await runStatementAsync(connection, 'MATCH (entry:Symbol) DELETE entry'); - await runStatementAsync(connection, 'MATCH (entry:Relation) DELETE entry'); - - let current = 0; - let statementsSinceYield = 0; - const yieldAfterStatement = async (): Promise => { - statementsSinceYield += 1; - if (yieldEvery > 0 && statementsSinceYield >= yieldEvery) { - statementsSinceYield = 0; - await waitForImmediate(); - } - }; - - for (const [filePath, entry] of entries) { - await persistAnalysisEntryAsync(connection, filePath, entry, yieldAfterStatement); - current += 1; - options.onProgress?.({ current, total }); + persistAnalysisEntry(writer, filePath, entry); } }); replaceDatabaseCache(tempDatabasePath, databasePath); diff --git a/packages/core/src/graphCache/database/io/saveAsync.ts b/packages/core/src/graphCache/database/io/saveAsync.ts new file mode 100644 index 000000000..4be06af8f --- /dev/null +++ b/packages/core/src/graphCache/database/io/saveAsync.ts @@ -0,0 +1,64 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { setImmediate as waitForImmediate } from 'node:timers/promises'; +import type { IWorkspaceAnalysisCache } from '../../../analysis/cache'; +import { runStatementAsync, withConnectionAsync } from './connection'; +import { ensureDatabaseDirectory, getWorkspaceAnalysisDatabasePath } from './paths'; +import { + cleanupTemporaryDatabase, + createTemporaryDatabasePath, + replaceDatabaseCache, +} from './temporary'; +import { + createWorkspaceAnalysisCacheWriterAsync, + persistAnalysisEntryAsync, + sortedCacheEntries, +} from '../query/write'; +import type { WorkspaceAnalysisDatabaseSaveOptions } from './save'; + +export async function saveWorkspaceAnalysisDatabaseCacheAsync( + workspaceRoot: string, + cache: IWorkspaceAnalysisCache, + options: WorkspaceAnalysisDatabaseSaveOptions = {}, +): Promise { + ensureDatabaseDirectory(workspaceRoot); + const databasePath = getWorkspaceAnalysisDatabasePath(workspaceRoot); + if (!fs.existsSync(path.dirname(databasePath))) { + return; + } + + const entries = sortedCacheEntries(cache); + const total = entries.length; + const yieldEvery = options.yieldEvery ?? 100; + const tempDatabasePath = createTemporaryDatabasePath(databasePath); + options.onProgress?.({ current: 0, total }); + + try { + await withConnectionAsync(tempDatabasePath, async (connection) => { + await runStatementAsync(connection, 'MATCH (entry:FileAnalysis) DELETE entry'); + await runStatementAsync(connection, 'MATCH (entry:Symbol) DELETE entry'); + await runStatementAsync(connection, 'MATCH (entry:Relation) DELETE entry'); + const writer = await createWorkspaceAnalysisCacheWriterAsync(connection); + + let current = 0; + let statementsSinceYield = 0; + const yieldAfterStatement = async (): Promise => { + statementsSinceYield += 1; + if (yieldEvery > 0 && statementsSinceYield >= yieldEvery) { + statementsSinceYield = 0; + await waitForImmediate(); + } + }; + + for (const [filePath, entry] of entries) { + await persistAnalysisEntryAsync(writer, filePath, entry, yieldAfterStatement); + current += 1; + options.onProgress?.({ current, total }); + } + }); + replaceDatabaseCache(tempDatabasePath, databasePath); + } catch (error) { + cleanupTemporaryDatabase(tempDatabasePath); + throw error; + } +} diff --git a/packages/core/src/graphCache/database/io/schema.ts b/packages/core/src/graphCache/database/io/schema.ts new file mode 100644 index 000000000..4fe7b9638 --- /dev/null +++ b/packages/core/src/graphCache/database/io/schema.ts @@ -0,0 +1,32 @@ +import type * as lb from '@ladybugdb/core'; +import { runStatementAsync, runStatementSync } from './connection.js'; + +export function ensureSchema(connection: lb.Connection): void { + runStatementSync( + connection, + 'CREATE NODE TABLE IF NOT EXISTS FileAnalysis(filePath STRING PRIMARY KEY, mtime INT64, size INT64, analysis STRING)', + ); + runStatementSync( + connection, + 'CREATE NODE TABLE IF NOT EXISTS Symbol(symbolId STRING PRIMARY KEY, filePath STRING, name STRING, kind STRING, signature STRING, rangeJson STRING, metadataJson STRING)', + ); + runStatementSync( + connection, + 'CREATE NODE TABLE IF NOT EXISTS Relation(relationId STRING PRIMARY KEY, filePath STRING, kind STRING, pluginId STRING, sourceId STRING, fromFilePath STRING, toFilePath STRING, fromNodeId STRING, toNodeId STRING, fromSymbolId STRING, toSymbolId STRING, specifier STRING, relationType STRING, variant STRING, resolvedPath STRING, metadataJson STRING)', + ); +} + +export async function ensureSchemaAsync(connection: lb.Connection): Promise { + await runStatementAsync( + connection, + 'CREATE NODE TABLE IF NOT EXISTS FileAnalysis(filePath STRING PRIMARY KEY, mtime INT64, size INT64, analysis STRING)', + ); + await runStatementAsync( + connection, + 'CREATE NODE TABLE IF NOT EXISTS Symbol(symbolId STRING PRIMARY KEY, filePath STRING, name STRING, kind STRING, signature STRING, rangeJson STRING, metadataJson STRING)', + ); + await runStatementAsync( + connection, + 'CREATE NODE TABLE IF NOT EXISTS Relation(relationId STRING PRIMARY KEY, filePath STRING, kind STRING, pluginId STRING, sourceId STRING, fromFilePath STRING, toFilePath STRING, fromNodeId STRING, toNodeId STRING, fromSymbolId STRING, toSymbolId STRING, specifier STRING, relationType STRING, variant STRING, resolvedPath STRING, metadataJson STRING)', + ); +} diff --git a/packages/core/src/graphCache/database/io/temporary.ts b/packages/core/src/graphCache/database/io/temporary.ts new file mode 100644 index 000000000..baaa49787 --- /dev/null +++ b/packages/core/src/graphCache/database/io/temporary.ts @@ -0,0 +1,38 @@ +import * as fs from 'node:fs'; + +const DATABASE_SIDECAR_SUFFIXES = ['.wal']; + +function getDatabaseSidecarPaths(databasePath: string): string[] { + return DATABASE_SIDECAR_SUFFIXES.map(suffix => `${databasePath}${suffix}`); +} + +function removePathIfPresent(filePath: string): void { + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { force: true }); + } +} + +export function createTemporaryDatabasePath(databasePath: string): string { + return `${databasePath}.${process.pid}.${Date.now()}.tmp`; +} + +export function replaceDatabaseCache(tempDatabasePath: string, databasePath: string): void { + for (const databaseSidecarPath of getDatabaseSidecarPaths(databasePath)) { + removePathIfPresent(databaseSidecarPath); + } + + fs.renameSync(tempDatabasePath, databasePath); + + for (const suffix of DATABASE_SIDECAR_SUFFIXES) { + const tempSidecarPath = `${tempDatabasePath}${suffix}`; + if (fs.existsSync(tempSidecarPath)) { + fs.renameSync(tempSidecarPath, `${databasePath}${suffix}`); + } + } +} + +export function cleanupTemporaryDatabase(tempDatabasePath: string): void { + for (const temporaryPath of [tempDatabasePath, ...getDatabaseSidecarPaths(tempDatabasePath)]) { + removePathIfPresent(temporaryPath); + } +} diff --git a/packages/core/src/graphCache/database/query/write.ts b/packages/core/src/graphCache/database/query/write.ts index ac6b9d66d..29a18a457 100644 --- a/packages/core/src/graphCache/database/query/write.ts +++ b/packages/core/src/graphCache/database/query/write.ts @@ -1,26 +1,29 @@ import type * as lb from '@ladybugdb/core'; -import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; import type { IWorkspaceAnalysisCache } from '../../../analysis/cache'; -import { runStatementAsync, runStatementSync } from '../io/connection'; -import { createRelationStatement } from '../relation/statement'; +import { + executeStatementAsync, + executeStatementSync, + prepareStatementAsync, + prepareStatementSync, +} from '../io/connection'; -function escapeCypherString(value: string): string { - return JSON.stringify(value); -} +const CREATE_FILE_ANALYSIS_STATEMENT = 'CREATE (entry:FileAnalysis {filePath: $filePath, mtime: $mtime, size: $size, analysis: $analysis})'; -function serializeJson(value: unknown): string { - return JSON.stringify(value ?? null); +export interface WorkspaceAnalysisCacheWriter { + connection: lb.Connection; + fileAnalysisStatement: lb.PreparedStatement; } -function createFileAnalysisStatement( +function createFileAnalysisParams( filePath: string, entry: IWorkspaceAnalysisCache['files'][string], -): string { - return `CREATE (entry:FileAnalysis {filePath: ${escapeCypherString(filePath)}, mtime: ${entry.mtime}, size: ${entry.size ?? 0}, analysis: ${escapeCypherString(JSON.stringify(entry.analysis))}})`; -} - -function createSymbolStatement(symbol: IAnalysisSymbol): string { - return `CREATE (entry:Symbol {symbolId: ${escapeCypherString(symbol.id)}, filePath: ${escapeCypherString(symbol.filePath)}, name: ${escapeCypherString(symbol.name)}, kind: ${escapeCypherString(symbol.kind)}, signature: ${escapeCypherString(symbol.signature ?? '')}, rangeJson: ${escapeCypherString(serializeJson(symbol.range))}, metadataJson: ${escapeCypherString(serializeJson(symbol.metadata))}})`; +): Record { + return { + filePath, + mtime: entry.mtime ?? 0, + size: entry.size ?? 0, + analysis: JSON.stringify(entry.analysis), + }; } export function sortedCacheEntries( @@ -29,48 +32,54 @@ export function sortedCacheEntries( return Object.entries(cache.files).sort(([left], [right]) => left.localeCompare(right)); } -export function persistAnalysisEntry( +export function createWorkspaceAnalysisCacheWriter( connection: lb.Connection, +): WorkspaceAnalysisCacheWriter { + return { + connection, + fileAnalysisStatement: prepareStatementSync(connection, CREATE_FILE_ANALYSIS_STATEMENT), + }; +} + +export async function createWorkspaceAnalysisCacheWriterAsync( + connection: lb.Connection, +): Promise { + const fileAnalysisStatement = await prepareStatementAsync(connection, CREATE_FILE_ANALYSIS_STATEMENT); + return { + connection, + fileAnalysisStatement, + }; +} + +export function persistAnalysisEntry( + writer: WorkspaceAnalysisCacheWriter, filePath: string, entry: IWorkspaceAnalysisCache['files'][string], ): void { - runStatementSync(connection, createFileAnalysisStatement(filePath, entry)); - - for (const symbol of entry.analysis.symbols ?? []) { - runStatementSync(connection, createSymbolStatement(symbol)); - } - - for (const [relationIndex, relation] of (entry.analysis.relations ?? []).entries()) { - runStatementSync(connection, createRelationStatement(filePath, relation, relationIndex)); - } + executeStatementSync(writer.connection, writer.fileAnalysisStatement, createFileAnalysisParams(filePath, entry)); } -async function runStatementAndYield( - connection: lb.Connection, - statement: string, +async function executeStatementAndYield( + writer: WorkspaceAnalysisCacheWriter, + preparedStatement: lb.PreparedStatement, + params: Record, afterStatement: () => Promise, ): Promise { - await runStatementAsync(connection, statement); + await executeStatementAsync(writer.connection, preparedStatement, params); await afterStatement(); } export async function persistAnalysisEntryAsync( - connection: lb.Connection, + writer: WorkspaceAnalysisCacheWriter, filePath: string, entry: IWorkspaceAnalysisCache['files'][string], afterStatement: () => Promise, ): Promise { - await runStatementAndYield(connection, createFileAnalysisStatement(filePath, entry), afterStatement); - - for (const symbol of entry.analysis.symbols ?? []) { - await runStatementAndYield(connection, createSymbolStatement(symbol), afterStatement); - } + await executeStatementAndYield( + writer, + writer.fileAnalysisStatement, + createFileAnalysisParams(filePath, entry), + afterStatement, + ); - for (const [relationIndex, relation] of (entry.analysis.relations ?? []).entries()) { - await runStatementAndYield( - connection, - createRelationStatement(filePath, relation, relationIndex), - afterStatement, - ); - } } diff --git a/packages/core/src/graphCache/database/snapshot.ts b/packages/core/src/graphCache/database/snapshot.ts index 31026ef76..308b10ee5 100644 --- a/packages/core/src/graphCache/database/snapshot.ts +++ b/packages/core/src/graphCache/database/snapshot.ts @@ -27,6 +27,14 @@ export interface WorkspaceAnalysisDatabaseSnapshot { relations: IAnalysisRelation[]; } +function readSymbolsFromFileAnalysis(files: WorkspaceAnalysisDatabaseSnapshot['files']): IAnalysisSymbol[] { + return files.flatMap(file => file.analysis.symbols ?? []); +} + +function readRelationsFromFileAnalysis(files: WorkspaceAnalysisDatabaseSnapshot['files']): IAnalysisRelation[] { + return files.flatMap(file => file.analysis.relations ?? []); +} + export function readWorkspaceAnalysisDatabaseSnapshot( workspaceRoot: string, ): WorkspaceAnalysisDatabaseSnapshot { @@ -40,20 +48,23 @@ export function readWorkspaceAnalysisDatabaseSnapshot( const fileRows = readRowsSync(connection, FILE_ANALYSIS_ROWS_QUERY); const symbolRows = readRowsSync(connection, SYMBOL_ROWS_QUERY) as SymbolRow[]; const relationRows = readRowsSync(connection, RELATION_ROWS_QUERY) as RelationRow[]; + const files = fileRows.flatMap((row) => { + const entry = createSnapshotFileEntry(row); + return entry ? [entry] : []; + }); + const symbols = symbolRows.flatMap((row) => { + const entry = createSnapshotSymbolEntry(row); + return entry ? [entry] : []; + }); + const relations = relationRows.flatMap((row) => { + const entry = createSnapshotRelationEntry(row); + return entry ? [entry] : []; + }); return { - files: fileRows.flatMap((row) => { - const entry = createSnapshotFileEntry(row); - return entry ? [entry] : []; - }), - symbols: symbolRows.flatMap((row) => { - const entry = createSnapshotSymbolEntry(row); - return entry ? [entry] : []; - }), - relations: relationRows.flatMap((row) => { - const entry = createSnapshotRelationEntry(row); - return entry ? [entry] : []; - }), + files, + symbols: symbols.length > 0 ? symbols : readSymbolsFromFileAnalysis(files), + relations: relations.length > 0 ? relations : readRelationsFromFileAnalysis(files), }; }); } catch (error) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 912f66854..0562908bc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -402,6 +402,7 @@ export type { ReadCodeGraphyWorkspaceStatusOptions, } from './workspace/status'; export { readCodeGraphyWorkspaceStatus } from './workspace/status'; +export { filterWorkspaceStatusPendingChangedFiles } from './workspace/statusPendingFiles'; export type { GraphQueryConfig, GraphQueryConnectionConfig, diff --git a/packages/core/src/indexing/analysis.ts b/packages/core/src/indexing/analysis.ts index 7c4613972..10330f5db 100644 --- a/packages/core/src/indexing/analysis.ts +++ b/packages/core/src/indexing/analysis.ts @@ -8,6 +8,23 @@ import { preAnalyzeCoreTreeSitterFiles } from '../treeSitter/core'; import type { IndexCodeGraphyWorkspaceOptions } from './contracts'; import { getFileStat } from './fileStat'; +function createCachedWorkspaceFileContentReader( + discovery: FileDiscovery, +): (file: IDiscoveryResult['files'][number]) => Promise { + const contentByRelativePath = new Map>(); + + return (file) => { + const cached = contentByRelativePath.get(file.relativePath); + if (cached) { + return cached; + } + + const content = discovery.readContent(file); + contentByRelativePath.set(file.relativePath, content); + return content; + }; +} + export async function analyzeWorkspaceIndexFiles(input: { cache: IWorkspaceAnalysisCache; discovery: FileDiscovery; @@ -17,6 +34,8 @@ export async function analyzeWorkspaceIndexFiles(input: { registry: CorePluginRegistry; workspaceRoot: string; }) { + const readContent = createCachedWorkspaceFileContentReader(input.discovery); + return analyzeWorkspacePipelineFiles({ analyzeFile: async (absolutePath, content, rootPath) => input.registry.analyzeFileResult( @@ -52,11 +71,11 @@ export async function analyzeWorkspaceIndexFiles(input: { input.disabledPlugins, ); }, - readContent: file => input.discovery.readContent(file), + readContent, }, signal, ), - readContent: file => input.discovery.readContent(file), + readContent, signal: input.options.signal, workspaceRoot: input.workspaceRoot, }); diff --git a/packages/core/src/indexing/contracts.ts b/packages/core/src/indexing/contracts.ts index daf625cfc..c1bdbd9f6 100644 --- a/packages/core/src/indexing/contracts.ts +++ b/packages/core/src/indexing/contracts.ts @@ -1,6 +1,7 @@ import type { IGraphData, IPlugin } from '@codegraphy-dev/plugin-api'; import type { IWorkspaceAnalysisCache } from '../analysis/cache'; import type { IDiscoveredFile } from '../discovery/contracts'; +import type { DiagnosticEventSink } from '../diagnostics/events'; import type { CodeGraphyWorkspaceSettings } from '../workspace/settings'; export interface IndexCodeGraphyWorkspacePluginEntry { @@ -25,6 +26,7 @@ export interface IndexCodeGraphyWorkspaceOptions { maxFiles?: number; respectGitignore?: boolean; signal?: AbortSignal; + diagnostics?: DiagnosticEventSink; onProgress?: (progress: { phase: string; current: number; total: number }) => void; logInfo?: (message: string) => void; warn?: (message: string) => void; diff --git a/packages/core/src/indexing/refresh.ts b/packages/core/src/indexing/refresh.ts index 8dcfbd4a5..7ce8a6ba0 100644 --- a/packages/core/src/indexing/refresh.ts +++ b/packages/core/src/indexing/refresh.ts @@ -1,402 +1,39 @@ -import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; -import type { IWorkspaceFileAnalysisResult } from '../analysis/fileAnalysis'; -import type { IProjectedConnection } from '../analysis/projectedConnection'; -import type { IDiscoveredFile } from '../discovery/contracts'; import type { IGraphData } from '../graph/contracts'; -import { getWorkspaceIndexPluginMatchingFiles } from '../plugins/status/extensions'; -import { - mapDiscoveredWorkspaceIndexFilesByRelativePath, - mergeDiscoveredWorkspaceIndexFiles, - selectDiscoveredWorkspaceIndexFileChanges, -} from './changedFiles'; - -type WorkspaceIndexPluginInfo = { - plugin: { - id: string; - supportedExtensions: readonly string[]; - }; -}; - -export interface WorkspaceIndexRefreshSource { - _analyzeFiles( - files: IDiscoveredFile[], - workspaceRoot: string, - onProgress?: (progress: { current: number; total: number; filePath: string }) => void, - signal?: AbortSignal, - pluginIds?: readonly string[], - disabledPlugins?: Set, - ): Promise; - _buildGraphData( - fileConnections: Map, - workspaceRoot: string, - disabledPlugins: Set, - ): IGraphData; - _buildGraphDataFromAnalysis( - fileAnalysis: Map, - workspaceRoot: string, - disabledPlugins: Set, - ): IGraphData; - _lastDiscoveredDirectories: string[]; - _lastDiscoveredFiles: IDiscoveredFile[]; - _lastFileAnalysis: Map; - _lastFileConnections: Map; - _lastWorkspaceRoot: string; - _preAnalyzePlugins( - files: IDiscoveredFile[], - workspaceRoot: string, - signal?: AbortSignal, - disabledPlugins?: Set, - ): Promise; - _readAnalysisFiles( - files: IDiscoveredFile[], - ): Promise>; - analyze( - filterPatterns?: string[], - disabledPlugins?: Set, - signal?: AbortSignal, - onProgress?: (progress: { phase: string; current: number; total: number }) => void, - ): Promise; - invalidateWorkspaceFiles(filePaths: readonly string[]): string[]; -} - -export interface WorkspaceIndexRefreshDependencies { - disabledPlugins: Set; - discoveredDirectories?: string[]; - discoveredFiles: IDiscoveredFile[]; - filePaths: readonly string[]; - filterPatterns: string[]; - notifyFilesChanged( - files: Array<{ absolutePath: string; relativePath: string; content: string }>, - workspaceRoot: string, - analysisContext?: undefined, - disabledPlugins?: Set, - ): Promise<{ additionalFilePaths: string[]; requiresFullRefresh: boolean }>; - onProgress?: (progress: { phase: string; current: number; total: number }) => void; - persistCache(): void; - persistIndexMetadata(): Promise; - signal?: AbortSignal; - workspaceRoot: string; -} - -export interface WorkspaceIndexAnalysisScopeRefreshDependencies { - disabledPlugins: Set; - discoveredDirectories?: string[]; - discoveredFiles: IDiscoveredFile[]; - onProgress?: (progress: { phase: string; current: number; total: number }) => void; - persistCache(): void; - persistIndexMetadata(): Promise; - signal?: AbortSignal; - workspaceRoot: string; -} - -export interface WorkspaceIndexPluginRefreshDependencies - extends WorkspaceIndexAnalysisScopeRefreshDependencies { - pluginIds: readonly string[]; - pluginInfos: readonly WorkspaceIndexPluginInfo[]; -} - -function analyzeWorkspaceIndexFromRefresh( - source: WorkspaceIndexRefreshSource, - dependencies: WorkspaceIndexRefreshDependencies, -): Promise { - return source.analyze( - dependencies.filterPatterns, - dependencies.disabledPlugins, - dependencies.signal, - progress => { - dependencies.onProgress?.({ - ...progress, - phase: progress.phase || 'Applying Changes', - }); - }, - ); -} - -function mergeWorkspaceIndexGraphData( - primaryGraphData: IGraphData, - fallbackGraphData: IGraphData, -): IGraphData { - const nodeIds = new Set(primaryGraphData.nodes.map(node => node.id)); - const edgeIds = new Set(primaryGraphData.edges.map(edge => - edge.id ?? `${edge.from}\0${edge.to}\0${edge.kind}`, - )); - - return { - nodes: [ - ...primaryGraphData.nodes, - ...fallbackGraphData.nodes.filter(node => !nodeIds.has(node.id)), - ], - edges: [ - ...primaryGraphData.edges, - ...fallbackGraphData.edges.filter(edge => - !edgeIds.has(edge.id ?? `${edge.from}\0${edge.to}\0${edge.kind}`), - ), - ], - }; -} - -function buildWorkspaceIndexGraphFromRefreshState( - source: WorkspaceIndexRefreshSource, - workspaceRoot: string, - disabledPlugins: Set, -): IGraphData { - const analysisGraphData = source._buildGraphDataFromAnalysis( - source._lastFileAnalysis, - workspaceRoot, - disabledPlugins, - ); - if (source._lastFileConnections.size === 0) { - return analysisGraphData; - } - - return mergeWorkspaceIndexGraphData( - analysisGraphData, - source._buildGraphData(source._lastFileConnections, workspaceRoot, disabledPlugins), - ); -} - -function applyWorkspaceIndexAnalysisResult( - source: WorkspaceIndexRefreshSource, - analysisResult: IWorkspaceFileAnalysisResult, -): void { - for (const [filePath, analysis] of analysisResult.fileAnalysis) { - source._lastFileAnalysis.set(filePath, analysis); - } - for (const [filePath, connections] of analysisResult.fileConnections) { - source._lastFileConnections.set(filePath, connections); - } -} - -function updateWorkspaceIndexDiscoveryState( - source: WorkspaceIndexRefreshSource, - dependencies: Pick< - WorkspaceIndexAnalysisScopeRefreshDependencies, - 'discoveredDirectories' | 'discoveredFiles' | 'workspaceRoot' - >, -): void { - source._lastDiscoveredDirectories = dependencies.discoveredDirectories ?? []; - source._lastDiscoveredFiles = [...dependencies.discoveredFiles]; - source._lastWorkspaceRoot = dependencies.workspaceRoot; -} - -function retainWorkspaceIndexDiscoveredFileConnections( - source: WorkspaceIndexRefreshSource, - discoveredFiles: readonly IDiscoveredFile[], -): void { - for (const file of discoveredFiles) { - if (!source._lastFileConnections.has(file.relativePath)) { - source._lastFileConnections.set(file.relativePath, []); - } - } -} - -function selectWorkspaceIndexPluginInfos( - pluginInfos: readonly WorkspaceIndexPluginInfo[], - pluginIds: readonly string[], -): WorkspaceIndexPluginInfo[] { - const selectedPluginIds = new Set(pluginIds); - return pluginInfos.filter(({ plugin }) => selectedPluginIds.has(plugin.id)); -} - -function selectWorkspaceIndexPluginFiles( - pluginInfos: readonly WorkspaceIndexPluginInfo[], - discoveredFiles: readonly IDiscoveredFile[], -): IDiscoveredFile[] { - const matchingFilePaths = new Set(); - - for (const pluginInfo of pluginInfos) { - for (const file of getWorkspaceIndexPluginMatchingFiles(pluginInfo, [...discoveredFiles])) { - matchingFilePaths.add(file.relativePath); - } - } - - return discoveredFiles.filter(file => matchingFilePaths.has(file.relativePath)); -} - -export async function refreshWorkspaceIndexAnalysisScope( +import type { + WorkspaceIndexAnalysisScopeRefreshDependencies, + WorkspaceIndexPluginRefreshDependencies, + WorkspaceIndexRefreshDependencies, + WorkspaceIndexRefreshSource, +} from './refresh/contracts'; +import { refreshWorkspaceIndexAnalysisScope as refreshWorkspaceIndexAnalysisScopeImpl } from './refresh/modes/analysisScope'; +import { refreshWorkspaceIndexChangedFiles as refreshWorkspaceIndexChangedFilesImpl } from './refresh/modes/changedFiles'; +import { refreshWorkspaceIndexPluginFiles as refreshWorkspaceIndexPluginFilesImpl } from './refresh/modes/pluginFiles'; + +export type { + WorkspaceIndexAnalysisScopeRefreshDependencies, + WorkspaceIndexPluginInfo, + WorkspaceIndexPluginRefreshDependencies, + WorkspaceIndexRefreshDependencies, + WorkspaceIndexRefreshSource, +} from './refresh/contracts'; + +export function refreshWorkspaceIndexAnalysisScope( source: WorkspaceIndexRefreshSource, dependencies: WorkspaceIndexAnalysisScopeRefreshDependencies, ): Promise { - updateWorkspaceIndexDiscoveryState(source, dependencies); - - dependencies.onProgress?.({ - phase: 'Applying Scope', - current: 0, - total: dependencies.discoveredFiles.length, - }); - - const analysisResult = await source._analyzeFiles( - [...dependencies.discoveredFiles], - dependencies.workspaceRoot, - progress => { - dependencies.onProgress?.({ - phase: 'Applying Scope', - current: progress.current, - total: progress.total, - }); - }, - dependencies.signal, - undefined, - dependencies.disabledPlugins, - ); - - source._lastFileAnalysis = analysisResult.fileAnalysis; - source._lastFileConnections = analysisResult.fileConnections; - dependencies.persistCache(); - - const graphData = buildWorkspaceIndexGraphFromRefreshState( - source, - dependencies.workspaceRoot, - dependencies.disabledPlugins, - ); - await dependencies.persistIndexMetadata(); - - return graphData; + return refreshWorkspaceIndexAnalysisScopeImpl(source, dependencies); } -export async function refreshWorkspaceIndexPluginFiles( +export function refreshWorkspaceIndexChangedFiles( source: WorkspaceIndexRefreshSource, - dependencies: WorkspaceIndexPluginRefreshDependencies, + dependencies: WorkspaceIndexRefreshDependencies, ): Promise { - updateWorkspaceIndexDiscoveryState(source, dependencies); - retainWorkspaceIndexDiscoveredFileConnections(source, dependencies.discoveredFiles); - - const pluginInfos = selectWorkspaceIndexPluginInfos( - dependencies.pluginInfos, - dependencies.pluginIds, - ); - const registeredPluginIds = pluginInfos.map(({ plugin }) => plugin.id); - if (pluginInfos.length === 0) { - const graphData = buildWorkspaceIndexGraphFromRefreshState( - source, - dependencies.workspaceRoot, - dependencies.disabledPlugins, - ); - await dependencies.persistIndexMetadata(); - return graphData; - } - - const pluginFiles = selectWorkspaceIndexPluginFiles(pluginInfos, dependencies.discoveredFiles); - if (pluginFiles.length > 0) { - dependencies.onProgress?.({ - phase: 'Applying Plugin', - current: 0, - total: pluginFiles.length, - }); - const analysisResult = await source._analyzeFiles( - pluginFiles, - dependencies.workspaceRoot, - progress => { - dependencies.onProgress?.({ - phase: 'Applying Plugin', - current: progress.current, - total: progress.total, - }); - }, - dependencies.signal, - registeredPluginIds, - dependencies.disabledPlugins, - ); - - for (const [filePath, analysis] of analysisResult.fileAnalysis) { - source._lastFileAnalysis.set(filePath, analysis); - } - for (const [filePath, connections] of analysisResult.fileConnections) { - source._lastFileConnections.set(filePath, connections); - } - dependencies.persistCache(); - } - - const graphData = buildWorkspaceIndexGraphFromRefreshState( - source, - dependencies.workspaceRoot, - dependencies.disabledPlugins, - ); - await dependencies.persistIndexMetadata(); - - return graphData; + return refreshWorkspaceIndexChangedFilesImpl(source, dependencies); } -export async function refreshWorkspaceIndexChangedFiles( +export function refreshWorkspaceIndexPluginFiles( source: WorkspaceIndexRefreshSource, - dependencies: WorkspaceIndexRefreshDependencies, + dependencies: WorkspaceIndexPluginRefreshDependencies, ): Promise { - const discoveredByRelativePath = mapDiscoveredWorkspaceIndexFilesByRelativePath( - dependencies.discoveredFiles, - ); - const changeSelection = selectDiscoveredWorkspaceIndexFileChanges( - dependencies.workspaceRoot, - dependencies.filePaths, - discoveredByRelativePath, - ); - const changedFiles = changeSelection.files; - - if (changeSelection.unmatchedFilePaths.length > 0) { - source.invalidateWorkspaceFiles(changeSelection.unmatchedFilePaths); - return analyzeWorkspaceIndexFromRefresh(source, dependencies); - } - - const changedAnalysisFiles = await source._readAnalysisFiles(changedFiles); - const incrementalLifecycle = await dependencies.notifyFilesChanged( - changedAnalysisFiles, - dependencies.workspaceRoot, - undefined, - dependencies.disabledPlugins, - ); - - if (incrementalLifecycle.requiresFullRefresh) { - return analyzeWorkspaceIndexFromRefresh(source, dependencies); - } - - const filesToAnalyze = mergeDiscoveredWorkspaceIndexFiles( - changedFiles, - incrementalLifecycle.additionalFilePaths, - discoveredByRelativePath, - ); - source._lastDiscoveredDirectories = dependencies.discoveredDirectories ?? []; - source._lastDiscoveredFiles = dependencies.discoveredFiles; - source._lastWorkspaceRoot = dependencies.workspaceRoot; - retainWorkspaceIndexDiscoveredFileConnections(source, dependencies.discoveredFiles); - - if (filesToAnalyze.length === 0) { - return buildWorkspaceIndexGraphFromRefreshState( - source, - dependencies.workspaceRoot, - dependencies.disabledPlugins, - ); - } - - source.invalidateWorkspaceFiles(filesToAnalyze.map((file) => file.absolutePath)); - dependencies.onProgress?.({ - phase: 'Applying Changes', - current: 0, - total: filesToAnalyze.length, - }); - - const analysisResult = await source._analyzeFiles( - filesToAnalyze, - dependencies.workspaceRoot, - progress => { - dependencies.onProgress?.({ - phase: 'Applying Changes', - current: progress.current, - total: progress.total, - }); - }, - dependencies.signal, - undefined, - dependencies.disabledPlugins, - ); - - applyWorkspaceIndexAnalysisResult(source, analysisResult); - - dependencies.persistCache(); - const graphData = buildWorkspaceIndexGraphFromRefreshState( - source, - dependencies.workspaceRoot, - dependencies.disabledPlugins, - ); - await dependencies.persistIndexMetadata(); - - return graphData; + return refreshWorkspaceIndexPluginFilesImpl(source, dependencies); } diff --git a/packages/core/src/indexing/refresh/contracts.ts b/packages/core/src/indexing/refresh/contracts.ts new file mode 100644 index 000000000..5bb72000b --- /dev/null +++ b/packages/core/src/indexing/refresh/contracts.ts @@ -0,0 +1,98 @@ +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IWorkspaceFileAnalysisResult } from '../../analysis/fileAnalysis'; +import type { IProjectedConnection } from '../../analysis/projectedConnection'; +import type { IDiscoveredFile } from '../../discovery/contracts'; +import type { IGraphData } from '../../graph/contracts'; + +export type WorkspaceIndexPluginInfo = { + plugin: { + id: string; + supportedExtensions: readonly string[]; + }; +}; + +export interface WorkspaceIndexRefreshSource { + _analyzeFiles( + files: IDiscoveredFile[], + workspaceRoot: string, + onProgress?: (progress: { current: number; total: number; filePath: string }) => void, + signal?: AbortSignal, + pluginIds?: readonly string[], + disabledPlugins?: Set, + ): Promise; + _buildGraphData( + fileConnections: Map, + workspaceRoot: string, + disabledPlugins: Set, + ): IGraphData; + _buildGraphDataFromAnalysis( + fileAnalysis: Map, + workspaceRoot: string, + disabledPlugins: Set, + ): IGraphData; + _lastDiscoveredDirectories: string[]; + _lastDiscoveredFiles: IDiscoveredFile[]; + _lastFileAnalysis: Map; + _lastFileConnections: Map; + _lastGraphData: IGraphData; + _lastWorkspaceRoot: string; + _patchGraphDataNodeMetrics?( + this: void, + graphData: IGraphData, + filePaths: readonly string[], + ): IGraphData; + _preAnalyzePlugins( + files: IDiscoveredFile[], + workspaceRoot: string, + signal?: AbortSignal, + disabledPlugins?: Set, + ): Promise; + _readAnalysisFiles( + files: IDiscoveredFile[], + ): Promise>; + analyze( + filterPatterns?: string[], + disabledPlugins?: Set, + signal?: AbortSignal, + onProgress?: (progress: { phase: string; current: number; total: number }) => void, + ): Promise; + invalidateWorkspaceFiles(filePaths: readonly string[]): string[]; +} + +export interface WorkspaceIndexRefreshDependencies { + deferMetricOnlyIndexMetadata?: boolean; + disabledPlugins: Set; + discoveredDirectories?: string[]; + discoveredFiles: IDiscoveredFile[]; + filePaths: readonly string[]; + filterPatterns: string[]; + notifyFilesChanged( + files: Array<{ absolutePath: string; relativePath: string; content: string }>, + workspaceRoot: string, + analysisContext?: undefined, + disabledPlugins?: Set, + ): Promise<{ additionalFilePaths: string[]; requiresFullRefresh: boolean }>; + onProgress?: (progress: { phase: string; current: number; total: number }) => void; + onDeferredIndexMetadataError?(error: unknown): void; + persistCache(): void; + persistIndexMetadata(): Promise; + signal?: AbortSignal; + workspaceRoot: string; +} + +export interface WorkspaceIndexAnalysisScopeRefreshDependencies { + disabledPlugins: Set; + discoveredDirectories?: string[]; + discoveredFiles: IDiscoveredFile[]; + onProgress?: (progress: { phase: string; current: number; total: number }) => void; + persistCache(): void; + persistIndexMetadata(): Promise; + signal?: AbortSignal; + workspaceRoot: string; +} + +export interface WorkspaceIndexPluginRefreshDependencies + extends WorkspaceIndexAnalysisScopeRefreshDependencies { + pluginIds: readonly string[]; + pluginInfos: readonly WorkspaceIndexPluginInfo[]; +} diff --git a/packages/core/src/indexing/refresh/graph.ts b/packages/core/src/indexing/refresh/graph.ts new file mode 100644 index 000000000..ba65e35f4 --- /dev/null +++ b/packages/core/src/indexing/refresh/graph.ts @@ -0,0 +1,75 @@ +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IProjectedConnection } from '../../analysis/projectedConnection'; +import type { IGraphData } from '../../graph/contracts'; +import { toRepoRelativeGraphPath } from '../../graph/symbolPaths'; +import type { WorkspaceIndexRefreshSource } from './contracts'; + +export function buildWorkspaceIndexGraphFromRefreshState( + source: WorkspaceIndexRefreshSource, + workspaceRoot: string, + disabledPlugins: Set, +): IGraphData { + const analysisGraphData = source._buildGraphDataFromAnalysis( + source._lastFileAnalysis, + workspaceRoot, + disabledPlugins, + ); + if (workspaceIndexAnalysisCoversConnections( + source._lastFileAnalysis, + source._lastFileConnections, + workspaceRoot, + )) { + source._lastGraphData = analysisGraphData; + return analysisGraphData; + } + + const graphData = mergeWorkspaceIndexGraphData( + analysisGraphData, + source._buildGraphData(source._lastFileConnections, workspaceRoot, disabledPlugins), + ); + source._lastGraphData = graphData; + return graphData; +} + +function mergeWorkspaceIndexGraphData( + primaryGraphData: IGraphData, + fallbackGraphData: IGraphData, +): IGraphData { + const nodeIds = new Set(primaryGraphData.nodes.map(node => node.id)); + const edgeIds = new Set(primaryGraphData.edges.map(edge => + edge.id ?? `${edge.from}\0${edge.to}\0${edge.kind}`, + )); + + return { + nodes: [ + ...primaryGraphData.nodes, + ...fallbackGraphData.nodes.filter(node => !nodeIds.has(node.id)), + ], + edges: [ + ...primaryGraphData.edges, + ...fallbackGraphData.edges.filter(edge => + !edgeIds.has(edge.id ?? `${edge.from}\0${edge.to}\0${edge.kind}`), + ), + ], + }; +} + +function workspaceIndexAnalysisCoversConnections( + fileAnalysis: ReadonlyMap, + fileConnections: ReadonlyMap, + workspaceRoot: string, +): boolean { + const analysisFilePaths = new Set( + [...fileAnalysis.keys()].map(filePath => + toRepoRelativeGraphPath(filePath, workspaceRoot), + ), + ); + + for (const filePath of fileConnections.keys()) { + if (!analysisFilePaths.has(toRepoRelativeGraphPath(filePath, workspaceRoot))) { + return false; + } + } + + return true; +} diff --git a/packages/core/src/indexing/refresh/modes/analysisScope.ts b/packages/core/src/indexing/refresh/modes/analysisScope.ts new file mode 100644 index 000000000..91d7335a3 --- /dev/null +++ b/packages/core/src/indexing/refresh/modes/analysisScope.ts @@ -0,0 +1,48 @@ +import type { IGraphData } from '../../../graph/contracts'; +import type { + WorkspaceIndexAnalysisScopeRefreshDependencies, + WorkspaceIndexRefreshSource, +} from '../contracts'; +import { buildWorkspaceIndexGraphFromRefreshState } from '../graph'; +import { updateWorkspaceIndexDiscoveryState } from '../state'; + +export async function refreshWorkspaceIndexAnalysisScope( + source: WorkspaceIndexRefreshSource, + dependencies: WorkspaceIndexAnalysisScopeRefreshDependencies, +): Promise { + updateWorkspaceIndexDiscoveryState(source, dependencies); + + dependencies.onProgress?.({ + phase: 'Applying Scope', + current: 0, + total: dependencies.discoveredFiles.length, + }); + + const analysisResult = await source._analyzeFiles( + [...dependencies.discoveredFiles], + dependencies.workspaceRoot, + progress => { + dependencies.onProgress?.({ + phase: 'Applying Scope', + current: progress.current, + total: progress.total, + }); + }, + dependencies.signal, + undefined, + dependencies.disabledPlugins, + ); + + source._lastFileAnalysis = analysisResult.fileAnalysis; + source._lastFileConnections = analysisResult.fileConnections; + dependencies.persistCache(); + + const graphData = buildWorkspaceIndexGraphFromRefreshState( + source, + dependencies.workspaceRoot, + dependencies.disabledPlugins, + ); + await dependencies.persistIndexMetadata(); + + return graphData; +} diff --git a/packages/core/src/indexing/refresh/modes/changedFiles.ts b/packages/core/src/indexing/refresh/modes/changedFiles.ts new file mode 100644 index 000000000..3fb786f92 --- /dev/null +++ b/packages/core/src/indexing/refresh/modes/changedFiles.ts @@ -0,0 +1,148 @@ +import type { IGraphData } from '../../../graph/contracts'; +import { + mapDiscoveredWorkspaceIndexFilesByRelativePath, + mergeDiscoveredWorkspaceIndexFiles, + selectDiscoveredWorkspaceIndexFileChanges, +} from '../../changedFiles'; +import type { + WorkspaceIndexRefreshDependencies, + WorkspaceIndexRefreshSource, +} from '../contracts'; +import { buildWorkspaceIndexGraphFromRefreshState } from '../graph'; +import { + canPatchWorkspaceIndexRefreshGraphData, + captureWorkspaceIndexRefreshGraphSnapshot, +} from '../snapshot/capture'; +import { + applyWorkspaceIndexAnalysisResult, + retainWorkspaceIndexDiscoveredFileConnections, +} from '../state'; + +export async function refreshWorkspaceIndexChangedFiles( + source: WorkspaceIndexRefreshSource, + dependencies: WorkspaceIndexRefreshDependencies, +): Promise { + const discoveredByRelativePath = mapDiscoveredWorkspaceIndexFilesByRelativePath( + dependencies.discoveredFiles, + ); + const changeSelection = selectDiscoveredWorkspaceIndexFileChanges( + dependencies.workspaceRoot, + dependencies.filePaths, + discoveredByRelativePath, + ); + const changedFiles = changeSelection.files; + + if (changeSelection.unmatchedFilePaths.length > 0) { + source.invalidateWorkspaceFiles(changeSelection.unmatchedFilePaths); + return analyzeWorkspaceIndexFromRefresh(source, dependencies); + } + + const changedAnalysisFiles = await source._readAnalysisFiles(changedFiles); + const incrementalLifecycle = await dependencies.notifyFilesChanged( + changedAnalysisFiles, + dependencies.workspaceRoot, + undefined, + dependencies.disabledPlugins, + ); + + if (incrementalLifecycle.requiresFullRefresh) { + return analyzeWorkspaceIndexFromRefresh(source, dependencies); + } + + const filesToAnalyze = mergeDiscoveredWorkspaceIndexFiles( + changedFiles, + incrementalLifecycle.additionalFilePaths, + discoveredByRelativePath, + ); + source._lastDiscoveredDirectories = dependencies.discoveredDirectories ?? []; + source._lastDiscoveredFiles = dependencies.discoveredFiles; + source._lastWorkspaceRoot = dependencies.workspaceRoot; + retainWorkspaceIndexDiscoveredFileConnections(source, dependencies.discoveredFiles); + + if (filesToAnalyze.length === 0) { + return buildWorkspaceIndexGraphFromRefreshState( + source, + dependencies.workspaceRoot, + dependencies.disabledPlugins, + ); + } + + const graphSnapshot = captureWorkspaceIndexRefreshGraphSnapshot(source, filesToAnalyze); + source.invalidateWorkspaceFiles(filesToAnalyze.map((file) => file.absolutePath)); + dependencies.onProgress?.({ + phase: 'Applying Changes', + current: 0, + total: filesToAnalyze.length, + }); + + const analysisResult = await source._analyzeFiles( + filesToAnalyze, + dependencies.workspaceRoot, + progress => { + dependencies.onProgress?.({ + phase: 'Applying Changes', + current: progress.current, + total: progress.total, + }); + }, + dependencies.signal, + undefined, + dependencies.disabledPlugins, + ); + + applyWorkspaceIndexAnalysisResult(source, analysisResult); + + dependencies.persistCache(); + if ( + canPatchWorkspaceIndexRefreshGraphData(graphSnapshot, analysisResult, filesToAnalyze) + && source._patchGraphDataNodeMetrics + ) { + const graphData = source._patchGraphDataNodeMetrics( + source._lastGraphData, + filesToAnalyze.map(file => file.relativePath), + ); + source._lastGraphData = graphData; + await persistMetricOnlyIndexMetadata(dependencies); + return graphData; + } + + const graphData = buildWorkspaceIndexGraphFromRefreshState( + source, + dependencies.workspaceRoot, + dependencies.disabledPlugins, + ); + await dependencies.persistIndexMetadata(); + + return graphData; +} + +function analyzeWorkspaceIndexFromRefresh( + source: WorkspaceIndexRefreshSource, + dependencies: WorkspaceIndexRefreshDependencies, +): Promise { + return source.analyze( + dependencies.filterPatterns, + dependencies.disabledPlugins, + dependencies.signal, + progress => { + dependencies.onProgress?.({ + ...progress, + phase: progress.phase || 'Applying Changes', + }); + }, + ); +} + +function persistMetricOnlyIndexMetadata( + dependencies: WorkspaceIndexRefreshDependencies, +): Promise | void { + const persistence = dependencies.persistIndexMetadata(); + if (dependencies.deferMetricOnlyIndexMetadata) { + void persistence.catch(error => { + dependencies.onDeferredIndexMetadataError?.(error); + }); + return; + } + + return persistence; +} diff --git a/packages/core/src/indexing/refresh/modes/pluginFiles.ts b/packages/core/src/indexing/refresh/modes/pluginFiles.ts new file mode 100644 index 000000000..c9707b414 --- /dev/null +++ b/packages/core/src/indexing/refresh/modes/pluginFiles.ts @@ -0,0 +1,64 @@ +import type { IGraphData } from '../../../graph/contracts'; +import type { + WorkspaceIndexPluginRefreshDependencies, + WorkspaceIndexRefreshSource, +} from '../contracts'; +import { buildWorkspaceIndexGraphFromRefreshState } from '../graph'; +import { + selectWorkspaceIndexPluginFiles, + selectWorkspaceIndexPluginInfos, +} from '../plugins'; +import { + applyWorkspaceIndexAnalysisResult, + retainWorkspaceIndexDiscoveredFileConnections, + updateWorkspaceIndexDiscoveryState, +} from '../state'; + +export async function refreshWorkspaceIndexPluginFiles( + source: WorkspaceIndexRefreshSource, + dependencies: WorkspaceIndexPluginRefreshDependencies, +): Promise { + updateWorkspaceIndexDiscoveryState(source, dependencies); + retainWorkspaceIndexDiscoveredFileConnections(source, dependencies.discoveredFiles); + + const pluginInfos = selectWorkspaceIndexPluginInfos( + dependencies.pluginInfos, + dependencies.pluginIds, + ); + const registeredPluginIds = pluginInfos.map(({ plugin }) => plugin.id); + + const pluginFiles = selectWorkspaceIndexPluginFiles(pluginInfos, dependencies.discoveredFiles); + if (pluginFiles.length > 0) { + dependencies.onProgress?.({ + phase: 'Applying Plugin', + current: 0, + total: pluginFiles.length, + }); + const analysisResult = await source._analyzeFiles( + pluginFiles, + dependencies.workspaceRoot, + progress => { + dependencies.onProgress?.({ + phase: 'Applying Plugin', + current: progress.current, + total: progress.total, + }); + }, + dependencies.signal, + registeredPluginIds, + dependencies.disabledPlugins, + ); + + applyWorkspaceIndexAnalysisResult(source, analysisResult); + dependencies.persistCache(); + } + + const graphData = buildWorkspaceIndexGraphFromRefreshState( + source, + dependencies.workspaceRoot, + dependencies.disabledPlugins, + ); + await dependencies.persistIndexMetadata(); + + return graphData; +} diff --git a/packages/core/src/indexing/refresh/plugins.ts b/packages/core/src/indexing/refresh/plugins.ts new file mode 100644 index 000000000..dc0a5a26e --- /dev/null +++ b/packages/core/src/indexing/refresh/plugins.ts @@ -0,0 +1,26 @@ +import type { IDiscoveredFile } from '../../discovery/contracts'; +import { getWorkspaceIndexPluginMatchingFiles } from '../../plugins/status/extensions'; +import type { WorkspaceIndexPluginInfo } from './contracts'; + +export function selectWorkspaceIndexPluginInfos( + pluginInfos: readonly WorkspaceIndexPluginInfo[], + pluginIds: readonly string[], +): WorkspaceIndexPluginInfo[] { + const selectedPluginIds = new Set(pluginIds); + return pluginInfos.filter(({ plugin }) => selectedPluginIds.has(plugin.id)); +} + +export function selectWorkspaceIndexPluginFiles( + pluginInfos: readonly WorkspaceIndexPluginInfo[], + discoveredFiles: readonly IDiscoveredFile[], +): IDiscoveredFile[] { + const matchingFilePaths = new Set(); + + for (const pluginInfo of pluginInfos) { + for (const file of getWorkspaceIndexPluginMatchingFiles(pluginInfo, [...discoveredFiles])) { + matchingFilePaths.add(file.relativePath); + } + } + + return discoveredFiles.filter(file => matchingFilePaths.has(file.relativePath)); +} diff --git a/packages/core/src/indexing/refresh/snapshot/capture.ts b/packages/core/src/indexing/refresh/snapshot/capture.ts new file mode 100644 index 000000000..3d63dc871 --- /dev/null +++ b/packages/core/src/indexing/refresh/snapshot/capture.ts @@ -0,0 +1,82 @@ +import type { IWorkspaceFileAnalysisResult } from '../../../analysis/fileAnalysis'; +import type { IDiscoveredFile } from '../../../discovery/contracts'; +import type { WorkspaceIndexRefreshSource } from '../contracts'; +import { canCaptureWorkspaceIndexRefreshGraphSnapshot } from './eligibility'; +import { + serializeWorkspaceIndexConnections, + serializeWorkspaceIndexGraphAnalysis, +} from './serialization'; + +interface WorkspaceIndexRefreshGraphSnapshot { + fileAnalysisByPath: Map; + fileConnectionsByPath: Map; +} + +export function captureWorkspaceIndexRefreshGraphSnapshot( + source: WorkspaceIndexRefreshSource, + files: readonly IDiscoveredFile[], +): WorkspaceIndexRefreshGraphSnapshot | undefined { + if (!canCaptureWorkspaceIndexRefreshGraphSnapshot(source)) { + return undefined; + } + + const snapshot: WorkspaceIndexRefreshGraphSnapshot = { + fileAnalysisByPath: new Map(), + fileConnectionsByPath: new Map(), + }; + + for (const file of files) { + if (!captureWorkspaceIndexRefreshSnapshotFile(source, snapshot, file.relativePath)) { + return undefined; + } + } + + return snapshot; +} + +export function canPatchWorkspaceIndexRefreshGraphData( + snapshot: WorkspaceIndexRefreshGraphSnapshot | undefined, + analysisResult: IWorkspaceFileAnalysisResult, + files: readonly IDiscoveredFile[], +): boolean { + if (!snapshot) { + return false; + } + + return files.every(file => + workspaceIndexRefreshSnapshotMatchesFile(snapshot, analysisResult, file.relativePath), + ); +} + +function captureWorkspaceIndexRefreshSnapshotFile( + source: WorkspaceIndexRefreshSource, + snapshot: WorkspaceIndexRefreshGraphSnapshot, + relativePath: string, +): boolean { + const analysis = source._lastFileAnalysis.get(relativePath); + if (!analysis) { + return false; + } + + snapshot.fileAnalysisByPath.set(relativePath, serializeWorkspaceIndexGraphAnalysis(analysis)); + snapshot.fileConnectionsByPath.set( + relativePath, + serializeWorkspaceIndexConnections(source._lastFileConnections.get(relativePath)), + ); + return true; +} + +function workspaceIndexRefreshSnapshotMatchesFile( + snapshot: WorkspaceIndexRefreshGraphSnapshot, + analysisResult: IWorkspaceFileAnalysisResult, + relativePath: string, +): boolean { + const analysis = analysisResult.fileAnalysis.get(relativePath); + if (!analysis) { + return false; + } + + return snapshot.fileAnalysisByPath.get(relativePath) === serializeWorkspaceIndexGraphAnalysis(analysis) + && snapshot.fileConnectionsByPath.get(relativePath) + === serializeWorkspaceIndexConnections(analysisResult.fileConnections.get(relativePath)); +} diff --git a/packages/core/src/indexing/refresh/snapshot/eligibility.ts b/packages/core/src/indexing/refresh/snapshot/eligibility.ts new file mode 100644 index 000000000..2dbb9f414 --- /dev/null +++ b/packages/core/src/indexing/refresh/snapshot/eligibility.ts @@ -0,0 +1,13 @@ +import type { IGraphData } from '../../../graph/contracts'; +import type { WorkspaceIndexRefreshSource } from '../contracts'; + +export function canCaptureWorkspaceIndexRefreshGraphSnapshot( + source: WorkspaceIndexRefreshSource, +): boolean { + return Boolean(source._patchGraphDataNodeMetrics) + && !isWorkspaceIndexGraphDataEmpty(source._lastGraphData); +} + +function isWorkspaceIndexGraphDataEmpty(graphData: IGraphData): boolean { + return graphData.nodes.length === 0 && graphData.edges.length === 0; +} diff --git a/packages/core/src/indexing/refresh/snapshot/serialization.ts b/packages/core/src/indexing/refresh/snapshot/serialization.ts new file mode 100644 index 000000000..64b69c1a4 --- /dev/null +++ b/packages/core/src/indexing/refresh/snapshot/serialization.ts @@ -0,0 +1,23 @@ +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IProjectedConnection } from '../../../analysis/projectedConnection'; + +export function serializeWorkspaceIndexGraphAnalysis(analysis: IFileAnalysisResult): string { + return JSON.stringify({ + edgeTypes: listOrEmpty(analysis.edgeTypes), + filePath: analysis.filePath, + nodeTypes: listOrEmpty(analysis.nodeTypes), + nodes: listOrEmpty(analysis.nodes), + relations: listOrEmpty(analysis.relations), + symbols: listOrEmpty(analysis.symbols), + }); +} + +export function serializeWorkspaceIndexConnections( + connections: IProjectedConnection[] | undefined, +): string { + return JSON.stringify(connections ?? []); +} + +function listOrEmpty(value: readonly T[] | undefined): readonly T[] { + return value ?? []; +} diff --git a/packages/core/src/indexing/refresh/state.ts b/packages/core/src/indexing/refresh/state.ts new file mode 100644 index 000000000..5eaeb68a7 --- /dev/null +++ b/packages/core/src/indexing/refresh/state.ts @@ -0,0 +1,41 @@ +import type { IWorkspaceFileAnalysisResult } from '../../analysis/fileAnalysis'; +import type { IDiscoveredFile } from '../../discovery/contracts'; +import type { + WorkspaceIndexAnalysisScopeRefreshDependencies, + WorkspaceIndexRefreshSource, +} from './contracts'; + +export function applyWorkspaceIndexAnalysisResult( + source: WorkspaceIndexRefreshSource, + analysisResult: IWorkspaceFileAnalysisResult, +): void { + for (const [filePath, analysis] of analysisResult.fileAnalysis) { + source._lastFileAnalysis.set(filePath, analysis); + } + for (const [filePath, connections] of analysisResult.fileConnections) { + source._lastFileConnections.set(filePath, connections); + } +} + +export function updateWorkspaceIndexDiscoveryState( + source: WorkspaceIndexRefreshSource, + dependencies: Pick< + WorkspaceIndexAnalysisScopeRefreshDependencies, + 'discoveredDirectories' | 'discoveredFiles' | 'workspaceRoot' + >, +): void { + source._lastDiscoveredDirectories = dependencies.discoveredDirectories ?? []; + source._lastDiscoveredFiles = [...dependencies.discoveredFiles]; + source._lastWorkspaceRoot = dependencies.workspaceRoot; +} + +export function retainWorkspaceIndexDiscoveredFileConnections( + source: WorkspaceIndexRefreshSource, + discoveredFiles: readonly IDiscoveredFile[], +): void { + for (const file of discoveredFiles) { + if (!source._lastFileConnections.has(file.relativePath)) { + source._lastFileConnections.set(file.relativePath, []); + } + } +} diff --git a/packages/core/src/indexing/workspace.ts b/packages/core/src/indexing/workspace.ts index ee69d2f2e..8816e087e 100644 --- a/packages/core/src/indexing/workspace.ts +++ b/packages/core/src/indexing/workspace.ts @@ -2,14 +2,15 @@ import { createEmptyWorkspaceAnalysisCache } from '../analysis/cache'; import { FileDiscovery } from '../discovery/file/service'; import { buildWorkspacePipelineGraphFromAnalysis } from '../graph/build'; import { saveWorkspaceAnalysisDatabaseCache } from '../graphCache/database/storage'; +import { createDisabledPluginSet } from '../plugins/activityState/model'; import { getGraphCachePath, resolveWorkspaceRoot } from '../workspace/paths'; import { analyzeWorkspaceIndexFiles } from './analysis'; -import { createDisabledPluginSet } from '../plugins/activityState/model'; import type { IndexCodeGraphyWorkspaceOptions, IndexCodeGraphyWorkspaceResult } from './contracts'; import { discoverWorkspaceIndexFiles } from './discovery'; import { persistWorkspaceIndexMetadata } from './metadata'; import { createWorkspaceIndexRegistry } from './registry'; import { createEffectiveIndexSettings } from './settings'; +import { timeIndexPhase, timeIndexPhaseSync } from './workspace/timing.js'; export { createCodeGraphyWorkspaceEngine, type CodeGraphyWorkspaceEngine, @@ -39,54 +40,103 @@ export async function indexCodeGraphyWorkspace( const cache = createEmptyWorkspaceAnalysisCache(); const settings = createEffectiveIndexSettings(workspaceRoot, options); const disabledPlugins = createDisabledPluginSet(settings, options.disabledPlugins); - const { registry, loadedPackagePlugins } = await createWorkspaceIndexRegistry( + const { registry, loadedPackagePlugins } = await timeIndexPhase( options, - settings, - workspaceRoot, - disabledPlugins, + 'load-plugins', + () => createWorkspaceIndexRegistry( + options, + settings, + workspaceRoot, + disabledPlugins, + ), + result => ({ + loadedPackagePlugins: result.loadedPackagePlugins.length, + registeredPlugins: result.registry.list().length, + }), ); - await registry.initializeAll(workspaceRoot); + await timeIndexPhase( + options, + 'initialize-plugins', + () => registry.initializeAll(workspaceRoot), + () => ({ registeredPlugins: registry.list().length }), + ); - const discoveryResult = await discoverWorkspaceIndexFiles({ - disabledPlugins, - discovery, + const discoveryResult = await timeIndexPhase( options, - registry, - settings, - workspaceRoot, - }); - const analysisResult = await analyzeWorkspaceIndexFiles({ - cache, - discovery, - discoveryResult, + 'discover-files', + () => discoverWorkspaceIndexFiles({ + disabledPlugins, + discovery, + options, + registry, + settings, + workspaceRoot, + }), + result => ({ + files: result.files.length, + directories: result.directories?.length ?? 0, + totalFound: result.totalFound ?? result.files.length, + limitReached: result.limitReached, + }), + ); + const analysisResult = await timeIndexPhase( options, - registry, - disabledPlugins, - workspaceRoot, - }); + 'analyze-files', + () => analyzeWorkspaceIndexFiles({ + cache, + discovery, + discoveryResult, + options, + registry, + disabledPlugins, + workspaceRoot, + }), + result => ({ + files: discoveryResult.files.length, + cacheHits: result.cacheHits, + cacheMisses: result.cacheMisses, + }), + ); - const graph = buildWorkspacePipelineGraphFromAnalysis({ - cacheFiles: cache.files, - churnCounts: {}, - directoryPaths: discoveryResult.directories ?? [], - gitIgnoredPaths: discoveryResult.gitIgnoredPaths ?? [], - disabledPlugins, - fileAnalysis: analysisResult.fileAnalysis, - getPluginForFile: absolutePath => registry.getPluginForFile(absolutePath), - showOrphans: true, - workspaceRoot, - }); + const graph = timeIndexPhaseSync( + options, + 'build-graph', + () => buildWorkspacePipelineGraphFromAnalysis({ + cacheFiles: cache.files, + churnCounts: {}, + directoryPaths: discoveryResult.directories ?? [], + gitIgnoredPaths: discoveryResult.gitIgnoredPaths ?? [], + disabledPlugins, + fileAnalysis: analysisResult.fileAnalysis, + getPluginForFile: absolutePath => registry.getPluginForFile(absolutePath), + showOrphans: true, + workspaceRoot, + }), + result => ({ + nodes: result.nodes.length, + edges: result.edges.length, + }), + ); registry.notifyPostAnalyze(graph, disabledPlugins); registry.notifyWorkspaceReady(graph, disabledPlugins); - saveWorkspaceAnalysisDatabaseCache(workspaceRoot, cache); - persistWorkspaceIndexMetadata({ - loadedPackagePlugins, - registry, - settings, - workspaceRoot, - }); + timeIndexPhaseSync( + options, + 'save-graph-cache', + () => saveWorkspaceAnalysisDatabaseCache(workspaceRoot, cache), + () => ({ files: Object.keys(cache.files).length }), + ); + timeIndexPhaseSync( + options, + 'persist-metadata', + () => persistWorkspaceIndexMetadata({ + loadedPackagePlugins, + registry, + settings, + workspaceRoot, + }), + ); options.logInfo?.(`[CodeGraphy] Graph built: ${graph.nodes.length} nodes, ${graph.edges.length} edges`); return { diff --git a/packages/core/src/indexing/workspace/timing.ts b/packages/core/src/indexing/workspace/timing.ts new file mode 100644 index 000000000..3a3f79903 --- /dev/null +++ b/packages/core/src/indexing/workspace/timing.ts @@ -0,0 +1,44 @@ +import { performance } from 'node:perf_hooks'; +import { createDiagnosticEvent } from '../../diagnostics/events.js'; +import type { IndexCodeGraphyWorkspaceOptions } from '../contracts.js'; + +function emitIndexPhaseCompleted( + options: IndexCodeGraphyWorkspaceOptions, + phase: string, + durationMs: number, + context: Record = {}, +): void { + options.diagnostics?.emit(createDiagnosticEvent({ + area: 'indexing', + event: 'phase-completed', + context: { + phase, + durationMs: Math.round(durationMs), + ...context, + }, + })); +} + +export async function timeIndexPhase( + options: IndexCodeGraphyWorkspaceOptions, + phase: string, + run: () => Promise, + createContext: (result: T) => Record = () => ({}), +): Promise { + const startedAt = performance.now(); + const result = await run(); + emitIndexPhaseCompleted(options, phase, performance.now() - startedAt, createContext(result)); + return result; +} + +export function timeIndexPhaseSync( + options: IndexCodeGraphyWorkspaceOptions, + phase: string, + run: () => T, + createContext: (result: T) => Record = () => ({}), +): T { + const startedAt = performance.now(); + const result = run(); + emitIndexPhaseCompleted(options, phase, performance.now() - startedAt, createContext(result)); + return result; +} diff --git a/packages/core/src/plugins/lifecycle/notify/analysis.ts b/packages/core/src/plugins/lifecycle/notify/analysis.ts index 5b58ebea0..5292545d8 100644 --- a/packages/core/src/plugins/lifecycle/notify/analysis.ts +++ b/packages/core/src/plugins/lifecycle/notify/analysis.ts @@ -1,21 +1,13 @@ import type { IPluginAnalysisContext } from '@codegraphy-dev/plugin-api'; import type { IGraphData } from '../../../graph/contracts'; import type { ILifecyclePluginInfo } from '../contracts'; +import { logLifecycleError } from './errors.js'; +import { getPluginFiles, type AnalyzeFile } from './files.js'; import { createWorkspacePluginAnalysisContext, withWorkspacePluginAnalysisOptions, } from '../../context/workspace'; -type AnalyzeFile = { - absolutePath: string; - relativePath: string; - content: string; -}; - -function logLifecycleError(hook: string, pluginId: string, error: unknown): void { - console.error(`[CodeGraphy] Error in ${hook} for ${pluginId}:`, error); -} - export async function notifyPreAnalyze( plugins: Map, files: AnalyzeFile[], @@ -32,9 +24,14 @@ export async function notifyPreAnalyze( continue; } + const pluginFiles = getPluginFiles(info, files); + if (pluginFiles.length === 0) { + continue; + } + try { await info.plugin.onPreAnalyze( - files, + pluginFiles, workspaceRoot, withWorkspacePluginAnalysisOptions(analysisContext, info.options), ); diff --git a/packages/core/src/plugins/lifecycle/notify/errors.ts b/packages/core/src/plugins/lifecycle/notify/errors.ts new file mode 100644 index 000000000..1c2603907 --- /dev/null +++ b/packages/core/src/plugins/lifecycle/notify/errors.ts @@ -0,0 +1,3 @@ +export function logLifecycleError(hook: string, pluginId: string, error: unknown): void { + console.error(`[CodeGraphy] Error in ${hook} for ${pluginId}:`, error); +} diff --git a/packages/core/src/plugins/lifecycle/notify/files.ts b/packages/core/src/plugins/lifecycle/notify/files.ts new file mode 100644 index 000000000..08637e981 --- /dev/null +++ b/packages/core/src/plugins/lifecycle/notify/files.ts @@ -0,0 +1,25 @@ +import type { ILifecyclePluginInfo } from '../contracts.js'; + +export type AnalyzeFile = { + absolutePath: string; + relativePath: string; + content: string; +}; + +export function getPluginFiles( + info: ILifecyclePluginInfo, + files: AnalyzeFile[], +): AnalyzeFile[] { + return files.filter((file) => pluginMatchesFile(info, file.relativePath)); +} + +function pluginMatchesFile(info: ILifecyclePluginInfo, relativePath: string): boolean { + if (info.plugin.supportedExtensions.includes('*')) { + return true; + } + + const lowercasePath = relativePath.toLowerCase(); + return info.plugin.supportedExtensions.some((extension) => + lowercasePath.endsWith(extension.toLowerCase()), + ); +} diff --git a/packages/core/src/treeSitter/runtime/languages/load.ts b/packages/core/src/treeSitter/runtime/languages/load.ts index 8c1127dfa..d486006f9 100644 --- a/packages/core/src/treeSitter/runtime/languages/load.ts +++ b/packages/core/src/treeSitter/runtime/languages/load.ts @@ -1,6 +1,9 @@ import type Parser from 'tree-sitter'; +import type { TreeSitterRuntimeBinding } from './kinds'; type TreeSitterConstructor = new () => Parser; +type TreeSitterLanguageBindingName = TreeSitterRuntimeBinding['language']; +type TreeSitterLanguageLoader = () => Promise; export interface ITreeSitterBindings { ParserCtor: TreeSitterConstructor; @@ -25,10 +28,89 @@ export interface ITreeSitterBindings { typeScript: Parser.Language; } +export interface ITreeSitterLanguageBinding { + ParserCtor: TreeSitterConstructor; + language: Parser.Language; +} + +function warnTreeSitterBindingsUnavailable(error: unknown): void { + console.warn( + `[CodeGraphy] Tree-sitter bindings unavailable; skipping core Tree-sitter analysis. ${String(error)}`, + ); +} + +let treeSitterParserCtorPromise: Promise | undefined; +function loadTreeSitterParserCtor(): Promise { + treeSitterParserCtorPromise ??= import('tree-sitter') + .then(parserModule => parserModule.default); + + return treeSitterParserCtorPromise; +} + +const TREE_SITTER_LANGUAGE_LOADERS: Record = { + cLanguage: async () => (await import('tree-sitter-c')).default as unknown as Parser.Language, + cpp: async () => (await import('tree-sitter-cpp')).default as unknown as Parser.Language, + csharp: async () => (await import('tree-sitter-c-sharp')).default as unknown as Parser.Language, + dart: async () => (await import('@driftlog/tree-sitter-dart')).default as unknown as Parser.Language, + go: async () => (await import('tree-sitter-go')).default as unknown as Parser.Language, + haskell: async () => (await import('tree-sitter-haskell')).default as unknown as Parser.Language, + java: async () => (await import('tree-sitter-java')).default as unknown as Parser.Language, + javaScript: async () => (await import('tree-sitter-javascript')).default as unknown as Parser.Language, + kotlin: async () => (await import('@tree-sitter-grammars/tree-sitter-kotlin')).default as unknown as Parser.Language, + lua: async () => (await import('@tree-sitter-grammars/tree-sitter-lua')).default as unknown as Parser.Language, + objectiveC: async () => (await import('tree-sitter-objc')).default as unknown as Parser.Language, + php: async () => ((await import('tree-sitter-php')).default as unknown as { php: Parser.Language }).php, + python: async () => (await import('tree-sitter-python')).default as unknown as Parser.Language, + ruby: async () => (await import('tree-sitter-ruby')).default as unknown as Parser.Language, + rust: async () => (await import('tree-sitter-rust')).default as unknown as Parser.Language, + scala: async () => (await import('tree-sitter-scala')).default as unknown as Parser.Language, + swift: async () => (await import('tree-sitter-swift')).default as unknown as Parser.Language, + tsx: async () => ((await import('tree-sitter-typescript')).default as unknown as { + tsx: Parser.Language; + }).tsx, + typeScript: async () => ((await import('tree-sitter-typescript')).default as unknown as { + typescript: Parser.Language; + }).typescript, +}; + +async function loadTreeSitterLanguage( + language: TreeSitterLanguageBindingName, +): Promise { + return TREE_SITTER_LANGUAGE_LOADERS[language](); +} + +const treeSitterLanguageBindingPromises = new Map< + TreeSitterLanguageBindingName, + Promise +>(); + +export function loadTreeSitterLanguageBinding( + language: TreeSitterLanguageBindingName, +): Promise { + const cached = treeSitterLanguageBindingPromises.get(language); + if (cached) { + return cached; + } + + const promise = Promise.all([ + loadTreeSitterParserCtor(), + loadTreeSitterLanguage(language), + ]) + .then(([ParserCtor, languageBinding]) => + ({ ParserCtor, language: languageBinding }), + ) + .catch((error: unknown) => { + warnTreeSitterBindingsUnavailable(error); + return null; + }); + treeSitterLanguageBindingPromises.set(language, promise); + return promise; +} + let treeSitterBindingsPromise: Promise | undefined; export async function loadTreeSitterBindings(): Promise { treeSitterBindingsPromise ??= Promise.all([ - import('tree-sitter'), + loadTreeSitterParserCtor(), import('tree-sitter-c'), import('tree-sitter-cpp'), import('tree-sitter-c-sharp'), @@ -49,7 +131,7 @@ export async function loadTreeSitterBindings(): Promise { - const ParserCtor = parserModule.default; const typeScriptLanguages = typeScriptModule.default as unknown as { tsx: Parser.Language; typescript: Parser.Language; @@ -99,9 +180,7 @@ export async function loadTreeSitterBindings(): Promise { - console.warn( - `[CodeGraphy] Tree-sitter bindings unavailable; skipping core Tree-sitter analysis. ${String(error)}`, - ); + warnTreeSitterBindingsUnavailable(error); return null; }); diff --git a/packages/core/src/treeSitter/runtime/languages/parser.ts b/packages/core/src/treeSitter/runtime/languages/parser.ts index ce4ec0541..568d0bfc7 100644 --- a/packages/core/src/treeSitter/runtime/languages/parser.ts +++ b/packages/core/src/treeSitter/runtime/languages/parser.ts @@ -5,7 +5,7 @@ import { TREE_SITTER_RUNTIME_BINDINGS, type TreeSitterLanguageKind, } from './catalog'; -import { loadTreeSitterBindings } from './load'; +import { loadTreeSitterLanguageBinding } from './load'; export interface ITreeSitterRuntime { languageKind: TreeSitterLanguageKind; @@ -17,11 +17,6 @@ async function getTreeSitterLanguageForFile(filePath: string): Promise<{ languageKind: TreeSitterLanguageKind; language: Parser.Language; } | null> { - const bindings = await loadTreeSitterBindings(); - if (!bindings) { - return null; - } - const binding = TREE_SITTER_RUNTIME_BINDINGS[ getFileExtension(filePath) as keyof typeof TREE_SITTER_RUNTIME_BINDINGS ]; @@ -29,10 +24,15 @@ async function getTreeSitterLanguageForFile(filePath: string): Promise<{ return null; } + const languageBinding = await loadTreeSitterLanguageBinding(binding.language); + if (!languageBinding) { + return null; + } + return { - ParserCtor: bindings.ParserCtor, + ParserCtor: languageBinding.ParserCtor, languageKind: binding.languageKind, - language: bindings[binding.language], + language: languageBinding.language, }; } diff --git a/packages/core/src/visibleGraph/filter.ts b/packages/core/src/visibleGraph/filter.ts index 2641a1d1b..cc04e607b 100644 --- a/packages/core/src/visibleGraph/filter.ts +++ b/packages/core/src/visibleGraph/filter.ts @@ -1,21 +1,12 @@ import type { IGraphData } from '../graph/contracts'; -import { globMatch } from '../globMatch'; import type { VisibleGraphFilterConfig } from './contracts'; import { filterEdgesToNodes } from './model'; - -function nodeMatchesPattern(node: IGraphData['nodes'][number], pattern: string): boolean { - return globMatch(node.id, pattern) - || (node.symbol?.filePath ? globMatch(node.symbol.filePath, pattern) : false); -} - -function edgeMatchesPattern(edge: IGraphData['edges'][number], pattern: string): boolean { - return ( - globMatch(edge.id, pattern) - || globMatch(edge.kind, pattern) - || globMatch(`${edge.from}->${edge.to}`, pattern) - || globMatch(`${edge.from}->${edge.to}#${edge.kind}`, pattern) - ); -} +import { + compileFilterPatterns, + edgeMatchesPattern, + getDirectEdgePatternMatchers, + nodeMatchesPattern, +} from './filterPatterns.js'; export function applyFilterPatterns( graphData: IGraphData, @@ -25,12 +16,18 @@ export function applyFilterPatterns( return graphData; } + const compiledPatterns = compileFilterPatterns(filter.patterns); const nodes = graphData.nodes.filter( - (node) => !filter.patterns.some((pattern) => nodeMatchesPattern(node, pattern)), + (node) => !compiledPatterns.some(({ matches }) => nodeMatchesPattern(node, matches)), ); const nodeFilteredEdges = filterEdgesToNodes(graphData.edges, nodes); + const edgePatternMatchers = getDirectEdgePatternMatchers(compiledPatterns); + if (edgePatternMatchers.length === 0) { + return { nodes, edges: nodeFilteredEdges }; + } + const edges = nodeFilteredEdges.filter( - (edge) => !filter.patterns.some((pattern) => edgeMatchesPattern(edge, pattern)), + (edge) => !edgePatternMatchers.some((matches) => edgeMatchesPattern(edge, matches)), ); return { nodes, edges }; diff --git a/packages/core/src/visibleGraph/filterPatterns.ts b/packages/core/src/visibleGraph/filterPatterns.ts new file mode 100644 index 000000000..6ac831414 --- /dev/null +++ b/packages/core/src/visibleGraph/filterPatterns.ts @@ -0,0 +1,44 @@ +import type { IGraphData } from '../graph/contracts.js'; +import { createGlobMatcher } from '../globMatch.js'; + +type GlobMatcher = ReturnType; + +export interface CompiledFilterPattern { + matches: GlobMatcher; + pattern: string; +} + +export function compileFilterPatterns(patterns: readonly string[]): CompiledFilterPattern[] { + return patterns.map(pattern => ({ + matches: createGlobMatcher(pattern), + pattern, + })); +} + +export function getDirectEdgePatternMatchers( + patterns: readonly CompiledFilterPattern[], +): GlobMatcher[] { + return patterns + .filter(({ pattern }) => canFilterEdgeDirectly(pattern)) + .map(({ matches }) => matches); +} + +export function nodeMatchesPattern(node: IGraphData['nodes'][number], matches: GlobMatcher): boolean { + return matches(node.id) + || (node.symbol?.filePath ? matches(node.symbol.filePath) : false); +} + +export function edgeMatchesPattern(edge: IGraphData['edges'][number], matches: GlobMatcher): boolean { + return ( + matches(edge.id) + || matches(edge.kind) + || matches(`${edge.from}->${edge.to}`) + || matches(`${edge.from}->${edge.to}#${edge.kind}`) + ); +} + +function canFilterEdgeDirectly(pattern: string): boolean { + return pattern.includes('->') + || pattern.includes('#') + || (!pattern.includes('*') && !pattern.includes('/')); +} diff --git a/packages/core/src/workspace/requestIndexing.ts b/packages/core/src/workspace/requestIndexing.ts index 113dc4ac5..3d557f529 100644 --- a/packages/core/src/workspace/requestIndexing.ts +++ b/packages/core/src/workspace/requestIndexing.ts @@ -33,7 +33,10 @@ export async function requestCodeGraphyIndexWorkspace( workspaceRoot, }, })); - const result = await indexCodeGraphyWorkspace({ workspaceRoot }); + const result = await indexCodeGraphyWorkspace({ + workspaceRoot, + ...(input.diagnostics ? { diagnostics: input.diagnostics } : {}), + }); const graphCache = path.relative(result.workspaceRoot, result.graphCachePath); input.diagnostics?.emit(createDiagnosticEvent({ diff --git a/packages/core/src/workspace/status.ts b/packages/core/src/workspace/status.ts index f69029918..c99881234 100644 --- a/packages/core/src/workspace/status.ts +++ b/packages/core/src/workspace/status.ts @@ -13,6 +13,7 @@ import { createDefaultStatusPluginSignature } from './statusPlugins'; import { collectCodeGraphyWorkspaceStaleReasons, } from './statusReasons'; +import { filterWorkspaceStatusPendingChangedFiles } from './statusPendingFiles'; import { createCodeGraphyWorkspaceStatusState } from './statusState'; export type { CodeGraphyWorkspaceStatus, @@ -39,13 +40,20 @@ export function readCodeGraphyWorkspaceStatus( ?? (options.plugins ? createCodeGraphyWorkspacePluginSignature(options.plugins) : createDefaultStatusPluginSignature(settings, options.userHomeDir)); + const pendingChangedFiles = filterWorkspaceStatusPendingChangedFiles( + meta.pendingChangedFiles, + { + lastIndexedAt: meta.lastIndexedAt, + workspaceRoot: resolvedWorkspaceRoot, + }, + ); const staleReasons = collectCodeGraphyWorkspaceStaleReasons({ hasGraphCache, indexedAt: meta.lastIndexedAt, metaPluginSignature: meta.pluginSignature, metaSettingsSignature: meta.settingsSignature, metaAnalysisVersion: meta.analysisVersion, - pendingChangedFiles: meta.pendingChangedFiles, + pendingChangedFiles, pluginSignature, settingsSignature, }); diff --git a/packages/core/src/workspace/statusPendingFiles.ts b/packages/core/src/workspace/statusPendingFiles.ts new file mode 100644 index 000000000..9bc566dfe --- /dev/null +++ b/packages/core/src/workspace/statusPendingFiles.ts @@ -0,0 +1,82 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + DEFAULT_EXCLUDE, + isDefaultExcludedPath, + matchesAnyPattern, +} from '../discovery/pathMatching'; + +function normalizePendingPath(filePath: string): string { + return filePath.replace(/\\/g, '/').replace(/\/+$/, ''); +} + +function resolvePendingPath(filePath: string, workspaceRoot: string | undefined): string { + if (path.isAbsolute(filePath) || !workspaceRoot) { + return filePath; + } + + return path.join(workspaceRoot, filePath); +} + +function parseIndexedAt(indexedAt: string | null | undefined): number | undefined { + if (!indexedAt) { + return undefined; + } + + const parsed = Date.parse(indexedAt); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function wasPendingPathCoveredByIndex( + filePath: string, + options: { + indexedAtMs: number | undefined; + stat: (filePath: string) => fs.Stats; + workspaceRoot: string | undefined; + }, +): boolean { + if (options.indexedAtMs === undefined) { + return false; + } + + try { + const stat = options.stat(resolvePendingPath(filePath, options.workspaceRoot)); + return stat.mtimeMs <= options.indexedAtMs; + } catch { + return false; + } +} + +export function filterWorkspaceStatusPendingChangedFiles( + filePaths: readonly string[], + options: { + lastIndexedAt?: string | null; + stat?: (filePath: string) => fs.Stats; + workspaceRoot?: string; + } = {}, +): string[] { + const normalizedWorkspaceRoot = options.workspaceRoot + ? normalizePendingPath(options.workspaceRoot) + : undefined; + const indexedAtMs = parseIndexedAt(options.lastIndexedAt); + const stat = options.stat ?? fs.statSync; + + return filePaths.filter((filePath) => { + if ( + normalizedWorkspaceRoot + && normalizePendingPath(filePath) === normalizedWorkspaceRoot + ) { + return false; + } + + if (isDefaultExcludedPath(filePath) || matchesAnyPattern(filePath, DEFAULT_EXCLUDE)) { + return false; + } + + return !wasPendingPathCoveredByIndex(filePath, { + indexedAtMs, + stat, + workspaceRoot: options.workspaceRoot, + }); + }); +} diff --git a/packages/core/tests/diagnostics/events.test.ts b/packages/core/tests/diagnostics/events.test.ts index 57dab706f..70f40c94a 100644 --- a/packages/core/tests/diagnostics/events.test.ts +++ b/packages/core/tests/diagnostics/events.test.ts @@ -55,6 +55,28 @@ describe('diagnostics/events', () => { edges: 20, }, })).toBe('[CodeGraphy] Indexing complete: 4 files, 12 nodes, 20 edges, operation=index-1'); + + expect(formatDiagnosticEventLine({ + area: 'indexing', + event: 'phase-completed', + context: { + phase: 'analyze-files', + durationMs: 2750, + files: 42, + cacheHits: 20, + cacheMisses: 22, + }, + })).toBe('[CodeGraphy] Indexing phase complete: phase=analyze-files, durationMs=2750, files=42, cacheHits=20, cacheMisses=22'); + }); + + it('formats unknown events with readable fallback context', () => { + expect(formatDiagnosticEventLine({ + area: 'graph-cache', + event: 'cache-load-failed', + context: { + operationId: 'index-1', + }, + })).toBe('[CodeGraphy] Cache load failed: area=graph-cache, operationId=index-1'); }); it('normalizes non-JSON primitive context values into readable strings', () => { @@ -82,4 +104,31 @@ describe('diagnostics/events', () => { }, }); }); + + it('normalizes nested object context values into JSON-safe values', () => { + expect(createDiagnosticEvent({ + area: 'diagnostics', + event: 'nested', + context: { + payload: { + enabled: true, + paths: new Set(['src/app.ts']), + error: new Error('nested failure'), + }, + }, + })).toEqual({ + area: 'diagnostics', + event: 'nested', + context: { + payload: { + enabled: true, + paths: ['src/app.ts'], + error: { + name: 'Error', + message: 'nested failure', + }, + }, + }, + }); + }); }); diff --git a/packages/core/tests/discovery/defaultExcludedPath.test.ts b/packages/core/tests/discovery/defaultExcludedPath.test.ts new file mode 100644 index 000000000..45e1a3449 --- /dev/null +++ b/packages/core/tests/discovery/defaultExcludedPath.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { isDefaultExcludedPath } from '../../src/discovery/defaultExcludedPath'; +import { DEFAULT_EXCLUDE } from '../../src/discovery/pathExclusions'; + +describe('discovery/defaultExcludedPath', () => { + it('keeps the default exclude pattern contract in sync with fast excludes', () => { + expect(DEFAULT_EXCLUDE).toEqual([ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/out/**', + '**/.git/**', + '**/.codegraphy/**', + '**/.turbo', + '**/.turbo/**', + '**/.worktrees', + '**/.worktrees/**', + '**/coverage/**', + '**/.DS_Store', + '**/*.min.js', + '**/*.bundle.js', + '**/*.map', + ]); + }); + + it('excludes generated and metadata path segments anywhere in the path', () => { + expect(isDefaultExcludedPath('node_modules/react/index.js')).toBe(true); + expect(isDefaultExcludedPath('packages/app/dist/index.js')).toBe(true); + expect(isDefaultExcludedPath('packages/app/build/index.js')).toBe(true); + expect(isDefaultExcludedPath('packages/app/out/index.js')).toBe(true); + expect(isDefaultExcludedPath('.git/objects/HEAD')).toBe(true); + expect(isDefaultExcludedPath('.codegraphy/graph.lbug')).toBe(true); + expect(isDefaultExcludedPath('packages/app/.turbo/cache')).toBe(true); + expect(isDefaultExcludedPath('.worktrees/speed-up-codegraphy/src/app.ts')).toBe(true); + expect(isDefaultExcludedPath('coverage/lcov.info')).toBe(true); + }); + + it('normalizes Windows separators before checking generated segments', () => { + expect(isDefaultExcludedPath('packages\\app\\dist\\index.js')).toBe(true); + expect(isDefaultExcludedPath('.codegraphy\\graph.lbug')).toBe(true); + expect(isDefaultExcludedPath('.worktrees\\branch\\src\\app.ts')).toBe(true); + }); + + it('excludes generated basenames and artifact suffixes', () => { + expect(isDefaultExcludedPath('src/.DS_Store')).toBe(true); + expect(isDefaultExcludedPath('src/vendor.min.js')).toBe(true); + expect(isDefaultExcludedPath('src/generated/.DS_Store')).toBe(true); + expect(isDefaultExcludedPath('src/assets/vendor.min.js')).toBe(true); + expect(isDefaultExcludedPath('src/vendor.bundle.js')).toBe(true); + expect(isDefaultExcludedPath('src/app.js.map')).toBe(true); + expect(isDefaultExcludedPath('src/app.js.map/')).toBe(true); + }); + + it('does not exclude similarly named source paths', () => { + expect(isDefaultExcludedPath('src/node_modules_cache/index.ts')).toBe(false); + expect(isDefaultExcludedPath('src/building/index.ts')).toBe(false); + expect(isDefaultExcludedPath('src/outbound/index.ts')).toBe(false); + expect(isDefaultExcludedPath('src/codegraphy.ts')).toBe(false); + expect(isDefaultExcludedPath('src/vendor.js')).toBe(false); + }); +}); diff --git a/packages/core/tests/discovery/knownDirectory.test.ts b/packages/core/tests/discovery/knownDirectory.test.ts new file mode 100644 index 000000000..c31559e21 --- /dev/null +++ b/packages/core/tests/discovery/knownDirectory.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { shouldSkipKnownDirectory } from '../../src/discovery/knownDirectory'; + +describe('discovery/knownDirectory', () => { + it('skips exact generated and repository metadata directories', () => { + expect(shouldSkipKnownDirectory('node_modules')).toBe(true); + expect(shouldSkipKnownDirectory('.git')).toBe(true); + expect(shouldSkipKnownDirectory('.codegraphy')).toBe(true); + }); + + it('skips descendants of generated and repository metadata directories', () => { + expect(shouldSkipKnownDirectory('node_modules/react')).toBe(true); + expect(shouldSkipKnownDirectory('.git/objects')).toBe(true); + expect(shouldSkipKnownDirectory('.codegraphy/graph.lbug')).toBe(true); + }); + + it('normalizes Windows separators before checking known directories', () => { + expect(shouldSkipKnownDirectory('node_modules\\react')).toBe(true); + expect(shouldSkipKnownDirectory('.git\\objects')).toBe(true); + expect(shouldSkipKnownDirectory('.codegraphy\\graph.lbug')).toBe(true); + }); + + it('does not skip similarly named or nested directories', () => { + expect(shouldSkipKnownDirectory('packages/demo/node_modules')).toBe(false); + expect(shouldSkipKnownDirectory('.github')).toBe(false); + expect(shouldSkipKnownDirectory('node_modules_cache')).toBe(false); + expect(shouldSkipKnownDirectory('.codegraphy-cache')).toBe(false); + }); +}); diff --git a/packages/core/tests/discovery/pathExclusions.test.ts b/packages/core/tests/discovery/pathExclusions.test.ts new file mode 100644 index 000000000..848c52369 --- /dev/null +++ b/packages/core/tests/discovery/pathExclusions.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_EXCLUDE } from '../../src/discovery/pathExclusions'; + +describe('discovery/pathExclusions', () => { + it('keeps default exclude patterns stable for workspace discovery', () => { + expect(DEFAULT_EXCLUDE).toEqual([ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/out/**', + '**/.git/**', + '**/.codegraphy/**', + '**/.turbo', + '**/.turbo/**', + '**/.worktrees', + '**/.worktrees/**', + '**/coverage/**', + '**/.DS_Store', + '**/*.min.js', + '**/*.bundle.js', + '**/*.map', + ]); + }); +}); diff --git a/packages/core/tests/discovery/pathMatching.test.ts b/packages/core/tests/discovery/pathMatching.test.ts index 1229bd0af..1f3e32d8b 100644 --- a/packages/core/tests/discovery/pathMatching.test.ts +++ b/packages/core/tests/discovery/pathMatching.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_EXCLUDE, + isDefaultExcludedPath, matchesAnyPattern, normalizeDiscoveryPath, shouldSkipKnownDirectory, @@ -15,7 +16,10 @@ describe('pathMatching', () => { '**/out/**', '**/.git/**', '**/.codegraphy/**', + '**/.turbo', '**/.turbo/**', + '**/.worktrees', + '**/.worktrees/**', '**/coverage/**', '**/.DS_Store', '**/*.min.js', @@ -32,6 +36,10 @@ describe('pathMatching', () => { expect(matchesAnyPattern('src/app.ts', ['*.ts'])).toBe(true); }); + it('matches when any pattern matches the normalized path', () => { + expect(matchesAnyPattern('src/app.ts', ['*.md', '*.ts'])).toBe(true); + }); + it('matches hidden files when dot matching is enabled', () => { expect(matchesAnyPattern('config/.env', ['*.env'])).toBe(true); }); @@ -40,6 +48,20 @@ describe('pathMatching', () => { expect(matchesAnyPattern('src\\app.ts', ['src/*.ts'])).toBe(true); }); + it('fast-matches default generated and build excludes', () => { + expect(isDefaultExcludedPath('/workspace/packages/plugin-typescript/.turbo')).toBe(true); + expect(isDefaultExcludedPath('/workspace/.worktrees/speed-up-codegraphy/src/app.ts')).toBe(true); + expect(isDefaultExcludedPath('packages/extension/dist/webview/index.js')).toBe(true); + expect(isDefaultExcludedPath('packages/core/src/index.ts')).toBe(false); + }); + + it('fast-matches default generated file suffix excludes', () => { + expect(isDefaultExcludedPath('dist/index.js.map')).toBe(true); + expect(isDefaultExcludedPath('src/vendor.bundle.js')).toBe(true); + expect(isDefaultExcludedPath('src/vendor.min.js')).toBe(true); + expect(isDefaultExcludedPath('src/vendor.js')).toBe(false); + }); + it('skips exact node_modules and git directories', () => { expect(shouldSkipKnownDirectory('node_modules')).toBe(true); expect(shouldSkipKnownDirectory('.git')).toBe(true); diff --git a/packages/core/tests/discovery/pathNormalization.test.ts b/packages/core/tests/discovery/pathNormalization.test.ts new file mode 100644 index 000000000..413dafb46 --- /dev/null +++ b/packages/core/tests/discovery/pathNormalization.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeDiscoveryPath } from '../../src/discovery/pathNormalization'; + +describe('discovery/pathNormalization', () => { + it('converts Windows path separators to forward slashes', () => { + expect(normalizeDiscoveryPath('src\\nested\\file.ts')).toBe('src/nested/file.ts'); + }); + + it('leaves normalized paths unchanged', () => { + expect(normalizeDiscoveryPath('src/nested/file.ts')).toBe('src/nested/file.ts'); + }); +}); diff --git a/packages/core/tests/globMatch.test.ts b/packages/core/tests/globMatch.test.ts index a2dc5a706..d1ec99a6b 100644 --- a/packages/core/tests/globMatch.test.ts +++ b/packages/core/tests/globMatch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { globMatch, globToRegex } from '../src/globMatch'; +import { createGlobMatcher, globMatch, globToRegex } from '../src/globMatch'; describe('globMatch', () => { it('supports basename, single-star, and recursive glob matching', () => { @@ -22,4 +22,12 @@ describe('globMatch', () => { expect(globToRegex('src/app+(test).ts').test('src/app+(test).ts')).toBe(true); expect(globToRegex('src/app+(test).ts').test('src/appptestt.ts')).toBe(false); }); + + it('creates reusable matchers with the same glob semantics', () => { + const matcher = createGlobMatcher('src/**/*.ts'); + + expect(matcher('src/index.ts')).toBe(true); + expect(matcher('src/deep/index.ts')).toBe(true); + expect(matcher('docs/index.ts')).toBe(false); + }); }); diff --git a/packages/core/tests/graph/edgeTargetCache.test.ts b/packages/core/tests/graph/edgeTargetCache.test.ts new file mode 100644 index 000000000..8a203bac1 --- /dev/null +++ b/packages/core/tests/graph/edgeTargetCache.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IPlugin } from '@codegraphy-dev/plugin-api'; +import type { IProjectedConnection } from '../../src/analysis/projectedConnection'; +import { + createCachedConnectionTargetResolver, + type ConnectionTargetResolver, +} from '../../src/graph/edgeTargetCache'; + +function createPlugin(id: string): IPlugin { + return { + id, + name: id, + version: '1.0.0', + apiVersion: '^3.0.0', + supportedExtensions: ['.ts'], + analyzeFile: vi.fn(async (filePath: string) => ({ filePath, relations: [] })), + } as IPlugin; +} + +function createConnection( + overrides: Partial = {}, +): IProjectedConnection { + return { + kind: 'import', + resolvedPath: '/workspace/src/target.ts', + sourceId: 'import', + specifier: './target', + ...overrides, + }; +} + +describe('core/graph/edgeTargetCache', () => { + it('reuses resolved-path targets for the same plugin and connection target', () => { + const fileConnections = new Map(); + const resolveConnectionTargetId = vi.fn(() => 'src/target.ts'); + const resolveCachedTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + fileConnections, + '/workspace', + ); + const plugin = createPlugin('plugin.typescript'); + const connection = createConnection(); + + expect(resolveCachedTarget(plugin, connection)).toBe('src/target.ts'); + expect(resolveCachedTarget(plugin, { ...connection, specifier: './renamed' })).toBe('src/target.ts'); + expect(resolveConnectionTargetId).toHaveBeenCalledOnce(); + expect(resolveConnectionTargetId).toHaveBeenCalledWith( + plugin, + connection, + fileConnections, + '/workspace', + ); + }); + + it('keeps resolved-path cache entries separate by plugin id', () => { + const resolveConnectionTargetId = vi + .fn() + .mockReturnValueOnce('src/typescript.ts') + .mockReturnValueOnce('src/vue.ts'); + const resolveCachedTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + new Map(), + '/workspace', + ); + const connection = createConnection(); + + expect(resolveCachedTarget(createPlugin('plugin.typescript'), connection)).toBe('src/typescript.ts'); + expect(resolveCachedTarget(createPlugin('plugin.vue'), connection)).toBe('src/vue.ts'); + expect(resolveConnectionTargetId).toHaveBeenCalledTimes(2); + }); + + it('caches null resolved-path targets', () => { + const resolveConnectionTargetId = vi.fn(() => null); + const resolveCachedTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + new Map(), + '/workspace', + ); + const connection = createConnection(); + + expect(resolveCachedTarget(undefined, connection)).toBeNull(); + expect(resolveCachedTarget(undefined, connection)).toBeNull(); + expect(resolveConnectionTargetId).toHaveBeenCalledOnce(); + }); + + it('uses the specifier as a cache key when no resolved path exists', () => { + const resolveConnectionTargetId = vi.fn(() => 'pkg:react'); + const resolveCachedTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + new Map(), + '/workspace', + ); + const connection = createConnection({ + resolvedPath: null, + specifier: 'react', + }); + + expect(resolveCachedTarget(createPlugin('plugin.typescript'), connection)).toBe('pkg:react'); + expect(resolveCachedTarget(createPlugin('plugin.typescript'), connection)).toBe('pkg:react'); + expect(resolveConnectionTargetId).toHaveBeenCalledOnce(); + }); + + it('keeps specifier cache entries separate by plugin id', () => { + const resolveConnectionTargetId = vi + .fn() + .mockReturnValueOnce('pkg:typescript-react') + .mockReturnValueOnce('pkg:vue-react'); + const resolveCachedTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + new Map(), + '/workspace', + ); + const connection = createConnection({ + resolvedPath: null, + specifier: 'react', + }); + + expect(resolveCachedTarget(createPlugin('plugin.typescript'), connection)).toBe('pkg:typescript-react'); + expect(resolveCachedTarget(createPlugin('plugin.vue'), connection)).toBe('pkg:vue-react'); + expect(resolveConnectionTargetId).toHaveBeenCalledTimes(2); + }); + + it('caches specifier targets without a plugin', () => { + const resolveConnectionTargetId = vi.fn(() => 'pkg:react'); + const resolveCachedTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + new Map(), + '/workspace', + ); + const connection = createConnection({ + resolvedPath: null, + specifier: 'react', + }); + + expect(resolveCachedTarget(undefined, connection)).toBe('pkg:react'); + expect(resolveCachedTarget(undefined, connection)).toBe('pkg:react'); + expect(resolveConnectionTargetId).toHaveBeenCalledOnce(); + }); + + it('does not cache connections without a resolved path or specifier', () => { + const resolveConnectionTargetId = vi + .fn() + .mockReturnValueOnce(null) + .mockReturnValueOnce('dynamic-target'); + const resolveCachedTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + new Map(), + '/workspace', + ); + const connection = createConnection({ + resolvedPath: null, + specifier: '', + }); + + expect(resolveCachedTarget(undefined, connection)).toBeNull(); + expect(resolveCachedTarget(undefined, connection)).toBe('dynamic-target'); + expect(resolveConnectionTargetId).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/tests/graph/edges.test.ts b/packages/core/tests/graph/edges.test.ts index b18cd3ecf..6ae9ffa6f 100644 --- a/packages/core/tests/graph/edges.test.ts +++ b/packages/core/tests/graph/edges.test.ts @@ -114,6 +114,23 @@ describe('core/graph/edges', () => { ]); }); + it('reuses resolved target ids for repeated resolved paths', () => { + const resolveTarget = vi.fn(() => 'src/utils.ts'); + const result = buildWorkspaceGraphEdges(createOptions({ + fileConnections: new Map([ + ['src/index.ts', [ + { specifier: './utils', resolvedPath: '/workspace/src/utils.ts', kind: 'import', sourceId: 'import' }, + { specifier: './utils', resolvedPath: '/workspace/src/utils.ts', kind: 'reference', sourceId: 'reference' }, + ]], + ['src/utils.ts', []], + ]), + getConnectionTargetId: resolveTarget, + })); + + expect(resolveTarget).toHaveBeenCalledOnce(); + expect(result.edges.map(edge => edge.to)).toEqual(['src/utils.ts', 'src/utils.ts']); + }); + it('filters only the disabled plugin provenance when multiple plugins contribute to one file', () => { const result = buildWorkspaceGraphEdges(createOptions({ disabledPlugins: new Set(['plugin.markdown']), diff --git a/packages/core/tests/graphCache/database/io/save.test.ts b/packages/core/tests/graphCache/database/io/save.test.ts new file mode 100644 index 000000000..ccaa71a3b --- /dev/null +++ b/packages/core/tests/graphCache/database/io/save.test.ts @@ -0,0 +1,259 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import { setImmediate as waitForImmediate } from 'node:timers/promises'; +import { + clearWorkspaceAnalysisDatabaseCache, + saveWorkspaceAnalysisDatabaseCache, +} from '../../../../src/graphCache/database/io/save'; +import { saveWorkspaceAnalysisDatabaseCacheAsync } from '../../../../src/graphCache/database/io/saveAsync'; +import * as connectionModule from '../../../../src/graphCache/database/io/connection'; +import * as pathsModule from '../../../../src/graphCache/database/io/paths'; +import * as temporaryModule from '../../../../src/graphCache/database/io/temporary'; +import * as writeModule from '../../../../src/graphCache/database/query/write'; + +const timerPromisesMock = vi.hoisted(() => ({ + setImmediate: vi.fn(async () => undefined), +})); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(), + }; +}); + +vi.mock('node:timers/promises', () => ({ + ...timerPromisesMock, + default: timerPromisesMock, +})); + +vi.mock('../../../../src/graphCache/database/io/connection', () => ({ + runStatementAsync: vi.fn(async () => undefined), + runStatementSync: vi.fn(), + withConnection: vi.fn(), + withConnectionAsync: vi.fn(), +})); + +vi.mock('../../../../src/graphCache/database/io/paths', () => ({ + ensureDatabaseDirectory: vi.fn(), + getWorkspaceAnalysisDatabasePath: vi.fn(), +})); + +vi.mock('../../../../src/graphCache/database/io/temporary', () => ({ + cleanupTemporaryDatabase: vi.fn(), + createTemporaryDatabasePath: vi.fn(), + replaceDatabaseCache: vi.fn(), +})); + +vi.mock('../../../../src/graphCache/database/query/write', () => ({ + createWorkspaceAnalysisCacheWriter: vi.fn(), + createWorkspaceAnalysisCacheWriterAsync: vi.fn(), + persistAnalysisEntry: vi.fn(), + persistAnalysisEntryAsync: vi.fn(), + sortedCacheEntries: vi.fn(), +})); + +const cache = { + version: '1', + files: { + 'src/b.ts': { mtime: 2, size: 20, analysis: {} }, + 'src/a.ts': { mtime: 1, size: 10, analysis: {} }, + }, +} as never; + +describe('graphCache/database/io/save', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(pathsModule.getWorkspaceAnalysisDatabasePath) + .mockReturnValue('/workspace/.codegraphy/graph.lbug'); + vi.mocked(temporaryModule.createTemporaryDatabasePath) + .mockReturnValue('/workspace/.codegraphy/graph.lbug.tmp'); + vi.mocked(writeModule.sortedCacheEntries).mockReturnValue([ + ['src/a.ts', { mtime: 1, size: 10, analysis: {} }], + ['src/b.ts', { mtime: 2, size: 20, analysis: {} }], + ] as never); + vi.mocked(writeModule.createWorkspaceAnalysisCacheWriter) + .mockReturnValue({ connection: 'connection', fileAnalysisStatement: 'statement' } as never); + vi.mocked(writeModule.createWorkspaceAnalysisCacheWriterAsync) + .mockResolvedValue({ connection: 'connection', fileAnalysisStatement: 'statement' } as never); + vi.mocked(connectionModule.withConnection).mockImplementation((_databasePath, callback) => + callback('connection' as never)); + vi.mocked(connectionModule.withConnectionAsync).mockImplementation(async (_databasePath, callback) => + callback('connection' as never)); + vi.mocked(writeModule.persistAnalysisEntryAsync).mockImplementation(async ( + _writer, + _filePath, + _entry, + afterStatement, + ) => { + await afterStatement(); + }); + }); + + it('writes a temporary database, replaces the cache, and persists sorted entries', () => { + saveWorkspaceAnalysisDatabaseCache('/workspace', cache); + + expect(pathsModule.ensureDatabaseDirectory).toHaveBeenCalledWith('/workspace'); + expect(connectionModule.withConnection).toHaveBeenCalledWith( + '/workspace/.codegraphy/graph.lbug.tmp', + expect.any(Function), + ); + expect(connectionModule.runStatementSync).toHaveBeenNthCalledWith( + 1, + 'connection', + 'MATCH (entry:FileAnalysis) DELETE entry', + ); + expect(connectionModule.runStatementSync).toHaveBeenNthCalledWith( + 2, + 'connection', + 'MATCH (entry:Symbol) DELETE entry', + ); + expect(connectionModule.runStatementSync).toHaveBeenNthCalledWith( + 3, + 'connection', + 'MATCH (entry:Relation) DELETE entry', + ); + expect(writeModule.persistAnalysisEntry).toHaveBeenNthCalledWith( + 1, + { connection: 'connection', fileAnalysisStatement: 'statement' }, + 'src/a.ts', + { mtime: 1, size: 10, analysis: {} }, + ); + expect(writeModule.persistAnalysisEntry).toHaveBeenNthCalledWith( + 2, + { connection: 'connection', fileAnalysisStatement: 'statement' }, + 'src/b.ts', + { mtime: 2, size: 20, analysis: {} }, + ); + expect(temporaryModule.replaceDatabaseCache).toHaveBeenCalledWith( + '/workspace/.codegraphy/graph.lbug.tmp', + '/workspace/.codegraphy/graph.lbug', + ); + expect(temporaryModule.cleanupTemporaryDatabase).not.toHaveBeenCalled(); + }); + + it('does not write when the database directory cannot be created', () => { + vi.mocked(fs.existsSync).mockReturnValueOnce(false); + + saveWorkspaceAnalysisDatabaseCache('/workspace', cache); + + expect(connectionModule.withConnection).not.toHaveBeenCalled(); + expect(temporaryModule.createTemporaryDatabasePath).not.toHaveBeenCalled(); + expect(temporaryModule.replaceDatabaseCache).not.toHaveBeenCalled(); + }); + + it('cleans up the temporary database when saving fails', () => { + vi.mocked(connectionModule.withConnection).mockImplementationOnce(() => { + throw new Error('write failed'); + }); + + expect(() => saveWorkspaceAnalysisDatabaseCache('/workspace', cache)).toThrow('write failed'); + expect(temporaryModule.cleanupTemporaryDatabase).toHaveBeenCalledWith('/workspace/.codegraphy/graph.lbug.tmp'); + expect(temporaryModule.replaceDatabaseCache).not.toHaveBeenCalled(); + }); + + it('clears existing database rows from every cache table', () => { + clearWorkspaceAnalysisDatabaseCache('/workspace'); + + expect(connectionModule.withConnection).toHaveBeenCalledWith( + '/workspace/.codegraphy/graph.lbug', + expect.any(Function), + ); + expect(connectionModule.runStatementSync).toHaveBeenNthCalledWith( + 1, + 'connection', + 'MATCH (entry:FileAnalysis) DELETE entry', + ); + expect(connectionModule.runStatementSync).toHaveBeenNthCalledWith( + 2, + 'connection', + 'MATCH (entry:Symbol) DELETE entry', + ); + expect(connectionModule.runStatementSync).toHaveBeenNthCalledWith( + 3, + 'connection', + 'MATCH (entry:Relation) DELETE entry', + ); + }); + + it('does not clear a missing database', () => { + vi.mocked(fs.existsSync).mockReturnValueOnce(false); + + clearWorkspaceAnalysisDatabaseCache('/workspace'); + + expect(connectionModule.withConnection).not.toHaveBeenCalled(); + expect(connectionModule.runStatementSync).not.toHaveBeenCalled(); + }); + + it('writes the async cache with progress and cooperative yielding', async () => { + const onProgress = vi.fn(); + + await saveWorkspaceAnalysisDatabaseCacheAsync('/workspace', cache, { + onProgress, + yieldEvery: 1, + }); + + expect(connectionModule.runStatementAsync).toHaveBeenNthCalledWith( + 1, + 'connection', + 'MATCH (entry:FileAnalysis) DELETE entry', + ); + expect(connectionModule.runStatementAsync).toHaveBeenNthCalledWith( + 2, + 'connection', + 'MATCH (entry:Symbol) DELETE entry', + ); + expect(connectionModule.runStatementAsync).toHaveBeenNthCalledWith( + 3, + 'connection', + 'MATCH (entry:Relation) DELETE entry', + ); + expect(writeModule.persistAnalysisEntryAsync).toHaveBeenCalledTimes(2); + expect(waitForImmediate).toHaveBeenCalledTimes(2); + expect(onProgress).toHaveBeenNthCalledWith(1, { current: 0, total: 2 }); + expect(onProgress).toHaveBeenNthCalledWith(2, { current: 1, total: 2 }); + expect(onProgress).toHaveBeenNthCalledWith(3, { current: 2, total: 2 }); + expect(temporaryModule.replaceDatabaseCache).toHaveBeenCalledWith( + '/workspace/.codegraphy/graph.lbug.tmp', + '/workspace/.codegraphy/graph.lbug', + ); + }); + + it('does not write the async cache when the database directory cannot be created', async () => { + vi.mocked(fs.existsSync).mockReturnValueOnce(false); + + await saveWorkspaceAnalysisDatabaseCacheAsync('/workspace', cache); + + expect(connectionModule.withConnectionAsync).not.toHaveBeenCalled(); + expect(temporaryModule.createTemporaryDatabasePath).not.toHaveBeenCalled(); + expect(temporaryModule.replaceDatabaseCache).not.toHaveBeenCalled(); + }); + + it('waits for the async yield interval before yielding', async () => { + await saveWorkspaceAnalysisDatabaseCacheAsync('/workspace', cache, { + yieldEvery: 2, + }); + + expect(waitForImmediate).toHaveBeenCalledTimes(1); + }); + + it('does not require async progress callbacks or positive yield intervals', async () => { + await saveWorkspaceAnalysisDatabaseCacheAsync('/workspace', cache, { + yieldEvery: 0, + }); + + expect(waitForImmediate).not.toHaveBeenCalled(); + expect(writeModule.persistAnalysisEntryAsync).toHaveBeenCalledTimes(2); + }); + + it('cleans up the temporary database when async saving fails', async () => { + vi.mocked(connectionModule.withConnectionAsync).mockRejectedValueOnce(new Error('async write failed')); + + await expect(saveWorkspaceAnalysisDatabaseCacheAsync('/workspace', cache)) + .rejects.toThrow('async write failed'); + expect(temporaryModule.cleanupTemporaryDatabase).toHaveBeenCalledWith('/workspace/.codegraphy/graph.lbug.tmp'); + expect(temporaryModule.replaceDatabaseCache).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/tests/graphCache/database/io/temporary.test.ts b/packages/core/tests/graphCache/database/io/temporary.test.ts new file mode 100644 index 000000000..6b0734ab9 --- /dev/null +++ b/packages/core/tests/graphCache/database/io/temporary.test.ts @@ -0,0 +1,57 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + cleanupTemporaryDatabase, + replaceDatabaseCache, +} from '../../../../src/graphCache/database/io/temporary'; + +let testDirectory: string | undefined; + +function createTestDirectory(): string { + testDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraphy-temp-db-')); + return testDirectory; +} + +function writeFile(filePath: string, contents: string): void { + fs.writeFileSync(filePath, contents, 'utf8'); +} + +afterEach(() => { + if (testDirectory) { + fs.rmSync(testDirectory, { force: true, recursive: true }); + testDirectory = undefined; + } +}); + +describe('graphCache/database/io/temporary', () => { + it('cleans up temporary database sidecar files', () => { + const directory = createTestDirectory(); + const tempDatabasePath = path.join(directory, 'graph.lbug.123.tmp'); + writeFile(tempDatabasePath, 'temp database'); + writeFile(`${tempDatabasePath}.wal`, 'temp wal'); + + cleanupTemporaryDatabase(tempDatabasePath); + + expect(fs.existsSync(tempDatabasePath)).toBe(false); + expect(fs.existsSync(`${tempDatabasePath}.wal`)).toBe(false); + }); + + it('replaces database sidecar files with temporary sidecars', () => { + const directory = createTestDirectory(); + const databasePath = path.join(directory, 'graph.lbug'); + const tempDatabasePath = path.join(directory, 'graph.lbug.123.tmp'); + writeFile(databasePath, 'old database'); + writeFile(`${databasePath}.wal`, 'old wal'); + writeFile(tempDatabasePath, 'new database'); + writeFile(`${tempDatabasePath}.wal`, 'new wal'); + + replaceDatabaseCache(tempDatabasePath, databasePath); + + expect(fs.readFileSync(databasePath, 'utf8')).toBe('new database'); + expect(fs.readFileSync(`${databasePath}.wal`, 'utf8')).toBe('new wal'); + expect(fs.existsSync(tempDatabasePath)).toBe(false); + expect(fs.existsSync(`${tempDatabasePath}.wal`)).toBe(false); + }); +}); diff --git a/packages/core/tests/graphCache/database/query/write.test.ts b/packages/core/tests/graphCache/database/query/write.test.ts index 2a2958a3d..a6367c25e 100644 --- a/packages/core/tests/graphCache/database/query/write.test.ts +++ b/packages/core/tests/graphCache/database/query/write.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; import { + createWorkspaceAnalysisCacheWriter, + createWorkspaceAnalysisCacheWriterAsync, persistAnalysisEntry, + persistAnalysisEntryAsync, sortedCacheEntries, + type WorkspaceAnalysisCacheWriter, } from '../../../../src/graphCache/database/query/write'; import * as cacheConnectionModule from '../../../../src/graphCache/database/io/connection'; -import * as relationStatementModule from '../../../../src/graphCache/database/relation/statement'; describe('graphCache/database/writeStatements', () => { it('sorts cache entries by file path', () => { @@ -19,63 +22,93 @@ describe('graphCache/database/writeStatements', () => { expect(entries.map(([filePath]) => filePath)).toEqual(['src/a.ts', 'src/z.ts']); }); - it('persists file, symbol, and relation statements in order', () => { - const runStatementSyncSpy = vi - .spyOn(cacheConnectionModule, 'runStatementSync') + it('prepares the canonical file analysis write statement once per cache write session', () => { + const fileStatement = {}; + const prepareStatementSyncSpy = vi + .spyOn(cacheConnectionModule, 'prepareStatementSync') + .mockReturnValueOnce(fileStatement as never); + + expect(createWorkspaceAnalysisCacheWriter({} as never)).toEqual({ + connection: {}, + fileAnalysisStatement: fileStatement, + }); + + expect(prepareStatementSyncSpy).toHaveBeenCalledTimes(1); + expect(prepareStatementSyncSpy).toHaveBeenNthCalledWith(1, {}, expect.stringContaining('filePath: $filePath')); + }); + + it('prepares the async canonical file analysis write statement once per cache write session', async () => { + const fileStatement = {}; + const prepareStatementAsyncSpy = vi + .spyOn(cacheConnectionModule, 'prepareStatementAsync') + .mockResolvedValueOnce(fileStatement as never); + + await expect(createWorkspaceAnalysisCacheWriterAsync({} as never)).resolves.toEqual({ + connection: {}, + fileAnalysisStatement: fileStatement, + }); + + expect(prepareStatementAsyncSpy).toHaveBeenCalledTimes(1); + expect(prepareStatementAsyncSpy).toHaveBeenNthCalledWith(1, {}, expect.stringContaining('filePath: $filePath')); + }); + + it('persists one canonical file analysis row through a prepared statement', () => { + const executeStatementSyncSpy = vi + .spyOn(cacheConnectionModule, 'executeStatementSync') .mockImplementation(() => []); - const createRelationStatementSpy = vi - .spyOn(relationStatementModule, 'createRelationStatement') - .mockReturnValue('RELATION'); + const writer = { + connection: {} as never, + fileAnalysisStatement: { kind: 'file' } as never, + } satisfies WorkspaceAnalysisCacheWriter; + const analysis = { + symbols: [ + { + id: 'symbol-1', + filePath: '/workspace/src/app.ts', + name: 'App', + kind: 'class', + }, + ], + relations: [ + { + filePath: '/workspace/src/app.ts', + fromFilePath: '/workspace/src/app.ts', + kind: 'import', + sourceId: 'plugin:import', + }, + ], + }; persistAnalysisEntry( - {} as never, + writer, '/workspace/src/app.ts', { mtime: 10, size: 20, - analysis: { - symbols: [ - { - id: 'symbol-1', - filePath: '/workspace/src/app.ts', - name: 'App', - kind: 'class', - }, - ], - relations: [ - { - filePath: '/workspace/src/app.ts', - fromFilePath: '/workspace/src/app.ts', - kind: 'import', - sourceId: 'plugin:import', - }, - ], - }, + analysis, } as never, ); - expect(runStatementSyncSpy).toHaveBeenNthCalledWith(1, {}, expect.stringContaining('CREATE (entry:FileAnalysis')); - expect(runStatementSyncSpy).toHaveBeenNthCalledWith(2, {}, expect.stringContaining('CREATE (entry:Symbol')); - expect(createRelationStatementSpy).toHaveBeenCalledWith( - '/workspace/src/app.ts', - { - filePath: '/workspace/src/app.ts', - fromFilePath: '/workspace/src/app.ts', - kind: 'import', - sourceId: 'plugin:import', - }, - 0, - ); - expect(runStatementSyncSpy).toHaveBeenNthCalledWith(3, {}, 'RELATION'); + expect(executeStatementSyncSpy).toHaveBeenCalledTimes(1); + expect(executeStatementSyncSpy).toHaveBeenNthCalledWith(1, {}, { kind: 'file' }, { + filePath: '/workspace/src/app.ts', + mtime: 10, + size: 20, + analysis: JSON.stringify(analysis), + }); }); - it('skips symbol and relation writes when the analysis omits them', () => { - const runStatementSyncSpy = vi - .spyOn(cacheConnectionModule, 'runStatementSync') + it('persists only the canonical row when the analysis omits symbols and relations', () => { + const executeStatementSyncSpy = vi + .spyOn(cacheConnectionModule, 'executeStatementSync') .mockImplementation(() => []); + const writer = { + connection: {} as never, + fileAnalysisStatement: { kind: 'file' } as never, + } satisfies WorkspaceAnalysisCacheWriter; persistAnalysisEntry( - {} as never, + writer, '/workspace/src/app.ts', { mtime: 10, @@ -84,7 +117,47 @@ describe('graphCache/database/writeStatements', () => { } as never, ); - expect(runStatementSyncSpy).toHaveBeenCalledTimes(1); - expect(runStatementSyncSpy).toHaveBeenCalledWith({}, expect.stringContaining('CREATE (entry:FileAnalysis')); + expect(executeStatementSyncSpy).toHaveBeenCalledTimes(1); + expect(executeStatementSyncSpy).toHaveBeenCalledWith(writer.connection, writer.fileAnalysisStatement, { + filePath: '/workspace/src/app.ts', + mtime: 10, + size: 20, + analysis: JSON.stringify({}), + }); + }); + + it('persists one canonical file analysis row asynchronously before yielding', async () => { + const sequence: string[] = []; + const executeStatementAsyncSpy = vi + .spyOn(cacheConnectionModule, 'executeStatementAsync') + .mockImplementation(async () => { + sequence.push('execute'); + }); + const afterStatement = vi.fn(async () => { + sequence.push('yield'); + }); + const writer = { + connection: {} as never, + fileAnalysisStatement: { kind: 'file' } as never, + } satisfies WorkspaceAnalysisCacheWriter; + + await persistAnalysisEntryAsync( + writer, + '/workspace/src/app.ts', + { + analysis: {}, + } as never, + afterStatement, + ); + + expect(executeStatementAsyncSpy).toHaveBeenCalledTimes(1); + expect(executeStatementAsyncSpy).toHaveBeenNthCalledWith(1, {}, { kind: 'file' }, { + filePath: '/workspace/src/app.ts', + mtime: 0, + size: 0, + analysis: JSON.stringify({}), + }); + expect(afterStatement).toHaveBeenCalledOnce(); + expect(sequence).toEqual(['execute', 'yield']); }); }); diff --git a/packages/core/tests/graphCache/database/snapshot.test.ts b/packages/core/tests/graphCache/database/snapshot.test.ts index 49aba0c03..7aaa6ddb5 100644 --- a/packages/core/tests/graphCache/database/snapshot.test.ts +++ b/packages/core/tests/graphCache/database/snapshot.test.ts @@ -85,6 +85,42 @@ describe('pipeline/database/cache/snapshot', () => { expect(readRowsSync).toHaveBeenNthCalledWith(3, 'connection', RELATION_ROWS_QUERY); }); + it('derives structured symbols and relations from file analysis rows when dedicated rows are absent', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(withConnection).mockImplementation((_path, callback) => callback('connection' as never)); + vi.mocked(readRowsSync) + .mockReturnValueOnce([{ id: 'file-1' }] as never) + .mockReturnValueOnce([]) + .mockReturnValueOnce([]); + vi.mocked(createSnapshotFileEntry).mockReturnValueOnce({ + filePath: 'src/file.ts', + mtime: 1, + analysis: { + filePath: 'src/file.ts', + symbols: [ + { id: 'symbol-1', filePath: 'src/file.ts', name: 'render', kind: 'function' }, + ], + relations: [ + { kind: 'import', sourceId: 'source', fromFilePath: 'src/file.ts' }, + ], + }, + } as never); + + expect(readWorkspaceAnalysisDatabaseSnapshot('/workspace')).toEqual({ + files: [{ + filePath: 'src/file.ts', + mtime: 1, + analysis: { + filePath: 'src/file.ts', + symbols: [{ id: 'symbol-1', filePath: 'src/file.ts', name: 'render', kind: 'function' }], + relations: [{ kind: 'import', sourceId: 'source', fromFilePath: 'src/file.ts' }], + }, + }], + symbols: [{ id: 'symbol-1', filePath: 'src/file.ts', name: 'render', kind: 'function' }], + relations: [{ kind: 'import', sourceId: 'source', fromFilePath: 'src/file.ts' }], + }); + }); + it('warns and falls back to an empty snapshot when reading the database fails', () => { vi.mocked(fs.existsSync).mockReturnValue(true); const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined); diff --git a/packages/core/tests/indexing/analysis.test.ts b/packages/core/tests/indexing/analysis.test.ts new file mode 100644 index 000000000..f8dadd4e4 --- /dev/null +++ b/packages/core/tests/indexing/analysis.test.ts @@ -0,0 +1,78 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createEmptyWorkspaceAnalysisCache } from '../../src/analysis/cache'; +import type { IDiscoveredFile } from '../../src/discovery/contracts'; +import { analyzeWorkspaceIndexFiles } from '../../src/indexing/analysis'; + +const tempRoots = new Set(); + +async function createWorkspaceRoot(): Promise { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-index-analysis-')); + tempRoots.add(workspaceRoot); + return workspaceRoot; +} + +function createDiscoveredFile(workspaceRoot: string, relativePath: string): IDiscoveredFile { + const extension = path.extname(relativePath); + return { + absolutePath: path.join(workspaceRoot, relativePath), + extension, + name: path.basename(relativePath), + relativePath, + }; +} + +afterEach(async () => { + await Promise.all([...tempRoots].map(workspaceRoot => + fs.rm(workspaceRoot, { recursive: true, force: true }), + )); + tempRoots.clear(); +}); + +describe('indexing/analysis', () => { + it('reuses pre-analysis file content for cold file analysis', async () => { + const workspaceRoot = await createWorkspaceRoot(); + await fs.mkdir(path.join(workspaceRoot, 'src'), { recursive: true }); + await fs.writeFile(path.join(workspaceRoot, 'src/a.txt'), 'a -> b\n', 'utf-8'); + await fs.writeFile(path.join(workspaceRoot, 'src/b.txt'), 'b\n', 'utf-8'); + const files = [ + createDiscoveredFile(workspaceRoot, 'src/a.txt'), + createDiscoveredFile(workspaceRoot, 'src/b.txt'), + ]; + const readContent = vi.fn(async (file: IDiscoveredFile) => + fs.readFile(file.absolutePath, 'utf-8'), + ); + + await analyzeWorkspaceIndexFiles({ + cache: createEmptyWorkspaceAnalysisCache(), + discovery: { readContent } as never, + discoveryResult: { + durationMs: 1, + files, + directories: [], + gitIgnoredPaths: [], + limitReached: false, + totalFound: files.length, + }, + disabledPlugins: new Set(), + options: { + workspaceRoot, + }, + registry: { + analyzeFileResult: vi.fn(async (absolutePath: string, content: string) => ({ + filePath: absolutePath, + relations: [], + symbols: content.trim().length > 0 ? [] : undefined, + })), + notifyPreAnalyze: vi.fn(async () => undefined), + } as never, + workspaceRoot, + }); + + expect(readContent).toHaveBeenCalledTimes(2); + expect(readContent).toHaveBeenCalledWith(files[0]); + expect(readContent).toHaveBeenCalledWith(files[1]); + }); +}); diff --git a/packages/core/tests/indexing/refresh.test.ts b/packages/core/tests/indexing/refresh.test.ts index c485f771a..ab687b6df 100644 --- a/packages/core/tests/indexing/refresh.test.ts +++ b/packages/core/tests/indexing/refresh.test.ts @@ -5,107 +5,15 @@ import { refreshWorkspaceIndexAnalysisScope, refreshWorkspaceIndexChangedFiles, refreshWorkspaceIndexPluginFiles, - type WorkspaceIndexRefreshDependencies, - type WorkspaceIndexRefreshSource, } from '../../src/indexing/refresh'; -import type { IDiscoveredFile } from '../../src/discovery/contracts'; import type { IGraphData } from '../../src/graph/contracts'; - -function createDiscoveredFile(relativePath: string): IDiscoveredFile { - const name = relativePath.split('/').at(-1) ?? relativePath; - return { - absolutePath: `/workspace/${relativePath}`, - extension: name.includes('.') ? name.slice(name.lastIndexOf('.')) : '', - name, - relativePath, - }; -} - -function createFileAnalysis(filePath: string): IFileAnalysisResult { - return { - filePath, - relations: [], - }; -} - -function createGraphNode(id: string) { - return { - color: '#808080', - id, - label: id.split('/').at(-1) ?? id, - }; -} - -function createSource( - overrides: Partial = {}, -): WorkspaceIndexRefreshSource { - const graph: IGraphData = { - nodes: [{ color: '#808080', id: 'src/app.ts', label: 'app.ts', nodeType: 'file' }], - edges: [], - }; - - return { - _analyzeFiles: vi.fn(async (files: IDiscoveredFile[]) => ({ - cacheHits: 0, - cacheMisses: files.length, - fileAnalysis: new Map(files.map(file => [ - file.relativePath, - createFileAnalysis(file.absolutePath), - ])), - fileConnections: new Map(files.map(file => [file.relativePath, []])), - })), - _buildGraphData: vi.fn((fileConnections: Map) => ({ - nodes: [...fileConnections.keys()].map(createGraphNode), - edges: [], - })), - _buildGraphDataFromAnalysis: vi.fn((fileAnalysis: Map) => ({ - nodes: [...fileAnalysis.keys()].map(createGraphNode), - edges: [], - })), - _lastDiscoveredDirectories: ['src'], - _lastDiscoveredFiles: [ - createDiscoveredFile('README.md'), - createDiscoveredFile('src/plugin.ts'), - createDiscoveredFile('src/plain.txt'), - ], - _lastFileAnalysis: new Map(), - _lastFileConnections: new Map([ - ['README.md', []], - ['src/plugin.ts', []], - ['src/plain.txt', []], - ]) as Map, - _lastWorkspaceRoot: '/workspace', - _preAnalyzePlugins: vi.fn(async () => undefined), - _readAnalysisFiles: vi.fn(async (files: IDiscoveredFile[]) => files.map(file => ({ - absolutePath: file.absolutePath, - relativePath: file.relativePath, - content: '', - }))), - analyze: vi.fn(async () => graph), - invalidateWorkspaceFiles: vi.fn(() => []), - ...overrides, - }; -} - -function refreshOptions( - overrides: Partial = {}, -): WorkspaceIndexRefreshDependencies { - return { - disabledPlugins: new Set(), - discoveredDirectories: ['src'], - discoveredFiles: [createDiscoveredFile('src/app.ts')], - filePaths: ['/workspace/src/app.ts'], - filterPatterns: [], - notifyFilesChanged: vi.fn(async () => ({ - additionalFilePaths: [], - requiresFullRefresh: false, - })), - persistCache: vi.fn(), - persistIndexMetadata: vi.fn(), - workspaceRoot: '/workspace', - ...overrides, - }; -} +import { + createDiscoveredFile, + createFileAnalysis, + createGraphNode, + createSource, + refreshOptions, +} from './refresh/fixture'; describe('indexing/refresh', () => { it('lets file analysis own pre-analysis during analysis scope refreshes', async () => { @@ -335,4 +243,103 @@ describe('indexing/refresh', () => { expect(persistCache).toHaveBeenCalledOnce(); expect(persistIndexMetadata).toHaveBeenCalledOnce(); }); + + it('skips the fallback connections graph build when analysis covers retained files', async () => { + const graph: IGraphData = { + nodes: [createGraphNode('src/app.ts'), createGraphNode('README.md')], + edges: [], + }; + const source = createSource({ + _buildGraphDataFromAnalysis: vi.fn(() => graph), + _lastFileAnalysis: new Map([ + ['README.md', createFileAnalysis('/workspace/README.md')], + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _lastFileConnections: new Map([ + ['README.md', []], + ['src/app.ts', []], + ]), + }); + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + discoveredFiles: [ + createDiscoveredFile('README.md'), + createDiscoveredFile('src/app.ts'), + ], + }))).resolves.toEqual(graph); + + expect(source._buildGraphDataFromAnalysis).toHaveBeenCalledWith( + source._lastFileAnalysis, + '/workspace', + new Set(), + ); + expect(source._buildGraphData).not.toHaveBeenCalled(); + }); + + it('patches only node metrics when changed-file analysis preserves graph structure', async () => { + const graph: IGraphData = { + nodes: [createGraphNode('src/app.ts')], + edges: [], + }; + const patchGraphDataNodeMetrics = vi.fn(() => graph); + const source = createSource({ + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _lastFileConnections: new Map([ + ['src/app.ts', []], + ]), + _patchGraphDataNodeMetrics: patchGraphDataNodeMetrics, + }); + const previousGraphData = source._lastGraphData; + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + persistIndexMetadata: vi.fn(async () => undefined), + }))).resolves.toBe(graph); + + expect(patchGraphDataNodeMetrics).toHaveBeenCalledWith( + previousGraphData, + ['src/app.ts'], + ); + expect(source._buildGraphDataFromAnalysis).not.toHaveBeenCalled(); + }); + + it('rebuilds the graph when changed-file analysis changes graph structure', async () => { + const graph: IGraphData = { + nodes: [createGraphNode('src/app.ts'), createGraphNode('src/next.ts')], + edges: [], + }; + const source = createSource({ + _analyzeFiles: vi.fn(async () => ({ + cacheHits: 0, + cacheMisses: 1, + fileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.changed.ts')], + ]), + fileConnections: new Map([ + ['src/app.ts', []], + ]), + })), + _buildGraphDataFromAnalysis: vi.fn(() => graph), + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _lastFileConnections: new Map([ + ['src/app.ts', []], + ]) as Map, + _patchGraphDataNodeMetrics: vi.fn(() => ({ + nodes: [createGraphNode('patched')], + edges: [], + })), + }); + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions())).resolves.toBe(graph); + + expect(source._patchGraphDataNodeMetrics).not.toHaveBeenCalled(); + expect(source._buildGraphDataFromAnalysis).toHaveBeenCalledWith( + source._lastFileAnalysis, + '/workspace', + new Set(), + ); + }); }); diff --git a/packages/core/tests/indexing/refresh/fixture.ts b/packages/core/tests/indexing/refresh/fixture.ts new file mode 100644 index 000000000..482ce0e27 --- /dev/null +++ b/packages/core/tests/indexing/refresh/fixture.ts @@ -0,0 +1,106 @@ +import { vi } from 'vitest'; + +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IDiscoveredFile } from '../../../src/discovery/contracts'; +import type { IGraphData } from '../../../src/graph/contracts'; +import type { + WorkspaceIndexRefreshDependencies, + WorkspaceIndexRefreshSource, +} from '../../../src/indexing/refresh'; + +export function createDiscoveredFile(relativePath: string): IDiscoveredFile { + const name = relativePath.split('/').at(-1) ?? relativePath; + return { + absolutePath: `/workspace/${relativePath}`, + extension: name.includes('.') ? name.slice(name.lastIndexOf('.')) : '', + name, + relativePath, + }; +} + +export function createFileAnalysis(filePath: string): IFileAnalysisResult { + return { + filePath, + relations: [], + }; +} + +export function createGraphNode(id: string) { + return { + color: '#808080', + id, + label: id.split('/').at(-1) ?? id, + }; +} + +export function createSource( + overrides: Partial = {}, +): WorkspaceIndexRefreshSource { + const graph: IGraphData = { + nodes: [{ color: '#808080', id: 'src/app.ts', label: 'app.ts', nodeType: 'file' }], + edges: [], + }; + + return { + _analyzeFiles: vi.fn(async (files: IDiscoveredFile[]) => ({ + cacheHits: 0, + cacheMisses: files.length, + fileAnalysis: new Map(files.map(file => [ + file.relativePath, + createFileAnalysis(file.absolutePath), + ])), + fileConnections: new Map(files.map(file => [file.relativePath, []])), + })), + _buildGraphData: vi.fn((fileConnections: Map) => ({ + nodes: [...fileConnections.keys()].map(createGraphNode), + edges: [], + })), + _buildGraphDataFromAnalysis: vi.fn((fileAnalysis: Map) => ({ + nodes: [...fileAnalysis.keys()].map(createGraphNode), + edges: [], + })), + _lastDiscoveredDirectories: ['src'], + _lastDiscoveredFiles: [ + createDiscoveredFile('README.md'), + createDiscoveredFile('src/plugin.ts'), + createDiscoveredFile('src/plain.txt'), + ], + _lastFileAnalysis: new Map(), + _lastFileConnections: new Map([ + ['README.md', []], + ['src/plugin.ts', []], + ['src/plain.txt', []], + ]) as Map, + _lastGraphData: graph, + _lastWorkspaceRoot: '/workspace', + _preAnalyzePlugins: vi.fn(async () => undefined), + _readAnalysisFiles: vi.fn(async (files: IDiscoveredFile[]) => files.map(file => ({ + absolutePath: file.absolutePath, + relativePath: file.relativePath, + content: '', + }))), + analyze: vi.fn(async () => graph), + invalidateWorkspaceFiles: vi.fn(() => []), + ...overrides, + }; +} + +export function refreshOptions( + overrides: Partial = {}, +): WorkspaceIndexRefreshDependencies { + return { + disabledPlugins: new Set(), + discoveredDirectories: ['src'], + discoveredFiles: [createDiscoveredFile('src/app.ts')], + filePaths: ['/workspace/src/app.ts'], + filterPatterns: [], + notifyFilesChanged: vi.fn(async () => ({ + additionalFilePaths: [], + requiresFullRefresh: false, + })), + persistCache: vi.fn(), + persistIndexMetadata: vi.fn(), + workspaceRoot: '/workspace', + ...overrides, + }; +} diff --git a/packages/core/tests/indexing/refresh/graph.test.ts b/packages/core/tests/indexing/refresh/graph.test.ts new file mode 100644 index 000000000..ffbb434a5 --- /dev/null +++ b/packages/core/tests/indexing/refresh/graph.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { IGraphData, IGraphEdge } from '../../../src/graph/contracts'; +import { buildWorkspaceIndexGraphFromRefreshState } from '../../../src/indexing/refresh/graph'; +import { + createFileAnalysis, + createGraphNode, + createSource, +} from './fixture'; + +describe('indexing/refresh/graph', () => { + it('uses the analysis graph directly when there are no retained file connections', () => { + const analysisGraph: IGraphData = { + nodes: [createGraphNode('src/app.ts')], + edges: [], + }; + const source = createSource({ + _buildGraphData: vi.fn(() => ({ + nodes: [createGraphNode('fallback')], + edges: [], + })), + _buildGraphDataFromAnalysis: vi.fn(() => analysisGraph), + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _lastFileConnections: new Map(), + }); + + expect(buildWorkspaceIndexGraphFromRefreshState( + source, + '/workspace', + new Set(), + )).toBe(analysisGraph); + expect(source._buildGraphData).not.toHaveBeenCalled(); + expect(source._lastGraphData).toBe(analysisGraph); + }); + + it('merges fallback graph data without duplicating nodes or edges', () => { + const duplicateExplicitEdge = createEdge('src/app.ts', 'src/dep.ts', 'import', 'edge:app-dep'); + const duplicateDerivedEdge = createEdge('src/app.ts', 'src/implicit.ts', 'import', undefined); + const uniqueDerivedEdge = createEdge('src/app.ts', 'src/fallback.ts', 'import', undefined); + const uniqueExplicitEdge = createEdge('src/app.ts', 'src/extra.ts', 'call', 'edge:app-extra'); + const analysisGraph: IGraphData = { + nodes: [ + createGraphNode('src/app.ts'), + createGraphNode('src/dep.ts'), + ], + edges: [ + duplicateExplicitEdge, + duplicateDerivedEdge, + ], + }; + const fallbackGraph: IGraphData = { + nodes: [ + createGraphNode('src/app.ts'), + createGraphNode('src/fallback.ts'), + ], + edges: [ + createEdge('src/app.ts', 'src/dep.ts', 'import', 'edge:app-dep'), + createEdge('src/app.ts', 'src/implicit.ts', 'import', undefined), + uniqueDerivedEdge, + uniqueExplicitEdge, + ], + }; + const source = createSource({ + _buildGraphData: vi.fn(() => fallbackGraph), + _buildGraphDataFromAnalysis: vi.fn(() => analysisGraph), + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _lastFileConnections: new Map([ + ['src/app.ts', []], + ['src/fallback.ts', []], + ]), + }); + + expect(buildWorkspaceIndexGraphFromRefreshState( + source, + '/workspace', + new Set(), + )).toEqual({ + nodes: [ + createGraphNode('src/app.ts'), + createGraphNode('src/dep.ts'), + createGraphNode('src/fallback.ts'), + ], + edges: [ + duplicateExplicitEdge, + duplicateDerivedEdge, + uniqueDerivedEdge, + uniqueExplicitEdge, + ], + }); + expect(source._lastGraphData).toEqual({ + nodes: [ + createGraphNode('src/app.ts'), + createGraphNode('src/dep.ts'), + createGraphNode('src/fallback.ts'), + ], + edges: [ + duplicateExplicitEdge, + duplicateDerivedEdge, + uniqueDerivedEdge, + uniqueExplicitEdge, + ], + }); + }); +}); + +function createEdge( + from: string, + to: string, + kind: IGraphEdge['kind'], + id: string | undefined, +): IGraphEdge { + return { + id: id as string, + from, + to, + kind, + sources: [], + }; +} diff --git a/packages/core/tests/indexing/refresh/modes/analysisScope.test.ts b/packages/core/tests/indexing/refresh/modes/analysisScope.test.ts new file mode 100644 index 000000000..3f0602544 --- /dev/null +++ b/packages/core/tests/indexing/refresh/modes/analysisScope.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IDiscoveredFile } from '../../../../src/discovery/contracts'; +import { refreshWorkspaceIndexAnalysisScope } from '../../../../src/indexing/refresh'; +import { + createDiscoveredFile, + createFileAnalysis, + createSource, +} from '../fixture'; + +describe('indexing/refresh/modes/analysisScope', () => { + it('records discovery state and forwards analysis progress as scope progress', async () => { + const onProgress = vi.fn(); + const persistCache = vi.fn(); + const persistIndexMetadata = vi.fn(); + const discoveredFiles = [ + createDiscoveredFile('src/app.ts'), + createDiscoveredFile('src/dep.ts'), + ]; + const source = createSource({ + _analyzeFiles: vi.fn(async (files: IDiscoveredFile[], _workspaceRoot, onFileProgress) => { + onFileProgress?.({ + current: 1, + filePath: '/workspace/src/app.ts', + total: files.length, + }); + return createAnalysisResult(files); + }), + }); + + await refreshWorkspaceIndexAnalysisScope(source, { + disabledPlugins: new Set(), + discoveredDirectories: ['src'], + discoveredFiles, + onProgress, + persistCache, + persistIndexMetadata, + signal: undefined, + workspaceRoot: '/workspace', + }); + + expect(source._lastDiscoveredDirectories).toEqual(['src']); + expect(source._lastDiscoveredFiles).toEqual(discoveredFiles); + expect(source._analyzeFiles).toHaveBeenCalledWith( + discoveredFiles, + '/workspace', + expect.any(Function), + undefined, + undefined, + new Set(), + ); + expect(onProgress).toHaveBeenNthCalledWith(1, { + phase: 'Applying Scope', + current: 0, + total: 2, + }); + expect(onProgress).toHaveBeenNthCalledWith(2, { + phase: 'Applying Scope', + current: 1, + total: 2, + }); + expect(persistCache).toHaveBeenCalledOnce(); + expect(persistIndexMetadata).toHaveBeenCalledOnce(); + }); + + it('does not require a scope progress callback', async () => { + const discoveredFiles = [createDiscoveredFile('src/app.ts')]; + const source = createSource({ + _analyzeFiles: vi.fn(async (files: IDiscoveredFile[], _workspaceRoot, onFileProgress) => { + onFileProgress?.({ + current: 1, + filePath: '/workspace/src/app.ts', + total: files.length, + }); + return createAnalysisResult(files); + }), + }); + + await expect(refreshWorkspaceIndexAnalysisScope(source, { + disabledPlugins: new Set(), + discoveredFiles, + onProgress: undefined, + persistCache: vi.fn(), + persistIndexMetadata: vi.fn(), + signal: undefined, + workspaceRoot: '/workspace', + })).resolves.toBeDefined(); + expect(source._analyzeFiles).toHaveBeenCalledOnce(); + }); +}); + +function createAnalysisResult(files: IDiscoveredFile[]) { + return { + cacheHits: 0, + cacheMisses: files.length, + fileAnalysis: new Map( + files.map(file => [ + file.relativePath, + createFileAnalysis(file.absolutePath), + ]), + ), + fileConnections: new Map(files.map(file => [file.relativePath, []])), + }; +} diff --git a/packages/core/tests/indexing/refresh/modes/changedFiles.test.ts b/packages/core/tests/indexing/refresh/modes/changedFiles.test.ts new file mode 100644 index 000000000..4cd9940db --- /dev/null +++ b/packages/core/tests/indexing/refresh/modes/changedFiles.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IDiscoveredFile } from '../../../../src/discovery/contracts'; +import type { IGraphData } from '../../../../src/graph/contracts'; +import { refreshWorkspaceIndexChangedFiles } from '../../../../src/indexing/refresh'; +import { + createDiscoveredFile, + createFileAnalysis, + createGraphNode, + createSource, + refreshOptions, +} from '../fixture'; + +describe('indexing/refresh/modes/changedFiles', () => { + it('records discovery state, invalidates changed files, and forwards incremental progress', async () => { + const onProgress = vi.fn(); + const discoveredFiles = [ + createDiscoveredFile('src/app.ts'), + createDiscoveredFile('src/generated.ts'), + ]; + const source = createSource({ + _analyzeFiles: vi.fn(async (files: IDiscoveredFile[], _workspaceRoot, onFileProgress) => { + onFileProgress?.({ + current: 1, + filePath: '/workspace/src/app.ts', + total: files.length, + }); + return createAnalysisResult(files.map(file => file.relativePath)); + }), + }); + + await refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + discoveredDirectories: ['src', 'generated'], + discoveredFiles, + notifyFilesChanged: vi.fn(async () => ({ + additionalFilePaths: ['src/generated.ts'], + requiresFullRefresh: false, + })), + onProgress, + })); + + expect(source._lastDiscoveredDirectories).toEqual(['src', 'generated']); + expect(source._lastDiscoveredFiles).toBe(discoveredFiles); + expect(source.invalidateWorkspaceFiles).toHaveBeenCalledWith([ + '/workspace/src/app.ts', + '/workspace/src/generated.ts', + ]); + expect(onProgress).toHaveBeenNthCalledWith(1, { + phase: 'Applying Changes', + current: 0, + total: 2, + }); + expect(onProgress).toHaveBeenNthCalledWith(2, { + phase: 'Applying Changes', + current: 1, + total: 2, + }); + }); + + it('rebuilds from retained analysis without analyzing when no files remain to refresh', async () => { + const graph: IGraphData = { + nodes: [createGraphNode('src/app.ts')], + edges: [], + }; + const persistCache = vi.fn(); + const source = createSource({ + _buildGraphDataFromAnalysis: vi.fn(() => graph), + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _lastFileConnections: new Map([ + ['src/app.ts', []], + ]), + }); + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + discoveredDirectories: undefined, + discoveredFiles: [createDiscoveredFile('src/app.ts')], + filePaths: ['/outside/src/app.ts'], + persistCache, + }))).resolves.toBe(graph); + + expect(source._lastDiscoveredDirectories).toEqual([]); + expect(source._analyzeFiles).not.toHaveBeenCalled(); + expect(source.invalidateWorkspaceFiles).not.toHaveBeenCalled(); + expect(persistCache).not.toHaveBeenCalled(); + }); + + it('does not require an incremental progress callback', async () => { + const source = createSource({ + _analyzeFiles: vi.fn(async (files: IDiscoveredFile[], _workspaceRoot, onFileProgress) => { + onFileProgress?.({ + current: 1, + filePath: '/workspace/src/app.ts', + total: files.length, + }); + return createAnalysisResult(files.map(file => file.relativePath)); + }), + }); + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + onProgress: undefined, + }))).resolves.toBeDefined(); + expect(source._analyzeFiles).toHaveBeenCalledOnce(); + }); + + it('labels fallback full-analysis progress as applying changes when no phase is provided', async () => { + const graph: IGraphData = { nodes: [], edges: [] }; + const onProgress = vi.fn(); + const source = createSource({ + analyze: vi.fn(async (_filterPatterns, _disabledPlugins, _signal, reportProgress) => { + reportProgress?.({ phase: '', current: 1, total: 2 }); + reportProgress?.({ phase: 'Scanning', current: 2, total: 2 }); + return graph; + }), + }); + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + filePaths: ['/workspace/src/deleted.ts'], + onProgress, + }))).resolves.toBe(graph); + + expect(onProgress).toHaveBeenNthCalledWith(1, { + phase: 'Applying Changes', + current: 1, + total: 2, + }); + expect(onProgress).toHaveBeenNthCalledWith(2, { + phase: 'Scanning', + current: 2, + total: 2, + }); + }); + + it('does not require a fallback full-analysis progress callback', async () => { + const graph: IGraphData = { nodes: [], edges: [] }; + const source = createSource({ + analyze: vi.fn(async (_filterPatterns, _disabledPlugins, _signal, reportProgress) => { + reportProgress?.({ phase: '', current: 1, total: 1 }); + return graph; + }), + }); + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + filePaths: ['/workspace/src/deleted.ts'], + onProgress: undefined, + }))).resolves.toBe(graph); + }); + + it('waits for metric-only metadata persistence when it is not deferred', async () => { + const graph: IGraphData = { + nodes: [createGraphNode('src/app.ts')], + edges: [], + }; + const source = createMetricOnlyPatchSource(graph); + let resolvePersistence: () => void = () => undefined; + const persistIndexMetadata = vi.fn(() => new Promise(resolve => { + resolvePersistence = resolve; + })); + + const refreshPromise = refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + persistIndexMetadata, + })); + const onSettled = vi.fn(); + void refreshPromise.then(onSettled); + await flushMicrotasks(); + + expect(onSettled).not.toHaveBeenCalled(); + expect(persistIndexMetadata).toHaveBeenCalledOnce(); + + resolvePersistence(); + await expect(refreshPromise).resolves.toBe(graph); + expect(onSettled).toHaveBeenCalledWith(graph); + }); + + it('reports deferred metric-only metadata persistence errors without blocking graph data', async () => { + const graph: IGraphData = { + nodes: [createGraphNode('src/app.ts')], + edges: [], + }; + const source = createMetricOnlyPatchSource(graph); + const error = new Error('metadata write failed'); + const onDeferredIndexMetadataError = vi.fn(); + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + deferMetricOnlyIndexMetadata: true, + onDeferredIndexMetadataError, + persistIndexMetadata: vi.fn(() => Promise.reject(error)), + }))).resolves.toBe(graph); + await Promise.resolve(); + + expect(onDeferredIndexMetadataError).toHaveBeenCalledWith(error); + }); + + it('does not require a deferred metadata error callback', async () => { + const graph: IGraphData = { + nodes: [createGraphNode('src/app.ts')], + edges: [], + }; + const source = createMetricOnlyPatchSource(graph); + let caughtError: unknown; + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + deferMetricOnlyIndexMetadata: true, + onDeferredIndexMetadataError: undefined, + persistIndexMetadata: vi.fn(() => createCapturedRejection(new Error('metadata write failed'), error => { + caughtError = error; + })), + }))).resolves.toBe(graph); + + expect(caughtError).toBeUndefined(); + }); +}); + +function createAnalysisResult(relativePaths: string[]) { + return { + cacheHits: 0, + cacheMisses: relativePaths.length, + fileAnalysis: new Map( + relativePaths.map(relativePath => [ + relativePath, + createFileAnalysis(`/workspace/${relativePath}`), + ]), + ), + fileConnections: new Map(relativePaths.map(relativePath => [relativePath, []])), + }; +} + +function createMetricOnlyPatchSource(graph: IGraphData) { + return createSource({ + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _lastFileConnections: new Map([ + ['src/app.ts', []], + ]), + _patchGraphDataNodeMetrics: vi.fn(() => graph), + }); +} + +async function flushMicrotasks(): Promise { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve(); + } +} + +function createCapturedRejection( + error: Error, + onCaughtError: (error: unknown) => void, +): Promise { + return { + catch(onRejected?: (error: unknown) => unknown) { + try { + onRejected?.(error); + } catch (caughtError) { + onCaughtError(caughtError); + } + return Promise.resolve(); + }, + } as Promise; +} diff --git a/packages/core/tests/indexing/refresh/modes/pluginFiles.test.ts b/packages/core/tests/indexing/refresh/modes/pluginFiles.test.ts new file mode 100644 index 000000000..60cde24ce --- /dev/null +++ b/packages/core/tests/indexing/refresh/modes/pluginFiles.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IDiscoveredFile } from '../../../../src/discovery/contracts'; +import { refreshWorkspaceIndexPluginFiles } from '../../../../src/indexing/refresh'; +import { + createDiscoveredFile, + createFileAnalysis, + createSource, +} from '../fixture'; + +describe('indexing/refresh/modes/pluginFiles', () => { + it('rebuilds from retained state when no requested plugins are registered', async () => { + const persistIndexMetadata = vi.fn(); + const source = createSource(); + + await refreshWorkspaceIndexPluginFiles(source, { + disabledPlugins: new Set(), + discoveredFiles: [createDiscoveredFile('src/app.ts')], + onProgress: vi.fn(), + persistCache: vi.fn(), + persistIndexMetadata, + pluginIds: ['codegraphy.missing'], + pluginInfos: [createPluginInfo('codegraphy.typescript', ['.ts'])], + signal: undefined, + workspaceRoot: '/workspace', + }); + + expect(source._analyzeFiles).not.toHaveBeenCalled(); + expect(persistIndexMetadata).toHaveBeenCalledOnce(); + }); + + it('skips analysis and progress when registered plugins have no matching files', async () => { + const onProgress = vi.fn(); + const persistCache = vi.fn(); + const source = createSource(); + + await refreshWorkspaceIndexPluginFiles(source, { + disabledPlugins: new Set(), + discoveredFiles: [createDiscoveredFile('src/app.ts')], + onProgress, + persistCache, + persistIndexMetadata: vi.fn(), + pluginIds: ['codegraphy.python'], + pluginInfos: [createPluginInfo('codegraphy.python', ['.py'])], + signal: undefined, + workspaceRoot: '/workspace', + }); + + expect(source._analyzeFiles).not.toHaveBeenCalled(); + expect(onProgress).not.toHaveBeenCalled(); + expect(persistCache).not.toHaveBeenCalled(); + }); + + it('analyzes matching plugin files and forwards plugin progress', async () => { + const onProgress = vi.fn(); + const persistCache = vi.fn(); + const persistIndexMetadata = vi.fn(); + const discoveredFiles = [ + createDiscoveredFile('README.md'), + createDiscoveredFile('src/app.ts'), + ]; + const source = createSource({ + _analyzeFiles: vi.fn(async (files: IDiscoveredFile[], _workspaceRoot, onFileProgress) => { + onFileProgress?.({ + current: 1, + filePath: '/workspace/src/app.ts', + total: files.length, + }); + return createAnalysisResult(files); + }), + }); + + await refreshWorkspaceIndexPluginFiles(source, { + disabledPlugins: new Set(), + discoveredFiles, + onProgress, + persistCache, + persistIndexMetadata, + pluginIds: ['codegraphy.typescript'], + pluginInfos: [createPluginInfo('codegraphy.typescript', ['.ts'])], + signal: undefined, + workspaceRoot: '/workspace', + }); + + expect(source._analyzeFiles).toHaveBeenCalledWith( + [createDiscoveredFile('src/app.ts')], + '/workspace', + expect.any(Function), + undefined, + ['codegraphy.typescript'], + new Set(), + ); + expect(onProgress).toHaveBeenNthCalledWith(1, { + phase: 'Applying Plugin', + current: 0, + total: 1, + }); + expect(onProgress).toHaveBeenNthCalledWith(2, { + phase: 'Applying Plugin', + current: 1, + total: 1, + }); + expect(persistCache).toHaveBeenCalledOnce(); + expect(persistIndexMetadata).toHaveBeenCalledOnce(); + }); + + it('does not require a plugin progress callback', async () => { + const source = createSource({ + _analyzeFiles: vi.fn(async (files: IDiscoveredFile[], _workspaceRoot, onFileProgress) => { + onFileProgress?.({ + current: 1, + filePath: '/workspace/src/app.ts', + total: files.length, + }); + return createAnalysisResult(files); + }), + }); + + await expect(refreshWorkspaceIndexPluginFiles(source, { + disabledPlugins: new Set(), + discoveredFiles: [createDiscoveredFile('src/app.ts')], + onProgress: undefined, + persistCache: vi.fn(), + persistIndexMetadata: vi.fn(), + pluginIds: ['codegraphy.typescript'], + pluginInfos: [createPluginInfo('codegraphy.typescript', ['.ts'])], + signal: undefined, + workspaceRoot: '/workspace', + })).resolves.toBeDefined(); + expect(source._analyzeFiles).toHaveBeenCalledOnce(); + }); +}); + +function createPluginInfo(id: string, supportedExtensions: readonly string[]) { + return { + plugin: { + id, + supportedExtensions, + }, + }; +} + +function createAnalysisResult(files: IDiscoveredFile[]) { + return { + cacheHits: 0, + cacheMisses: files.length, + fileAnalysis: new Map( + files.map(file => [ + file.relativePath, + createFileAnalysis(file.absolutePath), + ]), + ), + fileConnections: new Map(files.map(file => [file.relativePath, []])), + }; +} diff --git a/packages/core/tests/indexing/refresh/snapshot/capture.test.ts b/packages/core/tests/indexing/refresh/snapshot/capture.test.ts new file mode 100644 index 000000000..1f09023c8 --- /dev/null +++ b/packages/core/tests/indexing/refresh/snapshot/capture.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { IWorkspaceFileAnalysisResult } from '../../../../src/analysis/fileAnalysis'; +import type { IProjectedConnection } from '../../../../src/analysis/projectedConnection'; +import { + canPatchWorkspaceIndexRefreshGraphData, + captureWorkspaceIndexRefreshGraphSnapshot, +} from '../../../../src/indexing/refresh/snapshot/capture'; +import { + createDiscoveredFile, + createFileAnalysis, + createSource, +} from '../fixture'; + +describe('indexing/refresh/snapshot/capture', () => { + it('does not capture when metric patching is unavailable', () => { + const source = createSource({ + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + }); + + expect(captureWorkspaceIndexRefreshGraphSnapshot(source, [ + createDiscoveredFile('src/app.ts'), + ])).toBeUndefined(); + }); + + it('does not capture when any requested file is missing previous analysis', () => { + const source = createSource({ + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _patchGraphDataNodeMetrics: vi.fn(), + }); + + expect(captureWorkspaceIndexRefreshGraphSnapshot(source, [ + createDiscoveredFile('src/app.ts'), + createDiscoveredFile('src/missing.ts'), + ])).toBeUndefined(); + }); + + it('cannot patch graph data without a captured snapshot', () => { + expect(canPatchWorkspaceIndexRefreshGraphData( + undefined, + createAnalysisResult(['src/app.ts']), + [createDiscoveredFile('src/app.ts')], + )).toBe(false); + }); + + it('requires updated analysis for every captured file', () => { + const files = [ + createDiscoveredFile('src/app.ts'), + createDiscoveredFile('src/dep.ts'), + ]; + const snapshot = captureWorkspaceIndexRefreshGraphSnapshot( + createSnapshotSource(['src/app.ts', 'src/dep.ts']), + files, + ); + + expect(canPatchWorkspaceIndexRefreshGraphData( + snapshot, + createAnalysisResult(['src/app.ts']), + files, + )).toBe(false); + }); + + it('does not patch graph data when file connections change', () => { + const file = createDiscoveredFile('src/app.ts'); + const snapshot = captureWorkspaceIndexRefreshGraphSnapshot( + createSnapshotSource(['src/app.ts'], new Map([ + ['src/app.ts', [createConnection('./dep', '/workspace/src/dep.ts')]], + ])), + [file], + ); + + expect(canPatchWorkspaceIndexRefreshGraphData( + snapshot, + createAnalysisResult(['src/app.ts'], new Map([ + ['src/app.ts', [createConnection('./next', '/workspace/src/next.ts')]], + ])), + [file], + )).toBe(false); + }); + + it('patches graph data when captured analysis and connections still match', () => { + const file = createDiscoveredFile('src/app.ts'); + const connections = new Map([ + ['src/app.ts', [createConnection('./dep', '/workspace/src/dep.ts')]], + ]); + const snapshot = captureWorkspaceIndexRefreshGraphSnapshot( + createSnapshotSource(['src/app.ts'], connections), + [file], + ); + + expect(canPatchWorkspaceIndexRefreshGraphData( + snapshot, + createAnalysisResult(['src/app.ts'], connections), + [file], + )).toBe(true); + }); +}); + +function createSnapshotSource( + relativePaths: string[], + fileConnections = new Map(), +) { + return createSource({ + _lastFileAnalysis: new Map(relativePaths.map(relativePath => [ + relativePath, + createFileAnalysis(`/workspace/${relativePath}`), + ])), + _lastFileConnections: fileConnections, + _patchGraphDataNodeMetrics: vi.fn(), + }); +} + +function createAnalysisResult( + relativePaths: string[], + fileConnections = new Map(), +): IWorkspaceFileAnalysisResult { + return { + cacheHits: 0, + cacheMisses: relativePaths.length, + fileAnalysis: new Map(relativePaths.map(relativePath => [ + relativePath, + createFileAnalysis(`/workspace/${relativePath}`), + ])), + fileConnections, + }; +} + +function createConnection( + specifier: string, + resolvedPath: string, +): IProjectedConnection { + return { + kind: 'import', + resolvedPath, + sourceId: 'import', + specifier, + }; +} diff --git a/packages/core/tests/indexing/refresh/snapshot/eligibility.test.ts b/packages/core/tests/indexing/refresh/snapshot/eligibility.test.ts new file mode 100644 index 000000000..cb243ff04 --- /dev/null +++ b/packages/core/tests/indexing/refresh/snapshot/eligibility.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { canCaptureWorkspaceIndexRefreshGraphSnapshot } from '../../../../src/indexing/refresh/snapshot/eligibility'; +import { createGraphNode, createSource } from '../fixture'; + +describe('indexing/refresh/snapshot/eligibility', () => { + it('requires the metric patch helper', () => { + expect(canCaptureWorkspaceIndexRefreshGraphSnapshot(createSource())).toBe(false); + }); + + it('does not capture an empty graph', () => { + const source = createSource({ + _lastGraphData: { nodes: [], edges: [] }, + _patchGraphDataNodeMetrics: vi.fn(), + }); + + expect(canCaptureWorkspaceIndexRefreshGraphSnapshot(source)).toBe(false); + }); + + it('can capture a graph with nodes', () => { + const source = createSource({ + _lastGraphData: { nodes: [createGraphNode('src/app.ts')], edges: [] }, + _patchGraphDataNodeMetrics: vi.fn(), + }); + + expect(canCaptureWorkspaceIndexRefreshGraphSnapshot(source)).toBe(true); + }); + + it('can capture an edge-only graph', () => { + const source = createSource({ + _lastGraphData: { + nodes: [], + edges: [{ + id: 'src/app.ts->src/dep.ts#import', + from: 'src/app.ts', + to: 'src/dep.ts', + kind: 'import', + sources: [], + }], + }, + _patchGraphDataNodeMetrics: vi.fn(), + }); + + expect(canCaptureWorkspaceIndexRefreshGraphSnapshot(source)).toBe(true); + }); +}); diff --git a/packages/core/tests/indexing/refresh/snapshot/serialization.test.ts b/packages/core/tests/indexing/refresh/snapshot/serialization.test.ts new file mode 100644 index 000000000..01d219f68 --- /dev/null +++ b/packages/core/tests/indexing/refresh/snapshot/serialization.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IProjectedConnection } from '../../../../src/analysis/projectedConnection'; +import { + serializeWorkspaceIndexConnections, + serializeWorkspaceIndexGraphAnalysis, +} from '../../../../src/indexing/refresh/snapshot/serialization'; + +describe('indexing/refresh/snapshot/serialization', () => { + it('serializes missing analysis collections as empty lists', () => { + expect(serializeWorkspaceIndexGraphAnalysis({ + filePath: '/workspace/src/app.ts', + })).toBe(JSON.stringify({ + edgeTypes: [], + filePath: '/workspace/src/app.ts', + nodeTypes: [], + nodes: [], + relations: [], + symbols: [], + })); + }); + + it('preserves non-empty analysis collections', () => { + const analysis: IFileAnalysisResult = { + filePath: '/workspace/src/app.ts', + edgeTypes: [{ + id: 'import', + label: 'Import', + defaultColor: '#ffffff', + defaultVisible: true, + }], + nodeTypes: [{ + id: 'file', + label: 'File', + defaultColor: '#ffffff', + defaultVisible: true, + }], + nodes: [{ + id: 'src/app.ts#App', + nodeType: 'symbol', + label: 'App', + filePath: '/workspace/src/app.ts', + }], + relations: [{ + kind: 'import', + sourceId: 'import', + fromFilePath: '/workspace/src/app.ts', + toFilePath: '/workspace/src/dep.ts', + }], + symbols: [{ + id: 'src/app.ts#App', + name: 'App', + kind: 'class', + filePath: '/workspace/src/app.ts', + }], + }; + + expect(JSON.parse(serializeWorkspaceIndexGraphAnalysis(analysis))).toEqual(analysis); + }); + + it('serializes missing connections as an empty list', () => { + expect(serializeWorkspaceIndexConnections(undefined)).toBe('[]'); + }); + + it('preserves non-empty connections', () => { + const connections: IProjectedConnection[] = [{ + kind: 'import', + resolvedPath: '/workspace/src/dep.ts', + sourceId: 'import', + specifier: './dep', + }]; + + expect(JSON.parse(serializeWorkspaceIndexConnections(connections))).toEqual(connections); + }); +}); diff --git a/packages/core/tests/indexing/refresh/state.test.ts b/packages/core/tests/indexing/refresh/state.test.ts new file mode 100644 index 000000000..e9c175dd7 --- /dev/null +++ b/packages/core/tests/indexing/refresh/state.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import type { IWorkspaceFileAnalysisResult } from '../../../src/analysis/fileAnalysis'; +import type { IProjectedConnection } from '../../../src/analysis/projectedConnection'; +import { + applyWorkspaceIndexAnalysisResult, + retainWorkspaceIndexDiscoveredFileConnections, + updateWorkspaceIndexDiscoveryState, +} from '../../../src/indexing/refresh/state'; +import { + createDiscoveredFile, + createFileAnalysis, + createSource, +} from './fixture'; + +describe('indexing/refresh/state', () => { + it('applies analysis and connection results to the refresh source', () => { + const source = createSource(); + const connection = createConnection('./dep'); + const analysisResult: IWorkspaceFileAnalysisResult = { + cacheHits: 0, + cacheMisses: 1, + fileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + fileConnections: new Map([ + ['src/app.ts', [connection]], + ]), + }; + + applyWorkspaceIndexAnalysisResult(source, analysisResult); + + expect(source._lastFileAnalysis.get('src/app.ts')).toEqual( + createFileAnalysis('/workspace/src/app.ts'), + ); + expect(source._lastFileConnections.get('src/app.ts')).toEqual([connection]); + }); + + it('updates discovery state and defaults missing directories to an empty list', () => { + const source = createSource(); + const discoveredFiles = [createDiscoveredFile('src/app.ts')]; + + updateWorkspaceIndexDiscoveryState(source, { + discoveredDirectories: undefined, + discoveredFiles, + workspaceRoot: '/workspace-next', + }); + + expect(source._lastDiscoveredDirectories).toEqual([]); + expect(source._lastDiscoveredFiles).toEqual(discoveredFiles); + expect(source._lastDiscoveredFiles).not.toBe(discoveredFiles); + expect(source._lastWorkspaceRoot).toBe('/workspace-next'); + }); + + it('retains existing file connections and initializes missing discovered files', () => { + const source = createSource({ + _lastFileConnections: new Map([ + ['src/app.ts', [createConnection('./dep')]], + ]), + }); + + retainWorkspaceIndexDiscoveredFileConnections(source, [ + createDiscoveredFile('src/app.ts'), + createDiscoveredFile('src/missing.ts'), + ]); + + expect(source._lastFileConnections.get('src/app.ts')).toEqual([ + createConnection('./dep'), + ]); + expect(source._lastFileConnections.get('src/missing.ts')).toEqual([]); + }); +}); + +function createConnection(specifier: string): IProjectedConnection { + return { + kind: 'import', + resolvedPath: `/workspace/src/${specifier.slice(2)}.ts`, + sourceId: 'import', + specifier, + }; +} diff --git a/packages/core/tests/plugins/lifecycle/analysis.test.ts b/packages/core/tests/plugins/lifecycle/analysis.test.ts index 405884eee..bede73c7c 100644 --- a/packages/core/tests/plugins/lifecycle/analysis.test.ts +++ b/packages/core/tests/plugins/lifecycle/analysis.test.ts @@ -48,6 +48,54 @@ describe('plugins/lifecycle analysis notifications', () => { expect(onGraphRebuild).toHaveBeenCalledWith(emptyGraph); }); + it('routes pre-analysis files to matching plugin extensions', async () => { + const onTypeScriptPreAnalyze = vi.fn(); + const onMarkdownPreAnalyze = vi.fn(); + const onWildcardPreAnalyze = vi.fn(); + const plugins = new Map([ + ['ts', pluginInfo({ + id: 'ts', + name: 'TypeScript', + version: '1.0.0', + apiVersion: '2', + supportedExtensions: ['.ts'], + onPreAnalyze: onTypeScriptPreAnalyze, + })], + ['markdown', pluginInfo({ + id: 'markdown', + name: 'Markdown', + version: '1.0.0', + apiVersion: '2', + supportedExtensions: ['.md'], + onPreAnalyze: onMarkdownPreAnalyze, + })], + ['wildcard', pluginInfo({ + id: 'wildcard', + name: 'Wildcard', + version: '1.0.0', + apiVersion: '2', + supportedExtensions: ['*'], + onPreAnalyze: onWildcardPreAnalyze, + })], + ]); + const typeScriptFile = { + absolutePath: '/workspace/src/app.ts', + relativePath: 'src/app.ts', + content: 'content', + }; + const markdownFile = { + absolutePath: '/workspace/README.md', + relativePath: 'README.md', + content: '# docs', + }; + + await notifyPreAnalyze(plugins, [typeScriptFile, markdownFile], '/workspace'); + + expect(onTypeScriptPreAnalyze).toHaveBeenCalledWith([typeScriptFile], '/workspace', expect.any(Object)); + expect(onMarkdownPreAnalyze).toHaveBeenCalledWith([markdownFile], '/workspace', expect.any(Object)); + expect(onWildcardPreAnalyze).toHaveBeenCalledWith([typeScriptFile, markdownFile], '/workspace', expect.any(Object)); + }); + it('logs lifecycle hook errors without stopping later plugins', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); const afterRebuild = vi.fn(); diff --git a/packages/core/tests/treeSitter/languages/bindings.test.ts b/packages/core/tests/treeSitter/languages/bindings.test.ts index 9d44bbdd7..cd2690d86 100644 --- a/packages/core/tests/treeSitter/languages/bindings.test.ts +++ b/packages/core/tests/treeSitter/languages/bindings.test.ts @@ -108,6 +108,33 @@ describe('pipeline/plugins/treesitter/runtime/languages/load', () => { expect(second).toBe(first); }); + it('loads and caches only the requested language binding', async () => { + class FakeParser {} + const typeScript = { name: 'typescript' }; + + vi.doMock('tree-sitter', () => ({ default: FakeParser })); + vi.doMock('tree-sitter-javascript', () => { + throw new Error('should not load javascript grammar'); + }); + vi.doMock('tree-sitter-typescript', () => ({ + default: { + tsx: { name: 'tsx' }, + typescript: typeScript, + }, + })); + + const { loadTreeSitterLanguageBinding } = await import(MODULE_PATH); + + const first = await loadTreeSitterLanguageBinding('typeScript'); + const second = await loadTreeSitterLanguageBinding('typeScript'); + + expect(first).toEqual({ + ParserCtor: FakeParser, + language: typeScript, + }); + expect(second).toBe(first); + }); + it('logs unavailable bindings once and returns null on repeated failures', async () => { const warning = new Error('missing native module'); const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/packages/core/tests/treeSitter/languages/parser.test.ts b/packages/core/tests/treeSitter/languages/parser.test.ts index 72830d8a4..60e496dc7 100644 --- a/packages/core/tests/treeSitter/languages/parser.test.ts +++ b/packages/core/tests/treeSitter/languages/parser.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { loadTreeSitterBindings } = vi.hoisted(() => ({ - loadTreeSitterBindings: vi.fn(), +const { loadTreeSitterLanguageBinding } = vi.hoisted(() => ({ + loadTreeSitterLanguageBinding: vi.fn(), })); vi.mock( '../../../src/treeSitter/runtime/languages/load', () => ({ - loadTreeSitterBindings, + loadTreeSitterLanguageBinding, }), ); @@ -26,9 +26,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns a configured parser for supported files when bindings are available', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - typeScript: { id: 'typescript' }, + language: { id: 'typescript' }, }); const parser = await createTreeSitterParser('/workspace/src/app.ts'); @@ -39,15 +39,21 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for C and C++ files', async () => { - loadTreeSitterBindings.mockResolvedValue({ - ParserCtor: MockParser, - cLanguage: { id: 'c' }, - cpp: { id: 'cpp' }, - }); + loadTreeSitterLanguageBinding + .mockResolvedValueOnce({ + ParserCtor: MockParser, + language: { id: 'c' }, + }) + .mockResolvedValueOnce({ + ParserCtor: MockParser, + language: { id: 'cpp' }, + }); const cRuntime = await createTreeSitterRuntime('/workspace/src/main.c'); const cppRuntime = await createTreeSitterRuntime('/workspace/src/main.cpp'); + expect(loadTreeSitterLanguageBinding).toHaveBeenNthCalledWith(1, 'cLanguage'); + expect(loadTreeSitterLanguageBinding).toHaveBeenNthCalledWith(2, 'cpp'); expect(cRuntime?.languageKind).toBe('c'); expect((cRuntime?.parser as unknown as MockParser).setLanguage).toHaveBeenCalledWith({ id: 'c' }); expect(cppRuntime?.languageKind).toBe('cpp'); @@ -55,9 +61,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for Kotlin files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - kotlin: { id: 'kotlin' }, + language: { id: 'kotlin' }, }); const kotlinRuntime = await createTreeSitterRuntime('/workspace/src/App.kt'); @@ -74,9 +80,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for PHP files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - php: { id: 'php' }, + language: { id: 'php' }, }); const phpRuntime = await createTreeSitterRuntime('/workspace/src/App.php'); @@ -88,9 +94,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for Dart files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - dart: { id: 'dart' }, + language: { id: 'dart' }, }); const dartRuntime = await createTreeSitterRuntime('/workspace/lib/app/runner.dart'); @@ -102,9 +108,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for Ruby files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - ruby: { id: 'ruby' }, + language: { id: 'ruby' }, }); const rubyRuntime = await createTreeSitterRuntime('/workspace/lib/app/runner.rb'); @@ -116,9 +122,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for Haskell files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - haskell: { id: 'haskell' }, + language: { id: 'haskell' }, }); const haskellRuntime = await createTreeSitterRuntime('/workspace/src/App.hs'); @@ -135,9 +141,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for Lua files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - lua: { id: 'lua' }, + language: { id: 'lua' }, }); const luaRuntime = await createTreeSitterRuntime('/workspace/src/app.lua'); @@ -149,9 +155,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for Swift files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - swift: { id: 'swift' }, + language: { id: 'swift' }, }); const swiftRuntime = await createTreeSitterRuntime('/workspace/Sources/App/Runner.swift'); @@ -163,9 +169,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns a runtime with the parser and language kind for supported files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - javaScript: { id: 'javascript' }, + language: { id: 'javascript' }, }); const runtime = await createTreeSitterRuntime('/workspace/src/app.js'); @@ -179,7 +185,7 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns null for supported files when bindings are unavailable', async () => { - loadTreeSitterBindings.mockResolvedValue(null); + loadTreeSitterLanguageBinding.mockResolvedValue(null); await expect(createTreeSitterParser('/workspace/src/app.ts')).resolves.toBeNull(); await expect(createTreeSitterRuntime('/workspace/src/app.ts')).resolves.toBeNull(); @@ -189,6 +195,6 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { await expect(createTreeSitterParser('/workspace/README.md')).resolves.toBeNull(); await expect(createTreeSitterRuntime('/workspace/README.md')).resolves.toBeNull(); - expect(loadTreeSitterBindings).not.toHaveBeenCalled(); + expect(loadTreeSitterLanguageBinding).not.toHaveBeenCalled(); }); }); diff --git a/packages/core/tests/visibleGraph/collapse.test.ts b/packages/core/tests/visibleGraph/collapse.test.ts index 329b743a1..18652d1b0 100644 --- a/packages/core/tests/visibleGraph/collapse.test.ts +++ b/packages/core/tests/visibleGraph/collapse.test.ts @@ -119,6 +119,9 @@ describe('visibleGraph collapse and filtering', () => { expect(applyFilterPatterns(graphData, { patterns: ['import'] }).edges).toEqual([ edge('src/b.ts', 'src/a.ts', 'reference'), ]); + expect(applyFilterPatterns(graphData, { patterns: ['src/*->src/b.ts#import'] }).edges).toEqual([ + edge('src/b.ts', 'src/a.ts', 'reference'), + ]); }); it('hides descendants of collapsed folders and projects external edges onto the visible folder', () => { diff --git a/packages/core/tests/workspace/coreBacked.test.ts b/packages/core/tests/workspace/coreBacked.test.ts index db391030e..03d36762a 100644 --- a/packages/core/tests/workspace/coreBacked.test.ts +++ b/packages/core/tests/workspace/coreBacked.test.ts @@ -77,6 +77,35 @@ describe('core-backed CodeGraphy Workspace commands', () => { }), }), ])); + expect(diagnostics.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + area: 'indexing', + event: 'phase-completed', + context: expect.objectContaining({ + phase: 'discover-files', + durationMs: expect.any(Number), + files: 2, + }), + }), + expect.objectContaining({ + area: 'indexing', + event: 'phase-completed', + context: expect.objectContaining({ + phase: 'analyze-files', + durationMs: expect.any(Number), + files: 2, + }), + }), + expect.objectContaining({ + area: 'indexing', + event: 'phase-completed', + context: expect.objectContaining({ + phase: 'build-graph', + durationMs: expect.any(Number), + nodes: 2, + }), + }), + ])); }); it('emits factual verbose diagnostics for status requests', async () => { diff --git a/packages/core/tests/workspace/status.test.ts b/packages/core/tests/workspace/status.test.ts index 75deb1e75..77daad1ae 100644 --- a/packages/core/tests/workspace/status.test.ts +++ b/packages/core/tests/workspace/status.test.ts @@ -9,6 +9,7 @@ import { readCodeGraphyWorkspaceMeta, readCodeGraphyWorkspaceSettings, readCodeGraphyWorkspaceStatus, + writeCodeGraphyWorkspaceMeta, writeCodeGraphyWorkspaceSettings, } from '../../src'; @@ -93,4 +94,51 @@ describe('CodeGraphy Workspace status', () => { staleReasons: ['plugin-signature-changed'], }); }); + + it('does not mark the Graph Cache stale for generated pending refresh paths', async () => { + const workspaceRoot = await createWorkspace(); + await indexCodeGraphyWorkspace({ + workspaceRoot, + includeCorePlugins: false, + plugins: [textPlugin], + }); + const meta = readCodeGraphyWorkspaceMeta(workspaceRoot); + writeCodeGraphyWorkspaceMeta(workspaceRoot, { + ...meta, + pendingChangedFiles: [ + path.join(workspaceRoot, 'packages/plugin-typescript/.turbo'), + path.join(workspaceRoot, '.worktrees/speed-up-codegraphy/packages/core/src/index.ts'), + ], + }); + + expect(readCodeGraphyWorkspaceStatus(workspaceRoot, { + plugins: [textPlugin], + })).toMatchObject({ + state: 'fresh', + staleReasons: [], + }); + }); + + it('does not mark the Graph Cache stale for pending files already covered by the last index', async () => { + const workspaceRoot = await createWorkspace(); + await indexCodeGraphyWorkspace({ + workspaceRoot, + includeCorePlugins: false, + plugins: [textPlugin], + }); + const meta = readCodeGraphyWorkspaceMeta(workspaceRoot); + writeCodeGraphyWorkspaceMeta(workspaceRoot, { + ...meta, + pendingChangedFiles: [ + path.join(workspaceRoot, 'source.txt'), + ], + }); + + expect(readCodeGraphyWorkspaceStatus(workspaceRoot, { + plugins: [textPlugin], + })).toMatchObject({ + state: 'fresh', + staleReasons: [], + }); + }); }); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index d09dd6fcc..fe2e68433 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,6 +1,28 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; +const explicitTestIncludes = process.env.QUALITY_TOOLS_VITEST_INCLUDE_JSON + ?? process.env.CODEGRAPHY_VITEST_INCLUDE_JSON; + +function packageRelativeInclude(includePattern: string): string { + const negated = includePattern.startsWith('!'); + const pattern = negated ? includePattern.slice(1) : includePattern; + const packagePrefix = 'packages/core/'; + const relativePattern = pattern.startsWith(packagePrefix) + ? pattern.slice(packagePrefix.length) + : pattern; + + return negated ? `!${relativePattern}` : relativePattern; +} + +function resolveTestIncludes(): string[] { + if (!explicitTestIncludes) { + return ['tests/**/*.test.ts']; + } + + return (JSON.parse(explicitTestIncludes) as string[]).map(packageRelativeInclude); +} + export default defineConfig({ resolve: { alias: { @@ -10,7 +32,7 @@ export default defineConfig({ }, test: { environment: 'node', - include: ['tests/**/*.test.ts'], + include: resolveTestIncludes(), coverage: { provider: 'istanbul', reporter: ['text', 'html', 'json'], diff --git a/packages/extension/src/extension/commands/navigation.ts b/packages/extension/src/extension/commands/navigation.ts index d5442d293..ccfc8a69a 100644 --- a/packages/extension/src/extension/commands/navigation.ts +++ b/packages/extension/src/extension/commands/navigation.ts @@ -9,7 +9,12 @@ import type { CommandDefinition } from './definitions'; export function getNavCommands(provider: GraphViewProvider): CommandDefinition[] { return [ - { id: 'codegraphy.open', handler: () => { vscode.commands.executeCommand('workbench.view.extension.codegraphy'); } }, + { + id: 'codegraphy.open', + handler: () => { + void vscode.commands.executeCommand('workbench.view.extension.codegraphy'); + }, + }, { id: 'codegraphy.openInEditor', handler: () => { provider.openInEditor(); } }, { id: 'codegraphy.fitView', handler: () => { provider.sendCommand('FIT_VIEW'); } }, { id: 'codegraphy.zoomIn', handler: () => { provider.sendCommand('ZOOM_IN'); } }, diff --git a/packages/extension/src/extension/graphView/analysis/execution.ts b/packages/extension/src/extension/graphView/analysis/execution.ts index 284191629..207dc016e 100644 --- a/packages/extension/src/extension/graphView/analysis/execution.ts +++ b/packages/extension/src/extension/graphView/analysis/execution.ts @@ -1,4 +1,5 @@ import type { IGraphData } from '../../../shared/graph/contracts'; +import type { IGraphNodeMetricsUpdate } from '../../../shared/protocol/extensionToWebview'; import type { DiagnosticEventInput } from '@codegraphy-dev/core'; import { publishAnalysisFailure } from './execution/publish'; import { prepareGraphViewAnalysis } from './execution/prepare'; @@ -8,6 +9,11 @@ import type { CodeGraphyIndexFreshness } from '../../repoSettings/freshness'; export type GraphViewAnalysisMode = 'analyze' | 'load' | 'index' | 'refresh' | 'incremental'; export type GraphViewIndexingProgress = { phase: string; current: number; total: number }; +export interface GraphViewCachedGraphLoadOptions { + includeCurrentGitignoreMetadata?: boolean; + warmAnalysis?: boolean; +} + interface GraphViewAnalyzerLike { initialize(): Promise; hasIndex(): boolean; @@ -24,6 +30,7 @@ interface GraphViewAnalyzerLike { filterPatterns?: string[], disabledPlugins?: Set, signal?: AbortSignal, + options?: GraphViewCachedGraphLoadOptions, ): Promise; analyze( filterPatterns?: string[], @@ -68,8 +75,10 @@ export interface GraphViewAnalysisExecutionHandlers { hasWorkspace(): boolean; setRawGraphData(graphData: IGraphData): void; setGraphData(graphData: IGraphData): void; + getRawGraphData?(): IGraphData; getGraphData(): IGraphData; sendGraphDataUpdated(graphData: IGraphData): void; + sendGraphNodeMetricsUpdated?(updates: IGraphNodeMetricsUpdate[]): void; sendDepthState(): void; computeMergedGroups(): void; sendGroupsUpdated(): void; diff --git a/packages/extension/src/extension/graphView/analysis/execution/load.ts b/packages/extension/src/extension/graphView/analysis/execution/load.ts index 742311eda..525c5f08c 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/load.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/load.ts @@ -17,13 +17,55 @@ import { discoverGraphViewRawData, loadCachedGraphViewRawData, } from './load/analyzerData'; -import { getGraphIndexFreshness } from './load/freshness'; -import { selectGraphViewRawDataLoadDecision } from './load/routing'; +import { + hasReplayableGraphData, + selectGraphViewRawDataLoadDecisionForState, + type GraphViewRawDataLoadContext, + type GraphViewRawDataRoute, +} from './load/context'; + +async function loadDiscoveredGraphViewRawData(context: GraphViewRawDataLoadContext): Promise { + return discoverGraphViewRawData(context.signal, context.state, context.analyzer); +} + +async function loadCachedOrRefreshedGraphViewRawData(context: GraphViewRawDataLoadContext): Promise { + const cachedGraphData = await loadCachedGraphViewRawData(context.signal, context.state, context.analyzer, { + includeCurrentGitignoreMetadata: context.indexFreshness !== 'stale', + ...(context.indexFreshness === 'stale' ? { warmAnalysis: false } : {}), + }); + + return hasReplayableGraphData(cachedGraphData) + ? cachedGraphData + : refreshGraphViewRawData(context.signal, context.state, context.forwardProgress); +} -function hasReplayableGraphData(graphData: IGraphData): boolean { - return graphData.nodes.length > 0 || graphData.edges.length > 0; +async function loadRefreshedGraphViewRawData(context: GraphViewRawDataLoadContext): Promise { + return refreshGraphViewRawData(context.signal, context.state, context.forwardProgress); } +async function loadIncrementalGraphViewRawData(context: GraphViewRawDataLoadContext): Promise { + return refreshIncrementalGraphViewRawData(context.signal, context.state, context.forwardProgress); +} + +async function loadAnalyzedGraphViewRawData(context: GraphViewRawDataLoadContext): Promise { + return analyzeGraphViewRawData( + context.signal, + context.state, + context.analyzer, + context.forwardProgress, + ); +} + +const GRAPH_VIEW_RAW_DATA_LOADERS: Record Promise> = { + analyze: loadAnalyzedGraphViewRawData, + cached: loadCachedOrRefreshedGraphViewRawData, + discover: loadDiscoveredGraphViewRawData, + incremental: loadIncrementalGraphViewRawData, + refresh: loadRefreshedGraphViewRawData, +}; + export async function loadGraphViewRawData( signal: AbortSignal, state: GraphViewAnalysisExecutionState, @@ -34,22 +76,18 @@ export async function loadGraphViewRawData( return { rawGraphData: EMPTY_GRAPH_DATA, shouldDiscover: false }; } - const indexFreshness = getGraphIndexFreshness(analyzer); - const decision = selectGraphViewRawDataLoadDecision( - state.mode, - indexFreshness, - typeof analyzer.loadCachedGraph === 'function', - ); + const { decision, indexFreshness } = selectGraphViewRawDataLoadDecisionForState(state, analyzer); + const diagnosticIndexFreshness = indexFreshness ?? 'skipped'; handlers.emitDiagnostic?.({ area: 'extension.analysis', event: 'load-decision', context: { - mode: state.mode, - route: decision.route, - shouldDiscover: decision.shouldDiscover, - indexFreshness, - canReplayCache: typeof analyzer.loadCachedGraph === 'function', - }, + mode: state.mode, + route: decision.route, + shouldDiscover: decision.shouldDiscover, + indexFreshness: diagnosticIndexFreshness, + canReplayCache: typeof analyzer.loadCachedGraph === 'function', + }, }); const forwardProgress = createGraphViewAnalysisProgressForwarder(state.mode, handlers); @@ -57,44 +95,15 @@ export async function loadGraphViewRawData( sendInitialGraphViewAnalysisProgress(state.mode, handlers); } - if (decision.route === 'discover') { - return { - rawGraphData: await discoverGraphViewRawData(signal, state, analyzer), - shouldDiscover: decision.shouldDiscover, - }; - } - - if (decision.route === 'cached') { - const cachedGraphData = await loadCachedGraphViewRawData(signal, state, analyzer); - if (hasReplayableGraphData(cachedGraphData)) { - return { - rawGraphData: cachedGraphData, - shouldDiscover: decision.shouldDiscover, - }; - } - - return { - rawGraphData: await refreshGraphViewRawData(signal, state, forwardProgress), - shouldDiscover: decision.shouldDiscover, - }; - } - - if (decision.route === 'refresh') { - return { - rawGraphData: await refreshGraphViewRawData(signal, state, forwardProgress), - shouldDiscover: decision.shouldDiscover, - }; - } - - if (decision.route === 'incremental') { - return { - rawGraphData: await refreshIncrementalGraphViewRawData(signal, state, forwardProgress), - shouldDiscover: decision.shouldDiscover, - }; - } - + const rawGraphData = await GRAPH_VIEW_RAW_DATA_LOADERS[decision.route]({ + analyzer, + forwardProgress, + indexFreshness, + signal, + state, + }); return { - rawGraphData: await analyzeGraphViewRawData(signal, state, analyzer, forwardProgress), + rawGraphData, shouldDiscover: decision.shouldDiscover, }; } diff --git a/packages/extension/src/extension/graphView/analysis/execution/load/analyzerData.ts b/packages/extension/src/extension/graphView/analysis/execution/load/analyzerData.ts index 136d7203e..1c7ff55fd 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/load/analyzerData.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/load/analyzerData.ts @@ -1,5 +1,6 @@ import type { IGraphData } from '../../../../../shared/graph/contracts'; import type { + GraphViewCachedGraphLoadOptions, GraphViewAnalysisExecutionState, GraphViewIndexingProgress, } from '../../execution'; @@ -37,10 +38,12 @@ export async function loadCachedGraphViewRawData( signal: AbortSignal, state: GraphViewAnalysisExecutionState, analyzer: GraphViewAnalyzer, + options?: GraphViewCachedGraphLoadOptions, ): Promise { return (await analyzer.loadCachedGraph?.( state.filterPatterns, state.disabledPlugins, signal, + options, )) ?? EMPTY_GRAPH_DATA; } diff --git a/packages/extension/src/extension/graphView/analysis/execution/load/context.ts b/packages/extension/src/extension/graphView/analysis/execution/load/context.ts new file mode 100644 index 000000000..5eba3d7c3 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/load/context.ts @@ -0,0 +1,51 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import type { CodeGraphyIndexFreshness } from '../../../../repoSettings/freshness'; +import type { + GraphViewIndexingProgress, + GraphViewAnalysisExecutionState, +} from '../../execution'; +import { getGraphIndexFreshness } from './freshness'; +import { + selectGraphViewRawDataLoadDecision, + type GraphViewRawDataLoadDecision, +} from './routing'; + +export type GraphViewRawDataRoute = GraphViewRawDataLoadDecision['route']; +export type GraphViewAnalyzer = NonNullable; + +export interface GraphViewRawDataLoadContext { + analyzer: GraphViewAnalyzer; + forwardProgress: (progress: GraphViewIndexingProgress) => void; + indexFreshness: CodeGraphyIndexFreshness | undefined; + signal: AbortSignal; + state: GraphViewAnalysisExecutionState; +} + +export function hasReplayableGraphData(graphData: IGraphData): boolean { + return graphData.nodes.length > 0 || graphData.edges.length > 0; +} + +export function selectGraphViewRawDataLoadDecisionForState( + state: GraphViewAnalysisExecutionState, + analyzer: GraphViewAnalyzer, +): { + decision: GraphViewRawDataLoadDecision; + indexFreshness: CodeGraphyIndexFreshness | undefined; +} { + if (state.mode === 'incremental') { + return { + decision: { route: 'incremental', shouldDiscover: false }, + indexFreshness: undefined, + }; + } + + const indexFreshness = getGraphIndexFreshness(analyzer); + return { + decision: selectGraphViewRawDataLoadDecision( + state.mode, + indexFreshness, + typeof analyzer.loadCachedGraph === 'function', + ), + indexFreshness, + }; +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/prepare.ts b/packages/extension/src/extension/graphView/analysis/execution/prepare.ts index e63afcf9d..50a520ff5 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/prepare.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/prepare.ts @@ -22,6 +22,10 @@ function prepareAnalysisGroups( return true; } +function shouldPrepareAnalysisGroups(state: GraphViewAnalysisExecutionState): boolean { + return state.mode !== 'incremental'; +} + export async function prepareGraphViewAnalysis( signal: AbortSignal, requestId: number, @@ -45,7 +49,7 @@ export async function prepareGraphViewAnalysis( return false; } - if (!prepareAnalysisGroups(signal, requestId, handlers)) { + if (shouldPrepareAnalysisGroups(state) && !prepareAnalysisGroups(signal, requestId, handlers)) { return false; } diff --git a/packages/extension/src/extension/graphView/analysis/execution/progress.ts b/packages/extension/src/extension/graphView/analysis/execution/progress.ts index d6641e6cd..82ffcd228 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/progress.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/progress.ts @@ -3,6 +3,10 @@ import type { GraphViewAnalysisMode, GraphViewIndexingProgress, } from '../execution'; +import { createGraphViewIndexProgressCoalescer } from './progress/coalescer'; +import { supportsInitialProgress } from './progress/modes'; + +export { createGraphViewIndexProgressCoalescer } from './progress/coalescer'; const ANALYSIS_PHASE_BY_MODE: Record = { analyze: 'Indexing Workspace', @@ -11,19 +15,17 @@ const ANALYSIS_PHASE_BY_MODE: Record = { refresh: 'Refreshing Index', incremental: 'Applying Changes', }; - -function supportsInitialProgress(mode: GraphViewAnalysisMode): boolean { - return mode === 'index' || mode === 'refresh' || mode === 'incremental'; -} - export function createGraphViewAnalysisProgressForwarder( mode: GraphViewAnalysisMode, handlers: GraphViewAnalysisExecutionHandlers, ): (progress: GraphViewIndexingProgress) => void { const phase = ANALYSIS_PHASE_BY_MODE[mode]; + const sendProgress = createGraphViewIndexProgressCoalescer((progress) => { + handlers.sendIndexProgress?.(progress); + }); return (progress) => { - handlers.sendIndexProgress?.({ + sendProgress({ ...progress, phase: progress.phase || phase, }); diff --git a/packages/extension/src/extension/graphView/analysis/execution/progress/coalescer.ts b/packages/extension/src/extension/graphView/analysis/execution/progress/coalescer.ts new file mode 100644 index 000000000..c4cf9db14 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/progress/coalescer.ts @@ -0,0 +1,36 @@ +import type { GraphViewIndexingProgress } from '../../execution'; + +const MAX_PROGRESS_BUCKETS_PER_PHASE = 20; + +export function createGraphViewIndexProgressCoalescer( + sendProgress: (progress: TProgress) => void, +): (progress: TProgress) => void { + let lastPhase: string | undefined; + let lastTotal: number | undefined; + let lastBucket: number | undefined; + + return (progress) => { + const bucket = getGraphViewIndexProgressBucket(progress); + if ( + progress.phase === lastPhase + && progress.total === lastTotal + && bucket === lastBucket + ) { + return; + } + + lastPhase = progress.phase; + lastTotal = progress.total; + lastBucket = bucket; + sendProgress(progress); + }; +} + +function getGraphViewIndexProgressBucket(progress: GraphViewIndexingProgress): number { + if (progress.total <= MAX_PROGRESS_BUCKETS_PER_PHASE) { + return progress.current; + } + + const clampedCurrent = Math.max(0, Math.min(progress.current, progress.total)); + return Math.floor((clampedCurrent * MAX_PROGRESS_BUCKETS_PER_PHASE) / progress.total); +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/progress/modes.ts b/packages/extension/src/extension/graphView/analysis/execution/progress/modes.ts new file mode 100644 index 000000000..71adfb7b9 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/progress/modes.ts @@ -0,0 +1,5 @@ +import type { GraphViewAnalysisMode } from '../../execution'; + +export function supportsInitialProgress(mode: GraphViewAnalysisMode): boolean { + return mode === 'index' || mode === 'refresh' || mode === 'incremental'; +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish.ts b/packages/extension/src/extension/graphView/analysis/execution/publish.ts index b99da44d3..f872babd8 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish.ts @@ -3,33 +3,19 @@ import type { GraphViewAnalysisExecutionHandlers, GraphViewAnalysisExecutionState, } from '../execution'; -import type { CodeGraphyIndexFreshness } from '../../../repoSettings/freshness'; +import { + publishGraphDataMessage, + publishRawGraphUpdate, + publishStaticGraphMessages, +} from './publish/messages'; +import { createGraphPublicationPlan } from './publish/plan'; +import { + resolveGraphIndexStatus, + shouldReportGraphViewUpdateProgress, +} from './publish/status'; export const EMPTY_GRAPH_DATA: IGraphData = { nodes: [], edges: [] }; -function resolveGraphIndexStatus( - state: GraphViewAnalysisExecutionState | undefined, - hasIndex: boolean, -): { freshness: CodeGraphyIndexFreshness; detail: string } { - const status = state?.analyzer?.getIndexStatus?.(); - if (status) { - return status; - } - - return { - freshness: hasIndex ? 'fresh' : 'missing', - detail: hasIndex - ? 'CodeGraphy index is fresh.' - : 'CodeGraphy index is missing. Index the workspace to build the graph.', - }; -} - -function shouldReportGraphViewUpdateProgress( - state: GraphViewAnalysisExecutionState, -): boolean { - return state.mode === 'index' || state.mode === 'refresh' || state.mode === 'incremental'; -} - export function publishEmptyGraph( handlers: GraphViewAnalysisExecutionHandlers, hasIndex: boolean = false, @@ -51,6 +37,7 @@ export function publishAnalyzedGraph( ): void { const actualHasIndex = state.analyzer?.hasIndex() ?? hasIndex; const status = resolveGraphIndexStatus(state, actualHasIndex); + if (shouldReportGraphViewUpdateProgress(state)) { handlers.sendIndexProgress?.({ phase: 'Updating Graph View', @@ -58,22 +45,22 @@ export function publishAnalyzedGraph( total: 1, }); } - handlers.setRawGraphData(rawGraphData); - handlers.updateViewContext(); - handlers.applyViewTransform(); - handlers.computeMergedGroups(); - handlers.sendGroupsUpdated(); - handlers.sendDepthState(); - handlers.sendPluginStatuses(); - handlers.sendDecorations(); - handlers.sendContextMenuItems(); - handlers.sendPluginExporters?.(); - handlers.sendPluginToolbarActions?.(); - handlers.sendGraphViewContributionStatuses?.(); - handlers.sendPluginWebviewInjections?.(); + + const plan = createGraphPublicationPlan( + state, + handlers, + rawGraphData, + actualHasIndex, + status.freshness, + ); + publishRawGraphUpdate(state, handlers, rawGraphData, plan); const graphData = handlers.getGraphData(); - handlers.sendGraphDataUpdated(graphData); + if (!plan.shouldSendMetricPatch) { + publishStaticGraphMessages(handlers); + } + publishGraphDataMessage(handlers, graphData, plan); + handlers.sendGraphIndexStatusUpdated(actualHasIndex, status.freshness, status.detail); state.analyzer?.registry.notifyPostAnalyze(graphData, state.disabledPlugins); handlers.markWorkspaceReady(graphData, state.disabledPlugins); diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/equality/collections.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/collections.ts new file mode 100644 index 000000000..6c42cc9e6 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/collections.ts @@ -0,0 +1,53 @@ +type CompareGraphValue = (left: unknown, right: unknown) => boolean; + +function areGraphRecordsEqual( + left: Record, + right: Record, + compareValue: CompareGraphValue, +): boolean { + const leftRecord = left as unknown as Record; + const rightRecord = right as unknown as Record; + const keys = new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)]); + for (const key of keys) { + if (!compareValue(leftRecord[key], rightRecord[key])) { + return false; + } + } + + return true; +} + +function areGraphArraysEqual( + left: readonly unknown[], + right: readonly unknown[], + compareValue: CompareGraphValue, +): boolean { + return left.length === right.length + && left.every((leftValue, index) => compareValue(leftValue, right[index])); +} + +function isGraphRecord(value: unknown): value is Record { + return value !== null + && typeof value === 'object' + && !Array.isArray(value); +} + +export function compareGraphArrayValues( + left: unknown, + right: unknown, + compareValue: CompareGraphValue, +): boolean | undefined { + if (!Array.isArray(left) && !Array.isArray(right)) { + return undefined; + } + + return Array.isArray(left) && Array.isArray(right) && areGraphArraysEqual(left, right, compareValue); +} + +export function compareGraphRecordValues( + left: unknown, + right: unknown, + compareValue: CompareGraphValue, +): boolean { + return isGraphRecord(left) && isGraphRecord(right) && areGraphRecordsEqual(left, right, compareValue); +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/equality/data.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/data.ts new file mode 100644 index 000000000..4607180fd --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/data.ts @@ -0,0 +1,35 @@ +import type { IGraphData } from '../../../../../../shared/graph/contracts'; +import { areNodesEqualIgnoringMetrics } from './node'; +import { areGraphValuesEqual } from './values'; + +export function areGraphDataEqualIgnoringNodeMetrics( + currentRawGraphData: IGraphData, + nextRawGraphData: IGraphData, +): boolean { + if ( + currentRawGraphData.nodes.length !== nextRawGraphData.nodes.length + || currentRawGraphData.edges.length !== nextRawGraphData.edges.length + ) { + return false; + } + + for (let index = 0; index < currentRawGraphData.nodes.length; index += 1) { + if (!areNodesEqualIgnoringMetrics( + currentRawGraphData.nodes[index], + nextRawGraphData.nodes[index], + )) { + return false; + } + } + + for (let index = 0; index < currentRawGraphData.edges.length; index += 1) { + if (!areGraphValuesEqual( + currentRawGraphData.edges[index], + nextRawGraphData.edges[index], + )) { + return false; + } + } + + return true; +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/equality/node.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/node.ts new file mode 100644 index 000000000..224070340 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/node.ts @@ -0,0 +1,22 @@ +import type { IGraphNode } from '../../../../../../shared/graph/contracts'; +import { areGraphValuesEqual } from './values'; + +export function areNodesEqualIgnoringMetrics(left: IGraphNode, right: IGraphNode): boolean { + if (left === right) { + return true; + } + + const leftRecord = left as unknown as Record; + const rightRecord = right as unknown as Record; + const keys = new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)]); + keys.delete('churn'); + keys.delete('fileSize'); + + for (const key of keys) { + if (!areGraphValuesEqual(leftRecord[key], rightRecord[key])) { + return false; + } + } + + return true; +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/equality/payload.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/payload.ts new file mode 100644 index 000000000..6b9f2af51 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/payload.ts @@ -0,0 +1,17 @@ +import type { IGraphData } from '../../../../../../shared/graph/contracts'; + +export function areGraphDataPayloadsEqual(left: IGraphData, right: IGraphData): boolean { + if (left === right) { + return true; + } + + if (left.nodes.length !== right.nodes.length || left.edges.length !== right.edges.length) { + return false; + } + + try { + return JSON.stringify(left) === JSON.stringify(right); + } catch { + return false; + } +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/equality/values.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/values.ts new file mode 100644 index 000000000..801a8dfb9 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/values.ts @@ -0,0 +1,13 @@ +import { + compareGraphArrayValues, + compareGraphRecordValues, +} from './collections'; + +export function areGraphValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + + return compareGraphArrayValues(left, right, areGraphValuesEqual) + ?? compareGraphRecordValues(left, right, areGraphValuesEqual); +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/groupInputs.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/groupInputs.ts new file mode 100644 index 000000000..eee405026 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/groupInputs.ts @@ -0,0 +1,46 @@ +import type { IGraphData, IGraphNode } from '../../../../../shared/graph/contracts'; + +function createGraphGroupSymbolSignature(symbol: IGraphNode['symbol']): string | undefined { + if (!symbol) { + return undefined; + } + + return JSON.stringify([ + symbol.kind, + symbol.pluginKind, + symbol.source, + symbol.language, + symbol.filePath, + ]); +} + +function areGraphGroupSymbolInputsEqual( + left: IGraphNode['symbol'], + right: IGraphNode['symbol'], +): boolean { + return createGraphGroupSymbolSignature(left) === createGraphGroupSymbolSignature(right); +} + +function areGraphGroupNodeInputsEqual(left: IGraphNode, right: IGraphNode): boolean { + return left.nodeType === right.nodeType + && areGraphGroupSymbolInputsEqual(left.symbol, right.symbol); +} + +export function doGraphViewGroupsNeedRecompute( + currentRawGraphData: IGraphData, + nextRawGraphData: IGraphData, +): boolean { + if (currentRawGraphData.nodes.length !== nextRawGraphData.nodes.length) { + return true; + } + + const nextNodesById = new Map(nextRawGraphData.nodes.map(node => [node.id, node])); + for (const currentNode of currentRawGraphData.nodes) { + const nextNode = nextNodesById.get(currentNode.id); + if (!nextNode || !areGraphGroupNodeInputsEqual(currentNode, nextNode)) { + return true; + } + } + + return false; +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/messages.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/messages.ts new file mode 100644 index 000000000..85ac19dd6 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/messages.ts @@ -0,0 +1,69 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import type { + GraphViewAnalysisExecutionHandlers, + GraphViewAnalysisExecutionState, +} from '../../execution'; +import { doGraphViewGroupsNeedRecompute } from './groupInputs'; +import type { GraphPublicationPlan } from './plan'; + +export function publishRawGraphUpdate( + state: GraphViewAnalysisExecutionState, + handlers: GraphViewAnalysisExecutionHandlers, + rawGraphData: IGraphData, + plan: GraphPublicationPlan, +): void { + if (plan.reuseCurrentGraphPublication) { + return; + } + + handlers.setRawGraphData(rawGraphData); + handlers.updateViewContext(); + handlers.applyViewTransform(); + publishGraphGroupsIfNeeded(state, handlers, rawGraphData, plan.currentRawGraphData); +} + +function publishGraphGroupsIfNeeded( + state: GraphViewAnalysisExecutionState, + handlers: GraphViewAnalysisExecutionHandlers, + rawGraphData: IGraphData, + currentRawGraphData: IGraphData | undefined, +): void { + const canSkipGroupPublication = state.mode === 'incremental' + && currentRawGraphData + && !doGraphViewGroupsNeedRecompute(currentRawGraphData, rawGraphData); + + if (canSkipGroupPublication) { + return; + } + + handlers.computeMergedGroups(); + handlers.sendGroupsUpdated(); +} + +export function publishStaticGraphMessages(handlers: GraphViewAnalysisExecutionHandlers): void { + handlers.sendDepthState(); + handlers.sendPluginStatuses(); + handlers.sendDecorations(); + handlers.sendContextMenuItems(); + handlers.sendPluginExporters?.(); + handlers.sendPluginToolbarActions?.(); + handlers.sendGraphViewContributionStatuses?.(); + handlers.sendPluginWebviewInjections?.(); +} + +export function publishGraphDataMessage( + handlers: GraphViewAnalysisExecutionHandlers, + graphData: IGraphData, + plan: GraphPublicationPlan, +): void { + if (plan.reuseCurrentGraphPublication) { + return; + } + + if (plan.shouldSendMetricPatch && plan.metricOnlyUpdate) { + handlers.sendGraphNodeMetricsUpdated?.(plan.metricOnlyUpdate); + return; + } + + handlers.sendGraphDataUpdated(graphData); +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/changedPaths.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/changedPaths.ts new file mode 100644 index 000000000..dcb07a6c5 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/changedPaths.ts @@ -0,0 +1,63 @@ +import type { IGraphData, IGraphNode } from '../../../../../../shared/graph/contracts'; + +function normalizeGraphPath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +function isGraphNodeForChangedPath(nodeId: string, changedFilePath: string): boolean { + const normalizedNodeId = normalizeGraphPath(nodeId); + const normalizedChangedFilePath = normalizeGraphPath(changedFilePath); + return normalizedChangedFilePath === normalizedNodeId + || normalizedChangedFilePath.endsWith(`/${normalizedNodeId}`); +} + +function isGraphNodeAffectedByChangedPath(node: IGraphNode, changedFilePath: string): boolean { + const symbolFilePath = node.symbol?.filePath; + return isGraphNodeForChangedPath(node.id, changedFilePath) + || (symbolFilePath ? isGraphNodeForChangedPath(symbolFilePath, changedFilePath) : false); +} + +function findGraphNodeByChangedPath( + graphData: IGraphData, + changedFilePath: string, +): IGraphNode | undefined { + return graphData.nodes.find(node => isGraphNodeForChangedPath(node.id, changedFilePath)); +} + +export function hasChangedNodeMetricDifference( + currentRawGraphData: IGraphData, + nextRawGraphData: IGraphData, + changedFilePaths: readonly string[] | undefined, +): boolean { + if (!changedFilePaths?.length) { + return false; + } + + for (const changedFilePath of changedFilePaths) { + const currentNode = findGraphNodeByChangedPath(currentRawGraphData, changedFilePath); + const nextNode = findGraphNodeByChangedPath(nextRawGraphData, changedFilePath); + if (!currentNode || !nextNode) { + continue; + } + + if ( + currentNode.fileSize !== nextNode.fileSize + || currentNode.churn !== nextNode.churn + ) { + return true; + } + } + + return false; +} + +export function collectChangedPathNodes( + graphData: IGraphData, + changedFilePaths: readonly string[], +): IGraphNode[] { + return graphData.nodes.filter(node => + changedFilePaths.some(changedFilePath => + isGraphNodeAffectedByChangedPath(node, changedFilePath), + ), + ); +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/patch.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/patch.ts new file mode 100644 index 000000000..b0818f9fb --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/patch.ts @@ -0,0 +1,26 @@ +import type { IGraphData } from '../../../../../../shared/graph/contracts'; +import type { IGraphNodeMetricsUpdate } from '../../../../../../shared/protocol/extensionToWebview'; +import { collectChangedPathNodes } from './changedPaths'; +import { + collectMetricOnlyGraphUpdates, + createNodeMap, +} from './updates'; +import { areGraphDataEqualIgnoringNodeMetrics } from '../equality/data'; + +export function createMetricOnlyGraphUpdate( + currentRawGraphData: IGraphData | undefined, + nextRawGraphData: IGraphData, + changedFilePaths: readonly string[] | undefined, +): IGraphNodeMetricsUpdate[] | undefined { + if (!currentRawGraphData || !changedFilePaths?.length) { + return undefined; + } + + if (!areGraphDataEqualIgnoringNodeMetrics(currentRawGraphData, nextRawGraphData)) { + return undefined; + } + + const currentNodes = collectChangedPathNodes(currentRawGraphData, changedFilePaths); + const nextNodes = collectChangedPathNodes(nextRawGraphData, changedFilePaths); + return collectMetricOnlyGraphUpdates(currentNodes, createNodeMap(nextNodes)); +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/updates.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/updates.ts new file mode 100644 index 000000000..0e1c47d0d --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/updates.ts @@ -0,0 +1,39 @@ +import type { IGraphNode } from '../../../../../../shared/graph/contracts'; +import type { IGraphNodeMetricsUpdate } from '../../../../../../shared/protocol/extensionToWebview'; +import { areNodesEqualIgnoringMetrics } from '../equality/node'; + +export function createNodeMap(nodes: readonly IGraphNode[]): Map { + return new Map(nodes.map(node => [node.id, node])); +} + +function haveGraphNodeMetricsChanged(currentNode: IGraphNode, nextNode: IGraphNode): boolean { + return currentNode.fileSize !== nextNode.fileSize || currentNode.churn !== nextNode.churn; +} + +function createGraphNodeMetricsUpdate(nextNode: IGraphNode): IGraphNodeMetricsUpdate { + return { + id: nextNode.id, + fileSize: nextNode.fileSize, + churn: nextNode.churn, + }; +} + +export function collectMetricOnlyGraphUpdates( + currentNodes: readonly IGraphNode[], + nextNodesById: ReadonlyMap, +): IGraphNodeMetricsUpdate[] | undefined { + const updates: IGraphNodeMetricsUpdate[] = []; + + for (const currentNode of currentNodes) { + const nextNode = nextNodesById.get(currentNode.id); + if (!nextNode || !areNodesEqualIgnoringMetrics(currentNode, nextNode)) { + return undefined; + } + + if (haveGraphNodeMetricsChanged(currentNode, nextNode)) { + updates.push(createGraphNodeMetricsUpdate(nextNode)); + } + } + + return updates.length > 0 ? updates : undefined; +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/plan.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/plan.ts new file mode 100644 index 000000000..11b7c394a --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/plan.ts @@ -0,0 +1,63 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import type { IGraphNodeMetricsUpdate } from '../../../../../shared/protocol/extensionToWebview'; +import type { CodeGraphyIndexFreshness } from '../../../../repoSettings/freshness'; +import type { + GraphViewAnalysisExecutionHandlers, + GraphViewAnalysisExecutionState, +} from '../../execution'; +import { hasChangedNodeMetricDifference } from './metrics/changedPaths'; +import { createMetricOnlyGraphUpdate } from './metrics/patch'; +import { areGraphDataPayloadsEqual } from './equality/payload'; + +export interface GraphPublicationPlan { + currentRawGraphData: IGraphData | undefined; + metricOnlyUpdate: IGraphNodeMetricsUpdate[] | undefined; + reuseCurrentGraphPublication: boolean; + shouldSendMetricPatch: boolean; +} + +function canReuseCurrentGraphPublication( + state: GraphViewAnalysisExecutionState, + currentRawGraphData: IGraphData | undefined, + rawGraphData: IGraphData, + actualHasIndex: boolean, + freshness: CodeGraphyIndexFreshness, +): boolean { + if (state.mode !== 'incremental' || !actualHasIndex || freshness !== 'fresh') { + return false; + } + + return currentRawGraphData + ? !hasChangedNodeMetricDifference(currentRawGraphData, rawGraphData, state.changedFilePaths) + && areGraphDataPayloadsEqual(currentRawGraphData, rawGraphData) + : false; +} + +export function createGraphPublicationPlan( + state: GraphViewAnalysisExecutionState, + handlers: GraphViewAnalysisExecutionHandlers, + rawGraphData: IGraphData, + actualHasIndex: boolean, + freshness: CodeGraphyIndexFreshness, +): GraphPublicationPlan { + const currentRawGraphData = handlers.getRawGraphData?.(); + const metricOnlyUpdate = createMetricOnlyGraphUpdate( + currentRawGraphData, + rawGraphData, + state.changedFilePaths, + ); + + return { + currentRawGraphData, + metricOnlyUpdate, + reuseCurrentGraphPublication: canReuseCurrentGraphPublication( + state, + currentRawGraphData, + rawGraphData, + actualHasIndex, + freshness, + ), + shouldSendMetricPatch: metricOnlyUpdate !== undefined + && handlers.sendGraphNodeMetricsUpdated !== undefined, + }; +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/status.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/status.ts new file mode 100644 index 000000000..3247c8de8 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/status.ts @@ -0,0 +1,25 @@ +import type { CodeGraphyIndexFreshness } from '../../../../repoSettings/freshness'; +import type { GraphViewAnalysisExecutionState } from '../../execution'; + +export function resolveGraphIndexStatus( + state: GraphViewAnalysisExecutionState | undefined, + hasIndex: boolean, +): { freshness: CodeGraphyIndexFreshness; detail: string } { + const status = state?.analyzer?.getIndexStatus?.(); + if (status) { + return status; + } + + return { + freshness: hasIndex ? 'fresh' : 'missing', + detail: hasIndex + ? 'CodeGraphy index is fresh.' + : 'CodeGraphy index is missing. Index the workspace to build the graph.', + }; +} + +export function shouldReportGraphViewUpdateProgress( + state: GraphViewAnalysisExecutionState, +): boolean { + return state.mode === 'index' || state.mode === 'refresh' || state.mode === 'incremental'; +} diff --git a/packages/extension/src/extension/graphView/analysis/request.ts b/packages/extension/src/extension/graphView/analysis/request.ts index 983930eca..c0a54dc48 100644 --- a/packages/extension/src/extension/graphView/analysis/request.ts +++ b/packages/extension/src/extension/graphView/analysis/request.ts @@ -61,7 +61,9 @@ export async function runGraphViewAnalysisRequest( }, }); } catch (error) { - if (!handlers.isAbortError(error)) { + if (handlers.isAbortError(error)) { + return; + } else { handlers.emitDiagnostic?.({ area: 'extension.analysis', event: 'request-failed', diff --git a/packages/extension/src/extension/graphView/groups/defaults/builtIn.ts b/packages/extension/src/extension/graphView/groups/defaults/builtIn.ts index 3432d1801..90fe9a1fd 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/builtIn.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/builtIn.ts @@ -6,6 +6,19 @@ import { getCodeGraphyConfiguration } from '../../../repoSettings/current'; import { getMaterialThemeDefaultGroups } from './materialTheme/view'; import { getSymbolDefaultGroups } from './symbols'; +const builtInDefaultGroupsCache = new WeakMap>(); + +function getExtensionUriCacheKey(extensionUri: vscode.Uri): string { + return extensionUri.fsPath || extensionUri.path || extensionUri.toString(); +} + +function getBuiltInDefaultGroupsCacheKey( + extensionUri: vscode.Uri, + includeFolderMatches: boolean, +): string { + return `${getExtensionUriCacheKey(extensionUri)}|folder:${includeFolderMatches ? '1' : '0'}`; +} + export function getBuiltInGraphViewDefaultGroups( graphData: IGraphData, extensionUri: vscode.Uri, @@ -13,11 +26,22 @@ export function getBuiltInGraphViewDefaultGroups( const config = getCodeGraphyConfiguration(); const defaultNodeVisibility = createDefaultNodeVisibility(); const configuredNodeVisibility = config.get>('nodeVisibility', {}) ?? {}; + const includeFolderMatches = configuredNodeVisibility.folder ?? defaultNodeVisibility.folder; + const cacheKey = getBuiltInDefaultGroupsCacheKey(extensionUri, includeFolderMatches); + const cachedGroups = builtInDefaultGroupsCache.get(graphData)?.get(cacheKey); + if (cachedGroups) { + return cachedGroups; + } - return [ + const groups = [ ...getMaterialThemeDefaultGroups(graphData, extensionUri, { - includeFolderMatches: configuredNodeVisibility.folder ?? defaultNodeVisibility.folder, + includeFolderMatches, }), ...getSymbolDefaultGroups(graphData), ]; + + const cachedGroupsByInput = builtInDefaultGroupsCache.get(graphData) ?? new Map(); + cachedGroupsByInput.set(cacheKey, groups); + builtInDefaultGroupsCache.set(graphData, cachedGroupsByInput); + return groups; } diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch.ts index b80a68bce..da1ec9e48 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch.ts @@ -1,13 +1,49 @@ import type { MaterialMatch } from './model'; +import { + createExtensionMatch, + getExtensionCandidates, +} from './extensionMatch/candidates'; + +export interface MaterialExtensionMatcher { + iconNameByLowerExtension: Map; +} + +export function createMaterialExtensionMatcher( + extensions: Record, +): MaterialExtensionMatcher { + return { + iconNameByLowerExtension: new Map( + Object.entries(extensions).map(([extension, iconName]) => [ + extension.toLowerCase(), + iconName, + ]), + ), + }; +} export function findLongestExtensionMatch( baseName: string, entries: Iterable, +): MaterialMatch | undefined { + return findLongestExtensionMatchWithMatcher( + baseName, + createMaterialExtensionMatcher(Object.fromEntries(entries)), + ); +} + +export function findLongestExtensionMatchWithMatcher( + baseName: string, + matcher: MaterialExtensionMatcher, ): MaterialMatch | undefined { const lowerBaseName = baseName.toLowerCase(); let bestMatch: MaterialMatch | undefined; - for (const [extension, iconName] of entries) { + for (const extension of getExtensionCandidates(lowerBaseName)) { + const iconName = matcher.iconNameByLowerExtension.get(extension); + if (!iconName) { + continue; + } + const match = createExtensionMatch(baseName, lowerBaseName, extension, iconName); if (!match || (bestMatch && bestMatch.key.length >= match.key.length)) { continue; @@ -18,25 +54,3 @@ export function findLongestExtensionMatch( return bestMatch; } - -function createExtensionMatch( - baseName: string, - lowerBaseName: string, - extension: string, - iconName: string, -): MaterialMatch | undefined { - const lowerExtension = extension.toLowerCase(); - if (!matchesExtension(lowerBaseName, lowerExtension)) { - return undefined; - } - - return { - iconName, - key: lowerBaseName === lowerExtension ? baseName : baseName.slice(-extension.length), - kind: 'fileExtension', - }; -} - -function matchesExtension(lowerBaseName: string, lowerExtension: string): boolean { - return lowerBaseName === lowerExtension || lowerBaseName.endsWith(`.${lowerExtension}`); -} diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch/candidates.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch/candidates.ts new file mode 100644 index 000000000..2ee03c6be --- /dev/null +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch/candidates.ts @@ -0,0 +1,35 @@ +import type { MaterialMatch } from '../model'; + +export function getExtensionCandidates(lowerBaseName: string): string[] { + const candidates = [lowerBaseName]; + for (let index = lowerBaseName.indexOf('.'); index >= 0; index = lowerBaseName.indexOf('.', index + 1)) { + const extension = lowerBaseName.slice(index + 1); + if (extension) { + candidates.push(extension); + } + } + + return candidates; +} + +export function createExtensionMatch( + baseName: string, + lowerBaseName: string, + extension: string, + iconName: string, +): MaterialMatch | undefined { + const lowerExtension = extension.toLowerCase(); + if (!matchesExtension(lowerBaseName, lowerExtension)) { + return undefined; + } + + return { + iconName, + key: lowerBaseName === lowerExtension ? baseName : baseName.slice(-extension.length), + kind: 'fileExtension', + }; +} + +function matchesExtension(lowerBaseName: string, lowerExtension: string): boolean { + return lowerBaseName === lowerExtension || lowerBaseName.endsWith(`.${lowerExtension}`); +} diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/fileExtension.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/fileExtension.ts index 6ec583ae4..d67771ba1 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/fileExtension.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/fileExtension.ts @@ -1,9 +1,14 @@ import type { MaterialMatch } from './model'; -import { findLongestExtensionMatch } from './extensionMatch'; +import { + createMaterialExtensionMatcher, + findLongestExtensionMatchWithMatcher, + type MaterialExtensionMatcher, +} from './extensionMatch'; export function matchMaterialFileExtension( baseName: string, fileExtensions: Record, + matcher: MaterialExtensionMatcher = createMaterialExtensionMatcher(fileExtensions), ): MaterialMatch | undefined { - return findLongestExtensionMatch(baseName, Object.entries(fileExtensions)); + return findLongestExtensionMatchWithMatcher(baseName, matcher); } diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/files.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/files.ts index f1ed090de..05f327e5d 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/files.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/files.ts @@ -1,25 +1,58 @@ import type { IGroup } from '../../../../../shared/settings/groups'; import type { IGraphData } from '../../../../../shared/graph/contracts'; import { isExternalPackageNodeId } from '../../../../pipeline/graph/packageSpecifiers/nodeId'; -import type { MaterialThemeCacheEntry } from './model'; +import type { MaterialMatch, MaterialThemeCacheEntry } from './model'; import { createMaterialGroup } from './groups'; import { resolveIconData } from './icons'; import { findMaterialMatch } from './match'; +import { getMaterialBaseName } from './paths'; + +function hasPathSpecificFileNameRules( + baseName: string, + theme: MaterialThemeCacheEntry, +): boolean { + return Boolean(theme.pathMatchers.fileNames?.pathRulesByLowerBaseName.has(baseName.toLowerCase())); +} + +function findCachedMaterialFileMatch( + nodeId: string, + theme: MaterialThemeCacheEntry, + matchCacheByBaseName: Map, +): MaterialMatch | undefined { + const baseName = getMaterialBaseName(nodeId); + if (!baseName || hasPathSpecificFileNameRules(baseName, theme)) { + return findMaterialMatch(nodeId, theme.manifest, { + extensionMatcher: theme.extensionMatcher, + pathMatchers: theme.pathMatchers, + }); + } + + const cached = matchCacheByBaseName.get(baseName); + if (cached !== undefined) { + return cached ?? undefined; + } + + const match = findMaterialMatch(baseName, theme.manifest, { + extensionMatcher: theme.extensionMatcher, + pathMatchers: theme.pathMatchers, + }); + matchCacheByBaseName.set(baseName, match ?? null); + return match; +} export function collectMaterialFileGroups( graphData: IGraphData, theme: MaterialThemeCacheEntry, ): IGroup[] { const groupsById = new Map(); + const matchCacheByBaseName = new Map(); for (const node of graphData.nodes) { if (node.nodeType === 'package' || node.nodeType === 'folder' || isExternalPackageNodeId(node.id)) { continue; } - const match = findMaterialMatch(node.id, theme.manifest, { - pathMatchers: theme.pathMatchers, - }); + const match = findCachedMaterialFileMatch(node.id, theme, matchCacheByBaseName); if (!match) { continue; } diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/manifest.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/manifest.ts index ceba0bf23..1bef9839b 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/manifest.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/manifest.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as vscode from 'vscode'; import type { MaterialThemeCacheEntry, MaterialIconManifest } from './model'; +import { createMaterialExtensionMatcher } from './extensionMatch'; import { createMaterialPathRuleMatcher } from './pathMatch'; const materialThemeCache = new Map(); @@ -41,6 +42,9 @@ export function loadMaterialTheme(extensionUri: vscode.Uri): MaterialThemeCacheE const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as MaterialIconManifest; const theme = { + extensionMatcher: manifest.fileExtensions + ? createMaterialExtensionMatcher(manifest.fileExtensions) + : undefined, iconDataByName: new Map(), manifest, manifestPath, diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/match.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/match.ts index e5c1f8dcd..2d9eb8074 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/match.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/match.ts @@ -1,3 +1,4 @@ +import type { MaterialExtensionMatcher } from './extensionMatch'; import type { MaterialIconManifest, MaterialMatch, MaterialThemePathMatchers } from './model'; import { matchMaterialFileExtension } from './fileExtension'; import { matchMaterialFileName } from './fileName'; @@ -8,11 +9,15 @@ import { getMaterialBaseName } from './paths'; export function findMaterialMatch( nodeId: string, manifest: MaterialIconManifest, - options?: { nodeType?: 'file' | 'folder'; pathMatchers?: MaterialThemePathMatchers }, + options?: { + extensionMatcher?: MaterialExtensionMatcher; + nodeType?: 'file' | 'folder'; + pathMatchers?: MaterialThemePathMatchers; + }, ): MaterialMatch | undefined { return options?.nodeType === 'folder' ? findFolderMaterialMatch(nodeId, manifest, options.pathMatchers) - : findFileMaterialMatch(nodeId, manifest, options?.pathMatchers); + : findFileMaterialMatch(nodeId, manifest, options?.pathMatchers, options?.extensionMatcher); } function findFolderMaterialMatch( @@ -34,6 +39,7 @@ function findFileMaterialMatch( nodeId: string, manifest: MaterialIconManifest, pathMatchers: MaterialThemePathMatchers | undefined, + extensionMatcher: MaterialExtensionMatcher | undefined, ): MaterialMatch | undefined { const baseName = getMaterialBaseName(nodeId); if (!baseName) { @@ -41,7 +47,7 @@ function findFileMaterialMatch( } return findFileNameMaterialMatch(nodeId, manifest, pathMatchers) - ?? findFileExtensionMaterialMatch(baseName, manifest) + ?? findFileExtensionMaterialMatch(baseName, manifest, extensionMatcher) ?? findLanguageMaterialMatch(baseName, manifest); } @@ -58,9 +64,10 @@ function findFileNameMaterialMatch( function findFileExtensionMaterialMatch( baseName: string, manifest: MaterialIconManifest, + extensionMatcher: MaterialExtensionMatcher | undefined, ): MaterialMatch | undefined { return manifest.fileExtensions - ? matchMaterialFileExtension(baseName, manifest.fileExtensions) + ? matchMaterialFileExtension(baseName, manifest.fileExtensions, extensionMatcher) : undefined; } diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/model.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/model.ts index afcd524e0..debb4a37d 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/model.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/model.ts @@ -1,3 +1,6 @@ +import type { MaterialExtensionMatcher } from './extensionMatch'; +import type { MaterialPathRuleMatcher } from './pathMatch'; + export interface MaterialIconManifest { fileExtensions?: Record; fileNames?: Record; @@ -15,6 +18,7 @@ export interface MaterialIconData { } export interface MaterialThemeCacheEntry { + extensionMatcher?: MaterialExtensionMatcher; iconDataByName: Map; manifest: MaterialIconManifest; manifestPath: string; @@ -34,4 +38,3 @@ export interface MaterialMatch { } export const DEFAULT_MATERIAL_COLOR = '#90A4AE'; -import type { MaterialPathRuleMatcher } from './pathMatch'; diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts index aaefb1123..c8a44da2d 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts @@ -1,5 +1,15 @@ import type { MaterialMatch } from './model'; import { getMaterialBaseName, normalizePathSeparators } from './paths'; +import { + createMaterialPathRuleMatcher, + type MaterialPathRuleEntry, + type MaterialPathRuleMatcher, +} from './pathMatcher'; + +export { + createMaterialPathRuleMatcher, + type MaterialPathRuleMatcher, +}; type PathMatchKind = Extract; @@ -10,41 +20,6 @@ interface PathMatchContext { subjectPath: string; } -interface MaterialPathRuleEntry { - iconName: string; - lowerRule: string; - normalizedRule: string; -} - -export interface MaterialPathRuleMatcher { - baseNameRules: Map; - pathRules: MaterialPathRuleEntry[]; -} - -export function createMaterialPathRuleMatcher( - rules: Record, -): MaterialPathRuleMatcher { - const baseNameRules = new Map(); - const pathRules: MaterialPathRuleEntry[] = []; - - for (const [ruleKey, iconName] of Object.entries(rules)) { - const normalizedRule = normalizePathSeparators(ruleKey); - const lowerRule = normalizedRule.toLowerCase(); - const entry = { iconName, lowerRule, normalizedRule }; - - if (normalizedRule.includes('/')) { - pathRules.push(entry); - continue; - } - - baseNameRules.set(lowerRule, entry); - } - - pathRules.sort((left, right) => right.normalizedRule.length - left.normalizedRule.length); - - return { baseNameRules, pathRules }; -} - export function findLongestPathMatch( subjectPath: string, rules: Record, @@ -63,7 +38,7 @@ export function findLongestPathMatchWithMatcher( kind: PathMatchKind, ): MaterialMatch | undefined { const context = getPathMatchContext(subjectPath); - for (const rule of matcher.pathRules) { + for (const rule of matcher.pathRulesByLowerBaseName.get(context.lowerBaseName) ?? []) { if (!matchesPathRule(context, rule.normalizedRule, rule.lowerRule)) { continue; } diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatcher.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatcher.ts new file mode 100644 index 000000000..4fb1b37dc --- /dev/null +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatcher.ts @@ -0,0 +1,45 @@ +import { getMaterialBaseName, normalizePathSeparators } from './paths'; + +export interface MaterialPathRuleEntry { + iconName: string; + lowerRule: string; + normalizedRule: string; +} + +export interface MaterialPathRuleMatcher { + baseNameRules: Map; + pathRules: MaterialPathRuleEntry[]; + pathRulesByLowerBaseName: Map; +} + +export function createMaterialPathRuleMatcher( + rules: Record, +): MaterialPathRuleMatcher { + const baseNameRules = new Map(); + const pathRules: MaterialPathRuleEntry[] = []; + const pathRulesByLowerBaseName = new Map(); + + for (const [ruleKey, iconName] of Object.entries(rules)) { + const normalizedRule = normalizePathSeparators(ruleKey); + const lowerRule = normalizedRule.toLowerCase(); + const entry = { iconName, lowerRule, normalizedRule }; + + if (normalizedRule.includes('/')) { + pathRules.push(entry); + const lowerBaseName = getMaterialBaseName(normalizedRule).toLowerCase(); + const rulesForBaseName = pathRulesByLowerBaseName.get(lowerBaseName) ?? []; + rulesForBaseName.push(entry); + pathRulesByLowerBaseName.set(lowerBaseName, rulesForBaseName); + continue; + } + + baseNameRules.set(lowerRule, entry); + } + + pathRules.sort((left, right) => right.normalizedRule.length - left.normalizedRule.length); + for (const rulesForBaseName of pathRulesByLowerBaseName.values()) { + rulesForBaseName.sort((left, right) => right.normalizedRule.length - left.normalizedRule.length); + } + + return { baseNameRules, pathRules, pathRulesByLowerBaseName }; +} diff --git a/packages/extension/src/extension/graphView/provider/analysis/fullIndex.ts b/packages/extension/src/extension/graphView/provider/analysis/fullIndex.ts new file mode 100644 index 000000000..b8ca16aac --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/analysis/fullIndex.ts @@ -0,0 +1,117 @@ +import { scheduleFullIndexBackgroundAnalysis } from './fullIndex/background'; + +export { canReplayStaleCache } from './fullIndex/cacheReplay'; + +interface FullIndexAnalysisLogger { + logError(message: string, error: unknown): void; +} + +export interface FullIndexAnalysisCoordinator { + runAfterFullIndexAnalysis(runAnalysis: () => Promise): Promise; + runFullIndexAnalysis(runAnalysis: () => Promise): Promise; + runFullIndexAnalysisInBackground( + runAnalysis: () => Promise, + shouldStart?: () => boolean, + ): void; + waitForFullIndexAnalysis(): Promise; + waitForForegroundFullIndexAnalysis(): Promise; +} + +export type FullIndexAnalysisKind = 'background' | 'foreground'; + +class FullIndexAnalysisCoordinatorState implements FullIndexAnalysisCoordinator { + private _fullIndexAnalysisPromise: Promise | undefined; + private _fullIndexAnalysisKind: FullIndexAnalysisKind | undefined; + private _scheduledBackgroundAnalysis: ReturnType | undefined; + + constructor( + private readonly _dependencies: FullIndexAnalysisLogger, + ) {} + + private _clearScheduledBackgroundAnalysis(): void { + if (this._scheduledBackgroundAnalysis === undefined) { + return; + } + + clearTimeout(this._scheduledBackgroundAnalysis); + this._scheduledBackgroundAnalysis = undefined; + } + + async waitForFullIndexAnalysis(): Promise { + if (!this._fullIndexAnalysisPromise) { + return false; + } + + try { + await this._fullIndexAnalysisPromise; + } catch { + // The request that owns the reindex reports the failure. Competing + // fire-and-forget webview loads should not create duplicate errors. + } + return true; + } + + async waitForForegroundFullIndexAnalysis(): Promise { + if (this._fullIndexAnalysisKind === 'background') { + return false; + } + + return this.waitForFullIndexAnalysis(); + } + + async runFullIndexAnalysis( + runAnalysis: () => Promise, + kind: FullIndexAnalysisKind = 'foreground', + ): Promise { + if (kind === 'foreground') { + this._clearScheduledBackgroundAnalysis(); + } + + if (this._fullIndexAnalysisPromise) { + await this._fullIndexAnalysisPromise; + return; + } + + const analysisPromise = runAnalysis(); + this._fullIndexAnalysisPromise = analysisPromise; + this._fullIndexAnalysisKind = kind; + try { + await analysisPromise; + } finally { + if (this._fullIndexAnalysisPromise === analysisPromise) { + this._fullIndexAnalysisPromise = undefined; + this._fullIndexAnalysisKind = undefined; + } + } + } + + runFullIndexAnalysisInBackground( + runAnalysis: () => Promise, + shouldStart: () => boolean = () => true, + ): void { + scheduleFullIndexBackgroundAnalysis({ + fullIndexAnalysisPromise: this._fullIndexAnalysisPromise, + logError: (message, error) => this._dependencies.logError(message, error), + runFullIndexAnalysis: (nextRunAnalysis, kind) => + this.runFullIndexAnalysis(nextRunAnalysis, kind), + scheduledBackgroundAnalysis: this._scheduledBackgroundAnalysis, + setScheduledBackgroundAnalysis: scheduledBackgroundAnalysis => { + this._scheduledBackgroundAnalysis = scheduledBackgroundAnalysis; + }, + }, runAnalysis, shouldStart); + } + + async runAfterFullIndexAnalysis( + runAnalysis: () => Promise, + ): Promise { + this._clearScheduledBackgroundAnalysis(); + await this.waitForFullIndexAnalysis(); + await runAnalysis(); + } +} + +export function createFullIndexAnalysisCoordinator( + dependencies: FullIndexAnalysisLogger, +): FullIndexAnalysisCoordinator { + return new FullIndexAnalysisCoordinatorState(dependencies); +} diff --git a/packages/extension/src/extension/graphView/provider/analysis/fullIndex/background.ts b/packages/extension/src/extension/graphView/provider/analysis/fullIndex/background.ts new file mode 100644 index 000000000..9a924f66f --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/analysis/fullIndex/background.ts @@ -0,0 +1,35 @@ +import type { FullIndexAnalysisKind } from '../fullIndex'; + +interface FullIndexBackgroundScheduleState { + fullIndexAnalysisPromise: Promise | undefined; + logError(message: string, error: unknown): void; + runFullIndexAnalysis( + runAnalysis: () => Promise, + kind: FullIndexAnalysisKind, + ): Promise; + scheduledBackgroundAnalysis: ReturnType | undefined; + setScheduledBackgroundAnalysis( + scheduledBackgroundAnalysis: ReturnType | undefined, + ): void; +} + +export function scheduleFullIndexBackgroundAnalysis( + state: FullIndexBackgroundScheduleState, + runAnalysis: () => Promise, + shouldStart: () => boolean, +): void { + if (state.scheduledBackgroundAnalysis !== undefined || state.fullIndexAnalysisPromise) { + return; + } + + state.setScheduledBackgroundAnalysis(setTimeout(() => { + state.setScheduledBackgroundAnalysis(undefined); + if (!shouldStart()) { + return; + } + + void state.runFullIndexAnalysis(runAnalysis, 'background').catch(error => { + state.logError('[CodeGraphy] Background cache sync failed:', error); + }); + }, 0)); +} diff --git a/packages/extension/src/extension/graphView/provider/analysis/fullIndex/cacheReplay.ts b/packages/extension/src/extension/graphView/provider/analysis/fullIndex/cacheReplay.ts new file mode 100644 index 000000000..135eab66b --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/analysis/fullIndex/cacheReplay.ts @@ -0,0 +1,13 @@ +interface ReplayableCacheAnalyzer { + getIndexStatus?(): { freshness: string }; + loadCachedGraph?: unknown; +} + +interface ReplayableCacheSource { + _analyzer?: ReplayableCacheAnalyzer; +} + +export function canReplayStaleCache(source: ReplayableCacheSource): boolean { + return source._analyzer?.getIndexStatus?.().freshness === 'stale' + && typeof source._analyzer.loadCachedGraph === 'function'; +} diff --git a/packages/extension/src/extension/graphView/provider/analysis/handlers.ts b/packages/extension/src/extension/graphView/provider/analysis/handlers.ts index dcd5ed8f4..164d70b09 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/handlers.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/handlers.ts @@ -3,7 +3,11 @@ import type { GraphViewProviderAnalysisHandlers, GraphViewProviderAnalysisRequestHandlers, } from '../../analysis/lifecycle'; -import { sendGraphControlsUpdated } from '../../controls/send'; +import { + sendGraphDataUpdated, + sendGraphIndexStatusUpdated, + sendGraphNodeMetricsUpdated, +} from './handlers/messages'; import type { GraphViewProviderAnalysisMethodDependencies, GraphViewProviderAnalysisMethodsSource, @@ -34,17 +38,10 @@ export function createGraphViewProviderAnalysisHandlers( setGraphData: graphData => { setGraphViewProviderGraphData(source, graphData); }, + getRawGraphData: () => source._rawGraphData, getGraphData: () => source._graphData, - sendGraphDataUpdated: graphData => { - sendGraphControlsUpdated( - source._rawGraphData, - source._analyzer, - message => source._sendMessage(message), - undefined, - source._disabledPlugins, - ); - source._sendMessage({ type: 'GRAPH_DATA_UPDATED', payload: graphData }); - }, + sendGraphDataUpdated: graphData => sendGraphDataUpdated(source, graphData), + sendGraphNodeMetricsUpdated: updates => sendGraphNodeMetricsUpdated(source, updates), sendDepthState: () => source._sendDepthState(), computeMergedGroups: () => source._computeMergedGroups(), sendGroupsUpdated: () => source._sendGroupsUpdated(), @@ -53,12 +50,8 @@ export function createGraphViewProviderAnalysisHandlers( sendPluginStatuses: () => source._sendPluginStatuses(), sendDecorations: () => source._sendDecorations(), sendContextMenuItems: () => source._sendContextMenuItems(), - sendGraphIndexStatusUpdated: (hasIndex, freshness, detail) => { - source._sendMessage({ - type: 'GRAPH_INDEX_STATUS_UPDATED', - payload: { hasIndex, freshness, detail }, - }); - }, + sendGraphIndexStatusUpdated: (hasIndex, freshness, detail) => + sendGraphIndexStatusUpdated(source, hasIndex, freshness, detail), sendIndexProgress: progress => { source._sendMessage({ type: 'GRAPH_INDEX_PROGRESS', payload: progress }); }, diff --git a/packages/extension/src/extension/graphView/provider/analysis/handlers/messages.ts b/packages/extension/src/extension/graphView/provider/analysis/handlers/messages.ts new file mode 100644 index 000000000..b58c66170 --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/analysis/handlers/messages.ts @@ -0,0 +1,42 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import type { ExtensionToWebviewMessage } from '../../../../../shared/protocol/extensionToWebview'; +import type { GraphViewProviderAnalysisHandlers } from '../../../analysis/lifecycle'; +import { sendGraphControlsUpdated } from '../../../controls/send'; +import type { GraphViewProviderAnalysisMethodsSource } from '../methods'; + +type GraphNodeMetricUpdates = Parameters>[0]; +type GraphIndexStatusUpdated = GraphViewProviderAnalysisHandlers['sendGraphIndexStatusUpdated']; + +export function sendGraphDataUpdated( + source: GraphViewProviderAnalysisMethodsSource, + graphData: IGraphData, +): void { + sendGraphControlsUpdated( + source._rawGraphData, + source._analyzer, + (message: ExtensionToWebviewMessage) => source._sendMessage(message), + undefined, + source._disabledPlugins, + ); + source._sendMessage({ type: 'GRAPH_DATA_UPDATED', payload: graphData }); +} + +export function sendGraphNodeMetricsUpdated( + source: GraphViewProviderAnalysisMethodsSource, + updates: GraphNodeMetricUpdates, +): void { + source._sendMessage({ + type: 'GRAPH_NODE_METRICS_UPDATED', + payload: { nodes: updates }, + }); +} + +export const sendGraphIndexStatusUpdated: ( + source: GraphViewProviderAnalysisMethodsSource, + ...args: Parameters +) => void = (source, hasIndex, freshness, detail) => { + source._sendMessage({ + type: 'GRAPH_INDEX_STATUS_UPDATED', + payload: { hasIndex, freshness, detail }, + }); +}; diff --git a/packages/extension/src/extension/graphView/provider/analysis/methods.ts b/packages/extension/src/extension/graphView/provider/analysis/methods.ts index a04b7fb03..b3c06852a 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/methods.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/methods.ts @@ -1,19 +1,6 @@ -import * as vscode from 'vscode'; import type { IGraphData } from '../../../../shared/graph/contracts'; import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; -import { - executeGraphViewProviderAnalysis, - isGraphViewAbortError, - isGraphViewAnalysisStale, - markGraphViewWorkspaceReady, - runGraphViewProviderAnalysisRequest, - type GraphViewProviderAnalysisHandlers, - type GraphViewProviderAnalysisRequestHandlers, - type GraphViewProviderAnalysisState, -} from '../../analysis/lifecycle'; -import type { DiagnosticEventInput } from '@codegraphy-dev/core'; -import { getCodeGraphyConfiguration } from '../../../repoSettings/current'; -import { createExtensionDiagnosticLogger } from '../../../diagnostics/logger'; +import type { GraphViewProviderAnalysisState } from '../../analysis/lifecycle'; import { createGraphViewProviderAnalysisDelegates } from './delegates'; import { createGraphViewProviderWorkspaceReadyState, @@ -21,8 +8,21 @@ import { } from './state'; import { createGraphViewProviderDoAnalyzeAndSendData } from './execution'; import { createGraphViewProviderAnalyzeAndSendData } from './request'; +import { + canReplayStaleCache, + createFullIndexAnalysisCoordinator, +} from './fullIndex'; +import { + createDefaultGraphViewProviderAnalysisMethodDependencies, + type GraphViewProviderAnalysisMethodDependencies, +} from './methods/dependencies'; -interface GraphViewProviderWorkspaceReadyRegistryLike { +export { + createDefaultGraphViewProviderAnalysisMethodDependencies, + type GraphViewProviderAnalysisMethodDependencies, +} from './methods/dependencies'; + +export interface GraphViewProviderWorkspaceReadyRegistryLike { notifyWorkspaceReady( graphData: IGraphData, disabledPlugins?: ReadonlySet, @@ -47,6 +47,7 @@ export interface GraphViewProviderAnalysisMethodsSource { _rawGraphData: IGraphData; _firstAnalysis: boolean; _resolveFirstWorkspaceReady?: () => void; + _firstWorkspaceReadyPromise?: Promise; _sendMessage(message: ExtensionToWebviewMessage): void; _sendDepthState(): void; _computeMergedGroups(): void; @@ -83,129 +84,6 @@ export interface GraphViewProviderAnalysisMethods { _isAbortError(error: unknown): boolean; } -export interface GraphViewProviderAnalysisMethodDependencies { - runAnalysisRequest: ( - state: GraphViewProviderAnalysisState, - handlers: GraphViewProviderAnalysisRequestHandlers, - ) => Promise; - executeAnalysis: ( - signal: AbortSignal, - requestId: number, - state: GraphViewProviderAnalysisState, - handlers: GraphViewProviderAnalysisHandlers, - ) => Promise; - markWorkspaceReady: ( - state: { - firstAnalysis: boolean; - resolveFirstWorkspaceReady: (() => void) | undefined; - }, - registry: GraphViewProviderWorkspaceReadyRegistryLike | undefined, - graphData: IGraphData, - disabledPlugins?: ReadonlySet, - ) => void; - isAnalysisStale: ( - signal: AbortSignal, - requestId: number, - currentRequestId: number, - ) => boolean; - isAbortError(error: unknown): boolean; - hasWorkspace(): boolean; - logError(message: string, error: unknown): void; - emitDiagnostic?(input: DiagnosticEventInput): void; -} - -export function createDefaultGraphViewProviderAnalysisMethodDependencies(): GraphViewProviderAnalysisMethodDependencies { - const diagnostics = createExtensionDiagnosticLogger({ - isEnabled: () => getCodeGraphyConfiguration().get('verboseDiagnostics', false), - }); - - return { - runAnalysisRequest: runGraphViewProviderAnalysisRequest, - executeAnalysis: executeGraphViewProviderAnalysis, - markWorkspaceReady: markGraphViewWorkspaceReady, - isAnalysisStale: isGraphViewAnalysisStale, - isAbortError: isGraphViewAbortError, - hasWorkspace: () => (vscode.workspace.workspaceFolders?.length ?? 0) > 0, - logError: (message, error) => { - console.error(message, error); - }, - emitDiagnostic: input => diagnostics.emit(input), - }; -} - -interface FullIndexAnalysisCoordinator { - runAfterFullIndexAnalysis(runAnalysis: () => Promise): Promise; - runFullIndexAnalysis(runAnalysis: () => Promise): Promise; - runFullIndexAnalysisInBackground(runAnalysis: () => Promise): void; - waitForFullIndexAnalysis(): Promise; -} - -function createFullIndexAnalysisCoordinator( - dependencies: Pick, -): FullIndexAnalysisCoordinator { - let fullIndexAnalysisPromise: Promise | undefined; - - const waitForFullIndexAnalysis = async (): Promise => { - if (!fullIndexAnalysisPromise) { - return false; - } - - try { - await fullIndexAnalysisPromise; - } catch { - // The request that owns the reindex reports the failure. Competing - // fire-and-forget webview loads should not create duplicate errors. - } - return true; - }; - - const runFullIndexAnalysis = async ( - runAnalysis: () => Promise, - ): Promise => { - if (fullIndexAnalysisPromise) { - await fullIndexAnalysisPromise; - return; - } - - const analysisPromise = runAnalysis(); - fullIndexAnalysisPromise = analysisPromise; - try { - await analysisPromise; - } finally { - if (fullIndexAnalysisPromise === analysisPromise) { - fullIndexAnalysisPromise = undefined; - } - } - }; - - const runFullIndexAnalysisInBackground = ( - runAnalysis: () => Promise, - ): void => { - void runFullIndexAnalysis(runAnalysis).catch(error => { - dependencies.logError('[CodeGraphy] Background cache sync failed:', error); - }); - }; - - const runAfterFullIndexAnalysis = async ( - runAnalysis: () => Promise, - ): Promise => { - await waitForFullIndexAnalysis(); - await runAnalysis(); - }; - - return { - runAfterFullIndexAnalysis, - runFullIndexAnalysis, - runFullIndexAnalysisInBackground, - waitForFullIndexAnalysis, - }; -} - -function canReplayStaleCache(source: GraphViewProviderAnalysisMethodsSource): boolean { - return source._analyzer?.getIndexStatus?.().freshness === 'stale' - && typeof source._analyzer.loadCachedGraph === 'function'; -} - export function createGraphViewProviderAnalysisMethods( source: GraphViewProviderAnalysisMethodsSource, dependencies: GraphViewProviderAnalysisMethodDependencies = @@ -292,7 +170,11 @@ export function createGraphViewProviderAnalysisMethods( 'refresh', ); const _incrementalAnalyzeAndSendData = async (filePaths: readonly string[]): Promise => { - await fullIndexAnalysis.waitForFullIndexAnalysis(); + await fullIndexAnalysis.waitForForegroundFullIndexAnalysis(); + if (source._firstAnalysis && source._firstWorkspaceReadyPromise) { + await source._firstWorkspaceReadyPromise; + } + source._changedFilePaths = [...filePaths]; const doIncrementalAnalyzeAndSendData = createGraphViewProviderDoAnalyzeAndSendData( source, @@ -319,7 +201,10 @@ export function createGraphViewProviderAnalysisMethods( await _loadAndSendData(); if (canReplayStaleCache(source)) { - fullIndexAnalysis.runFullIndexAnalysisInBackground(_analyzeAndSendData); + fullIndexAnalysis.runFullIndexAnalysisInBackground( + _analyzeAndSendData, + () => source._analysisController === undefined, + ); } }, _indexAndSendData: () => fullIndexAnalysis.runFullIndexAnalysis(_indexAndSendData), diff --git a/packages/extension/src/extension/graphView/provider/analysis/methods/dependencies.ts b/packages/extension/src/extension/graphView/provider/analysis/methods/dependencies.ts new file mode 100644 index 000000000..a7d725adf --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/analysis/methods/dependencies.ts @@ -0,0 +1,66 @@ +import * as vscode from 'vscode'; +import type { DiagnosticEventInput } from '@codegraphy-dev/core'; +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import { + executeGraphViewProviderAnalysis, + isGraphViewAbortError, + isGraphViewAnalysisStale, + markGraphViewWorkspaceReady, + runGraphViewProviderAnalysisRequest, + type GraphViewProviderAnalysisHandlers, + type GraphViewProviderAnalysisRequestHandlers, + type GraphViewProviderAnalysisState, +} from '../../../analysis/lifecycle'; +import { createExtensionDiagnosticLogger } from '../../../../diagnostics/logger'; +import { getCodeGraphyConfiguration } from '../../../../repoSettings/current'; +import type { GraphViewProviderWorkspaceReadyRegistryLike } from '../methods'; + +export interface GraphViewProviderAnalysisMethodDependencies { + runAnalysisRequest: ( + state: GraphViewProviderAnalysisState, + handlers: GraphViewProviderAnalysisRequestHandlers, + ) => Promise; + executeAnalysis: ( + signal: AbortSignal, + requestId: number, + state: GraphViewProviderAnalysisState, + handlers: GraphViewProviderAnalysisHandlers, + ) => Promise; + markWorkspaceReady: ( + state: { + firstAnalysis: boolean; + resolveFirstWorkspaceReady: (() => void) | undefined; + }, + registry: GraphViewProviderWorkspaceReadyRegistryLike | undefined, + graphData: IGraphData, + disabledPlugins?: ReadonlySet, + ) => void; + isAnalysisStale: ( + signal: AbortSignal, + requestId: number, + currentRequestId: number, + ) => boolean; + isAbortError(error: unknown): boolean; + hasWorkspace(): boolean; + logError(message: string, error: unknown): void; + emitDiagnostic?(input: DiagnosticEventInput): void; +} + +export function createDefaultGraphViewProviderAnalysisMethodDependencies(): GraphViewProviderAnalysisMethodDependencies { + const diagnostics = createExtensionDiagnosticLogger({ + isEnabled: () => getCodeGraphyConfiguration().get('verboseDiagnostics', false), + }); + + return { + runAnalysisRequest: runGraphViewProviderAnalysisRequest, + executeAnalysis: executeGraphViewProviderAnalysis, + markWorkspaceReady: markGraphViewWorkspaceReady, + isAnalysisStale: isGraphViewAnalysisStale, + isAbortError: isGraphViewAbortError, + hasWorkspace: () => (vscode.workspace.workspaceFolders?.length ?? 0) > 0, + logError: (message, error) => { + console.error(message, error); + }, + emitDiagnostic: input => diagnostics.emit(input), + }; +} diff --git a/packages/extension/src/extension/graphView/provider/refresh.ts b/packages/extension/src/extension/graphView/provider/refresh.ts index 122be2f20..b564d54b3 100644 --- a/packages/extension/src/extension/graphView/provider/refresh.ts +++ b/packages/extension/src/extension/graphView/provider/refresh.ts @@ -1,465 +1,27 @@ -import type { IGraphData } from '../../../shared/graph/contracts'; -import type { ExtensionToWebviewMessage } from '../../../shared/protocol/extensionToWebview'; -import { getCodeGraphyConfiguration } from '../../repoSettings/current'; -import { rebuildGraphViewData, smartRebuildGraphView } from '../view/rebuild'; -import { createRebuildSenders } from './refresh/rebuild'; -import { runChangedFileRefresh, runIndexRefresh, runPrimaryRefresh, sendRefreshState } from './refresh/run'; - -type GraphViewScopedRefreshProgress = { phase: string; current: number; total: number }; - -interface GraphViewProviderRefreshAnalyzerLike { - hasIndex(): boolean; - rebuildGraph( - disabledPlugins: Set, - showOrphans: boolean, - ): IGraphData; - registry: { - notifyGraphRebuild( - graphData: IGraphData, - disabledPlugins?: ReadonlySet, - ): void; - }; - clearCache(): void; - refreshAnalysisScope?( - filterPatterns?: string[], - disabledPlugins?: Set, - signal?: AbortSignal, - onProgress?: (progress: GraphViewScopedRefreshProgress) => void, - ): Promise; - refreshPluginFiles?( - pluginIds: readonly string[], - filterPatterns?: string[], - disabledPlugins?: Set, - signal?: AbortSignal, - onProgress?: (progress: GraphViewScopedRefreshProgress) => void, - ): Promise; - refreshGitignoreMetadata?( - filterPatterns?: string[], - disabledPlugins?: Set, - signal?: AbortSignal, - ): Promise; -} - -interface RefreshCoordinatorState { - indexRefreshPromise: Promise | undefined; - queuedChangedFilePaths: Set; -} - -interface ScopedRefreshLifecycle { - setController(controller: AbortController): void; - clearController(controller: AbortController): void; - abort(): void; -} - -export interface GraphViewProviderRefreshMethodsSource { - _analyzer: GraphViewProviderRefreshAnalyzerLike | undefined; - _analysisController?: AbortController; - _analysisRequestId: number; - _disabledPlugins: Set; - _filterPatterns: string[]; - _rawGraphData: IGraphData; - _graphData: IGraphData; - _loadDisabledRulesAndPlugins(): boolean; - _loadGroupsAndFilterPatterns(): void; - _loadAndSendData?(): Promise; - _analyzeAndSendData(): Promise; - _refreshAndSendData?(): Promise; - _incrementalAnalyzeAndSendData?(filePaths: readonly string[]): Promise; - _sendAllSettings(): void; - _sendFavorites(favorites?: string[]): void; - _computeMergedGroups(): void; - _sendGroupsUpdated(): void; - _sendGraphControls?(): void; - _sendSettings(): void; - _sendPhysicsSettings(): void; - _updateViewContext(): void; - _applyViewTransform(): void; - _sendDepthState(): void; - _sendPluginStatuses(): void; - _sendDecorations(): void; - _sendMessage(message: ExtensionToWebviewMessage): void; - _rebuildAndSend?(this: void): void; -} - -export interface GraphViewProviderRefreshMethods { - refresh(): Promise; - refreshIndex(): Promise; - refreshGitignoreMetadata(): Promise; - refreshAnalysisScope(): Promise; - refreshPluginFiles(pluginIds: readonly string[]): Promise; - refreshChangedFiles(filePaths: readonly string[]): Promise; - refreshGroupSettings(): void; - refreshPhysicsSettings(): void; - refreshSettings(): void; - refreshToggleSettings(): void; - clearCacheAndRefresh(): Promise; - _rebuildAndSend(): void; - _smartRebuild(id: string): void; -} - -export interface GraphViewProviderRefreshMethodDependencies { - getShowOrphans(): boolean; - rebuildGraphData: typeof rebuildGraphViewData; - smartRebuildGraphData: typeof smartRebuildGraphView; -} - -export const DEFAULT_DEPENDENCIES: GraphViewProviderRefreshMethodDependencies = { - getShowOrphans: () => - getCodeGraphyConfiguration().get('showOrphans', true), - rebuildGraphData: rebuildGraphViewData, - smartRebuildGraphData: smartRebuildGraphView, -}; - -function isScopedRefreshStale( - source: GraphViewProviderRefreshMethodsSource, - signal: AbortSignal, - requestId: number, -): boolean { - return signal.aborted || source._analysisRequestId !== requestId; -} - -async function runScopedRefreshRequest( - source: GraphViewProviderRefreshMethodsSource, - runRefresh: ( - signal: AbortSignal, - onProgress: (progress: GraphViewScopedRefreshProgress) => void, - ) => Promise, - lifecycle: { - setController(controller: AbortController): void; - clearController(controller: AbortController): void; - }, -): Promise { - source._analysisController?.abort(); - const controller = new AbortController(); - source._analysisController = controller; - lifecycle.setController(controller); - const requestId = ++source._analysisRequestId; - - const forwardProgress = (progress: GraphViewScopedRefreshProgress): void => { - if (isScopedRefreshStale(source, controller.signal, requestId)) { - return; - } - source._sendMessage({ type: 'GRAPH_INDEX_PROGRESS', payload: progress }); - }; - - try { - const graphData = await runRefresh(controller.signal, forwardProgress); - if (isScopedRefreshStale(source, controller.signal, requestId)) { - return undefined; - } - return graphData; - } catch (error) { - if (isScopedRefreshStale(source, controller.signal, requestId)) { - return undefined; - } - throw error; - } finally { - lifecycle.clearController(controller); - if (source._analysisController === controller) { - source._analysisController = undefined; - } - } -} - -function publishScopedRefreshGraphData( - source: GraphViewProviderRefreshMethodsSource, - graphData: IGraphData, -): void { - source._rawGraphData = graphData; - source._updateViewContext(); - source._applyViewTransform(); - source._computeMergedGroups(); - source._sendGroupsUpdated(); - source._sendMessage({ type: 'GRAPH_DATA_UPDATED', payload: source._graphData }); - source._sendDepthState(); - source._sendGraphControls?.(); - source._sendPluginStatuses(); - source._sendDecorations(); - source._analyzer?.registry.notifyGraphRebuild(source._graphData, source._disabledPlugins); -} - -function createScopedRefreshLifecycle(): ScopedRefreshLifecycle { - let scopedRefreshController: AbortController | undefined; - - return { - setController(controller: AbortController): void { - scopedRefreshController = controller; - }, - clearController(controller: AbortController): void { - if (scopedRefreshController === controller) { - scopedRefreshController = undefined; - } - }, - abort(): void { - scopedRefreshController?.abort(); - }, - }; -} - -function createRefreshCoordinatorState(): RefreshCoordinatorState { - return { - indexRefreshPromise: undefined, - queuedChangedFilePaths: new Set(), - }; -} - -function prepareRefreshInputs(source: GraphViewProviderRefreshMethodsSource): void { - source._loadDisabledRulesAndPlugins(); - source._loadGroupsAndFilterPatterns(); -} - -function createRefreshMethod( - source: GraphViewProviderRefreshMethodsSource, - state: RefreshCoordinatorState, -): () => Promise { - return async (): Promise => { - if (state.indexRefreshPromise) { - await state.indexRefreshPromise; - return; - } - - prepareRefreshInputs(source); - await runPrimaryRefresh(source); - sendRefreshState(source); - }; -} - -function createRefreshIndexMethod( - source: GraphViewProviderRefreshMethodsSource, - state: RefreshCoordinatorState, - refreshChangedFiles: (filePaths: readonly string[]) => Promise, -): () => Promise { - return async (): Promise => { - if (state.indexRefreshPromise) { - await state.indexRefreshPromise; - return; - } - - state.indexRefreshPromise = (async (): Promise => { - prepareRefreshInputs(source); - await runIndexRefresh(source); - sendRefreshState(source); - })(); - - try { - await state.indexRefreshPromise; - } finally { - state.indexRefreshPromise = undefined; - } - - const queuedFilePaths = [...state.queuedChangedFilePaths]; - state.queuedChangedFilePaths = new Set(); - if (queuedFilePaths.length > 0) { - await refreshChangedFiles(queuedFilePaths); - } - }; -} - -function createRefreshAnalysisScopeMethod( - source: GraphViewProviderRefreshMethodsSource, - state: RefreshCoordinatorState, - refresh: () => Promise, - scopedRefreshLifecycle: ScopedRefreshLifecycle, -): () => Promise { - return async (): Promise => { - if (state.indexRefreshPromise) { - await state.indexRefreshPromise; - } - - prepareRefreshInputs(source); - if (!source._analyzer?.hasIndex() || !source._analyzer.refreshAnalysisScope) { - await refresh(); - return; - } - - const graphData = await runScopedRefreshRequest( - source, - (signal, onProgress) => source._analyzer!.refreshAnalysisScope!( - source._filterPatterns, - source._disabledPlugins, - signal, - onProgress, - ), - scopedRefreshLifecycle, - ); - publishGraphDataIfPresent(source, graphData); - }; -} - -function createRefreshGitignoreMetadataMethod( - source: GraphViewProviderRefreshMethodsSource, - state: RefreshCoordinatorState, - refreshIndex: () => Promise, - scopedRefreshLifecycle: ScopedRefreshLifecycle, -): () => Promise { - return async (): Promise => { - if (state.indexRefreshPromise) { - await state.indexRefreshPromise; - } - - prepareRefreshInputs(source); - if (!source._analyzer?.refreshGitignoreMetadata) { - await refreshIndex(); - return; - } - - const graphData = await runScopedRefreshRequest( - source, - signal => source._analyzer!.refreshGitignoreMetadata!( - source._filterPatterns, - source._disabledPlugins, - signal, - ), - scopedRefreshLifecycle, - ); - publishGraphDataIfPresent(source, graphData); - }; -} - -function createRefreshPluginFilesMethod( - source: GraphViewProviderRefreshMethodsSource, - state: RefreshCoordinatorState, - refresh: () => Promise, - scopedRefreshLifecycle: ScopedRefreshLifecycle, -): (pluginIds: readonly string[]) => Promise { - return async (pluginIds: readonly string[]): Promise => { - if (state.indexRefreshPromise) { - await state.indexRefreshPromise; - } - - prepareRefreshInputs(source); - if (!source._analyzer?.refreshPluginFiles) { - await refresh(); - return; - } - - const graphData = await runScopedRefreshRequest( - source, - (signal, onProgress) => source._analyzer!.refreshPluginFiles!( - pluginIds, - source._filterPatterns, - source._disabledPlugins, - signal, - onProgress, - ), - scopedRefreshLifecycle, - ); - publishGraphDataIfPresent(source, graphData); - }; -} - -function publishGraphDataIfPresent( - source: GraphViewProviderRefreshMethodsSource, - graphData: IGraphData | undefined, -): void { - if (!graphData) { - return; - } - - publishScopedRefreshGraphData(source, graphData); - sendRefreshState(source); -} - -function createRefreshChangedFilesMethod( - source: GraphViewProviderRefreshMethodsSource, - state: RefreshCoordinatorState, -): (filePaths: readonly string[]) => Promise { - return async (filePaths: readonly string[]): Promise => { - if (state.indexRefreshPromise) { - state.queuedChangedFilePaths = new Set([ - ...state.queuedChangedFilePaths, - ...filePaths, - ]); - return; - } - - prepareRefreshInputs(source); - await runChangedFileRefresh(source, filePaths); - sendRefreshState(source); - }; -} +import { + createGraphViewProviderRefreshMethods as createGraphViewProviderRefreshMethodsImpl, +} from './refresh/factory'; +import type { + GraphViewProviderRefreshMethodDependencies, + GraphViewProviderRefreshMethods, + GraphViewProviderRefreshMethodsSource, +} from './refresh/contracts'; +import { DEFAULT_DEPENDENCIES } from './refresh/defaults'; + +export type { + GraphViewProviderRefreshAnalyzerLike, + GraphViewProviderRefreshMethodDependencies, + GraphViewProviderRefreshMethods, + GraphViewProviderRefreshMethodsSource, + GraphViewScopedRefreshProgress, + RefreshCoordinatorState, + ScopedRefreshLifecycle, +} from './refresh/contracts'; +export { DEFAULT_DEPENDENCIES } from './refresh/defaults'; export function createGraphViewProviderRefreshMethods( source: GraphViewProviderRefreshMethodsSource, dependencies: GraphViewProviderRefreshMethodDependencies = DEFAULT_DEPENDENCIES, ): GraphViewProviderRefreshMethods { - const rebuildSenders = createRebuildSenders(source, dependencies); - const _rebuildAndSend = (): void => rebuildSenders.rebuildAndSend(); - const scopedRefreshLifecycle = createScopedRefreshLifecycle(); - const _smartRebuild = (id: string): void => { - scopedRefreshLifecycle.abort(); - rebuildSenders.smartRebuild(id); - }; - // Full reindex clears the persisted cache first, so competing refreshes - // must wait or they can rebuild from an empty intermediate index. - const state = createRefreshCoordinatorState(); - const refresh = createRefreshMethod(source, state); - const refreshChangedFiles = createRefreshChangedFilesMethod(source, state); - const refreshIndex = createRefreshIndexMethod(source, state, refreshChangedFiles); - const refreshAnalysisScope = createRefreshAnalysisScopeMethod( - source, - state, - refresh, - scopedRefreshLifecycle, - ); - const refreshGitignoreMetadata = createRefreshGitignoreMetadataMethod( - source, - state, - refreshIndex, - scopedRefreshLifecycle, - ); - const refreshPluginFiles = createRefreshPluginFilesMethod( - source, - state, - refresh, - scopedRefreshLifecycle, - ); - - const refreshPhysicsSettings = (): void => { - source._sendPhysicsSettings(); - }; - - const refreshGroupSettings = (): void => { - source._loadGroupsAndFilterPatterns(); - source._sendGroupsUpdated(); - }; - - const refreshSettings = (): void => { - source._sendSettings(); - source._sendGraphControls?.(); - }; - - const refreshToggleSettings = (): void => { - if (!source._loadDisabledRulesAndPlugins()) return; - scopedRefreshLifecycle.abort(); - if (source._rebuildAndSend) { - source._rebuildAndSend(); - return; - } - - _rebuildAndSend(); - }; - - const clearCacheAndRefresh = async (): Promise => { - source._analyzer?.clearCache(); - await refreshIndex(); - }; - - const methods: GraphViewProviderRefreshMethods = { - refresh, - refreshIndex, - refreshGitignoreMetadata, - refreshAnalysisScope, - refreshPluginFiles, - refreshChangedFiles, - refreshGroupSettings, - refreshPhysicsSettings, - refreshSettings, - refreshToggleSettings, - clearCacheAndRefresh, - _rebuildAndSend, - _smartRebuild, - }; - - return methods; + return createGraphViewProviderRefreshMethodsImpl(source, dependencies); } diff --git a/packages/extension/src/extension/graphView/provider/refresh/contracts.ts b/packages/extension/src/extension/graphView/provider/refresh/contracts.ts new file mode 100644 index 000000000..3bdea7f09 --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/refresh/contracts.ts @@ -0,0 +1,101 @@ +import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; +import type { rebuildGraphViewData, smartRebuildGraphView } from '../../view/rebuild'; + +export type GraphViewScopedRefreshProgress = { phase: string; current: number; total: number }; + +export interface GraphViewProviderRefreshAnalyzerLike { + hasIndex(): boolean; + rebuildGraph( + disabledPlugins: Set, + showOrphans: boolean, + ): IGraphData; + registry: { + notifyGraphRebuild( + graphData: IGraphData, + disabledPlugins?: ReadonlySet, + ): void; + }; + clearCache(): void; + refreshAnalysisScope?( + filterPatterns?: string[], + disabledPlugins?: Set, + signal?: AbortSignal, + onProgress?: (progress: GraphViewScopedRefreshProgress) => void, + ): Promise; + refreshPluginFiles?( + pluginIds: readonly string[], + filterPatterns?: string[], + disabledPlugins?: Set, + signal?: AbortSignal, + onProgress?: (progress: GraphViewScopedRefreshProgress) => void, + ): Promise; + refreshGitignoreMetadata?( + filterPatterns?: string[], + disabledPlugins?: Set, + signal?: AbortSignal, + ): Promise; +} + +export interface RefreshCoordinatorState { + indexRefreshPromise: Promise | undefined; + queuedChangedFilePaths: Set; +} + +export interface ScopedRefreshLifecycle { + setController(controller: AbortController): void; + clearController(controller: AbortController): void; + abort(): void; +} + +export interface GraphViewProviderRefreshMethodsSource { + _analyzer: GraphViewProviderRefreshAnalyzerLike | undefined; + _analysisController?: AbortController; + _analysisRequestId: number; + _disabledPlugins: Set; + _filterPatterns: string[]; + _rawGraphData: IGraphData; + _graphData: IGraphData; + _loadDisabledRulesAndPlugins(): boolean; + _loadGroupsAndFilterPatterns(): void; + _loadAndSendData?(): Promise; + _analyzeAndSendData(): Promise; + _refreshAndSendData?(): Promise; + _incrementalAnalyzeAndSendData?(filePaths: readonly string[]): Promise; + _sendAllSettings(): void; + _sendFavorites(favorites?: string[]): void; + _computeMergedGroups(): void; + _sendGroupsUpdated(): void; + _sendGraphControls?(): void; + _sendSettings(): void; + _sendPhysicsSettings(): void; + _updateViewContext(): void; + _applyViewTransform(): void; + _sendDepthState(): void; + _sendPluginStatuses(): void; + _sendDecorations(): void; + _sendMessage(message: ExtensionToWebviewMessage): void; + _rebuildAndSend?(this: void): void; +} + +export interface GraphViewProviderRefreshMethods { + refresh(): Promise; + refreshIndex(): Promise; + refreshGitignoreMetadata(): Promise; + refreshAnalysisScope(): Promise; + refreshPluginFiles(pluginIds: readonly string[]): Promise; + refreshChangedFiles(filePaths: readonly string[]): Promise; + refreshGroupSettings(): void; + refreshPhysicsSettings(): void; + refreshSettings(): void; + refreshToggleSettings(): void; + clearCacheAndRefresh(): Promise; + _rebuildAndSend(): void; + _smartRebuild(id: string): void; +} + +export interface GraphViewProviderRefreshMethodDependencies { + getShowOrphans(): boolean; + rebuildGraphData: typeof rebuildGraphViewData; + smartRebuildGraphData: typeof smartRebuildGraphView; +} diff --git a/packages/extension/src/extension/graphView/provider/refresh/coordinator.ts b/packages/extension/src/extension/graphView/provider/refresh/coordinator.ts new file mode 100644 index 000000000..3a7ea0a1e --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/refresh/coordinator.ts @@ -0,0 +1,23 @@ +import type { + GraphViewProviderRefreshMethodsSource, + RefreshCoordinatorState, +} from './contracts'; +import { canRunIncrementalChangedFileRefresh } from './run'; + +export function createRefreshCoordinatorState(): RefreshCoordinatorState { + return { + indexRefreshPromise: undefined, + queuedChangedFilePaths: new Set(), + }; +} + +export function prepareRefreshInputs(source: GraphViewProviderRefreshMethodsSource): void { + source._loadDisabledRulesAndPlugins(); + source._loadGroupsAndFilterPatterns(); +} + +export function canRunIndexedChangedFileRefresh( + source: GraphViewProviderRefreshMethodsSource, +): boolean { + return canRunIncrementalChangedFileRefresh(source); +} diff --git a/packages/extension/src/extension/graphView/provider/refresh/defaults.ts b/packages/extension/src/extension/graphView/provider/refresh/defaults.ts new file mode 100644 index 000000000..d89ac1424 --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/refresh/defaults.ts @@ -0,0 +1,10 @@ +import { getCodeGraphyConfiguration } from '../../../repoSettings/current'; +import { rebuildGraphViewData, smartRebuildGraphView } from '../../view/rebuild'; +import type { GraphViewProviderRefreshMethodDependencies } from './contracts'; + +export const DEFAULT_DEPENDENCIES: GraphViewProviderRefreshMethodDependencies = { + getShowOrphans: () => + getCodeGraphyConfiguration().get('showOrphans', true), + rebuildGraphData: rebuildGraphViewData, + smartRebuildGraphData: smartRebuildGraphView, +}; diff --git a/packages/extension/src/extension/graphView/provider/refresh/factory.ts b/packages/extension/src/extension/graphView/provider/refresh/factory.ts new file mode 100644 index 000000000..782add154 --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/refresh/factory.ts @@ -0,0 +1,85 @@ +import { createRebuildSenders } from './rebuild'; +import type { + GraphViewProviderRefreshMethodDependencies, + GraphViewProviderRefreshMethods, + GraphViewProviderRefreshMethodsSource, +} from './contracts'; +import { createRefreshCoordinatorState } from './coordinator'; +import { DEFAULT_DEPENDENCIES } from './defaults'; +import { + createRefreshChangedFilesMethod, + createRefreshIndexMethod, + createRefreshMethod, +} from './requests/methods'; +import { createScopedRefreshLifecycle } from './scoped/lifecycle'; +import { + createRefreshAnalysisScopeMethod, + createRefreshGitignoreMetadataMethod, + createRefreshPluginFilesMethod, +} from './scoped/methods'; + +export function createGraphViewProviderRefreshMethods( + source: GraphViewProviderRefreshMethodsSource, + dependencies: GraphViewProviderRefreshMethodDependencies = DEFAULT_DEPENDENCIES, +): GraphViewProviderRefreshMethods { + const rebuildSenders = createRebuildSenders(source, dependencies); + const _rebuildAndSend = (): void => rebuildSenders.rebuildAndSend(); + const scopedRefreshLifecycle = createScopedRefreshLifecycle(); + const _smartRebuild = (id: string): void => { + scopedRefreshLifecycle.abort(); + rebuildSenders.smartRebuild(id); + }; + const state = createRefreshCoordinatorState(); + const refresh = createRefreshMethod(source, state); + const refreshChangedFiles = createRefreshChangedFilesMethod(source, state); + const refreshIndex = createRefreshIndexMethod(source, state, refreshChangedFiles); + const refreshAnalysisScope = createRefreshAnalysisScopeMethod( + source, + state, + refresh, + scopedRefreshLifecycle, + ); + const refreshGitignoreMetadata = createRefreshGitignoreMetadataMethod( + source, + state, + refreshIndex, + scopedRefreshLifecycle, + ); + const refreshPluginFiles = createRefreshPluginFilesMethod( + source, + state, + refresh, + scopedRefreshLifecycle, + ); + + return { + refresh, + refreshIndex, + refreshGitignoreMetadata, + refreshAnalysisScope, + refreshPluginFiles, + refreshChangedFiles, + refreshGroupSettings: () => { + source._loadGroupsAndFilterPatterns(); + source._sendGroupsUpdated(); + }, + refreshPhysicsSettings: () => { + source._sendPhysicsSettings(); + }, + refreshSettings: () => { + source._sendSettings(); + source._sendGraphControls?.(); + }, + refreshToggleSettings: () => { + if (!source._loadDisabledRulesAndPlugins()) return; + scopedRefreshLifecycle.abort(); + (source._rebuildAndSend ?? _rebuildAndSend)(); + }, + clearCacheAndRefresh: async () => { + source._analyzer?.clearCache(); + await refreshIndex(); + }, + _rebuildAndSend, + _smartRebuild, + }; +} diff --git a/packages/extension/src/extension/graphView/provider/refresh/rebuild.ts b/packages/extension/src/extension/graphView/provider/refresh/rebuild.ts index 5428c18d3..2614ec5b8 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/rebuild.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/rebuild.ts @@ -2,7 +2,7 @@ import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/exte import type { GraphViewProviderRefreshMethodDependencies, GraphViewProviderRefreshMethodsSource, -} from '../refresh'; +} from './contracts'; export function createRebuildSenders( source: GraphViewProviderRefreshMethodsSource, diff --git a/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts b/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts new file mode 100644 index 000000000..511571cf7 --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts @@ -0,0 +1,87 @@ +import type { + GraphViewProviderRefreshMethodsSource, + RefreshCoordinatorState, +} from '../contracts'; +import { + canRunIndexedChangedFileRefresh, + prepareRefreshInputs, +} from '../coordinator'; +import { + runChangedFileRefresh, + runIndexRefresh, + runPrimaryRefresh, + sendRefreshState, +} from '../run'; + +export function createRefreshMethod( + source: GraphViewProviderRefreshMethodsSource, + state: RefreshCoordinatorState, +): () => Promise { + return async (): Promise => { + if (state.indexRefreshPromise) { + await state.indexRefreshPromise; + return; + } + + prepareRefreshInputs(source); + await runPrimaryRefresh(source); + sendRefreshState(source); + }; +} + +export function createRefreshIndexMethod( + source: GraphViewProviderRefreshMethodsSource, + state: RefreshCoordinatorState, + refreshChangedFiles: (filePaths: readonly string[]) => Promise, +): () => Promise { + return async (): Promise => { + if (state.indexRefreshPromise) { + await state.indexRefreshPromise; + return; + } + + state.indexRefreshPromise = runIndexRefreshWithInputs(source); + try { + await state.indexRefreshPromise; + } finally { + state.indexRefreshPromise = undefined; + } + + const queuedFilePaths = [...state.queuedChangedFilePaths]; + state.queuedChangedFilePaths = new Set(); + if (queuedFilePaths.length > 0) { + await refreshChangedFiles(queuedFilePaths); + } + }; +} + +export function createRefreshChangedFilesMethod( + source: GraphViewProviderRefreshMethodsSource, + state: RefreshCoordinatorState, +): (filePaths: readonly string[]) => Promise { + return async (filePaths: readonly string[]): Promise => { + if (state.indexRefreshPromise) { + state.queuedChangedFilePaths = new Set([ + ...state.queuedChangedFilePaths, + ...filePaths, + ]); + return; + } + + if (!canRunIndexedChangedFileRefresh(source)) { + prepareRefreshInputs(source); + } + const refreshMode = await runChangedFileRefresh(source, filePaths); + if (refreshMode !== 'incremental') { + sendRefreshState(source); + } + }; +} + +async function runIndexRefreshWithInputs( + source: GraphViewProviderRefreshMethodsSource, +): Promise { + prepareRefreshInputs(source); + await runIndexRefresh(source); + sendRefreshState(source); +} diff --git a/packages/extension/src/extension/graphView/provider/refresh/run.ts b/packages/extension/src/extension/graphView/provider/refresh/run.ts index d77058ee9..5e0edb0b3 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/run.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/run.ts @@ -1,6 +1,11 @@ -import type { GraphViewProviderRefreshMethodsSource } from '../refresh'; +import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { GraphViewProviderRefreshMethodsSource } from './contracts'; -export function sendRefreshState(source: GraphViewProviderRefreshMethodsSource): void { +export type ChangedFileRefreshMode = 'analysis' | 'incremental' | 'primary'; + +export function sendRefreshState( + source: GraphViewProviderRefreshMethodsSource, +): void { source._sendAllSettings(); source._sendGraphControls?.(); } @@ -23,19 +28,36 @@ export async function runIndexRefresh(source: GraphViewProviderRefreshMethodsSou await source._analyzeAndSendData(); } +function hasGraphData(graphData: IGraphData | undefined): boolean { + return (graphData?.nodes.length ?? 0) > 0 || (graphData?.edges.length ?? 0) > 0; +} + +export function canRunIncrementalChangedFileRefresh( + source: GraphViewProviderRefreshMethodsSource, +): boolean { + if (!source._analyzer || !source._incrementalAnalyzeAndSendData) { + return false; + } + + return source._analyzer.hasIndex() + || hasGraphData(source._rawGraphData) + || hasGraphData(source._graphData); +} + export async function runChangedFileRefresh( source: GraphViewProviderRefreshMethodsSource, filePaths: readonly string[], -): Promise { - if (!source._analyzer?.hasIndex()) { - await runPrimaryRefresh(source); - return; +): Promise { + if (canRunIncrementalChangedFileRefresh(source)) { + await source._incrementalAnalyzeAndSendData!(filePaths); + return 'incremental'; } - if (source._incrementalAnalyzeAndSendData) { - await source._incrementalAnalyzeAndSendData(filePaths); - return; + if (!source._analyzer?.hasIndex()) { + await runPrimaryRefresh(source); + return 'primary'; } await source._analyzeAndSendData(); + return 'analysis'; } diff --git a/packages/extension/src/extension/graphView/provider/refresh/scoped/lifecycle.ts b/packages/extension/src/extension/graphView/provider/refresh/scoped/lifecycle.ts new file mode 100644 index 000000000..f16b3af0a --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/refresh/scoped/lifecycle.ts @@ -0,0 +1,99 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import { createGraphViewIndexProgressCoalescer } from '../../../analysis/execution/progress'; +import type { + GraphViewProviderRefreshMethodsSource, + GraphViewScopedRefreshProgress, + ScopedRefreshLifecycle, +} from '../contracts'; +import { sendRefreshState } from '../run'; + +export function createScopedRefreshLifecycle(): ScopedRefreshLifecycle { + let scopedRefreshController: AbortController | undefined; + + return { + setController(controller: AbortController): void { + scopedRefreshController = controller; + }, + clearController(controller: AbortController): void { + if (scopedRefreshController === controller) { + scopedRefreshController = undefined; + } + }, + abort(): void { + scopedRefreshController?.abort(); + }, + }; +} + +export async function runScopedRefreshRequest( + source: GraphViewProviderRefreshMethodsSource, + runRefresh: ( + signal: AbortSignal, + onProgress: (progress: GraphViewScopedRefreshProgress) => void, + ) => Promise, + lifecycle: Pick, +): Promise { + source._analysisController?.abort(); + const controller = new AbortController(); + source._analysisController = controller; + lifecycle.setController(controller); + const requestId = ++source._analysisRequestId; + + const sendProgress = createGraphViewIndexProgressCoalescer((progress: GraphViewScopedRefreshProgress) => { + if (!isScopedRefreshStale(source, controller.signal, requestId)) { + source._sendMessage({ type: 'GRAPH_INDEX_PROGRESS', payload: progress }); + } + }); + + try { + const graphData = await runRefresh(controller.signal, sendProgress); + return isScopedRefreshStale(source, controller.signal, requestId) ? undefined : graphData; + } catch (error) { + if (!isScopedRefreshStale(source, controller.signal, requestId)) { + throw error; + } + return undefined; + } finally { + lifecycle.clearController(controller); + if (source._analysisController === controller) { + source._analysisController = undefined; + } + } +} + +export function publishScopedRefreshGraphData( + source: GraphViewProviderRefreshMethodsSource, + graphData: IGraphData, +): void { + source._rawGraphData = graphData; + source._updateViewContext(); + source._applyViewTransform(); + source._computeMergedGroups(); + source._sendGroupsUpdated(); + source._sendMessage({ type: 'GRAPH_DATA_UPDATED', payload: source._graphData }); + source._sendDepthState(); + source._sendGraphControls?.(); + source._sendPluginStatuses(); + source._sendDecorations(); + source._analyzer?.registry.notifyGraphRebuild(source._graphData, source._disabledPlugins); +} + +export function publishGraphDataIfPresent( + source: GraphViewProviderRefreshMethodsSource, + graphData: IGraphData | undefined, +): void { + if (!graphData) { + return; + } + + publishScopedRefreshGraphData(source, graphData); + sendRefreshState(source); +} + +function isScopedRefreshStale( + source: GraphViewProviderRefreshMethodsSource, + signal: AbortSignal, + requestId: number, +): boolean { + return signal.aborted || source._analysisRequestId !== requestId; +} diff --git a/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts b/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts new file mode 100644 index 000000000..a34aa97f5 --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts @@ -0,0 +1,103 @@ +import type { + GraphViewProviderRefreshMethodsSource, + RefreshCoordinatorState, + ScopedRefreshLifecycle, +} from '../contracts'; +import { prepareRefreshInputs } from '../coordinator'; +import { + publishGraphDataIfPresent, + runScopedRefreshRequest, +} from './lifecycle'; + +export function createRefreshAnalysisScopeMethod( + source: GraphViewProviderRefreshMethodsSource, + state: RefreshCoordinatorState, + refresh: () => Promise, + scopedRefreshLifecycle: ScopedRefreshLifecycle, +): () => Promise { + return async (): Promise => { + if (state.indexRefreshPromise) { + await state.indexRefreshPromise; + } + + prepareRefreshInputs(source); + if (!source._analyzer?.hasIndex() || !source._analyzer.refreshAnalysisScope) { + await refresh(); + return; + } + + const graphData = await runScopedRefreshRequest( + source, + (signal, onProgress) => source._analyzer!.refreshAnalysisScope!( + source._filterPatterns, + source._disabledPlugins, + signal, + onProgress, + ), + scopedRefreshLifecycle, + ); + publishGraphDataIfPresent(source, graphData); + }; +} + +export function createRefreshGitignoreMetadataMethod( + source: GraphViewProviderRefreshMethodsSource, + state: RefreshCoordinatorState, + refreshIndex: () => Promise, + scopedRefreshLifecycle: ScopedRefreshLifecycle, +): () => Promise { + return async (): Promise => { + if (state.indexRefreshPromise) { + await state.indexRefreshPromise; + } + + prepareRefreshInputs(source); + if (!source._analyzer?.refreshGitignoreMetadata) { + await refreshIndex(); + return; + } + + const graphData = await runScopedRefreshRequest( + source, + signal => source._analyzer!.refreshGitignoreMetadata!( + source._filterPatterns, + source._disabledPlugins, + signal, + ), + scopedRefreshLifecycle, + ); + publishGraphDataIfPresent(source, graphData); + }; +} + +export function createRefreshPluginFilesMethod( + source: GraphViewProviderRefreshMethodsSource, + state: RefreshCoordinatorState, + refresh: () => Promise, + scopedRefreshLifecycle: ScopedRefreshLifecycle, +): (pluginIds: readonly string[]) => Promise { + return async (pluginIds: readonly string[]): Promise => { + if (state.indexRefreshPromise) { + await state.indexRefreshPromise; + } + + prepareRefreshInputs(source); + if (!source._analyzer?.refreshPluginFiles) { + await refresh(); + return; + } + + const graphData = await runScopedRefreshRequest( + source, + (signal, onProgress) => source._analyzer!.refreshPluginFiles!( + pluginIds, + source._filterPatterns, + source._disabledPlugins, + signal, + onProgress, + ), + scopedRefreshLifecycle, + ); + publishGraphDataIfPresent(source, graphData); + }; +} diff --git a/packages/extension/src/extension/graphView/provider/runtime/state/model.ts b/packages/extension/src/extension/graphView/provider/runtime/state/model.ts index a864f0af0..45b98da35 100644 --- a/packages/extension/src/extension/graphView/provider/runtime/state/model.ts +++ b/packages/extension/src/extension/graphView/provider/runtime/state/model.ts @@ -112,6 +112,9 @@ export class GraphViewProviderRuntime { Object.assign(this, createGraphViewProviderRuntimeFlagState()); this._analyzer = new WorkspacePipeline(_context); + void this._analyzer.warmGraphCache().catch(error => { + console.warn('[CodeGraphy] Failed to warm repo-local Graph Cache.', error); + }); this._viewRegistry = new ViewRegistry(); this._eventBus = new EventBus(); this._decorationManager = new DecorationManager(); diff --git a/packages/extension/src/extension/graphView/provider/runtime/workspaceRefreshPersistence.ts b/packages/extension/src/extension/graphView/provider/runtime/workspaceRefreshPersistence.ts index 88610bcde..370340da4 100644 --- a/packages/extension/src/extension/graphView/provider/runtime/workspaceRefreshPersistence.ts +++ b/packages/extension/src/extension/graphView/provider/runtime/workspaceRefreshPersistence.ts @@ -2,6 +2,7 @@ import { readCodeGraphyRepoMeta, writeCodeGraphyRepoMeta, } from '../../../repoSettings/meta'; +import { shouldIgnoreWorkspaceFileWatcherRefresh } from '../../../workspaceFiles/ignore'; export interface PendingWorkspaceRefreshState { filePaths: Set; @@ -9,6 +10,35 @@ export interface PendingWorkspaceRefreshState { logMessage: string; } +function normalizeFilePath(filePath: string): string { + return filePath.replace(/\\/g, '/').replace(/\/+$/, ''); +} + +function filterPendingWorkspaceRefreshPaths( + workspaceRoot: string, + filePaths: readonly string[], +): string[] { + const normalizedWorkspaceRoot = normalizeFilePath(workspaceRoot); + return filePaths.filter((filePath) => { + if (normalizeFilePath(filePath) === normalizedWorkspaceRoot) { + return false; + } + + return !shouldIgnoreWorkspaceFileWatcherRefresh(filePath); + }); +} + +function persistPendingWorkspaceRefreshPaths( + workspaceRoot: string, + filePaths: readonly string[], +): void { + const meta = readCodeGraphyRepoMeta(workspaceRoot); + writeCodeGraphyRepoMeta(workspaceRoot, { + ...meta, + pendingChangedFiles: [...filePaths], + }); +} + export function persistPendingWorkspaceRefresh( workspaceRoot: string | undefined, filePaths: readonly string[], @@ -17,11 +47,10 @@ export function persistPendingWorkspaceRefresh( return; } - const meta = readCodeGraphyRepoMeta(workspaceRoot); - writeCodeGraphyRepoMeta(workspaceRoot, { - ...meta, - pendingChangedFiles: [...filePaths], - }); + persistPendingWorkspaceRefreshPaths( + workspaceRoot, + filterPendingWorkspaceRefreshPaths(workspaceRoot, filePaths), + ); } export function loadPersistedWorkspaceRefresh( @@ -32,13 +61,21 @@ export function loadPersistedWorkspaceRefresh( } const meta = readCodeGraphyRepoMeta(workspaceRoot); - if (meta.pendingChangedFiles.length === 0) { + const pendingChangedFiles = filterPendingWorkspaceRefreshPaths( + workspaceRoot, + meta.pendingChangedFiles, + ); + if (pendingChangedFiles.length !== meta.pendingChangedFiles.length) { + persistPendingWorkspaceRefreshPaths(workspaceRoot, pendingChangedFiles); + } + + if (pendingChangedFiles.length === 0) { return undefined; } return { - filePaths: new Set(meta.pendingChangedFiles), - gitignoreRefresh: meta.pendingChangedFiles.some(filePath => + filePaths: new Set(pendingChangedFiles), + gitignoreRefresh: pendingChangedFiles.some(filePath => filePath.replace(/\\/g, '/').endsWith('/.gitignore') || filePath.replace(/\\/g, '/') === '.gitignore' ), diff --git a/packages/extension/src/extension/graphView/provider/webview/messages.ts b/packages/extension/src/extension/graphView/provider/webview/messages.ts index 016267a13..7627e8bf1 100644 --- a/packages/extension/src/extension/graphView/provider/webview/messages.ts +++ b/packages/extension/src/extension/graphView/provider/webview/messages.ts @@ -11,11 +11,11 @@ export function sendGraphViewProviderWebviewMessage( dependencies: Pick, message: unknown, ): void { + const sidebarViews = getGraphViewProviderSidebarViews(source); dependencies.sendWebviewMessage( - getGraphViewProviderSidebarViews(source), + sidebarViews, source._panels, message, ); source._notifyExtensionMessage(message); } - diff --git a/packages/extension/src/extension/graphView/provider/webview/resolve.ts b/packages/extension/src/extension/graphView/provider/webview/resolve.ts index 528e37172..87348ecbd 100644 --- a/packages/extension/src/extension/graphView/provider/webview/resolve.ts +++ b/packages/extension/src/extension/graphView/provider/webview/resolve.ts @@ -1,7 +1,12 @@ import type * as vscode from 'vscode'; -import type { CodeGraphyWebviewKind } from '../../webview/html'; import type { GraphViewProviderWebviewMethodDependencies } from './defaultDependencies'; import type { GraphViewProviderSidebarViewSource } from './sidebarViews'; +import { + assignResolvedWebviewView, + clearResolvedWebviewView, + getWebviewKind, + maybeFlushPendingWorkspaceRefresh, +} from './resolve/views'; export interface GraphViewProviderWebviewResolveSource extends GraphViewProviderSidebarViewSource { _extensionUri: vscode.Uri; @@ -9,57 +14,6 @@ export interface GraphViewProviderWebviewResolveSource extends GraphViewProvider flushPendingWorkspaceRefresh?(): void; } -function isTimelineWebviewView(webviewView: vscode.WebviewView): boolean { - return webviewView.viewType === 'codegraphy.timelineView'; -} - -function getWebviewKind(webviewView: vscode.WebviewView): CodeGraphyWebviewKind { - if (isTimelineWebviewView(webviewView)) { - return 'timeline'; - } - - return 'graph'; -} - -function assignResolvedWebviewView( - source: GraphViewProviderWebviewResolveSource, - webviewView: vscode.WebviewView, - viewKind: CodeGraphyWebviewKind, - workspaceTitle: string | undefined, -): void { - if (viewKind === 'timeline') { - source._timelineView = webviewView; - return; - } - - webviewView.title = workspaceTitle ?? 'Graph'; - source._view = webviewView; -} - -function clearResolvedWebviewView( - source: GraphViewProviderWebviewResolveSource, - webviewView: vscode.WebviewView, - viewKind: CodeGraphyWebviewKind, -): void { - if (viewKind === 'timeline' && source._timelineView === webviewView) { - source._timelineView = undefined; - } - - if (viewKind === 'graph' && source._view === webviewView) { - source._view = undefined; - } -} - -function maybeFlushPendingWorkspaceRefresh( - source: GraphViewProviderWebviewResolveSource, - webviewView: vscode.WebviewView, - viewKind: CodeGraphyWebviewKind, -): void { - if (viewKind === 'graph' && webviewView.visible) { - source.flushPendingWorkspaceRefresh?.(); - } -} - export function resolveGraphViewProviderWebviewView( source: GraphViewProviderWebviewResolveSource, dependencies: Pick< @@ -97,6 +51,5 @@ export function resolveGraphViewProviderWebviewView( executeCommand: (command: string, key: string, value: boolean) => dependencies.executeCommand(command, key, value), } as never); - maybeFlushPendingWorkspaceRefresh(source, webviewView, viewKind); } diff --git a/packages/extension/src/extension/graphView/provider/webview/resolve/views.ts b/packages/extension/src/extension/graphView/provider/webview/resolve/views.ts new file mode 100644 index 000000000..98f5fd0f5 --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/webview/resolve/views.ts @@ -0,0 +1,50 @@ +import type * as vscode from 'vscode'; +import type { CodeGraphyWebviewKind } from '../../../webview/html'; +import type { GraphViewProviderWebviewResolveSource } from '../resolve'; + +export function getWebviewKind(webviewView: vscode.WebviewView): CodeGraphyWebviewKind { + return isTimelineWebviewView(webviewView) ? 'timeline' : 'graph'; +} + +export function assignResolvedWebviewView( + source: GraphViewProviderWebviewResolveSource, + webviewView: vscode.WebviewView, + viewKind: CodeGraphyWebviewKind, + workspaceTitle: string | undefined, +): void { + if (viewKind === 'timeline') { + source._timelineView = webviewView; + return; + } + + webviewView.title = workspaceTitle ?? 'Graph'; + source._view = webviewView; +} + +export function clearResolvedWebviewView( + source: GraphViewProviderWebviewResolveSource, + webviewView: vscode.WebviewView, + viewKind: CodeGraphyWebviewKind, +): void { + if (viewKind === 'timeline' && source._timelineView === webviewView) { + source._timelineView = undefined; + } + + if (viewKind === 'graph' && source._view === webviewView) { + source._view = undefined; + } +} + +export function maybeFlushPendingWorkspaceRefresh( + source: GraphViewProviderWebviewResolveSource, + webviewView: vscode.WebviewView, + viewKind: CodeGraphyWebviewKind, +): void { + if (viewKind === 'graph' && webviewView.visible) { + source.flushPendingWorkspaceRefresh?.(); + } +} + +function isTimelineWebviewView(webviewView: vscode.WebviewView): boolean { + return webviewView.viewType === 'codegraphy.timelineView'; +} diff --git a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts index 37cdb02fa..6d82a9cf8 100644 --- a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts +++ b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts @@ -1,4 +1,4 @@ -import * as vscode from 'vscode'; +import type * as vscode from 'vscode'; import type { IGraphData } from '../../../../shared/graph/contracts'; import type { WebviewToExtensionMessage } from '../../../../shared/protocol/webviewToExtension'; import type { IPluginFilterPatternGroup } from '../../../../shared/protocol/extensionToWebview'; diff --git a/packages/extension/src/extension/graphView/webview/html.ts b/packages/extension/src/extension/graphView/webview/html.ts index 79bdcb892..a959dce2e 100644 --- a/packages/extension/src/extension/graphView/webview/html.ts +++ b/packages/extension/src/extension/graphView/webview/html.ts @@ -32,13 +32,13 @@ export function createGraphViewHtml( - + CodeGraphy
- + `; } diff --git a/packages/extension/src/extension/graphView/webview/messages/listener.ts b/packages/extension/src/extension/graphView/webview/messages/listener.ts index 8ffc37de3..c521f0ca2 100644 --- a/packages/extension/src/extension/graphView/webview/messages/listener.ts +++ b/packages/extension/src/extension/graphView/webview/messages/listener.ts @@ -1,102 +1,11 @@ import type * as vscode from 'vscode'; -import type { WebviewToExtensionMessage } from '../../../../shared/protocol/webviewToExtension'; -import type { IGroup } from '../../../../shared/settings/groups'; -import { - dispatchGraphViewPluginMessage, - type GraphViewPluginMessageContext, -} from '../dispatch/plugin'; -import { - dispatchGraphViewPrimaryMessage, - type GraphViewPrimaryMessageContext, -} from '../dispatch/primary'; -import { replayDuplicateWebviewReady } from './ready'; +import type { GraphViewMessageListenerContext } from './webviewListener/contracts'; +import { createGraphViewWebviewMessageHandler } from './webviewListener/handler'; -export interface GraphViewMessageListenerContext - extends GraphViewPrimaryMessageContext, - GraphViewPluginMessageContext { - reprocessPluginFiles(pluginIds: readonly string[]): Promise; - setUserGroups(groups: IGroup[]): void; - setFilterPatterns(patterns: string[]): void; - setWebviewReadyNotified(nextValue: boolean): void; -} +export type { GraphViewMessageListenerContext } from './webviewListener/contracts'; const webviewMessageListenerDisposables = new WeakMap(); -type GraphViewPrimaryMessageResult = Awaited>; -type GraphViewPluginMessageResult = Awaited>; - -function applyGraphViewPrimaryMessageResult( - primaryResult: GraphViewPrimaryMessageResult, - context: GraphViewMessageListenerContext, -): boolean { - if (!primaryResult.handled) { - return false; - } - - if (primaryResult.userGroups !== undefined) { - context.setUserGroups(primaryResult.userGroups); - context.recomputeGroups(); - context.sendGroupsUpdated(); - } - if (primaryResult.filterPatterns !== undefined) { - context.setFilterPatterns(primaryResult.filterPatterns); - } - - return true; -} - -function applyGraphViewPluginMessageResult( - pluginResult: GraphViewPluginMessageResult, - context: GraphViewMessageListenerContext, -): void { - if (pluginResult.handled && pluginResult.readyNotified !== undefined) { - context.setWebviewReadyNotified(pluginResult.readyNotified); - } -} - -function createReadyState(context: GraphViewMessageListenerContext) { - return { - maxFiles: context.getMaxFiles(), - verboseDiagnostics: context.getConfig('verboseDiagnostics', false), - playbackSpeed: context.getPlaybackSpeed(), - depthMode: context.getDepthMode?.() ?? false, - dagMode: context.getDagMode(), - nodeSizeMode: context.getNodeSizeMode(), - focusedFile: context.getFocusedFile(), - hasWorkspace: context.hasWorkspace(), - firstAnalysis: context.isFirstAnalysis(), - readyNotified: context.isWebviewReadyNotified(), - }; -} - -function createGraphViewWebviewMessageHandler( - webview: vscode.Webview, - context: GraphViewMessageListenerContext, -): (message: WebviewToExtensionMessage) => Promise { - let webviewReadyHandled = false; - - return async function handleGraphViewWebviewMessage(message: WebviewToExtensionMessage): Promise { - if (message.type === 'WEBVIEW_READY' && webviewReadyHandled) { - await replayDuplicateWebviewReady(createReadyState(context), context); - return; - } - webviewReadyHandled ||= message.type === 'WEBVIEW_READY'; - - const primaryResult = await dispatchGraphViewPrimaryMessage(message, { - ...context, - asWebviewUri: uri => webview.asWebviewUri(uri), - }); - if (applyGraphViewPrimaryMessageResult(primaryResult, context)) { - return; - } - - applyGraphViewPluginMessageResult( - await dispatchGraphViewPluginMessage(message, context), - context, - ); - }; -} - export function setGraphViewWebviewMessageListener( webview: vscode.Webview, context: GraphViewMessageListenerContext, diff --git a/packages/extension/src/extension/graphView/webview/messages/ready.ts b/packages/extension/src/extension/graphView/webview/messages/ready.ts index 88e9b1e1e..0b54abf18 100644 --- a/packages/extension/src/extension/graphView/webview/messages/ready.ts +++ b/packages/extension/src/extension/graphView/webview/messages/ready.ts @@ -1,181 +1,56 @@ -import type { DagMode, NodeSizeMode } from '../../../../shared/settings/modes'; -import type { IPluginFilterPatternGroup } from '../../../../shared/protocol/extensionToWebview'; -import type { IGraphData } from '../../../../shared/graph/contracts'; -import { createExtensionDiagnosticLogger } from '../../../diagnostics/logger'; - -export interface GraphViewReadyState { - maxFiles: number; - verboseDiagnostics: boolean; - playbackSpeed: number; - depthMode?: boolean; - dagMode: DagMode; - nodeSizeMode: NodeSizeMode; - focusedFile: string | undefined; - hasWorkspace: boolean; - firstAnalysis: boolean; - readyNotified: boolean; -} - -export interface GraphViewReadyHandlers { - getGraphData(): IGraphData; - getFilterPatterns(): string[]; - getPluginFilterPatterns(): string[]; - getPluginFilterGroups?: () => IPluginFilterPatternGroup[]; - getConfig(key: string, defaultValue: T): T; - loadGroupsAndFilterPatterns(): void; - loadDisabledRulesAndPlugins(): void; - sendDepthState(): void; - sendGraphControls(): void; - loadAndSendData(): void | Promise; - sendFavorites(): void; - sendSettings(): void; - sendPhysicsSettings(): void; - sendGroupsUpdated(): void; - sendMessage(message: { type: string; payload?: unknown }): void; - sendCachedTimeline(): Promise; - sendDecorations(): void; - sendContextMenuItems(): void; - sendPluginStatuses?(): void; - sendPluginExporters?(): void; - sendPluginToolbarActions?(): void; - sendGraphViewContributionStatuses?(): void; - sendPluginWebviewInjections(): void; - sendActiveFile(): void; - waitForFirstWorkspaceReady(): PromiseLike; - notifyWebviewReady(): void; -} - -function sendWebviewReadyFilterPatterns(handlers: GraphViewReadyHandlers): void { - handlers.sendMessage({ - type: 'FILTER_PATTERNS_UPDATED', - payload: { - patterns: handlers.getFilterPatterns(), - pluginPatterns: handlers.getPluginFilterPatterns(), - pluginPatternGroups: handlers.getPluginFilterGroups?.() ?? [], - disabledCustomPatterns: handlers.getConfig('disabledCustomFilterPatterns', []), - disabledPluginPatterns: handlers.getConfig('disabledPluginFilterPatterns', []), - }, - }); -} +import { applyWebviewReady as applyWebviewReadyImpl } from './webviewReady/apply'; +import type { + GraphViewReadyHandlers, + GraphViewReadyState, +} from './webviewReady/contracts'; +import { + replayDuplicateWebviewReady as replayDuplicateWebviewReadyImpl, + replayWebviewReadyBootstrap as replayWebviewReadyBootstrapImpl, + replayWebviewReadyGraphBootstrap as replayWebviewReadyGraphBootstrapImpl, + shouldWaitForFirstWorkspaceGraph as shouldWaitForFirstWorkspaceGraphImpl, +} from './webviewReady/bootstrap'; +import { replayWebviewReadySettings as replayWebviewReadySettingsImpl } from './webviewReady/settingsReplay'; + +export type { + GraphViewReadyHandlers, + GraphViewReadyState, +} from './webviewReady/contracts'; export function replayWebviewReadySettings( state: GraphViewReadyState, handlers: GraphViewReadyHandlers, ): void { - createExtensionDiagnosticLogger({ - isEnabled: () => state.verboseDiagnostics, - }).emit({ - area: 'extension.webview', - event: 'ready-replayed', - context: { - hasWorkspace: state.hasWorkspace, - firstAnalysis: state.firstAnalysis, - readyNotified: state.readyNotified, - maxFiles: state.maxFiles, - }, - }); - handlers.loadGroupsAndFilterPatterns(); - handlers.loadDisabledRulesAndPlugins(); - handlers.sendDepthState(); - handlers.sendGraphControls(); - handlers.sendFavorites(); - handlers.sendSettings(); - handlers.sendPhysicsSettings(); - handlers.sendGroupsUpdated(); - sendWebviewReadyFilterPatterns(handlers); - handlers.sendMessage({ - type: 'MAX_FILES_UPDATED', - payload: { maxFiles: state.maxFiles }, - }); - handlers.sendMessage({ - type: 'VERBOSE_DIAGNOSTICS_UPDATED', - payload: { verboseDiagnostics: state.verboseDiagnostics }, - }); - handlers.sendMessage({ - type: 'PLAYBACK_SPEED_UPDATED', - payload: { speed: state.playbackSpeed }, - }); - handlers.sendMessage({ - type: 'DEPTH_MODE_UPDATED', - payload: { depthMode: state.depthMode ?? false }, - }); - handlers.sendMessage({ - type: 'DAG_MODE_UPDATED', - payload: { dagMode: state.dagMode }, - }); - handlers.sendMessage({ - type: 'NODE_SIZE_MODE_UPDATED', - payload: { nodeSizeMode: state.nodeSizeMode }, - }); - handlers.sendDecorations(); - handlers.sendContextMenuItems(); - handlers.sendPluginExporters?.(); - handlers.sendPluginToolbarActions?.(); - handlers.sendGraphViewContributionStatuses?.(); - handlers.sendPluginWebviewInjections(); - handlers.sendActiveFile(); + replayWebviewReadySettingsImpl(state, handlers); } export function replayWebviewReadyBootstrap( state: GraphViewReadyState, handlers: GraphViewReadyHandlers, ): void { - replayWebviewReadySettings(state, handlers); - replayWebviewReadyGraphBootstrap(handlers); + replayWebviewReadyBootstrapImpl(state, handlers); } export function replayWebviewReadyGraphBootstrap( handlers: Pick, + options: { includeGraphData?: boolean } = {}, ): void { - handlers.sendMessage({ type: 'GRAPH_DATA_UPDATED', payload: handlers.getGraphData() }); - handlers.sendMessage({ type: 'APP_BOOTSTRAP_COMPLETE' }); + replayWebviewReadyGraphBootstrapImpl(handlers, options); } export function shouldWaitForFirstWorkspaceGraph(state: GraphViewReadyState): boolean { - return state.hasWorkspace && state.firstAnalysis; + return shouldWaitForFirstWorkspaceGraphImpl(state); } -export async function replayDuplicateWebviewReady( +export function replayDuplicateWebviewReady( state: GraphViewReadyState, handlers: GraphViewReadyHandlers, ): Promise { - replayWebviewReadySettings(state, handlers); - - if (shouldWaitForFirstWorkspaceGraph(state)) { - return; - } - - replayWebviewReadyGraphBootstrap(handlers); + return replayDuplicateWebviewReadyImpl(state, handlers); } -export async function applyWebviewReady( +export function applyWebviewReady( state: GraphViewReadyState, handlers: GraphViewReadyHandlers, ): Promise { - replayWebviewReadySettings(state, handlers); - - await handlers.sendCachedTimeline(); - await handlers.loadAndSendData(); - sendWebviewReadyFilterPatterns(handlers); - handlers.sendPluginStatuses?.(); - - handlers.sendMessage({ type: 'APP_BOOTSTRAP_COMPLETE' }); - createExtensionDiagnosticLogger({ - isEnabled: () => state.verboseDiagnostics, - }).emit({ - area: 'extension.webview', - event: 'bootstrap-completed', - context: { - hasWorkspace: state.hasWorkspace, - firstAnalysis: state.firstAnalysis, - readyNotified: state.readyNotified, - }, - }); - - if (state.readyNotified) { - return true; - } - - handlers.notifyWebviewReady(); - return true; + return applyWebviewReadyImpl(state, handlers); } diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewListener/contracts.ts b/packages/extension/src/extension/graphView/webview/messages/webviewListener/contracts.ts new file mode 100644 index 000000000..b00c643c2 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewListener/contracts.ts @@ -0,0 +1,36 @@ +import type { WebviewToExtensionMessage } from '../../../../../shared/protocol/webviewToExtension'; +import type { IGroup } from '../../../../../shared/settings/groups'; +import type { + dispatchGraphViewPluginMessage, + GraphViewPluginMessageContext, +} from '../../dispatch/plugin'; +import type { + dispatchGraphViewPrimaryMessage, + GraphViewPrimaryMessageContext, +} from '../../dispatch/primary'; + +export interface GraphViewMessageListenerContext + extends GraphViewPrimaryMessageContext, + GraphViewPluginMessageContext { + reprocessPluginFiles(pluginIds: readonly string[]): Promise; + setUserGroups(groups: IGroup[]): void; + setFilterPatterns(patterns: string[]): void; + setWebviewReadyNotified(nextValue: boolean): void; +} + +export type GraphViewPrimaryMessageResult = + Awaited>; +export type GraphViewPluginMessageResult = + Awaited>; +export type WebviewReadyMessage = Extract; + +export interface WebviewReadyDelivery { + pageId?: string; + postedAt?: number; +} + +export interface WebviewReadyTracking { + completedAt?: number; + handled: boolean; + pageId?: string; +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewListener/handler.ts b/packages/extension/src/extension/graphView/webview/messages/webviewListener/handler.ts new file mode 100644 index 000000000..34e99730f --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewListener/handler.ts @@ -0,0 +1,49 @@ +import type * as vscode from 'vscode'; +import type { WebviewToExtensionMessage } from '../../../../../shared/protocol/webviewToExtension'; +import { dispatchGraphViewPluginMessage } from '../../dispatch/plugin'; +import { dispatchGraphViewPrimaryMessage } from '../../dispatch/primary'; +import type { + GraphViewMessageListenerContext, + WebviewReadyTracking, +} from './contracts'; +import { + getWebviewReadyDelivery, + handleWebviewReadyMessage, + markWebviewReadyCompleted, +} from './ready'; +import { + applyGraphViewPluginMessageResult, + applyGraphViewPrimaryMessageResult, +} from './results'; + +export function createGraphViewWebviewMessageHandler( + webview: vscode.Webview, + context: GraphViewMessageListenerContext, +): (message: WebviewToExtensionMessage) => Promise { + const webviewReadyTracking: WebviewReadyTracking = { handled: false }; + + return async function handleGraphViewWebviewMessage(message: WebviewToExtensionMessage): Promise { + const isWebviewReadyMessage = message.type === 'WEBVIEW_READY'; + if (isWebviewReadyMessage) { + const delivery = getWebviewReadyDelivery(message); + if (await handleWebviewReadyMessage(context, delivery, webviewReadyTracking)) { + return; + } + } + + const primaryResult = await dispatchGraphViewPrimaryMessage(message, { + ...context, + asWebviewUri: uri => webview.asWebviewUri(uri), + }); + if (applyGraphViewPrimaryMessageResult(primaryResult, context)) { + markWebviewReadyCompleted(webviewReadyTracking, isWebviewReadyMessage); + return; + } + + const pluginResult = await dispatchGraphViewPluginMessage(message, context); + applyGraphViewPluginMessageResult(pluginResult, context); + if (isWebviewReadyMessage && pluginResult.handled) { + markWebviewReadyCompleted(webviewReadyTracking, isWebviewReadyMessage); + } + }; +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewListener/ready.ts b/packages/extension/src/extension/graphView/webview/messages/webviewListener/ready.ts new file mode 100644 index 000000000..32c80b98a --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewListener/ready.ts @@ -0,0 +1,52 @@ +import { replayDuplicateWebviewReady } from '../ready'; +import type { + GraphViewMessageListenerContext, + WebviewReadyDelivery, + WebviewReadyMessage, + WebviewReadyTracking, +} from './contracts'; +import { shouldIgnoreDuplicateReady } from './readyDuplicate'; +import { createReadyState } from './readyState'; + +export function getWebviewReadyDelivery(message: WebviewReadyMessage): WebviewReadyDelivery { + const payload = (message as { payload?: unknown }).payload; + if (!payload || typeof payload !== 'object') { + return {}; + } + + const pageId = (payload as { pageId?: unknown }).pageId; + const postedAt = (payload as { postedAt?: unknown }).postedAt; + return { + ...(typeof pageId === 'string' && pageId.length > 0 ? { pageId } : {}), + ...(typeof postedAt === 'number' && Number.isFinite(postedAt) ? { postedAt } : {}), + }; +} + +export async function handleWebviewReadyMessage( + context: GraphViewMessageListenerContext, + delivery: WebviewReadyDelivery, + tracking: WebviewReadyTracking, +): Promise { + if (!tracking.handled) { + tracking.handled = true; + tracking.pageId = delivery.pageId; + return false; + } + + if (shouldIgnoreDuplicateReady(delivery, tracking)) { + return true; + } + + tracking.pageId = delivery.pageId; + await replayDuplicateWebviewReady(createReadyState(context), context); + return true; +} + +export function markWebviewReadyCompleted( + tracking: WebviewReadyTracking, + isWebviewReadyMessage: boolean, +): void { + if (isWebviewReadyMessage) { + tracking.completedAt = Date.now(); + } +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewListener/readyDuplicate.ts b/packages/extension/src/extension/graphView/webview/messages/webviewListener/readyDuplicate.ts new file mode 100644 index 000000000..e678f78aa --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewListener/readyDuplicate.ts @@ -0,0 +1,25 @@ +import type { + WebviewReadyDelivery, + WebviewReadyTracking, +} from './contracts'; + +export function shouldIgnoreDuplicateReady( + delivery: WebviewReadyDelivery, + tracking: WebviewReadyTracking, +): boolean { + return isSameReadyPage(delivery, tracking) + || wasReadyPostedBeforeBootstrapCompleted(delivery, tracking); +} + +function isSameReadyPage(delivery: WebviewReadyDelivery, tracking: WebviewReadyTracking): boolean { + return delivery.pageId !== undefined && delivery.pageId === tracking.pageId; +} + +function wasReadyPostedBeforeBootstrapCompleted( + delivery: WebviewReadyDelivery, + tracking: WebviewReadyTracking, +): boolean { + return delivery.postedAt !== undefined + && tracking.completedAt !== undefined + && delivery.postedAt <= tracking.completedAt; +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewListener/readyState.ts b/packages/extension/src/extension/graphView/webview/messages/webviewListener/readyState.ts new file mode 100644 index 000000000..03f1ef266 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewListener/readyState.ts @@ -0,0 +1,17 @@ +import type { GraphViewReadyState } from '../webviewReady/contracts'; +import type { GraphViewMessageListenerContext } from './contracts'; + +export function createReadyState(context: GraphViewMessageListenerContext): GraphViewReadyState { + return { + maxFiles: context.getMaxFiles(), + verboseDiagnostics: context.getConfig('verboseDiagnostics', false), + playbackSpeed: context.getPlaybackSpeed(), + depthMode: context.getDepthMode?.() ?? false, + dagMode: context.getDagMode(), + nodeSizeMode: context.getNodeSizeMode(), + focusedFile: context.getFocusedFile(), + hasWorkspace: context.hasWorkspace(), + firstAnalysis: context.isFirstAnalysis(), + readyNotified: context.isWebviewReadyNotified(), + }; +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewListener/results.ts b/packages/extension/src/extension/graphView/webview/messages/webviewListener/results.ts new file mode 100644 index 000000000..1be1cb319 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewListener/results.ts @@ -0,0 +1,34 @@ +import type { + GraphViewMessageListenerContext, + GraphViewPluginMessageResult, + GraphViewPrimaryMessageResult, +} from './contracts'; + +export function applyGraphViewPrimaryMessageResult( + primaryResult: GraphViewPrimaryMessageResult, + context: GraphViewMessageListenerContext, +): boolean { + if (!primaryResult.handled) { + return false; + } + + if (primaryResult.userGroups !== undefined) { + context.setUserGroups(primaryResult.userGroups); + context.recomputeGroups(); + context.sendGroupsUpdated(); + } + if (primaryResult.filterPatterns !== undefined) { + context.setFilterPatterns(primaryResult.filterPatterns); + } + + return true; +} + +export function applyGraphViewPluginMessageResult( + pluginResult: GraphViewPluginMessageResult, + context: GraphViewMessageListenerContext, +): void { + if (pluginResult.handled && pluginResult.readyNotified !== undefined) { + context.setWebviewReadyNotified(pluginResult.readyNotified); + } +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewReady/apply.ts b/packages/extension/src/extension/graphView/webview/messages/webviewReady/apply.ts new file mode 100644 index 000000000..09cd3ff6a --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewReady/apply.ts @@ -0,0 +1,49 @@ +import type { + GraphViewReadyHandlers, + GraphViewReadyState, +} from './contracts'; +import { emitWebviewBootstrapCompleted } from './diagnostics'; +import { areWebviewReadyFilterPatternsEqual } from './filterPatternEquality'; +import { + createWebviewReadyFilterPatternsPayload, +} from './filterPatterns'; +import { + replayWebviewReadyHydrationSettings, + replayWebviewReadySettings, +} from './settingsReplay'; + +export async function applyWebviewReady( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, +): Promise { + replayWebviewReadySettings(state, handlers); + + const initialFilterPatterns = createWebviewReadyFilterPatternsPayload(handlers); + await handlers.sendCachedTimeline(); + await handlers.loadAndSendData(); + const loadedFilterPatterns = createWebviewReadyFilterPatternsPayload(handlers); + if (!areWebviewReadyFilterPatternsEqual(initialFilterPatterns, loadedFilterPatterns)) { + handlers.sendMessage({ + type: 'FILTER_PATTERNS_UPDATED', + payload: loadedFilterPatterns, + }); + } + handlers.sendPluginStatuses?.(); + if (shouldReplayHydrationSettingsAfterLoad(state)) { + replayWebviewReadyHydrationSettings(state, handlers); + } + + handlers.sendMessage({ type: 'APP_BOOTSTRAP_COMPLETE' }); + emitWebviewBootstrapCompleted(state); + + if (state.readyNotified) { + return true; + } + + handlers.notifyWebviewReady(); + return true; +} + +function shouldReplayHydrationSettingsAfterLoad(state: GraphViewReadyState): boolean { + return state.hasWorkspace && state.firstAnalysis; +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewReady/bootstrap.ts b/packages/extension/src/extension/graphView/webview/messages/webviewReady/bootstrap.ts new file mode 100644 index 000000000..15d7a5ab9 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewReady/bootstrap.ts @@ -0,0 +1,43 @@ +import type { + GraphViewReadyHandlers, + GraphViewReadyState, +} from './contracts'; +import { replayWebviewReadySettings } from './settingsReplay'; + +interface ReplayWebviewReadyGraphBootstrapOptions { + includeGraphData?: boolean; +} + +export function replayWebviewReadyBootstrap( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, +): void { + replayWebviewReadySettings(state, handlers); + replayWebviewReadyGraphBootstrap(handlers); +} + +export function replayWebviewReadyGraphBootstrap( + handlers: Pick, + options: ReplayWebviewReadyGraphBootstrapOptions = {}, +): void { + if (options.includeGraphData ?? true) { + handlers.sendMessage({ type: 'GRAPH_DATA_UPDATED', payload: handlers.getGraphData() }); + } + handlers.sendMessage({ type: 'APP_BOOTSTRAP_COMPLETE' }); +} + +export function shouldWaitForFirstWorkspaceGraph(state: GraphViewReadyState): boolean { + return state.hasWorkspace && state.firstAnalysis; +} + +export async function replayDuplicateWebviewReady( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, +): Promise { + if (shouldWaitForFirstWorkspaceGraph(state)) { + return; + } + + replayWebviewReadySettings(state, handlers); + replayWebviewReadyGraphBootstrap(handlers, { includeGraphData: !state.readyNotified }); +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewReady/contracts.ts b/packages/extension/src/extension/graphView/webview/messages/webviewReady/contracts.ts new file mode 100644 index 000000000..2b8af8e24 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewReady/contracts.ts @@ -0,0 +1,45 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import type { IPluginFilterPatternGroup } from '../../../../../shared/protocol/extensionToWebview'; +import type { DagMode, NodeSizeMode } from '../../../../../shared/settings/modes'; + +export interface GraphViewReadyState { + maxFiles: number; + verboseDiagnostics: boolean; + playbackSpeed: number; + depthMode?: boolean; + dagMode: DagMode; + nodeSizeMode: NodeSizeMode; + focusedFile: string | undefined; + hasWorkspace: boolean; + firstAnalysis: boolean; + readyNotified: boolean; +} + +export interface GraphViewReadyHandlers { + getGraphData(): IGraphData; + getFilterPatterns(): string[]; + getPluginFilterPatterns(): string[]; + getPluginFilterGroups?: () => IPluginFilterPatternGroup[]; + getConfig(key: string, defaultValue: T): T; + loadGroupsAndFilterPatterns(): void; + loadDisabledRulesAndPlugins(): void; + sendDepthState(): void; + sendGraphControls(): void; + loadAndSendData(): void | Promise; + sendFavorites(): void; + sendSettings(): void; + sendPhysicsSettings(): void; + sendGroupsUpdated(): void; + sendMessage(message: { type: string; payload?: unknown }): void; + sendCachedTimeline(): Promise; + sendDecorations(): void; + sendContextMenuItems(): void; + sendPluginStatuses?(): void; + sendPluginExporters?(): void; + sendPluginToolbarActions?(): void; + sendGraphViewContributionStatuses?(): void; + sendPluginWebviewInjections(): void; + sendActiveFile(): void; + waitForFirstWorkspaceReady(): PromiseLike; + notifyWebviewReady(): void; +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewReady/diagnostics.ts b/packages/extension/src/extension/graphView/webview/messages/webviewReady/diagnostics.ts new file mode 100644 index 000000000..c6736d5d1 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewReady/diagnostics.ts @@ -0,0 +1,31 @@ +import { createExtensionDiagnosticLogger } from '../../../../diagnostics/logger'; +import type { GraphViewReadyState } from './contracts'; + +export function emitWebviewReadyReplayed(state: GraphViewReadyState): void { + createExtensionDiagnosticLogger({ + isEnabled: () => state.verboseDiagnostics, + }).emit({ + area: 'extension.webview', + event: 'ready-replayed', + context: { + hasWorkspace: state.hasWorkspace, + firstAnalysis: state.firstAnalysis, + readyNotified: state.readyNotified, + maxFiles: state.maxFiles, + }, + }); +} + +export function emitWebviewBootstrapCompleted(state: GraphViewReadyState): void { + createExtensionDiagnosticLogger({ + isEnabled: () => state.verboseDiagnostics, + }).emit({ + area: 'extension.webview', + event: 'bootstrap-completed', + context: { + hasWorkspace: state.hasWorkspace, + firstAnalysis: state.firstAnalysis, + readyNotified: state.readyNotified, + }, + }); +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewReady/filterPatternEquality.ts b/packages/extension/src/extension/graphView/webview/messages/webviewReady/filterPatternEquality.ts new file mode 100644 index 000000000..c1d7f0e27 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewReady/filterPatternEquality.ts @@ -0,0 +1,40 @@ +import type { IPluginFilterPatternGroup } from '../../../../../shared/protocol/extensionToWebview'; +import type { FilterPatternsPayload } from './filterPatterns'; + +export function areWebviewReadyFilterPatternsEqual( + left: FilterPatternsPayload, + right: FilterPatternsPayload, +): boolean { + return areStringArraysEqual(left.patterns, right.patterns) + && areStringArraysEqual(left.pluginPatterns, right.pluginPatterns) + && arePluginFilterPatternGroupsEqual(left.pluginPatternGroups, right.pluginPatternGroups) + && areStringArraysEqual(left.disabledCustomPatterns, right.disabledCustomPatterns) + && areStringArraysEqual(left.disabledPluginPatterns, right.disabledPluginPatterns); +} + +function areStringArraysEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function arePluginFilterPatternGroupsEqual( + left: readonly IPluginFilterPatternGroup[], + right: readonly IPluginFilterPatternGroup[], +): boolean { + return left.length === right.length + && left.every((leftGroup, index) => + arePluginFilterPatternGroupEqual(leftGroup, right[index]), + ); +} + +function arePluginFilterPatternGroupEqual( + left: IPluginFilterPatternGroup, + right: IPluginFilterPatternGroup | undefined, +): boolean { + if (!right) { + return false; + } + + return left.pluginId === right.pluginId + && left.pluginName === right.pluginName + && areStringArraysEqual(left.patterns, right.patterns); +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewReady/filterPatterns.ts b/packages/extension/src/extension/graphView/webview/messages/webviewReady/filterPatterns.ts new file mode 100644 index 000000000..4ef4769a2 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewReady/filterPatterns.ts @@ -0,0 +1,31 @@ +import type { ExtensionToWebviewMessage } from '../../../../../shared/protocol/extensionToWebview'; +import type { GraphViewReadyHandlers } from './contracts'; + +type FilterPatternsUpdatedMessage = Extract< + ExtensionToWebviewMessage, + { type: 'FILTER_PATTERNS_UPDATED' } +>; +export type FilterPatternsPayload = FilterPatternsUpdatedMessage['payload']; + +export function createWebviewReadyFilterPatternsPayload( + handlers: GraphViewReadyHandlers, +): FilterPatternsPayload { + return { + patterns: handlers.getFilterPatterns(), + pluginPatterns: handlers.getPluginFilterPatterns(), + pluginPatternGroups: handlers.getPluginFilterGroups?.() ?? [], + disabledCustomPatterns: handlers.getConfig('disabledCustomFilterPatterns', []), + disabledPluginPatterns: handlers.getConfig('disabledPluginFilterPatterns', []), + }; +} + +export function sendWebviewReadyFilterPatterns( + handlers: GraphViewReadyHandlers, +): FilterPatternsPayload { + const payload = createWebviewReadyFilterPatternsPayload(handlers); + handlers.sendMessage({ + type: 'FILTER_PATTERNS_UPDATED', + payload, + }); + return payload; +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewReady/settingsReplay.ts b/packages/extension/src/extension/graphView/webview/messages/webviewReady/settingsReplay.ts new file mode 100644 index 000000000..f023e2e04 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewReady/settingsReplay.ts @@ -0,0 +1,90 @@ +import type { + GraphViewReadyHandlers, + GraphViewReadyState, +} from './contracts'; +import { emitWebviewReadyReplayed } from './diagnostics'; +import { sendWebviewReadyFilterPatterns } from './filterPatterns'; + +interface ReplayWebviewReadySettingsMessagesOptions { + includeFilterPatterns: boolean; + includePluginBootstrap: boolean; +} + +export function replayWebviewReadySettings( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, +): void { + emitWebviewReadyReplayed(state); + replayWebviewReadySettingsMessages(state, handlers, { + includeFilterPatterns: true, + includePluginBootstrap: true, + }); +} + +export function replayWebviewReadyHydrationSettings( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, +): void { + replayWebviewReadySettingsMessages(state, handlers, { + includeFilterPatterns: false, + includePluginBootstrap: false, + }); +} + +function replayWebviewReadySettingsMessages( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, + options: ReplayWebviewReadySettingsMessagesOptions, +): void { + handlers.loadGroupsAndFilterPatterns(); + handlers.loadDisabledRulesAndPlugins(); + handlers.sendDepthState(); + handlers.sendGraphControls(); + handlers.sendFavorites(); + handlers.sendSettings(); + handlers.sendPhysicsSettings(); + handlers.sendGroupsUpdated(); + if (options.includeFilterPatterns) { + sendWebviewReadyFilterPatterns(handlers); + } + sendWebviewReadySettingValues(state, handlers); + handlers.sendDecorations(); + handlers.sendContextMenuItems(); + handlers.sendPluginExporters?.(); + handlers.sendPluginToolbarActions?.(); + if (options.includePluginBootstrap) { + handlers.sendGraphViewContributionStatuses?.(); + handlers.sendPluginWebviewInjections(); + } + handlers.sendActiveFile(); +} + +function sendWebviewReadySettingValues( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, +): void { + handlers.sendMessage({ + type: 'MAX_FILES_UPDATED', + payload: { maxFiles: state.maxFiles }, + }); + handlers.sendMessage({ + type: 'VERBOSE_DIAGNOSTICS_UPDATED', + payload: { verboseDiagnostics: state.verboseDiagnostics }, + }); + handlers.sendMessage({ + type: 'PLAYBACK_SPEED_UPDATED', + payload: { speed: state.playbackSpeed }, + }); + handlers.sendMessage({ + type: 'DEPTH_MODE_UPDATED', + payload: { depthMode: state.depthMode ?? false }, + }); + handlers.sendMessage({ + type: 'DAG_MODE_UPDATED', + payload: { dagMode: state.dagMode }, + }); + handlers.sendMessage({ + type: 'NODE_SIZE_MODE_UPDATED', + payload: { nodeSizeMode: state.nodeSizeMode }, + }); +} diff --git a/packages/extension/src/extension/graphView/webview/resolve.ts b/packages/extension/src/extension/graphView/webview/resolve.ts index 28bad2fc1..f46aae7d2 100644 --- a/packages/extension/src/extension/graphView/webview/resolve.ts +++ b/packages/extension/src/extension/graphView/webview/resolve.ts @@ -29,14 +29,17 @@ export function resolveGraphViewWebviewView( executeCommand, }: ResolveGraphViewWebviewOptions, ): void { + const localResourceRoots = getLocalResourceRoots(); webviewView.webview.options = { enableScripts: true, - localResourceRoots: getLocalResourceRoots(), + localResourceRoots, retainContextWhenHidden: true, }; setWebviewMessageListener(webviewView.webview); - webviewView.webview.html = getHtml(webviewView.webview); + + const html = getHtml(webviewView.webview); + webviewView.webview.html = html; void executeCommand('setContext', 'codegraphy.viewVisible', webviewView.visible); diff --git a/packages/extension/src/extension/pipeline/service/analysisFacade.ts b/packages/extension/src/extension/pipeline/service/analysisFacade.ts new file mode 100644 index 000000000..0d1385e9b --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/analysisFacade.ts @@ -0,0 +1,58 @@ +import type { IGraphData } from '../../../shared/graph/contracts'; +import type { WorkspacePipelineSourceOwner } from '../analysisSource'; +import { createEmptyWorkspaceAnalysisCache } from '../cache'; +import { WorkspacePipelineGraphDiscoveryFacade } from './graphDiscovery'; +import { + analyzeWorkspacePipeline, + rebuildWorkspacePipelineGraph, +} from './runtime/run'; + +export abstract class WorkspacePipelineAnalysisFacade extends WorkspacePipelineGraphDiscoveryFacade { + async analyze( + filterPatterns: string[] = [], + disabledPlugins: Set = new Set(), + signal?: AbortSignal, + onProgress?: (progress: { phase: string; current: number; total: number }) => void, + ): Promise { + return analyzeWorkspacePipeline( + this as unknown as WorkspacePipelineSourceOwner, + this._cache, + this._config, + this._discovery, + () => this._getWorkspaceRoot(), + this._getEffectiveCustomFilterPatterns(filterPatterns), + disabledPlugins, + onProgress, + signal, + async () => this._persistIndexMetadata(), + ); + } + + rebuildGraph(disabledPlugins: Set, showOrphans: boolean): IGraphData { + return rebuildWorkspacePipelineGraph( + this as unknown as WorkspacePipelineSourceOwner, + disabledPlugins, + showOrphans, + ); + } + + protected resetCacheForIndexRefresh(): void { + this._cache = createEmptyWorkspaceAnalysisCache(); + console.log('[CodeGraphy] Cache cleared'); + } + + async refreshIndex( + filterPatterns: string[] = [], + disabledPlugins: Set = new Set(), + signal?: AbortSignal, + onProgress?: (progress: { phase: string; current: number; total: number }) => void, + ): Promise { + this.resetCacheForIndexRefresh(); + return this.analyze(filterPatterns, disabledPlugins, signal, progress => { + onProgress?.({ + ...progress, + phase: progress.phase || 'Refreshing Index', + }); + }); + } +} diff --git a/packages/extension/src/extension/pipeline/service/base/internal.ts b/packages/extension/src/extension/pipeline/service/base/internal.ts index 3a19f427d..e36de658a 100644 --- a/packages/extension/src/extension/pipeline/service/base/internal.ts +++ b/packages/extension/src/extension/pipeline/service/base/internal.ts @@ -90,7 +90,7 @@ export abstract class WorkspacePipelineInternalBase extends WorkspacePipelineSta ); } - private _getActiveAnalysisPluginIds( + protected _getActiveAnalysisPluginIds( pluginIds: readonly string[] | undefined, disabledPlugins: ReadonlySet, ): string[] { @@ -109,7 +109,7 @@ export abstract class WorkspacePipelineInternalBase extends WorkspacePipelineSta showOrphans: boolean, disabledPlugins: Set = new Set(), ): IGraphData { - return buildWorkspacePipelineGraph( + const graphData = buildWorkspacePipelineGraph( this._cache, this._context, this._registry, @@ -120,6 +120,8 @@ export abstract class WorkspacePipelineInternalBase extends WorkspacePipelineSta this._lastDiscoveredDirectories, this._lastGitIgnoredPaths, ); + this._lastGraphData = graphData; + return graphData; } protected _buildGraphDataFromAnalysis( @@ -129,7 +131,7 @@ export abstract class WorkspacePipelineInternalBase extends WorkspacePipelineSta disabledPlugins: Set = new Set(), ): IGraphData { const nodeVisibility = this._config.get>('nodeVisibility', {}) ?? {}; - return buildWorkspacePipelineGraphFromAnalysis( + const graphData = buildWorkspacePipelineGraphFromAnalysis( this._cache, this._context, this._registry, @@ -141,6 +143,8 @@ export abstract class WorkspacePipelineInternalBase extends WorkspacePipelineSta { nodeVisibility }, this._lastGitIgnoredPaths, ); + this._lastGraphData = graphData; + return graphData; } protected _getWorkspaceRoot(): string | undefined { @@ -186,7 +190,10 @@ export abstract class WorkspacePipelineInternalBase extends WorkspacePipelineSta } protected async _persistIndexMetadata(): Promise { - await persistWorkspacePipelineIndexMetadata(this._getWorkspaceRoot(), { + const workspaceRoot = this._getWorkspaceRoot(); + await persistWorkspacePipelineIndexMetadata(workspaceRoot, { + getCurrentCommitSha: () => + workspaceRoot ? this._getCurrentCommitShaSync(workspaceRoot) : null, getPluginSignature: () => this._getPluginSignature(), getSettingsSignature: () => this._getSettingsSignature(), warn: (message: string, error: unknown) => { diff --git a/packages/extension/src/extension/pipeline/service/base/state.ts b/packages/extension/src/extension/pipeline/service/base/state.ts index 90409bedd..17dee518a 100644 --- a/packages/extension/src/extension/pipeline/service/base/state.ts +++ b/packages/extension/src/extension/pipeline/service/base/state.ts @@ -13,8 +13,9 @@ import { import { Configuration } from '../../../config/reader'; import { EventBus } from '../../../../core/plugins/events/bus'; import type { IWorkspaceAnalysisCache } from '../../cache'; +import type { IGraphData } from '../../../../shared/graph/contracts'; import { - loadWorkspaceAnalysisDatabaseCacheAsync, + loadWorkspaceAnalysisDatabaseCache, readWorkspaceAnalysisDatabaseSnapshot, type WorkspaceAnalysisDatabaseSnapshot, } from '../../database/cache/storage'; @@ -95,6 +96,14 @@ export abstract class WorkspacePipelineStateBase { this._engineState.workspaceRoot = workspaceRoot; } + protected get _lastGraphData(): IGraphData { + return this._engineState.graph; + } + + protected set _lastGraphData(graphData: IGraphData) { + this._engineState.graph = graphData; + } + setEventBus(eventBus: EventBus): void { this._eventBus = eventBus; } @@ -107,6 +116,10 @@ export abstract class WorkspacePipelineStateBase { return this._lastFileAnalysis; } + async warmGraphCache(): Promise { + await this._hydrateCacheFromGraphCache(); + } + readStructuredAnalysisSnapshot(): WorkspaceAnalysisDatabaseSnapshot { const workspaceRoot = this._getWorkspaceRoot(); if (!workspaceRoot) { @@ -122,7 +135,8 @@ export abstract class WorkspacePipelineStateBase { return; } - this._cacheHydrationPromise ??= loadWorkspaceAnalysisDatabaseCacheAsync(workspaceRoot) + this._cacheHydrationPromise ??= Promise.resolve() + .then(() => loadWorkspaceAnalysisDatabaseCache(workspaceRoot)) .then((cache) => { if (Object.keys(this._cache.files).length === 0) { this._cache = cache; diff --git a/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery.ts b/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery.ts new file mode 100644 index 000000000..37276b05a --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery.ts @@ -0,0 +1,54 @@ +import * as path from 'node:path'; +import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import { collectCachedGitIgnoredPaths } from './cachedDiscovery/gitignore'; + +export { collectCachedGitIgnoredPaths } from './cachedDiscovery/gitignore'; + +export interface CachedWorkspaceDiscoveryState { + directories: string[]; + files: IDiscoveredFile[]; + gitIgnoredPaths: string[]; +} + +export function createCachedDiscoveredFiles( + workspaceRoot: string, + filePaths: readonly string[], +): IDiscoveredFile[] { + return filePaths.map(relativePath => ({ + absolutePath: path.join(workspaceRoot, relativePath), + extension: path.extname(relativePath), + name: path.basename(relativePath), + relativePath, + })); +} + +export function collectCachedDirectoryPaths(filePaths: readonly string[]): string[] { + const directories = new Set(); + + for (const filePath of filePaths) { + let directory = path.posix.dirname(filePath.replace(/\\/g, '/')); + while (directory && directory !== '.') { + directories.add(directory); + directory = path.posix.dirname(directory); + } + } + + return [...directories].sort(); +} + +export function createCachedWorkspaceDiscoveryState( + workspaceRoot: string, + filePaths: readonly string[], + respectGitignore: boolean, +): CachedWorkspaceDiscoveryState { + const directories = collectCachedDirectoryPaths(filePaths); + return { + directories, + files: createCachedDiscoveredFiles(workspaceRoot, filePaths), + gitIgnoredPaths: collectCachedGitIgnoredPaths( + workspaceRoot, + [...directories, ...filePaths], + respectGitignore, + ), + }; +} diff --git a/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery/gitignore.ts b/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery/gitignore.ts new file mode 100644 index 000000000..32264715c --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery/gitignore.ts @@ -0,0 +1,60 @@ +import { spawnSync } from 'node:child_process'; + +function toGitPath(relativePath: string): string { + return relativePath.split(/[\\/]/).join('/'); +} + +function createCachedGitPathLookup(relativePaths: readonly string[]): Map { + return new Map(relativePaths.map(relativePath => [toGitPath(relativePath), relativePath])); +} + +function createGitCheckIgnoreInput(pathsByGitPath: ReadonlyMap): string { + return `${[...pathsByGitPath.keys()].join('\n')}\n`; +} + +function didGitCheckIgnoreFail(result: ReturnType): boolean { + if (result.error) { + return true; + } + + switch (result.status) { + case 0: + case 1: + return false; + default: + return true; + } +} + +function readGitIgnoredCachedPaths( + stdout: string, + pathsByGitPath: ReadonlyMap, +): string[] { + return stdout + .split(/\r?\n/) + .filter(Boolean) + .map(gitPath => pathsByGitPath.get(gitPath) ?? gitPath); +} + +export function collectCachedGitIgnoredPaths( + workspaceRoot: string, + relativePaths: readonly string[], + respectGitignore: boolean, +): string[] { + if (!respectGitignore || relativePaths.length === 0) { + return []; + } + + const pathsByGitPath = createCachedGitPathLookup(relativePaths); + + const result = spawnSync('git', ['-C', workspaceRoot, 'check-ignore', '--stdin'], { + encoding: 'utf8', + input: createGitCheckIgnoreInput(pathsByGitPath), + }); + + if (didGitCheckIgnoreFail(result)) { + return []; + } + + return readGitIgnoredCachedPaths(result.stdout, pathsByGitPath); +} diff --git a/packages/extension/src/extension/pipeline/service/cache/index.ts b/packages/extension/src/extension/pipeline/service/cache/index.ts index 37fdf0c8c..b28f2341f 100644 --- a/packages/extension/src/extension/pipeline/service/cache/index.ts +++ b/packages/extension/src/extension/pipeline/service/cache/index.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import { persistCodeGraphyWorkspaceIndexMetadata } from '@codegraphy-dev/core'; -import { readCodeGraphyRepoMeta } from '../../../repoSettings/meta'; +import { readCodeGraphyRepoMeta, writeCodeGraphyRepoMeta } from '../../../repoSettings/meta'; import { getWorkspaceAnalysisDatabasePath } from '../../database/cache/storage'; interface WorkspacePipelineSignatureDependencies { @@ -10,6 +10,7 @@ interface WorkspacePipelineSignatureDependencies { interface WorkspacePipelinePersistIndexDependencies extends WorkspacePipelineSignatureDependencies { + getCurrentCommitSha?: () => Promise | string | null; persistIndexMetadata?: typeof persistCodeGraphyWorkspaceIndexMetadata; warn(message: string, error: unknown): void; } @@ -38,10 +39,17 @@ export async function persistWorkspacePipelineIndexMetadata( } try { + const currentCommitSha = await dependencies.getCurrentCommitSha?.(); (dependencies.persistIndexMetadata ?? persistCodeGraphyWorkspaceIndexMetadata)(workspaceRoot, { pluginSignature: dependencies.getPluginSignature(), settingsSignature: dependencies.getSettingsSignature(), }); + if (dependencies.getCurrentCommitSha) { + writeCodeGraphyRepoMeta(workspaceRoot, { + ...readCodeGraphyRepoMeta(workspaceRoot), + lastIndexedCommit: currentCommitSha ?? null, + }); + } } catch (error) { dependencies.warn('[CodeGraphy] Failed to update repo index metadata.', error); } diff --git a/packages/extension/src/extension/pipeline/service/cachedGraph.ts b/packages/extension/src/extension/pipeline/service/cachedGraph.ts new file mode 100644 index 000000000..9e2dd4aa5 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraph.ts @@ -0,0 +1,112 @@ +import { + type IDiscoveredFile, + projectFileAnalysisConnections, + throwIfWorkspaceAnalysisAborted, +} from '@codegraphy-dev/core'; +import type { IGraphData } from '../../../shared/graph/contracts'; +import { createCachedWorkspaceDiscoveryState } from './cache/cachedDiscovery'; +import { + isMissingFileError, + isWorkspaceAnalysisAbortError, +} from './cachedGraphWarmup/errors'; +import { warmCachedGraphAnalysisFile } from './cachedGraphWarmup/execution'; +import { createCachedGraphAnalysisWarmupInput } from './cachedGraphWarmup/input'; +import { + WorkspacePipelineAnalysisFacade, +} from './analysisFacade'; + +export interface WorkspacePipelineCachedGraphLoadOptions { + includeCurrentGitignoreMetadata?: boolean; + warmAnalysis?: boolean; +} + +export abstract class WorkspacePipelineCachedGraphFacade extends WorkspacePipelineAnalysisFacade { + async loadCachedGraph( + _filterPatterns?: string[], + disabledPlugins: Set = new Set(), + signal?: AbortSignal, + options: WorkspacePipelineCachedGraphLoadOptions = {}, + ): Promise { + throwIfWorkspaceAnalysisAborted(signal); + await this._hydrateCacheFromGraphCache(); + throwIfWorkspaceAnalysisAborted(signal); + + const workspaceRoot = this._getWorkspaceRoot(); + if (!workspaceRoot) { + return { nodes: [], edges: [] }; + } + + const config = this._config.getAll(); + throwIfWorkspaceAnalysisAborted(signal); + + const fileAnalysis = new Map( + Object.entries(this._cache.files).map(([filePath, entry]) => [ + filePath, + entry.analysis, + ]), + ); + const cachedFilePaths = Object.keys(this._cache.files); + const includeCurrentGitignoreMetadata = options.includeCurrentGitignoreMetadata !== false; + const cachedDiscovery = createCachedWorkspaceDiscoveryState( + workspaceRoot, + cachedFilePaths, + config.respectGitignore && includeCurrentGitignoreMetadata, + ); + + this._lastDiscoveredFiles = cachedDiscovery.files; + this._lastDiscoveredDirectories = cachedDiscovery.directories; + this._lastGitIgnoredPaths = cachedDiscovery.gitIgnoredPaths; + this._lastFileAnalysis = fileAnalysis; + this._lastFileConnections = projectFileAnalysisConnections(fileAnalysis, workspaceRoot); + this._lastWorkspaceRoot = workspaceRoot; + + throwIfWorkspaceAnalysisAborted(signal); + + const graphData = this._buildGraphDataFromAnalysis( + fileAnalysis, + workspaceRoot, + config.showOrphans, + disabledPlugins, + ); + + if (options.warmAnalysis !== false) { + this._scheduleCachedGraphAnalysisWarmup( + cachedDiscovery.files, + workspaceRoot, + disabledPlugins, + signal, + ); + } + + return graphData; + } + + private _scheduleCachedGraphAnalysisWarmup( + files: readonly IDiscoveredFile[], + workspaceRoot: string, + disabledPlugins: Set, + signal?: AbortSignal, + ): void { + const input = createCachedGraphAnalysisWarmupInput({ + disabledPlugins, + files, + getActiveAnalysisPluginIds: disabledPluginSnapshot => + this._getActiveAnalysisPluginIds(undefined, disabledPluginSnapshot), + nodeVisibility: this._config.get>('nodeVisibility', {}) ?? {}, + registry: this._registry, + signal, + workspaceRoot, + }); + if (!input) { + return; + } + + void warmCachedGraphAnalysisFile(input, this._discovery, this._registry).catch(error => { + if (isWorkspaceAnalysisAbortError(error) || isMissingFileError(error)) { + return; + } + + console.warn('[CodeGraphy] Failed to warm cached graph analysis.', error); + }); + } +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/candidates.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/candidates.ts new file mode 100644 index 000000000..22171d1c3 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/candidates.ts @@ -0,0 +1,19 @@ +import type { IDiscoveredFile } from '@codegraphy-dev/core'; + +const CACHED_GRAPH_ANALYSIS_WARMUP_IGNORED_SEGMENTS = new Set([ + '.codegraphy', + '.git', + '.stryker-tmp', + '.turbo', + '.worktrees', + 'coverage', + 'dist', + 'node_modules', + 'out', + 'reports', +]); + +export function isCachedGraphAnalysisWarmupCandidate(file: IDiscoveredFile): boolean { + const segments = file.relativePath.replace(/\\/g, '/').split('/'); + return !segments.some(segment => CACHED_GRAPH_ANALYSIS_WARMUP_IGNORED_SEGMENTS.has(segment)); +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/contracts.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/contracts.ts new file mode 100644 index 000000000..ccc09bc8b --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/contracts.ts @@ -0,0 +1,41 @@ +import { + createWorkspacePluginAnalysisContext, + type IDiscoveredFile, +} from '@codegraphy-dev/core'; + +export interface CachedGraphWarmupRegistry { + analyzeFileResultForPlugins?: ( + absolutePath: string, + content: string, + workspaceRoot: string, + pluginIds: readonly string[], + analysisContext: ReturnType, + options: { disabledPlugins: Set }, + ) => Promise; + supportsFile?: (filePath: string) => boolean; +} + +export interface CachedGraphWarmupDiscovery { + readContent(file: IDiscoveredFile): Promise; +} + +export interface CachedGraphAnalysisWarmupInput { + analysisContext: ReturnType; + disabledPluginSnapshot: Set; + file: IDiscoveredFile; + pluginIds: readonly string[]; + signal?: AbortSignal; + workspaceRoot: string; +} + +export interface CachedGraphAnalysisWarmupOptions { + disabledPlugins: Set; + files: readonly IDiscoveredFile[]; + getActiveAnalysisPluginIds( + disabledPluginSnapshot: Set, + ): readonly string[]; + nodeVisibility: Record; + registry: CachedGraphWarmupRegistry; + signal?: AbortSignal; + workspaceRoot: string; +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/errors.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/errors.ts new file mode 100644 index 000000000..935eb7582 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/errors.ts @@ -0,0 +1,9 @@ +export function isWorkspaceAnalysisAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} + +export function isMissingFileError(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && (error as { code?: unknown }).code === 'ENOENT'; +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/execution.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/execution.ts new file mode 100644 index 000000000..ee6e69be4 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/execution.ts @@ -0,0 +1,28 @@ +import { throwIfWorkspaceAnalysisAborted } from '@codegraphy-dev/core'; +import type { + CachedGraphAnalysisWarmupInput, + CachedGraphWarmupDiscovery, + CachedGraphWarmupRegistry, +} from './contracts'; + +export async function warmCachedGraphAnalysisFile( + input: CachedGraphAnalysisWarmupInput, + discovery: CachedGraphWarmupDiscovery, + registry: CachedGraphWarmupRegistry, +): Promise { + if (typeof registry.analyzeFileResultForPlugins !== 'function') { + return; + } + + throwIfWorkspaceAnalysisAborted(input.signal); + const content = await discovery.readContent(input.file); + throwIfWorkspaceAnalysisAborted(input.signal); + await registry.analyzeFileResultForPlugins( + input.file.absolutePath, + content, + input.workspaceRoot, + input.pluginIds, + input.analysisContext, + { disabledPlugins: input.disabledPluginSnapshot }, + ); +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/input.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/input.ts new file mode 100644 index 000000000..935432307 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/input.ts @@ -0,0 +1,44 @@ +import { + createWorkspacePluginAnalysisContext, + SYMBOLS_ANALYSIS_CACHE_TIER, +} from '@codegraphy-dev/core'; +import { createWorkspacePipelineAnalysisCacheTiers } from '../cache/tiers'; +import type { + CachedGraphAnalysisWarmupInput, + CachedGraphAnalysisWarmupOptions, +} from './contracts'; +import { selectCachedGraphAnalysisWarmupFile } from './selection'; + +export function createCachedGraphAnalysisWarmupInput( + options: CachedGraphAnalysisWarmupOptions, +): CachedGraphAnalysisWarmupInput | undefined { + if (typeof options.registry.analyzeFileResultForPlugins !== 'function') { + return undefined; + } + + const file = selectCachedGraphAnalysisWarmupFile(options.registry, options.files); + if (!file) { + return undefined; + } + + const disabledPluginSnapshot = new Set(options.disabledPlugins); + const pluginIds = options.getActiveAnalysisPluginIds(disabledPluginSnapshot); + const cacheTiers = createWorkspacePipelineAnalysisCacheTiers( + options.nodeVisibility, + pluginIds, + ); + + return { + analysisContext: createWorkspacePluginAnalysisContext(options.workspaceRoot, { + features: { + symbols: cacheTiers.active === undefined + || cacheTiers.active.includes(SYMBOLS_ANALYSIS_CACHE_TIER), + }, + }), + disabledPluginSnapshot, + file, + pluginIds, + signal: options.signal, + workspaceRoot: options.workspaceRoot, + }; +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/ranking.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/ranking.ts new file mode 100644 index 000000000..4650b50dc --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/ranking.ts @@ -0,0 +1,33 @@ +import type { IDiscoveredFile } from '@codegraphy-dev/core'; + +export function selectMostRepresentedCachedGraphWarmupFile( + files: readonly IDiscoveredFile[], +): IDiscoveredFile | undefined { + const extensionStats = new Map(); + + for (const file of files) { + const extension = file.extension; + const stats = extensionStats.get(extension); + if (stats) { + stats.count += 1; + continue; + } + + extensionStats.set(extension, { + count: 1, + file, + }); + } + + let selected: { count: number; file: IDiscoveredFile } | undefined; + for (const stats of extensionStats.values()) { + if (!selected || stats.count > selected.count) { + selected = stats; + } + } + + return selected?.file; +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/selection.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/selection.ts new file mode 100644 index 000000000..fb05cf5ae --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/selection.ts @@ -0,0 +1,20 @@ +import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import { isCachedGraphAnalysisWarmupCandidate } from './candidates'; +import type { CachedGraphWarmupRegistry } from './contracts'; +import { selectMostRepresentedCachedGraphWarmupFile } from './ranking'; +import { getSupportedCachedGraphAnalysisWarmupFiles } from './support'; + +export function selectCachedGraphAnalysisWarmupFile( + registry: CachedGraphWarmupRegistry, + files: readonly IDiscoveredFile[], +): IDiscoveredFile | undefined { + const supportedFiles = getSupportedCachedGraphAnalysisWarmupFiles( + registry, + files.filter(isCachedGraphAnalysisWarmupCandidate), + ); + if (supportedFiles.length === 0) { + return getSupportedCachedGraphAnalysisWarmupFiles(registry, files)[0] ?? files[0]; + } + + return selectMostRepresentedCachedGraphWarmupFile(supportedFiles); +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/support.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/support.ts new file mode 100644 index 000000000..697e0cb40 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/support.ts @@ -0,0 +1,12 @@ +import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import type { CachedGraphWarmupRegistry } from './contracts'; + +export function getSupportedCachedGraphAnalysisWarmupFiles( + registry: CachedGraphWarmupRegistry, + files: readonly IDiscoveredFile[], +): IDiscoveredFile[] { + return files.filter(file => + registry.supportsFile?.(file.absolutePath) + || registry.supportsFile?.(file.relativePath), + ); +} diff --git a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts index 7017720da..cbe7e4f4d 100644 --- a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts +++ b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts @@ -1,286 +1,10 @@ -import * as path from 'node:path'; -import * as vscode from 'vscode'; import { - type IDiscoveredFile, - projectFileAnalysisConnections, - readCodeGraphyWorkspaceStatus, - throwIfWorkspaceAnalysisAborted, -} from '@codegraphy-dev/core'; -import type { IProjectedConnection } from '../../../core/plugins/types/contracts'; -import type { IGraphData } from '../../../shared/graph/contracts'; -import type { IPluginFilterPatternGroup } from '../../../shared/protocol/extensionToWebview'; -import { - getWorkspacePipelinePluginFilterGroups, - getWorkspacePipelinePluginFilterPatterns, - initializeWorkspacePipeline, - syncWorkspacePipelinePlugins, -} from '../plugins/bootstrap'; -import type { WorkspacePipelineSourceOwner } from '../analysisSource'; -import { WorkspacePipelineInternalBase } from './base/internal'; -import { - createWorkspacePipelineDiscoveryDependencies, - discoverWorkspacePipelineFilesWithWarnings, -} from './runtime/discovery'; -import { hasWorkspacePipelineIndex } from './cache/index'; -import { - analyzeWorkspacePipeline, - rebuildWorkspacePipelineGraph, -} from './runtime/run'; -import { createEmptyWorkspaceAnalysisCache } from '../cache'; - -function createCachedDiscoveredFiles( - workspaceRoot: string, - filePaths: readonly string[], -): IDiscoveredFile[] { - return filePaths.map(relativePath => ({ - absolutePath: path.join(workspaceRoot, relativePath), - extension: path.extname(relativePath), - name: path.basename(relativePath), - relativePath, - })); -} - -function collectCachedDirectoryPaths(filePaths: readonly string[]): string[] { - const directories = new Set(); - - for (const filePath of filePaths) { - let directory = path.posix.dirname(filePath.replace(/\\/g, '/')); - while (directory && directory !== '.') { - directories.add(directory); - directory = path.posix.dirname(directory); - } - } - - return [...directories].sort(); -} - -export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipelineInternalBase { - private _workspacePluginReloadQueue: Promise = Promise.resolve(); - - async initialize(): Promise { - await initializeWorkspacePipeline(this._registry, { - getWorkspaceRoot: () => this._getWorkspaceRoot(), - }); - - console.log('[CodeGraphy] WorkspacePipeline initialized'); - } - - async reloadWorkspacePlugins(): Promise { - const reload = this._workspacePluginReloadQueue.then(async () => { - this._registry.disposeAll(); - await this.initialize(); - }); - this._workspacePluginReloadQueue = reload.catch(() => undefined); - return reload; - } - - async syncWorkspacePlugins(): Promise { - const sync = this._workspacePluginReloadQueue.then(async () => { - await syncWorkspacePipelinePlugins(this._registry, { - getWorkspaceRoot: () => this._getWorkspaceRoot(), - }); - }); - this._workspacePluginReloadQueue = sync.catch(() => undefined); - return sync; - } - - getPluginFilterPatterns( - disabledPlugins: ReadonlySet = new Set(), - ): string[] { - return getWorkspacePipelinePluginFilterPatterns(this._registry, disabledPlugins); - } - - getPluginFilterGroups( - disabledPlugins: ReadonlySet = new Set(), - ): IPluginFilterPatternGroup[] { - return getWorkspacePipelinePluginFilterGroups(this._registry, disabledPlugins); - } - - private _getEffectiveCustomFilterPatterns(filterPatterns: string[]): string[] { - const disabledPatterns = new Set(this._config.disabledCustomFilterPatterns); - return filterPatterns.filter(pattern => !disabledPatterns.has(pattern)); - } - - private _getEffectivePluginFilterPatterns(disabledPlugins: ReadonlySet): string[] { - const disabledPatterns = new Set(this._config.disabledPluginFilterPatterns); - return this.getPluginFilterPatterns(disabledPlugins) - .filter(pattern => !disabledPatterns.has(pattern)); - } - - hasIndex(): boolean { - return hasWorkspacePipelineIndex(this._getWorkspaceRoot()); - } - - getIndexStatus(): { freshness: 'fresh' | 'stale' | 'missing'; detail: string } { - const workspaceRoot = this._getWorkspaceRoot(); - if (!workspaceRoot) { - return { - freshness: 'missing', - detail: 'CodeGraphy index is missing. Index the workspace to build the graph.', - }; - } - - if (!this.hasIndex()) { - return { - freshness: 'missing', - detail: 'CodeGraphy index is missing. Index the workspace to build the graph.', - }; - } - - const status = readCodeGraphyWorkspaceStatus(workspaceRoot, { - pluginSignature: this._getPluginSignature(), - settingsSignature: this._getSettingsSignature(), - }); - - return { - freshness: status.state, - detail: status.detail, - }; - } - - async discoverGraph( - filterPatterns: string[] = [], - disabledPlugins: Set = new Set(), - signal?: AbortSignal, - ): Promise { - const workspaceRoot = this._getWorkspaceRoot(); - if (!workspaceRoot) { - console.log('[CodeGraphy] No workspace folder open'); - return { nodes: [], edges: [] }; - } - - const config = this._config.getAll(); - const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( - createWorkspacePipelineDiscoveryDependencies(this._discovery), - workspaceRoot, - config, - this._getEffectiveCustomFilterPatterns(filterPatterns), - this._getEffectivePluginFilterPatterns(disabledPlugins), - signal, - message => { - vscode.window.showWarningMessage(message); - }, - ); - const fileConnections = new Map( - discoveryResult.files.map(file => [file.relativePath, []]), - ); - - this._lastDiscoveredDirectories = discoveryResult.directories ?? []; - this._lastDiscoveredFiles = discoveryResult.files; - this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; - this._lastFileAnalysis = new Map(); - this._lastFileConnections = fileConnections; - this._lastWorkspaceRoot = workspaceRoot; - - return this._buildGraphData( - fileConnections, - workspaceRoot, - true, - disabledPlugins, - ); - } - - async analyze( - filterPatterns: string[] = [], - disabledPlugins: Set = new Set(), - signal?: AbortSignal, - onProgress?: (progress: { phase: string; current: number; total: number }) => void, - ): Promise { - return analyzeWorkspacePipeline( - this as unknown as WorkspacePipelineSourceOwner, - this._cache, - this._config, - this._discovery, - () => this._getWorkspaceRoot(), - this._getEffectiveCustomFilterPatterns(filterPatterns), - disabledPlugins, - onProgress, - signal, - async () => this._persistIndexMetadata(), - ); - } - - async loadCachedGraph( - _filterPatterns: string[] = [], - disabledPlugins: Set = new Set(), - signal?: AbortSignal, - ): Promise { - throwIfWorkspaceAnalysisAborted(signal); - await this._hydrateCacheFromGraphCache(); - throwIfWorkspaceAnalysisAborted(signal); - - const workspaceRoot = this._getWorkspaceRoot(); - if (!workspaceRoot) { - return { nodes: [], edges: [] }; - } - - const config = this._config.getAll(); - const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( - createWorkspacePipelineDiscoveryDependencies(this._discovery), - workspaceRoot, - config, - this._getEffectiveCustomFilterPatterns(_filterPatterns), - this._getEffectivePluginFilterPatterns(disabledPlugins), - signal, - message => { - vscode.window.showWarningMessage(message); - }, - ); - throwIfWorkspaceAnalysisAborted(signal); - - const fileAnalysis = new Map( - Object.entries(this._cache.files).map(([filePath, entry]) => [ - filePath, - entry.analysis, - ]), - ); - const cachedFilePaths = Object.keys(this._cache.files); - - this._lastDiscoveredFiles = createCachedDiscoveredFiles(workspaceRoot, cachedFilePaths); - this._lastDiscoveredDirectories = discoveryResult.directories - ?? collectCachedDirectoryPaths(cachedFilePaths); - this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; - this._lastFileAnalysis = fileAnalysis; - this._lastFileConnections = projectFileAnalysisConnections(fileAnalysis, workspaceRoot); - this._lastWorkspaceRoot = workspaceRoot; - - throwIfWorkspaceAnalysisAborted(signal); - - return this._buildGraphDataFromAnalysis( - fileAnalysis, - workspaceRoot, - config.showOrphans, - disabledPlugins, - ); - } - - rebuildGraph(disabledPlugins: Set, showOrphans: boolean): IGraphData { - return rebuildWorkspacePipelineGraph( - this as unknown as WorkspacePipelineSourceOwner, - disabledPlugins, - showOrphans, - ); - } - - protected resetCacheForIndexRefresh(): void { - this._cache = createEmptyWorkspaceAnalysisCache(); - console.log('[CodeGraphy] Cache cleared'); - } + WorkspacePipelineCachedGraphFacade, + type WorkspacePipelineCachedGraphLoadOptions, +} from './cachedGraph'; - async refreshIndex( - filterPatterns: string[] = [], - disabledPlugins: Set = new Set(), - signal?: AbortSignal, - onProgress?: (progress: { phase: string; current: number; total: number }) => void, - ): Promise { - this.resetCacheForIndexRefresh(); - return this.analyze(filterPatterns, disabledPlugins, signal, progress => { - onProgress?.({ - ...progress, - phase: progress.phase || 'Refreshing Index', - }); - }); - } +export type { WorkspacePipelineCachedGraphLoadOptions }; +export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipelineCachedGraphFacade { abstract clearCache(): void; } diff --git a/packages/extension/src/extension/pipeline/service/graphDiscovery.ts b/packages/extension/src/extension/pipeline/service/graphDiscovery.ts new file mode 100644 index 000000000..3cdefcb11 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/graphDiscovery.ts @@ -0,0 +1,52 @@ +import * as vscode from 'vscode'; +import type { IGraphData } from '../../../shared/graph/contracts'; +import type { IProjectedConnection } from '../../../core/plugins/types/contracts'; +import { WorkspacePipelinePluginFacade } from './pluginFacade'; +import { + createWorkspacePipelineDiscoveryDependencies, + discoverWorkspacePipelineFilesWithWarnings, +} from './runtime/discovery'; + +export abstract class WorkspacePipelineGraphDiscoveryFacade extends WorkspacePipelinePluginFacade { + async discoverGraph( + filterPatterns: string[] = [], + disabledPlugins: Set = new Set(), + signal?: AbortSignal, + ): Promise { + const workspaceRoot = this._getWorkspaceRoot(); + if (!workspaceRoot) { + console.log('[CodeGraphy] No workspace folder open'); + return { nodes: [], edges: [] }; + } + + const config = this._config.getAll(); + const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( + createWorkspacePipelineDiscoveryDependencies(this._discovery), + workspaceRoot, + config, + this._getEffectiveCustomFilterPatterns(filterPatterns), + this._getEffectivePluginFilterPatterns(disabledPlugins), + signal, + message => { + vscode.window.showWarningMessage(message); + }, + ); + const fileConnections = new Map( + discoveryResult.files.map(file => [file.relativePath, []]), + ); + + this._lastDiscoveredDirectories = discoveryResult.directories ?? []; + this._lastDiscoveredFiles = discoveryResult.files; + this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; + this._lastFileAnalysis = new Map(); + this._lastFileConnections = fileConnections; + this._lastWorkspaceRoot = workspaceRoot; + + return this._buildGraphData( + fileConnections, + workspaceRoot, + true, + disabledPlugins, + ); + } +} diff --git a/packages/extension/src/extension/pipeline/service/indexStatus.ts b/packages/extension/src/extension/pipeline/service/indexStatus.ts new file mode 100644 index 000000000..2211c6038 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/indexStatus.ts @@ -0,0 +1,36 @@ +import { readCodeGraphyWorkspaceStatus } from '@codegraphy-dev/core'; + +export interface WorkspacePipelineIndexStatusInput { + hasIndex(): boolean; + pluginSignature: string | null; + settingsSignature: string; + workspaceRoot: string | undefined; +} + +export function getWorkspacePipelineIndexStatus( + input: WorkspacePipelineIndexStatusInput, +): { freshness: 'fresh' | 'stale' | 'missing'; detail: string } { + if (!input.workspaceRoot) { + return { + freshness: 'missing', + detail: 'CodeGraphy index is missing. Index the workspace to build the graph.', + }; + } + + if (!input.hasIndex()) { + return { + freshness: 'missing', + detail: 'CodeGraphy index is missing. Index the workspace to build the graph.', + }; + } + + const status = readCodeGraphyWorkspaceStatus(input.workspaceRoot, { + pluginSignature: input.pluginSignature, + settingsSignature: input.settingsSignature, + }); + + return { + freshness: status.state, + detail: status.detail, + }; +} diff --git a/packages/extension/src/extension/pipeline/service/pluginFacade.ts b/packages/extension/src/extension/pipeline/service/pluginFacade.ts new file mode 100644 index 000000000..8f8b114ea --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/pluginFacade.ts @@ -0,0 +1,76 @@ +import type { IPluginFilterPatternGroup } from '../../../shared/protocol/extensionToWebview'; +import { hasWorkspacePipelineIndex } from './cache/index'; +import { WorkspacePipelineInternalBase } from './base/internal'; +import { getWorkspacePipelineIndexStatus } from './indexStatus'; +import { + getEffectiveCustomFilterPatterns, + getEffectivePluginFilterPatterns, + getPipelinePluginFilterGroups, + getPipelinePluginFilterPatterns, + initializeWorkspacePipelinePlugins, + queueWorkspacePipelinePluginReload, + queueWorkspacePipelinePluginSync, +} from './pluginState'; + +export abstract class WorkspacePipelinePluginFacade extends WorkspacePipelineInternalBase { + private _workspacePluginReloadQueue: Promise = Promise.resolve(); + + async initialize(): Promise { + await initializeWorkspacePipelinePlugins(this._registry, () => this._getWorkspaceRoot()); + + console.log('[CodeGraphy] WorkspacePipeline initialized'); + } + + async reloadWorkspacePlugins(): Promise { + const { reload, nextQueue } = queueWorkspacePipelinePluginReload( + this._workspacePluginReloadQueue, + this._registry, + () => this.initialize(), + ); + this._workspacePluginReloadQueue = nextQueue; + return reload; + } + + async syncWorkspacePlugins(): Promise { + const { sync, nextQueue } = queueWorkspacePipelinePluginSync( + this._workspacePluginReloadQueue, + this._registry, + () => this._getWorkspaceRoot(), + ); + this._workspacePluginReloadQueue = nextQueue; + return sync; + } + + getPluginFilterPatterns( + disabledPlugins: ReadonlySet = new Set(), + ): string[] { + return getPipelinePluginFilterPatterns(this._registry, disabledPlugins); + } + + getPluginFilterGroups( + disabledPlugins: ReadonlySet = new Set(), + ): IPluginFilterPatternGroup[] { + return getPipelinePluginFilterGroups(this._registry, disabledPlugins); + } + + protected _getEffectiveCustomFilterPatterns(filterPatterns: string[]): string[] { + return getEffectiveCustomFilterPatterns(this._config, filterPatterns); + } + + protected _getEffectivePluginFilterPatterns(disabledPlugins: ReadonlySet): string[] { + return getEffectivePluginFilterPatterns(this._registry, this._config, disabledPlugins); + } + + hasIndex(): boolean { + return hasWorkspacePipelineIndex(this._getWorkspaceRoot()); + } + + getIndexStatus(): { freshness: 'fresh' | 'stale' | 'missing'; detail: string } { + return getWorkspacePipelineIndexStatus({ + hasIndex: () => this.hasIndex(), + pluginSignature: this._getPluginSignature(), + settingsSignature: this._getSettingsSignature(), + workspaceRoot: this._getWorkspaceRoot(), + }); + } +} diff --git a/packages/extension/src/extension/pipeline/service/pluginState.ts b/packages/extension/src/extension/pipeline/service/pluginState.ts new file mode 100644 index 000000000..7c2d9f3b4 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/pluginState.ts @@ -0,0 +1,86 @@ +import type { IPluginFilterPatternGroup } from '../../../shared/protocol/extensionToWebview'; +import { + getWorkspacePipelinePluginFilterGroups, + getWorkspacePipelinePluginFilterPatterns, + initializeWorkspacePipeline, + syncWorkspacePipelinePlugins, +} from '../plugins/bootstrap'; + +type WorkspacePipelinePluginRegistry = Parameters[0] & { + disposeAll(): void; +}; + +interface WorkspacePipelinePluginFilterConfig { + disabledCustomFilterPatterns: readonly string[]; + disabledPluginFilterPatterns: readonly string[]; +} + +export async function initializeWorkspacePipelinePlugins( + registry: WorkspacePipelinePluginRegistry, + getWorkspaceRoot: () => string | undefined, +): Promise { + await initializeWorkspacePipeline(registry, { getWorkspaceRoot }); +} + +export function queueWorkspacePipelinePluginReload( + queue: Promise, + registry: WorkspacePipelinePluginRegistry, + initialize: () => Promise, +): { nextQueue: Promise; reload: Promise } { + const reload = queue.then(async () => { + registry.disposeAll(); + await initialize(); + }); + + return { + nextQueue: reload.catch(() => undefined), + reload, + }; +} + +export function queueWorkspacePipelinePluginSync( + queue: Promise, + registry: WorkspacePipelinePluginRegistry, + getWorkspaceRoot: () => string | undefined, +): { nextQueue: Promise; sync: Promise } { + const sync = queue.then(async () => { + await syncWorkspacePipelinePlugins(registry, { getWorkspaceRoot }); + }); + + return { + nextQueue: sync.catch(() => undefined), + sync, + }; +} + +export function getPipelinePluginFilterPatterns( + registry: WorkspacePipelinePluginRegistry, + disabledPlugins: ReadonlySet = new Set(), +): string[] { + return getWorkspacePipelinePluginFilterPatterns(registry, disabledPlugins); +} + +export function getPipelinePluginFilterGroups( + registry: WorkspacePipelinePluginRegistry, + disabledPlugins: ReadonlySet = new Set(), +): IPluginFilterPatternGroup[] { + return getWorkspacePipelinePluginFilterGroups(registry, disabledPlugins); +} + +export function getEffectiveCustomFilterPatterns( + config: WorkspacePipelinePluginFilterConfig, + filterPatterns: string[], +): string[] { + const disabledPatterns = new Set(config.disabledCustomFilterPatterns); + return filterPatterns.filter(pattern => !disabledPatterns.has(pattern)); +} + +export function getEffectivePluginFilterPatterns( + registry: WorkspacePipelinePluginRegistry, + config: WorkspacePipelinePluginFilterConfig, + disabledPlugins: ReadonlySet, +): string[] { + const disabledPatterns = new Set(config.disabledPluginFilterPatterns); + return getPipelinePluginFilterPatterns(registry, disabledPlugins) + .filter(pattern => !disabledPatterns.has(pattern)); +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/context.ts b/packages/extension/src/extension/pipeline/service/refresh/context.ts new file mode 100644 index 000000000..c6303150d --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/context.ts @@ -0,0 +1,31 @@ +import type { FileDiscovery } from '@codegraphy-dev/core'; +import type { Configuration } from '../../../config/reader'; +import type { PluginRegistry } from '../../../../core/plugins/registry/manager'; +import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { AnalysisScopeRefreshFacade } from './scope'; +import type { RefreshSourceFacade } from './source'; + +export type RefreshProgress = { + phase: string; + current: number; + total: number; +}; + +export interface RefreshFacadeContext + extends AnalysisScopeRefreshFacade, RefreshSourceFacade { + _config: Pick; + _discovery: Pick; + _getActiveAnalysisPluginIds( + pluginIds: readonly string[] | undefined, + disabledPlugins: ReadonlySet, + ): string[]; + _getWorkspaceRoot(): string | undefined; + _lastGitIgnoredPaths: string[]; + _persistCache(): void; + _persistIndexMetadata(): Promise; + _registry: Pick; + _toWorkspaceRelativePath(workspaceRoot: string, filePath: string): string | undefined; + getPluginFilterPatterns(disabledPlugins: Set): string[]; +} + +export const EMPTY_REFRESH_GRAPH: IGraphData = { nodes: [], edges: [] }; diff --git a/packages/extension/src/extension/pipeline/service/refresh/discovery/changed.ts b/packages/extension/src/extension/pipeline/service/refresh/discovery/changed.ts new file mode 100644 index 000000000..e77327daa --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/discovery/changed.ts @@ -0,0 +1,76 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { IDiscoveredFile } from '@codegraphy-dev/core'; + +export interface ChangedFileDiscoveryState { + directories: string[]; + files: IDiscoveredFile[]; +} + +interface ReusableChangedFileDiscoveryInput { + filePaths: readonly string[]; + lastDiscoveredDirectories: readonly string[]; + lastDiscoveredFiles: IDiscoveredFile[]; + lastWorkspaceRoot: string; + toWorkspaceRelativePath(workspaceRoot: string, filePath: string): string | undefined; + workspaceRoot: string; +} + +export function getReusableChangedFileDiscoveryState( + input: ReusableChangedFileDiscoveryInput, +): ChangedFileDiscoveryState | undefined { + if (!hasReusableChangedFileDiscoveryState(input)) { + return undefined; + } + + const discoveredByRelativePath = createDiscoveredFilesByRelativePath(input.lastDiscoveredFiles); + + for (const filePath of input.filePaths) { + if (!canReuseChangedFileDiscovery(filePath, discoveredByRelativePath, input)) { + return undefined; + } + } + + return { + directories: [...input.lastDiscoveredDirectories], + files: input.lastDiscoveredFiles, + }; +} + +function hasReusableChangedFileDiscoveryState(input: ReusableChangedFileDiscoveryInput): boolean { + return input.filePaths.length > 0 + && input.lastWorkspaceRoot === input.workspaceRoot + && input.lastDiscoveredFiles.length > 0; +} + +function createDiscoveredFilesByRelativePath( + discoveredFiles: readonly IDiscoveredFile[], +): Map { + return new Map( + discoveredFiles.map(file => [ + normalizeGraphMetricFilePath(file.relativePath), + file, + ]), + ); +} + +function canReuseChangedFileDiscovery( + filePath: string, + discoveredByRelativePath: ReadonlyMap, + input: ReusableChangedFileDiscoveryInput, +): boolean { + const relativePath = input.toWorkspaceRelativePath(input.workspaceRoot, filePath); + return Boolean( + relativePath + && discoveredByRelativePath.has(relativePath) + && fs.existsSync(toAbsoluteChangedFilePath(input.workspaceRoot, filePath)), + ); +} + +function toAbsoluteChangedFilePath(workspaceRoot: string, filePath: string): string { + return path.isAbsolute(filePath) ? filePath : path.join(workspaceRoot, filePath); +} + +function normalizeGraphMetricFilePath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/discovery/workspace.ts b/packages/extension/src/extension/pipeline/service/refresh/discovery/workspace.ts new file mode 100644 index 000000000..40e6cd683 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/discovery/workspace.ts @@ -0,0 +1,49 @@ +import * as vscode from 'vscode'; +import type { FileDiscovery, IDiscoveredFile } from '@codegraphy-dev/core'; +import type { ICodeGraphyConfig } from '../../../../config/defaults'; +import type { WorkspacePipelineDiscoveryResult } from '../../../discovery'; +import { + createWorkspacePipelineDiscoveryDependencies, + discoverWorkspacePipelineFilesWithWarnings, +} from '../../runtime/discovery'; + +interface RefreshDiscoveryConfigReader { + getAll(): ICodeGraphyConfig; +} + +interface RefreshWorkspaceFileDiscoveryInput { + configReader: RefreshDiscoveryConfigReader; + disabledPlugins: Set; + discovery: Pick; + filterPatterns: readonly string[]; + getPluginFilterPatterns(disabledPlugins: Set): string[]; + signal?: AbortSignal; + workspaceRoot: string; +} + +interface RefreshWorkspaceFileDiscoveryResult { + config: ICodeGraphyConfig; + discoveryResult: WorkspacePipelineDiscoveryResult; +} + +export async function discoverRefreshWorkspaceFiles( + input: RefreshWorkspaceFileDiscoveryInput, +): Promise { + const config = input.configReader.getAll(); + const disabledCustomPatterns = new Set(config.disabledCustomFilterPatterns); + const disabledPluginPatterns = new Set(config.disabledPluginFilterPatterns); + const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( + createWorkspacePipelineDiscoveryDependencies(input.discovery), + input.workspaceRoot, + config, + input.filterPatterns.filter(pattern => !disabledCustomPatterns.has(pattern)), + input.getPluginFilterPatterns(input.disabledPlugins) + .filter(pattern => !disabledPluginPatterns.has(pattern)), + input.signal, + message => { + vscode.window.showWarningMessage(message); + }, + ); + + return { config, discoveryResult }; +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/metrics.ts b/packages/extension/src/extension/pipeline/service/refresh/metrics.ts new file mode 100644 index 000000000..0dd5881f8 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/metrics.ts @@ -0,0 +1,61 @@ +import type { IGraphData } from '../../../../shared/graph/contracts'; + +interface GraphMetricPatchResult { + changed: boolean; + node: IGraphData['nodes'][number]; +} + +interface PatchGraphDataNodeMetricsInput { + churnCounts: Record; + filePaths: readonly string[]; + fileSizes: Record; + graphData: IGraphData; +} + +export function patchGraphDataNodeMetrics(input: PatchGraphDataNodeMetricsInput): IGraphData { + const metricFilePaths = new Set(input.filePaths.map(normalizeGraphMetricFilePath)); + if (metricFilePaths.size === 0) { + return input.graphData; + } + + let changed = false; + const nodes = input.graphData.nodes.map((node) => { + const result = patchGraphDataNodeMetric(node, metricFilePaths, input); + changed ||= result.changed; + return result.node; + }); + + return changed ? { ...input.graphData, nodes } : input.graphData; +} + +function normalizeGraphMetricFilePath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +function getGraphMetricNodeFilePath(node: IGraphData['nodes'][number]): string { + const symbolFilePath = node.symbol?.filePath; + return normalizeGraphMetricFilePath( + typeof symbolFilePath === 'string' && symbolFilePath.length > 0 + ? symbolFilePath + : node.id, + ); +} + +function patchGraphDataNodeMetric( + node: IGraphData['nodes'][number], + metricFilePaths: ReadonlySet, + input: PatchGraphDataNodeMetricsInput, +): GraphMetricPatchResult { + const filePath = getGraphMetricNodeFilePath(node); + if (!metricFilePaths.has(filePath)) { + return { changed: false, node }; + } + + const fileSize = input.fileSizes[filePath]?.size; + const churn = input.churnCounts[filePath] ?? 0; + if (node.fileSize === fileSize && node.churn === churn) { + return { changed: false, node }; + } + + return { changed: true, node: { ...node, fileSize, churn } }; +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/modes/analysisScope.ts b/packages/extension/src/extension/pipeline/service/refresh/modes/analysisScope.ts new file mode 100644 index 000000000..18473aeae --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/modes/analysisScope.ts @@ -0,0 +1,73 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import { refreshWorkspacePipelineAnalysisScope } from '../../runtime/refresh'; +import { + canReuseCurrentAnalysisForScope, + rebuildAnalysisScopeFromCurrentAnalysis, +} from '../scope'; +import { createWorkspaceIndexRefreshSource } from '../source'; +import type { RefreshFacadeContext, RefreshProgress } from '../context'; +import { EMPTY_REFRESH_GRAPH } from '../context'; +import { discoverRefreshWorkspaceFiles } from '../discovery/workspace'; + +interface RefreshAnalysisScopeInput { + disabledPlugins: Set; + filterPatterns: string[]; + onProgress?: (progress: RefreshProgress) => void; + signal?: AbortSignal; +} + +export async function refreshAnalysisScopeForFacade( + facade: RefreshFacadeContext, + input: RefreshAnalysisScopeInput, +): Promise { + const workspaceRoot = facade._getWorkspaceRoot(); + if (!workspaceRoot) { + return EMPTY_REFRESH_GRAPH; + } + + const { config, discoveryResult } = await discoverRefreshWorkspaceFiles({ + configReader: facade._config, + disabledPlugins: input.disabledPlugins, + discovery: facade._discovery, + filterPatterns: input.filterPatterns, + getPluginFilterPatterns: plugins => facade.getPluginFilterPatterns(plugins), + signal: input.signal, + workspaceRoot, + }); + facade._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; + + if (canReuseCurrentAnalysisForScope({ + activePluginIds: facade._getActiveAnalysisPluginIds(undefined, input.disabledPlugins), + disabledPlugins: input.disabledPlugins, + discoveredFiles: discoveryResult.files, + lastFileAnalysis: facade._lastFileAnalysis, + nodeVisibility: facade._config.get>('nodeVisibility', {}) ?? {}, + })) { + return rebuildAnalysisScopeFromCurrentAnalysis(facade, { + disabledPlugins: input.disabledPlugins, + discoveredDirectories: discoveryResult.directories ?? [], + discoveredFiles: discoveryResult.files, + onProgress: input.onProgress, + showOrphans: config.showOrphans ?? true, + workspaceRoot, + }); + } + + return refreshWorkspacePipelineAnalysisScope(createWorkspaceIndexRefreshSource( + facade, + input.disabledPlugins, + ), { + disabledPlugins: input.disabledPlugins, + discoveredDirectories: discoveryResult.directories ?? [], + discoveredFiles: discoveryResult.files, + onProgress: input.onProgress, + persistCache: () => { + facade._persistCache(); + }, + persistIndexMetadata: async () => { + await facade._persistIndexMetadata(); + }, + signal: input.signal, + workspaceRoot, + }); +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/modes/changedFiles.ts b/packages/extension/src/extension/pipeline/service/refresh/modes/changedFiles.ts new file mode 100644 index 000000000..38c6ec18c --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/modes/changedFiles.ts @@ -0,0 +1,102 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import { refreshWorkspacePipelineChangedFiles } from '../../runtime/refresh'; +import { + getReusableChangedFileDiscoveryState, + type ChangedFileDiscoveryState, +} from '../discovery/changed'; +import type { RefreshFacadeContext, RefreshProgress } from '../context'; +import { EMPTY_REFRESH_GRAPH } from '../context'; +import { discoverRefreshWorkspaceFiles } from '../discovery/workspace'; +import { createWorkspaceIndexRefreshSource } from '../source'; + +interface RefreshChangedFilesInput { + disabledPlugins: Set; + filePaths: readonly string[]; + filterPatterns: string[]; + onProgress?: (progress: RefreshProgress) => void; + signal?: AbortSignal; +} + +export async function refreshChangedFilesForFacade( + facade: RefreshFacadeContext, + input: RefreshChangedFilesInput, +): Promise { + const workspaceRoot = facade._getWorkspaceRoot(); + if (!workspaceRoot) { + return EMPTY_REFRESH_GRAPH; + } + + const discoveryResult = await getChangedFileDiscoveryState(facade, input, workspaceRoot); + return refreshWorkspacePipelineChangedFiles(createWorkspaceIndexRefreshSource( + facade, + input.disabledPlugins, + ), { + deferMetricOnlyIndexMetadata: true, + disabledPlugins: input.disabledPlugins, + discoveredDirectories: discoveryResult.directories, + discoveredFiles: discoveryResult.files, + filePaths: input.filePaths, + filterPatterns: input.filterPatterns, + notifyFilesChanged: ( + files, + root, + analysisContext, + nextDisabledPlugins = input.disabledPlugins, + ) => + facade._registry.notifyFilesChanged( + files, + root, + analysisContext, + nextDisabledPlugins, + ), + onDeferredIndexMetadataError: error => { + console.warn('[CodeGraphy] Failed to persist metric-only refresh metadata.', error); + }, + onProgress: input.onProgress, + persistCache: () => { + facade._persistCache(); + }, + persistIndexMetadata: async () => { + await facade._persistIndexMetadata(); + }, + signal: input.signal, + workspaceRoot, + }); +} + +async function getChangedFileDiscoveryState( + facade: RefreshFacadeContext, + input: RefreshChangedFilesInput, + workspaceRoot: string, +): Promise { + const reusableDiscoveryState = getReusableChangedFileDiscoveryState({ + filePaths: input.filePaths, + lastDiscoveredDirectories: facade._lastDiscoveredDirectories, + lastDiscoveredFiles: facade._lastDiscoveredFiles, + lastWorkspaceRoot: facade._lastWorkspaceRoot, + toWorkspaceRelativePath: (root, filePath) => + facade._toWorkspaceRelativePath(root, filePath), + workspaceRoot, + }); + + if (reusableDiscoveryState) { + return reusableDiscoveryState; + } + + const discovered = await discoverRefreshWorkspaceFiles({ + configReader: facade._config, + disabledPlugins: input.disabledPlugins, + discovery: facade._discovery, + filterPatterns: input.filterPatterns, + getPluginFilterPatterns: plugins => facade.getPluginFilterPatterns(plugins), + signal: input.signal, + workspaceRoot, + }); + const discoveryResult = { + directories: discovered.discoveryResult.directories ?? [], + files: discovered.discoveryResult.files, + }; + facade._lastDiscoveredDirectories = discoveryResult.directories; + facade._lastGitIgnoredPaths = discovered.discoveryResult.gitIgnoredPaths ?? []; + return discoveryResult; +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/modes/gitignoreMetadata.ts b/packages/extension/src/extension/pipeline/service/refresh/modes/gitignoreMetadata.ts new file mode 100644 index 000000000..3ae75c040 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/modes/gitignoreMetadata.ts @@ -0,0 +1,46 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import type { RefreshFacadeContext } from '../context'; +import { EMPTY_REFRESH_GRAPH } from '../context'; +import { discoverRefreshWorkspaceFiles } from '../discovery/workspace'; + +interface RefreshGitignoreMetadataInput { + disabledPlugins: Set; + filterPatterns: string[]; + signal?: AbortSignal; +} + +export async function refreshGitignoreMetadataForFacade( + facade: RefreshFacadeContext, + input: RefreshGitignoreMetadataInput, +): Promise { + const workspaceRoot = facade._getWorkspaceRoot(); + if (!workspaceRoot) { + return EMPTY_REFRESH_GRAPH; + } + + const { config, discoveryResult } = await discoverRefreshWorkspaceFiles({ + configReader: facade._config, + disabledPlugins: input.disabledPlugins, + discovery: facade._discovery, + filterPatterns: input.filterPatterns, + getPluginFilterPatterns: plugins => facade.getPluginFilterPatterns(plugins), + signal: input.signal, + workspaceRoot, + }); + + facade._lastDiscoveredDirectories = discoveryResult.directories ?? []; + facade._lastDiscoveredFiles = discoveryResult.files; + facade._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; + facade._lastWorkspaceRoot = workspaceRoot; + + void facade._persistIndexMetadata().catch(error => { + console.warn('[CodeGraphy] Failed to persist gitignore metadata refresh.', error); + }); + + return facade._buildGraphDataFromAnalysis( + facade._lastFileAnalysis, + workspaceRoot, + config.showOrphans, + input.disabledPlugins, + ); +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/modes/pluginFiles.ts b/packages/extension/src/extension/pipeline/service/refresh/modes/pluginFiles.ts new file mode 100644 index 000000000..30d7d9695 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/modes/pluginFiles.ts @@ -0,0 +1,55 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import { refreshWorkspacePipelinePluginFiles } from '../../runtime/refresh'; +import { createWorkspaceIndexRefreshSource } from '../source'; +import type { RefreshFacadeContext, RefreshProgress } from '../context'; +import { EMPTY_REFRESH_GRAPH } from '../context'; +import { discoverRefreshWorkspaceFiles } from '../discovery/workspace'; + +interface RefreshPluginFilesInput { + disabledPlugins: Set; + filterPatterns: string[]; + onProgress?: (progress: RefreshProgress) => void; + pluginIds: readonly string[]; + signal?: AbortSignal; +} + +export async function refreshPluginFilesForFacade( + facade: RefreshFacadeContext, + input: RefreshPluginFilesInput, +): Promise { + const workspaceRoot = facade._getWorkspaceRoot(); + if (!workspaceRoot || input.pluginIds.length === 0) { + return EMPTY_REFRESH_GRAPH; + } + + const { discoveryResult } = await discoverRefreshWorkspaceFiles({ + configReader: facade._config, + disabledPlugins: input.disabledPlugins, + discovery: facade._discovery, + filterPatterns: input.filterPatterns, + getPluginFilterPatterns: plugins => facade.getPluginFilterPatterns(plugins), + signal: input.signal, + workspaceRoot, + }); + facade._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; + + return refreshWorkspacePipelinePluginFiles(createWorkspaceIndexRefreshSource( + facade, + input.disabledPlugins, + ), { + disabledPlugins: input.disabledPlugins, + discoveredDirectories: discoveryResult.directories ?? [], + discoveredFiles: discoveryResult.files, + onProgress: input.onProgress, + persistCache: () => { + facade._persistCache(); + }, + persistIndexMetadata: async () => { + await facade._persistIndexMetadata(); + }, + pluginIds: input.pluginIds, + pluginInfos: facade._registry.list(), + signal: input.signal, + workspaceRoot, + }); +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/scope.ts b/packages/extension/src/extension/pipeline/service/refresh/scope.ts new file mode 100644 index 000000000..bcde04c2c --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/scope.ts @@ -0,0 +1,85 @@ +import { + hasRequiredAnalysisCacheTiers, + type IDiscoveredFile, +} from '@codegraphy-dev/core'; +import type { IGraphData } from '../../../../shared/graph/contracts'; +import { createWorkspacePipelineAnalysisCacheTiers } from '../cache/tiers'; +import type { WorkspacePipelineRefreshSource } from '../runtime/refresh'; + +interface CurrentAnalysisScopeInput { + activePluginIds: readonly string[]; + discoveredFiles: readonly IDiscoveredFile[]; + disabledPlugins: Set; + lastFileAnalysis: WorkspacePipelineRefreshSource['_lastFileAnalysis']; + nodeVisibility: Record; +} + +export function canReuseCurrentAnalysisForScope(input: CurrentAnalysisScopeInput): boolean { + if (input.discoveredFiles.length === 0) { + return false; + } + + const requiredTiers = createWorkspacePipelineAnalysisCacheTiers( + input.nodeVisibility, + input.activePluginIds, + ).required; + + return input.discoveredFiles.every((file) => { + const analysis = input.lastFileAnalysis.get(file.relativePath); + return Boolean(analysis && hasRequiredAnalysisCacheTiers(analysis, requiredTiers)); + }); +} + +export interface AnalysisScopeRefreshFacade { + _buildGraphDataFromAnalysis( + fileAnalysis: WorkspacePipelineRefreshSource['_lastFileAnalysis'], + workspaceRoot: string, + showOrphans: boolean, + disabledPlugins: Set, + ): IGraphData; + _lastDiscoveredDirectories: string[]; + _lastDiscoveredFiles: IDiscoveredFile[]; + _lastFileAnalysis: WorkspacePipelineRefreshSource['_lastFileAnalysis']; + _lastWorkspaceRoot: string; + _persistIndexMetadata(): Promise; +} + +interface RebuildAnalysisScopeInput { + discoveredDirectories: readonly string[]; + discoveredFiles: IDiscoveredFile[]; + disabledPlugins: Set; + onProgress?: (progress: { phase: string; current: number; total: number }) => void; + showOrphans: boolean; + workspaceRoot: string; +} + +export async function rebuildAnalysisScopeFromCurrentAnalysis( + facade: AnalysisScopeRefreshFacade, + input: RebuildAnalysisScopeInput, +): Promise { + input.onProgress?.({ + phase: 'Applying Scope', + current: 0, + total: input.discoveredFiles.length, + }); + + facade._lastDiscoveredDirectories = [...input.discoveredDirectories]; + facade._lastDiscoveredFiles = input.discoveredFiles; + facade._lastWorkspaceRoot = input.workspaceRoot; + + const graphData = facade._buildGraphDataFromAnalysis( + facade._lastFileAnalysis, + input.workspaceRoot, + input.showOrphans, + input.disabledPlugins, + ); + + await facade._persistIndexMetadata(); + input.onProgress?.({ + phase: 'Applying Scope', + current: input.discoveredFiles.length, + total: input.discoveredFiles.length, + }); + + return graphData; +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/source.ts b/packages/extension/src/extension/pipeline/service/refresh/source.ts new file mode 100644 index 000000000..dbb48dfea --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/source.ts @@ -0,0 +1,109 @@ +import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { WorkspacePipelineRefreshSource } from '../runtime/refresh'; + +type RefreshSourceBuildGraphData = WorkspacePipelineRefreshSource['_buildGraphData']; +type RefreshSourceBuildGraphDataFromAnalysis = + WorkspacePipelineRefreshSource['_buildGraphDataFromAnalysis']; + +export interface RefreshSourceFacade { + _analyzeFiles: WorkspacePipelineRefreshSource['_analyzeFiles']; + _buildGraphData( + fileConnections: Parameters[0], + workspaceRoot: string, + showOrphans: boolean, + disabledPlugins: Set, + ): IGraphData; + _buildGraphDataFromAnalysis( + fileAnalysis: Parameters[0], + workspaceRoot: string, + showOrphans: boolean, + disabledPlugins: Set, + ): IGraphData; + _lastDiscoveredDirectories: WorkspacePipelineRefreshSource['_lastDiscoveredDirectories']; + _lastDiscoveredFiles: WorkspacePipelineRefreshSource['_lastDiscoveredFiles']; + _lastFileAnalysis: WorkspacePipelineRefreshSource['_lastFileAnalysis']; + _lastFileConnections: WorkspacePipelineRefreshSource['_lastFileConnections']; + _lastGraphData: WorkspacePipelineRefreshSource['_lastGraphData']; + _lastWorkspaceRoot: WorkspacePipelineRefreshSource['_lastWorkspaceRoot']; + _patchGraphDataNodeMetrics: NonNullable; + _preAnalyzePlugins: WorkspacePipelineRefreshSource['_preAnalyzePlugins']; + _readAnalysisFiles: WorkspacePipelineRefreshSource['_readAnalysisFiles']; + analyze: WorkspacePipelineRefreshSource['analyze']; + invalidateWorkspaceFiles: WorkspacePipelineRefreshSource['invalidateWorkspaceFiles']; +} + +export function createWorkspaceIndexRefreshSource( + facade: RefreshSourceFacade, + disabledPlugins: Set = new Set(), +): WorkspacePipelineRefreshSource { + const source = { + _analyzeFiles: ( + files, + root, + progress, + abortSignal, + pluginIds, + nextDisabledPlugins = disabledPlugins, + ) => facade._analyzeFiles( + files, + root, + progress, + abortSignal, + pluginIds, + nextDisabledPlugins, + ), + _buildGraphData: (fileConnections, root, selectedPlugins) => + facade._buildGraphData(fileConnections, root, true, selectedPlugins), + _buildGraphDataFromAnalysis: (fileAnalysis, root, selectedPlugins) => + facade._buildGraphDataFromAnalysis(fileAnalysis, root, true, selectedPlugins), + _patchGraphDataNodeMetrics: (graphData, filePaths) => + facade._patchGraphDataNodeMetrics(graphData, filePaths), + _preAnalyzePlugins: (files, root, abortSignal, nextDisabledPlugins = disabledPlugins) => + facade._preAnalyzePlugins(files, root, abortSignal, nextDisabledPlugins), + _readAnalysisFiles: files => facade._readAnalysisFiles(files), + analyze: (patterns, selectedPlugins, abortSignal, progress) => + facade.analyze(patterns, selectedPlugins, abortSignal, progress), + invalidateWorkspaceFiles: paths => facade.invalidateWorkspaceFiles(paths), + } as WorkspacePipelineRefreshSource; + + Object.defineProperties(source, { + _lastDiscoveredDirectories: { + get: () => facade._lastDiscoveredDirectories, + set: (directories: WorkspacePipelineRefreshSource['_lastDiscoveredDirectories']) => { + facade._lastDiscoveredDirectories = directories; + }, + }, + _lastDiscoveredFiles: { + get: () => facade._lastDiscoveredFiles, + set: (files: WorkspacePipelineRefreshSource['_lastDiscoveredFiles']) => { + facade._lastDiscoveredFiles = files; + }, + }, + _lastFileAnalysis: { + get: () => facade._lastFileAnalysis, + set: (fileAnalysis: WorkspacePipelineRefreshSource['_lastFileAnalysis']) => { + facade._lastFileAnalysis = fileAnalysis; + }, + }, + _lastFileConnections: { + get: () => facade._lastFileConnections, + set: (fileConnections: WorkspacePipelineRefreshSource['_lastFileConnections']) => { + facade._lastFileConnections = fileConnections; + }, + }, + _lastGraphData: { + get: () => facade._lastGraphData, + set: (graphData: WorkspacePipelineRefreshSource['_lastGraphData']) => { + facade._lastGraphData = graphData; + }, + }, + _lastWorkspaceRoot: { + get: () => facade._lastWorkspaceRoot, + set: (root: WorkspacePipelineRefreshSource['_lastWorkspaceRoot']) => { + facade._lastWorkspaceRoot = root; + }, + }, + }); + + return source; +} diff --git a/packages/extension/src/extension/pipeline/service/refreshFacade.ts b/packages/extension/src/extension/pipeline/service/refreshFacade.ts index e1175acab..bd49f98a6 100644 --- a/packages/extension/src/extension/pipeline/service/refreshFacade.ts +++ b/packages/extension/src/extension/pipeline/service/refreshFacade.ts @@ -1,83 +1,29 @@ -import * as vscode from 'vscode'; import type { IGraphData } from '../../../shared/graph/contracts'; import { WorkspacePipelineDiscoveryFacade } from './discoveryFacade'; -import { - createWorkspacePipelineDiscoveryDependencies, - discoverWorkspacePipelineFilesWithWarnings, -} from './runtime/discovery'; -import { - refreshWorkspacePipelineAnalysisScope, - refreshWorkspacePipelineChangedFiles, - refreshWorkspacePipelinePluginFiles, - type WorkspacePipelineRefreshSource, -} from './runtime/refresh'; +import { getCachedGitHistoryChurnCounts } from '../../gitHistory/cache/state'; +import { createGitHistoryPluginSignature } from '../../gitHistory/pluginSignature'; +import { refreshAnalysisScopeForFacade } from './refresh/modes/analysisScope'; +import { refreshChangedFilesForFacade } from './refresh/modes/changedFiles'; +import type { RefreshFacadeContext } from './refresh/context'; +import { refreshGitignoreMetadataForFacade } from './refresh/modes/gitignoreMetadata'; +import { patchGraphDataNodeMetrics } from './refresh/metrics'; +import { refreshPluginFilesForFacade } from './refresh/modes/pluginFiles'; export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDiscoveryFacade { - private _createWorkspaceIndexRefreshSource( - disabledPlugins: Set = new Set(), - ): WorkspacePipelineRefreshSource { - const source = { - _analyzeFiles: ( - files, - root, - progress, - abortSignal, - pluginIds, - nextDisabledPlugins = disabledPlugins, - ) => this._analyzeFiles( - files, - root, - progress, - abortSignal, - pluginIds, - nextDisabledPlugins, - ), - _buildGraphData: (fileConnections, root, selectedPlugins) => - this._buildGraphData(fileConnections, root, true, selectedPlugins), - _buildGraphDataFromAnalysis: (fileAnalysis, root, selectedPlugins) => - this._buildGraphDataFromAnalysis(fileAnalysis, root, true, selectedPlugins), - _preAnalyzePlugins: (files, root, abortSignal, nextDisabledPlugins = disabledPlugins) => - this._preAnalyzePlugins(files, root, abortSignal, nextDisabledPlugins), - _readAnalysisFiles: files => this._readAnalysisFiles(files), - analyze: (patterns, selectedPlugins, abortSignal, progress) => - this.analyze(patterns, selectedPlugins, abortSignal, progress), - invalidateWorkspaceFiles: paths => this.invalidateWorkspaceFiles(paths), - } as WorkspacePipelineRefreshSource; - - Object.defineProperties(source, { - _lastDiscoveredDirectories: { - get: () => this._lastDiscoveredDirectories, - set: (directories: WorkspacePipelineRefreshSource['_lastDiscoveredDirectories']) => { - this._lastDiscoveredDirectories = directories; - }, - }, - _lastDiscoveredFiles: { - get: () => this._lastDiscoveredFiles, - set: (files: WorkspacePipelineRefreshSource['_lastDiscoveredFiles']) => { - this._lastDiscoveredFiles = files; - }, - }, - _lastFileAnalysis: { - get: () => this._lastFileAnalysis, - set: (fileAnalysis: WorkspacePipelineRefreshSource['_lastFileAnalysis']) => { - this._lastFileAnalysis = fileAnalysis; - }, - }, - _lastFileConnections: { - get: () => this._lastFileConnections, - set: (fileConnections: WorkspacePipelineRefreshSource['_lastFileConnections']) => { - this._lastFileConnections = fileConnections; - }, - }, - _lastWorkspaceRoot: { - get: () => this._lastWorkspaceRoot, - set: (root: WorkspacePipelineRefreshSource['_lastWorkspaceRoot']) => { - this._lastWorkspaceRoot = root; - }, - }, + protected _patchGraphDataNodeMetrics( + graphData: IGraphData, + filePaths: readonly string[], + ): IGraphData { + const churnCounts = getCachedGitHistoryChurnCounts( + this._context.workspaceState, + createGitHistoryPluginSignature(this._registry), + ) ?? {}; + return patchGraphDataNodeMetrics({ + churnCounts, + filePaths, + fileSizes: this._cache.files, + graphData, }); - - return source; } async refreshAnalysisScope( @@ -86,41 +32,11 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi signal?: AbortSignal, onProgress?: (progress: { phase: string; current: number; total: number }) => void, ): Promise { - const workspaceRoot = this._getWorkspaceRoot(); - if (!workspaceRoot) { - return { nodes: [], edges: [] }; - } - - const config = this._config.getAll(); - const disabledCustomPatterns = new Set(config.disabledCustomFilterPatterns); - const disabledPluginPatterns = new Set(config.disabledPluginFilterPatterns); - const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( - createWorkspacePipelineDiscoveryDependencies(this._discovery), - workspaceRoot, - config, - filterPatterns.filter(pattern => !disabledCustomPatterns.has(pattern)), - this.getPluginFilterPatterns(disabledPlugins) - .filter(pattern => !disabledPluginPatterns.has(pattern)), - signal, - message => { - vscode.window.showWarningMessage(message); - }, - ); - this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; - - return refreshWorkspacePipelineAnalysisScope(this._createWorkspaceIndexRefreshSource(disabledPlugins), { + return refreshAnalysisScopeForFacade(this as unknown as RefreshFacadeContext, { disabledPlugins, - discoveredDirectories: discoveryResult.directories ?? [], - discoveredFiles: discoveryResult.files, + filterPatterns, onProgress, - persistCache: () => { - this._persistCache(); - }, - persistIndexMetadata: async () => { - await this._persistIndexMetadata(); - }, signal, - workspaceRoot, }); } @@ -131,42 +47,12 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi signal?: AbortSignal, onProgress?: (progress: { phase: string; current: number; total: number }) => void, ): Promise { - const workspaceRoot = this._getWorkspaceRoot(); - if (!workspaceRoot || pluginIds.length === 0) { - return { nodes: [], edges: [] }; - } - - const config = this._config.getAll(); - const disabledCustomPatterns = new Set(config.disabledCustomFilterPatterns); - const disabledPluginPatterns = new Set(config.disabledPluginFilterPatterns); - const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( - createWorkspacePipelineDiscoveryDependencies(this._discovery), - workspaceRoot, - config, - filterPatterns.filter(pattern => !disabledCustomPatterns.has(pattern)), - this.getPluginFilterPatterns(disabledPlugins) - .filter(pattern => !disabledPluginPatterns.has(pattern)), - signal, - message => { - vscode.window.showWarningMessage(message); - }, - ); - this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; - return refreshWorkspacePipelinePluginFiles(this._createWorkspaceIndexRefreshSource(disabledPlugins), { + return refreshPluginFilesForFacade(this as unknown as RefreshFacadeContext, { disabledPlugins, - discoveredDirectories: discoveryResult.directories ?? [], - discoveredFiles: discoveryResult.files, + filterPatterns, onProgress, - persistCache: () => { - this._persistCache(); - }, - persistIndexMetadata: async () => { - await this._persistIndexMetadata(); - }, pluginIds, - pluginInfos: this._registry.list(), signal, - workspaceRoot, }); } @@ -177,56 +63,12 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi signal?: AbortSignal, onProgress?: (progress: { phase: string; current: number; total: number }) => void, ): Promise { - const workspaceRoot = this._getWorkspaceRoot(); - if (!workspaceRoot) { - return { nodes: [], edges: [] }; - } - - const config = this._config.getAll(); - const disabledCustomPatterns = new Set(config.disabledCustomFilterPatterns); - const disabledPluginPatterns = new Set(config.disabledPluginFilterPatterns); - const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( - createWorkspacePipelineDiscoveryDependencies(this._discovery), - workspaceRoot, - config, - filterPatterns.filter(pattern => !disabledCustomPatterns.has(pattern)), - this.getPluginFilterPatterns(disabledPlugins) - .filter(pattern => !disabledPluginPatterns.has(pattern)), - signal, - message => { - vscode.window.showWarningMessage(message); - }, - ); - this._lastDiscoveredDirectories = discoveryResult.directories ?? []; - this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; - - return refreshWorkspacePipelineChangedFiles(this._createWorkspaceIndexRefreshSource(disabledPlugins), { + return refreshChangedFilesForFacade(this as unknown as RefreshFacadeContext, { disabledPlugins, - discoveredDirectories: discoveryResult.directories ?? [], - discoveredFiles: discoveryResult.files, filePaths, filterPatterns, - notifyFilesChanged: ( - files, - root, - analysisContext, - nextDisabledPlugins = disabledPlugins, - ) => - this._registry.notifyFilesChanged( - files, - root, - analysisContext, - nextDisabledPlugins, - ), onProgress, - persistCache: () => { - this._persistCache(); - }, - persistIndexMetadata: async () => { - await this._persistIndexMetadata(); - }, signal, - workspaceRoot, }); } @@ -235,42 +77,11 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi disabledPlugins: Set = new Set(), signal?: AbortSignal, ): Promise { - const workspaceRoot = this._getWorkspaceRoot(); - if (!workspaceRoot) { - return { nodes: [], edges: [] }; - } - - const config = this._config.getAll(); - const disabledCustomPatterns = new Set(config.disabledCustomFilterPatterns); - const disabledPluginPatterns = new Set(config.disabledPluginFilterPatterns); - const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( - createWorkspacePipelineDiscoveryDependencies(this._discovery), - workspaceRoot, - config, - filterPatterns.filter(pattern => !disabledCustomPatterns.has(pattern)), - this.getPluginFilterPatterns(disabledPlugins) - .filter(pattern => !disabledPluginPatterns.has(pattern)), + return refreshGitignoreMetadataForFacade(this as unknown as RefreshFacadeContext, { + disabledPlugins, + filterPatterns, signal, - message => { - vscode.window.showWarningMessage(message); - }, - ); - - this._lastDiscoveredDirectories = discoveryResult.directories ?? []; - this._lastDiscoveredFiles = discoveryResult.files; - this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; - this._lastWorkspaceRoot = workspaceRoot; - - void this._persistIndexMetadata().catch(error => { - console.warn('[CodeGraphy] Failed to persist gitignore metadata refresh.', error); }); - - return this._buildGraphDataFromAnalysis( - this._lastFileAnalysis, - workspaceRoot, - config.showOrphans, - disabledPlugins, - ); } abstract invalidateWorkspaceFiles(filePaths: readonly string[]): string[]; diff --git a/packages/extension/src/extension/repoSettings/freshness/index.ts b/packages/extension/src/extension/repoSettings/freshness/index.ts index de07f2da5..b66958ee0 100644 --- a/packages/extension/src/extension/repoSettings/freshness/index.ts +++ b/packages/extension/src/extension/repoSettings/freshness/index.ts @@ -1,4 +1,5 @@ import type { ICodeGraphyRepoMeta } from '../meta'; +import { filterWorkspaceStatusPendingChangedFiles } from '@codegraphy-dev/core'; import { createFreshDetail, createMissingDetail, @@ -38,12 +39,25 @@ export function evaluateCodeGraphyIndexStatus(input: { settingsSignature: string; }): CodeGraphyIndexStatus { const { meta, currentCommit, pluginSignature, settingsSignature } = input; + const pendingChangedFiles = filterWorkspaceStatusPendingChangedFiles( + meta.pendingChangedFiles, + { lastIndexedAt: meta.lastIndexedAt }, + ); + const statusMeta = { + ...meta, + pendingChangedFiles, + }; if (meta.lastIndexedAt === null) { return createMissingStatus(); } - const staleReasons = collectStaleReasons({ meta, currentCommit, pluginSignature, settingsSignature }); + const staleReasons = collectStaleReasons({ + meta: statusMeta, + currentCommit, + pluginSignature, + settingsSignature, + }); if (staleReasons.length === 0) { return createFreshStatus(); } @@ -52,6 +66,6 @@ export function evaluateCodeGraphyIndexStatus(input: { freshness: 'stale', hasIndex: false, staleReasons, - detail: createStaleDetail(staleReasons, meta.pendingChangedFiles), + detail: createStaleDetail(staleReasons, pendingChangedFiles), }; } diff --git a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts index 15f569dce..cd32d9942 100644 --- a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts +++ b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts @@ -5,36 +5,23 @@ import { shouldIgnoreWorkspaceFileWatcherRefresh, } from '../ignore'; import { scheduleWorkspaceRefresh } from './scheduler'; +import { + emitWorkspaceRenameEvents, + getRenameFilePaths, +} from './renameEvents'; +import { + consumeRecentSavedDocumentPath, + rememberRecentSavedDocumentPath, +} from './recentSaves'; +import { + isGitignorePath, + refreshWorkspacePaths, +} from './paths'; type WorkspaceRenameFiles = vscode.FileRenameEvent['files']; type WorkspaceFileEventName = 'workspace:fileCreated' | 'workspace:fileDeleted'; -function isGitignorePath(filePath: string): boolean { - return filePath.replace(/\\/g, '/').endsWith('/.gitignore') - || filePath.replace(/\\/g, '/') === '.gitignore'; -} - -function includesGitignorePath(filePaths: readonly string[]): boolean { - return filePaths.some(isGitignorePath); -} - -function refreshWorkspacePaths( - provider: GraphViewProvider, - logMessage: string, - filePaths: readonly string[], -): string[] { - const refreshPaths = filePaths.filter(filePath => - !shouldIgnoreWorkspaceFileWatcherRefresh(filePath), - ); - - if (refreshPaths.length > 0) { - scheduleWorkspaceRefresh(provider, logMessage, refreshPaths, 500, { - gitignoreRefresh: includesGitignorePath(refreshPaths), - }); - } - - return refreshPaths; -} +const WORKSPACE_CONTENT_CHANGE_REFRESH_DELAY_MS = 32; export function refreshWorkspaceSavedDocument( provider: GraphViewProvider, @@ -44,6 +31,7 @@ export function refreshWorkspaceSavedDocument( return; } + rememberRecentSavedDocumentPath(document.uri.fsPath); refreshWorkspaceChangedPath( provider, '[CodeGraphy] File saved, refreshing graph', @@ -51,6 +39,18 @@ export function refreshWorkspaceSavedDocument( ); } +export function refreshWorkspaceChangedFileWatcherPath( + provider: GraphViewProvider, + logMessage: string, + filePath: string, +): void { + if (consumeRecentSavedDocumentPath(filePath)) { + return; + } + + refreshWorkspaceChangedPath(provider, logMessage, filePath); +} + export function refreshWorkspaceChangedPath( provider: GraphViewProvider, logMessage: string, @@ -64,7 +64,7 @@ export function refreshWorkspaceChangedPath( provider, logMessage, [filePath], - 500, + WORKSPACE_CONTENT_CHANGE_REFRESH_DELAY_MS, { gitignoreRefresh: isGitignorePath(filePath) }, ); provider.emitEvent('workspace:fileChanged', { filePath }); @@ -87,22 +87,6 @@ export function refreshWorkspaceFileOperation( } } -function getRenameFilePaths(files: WorkspaceRenameFiles): string[] { - return files.flatMap(file => [file.oldUri.fsPath, file.newUri.fsPath]); -} - -function emitWorkspaceRenameEvents( - provider: GraphViewProvider, - files: WorkspaceRenameFiles, -): void { - for (const file of files) { - provider.emitEvent('workspace:fileRenamed', { - oldPath: file.oldUri.fsPath, - newPath: file.newUri.fsPath, - }); - } -} - export function refreshWorkspaceRenameOperation( provider: GraphViewProvider, files: WorkspaceRenameFiles, diff --git a/packages/extension/src/extension/workspaceFiles/refresh/paths.ts b/packages/extension/src/extension/workspaceFiles/refresh/paths.ts new file mode 100644 index 000000000..345bfe87b --- /dev/null +++ b/packages/extension/src/extension/workspaceFiles/refresh/paths.ts @@ -0,0 +1,33 @@ +import type { GraphViewProvider } from '../../graphViewProvider'; +import { shouldIgnoreWorkspaceFileWatcherRefresh } from '../ignore'; +import { scheduleWorkspaceRefresh } from './scheduler'; +import { normalizeFileWatcherPath } from './recentSaves'; + +const WORKSPACE_FILE_OPERATION_REFRESH_DELAY_MS = 500; + +export function refreshWorkspacePaths( + provider: GraphViewProvider, + logMessage: string, + filePaths: readonly string[], +): string[] { + const refreshPaths = filePaths.filter(filePath => + !shouldIgnoreWorkspaceFileWatcherRefresh(filePath), + ); + + if (refreshPaths.length > 0) { + scheduleWorkspaceRefresh(provider, logMessage, refreshPaths, WORKSPACE_FILE_OPERATION_REFRESH_DELAY_MS, { + gitignoreRefresh: includesGitignorePath(refreshPaths), + }); + } + + return refreshPaths; +} + +export function isGitignorePath(filePath: string): boolean { + const normalizedPath = normalizeFileWatcherPath(filePath); + return normalizedPath.endsWith('/.gitignore') || normalizedPath === '.gitignore'; +} + +function includesGitignorePath(filePaths: readonly string[]): boolean { + return filePaths.some(isGitignorePath); +} diff --git a/packages/extension/src/extension/workspaceFiles/refresh/recentSaves.ts b/packages/extension/src/extension/workspaceFiles/refresh/recentSaves.ts new file mode 100644 index 000000000..aecb4858a --- /dev/null +++ b/packages/extension/src/extension/workspaceFiles/refresh/recentSaves.ts @@ -0,0 +1,36 @@ +const RECENT_SAVE_WATCHER_SUPPRESSION_MS = 1000; +const recentSavedDocumentPaths = new Map(); + +export function rememberRecentSavedDocumentPath(filePath: string): void { + const now = Date.now(); + pruneRecentSavedDocumentPaths(now); + recentSavedDocumentPaths.set( + normalizeFileWatcherPath(filePath), + now + RECENT_SAVE_WATCHER_SUPPRESSION_MS, + ); +} + +export function consumeRecentSavedDocumentPath(filePath: string): boolean { + const now = Date.now(); + pruneRecentSavedDocumentPaths(now); + const normalizedPath = normalizeFileWatcherPath(filePath); + const expiresAt = recentSavedDocumentPaths.get(normalizedPath); + if (expiresAt === undefined) { + return false; + } + + recentSavedDocumentPaths.delete(normalizedPath); + return now <= expiresAt; +} + +export function normalizeFileWatcherPath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +function pruneRecentSavedDocumentPaths(now: number): void { + for (const [filePath, expiresAt] of recentSavedDocumentPaths) { + if (expiresAt < now) { + recentSavedDocumentPaths.delete(filePath); + } + } +} diff --git a/packages/extension/src/extension/workspaceFiles/refresh/renameEvents.ts b/packages/extension/src/extension/workspaceFiles/refresh/renameEvents.ts new file mode 100644 index 000000000..1fc5928a9 --- /dev/null +++ b/packages/extension/src/extension/workspaceFiles/refresh/renameEvents.ts @@ -0,0 +1,20 @@ +import * as vscode from 'vscode'; +import type { GraphViewProvider } from '../../graphViewProvider'; + +type WorkspaceRenameFiles = vscode.FileRenameEvent['files']; + +export function getRenameFilePaths(files: WorkspaceRenameFiles): string[] { + return files.flatMap(file => [file.oldUri.fsPath, file.newUri.fsPath]); +} + +export function emitWorkspaceRenameEvents( + provider: GraphViewProvider, + files: WorkspaceRenameFiles, +): void { + for (const file of files) { + provider.emitEvent('workspace:fileRenamed', { + oldPath: file.oldUri.fsPath, + newPath: file.newUri.fsPath, + }); + } +} diff --git a/packages/extension/src/extension/workspaceFiles/refresh/watchers.ts b/packages/extension/src/extension/workspaceFiles/refresh/watchers.ts index f61a7f973..fb8468368 100644 --- a/packages/extension/src/extension/workspaceFiles/refresh/watchers.ts +++ b/packages/extension/src/extension/workspaceFiles/refresh/watchers.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import type { GraphViewProvider } from '../../graphViewProvider'; import { - refreshWorkspaceChangedPath, + refreshWorkspaceChangedFileWatcherPath, refreshWorkspaceFileOperation, refreshWorkspaceRenameOperation, refreshWorkspaceSavedDocument, @@ -46,7 +46,7 @@ export function registerFileWatcher( ); context.subscriptions.push( fileWatcher.onDidChange((uri) => { - refreshWorkspaceChangedPath( + refreshWorkspaceChangedFileWatcherPath( provider, '[CodeGraphy] File changed, refreshing graph', uri.fsPath, @@ -75,7 +75,7 @@ export function registerFileWatcher( ); context.subscriptions.push( gitignoreWatcher.onDidChange((uri) => { - refreshWorkspaceChangedPath( + refreshWorkspaceChangedFileWatcherPath( provider, '[CodeGraphy] .gitignore changed, refreshing graph', uri.fsPath, diff --git a/packages/extension/src/shared/globMatch.ts b/packages/extension/src/shared/globMatch.ts index 1530b1dcc..5ba785919 100644 --- a/packages/extension/src/shared/globMatch.ts +++ b/packages/extension/src/shared/globMatch.ts @@ -1,44 +1,19 @@ -/** - * Convert a simple glob pattern to a RegExp. - * - * Rules: - * - `**` matches any path segments, including nested `/` - * - `*` matches anything except `/` - * - regex metacharacters are escaped - * - * Patterns are matched against the basename or path suffix, so `src/*` - * works anywhere in the tree while still keeping `*` and `**` semantics. - */ -export function globToRegex(pattern: string): RegExp { - let body = ''; - for (let index = 0; index < pattern.length; index += 1) { - const character = pattern[index]; - const nextCharacter = pattern[index + 1]; - const afterNextCharacter = pattern[index + 2]; - - if (character === '*' && nextCharacter === '*' && afterNextCharacter === '/') { - body += '(?:.*/)?'; - index += 2; - continue; - } - - if (character === '*' && nextCharacter === '*') { - body += '.*'; - index += 1; - continue; - } +import { createCombinedGlobMatcher as createCombinedGlobMatcherImpl } from './globMatch/combined/matcher'; +import { createGlobMatcher as createGlobMatcherImpl, globMatch as globMatchImpl } from './globMatch/matcher'; +import { globToRegex as globToRegexImpl } from './globMatch/regex'; - if (character === '*') { - body += '[^/]*'; - continue; - } +export function globToRegex(pattern: string): RegExp { + return globToRegexImpl(pattern); +} - body += character.replace(/([.+^${}()|[\]\\])/g, '\\$1'); - } +export function createGlobMatcher(pattern: string): (filePath: string) => boolean { + return createGlobMatcherImpl(pattern); +} - return new RegExp(`(?:^|/)${body}$`); +export function createCombinedGlobMatcher(patterns: readonly string[]): (filePath: string) => boolean { + return createCombinedGlobMatcherImpl(patterns); } export function globMatch(filePath: string, pattern: string): boolean { - return globToRegex(pattern).test(filePath); + return globMatchImpl(filePath, pattern); } diff --git a/packages/extension/src/shared/globMatch/combined/collection.ts b/packages/extension/src/shared/globMatch/combined/collection.ts new file mode 100644 index 000000000..d316563c0 --- /dev/null +++ b/packages/extension/src/shared/globMatch/combined/collection.ts @@ -0,0 +1,29 @@ +import type { CombinedFastGlobMatchers } from '../contracts'; +import { collectFastMatcher } from '../fast/collection'; +import { globToRegex } from '../regex'; + +export function collectCombinedFastMatchers(patterns: readonly string[]): { + fastMatchers: CombinedFastGlobMatchers; + regexPatterns: string[]; +} { + const fastMatchers: CombinedFastGlobMatchers = { + directMatchers: [], + literalSuffixes: [], + recursiveDirectoryNames: new Set(), + suffixes: [], + }; + const regexPatterns: string[] = []; + for (const pattern of patterns) { + if (!collectFastMatcher(fastMatchers, pattern)) { + regexPatterns.push(pattern); + } + } + + return { fastMatchers, regexPatterns }; +} + +export function createCombinedRegexMatcher(regexPatterns: readonly string[]): RegExp | null { + return regexPatterns.length > 0 + ? new RegExp(regexPatterns.map(pattern => `(?:${globToRegex(pattern).source})`).join('|')) + : null; +} diff --git a/packages/extension/src/shared/globMatch/combined/matcher.ts b/packages/extension/src/shared/globMatch/combined/matcher.ts new file mode 100644 index 000000000..4a92f3a92 --- /dev/null +++ b/packages/extension/src/shared/globMatch/combined/matcher.ts @@ -0,0 +1,55 @@ +import type { + CombinedFastGlobMatchers, + GlobMatcher, +} from '../contracts'; +import { + collectCombinedFastMatchers, + createCombinedRegexMatcher, +} from './collection'; +import { + containsRecursiveDirectoryName, + hasAnySuffix, + matchesAnyPathSuffix, +} from './predicates'; +import { createFastGlobMatcher } from '../fast/matcher'; +import { createGlobMatcher } from '../matcher'; + +function createCombinedFastMatcher( + fastMatchers: CombinedFastGlobMatchers, + regex: RegExp | null, +): GlobMatcher { + return (filePath: string): boolean => { + if ( + containsRecursiveDirectoryName(filePath, fastMatchers.recursiveDirectoryNames) + || hasAnySuffix(filePath, fastMatchers.suffixes) + || matchesAnyPathSuffix(filePath, fastMatchers.literalSuffixes) + ) { + return true; + } + + for (const matcher of fastMatchers.directMatchers) { + if (matcher(filePath)) { + return true; + } + } + + return regex ? regex.test(filePath) : false; + }; +} + +export function createCombinedGlobMatcher(patterns: readonly string[]): GlobMatcher { + if (patterns.length === 0) { + return () => false; + } + + if (patterns.length === 1) { + const pattern = patterns[0] ?? ''; + return createFastGlobMatcher(pattern) ?? createGlobMatcher(pattern); + } + + const { fastMatchers, regexPatterns } = collectCombinedFastMatchers(patterns); + const regex = regexPatterns.length > 0 + ? createCombinedRegexMatcher(regexPatterns) + : null; + return createCombinedFastMatcher(fastMatchers, regex); +} diff --git a/packages/extension/src/shared/globMatch/combined/predicates.ts b/packages/extension/src/shared/globMatch/combined/predicates.ts new file mode 100644 index 000000000..2a8afa236 --- /dev/null +++ b/packages/extension/src/shared/globMatch/combined/predicates.ts @@ -0,0 +1,46 @@ +import { matchesPathSuffix } from '../fast/pathSuffix'; + +export function matchesAnyPathSuffix(filePath: string, suffixes: readonly string[]): boolean { + for (const suffix of suffixes) { + if (matchesPathSuffix(filePath, suffix)) { + return true; + } + } + + return false; +} + +export function hasAnySuffix(filePath: string, suffixes: readonly string[]): boolean { + for (const suffix of suffixes) { + if (filePath.endsWith(suffix)) { + return true; + } + } + + return false; +} + +export function containsRecursiveDirectoryName( + filePath: string, + directoryNames: ReadonlySet, +): boolean { + if (directoryNames.size === 0) { + return false; + } + + let segmentStart = 0; + while (segmentStart < filePath.length) { + const slashIndex = filePath.indexOf('/', segmentStart); + if (slashIndex < 0) { + return false; + } + + if (directoryNames.has(filePath.slice(segmentStart, slashIndex))) { + return true; + } + + segmentStart = slashIndex + 1; + } + + return false; +} diff --git a/packages/extension/src/shared/globMatch/contracts.ts b/packages/extension/src/shared/globMatch/contracts.ts new file mode 100644 index 000000000..e4d8115ec --- /dev/null +++ b/packages/extension/src/shared/globMatch/contracts.ts @@ -0,0 +1,14 @@ +export type GlobMatcher = (filePath: string) => boolean; + +export interface CombinedFastGlobMatchers { + directMatchers: GlobMatcher[]; + literalSuffixes: string[]; + recursiveDirectoryNames: Set; + suffixes: string[]; +} + +export type FastGlobPattern = + | { kind: 'directChild'; directoryPath: string } + | { kind: 'literal'; suffix: string } + | { kind: 'recursiveDirectory'; directoryPath: string } + | { kind: 'suffix'; suffix: string }; diff --git a/packages/extension/src/shared/globMatch/fast/classification.ts b/packages/extension/src/shared/globMatch/fast/classification.ts new file mode 100644 index 000000000..da3bc5883 --- /dev/null +++ b/packages/extension/src/shared/globMatch/fast/classification.ts @@ -0,0 +1,29 @@ +import type { FastGlobPattern } from '../contracts'; +import { + getDirectoryPattern, + getExtensionSuffixPattern, + removeRecursivePrefix, +} from './patternParts'; + +export function classifyFastGlobPattern(pattern: string): FastGlobPattern | undefined { + const recursivePattern = removeRecursivePrefix(pattern); + + if (!recursivePattern.includes('*')) { + return { kind: 'literal', suffix: recursivePattern }; + } + + const suffix = getExtensionSuffixPattern(recursivePattern); + if (suffix) { + return { kind: 'suffix', suffix }; + } + + const recursiveDirectoryPath = getDirectoryPattern(recursivePattern, '/**'); + if (recursiveDirectoryPath) { + return { kind: 'recursiveDirectory', directoryPath: recursiveDirectoryPath }; + } + + const directChildDirectoryPath = getDirectoryPattern(recursivePattern, '/*'); + return directChildDirectoryPath + ? { kind: 'directChild', directoryPath: directChildDirectoryPath } + : undefined; +} diff --git a/packages/extension/src/shared/globMatch/fast/collection.ts b/packages/extension/src/shared/globMatch/fast/collection.ts new file mode 100644 index 000000000..389b946f0 --- /dev/null +++ b/packages/extension/src/shared/globMatch/fast/collection.ts @@ -0,0 +1,46 @@ +import { classifyFastGlobPattern } from './classification'; +import type { + CombinedFastGlobMatchers, + FastGlobPattern, +} from '../contracts'; +import { + createDirectChildMatcher, + createRecursiveDirectoryMatcher, +} from './directoryMatchers'; + +function addFastMatcher(fastMatchers: CombinedFastGlobMatchers, pattern: FastGlobPattern): void { + if (pattern.kind === 'literal') { + fastMatchers.literalSuffixes.push(pattern.suffix); + return; + } + + if (pattern.kind === 'suffix') { + fastMatchers.suffixes.push(pattern.suffix); + return; + } + + if (pattern.kind === 'directChild') { + fastMatchers.directMatchers.push(createDirectChildMatcher(pattern.directoryPath)); + return; + } + + if (!pattern.directoryPath.includes('/')) { + fastMatchers.recursiveDirectoryNames.add(pattern.directoryPath); + return; + } + + fastMatchers.directMatchers.push(createRecursiveDirectoryMatcher(pattern.directoryPath)); +} + +export function collectFastMatcher( + fastMatchers: CombinedFastGlobMatchers, + pattern: string, +): boolean { + const fastPattern = classifyFastGlobPattern(pattern); + if (!fastPattern) { + return false; + } + + addFastMatcher(fastMatchers, fastPattern); + return true; +} diff --git a/packages/extension/src/shared/globMatch/fast/directoryMatchers.ts b/packages/extension/src/shared/globMatch/fast/directoryMatchers.ts new file mode 100644 index 000000000..a80ed33d8 --- /dev/null +++ b/packages/extension/src/shared/globMatch/fast/directoryMatchers.ts @@ -0,0 +1,29 @@ +import type { GlobMatcher } from '../contracts'; + +export function createRecursiveDirectoryMatcher(directoryPath: string): GlobMatcher { + const rootPrefix = `${directoryPath}/`; + const nestedPrefix = `/${rootPrefix}`; + + return (filePath: string): boolean => ( + filePath.startsWith(rootPrefix) || filePath.includes(nestedPrefix) + ); +} + +export function createDirectChildMatcher(directoryPath: string): GlobMatcher { + const rootPrefix = `${directoryPath}/`; + const nestedPrefix = `/${rootPrefix}`; + + return (filePath: string): boolean => { + let start = 0; + if (!filePath.startsWith(rootPrefix)) { + const nestedStart = filePath.lastIndexOf(nestedPrefix); + if (nestedStart < 0) { + return false; + } + start = nestedStart + 1; + } + + const remainder = filePath.slice(start + rootPrefix.length); + return remainder.length > 0 && !remainder.includes('/'); + }; +} diff --git a/packages/extension/src/shared/globMatch/fast/matcher.ts b/packages/extension/src/shared/globMatch/fast/matcher.ts new file mode 100644 index 000000000..d9a45d311 --- /dev/null +++ b/packages/extension/src/shared/globMatch/fast/matcher.ts @@ -0,0 +1,31 @@ +import { classifyFastGlobPattern } from './classification'; +import type { GlobMatcher } from '../contracts'; +import { + createDirectChildMatcher, + createRecursiveDirectoryMatcher, +} from './directoryMatchers'; +import { matchesPathSuffix } from './pathSuffix'; +import { createSuffixMatcher } from './suffixMatcher'; + +export function createFastGlobMatcher(pattern: string): GlobMatcher | undefined { + if (!pattern) { + return () => false; + } + + const fastPattern = classifyFastGlobPattern(pattern); + if (!fastPattern) { + return undefined; + } + + if (fastPattern.kind === 'literal') { + return (filePath: string): boolean => matchesPathSuffix(filePath, fastPattern.suffix); + } + + if (fastPattern.kind === 'suffix') { + return createSuffixMatcher(fastPattern.suffix); + } + + return fastPattern.kind === 'directChild' + ? createDirectChildMatcher(fastPattern.directoryPath) + : createRecursiveDirectoryMatcher(fastPattern.directoryPath); +} diff --git a/packages/extension/src/shared/globMatch/fast/pathSuffix.ts b/packages/extension/src/shared/globMatch/fast/pathSuffix.ts new file mode 100644 index 000000000..ea6880da1 --- /dev/null +++ b/packages/extension/src/shared/globMatch/fast/pathSuffix.ts @@ -0,0 +1,3 @@ +export function matchesPathSuffix(filePath: string, suffix: string): boolean { + return filePath === suffix || filePath.endsWith(`/${suffix}`); +} diff --git a/packages/extension/src/shared/globMatch/fast/patternParts.ts b/packages/extension/src/shared/globMatch/fast/patternParts.ts new file mode 100644 index 000000000..b7e801996 --- /dev/null +++ b/packages/extension/src/shared/globMatch/fast/patternParts.ts @@ -0,0 +1,17 @@ +export function removeRecursivePrefix(pattern: string): string { + return pattern.startsWith('**/') ? pattern.slice(3) : pattern; +} + +export function getExtensionSuffixPattern(pattern: string): string | undefined { + const hasOnlyLeadingWildcard = pattern.startsWith('*.') && pattern.indexOf('*', 1) === -1; + return hasOnlyLeadingWildcard && !pattern.includes('/') ? pattern.slice(1) : undefined; +} + +export function getDirectoryPattern(pattern: string, ending: '/**' | '/*'): string | undefined { + if (!pattern.endsWith(ending)) { + return undefined; + } + + const directoryPath = pattern.slice(0, -ending.length); + return directoryPath && !directoryPath.includes('*') ? directoryPath : undefined; +} diff --git a/packages/extension/src/shared/globMatch/fast/suffixMatcher.ts b/packages/extension/src/shared/globMatch/fast/suffixMatcher.ts new file mode 100644 index 000000000..e299c0d61 --- /dev/null +++ b/packages/extension/src/shared/globMatch/fast/suffixMatcher.ts @@ -0,0 +1,11 @@ +import type { GlobMatcher } from '../contracts'; + +export function createSuffixMatcher(suffix: string): GlobMatcher { + const suffixLength = suffix.length; + const suffixFirstCode = suffix.charCodeAt(0); + return (filePath: string): boolean => ( + filePath.length >= suffixLength + && filePath.charCodeAt(filePath.length - suffixLength) === suffixFirstCode + && filePath.endsWith(suffix) + ); +} diff --git a/packages/extension/src/shared/globMatch/matcher.ts b/packages/extension/src/shared/globMatch/matcher.ts new file mode 100644 index 000000000..6678519c4 --- /dev/null +++ b/packages/extension/src/shared/globMatch/matcher.ts @@ -0,0 +1,17 @@ +import type { GlobMatcher } from './contracts'; +import { createFastGlobMatcher } from './fast/matcher'; +import { globToRegex } from './regex'; + +export function createGlobMatcher(pattern: string): GlobMatcher { + const fastMatcher = createFastGlobMatcher(pattern); + if (fastMatcher) { + return fastMatcher; + } + + const regex = globToRegex(pattern); + return (filePath: string): boolean => regex.test(filePath); +} + +export function globMatch(filePath: string, pattern: string): boolean { + return createGlobMatcher(pattern)(filePath); +} diff --git a/packages/extension/src/shared/globMatch/regex.ts b/packages/extension/src/shared/globMatch/regex.ts new file mode 100644 index 000000000..b1d31ef78 --- /dev/null +++ b/packages/extension/src/shared/globMatch/regex.ts @@ -0,0 +1,40 @@ +/** + * Convert a simple glob pattern to a RegExp. + * + * Rules: + * - `**` matches any path segments, including nested `/` + * - `*` matches anything except `/` + * - regex metacharacters are escaped + * + * Patterns are matched against the basename or path suffix, so `src/*` + * works anywhere in the tree while still keeping `*` and `**` semantics. + */ +export function globToRegex(pattern: string): RegExp { + let body = ''; + for (let index = 0; index < pattern.length; index += 1) { + const character = pattern[index]; + const nextCharacter = pattern[index + 1]; + const afterNextCharacter = pattern[index + 2]; + + if (character === '*' && nextCharacter === '*' && afterNextCharacter === '/') { + body += '(?:.*/)?'; + index += 2; + continue; + } + + if (character === '*' && nextCharacter === '*') { + body += '.*'; + index += 1; + continue; + } + + if (character === '*') { + body += '[^/]*'; + continue; + } + + body += character.replace(/([.+^${}()|[\]\\])/g, '\\$1'); + } + + return new RegExp(`(?:^|/)${body}$`); +} diff --git a/packages/extension/src/shared/protocol/extensionToWebview.ts b/packages/extension/src/shared/protocol/extensionToWebview.ts index b81db214e..99e33313e 100644 --- a/packages/extension/src/shared/protocol/extensionToWebview.ts +++ b/packages/extension/src/shared/protocol/extensionToWebview.ts @@ -1,5 +1,5 @@ import type { IFileInfo } from '../files/info'; -import type { IGraphData } from '../graph/contracts'; +import type { IGraphData, IGraphNode } from '../graph/contracts'; import type { IPluginContextMenuItem } from '../plugins/contextMenu'; import type { EdgeDecorationPayload, NodeDecorationPayload } from '../plugins/decorations'; import type { IPluginExporterItem } from '../plugins/exporters'; @@ -38,8 +38,15 @@ export interface IGraphViewContributionStatus { label: string; } +export interface IGraphNodeMetricsUpdate { + id: IGraphNode['id']; + fileSize: IGraphNode['fileSize']; + churn: IGraphNode['churn']; +} + export type ExtensionToWebviewMessage = | { type: 'GRAPH_DATA_UPDATED'; payload: IGraphData } + | { type: 'GRAPH_NODE_METRICS_UPDATED'; payload: { nodes: IGraphNodeMetricsUpdate[] } } | { type: 'APP_BOOTSTRAP_COMPLETE' } | { type: 'GRAPH_INDEX_STATUS_UPDATED'; diff --git a/packages/extension/src/shared/protocol/webviewToExtension.ts b/packages/extension/src/shared/protocol/webviewToExtension.ts index 9f9a753e4..90b82ac5e 100644 --- a/packages/extension/src/shared/protocol/webviewToExtension.ts +++ b/packages/extension/src/shared/protocol/webviewToExtension.ts @@ -16,11 +16,16 @@ export interface LegendIconImport { contentsBase64: string; } +export interface WebviewReadyPayload { + pageId: string; + postedAt: number; +} + export type WebviewToExtensionMessage = | { type: 'NODE_SELECTED'; payload: { nodeId: string } } | { type: 'NODE_DOUBLE_CLICKED'; payload: { nodeId: string } } | { type: 'CLEAR_FOCUSED_FILE' } - | { type: 'WEBVIEW_READY'; payload: null } + | { type: 'WEBVIEW_READY'; payload: WebviewReadyPayload | null } | { type: 'OPEN_FILE'; payload: { path: string } } | { type: 'OPEN_IN_EDITOR' } | { type: 'REVEAL_IN_EXPLORER'; payload: { path: string } } diff --git a/packages/extension/src/shared/visibleGraph/filter.ts b/packages/extension/src/shared/visibleGraph/filter.ts index 2641a1d1b..218996c17 100644 --- a/packages/extension/src/shared/visibleGraph/filter.ts +++ b/packages/extension/src/shared/visibleGraph/filter.ts @@ -1,22 +1,30 @@ import type { IGraphData } from '../graph/contracts'; -import { globMatch } from '../globMatch'; +import { createCombinedGlobMatcher } from '../globMatch'; import type { VisibleGraphFilterConfig } from './contracts'; import { filterEdgesToNodes } from './model'; -function nodeMatchesPattern(node: IGraphData['nodes'][number], pattern: string): boolean { - return globMatch(node.id, pattern) - || (node.symbol?.filePath ? globMatch(node.symbol.filePath, pattern) : false); +type GlobMatcher = ReturnType; + +function nodeMatchesPattern(node: IGraphData['nodes'][number], matches: GlobMatcher): boolean { + return matches(node.id) + || (node.symbol?.filePath ? matches(node.symbol.filePath) : false); } -function edgeMatchesPattern(edge: IGraphData['edges'][number], pattern: string): boolean { +function edgeMatchesPattern(edge: IGraphData['edges'][number], matches: GlobMatcher): boolean { return ( - globMatch(edge.id, pattern) - || globMatch(edge.kind, pattern) - || globMatch(`${edge.from}->${edge.to}`, pattern) - || globMatch(`${edge.from}->${edge.to}#${edge.kind}`, pattern) + matches(edge.id) + || matches(edge.kind) + || matches(`${edge.from}->${edge.to}`) + || matches(`${edge.from}->${edge.to}#${edge.kind}`) ); } +function canFilterEdgeDirectly(pattern: string): boolean { + return pattern.includes('->') + || pattern.includes('#') + || (!pattern.includes('*') && !pattern.includes('/')); +} + export function applyFilterPatterns( graphData: IGraphData, filter: VisibleGraphFilterConfig, @@ -25,12 +33,19 @@ export function applyFilterPatterns( return graphData; } + const nodePatternMatcher = createCombinedGlobMatcher(filter.patterns); const nodes = graphData.nodes.filter( - (node) => !filter.patterns.some((pattern) => nodeMatchesPattern(node, pattern)), + (node) => !nodeMatchesPattern(node, nodePatternMatcher), ); const nodeFilteredEdges = filterEdgesToNodes(graphData.edges, nodes); + const directEdgePatterns = filter.patterns.filter(canFilterEdgeDirectly); + if (directEdgePatterns.length === 0) { + return { nodes, edges: nodeFilteredEdges }; + } + + const edgePatternMatcher = createCombinedGlobMatcher(directEdgePatterns); const edges = nodeFilteredEdges.filter( - (edge) => !filter.patterns.some((pattern) => edgeMatchesPattern(edge, pattern)), + (edge) => !edgeMatchesPattern(edge, edgePatternMatcher), ); return { nodes, edges }; diff --git a/packages/extension/src/shared/visibleGraph/scope.ts b/packages/extension/src/shared/visibleGraph/scope.ts index 813835f1a..04655877f 100644 --- a/packages/extension/src/shared/visibleGraph/scope.ts +++ b/packages/extension/src/shared/visibleGraph/scope.ts @@ -1,142 +1,10 @@ import type { IGraphData } from '../graph/contracts'; import type { VisibleGraphScopeConfig } from './contracts'; -import { filterEdgesToNodes, getDisabledTypes } from './model'; +import { getDisabledTypes } from './model'; import { getScopedSymbolDefinitions } from './scope/definitions'; +import { resolveScopedEdges } from './scope/edges'; import { nodeMatchesScope } from './scope/nodes'; -function getEdgeContainingFileKey( - edge: IGraphData['edges'][number], - nodeById: ReadonlyMap, -): string { - const fromNode = nodeById.get(edge.from); - const toNode = nodeById.get(edge.to); - const fromFile = fromNode?.symbol?.filePath ?? edge.from; - const toFile = toNode?.symbol?.filePath ?? edge.to; - - return `${edge.kind}\0${fromFile}\0${toFile}`; -} - -function keepMostSpecificUniqueEdges( - nodes: IGraphData['nodes'], - edges: IGraphData['edges'], -): IGraphData['edges'] { - const nodeById = new Map(nodes.map((node) => [node.id, node])); - const bestEndpointPreferenceByKey = new Map(); - - for (const edge of edges) { - if (edge.kind === 'contains') { - continue; - } - const key = getEdgeContainingFileKey(edge, nodeById); - const endpointPreference = getEndpointPreference(edge, nodeById); - const currentEndpointPreference = bestEndpointPreferenceByKey.get(key); - bestEndpointPreferenceByKey.set( - key, - currentEndpointPreference === undefined - ? endpointPreference - : Math.max(currentEndpointPreference, endpointPreference), - ); - } - - const seenEdgeIds = new Set(); - return edges.filter((edge) => { - const key = getEdgeContainingFileKey(edge, nodeById); - const endpointPreference = getEndpointPreference(edge, nodeById); - if (edge.kind !== 'contains' - && endpointPreference !== (bestEndpointPreferenceByKey.get(key) ?? endpointPreference)) { - return false; - } - - if (seenEdgeIds.has(edge.id)) { - return false; - } - - seenEdgeIds.add(edge.id); - return true; - }); -} - -function getEndpointPreference( - edge: IGraphData['edges'][number], - nodeById: ReadonlyMap, -): number { - const fromNode = nodeById.get(edge.from); - const toNode = nodeById.get(edge.to); - const endpointSpecificity = Number(Boolean(fromNode?.symbol)) + Number(Boolean(toNode?.symbol)); - if (edge.kind === 'type-import') { - return -endpointSpecificity; - } - - return endpointSpecificity; -} - -function getEdgeKindSuffix(edge: IGraphData['edges'][number]): string { - const marker = edge.id.lastIndexOf('#'); - return marker >= 0 ? edge.id.slice(marker) : `#${edge.kind}`; -} - -function projectEdgeToVisibleNodes( - edge: IGraphData['edges'][number], - allNodeById: ReadonlyMap, - visibleNodeIds: ReadonlySet, -): IGraphData['edges'][number] | undefined { - if (edge.kind === 'contains') { - return edge; - } - - const from = projectEndpointToVisibleNode(edge.from, allNodeById, visibleNodeIds); - const to = projectEndpointToVisibleNode(edge.to, allNodeById, visibleNodeIds); - if (!from || !to) { - return undefined; - } - - if (from === edge.from && to === edge.to) { - return edge; - } - - if (from === to) { - return undefined; - } - - return { - ...edge, - id: `${from}->${to}${getEdgeKindSuffix(edge)}`, - from, - to, - }; -} - -function projectEndpointToVisibleNode( - nodeId: string, - allNodeById: ReadonlyMap, - visibleNodeIds: ReadonlySet, -): string | undefined { - if (visibleNodeIds.has(nodeId)) { - return nodeId; - } - - const containingFilePath = allNodeById.get(nodeId)?.symbol?.filePath; - if (containingFilePath && visibleNodeIds.has(containingFilePath)) { - return containingFilePath; - } - - return undefined; -} - -function projectEdgesToVisibleNodes( - edges: IGraphData['edges'], - graphNodes: IGraphData['nodes'], - visibleNodes: IGraphData['nodes'], -): IGraphData['edges'] { - const allNodeById = new Map(graphNodes.map((node) => [node.id, node])); - const visibleNodeIds = new Set(visibleNodes.map((node) => node.id)); - - return edges.flatMap((edge) => { - const projectedEdge = projectEdgeToVisibleNodes(edge, allNodeById, visibleNodeIds); - return projectedEdge ? [projectedEdge] : []; - }); -} - export function applyGraphScope( graphData: IGraphData, scope: VisibleGraphScopeConfig, @@ -150,12 +18,8 @@ export function applyGraphScope( scopedSymbolDefinitions, )); const scopedEdges = graphData.edges.filter((edge) => !disabledEdgeTypes.has(edge.kind)); - const edges = keepMostSpecificUniqueEdges( - nodes, - projectEdgesToVisibleNodes(scopedEdges, graphData.nodes, nodes), - ); return { nodes, - edges: filterEdgesToNodes(edges, nodes), + edges: resolveScopedEdges(nodes, graphData.nodes, scopedEdges), }; } diff --git a/packages/extension/src/shared/visibleGraph/scope/definitions.ts b/packages/extension/src/shared/visibleGraph/scope/definitions.ts index 9df0d5b4b..8d1f00096 100644 --- a/packages/extension/src/shared/visibleGraph/scope/definitions.ts +++ b/packages/extension/src/shared/visibleGraph/scope/definitions.ts @@ -1,11 +1,13 @@ import type { IGraphNodeTypeDefinition } from '../../graphControls/contracts'; import type { VisibleGraphScopeConfig } from '../contracts'; import { CORE_GRAPH_NODE_TYPES } from '../../graphControls/defaults/definitions'; +import { createGlobMatcher } from '../../globMatch'; export interface ScopedSymbolDefinition { definition: IGraphNodeTypeDefinition; enabled: boolean; specificity: number; + symbolFilePathMatches?: (value: string) => boolean; } export function getDefinitionSymbolKinds( @@ -49,6 +51,9 @@ export function getScopedSymbolDefinitions( definition, enabled: nodeVisibility.get(definition.id) ?? definition.defaultVisible, specificity: getDefinitionSpecificity(definition), + ...(definition.matchSymbolFilePath + ? { symbolFilePathMatches: createGlobMatcher(definition.matchSymbolFilePath) } + : {}), })) .sort((left, right) => right.specificity - left.specificity); } diff --git a/packages/extension/src/shared/visibleGraph/scope/edgeEndpointProjection.ts b/packages/extension/src/shared/visibleGraph/scope/edgeEndpointProjection.ts new file mode 100644 index 000000000..2792c3962 --- /dev/null +++ b/packages/extension/src/shared/visibleGraph/scope/edgeEndpointProjection.ts @@ -0,0 +1,16 @@ +import type { IGraphData } from '../../graph/contracts'; + +export function getVisibleEdgeEndpoint( + nodeId: string, + allNodeById: ReadonlyMap, + visibleNodeIds: ReadonlySet, +): string | undefined { + if (visibleNodeIds.has(nodeId)) { + return nodeId; + } + + const containingFilePath = allNodeById.get(nodeId)?.symbol?.filePath; + return containingFilePath && visibleNodeIds.has(containingFilePath) + ? containingFilePath + : undefined; +} diff --git a/packages/extension/src/shared/visibleGraph/scope/edgePreference.ts b/packages/extension/src/shared/visibleGraph/scope/edgePreference.ts new file mode 100644 index 000000000..5549fb370 --- /dev/null +++ b/packages/extension/src/shared/visibleGraph/scope/edgePreference.ts @@ -0,0 +1,37 @@ +import type { IGraphData } from '../../graph/contracts'; + +export function getEdgeContainingFileKey( + edge: IGraphData['edges'][number], + nodeById: ReadonlyMap, +): string { + const fromNode = nodeById.get(edge.from); + const toNode = nodeById.get(edge.to); + const fromFile = fromNode?.symbol?.filePath ?? edge.from; + const toFile = toNode?.symbol?.filePath ?? edge.to; + + return `${edge.kind}\0${fromFile}\0${toFile}`; +} + +export function getEndpointPreference( + edge: IGraphData['edges'][number], + nodeById: ReadonlyMap, +): number { + const fromNode = nodeById.get(edge.from); + const toNode = nodeById.get(edge.to); + const endpointSpecificity = Number(Boolean(fromNode?.symbol)) + Number(Boolean(toNode?.symbol)); + return edge.kind === 'type-import' ? -endpointSpecificity : endpointSpecificity; +} + +export function rememberBestEndpointPreference( + bestEndpointPreferenceByKey: Map, + key: string, + endpointPreference: number, +): void { + const currentEndpointPreference = bestEndpointPreferenceByKey.get(key); + bestEndpointPreferenceByKey.set( + key, + currentEndpointPreference === undefined + ? endpointPreference + : Math.max(currentEndpointPreference, endpointPreference), + ); +} diff --git a/packages/extension/src/shared/visibleGraph/scope/edgeProjection.ts b/packages/extension/src/shared/visibleGraph/scope/edgeProjection.ts new file mode 100644 index 000000000..c07ec00ff --- /dev/null +++ b/packages/extension/src/shared/visibleGraph/scope/edgeProjection.ts @@ -0,0 +1,57 @@ +import type { IGraphData } from '../../graph/contracts'; +import { getVisibleEdgeEndpoint } from './edgeEndpointProjection'; + +export function projectEdgesToVisibleNodes( + edges: IGraphData['edges'], + graphNodes: IGraphData['nodes'], + visibleNodes: IGraphData['nodes'], +): IGraphData['edges'] { + const allNodeById = new Map(graphNodes.map((node) => [node.id, node])); + const visibleNodeIds = new Set(visibleNodes.map((node) => node.id)); + const projectedEdges: IGraphData['edges'] = []; + + for (const edge of edges) { + const projectedEdge = projectEdgeToVisibleNodes(edge, allNodeById, visibleNodeIds); + if (projectedEdge) { + projectedEdges.push(projectedEdge); + } + } + + return projectedEdges; +} + +function projectEdgeToVisibleNodes( + edge: IGraphData['edges'][number], + allNodeById: ReadonlyMap, + visibleNodeIds: ReadonlySet, +): IGraphData['edges'][number] | undefined { + if (edge.kind === 'contains') { + return edge; + } + + const from = getVisibleEdgeEndpoint(edge.from, allNodeById, visibleNodeIds); + const to = getVisibleEdgeEndpoint(edge.to, allNodeById, visibleNodeIds); + if (!from || !to) { + return undefined; + } + + if (from === edge.from && to === edge.to) { + return edge; + } + + if (from === to) { + return undefined; + } + + return { + ...edge, + id: `${from}->${to}${getEdgeKindSuffix(edge)}`, + from, + to, + }; +} + +function getEdgeKindSuffix(edge: IGraphData['edges'][number]): string { + const marker = edge.id.lastIndexOf('#'); + return marker >= 0 ? edge.id.slice(marker) : `#${edge.kind}`; +} diff --git a/packages/extension/src/shared/visibleGraph/scope/edgeSelection.ts b/packages/extension/src/shared/visibleGraph/scope/edgeSelection.ts new file mode 100644 index 000000000..76140813a --- /dev/null +++ b/packages/extension/src/shared/visibleGraph/scope/edgeSelection.ts @@ -0,0 +1,63 @@ +import type { IGraphData } from '../../graph/contracts'; +import { + getEdgeContainingFileKey, + getEndpointPreference, + rememberBestEndpointPreference, +} from './edgePreference'; + +interface ScopedEdgeCandidate { + edge: IGraphData['edges'][number]; + endpointPreference?: number; + key?: string; +} + +export function keepMostSpecificUniqueEdges( + nodes: IGraphData['nodes'], + edges: IGraphData['edges'], +): IGraphData['edges'] { + const nodeById = new Map(nodes.map((node) => [node.id, node])); + const bestEndpointPreferenceByKey = new Map(); + const candidates = edges.map(edge => + createScopedEdgeCandidate(edge, nodeById, bestEndpointPreferenceByKey), + ); + const seenEdgeIds = new Set(); + const uniqueEdges: IGraphData['edges'] = []; + + for (const candidate of candidates) { + if (!shouldKeepScopedEdgeCandidate(candidate, bestEndpointPreferenceByKey)) { + continue; + } + + if (seenEdgeIds.has(candidate.edge.id)) { + continue; + } + + seenEdgeIds.add(candidate.edge.id); + uniqueEdges.push(candidate.edge); + } + + return uniqueEdges; +} + +function createScopedEdgeCandidate( + edge: IGraphData['edges'][number], + nodeById: ReadonlyMap, + bestEndpointPreferenceByKey: Map, +): ScopedEdgeCandidate { + if (edge.kind === 'contains') { + return { edge }; + } + + const key = getEdgeContainingFileKey(edge, nodeById); + const endpointPreference = getEndpointPreference(edge, nodeById); + rememberBestEndpointPreference(bestEndpointPreferenceByKey, key, endpointPreference); + return { edge, endpointPreference, key }; +} + +function shouldKeepScopedEdgeCandidate( + candidate: ScopedEdgeCandidate, + bestEndpointPreferenceByKey: ReadonlyMap, +): boolean { + return !candidate.key + || candidate.endpointPreference === (bestEndpointPreferenceByKey.get(candidate.key) ?? candidate.endpointPreference); +} diff --git a/packages/extension/src/shared/visibleGraph/scope/edges.ts b/packages/extension/src/shared/visibleGraph/scope/edges.ts new file mode 100644 index 000000000..b51786658 --- /dev/null +++ b/packages/extension/src/shared/visibleGraph/scope/edges.ts @@ -0,0 +1,16 @@ +import type { IGraphData } from '../../graph/contracts'; +import { filterEdgesToNodes } from '../model'; +import { projectEdgesToVisibleNodes } from './edgeProjection'; +import { keepMostSpecificUniqueEdges } from './edgeSelection'; + +export function resolveScopedEdges( + nodes: IGraphData['nodes'], + graphNodes: IGraphData['nodes'], + scopedEdges: IGraphData['edges'], +): IGraphData['edges'] { + const edges = keepMostSpecificUniqueEdges( + nodes, + projectEdgesToVisibleNodes(scopedEdges, graphNodes, nodes), + ); + return filterEdgesToNodes(edges, nodes); +} diff --git a/packages/extension/src/shared/visibleGraph/scope/nodes.ts b/packages/extension/src/shared/visibleGraph/scope/nodes.ts index 61ae35230..ccf5f8287 100644 --- a/packages/extension/src/shared/visibleGraph/scope/nodes.ts +++ b/packages/extension/src/shared/visibleGraph/scope/nodes.ts @@ -33,7 +33,7 @@ function getScopedSymbolVisibility( scopedSymbolDefinitions: readonly ScopedSymbolDefinition[], ): ScopedSymbolDefinition | undefined { const matchingDefinition = scopedSymbolDefinitions.find((item) => ( - symbolMatchesScopedDefinition(node, item.definition) + symbolMatchesScopedDefinition(node, item) )); return matchingDefinition; diff --git a/packages/extension/src/shared/visibleGraph/scope/symbolMatch.ts b/packages/extension/src/shared/visibleGraph/scope/symbolMatch.ts index 541d2705b..f82688081 100644 --- a/packages/extension/src/shared/visibleGraph/scope/symbolMatch.ts +++ b/packages/extension/src/shared/visibleGraph/scope/symbolMatch.ts @@ -1,23 +1,72 @@ import type { IGraphData } from '../../graph/contracts'; import type { IGraphNodeTypeDefinition } from '../../graphControls/contracts'; import { globMatch } from '../../globMatch'; +import type { ScopedSymbolDefinition } from './definitions'; import { getDefinitionSymbolKinds } from './definitions'; +type ScopedSymbolMatcher = IGraphNodeTypeDefinition | ScopedSymbolDefinition; +type GraphNode = IGraphData['nodes'][number]; +type GraphNodeSymbol = NonNullable; +type ScopedSymbolConstraintMatcher = ( + symbol: GraphNodeSymbol, + scopedDefinition: ScopedSymbolMatcher, + definition: IGraphNodeTypeDefinition, +) => boolean; + +function isCompiledScopedSymbolDefinition( + definition: ScopedSymbolMatcher, +): definition is ScopedSymbolDefinition { + return 'definition' in definition; +} + +function getMatcherDefinition(definition: ScopedSymbolMatcher): IGraphNodeTypeDefinition { + return isCompiledScopedSymbolDefinition(definition) ? definition.definition : definition; +} + export function symbolMatchesScopedDefinition( - node: IGraphData['nodes'][number], - definition: IGraphNodeTypeDefinition, + node: GraphNode, + scopedDefinition: ScopedSymbolMatcher, ): boolean { const symbol = node.symbol; if (!symbol) { return false; } + const definition = getMatcherDefinition(scopedDefinition); + return SCOPED_SYMBOL_CONSTRAINT_MATCHERS.every(matcher => + matcher(symbol, scopedDefinition, definition), + ); +} + +function optionalValueMatches(expected: T | undefined, actual: T | undefined): boolean { + return expected === undefined || expected === actual; +} + +function symbolKindMatchesDefinition(symbol: GraphNodeSymbol, definition: IGraphNodeTypeDefinition): boolean { const definitionSymbolKinds = getDefinitionSymbolKinds(definition); - return [ - !definitionSymbolKinds || definitionSymbolKinds.includes(symbol.kind), - !definition.matchSymbolPluginKind || definition.matchSymbolPluginKind === symbol.pluginKind, - !definition.matchSymbolSource || definition.matchSymbolSource === symbol.source, - !definition.matchSymbolLanguage || definition.matchSymbolLanguage === symbol.language, - !definition.matchSymbolFilePath || globMatch(symbol.filePath, definition.matchSymbolFilePath), - ].every(Boolean); + return definitionSymbolKinds === undefined || definitionSymbolKinds.includes(symbol.kind); } + +function symbolFilePathMatchesDefinition( + symbol: GraphNodeSymbol, + scopedDefinition: ScopedSymbolMatcher, + definition: IGraphNodeTypeDefinition, +): boolean { + if (!definition.matchSymbolFilePath) { + return true; + } + + if (isCompiledScopedSymbolDefinition(scopedDefinition) && scopedDefinition.symbolFilePathMatches) { + return scopedDefinition.symbolFilePathMatches(symbol.filePath); + } + + return globMatch(symbol.filePath, definition.matchSymbolFilePath); +} + +const SCOPED_SYMBOL_CONSTRAINT_MATCHERS: readonly ScopedSymbolConstraintMatcher[] = [ + (symbol, _scopedDefinition, definition) => symbolKindMatchesDefinition(symbol, definition), + (symbol, _scopedDefinition, definition) => optionalValueMatches(definition.matchSymbolPluginKind, symbol.pluginKind), + (symbol, _scopedDefinition, definition) => optionalValueMatches(definition.matchSymbolSource, symbol.source), + (symbol, _scopedDefinition, definition) => optionalValueMatches(definition.matchSymbolLanguage, symbol.language), + symbolFilePathMatchesDefinition, +]; diff --git a/packages/extension/src/webview/app/shell/graphScopeVisibility.ts b/packages/extension/src/webview/app/shell/graphScopeVisibility.ts index 08db4b316..1d4a8975a 100644 --- a/packages/extension/src/webview/app/shell/graphScopeVisibility.ts +++ b/packages/extension/src/webview/app/shell/graphScopeVisibility.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { GraphState } from '../../store/state'; export const GRAPH_SCOPE_RENDER_DEBOUNCE_MS = 80; @@ -8,6 +8,20 @@ interface GraphScopeVisibility { nodeVisibility: GraphState['nodeVisibility']; } +function hasVisibilityEntries(visibility: Record): boolean { + return Object.keys(visibility).length > 0; +} + +function isEmptyGraphScopeVisibility(visibility: GraphScopeVisibility): boolean { + return !hasVisibilityEntries(visibility.nodeVisibility) + && !hasVisibilityEntries(visibility.edgeVisibility); +} + +function hasGraphScopeVisibilityEntries(visibility: GraphScopeVisibility): boolean { + return hasVisibilityEntries(visibility.nodeVisibility) + || hasVisibilityEntries(visibility.edgeVisibility); +} + export function useDebouncedGraphScopeVisibility( nodeVisibility: GraphState['nodeVisibility'], edgeVisibility: GraphState['edgeVisibility'], @@ -16,8 +30,26 @@ export function useDebouncedGraphScopeVisibility( edgeVisibility, nodeVisibility, }); + const incomingVisibility = { + edgeVisibility, + nodeVisibility, + }; + const effectiveRenderVisibility = isEmptyGraphScopeVisibility(renderVisibility) + && hasGraphScopeVisibilityEntries(incomingVisibility) + ? incomingVisibility + : renderVisibility; + const renderVisibilityRef = useRef(renderVisibility); + renderVisibilityRef.current = effectiveRenderVisibility; useEffect(() => { + if (renderVisibilityRef.current.nodeVisibility === nodeVisibility) { + setRenderVisibility({ + edgeVisibility, + nodeVisibility, + }); + return; + } + const timer = setTimeout(() => { setRenderVisibility({ edgeVisibility, @@ -28,5 +60,5 @@ export function useDebouncedGraphScopeVisibility( return () => clearTimeout(timer); }, [edgeVisibility, nodeVisibility]); - return renderVisibility; + return effectiveRenderVisibility; } diff --git a/packages/extension/src/webview/app/shell/messageListener.ts b/packages/extension/src/webview/app/shell/messageListener.ts index 28f64cb48..6154c220e 100644 --- a/packages/extension/src/webview/app/shell/messageListener.ts +++ b/packages/extension/src/webview/app/shell/messageListener.ts @@ -9,6 +9,7 @@ import { parsePluginScopedMessage } from './messages'; import type { WebviewPluginHost } from '../../pluginHost/manager'; import { handlePluginInjectMessage } from './messageListener/pluginInjection'; import { removeDisabledPluginRegistrations } from './messageListener/pluginRegistrations'; +import { handlePluginDataUpdatedMessage } from './messageListener/pluginData'; import { postWebviewReadyOnce, resetWebviewReadyPosted } from './messageListener/ready'; import { handleCssSnippetsUpdatedMessage } from './messageListener/cssSnippets'; @@ -29,23 +30,6 @@ export interface InjectAssetsParams { export type ResetPluginAssets = (pluginId: string) => void; export type UpdatePluginData = (pluginId: string, data: unknown) => void; -function handlePluginDataUpdatedMessage( - raw: { type?: unknown; payload?: unknown }, - updatePluginData: UpdatePluginData, -): boolean { - if (raw.type !== 'PLUGIN_DATA_UPDATED' || !raw.payload || typeof raw.payload !== 'object') { - return false; - } - - const payload = raw.payload as { pluginId?: unknown; data?: unknown }; - if (typeof payload.pluginId !== 'string' || payload.pluginId.length === 0) { - return false; - } - - updatePluginData(payload.pluginId, payload.data); - return true; -} - /** * Create the message event handler for the App's window listener. */ @@ -62,7 +46,6 @@ export function createMessageHandler( if (!raw || typeof raw !== 'object' || typeof raw.type !== 'string') { return; } - if (handlePluginInjectMessage(raw, injectPluginAssets)) { return; } diff --git a/packages/extension/src/webview/app/shell/messageListener/pluginData.ts b/packages/extension/src/webview/app/shell/messageListener/pluginData.ts new file mode 100644 index 000000000..2a5502549 --- /dev/null +++ b/packages/extension/src/webview/app/shell/messageListener/pluginData.ts @@ -0,0 +1,18 @@ +import type { UpdatePluginData } from '../messageListener'; + +export function handlePluginDataUpdatedMessage( + raw: { type?: unknown; payload?: unknown }, + updatePluginData: UpdatePluginData, +): boolean { + if (raw.type !== 'PLUGIN_DATA_UPDATED' || !raw.payload || typeof raw.payload !== 'object') { + return false; + } + + const payload = raw.payload as { pluginId?: unknown; data?: unknown }; + if (typeof payload.pluginId !== 'string' || payload.pluginId.length === 0) { + return false; + } + + updatePluginData(payload.pluginId, payload.data); + return true; +} diff --git a/packages/extension/src/webview/app/shell/messageListener/ready.ts b/packages/extension/src/webview/app/shell/messageListener/ready.ts index 518240317..b435669ed 100644 --- a/packages/extension/src/webview/app/shell/messageListener/ready.ts +++ b/packages/extension/src/webview/app/shell/messageListener/ready.ts @@ -3,16 +3,35 @@ import { graphStore } from '../../../store/state'; type WindowWithCodeGraphyReadyFlag = Window & { __codegraphyWebviewReadyPosted?: boolean; + __codegraphyWebviewPageId?: string; }; +function createWebviewPageId(targetWindow: Window): string { + if (typeof targetWindow.crypto?.randomUUID === 'function') { + return targetWindow.crypto.randomUUID(); + } + + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function getWebviewPageId(targetWindow: Window): string { + const codeGraphyWindow = targetWindow as WindowWithCodeGraphyReadyFlag; + codeGraphyWindow.__codegraphyWebviewPageId ??= createWebviewPageId(targetWindow); + return codeGraphyWindow.__codegraphyWebviewPageId; +} + export function postWebviewReadyOnce(targetWindow: Window): void { const codeGraphyWindow = targetWindow as WindowWithCodeGraphyReadyFlag; // Keep the ready handshake single-shot for one webview page load. This avoids // duplicate ready messages during React development replays such as StrictMode. if (!codeGraphyWindow.__codegraphyWebviewReadyPosted) { + const pageId = getWebviewPageId(targetWindow); codeGraphyWindow.__codegraphyWebviewReadyPosted = true; graphStore.getState().beginInitialBootstrap(); - postMessage({ type: 'WEBVIEW_READY', payload: null }); + postMessage({ + type: 'WEBVIEW_READY', + payload: { pageId, postedAt: Date.now() }, + }); } } diff --git a/packages/extension/src/webview/app/shell/view.tsx b/packages/extension/src/webview/app/shell/view.tsx index 985ada2a0..52f2f3376 100644 --- a/packages/extension/src/webview/app/shell/view.tsx +++ b/packages/extension/src/webview/app/shell/view.tsx @@ -81,13 +81,14 @@ export default function App(): React.ReactElement { edgeVisibility: renderEdgeVisibility, nodeVisibility: renderNodeVisibility, } = useDebouncedGraphScopeVisibility(nodeVisibility, edgeVisibility); + const visibleGraphInput = isLoading ? null : graphData; const { filteredData, coloredData, edgeDecorations: graphEdgeDecorations, regexError, } = useFilteredGraph( - graphData, + visibleGraphInput, searchQuery, searchOptions, legends, @@ -104,7 +105,7 @@ export default function App(): React.ReactElement { activeFilterPatterns, edgeVisibility: renderEdgeVisibility, filteredData, - graphData, + graphData: visibleGraphInput, graphEdgeTypes, graphNodeTypes, nodeVisibility: renderNodeVisibility, @@ -129,7 +130,7 @@ export default function App(): React.ReactElement { return setupMessageListener(injectPluginAssets, pluginHost, resetPluginAssets, updatePluginData); }, [injectPluginAssets, pluginHost, resetPluginAssets, updatePluginData]); - const displayGraphData = coloredData || graphData; + const displayGraphData = coloredData || visibleGraphInput; useVisibleGraphStateResponse(displayGraphData); if (isLoading) return ; diff --git a/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx b/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx index eb7dda8f6..70dc4ad9d 100644 --- a/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx +++ b/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState, type MutableRefObject, type ReactElement } from 'react'; +import '../../../../../three/runtime'; +import { type MutableRefObject, type ReactElement } from 'react'; import ForceGraph3D from 'react-force-graph-3d'; import type { ForceGraphMethods as FG3DMethods, @@ -8,6 +9,13 @@ import type { LinkObject, NodeObject } from 'react-force-graph-2d'; import * as THREE from 'three'; import { DEFAULT_NODE_SIZE, type FGLink, type FGNode } from '../../../model/build'; import type { GraphSurfaceSharedProps } from '../sharedProps'; +import { + createNodeThreeObject, + type NodeThreeObjectDependencies, +} from '../../nodes/canvas3d'; +import { useDeferredSurface3dMount } from './threeDimensional/deferredMount'; + +export { useDeferredSurface3dMount } from './threeDimensional/deferredMount'; type ForceGraph3DRef = MutableRefObject | undefined>; type Surface3dMeasurementKey = 'measured' | 'unmeasured'; @@ -21,7 +29,7 @@ export interface Surface3dProps { getLinkParticles: (this: void, link: LinkObject) => number; getLinkWidth: (this: void, link: LinkObject) => number; getParticleColor: (this: void, link: LinkObject) => string; - nodeThreeObject: (this: void, node: NodeObject) => THREE.Object3D; + nodeThreeObjectContext: NodeThreeObjectDependencies; particleSize: number; particleSpeed: number; sharedProps: GraphSurfaceSharedProps; @@ -39,35 +47,6 @@ export function getSurface3dMeasurementKey( : 'measured'; } -export function useDeferredSurface3dMount(enabled: boolean): boolean { - const [isMounted, setIsMounted] = useState(!enabled); - - useEffect(() => { - if (!enabled) { - setIsMounted(true); - return; - } - - setIsMounted(false); - - let firstFrame: number | null = null; - let secondFrame: number | null = null; - - firstFrame = requestAnimationFrame(() => { - secondFrame = requestAnimationFrame(() => { - setIsMounted(true); - }); - }); - - return () => { - if (firstFrame !== null) cancelAnimationFrame(firstFrame); - if (secondFrame !== null) cancelAnimationFrame(secondFrame); - }; - }, [enabled]); - - return isMounted; -} - export function Surface3d({ backgroundColor, directionMode, @@ -77,7 +56,7 @@ export function Surface3d({ getLinkParticles, getLinkWidth, getParticleColor, - nodeThreeObject, + nodeThreeObjectContext, particleSize, particleSpeed, sharedProps, @@ -91,7 +70,8 @@ export function Surface3d({ nodeVal={(node: NodeObject) => (node as FGNode).size / DEFAULT_NODE_SIZE} nodeLabel="" nodeThreeObjectExtend={false} - nodeThreeObject={nodeThreeObject} + nodeThreeObject={(node: NodeObject): THREE.Object3D => + createNodeThreeObject(nodeThreeObjectContext, node as FGNode)} linkColor={getLinkColor} linkWidth={getLinkWidth} linkDirectionalArrowLength={directionMode === 'arrows' ? 6 : 0} diff --git a/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional/deferredMount.ts b/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional/deferredMount.ts new file mode 100644 index 000000000..40eb63296 --- /dev/null +++ b/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional/deferredMount.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; + +export function useDeferredSurface3dMount(enabled: boolean): boolean { + const [isMounted, setIsMounted] = useState(!enabled); + + useEffect(() => { + if (!enabled) { + setIsMounted(true); + return; + } + + setIsMounted(false); + + let firstFrame: number | null = null; + let secondFrame: number | null = null; + + firstFrame = requestAnimationFrame(() => { + secondFrame = requestAnimationFrame(() => { + setIsMounted(true); + }); + }); + + return () => { + if (firstFrame !== null) cancelAnimationFrame(firstFrame); + if (secondFrame !== null) cancelAnimationFrame(secondFrame); + }; + }, [enabled]); + + return isMounted; +} diff --git a/packages/extension/src/webview/components/graph/rendering/surface/view/twoDimensional.tsx b/packages/extension/src/webview/components/graph/rendering/surface/view/twoDimensional.tsx index 8d4d5e110..c6b313dd2 100644 --- a/packages/extension/src/webview/components/graph/rendering/surface/view/twoDimensional.tsx +++ b/packages/extension/src/webview/components/graph/rendering/surface/view/twoDimensional.tsx @@ -53,6 +53,9 @@ export function Surface2d({ particleSpeed, sharedProps, }: Surface2dProps): ReactElement { + const arrowColor = getArrowColor({} as LinkObject); + const arrowRelPos = getArrowRelPos({} as LinkObject); + return ( void; nodeCanvasObject: (this: void, node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => void; nodePointerAreaPaint: (this: void, node: NodeObject, color: string, ctx: CanvasRenderingContext2D) => void; - nodeThreeObject: (this: void, node: NodeObject) => ReturnType; } type GraphCallbackRefs = UseGraphCallbacksOptions['refs']; @@ -93,15 +91,6 @@ function getNodeCanvasContext({ }; } -function getNodeThreeObjectContext(refs: GraphCallbackRefs) { - return { - meshesRef: refs.meshesRef, - graphAppearanceRef: refs.graphAppearanceRef, - showLabelsRef: refs.showLabelsRef, - spritesRef: refs.spritesRef, - }; -} - export function useGraphCallbacks({ pluginHost, refs, @@ -159,9 +148,6 @@ export function useGraphCallbacks({ getLinkWidth(link) { return getGraphLinkWidth(getLinkRenderingContext(contextRef.current.refs), link as FGLink); }, - nodeThreeObject(node) { - return createNodeThreeObject(getNodeThreeObjectContext(contextRef.current.refs), node as FGNode); - }, }; } diff --git a/packages/extension/src/webview/components/graph/runtime/use/indicators/directional.ts b/packages/extension/src/webview/components/graph/runtime/use/indicators/directional.ts index 944caaa89..1ee51317f 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/indicators/directional.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/indicators/directional.ts @@ -35,12 +35,15 @@ export function applyDirectionalSettings( physicsPaused, }: DirectionalOptions, ): void { + const arrowColor = getArrowColor({} as LinkObject); + const arrowRelPos = getArrowRelPos({} as LinkObject); + graph.linkDirectionalArrowLength?.(directionMode === 'arrows' ? 12 : 0); - graph.linkDirectionalArrowRelPos?.(getArrowRelPos); + graph.linkDirectionalArrowRelPos?.(arrowRelPos); graph.linkDirectionalParticles?.(directionMode === 'particles' ? getLinkParticles : 0); graph.linkDirectionalParticleWidth?.(particleSize); graph.linkDirectionalParticleSpeed?.(particleSpeed); - graph.linkDirectionalArrowColor?.(getArrowColor); + graph.linkDirectionalArrowColor?.(arrowColor); graph.linkDirectionalParticleColor?.(getParticleColor); graph.d3ReheatSimulation?.(); if (!physicsPaused) { diff --git a/packages/extension/src/webview/components/graph/runtime/use/indicators/labelVisibility.ts b/packages/extension/src/webview/components/graph/runtime/use/indicators/labelVisibility.ts index 8f6f87f37..2d5e5bd18 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/indicators/labelVisibility.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/indicators/labelVisibility.ts @@ -2,7 +2,7 @@ import { useEffect, type MutableRefObject, } from 'react'; -import SpriteText from 'three-spritetext'; +import type SpriteText from 'three-spritetext'; import { setSpriteVisible } from '../../../support/contracts/forceGraph'; interface UseLabelVisibilityOptions { diff --git a/packages/extension/src/webview/components/graph/runtime/use/indicators/meshHighlights.ts b/packages/extension/src/webview/components/graph/runtime/use/indicators/meshHighlights.ts index efa7b11d9..7ab9246c7 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/indicators/meshHighlights.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/indicators/meshHighlights.ts @@ -2,7 +2,7 @@ import { useEffect, type MutableRefObject, } from 'react'; -import * as THREE from 'three'; +import type * as THREE from 'three'; import { DEFAULT_GRAPH_APPEARANCE, type GraphAppearance } from '../../../appearance/model'; import type { FGLink, FGNode } from '../../../model/build'; diff --git a/packages/extension/src/webview/components/graph/runtime/use/rendering.ts b/packages/extension/src/webview/components/graph/runtime/use/rendering.ts index 859f6a598..faa186890 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/rendering.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/rendering.ts @@ -9,8 +9,8 @@ import type { import type { ForceGraphMethods as FG3DMethods, } from 'react-force-graph-3d'; -import * as THREE from 'three'; -import SpriteText from 'three-spritetext'; +import type * as THREE from 'three'; +import type SpriteText from 'three-spritetext'; import type { IGraphData } from '../../../../../shared/graph/contracts'; import type { IPhysicsSettings } from '../../../../../shared/settings/physics'; import { ThemeKind } from '../../../../theme/useTheme'; diff --git a/packages/extension/src/webview/components/graph/runtime/use/state.ts b/packages/extension/src/webview/components/graph/runtime/use/state.ts index e15cc8237..00f803f17 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/state.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/state.ts @@ -10,8 +10,8 @@ import { import type { CoreGraphViewContributionSet } from '@codegraphy-dev/core'; import type { ForceGraphMethods as FG2DMethods } from 'react-force-graph-2d'; import type { ForceGraphMethods as FG3DMethods } from 'react-force-graph-3d'; -import * as THREE from 'three'; -import SpriteText from 'three-spritetext'; +import type * as THREE from 'three'; +import type SpriteText from 'three-spritetext'; import type { IFileInfo } from '../../../../../shared/files/info'; import type { IGraphData } from '../../../../../shared/graph/contracts'; import type { EdgeDecorationPayload, NodeDecorationPayload } from '../../../../../shared/plugins/decorations'; diff --git a/packages/extension/src/webview/components/graph/support/contracts/forceGraph.ts b/packages/extension/src/webview/components/graph/support/contracts/forceGraph.ts index ba541b307..414460f60 100644 --- a/packages/extension/src/webview/components/graph/support/contracts/forceGraph.ts +++ b/packages/extension/src/webview/components/graph/support/contracts/forceGraph.ts @@ -1,5 +1,5 @@ import type { ForceGraphMethods as FG2DMethods, LinkObject, NodeObject } from 'react-force-graph-2d'; -import SpriteText from 'three-spritetext'; +import type SpriteText from 'three-spritetext'; export type FG2DExtMethods = FG2DMethods & { diff --git a/packages/extension/src/webview/components/graph/viewport/accessibilityLayer/events.ts b/packages/extension/src/webview/components/graph/viewport/accessibilityLayer/events.ts new file mode 100644 index 000000000..f8cb8b5a0 --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/accessibilityLayer/events.ts @@ -0,0 +1,71 @@ +import type { + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, +} from 'react'; + +type AccessibilityEvent = + | MouseEvent + | ReactMouseEvent + | ReactKeyboardEvent; + +export function toNativeMouseEvent( + type: 'click' | 'contextmenu', + event: AccessibilityEvent, +): MouseEvent { + const nativeEvent = getNativeMouseEvent(event); + if (nativeEvent) { + return nativeEvent; + } + + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + button: mouseButtonForEventType(type), + buttons: mouseButtonForEventType(type), + clientX: getMouseEventCoordinate(event, 'clientX'), + clientY: getMouseEventCoordinate(event, 'clientY'), + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + }); +} + +export function handleAccessibilityNodeKeyDown( + nodeId: string, + handleNodeClick: (nodeId: string, event: AccessibilityEvent) => void, + event: ReactKeyboardEvent, +): void { + if (!isKeyboardActivation(event.key)) { + return; + } + + event.preventDefault(); + handleNodeClick(nodeId, event); +} + +function getNativeMouseEvent(event: AccessibilityEvent): MouseEvent | undefined { + if (event instanceof MouseEvent) { + return event; + } + + return event.nativeEvent instanceof MouseEvent ? event.nativeEvent : undefined; +} + +function mouseButtonForEventType(type: 'click' | 'contextmenu'): number { + return type === 'contextmenu' ? 2 : 0; +} + +function getMouseEventCoordinate( + event: AccessibilityEvent, + key: 'clientX' | 'clientY', +): number { + if (!(key in event)) { + return 0; + } + + return (event as MouseEvent | ReactMouseEvent)[key]; +} + +function isKeyboardActivation(key: string): boolean { + return key === 'Enter' || key === ' '; +} diff --git a/packages/extension/src/webview/components/graph/viewport/accessibilityLayer/overlay.tsx b/packages/extension/src/webview/components/graph/viewport/accessibilityLayer/overlay.tsx new file mode 100644 index 000000000..c07b70552 --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/accessibilityLayer/overlay.tsx @@ -0,0 +1,103 @@ +import type { + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, + ReactElement, +} from 'react'; +import type { GraphAccessibilityItems } from '../accessibility'; +import type { FGLink, FGNode } from '../../model/build'; +import { + handleAccessibilityNodeKeyDown, + toNativeMouseEvent, +} from './events'; + +type AccessibilityEvent = + | MouseEvent + | ReactMouseEvent + | ReactKeyboardEvent; + +export function GraphAccessibilityOverlay({ + accessibilityItems, + graphLinks, + graphNodes, + onEdgeContextMenu, + onNodeClick, + onNodeContextMenu, + onNodeHover, +}: { + accessibilityItems: GraphAccessibilityItems; + graphLinks: readonly FGLink[]; + graphNodes: readonly FGNode[]; + onEdgeContextMenu(this: void, link: FGLink, event: MouseEvent): void; + onNodeClick(this: void, node: FGNode, event: MouseEvent): void; + onNodeContextMenu(this: void, nodeId: string, event: MouseEvent): void; + onNodeHover(this: void, node: FGNode | null): void; +}): ReactElement { + const findNode = (nodeId: string) => graphNodes.find(node => node.id === nodeId) ?? null; + const findLink = (edgeId: string) => graphLinks.find(link => link.id === edgeId) ?? null; + const handleNodeClick = (nodeId: string, event: AccessibilityEvent) => { + const node = findNode(nodeId); + if (!node) return; + + onNodeClick(node, toNativeMouseEvent('click', event)); + }; + const handleNodeContextMenu = (nodeId: string, event: ReactMouseEvent) => { + if (!findNode(nodeId)) return; + + event.preventDefault(); + event.stopPropagation(); + onNodeContextMenu(nodeId, toNativeMouseEvent('contextmenu', event)); + }; + const handleEdgeContextMenu = (edgeId: string, event: ReactMouseEvent) => { + const link = findLink(edgeId); + if (!link) return; + + event.preventDefault(); + event.stopPropagation(); + onEdgeContextMenu(link, toNativeMouseEvent('contextmenu', event)); + }; + const handleNodeHover = (nodeId: string) => { + onNodeHover(findNode(nodeId)); + }; + + return ( +
+ {accessibilityItems.nodes.map(node => ( +
onNodeHover(null)} + onClick={event => handleNodeClick(node.id, event)} + onContextMenu={event => handleNodeContextMenu(node.id, event)} + onFocus={() => handleNodeHover(node.id)} + onKeyDown={event => handleAccessibilityNodeKeyDown(node.id, handleNodeClick, event)} + onMouseOut={() => onNodeHover(null)} + onMouseOver={() => handleNodeHover(node.id)} + style={{ + height: node.radius * 2, + left: node.x, + top: node.y, + transform: 'translate(-50%, -50%)', + width: node.radius * 2, + }} + /> + ))} +
+ {accessibilityItems.edges.map(edge => ( + handleEdgeContextMenu(edge.id, event)} + /> + ))} +
+
+ ); +} diff --git a/packages/extension/src/webview/components/graph/viewport/contextMenu/view.tsx b/packages/extension/src/webview/components/graph/viewport/contextMenu/view.tsx new file mode 100644 index 000000000..5be4e529d --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/contextMenu/view.tsx @@ -0,0 +1,81 @@ +import { + useRef, + type ReactElement, +} from 'react'; +import { + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, +} from '../../../ui/context/menu'; +import type { GraphContextMenuEntry } from '../../contextMenu/contracts'; +import type { ViewportProps } from '../contracts'; + +export function ViewportContextMenuItems({ + handleMenuAction, + menuEntries, +}: Pick): ReactElement { + return ( + <> + {menuEntries.map(entry => { + if (entry.kind === 'separator') { + return ; + } + + return ( + + ); + })} + + ); +} + +export function createMenuEntriesSignature(menuEntries: readonly GraphContextMenuEntry[]): string { + return menuEntries + .map(entry => entry.kind === 'separator' ? `${entry.id}:separator` : `${entry.id}:${entry.label}`) + .join('|'); +} + +function ViewportContextMenuItem({ + entry, + handleMenuAction, +}: { + entry: Extract; + handleMenuAction: ViewportProps['handleMenuAction']; +}): ReactElement { + const handledRef = useRef(false); + const handleAction = (): void => { + if (handledRef.current) { + return; + } + + handledRef.current = true; + queueMicrotask(() => { + handledRef.current = false; + }); + + if (entry.contextSelection) { + handleMenuAction({ + action: entry.action, + contextSelection: entry.contextSelection, + }); + } + }; + + return ( + + {entry.label} + {entry.shortcut ? {entry.shortcut} : null} + + ); +} diff --git a/packages/extension/src/webview/components/graph/viewport/contracts.ts b/packages/extension/src/webview/components/graph/viewport/contracts.ts new file mode 100644 index 000000000..9cd9215d0 --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/contracts.ts @@ -0,0 +1,48 @@ +import type { Ref } from 'react'; +import type { MouseEvent as ReactMouseEvent } from 'react'; +import type { DirectionMode } from '../../../../shared/settings/modes'; +import type { WebviewPluginHost } from '../../../pluginHost/manager'; +import type { + GraphContextMenuActionInvocation, + GraphContextMenuEntry, +} from '../contextMenu/contracts'; +import type { GraphMarqueeSelectionState } from '../marqueeSelection/model'; +import type { FGLink, FGNode } from '../model/build'; +import type { Surface2dProps } from '../rendering/surface/view/twoDimensional'; +import type { Surface3dProps } from '../rendering/surface/view/threeDimensional'; +import type { GraphTooltipState } from '../tooltip/model'; +import type { GraphAccessibilityItems } from './accessibility'; + +export interface ViewportProps { + accessibilityItems?: GraphAccessibilityItems; + canvasBackgroundColor: string; + containerBackgroundColor: string; + borderColor: string; + containerRef: Ref; + directionMode: DirectionMode; + graphMode: '2d' | '3d'; + handleContextMenu: (this: void, event: ReactMouseEvent) => void; + handleMenuAction: (this: void, invocation: GraphContextMenuActionInvocation) => void; + handleMouseDownCapture: (this: void, event: ReactMouseEvent) => void; + handleMouseLeave: (this: void) => void; + handleMouseMoveCapture: (this: void, event: ReactMouseEvent) => void; + handleMouseUpCapture: (this: void, event: ReactMouseEvent) => void; + handleEdgeContextMenu?: (this: void, link: FGLink, event: MouseEvent) => void; + handleNodeClick?: (this: void, node: FGNode, event: MouseEvent) => void; + handleNodeContextMenu?: (this: void, nodeId: string, event: MouseEvent) => void; + handleNodeHover?: (this: void, node: FGNode | null) => void; + marqueeSelection?: GraphMarqueeSelectionState | null; + menuEntries: GraphContextMenuEntry[]; + surface2dProps: Omit; + surface3dProps: Omit; + tooltipData: GraphTooltipState; + onSurface3dError?: (error: Error) => void; + pluginHost?: WebviewPluginHost; +} + +export interface ResolvedViewportHandlers { + handleEdgeContextMenu: NonNullable; + handleNodeClick: NonNullable; + handleNodeContextMenu: NonNullable; + handleNodeHover: NonNullable; +} diff --git a/packages/extension/src/webview/components/graph/viewport/handlers.ts b/packages/extension/src/webview/components/graph/viewport/handlers.ts new file mode 100644 index 000000000..8572fdccb --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/handlers.ts @@ -0,0 +1,31 @@ +import type { GraphAccessibilityItems } from './accessibility'; +import type { ResolvedViewportHandlers, ViewportProps } from './contracts'; + +const EMPTY_ACCESSIBILITY_ITEMS: GraphAccessibilityItems = { nodes: [], edges: [] }; +const ignoreEdgeContextMenu: NonNullable = () => undefined; +const ignoreNodeClick: NonNullable = () => undefined; +const ignoreNodeContextMenu: NonNullable = () => undefined; +const ignoreNodeHover: NonNullable = () => undefined; + +export function resolveViewportAccessibilityItems( + accessibilityItems: ViewportProps['accessibilityItems'], +): GraphAccessibilityItems { + return accessibilityItems ?? EMPTY_ACCESSIBILITY_ITEMS; +} + +export function resolveViewportHandlers({ + handleEdgeContextMenu, + handleNodeClick, + handleNodeContextMenu, + handleNodeHover, +}: Pick< + ViewportProps, + 'handleEdgeContextMenu' | 'handleNodeClick' | 'handleNodeContextMenu' | 'handleNodeHover' +>): ResolvedViewportHandlers { + return { + handleEdgeContextMenu: handleEdgeContextMenu ?? ignoreEdgeContextMenu, + handleNodeClick: handleNodeClick ?? ignoreNodeClick, + handleNodeContextMenu: handleNodeContextMenu ?? ignoreNodeContextMenu, + handleNodeHover: handleNodeHover ?? ignoreNodeHover, + }; +} diff --git a/packages/extension/src/webview/components/graph/viewport/overlays/marquee.tsx b/packages/extension/src/webview/components/graph/viewport/overlays/marquee.tsx new file mode 100644 index 000000000..05ed51dfa --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/overlays/marquee.tsx @@ -0,0 +1,19 @@ +import type { ReactElement } from 'react'; +import type { ViewportProps } from '../contracts'; + +export function ViewportMarqueeSelectionOverlay({ + marqueeSelection, +}: Pick): ReactElement | null { + return marqueeSelection ? ( +
+ ) : null; +} diff --git a/packages/extension/src/webview/components/graph/viewport/overlays/plugins.tsx b/packages/extension/src/webview/components/graph/viewport/overlays/plugins.tsx new file mode 100644 index 000000000..11922d668 --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/overlays/plugins.tsx @@ -0,0 +1,54 @@ +import type { ReactElement } from 'react'; +import { SlotHost } from '../../../../pluginHost/slotHost/view'; +import type { ViewportProps } from '../contracts'; + +export function ViewportPluginOverlay({ + pluginHost, +}: Pick): ReactElement | null { + return pluginHost ? ( + <> + + + + ) : null; +} + +export function ViewportPluginBackground({ + pluginHost, +}: Pick): ReactElement | null { + return pluginHost ? ( + + ) : null; +} + +export function ViewportPluginWorldOverlay({ + pluginHost, +}: Pick): ReactElement | null { + return pluginHost ? ( + + ) : null; +} diff --git a/packages/extension/src/webview/components/graph/viewport/shell.tsx b/packages/extension/src/webview/components/graph/viewport/shell.tsx index 49328f149..b8bccc459 100644 --- a/packages/extension/src/webview/components/graph/viewport/shell.tsx +++ b/packages/extension/src/webview/components/graph/viewport/shell.tsx @@ -1,4 +1,10 @@ -import { useEffect, useRef, useState, type ReactElement } from 'react'; +import { + useCallback, + useEffect, + useRef, + useState, + type ReactElement, +} from 'react'; import type { CoreGraphViewContributionSet } from '@codegraphy-dev/core'; import type { ThemeKind } from '../../../theme/useTheme'; import type { GraphAppearance } from '../appearance/model'; @@ -9,22 +15,19 @@ import type { UseGraphInteractionRuntimeResult } from '../runtime/use/interactio import type { GraphRuntime } from '../runtime/use/state'; import { useGraphRenderingRuntime } from '../runtime/use/rendering'; import { useGraphEventEffects } from '../runtime/use/events/effects'; -import { Viewport } from './view'; +import { Viewport, type ViewportProps } from './view'; import { graphStore } from '../../../store/state'; import { publishGraphViewportScale as publishGraphViewportScaleChange } from './shell/scale'; import { buildRenderingRuntimeOptions } from './shell/runtimeOptions'; import { useGraphViewportModelOptions } from './shell/modelOptions'; import { createGraphViewportSurfaceProps } from './shell/surfaceProps'; +import { publishCurrentGraphAccessibilityItems } from './shell/accessibilityItems'; +import { publishPluginGraphViewViewportState } from './shell/pluginState'; +import type { GraphViewport2dControls } from './shell/state'; import { - createGraphViewViewportState, - type GraphViewport2dControls, -} from './shell/viewportState'; -import { - createGraphAccessibilityItems, type GraphAccessibilityItems, type GraphScreenProjector, } from './accessibility'; -import type { FGLink, FGNode } from '../model/build'; export interface GraphViewportShellProps { appearance?: GraphAppearance; @@ -39,24 +42,6 @@ export interface GraphViewportShellProps { viewState: GraphViewStoreState; } -function createGraphAccessibilitySignature( - nodes: readonly FGNode[], - links: readonly FGLink[], -): string { - const nodeSignature = nodes - .map(node => `${node.id}:${node.size}:${Number.isFinite(node.x) && Number.isFinite(node.y) ? 'ready' : 'pending'}`) - .join('|'); - const linkSignature = links - .map(link => `${link.id}:${resolveLinkEndpoint(link.source)}:${resolveLinkEndpoint(link.target)}`) - .join('|'); - - return `${nodeSignature}::${linkSignature}`; -} - -function resolveLinkEndpoint(endpoint: string | FGNode): string { - return typeof endpoint === 'string' ? endpoint : endpoint.id; -} - export function GraphViewportShell({ appearance, callbacks, @@ -72,6 +57,7 @@ export function GraphViewportShell({ const lastPublishedViewportScaleRef = useRef(null); const lastAccessibilitySignatureRef = useRef(''); const accessibilityDirtyRef = useRef(true); + const renderFramePostRef = useRef(() => undefined); const [accessibilityItems, setAccessibilityItems] = useState({ nodes: [], edges: [], @@ -123,57 +109,43 @@ export function GraphViewportShell({ }; const publishGraphViewViewportState = (globalScale: number): void => { - if (!pluginHost) { - return; - } - - if (pluginHost.hasGraphViewViewportConsumers?.() === false) { - return; - } - - const graph = graphState.renderer.fg2dRef.current as GraphViewport2dControls | undefined; - pluginHost.setGraphViewViewportState(createGraphViewViewportState({ + publishPluginGraphViewViewportState({ globalScale, - graph, + graph: graphState.renderer.fg2dRef.current as GraphViewport2dControls | undefined, graphMode: viewState.graphMode, nodes: graphState.renderer.graphDataRef.current.nodes, + pluginHost, timelineActive: viewState.timelineActive, - })); + }); }; const publishGraphAccessibilityItems = (): void => { - if (viewState.graphMode !== '2d') { - return; - } - - if (!accessibilityDirtyRef.current) { - return; - } - - const graph = graphState.renderer.fg2dRef.current as GraphScreenProjector | undefined; const nodes = graphState.renderer.graphDataRef.current.nodes; const links = graphState.renderer.graphDataRef.current.links; - const ready = nodes.every(node => Number.isFinite(node.x) && Number.isFinite(node.y)); - if (!ready) { - return; - } - - const signature = createGraphAccessibilitySignature(nodes, links); - if (signature === lastAccessibilitySignatureRef.current) { - accessibilityDirtyRef.current = false; - return; - } - - lastAccessibilitySignatureRef.current = signature; - const items = createGraphAccessibilityItems( - nodes, + const graph = graphState.renderer.fg2dRef.current as GraphScreenProjector | undefined; + publishCurrentGraphAccessibilityItems({ + accessibilityDirtyRef, + graph: typeof graph?.graph2ScreenCoords === 'function' ? graph : undefined, + graphMode: viewState.graphMode, + lastAccessibilitySignatureRef, links, - typeof graph?.graph2ScreenCoords === 'function' ? graph : undefined, - ); - setAccessibilityItems(items); - accessibilityDirtyRef.current = false; + nodes, + setAccessibilityItems, + }); + }; + + renderFramePostRef.current = (ctx, globalScale) => { + publishGraphViewportScale(globalScale); + publishGraphViewViewportState(globalScale); + publishGraphAccessibilityItems(); + viewportRuntime.renderPluginOverlays(ctx, globalScale); }; + const handleRenderFramePost = useCallback( + (ctx, globalScale) => renderFramePostRef.current(ctx, globalScale), + [], + ); + useEffect(() => { return () => { pluginHost?.setGraphViewViewportState(null); @@ -187,12 +159,7 @@ export function GraphViewportShell({ const surfaceProps = createGraphViewportSurfaceProps({ callbacks, graphState, - onRenderFramePost: (ctx, globalScale) => { - publishGraphViewportScale(globalScale); - publishGraphViewViewportState(globalScale); - publishGraphAccessibilityItems(); - viewportRuntime.renderPluginOverlays(ctx, globalScale); - }, + onRenderFramePost: handleRenderFramePost, sharedProps: viewportModel.sharedProps, viewState, }); diff --git a/packages/extension/src/webview/components/graph/viewport/shell/accessibilityItems.ts b/packages/extension/src/webview/components/graph/viewport/shell/accessibilityItems.ts new file mode 100644 index 000000000..b1af41674 --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/shell/accessibilityItems.ts @@ -0,0 +1,70 @@ +import type { + Dispatch, + MutableRefObject, + SetStateAction, +} from 'react'; +import type { GraphViewStoreState } from '../../view/store'; +import { + createGraphAccessibilityItems, + type GraphAccessibilityItems, + type GraphScreenProjector, +} from '../accessibility'; +import type { FGLink, FGNode } from '../../model/build'; + +export function publishCurrentGraphAccessibilityItems({ + accessibilityDirtyRef, + graph, + graphMode, + lastAccessibilitySignatureRef, + links, + nodes, + setAccessibilityItems, +}: { + accessibilityDirtyRef: MutableRefObject; + graph: GraphScreenProjector | undefined; + graphMode: GraphViewStoreState['graphMode']; + lastAccessibilitySignatureRef: MutableRefObject; + links: readonly FGLink[]; + nodes: readonly FGNode[]; + setAccessibilityItems: Dispatch>; +}): void { + if (graphMode !== '2d' || !accessibilityDirtyRef.current) { + return; + } + + if (!areGraphAccessibilityNodePositionsReady(nodes)) { + return; + } + + const signature = createGraphAccessibilitySignature(nodes, links); + if (signature === lastAccessibilitySignatureRef.current) { + accessibilityDirtyRef.current = false; + return; + } + + lastAccessibilitySignatureRef.current = signature; + setAccessibilityItems(createGraphAccessibilityItems(nodes, links, graph)); + accessibilityDirtyRef.current = false; +} + +function createGraphAccessibilitySignature( + nodes: readonly FGNode[], + links: readonly FGLink[], +): string { + const nodeSignature = nodes + .map(node => `${node.id}:${node.size}:${Number.isFinite(node.x) && Number.isFinite(node.y) ? 'ready' : 'pending'}`) + .join('|'); + const linkSignature = links + .map(link => `${link.id}:${resolveLinkEndpoint(link.source)}:${resolveLinkEndpoint(link.target)}`) + .join('|'); + + return `${nodeSignature}::${linkSignature}`; +} + +function resolveLinkEndpoint(endpoint: string | FGNode): string { + return typeof endpoint === 'string' ? endpoint : endpoint.id; +} + +function areGraphAccessibilityNodePositionsReady(nodes: readonly FGNode[]): boolean { + return nodes.every(node => Number.isFinite(node.x) && Number.isFinite(node.y)); +} diff --git a/packages/extension/src/webview/components/graph/viewport/shell/pluginState.ts b/packages/extension/src/webview/components/graph/viewport/shell/pluginState.ts new file mode 100644 index 000000000..e49ead338 --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/shell/pluginState.ts @@ -0,0 +1,32 @@ +import type { WebviewPluginHost } from '../../../../pluginHost/manager'; +import type { GraphViewStoreState } from '../../view/store'; +import type { FGNode } from '../../model/build'; +import { createGraphViewViewportState, type GraphViewport2dControls } from './state'; + +export function publishPluginGraphViewViewportState({ + globalScale, + graph, + graphMode, + nodes, + pluginHost, + timelineActive, +}: { + globalScale: number; + graph: GraphViewport2dControls | undefined; + graphMode: GraphViewStoreState['graphMode']; + nodes: readonly FGNode[]; + pluginHost: WebviewPluginHost | undefined; + timelineActive: boolean; +}): void { + if (!pluginHost || pluginHost.hasGraphViewViewportConsumers?.() === false) { + return; + } + + pluginHost.setGraphViewViewportState(createGraphViewViewportState({ + globalScale, + graph, + graphMode, + nodes: [...nodes], + timelineActive, + })); +} diff --git a/packages/extension/src/webview/components/graph/viewport/shell/viewportState.ts b/packages/extension/src/webview/components/graph/viewport/shell/state.ts similarity index 100% rename from packages/extension/src/webview/components/graph/viewport/shell/viewportState.ts rename to packages/extension/src/webview/components/graph/viewport/shell/state.ts diff --git a/packages/extension/src/webview/components/graph/viewport/shell/surfaceProps.ts b/packages/extension/src/webview/components/graph/viewport/shell/surfaceProps.ts index 1dfb76dcd..d363aff4d 100644 --- a/packages/extension/src/webview/components/graph/viewport/shell/surfaceProps.ts +++ b/packages/extension/src/webview/components/graph/viewport/shell/surfaceProps.ts @@ -42,7 +42,12 @@ export function createGraphViewportSurfaceProps({ getLinkParticles: callbacks.getLinkParticles, getLinkWidth: callbacks.getLinkWidth, getParticleColor: callbacks.getParticleColor, - nodeThreeObject: callbacks.nodeThreeObject, + nodeThreeObjectContext: { + graphAppearanceRef: graphState.graphAppearanceRef, + meshesRef: graphState.renderCaches.meshesRef, + showLabelsRef: graphState.showLabelsRef, + spritesRef: graphState.renderCaches.spritesRef, + }, particleSize: viewState.particleSize, particleSpeed: viewState.particleSpeed, sharedProps, diff --git a/packages/extension/src/webview/components/graph/viewport/surface/equality.ts b/packages/extension/src/webview/components/graph/viewport/surface/equality.ts new file mode 100644 index 000000000..9b042776a --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/surface/equality.ts @@ -0,0 +1,62 @@ +import type { ViewportSurfaceProps } from './view'; + +const SURFACE_2D_PROP_KEYS = [ + 'fg2dRef', + 'getArrowColor', + 'getArrowRelPos', + 'getLinkColor', + 'getLinkParticles', + 'getLinkWidth', + 'getParticleColor', + 'linkCanvasObject', + 'nodeCanvasObject', + 'nodePointerAreaPaint', + 'onRenderFramePost', + 'particleSize', + 'particleSpeed', + 'sharedProps', +] as const; + +const SURFACE_3D_PROP_KEYS = [ + 'fg3dRef', + 'getArrowColor', + 'getLinkColor', + 'getLinkParticles', + 'getLinkWidth', + 'getParticleColor', + 'particleSize', + 'particleSpeed', + 'sharedProps', +] as const; + +const NODE_THREE_OBJECT_CONTEXT_KEYS = [ + 'graphAppearanceRef', + 'meshesRef', + 'showLabelsRef', + 'spritesRef', +] as const; + +export function areViewportSurfacePropsEqual( + previous: ViewportSurfaceProps, + next: ViewportSurfaceProps, +): boolean { + return previous.canvasBackgroundColor === next.canvasBackgroundColor + && previous.directionMode === next.directionMode + && previous.graphMode === next.graphMode + && previous.onSurface3dError === next.onSurface3dError + && propsEqualByKeys(previous.surface2dProps, next.surface2dProps, SURFACE_2D_PROP_KEYS) + && propsEqualByKeys(previous.surface3dProps, next.surface3dProps, SURFACE_3D_PROP_KEYS) + && propsEqualByKeys( + previous.surface3dProps.nodeThreeObjectContext, + next.surface3dProps.nodeThreeObjectContext, + NODE_THREE_OBJECT_CONTEXT_KEYS, + ); +} + +function propsEqualByKeys( + previous: T, + next: T, + keys: readonly K[], +): boolean { + return keys.every(key => previous[key] === next[key]); +} diff --git a/packages/extension/src/webview/components/graph/viewport/surface/view.tsx b/packages/extension/src/webview/components/graph/viewport/surface/view.tsx new file mode 100644 index 000000000..9c450980a --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/surface/view.tsx @@ -0,0 +1,74 @@ +import { + lazy, + memo, + Suspense, + type ReactElement, +} from 'react'; +import type { DirectionMode } from '../../../../../shared/settings/modes'; +import { + Surface2d, + type Surface2dProps, +} from '../../rendering/surface/view/twoDimensional'; +import type { Surface3dProps } from '../../rendering/surface/view/threeDimensional'; +import { SurfaceFallbackBoundary } from '../../rendering/surface/view/fallbackBoundary'; +import { areViewportSurfacePropsEqual } from './equality'; + +const LazyDeferredSurface3d = lazy(async () => { + const module = await import('../../rendering/surface/view/threeDimensional'); + return { default: module.DeferredSurface3d }; +}); + +export interface ViewportSurfaceProps { + canvasBackgroundColor: string; + directionMode: DirectionMode; + graphMode: '2d' | '3d'; + onSurface3dError?: (error: Error) => void; + surface2dProps: Omit; + surface3dProps: Omit; +} + +function ViewportSurface({ + canvasBackgroundColor, + directionMode, + graphMode, + onSurface3dError, + surface2dProps, + surface3dProps, +}: ViewportSurfaceProps): ReactElement { + if (graphMode === '2d') { + return ( + + ); + } + + const fallback = ( + + ); + + return ( + + + + + + ); +} + +export const MemoizedViewportSurface = memo(ViewportSurface, areViewportSurfacePropsEqual); diff --git a/packages/extension/src/webview/components/graph/viewport/tooltip/props.ts b/packages/extension/src/webview/components/graph/viewport/tooltip/props.ts new file mode 100644 index 000000000..ca4002e31 --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/tooltip/props.ts @@ -0,0 +1,25 @@ +import type { ComponentProps } from 'react'; +import { NodeTooltip } from '../../../nodeTooltip/view'; +import type { ViewportProps } from '../contracts'; + +type NodeTooltipComponentProps = ComponentProps; + +export function createNodeTooltipProps({ + pluginHost, + tooltipData, +}: Pick): NodeTooltipComponentProps { + return { + extraActions: tooltipData.pluginActions, + extraSections: tooltipData.pluginSections, + incomingCount: tooltipData.incomingCount ?? tooltipData.info?.incomingCount ?? 0, + lastModified: tooltipData.info?.lastModified, + nodeRect: tooltipData.nodeRect, + outgoingCount: tooltipData.outgoingCount ?? tooltipData.info?.outgoingCount ?? 0, + path: tooltipData.path, + plugin: tooltipData.info?.plugin ?? tooltipData.symbol?.plugin, + pluginHost, + size: tooltipData.info?.size, + symbol: tooltipData.symbol, + visible: tooltipData.visible, + }; +} diff --git a/packages/extension/src/webview/components/graph/viewport/view.tsx b/packages/extension/src/webview/components/graph/viewport/view.tsx index 075615ca5..7ee93db0d 100644 --- a/packages/extension/src/webview/components/graph/viewport/view.tsx +++ b/packages/extension/src/webview/components/graph/viewport/view.tsx @@ -1,252 +1,34 @@ -import { useRef, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, type Ref } from 'react'; -import type { DirectionMode } from '../../../../shared/settings/modes'; -import type { GraphMarqueeSelectionState } from '../marqueeSelection/model'; -import type { GraphTooltipState } from '../tooltip/model'; +import type { ReactElement } from 'react'; import { ContextMenu, ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuShortcut, ContextMenuTrigger, } from '../../ui/context/menu'; import { NodeTooltip } from '../../nodeTooltip/view'; -import type { - GraphContextMenuActionInvocation, - GraphContextMenuEntry, -} from '../contextMenu/contracts'; +import type { FGLink, FGNode } from '../model/build'; +import { GraphAccessibilityOverlay } from './accessibilityLayer/overlay'; import { - Surface2d, - type Surface2dProps, -} from '../rendering/surface/view/twoDimensional'; + createMenuEntriesSignature, + ViewportContextMenuItems, +} from './contextMenu/view'; +import type { ViewportProps } from './contracts'; import { - DeferredSurface3d, - type Surface3dProps, -} from '../rendering/surface/view/threeDimensional'; -import { SurfaceFallbackBoundary } from '../rendering/surface/view/fallbackBoundary'; -import type { WebviewPluginHost } from '../../../pluginHost/manager'; -import { SlotHost } from '../../../pluginHost/slotHost/view'; -import type { GraphAccessibilityItems } from './accessibility'; -import type { FGLink, FGNode } from '../model/build'; - -export interface ViewportProps { - accessibilityItems?: GraphAccessibilityItems; - canvasBackgroundColor: string; - containerBackgroundColor: string; - borderColor: string; - containerRef: Ref; - directionMode: DirectionMode; - graphMode: '2d' | '3d'; - handleContextMenu: (this: void, event: ReactMouseEvent) => void; - handleMenuAction: (this: void, invocation: GraphContextMenuActionInvocation) => void; - handleMouseDownCapture: (this: void, event: ReactMouseEvent) => void; - handleMouseLeave: (this: void) => void; - handleMouseMoveCapture: (this: void, event: ReactMouseEvent) => void; - handleMouseUpCapture: (this: void, event: ReactMouseEvent) => void; - handleEdgeContextMenu?: (this: void, link: FGLink, event: MouseEvent) => void; - handleNodeClick?: (this: void, node: FGNode, event: MouseEvent) => void; - handleNodeContextMenu?: (this: void, nodeId: string, event: MouseEvent) => void; - handleNodeHover?: (this: void, node: FGNode | null) => void; - marqueeSelection?: GraphMarqueeSelectionState | null; - menuEntries: GraphContextMenuEntry[]; - surface2dProps: Omit; - surface3dProps: Omit; - tooltipData: GraphTooltipState; - onSurface3dError?: (error: Error) => void; - pluginHost?: WebviewPluginHost; -} - -interface ViewportSurfaceProps { - canvasBackgroundColor: string; - directionMode: DirectionMode; - graphMode: '2d' | '3d'; - onSurface3dError?: (error: Error) => void; - surface2dProps: Omit; - surface3dProps: Omit; -} - -function ViewportSurface({ - canvasBackgroundColor, - directionMode, - graphMode, - onSurface3dError, - surface2dProps, - surface3dProps, -}: ViewportSurfaceProps): ReactElement { - if (graphMode === '2d') { - return ( - - ); - } - - const fallback = ( - - ); - - return ( - - - - ); -} - -function ViewportPluginOverlay({ - pluginHost, -}: Pick): ReactElement | null { - return pluginHost ? ( - <> - - - - ) : null; -} - -function ViewportPluginBackground({ - pluginHost, -}: Pick): ReactElement | null { - return pluginHost ? ( - - ) : null; -} - -function ViewportPluginWorldOverlay({ - pluginHost, -}: Pick): ReactElement | null { - return pluginHost ? ( - - ) : null; -} - -function ViewportMarqueeSelectionOverlay({ - marqueeSelection, -}: Pick): ReactElement | null { - return marqueeSelection ? ( -
- ) : null; -} - -function ViewportContextMenuItems({ - handleMenuAction, - menuEntries, -}: Pick): ReactElement { - return ( - <> - {menuEntries.map(entry => { - if (entry.kind === 'separator') { - return ; - } - - return ( - - ); - })} - - ); -} - -function ViewportContextMenuItem({ - entry, - handleMenuAction, -}: { - entry: Extract; - handleMenuAction: ViewportProps['handleMenuAction']; -}): ReactElement { - const handledRef = useRef(false); - const handleAction = (): void => { - if (handledRef.current) { - return; - } - - handledRef.current = true; - queueMicrotask(() => { - handledRef.current = false; - }); - - if (entry.contextSelection) { - handleMenuAction({ - action: entry.action, - contextSelection: entry.contextSelection, - }); - } - }; - - return ( - - {entry.label} - {entry.shortcut ? {entry.shortcut} : null} - - ); -} + resolveViewportAccessibilityItems, + resolveViewportHandlers, +} from './handlers'; +import { ViewportMarqueeSelectionOverlay } from './overlays/marquee'; +import { + ViewportPluginBackground, + ViewportPluginOverlay, + ViewportPluginWorldOverlay, +} from './overlays/plugins'; +import { MemoizedViewportSurface } from './surface/view'; +import { createNodeTooltipProps } from './tooltip/props'; -function createMenuEntriesSignature(menuEntries: readonly GraphContextMenuEntry[]): string { - return menuEntries - .map(entry => entry.kind === 'separator' ? `${entry.id}:separator` : `${entry.id}:${entry.label}`) - .join('|'); -} +export type { ViewportProps } from './contracts'; export function Viewport({ - accessibilityItems = { nodes: [], edges: [] }, + accessibilityItems, canvasBackgroundColor, containerBackgroundColor, borderColor, @@ -259,10 +41,10 @@ export function Viewport({ handleMouseLeave, handleMouseMoveCapture, handleMouseUpCapture, - handleEdgeContextMenu = () => undefined, - handleNodeClick = () => undefined, - handleNodeContextMenu = () => undefined, - handleNodeHover = () => undefined, + handleEdgeContextMenu, + handleNodeClick, + handleNodeContextMenu, + handleNodeHover, marqueeSelection, menuEntries, surface2dProps, @@ -272,6 +54,14 @@ export function Viewport({ pluginHost, }: ViewportProps): ReactElement { const menuEntriesSignature = createMenuEntriesSignature(menuEntries); + const resolvedAccessibilityItems = resolveViewportAccessibilityItems(accessibilityItems); + const resolvedHandlers = resolveViewportHandlers({ + handleEdgeContextMenu, + handleNodeClick, + handleNodeContextMenu, + handleNodeHover, + }); + return ( @@ -289,7 +79,7 @@ export function Viewport({ tabIndex={0} > -
@@ -319,146 +109,7 @@ export function Viewport({ /> - + ); } - -function toNativeMouseEvent( - type: 'click' | 'contextmenu', - event: MouseEvent | ReactMouseEvent | ReactKeyboardEvent, -): MouseEvent { - if (event instanceof MouseEvent) { - return event; - } - - if (event.nativeEvent instanceof MouseEvent) { - return event.nativeEvent; - } - - return new MouseEvent(type, { - bubbles: true, - cancelable: true, - button: type === 'contextmenu' ? 2 : 0, - buttons: type === 'contextmenu' ? 2 : 0, - clientX: 'clientX' in event ? event.clientX : 0, - clientY: 'clientY' in event ? event.clientY : 0, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, - shiftKey: event.shiftKey, - }); -} - -function GraphAccessibilityOverlay({ - accessibilityItems, - graphLinks, - graphNodes, - onEdgeContextMenu, - onNodeClick, - onNodeContextMenu, - onNodeHover, -}: { - accessibilityItems: GraphAccessibilityItems; - graphLinks: readonly FGLink[]; - graphNodes: readonly FGNode[]; - onEdgeContextMenu(this: void, link: FGLink, event: MouseEvent): void; - onNodeClick(this: void, node: FGNode, event: MouseEvent): void; - onNodeContextMenu(this: void, nodeId: string, event: MouseEvent): void; - onNodeHover(this: void, node: FGNode | null): void; -}): ReactElement { - const findNode = (nodeId: string) => graphNodes.find(node => node.id === nodeId) ?? null; - const findLink = (edgeId: string) => graphLinks.find(link => link.id === edgeId) ?? null; - const handleNodeClick = ( - nodeId: string, - event: MouseEvent | ReactMouseEvent | ReactKeyboardEvent, - ) => { - const node = findNode(nodeId); - if (!node) return; - - onNodeClick(node, toNativeMouseEvent('click', event)); - }; - const handleNodeContextMenu = ( - nodeId: string, - event: ReactMouseEvent, - ) => { - if (!findNode(nodeId)) return; - - event.preventDefault(); - event.stopPropagation(); - onNodeContextMenu(nodeId, toNativeMouseEvent('contextmenu', event)); - }; - const handleEdgeContextMenu = ( - edgeId: string, - event: ReactMouseEvent, - ) => { - const link = findLink(edgeId); - if (!link) return; - - event.preventDefault(); - event.stopPropagation(); - onEdgeContextMenu(link, toNativeMouseEvent('contextmenu', event)); - }; - const handleNodeHover = (nodeId: string) => { - onNodeHover(findNode(nodeId)); - }; - - return ( -
- {accessibilityItems.nodes.map(node => ( -
onNodeHover(null)} - onClick={event => handleNodeClick(node.id, event)} - onContextMenu={event => handleNodeContextMenu(node.id, event)} - onFocus={() => handleNodeHover(node.id)} - onKeyDown={event => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleNodeClick(node.id, event); - } - }} - onMouseOut={() => onNodeHover(null)} - onMouseOver={() => handleNodeHover(node.id)} - style={{ - height: node.radius * 2, - left: node.x, - top: node.y, - transform: 'translate(-50%, -50%)', - width: node.radius * 2, - }} - /> - ))} -
- {accessibilityItems.edges.map(edge => ( - handleEdgeContextMenu(edge.id, event)} - /> - ))} -
-
- ); -} diff --git a/packages/extension/src/webview/globMatch.ts b/packages/extension/src/webview/globMatch.ts index 5daaa0acd..d9a9c7241 100644 --- a/packages/extension/src/webview/globMatch.ts +++ b/packages/extension/src/webview/globMatch.ts @@ -1 +1 @@ -export { globMatch, globToRegex } from '../shared/globMatch'; +export { createGlobMatcher, globMatch, globToRegex } from '../shared/globMatch'; diff --git a/packages/extension/src/webview/main.tsx b/packages/extension/src/webview/main.tsx index d1107b87d..27501939d 100644 --- a/packages/extension/src/webview/main.tsx +++ b/packages/extension/src/webview/main.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; -import './three/runtime'; import App from './app/view'; import TimelineApp from './app/timeline/view'; import './index.css'; diff --git a/packages/extension/src/webview/search/filteredGraph/cacheKeys.ts b/packages/extension/src/webview/search/filteredGraph/cacheKeys.ts new file mode 100644 index 000000000..b6a0d3037 --- /dev/null +++ b/packages/extension/src/webview/search/filteredGraph/cacheKeys.ts @@ -0,0 +1,58 @@ +import type { SearchOptions } from '../../components/searchBar/field/model'; +import type { + IGraphEdgeTypeDefinition, + IGraphNodeTypeDefinition, +} from '../../../shared/graphControls/contracts'; +import type { IGroup } from '../../../shared/settings/groups'; + +function sortedRecordEntries(record: Record): [string, TValue][] { + return Object.entries(record).sort(([left], [right]) => left.localeCompare(right)); +} + +export function createStyledGraphCacheKey({ + edgeTypes, + nodeColors, +}: { + edgeTypes: IGraphEdgeTypeDefinition[]; + nodeColors: Record; +}): string { + return JSON.stringify({ + edgeTypes: edgeTypes.map(({ defaultColor, id }) => [id, defaultColor]), + nodeColors: sortedRecordEntries(nodeColors), + }); +} + +export function createLegendGraphCacheKey(legends: IGroup[]): string { + return JSON.stringify(legends); +} + +export function createVisibleGraphCacheKey({ + edgeTypes, + edgeVisibility, + filterPatterns, + nodeTypes, + nodeVisibility, + searchOptions, + searchQuery, + showOrphans, +}: { + edgeTypes: IGraphEdgeTypeDefinition[]; + edgeVisibility: Record; + filterPatterns: readonly string[]; + nodeTypes: IGraphNodeTypeDefinition[]; + nodeVisibility: Record; + searchOptions: SearchOptions; + searchQuery: string; + showOrphans: boolean; +}): string { + return JSON.stringify({ + edgeTypes: edgeTypes.map(({ defaultVisible, id }) => [id, defaultVisible]), + edgeVisibility: sortedRecordEntries(edgeVisibility), + filterPatterns, + nodeTypes: nodeTypes.map(({ defaultVisible, id }) => [id, defaultVisible]), + nodeVisibility: sortedRecordEntries(nodeVisibility), + searchOptions, + searchQuery, + showOrphans, + }); +} diff --git a/packages/extension/src/webview/search/filteredGraph/coloredResult.ts b/packages/extension/src/webview/search/filteredGraph/coloredResult.ts new file mode 100644 index 000000000..f7dee6f60 --- /dev/null +++ b/packages/extension/src/webview/search/filteredGraph/coloredResult.ts @@ -0,0 +1,30 @@ +import type { IGraphData } from '../../../shared/graph/contracts'; +import type { IGroup } from '../../../shared/settings/groups'; +import { applyLegendRules } from '../filtering/rules'; +import { cacheReferenceResult, getReferenceResult } from './referenceCache'; +import type { ReferenceResultCache } from './referenceCache'; + +export function getColoredGraphResult({ + cache, + filteredData, + key, + legends, +}: { + cache: ReferenceResultCache; + filteredData: IGraphData | null; + key: string; + legends: IGroup[]; +}): IGraphData | null { + if (!filteredData) { + return null; + } + + const cached = getReferenceResult(cache, filteredData, key); + if (cached) { + return cached; + } + + const result = applyLegendRules(filteredData, legends)!; + cacheReferenceResult(cache, filteredData, key, result); + return result; +} diff --git a/packages/extension/src/webview/search/filteredGraph/referenceCache.ts b/packages/extension/src/webview/search/filteredGraph/referenceCache.ts new file mode 100644 index 000000000..80c2b7c67 --- /dev/null +++ b/packages/extension/src/webview/search/filteredGraph/referenceCache.ts @@ -0,0 +1,62 @@ +const REFERENCE_RESULT_CACHE_LIMIT = 6; + +export interface ReferenceResultCache { + entries: Map; + nextReferenceId: number; + referenceIds: WeakMap; +} + +export function createReferenceResultCache(): ReferenceResultCache { + return { + entries: new Map(), + nextReferenceId: 1, + referenceIds: new WeakMap(), + }; +} + +export function getReferenceResult( + cache: ReferenceResultCache, + reference: object, + key: string, +): TValue | undefined { + return cache.entries.get(getReferenceResultCacheKey(cache, reference, key)); +} + +export function cacheReferenceResult( + cache: ReferenceResultCache, + reference: object, + key: string, + result: TValue, +): void { + const cacheKey = getReferenceResultCacheKey(cache, reference, key); + cache.entries.delete(cacheKey); + cache.entries.set(cacheKey, result); + + while (cache.entries.size > REFERENCE_RESULT_CACHE_LIMIT) { + const oldestKey = cache.entries.keys().next().value as string; + cache.entries.delete(oldestKey); + } +} + +function getReferenceResultCacheKey( + cache: ReferenceResultCache, + reference: object, + key: string, +): string { + return `${getReferenceId(cache, reference)}:${key}`; +} + +function getReferenceId( + cache: ReferenceResultCache, + reference: object, +): number { + const existing = cache.referenceIds.get(reference); + if (existing !== undefined) { + return existing; + } + + const id = cache.nextReferenceId; + cache.nextReferenceId += 1; + cache.referenceIds.set(reference, id); + return id; +} diff --git a/packages/extension/src/webview/search/filteredGraph/styledResult.ts b/packages/extension/src/webview/search/filteredGraph/styledResult.ts new file mode 100644 index 000000000..50111cf87 --- /dev/null +++ b/packages/extension/src/webview/search/filteredGraph/styledResult.ts @@ -0,0 +1,38 @@ +import type { IGraphData } from '../../../shared/graph/contracts'; +import type { IGraphEdgeTypeDefinition } from '../../../shared/graphControls/contracts'; +import { applyEdgeTypeDefaultColors } from '../../graphControls/filtering/edges'; +import { applyNodeTypeColors, withResolvedNodeTypes } from '../../graphControls/filtering/nodes'; +import { withSharedEdgeTypeAliases } from '../visibleGraphConfig'; +import { cacheReferenceResult, getReferenceResult } from './referenceCache'; +import type { ReferenceResultCache } from './referenceCache'; + +export function getStyledGraphResult({ + cache, + edgeTypes, + graph, + key, + nodeColors, +}: { + cache: ReferenceResultCache; + edgeTypes: IGraphEdgeTypeDefinition[]; + graph: IGraphData | null; + key: string; + nodeColors: Record; +}): IGraphData | null { + if (!graph) { + return null; + } + + const cached = getReferenceResult(cache, graph, key); + if (cached) { + return cached; + } + + const edgeTypesForStyling = withSharedEdgeTypeAliases(edgeTypes); + const result = { + nodes: applyNodeTypeColors(withResolvedNodeTypes(graph.nodes), nodeColors), + edges: applyEdgeTypeDefaultColors(graph.edges, edgeTypesForStyling), + }; + cacheReferenceResult(cache, graph, key, result); + return result; +} diff --git a/packages/extension/src/webview/search/filteredGraph/visibleCache.ts b/packages/extension/src/webview/search/filteredGraph/visibleCache.ts new file mode 100644 index 000000000..7bdeec705 --- /dev/null +++ b/packages/extension/src/webview/search/filteredGraph/visibleCache.ts @@ -0,0 +1,30 @@ +import type { VisibleGraphResult } from '../../../shared/visibleGraph'; +import type { IGraphData } from '../../../shared/graph/contracts'; + +const VISIBLE_GRAPH_CACHE_LIMIT = 6; + +export interface VisibleGraphCache { + entries: Map; + graphData: IGraphData | null | undefined; +} + +export function createVisibleGraphCache(): VisibleGraphCache { + return { + entries: new Map(), + graphData: undefined, + }; +} + +export function cacheVisibleGraphResult( + cache: VisibleGraphCache, + key: string, + result: VisibleGraphResult, +): void { + cache.entries.delete(key); + cache.entries.set(key, result); + + while (cache.entries.size > VISIBLE_GRAPH_CACHE_LIMIT) { + const oldestKey = cache.entries.keys().next().value as string; + cache.entries.delete(oldestKey); + } +} diff --git a/packages/extension/src/webview/search/filteredGraph/visibleResult.ts b/packages/extension/src/webview/search/filteredGraph/visibleResult.ts new file mode 100644 index 000000000..2970fcd27 --- /dev/null +++ b/packages/extension/src/webview/search/filteredGraph/visibleResult.ts @@ -0,0 +1,62 @@ +import type { SearchOptions } from '../../components/searchBar/field/model'; +import { deriveVisibleGraph } from '../../../shared/visibleGraph'; +import type { VisibleGraphResult } from '../../../shared/visibleGraph'; +import type { IGraphData } from '../../../shared/graph/contracts'; +import type { + IGraphEdgeTypeDefinition, + IGraphNodeTypeDefinition, +} from '../../../shared/graphControls/contracts'; +import { buildVisibleGraphConfig } from '../visibleGraphConfig'; +import { cacheVisibleGraphResult } from './visibleCache'; +import type { VisibleGraphCache } from './visibleCache'; + +export function getVisibleGraphResult({ + cache, + edgeTypes, + edgeVisibility, + filterPatterns, + graphData, + key, + nodeTypes, + nodeVisibility, + searchOptions, + searchQuery, + showOrphans, +}: { + cache: VisibleGraphCache; + edgeTypes: IGraphEdgeTypeDefinition[]; + edgeVisibility: Record; + filterPatterns: readonly string[]; + graphData: IGraphData | null; + key: string; + nodeTypes: IGraphNodeTypeDefinition[]; + nodeVisibility: Record; + searchOptions: SearchOptions; + searchQuery: string; + showOrphans: boolean; +}): VisibleGraphResult { + if (cache.graphData !== graphData) { + cache.graphData = graphData; + cache.entries.clear(); + } + + const cached = cache.entries.get(key); + if (cached) { + cache.entries.delete(key); + cache.entries.set(key, cached); + return cached; + } + + const result = deriveVisibleGraph(graphData, buildVisibleGraphConfig({ + edgeTypes, + edgeVisibility, + filterPatterns, + nodeTypes, + nodeVisibility, + searchOptions, + searchQuery, + showOrphans, + })); + cacheVisibleGraphResult(cache, key, result); + return result; +} diff --git a/packages/extension/src/webview/search/filtering/rules.ts b/packages/extension/src/webview/search/filtering/rules.ts index 734a130ab..124f180fc 100644 --- a/packages/extension/src/webview/search/filtering/rules.ts +++ b/packages/extension/src/webview/search/filtering/rules.ts @@ -1,7 +1,7 @@ import type { IGraphData } from '../../../shared/graph/contracts'; import type { IGroup } from '../../../shared/settings/groups'; -import { applyEdgeLegendRules } from './rules/edges'; -import { applyNodeLegendRules, getOrderedActiveRules } from './rules/nodes'; +import { applyCompiledEdgeLegendRules, compileEdgeLegendRules } from './rules/edges'; +import { applyCompiledNodeLegendRules, compileNodeLegendRules, getOrderedActiveRules } from './rules/nodes'; export function applyLegendRules( data: IGraphData | null, @@ -16,11 +16,21 @@ export function applyLegendRules( } const activeRules = getOrderedActiveRules(legends); + if (activeRules.length === 0) { + return data; + } + + const nodeRules = compileNodeLegendRules(activeRules); + const edgeRules = compileEdgeLegendRules(activeRules); return { ...data, - nodes: data.nodes.map((node) => applyNodeLegendRules(node, activeRules)), - edges: data.edges.map((edge) => applyEdgeLegendRules(edge, activeRules)), + nodes: nodeRules.length === 0 + ? data.nodes + : data.nodes.map((node) => applyCompiledNodeLegendRules(node, nodeRules)), + edges: edgeRules.length === 0 + ? data.edges + : data.edges.map((edge) => applyCompiledEdgeLegendRules(edge, edgeRules)), }; } diff --git a/packages/extension/src/webview/search/filtering/rules/edges.ts b/packages/extension/src/webview/search/filtering/rules/edges.ts index da25d48f4..2276c1e4d 100644 --- a/packages/extension/src/webview/search/filtering/rules/edges.ts +++ b/packages/extension/src/webview/search/filtering/rules/edges.ts @@ -1,4 +1,4 @@ -import { globMatch } from '../../../globMatch'; +import { createGlobMatcher } from '../../../globMatch'; import type { IGraphData } from '../../../../shared/graph/contracts'; import type { IGroup } from '../../../../shared/settings/groups'; @@ -6,31 +6,70 @@ function ruleTargetsEdges(rule: IGroup): boolean { return (rule.target ?? 'node') !== 'node'; } +export interface CompiledEdgeLegendRule { + patternMatches: (value: string) => boolean; + rule: IGroup; +} + +type EdgeLegendRuleInput = IGroup | CompiledEdgeLegendRule; + +export function compileEdgeLegendRules(activeRules: IGroup[]): CompiledEdgeLegendRule[] { + return activeRules + .filter(ruleTargetsEdges) + .map((rule) => ({ + patternMatches: createGlobMatcher(rule.pattern), + rule, + })); +} + +function isCompiledEdgeLegendRule(rule: EdgeLegendRuleInput): rule is CompiledEdgeLegendRule { + return 'patternMatches' in rule && 'rule' in rule; +} + +function normalizeEdgeLegendRules(activeRules: readonly EdgeLegendRuleInput[]): CompiledEdgeLegendRule[] { + if (activeRules.every(isCompiledEdgeLegendRule)) { + return [...activeRules]; + } + + return compileEdgeLegendRules(activeRules.filter((rule): rule is IGroup => !isCompiledEdgeLegendRule(rule))); +} + function matchesEdgeRule( edge: IGraphData['edges'][number], - rule: IGroup, + fromTo: string, + fromToKind: string, + rule: CompiledEdgeLegendRule, ): boolean { return ( - globMatch(edge.id, rule.pattern) - || globMatch(edge.kind, rule.pattern) - || globMatch(`${edge.from}->${edge.to}`, rule.pattern) - || globMatch(`${edge.from}->${edge.to}#${edge.kind}`, rule.pattern) + rule.patternMatches(edge.id) + || rule.patternMatches(edge.kind) + || rule.patternMatches(fromTo) + || rule.patternMatches(fromToKind) ); } -export function applyEdgeLegendRules( +export function applyCompiledEdgeLegendRules( edge: IGraphData['edges'][number], - activeRules: IGroup[], + activeRules: readonly CompiledEdgeLegendRule[], ): IGraphData['edges'][number] { const nextEdge = { ...edge }; + const fromTo = `${edge.from}->${edge.to}`; + const fromToKind = `${fromTo}#${edge.kind}`; - for (const rule of activeRules) { - if (!ruleTargetsEdges(rule) || !matchesEdgeRule(edge, rule)) { + for (const compiledRule of activeRules) { + if (!matchesEdgeRule(edge, fromTo, fromToKind, compiledRule)) { continue; } - nextEdge.color = rule.color; + nextEdge.color = compiledRule.rule.color; } return nextEdge; } + +export function applyEdgeLegendRules( + edge: IGraphData['edges'][number], + activeRules: readonly EdgeLegendRuleInput[], +): IGraphData['edges'][number] { + return applyCompiledEdgeLegendRules(edge, normalizeEdgeLegendRules(activeRules)); +} diff --git a/packages/extension/src/webview/search/filtering/rules/nodeLegend/apply.ts b/packages/extension/src/webview/search/filtering/rules/nodeLegend/apply.ts new file mode 100644 index 000000000..79ca9b7c4 --- /dev/null +++ b/packages/extension/src/webview/search/filtering/rules/nodeLegend/apply.ts @@ -0,0 +1,60 @@ +import { DEFAULT_NODE_COLOR } from '../../../../../shared/fileColors'; +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import { normalizeNodeLegendRules } from './compile'; +import type { + CompiledNodeLegendRule, + NodeLegendRuleInput, +} from './contracts'; +import { + compiledRuleMatchesNode, + getCaseInsensitiveNodeCandidates, +} from './match'; + +export function applyCompiledNodeLegendRules( + node: IGraphData['nodes'][number], + activeRules: readonly CompiledNodeLegendRule[], +): IGraphData['nodes'][number] { + const nextNode = { + ...node, + color: node.color || DEFAULT_NODE_COLOR, + }; + let candidates: readonly string[] | undefined; + const getCandidates = (): readonly string[] => { + candidates ??= getCaseInsensitiveNodeCandidates(node); + return candidates; + }; + + for (const compiledRule of activeRules) { + if (!compiledRuleMatchesNode(node, getCandidates, compiledRule)) { + continue; + } + + applyCompiledNodeLegendRule(nextNode, compiledRule); + } + + return nextNode; +} + +export function applyNodeLegendRules( + node: IGraphData['nodes'][number], + activeRules: readonly NodeLegendRuleInput[], +): IGraphData['nodes'][number] { + return applyCompiledNodeLegendRules(node, normalizeNodeLegendRules(activeRules)); +} + +function applyCompiledNodeLegendRule( + node: IGraphData['nodes'][number], + compiledRule: CompiledNodeLegendRule, +): void { + const { rule } = compiledRule; + node.color = rule.color; + if (rule.shape2D) { + node.shape2D = rule.shape2D; + } + if (rule.shape3D) { + node.shape3D = rule.shape3D; + } + if (rule.imageUrl) { + node.imageUrl = rule.imageUrl; + } +} diff --git a/packages/extension/src/webview/search/filtering/rules/nodeLegend/compile.ts b/packages/extension/src/webview/search/filtering/rules/nodeLegend/compile.ts new file mode 100644 index 000000000..a5028d418 --- /dev/null +++ b/packages/extension/src/webview/search/filtering/rules/nodeLegend/compile.ts @@ -0,0 +1,53 @@ +import type { IGroup } from '../../../../../shared/settings/groups'; +import { createGlobMatcher } from '../../../../globMatch'; +import { ruleTargetsNodes } from '../nodeMatcher'; +import type { CompiledNodeLegendRule, NodeLegendRuleInput } from './contracts'; + +export function getOrderedActiveRules(legends: IGroup[]): IGroup[] { + return legends + .filter((group) => !group.disabled) + .reverse(); +} + +export function compileNodeLegendRules(activeRules: IGroup[]): CompiledNodeLegendRule[] { + return activeRules + .filter(ruleTargetsNodes) + .map((rule) => ({ + caseInsensitivePatternMatches: createGlobMatcher(rule.pattern.toLowerCase()), + hasConstraints: hasNodeLegendConstraints(rule), + patternMatches: createGlobMatcher(rule.pattern), + patternHasPathSeparator: rule.pattern.includes('/'), + rule, + ...(rule.matchSymbolFilePath + ? { symbolFilePathMatches: createGlobMatcher(rule.matchSymbolFilePath) } + : {}), + })); +} + +export function normalizeNodeLegendRules( + activeRules: readonly NodeLegendRuleInput[], +): CompiledNodeLegendRule[] { + if (activeRules.every(isCompiledNodeLegendRule)) { + return [...activeRules]; + } + + return compileNodeLegendRules(activeRules.filter((rule): rule is IGroup => + !isCompiledNodeLegendRule(rule), + )); +} + +function hasNodeLegendConstraints(rule: IGroup): boolean { + return Boolean( + rule.matchNodeType + || rule.matchSymbolKind + || rule.matchSymbolKinds?.length + || rule.matchSymbolPluginKind + || rule.matchSymbolSource + || rule.matchSymbolLanguage + || rule.matchSymbolFilePath, + ); +} + +function isCompiledNodeLegendRule(rule: NodeLegendRuleInput): rule is CompiledNodeLegendRule { + return 'patternMatches' in rule && 'rule' in rule; +} diff --git a/packages/extension/src/webview/search/filtering/rules/nodeLegend/constraints.ts b/packages/extension/src/webview/search/filtering/rules/nodeLegend/constraints.ts new file mode 100644 index 000000000..9ed5e3f7a --- /dev/null +++ b/packages/extension/src/webview/search/filtering/rules/nodeLegend/constraints.ts @@ -0,0 +1,50 @@ +import type { + CompiledNodeLegendRule, + GraphNode, + GraphNodeSymbol, +} from './contracts'; + +type NodeLegendConstraintMatcher = ( + node: GraphNode, + symbol: GraphNodeSymbol, + compiledRule: CompiledNodeLegendRule, +) => boolean; + +const NODE_LEGEND_CONSTRAINT_MATCHERS: readonly NodeLegendConstraintMatcher[] = [ + (node, _symbol, { rule }) => optionalRuleValueMatches(rule.matchNodeType, node.nodeType), + (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolKind, symbol?.kind), + (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolPluginKind, symbol?.pluginKind), + (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolSource, symbol?.source), + (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolLanguage, symbol?.language), + (_node, symbol, { rule }) => optionalSymbolKindsMatch(rule.matchSymbolKinds, symbol?.kind), + (_node, symbol, compiledRule) => optionalSymbolFilePathMatches(compiledRule, symbol?.filePath), +]; + +export function compiledRuleConstraintsMatchNode( + node: GraphNode, + compiledRule: CompiledNodeLegendRule, +): boolean { + return !compiledRule.hasConstraints + || NODE_LEGEND_CONSTRAINT_MATCHERS.every(matcher => + matcher(node, node.symbol, compiledRule), + ); +} + +function optionalRuleValueMatches(expected: T | undefined, actual: T | undefined): boolean { + return expected === undefined || expected === actual; +} + +function optionalSymbolKindsMatch( + expected: readonly string[] | undefined, + actual: string | undefined, +): boolean { + return expected === undefined || Boolean(actual && expected.includes(actual)); +} + +function optionalSymbolFilePathMatches( + compiledRule: CompiledNodeLegendRule, + filePath: string | undefined, +): boolean { + return compiledRule.symbolFilePathMatches === undefined + || Boolean(filePath && compiledRule.symbolFilePathMatches(filePath)); +} diff --git a/packages/extension/src/webview/search/filtering/rules/nodeLegend/contracts.ts b/packages/extension/src/webview/search/filtering/rules/nodeLegend/contracts.ts new file mode 100644 index 000000000..6f9d9168a --- /dev/null +++ b/packages/extension/src/webview/search/filtering/rules/nodeLegend/contracts.ts @@ -0,0 +1,16 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import type { IGroup } from '../../../../../shared/settings/groups'; + +export type GraphNode = IGraphData['nodes'][number]; +export type GraphNodeSymbol = GraphNode['symbol']; + +export interface CompiledNodeLegendRule { + caseInsensitivePatternMatches: (value: string) => boolean; + hasConstraints: boolean; + patternMatches: (value: string) => boolean; + patternHasPathSeparator: boolean; + rule: IGroup; + symbolFilePathMatches?: (value: string) => boolean; +} + +export type NodeLegendRuleInput = IGroup | CompiledNodeLegendRule; diff --git a/packages/extension/src/webview/search/filtering/rules/nodeLegend/match.ts b/packages/extension/src/webview/search/filtering/rules/nodeLegend/match.ts new file mode 100644 index 000000000..b265d49fc --- /dev/null +++ b/packages/extension/src/webview/search/filtering/rules/nodeLegend/match.ts @@ -0,0 +1,63 @@ +import type { + CompiledNodeLegendRule, + GraphNode, +} from './contracts'; +import { compiledRuleConstraintsMatchNode } from './constraints'; + +export function compiledRuleMatchesNode( + node: GraphNode, + getCandidates: () => readonly string[], + compiledRule: CompiledNodeLegendRule, +): boolean { + return compiledRuleConstraintsMatchNode(node, compiledRule) + && compiledRulePatternMatchesNode(node, getCandidates, compiledRule); +} + +export function getCaseInsensitiveNodeCandidates(node: GraphNode): string[] { + const symbol = node.symbol; + return [ + node.label, + symbol?.name, + symbol?.kind, + symbol?.pluginKind, + symbol?.filePath, + ] + .filter((candidate): candidate is string => Boolean(candidate)) + .map((candidate) => candidate.toLowerCase()); +} + +function compiledRulePatternMatchesNode( + node: GraphNode, + getCandidates: () => readonly string[], + compiledRule: CompiledNodeLegendRule, +): boolean { + if (compiledRule.patternMatches(node.id)) { + return true; + } + + if (compiledRule.rule.isPluginDefault) { + return false; + } + + if (compiledRule.patternHasPathSeparator) { + const symbol = node.symbol; + return pathCandidateMatchesNodeRule(node.label, compiledRule) + || pathCandidateMatchesNodeRule(symbol?.name, compiledRule) + || pathCandidateMatchesNodeRule(symbol?.kind, compiledRule) + || pathCandidateMatchesNodeRule(symbol?.pluginKind, compiledRule) + || pathCandidateMatchesNodeRule(symbol?.filePath, compiledRule); + } + + return getCandidates().some((candidate) => compiledRule.caseInsensitivePatternMatches(candidate)); +} + +function pathCandidateMatchesNodeRule( + value: string | undefined, + compiledRule: CompiledNodeLegendRule, +): boolean { + return Boolean( + value + && value.includes('/') + && compiledRule.caseInsensitivePatternMatches(value.toLowerCase()), + ); +} diff --git a/packages/extension/src/webview/search/filtering/rules/nodes.ts b/packages/extension/src/webview/search/filtering/rules/nodes.ts index 559973dd1..35052e369 100644 --- a/packages/extension/src/webview/search/filtering/rules/nodes.ts +++ b/packages/extension/src/webview/search/filtering/rules/nodes.ts @@ -1,39 +1,38 @@ -import { DEFAULT_NODE_COLOR } from '../../../../shared/fileColors'; import type { IGraphData } from '../../../../shared/graph/contracts'; import type { IGroup } from '../../../../shared/settings/groups'; -import { ruleMatchesNode, ruleTargetsNodes } from './nodeMatcher'; +import { + applyCompiledNodeLegendRules as applyCompiledNodeLegendRulesImpl, + applyNodeLegendRules as applyNodeLegendRulesImpl, +} from './nodeLegend/apply'; +import { + compileNodeLegendRules as compileNodeLegendRulesImpl, + getOrderedActiveRules as getOrderedActiveRulesImpl, +} from './nodeLegend/compile'; +import type { + CompiledNodeLegendRule, + NodeLegendRuleInput, +} from './nodeLegend/contracts'; + +export type { CompiledNodeLegendRule } from './nodeLegend/contracts'; export function getOrderedActiveRules(legends: IGroup[]): IGroup[] { - return legends - .filter((group) => !group.disabled) - .reverse(); + return getOrderedActiveRulesImpl(legends); } -export function applyNodeLegendRules( +export function compileNodeLegendRules(activeRules: IGroup[]): CompiledNodeLegendRule[] { + return compileNodeLegendRulesImpl(activeRules); +} + +export function applyCompiledNodeLegendRules( node: IGraphData['nodes'][number], - activeRules: IGroup[], + activeRules: readonly CompiledNodeLegendRule[], ): IGraphData['nodes'][number] { - const nextNode = { - ...node, - color: node.color || DEFAULT_NODE_COLOR, - }; - - for (const rule of activeRules) { - if (!ruleTargetsNodes(rule) || !ruleMatchesNode(node, rule)) { - continue; - } - - nextNode.color = rule.color; - if (rule.shape2D) { - nextNode.shape2D = rule.shape2D; - } - if (rule.shape3D) { - nextNode.shape3D = rule.shape3D; - } - if (rule.imageUrl) { - nextNode.imageUrl = rule.imageUrl; - } - } + return applyCompiledNodeLegendRulesImpl(node, activeRules); +} - return nextNode; +export function applyNodeLegendRules( + node: IGraphData['nodes'][number], + activeRules: readonly NodeLegendRuleInput[], +): IGraphData['nodes'][number] { + return applyNodeLegendRulesImpl(node, activeRules); } diff --git a/packages/extension/src/webview/search/useFilteredGraph.ts b/packages/extension/src/webview/search/useFilteredGraph.ts index 77d22b56d..2f4025ea5 100644 --- a/packages/extension/src/webview/search/useFilteredGraph.ts +++ b/packages/extension/src/webview/search/useFilteredGraph.ts @@ -4,10 +4,8 @@ * @module webview/useFilteredGraph */ -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import type { SearchOptions } from '../components/searchBar/field/model'; -import { applyLegendRules } from './filtering/rules'; -import { deriveVisibleGraph } from '../../shared/visibleGraph'; import type { IGraphData } from '../../shared/graph/contracts'; import type { IGraphEdgeTypeDefinition, @@ -15,15 +13,13 @@ import type { } from '../../shared/graphControls/contracts'; import type { IGroup } from '../../shared/settings/groups'; import type { EdgeDecorationPayload } from '../../shared/plugins/decorations'; -import { - applyEdgeTypeDefaultColors, - filterVisibleEdgeDecorations, -} from '../graphControls/filtering/edges'; -import { applyNodeTypeColors, withResolvedNodeTypes } from '../graphControls/filtering/nodes'; -import { - buildVisibleGraphConfig, - withSharedEdgeTypeAliases, -} from './visibleGraphConfig'; +import { filterVisibleEdgeDecorations } from '../graphControls/filtering/edges'; +import { createLegendGraphCacheKey, createStyledGraphCacheKey, createVisibleGraphCacheKey } from './filteredGraph/cacheKeys'; +import { getColoredGraphResult } from './filteredGraph/coloredResult'; +import { createReferenceResultCache } from './filteredGraph/referenceCache'; +import { getStyledGraphResult } from './filteredGraph/styledResult'; +import { createVisibleGraphCache } from './filteredGraph/visibleCache'; +import { getVisibleGraphResult } from './filteredGraph/visibleResult'; export interface IFilteredGraph { /** Graph after node/edge search filtering (null when no graph data). */ @@ -54,17 +50,51 @@ export function useFilteredGraph( showOrphans = true, nodeTypes: IGraphNodeTypeDefinition[] = [], ): IFilteredGraph { + const coloredGraphCache = useRef(createReferenceResultCache()); + const styledGraphCache = useRef(createReferenceResultCache()); + const visibleGraphCache = useRef(createVisibleGraphCache()); + const legendGraphCacheKey = useMemo(() => createLegendGraphCacheKey(legends), [legends]); + const styledGraphCacheKey = useMemo(() => createStyledGraphCacheKey({ + edgeTypes, + nodeColors, + }), [ + edgeTypes, + nodeColors, + ]); + const visibleGraphCacheKey = useMemo(() => createVisibleGraphCacheKey({ + edgeTypes, + edgeVisibility, + filterPatterns, + nodeTypes, + nodeVisibility, + searchOptions, + searchQuery, + showOrphans, + }), [ + edgeTypes, + edgeVisibility, + filterPatterns, + nodeTypes, + nodeVisibility, + searchOptions, + searchQuery, + showOrphans, + ]); + const visibleGraph = useMemo(() => { - return deriveVisibleGraph(graphData, buildVisibleGraphConfig({ + return getVisibleGraphResult({ + cache: visibleGraphCache.current, edgeTypes, edgeVisibility, filterPatterns, + graphData, + key: visibleGraphCacheKey, nodeTypes, nodeVisibility, searchOptions, searchQuery, showOrphans, - })); + }); }, [ edgeTypes, edgeVisibility, @@ -75,25 +105,27 @@ export function useFilteredGraph( searchOptions, searchQuery, showOrphans, + visibleGraphCacheKey, ]); const filteredData = useMemo(() => { - if (!visibleGraph.graphData) { - return null; - } - - const edgeTypesForStyling = withSharedEdgeTypeAliases(edgeTypes); - - return { - nodes: applyNodeTypeColors(withResolvedNodeTypes(visibleGraph.graphData.nodes), nodeColors), - edges: applyEdgeTypeDefaultColors(visibleGraph.graphData.edges, edgeTypesForStyling), - }; - }, [edgeTypes, nodeColors, visibleGraph.graphData]); + return getStyledGraphResult({ + cache: styledGraphCache.current, + edgeTypes, + graph: visibleGraph.graphData, + key: styledGraphCacheKey, + nodeColors, + }); + }, [edgeTypes, nodeColors, styledGraphCacheKey, visibleGraph.graphData]); - const coloredData = useMemo( - () => applyLegendRules(filteredData, legends), - [filteredData, legends], - ); + const coloredData = useMemo(() => { + return getColoredGraphResult({ + cache: coloredGraphCache.current, + filteredData, + key: legendGraphCacheKey, + legends, + }); + }, [filteredData, legendGraphCacheKey, legends]); const controlsEdgeDecorations = useMemo( () => filterVisibleEdgeDecorations(filteredData?.edges ?? [], edgeDecorations), diff --git a/packages/extension/src/webview/store/messageHandlers/graph.ts b/packages/extension/src/webview/store/messageHandlers/graph.ts index 61ed2c968..dc8e13d6b 100644 --- a/packages/extension/src/webview/store/messageHandlers/graph.ts +++ b/packages/extension/src/webview/store/messageHandlers/graph.ts @@ -6,79 +6,8 @@ import { } from '../optimistic/groups/updates'; import { arePlainValuesEqual } from './equality/compare'; -export function handleGraphDataUpdated( - message: Extract, - ctx?: Pick, -): PartialState { - const state = ctx?.getState(); - const waitingForInitialBootstrap = Boolean( - state?.awaitingInitialBootstrap - && !state.bootstrapComplete, - ); - const initialBootstrapFinished = Boolean( - state?.awaitingInitialBootstrap - && state.bootstrapComplete - ); - - return { - graphData: message.payload, - ...(initialBootstrapFinished ? { awaitingInitialBootstrap: false } : {}), - isLoading: waitingForInitialBootstrap, - graphIsIndexing: false, - graphIndexProgress: null, - }; -} - -export function handleAppBootstrapComplete( - _message: Extract, - ctx: Pick, -): PartialState { - const state = ctx.getState(); - const graphReady = state.graphData !== null; - - return { - bootstrapComplete: true, - awaitingInitialBootstrap: graphReady ? false : state.awaitingInitialBootstrap, - isLoading: graphReady ? false : state.isLoading, - }; -} - -export function handleGraphIndexStatusUpdated( - message: Extract, -): PartialState { - const indexIsReady = message.payload.hasIndex && message.payload.freshness === 'fresh'; - - return { - graphHasIndex: message.payload.hasIndex, - graphIndexFreshness: message.payload.freshness, - graphIndexDetail: message.payload.detail, - ...(indexIsReady ? { - graphIsIndexing: false, - graphIndexProgress: null, - } : {}), - }; -} - -export function handleGraphIndexProgress( - message: Extract, -): PartialState { - return { - graphIsIndexing: true, - graphIndexProgress: message.payload, - }; -} - -export function handleGraphControlsUpdated( - message: Extract, -): PartialState { - return { - graphNodeTypes: message.payload.nodeTypes, - graphEdgeTypes: message.payload.edgeTypes, - nodeColors: message.payload.nodeColors, - nodeVisibility: message.payload.nodeVisibility, - edgeVisibility: message.payload.edgeVisibility, - }; -} +export * from './graphControls'; +export * from './graphData'; export function handleFavoritesUpdated( message: Extract, @@ -111,15 +40,6 @@ function areSetsEqual(left: ReadonlySet, right: ReadonlySet): bo return true; } -export function handleSettingsUpdated( - message: Extract, -): PartialState { - return { - bidirectionalMode: message.payload.bidirectionalEdges, - showOrphans: message.payload.showOrphans, - }; -} - export function handleLegendsUpdated( message: Extract, ctx: IHandlerContext, @@ -160,62 +80,3 @@ export function handleFilterPatternsUpdated( disabledPluginFilterPatterns: message.payload.disabledPluginPatterns, }; } - -export function handleDepthModeUpdated( - message: Extract, -): PartialState { - return { depthMode: message.payload.depthMode }; -} - -export function handlePhysicsSettingsUpdated( - message: Extract, -): PartialState { - return { physicsSettings: message.payload }; -} - -export function handleDepthLimitUpdated( - message: Extract, -): PartialState { - return { depthLimit: message.payload.depthLimit }; -} - -export function handleDepthLimitRangeUpdated( - message: Extract, -): PartialState { - return { maxDepthLimit: message.payload.maxDepthLimit }; -} - -export function handleDirectionSettingsUpdated( - message: Extract, -): PartialState { - return { - directionMode: message.payload.directionMode, - directionColor: message.payload.directionColor, - particleSpeed: message.payload.particleSpeed, - particleSize: message.payload.particleSize, - }; -} - -export function handleShowLabelsUpdated( - message: Extract, -): PartialState { - return { showLabels: message.payload.showLabels }; -} - -export function handleMaxFilesUpdated( - message: Extract, -): PartialState { - return { maxFiles: message.payload.maxFiles }; -} - -export function handleVerboseDiagnosticsUpdated( - message: Extract, -): PartialState { - return { verboseDiagnostics: message.payload.verboseDiagnostics }; -} - -export function handleActiveFileUpdated( - message: Extract, -): PartialState { - return { activeFilePath: message.payload.filePath ?? null }; -} diff --git a/packages/extension/src/webview/store/messageHandlers/graphControls.ts b/packages/extension/src/webview/store/messageHandlers/graphControls.ts new file mode 100644 index 000000000..22a07a2c6 --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphControls.ts @@ -0,0 +1,116 @@ +import type { ExtensionToWebviewMessage } from '../../../shared/protocol/extensionToWebview'; +import type { IHandlerContext, PartialState } from '../messageTypes'; +import { createGraphControlsStatePatch } from './graphControls/patch'; + +export function handleGraphIndexStatusUpdated( + message: Extract, +): PartialState { + const indexIsReady = message.payload.hasIndex && message.payload.freshness === 'fresh'; + + return { + graphHasIndex: message.payload.hasIndex, + graphIndexFreshness: message.payload.freshness, + graphIndexDetail: message.payload.detail, + ...(indexIsReady ? { + graphIsIndexing: false, + graphIndexProgress: null, + } : {}), + }; +} + +export function handleGraphIndexProgress( + message: Extract, +): PartialState { + return { + graphIsIndexing: true, + graphIndexProgress: message.payload, + }; +} + +export function handleGraphControlsUpdated( + message: Extract, + ctx?: Pick, +): PartialState | void { + const state = ctx?.getState(); + if (!state) { + return { + graphNodeTypes: message.payload.nodeTypes, + graphEdgeTypes: message.payload.edgeTypes, + nodeColors: message.payload.nodeColors, + nodeVisibility: message.payload.nodeVisibility, + edgeVisibility: message.payload.edgeVisibility, + }; + } + + const next = createGraphControlsStatePatch(state, message.payload); + + return Object.keys(next).length > 0 ? next : undefined; +} + +export function handleSettingsUpdated( + message: Extract, +): PartialState { + return { + bidirectionalMode: message.payload.bidirectionalEdges, + showOrphans: message.payload.showOrphans, + }; +} + +export function handleDepthModeUpdated( + message: Extract, +): PartialState { + return { depthMode: message.payload.depthMode }; +} + +export function handlePhysicsSettingsUpdated( + message: Extract, +): PartialState { + return { physicsSettings: message.payload }; +} + +export function handleDepthLimitUpdated( + message: Extract, +): PartialState { + return { depthLimit: message.payload.depthLimit }; +} + +export function handleDepthLimitRangeUpdated( + message: Extract, +): PartialState { + return { maxDepthLimit: message.payload.maxDepthLimit }; +} + +export function handleDirectionSettingsUpdated( + message: Extract, +): PartialState { + return { + directionMode: message.payload.directionMode, + directionColor: message.payload.directionColor, + particleSpeed: message.payload.particleSpeed, + particleSize: message.payload.particleSize, + }; +} + +export function handleShowLabelsUpdated( + message: Extract, +): PartialState { + return { showLabels: message.payload.showLabels }; +} + +export function handleMaxFilesUpdated( + message: Extract, +): PartialState { + return { maxFiles: message.payload.maxFiles }; +} + +export function handleVerboseDiagnosticsUpdated( + message: Extract, +): PartialState { + return { verboseDiagnostics: message.payload.verboseDiagnostics }; +} + +export function handleActiveFileUpdated( + message: Extract, +): PartialState { + return { activeFilePath: message.payload.filePath ?? null }; +} diff --git a/packages/extension/src/webview/store/messageHandlers/graphControls/patch.ts b/packages/extension/src/webview/store/messageHandlers/graphControls/patch.ts new file mode 100644 index 000000000..ad58f7677 --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphControls/patch.ts @@ -0,0 +1,34 @@ +import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; +import type { PartialState } from '../../messageTypes'; +import { arePlainValuesEqual } from '../equality/compare'; + +type GraphControlsPayload = Extract< + ExtensionToWebviewMessage, + { type: 'GRAPH_CONTROLS_UPDATED' } +>['payload']; + +export function createGraphControlsStatePatch( + state: PartialState, + payload: GraphControlsPayload, +): PartialState { + const next: PartialState = {}; + + assignChangedGraphControl(next, 'graphNodeTypes', state.graphNodeTypes, payload.nodeTypes); + assignChangedGraphControl(next, 'graphEdgeTypes', state.graphEdgeTypes, payload.edgeTypes); + assignChangedGraphControl(next, 'nodeColors', state.nodeColors, payload.nodeColors); + assignChangedGraphControl(next, 'nodeVisibility', state.nodeVisibility, payload.nodeVisibility); + assignChangedGraphControl(next, 'edgeVisibility', state.edgeVisibility, payload.edgeVisibility); + + return next; +} + +function assignChangedGraphControl( + next: PartialState, + key: K, + currentValue: PartialState[K], + nextValue: PartialState[K], +): void { + if (!arePlainValuesEqual(currentValue, nextValue)) { + next[key] = nextValue; + } +} diff --git a/packages/extension/src/webview/store/messageHandlers/graphData.ts b/packages/extension/src/webview/store/messageHandlers/graphData.ts new file mode 100644 index 000000000..e00b9771e --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphData.ts @@ -0,0 +1,30 @@ +import type { IHandlerContext, PartialState } from '../messageTypes'; +import { handleAppBootstrapComplete as handleAppBootstrapCompleteImpl } from './graphDataMessage/bootstrap'; +import type { + AppBootstrapCompleteMessage, + GraphDataUpdatedMessage, + GraphNodeMetricsUpdateMessage, +} from './graphDataMessage/contracts'; +import { handleGraphNodeMetricsUpdated as handleGraphNodeMetricsUpdatedImpl } from './graphDataMessage/metrics'; +import { handleGraphDataUpdated as handleGraphDataUpdatedImpl } from './graphDataMessage/payload'; + +export function handleGraphDataUpdated( + message: GraphDataUpdatedMessage, + ctx?: Pick, +): PartialState | void { + return handleGraphDataUpdatedImpl(message, ctx); +} + +export function handleGraphNodeMetricsUpdated( + message: GraphNodeMetricsUpdateMessage, + ctx?: Pick, +): PartialState | void { + return handleGraphNodeMetricsUpdatedImpl(message, ctx); +} + +export function handleAppBootstrapComplete( + message: AppBootstrapCompleteMessage, + ctx: Pick, +): PartialState { + return handleAppBootstrapCompleteImpl(message, ctx); +} diff --git a/packages/extension/src/webview/store/messageHandlers/graphDataMessage/bootstrap.ts b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/bootstrap.ts new file mode 100644 index 000000000..627615ff0 --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/bootstrap.ts @@ -0,0 +1,16 @@ +import type { IHandlerContext, PartialState } from '../../messageTypes'; +import type { AppBootstrapCompleteMessage } from './contracts'; + +export function handleAppBootstrapComplete( + _message: AppBootstrapCompleteMessage, + ctx: Pick, +): PartialState { + const state = ctx.getState(); + const graphReady = state.graphData !== null; + + return { + bootstrapComplete: true, + awaitingInitialBootstrap: graphReady ? false : state.awaitingInitialBootstrap, + isLoading: graphReady ? false : state.isLoading, + }; +} diff --git a/packages/extension/src/webview/store/messageHandlers/graphDataMessage/contracts.ts b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/contracts.ts new file mode 100644 index 000000000..db251358d --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/contracts.ts @@ -0,0 +1,17 @@ +import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; + +export type GraphDataUpdatedMessage = Extract< + ExtensionToWebviewMessage, + { type: 'GRAPH_DATA_UPDATED' } +>; +export type GraphNodeMetricsUpdateMessage = Extract< + ExtensionToWebviewMessage, + { type: 'GRAPH_NODE_METRICS_UPDATED' } +>; +export type GraphNodeMetricsUpdate = GraphNodeMetricsUpdateMessage['payload']['nodes'][number]; +export type AppBootstrapCompleteMessage = Extract< + ExtensionToWebviewMessage, + { type: 'APP_BOOTSTRAP_COMPLETE' } +>; +export type GraphNode = IGraphData['nodes'][number]; diff --git a/packages/extension/src/webview/store/messageHandlers/graphDataMessage/duplicate.ts b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/duplicate.ts new file mode 100644 index 000000000..5e0985a06 --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/duplicate.ts @@ -0,0 +1,39 @@ +import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { IHandlerContext } from '../../messageTypes'; + +export function shouldSkipDuplicateGraphData( + state: ReturnType>, + payload: IGraphData, +): boolean { + if ( + !state.graphData + || state.graphIsIndexing + || areGraphDataPayloadsEqual(state.graphData, payload) === false + ) { + return false; + } + + return ( + ( + state.bootstrapComplete + && !state.awaitingInitialBootstrap + && !state.isLoading + ) + || ( + state.awaitingInitialBootstrap + && !state.bootstrapComplete + ) + ); +} + +function areGraphDataPayloadsEqual(left: IGraphData, right: IGraphData): boolean { + if (left.nodes.length !== right.nodes.length || left.edges.length !== right.edges.length) { + return false; + } + + try { + return JSON.stringify(left) === JSON.stringify(right); + } catch { + return false; + } +} diff --git a/packages/extension/src/webview/store/messageHandlers/graphDataMessage/metricUpdates.ts b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/metricUpdates.ts new file mode 100644 index 000000000..718c1a5ea --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/metricUpdates.ts @@ -0,0 +1,55 @@ +import type { NodeSizeMode } from '../../../../shared/settings/modes'; +import type { + GraphNode, + GraphNodeMetricsUpdate, +} from './contracts'; + +export function nodeSizeModeUsesNodeMetrics(mode: NodeSizeMode): boolean { + return mode === 'file-size' || mode === 'churn'; +} + +export function applyMetricUpdatesInPlace( + graphData: { nodes: GraphNode[] }, + updatesById: ReadonlyMap, +): boolean { + let changed = false; + + for (const node of graphData.nodes) { + const update = updatesById.get(node.id); + if (!update || !nodeMetricsDiffer(node, update)) { + continue; + } + + node.fileSize = update.fileSize; + node.churn = update.churn; + changed = true; + } + + return changed; +} + +export function applyMetricUpdates( + nodes: readonly GraphNode[], + updatesById: ReadonlyMap, +): { changed: boolean; nodes: GraphNode[] } { + let changed = false; + const nextNodes = nodes.map((node) => { + const update = updatesById.get(node.id); + if (!update || !nodeMetricsDiffer(node, update)) { + return node; + } + + changed = true; + return { + ...node, + fileSize: update.fileSize, + churn: update.churn, + }; + }); + + return { changed, nodes: nextNodes }; +} + +function nodeMetricsDiffer(node: GraphNode, update: GraphNodeMetricsUpdate): boolean { + return node.fileSize !== update.fileSize || node.churn !== update.churn; +} diff --git a/packages/extension/src/webview/store/messageHandlers/graphDataMessage/metrics.ts b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/metrics.ts new file mode 100644 index 000000000..ff72f8daf --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/metrics.ts @@ -0,0 +1,51 @@ +import type { IHandlerContext, PartialState } from '../../messageTypes'; +import type { GraphNodeMetricsUpdateMessage } from './contracts'; +import { + applyMetricUpdates, + applyMetricUpdatesInPlace, + nodeSizeModeUsesNodeMetrics, +} from './metricUpdates'; + +export function handleGraphNodeMetricsUpdated( + message: GraphNodeMetricsUpdateMessage, + ctx?: Pick, +): PartialState | void { + const state = ctx?.getState(); + if (!state?.graphData) { + return undefined; + } + + const updatesById = new Map(message.payload.nodes.map(node => [node.id, node])); + const waitingForInitialBootstrap = Boolean( + state.awaitingInitialBootstrap + && !state.bootstrapComplete, + ); + + if (!nodeSizeModeUsesNodeMetrics(state.nodeSizeMode)) { + applyMetricUpdatesInPlace(state.graphData, updatesById); + + return { + isLoading: waitingForInitialBootstrap, + graphIsIndexing: false, + graphIndexProgress: null, + }; + } + + const nextNodes = applyMetricUpdates(state.graphData.nodes, updatesById); + if (!nextNodes.changed) { + return { + graphIsIndexing: false, + graphIndexProgress: null, + }; + } + + return { + graphData: { + ...state.graphData, + nodes: nextNodes.nodes, + }, + isLoading: waitingForInitialBootstrap, + graphIsIndexing: false, + graphIndexProgress: null, + }; +} diff --git a/packages/extension/src/webview/store/messageHandlers/graphDataMessage/payload.ts b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/payload.ts new file mode 100644 index 000000000..eb2ffe0f5 --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/payload.ts @@ -0,0 +1,30 @@ +import type { PartialState, IHandlerContext } from '../../messageTypes'; +import type { GraphDataUpdatedMessage } from './contracts'; +import { shouldSkipDuplicateGraphData } from './duplicate'; + +export function handleGraphDataUpdated( + message: GraphDataUpdatedMessage, + ctx?: Pick, +): PartialState | void { + const state = ctx?.getState(); + if (state && shouldSkipDuplicateGraphData(state, message.payload)) { + return undefined; + } + + const waitingForInitialBootstrap = Boolean( + state?.awaitingInitialBootstrap + && !state.bootstrapComplete, + ); + const initialBootstrapFinished = Boolean( + state?.awaitingInitialBootstrap + && state.bootstrapComplete + ); + + return { + graphData: message.payload, + ...(initialBootstrapFinished ? { awaitingInitialBootstrap: false } : {}), + isLoading: waitingForInitialBootstrap, + graphIsIndexing: false, + graphIndexProgress: null, + }; +} diff --git a/packages/extension/src/webview/store/messages.ts b/packages/extension/src/webview/store/messages.ts index 5708ed726..18f3117f1 100644 --- a/packages/extension/src/webview/store/messages.ts +++ b/packages/extension/src/webview/store/messages.ts @@ -5,6 +5,7 @@ import { handleGraphIndexStatusUpdated, handleGraphControlsUpdated, handleFavoritesUpdated, + handleGraphNodeMetricsUpdated, handleSettingsUpdated, handleLegendsUpdated, handleFilterPatternsUpdated, @@ -50,6 +51,11 @@ export const MESSAGE_HANDLERS: Record< > = { GRAPH_DATA_UPDATED: (msg, ctx) => handleGraphDataUpdated(msg as Extract, ctx), + GRAPH_NODE_METRICS_UPDATED: (msg, ctx) => + handleGraphNodeMetricsUpdated( + msg as Extract, + ctx, + ), APP_BOOTSTRAP_COMPLETE: (msg, ctx) => handleAppBootstrapComplete( msg as Extract, @@ -63,9 +69,10 @@ export const MESSAGE_HANDLERS: Record< handleGraphIndexProgress( msg as Extract ), - GRAPH_CONTROLS_UPDATED: (msg) => + GRAPH_CONTROLS_UPDATED: (msg, ctx) => handleGraphControlsUpdated( - msg as Extract + msg as Extract, + ctx, ), FAVORITES_UPDATED: (msg, ctx) => handleFavoritesUpdated(msg as Extract, ctx), diff --git a/packages/extension/tests/__mocks__/vscode.ts b/packages/extension/tests/__mocks__/vscode.ts index c40ab6ff5..0ee7a620c 100644 --- a/packages/extension/tests/__mocks__/vscode.ts +++ b/packages/extension/tests/__mocks__/vscode.ts @@ -21,6 +21,13 @@ export const commands = { executeCommand: vi.fn(), }; +export class Position { + constructor( + public readonly line: number, + public readonly character: number, + ) {} +} + export const workspace = { getConfiguration: vi.fn(() => ({ get: vi.fn(), diff --git a/packages/extension/tests/acceptance/graphView/vscode.ts b/packages/extension/tests/acceptance/graphView/vscode.ts index 0e8d3dec7..5f4a31feb 100644 --- a/packages/extension/tests/acceptance/graphView/vscode.ts +++ b/packages/extension/tests/acceptance/graphView/vscode.ts @@ -83,7 +83,10 @@ export async function openGraphView(page: Page): Promise { throw lastError; } -export async function waitForGraphFrame(page: Page): Promise { +export async function waitForGraphFrame( + page: Page, + timeoutMs = VSCODE_PLAYWRIGHT_WAIT_TIMEOUT_MS, +): Promise { await expect.poll(async () => { for (const frame of page.frames().filter(candidate => candidate.url().includes('fake.html'))) { if (await isReadyGraphFrame(frame)) { @@ -92,7 +95,7 @@ export async function waitForGraphFrame(page: Page): Promise { } return false; - }, { timeout: VSCODE_PLAYWRIGHT_WAIT_TIMEOUT_MS }).toBe(true); + }, { timeout: timeoutMs }).toBe(true); for (const frame of page.frames().filter(candidate => candidate.url().includes('fake.html'))) { if (await isReadyGraphFrame(frame)) { diff --git a/packages/extension/tests/core/plugins/registry/errorHandling.logsonloadfailureswiththetoignoresreplayreadinessforplugincallsforunknown.test.ts b/packages/extension/tests/core/plugins/registry/errorHandling.logsonloadfailureswiththetoignoresreplayreadinessforplugincallsforunknown.test.ts index eab32c303..93a34c219 100644 --- a/packages/extension/tests/core/plugins/registry/errorHandling.logsonloadfailureswiththetoignoresreplayreadinessforplugincallsforunknown.test.ts +++ b/packages/extension/tests/core/plugins/registry/errorHandling.logsonloadfailureswiththetoignoresreplayreadinessforplugincallsforunknown.test.ts @@ -78,7 +78,9 @@ describe('PluginRegistry error handling', () => { registry.register(plugin); registry.notifyWorkspaceReady({ nodes: [], edges: [] }); - await registry.notifyPreAnalyze([], '/workspace'); + await registry.notifyPreAnalyze([ + { absolutePath: '/workspace/a.test', relativePath: 'a.test', content: 'const x = 1;' }, + ], '/workspace'); registry.notifyPostAnalyze({ nodes: [], edges: [] }); registry.notifyGraphRebuild({ nodes: [], edges: [] }); registry.notifyWebviewReady(); diff --git a/packages/extension/tests/core/plugins/registry/v2.notificationhookstoeventemission.test.ts b/packages/extension/tests/core/plugins/registry/v2.notificationhookstoeventemission.test.ts index c9084cef9..23c80b741 100644 --- a/packages/extension/tests/core/plugins/registry/v2.notificationhookstoeventemission.test.ts +++ b/packages/extension/tests/core/plugins/registry/v2.notificationhookstoeventemission.test.ts @@ -71,7 +71,7 @@ describe('PluginRegistry v2', () => { const { registry } = createConfiguredRegistry(); const plugin = createV2Plugin('notify-all'); const graph: IGraphData = { nodes: [{ id: 'x', label: 'x', color: '#fff' }], edges: [] }; - const files = [{ absolutePath: '/workspace/a.ts', relativePath: 'a.ts', content: 'const x = 1;' }]; + const files = [{ absolutePath: '/workspace/a.test', relativePath: 'a.test', content: 'const x = 1;' }]; registry.register(plugin); registry.notifyWorkspaceReady(graph); diff --git a/packages/extension/tests/extension/commands/navigation.test.ts b/packages/extension/tests/extension/commands/navigation.test.ts index d522d346a..7bdcfa8f7 100644 --- a/packages/extension/tests/extension/commands/navigation.test.ts +++ b/packages/extension/tests/extension/commands/navigation.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as vscode from 'vscode'; + import { getNavCommands } from '../../../src/extension/commands/navigation'; function makeProvider() { diff --git a/packages/extension/tests/extension/graphView/analysis/execution/fixtures.ts b/packages/extension/tests/extension/graphView/analysis/execution/fixtures.ts index 474ef0123..4ed7def36 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/fixtures.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/fixtures.ts @@ -39,15 +39,19 @@ export function createExecutionAnalyzer( export function createExecutionHandlers( overrides: Partial = {}, ) { + let rawGraphData: IGraphData = { nodes: [], edges: [] }; let graphData: IGraphData = { nodes: [], edges: [] }; const handlers: GraphViewAnalysisExecutionHandlers = { isAnalysisStale: vi.fn(() => false), hasWorkspace: vi.fn(() => true), - setRawGraphData: vi.fn(), + setRawGraphData: vi.fn((nextGraphData: IGraphData) => { + rawGraphData = nextGraphData; + }), setGraphData: vi.fn((nextGraphData: IGraphData) => { graphData = nextGraphData; }), + getRawGraphData: vi.fn(() => rawGraphData), getGraphData: vi.fn(() => graphData), sendGraphDataUpdated: vi.fn(), sendDepthState: vi.fn(), diff --git a/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts index 84a81ce83..662516757 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts @@ -95,7 +95,12 @@ describe('graph view analysis execution load', () => { expect(result.shouldDiscover).toBe(false); expect(result.rawGraphData).toEqual(cachedGraph); - expect(loadCachedGraph).toHaveBeenCalledOnce(); + expect(loadCachedGraph).toHaveBeenCalledWith( + [], + new Set(), + expect.any(AbortSignal), + { includeCurrentGitignoreMetadata: true }, + ); expect(analyze).not.toHaveBeenCalled(); expect(refreshIndex).not.toHaveBeenCalled(); expect(handlers.emitDiagnostic).toHaveBeenCalledWith({ @@ -145,7 +150,12 @@ describe('graph view analysis execution load', () => { expect(result.shouldDiscover).toBe(false); expect(result.rawGraphData).toEqual(cachedGraph); - expect(loadCachedGraph).toHaveBeenCalledOnce(); + expect(loadCachedGraph).toHaveBeenCalledWith( + [], + new Set(), + expect.any(AbortSignal), + { includeCurrentGitignoreMetadata: false, warmAnalysis: false }, + ); expect(refreshIndex).not.toHaveBeenCalled(); expect(analyze).not.toHaveBeenCalled(); }); @@ -381,6 +391,39 @@ describe('graph view analysis execution load', () => { }); }); + it('routes incremental refreshes without reading index freshness', async () => { + const incrementalGraph = { + nodes: [{ id: 'src/changed.ts', label: 'src/changed.ts', color: '#ffffff' }], + edges: [], + }; + const getIndexStatus = vi.fn(() => { + throw new Error('incremental refresh should not read index freshness'); + }); + const refreshChangedFiles = vi.fn(async () => incrementalGraph); + const state = createExecutionState({ + mode: 'incremental', + changedFilePaths: ['src/changed.ts'], + analyzer: createExecutionAnalyzer({ + getIndexStatus, + refreshChangedFiles, + }), + analyzerInitialized: true, + }); + + const result = await loadGraphViewRawData( + new AbortController().signal, + state, + createExecutionHandlers().handlers, + ); + + expect(result).toEqual({ + rawGraphData: incrementalGraph, + shouldDiscover: false, + }); + expect(getIndexStatus).not.toHaveBeenCalled(); + expect(refreshChangedFiles).toHaveBeenCalledOnce(); + }); + it('falls back to full analysis for incremental mode when changed-file refresh is unavailable', async () => { const analyzedGraph = { nodes: [{ id: 'src/fallback.ts', label: 'src/fallback.ts', color: '#ffffff' }], diff --git a/packages/extension/tests/extension/graphView/analysis/execution/prepare.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/prepare.test.ts index 0f47d0edd..f69bfd511 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/prepare.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/prepare.test.ts @@ -129,4 +129,21 @@ describe('graph view analysis execution prepare', () => { expect(handlers.computeMergedGroups).toHaveBeenCalledOnce(); expect(handlers.sendGroupsUpdated).toHaveBeenCalledOnce(); }); + + it('skips pre-refresh group publication for incremental analysis', async () => { + const state = createExecutionState({ + analyzer: createExecutionAnalyzer(), + analyzerInitialized: true, + mode: 'incremental', + }); + const { handlers } = createExecutionHandlers(); + + await expect( + prepareGraphViewAnalysis(new AbortController().signal, 1, state, handlers), + ).resolves.toBe(true); + + expect(handlers.computeMergedGroups).not.toHaveBeenCalled(); + expect(handlers.sendGroupsUpdated).not.toHaveBeenCalled(); + expect(handlers.hasWorkspace).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/progress.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/progress.test.ts index bee6d813a..98a02329b 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/progress.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/progress.test.ts @@ -8,8 +8,9 @@ import { createExecutionHandlers } from './fixtures'; describe('graph view analysis execution progress', () => { it('preserves analyzer progress phase labels', () => { const { handlers } = createExecutionHandlers(); + const forwardProgress = createGraphViewAnalysisProgressForwarder('refresh', handlers); - createGraphViewAnalysisProgressForwarder('refresh', handlers)({ + forwardProgress({ phase: 'Saving Graph Cache', current: 2, total: 5, @@ -22,6 +23,66 @@ describe('graph view analysis execution progress', () => { }); }); + it('coalesces dense progress updates while preserving the first and final states', () => { + const { handlers } = createExecutionHandlers(); + const forwardProgress = createGraphViewAnalysisProgressForwarder('refresh', handlers); + + for (let current = 1; current <= 100; current += 1) { + forwardProgress({ + phase: 'Refreshing Index', + current, + total: 100, + }); + } + + expect(handlers.sendIndexProgress).toHaveBeenCalledTimes(21); + expect(handlers.sendIndexProgress).toHaveBeenNthCalledWith(1, { + phase: 'Refreshing Index', + current: 1, + total: 100, + }); + expect(handlers.sendIndexProgress).not.toHaveBeenCalledWith({ + phase: 'Refreshing Index', + current: 2, + total: 100, + }); + expect(handlers.sendIndexProgress).toHaveBeenLastCalledWith({ + phase: 'Refreshing Index', + current: 100, + total: 100, + }); + }); + + it('keeps every progress update for small totals', () => { + const { handlers } = createExecutionHandlers(); + const forwardProgress = createGraphViewAnalysisProgressForwarder('refresh', handlers); + + for (let current = 1; current <= 5; current += 1) { + forwardProgress({ + phase: 'Refreshing Index', + current, + total: 5, + }); + } + + expect(handlers.sendIndexProgress).toHaveBeenCalledTimes(5); + }); + + it('keeps phase changes even when dense progress stays in the same bucket', () => { + const { handlers } = createExecutionHandlers(); + const forwardProgress = createGraphViewAnalysisProgressForwarder('refresh', handlers); + + forwardProgress({ phase: 'Refreshing Index', current: 1, total: 100 }); + forwardProgress({ phase: 'Saving Graph Cache', current: 2, total: 100 }); + + expect(handlers.sendIndexProgress).toHaveBeenCalledTimes(2); + expect(handlers.sendIndexProgress).toHaveBeenLastCalledWith({ + phase: 'Saving Graph Cache', + current: 2, + total: 100, + }); + }); + it('falls back to the mode label when a progress update does not name its phase', () => { const { handlers } = createExecutionHandlers(); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts index 313b757eb..6ece49173 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { IGraphData } from '../../../../../src/shared/graph/contracts'; + import { publishAnalyzedGraph, publishAnalysisFailure, @@ -183,6 +184,428 @@ describe('graph view analysis execution publish', () => { ); }); + it('skips graph-specific publication when an incremental refresh leaves the raw graph unchanged', () => { + const rawGraphData: IGraphData = { + nodes: [{ id: 'src/index.ts', label: 'src/index.ts', color: '#ffffff' }], + edges: [ + { + id: 'src/index.ts->src/view.ts#import', + from: 'src/index.ts', + to: 'src/view.ts', + kind: 'import', + sources: [ + { + id: 'typescript:src/index.ts->src/view.ts', + pluginId: 'typescript', + sourceId: 'src/index.ts->src/view.ts', + label: 'TypeScript import', + }, + ], + }, + ], + }; + const state = createExecutionState({ + mode: 'incremental', + analyzer: createExecutionAnalyzer(), + }); + const sendPluginWebviewInjections = vi.fn(); + const { handlers, getGraphData } = createExecutionHandlers({ + sendPluginExporters: vi.fn(), + sendPluginToolbarActions: vi.fn(), + sendPluginWebviewInjections, + }); + handlers.setRawGraphData(rawGraphData); + handlers.setGraphData(rawGraphData); + vi.mocked(handlers.setRawGraphData).mockClear(); + vi.mocked(handlers.setGraphData).mockClear(); + + publishAnalyzedGraph(state, handlers, rawGraphData, true); + + expect(handlers.setRawGraphData).not.toHaveBeenCalled(); + expect(handlers.setGraphData).not.toHaveBeenCalled(); + expect(handlers.updateViewContext).not.toHaveBeenCalled(); + expect(handlers.applyViewTransform).not.toHaveBeenCalled(); + expect(handlers.computeMergedGroups).not.toHaveBeenCalled(); + expect(handlers.sendGroupsUpdated).not.toHaveBeenCalled(); + expect(handlers.sendGraphDataUpdated).not.toHaveBeenCalled(); + expect(handlers.sendDepthState).toHaveBeenCalledOnce(); + expect(handlers.sendPluginStatuses).toHaveBeenCalledOnce(); + expect(handlers.sendDecorations).toHaveBeenCalledOnce(); + expect(handlers.sendContextMenuItems).toHaveBeenCalledOnce(); + expect(handlers.sendPluginExporters).toHaveBeenCalledOnce(); + expect(handlers.sendPluginToolbarActions).toHaveBeenCalledOnce(); + expect(sendPluginWebviewInjections).toHaveBeenCalledOnce(); + expect(handlers.sendGraphIndexStatusUpdated).toHaveBeenCalledWith( + true, + 'fresh', + 'CodeGraphy index is fresh.', + ); + expect(state.analyzer?.registry.notifyPostAnalyze).toHaveBeenCalledWith( + getGraphData(), + state.disabledPlugins, + ); + expect(handlers.markWorkspaceReady).toHaveBeenCalledWith( + getGraphData(), + state.disabledPlugins, + ); + }); + + it('skips group publication when an incremental refresh only changes node sizing metrics', () => { + const currentGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 100, + churn: 1, + }], + edges: [], + }; + const nextGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 120, + churn: 2, + }], + edges: [], + }; + const state = createExecutionState({ + mode: 'incremental', + analyzer: createExecutionAnalyzer(), + }); + const { handlers } = createExecutionHandlers({ + applyViewTransform: vi.fn(() => { + handlers.setGraphData(nextGraphData); + }), + }); + handlers.setRawGraphData(currentGraphData); + handlers.setGraphData(currentGraphData); + vi.mocked(handlers.setRawGraphData).mockClear(); + vi.mocked(handlers.setGraphData).mockClear(); + + publishAnalyzedGraph(state, handlers, nextGraphData, true); + + expect(handlers.setRawGraphData).toHaveBeenCalledWith(nextGraphData); + expect(handlers.updateViewContext).toHaveBeenCalledOnce(); + expect(handlers.applyViewTransform).toHaveBeenCalledOnce(); + expect(handlers.computeMergedGroups).not.toHaveBeenCalled(); + expect(handlers.sendGroupsUpdated).not.toHaveBeenCalled(); + expect(handlers.sendGraphDataUpdated).toHaveBeenCalledWith(nextGraphData); + }); + + it('sends node metric patches instead of full graph data for metric-only incremental refreshes', () => { + const currentGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 100, + churn: 1, + }], + edges: [], + }; + const nextGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 120, + churn: 2, + }], + edges: [], + }; + const state = createExecutionState({ + mode: 'incremental', + changedFilePaths: ['/workspace/src/index.ts'], + analyzer: createExecutionAnalyzer(), + }); + const sendGraphNodeMetricsUpdated = vi.fn(); + const { handlers } = createExecutionHandlers({ + applyViewTransform: vi.fn(() => { + handlers.setGraphData(nextGraphData); + }), + sendGraphNodeMetricsUpdated, + }); + handlers.setRawGraphData(currentGraphData); + handlers.setGraphData(currentGraphData); + vi.mocked(handlers.setRawGraphData).mockClear(); + vi.mocked(handlers.setGraphData).mockClear(); + + publishAnalyzedGraph(state, handlers, nextGraphData, true); + + expect(sendGraphNodeMetricsUpdated).toHaveBeenCalledWith([ + { id: 'src/index.ts', fileSize: 120, churn: 2 }, + ]); + expect(handlers.sendGraphDataUpdated).not.toHaveBeenCalled(); + }); + + it('skips static graph-state broadcasts for metric-only incremental patches', () => { + const currentGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 100, + churn: 1, + }], + edges: [], + }; + const nextGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 120, + churn: 2, + }], + edges: [], + }; + const state = createExecutionState({ + mode: 'incremental', + changedFilePaths: ['/workspace/src/index.ts'], + analyzer: createExecutionAnalyzer(), + }); + const sendGraphNodeMetricsUpdated = vi.fn(); + const sendPluginExporters = vi.fn(); + const sendPluginToolbarActions = vi.fn(); + const sendPluginWebviewInjections = vi.fn(); + const { handlers, getGraphData } = createExecutionHandlers({ + applyViewTransform: vi.fn(() => { + handlers.setGraphData(nextGraphData); + }), + sendGraphNodeMetricsUpdated, + sendPluginExporters, + sendPluginToolbarActions, + sendPluginWebviewInjections, + }); + handlers.setRawGraphData(currentGraphData); + handlers.setGraphData(currentGraphData); + vi.mocked(handlers.setRawGraphData).mockClear(); + vi.mocked(handlers.setGraphData).mockClear(); + + publishAnalyzedGraph(state, handlers, nextGraphData, true); + + expect(sendGraphNodeMetricsUpdated).toHaveBeenCalledOnce(); + expect(handlers.sendDepthState).not.toHaveBeenCalled(); + expect(handlers.sendPluginStatuses).not.toHaveBeenCalled(); + expect(handlers.sendDecorations).not.toHaveBeenCalled(); + expect(handlers.sendContextMenuItems).not.toHaveBeenCalled(); + expect(sendPluginExporters).not.toHaveBeenCalled(); + expect(sendPluginToolbarActions).not.toHaveBeenCalled(); + expect(handlers.sendGraphViewContributionStatuses).not.toHaveBeenCalled(); + expect(sendPluginWebviewInjections).not.toHaveBeenCalled(); + expect(handlers.sendGraphIndexStatusUpdated).toHaveBeenCalledWith( + true, + 'fresh', + 'CodeGraphy index is fresh.', + ); + expect(state.analyzer?.registry.notifyPostAnalyze).toHaveBeenCalledWith( + getGraphData(), + state.disabledPlugins, + ); + expect(handlers.markWorkspaceReady).toHaveBeenCalledWith( + getGraphData(), + state.disabledPlugins, + ); + }); + + it('falls back to full graph publication when changed node metrics also change edges', () => { + const currentGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 100, + }], + edges: [], + }; + const nextGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 120, + }], + edges: [{ + id: 'src/index.ts->src/view.ts#import', + from: 'src/index.ts', + to: 'src/view.ts', + kind: 'import', + sources: [], + }], + }; + const state = createExecutionState({ + mode: 'incremental', + changedFilePaths: ['/workspace/src/index.ts'], + analyzer: createExecutionAnalyzer(), + }); + const sendGraphNodeMetricsUpdated = vi.fn(); + const { handlers } = createExecutionHandlers({ + applyViewTransform: vi.fn(() => { + handlers.setGraphData(nextGraphData); + }), + sendGraphNodeMetricsUpdated, + }); + handlers.setRawGraphData(currentGraphData); + handlers.setGraphData(currentGraphData); + vi.mocked(handlers.setRawGraphData).mockClear(); + vi.mocked(handlers.setGraphData).mockClear(); + + publishAnalyzedGraph(state, handlers, nextGraphData, true); + + expect(sendGraphNodeMetricsUpdated).not.toHaveBeenCalled(); + expect(handlers.sendGraphDataUpdated).toHaveBeenCalledWith(nextGraphData); + }); + + it('falls back to full graph publication when an unrelated edge changes during a metric update', () => { + const currentGraphData: IGraphData = { + nodes: [ + { + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 100, + }, + { + id: 'src/other.ts', + label: 'other.ts', + color: '#ffffff', + }, + { + id: 'src/leaf.ts', + label: 'leaf.ts', + color: '#ffffff', + }, + ], + edges: [{ + id: 'src/other.ts->src/leaf.ts#import', + from: 'src/other.ts', + to: 'src/leaf.ts', + kind: 'import', + sources: [], + }], + }; + const nextGraphData: IGraphData = { + nodes: [ + { + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 120, + }, + { + id: 'src/other.ts', + label: 'other.ts', + color: '#ffffff', + }, + { + id: 'src/leaf.ts', + label: 'leaf.ts', + color: '#ffffff', + }, + ], + edges: [{ + id: 'src/other.ts->src/leaf.ts#reference', + from: 'src/other.ts', + to: 'src/leaf.ts', + kind: 'reference', + sources: [], + }], + }; + const state = createExecutionState({ + mode: 'incremental', + changedFilePaths: ['/workspace/src/index.ts'], + analyzer: createExecutionAnalyzer(), + }); + const sendGraphNodeMetricsUpdated = vi.fn(); + const { handlers } = createExecutionHandlers({ + applyViewTransform: vi.fn(() => { + handlers.setGraphData(nextGraphData); + }), + sendGraphNodeMetricsUpdated, + }); + handlers.setRawGraphData(currentGraphData); + handlers.setGraphData(currentGraphData); + vi.mocked(handlers.setRawGraphData).mockClear(); + vi.mocked(handlers.setGraphData).mockClear(); + + publishAnalyzedGraph(state, handlers, nextGraphData, true); + + expect(sendGraphNodeMetricsUpdated).not.toHaveBeenCalled(); + expect(handlers.sendGraphDataUpdated).toHaveBeenCalledWith(nextGraphData); + }); + + it('skips unrelated edge serialization when a changed node metric already differs', () => { + let serializedUnrelatedEdgeCount = 0; + const affectedEdge = { + id: 'src/index.ts->src/view.ts#import', + from: 'src/index.ts', + to: 'src/view.ts', + kind: 'import', + sources: [], + } satisfies IGraphData['edges'][number]; + const unrelatedEdge = { + id: 'src/other.ts->src/leaf.ts#import', + from: 'src/other.ts', + to: 'src/leaf.ts', + kind: 'import', + sources: [], + toJSON: () => { + serializedUnrelatedEdgeCount += 1; + return { + id: 'src/other.ts->src/leaf.ts#import', + from: 'src/other.ts', + to: 'src/leaf.ts', + kind: 'import', + sources: [], + }; + }, + } as IGraphData['edges'][number] & { toJSON(): unknown }; + const currentGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 100, + }], + edges: [affectedEdge, unrelatedEdge], + }; + const nextGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 120, + }], + edges: [affectedEdge, unrelatedEdge], + }; + const state = createExecutionState({ + mode: 'incremental', + changedFilePaths: ['/workspace/src/index.ts'], + analyzer: createExecutionAnalyzer(), + }); + const sendGraphNodeMetricsUpdated = vi.fn(); + const { handlers } = createExecutionHandlers({ + applyViewTransform: vi.fn(() => { + handlers.setGraphData(nextGraphData); + }), + sendGraphNodeMetricsUpdated, + }); + handlers.setRawGraphData(currentGraphData); + handlers.setGraphData(currentGraphData); + vi.mocked(handlers.setRawGraphData).mockClear(); + vi.mocked(handlers.setGraphData).mockClear(); + + publishAnalyzedGraph(state, handlers, nextGraphData, true); + + expect(serializedUnrelatedEdgeCount).toBe(0); + expect(sendGraphNodeMetricsUpdated).toHaveBeenCalledWith([ + { id: 'src/index.ts', fileSize: 120, churn: undefined }, + ]); + }); + it('publishes the transformed graph without post-analyze hooks when no analyzer is available', () => { const rawGraphData: IGraphData = { nodes: [{ id: 'src/index.ts', label: 'src/index.ts', color: '#ffffff' }], diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/collections.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/collections.test.ts new file mode 100644 index 000000000..8510bd52d --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/collections.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { + compareGraphArrayValues, + compareGraphRecordValues, +} from '../../../../../../../src/extension/graphView/analysis/execution/publish/equality/collections'; + +const comparePrimitiveValue = (left: unknown, right: unknown) => Object.is(left, right); + +describe('extension/graphView/analysis/execution/publish/equality/collections', () => { + it('compares arrays by length and every indexed value', () => { + expect(compareGraphArrayValues([1, 2], [1, 2], comparePrimitiveValue)).toBe(true); + expect(compareGraphArrayValues([1, 2], [1], comparePrimitiveValue)).toBe(false); + expect(compareGraphArrayValues([1], [1, 2], comparePrimitiveValue)).toBe(false); + expect(compareGraphArrayValues([1, 2], [1, 3], comparePrimitiveValue)).toBe(false); + }); + + it('distinguishes one-sided arrays from non-array pairs', () => { + expect(compareGraphArrayValues([1], { 0: 1 }, comparePrimitiveValue)).toBe(false); + expect(compareGraphArrayValues([1], '1', comparePrimitiveValue)).toBe(false); + expect(compareGraphArrayValues('1', [1], comparePrimitiveValue)).toBe(false); + expect(compareGraphArrayValues('left', 'right', comparePrimitiveValue)).toBeUndefined(); + }); + + it('compares records by every key from both records', () => { + expect(compareGraphRecordValues( + { id: 'src/a.ts', kind: 'file' }, + { kind: 'file', id: 'src/a.ts' }, + comparePrimitiveValue, + )).toBe(true); + expect(compareGraphRecordValues({ id: 'src/a.ts' }, { id: 'src/b.ts' }, comparePrimitiveValue)).toBe(false); + expect(compareGraphRecordValues({ id: 'src/a.ts' }, { id: 'src/a.ts', extra: true }, comparePrimitiveValue)).toBe(false); + }); + + it('rejects nulls and one-sided records', () => { + expect(compareGraphRecordValues(null, {}, comparePrimitiveValue)).toBe(false); + expect(compareGraphRecordValues({ id: 'src/a.ts' }, ['src/a.ts'], comparePrimitiveValue)).toBe(false); + }); +}); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/data.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/data.test.ts new file mode 100644 index 000000000..89454094d --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/data.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../../../../src/shared/graph/contracts'; +import { areGraphDataEqualIgnoringNodeMetrics } from '../../../../../../../src/extension/graphView/analysis/execution/publish/equality/data'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createEdge(overrides: Partial = {}): IGraphEdge { + return { + id: 'src/a.ts->src/b.ts#import', + from: 'src/a.ts', + to: 'src/b.ts', + kind: 'import', + sources: [], + ...overrides, + }; +} + +function createGraph(overrides: Partial = {}): IGraphData { + return { + nodes: [createNode()], + edges: [createEdge()], + ...overrides, + }; +} + +function createEmptyTripWireArray(message: string): T[] { + return new Proxy([], { + get(target, property, receiver) { + if (property === '0') { + throw new Error(message); + } + + return Reflect.get(target, property, receiver); + }, + }); +} + +describe('extension/graphView/analysis/execution/publish/equality/data', () => { + it('treats matching graphs as equal while ignoring node metrics', () => { + expect( + areGraphDataEqualIgnoringNodeMetrics( + createGraph({ nodes: [createNode({ churn: 1, fileSize: 10 })] }), + createGraph({ nodes: [createNode({ churn: 9, fileSize: 90 })] }), + ), + ).toBe(true); + }); + + it('rejects graphs when only the next graph has an extra node', () => { + expect( + areGraphDataEqualIgnoringNodeMetrics( + createGraph(), + createGraph({ nodes: [createNode(), createNode({ id: 'src/b.ts' })] }), + ), + ).toBe(false); + }); + + it('rejects graphs when only the next graph has an extra edge', () => { + expect( + areGraphDataEqualIgnoringNodeMetrics( + createGraph(), + createGraph({ edges: [createEdge(), createEdge({ id: 'src/b.ts->src/c.ts#import' })] }), + ), + ).toBe(false); + }); + + it('rejects non-metric node differences', () => { + expect( + areGraphDataEqualIgnoringNodeMetrics( + createGraph(), + createGraph({ nodes: [createNode({ label: 'renamed.ts' })] }), + ), + ).toBe(false); + }); + + it('rejects edge differences', () => { + expect( + areGraphDataEqualIgnoringNodeMetrics( + createGraph(), + createGraph({ edges: [createEdge({ kind: 'call' })] }), + ), + ).toBe(false); + }); + + it('does not inspect node slots when both graphs have no nodes', () => { + expect( + areGraphDataEqualIgnoringNodeMetrics( + createGraph({ + nodes: createEmptyTripWireArray('empty node arrays should not read item zero'), + edges: [], + }), + createGraph({ + nodes: createEmptyTripWireArray('empty node arrays should not read item zero'), + edges: [], + }), + ), + ).toBe(true); + }); + + it('does not inspect edge slots when both graphs have no edges', () => { + expect( + areGraphDataEqualIgnoringNodeMetrics( + createGraph({ + nodes: [], + edges: createEmptyTripWireArray('empty edge arrays should not read item zero'), + }), + createGraph({ + nodes: [], + edges: createEmptyTripWireArray('empty edge arrays should not read item zero'), + }), + ), + ).toBe(true); + }); +}); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/node.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/node.test.ts new file mode 100644 index 000000000..3357b536e --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/node.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphNode } from '../../../../../../../src/shared/graph/contracts'; +import { areNodesEqualIgnoringMetrics } from '../../../../../../../src/extension/graphView/analysis/execution/publish/equality/node'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +describe('extension/graphView/analysis/execution/publish/equality/node', () => { + it('treats the same node object as equal', () => { + const node = createNode(); + Object.defineProperty(node, 'label', { + enumerable: true, + get: () => { + throw new Error('same-node identity should not read node fields'); + }, + }); + + expect(areNodesEqualIgnoringMetrics(node, node)).toBe(true); + }); + + it('ignores file size and churn metric differences', () => { + expect( + areNodesEqualIgnoringMetrics( + createNode({ churn: 1, fileSize: 10 }), + createNode({ churn: 4, fileSize: 20 }), + ), + ).toBe(true); + }); + + it('rejects non-metric node differences', () => { + expect( + areNodesEqualIgnoringMetrics( + createNode({ label: 'a.ts' }), + createNode({ label: 'renamed.ts' }), + ), + ).toBe(false); + }); +}); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/payload.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/payload.test.ts new file mode 100644 index 000000000..2f55e8bf7 --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/payload.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../../../../src/shared/graph/contracts'; +import { areGraphDataPayloadsEqual } from '../../../../../../../src/extension/graphView/analysis/execution/publish/equality/payload'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createEdge(overrides: Partial = {}): IGraphEdge { + return { + id: 'src/a.ts->src/b.ts#import', + from: 'src/a.ts', + to: 'src/b.ts', + kind: 'import', + sources: [], + ...overrides, + }; +} + +function createGraph(overrides: Partial = {}): IGraphData { + return { + nodes: [createNode()], + edges: [createEdge()], + ...overrides, + }; +} + +function withStableJson(items: T, jsonValue: unknown): T { + Object.defineProperty(items, 'toJSON', { + value: () => jsonValue, + }); + return items; +} + +describe('extension/graphView/analysis/execution/publish/equality/payload', () => { + it('treats the same graph payload object as equal without reading fields', () => { + const graph = createGraph(); + Object.defineProperty(graph, 'nodes', { + enumerable: true, + get: () => { + throw new Error('same-payload identity should not read graph fields'); + }, + }); + + expect(areGraphDataPayloadsEqual(graph, graph)).toBe(true); + }); + + it('rejects payloads with different node or edge counts', () => { + expect( + areGraphDataPayloadsEqual( + createGraph({ nodes: [createNode(), createNode({ id: 'src/b.ts' })] }), + createGraph(), + ), + ).toBe(false); + expect( + areGraphDataPayloadsEqual( + createGraph({ edges: [] }), + createGraph(), + ), + ).toBe(false); + }); + + it('rejects count-only differences before serializing payloads', () => { + expect( + areGraphDataPayloadsEqual( + createGraph({ + nodes: withStableJson([createNode(), createNode({ id: 'src/b.ts' })], ['same nodes']), + }), + createGraph({ + nodes: withStableJson([createNode()], ['same nodes']), + }), + ), + ).toBe(false); + expect( + areGraphDataPayloadsEqual( + createGraph({ + edges: withStableJson([], ['same edges']), + }), + createGraph({ + edges: withStableJson([createEdge()], ['same edges']), + }), + ), + ).toBe(false); + }); + + it('compares serialized graph payloads when counts match', () => { + expect(areGraphDataPayloadsEqual(createGraph(), createGraph())).toBe(true); + expect( + areGraphDataPayloadsEqual( + createGraph(), + createGraph({ nodes: [createNode({ label: 'renamed.ts' })] }), + ), + ).toBe(false); + }); + + it('rejects payloads that cannot be serialized', () => { + const circularGraph = createGraph(); + (circularGraph.nodes[0] as IGraphNode & { graph?: IGraphData }).graph = circularGraph; + + expect(areGraphDataPayloadsEqual(circularGraph, createGraph())).toBe(false); + }); +}); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/values.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/values.test.ts new file mode 100644 index 000000000..b7104f8d8 --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/values.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { areGraphValuesEqual } from '../../../../../../../src/extension/graphView/analysis/execution/publish/equality/values'; + +describe('extension/graphView/analysis/execution/publish/equality/values', () => { + it('treats equal nested array graph values as equal', () => { + expect( + areGraphValuesEqual( + ['src/a.ts', { metrics: [12, 4] }], + ['src/a.ts', { metrics: [12, 4] }], + ), + ).toBe(true); + }); +}); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/groupInputs.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/groupInputs.test.ts new file mode 100644 index 000000000..df81f003b --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/groupInputs.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData, IGraphNode } from '../../../../../../src/shared/graph/contracts'; +import { doGraphViewGroupsNeedRecompute } from '../../../../../../src/extension/graphView/analysis/execution/publish/groupInputs'; + +type GraphNodeSymbol = NonNullable; + +function createSymbol(overrides: Partial = {}): GraphNodeSymbol { + return { + id: 'src/a.ts#Component', + name: 'Component', + kind: 'class', + filePath: 'src/a.ts', + pluginKind: 'typescript:class', + source: 'typescript', + language: 'typescript', + ...overrides, + }; +} + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createGraph(nodes: IGraphNode[]): IGraphData { + return { + nodes, + edges: [], + }; +} + +describe('extension/graphView/analysis/execution/publish/groupInputs', () => { + it('keeps graph view groups when node group inputs are unchanged', () => { + expect( + doGraphViewGroupsNeedRecompute( + createGraph([ + createNode({ id: 'src/a.ts', symbol: createSymbol({ filePath: 'src/a.ts' }) }), + createNode({ id: 'src/b.ts', symbol: createSymbol({ id: 'src/b.ts#Component', filePath: 'src/b.ts' }) }), + ]), + createGraph([ + createNode({ id: 'src/b.ts', symbol: createSymbol({ id: 'src/b.ts#Component', filePath: 'src/b.ts' }) }), + createNode({ id: 'src/a.ts', symbol: createSymbol({ filePath: 'src/a.ts' }) }), + ]), + ), + ).toBe(false); + }); + + it('recomputes graph view groups when the next graph adds a node', () => { + expect( + doGraphViewGroupsNeedRecompute( + createGraph([createNode()]), + createGraph([createNode(), createNode({ id: 'src/b.ts' })]), + ), + ).toBe(true); + }); + + it('recomputes graph view groups when a current node id is absent', () => { + expect( + doGraphViewGroupsNeedRecompute( + createGraph([createNode({ id: 'src/a.ts' })]), + createGraph([createNode({ id: 'src/b.ts' })]), + ), + ).toBe(true); + }); + + it('recomputes graph view groups when node type changes', () => { + expect( + doGraphViewGroupsNeedRecompute( + createGraph([createNode({ nodeType: 'file' })]), + createGraph([createNode({ nodeType: 'symbol' })]), + ), + ).toBe(true); + }); + + it('recomputes graph view groups when symbol presence changes', () => { + expect( + doGraphViewGroupsNeedRecompute( + createGraph([createNode()]), + createGraph([createNode({ symbol: createSymbol() })]), + ), + ).toBe(true); + }); + + it.each([ + ['kind', { kind: 'function' }], + ['plugin kind', { pluginKind: 'typescript:function' }], + ['source', { source: 'markdown' }], + ['language', { language: 'markdown' }], + ['file path', { filePath: 'src/b.ts' }], + ] as const)('recomputes graph view groups when symbol %s changes', (_field, symbolOverrides) => { + expect( + doGraphViewGroupsNeedRecompute( + createGraph([createNode({ symbol: createSymbol() })]), + createGraph([createNode({ symbol: createSymbol(symbolOverrides) })]), + ), + ).toBe(true); + }); + + it('keeps graph view groups when symbol display details change', () => { + expect( + doGraphViewGroupsNeedRecompute( + createGraph([createNode({ symbol: createSymbol() })]), + createGraph([createNode({ + symbol: createSymbol({ + id: 'src/a.ts#RenamedComponent', + name: 'RenamedComponent', + range: { startLine: 10, endLine: 20 }, + signature: 'class RenamedComponent', + }), + })]), + ), + ).toBe(false); + }); +}); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/messages.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/messages.test.ts new file mode 100644 index 000000000..efe30ac6f --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/messages.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../../../src/shared/graph/contracts'; +import type { + GraphViewAnalysisExecutionHandlers, + GraphViewAnalysisExecutionState, + GraphViewAnalysisMode, +} from '../../../../../../src/extension/graphView/analysis/execution'; +import type { GraphPublicationPlan } from '../../../../../../src/extension/graphView/analysis/execution/publish/plan'; +import { + publishGraphDataMessage, + publishRawGraphUpdate, + publishStaticGraphMessages, +} from '../../../../../../src/extension/graphView/analysis/execution/publish/messages'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createEdge(overrides: Partial = {}): IGraphEdge { + return { + id: 'src/a.ts->src/b.ts#import', + from: 'src/a.ts', + to: 'src/b.ts', + kind: 'import', + sources: [], + ...overrides, + }; +} + +function createGraph(overrides: Partial = {}): IGraphData { + return { + nodes: [createNode()], + edges: [createEdge()], + ...overrides, + }; +} + +function createState(mode: GraphViewAnalysisMode): GraphViewAnalysisExecutionState { + return { + analyzer: undefined, + analyzerInitialized: false, + analyzerInitPromise: undefined, + mode, + filterPatterns: [], + disabledPlugins: new Set(), + }; +} + +function createHandlers( + overrides: Partial = {}, +): GraphViewAnalysisExecutionHandlers { + return { + setRawGraphData: vi.fn(), + updateViewContext: vi.fn(), + applyViewTransform: vi.fn(), + computeMergedGroups: vi.fn(), + sendGroupsUpdated: vi.fn(), + sendDepthState: vi.fn(), + sendPluginStatuses: vi.fn(), + sendDecorations: vi.fn(), + sendContextMenuItems: vi.fn(), + sendGraphDataUpdated: vi.fn(), + isAnalysisStale: vi.fn(), + hasWorkspace: vi.fn(), + setGraphData: vi.fn(), + getGraphData: vi.fn(), + sendGraphIndexStatusUpdated: vi.fn(), + markWorkspaceReady: vi.fn(), + isAbortError: vi.fn(), + logError: vi.fn(), + ...overrides, + } as GraphViewAnalysisExecutionHandlers; +} + +function createPlan(overrides: Partial = {}): GraphPublicationPlan { + return { + currentRawGraphData: undefined, + metricOnlyUpdate: undefined, + reuseCurrentGraphPublication: false, + shouldSendMetricPatch: false, + ...overrides, + }; +} + +describe('extension/graphView/analysis/execution/publish/messages', () => { + it('skips raw graph publication when the current publication can be reused', () => { + const handlers = createHandlers(); + + publishRawGraphUpdate( + createState('incremental'), + handlers, + createGraph(), + createPlan({ reuseCurrentGraphPublication: true }), + ); + + expect(handlers.setRawGraphData).not.toHaveBeenCalled(); + expect(handlers.computeMergedGroups).not.toHaveBeenCalled(); + }); + + it('publishes groups outside incremental mode even when group inputs match', () => { + const currentGraph = createGraph(); + const handlers = createHandlers(); + + publishRawGraphUpdate( + createState('refresh'), + handlers, + createGraph(), + createPlan({ currentRawGraphData: currentGraph }), + ); + + expect(handlers.setRawGraphData).toHaveBeenCalledOnce(); + expect(handlers.computeMergedGroups).toHaveBeenCalledOnce(); + expect(handlers.sendGroupsUpdated).toHaveBeenCalledOnce(); + }); + + it('skips group publication for unchanged incremental group inputs', () => { + const currentGraph = createGraph(); + const handlers = createHandlers(); + + publishRawGraphUpdate( + createState('incremental'), + handlers, + createGraph(), + createPlan({ currentRawGraphData: currentGraph }), + ); + + expect(handlers.setRawGraphData).toHaveBeenCalledOnce(); + expect(handlers.computeMergedGroups).not.toHaveBeenCalled(); + expect(handlers.sendGroupsUpdated).not.toHaveBeenCalled(); + }); + + it('publishes static graph messages without optional contribution broadcasters', () => { + const handlers = createHandlers(); + + expect(() => publishStaticGraphMessages(handlers)).not.toThrow(); + expect(handlers.sendDepthState).toHaveBeenCalledOnce(); + expect(handlers.sendPluginStatuses).toHaveBeenCalledOnce(); + expect(handlers.sendContextMenuItems).toHaveBeenCalledOnce(); + }); + + it('sends metric patches instead of full graph data when the plan enables patches', () => { + const sendGraphNodeMetricsUpdated = vi.fn(); + const handlers = createHandlers({ sendGraphNodeMetricsUpdated }); + const metricOnlyUpdate = [{ id: 'src/a.ts', fileSize: 15, churn: 2 }]; + + publishGraphDataMessage( + handlers, + createGraph(), + createPlan({ metricOnlyUpdate, shouldSendMetricPatch: true }), + ); + + expect(sendGraphNodeMetricsUpdated).toHaveBeenCalledWith(metricOnlyUpdate); + expect(handlers.sendGraphDataUpdated).not.toHaveBeenCalled(); + }); + + it('sends full graph data when metric patch updates are absent', () => { + const sendGraphNodeMetricsUpdated = vi.fn(); + const handlers = createHandlers({ sendGraphNodeMetricsUpdated }); + const graphData = createGraph(); + + publishGraphDataMessage( + handlers, + graphData, + createPlan({ shouldSendMetricPatch: true }), + ); + + expect(sendGraphNodeMetricsUpdated).not.toHaveBeenCalled(); + expect(handlers.sendGraphDataUpdated).toHaveBeenCalledWith(graphData); + }); + + it('sends full graph data when metric patch publication is disabled', () => { + const sendGraphNodeMetricsUpdated = vi.fn(); + const handlers = createHandlers({ sendGraphNodeMetricsUpdated }); + const graphData = createGraph(); + const metricOnlyUpdate = [{ id: 'src/a.ts', fileSize: 15, churn: 2 }]; + + publishGraphDataMessage( + handlers, + graphData, + createPlan({ metricOnlyUpdate, shouldSendMetricPatch: false }), + ); + + expect(sendGraphNodeMetricsUpdated).not.toHaveBeenCalled(); + expect(handlers.sendGraphDataUpdated).toHaveBeenCalledWith(graphData); + }); + + it('does not throw when an inconsistent metric patch plan lacks a sender', () => { + expect(() => publishGraphDataMessage( + createHandlers(), + createGraph(), + createPlan({ + metricOnlyUpdate: [{ id: 'src/a.ts', fileSize: 15, churn: 2 }], + shouldSendMetricPatch: true, + }), + )).not.toThrow(); + }); +}); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/changedPaths.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/changedPaths.test.ts new file mode 100644 index 000000000..96a2b6c27 --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/changedPaths.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData, IGraphNode } from '../../../../../../../src/shared/graph/contracts'; +import { + collectChangedPathNodes, + hasChangedNodeMetricDifference, +} from '../../../../../../../src/extension/graphView/analysis/execution/publish/metrics/changedPaths'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createGraph(nodes: IGraphNode[]): IGraphData { + return { + nodes, + edges: [], + }; +} + +describe('extension/graphView/analysis/execution/publish/metrics/changedPaths', () => { + it('returns false when no changed file paths are available', () => { + expect( + hasChangedNodeMetricDifference( + createGraph([createNode({ fileSize: 10 })]), + createGraph([createNode({ fileSize: 20 })]), + undefined, + ), + ).toBe(false); + }); + + it('detects file size metric differences for changed nodes', () => { + expect( + hasChangedNodeMetricDifference( + createGraph([createNode({ fileSize: 10, churn: 1 })]), + createGraph([createNode({ fileSize: 20, churn: 1 })]), + ['src/a.ts'], + ), + ).toBe(true); + }); + + it('detects churn metric differences for changed nodes', () => { + expect( + hasChangedNodeMetricDifference( + createGraph([createNode({ fileSize: 10, churn: 1 })]), + createGraph([createNode({ fileSize: 10, churn: 2 })]), + ['src/a.ts'], + ), + ).toBe(true); + }); + + it('ignores changed paths when either graph is missing the node', () => { + expect( + hasChangedNodeMetricDifference( + createGraph([createNode({ id: 'src/a.ts', fileSize: 10 })]), + createGraph([createNode({ id: 'src/b.ts', fileSize: 20 })]), + ['src/a.ts'], + ), + ).toBe(false); + }); + + it('returns false when changed node metrics are unchanged', () => { + expect( + hasChangedNodeMetricDifference( + createGraph([createNode({ fileSize: 10, churn: 1 })]), + createGraph([createNode({ fileSize: 10, churn: 1 })]), + ['src/a.ts'], + ), + ).toBe(false); + }); + + it('collects nodes whose id exactly matches a changed path', () => { + const matchingNode = createNode({ id: 'src/a.ts' }); + const unrelatedNode = createNode({ id: 'src/b.ts' }); + + expect(collectChangedPathNodes( + createGraph([matchingNode, unrelatedNode]), + ['src/a.ts', 'src/c.ts'], + )).toEqual([matchingNode]); + }); + + it('collects nodes whose id matches a workspace-prefixed changed path', () => { + const matchingNode = createNode({ id: 'src/a.ts' }); + const unrelatedNode = createNode({ id: 'src/b.ts' }); + + expect(collectChangedPathNodes( + createGraph([matchingNode, unrelatedNode]), + ['/workspace/project/src/a.ts'], + )).toEqual([matchingNode]); + }); + + it('collects nodes from Windows-style changed paths', () => { + const matchingNode = createNode({ id: 'src/a.ts' }); + const unrelatedNode = createNode({ id: 'src/b.ts' }); + + expect(collectChangedPathNodes( + createGraph([matchingNode, unrelatedNode]), + ['C:\\workspace\\project\\src\\a.ts'], + )).toEqual([matchingNode]); + }); + + it('collects symbol nodes by symbol file path', () => { + const symbolNode = createNode({ + id: 'src/a.ts#Component', + nodeType: 'symbol', + symbol: { + id: 'src/a.ts#Component', + name: 'Component', + kind: 'class', + filePath: 'src/a.ts', + }, + }); + const unrelatedNode = createNode({ id: 'src/b.ts#Component', nodeType: 'symbol' }); + + expect(collectChangedPathNodes( + createGraph([symbolNode, unrelatedNode]), + ['src/a.ts'], + )).toEqual([symbolNode]); + }); +}); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/patch.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/patch.test.ts new file mode 100644 index 000000000..4e28d2fff --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/patch.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../../../../src/shared/graph/contracts'; +import { createMetricOnlyGraphUpdate } from '../../../../../../../src/extension/graphView/analysis/execution/publish/metrics/patch'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createEdge(overrides: Partial = {}): IGraphEdge { + return { + id: 'src/a.ts->src/b.ts#import', + from: 'src/a.ts', + to: 'src/b.ts', + kind: 'import', + sources: [], + ...overrides, + }; +} + +function createGraph(overrides: Partial = {}): IGraphData { + return { + nodes: [createNode({ fileSize: 10, churn: 1 })], + edges: [createEdge()], + ...overrides, + }; +} + +describe('extension/graphView/analysis/execution/publish/metrics/patch', () => { + it('returns metric patches for metric-only changed path updates', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph(), + createGraph({ nodes: [createNode({ fileSize: 15, churn: 2 })] }), + ['src/a.ts'], + ), + ).toEqual([{ id: 'src/a.ts', fileSize: 15, churn: 2 }]); + }); + + it('returns undefined when no current graph exists', () => { + expect( + createMetricOnlyGraphUpdate( + undefined, + createGraph(), + ['src/a.ts'], + ), + ).toBeUndefined(); + }); + + it('returns undefined when no changed paths are available', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph(), + createGraph({ nodes: [createNode({ fileSize: 15 })] }), + [], + ), + ).toBeUndefined(); + }); + + it('returns undefined when node counts changed', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph(), + createGraph({ nodes: [createNode(), createNode({ id: 'src/b.ts' })] }), + ['src/a.ts'], + ), + ).toBeUndefined(); + }); + + it('returns undefined when edge counts changed', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph(), + createGraph({ edges: [createEdge(), createEdge({ id: 'src/b.ts->src/c.ts#import' })] }), + ['src/a.ts'], + ), + ).toBeUndefined(); + }); + + it('returns undefined when graph differences are not metric-only', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph(), + createGraph({ nodes: [createNode({ label: 'renamed.ts', fileSize: 15 })] }), + ['src/a.ts'], + ), + ).toBeUndefined(); + }); + + it('returns undefined when no changed path nodes are present', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph(), + createGraph({ nodes: [createNode({ fileSize: 15 })] }), + ['src/missing.ts'], + ), + ).toBeUndefined(); + }); + + it('returns undefined when changed path node sets differ', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph({ + nodes: [ + createNode({ + id: 'src/a.ts#Component', + nodeType: 'symbol', + symbol: { + id: 'src/a.ts#Component', + name: 'Component', + kind: 'class', + filePath: 'src/a.ts', + }, + }), + ], + }), + createGraph({ + nodes: [createNode({ id: 'src/a.ts#Component', nodeType: 'symbol' })], + }), + ['src/a.ts'], + ), + ).toBeUndefined(); + }); + + it('returns undefined when changed node metrics are unchanged', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph(), + createGraph(), + ['src/a.ts'], + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/updates.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/updates.test.ts new file mode 100644 index 000000000..d13ef1ca8 --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/updates.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphNode } from '../../../../../../../src/shared/graph/contracts'; +import { + collectMetricOnlyGraphUpdates, + createNodeMap, +} from '../../../../../../../src/extension/graphView/analysis/execution/publish/metrics/updates'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +describe('extension/graphView/analysis/execution/publish/metrics/updates', () => { + it('indexes graph nodes by id', () => { + const firstNode = createNode({ id: 'src/a.ts' }); + const secondNode = createNode({ id: 'src/b.ts' }); + + expect(createNodeMap([firstNode, secondNode])).toEqual(new Map([ + ['src/a.ts', firstNode], + ['src/b.ts', secondNode], + ])); + }); + + it('returns metric patches for changed file size and churn values', () => { + expect( + collectMetricOnlyGraphUpdates( + [ + createNode({ id: 'src/a.ts', fileSize: 10, churn: 1 }), + createNode({ id: 'src/b.ts', fileSize: 20, churn: 2 }), + ], + createNodeMap([ + createNode({ id: 'src/a.ts', fileSize: 15, churn: 1 }), + createNode({ id: 'src/b.ts', fileSize: 20, churn: 3 }), + ]), + ), + ).toEqual([ + { id: 'src/a.ts', fileSize: 15, churn: 1 }, + { id: 'src/b.ts', fileSize: 20, churn: 3 }, + ]); + }); + + it('returns undefined when no node metrics changed', () => { + expect( + collectMetricOnlyGraphUpdates( + [createNode({ fileSize: 10, churn: 1 })], + createNodeMap([createNode({ fileSize: 10, churn: 1 })]), + ), + ).toBeUndefined(); + }); + + it('returns undefined when a next node is missing', () => { + expect( + collectMetricOnlyGraphUpdates( + [createNode({ id: 'src/a.ts' })], + createNodeMap([createNode({ id: 'src/b.ts' })]), + ), + ).toBeUndefined(); + }); + + it('returns undefined when a changed node has non-metric differences', () => { + expect( + collectMetricOnlyGraphUpdates( + [createNode({ fileSize: 10, churn: 1 })], + createNodeMap([createNode({ label: 'renamed.ts', fileSize: 15, churn: 1 })]), + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/plan.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/plan.test.ts new file mode 100644 index 000000000..59b6388f7 --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/plan.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../../../src/shared/graph/contracts'; +import type { + GraphViewAnalysisExecutionHandlers, + GraphViewAnalysisExecutionState, + GraphViewAnalysisMode, +} from '../../../../../../src/extension/graphView/analysis/execution'; +import { createGraphPublicationPlan } from '../../../../../../src/extension/graphView/analysis/execution/publish/plan'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createEdge(overrides: Partial = {}): IGraphEdge { + return { + id: 'src/a.ts->src/b.ts#import', + from: 'src/a.ts', + to: 'src/b.ts', + kind: 'import', + sources: [], + ...overrides, + }; +} + +function createGraph(overrides: Partial = {}): IGraphData { + return { + nodes: [createNode({ fileSize: 10, churn: 1 })], + edges: [createEdge()], + ...overrides, + }; +} + +function createState( + mode: GraphViewAnalysisMode, + overrides: Partial = {}, +): GraphViewAnalysisExecutionState { + return { + analyzer: undefined, + analyzerInitialized: false, + analyzerInitPromise: undefined, + mode, + filterPatterns: [], + disabledPlugins: new Set(), + ...overrides, + }; +} + +function createHandlers( + overrides: Partial = {}, +): GraphViewAnalysisExecutionHandlers { + return overrides as GraphViewAnalysisExecutionHandlers; +} + +describe('extension/graphView/analysis/execution/publish/plan', () => { + it('reuses the current graph publication for unchanged fresh incremental graphs', () => { + const currentGraph = createGraph(); + + expect(createGraphPublicationPlan( + createState('incremental'), + createHandlers({ getRawGraphData: () => currentGraph }), + createGraph(), + true, + 'fresh', + )).toMatchObject({ + currentRawGraphData: currentGraph, + metricOnlyUpdate: undefined, + reuseCurrentGraphPublication: true, + shouldSendMetricPatch: false, + }); + }); + + it.each([ + ['analyze mode', createState('analyze'), true, 'fresh'], + ['missing index', createState('incremental'), false, 'fresh'], + ['stale freshness', createState('incremental'), true, 'stale'], + ] as const)('does not reuse the current graph publication for %s', (_caseName, state, actualHasIndex, freshness) => { + const currentGraph = createGraph(); + + expect(createGraphPublicationPlan( + state, + createHandlers({ getRawGraphData: () => currentGraph }), + createGraph(), + actualHasIndex, + freshness, + ).reuseCurrentGraphPublication).toBe(false); + }); + + it('does not reuse the current graph publication when no current graph is available', () => { + expect(createGraphPublicationPlan( + createState('incremental'), + createHandlers(), + createGraph(), + true, + 'fresh', + )).toMatchObject({ + currentRawGraphData: undefined, + metricOnlyUpdate: undefined, + reuseCurrentGraphPublication: false, + shouldSendMetricPatch: false, + }); + }); + + it('enables metric patch publication when a metric-only update and sender are available', () => { + expect(createGraphPublicationPlan( + createState('incremental', { changedFilePaths: ['src/a.ts'] }), + createHandlers({ + getRawGraphData: () => createGraph(), + sendGraphNodeMetricsUpdated: () => {}, + }), + createGraph({ nodes: [createNode({ fileSize: 15, churn: 2 })] }), + true, + 'fresh', + )).toMatchObject({ + metricOnlyUpdate: [{ id: 'src/a.ts', fileSize: 15, churn: 2 }], + reuseCurrentGraphPublication: false, + shouldSendMetricPatch: true, + }); + }); + + it('keeps metric patches disabled when the sender is unavailable', () => { + expect(createGraphPublicationPlan( + createState('incremental', { changedFilePaths: ['src/a.ts'] }), + createHandlers({ getRawGraphData: () => createGraph() }), + createGraph({ nodes: [createNode({ fileSize: 15, churn: 2 })] }), + true, + 'fresh', + )).toMatchObject({ + metricOnlyUpdate: [{ id: 'src/a.ts', fileSize: 15, churn: 2 }], + reuseCurrentGraphPublication: false, + shouldSendMetricPatch: false, + }); + }); + + it('keeps metric patches disabled when no metric-only update exists', () => { + expect(createGraphPublicationPlan( + createState('incremental'), + createHandlers({ + getRawGraphData: () => createGraph(), + sendGraphNodeMetricsUpdated: () => {}, + }), + createGraph(), + true, + 'fresh', + )).toMatchObject({ + metricOnlyUpdate: undefined, + reuseCurrentGraphPublication: true, + shouldSendMetricPatch: false, + }); + }); +}); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/status.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/status.test.ts new file mode 100644 index 000000000..3ed9bf9d0 --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/status.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import type { + GraphViewAnalysisExecutionState, + GraphViewAnalysisMode, +} from '../../../../../../src/extension/graphView/analysis/execution'; +import { + resolveGraphIndexStatus, + shouldReportGraphViewUpdateProgress, +} from '../../../../../../src/extension/graphView/analysis/execution/publish/status'; + +function createState( + mode: GraphViewAnalysisMode, + overrides: Partial = {}, +): GraphViewAnalysisExecutionState { + return { + analyzer: undefined, + analyzerInitialized: false, + analyzerInitPromise: undefined, + mode, + filterPatterns: [], + disabledPlugins: new Set(), + ...overrides, + }; +} + +describe('extension/graphView/analysis/execution/publish/status', () => { + it('uses analyzer-provided index status when available', () => { + expect(resolveGraphIndexStatus(createState('load', { + analyzer: { + getIndexStatus: () => ({ + freshness: 'stale', + detail: 'CodeGraphy index is stale: plugins changed.', + }), + } as GraphViewAnalysisExecutionState['analyzer'], + }), true)).toEqual({ + freshness: 'stale', + detail: 'CodeGraphy index is stale: plugins changed.', + }); + }); + + it('falls back to fresh status when an index exists', () => { + expect(resolveGraphIndexStatus(createState('load'), true)).toEqual({ + freshness: 'fresh', + detail: 'CodeGraphy index is fresh.', + }); + }); + + it('falls back to missing status when no index exists', () => { + expect(resolveGraphIndexStatus(undefined, false)).toEqual({ + freshness: 'missing', + detail: 'CodeGraphy index is missing. Index the workspace to build the graph.', + }); + }); + + it.each([ + 'index', + 'refresh', + 'incremental', + ] as const)('reports graph view update progress for %s mode', (mode) => { + expect(shouldReportGraphViewUpdateProgress(createState(mode))).toBe(true); + }); + + it.each([ + 'analyze', + 'load', + ] as const)('skips graph view update progress for %s mode', (mode) => { + expect(shouldReportGraphViewUpdateProgress(createState(mode))).toBe(false); + }); +}); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publishEntry.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publishEntry.test.ts new file mode 100644 index 000000000..433fbdeab --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publishEntry.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; +import { + publishAnalyzedGraph, + publishAnalysisFailure, +} from '../../../../../src/extension/graphView/analysis/execution/publish'; +import { + createExecutionHandlers, + createExecutionState, +} from './fixtures'; + +const rawGraphData: IGraphData = { + nodes: [{ id: 'src/index.ts', label: 'src/index.ts', color: '#ffffff' }], + edges: [], +}; + +describe('graph view analysis execution publish entry points', () => { + it('skips graph view update progress outside indexed update modes', () => { + const { handlers } = createExecutionHandlers({ + sendIndexProgress: vi.fn(), + }); + + publishAnalyzedGraph( + createExecutionState({ mode: 'analyze' }), + handlers, + rawGraphData, + true, + ); + + expect(handlers.sendIndexProgress).not.toHaveBeenCalled(); + }); + + it('does not require a progress broadcaster for indexed update modes', () => { + const { handlers } = createExecutionHandlers({ + sendIndexProgress: undefined, + }); + + expect(() => publishAnalyzedGraph( + createExecutionState({ mode: 'index' }), + handlers, + rawGraphData, + true, + )).not.toThrow(); + }); + + it('publishes analysis failure without optional contribution status broadcaster', () => { + const { handlers } = createExecutionHandlers({ + sendGraphViewContributionStatuses: undefined, + }); + + expect(() => publishAnalysisFailure(handlers)).not.toThrow(); + expect(handlers.markWorkspaceReady).toHaveBeenCalledWith({ nodes: [], edges: [] }); + }); +}); diff --git a/packages/extension/tests/extension/graphView/analysis/request.test.ts b/packages/extension/tests/extension/graphView/analysis/request.test.ts index ecca00060..c43893ce9 100644 --- a/packages/extension/tests/extension/graphView/analysis/request.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/request.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + import { runGraphViewAnalysisRequest, type GraphViewAnalysisRequestState, @@ -15,6 +16,50 @@ function createState( } describe('graph view analysis request', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('emits request lifecycle diagnostics with mode context', async () => { + const state = createState({ + mode: 'load', + filterPatterns: ['src/**'], + disabledPlugins: new Set(['plugin.test']), + } as Partial); + const emitDiagnostic = vi.fn(); + + await runGraphViewAnalysisRequest(state, { + executeAnalysis: vi.fn(() => Promise.resolve()), + emitDiagnostic, + isAbortError: vi.fn(() => false), + logError: vi.fn(), + updateAnalysisController: vi.fn(), + updateAnalysisRequestId: vi.fn(), + }); + + expect(emitDiagnostic).toHaveBeenCalledWith({ + area: 'extension.analysis', + event: 'request-started', + context: { + requestId: 1, + mode: 'load', + filterPatternCount: 1, + disabledPluginCount: 1, + }, + }); + expect(emitDiagnostic).toHaveBeenCalledWith({ + area: 'extension.analysis', + event: 'request-completed', + context: expect.objectContaining({ + requestId: 1, + mode: 'load', + filterPatternCount: 1, + disabledPluginCount: 1, + durationMs: expect.any(Number), + }), + }); + }); + it('aborts the previous controller and clears the active request on success', async () => { const previousController = new AbortController(); const abortSpy = vi.spyOn(previousController, 'abort'); diff --git a/packages/extension/tests/extension/graphView/groups/defaults/builtIn.test.ts b/packages/extension/tests/extension/graphView/groups/defaults/builtIn.test.ts index b682d56b2..7caf541b8 100644 --- a/packages/extension/tests/extension/graphView/groups/defaults/builtIn.test.ts +++ b/packages/extension/tests/extension/graphView/groups/defaults/builtIn.test.ts @@ -1,9 +1,16 @@ import * as vscode from 'vscode'; import * as path from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getBuiltInGraphViewDefaultGroups } from '../../../../../src/extension/graphView/groups/defaults/builtIn'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; describe('graphView/builtInDefaultGroups', () => { + beforeEach(() => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: (_key: string, defaultValue: T): T => defaultValue, + } as never); + }); + it('materializes matching Material theme defaults for the current graph and keeps Material Icon Theme metadata', () => { const groups = getBuiltInGraphViewDefaultGroups( { @@ -210,4 +217,55 @@ describe('graphView/builtInDefaultGroups', () => { 'default:symbol-kind:plugin', ])); }); + + it('reuses computed defaults for repeated same graph inputs', () => { + const graphData: IGraphData = { + nodes: [ + { id: 'package.json', label: 'package.json', color: '#000000', nodeType: 'file' }, + { + id: 'src/app.ts#format:function', + label: 'format', + color: '#000000', + nodeType: 'symbol', + symbol: { + id: 'src/app.ts#format:function', + name: 'format', + kind: 'function', + filePath: 'src/app.ts', + }, + }, + ], + edges: [], + }; + const extensionUri = vscode.Uri.file(path.resolve(process.cwd(), '../..')); + + const first = getBuiltInGraphViewDefaultGroups(graphData, extensionUri); + const second = getBuiltInGraphViewDefaultGroups(graphData, extensionUri); + + expect(second).toBe(first); + }); + + it('recomputes defaults when folder visibility changes for the same graph', () => { + let nodeVisibility: Record = {}; + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: (key: string, defaultValue: T): T => ( + key === 'nodeVisibility' ? nodeVisibility as T : defaultValue + ), + } as never); + + const graphData: IGraphData = { + nodes: [ + { id: 'src', label: 'src', color: '#000000', nodeType: 'folder' }, + { id: 'src/app.ts', label: 'app.ts', color: '#000000', nodeType: 'file' }, + ], + edges: [], + }; + const extensionUri = vscode.Uri.file(path.resolve(process.cwd(), '../..')); + const hiddenFolders = getBuiltInGraphViewDefaultGroups(graphData, extensionUri); + + nodeVisibility = { folder: true }; + const visibleFolders = getBuiltInGraphViewDefaultGroups(graphData, extensionUri); + + expect(visibleFolders).not.toBe(hiddenFolders); + }); }); diff --git a/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/extensionMatch.test.ts b/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/extensionMatch.test.ts index 91b4826a1..e887ec871 100644 --- a/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/extensionMatch.test.ts +++ b/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/extensionMatch.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { findLongestExtensionMatch } from '../../../../../../src/extension/graphView/groups/defaults/materialTheme/extensionMatch'; +import { + createMaterialExtensionMatcher, + findLongestExtensionMatch, + findLongestExtensionMatchWithMatcher, +} from '../../../../../../src/extension/graphView/groups/defaults/materialTheme/extensionMatch'; describe('graphView/materialTheme/extensionMatch', () => { it('matches bare extension filenames', () => { @@ -29,4 +33,18 @@ describe('graphView/materialTheme/extensionMatch', () => { kind: 'fileExtension', }); }); + + it('reuses a prepared extension matcher while preserving longest-match behavior', () => { + const matcher = createMaterialExtensionMatcher({ + ts: 'typescript', + 'test.ts': 'test-typescript', + 'd.test.ts': 'definition-test', + }); + + expect(findLongestExtensionMatchWithMatcher('main.d.test.ts', matcher)).toEqual({ + iconName: 'definition-test', + key: 'd.test.ts', + kind: 'fileExtension', + }); + }); }); diff --git a/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/files.test.ts b/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/files.test.ts index 9444827f9..3da07d46f 100644 --- a/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/files.test.ts +++ b/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/files.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import type { IGraphData } from '../../../../../../src/shared/graph/contracts'; import { collectMaterialFileGroups } from '../../../../../../src/extension/graphView/groups/defaults/materialTheme/files'; import type { MaterialThemeCacheEntry } from '../../../../../../src/extension/graphView/groups/defaults/materialTheme/model'; +import { createMaterialPathRuleMatcher } from '../../../../../../src/extension/graphView/groups/defaults/materialTheme/pathMatch'; const tempDirs: string[] = []; @@ -18,17 +19,31 @@ function createTheme(): MaterialThemeCacheEntry { fs.writeFileSync(manifestPath, '{}'); fs.writeFileSync(path.join(iconRoot, 'typescript.svg'), ''); fs.writeFileSync(path.join(iconRoot, 'readme.svg'), ''); + fs.writeFileSync(path.join(iconRoot, 'vite.svg'), ''); + fs.writeFileSync(path.join(iconRoot, 'web-vite.svg'), ''); + fs.writeFileSync(path.join(iconRoot, 'api-vite.svg'), ''); + const fileNames = { + 'readme.md': 'readme', + 'vite.config.ts': 'vite', + 'apps/web/vite.config.ts': 'web-vite', + 'packages/api/vite.config.ts': 'api-vite', + }; return { iconDataByName: new Map(), manifestPath, - pathMatchers: {}, + pathMatchers: { + fileNames: createMaterialPathRuleMatcher(fileNames), + }, manifest: { fileExtensions: { ts: 'typescript' }, - fileNames: { 'readme.md': 'readme' }, + fileNames, iconDefinitions: { typescript: { iconPath: '../icons/typescript.svg' }, readme: { iconPath: '../icons/readme.svg' }, + vite: { iconPath: '../icons/vite.svg' }, + 'web-vite': { iconPath: '../icons/web-vite.svg' }, + 'api-vite': { iconPath: '../icons/api-vite.svg' }, }, }, }; @@ -58,4 +73,24 @@ describe('graphView/materialTheme/files', () => { 'default:fileName:README.md', ]); }); + + it('keeps path-specific filename groups distinct while caching basename-only matches', () => { + const groups = collectMaterialFileGroups({ + nodes: [ + { id: 'apps/web/vite.config.ts', label: 'vite.config.ts', color: '#000000' }, + { id: 'packages/api/vite.config.ts', label: 'vite.config.ts', color: '#000000' }, + { id: 'examples/basic/vite.config.ts', label: 'vite.config.ts', color: '#000000' }, + { id: 'src/main.ts', label: 'main.ts', color: '#000000' }, + { id: 'tests/main.ts', label: 'main.ts', color: '#000000' }, + ], + edges: [], + } satisfies IGraphData, createTheme()); + + expect(groups.map((group) => group.id)).toEqual([ + 'default:fileName:apps/web/vite.config.ts', + 'default:fileName:packages/api/vite.config.ts', + 'default:fileName:vite.config.ts', + 'default:fileExtension:ts', + ]); + }); }); diff --git a/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/pathMatch.test.ts b/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/pathMatch.test.ts index 50a2a4cb4..161dd4a6c 100644 --- a/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/pathMatch.test.ts +++ b/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/pathMatch.test.ts @@ -37,6 +37,24 @@ describe('graphView/materialTheme/pathMatch', () => { }); }); + it('indexes scoped path rules by basename for candidate lookup', () => { + const matcher = createMaterialPathRuleMatcher({ + 'apps/web/vite.config.ts': 'web-vite', + 'packages/api/vite.config.ts': 'api-vite', + 'apps/web/package.json': 'package', + }); + + expect( + matcher.pathRulesByLowerBaseName.get('vite.config.ts')?.map(rule => rule.normalizedRule), + ).toEqual([ + 'packages/api/vite.config.ts', + 'apps/web/vite.config.ts', + ]); + expect( + matcher.pathRulesByLowerBaseName.get('package.json')?.map(rule => rule.normalizedRule), + ).toEqual(['apps/web/package.json']); + }); + it('returns undefined for non-matches', () => { expect(findLongestPathMatch('src/main.ts', { 'package.json': 'package' }, 'fileName')).toBeUndefined(); }); diff --git a/packages/extension/tests/extension/graphView/provider/analysis/handlers.test.ts b/packages/extension/tests/extension/graphView/provider/analysis/handlers.test.ts index 17e234bc9..00e05b8eb 100644 --- a/packages/extension/tests/extension/graphView/provider/analysis/handlers.test.ts +++ b/packages/extension/tests/extension/graphView/provider/analysis/handlers.test.ts @@ -110,6 +110,7 @@ describe('graphView/provider/analysis/handlers', () => { nodes: [{ id: 'raw', label: 'raw', color: '#ffffff' }], edges: [], }); + expect(handlers.getRawGraphData?.()).toEqual(source._rawGraphData); expect(source._graphData).toEqual(graphData); expect(source._sendMessage).toHaveBeenCalledWith({ type: 'GRAPH_DATA_UPDATED', @@ -166,6 +167,7 @@ describe('graphView/provider/analysis/handlers', () => { }); expect(handlers.hasWorkspace()).toBe(false); + expect(handlers.getRawGraphData?.()).toBe(source._rawGraphData); expect(handlers.getGraphData()).toBe(source._graphData); handlers.sendGraphDataUpdated(graphData); diff --git a/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts b/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts index 025eed611..adcf4945f 100644 --- a/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts +++ b/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { IGraphData } from '../../../../../src/shared/graph/contracts'; import { createGraphViewProviderAnalysisDelegates } from '../../../../../src/extension/graphView/provider/analysis/delegates'; import { createGraphViewProviderAnalysisMethods } from '../../../../../src/extension/graphView/provider/analysis/methods'; +import { createSource } from './methods/fixture'; vi.mock('../../../../../src/extension/graphView/provider/analysis/delegates', async importOriginal => { const actual = await importOriginal< @@ -14,69 +15,6 @@ vi.mock('../../../../../src/extension/graphView/provider/analysis/delegates', as }; }); -function createSource( - overrides: Partial> = {}, -): { - _analysisController?: AbortController; - _analysisRequestId: number; - _analyzer?: { - registry: { - notifyWorkspaceReady: ReturnType; - }; - }; - _analyzerInitialized: boolean; - _analyzerInitPromise?: Promise; - _filterPatterns: string[]; - _disabledPlugins: Set; - _graphData: IGraphData; - _rawGraphData: IGraphData; - _firstAnalysis: boolean; - _resolveFirstWorkspaceReady?: ReturnType; - _sendMessage: ReturnType; - _sendDepthState: ReturnType; - _computeMergedGroups: ReturnType; - _sendGroupsUpdated: ReturnType; - _updateViewContext: ReturnType; - _applyViewTransform: ReturnType; - _sendPluginStatuses: ReturnType; - _sendDecorations: ReturnType; - _sendContextMenuItems: ReturnType; - _analyzeAndSendData?: () => Promise; - _doAnalyzeAndSendData?: (signal: AbortSignal, requestId: number) => Promise; - _markWorkspaceReady?: (graph: IGraphData) => void; - _isAnalysisStale?: (signal: AbortSignal, requestId: number) => boolean; - _isAbortError?: (error: unknown) => boolean; - [key: string]: unknown; -} { - return { - _analysisController: undefined, - _analysisRequestId: 7, - _analyzer: { - registry: { - notifyWorkspaceReady: vi.fn(), - }, - }, - _analyzerInitialized: false, - _analyzerInitPromise: undefined, - _filterPatterns: [], - _disabledPlugins: new Set(), - _graphData: { nodes: [], edges: [] }, - _rawGraphData: { nodes: [], edges: [] }, - _firstAnalysis: true, - _resolveFirstWorkspaceReady: vi.fn(), - _sendMessage: vi.fn(), - _sendDepthState: vi.fn(), - _computeMergedGroups: vi.fn(), - _sendGroupsUpdated: vi.fn(), - _updateViewContext: vi.fn(), - _applyViewTransform: vi.fn(), - _sendPluginStatuses: vi.fn(), - _sendDecorations: vi.fn(), - _sendContextMenuItems: vi.fn(), - ...overrides, - }; -} - describe('graphView/provider/analysis/methods', () => { it('returns analysis helpers without mutating the provider source', () => { const source = createSource(); @@ -322,7 +260,7 @@ describe('graphView/provider/analysis/methods', () => { expect(runAnalysisRequest).toHaveBeenCalledOnce(); }); - it('starts stale cache sync in the background after cached load returns', async () => { + it('defers stale cache sync until after cached load returns to the caller', async () => { const source = createSource({ _analyzer: { getIndexStatus: vi.fn(() => ({ @@ -361,6 +299,10 @@ describe('graphView/provider/analysis/methods', () => { await methods._loadAndSendData(); await Promise.resolve(); + expect(events).toEqual(['load:start', 'load:end']); + + await new Promise(resolve => setTimeout(resolve, 0)); + expect(events).toEqual(['load:start', 'load:end', 'analyze:start']); finishCacheSync?.(); @@ -369,6 +311,123 @@ describe('graphView/provider/analysis/methods', () => { expect(events).toEqual(['load:start', 'load:end', 'analyze:start', 'analyze:end']); }); + it('starts incremental analysis before deferred stale cache background sync', async () => { + const source = createSource({ + _firstAnalysis: false, + _analyzer: { + getIndexStatus: vi.fn(() => ({ + freshness: 'stale', + detail: 'CodeGraphy Workspace Graph Cache is stale: enabled plugins changed.', + })), + loadCachedGraph: vi.fn(async () => ({ nodes: [], edges: [] })), + analyze: vi.fn(async () => ({ nodes: [], edges: [] })), + refreshIndex: vi.fn(async () => ({ nodes: [], edges: [] })), + registry: { + notifyWorkspaceReady: vi.fn(), + }, + }, + }); + const events: string[] = []; + let finishCacheSync: (() => void) | undefined; + const runAnalysisRequest = vi.fn(async state => { + events.push(`${state.mode}:start`); + if (state.mode === 'analyze') { + await new Promise(resolve => { + finishCacheSync = resolve; + }); + } + events.push(`${state.mode}:end`); + }); + const methods = createGraphViewProviderAnalysisMethods(source as never, { + runAnalysisRequest, + executeAnalysis: vi.fn(async () => undefined), + markWorkspaceReady: vi.fn(), + isAnalysisStale: vi.fn(() => false), + isAbortError: vi.fn(() => false), + hasWorkspace: vi.fn(() => true), + logError: vi.fn(), + }); + + await methods._loadAndSendData(); + await Promise.resolve(); + const incremental = methods._incrementalAnalyzeAndSendData(['src/changed.ts']); + await incremental; + + expect(events).toEqual([ + 'load:start', + 'load:end', + 'incremental:start', + 'incremental:end', + ]); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(events).toEqual([ + 'load:start', + 'load:end', + 'incremental:start', + 'incremental:end', + 'analyze:start', + ]); + + finishCacheSync?.(); + await Promise.resolve(); + + expect(source._changedFilePaths).toEqual(['src/changed.ts']); + }); + + it('waits for first workspace readiness before starting incremental analysis', async () => { + let markFirstWorkspaceReady: (() => void) | undefined; + const firstWorkspaceReadyPromise = new Promise(resolve => { + markFirstWorkspaceReady = resolve; + }); + const source = createSource({ + _firstWorkspaceReadyPromise: firstWorkspaceReadyPromise, + }); + const events: string[] = []; + let finishLoad: (() => void) | undefined; + const runAnalysisRequest = vi.fn(async state => { + events.push(`${state.mode}:start`); + if (state.mode === 'load') { + await new Promise(resolve => { + finishLoad = resolve; + }); + source._firstAnalysis = false; + markFirstWorkspaceReady?.(); + } + events.push(`${state.mode}:end`); + }); + const methods = createGraphViewProviderAnalysisMethods(source as never, { + runAnalysisRequest, + executeAnalysis: vi.fn(async () => undefined), + markWorkspaceReady: vi.fn(), + isAnalysisStale: vi.fn(() => false), + isAbortError: vi.fn(() => false), + hasWorkspace: vi.fn(() => true), + logError: vi.fn(), + }); + + const load = methods._loadAndSendData(); + await Promise.resolve(); + const incremental = methods._incrementalAnalyzeAndSendData(['src/changed.ts']); + await Promise.resolve(); + + expect(events).toEqual(['load:start']); + expect(runAnalysisRequest).toHaveBeenCalledOnce(); + + finishLoad?.(); + await load; + await incremental; + + expect(events).toEqual([ + 'load:start', + 'load:end', + 'incremental:start', + 'incremental:end', + ]); + expect(source._changedFilePaths).toEqual(['src/changed.ts']); + }); + it('falls back to the delegate wrappers when source-owned analysis methods are unavailable', async () => { const source = createSource({ _analyzer: undefined, diff --git a/packages/extension/tests/extension/graphView/provider/analysis/methods/fixture.ts b/packages/extension/tests/extension/graphView/provider/analysis/methods/fixture.ts new file mode 100644 index 000000000..f8eb532e6 --- /dev/null +++ b/packages/extension/tests/extension/graphView/provider/analysis/methods/fixture.ts @@ -0,0 +1,70 @@ +import { vi } from 'vitest'; + +import type { IGraphData } from '../../../../../../src/shared/graph/contracts'; + +export function createSource( + overrides: Partial> = {}, +): { + _analysisController?: AbortController; + _analysisRequestId: number; + _analyzer?: { + registry: { + notifyWorkspaceReady: ReturnType; + }; + }; + _analyzerInitialized: boolean; + _analyzerInitPromise?: Promise; + _filterPatterns: string[]; + _disabledPlugins: Set; + _graphData: IGraphData; + _rawGraphData: IGraphData; + _firstAnalysis: boolean; + _resolveFirstWorkspaceReady?: ReturnType; + _firstWorkspaceReadyPromise: Promise; + _sendMessage: ReturnType; + _sendDepthState: ReturnType; + _computeMergedGroups: ReturnType; + _sendGroupsUpdated: ReturnType; + _updateViewContext: ReturnType; + _applyViewTransform: ReturnType; + _sendPluginStatuses: ReturnType; + _sendDecorations: ReturnType; + _sendContextMenuItems: ReturnType; + _analyzeAndSendData?: () => Promise; + _doAnalyzeAndSendData?: (signal: AbortSignal, requestId: number) => Promise; + _markWorkspaceReady?: (graph: IGraphData) => void; + _isAnalysisStale?: (signal: AbortSignal, requestId: number) => boolean; + _isAbortError?: (error: unknown) => boolean; + [key: string]: unknown; +} { + const firstWorkspaceReadyPromise = Promise.resolve(); + + return { + _analysisController: undefined, + _analysisRequestId: 7, + _analyzer: { + registry: { + notifyWorkspaceReady: vi.fn(), + }, + }, + _analyzerInitialized: false, + _analyzerInitPromise: undefined, + _filterPatterns: [], + _disabledPlugins: new Set(), + _graphData: { nodes: [], edges: [] }, + _rawGraphData: { nodes: [], edges: [] }, + _firstAnalysis: true, + _resolveFirstWorkspaceReady: vi.fn(), + _firstWorkspaceReadyPromise: firstWorkspaceReadyPromise, + _sendMessage: vi.fn(), + _sendDepthState: vi.fn(), + _computeMergedGroups: vi.fn(), + _sendGroupsUpdated: vi.fn(), + _updateViewContext: vi.fn(), + _applyViewTransform: vi.fn(), + _sendPluginStatuses: vi.fn(), + _sendDecorations: vi.fn(), + _sendContextMenuItems: vi.fn(), + ...overrides, + }; +} diff --git a/packages/extension/tests/extension/graphView/provider/refresh.test.ts b/packages/extension/tests/extension/graphView/provider/refresh.test.ts index dabeebbe4..d32f62c5b 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh.test.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh.test.ts @@ -150,4 +150,23 @@ describe('graphView/provider/refresh', () => { }); + describe('refreshChangedFiles', () => { + it('uses indexed incremental analysis without replaying full settings state', async () => { + const source = createSource(); + const methods = createGraphViewProviderRefreshMethods(source as never, { + getShowOrphans: vi.fn(() => true), + rebuildGraphData: vi.fn(), + smartRebuildGraphData: vi.fn(), + }); + + await methods.refreshChangedFiles(['src/example.ts']); + + expect(source._loadDisabledRulesAndPlugins).not.toHaveBeenCalled(); + expect(source._loadGroupsAndFilterPatterns).not.toHaveBeenCalled(); + expect(source._incrementalAnalyzeAndSendData).toHaveBeenCalledWith(['src/example.ts']); + expect(source._sendAllSettings).not.toHaveBeenCalled(); + expect(source._sendGraphControls).not.toHaveBeenCalled(); + }); + }); + }); diff --git a/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts b/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts index a8731a7c3..cf609a6e2 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; + import { runChangedFileRefresh, runIndexRefresh, @@ -15,6 +16,8 @@ function createSource(overrides: Partial> = {}) { _refreshAndSendData: vi.fn(async () => undefined), _analyzeAndSendData: vi.fn(async () => undefined), _incrementalAnalyzeAndSendData: vi.fn(async () => undefined), + _rawGraphData: { nodes: [], edges: [] }, + _graphData: { nodes: [], edges: [] }, _analyzer: { hasIndex: vi.fn(() => true) }, ...overrides, }; @@ -60,8 +63,9 @@ describe('graphView/provider/refresh/run', () => { _loadAndSendData: vi.fn(async () => undefined), }); - await runChangedFileRefresh(source as never, ['src/app.ts']); + const refreshMode = await runChangedFileRefresh(source as never, ['src/app.ts']); + expect(refreshMode).toBe('primary'); expect(source._loadAndSendData).toHaveBeenCalledOnce(); expect(source._incrementalAnalyzeAndSendData).not.toHaveBeenCalled(); }); @@ -72,8 +76,9 @@ describe('graphView/provider/refresh/run', () => { _loadAndSendData: vi.fn(async () => undefined), }); - await runChangedFileRefresh(source as never, ['src/app.ts']); + const refreshMode = await runChangedFileRefresh(source as never, ['src/app.ts']); + expect(refreshMode).toBe('primary'); expect(source._loadAndSendData).toHaveBeenCalledOnce(); expect(source._incrementalAnalyzeAndSendData).not.toHaveBeenCalled(); }); @@ -81,20 +86,74 @@ describe('graphView/provider/refresh/run', () => { it('uses incremental refresh when an indexed analyzer is available', async () => { const source = createSource(); - await runChangedFileRefresh(source as never, ['src/app.ts']); + const refreshMode = await runChangedFileRefresh(source as never, ['src/app.ts']); + expect(refreshMode).toBe('incremental'); expect(source._incrementalAnalyzeAndSendData).toHaveBeenCalledWith(['src/app.ts']); expect(source._analyzeAndSendData).not.toHaveBeenCalled(); expect(source._loadAndSendData).not.toHaveBeenCalled(); }); + it('uses incremental refresh for a loaded graph while index metadata is unavailable', async () => { + const source = createSource({ + _analyzer: { hasIndex: vi.fn(() => false) }, + _rawGraphData: { + nodes: [{ id: 'src/app.ts' }], + edges: [], + }, + }); + + const refreshMode = await runChangedFileRefresh(source as never, ['src/app.ts']); + + expect(refreshMode).toBe('incremental'); + expect(source._incrementalAnalyzeAndSendData).toHaveBeenCalledWith(['src/app.ts']); + expect(source._loadAndSendData).not.toHaveBeenCalled(); + expect(source._analyzeAndSendData).not.toHaveBeenCalled(); + }); + + it('uses incremental refresh for an edge-only loaded graph while index metadata is unavailable', async () => { + const source = createSource({ + _analyzer: { hasIndex: vi.fn(() => false) }, + _rawGraphData: { + nodes: [], + edges: [{ from: 'src/app.ts', to: 'src/dep.ts' }], + }, + }); + + const refreshMode = await runChangedFileRefresh(source as never, ['src/app.ts']); + + expect(refreshMode).toBe('incremental'); + expect(source._incrementalAnalyzeAndSendData).toHaveBeenCalledWith(['src/app.ts']); + expect(source._loadAndSendData).not.toHaveBeenCalled(); + expect(source._analyzeAndSendData).not.toHaveBeenCalled(); + }); + + it('uses visible graph data when raw graph data has not loaded yet', async () => { + const source = createSource({ + _analyzer: { hasIndex: vi.fn(() => false) }, + _rawGraphData: undefined, + _graphData: { + nodes: [{ id: 'src/app.ts' }], + edges: [], + }, + }); + + const refreshMode = await runChangedFileRefresh(source as never, ['src/app.ts']); + + expect(refreshMode).toBe('incremental'); + expect(source._incrementalAnalyzeAndSendData).toHaveBeenCalledWith(['src/app.ts']); + expect(source._loadAndSendData).not.toHaveBeenCalled(); + expect(source._analyzeAndSendData).not.toHaveBeenCalled(); + }); + it('falls back to full analysis when incremental refresh is unavailable', async () => { const source = createSource({ _incrementalAnalyzeAndSendData: undefined, }); - await runChangedFileRefresh(source as never, ['src/app.ts']); + const refreshMode = await runChangedFileRefresh(source as never, ['src/app.ts']); + expect(refreshMode).toBe('analysis'); expect(source._analyzeAndSendData).toHaveBeenCalledOnce(); }); }); diff --git a/packages/extension/tests/extension/graphView/provider/refresh/targeted.test.ts b/packages/extension/tests/extension/graphView/provider/refresh/targeted.test.ts index 588593b78..c058ed654 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh/targeted.test.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh/targeted.test.ts @@ -24,6 +24,29 @@ describe('graphView/provider/refresh targeted refreshes', () => { expect(source._sendFavorites).not.toHaveBeenCalled(); }); + it('refreshChangedFiles stays incremental for a loaded graph while index metadata is unavailable', async () => { + const source = createSource({ + _rawGraphData: { + nodes: [{ id: 'src/example.ts' }], + edges: [], + }, + }); + source._analyzer.hasIndex.mockReturnValue(false); + const methods = createGraphViewProviderRefreshMethods(source as never, { + getShowOrphans: vi.fn(() => true), + rebuildGraphData: vi.fn(), + smartRebuildGraphData: vi.fn(), + }); + + await methods.refreshChangedFiles(['src/example.ts']); + + expect(source._loadDisabledRulesAndPlugins).not.toHaveBeenCalled(); + expect(source._loadGroupsAndFilterPatterns).not.toHaveBeenCalled(); + expect(source._incrementalAnalyzeAndSendData).toHaveBeenCalledWith(['src/example.ts']); + expect(source._loadAndSendData).not.toHaveBeenCalled(); + expect(source._sendAllSettings).not.toHaveBeenCalled(); + }); + it('refreshPluginFiles publishes the targeted plugin refresh result without rebuilding it again', async () => { const source = createSource(); source._analyzer.hasIndex.mockReturnValue(false); diff --git a/packages/extension/tests/extension/graphView/provider/runtime.trackstheinstalledpluginactivationtokeepsqueuedworkspacechangespending.test.ts b/packages/extension/tests/extension/graphView/provider/runtime.trackstheinstalledpluginactivationtokeepsqueuedworkspacechangespending.test.ts index dfc3df885..819fba82b 100644 --- a/packages/extension/tests/extension/graphView/provider/runtime.trackstheinstalledpluginactivationtokeepsqueuedworkspacechangespending.test.ts +++ b/packages/extension/tests/extension/graphView/provider/runtime.trackstheinstalledpluginactivationtokeepsqueuedworkspacechangespending.test.ts @@ -57,7 +57,9 @@ async function loadSubject( }; vi.doMock('../../../../src/extension/pipeline/service/lifecycleFacade', () => ({ - WorkspacePipeline: class WorkspacePipeline {}, + WorkspacePipeline: class WorkspacePipeline { + warmGraphCache = vi.fn(async () => undefined); + }, })); vi.doMock('../../../../src/core/views', () => ({ ViewRegistry: class ViewRegistry { diff --git a/packages/extension/tests/extension/graphView/provider/runtime/fixture.ts b/packages/extension/tests/extension/graphView/provider/runtime/fixture.ts index eaa09c071..49a06749f 100644 --- a/packages/extension/tests/extension/graphView/provider/runtime/fixture.ts +++ b/packages/extension/tests/extension/graphView/provider/runtime/fixture.ts @@ -55,7 +55,9 @@ export async function loadSubject( }; vi.doMock('../../../../../src/extension/pipeline/service/lifecycleFacade', () => ({ - WorkspacePipeline: class WorkspacePipeline {}, + WorkspacePipeline: class WorkspacePipeline { + warmGraphCache = vi.fn(async () => undefined); + }, })); vi.doMock('../../../../../src/core/views', () => ({ ViewRegistry: class ViewRegistry { diff --git a/packages/extension/tests/extension/graphView/provider/runtime/state/model.test.ts b/packages/extension/tests/extension/graphView/provider/runtime/state/model.test.ts index 2d4c0875d..18dd050cc 100644 --- a/packages/extension/tests/extension/graphView/provider/runtime/state/model.test.ts +++ b/packages/extension/tests/extension/graphView/provider/runtime/state/model.test.ts @@ -10,6 +10,7 @@ const stateHarness = vi.hoisted(() => { analyzerInstances: [] as Array<{ context: unknown; invalidateWorkspaceFiles: ReturnType; + warmGraphCache: ReturnType; }>, viewRegistryInstances: [] as Array<{ id: string }>, decorationManagerInstances: [] as Array<{ id: string }>, @@ -115,11 +116,13 @@ vi.mock('vscode', () => ({ vi.mock('../../../../../../src/extension/pipeline/service/lifecycleFacade', () => ({ WorkspacePipeline: class WorkspacePipeline { invalidateWorkspaceFiles = vi.fn((filePaths: readonly string[]) => [...filePaths]); + warmGraphCache = vi.fn(async () => undefined); constructor(context: unknown) { stateHarness.analyzerInstances.push({ context, invalidateWorkspaceFiles: this.invalidateWorkspaceFiles, + warmGraphCache: this.warmGraphCache, }); } }, @@ -306,6 +309,7 @@ describe('graphView/provider/runtime/state/model', () => { ]; expect(stateHarness.analyzerInstances).toHaveLength(1); + expect(stateHarness.analyzerInstances[0]?.warmGraphCache).toHaveBeenCalledOnce(); expect(stateHarness.initializeRuntimeStateServices).toHaveBeenCalledOnce(); expect(stateHarness.restorePersistedRuntimeState).toHaveBeenCalledWith( context, diff --git a/packages/extension/tests/extension/graphView/provider/runtime/workspaceRefreshPersistence.test.ts b/packages/extension/tests/extension/graphView/provider/runtime/workspaceRefreshPersistence.test.ts index a5dfa4a1b..19de16689 100644 --- a/packages/extension/tests/extension/graphView/provider/runtime/workspaceRefreshPersistence.test.ts +++ b/packages/extension/tests/extension/graphView/provider/runtime/workspaceRefreshPersistence.test.ts @@ -42,6 +42,22 @@ describe('graphView/provider/runtime/workspaceRefreshPersistence', () => { ]); }); + it('filters generated pending paths before persisting workspace refresh metadata', () => { + persistPendingWorkspaceRefresh('/test/workspace', [ + '/test/workspace', + '/test/workspace/packages/core/.turbo', + '/test/workspace/.worktrees/speed-up-codegraphy/src/index.ts', + '/test/workspace/src/a.ts', + ]); + + expect(metaState.writes).toEqual([ + { + workspaceRoot: '/test/workspace', + pendingChangedFiles: ['/test/workspace/src/a.ts'], + }, + ]); + }); + it('skips persistence and loading when no workspace root exists', () => { persistPendingWorkspaceRefresh(undefined, ['src/a.ts']); @@ -59,6 +75,27 @@ describe('graphView/provider/runtime/workspaceRefreshPersistence', () => { }); }); + it('cleans generated pending paths when loading persisted workspace refresh data', () => { + metaState.pendingChangedFiles = [ + '/test/workspace', + '/test/workspace/packages/core/.turbo', + '/test/workspace/.worktrees/speed-up-codegraphy/src/index.ts', + '/test/workspace/src/a.ts', + ]; + + expect(loadPersistedWorkspaceRefresh('/test/workspace')).toEqual({ + filePaths: new Set(['/test/workspace/src/a.ts']), + gitignoreRefresh: false, + logMessage: '[CodeGraphy] Applying pending workspace changes', + }); + expect(metaState.writes).toEqual([ + { + workspaceRoot: '/test/workspace', + pendingChangedFiles: ['/test/workspace/src/a.ts'], + }, + ]); + }); + it('marks persisted gitignore changes as metadata refreshes', () => { metaState.pendingChangedFiles = ['src/a.ts', '/test/workspace/.gitignore']; diff --git a/packages/extension/tests/extension/graphView/webview/html.test.ts b/packages/extension/tests/extension/graphView/webview/html.test.ts index 31ca31307..adf975cab 100644 --- a/packages/extension/tests/extension/graphView/webview/html.test.ts +++ b/packages/extension/tests/extension/graphView/webview/html.test.ts @@ -30,10 +30,11 @@ describe('graphView/webview/html', () => { 'light', ); - expect(html).toContain("script-src 'nonce-nonce-value'"); + expect(html).toContain("script-src vscode-webview://test 'nonce-nonce-value'"); expect(html).toContain("img-src vscode-webview://test data:"); expect(html).toContain('webview:/test/extension/dist/webview/index.js'); expect(html).toContain('webview:/test/extension/dist/webview/index.css'); + expect(html).toContain(''); expect(html).toContain('data-codegraphy-view="graph"'); expect(html).toContain('data-codegraphy-theme="light"'); expect(html).toContain('
'); diff --git a/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts b/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts index 3f946257c..cf82018f6 100644 --- a/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts +++ b/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts @@ -1,108 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; -import type { NodeSizeMode } from '../../../../../src/shared/settings/modes'; -import type { IGraphData } from '../../../../../src/shared/graph/contracts'; import type { IGroup } from '../../../../../src/shared/settings/groups'; -import type { IViewContext } from '../../../../../src/core/views/contracts'; import { setGraphViewWebviewMessageListener, - type GraphViewMessageListenerContext, } from '../../../../../src/extension/graphView/webview/messages/listener'; - -function createContext( - overrides: Partial = {}, -): GraphViewMessageListenerContext { - const context = { - getTimelineActive: vi.fn(() => false), - getCurrentCommitSha: vi.fn(() => undefined), - getCanMutateGraphRevision: vi.fn(() => true), - getUserGroups: vi.fn(() => []), - getFilterPatterns: vi.fn(() => []), - getGraphData: vi.fn(() => ({ nodes: [], edges: [] } satisfies IGraphData)), - getViewContext: vi.fn(() => ({ activePlugins: new Set() } satisfies IViewContext)), - getFocusedFile: vi.fn(() => undefined), - setFocusedFile: vi.fn(), - openSelectedNode: vi.fn(() => Promise.resolve()), - activateNode: vi.fn(() => Promise.resolve()), - previewFileAtCommit: vi.fn(() => Promise.resolve()), - openFile: vi.fn(() => Promise.resolve()), - revealInExplorer: vi.fn(() => Promise.resolve()), - copyToClipboard: vi.fn(() => Promise.resolve()), - deleteFiles: vi.fn(() => Promise.resolve()), - renameFile: vi.fn(() => Promise.resolve()), - createFile: vi.fn(() => Promise.resolve()), - createFolder: vi.fn(() => Promise.resolve()), - toggleFavorites: vi.fn(() => Promise.resolve()), - addToExclude: vi.fn(() => Promise.resolve()), - analyzeAndSendData: vi.fn(() => Promise.resolve()), - refreshIndex: vi.fn(() => Promise.resolve()), - clearCacheAndRefresh: vi.fn(() => Promise.resolve()), - getFileInfo: vi.fn(() => Promise.resolve()), - undo: vi.fn(() => Promise.resolve(undefined)), - redo: vi.fn(() => Promise.resolve(undefined)), - showInformationMessage: vi.fn(), - changeView: vi.fn(() => Promise.resolve()), - setDepthLimit: vi.fn(() => Promise.resolve()), - updateDagMode: vi.fn(() => Promise.resolve()), - updateNodeSizeMode: vi.fn(() => Promise.resolve()), - indexRepository: vi.fn(() => Promise.resolve()), - jumpToCommit: vi.fn(() => Promise.resolve()), - resetTimeline: vi.fn(() => Promise.resolve()), - sendPhysicsSettings: vi.fn(), - updatePhysicsSetting: vi.fn(() => Promise.resolve()), - resetPhysicsSettings: vi.fn(() => Promise.resolve()), - workspaceFolder: undefined, - persistLegends: vi.fn(() => Promise.resolve()), - persistDefaultLegendVisibility: vi.fn(() => Promise.resolve()), - recomputeGroups: vi.fn(), - sendGroupsUpdated: vi.fn(), - showOpenDialog: vi.fn(() => Promise.resolve(undefined)), - createDirectory: vi.fn(() => Promise.resolve()), - copyFile: vi.fn(() => Promise.resolve()), - getConfig: vi.fn((_key: string, defaultValue: T) => defaultValue), - updateConfig: vi.fn(() => Promise.resolve()), - getPluginFilterPatterns: vi.fn(() => []), - getPluginFilterGroups: vi.fn(() => []), - sendGraphControls: vi.fn(), - sendMessage: vi.fn(), - applyViewTransform: vi.fn(), - smartRebuild: vi.fn(), - resetAllSettings: vi.fn(() => Promise.resolve()), - getMaxFiles: vi.fn(() => 500), - getPlaybackSpeed: vi.fn(() => 1), - getDagMode: vi.fn(() => null), - getNodeSizeMode: vi.fn(() => 'connections' as NodeSizeMode), - hasWorkspace: vi.fn(() => false), - isFirstAnalysis: vi.fn(() => false), - isWebviewReadyNotified: vi.fn(() => false), - loadGroupsAndFilterPatterns: vi.fn(), - loadDisabledRulesAndPlugins: vi.fn(), - sendDepthState: vi.fn(), - loadAndSendData: vi.fn(() => Promise.resolve()), - sendFavorites: vi.fn(), - sendSettings: vi.fn(), - sendCachedTimeline: vi.fn(), - sendDecorations: vi.fn(), - sendContextMenuItems: vi.fn(), - sendPluginWebviewInjections: vi.fn(), - sendActiveFile: vi.fn(), - waitForFirstWorkspaceReady: vi.fn(() => Promise.resolve()), - notifyWebviewReady: vi.fn(), - getInteractionPluginApi: vi.fn(), - getContextMenuPluginApi: vi.fn(), - emitEvent: vi.fn(), - findNode: vi.fn(), - findEdge: vi.fn(), - logError: vi.fn(), - setUserGroups: vi.fn(), - setFilterPatterns: vi.fn(), - setWebviewReadyNotified: vi.fn(), - ...overrides, - }; - - context.sendGraphControls ??= vi.fn(); - - return context as GraphViewMessageListenerContext; -} +import { createContext } from './listener/fixture'; describe('graph view webview message listener', () => { it('stores user group updates from primary dispatch flows', async () => { @@ -201,7 +102,7 @@ describe('graph view webview message listener', () => { expect(context.setWebviewReadyNotified).toHaveBeenCalledWith(true); }); - it('replays settings but not empty bootstrap payloads for duplicate WEBVIEW_READY during first analysis', async () => { + it('does not replay settings or empty bootstrap payloads for duplicate WEBVIEW_READY during first analysis', async () => { let messageHandler: ((message: unknown) => Promise) | undefined; const webview = { onDidReceiveMessage: vi.fn((handler: (message: unknown) => Promise) => { @@ -258,6 +159,62 @@ describe('graph view webview message listener', () => { expect(context.setWebviewReadyNotified).toHaveBeenCalledTimes(1); }); + it('ignores repeated WEBVIEW_READY deliveries from the same webview page after bootstrap', async () => { + let messageHandler: ((message: unknown) => Promise) | undefined; + const webview = { + onDidReceiveMessage: vi.fn((handler: (message: unknown) => Promise) => { + messageHandler = handler; + return { dispose: () => {} }; + }), + }; + const context = createContext({ + hasWorkspace: vi.fn(() => true), + isFirstAnalysis: vi.fn(() => false), + isWebviewReadyNotified: vi.fn(() => true), + }); + const readyMessage = { type: 'WEBVIEW_READY', payload: { pageId: 'page-a' } }; + + setGraphViewWebviewMessageListener(webview as never, context); + await messageHandler?.(readyMessage); + await messageHandler?.(readyMessage); + + expect(context.loadAndSendData).toHaveBeenCalledTimes(1); + expect(context.sendSettings).toHaveBeenCalledTimes(1); + expect(context.sendPhysicsSettings).toHaveBeenCalledTimes(1); + expect( + vi.mocked(context.sendMessage).mock.calls.filter(([message]) => + (message as { type?: string }).type === 'APP_BOOTSTRAP_COMPLETE' + ), + ).toHaveLength(1); + }); + + it('ignores new-page WEBVIEW_READY deliveries posted before the previous bootstrap completed', async () => { + let messageHandler: ((message: unknown) => Promise) | undefined; + const webview = { + onDidReceiveMessage: vi.fn((handler: (message: unknown) => Promise) => { + messageHandler = handler; + return { dispose: () => {} }; + }), + }; + const context = createContext({ + hasWorkspace: vi.fn(() => true), + isFirstAnalysis: vi.fn(() => false), + isWebviewReadyNotified: vi.fn(() => true), + }); + + setGraphViewWebviewMessageListener(webview as never, context); + await messageHandler?.({ type: 'WEBVIEW_READY', payload: { pageId: 'page-a', postedAt: 1 } }); + await messageHandler?.({ type: 'WEBVIEW_READY', payload: { pageId: 'page-b', postedAt: 1 } }); + + expect(context.loadAndSendData).toHaveBeenCalledTimes(1); + expect(context.sendSettings).toHaveBeenCalledTimes(1); + expect( + vi.mocked(context.sendMessage).mock.calls.filter(([message]) => + (message as { type?: string }).type === 'APP_BOOTSTRAP_COMPLETE' + ), + ).toHaveLength(1); + }); + it('replaces the previous listener when the same webview is wired again', async () => { const activeHandlers = new Set<(message: unknown) => Promise>(); const webview = { diff --git a/packages/extension/tests/extension/graphView/webview/messages/listener/fixture.ts b/packages/extension/tests/extension/graphView/webview/messages/listener/fixture.ts new file mode 100644 index 000000000..6468acc42 --- /dev/null +++ b/packages/extension/tests/extension/graphView/webview/messages/listener/fixture.ts @@ -0,0 +1,102 @@ +import { vi } from 'vitest'; + +import type { IViewContext } from '../../../../../../src/core/views/contracts'; +import type { GraphViewMessageListenerContext } from '../../../../../../src/extension/graphView/webview/messages/listener'; +import type { IGraphData } from '../../../../../../src/shared/graph/contracts'; +import type { NodeSizeMode } from '../../../../../../src/shared/settings/modes'; + +export function createContext( + overrides: Partial = {}, +): GraphViewMessageListenerContext { + const context = { + getTimelineActive: vi.fn(() => false), + getCurrentCommitSha: vi.fn(() => undefined), + getCanMutateGraphRevision: vi.fn(() => true), + getUserGroups: vi.fn(() => []), + getFilterPatterns: vi.fn(() => []), + getGraphData: vi.fn(() => ({ nodes: [], edges: [] } satisfies IGraphData)), + getViewContext: vi.fn(() => ({ activePlugins: new Set() } satisfies IViewContext)), + getFocusedFile: vi.fn(() => undefined), + setFocusedFile: vi.fn(), + openSelectedNode: vi.fn(() => Promise.resolve()), + activateNode: vi.fn(() => Promise.resolve()), + previewFileAtCommit: vi.fn(() => Promise.resolve()), + openFile: vi.fn(() => Promise.resolve()), + revealInExplorer: vi.fn(() => Promise.resolve()), + copyToClipboard: vi.fn(() => Promise.resolve()), + deleteFiles: vi.fn(() => Promise.resolve()), + renameFile: vi.fn(() => Promise.resolve()), + createFile: vi.fn(() => Promise.resolve()), + createFolder: vi.fn(() => Promise.resolve()), + toggleFavorites: vi.fn(() => Promise.resolve()), + addToExclude: vi.fn(() => Promise.resolve()), + analyzeAndSendData: vi.fn(() => Promise.resolve()), + refreshIndex: vi.fn(() => Promise.resolve()), + clearCacheAndRefresh: vi.fn(() => Promise.resolve()), + getFileInfo: vi.fn(() => Promise.resolve()), + undo: vi.fn(() => Promise.resolve(undefined)), + redo: vi.fn(() => Promise.resolve(undefined)), + showInformationMessage: vi.fn(), + changeView: vi.fn(() => Promise.resolve()), + setDepthLimit: vi.fn(() => Promise.resolve()), + updateDagMode: vi.fn(() => Promise.resolve()), + updateNodeSizeMode: vi.fn(() => Promise.resolve()), + indexRepository: vi.fn(() => Promise.resolve()), + jumpToCommit: vi.fn(() => Promise.resolve()), + resetTimeline: vi.fn(() => Promise.resolve()), + sendPhysicsSettings: vi.fn(), + updatePhysicsSetting: vi.fn(() => Promise.resolve()), + resetPhysicsSettings: vi.fn(() => Promise.resolve()), + workspaceFolder: undefined, + persistLegends: vi.fn(() => Promise.resolve()), + persistDefaultLegendVisibility: vi.fn(() => Promise.resolve()), + recomputeGroups: vi.fn(), + sendGroupsUpdated: vi.fn(), + showOpenDialog: vi.fn(() => Promise.resolve(undefined)), + createDirectory: vi.fn(() => Promise.resolve()), + copyFile: vi.fn(() => Promise.resolve()), + getConfig: vi.fn((_key: string, defaultValue: T) => defaultValue), + updateConfig: vi.fn(() => Promise.resolve()), + getPluginFilterPatterns: vi.fn(() => []), + getPluginFilterGroups: vi.fn(() => []), + sendGraphControls: vi.fn(), + sendMessage: vi.fn(), + applyViewTransform: vi.fn(), + smartRebuild: vi.fn(), + resetAllSettings: vi.fn(() => Promise.resolve()), + getMaxFiles: vi.fn(() => 500), + getPlaybackSpeed: vi.fn(() => 1), + getDagMode: vi.fn(() => null), + getNodeSizeMode: vi.fn(() => 'connections' as NodeSizeMode), + hasWorkspace: vi.fn(() => false), + isFirstAnalysis: vi.fn(() => false), + isWebviewReadyNotified: vi.fn(() => false), + loadGroupsAndFilterPatterns: vi.fn(), + loadDisabledRulesAndPlugins: vi.fn(), + sendDepthState: vi.fn(), + loadAndSendData: vi.fn(() => Promise.resolve()), + sendFavorites: vi.fn(), + sendSettings: vi.fn(), + sendCachedTimeline: vi.fn(), + sendDecorations: vi.fn(), + sendContextMenuItems: vi.fn(), + sendPluginWebviewInjections: vi.fn(), + sendActiveFile: vi.fn(), + waitForFirstWorkspaceReady: vi.fn(() => Promise.resolve()), + notifyWebviewReady: vi.fn(), + getInteractionPluginApi: vi.fn(), + getContextMenuPluginApi: vi.fn(), + emitEvent: vi.fn(), + findNode: vi.fn(), + findEdge: vi.fn(), + logError: vi.fn(), + setUserGroups: vi.fn(), + setFilterPatterns: vi.fn(), + setWebviewReadyNotified: vi.fn(), + ...overrides, + }; + + context.sendGraphControls ??= vi.fn(); + + return context as GraphViewMessageListenerContext; +} diff --git a/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts b/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts index 4691d92b5..46c2dcf01 100644 --- a/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts +++ b/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts @@ -1,38 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; -import { applyWebviewReady } from '../../../../../src/extension/graphView/webview/messages/ready'; - -function createHandlers() { - return { - getGraphData: vi.fn(() => ({ - nodes: [{ id: 'cached.ts', label: 'cached.ts', color: '#ffffff' }], - edges: [], - })), - getFilterPatterns: vi.fn(() => ['dist/**']), - getPluginFilterPatterns: vi.fn(() => ['venv/**']), - getPluginFilterGroups: vi.fn(() => []), - getConfig: vi.fn((_key: string, defaultValue: T): T => defaultValue), - loadGroupsAndFilterPatterns: vi.fn(), - loadDisabledRulesAndPlugins: vi.fn(), - sendDepthState: vi.fn(), - sendGraphControls: vi.fn(), - loadAndSendData: vi.fn(), - sendFavorites: vi.fn(), - sendSettings: vi.fn(), - sendPhysicsSettings: vi.fn(), - sendGroupsUpdated: vi.fn(), - sendMessage: vi.fn(), - sendCachedTimeline: vi.fn(), - sendDecorations: vi.fn(), - sendContextMenuItems: vi.fn(), - sendPluginStatuses: vi.fn(), - sendPluginWebviewInjections: vi.fn(), - sendPluginToolbarActions: vi.fn(), - sendGraphViewContributionStatuses: vi.fn(), - sendActiveFile: vi.fn(), - waitForFirstWorkspaceReady: vi.fn(() => Promise.resolve()), - notifyWebviewReady: vi.fn(), - }; -} +import { + applyWebviewReady, + replayDuplicateWebviewReady, +} from '../../../../../src/extension/graphView/webview/messages/ready'; +import { createHandlers } from './ready/fixture'; describe('graph view ready message', () => { it('sends the initial webview payloads and notifies readiness', async () => { @@ -117,6 +88,11 @@ describe('graph view ready message', () => { handlers.sendSettings.mockImplementation(() => callOrder.push('settings')); handlers.sendGraphControls.mockImplementation(() => callOrder.push('controls')); handlers.sendPluginWebviewInjections.mockImplementation(() => callOrder.push('plugin-injections')); + handlers.sendMessage.mockImplementation((message: { type: string }) => { + if (message.type === 'FILTER_PATTERNS_UPDATED') { + callOrder.push('filters'); + } + }); handlers.loadAndSendData.mockImplementation(() => { callOrder.push('analyze'); }); @@ -143,6 +119,67 @@ describe('graph view ready message', () => { expect(callOrder.indexOf('settings')).toBeLessThan(callOrder.indexOf('analyze')); expect(callOrder.indexOf('controls')).toBeLessThan(callOrder.indexOf('analyze')); expect(callOrder.indexOf('plugin-injections')).toBeLessThan(callOrder.indexOf('analyze')); + expect(callOrder.indexOf('filters')).toBeLessThan(callOrder.indexOf('analyze')); + }); + + it('does not replay unchanged filter patterns after graph loading', async () => { + const events: string[] = []; + const handlers = createHandlers(); + handlers.sendMessage.mockImplementation((message: { type: string }) => { + if (message.type === 'FILTER_PATTERNS_UPDATED') { + events.push('filters'); + } + }); + handlers.loadAndSendData.mockImplementation(() => { + events.push('graph'); + }); + + await applyWebviewReady( + { + maxFiles: 500, + verboseDiagnostics: false, + playbackSpeed: 1, + dagMode: null, + nodeSizeMode: 'connections', + focusedFile: undefined, + hasWorkspace: true, + firstAnalysis: true, + readyNotified: false, + }, + handlers + ); + + expect(events).toEqual(['filters', 'graph']); + expect(handlers.sendMessage.mock.calls.filter(([message]) => + (message as { type?: string }).type === 'FILTER_PATTERNS_UPDATED' + )).toHaveLength(1); + }); + + it('does not replay unchanged plugin filter groups after graph loading', async () => { + const handlers = createHandlers(); + handlers.getPluginFilterPatterns.mockReturnValue(['**/*.meta']); + handlers.getPluginFilterGroups = vi.fn(() => [ + { pluginId: 'codegraphy.unity', pluginName: 'Unity', patterns: ['**/*.meta'] }, + ]); + + await applyWebviewReady( + { + maxFiles: 500, + verboseDiagnostics: false, + playbackSpeed: 1, + dagMode: null, + nodeSizeMode: 'connections', + focusedFile: undefined, + hasWorkspace: true, + firstAnalysis: true, + readyNotified: false, + }, + handlers + ); + + expect(handlers.sendMessage.mock.calls.filter(([message]) => + (message as { type?: string }).type === 'FILTER_PATTERNS_UPDATED' + )).toHaveLength(1); }); it('replays plugin filters that become available while loading graph data', async () => { @@ -238,6 +275,45 @@ describe('graph view ready message', () => { expect(events).toEqual(['graph:start', 'graph:end', 'plugins', 'bootstrap']); }); + it('hydrates settings again after initial workspace graph loading before bootstrap', async () => { + const events: string[] = []; + const handlers = createHandlers(); + handlers.sendSettings.mockImplementation(() => events.push('settings')); + handlers.sendGroupsUpdated.mockImplementation(() => events.push('legends')); + handlers.loadAndSendData.mockImplementation(() => { + events.push('graph'); + }); + handlers.sendMessage.mockImplementation((message: { type: string }) => { + if (message.type === 'APP_BOOTSTRAP_COMPLETE') { + events.push('bootstrap'); + } + }); + + await applyWebviewReady( + { + maxFiles: 500, + verboseDiagnostics: false, + playbackSpeed: 1, + dagMode: null, + nodeSizeMode: 'connections', + focusedFile: undefined, + hasWorkspace: true, + firstAnalysis: true, + readyNotified: false, + }, + handlers + ); + + expect(events).toEqual([ + 'settings', + 'legends', + 'graph', + 'settings', + 'legends', + 'bootstrap', + ]); + }); + it('does not block bootstrap on first workspace-ready plugin notifications', async () => { const events: string[] = []; const handlers = createHandlers(); @@ -312,6 +388,37 @@ describe('graph view ready message', () => { expect(readyNotified).toBe(true); }); + it('does not resend full graph data for duplicate ready after bootstrap', async () => { + const handlers = createHandlers(); + + await replayDuplicateWebviewReady( + { + maxFiles: 500, + verboseDiagnostics: false, + playbackSpeed: 1, + dagMode: null, + nodeSizeMode: 'connections', + focusedFile: undefined, + hasWorkspace: true, + firstAnalysis: false, + readyNotified: true, + }, + handlers, + ); + + expect(handlers.getGraphData).not.toHaveBeenCalled(); + expect(handlers.sendMessage).not.toHaveBeenCalledWith({ + type: 'GRAPH_DATA_UPDATED', + payload: { + nodes: [{ id: 'cached.ts', label: 'cached.ts', color: '#ffffff' }], + edges: [], + }, + }); + expect(handlers.sendMessage).toHaveBeenCalledWith({ + type: 'APP_BOOTSTRAP_COMPLETE', + }); + }); + it('waits for cached timeline replay before notifying readiness', async () => { const events: string[] = []; const handlers = createHandlers(); diff --git a/packages/extension/tests/extension/graphView/webview/messages/ready/fixture.ts b/packages/extension/tests/extension/graphView/webview/messages/ready/fixture.ts new file mode 100644 index 000000000..a45a12a7b --- /dev/null +++ b/packages/extension/tests/extension/graphView/webview/messages/ready/fixture.ts @@ -0,0 +1,34 @@ +import { vi } from 'vitest'; + +export function createHandlers() { + return { + getGraphData: vi.fn(() => ({ + nodes: [{ id: 'cached.ts', label: 'cached.ts', color: '#ffffff' }], + edges: [], + })), + getFilterPatterns: vi.fn(() => ['dist/**']), + getPluginFilterPatterns: vi.fn(() => ['venv/**']), + getPluginFilterGroups: vi.fn(() => []), + getConfig: vi.fn((_key: string, defaultValue: T): T => defaultValue), + loadGroupsAndFilterPatterns: vi.fn(), + loadDisabledRulesAndPlugins: vi.fn(), + sendDepthState: vi.fn(), + sendGraphControls: vi.fn(), + loadAndSendData: vi.fn(), + sendFavorites: vi.fn(), + sendSettings: vi.fn(), + sendPhysicsSettings: vi.fn(), + sendGroupsUpdated: vi.fn(), + sendMessage: vi.fn(), + sendCachedTimeline: vi.fn(), + sendDecorations: vi.fn(), + sendContextMenuItems: vi.fn(), + sendPluginStatuses: vi.fn(), + sendPluginWebviewInjections: vi.fn(), + sendPluginToolbarActions: vi.fn(), + sendGraphViewContributionStatuses: vi.fn(), + sendActiveFile: vi.fn(), + waitForFirstWorkspaceReady: vi.fn(() => Promise.resolve()), + notifyWebviewReady: vi.fn(), + }; +} diff --git a/packages/extension/tests/extension/graphViewProvider.bootstrap.test.ts b/packages/extension/tests/extension/graphViewProvider.bootstrap.test.ts index cc21ec106..fa1f674b2 100644 --- a/packages/extension/tests/extension/graphViewProvider.bootstrap.test.ts +++ b/packages/extension/tests/extension/graphViewProvider.bootstrap.test.ts @@ -27,7 +27,9 @@ async function loadSubject( | undefined, ) { vi.doMock('../../src/extension/pipeline/service/lifecycleFacade', () => ({ - WorkspacePipeline: class WorkspacePipeline {}, + WorkspacePipeline: class WorkspacePipeline { + warmGraphCache = vi.fn(async () => undefined); + }, })); vi.doMock('../../src/core/views', () => ({ ViewRegistry: class ViewRegistry { diff --git a/packages/extension/tests/extension/pipeline/service/analysisFacade.test.ts b/packages/extension/tests/extension/pipeline/service/analysisFacade.test.ts new file mode 100644 index 000000000..5bac5c8e1 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/analysisFacade.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FileDiscovery } from '@codegraphy-dev/core'; +import type { PluginRegistry } from '../../../../src/core/plugins/registry/manager'; +import type { Configuration } from '../../../../src/extension/config/reader'; +import type { IWorkspaceAnalysisCache } from '../../../../src/extension/pipeline/cache'; +import { WorkspacePipelineAnalysisFacade } from '../../../../src/extension/pipeline/service/analysisFacade'; +import { + analyzeWorkspacePipeline, + rebuildWorkspacePipelineGraph, +} from '../../../../src/extension/pipeline/service/runtime/run'; + +vi.mock('../../../../src/extension/pipeline/service/runtime/run', () => ({ + analyzeWorkspacePipeline: vi.fn(), + rebuildWorkspacePipelineGraph: vi.fn(), +})); + +vi.mock('vscode', () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace' } }], + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + update: vi.fn(), + inspect: vi.fn(), + })), + }, +})); + +class TestAnalysisFacade extends WorkspacePipelineAnalysisFacade { + readonly getWorkspaceRoot = vi.fn<() => string | undefined>(() => '/workspace'); + readonly effectiveCustomFilterPatterns = vi.fn((patterns: string[]) => + patterns.map(pattern => `effective:${pattern}`), + ); + readonly persistIndexMetadata = vi.fn(async () => undefined); + + constructor() { + super({ + subscriptions: [], + workspaceState: { + get: vi.fn(), + update: vi.fn(), + }, + } as never); + this._cache = { + files: { + 'src/stale.ts': { analysis: {}, mtime: 1, size: 10 }, + }, + } as unknown as IWorkspaceAnalysisCache; + } + + _config = { + get: vi.fn((_key: string, defaultValue: unknown) => defaultValue), + getAll: vi.fn(() => ({ showOrphans: true, respectGitignore: true })), + } as unknown as Configuration; + + _discovery = { kind: 'discovery' } as unknown as FileDiscovery; + _registry = { + list: vi.fn(() => []), + } as unknown as PluginRegistry; + + public override get _cache(): IWorkspaceAnalysisCache { + return super._cache; + } + + public override set _cache(cache: IWorkspaceAnalysisCache) { + super._cache = cache; + } + + protected override _getWorkspaceRoot(): string | undefined { + return this.getWorkspaceRoot(); + } + + protected override _getEffectiveCustomFilterPatterns(filterPatterns: string[]): string[] { + return this.effectiveCustomFilterPatterns(filterPatterns); + } + + protected override async _persistIndexMetadata(): Promise { + await this.persistIndexMetadata(); + } +} + +describe('extension/pipeline/service/analysisFacade', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(analyzeWorkspacePipeline).mockResolvedValue({ + nodes: [{ id: 'analysis', label: 'Analysis', color: '#111111' }], + edges: [], + }); + vi.mocked(rebuildWorkspacePipelineGraph).mockReturnValue({ + nodes: [{ id: 'rebuild', label: 'Rebuild', color: '#222222' }], + edges: [], + }); + }); + + it('delegates analysis with effective filters and index metadata persistence', async () => { + const facade = new TestAnalysisFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + const onProgress = vi.fn(); + + await expect( + facade.analyze(['src/**'], disabledPlugins, signal, onProgress), + ).resolves.toEqual({ + nodes: [{ id: 'analysis', label: 'Analysis', color: '#111111' }], + edges: [], + }); + + expect(analyzeWorkspacePipeline).toHaveBeenCalledWith( + facade, + facade._cache, + facade._config, + facade._discovery, + expect.any(Function), + ['effective:src/**'], + disabledPlugins, + onProgress, + signal, + expect.any(Function), + ); + expect(vi.mocked(analyzeWorkspacePipeline).mock.calls[0][4]()).toBe('/workspace'); + await vi.mocked(analyzeWorkspacePipeline).mock.calls[0][9](); + expect(facade.persistIndexMetadata).toHaveBeenCalledOnce(); + + await facade.analyze(); + expect(facade.effectiveCustomFilterPatterns).toHaveBeenLastCalledWith([]); + }); + + it('delegates graph rebuilding with disabled plugins and orphan visibility', () => { + const facade = new TestAnalysisFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + + expect(facade.rebuildGraph(disabledPlugins, false)).toEqual({ + nodes: [{ id: 'rebuild', label: 'Rebuild', color: '#222222' }], + edges: [], + }); + expect(rebuildWorkspacePipelineGraph).toHaveBeenCalledWith( + facade, + disabledPlugins, + false, + ); + }); + + it('refreshes from an empty cache and forwards progress with fallback phases', async () => { + const facade = new TestAnalysisFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + const onProgress = vi.fn(); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const cacheBeforeRefresh = facade._cache; + const analyzeSpy = vi + .spyOn(facade, 'analyze') + .mockImplementation(async (_filters, _disabledPlugins, _signal, reportProgress) => { + reportProgress?.({ phase: '', current: 1, total: 2 }); + reportProgress?.({ phase: 'Analyzing', current: 2, total: 2 }); + return { + nodes: [{ id: 'refresh', label: 'Refresh', color: '#333333' }], + edges: [], + }; + }); + + await expect( + facade.refreshIndex(undefined, disabledPlugins, signal, onProgress), + ).resolves.toEqual({ + nodes: [{ id: 'refresh', label: 'Refresh', color: '#333333' }], + edges: [], + }); + + expect(facade._cache).not.toBe(cacheBeforeRefresh); + expect(facade._cache.files).toEqual({}); + expect(logSpy).toHaveBeenCalledWith('[CodeGraphy] Cache cleared'); + expect(analyzeSpy).toHaveBeenCalledWith( + [], + disabledPlugins, + signal, + expect.any(Function), + ); + expect(onProgress).toHaveBeenNthCalledWith(1, { + phase: 'Refreshing Index', + current: 1, + total: 2, + }); + expect(onProgress).toHaveBeenNthCalledWith(2, { + phase: 'Analyzing', + current: 2, + total: 2, + }); + + await expect( + facade.refreshIndex(['src/**'], disabledPlugins, signal), + ).resolves.toEqual({ + nodes: [{ id: 'refresh', label: 'Refresh', color: '#333333' }], + edges: [], + }); + expect(analyzeSpy).toHaveBeenLastCalledWith( + ['src/**'], + disabledPlugins, + signal, + expect.any(Function), + ); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/base/internal.test.ts b/packages/extension/tests/extension/pipeline/service/base/internal.test.ts index 6c1869a47..e8aba0c81 100644 --- a/packages/extension/tests/extension/pipeline/service/base/internal.test.ts +++ b/packages/extension/tests/extension/pipeline/service/base/internal.test.ts @@ -26,6 +26,7 @@ import { createWorkspacePipelineSettingsSignature, readWorkspacePipelineCurrentCommitShaSync, } from '../../../../../src/extension/pipeline/service/cache/signatures'; +import { createWorkspacePipelineAnalysisCacheTiers } from '../../../../../src/extension/pipeline/service/cache/tiers'; import { preAnalyzeCoreTreeSitterFiles } from '@codegraphy-dev/core'; vi.mock('../../../../../src/extension/pipeline/serviceAdapters', () => ({ @@ -65,6 +66,10 @@ vi.mock('../../../../../src/extension/pipeline/service/cache/signatures', () => readWorkspacePipelineCurrentCommitShaSync: vi.fn(), })); +vi.mock('../../../../../src/extension/pipeline/service/cache/tiers', () => ({ + createWorkspacePipelineAnalysisCacheTiers: vi.fn(), +})); + vi.mock('@codegraphy-dev/core', async (importOriginal) => ({ ...(await importOriginal()), preAnalyzeCoreTreeSitterFiles: vi.fn(), @@ -225,6 +230,11 @@ describe('extension/pipeline/service/internalBase', () => { vi.clearAllMocks(); vi.mocked(createWorkspacePipelinePluginSignature).mockReturnValue('plugin-signature'); vi.mocked(createWorkspacePipelineSettingsSignature).mockReturnValue('settings-signature'); + vi.mocked(createWorkspacePipelineAnalysisCacheTiers).mockReturnValue({ + active: ['baseline', 'plugin:plugin.a'], + completed: ['baseline', 'plugin:plugin.a'], + required: ['baseline', 'plugin:plugin.a'], + }); vi.mocked(readWorkspacePipelineCurrentCommitSha).mockResolvedValue('async-commit-sha'); vi.mocked(readWorkspacePipelineCurrentCommitShaSync).mockReturnValue('commit-sha'); vi.mocked(readWorkspacePipelineFileStat).mockResolvedValue({ @@ -295,6 +305,15 @@ describe('extension/pipeline/service/internalBase', () => { const progress = vi.fn(); const disabledPlugins = new Set(['plugin.disabled']); source.setEventBus({ emit: vi.fn() } as never); + source._registry = { + ...source._registry, + list: vi.fn(() => [ + { plugin: { id: 'plugin.a' } }, + { plugin: { id: '' } }, + { plugin: { id: undefined } }, + { plugin: { id: 'plugin.disabled' } }, + ]), + } as never; const state = source as unknown as { _eventBus: unknown }; const getFileStat = vi .spyOn(source as unknown as { _getFileStat: (filePath: string) => Promise<{ mtime: number; size: number } | null> }, '_getFileStat') @@ -325,6 +344,10 @@ describe('extension/pipeline/service/internalBase', () => { ['plugin.a'], disabledPlugins, ); + expect(createWorkspacePipelineAnalysisCacheTiers).toHaveBeenCalledWith( + { file: true, symbol: false }, + ['plugin.a'], + ); await expect( vi.mocked(analyzeWorkspacePipelineDiscoveredFiles).mock.calls[0][4]('/workspace/src/a.ts'), ).resolves.toEqual({ mtime: 1, size: 2 }); @@ -484,6 +507,9 @@ describe('extension/pipeline/service/internalBase', () => { expect(getPluginSignature).toHaveBeenCalledOnce(); expect(dependencies.getSettingsSignature()).toBe('settings-signature'); expect(getSettingsSignature).toHaveBeenCalledOnce(); + expect(dependencies.getCurrentCommitSha).toBeDefined(); + expect(dependencies.getCurrentCommitSha?.()).toBe('commit-sha'); + expect(readWorkspacePipelineCurrentCommitShaSync).toHaveBeenCalledWith('/workspace'); dependencies.warn('failed to persist', new Error('boom')); expect(warnSpy).toHaveBeenCalledWith('failed to persist', expect.any(Error)); }); diff --git a/packages/extension/tests/extension/pipeline/service/base/state.test.ts b/packages/extension/tests/extension/pipeline/service/base/state.test.ts index 34faed81a..c9749d8c2 100644 --- a/packages/extension/tests/extension/pipeline/service/base/state.test.ts +++ b/packages/extension/tests/extension/pipeline/service/base/state.test.ts @@ -6,10 +6,14 @@ import { PluginRegistry } from '../../../../../src/core/plugins/registry/manager import { WorkspacePipelineStateBase } from '../../../../../src/extension/pipeline/service/base/state'; const stateBaseHarness = vi.hoisted(() => ({ + loadWorkspaceAnalysisDatabaseCache: vi.fn(), + loadWorkspaceAnalysisDatabaseCacheAsync: vi.fn(), readWorkspaceAnalysisDatabaseSnapshot: vi.fn(), })); vi.mock('../../../../../src/extension/pipeline/database/cache/storage.ts', () => ({ + loadWorkspaceAnalysisDatabaseCache: stateBaseHarness.loadWorkspaceAnalysisDatabaseCache, + loadWorkspaceAnalysisDatabaseCacheAsync: stateBaseHarness.loadWorkspaceAnalysisDatabaseCacheAsync, readWorkspaceAnalysisDatabaseSnapshot: stateBaseHarness.readWorkspaceAnalysisDatabaseSnapshot, })); @@ -38,6 +42,10 @@ function createContext(): vscode.ExtensionContext { } describe('extension/pipeline/service/stateBase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('initializes core collaborators and returns an empty structured snapshot without a workspace root', () => { Object.defineProperty(vscode.workspace, 'workspaceFolders', { configurable: true, @@ -97,18 +105,158 @@ describe('extension/pipeline/service/stateBase', () => { expect(state._discovery).toBeInstanceOf(FileDiscovery); }); + it('warms the repo-local Graph Cache using the shared hydration promise', async () => { + stateBaseHarness.loadWorkspaceAnalysisDatabaseCache.mockReturnValueOnce({ + version: '2.1.0', + files: { + 'src/app.ts': { + mtime: 1, + analysis: { filePath: '/workspace/src/app.ts', relations: [] }, + }, + }, + }); + const state = new TestWorkspacePipelineState(createContext(), '/workspace') as TestWorkspacePipelineState & { + _cache: unknown; + warmGraphCache(): Promise; + }; + + const firstWarm = state.warmGraphCache(); + const secondWarm = state.warmGraphCache(); + + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); + + await Promise.all([firstWarm, secondWarm]); + + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).toHaveBeenCalledOnce(); + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).toHaveBeenCalledWith('/workspace'); + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCacheAsync).not.toHaveBeenCalled(); + expect(state._cache).toEqual({ + version: '2.1.0', + files: { + 'src/app.ts': { + mtime: 1, + analysis: { filePath: '/workspace/src/app.ts', relations: [] }, + }, + }, + }); + }); + + it('skips Graph Cache warming without a workspace root or when cache is already populated', async () => { + const stateWithoutRoot = new TestWorkspacePipelineState(createContext()) as TestWorkspacePipelineState & { + warmGraphCache(): Promise; + }; + + await stateWithoutRoot.warmGraphCache(); + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); + + const stateWithCache = new TestWorkspacePipelineState(createContext(), '/workspace') as TestWorkspacePipelineState & { + _cache: unknown; + warmGraphCache(): Promise; + }; + stateWithCache._cache = { + version: '2.1.0', + files: { + 'src/current.ts': { + mtime: 2, + analysis: { filePath: '/workspace/src/current.ts', relations: [] }, + }, + }, + }; + + await stateWithCache.warmGraphCache(); + + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); + }); + + it('does not overwrite cache populated while Graph Cache hydration is pending', async () => { + let resolveHydration!: (cache: unknown) => void; + stateBaseHarness.loadWorkspaceAnalysisDatabaseCache.mockReturnValueOnce( + new Promise(resolve => { + resolveHydration = resolve; + }), + ); + const state = new TestWorkspacePipelineState(createContext(), '/workspace') as TestWorkspacePipelineState & { + _cache: unknown; + warmGraphCache(): Promise; + }; + const populatedDuringHydration = { + version: '2.1.0', + files: { + 'src/current.ts': { + mtime: 2, + analysis: { filePath: '/workspace/src/current.ts', relations: [] }, + }, + }, + }; + + const warm = state.warmGraphCache(); + state._cache = populatedDuringHydration; + resolveHydration({ + version: '2.1.0', + files: { + 'src/stale.ts': { + mtime: 1, + analysis: { filePath: '/workspace/src/stale.ts', relations: [] }, + }, + }, + }); + await warm; + + expect(state._cache).toBe(populatedDuringHydration); + }); + + it('clears the shared hydration promise so empty cache warms can retry', async () => { + stateBaseHarness.loadWorkspaceAnalysisDatabaseCache.mockReturnValue({ + version: '2.1.0', + files: {}, + }); + const state = new TestWorkspacePipelineState(createContext(), '/workspace') as TestWorkspacePipelineState & { + warmGraphCache(): Promise; + }; + + await state.warmGraphCache(); + await state.warmGraphCache(); + + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).toHaveBeenCalledTimes(2); + }); + it('stores retained indexing fields in the core engine state', () => { const state = new TestWorkspacePipelineState(createContext()) as TestWorkspacePipelineState & { _cache: { files: Record }; _engineState: { cache: unknown; fileAnalysis: unknown; + fileConnections: unknown; + discoveredFiles: unknown; + graph: unknown; workspaceRoot: string; }; _lastFileAnalysis: Map; + _lastFileConnections: Map; + _lastDiscoveredFiles: unknown[]; + _lastGraphData: unknown; _lastWorkspaceRoot: string; }; const fileAnalysis = new Map([['src/a.ts', { filePath: '/workspace/src/a.ts' }]]); + const fileConnections = new Map([[ + 'src/a.ts', + [{ + kind: 'import' as const, + sourceId: 'src/a.ts', + specifier: './b', + resolvedPath: '/workspace/src/b.ts', + }], + ]]); + const discoveredFiles = [{ + absolutePath: '/workspace/src/a.ts', + extension: '.ts', + name: 'a.ts', + relativePath: 'src/a.ts', + }]; + const graphData = { + nodes: [{ id: 'src/a.ts', label: 'a.ts', color: '#67E8F9', nodeType: 'file' }], + edges: [], + }; state._cache = { version: 'test', @@ -120,10 +268,18 @@ describe('extension/pipeline/service/stateBase', () => { }, }; state._lastFileAnalysis = fileAnalysis; + state._lastFileConnections = fileConnections; + state._lastDiscoveredFiles = discoveredFiles; + state._lastGraphData = graphData; state._lastWorkspaceRoot = '/workspace'; expect(state._engineState.cache).toBe(state._cache); expect(state._engineState.fileAnalysis).toBe(fileAnalysis); + expect(state._lastFileConnections).toBe(fileConnections); + expect(state._engineState.fileConnections).toBe(fileConnections); + expect(state._engineState.discoveredFiles).toBe(discoveredFiles); + expect(state._lastGraphData).toBe(graphData); + expect(state._engineState.graph).toBe(graphData); expect(state._engineState.workspaceRoot).toBe('/workspace'); }); }); diff --git a/packages/extension/tests/extension/pipeline/service/cache/cachedDiscovery.test.ts b/packages/extension/tests/extension/pipeline/service/cache/cachedDiscovery.test.ts new file mode 100644 index 000000000..978fd01e4 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cache/cachedDiscovery.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { + collectCachedGitIgnoredPaths, + collectCachedDirectoryPaths, + createCachedWorkspaceDiscoveryState, +} from '../../../../../src/extension/pipeline/service/cache/cachedDiscovery'; + +const childProcessMock = vi.hoisted(() => ({ + spawnSync: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + ...childProcessMock, + default: childProcessMock, +})); + +describe('pipeline/service/cache/cachedDiscovery', () => { + beforeEach(() => { + vi.mocked(spawnSync).mockReset(); + }); + + it('derives discovered file and directory metadata from cached relative paths', () => { + vi.mocked(spawnSync).mockReturnValue({ + error: undefined, + status: 1, + stdout: '', + } as never); + + expect( + createCachedWorkspaceDiscoveryState( + '/workspace', + ['src/nested/cached.ts', 'README.md'], + true, + ), + ).toEqual({ + directories: ['src', 'src/nested'], + files: [ + { + absolutePath: '/workspace/src/nested/cached.ts', + extension: '.ts', + name: 'cached.ts', + relativePath: 'src/nested/cached.ts', + }, + { + absolutePath: '/workspace/README.md', + extension: '.md', + name: 'README.md', + relativePath: 'README.md', + }, + ], + gitIgnoredPaths: [], + }); + expect(spawnSync).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace', 'check-ignore', '--stdin'], + { + encoding: 'utf8', + input: 'src\nsrc/nested\nsrc/nested/cached.ts\nREADME.md\n', + }, + ); + }); + + it('normalizes windows separators while deriving cached directory ancestry', () => { + expect( + collectCachedDirectoryPaths([ + 'src\\nested\\cached.ts', + 'src\\other\\child.ts', + ]), + ).toEqual([ + 'src', + 'src/nested', + 'src/other', + ]); + }); + + it('collects current gitignore matches only for cached paths', () => { + vi.mocked(spawnSync).mockReturnValue({ + error: undefined, + status: 0, + stdout: 'src/generated.ts\n', + } as never); + + expect( + collectCachedGitIgnoredPaths( + '/workspace', + ['src', 'src/generated.ts', 'src/kept.ts'], + true, + ), + ).toEqual(['src/generated.ts']); + expect(spawnSync).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace', 'check-ignore', '--stdin'], + { + encoding: 'utf8', + input: 'src\nsrc/generated.ts\nsrc/kept.ts\n', + }, + ); + }); + + it('maps git-normalized ignored paths back to cached relative paths', () => { + vi.mocked(spawnSync).mockReturnValue({ + error: undefined, + status: 0, + stdout: 'src/generated.ts\nexternal/generated.ts\n', + } as never); + + expect( + collectCachedGitIgnoredPaths( + '/workspace', + ['src\\generated.ts'], + true, + ), + ).toEqual(['src\\generated.ts', 'external/generated.ts']); + expect(spawnSync).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace', 'check-ignore', '--stdin'], + { + encoding: 'utf8', + input: 'src/generated.ts\n', + }, + ); + }); + + it('returns no ignored paths when git check-ignore fails', () => { + vi.mocked(spawnSync).mockReturnValueOnce({ + error: undefined, + status: 2, + stdout: 'src/generated.ts\n', + } as never); + + expect( + collectCachedGitIgnoredPaths( + '/workspace', + ['src/generated.ts'], + true, + ), + ).toEqual([]); + + vi.mocked(spawnSync).mockReturnValueOnce({ + error: new Error('git failed'), + status: 0, + stdout: 'src/generated.ts\n', + } as never); + + expect( + collectCachedGitIgnoredPaths( + '/workspace', + ['src/generated.ts'], + true, + ), + ).toEqual([]); + }); + + it('skips git when gitignore handling is disabled', () => { + expect(collectCachedGitIgnoredPaths('/workspace', ['src/generated.ts'], false)).toEqual([]); + expect(spawnSync).not.toHaveBeenCalled(); + }); + + it('skips git when there are no cached paths to check', () => { + expect(collectCachedGitIgnoredPaths('/workspace', [], true)).toEqual([]); + expect(spawnSync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/cache/index.test.ts b/packages/extension/tests/extension/pipeline/service/cache/index.test.ts index 6f38a9b3c..5a1f8e274 100644 --- a/packages/extension/tests/extension/pipeline/service/cache/index.test.ts +++ b/packages/extension/tests/extension/pipeline/service/cache/index.test.ts @@ -8,11 +8,13 @@ import { } from '../../../../../src/extension/pipeline/service/cache/index'; import { readCodeGraphyRepoMeta, + writeCodeGraphyRepoMeta, } from '../../../../../src/extension/repoSettings/meta'; import type { ICodeGraphyRepoMeta } from '../../../../../src/extension/repoSettings/meta'; vi.mock('../../../../../src/extension/repoSettings/meta', () => ({ readCodeGraphyRepoMeta: vi.fn(), + writeCodeGraphyRepoMeta: vi.fn(), })); describe('pipeline/service/cache/index', () => { @@ -127,9 +129,32 @@ describe('pipeline/service/cache/index', () => { pluginSignature: 'next-plugin-signature', settingsSignature: 'next-settings-signature', }); + expect(writeCodeGraphyRepoMeta).not.toHaveBeenCalled(); expect(warn).not.toHaveBeenCalled(); }); + it('records the current commit when index metadata is persisted', async () => { + const persistIndexMetadata = vi.fn(); + const dependencies = { + getCurrentCommitSha: vi.fn(() => 'def456'), + getPluginSignature: vi.fn(() => 'next-plugin-signature'), + getSettingsSignature: vi.fn(() => 'next-settings-signature'), + persistIndexMetadata, + warn: vi.fn(), + }; + + await persistWorkspacePipelineIndexMetadata('/workspace', dependencies); + + expect(persistIndexMetadata).toHaveBeenCalledWith('/workspace', { + pluginSignature: 'next-plugin-signature', + settingsSignature: 'next-settings-signature', + }); + expect(writeCodeGraphyRepoMeta).toHaveBeenCalledWith('/workspace', { + ...meta(), + lastIndexedCommit: 'def456', + }); + }); + it('delegates pending changed file cleanup to core metadata persistence', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-04-16T08:45:00.000Z')); diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts new file mode 100644 index 000000000..a1f669dce --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts @@ -0,0 +1,300 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + projectFileAnalysisConnections, + throwIfWorkspaceAnalysisAborted, + type FileDiscovery, + type IDiscoveredFile, +} from '@codegraphy-dev/core'; +import type { PluginRegistry } from '../../../../src/core/plugins/registry/manager'; +import type { Configuration } from '../../../../src/extension/config/reader'; +import { WorkspacePipelineCachedGraphFacade } from '../../../../src/extension/pipeline/service/cachedGraph'; +import { createCachedWorkspaceDiscoveryState } from '../../../../src/extension/pipeline/service/cache/cachedDiscovery'; +import { + isMissingFileError, + isWorkspaceAnalysisAbortError, +} from '../../../../src/extension/pipeline/service/cachedGraphWarmup/errors'; +import { warmCachedGraphAnalysisFile } from '../../../../src/extension/pipeline/service/cachedGraphWarmup/execution'; +import { createCachedGraphAnalysisWarmupInput } from '../../../../src/extension/pipeline/service/cachedGraphWarmup/input'; + +vi.mock('@codegraphy-dev/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + projectFileAnalysisConnections: vi.fn(), + throwIfWorkspaceAnalysisAborted: vi.fn(), + }; +}); + +vi.mock('../../../../src/extension/pipeline/service/cache/cachedDiscovery', () => ({ + createCachedWorkspaceDiscoveryState: vi.fn(), +})); +vi.mock('../../../../src/extension/pipeline/service/cachedGraphWarmup/errors', () => ({ + isMissingFileError: vi.fn(), + isWorkspaceAnalysisAbortError: vi.fn(), +})); +vi.mock('../../../../src/extension/pipeline/service/cachedGraphWarmup/execution', () => ({ + warmCachedGraphAnalysisFile: vi.fn(), +})); +vi.mock('../../../../src/extension/pipeline/service/cachedGraphWarmup/input', () => ({ + createCachedGraphAnalysisWarmupInput: vi.fn(), +})); + +vi.mock('vscode', () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace' } }], + getConfiguration: vi.fn(() => ({ get: vi.fn(), inspect: vi.fn(), update: vi.fn() })), + }, +})); + +const cachedAnalysis = { filePath: '/workspace/src/cached.ts', imports: [], relations: [], symbols: [] }; +const cachedFiles: IDiscoveredFile[] = [{ + absolutePath: '/workspace/src/cached.ts', + relativePath: 'src/cached.ts', +}] as never; + +class TestCachedGraphFacade extends WorkspacePipelineCachedGraphFacade { + readonly getWorkspaceRoot = vi.fn<() => string | undefined>(() => '/workspace'); + readonly hydrateCacheFromGraphCache = vi.fn(async () => undefined); + readonly activeAnalysisPluginIds = vi.fn(( + _pluginIds: readonly string[] | undefined, _disabledPlugins: ReadonlySet, + ) => ['plugin.active']); + readonly buildGraphDataFromAnalysis = vi.fn(( + _fileAnalysis: Map, _workspaceRoot: string, _showOrphans: boolean, _disabledPlugins: Set, + ) => ({ nodes: [{ id: 'graph', label: 'Graph', color: '#333333' }], edges: [] })); + + constructor() { + super({ + subscriptions: [], + workspaceState: { + get: vi.fn(), + update: vi.fn(), + }, + } as never); + this._cache = { + files: { + 'src/cached.ts': { analysis: cachedAnalysis, mtime: 1, size: 10 }, + }, + } as never; + } + + _config = { + get: vi.fn((key: string, defaultValue: unknown) => + key === 'nodeVisibility' ? { Symbol: true } : defaultValue, + ), + getAll: vi.fn(() => ({ respectGitignore: true, showOrphans: false })), + } as unknown as Configuration; + + _discovery = { kind: 'discovery' } as unknown as FileDiscovery; + _registry = { + list: vi.fn(() => []), + } as unknown as PluginRegistry; + + protected override _getWorkspaceRoot(): string | undefined { + return this.getWorkspaceRoot(); + } + + protected override async _hydrateCacheFromGraphCache(): Promise { + await this.hydrateCacheFromGraphCache(); + } + + protected override _getActiveAnalysisPluginIds( + pluginIds: readonly string[] | undefined, + disabledPlugins: ReadonlySet, + ): string[] { + return this.activeAnalysisPluginIds(pluginIds, disabledPlugins); + } + + protected override _buildGraphDataFromAnalysis( + fileAnalysis: Map, + workspaceRoot: string, + showOrphans: boolean, + disabledPlugins: Set, + ) { + return this.buildGraphDataFromAnalysis(fileAnalysis, workspaceRoot, showOrphans, disabledPlugins); + } +} + +interface CachedGraphState { + _lastDiscoveredDirectories: string[]; + _lastDiscoveredFiles: IDiscoveredFile[]; + _lastFileAnalysis: Map; + _lastFileConnections: Map; + _lastGitIgnoredPaths: string[]; + _lastWorkspaceRoot: string; +} + +function cachedGraphState(facade: TestCachedGraphFacade): CachedGraphState { + return facade as unknown as CachedGraphState; +} + +function setupCachedDiscovery(): Map { + const projectedConnections = new Map([['src/cached.ts', []]]); + + vi.mocked(projectFileAnalysisConnections).mockReturnValue(projectedConnections as never); + vi.mocked(createCachedWorkspaceDiscoveryState).mockReturnValue({ + directories: ['src'], + files: cachedFiles, + gitIgnoredPaths: ['dist/generated.ts'], + }); + vi.mocked(createCachedGraphAnalysisWarmupInput).mockReturnValue({ + file: cachedFiles[0], + } as never); + vi.mocked(warmCachedGraphAnalysisFile).mockResolvedValue(undefined); + + return projectedConnections; +} + +async function flushWarmupCatch(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('extension/pipeline/service/cachedGraph', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isMissingFileError).mockReturnValue(false); + vi.mocked(isWorkspaceAnalysisAbortError).mockReturnValue(false); + setupCachedDiscovery(); + }); + + it('returns an empty graph after hydration when no workspace is open', async () => { + const facade = new TestCachedGraphFacade(); + facade.getWorkspaceRoot.mockReturnValue(undefined); + + await expect(facade.loadCachedGraph()).resolves.toEqual({ nodes: [], edges: [] }); + + expect(facade.hydrateCacheFromGraphCache).toHaveBeenCalledOnce(); + expect(facade._config.getAll).not.toHaveBeenCalled(); + expect(createCachedWorkspaceDiscoveryState).not.toHaveBeenCalled(); + expect(warmCachedGraphAnalysisFile).not.toHaveBeenCalled(); + }); + + it('replays cached analysis into graph state and schedules warmup by default', async () => { + const facade = new TestCachedGraphFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + const projectedConnections = setupCachedDiscovery(); + + await expect( + facade.loadCachedGraph(['ignored'], disabledPlugins, signal), + ).resolves.toEqual({ + nodes: [{ id: 'graph', label: 'Graph', color: '#333333' }], + edges: [], + }); + + expect(throwIfWorkspaceAnalysisAborted).toHaveBeenCalledWith(signal); + expect(createCachedWorkspaceDiscoveryState).toHaveBeenCalledWith( + '/workspace', + ['src/cached.ts'], + true, + ); + expect(projectFileAnalysisConnections).toHaveBeenCalledWith( + new Map([['src/cached.ts', cachedAnalysis]]), + '/workspace', + ); + const retainedState = cachedGraphState(facade); + expect(retainedState._lastDiscoveredFiles).toEqual(cachedFiles); + expect(retainedState._lastDiscoveredDirectories).toEqual(['src']); + expect(retainedState._lastGitIgnoredPaths).toEqual(['dist/generated.ts']); + expect(retainedState._lastFileAnalysis).toEqual(new Map([['src/cached.ts', cachedAnalysis]])); + expect(retainedState._lastFileConnections).toBe(projectedConnections); + expect(retainedState._lastWorkspaceRoot).toBe('/workspace'); + expect(facade.buildGraphDataFromAnalysis).toHaveBeenCalledWith( + new Map([['src/cached.ts', cachedAnalysis]]), + '/workspace', + false, + disabledPlugins, + ); + + expect(createCachedGraphAnalysisWarmupInput).toHaveBeenCalledWith({ + disabledPlugins, + files: cachedFiles, + getActiveAnalysisPluginIds: expect.any(Function), + nodeVisibility: { Symbol: true }, + registry: facade._registry, + signal, + workspaceRoot: '/workspace', + }); + const warmupInput = vi.mocked(createCachedGraphAnalysisWarmupInput).mock.calls[0][0]; + expect(warmupInput.getActiveAnalysisPluginIds(new Set(['disabled']))).toEqual(['plugin.active']); + expect(facade.activeAnalysisPluginIds).toHaveBeenCalledWith(undefined, new Set(['disabled'])); + expect(warmCachedGraphAnalysisFile).toHaveBeenCalledWith( + { file: cachedFiles[0] }, + facade._discovery, + facade._registry, + ); + }); + + it('honors gitignore and warmup replay options independently', async () => { + const facade = new TestCachedGraphFacade(); + + await facade.loadCachedGraph([], new Set(), undefined, { + includeCurrentGitignoreMetadata: false, + warmAnalysis: false, + }); + + expect(createCachedWorkspaceDiscoveryState).toHaveBeenLastCalledWith( + '/workspace', + ['src/cached.ts'], + false, + ); + expect(createCachedGraphAnalysisWarmupInput).not.toHaveBeenCalled(); + expect(warmCachedGraphAnalysisFile).not.toHaveBeenCalled(); + + vi.clearAllMocks(); + setupCachedDiscovery(); + vi.mocked(facade._config.getAll).mockReturnValueOnce({ + showOrphans: true, + respectGitignore: false, + } as never); + + await facade.loadCachedGraph(); + + expect(createCachedWorkspaceDiscoveryState).toHaveBeenLastCalledWith( + '/workspace', + ['src/cached.ts'], + false, + ); + expect(facade.buildGraphDataFromAnalysis).toHaveBeenLastCalledWith( + new Map([['src/cached.ts', cachedAnalysis]]), + '/workspace', + true, + new Set(), + ); + }); + + it('logs only unexpected cached analysis warmup failures', async () => { + const facade = new TestCachedGraphFacade(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const abortError = new Error('aborted'); + const missingFileError = new Error('missing'); + const failedError = new Error('failed'); + + vi.mocked(warmCachedGraphAnalysisFile).mockRejectedValueOnce(abortError); + vi.mocked(isWorkspaceAnalysisAbortError).mockImplementation(error => error === abortError); + await facade.loadCachedGraph(); + await flushWarmupCatch(); + expect(warnSpy).not.toHaveBeenCalled(); + + vi.mocked(warmCachedGraphAnalysisFile).mockRejectedValueOnce(missingFileError); + vi.mocked(isWorkspaceAnalysisAbortError).mockReturnValue(false); + vi.mocked(isMissingFileError).mockImplementation(error => error === missingFileError); + await facade.loadCachedGraph(); + await flushWarmupCatch(); + expect(warnSpy).not.toHaveBeenCalled(); + + vi.mocked(warmCachedGraphAnalysisFile).mockRejectedValueOnce(failedError); + vi.mocked(isMissingFileError).mockReturnValue(false); + await facade.loadCachedGraph(); + await flushWarmupCatch(); + + expect(warnSpy).toHaveBeenCalledWith( + '[CodeGraphy] Failed to warm cached graph analysis.', + failedError, + ); + + vi.mocked(createCachedGraphAnalysisWarmupInput).mockReturnValueOnce(undefined); + await facade.loadCachedGraph(); + expect(warmCachedGraphAnalysisFile).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/candidates.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/candidates.test.ts new file mode 100644 index 000000000..892184e77 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/candidates.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import { isCachedGraphAnalysisWarmupCandidate } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/candidates'; + +function file(relativePath: string): IDiscoveredFile { + return { absolutePath: `/workspace/${relativePath}`, relativePath } as IDiscoveredFile; +} + +describe('extension/pipeline/service/cachedGraphWarmup/candidates', () => { + it('keeps source files and rejects generated or tool-owned paths', () => { + expect(isCachedGraphAnalysisWarmupCandidate(file('src/app.ts'))).toBe(true); + expect(isCachedGraphAnalysisWarmupCandidate(file('dist/app.js'))).toBe(false); + expect(isCachedGraphAnalysisWarmupCandidate(file('packages/core/.codegraphy/graph.lbug'))).toBe(false); + expect(isCachedGraphAnalysisWarmupCandidate(file('node_modules/pkg/index.js'))).toBe(false); + expect(isCachedGraphAnalysisWarmupCandidate(file('src\\coverage\\report.ts'))).toBe(false); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/errors.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/errors.test.ts new file mode 100644 index 000000000..17099176e --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/errors.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { + isMissingFileError, + isWorkspaceAnalysisAbortError, +} from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/errors'; + +describe('extension/pipeline/service/cachedGraphWarmup/errors', () => { + it('detects only AbortError instances as workspace analysis aborts', () => { + const abortError = new Error('aborted'); + abortError.name = 'AbortError'; + + expect(isWorkspaceAnalysisAbortError(abortError)).toBe(true); + expect(isWorkspaceAnalysisAbortError(new Error('AbortError'))).toBe(false); + expect(isWorkspaceAnalysisAbortError({ name: 'AbortError' })).toBe(false); + }); + + it('detects only Error instances with ENOENT codes as missing files', () => { + const missingFileError = Object.assign(new Error('missing'), { code: 'ENOENT' }); + + expect(isMissingFileError(missingFileError)).toBe(true); + expect(isMissingFileError(Object.assign(new Error('missing'), { code: 'EACCES' }))).toBe(false); + expect(isMissingFileError({ code: 'ENOENT' })).toBe(false); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/execution.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/execution.test.ts new file mode 100644 index 000000000..bc6398247 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/execution.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { throwIfWorkspaceAnalysisAborted, type IDiscoveredFile } from '@codegraphy-dev/core'; +import { warmCachedGraphAnalysisFile } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/execution'; +import type { CachedGraphAnalysisWarmupInput } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/contracts'; + +vi.mock('@codegraphy-dev/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + throwIfWorkspaceAnalysisAborted: vi.fn(), + }; +}); + +const file = { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', +} as IDiscoveredFile; + +function createInput(): CachedGraphAnalysisWarmupInput { + return { + analysisContext: { workspaceRoot: '/workspace' } as never, + disabledPluginSnapshot: new Set(['plugin.disabled']), + file, + pluginIds: ['plugin.active'], + signal: new AbortController().signal, + workspaceRoot: '/workspace', + }; +} + +describe('extension/pipeline/service/cachedGraphWarmup/execution', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not read files when the registry cannot analyze warmup results', async () => { + const discovery = { readContent: vi.fn() }; + + await warmCachedGraphAnalysisFile(createInput(), discovery, {}); + + expect(discovery.readContent).not.toHaveBeenCalled(); + expect(throwIfWorkspaceAnalysisAborted).not.toHaveBeenCalled(); + }); + + it('reads content and analyzes the selected warmup file with abort checks', async () => { + const input = createInput(); + const discovery = { readContent: vi.fn(async () => 'content') }; + const analyzeFileResultForPlugins = vi.fn(async () => undefined); + + await warmCachedGraphAnalysisFile(input, discovery, { analyzeFileResultForPlugins }); + + expect(throwIfWorkspaceAnalysisAborted).toHaveBeenNthCalledWith(1, input.signal); + expect(discovery.readContent).toHaveBeenCalledWith(file); + expect(throwIfWorkspaceAnalysisAborted).toHaveBeenNthCalledWith(2, input.signal); + expect(analyzeFileResultForPlugins).toHaveBeenCalledWith( + '/workspace/src/a.ts', + 'content', + '/workspace', + ['plugin.active'], + input.analysisContext, + { disabledPlugins: new Set(['plugin.disabled']) }, + ); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/input.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/input.test.ts new file mode 100644 index 000000000..480291f73 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/input.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createWorkspacePluginAnalysisContext, + SYMBOLS_ANALYSIS_CACHE_TIER, + type IDiscoveredFile, +} from '@codegraphy-dev/core'; +import { createWorkspacePipelineAnalysisCacheTiers } from '../../../../../src/extension/pipeline/service/cache/tiers'; +import { createCachedGraphAnalysisWarmupInput } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/input'; +import { selectCachedGraphAnalysisWarmupFile } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/selection'; +import type { CachedGraphAnalysisWarmupOptions } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/contracts'; + +vi.mock('@codegraphy-dev/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createWorkspacePluginAnalysisContext: vi.fn((workspaceRoot, options) => ({ + options, + workspaceRoot, + })), + }; +}); + +vi.mock('../../../../../src/extension/pipeline/service/cache/tiers', () => ({ + createWorkspacePipelineAnalysisCacheTiers: vi.fn(), +})); + +vi.mock('../../../../../src/extension/pipeline/service/cachedGraphWarmup/selection', () => ({ + selectCachedGraphAnalysisWarmupFile: vi.fn(), +})); + +const selectedFile = { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', +} as IDiscoveredFile; + +function createOptions( + overrides: Partial = {}, +): CachedGraphAnalysisWarmupOptions { + return { + disabledPlugins: new Set(['plugin.disabled']), + files: [selectedFile], + getActiveAnalysisPluginIds: vi.fn(() => ['plugin.active']), + nodeVisibility: { Symbol: true }, + registry: { + analyzeFileResultForPlugins: vi.fn(), + supportsFile: vi.fn(() => true), + }, + signal: new AbortController().signal, + workspaceRoot: '/workspace', + ...overrides, + }; +} + +describe('extension/pipeline/service/cachedGraphWarmup/input', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(selectCachedGraphAnalysisWarmupFile).mockReturnValue(selectedFile); + vi.mocked(createWorkspacePipelineAnalysisCacheTiers).mockReturnValue({ + active: undefined, + } as never); + }); + + it('does not build input when the registry cannot analyze warmup files', () => { + const options = createOptions({ + registry: { supportsFile: vi.fn(() => true) }, + }); + + expect(createCachedGraphAnalysisWarmupInput(options)).toBeUndefined(); + expect(selectCachedGraphAnalysisWarmupFile).not.toHaveBeenCalled(); + }); + + it('does not build input when no warmup file can be selected', () => { + vi.mocked(selectCachedGraphAnalysisWarmupFile).mockReturnValue(undefined); + const options = createOptions(); + + expect(createCachedGraphAnalysisWarmupInput(options)).toBeUndefined(); + expect(selectCachedGraphAnalysisWarmupFile).toHaveBeenCalledWith( + options.registry, + [selectedFile], + ); + }); + + it('builds warmup input with a disabled-plugin snapshot and plugin analysis context', () => { + const options = createOptions(); + + const input = createCachedGraphAnalysisWarmupInput(options); + + expect(options.getActiveAnalysisPluginIds).toHaveBeenCalledWith(new Set(['plugin.disabled'])); + expect(createWorkspacePipelineAnalysisCacheTiers).toHaveBeenCalledWith( + { Symbol: true }, + ['plugin.active'], + ); + expect(createWorkspacePluginAnalysisContext).toHaveBeenCalledWith('/workspace', { + features: { symbols: true }, + }); + expect(input).toEqual({ + analysisContext: { + options: { features: { symbols: true } }, + workspaceRoot: '/workspace', + }, + disabledPluginSnapshot: new Set(['plugin.disabled']), + file: selectedFile, + pluginIds: ['plugin.active'], + signal: options.signal, + workspaceRoot: '/workspace', + }); + expect(input?.disabledPluginSnapshot).not.toBe(options.disabledPlugins); + }); + + it('enables symbols only when the active cache tiers include symbol analysis', () => { + vi.mocked(createWorkspacePipelineAnalysisCacheTiers).mockReturnValueOnce({ + active: ['baseline'], + } as never); + createCachedGraphAnalysisWarmupInput(createOptions()); + expect(createWorkspacePluginAnalysisContext).toHaveBeenLastCalledWith('/workspace', { + features: { symbols: false }, + }); + + vi.mocked(createWorkspacePipelineAnalysisCacheTiers).mockReturnValueOnce({ + active: [SYMBOLS_ANALYSIS_CACHE_TIER], + } as never); + createCachedGraphAnalysisWarmupInput(createOptions()); + expect(createWorkspacePluginAnalysisContext).toHaveBeenLastCalledWith('/workspace', { + features: { symbols: true }, + }); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/ranking.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/ranking.test.ts new file mode 100644 index 000000000..9b9e3dcd4 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/ranking.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import { selectMostRepresentedCachedGraphWarmupFile } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/ranking'; + +function file(relativePath: string, extension: string): IDiscoveredFile { + return { absolutePath: `/workspace/${relativePath}`, extension, relativePath } as IDiscoveredFile; +} + +describe('extension/pipeline/service/cachedGraphWarmup/ranking', () => { + it('returns undefined when there are no supported files', () => { + expect(selectMostRepresentedCachedGraphWarmupFile([])).toBeUndefined(); + }); + + it('selects the first file from the most represented extension', () => { + const python = file('src/b.py', '.py'); + const firstTypeScript = file('src/a.ts', '.ts'); + const secondTypeScript = file('src/c.ts', '.ts'); + + expect(selectMostRepresentedCachedGraphWarmupFile([ + python, + firstTypeScript, + secondTypeScript, + ])).toBe(firstTypeScript); + }); + + it('uses the earliest represented extension as the tie breaker', () => { + const python = file('src/b.py', '.py'); + const typeScript = file('src/a.ts', '.ts'); + + expect(selectMostRepresentedCachedGraphWarmupFile([python, typeScript])).toBe(python); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/selection.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/selection.test.ts new file mode 100644 index 000000000..a558a32e6 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/selection.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import { selectCachedGraphAnalysisWarmupFile } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/selection'; + +function file(relativePath: string, extension: string): IDiscoveredFile { + return { absolutePath: `/workspace/${relativePath}`, extension, relativePath } as IDiscoveredFile; +} + +describe('extension/pipeline/service/cachedGraphWarmup/selection', () => { + const generated = file('dist/generated.ts', '.ts'); + const firstTypeScript = file('src/a.ts', '.ts'); + const python = file('src/b.py', '.py'); + const secondTypeScript = file('src/c.ts', '.ts'); + + it('uses the first file when the registry has no support predicate', () => { + expect(selectCachedGraphAnalysisWarmupFile({}, [firstTypeScript, python])).toBe(firstTypeScript); + expect(selectCachedGraphAnalysisWarmupFile({}, [])).toBeUndefined(); + }); + + it('selects the most represented supported candidate outside generated folders', () => { + const supportsFile = vi.fn((filePath: string) => filePath.endsWith('.ts')); + + expect(selectCachedGraphAnalysisWarmupFile( + { supportsFile }, + [generated, firstTypeScript, python, secondTypeScript], + )).toBe(firstTypeScript); + expect(supportsFile).not.toHaveBeenCalledWith('/workspace/dist/generated.ts'); + }); + + it('falls back to supported generated files and then the first file', () => { + expect(selectCachedGraphAnalysisWarmupFile( + { supportsFile: filePath => filePath.includes('generated') }, + [generated, python], + )).toBe(generated); + + expect(selectCachedGraphAnalysisWarmupFile( + { supportsFile: () => false }, + [generated, python], + )).toBe(generated); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/support.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/support.test.ts new file mode 100644 index 000000000..fcd4dd907 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/support.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import { getSupportedCachedGraphAnalysisWarmupFiles } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/support'; + +const files = [ + { absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts' }, + { absolutePath: '/workspace/src/b.py', relativePath: 'src/b.py' }, +] as IDiscoveredFile[]; + +describe('extension/pipeline/service/cachedGraphWarmup/support', () => { + it('keeps files supported by absolute or relative path', () => { + const supportsFile = vi.fn((filePath: string) => + filePath === '/workspace/src/a.ts' || filePath === 'src/b.py', + ); + + expect(getSupportedCachedGraphAnalysisWarmupFiles({ supportsFile }, files)).toEqual(files); + expect(supportsFile).toHaveBeenCalledWith('/workspace/src/a.ts'); + expect(supportsFile).toHaveBeenCalledWith('/workspace/src/b.py'); + expect(supportsFile).toHaveBeenCalledWith('src/b.py'); + }); + + it('returns no files when the registry has no support predicate or rejects every path', () => { + expect(getSupportedCachedGraphAnalysisWarmupFiles({}, files)).toEqual([]); + expect(getSupportedCachedGraphAnalysisWarmupFiles({ supportsFile: () => false }, files)).toEqual([]); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts b/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts index b69f11b60..4a1f32b5e 100644 --- a/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as vscode from 'vscode'; +import { spawnSync } from 'node:child_process'; import { WorkspacePipelineDiscoveryFacade } from '../../../../src/extension/pipeline/service/discoveryFacade'; import type { Configuration } from '../../../../src/extension/config/reader'; import type { FileDiscovery } from '@codegraphy-dev/core'; @@ -41,6 +42,10 @@ vi.mock('../../../../src/extension/pipeline/service/runtime/run', () => ({ rebuildWorkspacePipelineGraph: vi.fn(), })); +vi.mock('node:child_process', () => ({ + spawnSync: vi.fn(), +})); + vi.mock('vscode', () => ({ workspace: { workspaceFolders: [{ uri: { fsPath: '/workspace' } }], @@ -89,6 +94,7 @@ class TestDiscoveryFacade extends WorkspacePipelineDiscoveryFacade { } _config = { + get: vi.fn((_key: string, defaultValue: unknown) => defaultValue), getAll: vi.fn(() => ({ showOrphans: true, respectGitignore: true })), } as unknown as Configuration; @@ -129,6 +135,11 @@ describe('pipeline/service/discoveryFacade', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(spawnSync).mockReturnValue({ + error: undefined, + status: 1, + stdout: '', + } as never); vi.mocked(createWorkspacePipelineDiscoveryDependencies).mockReturnValue('discovery-deps' as never); vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mockResolvedValue({ directories: ['src/new-folder'], @@ -458,6 +469,220 @@ describe('pipeline/service/discoveryFacade', () => { expect(discoveryState(facade)._lastDiscoveredDirectories).toEqual(['src', 'src/nested']); }); + it('loads cached graph data without walking the workspace again', async () => { + const facade = new TestDiscoveryFacade(); + const cachedAnalysis = { + filePath: '/workspace/src/nested/cached.ts', + relations: [], + }; + facade._cache = { + version: 'test', + files: { + 'src/nested/cached.ts': { + mtime: 1, + analysis: cachedAnalysis, + }, + }, + } as never; + vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mockRejectedValueOnce( + new Error('full discovery should not run for cached replay'), + ); + vi.spyOn( + facade as unknown as { + _buildGraphDataFromAnalysis: (...args: unknown[]) => unknown; + }, + '_buildGraphDataFromAnalysis', + ).mockReturnValue({ + nodes: [{ id: 'src/nested/cached.ts', label: 'cached.ts', color: '#333333' }], + edges: [], + }); + + await expect(facade.loadCachedGraph()).resolves.toEqual({ + nodes: [{ id: 'src/nested/cached.ts', label: 'cached.ts', color: '#333333' }], + edges: [], + }); + + expect(discoverWorkspacePipelineFilesWithWarnings).not.toHaveBeenCalled(); + expect(discoveryState(facade)._lastDiscoveredDirectories).toEqual(['src', 'src/nested']); + expect(discoveryState(facade)._lastGitIgnoredPaths).toEqual([]); + }); + + it('warms one cached source file through the routed analyzer after cached replay', async () => { + const facade = new TestDiscoveryFacade(); + const warmContent = createDeferred(); + const analyzeFileResultForPlugins = vi.fn(async () => ({ + filePath: '/workspace/src/nested/cached.ts', + relations: [], + })); + facade._discovery = { + readContent: vi.fn(() => warmContent.promise), + } as unknown as FileDiscovery; + facade._registry = { + analyzeFileResultForPlugins, + list: vi.fn(() => [{ plugin: { id: 'plugin.typescript' } }]), + supportsFile: vi.fn(() => true), + } as unknown as PluginRegistry; + facade._cache = { + version: 'test', + files: { + 'docs/readme.md': { + mtime: 1, + analysis: { + filePath: '/workspace/docs/readme.md', + relations: [], + }, + }, + '.stryker-tmp/sandbox/src/mutant.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/.stryker-tmp/sandbox/src/mutant.ts', + relations: [], + }, + }, + 'src/nested/cached.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/src/nested/cached.ts', + relations: [], + }, + }, + 'src/nested/next.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/src/nested/next.ts', + relations: [], + }, + }, + }, + } as never; + vi.spyOn( + facade as unknown as { + _buildGraphDataFromAnalysis: (...args: unknown[]) => unknown; + }, + '_buildGraphDataFromAnalysis', + ).mockReturnValue({ + nodes: [{ id: 'src/nested/cached.ts', label: 'cached.ts', color: '#333333' }], + edges: [], + }); + + await expect(facade.loadCachedGraph()).resolves.toEqual({ + nodes: [{ id: 'src/nested/cached.ts', label: 'cached.ts', color: '#333333' }], + edges: [], + }); + + expect(facade._discovery.readContent).toHaveBeenCalledWith({ + absolutePath: '/workspace/src/nested/cached.ts', + extension: '.ts', + name: 'cached.ts', + relativePath: 'src/nested/cached.ts', + }); + expect(analyzeFileResultForPlugins).not.toHaveBeenCalled(); + + warmContent.resolve('export const cached = 1;\n'); + await vi.waitFor(() => expect(analyzeFileResultForPlugins).toHaveBeenCalledOnce()); + expect(analyzeFileResultForPlugins).toHaveBeenCalledWith( + '/workspace/src/nested/cached.ts', + 'export const cached = 1;\n', + '/workspace', + ['plugin.typescript'], + expect.objectContaining({ + features: expect.objectContaining({ symbols: false }), + mode: 'workspace', + }), + { disabledPlugins: new Set() }, + ); + }); + + it('does not warm cached source analysis when cached replay disables warm-up', async () => { + const facade = new TestDiscoveryFacade(); + const analyzeFileResultForPlugins = vi.fn(); + facade._discovery = { + readContent: vi.fn(async () => 'export const cached = 1;\n'), + } as unknown as FileDiscovery; + facade._registry = { + analyzeFileResultForPlugins, + list: vi.fn(() => [{ plugin: { id: 'plugin.typescript' } }]), + supportsFile: vi.fn(() => true), + } as unknown as PluginRegistry; + facade._cache = { + version: 'test', + files: { + 'src/nested/cached.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/src/nested/cached.ts', + relations: [], + }, + }, + }, + } as never; + vi.spyOn( + facade as unknown as { + _buildGraphDataFromAnalysis: (...args: unknown[]) => unknown; + }, + '_buildGraphDataFromAnalysis', + ).mockReturnValue({ + nodes: [{ id: 'src/nested/cached.ts', label: 'cached.ts', color: '#333333' }], + edges: [], + }); + + await expect(facade.loadCachedGraph([], new Set(), undefined, { + warmAnalysis: false, + })).resolves.toEqual({ + nodes: [{ id: 'src/nested/cached.ts', label: 'cached.ts', color: '#333333' }], + edges: [], + }); + + expect(facade._discovery.readContent).not.toHaveBeenCalled(); + expect(analyzeFileResultForPlugins).not.toHaveBeenCalled(); + }); + + it('skips cached analysis warm-up quietly when the selected file disappeared', async () => { + const facade = new TestDiscoveryFacade(); + const readError = Object.assign(new Error('missing cached file'), { code: 'ENOENT' }); + const analyzeFileResultForPlugins = vi.fn(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + facade._discovery = { + readContent: vi.fn(async () => { + throw readError; + }), + } as unknown as FileDiscovery; + facade._registry = { + analyzeFileResultForPlugins, + list: vi.fn(() => [{ plugin: { id: 'plugin.typescript' } }]), + supportsFile: vi.fn(() => true), + } as unknown as PluginRegistry; + facade._cache = { + version: 'test', + files: { + 'src/gone.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/src/gone.ts', + relations: [], + }, + }, + }, + } as never; + vi.spyOn( + facade as unknown as { + _buildGraphDataFromAnalysis: (...args: unknown[]) => unknown; + }, + '_buildGraphDataFromAnalysis', + ).mockReturnValue({ + nodes: [{ id: 'src/gone.ts', label: 'gone.ts', color: '#333333' }], + edges: [], + }); + + await facade.loadCachedGraph(); + + await vi.waitFor(() => expect(facade._discovery.readContent).toHaveBeenCalledOnce()); + expect(analyzeFileResultForPlugins).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + it('applies current gitignore metadata when replaying cached graph data', async () => { const facade = new TestDiscoveryFacade(); const cachedAnalysis = { @@ -473,15 +698,10 @@ describe('pipeline/service/discoveryFacade', () => { }, }, } as never; - vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mockResolvedValueOnce({ - directories: ['example-python', 'example-python/src'], - files: [ - { - absolutePath: '/workspace/example-python/src/main.py', - relativePath: 'example-python/src/main.py', - }, - ], - gitIgnoredPaths: ['example-python/src/main.py'], + vi.mocked(spawnSync).mockReturnValueOnce({ + error: undefined, + status: 0, + stdout: 'example-python/src/main.py\n', } as never); vi.spyOn( facade as unknown as { @@ -495,15 +715,51 @@ describe('pipeline/service/discoveryFacade', () => { await facade.loadCachedGraph(); - expect(discoverWorkspacePipelineFilesWithWarnings).toHaveBeenCalledWith( - 'discovery-deps', - '/workspace', - { showOrphans: true, respectGitignore: true }, + expect(discoverWorkspacePipelineFilesWithWarnings).not.toHaveBeenCalled(); + expect(spawnSync).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace', 'check-ignore', '--stdin'], + { + encoding: 'utf8', + input: 'example-python\nexample-python/src\nexample-python/src/main.py\n', + }, + ); + expect(discoveryState(facade)._lastGitIgnoredPaths).toEqual(['example-python/src/main.py']); + }); + + it('can defer current gitignore metadata while replaying stale cached graph data', async () => { + const facade = new TestDiscoveryFacade(); + const cachedAnalysis = { + filePath: '/workspace/example-python/src/main.py', + relations: [], + }; + facade._cache = { + version: 'test', + files: { + 'example-python/src/main.py': { + mtime: 1, + analysis: cachedAnalysis, + }, + }, + } as never; + vi.spyOn( + facade as unknown as { + _buildGraphDataFromAnalysis: (...args: unknown[]) => unknown; + }, + '_buildGraphDataFromAnalysis', + ).mockReturnValue({ + nodes: [{ id: 'example-python/src/main.py', label: 'main.py', color: '#333333' }], + edges: [], + }); + + await facade.loadCachedGraph( [], - ['plugin-filter'], + new Set(), undefined, - expect.any(Function), + { includeCurrentGitignoreMetadata: false }, ); - expect(discoveryState(facade)._lastGitIgnoredPaths).toEqual(['example-python/src/main.py']); + + expect(spawnSync).not.toHaveBeenCalled(); + expect(discoveryState(facade)._lastGitIgnoredPaths).toEqual([]); }); }); diff --git a/packages/extension/tests/extension/pipeline/service/graphDiscovery.test.ts b/packages/extension/tests/extension/pipeline/service/graphDiscovery.test.ts new file mode 100644 index 000000000..396ed517d --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/graphDiscovery.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import type { FileDiscovery, IDiscoveredFile } from '@codegraphy-dev/core'; +import type { IProjectedConnection } from '../../../../src/core/plugins/types/contracts'; +import type { Configuration } from '../../../../src/extension/config/reader'; +import { WorkspacePipelineGraphDiscoveryFacade } from '../../../../src/extension/pipeline/service/graphDiscovery'; +import { + createWorkspacePipelineDiscoveryDependencies, + discoverWorkspacePipelineFilesWithWarnings, +} from '../../../../src/extension/pipeline/service/runtime/discovery'; +import type { IGraphData } from '../../../../src/shared/graph/contracts'; + +vi.mock('../../../../src/extension/pipeline/service/runtime/discovery', () => ({ + createWorkspacePipelineDiscoveryDependencies: vi.fn(), + discoverWorkspacePipelineFilesWithWarnings: vi.fn(), +})); + +vi.mock('vscode', () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace' } }], + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + inspect: vi.fn(), + update: vi.fn(), + })), + createFileSystemWatcher: vi.fn(), + onDidChangeConfiguration: vi.fn(), + onDidSaveTextDocument: vi.fn(), + }, + window: { + showWarningMessage: vi.fn(), + }, +})); + +class TestGraphDiscoveryFacade extends WorkspacePipelineGraphDiscoveryFacade { + readonly buildGraphData = vi.fn(( + _fileConnections: Map, + _workspaceRoot: string, + _showOrphans: boolean, + _disabledPlugins: Set, + ): IGraphData => ({ + nodes: [{ id: 'graph', label: 'Graph', color: '#333333' }], + edges: [], + })); + readonly getWorkspaceRoot = vi.fn<() => string | undefined>(() => '/workspace'); + + _config = { + get: vi.fn((_key: string, defaultValue: unknown) => defaultValue), + getAll: vi.fn(() => ({ respectGitignore: true, showOrphans: true })), + } as unknown as Configuration; + + _discovery = { kind: 'discovery' } as unknown as FileDiscovery; + + get discoveredDirectories(): string[] { + return this._lastDiscoveredDirectories; + } + + get discoveredFiles(): IDiscoveredFile[] { + return this._lastDiscoveredFiles; + } + + get fileAnalysis(): ReadonlyMap { + return this._lastFileAnalysis; + } + + get fileConnections(): ReadonlyMap { + return this._lastFileConnections; + } + + get gitIgnoredPaths(): string[] { + return this._lastGitIgnoredPaths; + } + + get workspaceRoot(): string { + return this._lastWorkspaceRoot; + } + + constructor() { + super({ + subscriptions: [], + workspaceState: { + get: vi.fn(), + update: vi.fn(), + }, + } as never); + } + + protected override _buildGraphData( + fileConnections: Map, + workspaceRoot: string, + showOrphans: boolean, + disabledPlugins: Set = new Set(), + ): IGraphData { + return this.buildGraphData(fileConnections, workspaceRoot, showOrphans, disabledPlugins); + } + + protected override _getEffectiveCustomFilterPatterns(filterPatterns: string[]): string[] { + return filterPatterns.length > 0 ? [`custom:${filterPatterns.join(',')}`] : []; + } + + protected override _getEffectivePluginFilterPatterns(disabledPlugins: ReadonlySet): string[] { + return [...disabledPlugins].map(pluginId => `plugin:${pluginId}`); + } + + protected override _getWorkspaceRoot(): string | undefined { + return this.getWorkspaceRoot(); + } +} + +describe('extension/pipeline/service/graphDiscovery', () => { + const fileA = { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', + } as IDiscoveredFile; + const fileB = { + absolutePath: '/workspace/src/b.ts', + relativePath: 'src/b.ts', + } as IDiscoveredFile; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createWorkspacePipelineDiscoveryDependencies).mockReturnValue('discovery-deps' as never); + vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mockResolvedValue({ + directories: ['src'], + files: [fileA, fileB], + gitIgnoredPaths: ['dist/generated.ts'], + } as never); + }); + + it('returns an empty graph without discovery when no workspace is open', async () => { + const facade = new TestGraphDiscoveryFacade(); + facade.getWorkspaceRoot.mockReturnValue(undefined); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await expect(facade.discoverGraph()).resolves.toEqual({ nodes: [], edges: [] }); + + expect(log).toHaveBeenCalledWith('[CodeGraphy] No workspace folder open'); + expect(discoverWorkspacePipelineFilesWithWarnings).not.toHaveBeenCalled(); + expect(facade.buildGraphData).not.toHaveBeenCalled(); + }); + + it('discovers workspace files, stores discovery state, and builds the cold graph', async () => { + const facade = new TestGraphDiscoveryFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + + await expect(facade.discoverGraph(['dist/**'], disabledPlugins, signal)).resolves.toEqual({ + nodes: [{ id: 'graph', label: 'Graph', color: '#333333' }], + edges: [], + }); + + expect(createWorkspacePipelineDiscoveryDependencies).toHaveBeenCalledWith(facade._discovery); + expect(discoverWorkspacePipelineFilesWithWarnings).toHaveBeenCalledWith( + 'discovery-deps', + '/workspace', + { respectGitignore: true, showOrphans: true }, + ['custom:dist/**'], + ['plugin:plugin.disabled'], + signal, + expect.any(Function), + ); + + const warn = vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mock.calls[0][6]; + warn('Discovery warning'); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith('Discovery warning'); + + const expectedConnections = new Map([ + ['src/a.ts', []], + ['src/b.ts', []], + ]); + expect(facade.buildGraphData).toHaveBeenCalledWith( + expectedConnections, + '/workspace', + true, + disabledPlugins, + ); + expect(facade.discoveredDirectories).toEqual(['src']); + expect(facade.discoveredFiles).toEqual([fileA, fileB]); + expect(facade.fileAnalysis).toEqual(new Map()); + expect(facade.fileConnections).toEqual(expectedConnections); + expect(facade.gitIgnoredPaths).toEqual(['dist/generated.ts']); + expect(facade.workspaceRoot).toBe('/workspace'); + }); + + it('stores empty directory and gitignore lists when discovery omits them', async () => { + const facade = new TestGraphDiscoveryFacade(); + vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mockResolvedValueOnce({ + files: [fileA], + } as never); + + await facade.discoverGraph(); + + expect(discoverWorkspacePipelineFilesWithWarnings).toHaveBeenCalledWith( + 'discovery-deps', + '/workspace', + { respectGitignore: true, showOrphans: true }, + [], + [], + undefined, + expect.any(Function), + ); + expect(facade.discoveredDirectories).toEqual([]); + expect(facade.gitIgnoredPaths).toEqual([]); + expect(facade.fileConnections).toEqual(new Map([['src/a.ts', []]])); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/indexStatus.test.ts b/packages/extension/tests/extension/pipeline/service/indexStatus.test.ts new file mode 100644 index 000000000..7ab21246a --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/indexStatus.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { readCodeGraphyWorkspaceStatus } from '@codegraphy-dev/core'; +import { getWorkspacePipelineIndexStatus } from '../../../../src/extension/pipeline/service/indexStatus'; + +vi.mock('@codegraphy-dev/core', () => ({ + readCodeGraphyWorkspaceStatus: vi.fn(), +})); + +const missingIndexStatus = { + freshness: 'missing', + detail: 'CodeGraphy index is missing. Index the workspace to build the graph.', +}; + +describe('extension/pipeline/service/indexStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(readCodeGraphyWorkspaceStatus).mockReturnValue({ + detail: 'CodeGraphy index is fresh.', + state: 'fresh', + } as never); + }); + + it('reports a missing index without probing storage when no workspace is open', () => { + const hasIndex = vi.fn(() => true); + + expect(getWorkspacePipelineIndexStatus({ + hasIndex, + pluginSignature: 'plugins', + settingsSignature: 'settings', + workspaceRoot: undefined, + })).toEqual(missingIndexStatus); + expect(hasIndex).not.toHaveBeenCalled(); + expect(readCodeGraphyWorkspaceStatus).not.toHaveBeenCalled(); + }); + + it('reports a missing index without reading status when no index exists', () => { + const hasIndex = vi.fn(() => false); + + expect(getWorkspacePipelineIndexStatus({ + hasIndex, + pluginSignature: 'plugins', + settingsSignature: 'settings', + workspaceRoot: '/workspace', + })).toEqual(missingIndexStatus); + expect(hasIndex).toHaveBeenCalledOnce(); + expect(readCodeGraphyWorkspaceStatus).not.toHaveBeenCalled(); + }); + + it('reads and returns the workspace status with current signatures', () => { + const hasIndex = vi.fn(() => true); + vi.mocked(readCodeGraphyWorkspaceStatus).mockReturnValue({ + detail: 'Plugin signature changed.', + state: 'stale', + } as never); + + expect(getWorkspacePipelineIndexStatus({ + hasIndex, + pluginSignature: null, + settingsSignature: 'settings', + workspaceRoot: '/workspace', + })).toEqual({ + freshness: 'stale', + detail: 'Plugin signature changed.', + }); + expect(hasIndex).toHaveBeenCalledOnce(); + expect(readCodeGraphyWorkspaceStatus).toHaveBeenCalledWith('/workspace', { + pluginSignature: null, + settingsSignature: 'settings', + }); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/pluginFacade.test.ts b/packages/extension/tests/extension/pipeline/service/pluginFacade.test.ts new file mode 100644 index 000000000..07132a2df --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/pluginFacade.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FileDiscovery } from '@codegraphy-dev/core'; +import type { PluginRegistry } from '../../../../src/core/plugins/registry/manager'; +import type { Configuration } from '../../../../src/extension/config/reader'; +import { hasWorkspacePipelineIndex } from '../../../../src/extension/pipeline/service/cache/index'; +import { getWorkspacePipelineIndexStatus } from '../../../../src/extension/pipeline/service/indexStatus'; +import { WorkspacePipelinePluginFacade } from '../../../../src/extension/pipeline/service/pluginFacade'; +import { + getEffectiveCustomFilterPatterns, + getEffectivePluginFilterPatterns, + getPipelinePluginFilterGroups, + getPipelinePluginFilterPatterns, + initializeWorkspacePipelinePlugins, + queueWorkspacePipelinePluginReload, + queueWorkspacePipelinePluginSync, +} from '../../../../src/extension/pipeline/service/pluginState'; + +vi.mock('../../../../src/extension/pipeline/service/cache/index', () => ({ + hasWorkspacePipelineIndex: vi.fn(), +})); + +vi.mock('../../../../src/extension/pipeline/service/indexStatus', () => ({ + getWorkspacePipelineIndexStatus: vi.fn(), +})); + +vi.mock('../../../../src/extension/pipeline/service/pluginState', () => ({ + getEffectiveCustomFilterPatterns: vi.fn(), + getEffectivePluginFilterPatterns: vi.fn(), + getPipelinePluginFilterGroups: vi.fn(), + getPipelinePluginFilterPatterns: vi.fn(), + initializeWorkspacePipelinePlugins: vi.fn(), + queueWorkspacePipelinePluginReload: vi.fn(), + queueWorkspacePipelinePluginSync: vi.fn(), +})); + +vi.mock('vscode', () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace' } }], + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + inspect: vi.fn(), + update: vi.fn(), + })), + createFileSystemWatcher: vi.fn(), + onDidChangeConfiguration: vi.fn(), + onDidSaveTextDocument: vi.fn(), + }, +})); + +class TestPluginFacade extends WorkspacePipelinePluginFacade { + readonly getWorkspaceRoot = vi.fn<() => string | undefined>(() => '/workspace'); + + _config = { id: 'config' } as unknown as Configuration; + _discovery = { kind: 'discovery' } as unknown as FileDiscovery; + _registry = { id: 'registry' } as unknown as PluginRegistry; + + constructor() { + super({ + subscriptions: [], + workspaceState: { + get: vi.fn(), + update: vi.fn(), + }, + } as never); + } + + effectiveCustomFilterPatterns(filterPatterns: string[]): string[] { + return this._getEffectiveCustomFilterPatterns(filterPatterns); + } + + effectivePluginFilterPatterns(disabledPlugins: ReadonlySet): string[] { + return this._getEffectivePluginFilterPatterns(disabledPlugins); + } + + protected override _getPluginSignature(): string | null { + return 'plugin-signature'; + } + + protected override _getSettingsSignature(): string { + return 'settings-signature'; + } + + protected override _getWorkspaceRoot(): string | undefined { + return this.getWorkspaceRoot(); + } +} + +describe('extension/pipeline/service/pluginFacade', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getPipelinePluginFilterPatterns).mockReturnValue(['plugin-filter']); + vi.mocked(getPipelinePluginFilterGroups).mockReturnValue([{ + patterns: ['generated/**'], + pluginId: 'plugin.disabled', + pluginName: 'Disabled Plugin', + }]); + vi.mocked(getEffectiveCustomFilterPatterns).mockReturnValue(['custom-filter']); + vi.mocked(getEffectivePluginFilterPatterns).mockReturnValue(['effective-plugin-filter']); + vi.mocked(hasWorkspacePipelineIndex).mockReturnValue(true); + vi.mocked(getWorkspacePipelineIndexStatus).mockReturnValue({ + freshness: 'fresh', + detail: 'Index is fresh.', + }); + vi.mocked(queueWorkspacePipelinePluginReload).mockImplementation((_queue, _registry, reload) => { + const reloadResult = Promise.resolve(reload()).then(() => undefined); + return { + nextQueue: reloadResult, + reload: reloadResult, + }; + }); + vi.mocked(queueWorkspacePipelinePluginSync).mockImplementation((_queue, _registry, getWorkspaceRoot) => { + const sync = Promise.resolve(getWorkspaceRoot()).then(() => undefined); + return { + nextQueue: sync, + sync, + }; + }); + }); + + it('initializes and reloads workspace plugins through callback-based helpers', async () => { + const facade = new TestPluginFacade(); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await facade.initialize(); + + expect(initializeWorkspacePipelinePlugins).toHaveBeenCalledWith(facade._registry, expect.any(Function)); + expect(vi.mocked(initializeWorkspacePipelinePlugins).mock.calls[0][1]()).toBe('/workspace'); + expect(log).toHaveBeenCalledWith('[CodeGraphy] WorkspacePipeline initialized'); + + vi.mocked(initializeWorkspacePipelinePlugins).mockClear(); + await facade.reloadWorkspacePlugins(); + + expect(queueWorkspacePipelinePluginReload).toHaveBeenCalledWith( + expect.any(Promise), + facade._registry, + expect.any(Function), + ); + expect(initializeWorkspacePipelinePlugins).toHaveBeenCalledWith(facade._registry, expect.any(Function)); + }); + + it('syncs workspace plugins with the current workspace-root callback', async () => { + const facade = new TestPluginFacade(); + + await facade.syncWorkspacePlugins(); + + expect(queueWorkspacePipelinePluginSync).toHaveBeenCalledWith( + expect.any(Promise), + facade._registry, + expect.any(Function), + ); + expect(facade.getWorkspaceRoot).toHaveBeenCalledOnce(); + }); + + it('delegates plugin filters and effective filter resolution to plugin state helpers', () => { + const facade = new TestPluginFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + + expect(facade.getPluginFilterPatterns(disabledPlugins)).toEqual(['plugin-filter']); + expect(getPipelinePluginFilterPatterns).toHaveBeenCalledWith(facade._registry, disabledPlugins); + + expect(facade.getPluginFilterGroups(disabledPlugins)).toEqual([{ + patterns: ['generated/**'], + pluginId: 'plugin.disabled', + pluginName: 'Disabled Plugin', + }]); + expect(getPipelinePluginFilterGroups).toHaveBeenCalledWith(facade._registry, disabledPlugins); + + expect(facade.effectiveCustomFilterPatterns(['dist/**'])).toEqual(['custom-filter']); + expect(getEffectiveCustomFilterPatterns).toHaveBeenCalledWith(facade._config, ['dist/**']); + + expect(facade.effectivePluginFilterPatterns(disabledPlugins)).toEqual(['effective-plugin-filter']); + expect(getEffectivePluginFilterPatterns).toHaveBeenCalledWith( + facade._registry, + facade._config, + disabledPlugins, + ); + }); + + it('delegates index checks and index status inputs through current facade state', () => { + const facade = new TestPluginFacade(); + + expect(facade.hasIndex()).toBe(true); + expect(hasWorkspacePipelineIndex).toHaveBeenCalledWith('/workspace'); + + expect(facade.getIndexStatus()).toEqual({ + freshness: 'fresh', + detail: 'Index is fresh.', + }); + + const statusInput = vi.mocked(getWorkspacePipelineIndexStatus).mock.calls[0][0]; + expect(statusInput.pluginSignature).toBe('plugin-signature'); + expect(statusInput.settingsSignature).toBe('settings-signature'); + expect(statusInput.workspaceRoot).toBe('/workspace'); + expect(statusInput.hasIndex()).toBe(true); + expect(hasWorkspacePipelineIndex).toHaveBeenLastCalledWith('/workspace'); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/pluginState.test.ts b/packages/extension/tests/extension/pipeline/service/pluginState.test.ts new file mode 100644 index 000000000..8106398bc --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/pluginState.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + getEffectiveCustomFilterPatterns, + getEffectivePluginFilterPatterns, + getPipelinePluginFilterGroups, + getPipelinePluginFilterPatterns, + initializeWorkspacePipelinePlugins, + queueWorkspacePipelinePluginReload, + queueWorkspacePipelinePluginSync, +} from '../../../../src/extension/pipeline/service/pluginState'; +import { + getWorkspacePipelinePluginFilterGroups, + getWorkspacePipelinePluginFilterPatterns, + initializeWorkspacePipeline, + syncWorkspacePipelinePlugins, +} from '../../../../src/extension/pipeline/plugins/bootstrap'; + +vi.mock('../../../../src/extension/pipeline/plugins/bootstrap', () => ({ + getWorkspacePipelinePluginFilterGroups: vi.fn(), + getWorkspacePipelinePluginFilterPatterns: vi.fn(), + initializeWorkspacePipeline: vi.fn(), + syncWorkspacePipelinePlugins: vi.fn(), +})); + +function createDeferred(): { + promise: Promise; + resolve(): void; + reject(reason: unknown): void; +} { + let resolve!: () => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + + return { promise, resolve, reject }; +} + +describe('extension/pipeline/service/pluginState', () => { + const registry = { + disposeAll: vi.fn(), + id: 'registry', + } as unknown as Parameters[0] & { + disposeAll: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getWorkspacePipelinePluginFilterPatterns).mockReturnValue([ + 'generated/**', + 'vendor/**', + 'node_modules/**', + ]); + vi.mocked(getWorkspacePipelinePluginFilterGroups).mockReturnValue([{ + patterns: ['generated/**'], + pluginId: 'plugin.generated', + pluginName: 'Generated Plugin', + }]); + }); + + it('initializes workspace plugins with a live workspace-root callback', async () => { + const getWorkspaceRoot = vi.fn(() => '/workspace'); + + await initializeWorkspacePipelinePlugins(registry, getWorkspaceRoot); + + expect(initializeWorkspacePipeline).toHaveBeenCalledWith(registry, { + getWorkspaceRoot, + }); + expect(vi.mocked(initializeWorkspacePipeline).mock.calls[0][1].getWorkspaceRoot()).toBe('/workspace'); + }); + + it('queues plugin reload after existing work, disposes plugins, and initializes again', async () => { + const existingWork = createDeferred(); + const initialize = vi.fn(async () => undefined); + + const { nextQueue, reload } = queueWorkspacePipelinePluginReload( + existingWork.promise, + registry, + initialize, + ); + + await Promise.resolve(); + expect(registry.disposeAll).not.toHaveBeenCalled(); + expect(initialize).not.toHaveBeenCalled(); + + existingWork.resolve(); + await reload; + await nextQueue; + + expect(registry.disposeAll).toHaveBeenCalledOnce(); + expect(initialize).toHaveBeenCalledOnce(); + expect(registry.disposeAll.mock.invocationCallOrder[0]).toBeLessThan( + initialize.mock.invocationCallOrder[0], + ); + }); + + it('keeps the reload queue usable when a reload fails', async () => { + const initialize = vi.fn(async () => { + throw new Error('reload failed'); + }); + + const { nextQueue, reload } = queueWorkspacePipelinePluginReload( + Promise.resolve(), + registry, + initialize, + ); + + await expect(reload).rejects.toThrow('reload failed'); + await expect(nextQueue).resolves.toBeUndefined(); + }); + + it('queues workspace plugin sync with a live workspace-root callback', async () => { + const existingWork = createDeferred(); + const getWorkspaceRoot = vi.fn(() => '/workspace'); + vi.mocked(syncWorkspacePipelinePlugins).mockImplementation(async (_registry, options) => { + expect(options.getWorkspaceRoot()).toBe('/workspace'); + }); + + const { nextQueue, sync } = queueWorkspacePipelinePluginSync( + existingWork.promise, + registry, + getWorkspaceRoot, + ); + + await Promise.resolve(); + expect(syncWorkspacePipelinePlugins).not.toHaveBeenCalled(); + + existingWork.resolve(); + await sync; + await nextQueue; + + expect(syncWorkspacePipelinePlugins).toHaveBeenCalledWith(registry, { + getWorkspaceRoot, + }); + }); + + it('keeps the sync queue usable when a sync fails', async () => { + vi.mocked(syncWorkspacePipelinePlugins).mockRejectedValueOnce(new Error('sync failed')); + + const { nextQueue, sync } = queueWorkspacePipelinePluginSync( + Promise.resolve(), + registry, + () => '/workspace', + ); + + await expect(sync).rejects.toThrow('sync failed'); + await expect(nextQueue).resolves.toBeUndefined(); + }); + + it('delegates plugin filter patterns and groups to bootstrap helpers', () => { + const disabledPlugins = new Set(['plugin.disabled']); + + expect(getPipelinePluginFilterPatterns(registry, disabledPlugins)).toEqual([ + 'generated/**', + 'vendor/**', + 'node_modules/**', + ]); + expect(getWorkspacePipelinePluginFilterPatterns).toHaveBeenCalledWith(registry, disabledPlugins); + + expect(getPipelinePluginFilterGroups(registry, disabledPlugins)).toEqual([{ + patterns: ['generated/**'], + pluginId: 'plugin.generated', + pluginName: 'Generated Plugin', + }]); + expect(getWorkspacePipelinePluginFilterGroups).toHaveBeenCalledWith(registry, disabledPlugins); + }); + + it('removes disabled custom and plugin filter patterns', () => { + expect(getEffectiveCustomFilterPatterns( + { + disabledCustomFilterPatterns: ['vendor/**'], + disabledPluginFilterPatterns: [], + }, + ['generated/**', 'vendor/**', 'node_modules/**'], + )).toEqual(['generated/**', 'node_modules/**']); + + expect(getEffectivePluginFilterPatterns( + registry, + { + disabledCustomFilterPatterns: [], + disabledPluginFilterPatterns: ['vendor/**'], + }, + new Set(['plugin.disabled']), + )).toEqual(['generated/**', 'node_modules/**']); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/refresh/context.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/context.test.ts new file mode 100644 index 000000000..261fb5b9e --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/context.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; +import { EMPTY_REFRESH_GRAPH } from '../../../../../src/extension/pipeline/service/refresh/context'; + +describe('extension/pipeline/service/refresh/context', () => { + it('uses an empty graph shape for refresh fallbacks', () => { + expect(EMPTY_REFRESH_GRAPH).toEqual({ + nodes: [], + edges: [], + }); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/refresh/discovery/changed.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/discovery/changed.test.ts new file mode 100644 index 000000000..acb05e0bb --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/discovery/changed.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import { + getReusableChangedFileDiscoveryState, + type ChangedFileDiscoveryState, +} from '../../../../../../src/extension/pipeline/service/refresh/discovery/changed'; + +const fsMock = vi.hoisted(() => ({ + existsSync: vi.fn(), +})); + +vi.mock('node:fs', () => ({ + default: fsMock, + existsSync: fsMock.existsSync, +})); + +function createFile(relativePath: string) { + return { + absolutePath: `/workspace/${relativePath.replace(/\\/g, '/')}`, + extension: '.ts', + name: relativePath.split(/[\\/]/).at(-1) ?? relativePath, + relativePath, + }; +} + +function createInput( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + filePaths: ['src/a.ts'], + lastDiscoveredDirectories: ['src'], + lastDiscoveredFiles: [createFile('src/a.ts')], + lastWorkspaceRoot: '/workspace', + toWorkspaceRelativePath: vi.fn((_workspaceRoot, filePath) => + filePath.replace(/^\/workspace\//, '').replace(/\\/g, '/'), + ), + workspaceRoot: '/workspace', + ...overrides, + }; +} + +describe('extension/pipeline/service/refresh/discovery/changed', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fs.existsSync).mockReturnValue(true); + }); + + it.each([ + ['without changed file paths', { filePaths: [] }], + ['after the workspace root changes', { lastWorkspaceRoot: '/other-workspace' }], + ])('does not reuse discovery state %s', (_label, overrides) => { + expect(getReusableChangedFileDiscoveryState(createInput(overrides))).toBeUndefined(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('does not resolve changed paths without previous discovered files', () => { + const input = createInput({ lastDiscoveredFiles: [] }); + + expect(getReusableChangedFileDiscoveryState(input)).toBeUndefined(); + expect(input.toWorkspaceRelativePath).not.toHaveBeenCalled(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('reuses previous discovery when every changed path is still discovered and exists', () => { + const directories = ['src', 'src/nested']; + const files = [ + createFile('src\\a.ts'), + createFile('src/nested/b.ts'), + ]; + const input = createInput({ + filePaths: ['src/a.ts', '/workspace/src/nested/b.ts'], + lastDiscoveredDirectories: directories, + lastDiscoveredFiles: files, + }); + + const result = getReusableChangedFileDiscoveryState(input) as ChangedFileDiscoveryState; + + expect(result.files).toBe(files); + expect(result.directories).toEqual(directories); + expect(result.directories).not.toBe(directories); + expect(input.toWorkspaceRelativePath).toHaveBeenNthCalledWith( + 1, + '/workspace', + 'src/a.ts', + ); + expect(input.toWorkspaceRelativePath).toHaveBeenNthCalledWith( + 2, + '/workspace', + '/workspace/src/nested/b.ts', + ); + expect(fs.existsSync).toHaveBeenNthCalledWith(1, '/workspace/src/a.ts'); + expect(fs.existsSync).toHaveBeenNthCalledWith(2, '/workspace/src/nested/b.ts'); + }); + + it('does not reuse discovery when a changed path cannot become workspace-relative', () => { + expect( + getReusableChangedFileDiscoveryState(createInput({ + toWorkspaceRelativePath: vi.fn(() => undefined), + })), + ).toBeUndefined(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('does not reuse discovery when a changed file was not previously discovered', () => { + expect( + getReusableChangedFileDiscoveryState(createInput({ + filePaths: ['src/missing.ts'], + })), + ).toBeUndefined(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('does not reuse discovery when a changed file no longer exists on disk', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + expect(getReusableChangedFileDiscoveryState(createInput())).toBeUndefined(); + expect(fs.existsSync).toHaveBeenCalledWith('/workspace/src/a.ts'); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/refresh/discovery/workspace.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/discovery/workspace.test.ts new file mode 100644 index 000000000..2f1cb09e9 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/discovery/workspace.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import type { ICodeGraphyConfig } from '../../../../../../src/extension/config/defaults'; +import { + discoverRefreshWorkspaceFiles, +} from '../../../../../../src/extension/pipeline/service/refresh/discovery/workspace'; +import { + createWorkspacePipelineDiscoveryDependencies, + discoverWorkspacePipelineFilesWithWarnings, +} from '../../../../../../src/extension/pipeline/service/runtime/discovery'; + +const vscodeMock = vi.hoisted(() => ({ + showWarningMessage: vi.fn(), +})); + +vi.mock('vscode', () => ({ + window: { + showWarningMessage: vscodeMock.showWarningMessage, + }, +})); + +vi.mock('../../../../../../src/extension/pipeline/service/runtime/discovery', () => ({ + createWorkspacePipelineDiscoveryDependencies: vi.fn(), + discoverWorkspacePipelineFilesWithWarnings: vi.fn(), +})); + +describe('extension/pipeline/service/refresh/discovery/workspace', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createWorkspacePipelineDiscoveryDependencies).mockReturnValue('discovery-deps' as never); + }); + + it('discovers workspace files with enabled filter patterns and relays warnings', async () => { + const config = { + disabledCustomFilterPatterns: ['dist/**'], + disabledPluginFilterPatterns: ['plugin.disabled/**'], + maxFiles: 500, + } as ICodeGraphyConfig; + const discoveryResult = { + directories: ['src'], + durationMs: 10, + files: [{ absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts' }], + gitIgnoredPaths: new Set(), + limitReached: true, + totalFound: 1, + }; + vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mockResolvedValue(discoveryResult as never); + const disabledPlugins = new Set(['plugin.disabled']); + const discovery = { discover: vi.fn() }; + const signal = new AbortController().signal; + const getPluginFilterPatterns = vi.fn(() => [ + 'plugin.enabled/**', + 'plugin.disabled/**', + ]); + + const result = await discoverRefreshWorkspaceFiles({ + configReader: { getAll: vi.fn(() => config) }, + disabledPlugins, + discovery, + filterPatterns: ['src/**', 'dist/**', 'tests/**'], + getPluginFilterPatterns, + signal, + workspaceRoot: '/workspace', + }); + + expect(createWorkspacePipelineDiscoveryDependencies).toHaveBeenCalledWith(discovery); + expect(getPluginFilterPatterns).toHaveBeenCalledWith(disabledPlugins); + expect(discoverWorkspacePipelineFilesWithWarnings).toHaveBeenCalledWith( + 'discovery-deps', + '/workspace', + config, + ['src/**', 'tests/**'], + ['plugin.enabled/**'], + signal, + expect.any(Function), + ); + + const warningCallback = vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mock.calls[0][6]; + warningCallback('workspace discovery warning'); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith('workspace discovery warning'); + expect(result).toEqual({ config, discoveryResult }); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/refresh/metrics.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/metrics.test.ts new file mode 100644 index 000000000..2955ac567 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/metrics.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData, IGraphNode } from '../../../../../src/shared/graph/contracts'; +import { patchGraphDataNodeMetrics } from '../../../../../src/extension/pipeline/service/refresh/metrics'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createGraph(nodes: IGraphNode[]): IGraphData { + return { + nodes, + edges: [], + }; +} + +describe('extension/pipeline/service/refresh/metrics', () => { + it('returns the same graph when there are no metric file paths', () => { + const graphData = { + get nodes(): IGraphNode[] { + throw new Error('nodes should not be read without metric file paths'); + }, + edges: [], + } as IGraphData; + + expect( + patchGraphDataNodeMetrics({ + churnCounts: {}, + filePaths: [], + fileSizes: {}, + graphData, + }), + ).toBe(graphData); + }); + + it('returns the same graph when metric paths do not match any node', () => { + const graphData = createGraph([createNode({ id: 'src/a.ts' })]); + + expect( + patchGraphDataNodeMetrics({ + churnCounts: { 'src/other.ts': 4 }, + filePaths: ['src/other.ts'], + fileSizes: { 'src/other.ts': { size: 64 } }, + graphData, + }), + ).toBe(graphData); + }); + + it('patches matching node metrics while preserving unchanged nodes', () => { + const unchangedNode = createNode({ id: 'src/unchanged.ts', label: 'unchanged.ts' }); + const graphData = createGraph([ + createNode({ id: 'src\\a.ts', fileSize: 12, churn: 1 }), + unchangedNode, + ]); + + const patchedGraph = patchGraphDataNodeMetrics({ + churnCounts: { 'src/a.ts': 4 }, + filePaths: ['src\\a.ts'], + fileSizes: { 'src/a.ts': { size: 64 } }, + graphData, + }); + + expect(patchedGraph).not.toBe(graphData); + expect(patchedGraph.nodes).toEqual([ + createNode({ id: 'src\\a.ts', fileSize: 64, churn: 4 }), + unchangedNode, + ]); + expect(patchedGraph.nodes[1]).toBe(unchangedNode); + }); + + it('uses symbol file paths when matching metric updates', () => { + const graphData = createGraph([ + createNode({ + id: 'symbol:loadUser', + nodeType: 'symbol', + symbol: { + id: 'symbol:loadUser', + name: 'loadUser', + kind: 'function', + filePath: 'src/users.ts', + }, + }), + ]); + + expect( + patchGraphDataNodeMetrics({ + churnCounts: { 'src/users.ts': 7 }, + filePaths: ['src/users.ts'], + fileSizes: { 'src/users.ts': { size: 128 } }, + graphData, + }).nodes[0], + ).toEqual(createNode({ + id: 'symbol:loadUser', + nodeType: 'symbol', + fileSize: 128, + churn: 7, + symbol: { + id: 'symbol:loadUser', + name: 'loadUser', + kind: 'function', + filePath: 'src/users.ts', + }, + })); + }); + + it('falls back to node ids when symbol file paths are empty', () => { + const graphData = createGraph([ + createNode({ + id: 'src/a.ts', + symbol: { + id: 'symbol:a', + name: 'a', + kind: 'function', + filePath: '', + }, + }), + ]); + + expect( + patchGraphDataNodeMetrics({ + churnCounts: { 'src/a.ts': 3 }, + filePaths: ['src/a.ts'], + fileSizes: { 'src/a.ts': { size: 20 } }, + graphData, + }).nodes[0], + ).toEqual(createNode({ + fileSize: 20, + churn: 3, + symbol: { + id: 'symbol:a', + name: 'a', + kind: 'function', + filePath: '', + }, + })); + }); + + it('returns the same graph when matching metrics are unchanged', () => { + const graphData = createGraph([createNode({ fileSize: 64, churn: 2 })]); + + expect( + patchGraphDataNodeMetrics({ + churnCounts: { 'src/a.ts': 2 }, + filePaths: ['src/a.ts'], + fileSizes: { 'src/a.ts': { size: 64 } }, + graphData, + }), + ).toBe(graphData); + }); + + it('patches when only one metric changes', () => { + const graphData = createGraph([ + createNode({ id: 'src/size.ts', label: 'size.ts', fileSize: 10, churn: 2 }), + createNode({ id: 'src/churn.ts', label: 'churn.ts', fileSize: 20, churn: 1 }), + ]); + + expect( + patchGraphDataNodeMetrics({ + churnCounts: { 'src/size.ts': 2, 'src/churn.ts': 5 }, + filePaths: ['src/size.ts', 'src/churn.ts'], + fileSizes: { + 'src/size.ts': { size: 16 }, + 'src/churn.ts': { size: 20 }, + }, + graphData, + }).nodes, + ).toEqual([ + createNode({ id: 'src/size.ts', label: 'size.ts', fileSize: 16, churn: 2 }), + createNode({ id: 'src/churn.ts', label: 'churn.ts', fileSize: 20, churn: 5 }), + ]); + }); + + it('preserves unchanged matched nodes when another matched node changes', () => { + const graphData = createGraph([ + createNode({ id: 'src/stable.ts', label: 'stable.ts', fileSize: 10, churn: 2 }), + createNode({ id: 'src/changed.ts', label: 'changed.ts', fileSize: 20, churn: 1 }), + ]); + + expect( + patchGraphDataNodeMetrics({ + churnCounts: { 'src/stable.ts': 2, 'src/changed.ts': 5 }, + filePaths: ['src/stable.ts', 'src/changed.ts'], + fileSizes: { + 'src/stable.ts': { size: 10 }, + 'src/changed.ts': { size: 20 }, + }, + graphData, + }).nodes, + ).toEqual([ + createNode({ id: 'src/stable.ts', label: 'stable.ts', fileSize: 10, churn: 2 }), + createNode({ id: 'src/changed.ts', label: 'changed.ts', fileSize: 20, churn: 5 }), + ]); + }); + + it('defaults missing size and churn metrics without throwing', () => { + const graphData = createGraph([createNode()]); + + expect( + patchGraphDataNodeMetrics({ + churnCounts: {}, + filePaths: ['src/a.ts'], + fileSizes: {}, + graphData, + }).nodes[0], + ).toEqual(createNode({ fileSize: undefined, churn: 0 })); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/refresh/modes/analysisScope.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/modes/analysisScope.test.ts new file mode 100644 index 000000000..13770b0f8 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/modes/analysisScope.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IGraphData } from '../../../../../../src/shared/graph/contracts'; +import { + refreshAnalysisScopeForFacade, +} from '../../../../../../src/extension/pipeline/service/refresh/modes/analysisScope'; +import type { RefreshFacadeContext } from '../../../../../../src/extension/pipeline/service/refresh/context'; +import { refreshWorkspacePipelineAnalysisScope } from '../../../../../../src/extension/pipeline/service/runtime/refresh'; +import { + canReuseCurrentAnalysisForScope, + rebuildAnalysisScopeFromCurrentAnalysis, +} from '../../../../../../src/extension/pipeline/service/refresh/scope'; +import { createWorkspaceIndexRefreshSource } from '../../../../../../src/extension/pipeline/service/refresh/source'; +import { discoverRefreshWorkspaceFiles } from '../../../../../../src/extension/pipeline/service/refresh/discovery/workspace'; + +vi.mock('../../../../../../src/extension/pipeline/service/runtime/refresh', () => ({ + refreshWorkspacePipelineAnalysisScope: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/scope', () => ({ + canReuseCurrentAnalysisForScope: vi.fn(), + rebuildAnalysisScopeFromCurrentAnalysis: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/source', () => ({ + createWorkspaceIndexRefreshSource: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/discovery/workspace', () => ({ + discoverRefreshWorkspaceFiles: vi.fn(), +})); + +function createGraph(id: string): IGraphData { + return { + nodes: [{ id, label: id, color: '#67E8F9', nodeType: 'file' }], + edges: [], + }; +} + +function createFile(relativePath = 'src/a.ts') { + return { + absolutePath: `/workspace/${relativePath}`, + extension: '.ts', + name: relativePath.split('/').at(-1) ?? relativePath, + relativePath, + }; +} + +function createFacade( + overrides: Partial = {}, +): RefreshFacadeContext { + return { + _analyzeFiles: vi.fn(), + _buildGraphData: vi.fn(), + _buildGraphDataFromAnalysis: vi.fn(), + _config: { + get: vi.fn(() => ({ file: true })), + getAll: vi.fn(), + }, + _discovery: { discover: vi.fn() }, + _getActiveAnalysisPluginIds: vi.fn(() => ['plugin.a']), + _getWorkspaceRoot: vi.fn(() => '/workspace'), + _lastDiscoveredDirectories: [], + _lastDiscoveredFiles: [], + _lastFileAnalysis: new Map([['src/a.ts', { filePath: '/workspace/src/a.ts' }]]), + _lastFileConnections: new Map(), + _lastGitIgnoredPaths: ['old-ignore'], + _lastGraphData: createGraph('last'), + _lastWorkspaceRoot: '/workspace', + _patchGraphDataNodeMetrics: vi.fn(), + _persistCache: vi.fn(), + _persistIndexMetadata: vi.fn(async () => undefined), + _preAnalyzePlugins: vi.fn(), + _readAnalysisFiles: vi.fn(), + _registry: { + list: vi.fn(() => []), + notifyFilesChanged: vi.fn(), + }, + _toWorkspaceRelativePath: vi.fn(), + analyze: vi.fn(), + getPluginFilterPatterns: vi.fn(() => ['plugin/**']), + invalidateWorkspaceFiles: vi.fn(), + ...overrides, + } as unknown as RefreshFacadeContext; +} + +function mockDiscoveryResult( + overrides: Partial>['discoveryResult']> = {}, + config: Record = { showOrphans: false }, +) { + const files = [createFile()]; + vi.mocked(discoverRefreshWorkspaceFiles).mockResolvedValue({ + config, + discoveryResult: { + directories: ['src'], + files, + gitIgnoredPaths: ['ignored.ts'], + ...overrides, + }, + } as never); + return files; +} + +describe('extension/pipeline/service/refresh/modes/analysisScope', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createWorkspaceIndexRefreshSource).mockReturnValue('refresh-source' as never); + }); + + it('returns an empty graph without workspace discovery when no workspace is open', async () => { + await expect( + refreshAnalysisScopeForFacade(createFacade({ + _getWorkspaceRoot: vi.fn(() => undefined), + }), { + disabledPlugins: new Set(), + filterPatterns: ['src/**'], + }), + ).resolves.toEqual({ nodes: [], edges: [] }); + + expect(discoverRefreshWorkspaceFiles).not.toHaveBeenCalled(); + expect(refreshWorkspacePipelineAnalysisScope).not.toHaveBeenCalled(); + }); + + it('rebuilds the graph from reusable analysis for analysis scope changes', async () => { + const graph = createGraph('rebuilt'); + const files = mockDiscoveryResult(); + const facade = createFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + const onProgress = vi.fn(); + vi.mocked(canReuseCurrentAnalysisForScope).mockReturnValue(true); + vi.mocked(rebuildAnalysisScopeFromCurrentAnalysis).mockResolvedValue(graph); + + await expect( + refreshAnalysisScopeForFacade(facade, { + disabledPlugins, + filterPatterns: ['src/**'], + onProgress, + signal, + }), + ).resolves.toBe(graph); + + expect(discoverRefreshWorkspaceFiles).toHaveBeenCalledWith({ + configReader: facade._config, + disabledPlugins, + discovery: facade._discovery, + filterPatterns: ['src/**'], + getPluginFilterPatterns: expect.any(Function), + signal, + workspaceRoot: '/workspace', + }); + const getPluginFilterPatterns = vi.mocked(discoverRefreshWorkspaceFiles).mock.calls[0][0].getPluginFilterPatterns; + expect(getPluginFilterPatterns(disabledPlugins)).toEqual(['plugin/**']); + expect(facade.getPluginFilterPatterns).toHaveBeenCalledWith(disabledPlugins); + expect(facade._lastGitIgnoredPaths).toEqual(['ignored.ts']); + expect(facade._getActiveAnalysisPluginIds).toHaveBeenCalledWith(undefined, disabledPlugins); + expect(facade._config.get).toHaveBeenCalledWith('nodeVisibility', {}); + expect(canReuseCurrentAnalysisForScope).toHaveBeenCalledWith({ + activePluginIds: ['plugin.a'], + disabledPlugins, + discoveredFiles: files, + lastFileAnalysis: facade._lastFileAnalysis, + nodeVisibility: { file: true }, + }); + expect(rebuildAnalysisScopeFromCurrentAnalysis).toHaveBeenCalledWith(facade, { + disabledPlugins, + discoveredDirectories: ['src'], + discoveredFiles: files, + onProgress, + showOrphans: false, + workspaceRoot: '/workspace', + }); + expect(refreshWorkspacePipelineAnalysisScope).not.toHaveBeenCalled(); + }); + + it('uses reusable analysis defaults when discovery omits optional scope fields', async () => { + const graph = createGraph('rebuilt-defaults'); + const files = mockDiscoveryResult({ + directories: undefined, + gitIgnoredPaths: undefined, + }, {}); + const facade = createFacade(); + const disabledPlugins = new Set(); + vi.mocked(canReuseCurrentAnalysisForScope).mockReturnValue(true); + vi.mocked(rebuildAnalysisScopeFromCurrentAnalysis).mockResolvedValue(graph); + + await expect( + refreshAnalysisScopeForFacade(facade, { + disabledPlugins, + filterPatterns: [], + }), + ).resolves.toBe(graph); + + expect(facade._lastGitIgnoredPaths).toEqual([]); + expect(facade._config.get).toHaveBeenCalledWith('nodeVisibility', {}); + expect(rebuildAnalysisScopeFromCurrentAnalysis).toHaveBeenCalledWith(facade, expect.objectContaining({ + discoveredDirectories: [], + discoveredFiles: files, + showOrphans: true, + })); + }); + + it('runs a full analysis-scope refresh when current analysis cannot be reused', async () => { + const graph = createGraph('refreshed'); + const files = mockDiscoveryResult({ + directories: undefined, + gitIgnoredPaths: undefined, + }); + const facade = createFacade(); + const disabledPlugins = new Set(); + const signal = new AbortController().signal; + const onProgress = vi.fn(); + vi.mocked(canReuseCurrentAnalysisForScope).mockReturnValue(false); + vi.mocked(refreshWorkspacePipelineAnalysisScope).mockResolvedValue(graph); + + await expect( + refreshAnalysisScopeForFacade(facade, { + disabledPlugins, + filterPatterns: ['src/**'], + onProgress, + signal, + }), + ).resolves.toBe(graph); + + expect(facade._lastGitIgnoredPaths).toEqual([]); + expect(createWorkspaceIndexRefreshSource).toHaveBeenCalledWith(facade, disabledPlugins); + expect(refreshWorkspacePipelineAnalysisScope).toHaveBeenCalledWith('refresh-source', { + disabledPlugins, + discoveredDirectories: [], + discoveredFiles: files, + onProgress, + persistCache: expect.any(Function), + persistIndexMetadata: expect.any(Function), + signal, + workspaceRoot: '/workspace', + }); + + const refreshOptions = vi.mocked(refreshWorkspacePipelineAnalysisScope).mock.calls[0][1]; + refreshOptions.persistCache(); + await refreshOptions.persistIndexMetadata(); + expect(facade._persistCache).toHaveBeenCalledOnce(); + expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); + expect(rebuildAnalysisScopeFromCurrentAnalysis).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/refresh/modes/changedFiles.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/modes/changedFiles.test.ts new file mode 100644 index 000000000..0a85567ed --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/modes/changedFiles.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IGraphData } from '../../../../../../src/shared/graph/contracts'; +import { + refreshChangedFilesForFacade, +} from '../../../../../../src/extension/pipeline/service/refresh/modes/changedFiles'; +import type { RefreshFacadeContext } from '../../../../../../src/extension/pipeline/service/refresh/context'; +import { refreshWorkspacePipelineChangedFiles } from '../../../../../../src/extension/pipeline/service/runtime/refresh'; +import { getReusableChangedFileDiscoveryState } from '../../../../../../src/extension/pipeline/service/refresh/discovery/changed'; +import { discoverRefreshWorkspaceFiles } from '../../../../../../src/extension/pipeline/service/refresh/discovery/workspace'; +import { createWorkspaceIndexRefreshSource } from '../../../../../../src/extension/pipeline/service/refresh/source'; + +vi.mock('../../../../../../src/extension/pipeline/service/runtime/refresh', () => ({ + refreshWorkspacePipelineChangedFiles: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/discovery/changed', () => ({ + getReusableChangedFileDiscoveryState: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/discovery/workspace', () => ({ + discoverRefreshWorkspaceFiles: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/source', () => ({ + createWorkspaceIndexRefreshSource: vi.fn(), +})); + +function createGraph(id: string): IGraphData { + return { + nodes: [{ id, label: id, color: '#67E8F9', nodeType: 'file' }], + edges: [], + }; +} + +function createFile(relativePath = 'src/a.ts') { + return { + absolutePath: `/workspace/${relativePath}`, + extension: '.ts', + name: relativePath.split('/').at(-1) ?? relativePath, + relativePath, + }; +} + +function createFacade( + overrides: Partial = {}, +): RefreshFacadeContext { + return { + _analyzeFiles: vi.fn(), + _buildGraphData: vi.fn(), + _buildGraphDataFromAnalysis: vi.fn(), + _config: { + get: vi.fn(), + getAll: vi.fn(), + }, + _discovery: { discover: vi.fn() }, + _getActiveAnalysisPluginIds: vi.fn(() => []), + _getWorkspaceRoot: vi.fn(() => '/workspace'), + _lastDiscoveredDirectories: ['src'], + _lastDiscoveredFiles: [createFile()], + _lastFileAnalysis: new Map(), + _lastFileConnections: new Map(), + _lastGitIgnoredPaths: ['old-ignore'], + _lastGraphData: createGraph('last'), + _lastWorkspaceRoot: '/workspace', + _patchGraphDataNodeMetrics: vi.fn(), + _persistCache: vi.fn(), + _persistIndexMetadata: vi.fn(async () => undefined), + _preAnalyzePlugins: vi.fn(), + _readAnalysisFiles: vi.fn(), + _registry: { + list: vi.fn(() => []), + notifyFilesChanged: vi.fn(), + }, + _toWorkspaceRelativePath: vi.fn((_root, filePath) => filePath.replace('/workspace/', '')), + analyze: vi.fn(), + getPluginFilterPatterns: vi.fn(() => ['plugin/**']), + invalidateWorkspaceFiles: vi.fn(), + ...overrides, + } as unknown as RefreshFacadeContext; +} + +describe('extension/pipeline/service/refresh/modes/changedFiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createWorkspaceIndexRefreshSource).mockReturnValue('refresh-source' as never); + }); + + it('returns an empty graph without changed-file discovery when no workspace is open', async () => { + await expect( + refreshChangedFilesForFacade(createFacade({ + _getWorkspaceRoot: vi.fn(() => undefined), + }), { + disabledPlugins: new Set(), + filePaths: ['src/a.ts'], + filterPatterns: ['src/**'], + }), + ).resolves.toEqual({ nodes: [], edges: [] }); + + expect(getReusableChangedFileDiscoveryState).not.toHaveBeenCalled(); + expect(refreshWorkspacePipelineChangedFiles).not.toHaveBeenCalled(); + }); + + it('refreshes changed files from reusable discovery state', async () => { + const graph = createGraph('changed'); + const files = [createFile()]; + const directories = ['src', 'src/nested']; + const disabledPlugins = new Set(['plugin.disabled']); + const explicitDisabledPlugins = new Set(['plugin.next']); + const signal = new AbortController().signal; + const onProgress = vi.fn(); + const facade = createFacade(); + vi.mocked(getReusableChangedFileDiscoveryState).mockReturnValue({ directories, files }); + vi.mocked(refreshWorkspacePipelineChangedFiles).mockResolvedValue(graph); + + await expect( + refreshChangedFilesForFacade(facade, { + disabledPlugins, + filePaths: ['src/a.ts'], + filterPatterns: ['src/**'], + onProgress, + signal, + }), + ).resolves.toBe(graph); + + expect(getReusableChangedFileDiscoveryState).toHaveBeenCalledWith({ + filePaths: ['src/a.ts'], + lastDiscoveredDirectories: facade._lastDiscoveredDirectories, + lastDiscoveredFiles: facade._lastDiscoveredFiles, + lastWorkspaceRoot: facade._lastWorkspaceRoot, + toWorkspaceRelativePath: expect.any(Function), + workspaceRoot: '/workspace', + }); + const toWorkspaceRelativePath = vi.mocked(getReusableChangedFileDiscoveryState).mock.calls[0][0].toWorkspaceRelativePath; + expect(toWorkspaceRelativePath('/workspace', '/workspace/src/a.ts')).toBe('src/a.ts'); + expect(facade._toWorkspaceRelativePath).toHaveBeenCalledWith('/workspace', '/workspace/src/a.ts'); + expect(discoverRefreshWorkspaceFiles).not.toHaveBeenCalled(); + expect(createWorkspaceIndexRefreshSource).toHaveBeenCalledWith(facade, disabledPlugins); + expect(refreshWorkspacePipelineChangedFiles).toHaveBeenCalledWith('refresh-source', { + deferMetricOnlyIndexMetadata: true, + disabledPlugins, + discoveredDirectories: directories, + discoveredFiles: files, + filePaths: ['src/a.ts'], + filterPatterns: ['src/**'], + notifyFilesChanged: expect.any(Function), + onDeferredIndexMetadataError: expect.any(Function), + onProgress, + persistCache: expect.any(Function), + persistIndexMetadata: expect.any(Function), + signal, + workspaceRoot: '/workspace', + }); + + const refreshOptions = vi.mocked(refreshWorkspacePipelineChangedFiles).mock.calls[0][1]; + refreshOptions.notifyFilesChanged(['src/a.ts'] as never, '/workspace', 'analysis-context' as never); + expect(facade._registry.notifyFilesChanged).toHaveBeenCalledWith( + ['src/a.ts'], + '/workspace', + 'analysis-context', + disabledPlugins, + ); + refreshOptions.notifyFilesChanged(['src/b.ts'] as never, '/workspace', 'next-context' as never, explicitDisabledPlugins); + expect(facade._registry.notifyFilesChanged).toHaveBeenLastCalledWith( + ['src/b.ts'], + '/workspace', + 'next-context', + explicitDisabledPlugins, + ); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const error = new Error('persist failed'); + expect(refreshOptions.onDeferredIndexMetadataError).toBeDefined(); + refreshOptions.onDeferredIndexMetadataError?.(error); + expect(warn).toHaveBeenCalledWith( + '[CodeGraphy] Failed to persist metric-only refresh metadata.', + error, + ); + warn.mockRestore(); + + refreshOptions.persistCache(); + await refreshOptions.persistIndexMetadata(); + expect(facade._persistCache).toHaveBeenCalledOnce(); + expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); + }); + + it('discovers workspace files when previous changed-file discovery cannot be reused', async () => { + const graph = createGraph('discovered'); + const files = [createFile('src/new.ts')]; + const disabledPlugins = new Set(); + const signal = new AbortController().signal; + const facade = createFacade(); + vi.mocked(getReusableChangedFileDiscoveryState).mockReturnValue(undefined); + vi.mocked(discoverRefreshWorkspaceFiles).mockResolvedValue({ + config: {}, + discoveryResult: { + directories: undefined, + files, + gitIgnoredPaths: undefined, + }, + } as never); + vi.mocked(refreshWorkspacePipelineChangedFiles).mockResolvedValue(graph); + + await expect( + refreshChangedFilesForFacade(facade, { + disabledPlugins, + filePaths: ['src/new.ts'], + filterPatterns: ['src/**'], + signal, + }), + ).resolves.toBe(graph); + + expect(discoverRefreshWorkspaceFiles).toHaveBeenCalledWith({ + configReader: facade._config, + disabledPlugins, + discovery: facade._discovery, + filterPatterns: ['src/**'], + getPluginFilterPatterns: expect.any(Function), + signal, + workspaceRoot: '/workspace', + }); + const getPluginFilterPatterns = vi.mocked(discoverRefreshWorkspaceFiles).mock.calls[0][0].getPluginFilterPatterns; + expect(getPluginFilterPatterns(disabledPlugins)).toEqual(['plugin/**']); + expect(facade.getPluginFilterPatterns).toHaveBeenCalledWith(disabledPlugins); + expect(facade._lastDiscoveredDirectories).toEqual([]); + expect(facade._lastGitIgnoredPaths).toEqual([]); + expect(refreshWorkspacePipelineChangedFiles).toHaveBeenCalledWith('refresh-source', expect.objectContaining({ + discoveredDirectories: [], + discoveredFiles: files, + workspaceRoot: '/workspace', + })); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/refresh/modes/gitignoreMetadata.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/modes/gitignoreMetadata.test.ts new file mode 100644 index 000000000..e049c738c --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/modes/gitignoreMetadata.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IGraphData } from '../../../../../../src/shared/graph/contracts'; +import { + refreshGitignoreMetadataForFacade, +} from '../../../../../../src/extension/pipeline/service/refresh/modes/gitignoreMetadata'; +import type { RefreshFacadeContext } from '../../../../../../src/extension/pipeline/service/refresh/context'; +import { discoverRefreshWorkspaceFiles } from '../../../../../../src/extension/pipeline/service/refresh/discovery/workspace'; + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/discovery/workspace', () => ({ + discoverRefreshWorkspaceFiles: vi.fn(), +})); + +function createGraph(id: string): IGraphData { + return { + nodes: [{ id, label: id, color: '#67E8F9', nodeType: 'file' }], + edges: [], + }; +} + +function createFile(relativePath = 'src/a.ts') { + return { + absolutePath: `/workspace/${relativePath}`, + extension: '.ts', + name: relativePath.split('/').at(-1) ?? relativePath, + relativePath, + }; +} + +function createFacade( + overrides: Partial = {}, +): RefreshFacadeContext { + return { + _analyzeFiles: vi.fn(), + _buildGraphData: vi.fn(), + _buildGraphDataFromAnalysis: vi.fn(() => createGraph('rebuilt')), + _config: { + get: vi.fn(), + getAll: vi.fn(), + }, + _discovery: { discover: vi.fn() }, + _getActiveAnalysisPluginIds: vi.fn(() => []), + _getWorkspaceRoot: vi.fn(() => '/workspace'), + _lastDiscoveredDirectories: ['old-src'], + _lastDiscoveredFiles: [createFile('old.ts')], + _lastFileAnalysis: new Map([['src/a.ts', { filePath: '/workspace/src/a.ts' }]]), + _lastFileConnections: new Map(), + _lastGitIgnoredPaths: ['old-ignore'], + _lastGraphData: createGraph('last'), + _lastWorkspaceRoot: '/old-workspace', + _patchGraphDataNodeMetrics: vi.fn(), + _persistCache: vi.fn(), + _persistIndexMetadata: vi.fn(async () => undefined), + _preAnalyzePlugins: vi.fn(), + _readAnalysisFiles: vi.fn(), + _registry: { + list: vi.fn(() => []), + notifyFilesChanged: vi.fn(), + }, + _toWorkspaceRelativePath: vi.fn(), + analyze: vi.fn(), + getPluginFilterPatterns: vi.fn(() => ['plugin/**']), + invalidateWorkspaceFiles: vi.fn(), + ...overrides, + } as unknown as RefreshFacadeContext; +} + +describe('extension/pipeline/service/refresh/modes/gitignoreMetadata', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns an empty graph without metadata discovery when no workspace is open', async () => { + await expect( + refreshGitignoreMetadataForFacade(createFacade({ + _getWorkspaceRoot: vi.fn(() => undefined), + }), { + disabledPlugins: new Set(), + filterPatterns: ['src/**'], + }), + ).resolves.toEqual({ nodes: [], edges: [] }); + + expect(discoverRefreshWorkspaceFiles).not.toHaveBeenCalled(); + }); + + it('updates retained gitignore discovery state and rebuilds graph data', async () => { + const graph = createGraph('gitignore'); + const files = [createFile('src/new.ts')]; + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + const persistError = new Error('metadata failed'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const facade = createFacade({ + _buildGraphDataFromAnalysis: vi.fn(() => graph), + _persistIndexMetadata: vi.fn(async () => { + throw persistError; + }), + }); + vi.mocked(discoverRefreshWorkspaceFiles).mockResolvedValue({ + config: { showOrphans: false }, + discoveryResult: { + directories: undefined, + files, + gitIgnoredPaths: undefined, + }, + } as never); + + await expect( + refreshGitignoreMetadataForFacade(facade, { + disabledPlugins, + filterPatterns: ['src/**'], + signal, + }), + ).resolves.toBe(graph); + await Promise.resolve(); + + expect(discoverRefreshWorkspaceFiles).toHaveBeenCalledWith({ + configReader: facade._config, + disabledPlugins, + discovery: facade._discovery, + filterPatterns: ['src/**'], + getPluginFilterPatterns: expect.any(Function), + signal, + workspaceRoot: '/workspace', + }); + const getPluginFilterPatterns = vi.mocked(discoverRefreshWorkspaceFiles).mock.calls[0][0].getPluginFilterPatterns; + expect(getPluginFilterPatterns(disabledPlugins)).toEqual(['plugin/**']); + expect(facade.getPluginFilterPatterns).toHaveBeenCalledWith(disabledPlugins); + expect(facade._lastDiscoveredDirectories).toEqual([]); + expect(facade._lastDiscoveredFiles).toBe(files); + expect(facade._lastGitIgnoredPaths).toEqual([]); + expect(facade._lastWorkspaceRoot).toBe('/workspace'); + expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith( + '[CodeGraphy] Failed to persist gitignore metadata refresh.', + persistError, + ); + expect(facade._buildGraphDataFromAnalysis).toHaveBeenCalledWith( + facade._lastFileAnalysis, + '/workspace', + false, + disabledPlugins, + ); + warn.mockRestore(); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/refresh/modes/pluginFiles.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/modes/pluginFiles.test.ts new file mode 100644 index 000000000..9b81e7114 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/modes/pluginFiles.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IGraphData } from '../../../../../../src/shared/graph/contracts'; +import { + refreshPluginFilesForFacade, +} from '../../../../../../src/extension/pipeline/service/refresh/modes/pluginFiles'; +import type { RefreshFacadeContext } from '../../../../../../src/extension/pipeline/service/refresh/context'; +import { refreshWorkspacePipelinePluginFiles } from '../../../../../../src/extension/pipeline/service/runtime/refresh'; +import { discoverRefreshWorkspaceFiles } from '../../../../../../src/extension/pipeline/service/refresh/discovery/workspace'; +import { createWorkspaceIndexRefreshSource } from '../../../../../../src/extension/pipeline/service/refresh/source'; + +vi.mock('../../../../../../src/extension/pipeline/service/runtime/refresh', () => ({ + refreshWorkspacePipelinePluginFiles: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/discovery/workspace', () => ({ + discoverRefreshWorkspaceFiles: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/source', () => ({ + createWorkspaceIndexRefreshSource: vi.fn(), +})); + +function createGraph(id: string): IGraphData { + return { + nodes: [{ id, label: id, color: '#67E8F9', nodeType: 'file' }], + edges: [], + }; +} + +function createFile(relativePath = 'src/a.ts') { + return { + absolutePath: `/workspace/${relativePath}`, + extension: '.ts', + name: relativePath.split('/').at(-1) ?? relativePath, + relativePath, + }; +} + +function createFacade( + overrides: Partial = {}, +): RefreshFacadeContext { + return { + _analyzeFiles: vi.fn(), + _buildGraphData: vi.fn(), + _buildGraphDataFromAnalysis: vi.fn(), + _config: { + get: vi.fn(), + getAll: vi.fn(), + }, + _discovery: { discover: vi.fn() }, + _getActiveAnalysisPluginIds: vi.fn(() => []), + _getWorkspaceRoot: vi.fn(() => '/workspace'), + _lastDiscoveredDirectories: [], + _lastDiscoveredFiles: [], + _lastFileAnalysis: new Map(), + _lastFileConnections: new Map(), + _lastGitIgnoredPaths: ['old-ignore'], + _lastGraphData: createGraph('last'), + _lastWorkspaceRoot: '/workspace', + _patchGraphDataNodeMetrics: vi.fn(), + _persistCache: vi.fn(), + _persistIndexMetadata: vi.fn(async () => undefined), + _preAnalyzePlugins: vi.fn(), + _readAnalysisFiles: vi.fn(), + _registry: { + list: vi.fn(() => [{ id: 'plugin.a', name: 'Plugin A' }]), + notifyFilesChanged: vi.fn(), + }, + _toWorkspaceRelativePath: vi.fn(), + analyze: vi.fn(), + getPluginFilterPatterns: vi.fn(() => ['plugin/**']), + invalidateWorkspaceFiles: vi.fn(), + ...overrides, + } as unknown as RefreshFacadeContext; +} + +describe('extension/pipeline/service/refresh/modes/pluginFiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createWorkspaceIndexRefreshSource).mockReturnValue('refresh-source' as never); + }); + + it('returns an empty graph without plugin discovery when no workspace is open', async () => { + await expect( + refreshPluginFilesForFacade(createFacade({ + _getWorkspaceRoot: vi.fn(() => undefined), + }), { + disabledPlugins: new Set(), + filterPatterns: ['src/**'], + pluginIds: ['plugin.a'], + }), + ).resolves.toEqual({ nodes: [], edges: [] }); + + expect(discoverRefreshWorkspaceFiles).not.toHaveBeenCalled(); + expect(refreshWorkspacePipelinePluginFiles).not.toHaveBeenCalled(); + }); + + it('returns an empty graph without plugin discovery when no plugin ids are selected', async () => { + await expect( + refreshPluginFilesForFacade(createFacade(), { + disabledPlugins: new Set(), + filterPatterns: ['src/**'], + pluginIds: [], + }), + ).resolves.toEqual({ nodes: [], edges: [] }); + + expect(discoverRefreshWorkspaceFiles).not.toHaveBeenCalled(); + expect(refreshWorkspacePipelinePluginFiles).not.toHaveBeenCalled(); + }); + + it('refreshes selected plugin files with discovered workspace files', async () => { + const graph = createGraph('plugin-files'); + const files = [createFile('src/plugin.ts')]; + const disabledPlugins = new Set(['plugin.disabled']); + const pluginIds = ['plugin.a']; + const signal = new AbortController().signal; + const onProgress = vi.fn(); + const facade = createFacade(); + vi.mocked(discoverRefreshWorkspaceFiles).mockResolvedValue({ + config: {}, + discoveryResult: { + directories: undefined, + files, + gitIgnoredPaths: undefined, + }, + } as never); + vi.mocked(refreshWorkspacePipelinePluginFiles).mockResolvedValue(graph); + + await expect( + refreshPluginFilesForFacade(facade, { + disabledPlugins, + filterPatterns: ['src/**'], + onProgress, + pluginIds, + signal, + }), + ).resolves.toBe(graph); + + expect(discoverRefreshWorkspaceFiles).toHaveBeenCalledWith({ + configReader: facade._config, + disabledPlugins, + discovery: facade._discovery, + filterPatterns: ['src/**'], + getPluginFilterPatterns: expect.any(Function), + signal, + workspaceRoot: '/workspace', + }); + const getPluginFilterPatterns = vi.mocked(discoverRefreshWorkspaceFiles).mock.calls[0][0].getPluginFilterPatterns; + expect(getPluginFilterPatterns(disabledPlugins)).toEqual(['plugin/**']); + expect(facade.getPluginFilterPatterns).toHaveBeenCalledWith(disabledPlugins); + expect(facade._lastGitIgnoredPaths).toEqual([]); + expect(createWorkspaceIndexRefreshSource).toHaveBeenCalledWith(facade, disabledPlugins); + expect(facade._registry.list).toHaveBeenCalledOnce(); + expect(refreshWorkspacePipelinePluginFiles).toHaveBeenCalledWith('refresh-source', { + disabledPlugins, + discoveredDirectories: [], + discoveredFiles: files, + onProgress, + persistCache: expect.any(Function), + persistIndexMetadata: expect.any(Function), + pluginIds, + pluginInfos: [{ id: 'plugin.a', name: 'Plugin A' }], + signal, + workspaceRoot: '/workspace', + }); + + const refreshOptions = vi.mocked(refreshWorkspacePipelinePluginFiles).mock.calls[0][1]; + refreshOptions.persistCache(); + await refreshOptions.persistIndexMetadata(); + expect(facade._persistCache).toHaveBeenCalledOnce(); + expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/refresh/scope.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/scope.test.ts new file mode 100644 index 000000000..90935c84d --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/scope.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { hasRequiredAnalysisCacheTiers } from '@codegraphy-dev/core'; +import { + canReuseCurrentAnalysisForScope, + rebuildAnalysisScopeFromCurrentAnalysis, + type AnalysisScopeRefreshFacade, +} from '../../../../../src/extension/pipeline/service/refresh/scope'; +import { createWorkspacePipelineAnalysisCacheTiers } from '../../../../../src/extension/pipeline/service/cache/tiers'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; + +vi.mock('@codegraphy-dev/core', async (importOriginal) => ({ + ...(await importOriginal()), + hasRequiredAnalysisCacheTiers: vi.fn(), +})); + +vi.mock('../../../../../src/extension/pipeline/service/cache/tiers', () => ({ + createWorkspacePipelineAnalysisCacheTiers: vi.fn(), +})); + +function createGraph(id = 'graph'): IGraphData { + return { + nodes: [{ id, label: id, color: '#67E8F9', nodeType: 'file' }], + edges: [], + }; +} + +function createFile(relativePath: string) { + return { + absolutePath: `/workspace/${relativePath}`, + extension: '.ts', + name: relativePath.split('/').at(-1) ?? relativePath, + relativePath, + }; +} + +describe('extension/pipeline/service/refresh/scope', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createWorkspacePipelineAnalysisCacheTiers).mockReturnValue({ + active: ['baseline'], + completed: ['baseline'], + required: ['baseline', 'plugin:plugin.a'], + }); + }); + + it('does not reuse current analysis when no files are discovered', () => { + expect( + canReuseCurrentAnalysisForScope({ + activePluginIds: ['plugin.a'], + discoveredFiles: [], + disabledPlugins: new Set(), + lastFileAnalysis: new Map(), + nodeVisibility: { file: true }, + }), + ).toBe(false); + expect(createWorkspacePipelineAnalysisCacheTiers).not.toHaveBeenCalled(); + }); + + it('reuses current analysis only when every discovered file has required tiers', () => { + const firstAnalysis = { filePath: '/workspace/src/a.ts' }; + const secondAnalysis = { filePath: '/workspace/src/b.ts' }; + const lastFileAnalysis = new Map([ + ['src/a.ts', firstAnalysis], + ['src/b.ts', secondAnalysis], + ]); + vi.mocked(hasRequiredAnalysisCacheTiers) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + + expect( + canReuseCurrentAnalysisForScope({ + activePluginIds: ['plugin.a'], + discoveredFiles: [createFile('src/a.ts'), createFile('src/b.ts')], + disabledPlugins: new Set(['plugin.disabled']), + lastFileAnalysis: lastFileAnalysis as never, + nodeVisibility: { file: true, symbol: false }, + }), + ).toBe(false); + + expect(createWorkspacePipelineAnalysisCacheTiers).toHaveBeenCalledWith( + { file: true, symbol: false }, + ['plugin.a'], + ); + expect(hasRequiredAnalysisCacheTiers).toHaveBeenNthCalledWith( + 1, + firstAnalysis, + ['baseline', 'plugin:plugin.a'], + ); + expect(hasRequiredAnalysisCacheTiers).toHaveBeenNthCalledWith( + 2, + secondAnalysis, + ['baseline', 'plugin:plugin.a'], + ); + + vi.mocked(hasRequiredAnalysisCacheTiers).mockReset(); + vi.mocked(hasRequiredAnalysisCacheTiers).mockReturnValue(true); + + expect( + canReuseCurrentAnalysisForScope({ + activePluginIds: ['plugin.a'], + discoveredFiles: [createFile('src/a.ts'), createFile('src/b.ts')], + disabledPlugins: new Set(), + lastFileAnalysis: lastFileAnalysis as never, + nodeVisibility: { file: true, symbol: false }, + }), + ).toBe(true); + }); + + it('does not reuse current analysis when a discovered file has no analysis', () => { + vi.mocked(hasRequiredAnalysisCacheTiers).mockReturnValue(true); + + expect( + canReuseCurrentAnalysisForScope({ + activePluginIds: [], + discoveredFiles: [createFile('src/missing.ts')], + disabledPlugins: new Set(), + lastFileAnalysis: new Map() as never, + nodeVisibility: {}, + }), + ).toBe(false); + expect(hasRequiredAnalysisCacheTiers).not.toHaveBeenCalled(); + }); + + it('rebuilds graph data from current analysis and updates retained scope state', async () => { + const graphData = createGraph('rebuilt'); + const fileAnalysis = new Map([['src/a.ts', { filePath: '/workspace/src/a.ts' }]]); + const facade: AnalysisScopeRefreshFacade = { + _buildGraphDataFromAnalysis: vi.fn(() => graphData) as never, + _lastDiscoveredDirectories: [], + _lastDiscoveredFiles: [], + _lastFileAnalysis: fileAnalysis as never, + _lastWorkspaceRoot: '', + _persistIndexMetadata: vi.fn(async () => undefined), + }; + const discoveredDirectories = ['src', 'src/nested']; + const discoveredFiles = [createFile('src/a.ts')]; + const disabledPlugins = new Set(['plugin.disabled']); + const onProgress = vi.fn(); + + await expect( + rebuildAnalysisScopeFromCurrentAnalysis(facade, { + discoveredDirectories, + discoveredFiles, + disabledPlugins, + onProgress, + showOrphans: false, + workspaceRoot: '/workspace', + }), + ).resolves.toBe(graphData); + + expect(onProgress).toHaveBeenNthCalledWith(1, { + phase: 'Applying Scope', + current: 0, + total: 1, + }); + expect(onProgress).toHaveBeenNthCalledWith(2, { + phase: 'Applying Scope', + current: 1, + total: 1, + }); + expect(facade._lastDiscoveredDirectories).toEqual(discoveredDirectories); + expect(facade._lastDiscoveredDirectories).not.toBe(discoveredDirectories); + expect(facade._lastDiscoveredFiles).toBe(discoveredFiles); + expect(facade._lastWorkspaceRoot).toBe('/workspace'); + expect(facade._buildGraphDataFromAnalysis).toHaveBeenCalledWith( + fileAnalysis, + '/workspace', + false, + disabledPlugins, + ); + expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); + }); + + it('rebuilds analysis scope without progress callbacks', async () => { + const graphData = createGraph('no-progress'); + const facade: AnalysisScopeRefreshFacade = { + _buildGraphDataFromAnalysis: vi.fn(() => graphData) as never, + _lastDiscoveredDirectories: [], + _lastDiscoveredFiles: [], + _lastFileAnalysis: new Map() as never, + _lastWorkspaceRoot: '', + _persistIndexMetadata: vi.fn(async () => undefined), + }; + + await expect( + rebuildAnalysisScopeFromCurrentAnalysis(facade, { + discoveredDirectories: [], + discoveredFiles: [], + disabledPlugins: new Set(), + showOrphans: true, + workspaceRoot: '/workspace', + }), + ).resolves.toBe(graphData); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/refresh/source.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/source.test.ts new file mode 100644 index 000000000..276983352 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/source.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; +import { + createWorkspaceIndexRefreshSource, + type RefreshSourceFacade, +} from '../../../../../src/extension/pipeline/service/refresh/source'; + +function createGraph(id = 'graph'): IGraphData { + return { + nodes: [{ id, label: id, color: '#67E8F9', nodeType: 'file' }], + edges: [], + }; +} + +function createRefreshFacade(): RefreshSourceFacade { + const graphData = createGraph(); + return { + _analyzeFiles: vi.fn(async () => ({ + fileAnalysis: new Map(), + fileConnections: new Map(), + })) as unknown as RefreshSourceFacade['_analyzeFiles'], + _buildGraphData: vi.fn(() => graphData) as unknown as RefreshSourceFacade['_buildGraphData'], + _buildGraphDataFromAnalysis: vi.fn(() => graphData) as unknown as RefreshSourceFacade['_buildGraphDataFromAnalysis'], + _lastDiscoveredDirectories: ['src'], + _lastDiscoveredFiles: [{ absolutePath: '/workspace/src/a.ts', extension: '.ts', name: 'a.ts', relativePath: 'src/a.ts' }], + _lastFileAnalysis: new Map(), + _lastFileConnections: new Map(), + _lastGraphData: graphData, + _lastWorkspaceRoot: '/workspace', + _patchGraphDataNodeMetrics: vi.fn(() => graphData) as unknown as RefreshSourceFacade['_patchGraphDataNodeMetrics'], + _preAnalyzePlugins: vi.fn(async () => undefined) as unknown as RefreshSourceFacade['_preAnalyzePlugins'], + _readAnalysisFiles: vi.fn(async () => []) as unknown as RefreshSourceFacade['_readAnalysisFiles'], + analyze: vi.fn(async () => graphData) as unknown as RefreshSourceFacade['analyze'], + invalidateWorkspaceFiles: vi.fn() as unknown as RefreshSourceFacade['invalidateWorkspaceFiles'], + }; +} + +describe('extension/pipeline/service/refresh/source', () => { + it('delegates refresh source methods with default and override plugin disable sets', async () => { + const facade = createRefreshFacade(); + const defaultDisabledPlugins = new Set(['plugin.default']); + const overrideDisabledPlugins = new Set(['plugin.override']); + const source = createWorkspaceIndexRefreshSource(facade, defaultDisabledPlugins); + const files = [{ absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts' }]; + const progress = vi.fn(); + const abortSignal = new AbortController().signal; + const pluginIds = ['plugin.a']; + const fileConnections = new Map([['src/a.ts', []]]); + const fileAnalysis = new Map([['src/a.ts', { filePath: '/workspace/src/a.ts', relations: [] }]]); + const graphData = createGraph('input'); + const patterns = ['src/**']; + + await source._analyzeFiles( + files as never, + '/workspace', + progress, + abortSignal, + pluginIds, + ); + expect(facade._analyzeFiles).toHaveBeenCalledWith( + files, + '/workspace', + progress, + abortSignal, + pluginIds, + defaultDisabledPlugins, + ); + + await source._analyzeFiles( + files as never, + '/workspace', + progress, + abortSignal, + pluginIds, + overrideDisabledPlugins, + ); + expect(facade._analyzeFiles).toHaveBeenLastCalledWith( + files, + '/workspace', + progress, + abortSignal, + pluginIds, + overrideDisabledPlugins, + ); + + source._buildGraphData(fileConnections as never, '/workspace', overrideDisabledPlugins); + expect(facade._buildGraphData).toHaveBeenCalledWith( + fileConnections, + '/workspace', + true, + overrideDisabledPlugins, + ); + + source._buildGraphDataFromAnalysis(fileAnalysis as never, '/workspace', overrideDisabledPlugins); + expect(facade._buildGraphDataFromAnalysis).toHaveBeenCalledWith( + fileAnalysis, + '/workspace', + true, + overrideDisabledPlugins, + ); + + expect(source._patchGraphDataNodeMetrics).toBeDefined(); + source._patchGraphDataNodeMetrics?.(graphData, ['src/a.ts']); + expect(facade._patchGraphDataNodeMetrics).toHaveBeenCalledWith(graphData, ['src/a.ts']); + + await source._preAnalyzePlugins(files as never, '/workspace', abortSignal); + expect(facade._preAnalyzePlugins).toHaveBeenCalledWith( + files, + '/workspace', + abortSignal, + defaultDisabledPlugins, + ); + + await source._preAnalyzePlugins( + files as never, + '/workspace', + abortSignal, + overrideDisabledPlugins, + ); + expect(facade._preAnalyzePlugins).toHaveBeenLastCalledWith( + files, + '/workspace', + abortSignal, + overrideDisabledPlugins, + ); + + await source._readAnalysisFiles(files as never); + expect(facade._readAnalysisFiles).toHaveBeenCalledWith(files); + + await source.analyze(patterns, overrideDisabledPlugins, abortSignal, progress); + expect(facade.analyze).toHaveBeenCalledWith( + patterns, + overrideDisabledPlugins, + abortSignal, + progress, + ); + + source.invalidateWorkspaceFiles(['src/a.ts']); + expect(facade.invalidateWorkspaceFiles).toHaveBeenCalledWith(['src/a.ts']); + }); + + it('mirrors refresh source retained state through live accessors', () => { + const facade = createRefreshFacade(); + const source = createWorkspaceIndexRefreshSource(facade); + const nextDirectories = ['src', 'test']; + const nextFiles = [{ absolutePath: '/workspace/src/b.ts', extension: '.ts', name: 'b.ts', relativePath: 'src/b.ts' }]; + const nextFileAnalysis = new Map([['src/b.ts', { filePath: '/workspace/src/b.ts', relations: [] }]]); + const nextFileConnections = new Map([['src/b.ts', []]]); + const nextGraphData = createGraph('next'); + + expect(source._lastDiscoveredDirectories).toBe(facade._lastDiscoveredDirectories); + source._lastDiscoveredDirectories = nextDirectories as never; + expect(facade._lastDiscoveredDirectories).toBe(nextDirectories); + + expect(source._lastDiscoveredFiles).toBe(facade._lastDiscoveredFiles); + source._lastDiscoveredFiles = nextFiles as never; + expect(facade._lastDiscoveredFiles).toBe(nextFiles); + + expect(source._lastFileAnalysis).toBe(facade._lastFileAnalysis); + source._lastFileAnalysis = nextFileAnalysis as never; + expect(facade._lastFileAnalysis).toBe(nextFileAnalysis); + + expect(source._lastFileConnections).toBe(facade._lastFileConnections); + source._lastFileConnections = nextFileConnections as never; + expect(facade._lastFileConnections).toBe(nextFileConnections); + + expect(source._lastGraphData).toBe(facade._lastGraphData); + source._lastGraphData = nextGraphData; + expect(facade._lastGraphData).toBe(nextGraphData); + + expect(source._lastWorkspaceRoot).toBe('/workspace'); + source._lastWorkspaceRoot = '/next-workspace'; + expect(facade._lastWorkspaceRoot).toBe('/next-workspace'); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts b/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts index 8dec2b49f..390b5eb05 100644 --- a/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; +import fs from 'node:fs'; import * as vscode from 'vscode'; import { WorkspacePipelineRefreshFacade } from '../../../../src/extension/pipeline/service/refreshFacade'; import { @@ -8,7 +9,14 @@ import { import { refreshWorkspacePipelineAnalysisScope, refreshWorkspacePipelineChangedFiles, + refreshWorkspacePipelinePluginFiles, } from '../../../../src/extension/pipeline/service/runtime/refresh'; +import { + CACHE_VERSION, + CACHE_VERSION_KEY, + CHURN_COUNTS_STATE_KEY, + PLUGIN_SIGNATURE_KEY, +} from '../../../../src/extension/gitHistory/cache/stateKeys'; vi.mock('../../../../src/extension/pipeline/service/runtime/discovery', () => ({ createWorkspacePipelineDiscoveryDependencies: vi.fn(), @@ -18,6 +26,7 @@ vi.mock('../../../../src/extension/pipeline/service/runtime/discovery', () => ({ vi.mock('../../../../src/extension/pipeline/service/runtime/refresh', () => ({ refreshWorkspacePipelineAnalysisScope: vi.fn(), refreshWorkspacePipelineChangedFiles: vi.fn(), + refreshWorkspacePipelinePluginFiles: vi.fn(), })); vi.mock('vscode', () => ({ @@ -53,11 +62,18 @@ class TestRefreshFacade extends WorkspacePipelineRefreshFacade { } _config = { + get: vi.fn((key: string, defaultValue: unknown) => { + if (key === 'nodeVisibility') { + return {}; + } + return defaultValue; + }), getAll: vi.fn(() => ({ showOrphans: true, respectGitignore: true })), } as never; _discovery = { kind: 'discovery' } as never; _registry = { + list: vi.fn(() => [{ plugin: { id: 'plugin.a' } }]), notifyFilesChanged: vi.fn(async () => ({ additionalFilePaths: [], requiresFullRefresh: false })), } as never; @@ -69,6 +85,14 @@ class TestRefreshFacade extends WorkspacePipelineRefreshFacade { super._lastDiscoveredFiles = files; } + public override get _lastDiscoveredDirectories(): string[] { + return super._lastDiscoveredDirectories; + } + + public override set _lastDiscoveredDirectories(directories: string[]) { + super._lastDiscoveredDirectories = directories; + } + public override get _lastGitIgnoredPaths(): string[] { return super._lastGitIgnoredPaths; } @@ -101,6 +125,22 @@ class TestRefreshFacade extends WorkspacePipelineRefreshFacade { super._lastWorkspaceRoot = workspaceRoot; } + public override get _lastGraphData(): never { + return super._lastGraphData as never; + } + + public override set _lastGraphData(graphData: never) { + super._lastGraphData = graphData; + } + + public override get _cache(): never { + return super._cache as never; + } + + public override set _cache(cache: never) { + super._cache = cache; + } + _getWorkspaceRoot = vi.fn(() => '/workspace'); getPluginFilterPatterns = vi.fn(() => ['plugin-filter']); _persistCache = vi.fn(); @@ -136,6 +176,10 @@ describe('pipeline/service/refreshFacade', () => { nodes: [{ id: 'scope-refresh' }], edges: [], } as never); + vi.mocked(refreshWorkspacePipelinePluginFiles).mockResolvedValue({ + nodes: [{ id: 'plugin-refresh' }], + edges: [], + } as never); }); it('returns an empty graph immediately when no workspace root is available', async () => { @@ -275,6 +319,108 @@ describe('pipeline/service/refreshFacade', () => { expect(facade.invalidateWorkspaceFiles).toHaveBeenCalledWith(['/workspace/src/a.ts']); }); + it('reuses the current discovered files for existing changed files', async () => { + const facade = new TestRefreshFacade(); + facade._lastWorkspaceRoot = '/workspace'; + facade._lastDiscoveredDirectories = ['src']; + facade._lastDiscoveredFiles = [ + { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', + extension: '.ts', + name: 'a.ts', + }, + ] as never; + vi.spyOn(fs, 'existsSync').mockImplementation(filePath => filePath === '/workspace/src/a.ts'); + + await facade.refreshChangedFiles(['/workspace/src/a.ts']); + + expect(discoverWorkspacePipelineFilesWithWarnings).not.toHaveBeenCalled(); + const [, refreshDependencies] = vi.mocked(refreshWorkspacePipelineChangedFiles).mock.calls[0]; + expect(refreshDependencies.discoveredDirectories).toEqual(['src']); + expect(refreshDependencies.discoveredFiles).toEqual([ + { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', + extension: '.ts', + name: 'a.ts', + }, + ]); + }); + + it('patches delegated graph metrics for file and symbol nodes', async () => { + const facade = new TestRefreshFacade(); + facade._cache = { + files: { + 'src/a.ts': { size: 12 }, + }, + } as never; + facade._registry = { + notifyFilesChanged: vi.fn(async () => ({ additionalFilePaths: [], requiresFullRefresh: false })), + list: vi.fn(() => [{ plugin: { id: 'plugin.a', version: '1.0.0' } }]), + } as never; + const workspaceState = ( + facade as unknown as { + _context: { workspaceState: { get: ReturnType } }; + } + )._context.workspaceState; + workspaceState.get.mockImplementation((key: string) => { + if (key === CACHE_VERSION_KEY) { + return CACHE_VERSION; + } + if (key === PLUGIN_SIGNATURE_KEY) { + return 'plugin.a@1.0.0'; + } + if (key === CHURN_COUNTS_STATE_KEY) { + return { 'src/a.ts': 7 }; + } + return undefined; + }); + + await facade.refreshChangedFiles(['/workspace/src/a.ts']); + + const [refreshSource] = vi.mocked(refreshWorkspacePipelineChangedFiles).mock.calls[0]; + expect(refreshSource._patchGraphDataNodeMetrics?.({ + nodes: [ + { color: '#fff', id: 'src/a.ts', label: 'a.ts', fileSize: 10, churn: 1 }, + { + color: '#fff', + id: 'src/a.ts#run:function', + label: 'run', + symbol: { + filePath: 'src/a.ts', + id: 'src/a.ts#run:function', + kind: 'function', + name: 'run', + }, + fileSize: 10, + churn: 1, + }, + { color: '#fff', id: 'src/b.ts', label: 'b.ts', fileSize: 4, churn: 2 }, + ], + edges: [], + }, ['src/a.ts'])).toEqual({ + nodes: [ + { color: '#fff', id: 'src/a.ts', label: 'a.ts', fileSize: 12, churn: 7 }, + { + color: '#fff', + id: 'src/a.ts#run:function', + label: 'run', + symbol: { + filePath: 'src/a.ts', + id: 'src/a.ts#run:function', + kind: 'function', + name: 'run', + }, + fileSize: 12, + churn: 7, + }, + { color: '#fff', id: 'src/b.ts', label: 'b.ts', fileSize: 4, churn: 2 }, + ], + edges: [], + }); + }); + it('builds delegated discovery and refresh dependencies for analysis-scope refreshes', async () => { const facade = new TestRefreshFacade(); const disabledPlugins = new Set(['plugin.disabled']); @@ -326,6 +472,142 @@ describe('pipeline/service/refreshFacade', () => { ); }); + it('uses empty filters by default for analysis-scope refreshes', async () => { + const facade = new TestRefreshFacade(); + + await facade.refreshAnalysisScope(); + + expect(discoverWorkspacePipelineFilesWithWarnings).toHaveBeenCalledWith( + 'discovery-deps', + '/workspace', + { showOrphans: true, respectGitignore: true }, + [], + ['plugin-filter'], + undefined, + expect.any(Function), + ); + }); + + it('builds delegated discovery and refresh dependencies for plugin-file refreshes', async () => { + const facade = new TestRefreshFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + const onProgress = vi.fn(); + + const result = await facade.refreshPluginFiles( + ['plugin.a'], + undefined, + disabledPlugins, + signal, + onProgress, + ); + + expect(result).toEqual({ nodes: [{ id: 'plugin-refresh' }], edges: [] }); + expect(facade._lastGitIgnoredPaths).toEqual(['example-python/app.py']); + expect(discoverWorkspacePipelineFilesWithWarnings).toHaveBeenCalledWith( + 'discovery-deps', + '/workspace', + { showOrphans: true, respectGitignore: true }, + [], + ['plugin-filter'], + signal, + expect.any(Function), + ); + + const [refreshSource, refreshDependencies] = vi.mocked(refreshWorkspacePipelinePluginFiles).mock.calls[0]; + expect(refreshDependencies.disabledPlugins).toBe(disabledPlugins); + expect(refreshDependencies.discoveredDirectories).toEqual([]); + expect(refreshDependencies.discoveredFiles).toEqual([ + { absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts' }, + ]); + expect(refreshDependencies.onProgress).toBe(onProgress); + expect(refreshDependencies.pluginIds).toEqual(['plugin.a']); + expect(refreshDependencies.pluginInfos).toEqual([{ plugin: { id: 'plugin.a' } }]); + expect(refreshDependencies.signal).toBe(signal); + expect(refreshDependencies.workspaceRoot).toBe('/workspace'); + + refreshDependencies.persistCache(); + expect(facade._persistCache).toHaveBeenCalledOnce(); + + await refreshDependencies.persistIndexMetadata(); + expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); + + await refreshSource._analyzeFiles([], '/workspace', undefined, signal); + expect(facade._analyzeFiles).toHaveBeenCalledWith( + [], + '/workspace', + undefined, + signal, + undefined, + disabledPlugins, + ); + }); + + it('rebuilds analysis scope from tier-complete cached analysis without reanalyzing files', async () => { + const facade = new TestRefreshFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + const onProgress = vi.fn(); + const graphData = { + nodes: [{ id: 'src/a.ts#run:function' }], + edges: [], + }; + facade._config = { + get: vi.fn((key: string, defaultValue: unknown) => { + if (key === 'nodeVisibility') { + return { symbol: true, 'symbol:function': true }; + } + return defaultValue; + }), + getAll: vi.fn(() => ({ showOrphans: true, respectGitignore: true })), + } as never; + facade._lastFileAnalysis = new Map([ + ['src/a.ts', { + filePath: '/workspace/src/a.ts', + relations: [], + symbols: [{ + filePath: '/workspace/src/a.ts', + id: '/workspace/src/a.ts:function:run', + kind: 'function', + name: 'run', + }], + cache: { + tiers: ['baseline', 'symbols', 'plugin:plugin.a'], + }, + }], + ]) as never; + facade._buildGraphDataFromAnalysis = vi.fn(() => graphData) as never; + + await expect( + facade.refreshAnalysisScope(['dist/**'], disabledPlugins, signal, onProgress), + ).resolves.toBe(graphData); + + expect(refreshWorkspacePipelineAnalysisScope).not.toHaveBeenCalled(); + expect(facade._lastDiscoveredFiles).toEqual([ + { absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts' }, + ]); + expect(facade._lastGitIgnoredPaths).toEqual(['example-python/app.py']); + expect(facade._lastWorkspaceRoot).toBe('/workspace'); + expect(facade._buildGraphDataFromAnalysis).toHaveBeenCalledWith( + facade._lastFileAnalysis, + '/workspace', + true, + disabledPlugins, + ); + expect(facade._persistCache).not.toHaveBeenCalled(); + expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); + expect(onProgress).toHaveBeenNthCalledWith(1, { + phase: 'Applying Scope', + current: 0, + total: 1, + }); + expect(onProgress).toHaveBeenNthCalledWith(2, { + phase: 'Applying Scope', + current: 1, + total: 1, + }); + }); + it('refreshes gitignore metadata by rebuilding from cached analysis without analyzing files', async () => { const facade = new TestRefreshFacade(); const disabledPlugins = new Set(['plugin.disabled']); @@ -353,7 +635,7 @@ describe('pipeline/service/refreshFacade', () => { })) as never; await expect( - facade.refreshGitignoreMetadata(['dist/**'], disabledPlugins), + facade.refreshGitignoreMetadata(undefined, disabledPlugins), ).resolves.toEqual({ nodes: [{ color: '#64748B', @@ -366,6 +648,15 @@ describe('pipeline/service/refreshFacade', () => { expect(facade._analyzeFiles).not.toHaveBeenCalled(); expect(facade._lastGitIgnoredPaths).toEqual(['example-python/src/main.py']); + expect(discoverWorkspacePipelineFilesWithWarnings).toHaveBeenCalledWith( + 'discovery-deps', + '/workspace', + { showOrphans: true, respectGitignore: true }, + [], + ['plugin-filter'], + undefined, + expect.any(Function), + ); expect(facade._buildGraphDataFromAnalysis).toHaveBeenCalledWith( facade._lastFileAnalysis, '/workspace', diff --git a/packages/extension/tests/extension/pipeline/service/runtime/refresh.test.ts b/packages/extension/tests/extension/pipeline/service/runtime/refresh.test.ts index e36169673..8cb177067 100644 --- a/packages/extension/tests/extension/pipeline/service/runtime/refresh.test.ts +++ b/packages/extension/tests/extension/pipeline/service/runtime/refresh.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { IFileAnalysisResult } from '../../../../../src/core/plugins/types/contracts'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; import { refreshWorkspacePipelineChangedFiles } from '../../../../../src/extension/pipeline/service/runtime/refresh'; function createSource() { @@ -7,11 +8,26 @@ function createSource() { _analyzeFiles: vi.fn(), _buildGraphData: vi.fn(() => ({ nodes: [], edges: [] })), _buildGraphDataFromAnalysis: vi.fn(() => ({ nodes: [{ id: 'node' }], edges: [] })), + _lastGraphData: { + nodes: [ + { id: 'src/a.ts', fileSize: 10, churn: 1 }, + { id: 'src/a.ts#run:function', symbol: { filePath: 'src/a.ts' }, fileSize: 10, churn: 1 }, + ], + edges: [{ from: 'src/a.ts', to: 'src/b.ts', kind: 'import' }], + }, _lastDiscoveredDirectories: [] as string[], _lastDiscoveredFiles: [] as Array<{ absolutePath: string; relativePath: string }>, _lastFileAnalysis: new Map(), _lastFileConnections: new Map(), _lastWorkspaceRoot: '', + _patchGraphDataNodeMetrics: vi.fn((graphData: IGraphData) => ({ + ...graphData, + nodes: graphData.nodes.map(node => ( + node.id === 'src/a.ts' || node.symbol?.filePath === 'src/a.ts' + ? { ...node, fileSize: 12, churn: 1 } + : node + )), + })), _readAnalysisFiles: vi.fn(), analyze: vi.fn(), invalidateWorkspaceFiles: vi.fn(), @@ -174,6 +190,182 @@ describe('pipeline/service/refresh', () => { expect(graph).toEqual({ nodes: [{ id: 'node' }], edges: [] }); }); + it('patches node metrics without rebuilding the graph when changed-file analysis is graph-equivalent', async () => { + const source = createSource(); + const dependencies = createDependencies(); + const existingAnalysis: IFileAnalysisResult = { + filePath: '/workspace/src/a.ts', + relations: [{ + fromFilePath: '/workspace/src/a.ts', + kind: 'import', + resolvedPath: '/workspace/src/b.ts', + sourceId: 'src/a.ts', + toFilePath: '/workspace/src/b.ts', + }], + symbols: [{ + filePath: '/workspace/src/a.ts', + id: '/workspace/src/a.ts:function:run', + kind: 'function', + name: 'run', + }], + }; + source._lastFileAnalysis.set('src/a.ts', existingAnalysis); + source._lastFileConnections.set('src/a.ts', [{ kind: 'import' }]); + source._readAnalysisFiles.mockResolvedValue([ + { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', + content: 'content:a', + }, + ]); + dependencies.notifyFilesChanged.mockResolvedValue({ + additionalFilePaths: [], + requiresFullRefresh: false, + }); + source._analyzeFiles.mockResolvedValue({ + cacheHits: 0, + cacheMisses: 1, + fileAnalysis: new Map([['src/a.ts', { ...existingAnalysis }]]), + fileConnections: new Map([['src/a.ts', [{ kind: 'import' }]]]), + }); + const previousGraphData = source._lastGraphData; + + const graph = await refreshWorkspacePipelineChangedFiles(source as never, dependencies as never); + + expect(source._buildGraphDataFromAnalysis).not.toHaveBeenCalled(); + expect(source._buildGraphData).not.toHaveBeenCalled(); + expect(source._patchGraphDataNodeMetrics).toHaveBeenCalledWith( + previousGraphData, + ['src/a.ts'], + ); + expect(source._lastGraphData).toBe(graph); + expect(graph).toEqual({ + nodes: [ + { id: 'src/a.ts', fileSize: 12, churn: 1 }, + { id: 'src/a.ts#run:function', symbol: { filePath: 'src/a.ts' }, fileSize: 12, churn: 1 }, + ], + edges: [{ from: 'src/a.ts', to: 'src/b.ts', kind: 'import' }], + }); + expect(dependencies.persistCache).toHaveBeenCalledOnce(); + expect(dependencies.persistIndexMetadata).toHaveBeenCalledOnce(); + }); + + it('can return metric-only graph patches before index metadata persistence settles', async () => { + const source = createSource(); + const dependencies = createDependencies(); + const existingAnalysis: IFileAnalysisResult = { + filePath: '/workspace/src/a.ts', + relations: [{ + fromFilePath: '/workspace/src/a.ts', + kind: 'import', + resolvedPath: '/workspace/src/b.ts', + sourceId: 'src/a.ts', + toFilePath: '/workspace/src/b.ts', + }], + symbols: [{ + filePath: '/workspace/src/a.ts', + id: '/workspace/src/a.ts:function:run', + kind: 'function', + name: 'run', + }], + }; + source._lastFileAnalysis.set('src/a.ts', existingAnalysis); + source._lastFileConnections.set('src/a.ts', [{ kind: 'import' }]); + source._readAnalysisFiles.mockResolvedValue([ + { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', + content: 'content:a', + }, + ]); + dependencies.notifyFilesChanged.mockResolvedValue({ + additionalFilePaths: [], + requiresFullRefresh: false, + }); + source._analyzeFiles.mockResolvedValue({ + cacheHits: 0, + cacheMisses: 1, + fileAnalysis: new Map([['src/a.ts', { ...existingAnalysis }]]), + fileConnections: new Map([['src/a.ts', [{ kind: 'import' }]]]), + }); + let resolvePersistIndexMetadata: (() => void) | undefined; + dependencies.persistIndexMetadata = vi.fn(() => new Promise(resolve => { + resolvePersistIndexMetadata = resolve; + })); + (dependencies as typeof dependencies & { deferMetricOnlyIndexMetadata: boolean }) + .deferMetricOnlyIndexMetadata = true; + + const graphPromise = refreshWorkspacePipelineChangedFiles(source as never, dependencies as never); + const result = await Promise.race([ + graphPromise.then(graph => ({ status: 'resolved' as const, graph })), + new Promise<{ status: 'pending' }>(resolve => { + setTimeout(() => resolve({ status: 'pending' }), 0); + }), + ]); + + expect(result.status).toBe('resolved'); + expect(dependencies.persistIndexMetadata).toHaveBeenCalledOnce(); + resolvePersistIndexMetadata?.(); + expect(await graphPromise).toEqual((result as { graph: IGraphData }).graph); + }); + + it('reports deferred metric-only index metadata persistence failures', async () => { + const source = createSource(); + const dependencies = createDependencies(); + const existingAnalysis: IFileAnalysisResult = { + filePath: '/workspace/src/a.ts', + relations: [{ + fromFilePath: '/workspace/src/a.ts', + kind: 'import', + resolvedPath: '/workspace/src/b.ts', + sourceId: 'src/a.ts', + toFilePath: '/workspace/src/b.ts', + }], + symbols: [{ + filePath: '/workspace/src/a.ts', + id: '/workspace/src/a.ts:function:run', + kind: 'function', + name: 'run', + }], + }; + source._lastFileAnalysis.set('src/a.ts', existingAnalysis); + source._lastFileConnections.set('src/a.ts', [{ kind: 'import' }]); + source._readAnalysisFiles.mockResolvedValue([ + { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', + content: 'content:a', + }, + ]); + dependencies.notifyFilesChanged.mockResolvedValue({ + additionalFilePaths: [], + requiresFullRefresh: false, + }); + source._analyzeFiles.mockResolvedValue({ + cacheHits: 0, + cacheMisses: 1, + fileAnalysis: new Map([['src/a.ts', { ...existingAnalysis }]]), + fileConnections: new Map([['src/a.ts', [{ kind: 'import' }]]]), + }); + const persistenceError = new Error('persist failed'); + const onDeferredIndexMetadataError = vi.fn(); + dependencies.persistIndexMetadata = vi.fn(async () => { + throw persistenceError; + }); + (dependencies as typeof dependencies & { + deferMetricOnlyIndexMetadata: boolean; + onDeferredIndexMetadataError: (error: unknown) => void; + }).deferMetricOnlyIndexMetadata = true; + (dependencies as typeof dependencies & { + onDeferredIndexMetadataError: (error: unknown) => void; + }).onDeferredIndexMetadataError = onDeferredIndexMetadataError; + + await refreshWorkspacePipelineChangedFiles(source as never, dependencies as never); + await Promise.resolve(); + + expect(onDeferredIndexMetadataError).toHaveBeenCalledWith(persistenceError); + }); + it('supports refreshes without a progress callback', async () => { const source = createSource(); const dependencies = createDependencies(); diff --git a/packages/extension/tests/extension/pipeline/serviceAdapters.test.ts b/packages/extension/tests/extension/pipeline/serviceAdapters.test.ts index e370f4462..f8d55ca0f 100644 --- a/packages/extension/tests/extension/pipeline/serviceAdapters.test.ts +++ b/packages/extension/tests/extension/pipeline/serviceAdapters.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { analyzeWorkspacePipelineFiles, buildWorkspacePipelineGraphData, @@ -10,6 +10,10 @@ import { import { CACHE_VERSION } from '../../../src/extension/gitHistory/cache/stateKeys'; describe('pipeline/serviceAdapters', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('pre-analyzes files with shared registry and discovery adapters', async () => { const notifyPreAnalyze = vi.fn(async () => undefined); const readContent = vi.fn(async () => 'content'); diff --git a/packages/extension/tests/extension/pluginIntegration/installed/activation.test.ts b/packages/extension/tests/extension/pluginIntegration/installed/activation.test.ts index 1f4c40562..2fb1d660b 100644 --- a/packages/extension/tests/extension/pluginIntegration/installed/activation.test.ts +++ b/packages/extension/tests/extension/pluginIntegration/installed/activation.test.ts @@ -158,7 +158,8 @@ describe('extension/pluginIntegration/installedPluginActivation', () => { expect(pluginIds).toEqual( expect.arrayContaining(['codegraphy.markdown', installedPackage!.pluginId]), ); - expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); + expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCache).toHaveBeenCalled(); + expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCacheAsync).not.toHaveBeenCalled(); expect(mockState.databaseCache.saveWorkspaceAnalysisDatabaseCacheAsync).toHaveBeenCalled(); }, 15000); }); diff --git a/packages/extension/tests/extension/pluginIntegration/installed/statuses.test.ts b/packages/extension/tests/extension/pluginIntegration/installed/statuses.test.ts index 45374b188..7b1b37962 100644 --- a/packages/extension/tests/extension/pluginIntegration/installed/statuses.test.ts +++ b/packages/extension/tests/extension/pluginIntegration/installed/statuses.test.ts @@ -270,7 +270,8 @@ describe('extension/pluginIntegration/installedPluginStatuses', () => { ]), ); await internals._analysisMethods._analyzeAndSendData(); - expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); + expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCache).toHaveBeenCalled(); + expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCacheAsync).not.toHaveBeenCalled(); expect(mockState.databaseCache.saveWorkspaceAnalysisDatabaseCacheAsync).toHaveBeenCalled(); }, 15000); diff --git a/packages/extension/tests/extension/pluginIntegration/typescript.test.ts b/packages/extension/tests/extension/pluginIntegration/typescript.test.ts index 1092e079a..05f6a79b6 100644 --- a/packages/extension/tests/extension/pluginIntegration/typescript.test.ts +++ b/packages/extension/tests/extension/pluginIntegration/typescript.test.ts @@ -164,7 +164,8 @@ describe('extension/pluginIntegration/typescript', () => { ).map((pluginInfo) => pluginInfo.plugin.id); expect(pluginIds).toContain('codegraphy.typescript'); - expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); + expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCache).toHaveBeenCalled(); + expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCacheAsync).not.toHaveBeenCalled(); expect(mockState.databaseCache.clearWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); expect(mockState.databaseCache.saveWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); expect(mockState.databaseCache.saveWorkspaceAnalysisDatabaseCacheAsync).toHaveBeenCalled(); diff --git a/packages/extension/tests/extension/repoSettings/freshness/index.test.ts b/packages/extension/tests/extension/repoSettings/freshness/index.test.ts index 1948a069f..400cbd08a 100644 --- a/packages/extension/tests/extension/repoSettings/freshness/index.test.ts +++ b/packages/extension/tests/extension/repoSettings/freshness/index.test.ts @@ -1,3 +1,6 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { describe, expect, it } from 'vitest'; import { evaluateCodeGraphyIndexStatus } from '../../../../src/extension/repoSettings/freshness'; import type { ICodeGraphyRepoMeta } from '../../../../src/extension/repoSettings/meta'; @@ -59,6 +62,51 @@ describe('repoSettings/freshness/index', () => { }); }); + it('ignores generated pending changed files when evaluating index freshness', () => { + expect(evaluateCodeGraphyIndexStatus({ + meta: { + ...indexedMeta, + pendingChangedFiles: [ + '/workspace/packages/plugin-typescript/.turbo', + '/workspace/.worktrees/speed-up-codegraphy/packages/extension/src/extension.ts', + ], + }, + currentCommit: 'abc123', + pluginSignature: 'plugins', + settingsSignature: 'settings', + })).toMatchObject({ + freshness: 'fresh', + hasIndex: true, + staleReasons: [], + }); + }); + + it('ignores pending changed files already covered by index metadata', () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraphy-index-freshness-')); + const filePath = path.join(workspaceRoot, 'src/types.ts'); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, 'export type Example = string;\n'); + + try { + expect(evaluateCodeGraphyIndexStatus({ + meta: { + ...indexedMeta, + lastIndexedAt: new Date(Date.now() + 1_000).toISOString(), + pendingChangedFiles: [filePath], + }, + currentCommit: 'abc123', + pluginSignature: 'plugins', + settingsSignature: 'settings', + })).toMatchObject({ + freshness: 'fresh', + hasIndex: true, + staleReasons: [], + }); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); + it('describes plural pending changed files', () => { expect(evaluateCodeGraphyIndexStatus({ meta: { diff --git a/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.registersavehandler.test.ts b/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.registersavehandler.test.ts index 9abbc7d04..80eed56e2 100644 --- a/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.registersavehandler.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.registersavehandler.test.ts @@ -82,9 +82,9 @@ describe('registerSaveHandler', () => { const listener = mock.mock.calls[0]?.[0] as (doc: unknown) => void; listener({ uri: { fsPath: '/workspace/src/a.ts' } }); - vi.advanceTimersByTime(200); + vi.advanceTimersByTime(25); listener({ uri: { fsPath: '/workspace/src/b.ts' } }); - vi.advanceTimersByTime(500); + vi.advanceTimersByTime(50); expect(provider.refresh).toHaveBeenCalledOnce(); }); diff --git a/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.workspacerefreshcoalescing.test.ts b/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.workspacerefreshcoalescing.test.ts index a9b23544d..bf695cf3e 100644 --- a/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.workspacerefreshcoalescing.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.workspacerefreshcoalescing.test.ts @@ -61,7 +61,7 @@ describe('workspace refresh coalescing', () => { const saveListener = saveMock.mock.calls[0]?.[0] as (doc: unknown) => void; saveListener({ uri: { fsPath: '/workspace/src/app.ts' } }); - vi.advanceTimersByTime(250); + vi.advanceTimersByTime(25); createListener!({ fsPath: '/workspace/src/app.ts.tmp' }); vi.advanceTimersByTime(499); diff --git a/packages/extension/tests/extension/workspaceFiles/ignore.test.ts b/packages/extension/tests/extension/workspaceFiles/ignore.test.ts index 1bfbbdb18..25a4fd685 100644 --- a/packages/extension/tests/extension/workspaceFiles/ignore.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/ignore.test.ts @@ -31,6 +31,8 @@ describe('extension/workspaceFiles/ignore', () => { '/workspace/out/app.js', '/workspace/.git/config', '/workspace/.turbo/cache/abc-meta.json', + '/workspace/packages/plugin-typescript/.turbo', + '/workspace/.worktrees/speed-up-codegraphy/packages/extension/src/extension.ts', '/workspace/coverage/report.json', '/workspace/assets/app.min.js', '/workspace/assets/app.bundle.js', diff --git a/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts b/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts index dd7b3200e..06a92c2a2 100644 --- a/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts @@ -37,7 +37,10 @@ describe('workspaceFiles/refresh/operations', () => { provider as never, { uri: uri('/workspace/src/app.ts') } as vscode.TextDocument, ); - vi.advanceTimersByTime(500); + vi.advanceTimersByTime(31); + expect(provider.invalidateWorkspaceFiles).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); expect(provider.invalidateWorkspaceFiles).toHaveBeenCalledWith(['/workspace/src/app.ts']); expect(provider.emitEvent).toHaveBeenCalledWith('workspace:fileChanged', { @@ -55,7 +58,10 @@ describe('workspaceFiles/refresh/operations', () => { provider as never, { uri: uri('/workspace/.gitignore') } as vscode.TextDocument, ); - vi.advanceTimersByTime(500); + vi.advanceTimersByTime(31); + expect(provider.refreshGitignoreMetadata).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); expect(provider.refreshGitignoreMetadata).toHaveBeenCalledOnce(); expect(provider.refreshIndex).not.toHaveBeenCalled(); diff --git a/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts b/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts index 5f77f2dfe..1efbf48a2 100644 --- a/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts @@ -194,6 +194,46 @@ describe('workspaceFiles/refresh/watchers', () => { }); }); + it('suppresses file-system change duplicates after saved document refreshes', () => { + vi.useFakeTimers(); + const context = makeContext(); + const provider = makeProvider(); + const triggerSave = captureSaveListener(); + + registerSaveHandler(context as unknown as vscode.ExtensionContext, provider as never); + registerFileWatcher(context as unknown as vscode.ExtensionContext, provider as never); + triggerSave({ uri: uri('/workspace/src/app.ts') } as vscode.TextDocument); + vi.advanceTimersByTime(32); + watcherListeners.change?.(uri('/workspace/src/app.ts')); + vi.advanceTimersByTime(500); + + expect(provider.refresh).toHaveBeenCalledOnce(); + expect(provider.emitEvent).toHaveBeenCalledOnce(); + expect(provider.emitEvent).toHaveBeenCalledWith('workspace:fileChanged', { + filePath: '/workspace/src/app.ts', + }); + }); + + it('allows file-system changes after saved document suppression expires', () => { + vi.useFakeTimers(); + const context = makeContext(); + const provider = makeProvider(); + const triggerSave = captureSaveListener(); + + registerSaveHandler(context as unknown as vscode.ExtensionContext, provider as never); + registerFileWatcher(context as unknown as vscode.ExtensionContext, provider as never); + triggerSave({ uri: uri('/workspace/src/app.ts') } as vscode.TextDocument); + vi.advanceTimersByTime(1001); + watcherListeners.change?.(uri('/workspace/src/app.ts')); + vi.advanceTimersByTime(500); + + expect(provider.refresh).toHaveBeenCalledTimes(2); + expect(provider.emitEvent).toHaveBeenCalledTimes(2); + expect(provider.emitEvent).toHaveBeenLastCalledWith('workspace:fileChanged', { + filePath: '/workspace/src/app.ts', + }); + }); + it('wires file-system create and delete watchers to workspace events', () => { vi.useFakeTimers(); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); diff --git a/packages/extension/tests/shared/globMatch.test.ts b/packages/extension/tests/shared/globMatch.test.ts index 25dcdff20..1cdee2dff 100644 --- a/packages/extension/tests/shared/globMatch.test.ts +++ b/packages/extension/tests/shared/globMatch.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { globMatch, globToRegex } from '../../src/shared/globMatch'; +import { performance } from 'node:perf_hooks'; +import { + createCombinedGlobMatcher, + createGlobMatcher, + globMatch, + globToRegex, +} from '../../src/shared/globMatch'; describe('shared/globMatch', () => { it('matches basename patterns against nested paths', () => { @@ -24,4 +30,197 @@ describe('shared/globMatch', () => { expect(globMatch('src/types/apiXd.ts', '*.d.ts')).toBe(false); expect(globToRegex('*.d.ts')).toBeInstanceOf(RegExp); }); + + it('creates reusable matchers with the same glob semantics', () => { + const matcher = createGlobMatcher('src/**/*.ts'); + + expect(matcher('src/index.ts')).toBe(true); + expect(matcher('src/deep/index.ts')).toBe(true); + expect(matcher('docs/index.ts')).toBe(false); + }); + + it('keeps repeated simple single-glob checks cheap', () => { + const patterns = [ + '*.ts', + '*.tsx', + '*.json', + '*.md', + '*.gd', + '*.cs', + '*.sln', + '*.meta', + '*.yml', + '*.yaml', + '*.js', + '*.css', + '*.vue', + '*.svelte', + '*.go', + '*.rs', + '*.rb', + '*.py', + '*.java', + '*.php', + '*.lua', + '*.swift', + '*.dart', + '*.hpp', + '*.cpp', + '*.c', + '*.h', + ]; + const matchers = patterns.flatMap((pattern) => [ + createGlobMatcher(pattern), + createGlobMatcher(pattern), + createGlobMatcher(pattern), + createGlobMatcher(pattern), + ]); + const regexMatchers = patterns.flatMap((pattern) => { + const regex = globToRegex(pattern); + return [ + (filePath: string) => regex.test(filePath), + (filePath: string) => regex.test(filePath), + (filePath: string) => regex.test(filePath), + (filePath: string) => regex.test(filePath), + ]; + }); + const paths = Array.from({ length: 2_300 }, (_, index) => ( + `packages/package-${index % 100}/src/file-${index}.${index % 5 === 0 ? 'ts' : 'txt'}` + )); + + const countMatches = (nextMatchers: Array<(filePath: string) => boolean>) => { + let matchedCount = 0; + for (const filePath of paths) { + for (const matcher of nextMatchers) { + if (matcher(filePath)) { + matchedCount += 1; + } + } + } + return matchedCount; + }; + countMatches(matchers); + countMatches(regexMatchers); + + const startedAt = performance.now(); + const matchedCount = countMatches(matchers); + const elapsedMs = performance.now() - startedAt; + const regexStartedAt = performance.now(); + const regexMatchedCount = countMatches(regexMatchers); + const regexElapsedMs = performance.now() - regexStartedAt; + + expect(matchedCount).toBe(1_840); + expect(regexMatchedCount).toBe(matchedCount); + expect(elapsedMs).toBeLessThan(50); + expect(elapsedMs).toBeLessThan(regexElapsedMs * 0.75); + }); + + it('creates one matcher that preserves any-pattern glob semantics', () => { + const matcher = createCombinedGlobMatcher([ + '**/tests/**', + 'reports/**', + '*.d.ts', + ]); + + expect(matcher('packages/extension/tests/unit.test.ts')).toBe(true); + expect(matcher('reports/performance/latest.json')).toBe(true); + expect(matcher('src/types/api.d.ts')).toBe(true); + expect(matcher('src/index.ts')).toBe(false); + }); + + it('preserves direct-child path boundaries in combined matchers', () => { + const matcher = createCombinedGlobMatcher(['src/*']); + + expect(matcher('src/index.ts')).toBe(true); + expect(matcher('packages/extension/src/index.ts')).toBe(true); + expect(matcher('packages/extension/src/deep/index.ts')).toBe(false); + expect(matcher('packages/extension/xsrc/index.ts')).toBe(false); + }); + + it('falls back to glob regexes for complex combined patterns', () => { + const matcher = createCombinedGlobMatcher([ + '**/Assets/AddressableAssetsData/**/*.bin*', + '**/[Ll]ibrary/**', + ]); + + expect(matcher('project/Assets/AddressableAssetsData/android/catalog.bin.hash')).toBe(true); + expect(matcher('project/Assets/AddressableAssetsData/catalog.json')).toBe(false); + expect(matcher('project/Library/generated.asset')).toBe(false); + expect(matcher('project/[Ll]ibrary/generated.asset')).toBe(true); + }); + + it('creates an empty combined matcher that never matches', () => { + const matcher = createCombinedGlobMatcher([]); + + expect(matcher('src/index.ts')).toBe(false); + }); + + it('rejects nonmatching paths quickly with many plugin default filters', () => { + const matcher = createCombinedGlobMatcher([ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/out/**', + '**/.next/**', + '**/.nuxt/**', + '**/coverage/**', + '**/.turbo/**', + '**/.godot/**', + '**/.import/**', + '**/*.import', + '**/.mono/**', + '**/addons/**', + '**/*.uid', + '**/.svelte-kit/**', + '**/[Ll]ibrary/**', + '**/[Tt]emp/**', + '**/[Oo]bj/**', + '**/[Bb]uild/**', + '**/[Bb]uilds/**', + '**/[Ll]ogs/**', + '**/[Pp]roject[Ss]ettings/**', + '**/[Uu]ser[Ss]ettings/**', + '**/[Mm]emory[Cc]aptures/**', + '**/.vs/**', + '**/.gradle/**', + '**/.idea/**', + '**/Assets/Packages/**', + '**/Assets/Plugins/Editor/JetBrains/**', + '**/ExportedObj/**', + '**/.consulo/**', + '**/*.meta', + '**/*.csproj', + '**/*.unityproj', + '**/*.sln', + '**/*.slnx', + '**/*.suo', + '**/*.user', + '**/*.userprefs', + '**/*.pidb', + '**/*.booproj', + '**/*.tmp', + '**/*.pdb', + '**/*.mdb', + '**/*.pidb.meta', + '**/*.pdb.meta', + '**/*.mdb.meta', + '**/*.opendb', + '**/*.VC.db', + '**/sysinfo.txt', + '**/crashlytics-build.properties', + '**/Assets/AddressableAssetsData/**/*.bin*', + '**/Assets/StreamingAssets/aa.meta', + '**/Assets/StreamingAssets/aa/**', + ]); + const paths = Array.from({ length: 10_000 }, (_, index) => ( + `packages/package-${index % 100}/src/deep/file-${index}.ts` + )); + + const startedAt = performance.now(); + const matchedCount = paths.filter(matcher).length; + const elapsedMs = performance.now() - startedAt; + + expect(matchedCount).toBe(0); + expect(elapsedMs).toBeLessThan(120); + }); }); diff --git a/packages/extension/tests/shared/visibleGraph/derive.test.ts b/packages/extension/tests/shared/visibleGraph/derive.test.ts index e4d803124..dc35ee9af 100644 --- a/packages/extension/tests/shared/visibleGraph/derive.test.ts +++ b/packages/extension/tests/shared/visibleGraph/derive.test.ts @@ -64,6 +64,31 @@ describe('shared/visibleGraph/deriveVisibleGraph', () => { }); + it('filters edges by wildcard edge id patterns', () => { + const result = deriveVisibleGraph( + { + nodes: [ + node('src/app.ts'), + node('src/generated.ts'), + node('src/other.ts'), + ], + edges: [ + edge('src/app.ts', 'src/generated.ts', 'import'), + edge('src/other.ts', 'src/app.ts', 'reference'), + ], + }, + { + filter: { patterns: ['src/*->src/generated.ts#import'] }, + }, + ); + + expect(ids(result.graphData)).toEqual({ + nodes: ['src/app.ts', 'src/generated.ts', 'src/other.ts'], + edges: ['src/other.ts->src/app.ts#reference'], + }); + }); + + it('keeps enabled child symbol rows hidden when their parent rows are disabled', () => { const result = deriveVisibleGraph( diff --git a/packages/extension/tests/shared/visibleGraph/scope.test.ts b/packages/extension/tests/shared/visibleGraph/scope.test.ts index 2f15344b3..4eeb8344a 100644 --- a/packages/extension/tests/shared/visibleGraph/scope.test.ts +++ b/packages/extension/tests/shared/visibleGraph/scope.test.ts @@ -1,47 +1,10 @@ import { describe, expect, it } from 'vitest'; -import type { IGraphData, IGraphEdge, IGraphNode } from '../../../src/shared/graph/contracts'; import { applyGraphScope } from '../../../src/shared/visibleGraph/scope'; import { getDefinitionSymbolKinds, getScopedSymbolDefinitions, } from '../../../src/shared/visibleGraph/scope/definitions'; - -function node(id: string, nodeType = 'file'): IGraphNode { - return { - id, - label: id.split('/').pop() ?? id, - color: '#111111', - nodeType, - }; -} - -function symbolNode( - id: string, - symbol: NonNullable, - nodeType = 'symbol', -): IGraphNode { - return { - ...node(id, nodeType), - symbol, - }; -} - -function edge(from: string, to: string, kind: IGraphEdge['kind']): IGraphEdge { - return { - id: `${from}->${to}#${kind}`, - from, - to, - kind, - sources: [], - }; -} - -function ids(graphData: IGraphData): { nodes: string[]; edges: string[] } { - return { - nodes: graphData.nodes.map((item) => item.id), - edges: graphData.edges.map((item) => item.id), - }; -} +import { edge, ids, node, symbolNode } from './scope/fixture'; describe('shared/visibleGraph/scope', () => { it('filters disabled nodes, disabled edge kinds, and edges attached to hidden nodes', () => { @@ -195,6 +158,18 @@ describe('shared/visibleGraph/scope', () => { ]); }); + it('precompiles scoped symbol file path matchers', () => { + const scopedDefinitions = getScopedSymbolDefinitions({ + nodes: [ + { type: 'plugin:codegraphy.gdscript:symbol:godot-class-name', enabled: true }, + ], + edges: [], + }); + + expect(scopedDefinitions[0]?.symbolFilePathMatches?.('scripts/player.gd')).toBe(true); + expect(scopedDefinitions[0]?.symbolFilePathMatches?.('scripts/player.ts')).toBe(false); + }); + it('keeps symbol nodes that are disconnected after edge scope is applied', () => { const result = applyGraphScope( { @@ -362,105 +337,6 @@ describe('shared/visibleGraph/scope', () => { }); }); - it('keeps Unity file to GameObject containment when Component symbols are visible', () => { - const result = applyGraphScope( - { - nodes: [ - node('Assets/Prefabs/Enemy1.prefab'), - symbolNode('Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', { - id: 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', - name: 'Enemy1', - kind: 'game-object', - filePath: 'Assets/Prefabs/Enemy1.prefab', - pluginKind: 'game-object', - source: 'codegraphy.unity', - language: 'unity', - }), - symbolNode('Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', { - id: 'Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', - name: 'EnemyMovement', - kind: 'component', - filePath: 'Assets/Prefabs/Enemy1.prefab', - pluginKind: 'component', - source: 'codegraphy.unity', - language: 'unity', - }), - ], - edges: [ - edge('Assets/Prefabs/Enemy1.prefab', 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', 'contains'), - edge('Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', 'Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', 'contains'), - ], - }, - { - nodes: [ - { type: 'file', enabled: true }, - { type: 'symbol', enabled: true }, - { type: 'plugin:codegraphy.unity:symbol', enabled: true }, - { type: 'plugin:codegraphy.unity:symbol:game-object', enabled: true }, - { type: 'plugin:codegraphy.unity:symbol:component', enabled: true }, - ], - edges: [{ type: 'contains', enabled: true }], - }, - ); - - expect(ids(result)).toEqual({ - nodes: [ - 'Assets/Prefabs/Enemy1.prefab', - 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', - 'Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', - ], - edges: [ - 'Assets/Prefabs/Enemy1.prefab->Assets/Prefabs/Enemy1.prefab#Enemy1:game-object#contains', - 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object->Assets/Prefabs/Enemy1.prefab#EnemyMovement:component#contains', - ], - }); - }); - - it('projects hidden symbol endpoints back to visible containing files', () => { - const result = applyGraphScope( - { - nodes: [ - node('scripts/spawning/enemy_spawner.gd'), - node('resources/enemy_spawn_config.tres'), - symbolNode('resources/enemy_spawn_config.tres#EnemySpawnConfig:resource', { - id: 'resources/enemy_spawn_config.tres#EnemySpawnConfig:resource', - name: 'EnemySpawnConfig', - kind: 'resource', - filePath: 'resources/enemy_spawn_config.tres', - pluginKind: 'resource', - source: 'codegraphy.gdscript', - }), - ], - edges: [{ - id: 'scripts/spawning/enemy_spawner.gd->resources/enemy_spawn_config.tres#EnemySpawnConfig:resource#load:static', - from: 'scripts/spawning/enemy_spawner.gd', - to: 'resources/enemy_spawn_config.tres#EnemySpawnConfig:resource', - kind: 'load', - sources: [], - }], - }, - { - nodes: [ - { type: 'file', enabled: true }, - { type: 'symbol', enabled: false }, - { type: 'plugin:codegraphy.gdscript:symbol:resource', enabled: false }, - ], - edges: [ - { type: 'load', enabled: true }, - ], - }, - ); - - expect(ids(result)).toEqual({ - nodes: [ - 'scripts/spawning/enemy_spawner.gd', - 'resources/enemy_spawn_config.tres', - ], - edges: [ - 'scripts/spawning/enemy_spawner.gd->resources/enemy_spawn_config.tres#load:static', - ], - }); - }); it('keeps file-level type imports when imported type symbols are visible', () => { const result = applyGraphScope( @@ -549,251 +425,38 @@ describe('shared/visibleGraph/scope', () => { }); }); - it('uses the most specific plugin symbol rule before a general symbol kind rule', () => { + it('keeps file-level self edges while dropping symbol edges projected onto the same file', () => { const result = applyGraphScope( { nodes: [ - node('src/user.ts'), - symbolNode('src/user.ts#User:class', { - id: 'src/user.ts#User:class', - name: 'User', - kind: 'class', - filePath: 'src/user.ts', - }), - symbolNode('scripts/player.gd#Player:class', { - id: 'scripts/player.gd#Player:class', - name: 'Player', + node('src/runner.dart'), + symbolNode('src/runner.dart#Runner:class', { + id: 'src/runner.dart#Runner:class', + name: 'Runner', kind: 'class', - filePath: 'scripts/player.gd', - pluginKind: 'godot-class-name', - source: 'codegraphy.gdscript', - language: 'gdscript', + filePath: 'src/runner.dart', }), - ], - edges: [ - edge('src/user.ts', 'src/user.ts#User:class', 'contains'), - edge('src/user.ts', 'scripts/player.gd#Player:class', 'reference'), - ], - }, - { - nodes: [ - { type: 'file', enabled: true }, - { type: 'symbol', enabled: true }, - { type: 'symbol:class', enabled: true }, - { type: 'plugin:codegraphy.gdscript:symbol:godot-class-name', enabled: false }, - ], - edges: [ - { type: 'contains', enabled: true }, - { type: 'reference', enabled: true }, - ], - }, - ); - - expect(ids(result)).toEqual({ - nodes: ['src/user.ts', 'src/user.ts#User:class'], - edges: ['src/user.ts->src/user.ts#User:class#contains'], - }); - }); - - it('matches Godot scene-node and exported-property scoped rows', () => { - const result = applyGraphScope( - { - nodes: [ - node('scenes/player.tscn'), - node('scripts/player.gd'), - symbolNode('scenes/player.tscn#HealthComponent:scene-node', { - id: 'scenes/player.tscn#HealthComponent:scene-node', - name: 'HealthComponent', - kind: 'scene-node', - filePath: 'scenes/player.tscn', - pluginKind: 'scene-node', - source: 'codegraphy.gdscript', - language: 'godot-resource', - }), - symbolNode('scripts/player.gd#projectile_scene:variable', { - id: 'scripts/player.gd#projectile_scene:variable', - name: 'projectile_scene', - kind: 'variable', - filePath: 'scripts/player.gd', - pluginKind: 'exported-property', - source: 'codegraphy.gdscript', - language: 'gdscript', - }, 'variable'), - symbolNode('scripts/player.gd#_can_fire:variable', { - id: 'scripts/player.gd#_can_fire:variable', - name: '_can_fire', - kind: 'variable', - filePath: 'scripts/player.gd', - source: 'codegraphy.gdscript', - language: 'gdscript', - }, 'variable'), - ], - edges: [ - edge('scenes/player.tscn', 'scenes/player.tscn#HealthComponent:scene-node', 'contains'), - edge('scripts/player.gd', 'scripts/player.gd#projectile_scene:variable', 'contains'), - edge('scripts/player.gd', 'scripts/player.gd#_can_fire:variable', 'contains'), - ], - }, - { - nodes: [ - { type: 'file', enabled: true }, - { type: 'symbol', enabled: true }, - { type: 'variable', enabled: true }, - { type: 'plugin:codegraphy.gdscript:symbol:scene-node', enabled: true }, - { type: 'plugin:codegraphy.gdscript:symbol:exported-property', enabled: true }, - ], - edges: [ - { type: 'contains', enabled: true }, - ], - }, - ); - - expect(ids(result)).toEqual({ - nodes: [ - 'scenes/player.tscn', - 'scripts/player.gd', - 'scenes/player.tscn#HealthComponent:scene-node', - 'scripts/player.gd#projectile_scene:variable', - ], - edges: [ - 'scenes/player.tscn->scenes/player.tscn#HealthComponent:scene-node#contains', - 'scripts/player.gd->scripts/player.gd#projectile_scene:variable#contains', - ], - }); - }); - - it('requires every plugin-specific symbol field to match', () => { - const matching = symbolNode('scripts/player.gd#Player:class', { - id: 'scripts/player.gd#Player:class', - name: 'Player', - kind: 'class', - filePath: 'scripts/player.gd', - pluginKind: 'godot-class-name', - source: 'codegraphy.gdscript', - language: 'gdscript', - }); - const wrongPluginKind = symbolNode('scripts/enemy.gd#Enemy:class', { - id: 'scripts/enemy.gd#Enemy:class', - name: 'Enemy', - kind: 'class', - filePath: 'scripts/enemy.gd', - pluginKind: 'ordinary-class', - source: 'codegraphy.gdscript', - language: 'gdscript', - }); - const wrongSource = symbolNode('scripts/npc.gd#Npc:class', { - id: 'scripts/npc.gd#Npc:class', - name: 'Npc', - kind: 'class', - filePath: 'scripts/npc.gd', - pluginKind: 'godot-class-name', - source: 'other-plugin', - language: 'gdscript', - }); - const wrongLanguage = symbolNode('scripts/item.gd#Item:class', { - id: 'scripts/item.gd#Item:class', - name: 'Item', - kind: 'class', - filePath: 'scripts/item.gd', - pluginKind: 'godot-class-name', - source: 'codegraphy.gdscript', - language: 'typescript', - }); - const wrongFilePath = symbolNode('src/player.ts#Player:class', { - id: 'src/player.ts#Player:class', - name: 'Player', - kind: 'class', - filePath: 'src/player.ts', - pluginKind: 'godot-class-name', - source: 'codegraphy.gdscript', - language: 'gdscript', - }); - - const result = applyGraphScope( - { - nodes: [ - matching, - wrongPluginKind, - wrongSource, - wrongLanguage, - wrongFilePath, - ], - edges: [], - }, - { - nodes: [ - { type: 'symbol', enabled: true }, - { type: 'symbol:class', enabled: false }, - { type: 'plugin:codegraphy.gdscript:symbol:godot-class-name', enabled: true }, - ], - edges: [], - }, - ); - - expect(ids(result)).toEqual({ - nodes: ['scripts/player.gd#Player:class'], - edges: [], - }); - }); - - - it('filters Unity component symbols through plugin scope rows', () => { - const result = applyGraphScope( - { - nodes: [ - node('Assets/Scenes/SampleScene.unity'), - symbolNode('Assets/Scenes/SampleScene.unity#unity:game-object:1000', { - id: 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', - name: 'Player', - kind: 'game-object', - filePath: 'Assets/Scenes/SampleScene.unity', - pluginKind: 'game-object', - source: 'codegraphy.unity', - language: 'unity', - }), - symbolNode('Assets/Scenes/SampleScene.unity#unity:component:1001', { - id: 'Assets/Scenes/SampleScene.unity#unity:component:1001', - name: 'Transform', - kind: 'component', - filePath: 'Assets/Scenes/SampleScene.unity', - pluginKind: 'component', - source: 'codegraphy.unity', - language: 'unity', + symbolNode('src/runner.dart#run:method', { + id: 'src/runner.dart#run:method', + name: 'run', + kind: 'method', + filePath: 'src/runner.dart', }), ], edges: [ - edge( - 'Assets/Scenes/SampleScene.unity', - 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', - 'contains', - ), - edge( - 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', - 'Assets/Scenes/SampleScene.unity#unity:component:1001', - 'contains', - ), + edge('src/runner.dart', 'src/runner.dart', 'call'), + edge('src/runner.dart#run:method', 'src/runner.dart#Runner:class', 'call'), ], }, { - nodes: [ - { type: 'file', enabled: true }, - { type: 'symbol', enabled: true }, - { type: 'plugin:codegraphy.unity:symbol', enabled: true }, - { type: 'plugin:codegraphy.unity:symbol:game-object', enabled: true }, - { type: 'plugin:codegraphy.unity:symbol:component', enabled: false }, - ], - edges: [{ type: 'contains', enabled: true }], + nodes: [{ type: 'file', enabled: true }], + edges: [{ type: 'call', enabled: true }], }, ); expect(ids(result)).toEqual({ - nodes: [ - 'Assets/Scenes/SampleScene.unity', - 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', - ], - edges: [ - 'Assets/Scenes/SampleScene.unity->Assets/Scenes/SampleScene.unity#unity:game-object:1000#contains', - ], + nodes: ['src/runner.dart'], + edges: ['src/runner.dart->src/runner.dart#call'], }); }); diff --git a/packages/extension/tests/shared/visibleGraph/scope/edgeEndpointProjection.test.ts b/packages/extension/tests/shared/visibleGraph/scope/edgeEndpointProjection.test.ts new file mode 100644 index 000000000..707d8cb96 --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/edgeEndpointProjection.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { getVisibleEdgeEndpoint } from '../../../../src/shared/visibleGraph/scope/edgeEndpointProjection'; +import { node, symbolNode } from './fixture'; + +describe('shared/visibleGraph/scope/edgeEndpointProjection', () => { + it('returns visible node ids without projection', () => { + expect(getVisibleEdgeEndpoint( + 'src/app.ts', + new Map([ + ['src/app.ts', node('src/app.ts')], + ]), + new Set(['src/app.ts']), + )).toBe('src/app.ts'); + }); + + it('projects hidden symbol ids to their visible containing file', () => { + expect(getVisibleEdgeEndpoint( + 'src/app.ts#main:function', + new Map([ + ['src/app.ts', node('src/app.ts')], + ['src/app.ts#main:function', symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + })], + ]), + new Set(['src/app.ts']), + )).toBe('src/app.ts'); + }); + + it('does not project known symbols when their containing file is hidden', () => { + expect(getVisibleEdgeEndpoint( + 'src/app.ts#main:function', + new Map([ + ['src/app.ts', node('src/app.ts')], + ['src/app.ts#main:function', symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + })], + ]), + new Set(['src/other.ts']), + )).toBeUndefined(); + }); +}); diff --git a/packages/extension/tests/shared/visibleGraph/scope/edgePreference.test.ts b/packages/extension/tests/shared/visibleGraph/scope/edgePreference.test.ts new file mode 100644 index 000000000..329b5228b --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/edgePreference.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + getEdgeContainingFileKey, + getEndpointPreference, + rememberBestEndpointPreference, +} from '../../../../src/shared/visibleGraph/scope/edgePreference'; +import { edge, node, symbolNode } from './fixture'; + +describe('shared/visibleGraph/scope/edgePreference', () => { + it('builds grouping keys from containing files for symbol endpoints', () => { + expect(getEdgeContainingFileKey( + edge('src/app.ts#main:function', 'src/logger.ts#write:function', 'call'), + new Map([ + ['src/app.ts#main:function', symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + })], + ['src/logger.ts#write:function', symbolNode('src/logger.ts#write:function', { + id: 'src/logger.ts#write:function', + name: 'write', + kind: 'function', + filePath: 'src/logger.ts', + })], + ]), + )).toBe('call\0src/app.ts\0src/logger.ts'); + }); + + it('falls back to raw endpoint ids when nodes are missing from the lookup', () => { + expect(getEdgeContainingFileKey( + edge('src/missing-from.ts#main:function', 'src/missing-to.ts#write:function', 'call'), + new Map(), + )).toBe('call\0src/missing-from.ts#main:function\0src/missing-to.ts#write:function'); + }); + + it('prefers edges with more symbol endpoints except type imports', () => { + const nodeById = new Map([ + ['src/app.ts', node('src/app.ts')], + ['src/logger.ts#write:function', symbolNode('src/logger.ts#write:function', { + id: 'src/logger.ts#write:function', + name: 'write', + kind: 'function', + filePath: 'src/logger.ts', + })], + ]); + + expect(getEndpointPreference(edge('src/app.ts', 'src/logger.ts#write:function', 'call'), nodeById)).toBe(1); + expect(getEndpointPreference(edge('src/app.ts', 'src/logger.ts#write:function', 'type-import'), nodeById)).toBe(-1); + }); + + it('treats missing endpoint nodes as file-level preference', () => { + expect(getEndpointPreference( + edge('src/missing-from.ts#main:function', 'src/missing-to.ts#write:function', 'call'), + new Map(), + )).toBe(0); + }); + + it('remembers the highest endpoint preference for each grouping key', () => { + const preferences = new Map(); + + rememberBestEndpointPreference(preferences, 'call\0src/app.ts\0src/logger.ts', 1); + rememberBestEndpointPreference(preferences, 'call\0src/app.ts\0src/logger.ts', 0); + rememberBestEndpointPreference(preferences, 'call\0src/app.ts\0src/logger.ts', 2); + + expect(preferences.get('call\0src/app.ts\0src/logger.ts')).toBe(2); + }); +}); diff --git a/packages/extension/tests/shared/visibleGraph/scope/edgeProjection.test.ts b/packages/extension/tests/shared/visibleGraph/scope/edgeProjection.test.ts new file mode 100644 index 000000000..f6249aabc --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/edgeProjection.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphEdge } from '../../../../src/shared/graph/contracts'; +import { projectEdgesToVisibleNodes } from '../../../../src/shared/visibleGraph/scope/edgeProjection'; +import { edge, ids, node, symbolNode } from './fixture'; + +describe('shared/visibleGraph/scope/edgeProjection', () => { + it('drops edges when either endpoint cannot be projected to a visible node', () => { + const result = projectEdgesToVisibleNodes( + [ + edge('src/app.ts', 'src/missing.ts#missing:function', 'call'), + ], + [ + node('src/app.ts'), + ], + [ + node('src/app.ts'), + ], + ); + + expect(ids({ nodes: [], edges: result })).toEqual({ + nodes: [], + edges: [], + }); + }); + + it('keeps already visible edges unchanged even when they are file-level self edges', () => { + const visibleSelfEdge: IGraphEdge = { + ...edge('src/runner.dart', 'src/runner.dart', 'call'), + id: 'stable-self-call-id', + }; + + const result = projectEdgesToVisibleNodes( + [ + visibleSelfEdge, + ], + [ + node('src/runner.dart'), + ], + [ + node('src/runner.dart'), + ], + ); + + expect(result).toEqual([visibleSelfEdge]); + }); + + it('projects hidden symbol endpoints to their visible containing files', () => { + const result = projectEdgesToVisibleNodes( + [ + edge('src/app.ts#main:function', 'src/logger.ts', 'call'), + ], + [ + node('src/app.ts'), + node('src/logger.ts'), + symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + }), + ], + [ + node('src/app.ts'), + node('src/logger.ts'), + ], + ); + + expect(ids({ nodes: [], edges: result })).toEqual({ + nodes: [], + edges: ['src/app.ts->src/logger.ts#call'], + }); + }); + + it('drops hidden symbol edges projected onto the same visible file', () => { + const result = projectEdgesToVisibleNodes( + [ + edge('src/runner.dart#run:method', 'src/runner.dart#Runner:class', 'call'), + ], + [ + node('src/runner.dart'), + symbolNode('src/runner.dart#run:method', { + id: 'src/runner.dart#run:method', + name: 'run', + kind: 'method', + filePath: 'src/runner.dart', + }), + symbolNode('src/runner.dart#Runner:class', { + id: 'src/runner.dart#Runner:class', + name: 'Runner', + kind: 'class', + filePath: 'src/runner.dart', + }), + ], + [ + node('src/runner.dart'), + ], + ); + + expect(ids({ nodes: [], edges: result })).toEqual({ + nodes: [], + edges: [], + }); + }); + + it('uses the edge kind as the projected id suffix when the source id has no suffix marker', () => { + const result = projectEdgesToVisibleNodes( + [ + { + ...edge('src/app.ts#main:function', 'src/logger.ts', 'call'), + id: 'stable-call-id', + }, + ], + [ + node('src/app.ts'), + node('src/logger.ts'), + symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + }), + ], + [ + node('src/app.ts'), + node('src/logger.ts'), + ], + ); + + expect(ids({ nodes: [], edges: result })).toEqual({ + nodes: [], + edges: ['src/app.ts->src/logger.ts#call'], + }); + }); + + it('keeps source id suffixes that start at the first character', () => { + const result = projectEdgesToVisibleNodes( + [ + { + ...edge('src/app.ts#main:function', 'src/logger.ts', 'call'), + id: '#legacy-call', + }, + ], + [ + node('src/app.ts'), + node('src/logger.ts'), + symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + }), + ], + [ + node('src/app.ts'), + node('src/logger.ts'), + ], + ); + + expect(ids({ nodes: [], edges: result })).toEqual({ + nodes: [], + edges: ['src/app.ts->src/logger.ts#legacy-call'], + }); + }); +}); diff --git a/packages/extension/tests/shared/visibleGraph/scope/edgeSelection.test.ts b/packages/extension/tests/shared/visibleGraph/scope/edgeSelection.test.ts new file mode 100644 index 000000000..cc47c3ba4 --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/edgeSelection.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { keepMostSpecificUniqueEdges } from '../../../../src/shared/visibleGraph/scope/edgeSelection'; +import { edge, ids, node } from './fixture'; + +describe('shared/visibleGraph/scope/edgeSelection', () => { + it('returns an empty edge list when there are no candidates', () => { + expect(keepMostSpecificUniqueEdges([], [])).toEqual([]); + }); + + it('keeps contains edges without endpoint preference filtering', () => { + const result = keepMostSpecificUniqueEdges( + [ + node('src/app.ts'), + node('src/app.ts#main:function', 'symbol'), + ], + [ + edge('src/app.ts', 'src/app.ts#main:function', 'contains'), + ], + ); + + expect(ids({ nodes: [], edges: result })).toEqual({ + nodes: [], + edges: ['src/app.ts->src/app.ts#main:function#contains'], + }); + }); +}); diff --git a/packages/extension/tests/shared/visibleGraph/scope/fixture.ts b/packages/extension/tests/shared/visibleGraph/scope/fixture.ts new file mode 100644 index 000000000..8317474da --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/fixture.ts @@ -0,0 +1,38 @@ +import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../src/shared/graph/contracts'; + +export function node(id: string, nodeType = 'file'): IGraphNode { + return { + id, + label: id.split('/').pop() ?? id, + color: '#111111', + nodeType, + }; +} + +export function symbolNode( + id: string, + symbol: NonNullable, + nodeType = 'symbol', +): IGraphNode { + return { + ...node(id, nodeType), + symbol, + }; +} + +export function edge(from: string, to: string, kind: IGraphEdge['kind']): IGraphEdge { + return { + id: `${from}->${to}#${kind}`, + from, + to, + kind, + sources: [], + }; +} + +export function ids(graphData: IGraphData): { nodes: string[]; edges: string[] } { + return { + nodes: graphData.nodes.map((item) => item.id), + edges: graphData.edges.map((item) => item.id), + }; +} diff --git a/packages/extension/tests/shared/visibleGraph/scope/nodes.test.ts b/packages/extension/tests/shared/visibleGraph/scope/nodes.test.ts new file mode 100644 index 000000000..76f80c184 --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/nodes.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphNodeTypeDefinition } from '../../../../src/shared/graphControls/contracts'; +import type { ScopedSymbolDefinition } from '../../../../src/shared/visibleGraph/scope/definitions'; +import { nodeMatchesScope } from '../../../../src/shared/visibleGraph/scope/nodes'; +import { node, symbolNode } from './fixture'; + +describe('shared/visibleGraph/scope/nodes', () => { + it('hides core child node types when their direct parent is disabled', () => { + expect(nodeMatchesScope( + node('src/app.ts#main:function', 'symbol:function'), + new Set(['symbol']), + [], + )).toBe(false); + }); + + it('hides core child node types when a grandparent is disabled', () => { + expect(nodeMatchesScope( + node('src/app.ts#count:variable', 'variable:plain'), + new Set(['symbol']), + [], + )).toBe(false); + }); + + it('does not throw for unknown node types without a registered parent', () => { + expect(nodeMatchesScope( + node('src/app.ts#custom', 'plugin:custom-node'), + new Set(), + [], + )).toBe(true); + }); + + it('hides scoped symbols when their scoped parent row is disabled', () => { + expect(nodeMatchesScope( + symbolNode('src/app.ts#counter:local', { + id: 'src/app.ts#counter:local', + name: 'counter', + kind: 'local', + filePath: 'src/app.ts', + }, 'file'), + new Set(['variable']), + [ + createScopedDefinition({ + id: 'symbol:local', + parentId: 'variable', + matchSymbolKinds: ['local'], + enabled: true, + }), + ], + )).toBe(false); + }); + + it('does not throw for scoped symbol parents outside the core hierarchy', () => { + expect(nodeMatchesScope( + symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + }, 'file'), + new Set(), + [ + createScopedDefinition({ + id: 'plugin:custom:function', + parentId: 'plugin:custom-parent', + matchSymbolKinds: ['function'], + enabled: true, + }), + ], + )).toBe(true); + }); +}); + +function createScopedDefinition(input: { + id: string; + parentId: string; + matchSymbolKinds: string[]; + enabled: boolean; +}): ScopedSymbolDefinition { + return { + definition: createNodeTypeDefinition(input), + enabled: input.enabled, + specificity: 1, + }; +} + +function createNodeTypeDefinition(input: { + id: string; + parentId: string; + matchSymbolKinds: string[]; +}): IGraphNodeTypeDefinition { + return { + id: input.id, + label: input.id, + defaultColor: '#111111', + defaultVisible: false, + parentId: input.parentId, + matchSymbolKinds: input.matchSymbolKinds, + }; +} diff --git a/packages/extension/tests/shared/visibleGraph/scope/pluginSymbols.test.ts b/packages/extension/tests/shared/visibleGraph/scope/pluginSymbols.test.ts new file mode 100644 index 000000000..518bbdb1c --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/pluginSymbols.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it } from 'vitest'; +import { applyGraphScope } from '../../../../src/shared/visibleGraph/scope'; +import { edge, ids, node, symbolNode } from './fixture'; + +describe('shared/visibleGraph/scope plugin symbols', () => { +it('keeps Unity file to GameObject containment when Component symbols are visible', () => { + const result = applyGraphScope( + { + nodes: [ + node('Assets/Prefabs/Enemy1.prefab'), + symbolNode('Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', { + id: 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', + name: 'Enemy1', + kind: 'game-object', + filePath: 'Assets/Prefabs/Enemy1.prefab', + pluginKind: 'game-object', + source: 'codegraphy.unity', + language: 'unity', + }), + symbolNode('Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', { + id: 'Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', + name: 'EnemyMovement', + kind: 'component', + filePath: 'Assets/Prefabs/Enemy1.prefab', + pluginKind: 'component', + source: 'codegraphy.unity', + language: 'unity', + }), + ], + edges: [ + edge('Assets/Prefabs/Enemy1.prefab', 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', 'contains'), + edge('Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', 'Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', 'contains'), + ], + }, + { + nodes: [ + { type: 'file', enabled: true }, + { type: 'symbol', enabled: true }, + { type: 'plugin:codegraphy.unity:symbol', enabled: true }, + { type: 'plugin:codegraphy.unity:symbol:game-object', enabled: true }, + { type: 'plugin:codegraphy.unity:symbol:component', enabled: true }, + ], + edges: [{ type: 'contains', enabled: true }], + }, + ); + + expect(ids(result)).toEqual({ + nodes: [ + 'Assets/Prefabs/Enemy1.prefab', + 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', + 'Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', + ], + edges: [ + 'Assets/Prefabs/Enemy1.prefab->Assets/Prefabs/Enemy1.prefab#Enemy1:game-object#contains', + 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object->Assets/Prefabs/Enemy1.prefab#EnemyMovement:component#contains', + ], + }); + }); + + it('projects hidden symbol endpoints back to visible containing files', () => { + const result = applyGraphScope( + { + nodes: [ + node('scripts/spawning/enemy_spawner.gd'), + node('resources/enemy_spawn_config.tres'), + symbolNode('resources/enemy_spawn_config.tres#EnemySpawnConfig:resource', { + id: 'resources/enemy_spawn_config.tres#EnemySpawnConfig:resource', + name: 'EnemySpawnConfig', + kind: 'resource', + filePath: 'resources/enemy_spawn_config.tres', + pluginKind: 'resource', + source: 'codegraphy.gdscript', + }), + ], + edges: [{ + id: 'scripts/spawning/enemy_spawner.gd->resources/enemy_spawn_config.tres#EnemySpawnConfig:resource#load:static', + from: 'scripts/spawning/enemy_spawner.gd', + to: 'resources/enemy_spawn_config.tres#EnemySpawnConfig:resource', + kind: 'load', + sources: [], + }], + }, + { + nodes: [ + { type: 'file', enabled: true }, + { type: 'symbol', enabled: false }, + { type: 'plugin:codegraphy.gdscript:symbol:resource', enabled: false }, + ], + edges: [ + { type: 'load', enabled: true }, + ], + }, + ); + + expect(ids(result)).toEqual({ + nodes: [ + 'scripts/spawning/enemy_spawner.gd', + 'resources/enemy_spawn_config.tres', + ], + edges: [ + 'scripts/spawning/enemy_spawner.gd->resources/enemy_spawn_config.tres#load:static', + ], + }); + }); + it('uses the most specific plugin symbol rule before a general symbol kind rule', () => { + const result = applyGraphScope( + { + nodes: [ + node('src/user.ts'), + symbolNode('src/user.ts#User:class', { + id: 'src/user.ts#User:class', + name: 'User', + kind: 'class', + filePath: 'src/user.ts', + }), + symbolNode('scripts/player.gd#Player:class', { + id: 'scripts/player.gd#Player:class', + name: 'Player', + kind: 'class', + filePath: 'scripts/player.gd', + pluginKind: 'godot-class-name', + source: 'codegraphy.gdscript', + language: 'gdscript', + }), + ], + edges: [ + edge('src/user.ts', 'src/user.ts#User:class', 'contains'), + edge('src/user.ts', 'scripts/player.gd#Player:class', 'reference'), + ], + }, + { + nodes: [ + { type: 'file', enabled: true }, + { type: 'symbol', enabled: true }, + { type: 'symbol:class', enabled: true }, + { type: 'plugin:codegraphy.gdscript:symbol:godot-class-name', enabled: false }, + ], + edges: [ + { type: 'contains', enabled: true }, + { type: 'reference', enabled: true }, + ], + }, + ); + + expect(ids(result)).toEqual({ + nodes: ['src/user.ts', 'src/user.ts#User:class'], + edges: ['src/user.ts->src/user.ts#User:class#contains'], + }); + }); + + it('matches Godot scene-node and exported-property scoped rows', () => { + const result = applyGraphScope( + { + nodes: [ + node('scenes/player.tscn'), + node('scripts/player.gd'), + symbolNode('scenes/player.tscn#HealthComponent:scene-node', { + id: 'scenes/player.tscn#HealthComponent:scene-node', + name: 'HealthComponent', + kind: 'scene-node', + filePath: 'scenes/player.tscn', + pluginKind: 'scene-node', + source: 'codegraphy.gdscript', + language: 'godot-resource', + }), + symbolNode('scripts/player.gd#projectile_scene:variable', { + id: 'scripts/player.gd#projectile_scene:variable', + name: 'projectile_scene', + kind: 'variable', + filePath: 'scripts/player.gd', + pluginKind: 'exported-property', + source: 'codegraphy.gdscript', + language: 'gdscript', + }, 'variable'), + symbolNode('scripts/player.gd#_can_fire:variable', { + id: 'scripts/player.gd#_can_fire:variable', + name: '_can_fire', + kind: 'variable', + filePath: 'scripts/player.gd', + source: 'codegraphy.gdscript', + language: 'gdscript', + }, 'variable'), + ], + edges: [ + edge('scenes/player.tscn', 'scenes/player.tscn#HealthComponent:scene-node', 'contains'), + edge('scripts/player.gd', 'scripts/player.gd#projectile_scene:variable', 'contains'), + edge('scripts/player.gd', 'scripts/player.gd#_can_fire:variable', 'contains'), + ], + }, + { + nodes: [ + { type: 'file', enabled: true }, + { type: 'symbol', enabled: true }, + { type: 'variable', enabled: true }, + { type: 'plugin:codegraphy.gdscript:symbol:scene-node', enabled: true }, + { type: 'plugin:codegraphy.gdscript:symbol:exported-property', enabled: true }, + ], + edges: [ + { type: 'contains', enabled: true }, + ], + }, + ); + + expect(ids(result)).toEqual({ + nodes: [ + 'scenes/player.tscn', + 'scripts/player.gd', + 'scenes/player.tscn#HealthComponent:scene-node', + 'scripts/player.gd#projectile_scene:variable', + ], + edges: [ + 'scenes/player.tscn->scenes/player.tscn#HealthComponent:scene-node#contains', + 'scripts/player.gd->scripts/player.gd#projectile_scene:variable#contains', + ], + }); + }); + + it('requires every plugin-specific symbol field to match', () => { + const matching = symbolNode('scripts/player.gd#Player:class', { + id: 'scripts/player.gd#Player:class', + name: 'Player', + kind: 'class', + filePath: 'scripts/player.gd', + pluginKind: 'godot-class-name', + source: 'codegraphy.gdscript', + language: 'gdscript', + }); + const wrongPluginKind = symbolNode('scripts/enemy.gd#Enemy:class', { + id: 'scripts/enemy.gd#Enemy:class', + name: 'Enemy', + kind: 'class', + filePath: 'scripts/enemy.gd', + pluginKind: 'ordinary-class', + source: 'codegraphy.gdscript', + language: 'gdscript', + }); + const wrongSource = symbolNode('scripts/npc.gd#Npc:class', { + id: 'scripts/npc.gd#Npc:class', + name: 'Npc', + kind: 'class', + filePath: 'scripts/npc.gd', + pluginKind: 'godot-class-name', + source: 'other-plugin', + language: 'gdscript', + }); + const wrongLanguage = symbolNode('scripts/item.gd#Item:class', { + id: 'scripts/item.gd#Item:class', + name: 'Item', + kind: 'class', + filePath: 'scripts/item.gd', + pluginKind: 'godot-class-name', + source: 'codegraphy.gdscript', + language: 'typescript', + }); + const wrongFilePath = symbolNode('src/player.ts#Player:class', { + id: 'src/player.ts#Player:class', + name: 'Player', + kind: 'class', + filePath: 'src/player.ts', + pluginKind: 'godot-class-name', + source: 'codegraphy.gdscript', + language: 'gdscript', + }); + + const result = applyGraphScope( + { + nodes: [ + matching, + wrongPluginKind, + wrongSource, + wrongLanguage, + wrongFilePath, + ], + edges: [], + }, + { + nodes: [ + { type: 'symbol', enabled: true }, + { type: 'symbol:class', enabled: false }, + { type: 'plugin:codegraphy.gdscript:symbol:godot-class-name', enabled: true }, + ], + edges: [], + }, + ); + + expect(ids(result)).toEqual({ + nodes: ['scripts/player.gd#Player:class'], + edges: [], + }); + }); + + + it('filters Unity component symbols through plugin scope rows', () => { + const result = applyGraphScope( + { + nodes: [ + node('Assets/Scenes/SampleScene.unity'), + symbolNode('Assets/Scenes/SampleScene.unity#unity:game-object:1000', { + id: 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', + name: 'Player', + kind: 'game-object', + filePath: 'Assets/Scenes/SampleScene.unity', + pluginKind: 'game-object', + source: 'codegraphy.unity', + language: 'unity', + }), + symbolNode('Assets/Scenes/SampleScene.unity#unity:component:1001', { + id: 'Assets/Scenes/SampleScene.unity#unity:component:1001', + name: 'Transform', + kind: 'component', + filePath: 'Assets/Scenes/SampleScene.unity', + pluginKind: 'component', + source: 'codegraphy.unity', + language: 'unity', + }), + ], + edges: [ + edge( + 'Assets/Scenes/SampleScene.unity', + 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', + 'contains', + ), + edge( + 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', + 'Assets/Scenes/SampleScene.unity#unity:component:1001', + 'contains', + ), + ], + }, + { + nodes: [ + { type: 'file', enabled: true }, + { type: 'symbol', enabled: true }, + { type: 'plugin:codegraphy.unity:symbol', enabled: true }, + { type: 'plugin:codegraphy.unity:symbol:game-object', enabled: true }, + { type: 'plugin:codegraphy.unity:symbol:component', enabled: false }, + ], + edges: [{ type: 'contains', enabled: true }], + }, + ); + + expect(ids(result)).toEqual({ + nodes: [ + 'Assets/Scenes/SampleScene.unity', + 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', + ], + edges: [ + 'Assets/Scenes/SampleScene.unity->Assets/Scenes/SampleScene.unity#unity:game-object:1000#contains', + ], + }); + }); +}); diff --git a/packages/extension/tests/shared/visibleGraph/scope/symbolMatch.test.ts b/packages/extension/tests/shared/visibleGraph/scope/symbolMatch.test.ts new file mode 100644 index 000000000..1956dceb6 --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/symbolMatch.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphNodeTypeDefinition } from '../../../../src/shared/graphControls/contracts'; +import type { ScopedSymbolDefinition } from '../../../../src/shared/visibleGraph/scope/definitions'; +import { symbolMatchesScopedDefinition } from '../../../../src/shared/visibleGraph/scope/symbolMatch'; +import { node, symbolNode } from './fixture'; + +describe('shared/visibleGraph/scope/symbolMatch', () => { + it('does not match nodes without symbol metadata', () => { + expect(symbolMatchesScopedDefinition( + node('src/app.ts'), + createDefinition({ id: 'symbol:function', matchSymbolKinds: ['function'] }), + )).toBe(false); + }); + + it('matches any symbol kind when a definition has no symbol-kind constraint', () => { + expect(symbolMatchesScopedDefinition( + symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + }), + createDefinition({ id: 'plugin:custom:any-symbol' }), + )).toBe(true); + }); + + it('uses raw glob matching for uncompiled file path constraints', () => { + const definition = createDefinition({ + id: 'symbol:function', + matchSymbolKinds: ['function'], + matchSymbolFilePath: 'src/**/*.ts', + }); + + expect(symbolMatchesScopedDefinition( + symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + }), + definition, + )).toBe(true); + expect(symbolMatchesScopedDefinition( + symbolNode('test/app.ts#main:function', { + id: 'test/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'test/app.ts', + }), + definition, + )).toBe(false); + }); + + it('uses compiled file path matchers when available', () => { + const definition = createDefinition({ + id: 'symbol:function', + matchSymbolKinds: ['function'], + matchSymbolFilePath: 'src/**/*.ts', + }); + const scopedDefinition: ScopedSymbolDefinition = { + definition, + enabled: true, + specificity: 1, + symbolFilePathMatches: filePath => filePath.endsWith('.generated.ts'), + }; + + expect(symbolMatchesScopedDefinition( + symbolNode('test/app.generated.ts#main:function', { + id: 'test/app.generated.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'test/app.generated.ts', + }), + scopedDefinition, + )).toBe(true); + expect(symbolMatchesScopedDefinition( + symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + }), + scopedDefinition, + )).toBe(false); + }); + + it('ignores compiled matcher fields on plain definitions', () => { + const definition = { + ...createDefinition({ + id: 'symbol:function', + matchSymbolKinds: ['function'], + matchSymbolFilePath: 'src/**/*.ts', + }), + symbolFilePathMatches: () => true, + }; + + expect(symbolMatchesScopedDefinition( + symbolNode('test/app.ts#main:function', { + id: 'test/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'test/app.ts', + }), + definition, + )).toBe(false); + }); +}); + +function createDefinition(input: { + id: string; + matchSymbolKinds?: string[]; + matchSymbolFilePath?: string; +}): IGraphNodeTypeDefinition { + return { + id: input.id, + label: input.id, + defaultColor: '#111111', + defaultVisible: false, + ...(input.matchSymbolKinds ? { matchSymbolKinds: input.matchSymbolKinds } : {}), + ...(input.matchSymbolFilePath ? { matchSymbolFilePath: input.matchSymbolFilePath } : {}), + }; +} diff --git a/packages/extension/tests/webview/Graph.wiring.test.tsx b/packages/extension/tests/webview/Graph.wiring.test.tsx index 636e8972f..a6c49e657 100644 --- a/packages/extension/tests/webview/Graph.wiring.test.tsx +++ b/packages/extension/tests/webview/Graph.wiring.test.tsx @@ -166,7 +166,6 @@ function createCallbacks() { linkCanvasObject: vi.fn(), nodeCanvasObject: vi.fn(), nodePointerAreaPaint: vi.fn(), - nodeThreeObject: vi.fn(), }; } @@ -265,7 +264,7 @@ describe('Graph wiring', () => { callbacks: expect.objectContaining({ getArrowColor: expect.any(Function), getLinkColor: expect.any(Function), - nodeThreeObject: expect.any(Function), + nodeCanvasObject: expect.any(Function), }), graphDataLayoutKey: expect.any(String), graphState: expect.objectContaining({ diff --git a/packages/extension/tests/webview/app/shell/graphScopeVisibility.test.tsx b/packages/extension/tests/webview/app/shell/graphScopeVisibility.test.tsx index bbcdb0d92..32e79fd30 100644 --- a/packages/extension/tests/webview/app/shell/graphScopeVisibility.test.tsx +++ b/packages/extension/tests/webview/app/shell/graphScopeVisibility.test.tsx @@ -10,6 +10,63 @@ describe('useDebouncedGraphScopeVisibility', () => { vi.useRealTimers(); }); + it('renders the first populated graph scope immediately', () => { + vi.useFakeTimers(); + const initialNodeVisibility = {}; + const initialEdgeVisibility = {}; + const nextNodeVisibility = { file: true }; + const nextEdgeVisibility = { include: true }; + + const { result, rerender } = renderHook( + ({ nodeVisibility, edgeVisibility }) => useDebouncedGraphScopeVisibility( + nodeVisibility, + edgeVisibility, + ), + { + initialProps: { + edgeVisibility: initialEdgeVisibility, + nodeVisibility: initialNodeVisibility, + }, + }, + ); + + rerender({ + edgeVisibility: nextEdgeVisibility, + nodeVisibility: nextNodeVisibility, + }); + + expect(result.current).toEqual({ + edgeVisibility: nextEdgeVisibility, + nodeVisibility: nextNodeVisibility, + }); + }); + + it('renders edge-only graph scope changes immediately', () => { + vi.useFakeTimers(); + const nodeVisibility = { file: true }; + const initialEdgeVisibility = { include: true }; + const nextEdgeVisibility = { include: false }; + + const { result, rerender } = renderHook( + ({ edgeVisibility }) => useDebouncedGraphScopeVisibility( + nodeVisibility, + edgeVisibility, + ), + { + initialProps: { + edgeVisibility: initialEdgeVisibility, + }, + }, + ); + + rerender({ edgeVisibility: nextEdgeVisibility }); + + expect(result.current).toEqual({ + edgeVisibility: nextEdgeVisibility, + nodeVisibility, + }); + }); + it('keeps the current render visibility until rapid graph scope changes settle', () => { vi.useFakeTimers(); const initialNodeVisibility = { file: true }; diff --git a/packages/extension/tests/webview/app/shell/messageListener.test.ts b/packages/extension/tests/webview/app/shell/messageListener.test.ts index 292b8742d..93c6b41fa 100644 --- a/packages/extension/tests/webview/app/shell/messageListener.test.ts +++ b/packages/extension/tests/webview/app/shell/messageListener.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { graphStore } from '../../../../src/webview/store/state'; import { createMessageHandler, setupMessageListener, type InjectAssetsParams } from '../../../../src/webview/app/shell/messageListener'; import type { WebviewPluginHost } from '../../../../src/webview/pluginHost/manager'; @@ -13,12 +13,14 @@ describe('app message listener', () => { beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); - delete (window as Window & { __codegraphyWebviewReadyPosted?: boolean }) - .__codegraphyWebviewReadyPosted; - }); - - afterEach(() => { - vi.restoreAllMocks(); + delete (window as Window & { + __codegraphyWebviewReadyPosted?: boolean; + __codegraphyWebviewPageId?: string; + }).__codegraphyWebviewReadyPosted; + delete (window as Window & { + __codegraphyWebviewReadyPosted?: boolean; + __codegraphyWebviewPageId?: string; + }).__codegraphyWebviewPageId; }); it('ignores invalid window messages', () => { @@ -265,7 +267,10 @@ describe('app message listener', () => { expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function)); expect(beginInitialBootstrap).toHaveBeenCalledOnce(); - expect(postMessage).toHaveBeenCalledWith({ type: 'WEBVIEW_READY', payload: null }); + expect(postMessage).toHaveBeenCalledWith({ + type: 'WEBVIEW_READY', + payload: { pageId: expect.any(String), postedAt: expect.any(Number) }, + }); const registeredHandler = addEventListenerSpy.mock.calls[0]?.[1]; cleanup(); @@ -281,7 +286,10 @@ describe('app message listener', () => { const secondCleanup = setupMessageListener(injectPluginAssets, pluginHost); expect(postMessage).toHaveBeenCalledTimes(1); - expect(postMessage).toHaveBeenCalledWith({ type: 'WEBVIEW_READY', payload: null }); + expect(postMessage).toHaveBeenCalledWith({ + type: 'WEBVIEW_READY', + payload: { pageId: expect.any(String), postedAt: expect.any(Number) }, + }); firstCleanup(); secondCleanup(); @@ -296,8 +304,19 @@ describe('app message listener', () => { const secondCleanup = setupMessageListener(injectPluginAssets, pluginHost); expect(postMessage).toHaveBeenCalledTimes(2); - expect(postMessage).toHaveBeenNthCalledWith(1, { type: 'WEBVIEW_READY', payload: null }); - expect(postMessage).toHaveBeenNthCalledWith(2, { type: 'WEBVIEW_READY', payload: null }); + expect(postMessage).toHaveBeenNthCalledWith(1, { + type: 'WEBVIEW_READY', + payload: { pageId: expect.any(String), postedAt: expect.any(Number) }, + }); + expect(postMessage).toHaveBeenNthCalledWith(2, { + type: 'WEBVIEW_READY', + payload: { pageId: expect.any(String), postedAt: expect.any(Number) }, + }); + const firstPayload = (vi.mocked(postMessage).mock.calls[0]?.[0] as { payload?: { pageId?: string } } | undefined) + ?.payload; + const secondPayload = (vi.mocked(postMessage).mock.calls[1]?.[0] as { payload?: { pageId?: string } } | undefined) + ?.payload; + expect(secondPayload?.pageId).toBe(firstPayload?.pageId); secondCleanup(); }); diff --git a/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts b/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts index d353fd175..dda0bdd97 100644 --- a/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts +++ b/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts @@ -12,8 +12,14 @@ describe('app/shell/messageListener/ready', () => { beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); - delete (window as Window & { __codegraphyWebviewReadyPosted?: boolean }) - .__codegraphyWebviewReadyPosted; + delete (window as Window & { + __codegraphyWebviewReadyPosted?: boolean; + __codegraphyWebviewPageId?: string; + }).__codegraphyWebviewReadyPosted; + delete (window as Window & { + __codegraphyWebviewReadyPosted?: boolean; + __codegraphyWebviewPageId?: string; + }).__codegraphyWebviewPageId; }); it('posts webview ready only once per window lifecycle', () => { @@ -24,6 +30,10 @@ describe('app/shell/messageListener/ready', () => { expect(beginInitialBootstrap).toHaveBeenCalledOnce(); expect(postMessage).toHaveBeenCalledTimes(1); - expect(postMessage).toHaveBeenCalledWith({ type: 'WEBVIEW_READY', payload: null }); + expect(postMessage).toHaveBeenCalledWith({ + type: 'WEBVIEW_READY', + payload: { pageId: expect.any(String), postedAt: expect.any(Number) }, + }); }); + }); diff --git a/packages/extension/tests/webview/app/shell/view.messages.test.tsx b/packages/extension/tests/webview/app/shell/view.messages.test.tsx new file mode 100644 index 000000000..c98cda675 --- /dev/null +++ b/packages/extension/tests/webview/app/shell/view.messages.test.tsx @@ -0,0 +1,112 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../../../../src/webview/app/view'; +import { graphStore } from '../../../../src/webview/store/state'; +import { messageListeners, resetStore, sendMessage } from './view/fixture'; + +describe('App: message handlers', () => { + beforeEach(() => { + messageListeners.length = 0; + delete (window as Window & { __codegraphyWebviewReadyPosted?: boolean }) + .__codegraphyWebviewReadyPosted; + resetStore(); + vi.useRealTimers(); + }); + afterEach(() => vi.useRealTimers()); + + it('SETTINGS_UPDATED updates settings state', async () => { + render(); + await act(async () => { + sendMessage({ + type: 'SETTINGS_UPDATED', + payload: { + bidirectionalEdges: 'combined', + showOrphans: false, + }, + }); + }); + expect(graphStore.getState().bidirectionalMode).toBe('combined'); + expect(graphStore.getState().showOrphans).toBe(false); + }); + + it('DIRECTION_SETTINGS_UPDATED updates direction mode state', async () => { + render(); + await act(async () => { + sendMessage({ type: 'DIRECTION_SETTINGS_UPDATED', payload: { directionMode: 'particles', directionColor: '#00FF00', particleSpeed: 0.01, particleSize: 6 } }); + }); + expect(graphStore.getState().directionMode).toBe('particles'); + expect(graphStore.getState().directionColor).toBe('#00FF00'); + expect(graphStore.getState().particleSpeed).toBe(0.01); + expect(graphStore.getState().particleSize).toBe(6); + }); + + it('FAVORITES_UPDATED message is handled without error', async () => { + render(); + await act(async () => { + sendMessage({ type: 'FAVORITES_UPDATED', payload: { favorites: ['src/index.ts'] } }); + }); + expect(graphStore.getState().favorites).toEqual(new Set(['src/index.ts'])); + }); + + it('FILTER_PATTERNS_UPDATED message is handled', async () => { + render(); + await act(async () => { + sendMessage({ + type: 'FILTER_PATTERNS_UPDATED', + payload: { + patterns: ['**/*.test.ts'], + pluginPatterns: [], + pluginPatternGroups: [], + disabledCustomPatterns: [], + disabledPluginPatterns: [], + }, + }); + }); + expect(graphStore.getState().filterPatterns).toEqual(['**/*.test.ts']); + }); + + it('LEGENDS_UPDATED message is handled', async () => { + render(); + await act(async () => { + sendMessage({ + type: 'LEGENDS_UPDATED', + payload: { legends: [{ id: 'g1', pattern: 'src/**', color: '#ff0000' }] }, + }); + }); + expect(graphStore.getState().legends).toEqual([{ id: 'g1', pattern: 'src/**', color: '#ff0000' }]); + }); + + it('PHYSICS_SETTINGS_UPDATED message is handled', async () => { + render(); + const physics = { + repelForce: 4, + centerForce: 0.02, + linkDistance: 150, + linkForce: 0.05, + damping: 0.5, + }; + await act(async () => { + sendMessage({ + type: 'PHYSICS_SETTINGS_UPDATED', + payload: physics, + }); + }); + expect(graphStore.getState().physicsSettings).toEqual(physics); + }); + + it('DEPTH_LIMIT_UPDATED message is handled', async () => { + render(); + await act(async () => { + sendMessage({ type: 'DEPTH_LIMIT_UPDATED', payload: { depthLimit: 3 } }); + }); + expect(graphStore.getState().depthLimit).toBe(3); + }); + + it('DEPTH_LIMIT_RANGE_UPDATED message is handled', async () => { + render(); + await act(async () => { + sendMessage({ type: 'DEPTH_LIMIT_RANGE_UPDATED', payload: { maxDepthLimit: 2 } }); + }); + expect(graphStore.getState().maxDepthLimit).toBe(2); + }); +}); diff --git a/packages/extension/tests/webview/app/shell/view.test.tsx b/packages/extension/tests/webview/app/shell/view.test.tsx index e6c8de371..677e88021 100644 --- a/packages/extension/tests/webview/app/shell/view.test.tsx +++ b/packages/extension/tests/webview/app/shell/view.test.tsx @@ -2,66 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, act, fireEvent, waitFor } from '@testing-library/react'; import App from '../../../../src/webview/app/view'; import { graphStore } from '../../../../src/webview/store/state'; -import { DEFAULT_DIRECTION_COLOR } from '../../../../src/shared/fileColors'; import { STRUCTURAL_NESTS_EDGE_KIND } from '../../../../src/shared/graphControls/defaults/definitions'; - -// Mock window message listeners -const messageListeners: ((event: MessageEvent) => void)[] = []; - -vi.stubGlobal('addEventListener', (type: string, listener: (event: MessageEvent) => void) => { - if (type === 'message') { - messageListeners.push(listener); - } -}); - -vi.stubGlobal('removeEventListener', (type: string, listener: (event: MessageEvent) => void) => { - if (type === 'message') { - const index = messageListeners.indexOf(listener); - if (index > -1) messageListeners.splice(index, 1); - } -}); - -/** Reset store to initial state between tests */ -function resetStore() { - graphStore.setState({ - graphData: null, - isLoading: true, - searchQuery: '', - searchOptions: { matchCase: false, wholeWord: false, regex: false }, - favorites: new Set(), - bidirectionalMode: 'separate', - showOrphans: true, - directionMode: 'arrows', - directionColor: DEFAULT_DIRECTION_COLOR, - particleSpeed: 0.005, - particleSize: 4, - showLabels: true, - graphMode: '2d', - nodeSizeMode: 'connections', - physicsSettings: { repelForce: 10, linkDistance: 80, linkForce: 0.15, damping: 0.7, centerForce: 0.1 }, - graphHasIndex: false, - graphIsIndexing: false, - graphIndexProgress: null, - awaitingInitialBootstrap: false, - bootstrapComplete: false, - pendingPluginAssetLoads: 0, - depthMode: false, - depthLimit: 1, - maxDepthLimit: 10, - legends: [], - filterPatterns: [], - pluginFilterPatterns: [], - pluginFilterGroups: [], - pluginStatuses: [], - graphNodeTypes: [], - graphEdgeTypes: [], - nodeColors: {}, - nodeVisibility: {}, - edgeVisibility: {}, - activePanel: 'none', - maxFiles: 500, - }); -} +import { messageListeners, resetStore, sendMessage } from './view/fixture'; describe('App', () => { beforeEach(() => { @@ -138,6 +80,38 @@ describe('App', () => { expect(screen.getByTitle('Graph Scope')).toBeInTheDocument(); }); + it('applies queued graph and filter updates after startup bootstrap completes', async () => { + render(); + + await act(async () => { + sendMessage({ + type: 'FILTER_PATTERNS_UPDATED', + payload: { + patterns: ['dist/**'], + pluginPatterns: [], + pluginPatternGroups: [], + disabledCustomPatterns: [], + disabledPluginPatterns: [], + }, + }); + sendMessage({ + type: 'GRAPH_DATA_UPDATED', + payload: { + nodes: [{ id: 'src/app.ts', label: 'app.ts', color: '#3B82F6' }], + edges: [], + }, + }); + }); + + expect(screen.getByText('Loading graph...')).toBeInTheDocument(); + + await act(async () => { + sendMessage({ type: 'APP_BOOTSTRAP_COMPLETE' }); + }); + + expect(screen.getByText('1 node • 0 connections')).toBeInTheDocument(); + }); + it('keeps the first graph visible while startup plugin assets finish loading', async () => { let resolveInjection: (() => void) | undefined; const pendingImport = new Promise((resolve) => { @@ -675,117 +649,3 @@ describe('App', () => { expect(screen.queryByTitle('Open in Editor')).not.toBeInTheDocument(); }); }); - -// ── Message Handler Coverage ──────────────────────────────────────────────── - -function sendMessage(data: unknown) { - const event = new MessageEvent('message', { data }); - messageListeners.forEach((listener) => listener(event)); -} - -describe('App: message handlers', () => { - beforeEach(() => { - messageListeners.length = 0; - delete (window as Window & { __codegraphyWebviewReadyPosted?: boolean }) - .__codegraphyWebviewReadyPosted; - resetStore(); - vi.useRealTimers(); - }); - afterEach(() => vi.useRealTimers()); - - it('SETTINGS_UPDATED updates settings state', async () => { - render(); - await act(async () => { - sendMessage({ - type: 'SETTINGS_UPDATED', - payload: { - bidirectionalEdges: 'combined', - showOrphans: false, - }, - }); - }); - expect(graphStore.getState().bidirectionalMode).toBe('combined'); - expect(graphStore.getState().showOrphans).toBe(false); - }); - - it('DIRECTION_SETTINGS_UPDATED updates direction mode state', async () => { - render(); - await act(async () => { - sendMessage({ type: 'DIRECTION_SETTINGS_UPDATED', payload: { directionMode: 'particles', directionColor: '#00FF00', particleSpeed: 0.01, particleSize: 6 } }); - }); - expect(graphStore.getState().directionMode).toBe('particles'); - expect(graphStore.getState().directionColor).toBe('#00FF00'); - expect(graphStore.getState().particleSpeed).toBe(0.01); - expect(graphStore.getState().particleSize).toBe(6); - }); - - it('FAVORITES_UPDATED message is handled without error', async () => { - render(); - await act(async () => { - sendMessage({ type: 'FAVORITES_UPDATED', payload: { favorites: ['src/index.ts'] } }); - }); - expect(graphStore.getState().favorites).toEqual(new Set(['src/index.ts'])); - }); - - it('FILTER_PATTERNS_UPDATED message is handled', async () => { - render(); - await act(async () => { - sendMessage({ - type: 'FILTER_PATTERNS_UPDATED', - payload: { - patterns: ['**/*.test.ts'], - pluginPatterns: [], - pluginPatternGroups: [], - disabledCustomPatterns: [], - disabledPluginPatterns: [], - }, - }); - }); - expect(graphStore.getState().filterPatterns).toEqual(['**/*.test.ts']); - }); - - it('LEGENDS_UPDATED message is handled', async () => { - render(); - await act(async () => { - sendMessage({ - type: 'LEGENDS_UPDATED', - payload: { legends: [{ id: 'g1', pattern: 'src/**', color: '#ff0000' }] }, - }); - }); - expect(graphStore.getState().legends).toEqual([{ id: 'g1', pattern: 'src/**', color: '#ff0000' }]); - }); - - it('PHYSICS_SETTINGS_UPDATED message is handled', async () => { - render(); - const physics = { - repelForce: 4, - centerForce: 0.02, - linkDistance: 150, - linkForce: 0.05, - damping: 0.5, - }; - await act(async () => { - sendMessage({ - type: 'PHYSICS_SETTINGS_UPDATED', - payload: physics, - }); - }); - expect(graphStore.getState().physicsSettings).toEqual(physics); - }); - - it('DEPTH_LIMIT_UPDATED message is handled', async () => { - render(); - await act(async () => { - sendMessage({ type: 'DEPTH_LIMIT_UPDATED', payload: { depthLimit: 3 } }); - }); - expect(graphStore.getState().depthLimit).toBe(3); - }); - - it('DEPTH_LIMIT_RANGE_UPDATED message is handled', async () => { - render(); - await act(async () => { - sendMessage({ type: 'DEPTH_LIMIT_RANGE_UPDATED', payload: { maxDepthLimit: 2 } }); - }); - expect(graphStore.getState().maxDepthLimit).toBe(2); - }); -}); diff --git a/packages/extension/tests/webview/app/shell/view/fixture.ts b/packages/extension/tests/webview/app/shell/view/fixture.ts new file mode 100644 index 000000000..b6def8244 --- /dev/null +++ b/packages/extension/tests/webview/app/shell/view/fixture.ts @@ -0,0 +1,65 @@ +import { vi } from 'vitest'; + +import { DEFAULT_DIRECTION_COLOR } from '../../../../../src/shared/fileColors'; +import { graphStore } from '../../../../../src/webview/store/state'; + +export const messageListeners: Array<(event: MessageEvent) => void> = []; + +vi.stubGlobal('addEventListener', (type: string, listener: (event: MessageEvent) => void) => { + if (type === 'message') { + messageListeners.push(listener); + } +}); + +vi.stubGlobal('removeEventListener', (type: string, listener: (event: MessageEvent) => void) => { + if (type === 'message') { + const index = messageListeners.indexOf(listener); + if (index > -1) messageListeners.splice(index, 1); + } +}); + +export function resetStore() { + graphStore.setState({ + graphData: null, + isLoading: true, + searchQuery: '', + searchOptions: { matchCase: false, wholeWord: false, regex: false }, + favorites: new Set(), + bidirectionalMode: 'separate', + showOrphans: true, + directionMode: 'arrows', + directionColor: DEFAULT_DIRECTION_COLOR, + particleSpeed: 0.005, + particleSize: 4, + showLabels: true, + graphMode: '2d', + nodeSizeMode: 'connections', + physicsSettings: { repelForce: 10, linkDistance: 80, linkForce: 0.15, damping: 0.7, centerForce: 0.1 }, + graphHasIndex: false, + graphIsIndexing: false, + graphIndexProgress: null, + awaitingInitialBootstrap: false, + bootstrapComplete: false, + pendingPluginAssetLoads: 0, + depthMode: false, + depthLimit: 1, + maxDepthLimit: 10, + legends: [], + filterPatterns: [], + pluginFilterPatterns: [], + pluginFilterGroups: [], + pluginStatuses: [], + graphNodeTypes: [], + graphEdgeTypes: [], + nodeColors: {}, + nodeVisibility: {}, + edgeVisibility: {}, + activePanel: 'none', + maxFiles: 500, + }); +} + +export function sendMessage(data: unknown) { + const event = new MessageEvent('message', { data }); + messageListeners.forEach((listener) => listener(event)); +} diff --git a/packages/extension/tests/webview/graph/drag.test.tsx b/packages/extension/tests/webview/graph/drag.test.tsx index cd999e4a0..2c6c47de3 100644 --- a/packages/extension/tests/webview/graph/drag.test.tsx +++ b/packages/extension/tests/webview/graph/drag.test.tsx @@ -77,13 +77,7 @@ describe('Graph: force-graph rendering', () => { render(); const props = ForceGraph2D.getLastProps(); expect(props.linkDirectionalArrowLength).toBeGreaterThan(0); - expect(props.linkDirectionalArrowRelPos).toEqual(expect.any(Function)); - - const relPos = props.linkDirectionalArrowRelPos({ - source: { id: 'a.ts', x: 0, y: 0, size: 10 }, - target: { id: 'b.ts', x: 100, y: 0, size: 10 }, - }); - expect(relPos).toBe(1); + expect(props.linkDirectionalArrowRelPos).toBe(1); expect(props.nodeRelSize).toBe(1); expect(props.nodeVal({ size: 10 })).toBe(100); }); @@ -101,7 +95,7 @@ describe('Graph: force-graph rendering', () => { }); expect(mockMethods.linkDirectionalArrowLength).toHaveBeenLastCalledWith(0); - expect(mockMethods.linkDirectionalArrowRelPos).toHaveBeenLastCalledWith(expect.any(Function)); + expect(mockMethods.linkDirectionalArrowRelPos).toHaveBeenLastCalledWith(1); expect(mockMethods.linkDirectionalParticles).toHaveBeenLastCalledWith(expect.any(Function)); expect(mockMethods.linkDirectionalParticleSpeed).toHaveBeenLastCalledWith(0.005); expect(mockMethods.d3ReheatSimulation).toHaveBeenCalledTimes(1); diff --git a/packages/extension/tests/webview/graph/rendering/sharedProps.test.ts b/packages/extension/tests/webview/graph/rendering/sharedProps.test.ts index f1124d21b..0a262307b 100644 --- a/packages/extension/tests/webview/graph/rendering/sharedProps.test.ts +++ b/packages/extension/tests/webview/graph/rendering/sharedProps.test.ts @@ -78,7 +78,7 @@ describe('graph/rendering/surface/sharedProps', () => { expect(props.d3VelocityDecay).toBe(0.7); expect(props.d3AlphaDecay).toBe(0.0228); expect(props.warmupTicks).toBe(0); - expect(props.cooldownTicks).toBeGreaterThan(0); + expect(props.cooldownTicks).toBe(INTERACTIVE_COOLDOWN_TICKS); expect(props.dagMode).toBe('td'); expect(props.dagLevelDistance).toBe(60); }); @@ -108,6 +108,17 @@ describe('graph/rendering/surface/sharedProps', () => { expect(props.cooldownTicks).toBe(INTERACTIVE_COOLDOWN_TICKS); }); + it('keeps unpositioned interactive graphs on the normal physics cooldown', () => { + const props = buildSharedGraphProps(createOptions({ + graphData: { + links: [createLink()], + nodes: [createNode()], + }, + })); + + expect(props.cooldownTicks).toBe(INTERACTIVE_COOLDOWN_TICKS); + }); + it('normalizes width and height independently', () => { const widthOnly = buildSharedGraphProps(createOptions({ containerSize: { height: 0, width: 320 }, diff --git a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passesgetparticlecoloraslinkdirectionalparticlecolortocancelsbothscheduledanimationframes.test.tsx b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passesgetparticlecoloraslinkdirectionalparticlecolortocancelsbothscheduledanimationframes.test.tsx index 652c9089b..7c79d1d2d 100644 --- a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passesgetparticlecoloraslinkdirectionalparticlecolortocancelsbothscheduledanimationframes.test.tsx +++ b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passesgetparticlecoloraslinkdirectionalparticlecolortocancelsbothscheduledanimationframes.test.tsx @@ -31,6 +31,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function createDefaultProps() { return { backgroundColor: '#1e1e1e', @@ -41,7 +50,7 @@ function createDefaultProps() { getLinkParticles: vi.fn(() => 2), getLinkWidth: vi.fn(() => 1), getParticleColor: vi.fn(() => '#ff0000'), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 4, particleSpeed: 0.005, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passeslinkwidthcallbacktopassesgetarrowcoloraslinkdirectionalarrowcolor.test.tsx b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passeslinkwidthcallbacktopassesgetarrowcoloraslinkdirectionalarrowcolor.test.tsx index 6d651578a..a73f7a0e9 100644 --- a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passeslinkwidthcallbacktopassesgetarrowcoloraslinkdirectionalarrowcolor.test.tsx +++ b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passeslinkwidthcallbacktopassesgetarrowcoloraslinkdirectionalarrowcolor.test.tsx @@ -29,6 +29,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function createDefaultProps() { return { backgroundColor: '#1e1e1e', @@ -39,7 +48,7 @@ function createDefaultProps() { getLinkParticles: vi.fn(() => 2), getLinkWidth: vi.fn(() => 1), getParticleColor: vi.fn(() => '#ff0000'), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 4, particleSpeed: 0.005, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.setslinkdirectionalparticlesto0whentopasseslinkcolorcallback.test.tsx b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.setslinkdirectionalparticlesto0whentopasseslinkcolorcallback.test.tsx index ae0d3547f..5acd827fb 100644 --- a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.setslinkdirectionalparticlesto0whentopasseslinkcolorcallback.test.tsx +++ b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.setslinkdirectionalparticlesto0whentopasseslinkcolorcallback.test.tsx @@ -29,6 +29,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function createDefaultProps() { return { backgroundColor: '#1e1e1e', @@ -39,7 +48,7 @@ function createDefaultProps() { getLinkParticles: vi.fn(() => 2), getLinkWidth: vi.fn(() => 1), getParticleColor: vi.fn(() => '#ff0000'), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 4, particleSpeed: 0.005, sharedProps: createSharedProps(), @@ -98,7 +107,7 @@ describe('Surface3d', () => { const defaultProps = createDefaultProps(); render(); const props = (ForceGraph3D as unknown as { getLastProps: () => Record }).getLastProps(); - expect(props.nodeThreeObject).toBe(defaultProps.nodeThreeObject); + expect(props.nodeThreeObject).toEqual(expect.any(Function)); }); diff --git a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.test.tsx b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.test.tsx index 6385d31fe..f43ba525b 100644 --- a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.test.tsx +++ b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.test.tsx @@ -30,6 +30,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function createDefaultProps() { return { backgroundColor: '#1e1e1e', @@ -40,7 +49,7 @@ function createDefaultProps() { getLinkParticles: vi.fn(() => 2), getLinkWidth: vi.fn(() => 1), getParticleColor: vi.fn(() => '#ff0000'), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 4, particleSpeed: 0.005, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/rendering/surface/view/twoDimensional.test.tsx b/packages/extension/tests/webview/graph/rendering/surface/view/twoDimensional.test.tsx index 9430c477e..5ef01e6a7 100644 --- a/packages/extension/tests/webview/graph/rendering/surface/view/twoDimensional.test.tsx +++ b/packages/extension/tests/webview/graph/rendering/surface/view/twoDimensional.test.tsx @@ -77,6 +77,17 @@ describe('Surface2d', () => { expect(props.linkDirectionalArrowLength).toBe(12); }); + it('passes constant arrow position and color values', () => { + const defaultProps = createDefaultProps(); + render(); + const props = (ForceGraph2D as unknown as { getLastProps: () => Record }).getLastProps(); + + expect(props.linkDirectionalArrowRelPos).toBe(1); + expect(props.linkDirectionalArrowColor).toBe('#ffffff'); + expect(defaultProps.getArrowRelPos).toHaveBeenCalledOnce(); + expect(defaultProps.getArrowColor).toHaveBeenCalledOnce(); + }); + it('sets linkDirectionalArrowLength to 0 when direction mode is not arrows', () => { diff --git a/packages/extension/tests/webview/graph/rendering/useGraphCallbacks.delegatesgetparticlecolortogetgraphdirectionalcolorandtokeepscallbackidentitiesstableacross.test.tsx b/packages/extension/tests/webview/graph/rendering/useGraphCallbacks.delegatesgetparticlecolortogetgraphdirectionalcolorandtokeepscallbackidentitiesstableacross.test.tsx index e7deec92f..478e15015 100644 --- a/packages/extension/tests/webview/graph/rendering/useGraphCallbacks.delegatesgetparticlecolortogetgraphdirectionalcolorandtokeepscallbackidentitiesstableacross.test.tsx +++ b/packages/extension/tests/webview/graph/rendering/useGraphCallbacks.delegatesgetparticlecolortogetgraphdirectionalcolorandtokeepscallbackidentitiesstableacross.test.tsx @@ -8,7 +8,6 @@ import { } from '../../../../src/webview/components/graph/rendering/useGraphCallbacks'; const renderingHarness = vi.hoisted(() => ({ - createNodeThreeObject: vi.fn(), getGraphArrowRelPos: vi.fn(), getGraphDirectionalColor: vi.fn(), getGraphLinkColor: vi.fn(), @@ -39,10 +38,6 @@ vi.mock('../../../../src/webview/components/graph/rendering/nodes/canvas2d', () renderNodeCanvas: renderingHarness.renderNodeCanvas, })); -vi.mock('../../../../src/webview/components/graph/rendering/nodes/canvas3d', () => ({ - createNodeThreeObject: renderingHarness.createNodeThreeObject, -})); - function createRefs(): UseGraphCallbacksOptions['refs'] { return { directionColorRef: { current: 'cycle' }, @@ -84,7 +79,6 @@ function renderUseGraphCallbacks(options: Partial = {} describe('graph/rendering/useGraphCallbacks', () => { beforeEach(() => { - renderingHarness.createNodeThreeObject.mockReset(); renderingHarness.getGraphArrowRelPos.mockReset(); renderingHarness.getGraphDirectionalColor.mockReset(); renderingHarness.getGraphLinkColor.mockReset(); @@ -134,24 +128,6 @@ describe('graph/rendering/useGraphCallbacks', () => { - it('delegates nodeThreeObject to createNodeThreeObject and returns its result', () => { - const node = { id: 'node-1' } as FGNode as NodeObject; - const threeObject = { isThreeObject: true }; - renderingHarness.createNodeThreeObject.mockReturnValue(threeObject); - const { refs, result } = renderUseGraphCallbacks(); - - const returnedObject = result.current.nodeThreeObject(node); - - expect(returnedObject).toBe(threeObject); - expect(renderingHarness.createNodeThreeObject).toHaveBeenCalledWith({ - meshesRef: refs.meshesRef, - showLabelsRef: refs.showLabelsRef, - spritesRef: refs.spritesRef, - }, node); - }); - - - it('keeps callback identities stable across rerenders while using the latest inputs', () => { const initialCallbacks = renderUseGraphCallbacks(); const node = { id: 'node-2' } as FGNode as NodeObject; diff --git a/packages/extension/tests/webview/graph/runtime/use/indicators/directional.reappliessettingswhenparticlesizetoreappliessettingswhenthegraph.test.tsx b/packages/extension/tests/webview/graph/runtime/use/indicators/directional.reappliessettingswhenparticlesizetoreappliessettingswhenthegraph.test.tsx index 26e9a76ec..adbf7d8dd 100644 --- a/packages/extension/tests/webview/graph/runtime/use/indicators/directional.reappliessettingswhenparticlesizetoreappliessettingswhenthegraph.test.tsx +++ b/packages/extension/tests/webview/graph/runtime/use/indicators/directional.reappliessettingswhenparticlesizetoreappliessettingswhenthegraph.test.tsx @@ -24,8 +24,8 @@ function createDirectionalOptions( ): Parameters[1] { return { directionMode: 'particles', - getArrowColor: vi.fn(), - getArrowRelPos: vi.fn(), + getArrowColor: vi.fn(() => '#abcdef'), + getArrowRelPos: vi.fn(() => 1), getLinkParticles: vi.fn(), getParticleColor: vi.fn(), particleSize: 3, @@ -110,7 +110,7 @@ describe('useDirectional', () => { (props: Parameters[0]) => useDirectional(props), { initialProps: options }, ); - const getArrowColor = vi.fn(); + const getArrowColor = vi.fn(() => '#fedcba'); vi.clearAllMocks(); rerender({ @@ -118,7 +118,7 @@ describe('useDirectional', () => { getArrowColor, }); - expect(graph.linkDirectionalArrowColor).toHaveBeenCalledWith(getArrowColor); + expect(graph.linkDirectionalArrowColor).toHaveBeenCalledWith('#fedcba'); expect(graph.d3ReheatSimulation).toHaveBeenCalledOnce(); }); @@ -133,7 +133,7 @@ describe('useDirectional', () => { (props: Parameters[0]) => useDirectional(props), { initialProps: options }, ); - const getArrowRelPos = vi.fn(); + const getArrowRelPos = vi.fn(() => 0.75); vi.clearAllMocks(); rerender({ @@ -141,7 +141,7 @@ describe('useDirectional', () => { getArrowRelPos, }); - expect(graph.linkDirectionalArrowRelPos).toHaveBeenCalledWith(getArrowRelPos); + expect(graph.linkDirectionalArrowRelPos).toHaveBeenCalledWith(0.75); expect(graph.d3ReheatSimulation).toHaveBeenCalledOnce(); }); diff --git a/packages/extension/tests/webview/graph/runtime/use/indicators/directional.test.tsx b/packages/extension/tests/webview/graph/runtime/use/indicators/directional.test.tsx index eb2632d2f..24e88483b 100644 --- a/packages/extension/tests/webview/graph/runtime/use/indicators/directional.test.tsx +++ b/packages/extension/tests/webview/graph/runtime/use/indicators/directional.test.tsx @@ -24,8 +24,8 @@ function createDirectionalOptions( ): Parameters[1] { return { directionMode: 'particles', - getArrowColor: vi.fn(), - getArrowRelPos: vi.fn(), + getArrowColor: vi.fn(() => '#abcdef'), + getArrowRelPos: vi.fn(() => 1), getLinkParticles: vi.fn(), getParticleColor: vi.fn(), particleSize: 3, @@ -63,11 +63,11 @@ describe('useDirectional', () => { applyDirectionalSettings(graph, options); expect(graph.linkDirectionalArrowLength).toHaveBeenCalledWith(0); - expect(graph.linkDirectionalArrowRelPos).toHaveBeenCalledWith(options.getArrowRelPos); + expect(graph.linkDirectionalArrowRelPos).toHaveBeenCalledWith(1); expect(graph.linkDirectionalParticles).toHaveBeenCalledWith(options.getLinkParticles); expect(graph.linkDirectionalParticleWidth).toHaveBeenCalledWith(3); expect(graph.linkDirectionalParticleSpeed).toHaveBeenCalledWith(0.15); - expect(graph.linkDirectionalArrowColor).toHaveBeenCalledWith(options.getArrowColor); + expect(graph.linkDirectionalArrowColor).toHaveBeenCalledWith('#abcdef'); expect(graph.linkDirectionalParticleColor).toHaveBeenCalledWith(options.getParticleColor); expect(graph.d3ReheatSimulation).toHaveBeenCalledOnce(); expect(graph.resumeAnimation).toHaveBeenCalledOnce(); diff --git a/packages/extension/tests/webview/graph/viewport/shell.test.tsx b/packages/extension/tests/webview/graph/viewport/shell.test.tsx index 52e9adb77..54915c40a 100644 --- a/packages/extension/tests/webview/graph/viewport/shell.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/shell.test.tsx @@ -148,6 +148,7 @@ function createGraphState(graphData: GraphRuntime['renderer']['graphData']): Gra }, edgeDecorationsRef: { current: {} }, favoritesRef: { current: new Set() }, + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, nodeDecorationsRef: { current: {} }, nodeSizeModeRef: { current: 'connections' }, setHighlightVersion: vi.fn(), @@ -206,7 +207,6 @@ function createCallbacks() { linkCanvasObject: vi.fn(), nodeCanvasObject: vi.fn(), nodePointerAreaPaint: vi.fn(), - nodeThreeObject: vi.fn(), }; } @@ -405,7 +405,12 @@ describe('graph/viewport/shell', () => { getArrowColor: callbacks.getArrowColor, getLinkColor: callbacks.getLinkColor, getParticleColor: callbacks.getParticleColor, - nodeThreeObject: callbacks.nodeThreeObject, + nodeThreeObjectContext: { + graphAppearanceRef: graphState.graphAppearanceRef, + meshesRef: graphState.renderCaches.meshesRef, + showLabelsRef: graphState.showLabelsRef, + spritesRef: graphState.renderCaches.spritesRef, + }, particleSize: 3, particleSpeed: 0.2, sharedProps: expect.objectContaining({ dagMode: 'td' }), diff --git a/packages/extension/tests/webview/graph/viewport/shell/viewportState.test.ts b/packages/extension/tests/webview/graph/viewport/shell/state.test.ts similarity index 97% rename from packages/extension/tests/webview/graph/viewport/shell/viewportState.test.ts rename to packages/extension/tests/webview/graph/viewport/shell/state.test.ts index f1167dba0..904f3b4f1 100644 --- a/packages/extension/tests/webview/graph/viewport/shell/viewportState.test.ts +++ b/packages/extension/tests/webview/graph/viewport/shell/state.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it, vi } from 'vitest'; import { createGraphViewViewportState, toGraphViewViewportNodes, -} from '../../../../../src/webview/components/graph/viewport/shell/viewportState'; +} from '../../../../../src/webview/components/graph/viewport/shell/state'; -describe('graph/viewport/shell/viewportState', () => { +describe('graph/viewport/shell/state', () => { it('sanitizes viewport node fields while preserving plugin-owned custom state', () => { const nodes = toGraphViewViewportNodes([{ customRuntimeState: { owner: 'plugin-a' }, diff --git a/packages/extension/tests/webview/graph/viewport/tooltip.renderssurface2dfor2dmode.test.tsx b/packages/extension/tests/webview/graph/viewport/tooltip.renderssurface2dfor2dmode.test.tsx index 5008d3d46..88aac0b91 100644 --- a/packages/extension/tests/webview/graph/viewport/tooltip.renderssurface2dfor2dmode.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/tooltip.renderssurface2dfor2dmode.test.tsx @@ -33,7 +33,7 @@ vi.mock('../../../../src/webview/components/graph/rendering/surface/view/twoDime })); vi.mock('../../../../src/webview/components/graph/rendering/surface/view/threeDimensional', () => ({ - Surface3d: (props: Record) => { + DeferredSurface3d: (props: Record) => { harness.surface3d(props); return
; }, @@ -79,6 +79,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function renderViewport(overrides: Partial> = {}) { const handleContextMenu = vi.fn(); const handleMouseLeave = vi.fn(); @@ -125,7 +134,7 @@ function renderViewport(overrides: Partial getLinkParticles: vi.fn(), getLinkWidth: vi.fn(), getParticleColor: vi.fn(), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 2, particleSpeed: 0.1, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/viewport/tooltip.test.tsx b/packages/extension/tests/webview/graph/viewport/tooltip.test.tsx index 1bfc6471e..0f98e134a 100644 --- a/packages/extension/tests/webview/graph/viewport/tooltip.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/tooltip.test.tsx @@ -33,7 +33,7 @@ vi.mock('../../../../src/webview/components/graph/rendering/surface/view/twoDime })); vi.mock('../../../../src/webview/components/graph/rendering/surface/view/threeDimensional', () => ({ - Surface3d: (props: Record) => { + DeferredSurface3d: (props: Record) => { harness.surface3d(props); return
; }, @@ -79,6 +79,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function renderViewport(overrides: Partial> = {}) { const handleContextMenu = vi.fn(); const handleMouseLeave = vi.fn(); @@ -125,7 +134,7 @@ function renderViewport(overrides: Partial getLinkParticles: vi.fn(), getLinkWidth: vi.fn(), getParticleColor: vi.fn(), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 2, particleSpeed: 0.1, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/viewport/tooltip.viewportstylemutationsl72.test.tsx b/packages/extension/tests/webview/graph/viewport/tooltip.viewportstylemutationsl72.test.tsx index 1fc8bf517..33de7ce6e 100644 --- a/packages/extension/tests/webview/graph/viewport/tooltip.viewportstylemutationsl72.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/tooltip.viewportstylemutationsl72.test.tsx @@ -33,7 +33,7 @@ vi.mock('../../../../src/webview/components/graph/rendering/surface/view/twoDime })); vi.mock('../../../../src/webview/components/graph/rendering/surface/view/threeDimensional', () => ({ - Surface3d: (props: Record) => { + DeferredSurface3d: (props: Record) => { harness.surface3d(props); return
; }, @@ -79,6 +79,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function renderViewport(overrides: Partial> = {}) { const handleContextMenu = vi.fn(); const handleMouseLeave = vi.fn(); @@ -125,7 +134,7 @@ function renderViewport(overrides: Partial getLinkParticles: vi.fn(), getLinkWidth: vi.fn(), getParticleColor: vi.fn(), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 2, particleSpeed: 0.1, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/viewport/tooltip.viewporttooltipcountmutationsl111.test.tsx b/packages/extension/tests/webview/graph/viewport/tooltip.viewporttooltipcountmutationsl111.test.tsx index e0660b2e3..080279917 100644 --- a/packages/extension/tests/webview/graph/viewport/tooltip.viewporttooltipcountmutationsl111.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/tooltip.viewporttooltipcountmutationsl111.test.tsx @@ -33,7 +33,7 @@ vi.mock('../../../../src/webview/components/graph/rendering/surface/view/twoDime })); vi.mock('../../../../src/webview/components/graph/rendering/surface/view/threeDimensional', () => ({ - Surface3d: (props: Record) => { + DeferredSurface3d: (props: Record) => { harness.surface3d(props); return
; }, @@ -79,6 +79,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function renderViewport(overrides: Partial> = {}) { const handleContextMenu = vi.fn(); const handleMouseLeave = vi.fn(); @@ -125,7 +134,7 @@ function renderViewport(overrides: Partial getLinkParticles: vi.fn(), getLinkWidth: vi.fn(), getParticleColor: vi.fn(), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 2, particleSpeed: 0.1, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/viewport/view.mutations.test.tsx b/packages/extension/tests/webview/graph/viewport/view.mutations.test.tsx index efeb0dc9a..97d199ab9 100644 --- a/packages/extension/tests/webview/graph/viewport/view.mutations.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/view.mutations.test.tsx @@ -25,7 +25,7 @@ vi.mock('../../../../src/webview/components/graph/rendering/surface/view/twoDime })); vi.mock('../../../../src/webview/components/graph/rendering/surface/view/threeDimensional', () => ({ - Surface3d: (props: Record) => { + DeferredSurface3d: (props: Record) => { harness.surface3d(props); return
; }, @@ -73,6 +73,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function renderViewport(overrides: Partial> = {}) { const handleMenuAction = vi.fn(); const handleContextMenu = vi.fn(); @@ -116,7 +125,7 @@ function renderViewport(overrides: Partial getLinkParticles: vi.fn(), getLinkWidth: vi.fn(), getParticleColor: vi.fn(), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 2, particleSpeed: 0.1, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/viewport/view.test.tsx b/packages/extension/tests/webview/graph/viewport/view.test.tsx index d89f89b26..c1c0c736a 100644 --- a/packages/extension/tests/webview/graph/viewport/view.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/view.test.tsx @@ -117,6 +117,15 @@ function createSharedProps(): GraphSurfaceSharedProps { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function createGraphNode(id: string): FGNode { return { id, @@ -173,7 +182,7 @@ function createSurface3dProps( getLinkParticles: vi.fn(), getLinkWidth: vi.fn(), getParticleColor: vi.fn(), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 2, particleSpeed: 0.1, sharedProps, @@ -232,10 +241,10 @@ describe('Viewport', () => { renderViewport({ graphMode: '3d', onSurface3dError }); await waitFor(() => { - expect(screen.getByTestId('surface-2d')).toBeInTheDocument(); + expect(onSurface3dError).toHaveBeenCalledWith(expect.any(Error)); }); + expect(screen.getByTestId('surface-2d')).toBeInTheDocument(); expect(screen.queryByTestId('surface-3d')).not.toBeInTheDocument(); - expect(onSurface3dError).toHaveBeenCalledWith(expect.any(Error)); harness.throwSurface3d = false; consoleError.mockRestore(); @@ -261,6 +270,62 @@ describe('Viewport', () => { ); }); + it('does not rerender the 2d graph surface when only viewport overlays change', () => { + const surface2dProps = createSurface2dProps(); + const surface3dProps = createSurface3dProps(surface2dProps.sharedProps); + const { rerender } = render( + , + ); + + expect(harness.surface2d).toHaveBeenCalledTimes(1); + + rerender( + , + ); + + expect(harness.surface2d).toHaveBeenCalledTimes(1); + expect(harness.nodeTooltip).toHaveBeenCalledWith(expect.objectContaining({ + path: 'src/next.ts', + visible: true, + })); + }); + it('renders the 3d graph surface when graphMode is 3d', async () => { renderViewport({ graphMode: '3d' }); diff --git a/packages/extension/tests/webview/main.test.tsx b/packages/extension/tests/webview/main.test.tsx index e8b6828f8..32a68d42a 100644 --- a/packages/extension/tests/webview/main.test.tsx +++ b/packages/extension/tests/webview/main.test.tsx @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; const mocks = vi.hoisted(() => { const render = vi.fn(); @@ -93,4 +95,22 @@ describe('main', () => { expect(mocks.render).not.toHaveBeenCalled(); expect((window as unknown as { vscode: unknown }).vscode).toBe(mocks.vscodeApi); }); + + it('does not load the Three.js runtime from the webview entrypoint', () => { + const source = fs.readFileSync( + path.resolve(__dirname, '../../src/webview/main.tsx'), + 'utf8', + ); + + expect(source).not.toContain("import './three/runtime'"); + }); + + it('does not load 3d node rendering from shared graph callbacks', () => { + const source = fs.readFileSync( + path.resolve(__dirname, '../../src/webview/components/graph/rendering/useGraphCallbacks.ts'), + 'utf8', + ); + + expect(source).not.toContain('nodes/canvas3d'); + }); }); diff --git a/packages/extension/tests/webview/search/filteredGraph/cacheKeys.test.ts b/packages/extension/tests/webview/search/filteredGraph/cacheKeys.test.ts new file mode 100644 index 000000000..b1503168b --- /dev/null +++ b/packages/extension/tests/webview/search/filteredGraph/cacheKeys.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import type { + IGraphEdgeTypeDefinition, + IGraphNodeTypeDefinition, +} from '../../../../src/shared/graphControls/contracts'; +import type { IGroup } from '../../../../src/shared/settings/groups'; +import { + createLegendGraphCacheKey, + createStyledGraphCacheKey, + createVisibleGraphCacheKey, +} from '../../../../src/webview/search/filteredGraph/cacheKeys'; + +describe('webview/search/filteredGraph/cacheKeys', () => { + it('builds styled graph keys from edge colors and sorted node colors', () => { + const key = createStyledGraphCacheKey({ + edgeTypes: [ + createEdgeType('import', { defaultColor: '#93c5fd' }), + createEdgeType('call', { defaultColor: '#fca5a5' }), + ], + nodeColors: { + symbol: '#fde047', + file: '#86efac', + }, + }); + + expect(JSON.parse(key)).toEqual({ + edgeTypes: [ + ['import', '#93c5fd'], + ['call', '#fca5a5'], + ], + nodeColors: [ + ['file', '#86efac'], + ['symbol', '#fde047'], + ], + }); + }); + + it('keeps styled graph keys stable when node color insertion order changes', () => { + const edgeTypes = [createEdgeType('import', { defaultColor: '#93c5fd' })]; + + const firstKey = createStyledGraphCacheKey({ + edgeTypes, + nodeColors: { + symbol: '#fde047', + file: '#86efac', + }, + }); + const secondKey = createStyledGraphCacheKey({ + edgeTypes, + nodeColors: { + file: '#86efac', + symbol: '#fde047', + }, + }); + + expect(firstKey).toBe(secondKey); + }); + + it('builds visible graph keys from filters, search, sorted visibility, and type defaults', () => { + const key = createVisibleGraphCacheKey({ + edgeTypes: [ + createEdgeType('import', { defaultVisible: true }), + createEdgeType('call', { defaultVisible: false }), + ], + edgeVisibility: { + call: false, + import: true, + }, + filterPatterns: ['dist/**'], + nodeTypes: [ + createNodeType('file', { defaultVisible: true }), + createNodeType('symbol', { defaultVisible: false }), + ], + nodeVisibility: { + symbol: false, + file: true, + }, + searchOptions: { + matchCase: true, + regex: false, + wholeWord: true, + }, + searchQuery: 'GraphView', + showOrphans: false, + }); + + expect(JSON.parse(key)).toEqual({ + edgeTypes: [ + ['import', true], + ['call', false], + ], + edgeVisibility: [ + ['call', false], + ['import', true], + ], + filterPatterns: ['dist/**'], + nodeTypes: [ + ['file', true], + ['symbol', false], + ], + nodeVisibility: [ + ['file', true], + ['symbol', false], + ], + searchOptions: { + matchCase: true, + regex: false, + wholeWord: true, + }, + searchQuery: 'GraphView', + showOrphans: false, + }); + }); + + it('keeps visible graph keys stable when visibility insertion order changes', () => { + const options = { + edgeTypes: [createEdgeType('import', { defaultVisible: true })], + filterPatterns: ['dist/**'], + nodeTypes: [createNodeType('file', { defaultVisible: true })], + searchOptions: { + matchCase: false, + regex: false, + wholeWord: false, + }, + searchQuery: '', + showOrphans: true, + }; + + const firstKey = createVisibleGraphCacheKey({ + ...options, + edgeVisibility: { + type: true, + import: false, + }, + nodeVisibility: { + symbol: false, + file: true, + }, + }); + const secondKey = createVisibleGraphCacheKey({ + ...options, + edgeVisibility: { + import: false, + type: true, + }, + nodeVisibility: { + file: true, + symbol: false, + }, + }); + + expect(firstKey).toBe(secondKey); + }); + + it('serializes legend rules for legend graph keys', () => { + const legends: IGroup[] = [ + { + id: 'highlight-tests', + pattern: '**/*.test.ts', + color: '#f9a8d4', + target: 'node', + }, + ]; + + expect(JSON.parse(createLegendGraphCacheKey(legends))).toEqual(legends); + }); +}); + +function createEdgeType( + id: IGraphEdgeTypeDefinition['id'], + overrides: Partial = {}, +): IGraphEdgeTypeDefinition { + return { + id, + label: id, + defaultColor: '#94a3b8', + defaultVisible: true, + ...overrides, + }; +} + +function createNodeType( + id: IGraphNodeTypeDefinition['id'], + overrides: Partial = {}, +): IGraphNodeTypeDefinition { + return { + id, + label: id, + defaultColor: '#94a3b8', + defaultVisible: true, + ...overrides, + }; +} diff --git a/packages/extension/tests/webview/search/filteredGraph/coloredResult.test.ts b/packages/extension/tests/webview/search/filteredGraph/coloredResult.test.ts new file mode 100644 index 000000000..090daad3b --- /dev/null +++ b/packages/extension/tests/webview/search/filteredGraph/coloredResult.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData } from '../../../../src/shared/graph/contracts'; +import type { IGroup } from '../../../../src/shared/settings/groups'; +import { getColoredGraphResult } from '../../../../src/webview/search/filteredGraph/coloredResult'; +import { createReferenceResultCache } from '../../../../src/webview/search/filteredGraph/referenceCache'; + +describe('webview/search/filteredGraph/coloredResult', () => { + it('returns null when there is no filtered graph data', () => { + const result = getColoredGraphResult({ + cache: createReferenceResultCache(), + filteredData: null, + key: 'legends', + legends: [], + }); + + expect(result).toBeNull(); + }); + + it('applies legend rules and caches the colored result by graph reference and key', () => { + const cache = createReferenceResultCache(); + const filteredData = createGraphData(); + const legends: IGroup[] = [ + { + id: 'source-files', + pattern: 'src/**', + color: '#f9a8d4', + }, + ]; + + const firstResult = getColoredGraphResult({ + cache, + filteredData, + key: 'source-files', + legends, + }); + const secondResult = getColoredGraphResult({ + cache, + filteredData, + key: 'source-files', + legends, + }); + + expect(firstResult).not.toBe(filteredData); + expect(firstResult?.nodes[0]?.color).toBe('#f9a8d4'); + expect(secondResult).toBe(firstResult); + }); + + it('keeps cached colored results isolated by key', () => { + const cache = createReferenceResultCache(); + const filteredData = createGraphData(); + const firstLegend = createLegend('source-files', '#f9a8d4'); + const secondLegend = createLegend('all-files', '#67e8f9'); + + const firstResult = getColoredGraphResult({ + cache, + filteredData, + key: 'source-files', + legends: [firstLegend], + }); + const secondResult = getColoredGraphResult({ + cache, + filteredData, + key: 'all-files', + legends: [secondLegend], + }); + + expect(firstResult?.nodes[0]?.color).toBe('#f9a8d4'); + expect(secondResult?.nodes[0]?.color).toBe('#67e8f9'); + expect(secondResult).not.toBe(firstResult); + }); +}); + +function createGraphData(): IGraphData { + return { + nodes: [ + { + id: 'src/app.ts', + label: 'app.ts', + color: '#94a3b8', + }, + ], + edges: [], + }; +} + +function createLegend(id: string, color: string): IGroup { + return { + id, + pattern: 'src/**', + color, + }; +} diff --git a/packages/extension/tests/webview/search/filteredGraph/referenceCache.test.ts b/packages/extension/tests/webview/search/filteredGraph/referenceCache.test.ts new file mode 100644 index 000000000..e774ee8d4 --- /dev/null +++ b/packages/extension/tests/webview/search/filteredGraph/referenceCache.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { + cacheReferenceResult, + createReferenceResultCache, + getReferenceResult, +} from '../../../../src/webview/search/filteredGraph/referenceCache'; + +describe('webview/search/filteredGraph/referenceCache', () => { + it('returns cached results for the same reference and key', () => { + const cache = createReferenceResultCache(); + const reference = {}; + + cacheReferenceResult(cache, reference, 'visible', 'cached graph'); + + expect(getReferenceResult(cache, reference, 'visible')).toBe('cached graph'); + }); + + it('keeps results isolated by reference object', () => { + const cache = createReferenceResultCache(); + const firstReference = {}; + const secondReference = {}; + + cacheReferenceResult(cache, firstReference, 'visible', 'first graph'); + cacheReferenceResult(cache, secondReference, 'visible', 'second graph'); + + expect(getReferenceResult(cache, firstReference, 'visible')).toBe('first graph'); + expect(getReferenceResult(cache, secondReference, 'visible')).toBe('second graph'); + }); + + it('assigns increasing ids to new reference objects', () => { + const cache = createReferenceResultCache(); + + cacheReferenceResult(cache, {}, 'visible', 'first graph'); + cacheReferenceResult(cache, {}, 'visible', 'second graph'); + + expect(cache.nextReferenceId).toBe(3); + }); + + it('keeps results isolated by key for the same reference', () => { + const cache = createReferenceResultCache(); + const reference = {}; + + cacheReferenceResult(cache, reference, 'visible', 'visible graph'); + cacheReferenceResult(cache, reference, 'styled', 'styled graph'); + + expect(getReferenceResult(cache, reference, 'visible')).toBe('visible graph'); + expect(getReferenceResult(cache, reference, 'styled')).toBe('styled graph'); + }); + + it('replaces an existing result and moves it to the newest cache slot', () => { + const cache = createReferenceResultCache(); + const reference = {}; + + cacheReferenceResult(cache, reference, 'first', 'stale first'); + cacheReferenceResult(cache, reference, 'second', 'second'); + cacheReferenceResult(cache, reference, 'third', 'third'); + cacheReferenceResult(cache, reference, 'fourth', 'fourth'); + cacheReferenceResult(cache, reference, 'fifth', 'fifth'); + cacheReferenceResult(cache, reference, 'sixth', 'sixth'); + cacheReferenceResult(cache, reference, 'first', 'fresh first'); + cacheReferenceResult(cache, reference, 'seventh', 'seventh'); + + expect(getReferenceResult(cache, reference, 'first')).toBe('fresh first'); + expect(getReferenceResult(cache, reference, 'second')).toBeUndefined(); + expect(getReferenceResult(cache, reference, 'seventh')).toBe('seventh'); + }); + + it('evicts the oldest entries after six cached results', () => { + const cache = createReferenceResultCache(); + const reference = {}; + + for (let index = 1; index <= 7; index += 1) { + cacheReferenceResult(cache, reference, `key-${index}`, `result-${index}`); + } + + expect(getReferenceResult(cache, reference, 'key-1')).toBeUndefined(); + expect(getReferenceResult(cache, reference, 'key-2')).toBe('result-2'); + expect(getReferenceResult(cache, reference, 'key-7')).toBe('result-7'); + }); +}); diff --git a/packages/extension/tests/webview/search/filteredGraph/styledResult.test.ts b/packages/extension/tests/webview/search/filteredGraph/styledResult.test.ts new file mode 100644 index 000000000..fa088fb9e --- /dev/null +++ b/packages/extension/tests/webview/search/filteredGraph/styledResult.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData } from '../../../../src/shared/graph/contracts'; +import type { IGraphEdgeTypeDefinition } from '../../../../src/shared/graphControls/contracts'; +import { createReferenceResultCache } from '../../../../src/webview/search/filteredGraph/referenceCache'; +import { getStyledGraphResult } from '../../../../src/webview/search/filteredGraph/styledResult'; + +describe('webview/search/filteredGraph/styledResult', () => { + it('returns null when there is no visible graph data', () => { + const result = getStyledGraphResult({ + cache: createReferenceResultCache(), + edgeTypes: [], + graph: null, + key: 'styles', + nodeColors: {}, + }); + + expect(result).toBeNull(); + }); + + it('applies node and edge colors and caches the styled result by graph reference and key', () => { + const cache = createReferenceResultCache(); + const graph = createGraphData(); + const edgeTypes = [createEdgeType('import', '#38bdf8')]; + + const firstResult = getStyledGraphResult({ + cache, + edgeTypes, + graph, + key: 'blue-files', + nodeColors: { + file: '#a7f3d0', + }, + }); + const secondResult = getStyledGraphResult({ + cache, + edgeTypes, + graph, + key: 'blue-files', + nodeColors: { + file: '#a7f3d0', + }, + }); + + expect(firstResult).not.toBe(graph); + expect(firstResult?.nodes[0]).toMatchObject({ + color: '#a7f3d0', + nodeType: 'file', + }); + expect(firstResult?.edges[0]?.color).toBe('#38bdf8'); + expect(secondResult).toBe(firstResult); + }); + + it('keeps cached styled results isolated by key', () => { + const cache = createReferenceResultCache(); + const graph = createGraphData(); + + const firstResult = getStyledGraphResult({ + cache, + edgeTypes: [createEdgeType('import', '#38bdf8')], + graph, + key: 'blue-imports', + nodeColors: { + file: '#a7f3d0', + }, + }); + const secondResult = getStyledGraphResult({ + cache, + edgeTypes: [createEdgeType('import', '#fca5a5')], + graph, + key: 'red-imports', + nodeColors: { + file: '#fde047', + }, + }); + + expect(firstResult?.nodes[0]?.color).toBe('#a7f3d0'); + expect(firstResult?.edges[0]?.color).toBe('#38bdf8'); + expect(secondResult?.nodes[0]?.color).toBe('#fde047'); + expect(secondResult?.edges[0]?.color).toBe('#fca5a5'); + expect(secondResult).not.toBe(firstResult); + }); +}); + +function createGraphData(): IGraphData { + return { + nodes: [ + { + id: 'src/app.ts', + label: 'app.ts', + color: '#94a3b8', + }, + { + id: 'src/util.ts', + label: 'util.ts', + color: '#94a3b8', + }, + ], + edges: [ + { + id: 'src/app.ts->src/util.ts#import', + from: 'src/app.ts', + to: 'src/util.ts', + kind: 'import', + sources: [], + }, + ], + }; +} + +function createEdgeType( + id: IGraphEdgeTypeDefinition['id'], + defaultColor: string, +): IGraphEdgeTypeDefinition { + return { + id, + label: id, + defaultColor, + defaultVisible: true, + }; +} diff --git a/packages/extension/tests/webview/search/filteredGraph/visibleCache.test.ts b/packages/extension/tests/webview/search/filteredGraph/visibleCache.test.ts new file mode 100644 index 000000000..e4da92a78 --- /dev/null +++ b/packages/extension/tests/webview/search/filteredGraph/visibleCache.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import type { VisibleGraphResult } from '../../../../src/shared/visibleGraph/contracts'; +import { + cacheVisibleGraphResult, + createVisibleGraphCache, +} from '../../../../src/webview/search/filteredGraph/visibleCache'; + +describe('webview/search/filteredGraph/visibleCache', () => { + it('creates an empty cache ready for visible graph results', () => { + const cache = createVisibleGraphCache(); + + expect(cache.entries).toBeInstanceOf(Map); + expect(cache.entries.size).toBe(0); + expect(cache.graphData).toBeUndefined(); + }); + + it('stores cached results by key', () => { + const cache = createVisibleGraphCache(); + const cachedResult = createVisibleResult(); + + cacheVisibleGraphResult(cache, 'visible', cachedResult); + + expect(cache.entries.get('visible')).toBe(cachedResult); + }); + + it('replaces an existing result and moves it to the newest cache slot', () => { + const cache = createVisibleGraphCache(); + const staleFirstResult = createVisibleResult(); + const freshFirstResult = createVisibleResult(); + const seventhResult = createVisibleResult(); + + cacheVisibleGraphResult(cache, 'first', staleFirstResult); + cacheVisibleGraphResult(cache, 'second', createVisibleResult()); + cacheVisibleGraphResult(cache, 'third', createVisibleResult()); + cacheVisibleGraphResult(cache, 'fourth', createVisibleResult()); + cacheVisibleGraphResult(cache, 'fifth', createVisibleResult()); + cacheVisibleGraphResult(cache, 'sixth', createVisibleResult()); + cacheVisibleGraphResult(cache, 'first', freshFirstResult); + cacheVisibleGraphResult(cache, 'seventh', seventhResult); + + expect(cache.entries.get('first')).toBe(freshFirstResult); + expect(cache.entries.has('second')).toBe(false); + expect(cache.entries.get('seventh')).toBe(seventhResult); + }); + + it('evicts the oldest entries after six cached results', () => { + const cache = createVisibleGraphCache(); + const seventhResult = createVisibleResult(); + + for (let index = 1; index <= 6; index += 1) { + cacheVisibleGraphResult(cache, `key-${index}`, createVisibleResult()); + } + + cacheVisibleGraphResult(cache, 'key-7', seventhResult); + + expect(cache.entries.size).toBe(6); + expect(cache.entries.has('key-1')).toBe(false); + expect(cache.entries.has('key-2')).toBe(true); + expect(cache.entries.get('key-7')).toBe(seventhResult); + }); +}); + +function createVisibleResult(): VisibleGraphResult { + return { + graphData: { + nodes: [], + edges: [], + }, + regexError: null, + }; +} diff --git a/packages/extension/tests/webview/search/filteredGraph/visibleResult.test.ts b/packages/extension/tests/webview/search/filteredGraph/visibleResult.test.ts new file mode 100644 index 000000000..982a9b872 --- /dev/null +++ b/packages/extension/tests/webview/search/filteredGraph/visibleResult.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import type { SearchOptions } from '../../../../src/webview/components/searchBar/field/model'; +import type { IGraphData } from '../../../../src/shared/graph/contracts'; +import { createVisibleGraphCache } from '../../../../src/webview/search/filteredGraph/visibleCache'; +import { getVisibleGraphResult } from '../../../../src/webview/search/filteredGraph/visibleResult'; + +describe('webview/search/filteredGraph/visibleResult', () => { + it('returns and caches an empty visible result when there is no graph data', () => { + const cache = createVisibleGraphCache(); + + const result = getVisibleGraphResult({ + ...createVisibleGraphOptions(), + cache, + graphData: null, + key: 'empty', + }); + + expect(result).toEqual({ + graphData: null, + regexError: null, + }); + expect(cache.graphData).toBeNull(); + expect(cache.entries.get('empty')).toBe(result); + }); + + it('derives visible graph data from search and filter settings, then reuses the cached result', () => { + const cache = createVisibleGraphCache(); + const graphData = createGraphData(['src/app.ts', 'src/util.ts', 'src/hidden.ts']); + + const firstResult = getVisibleGraphResult({ + ...createVisibleGraphOptions(), + cache, + filterPatterns: ['src/hidden.ts'], + graphData, + key: 'app-only', + searchQuery: 'app', + }); + const secondResult = getVisibleGraphResult({ + ...createVisibleGraphOptions(), + cache, + filterPatterns: ['src/hidden.ts'], + graphData, + key: 'app-only', + searchQuery: 'app', + }); + + expect(cache.graphData).toBe(graphData); + expect(firstResult.graphData?.nodes.map((node) => node.id)).toEqual(['src/app.ts']); + expect(firstResult.graphData?.edges).toEqual([]); + expect(secondResult).toBe(firstResult); + }); + + it('clears cached visible results when the graph data reference changes', () => { + const cache = createVisibleGraphCache(); + const firstGraph = createGraphData(['src/app.ts']); + const secondGraph = createGraphData(['src/component.ts']); + + getVisibleGraphResult({ + ...createVisibleGraphOptions(), + cache, + graphData: firstGraph, + key: 'same-key', + }); + const secondResult = getVisibleGraphResult({ + ...createVisibleGraphOptions(), + cache, + graphData: secondGraph, + key: 'same-key', + }); + + expect(cache.graphData).toBe(secondGraph); + expect(secondResult.graphData?.nodes.map((node) => node.id)).toEqual(['src/component.ts']); + }); +}); + +function createVisibleGraphOptions(): { + edgeTypes: []; + edgeVisibility: Record; + filterPatterns: readonly string[]; + nodeTypes: []; + nodeVisibility: Record; + searchOptions: SearchOptions; + searchQuery: string; + showOrphans: boolean; +} { + return { + edgeTypes: [], + edgeVisibility: {}, + filterPatterns: [], + nodeTypes: [], + nodeVisibility: {}, + searchOptions: { + matchCase: false, + regex: false, + wholeWord: false, + }, + searchQuery: '', + showOrphans: true, + }; +} + +function createGraphData(nodeIds: string[]): IGraphData { + return { + nodes: nodeIds.map((id) => ({ + id, + label: id.split('/').at(-1) ?? id, + color: '#94a3b8', + })), + edges: nodeIds.length > 1 + ? [ + { + id: `${nodeIds[0]}->${nodeIds[1]}#import`, + from: nodeIds[0] ?? '', + to: nodeIds[1] ?? '', + kind: 'import', + sources: [], + }, + ] + : [], + }; +} diff --git a/packages/extension/tests/webview/search/filtering/rules.test.ts b/packages/extension/tests/webview/search/filtering/rules.test.ts index e72302b6f..ce01d3245 100644 --- a/packages/extension/tests/webview/search/filtering/rules.test.ts +++ b/packages/extension/tests/webview/search/filtering/rules.test.ts @@ -78,4 +78,15 @@ describe('search/filtering/rules', () => { expect(result?.edges[1]?.color).toBe('#ff8800'); expect(result?.nodes[0]?.color).toBe('#111111'); }); + + it('reuses edge rows when no active legend rule targets edges', () => { + const groups: IGroup[] = [ + { id: 'typescript', pattern: '*.ts', color: '#ff0000' }, + { id: 'disabled-edge', pattern: 'import', color: '#ff8800', target: 'edge', disabled: true }, + ]; + + const result = applyLegendRules(graphData, groups); + + expect(result?.edges).toBe(graphData.edges); + }); }); diff --git a/packages/extension/tests/webview/search/filtering/rules/nodes.test.ts b/packages/extension/tests/webview/search/filtering/rules/nodes.test.ts index 6ca69e2aa..19bc1eb5a 100644 --- a/packages/extension/tests/webview/search/filtering/rules/nodes.test.ts +++ b/packages/extension/tests/webview/search/filtering/rules/nodes.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_NODE_COLOR } from '../../../../../src/shared/fileColors'; -import { applyNodeLegendRules, getOrderedActiveRules } from '../../../../../src/webview/search/filtering/rules/nodes'; +import { + applyNodeLegendRules, + compileNodeLegendRules, + getOrderedActiveRules, +} from '../../../../../src/webview/search/filtering/rules/nodes'; import { buildGraphViewMergedGroups } from '../../../../../src/extension/graphView/groups/merged'; describe('search/filtering/rules/nodes', () => { @@ -15,6 +19,23 @@ describe('search/filtering/rules/nodes', () => { expect(legends.map((rule) => rule.id)).toEqual(['first', 'disabled', 'last']); }); + it('precomputes whether compiled rules have scoped constraints', () => { + const compiledRules = compileNodeLegendRules([ + { id: 'plain', pattern: 'src/**', color: '#111111' }, + { + id: 'scoped', + pattern: '**', + color: '#222222', + matchNodeType: 'symbol', + }, + ]); + + expect(compiledRules.map((rule) => [rule.rule.id, rule.hasConstraints])).toEqual([ + ['plain', false], + ['scoped', true], + ]); + }); + it('drops disabled rules and applies active rules from bottom to top', () => { const activeRules = getOrderedActiveRules([ { id: 'specific', pattern: 'src/App.ts', color: '#00ff00', imageUrl: 'icon.png' }, @@ -159,6 +180,32 @@ describe('search/filtering/rules/nodes', () => { }); }); + it('matches path-based custom rules against symbol containing file paths', () => { + const activeRules = getOrderedActiveRules([ + { id: 'core', pattern: 'packages/core/**', color: '#00ff00' }, + ]); + + expect( + applyNodeLegendRules( + { + id: 'symbol:function:parseGraph', + label: 'parseGraph', + color: '#111111', + nodeType: 'symbol', + symbol: { + id: 'symbol:function:parseGraph', + name: 'parseGraph', + kind: 'function', + filePath: 'packages/core/src/graph/parser.ts', + }, + }, + activeRules, + ), + ).toMatchObject({ + color: '#00ff00', + }); + }); + it('applies scoped symbol rules by kind, plugin kind, source, language, and containing file', () => { const activeRules = getOrderedActiveRules([ { diff --git a/packages/extension/tests/webview/search/useFilteredGraph.mutations.test.ts b/packages/extension/tests/webview/search/useFilteredGraph.mutations.test.ts index 7466a6780..314c80192 100644 --- a/packages/extension/tests/webview/search/useFilteredGraph.mutations.test.ts +++ b/packages/extension/tests/webview/search/useFilteredGraph.mutations.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { renderHook } from '@testing-library/react'; import type { IGraphData } from '../../../src/shared/graph/contracts'; +import type { IGraphEdgeTypeDefinition } from '../../../src/shared/graphControls/contracts'; import type { IGroup } from '../../../src/shared/settings/groups'; const deriveVisibleGraphMock = vi.hoisted(() => vi.fn()); @@ -132,4 +133,56 @@ describe('useFilteredGraph dependency array mutations', () => { expect.objectContaining({ showOrphans: false }), ); }); + + it('reuses derived visible graphs when graph scope returns to a cached config', () => { + const edgeTypes: IGraphEdgeTypeDefinition[] = [ + { id: 'import', label: 'Imports', defaultColor: '#60a5fa', defaultVisible: true }, + ]; + const { rerender } = renderHook( + ({ edgeVisibility }) => + useFilteredGraph(graphA, '', defaultOptions, [], {}, {}, edgeVisibility, edgeTypes), + { initialProps: { edgeVisibility: {} as Record } }, + ); + + expect(deriveVisibleGraphMock).toHaveBeenCalledTimes(1); + + rerender({ edgeVisibility: { import: false } }); + + expect(deriveVisibleGraphMock).toHaveBeenCalledTimes(2); + + rerender({ edgeVisibility: {} }); + + expect(deriveVisibleGraphMock).toHaveBeenCalledTimes(2); + }); + + it('reuses colored graph data when graph scope returns to a cached visible graph', () => { + const edgeTypes: IGraphEdgeTypeDefinition[] = [ + { id: 'import', label: 'Imports', defaultColor: '#60a5fa', defaultVisible: true }, + ]; + const groups: IGroup[] = [{ id: 'source', pattern: 'src/**', color: '#ff0000' }]; + deriveVisibleGraphMock.mockImplementation(( + _graphData: IGraphData | null, + config: { scope?: { edges?: { type: string; enabled: boolean }[] } }, + ) => { + const importEnabled = config.scope?.edges?.find(edge => edge.type === 'import')?.enabled !== false; + return { + graphData: importEnabled ? graphA : graphB, + regexError: null, + }; + }); + const { result, rerender } = renderHook( + ({ edgeVisibility }) => + useFilteredGraph(graphA, '', defaultOptions, groups, {}, {}, edgeVisibility, edgeTypes), + { initialProps: { edgeVisibility: {} as Record } }, + ); + const initialColoredData = result.current.coloredData; + + rerender({ edgeVisibility: { import: false } }); + + expect(result.current.coloredData).not.toBe(initialColoredData); + + rerender({ edgeVisibility: {} }); + + expect(result.current.coloredData).toBe(initialColoredData); + }); }); diff --git a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts index d312c5c35..b33d9c360 100644 --- a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts +++ b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts @@ -12,6 +12,7 @@ import { handleGraphDataUpdated, handleGraphIndexProgress, handleGraphIndexStatusUpdated, + handleGraphNodeMetricsUpdated, handleLegendsUpdated, handleMaxFilesUpdated, handlePhysicsSettingsUpdated, @@ -19,78 +20,8 @@ import { handleShowLabelsUpdated, handleVerboseDiagnosticsUpdated, } from '../../../../src/webview/store/messageHandlers/graph'; -import type { IStoreFields } from '../../../../src/webview/store/messageTypes'; import type { IGraphControlsSnapshot } from '../../../../src/shared/graphControls/contracts'; - -function createState( - overrides: Partial = {}, -): IStoreFields { - return { - graphData: null, - graphHasIndex: false, - graphIndexFreshness: 'missing', - graphIndexDetail: null, - graphIsIndexing: false, - graphIndexProgress: null, - isLoading: true, - awaitingInitialBootstrap: false, - bootstrapComplete: false, - pendingPluginAssetLoads: 0, - searchQuery: '', - searchOptions: { matchCase: false, wholeWord: false, regex: false }, - favorites: new Set(), - pendingFavoriteSnapshot: null, - bidirectionalMode: 'separate', - showOrphans: true, - directionMode: 'none', - directionColor: '#ffffff', - particleSpeed: 0, - particleSize: 1, - physicsPaused: false, - showLabels: true, - cssSnippets: {}, - graphMode: '2d', - graphViewportScale: null, - nodeSizeMode: 'uniform', - physicsSettings: { repelForce: 10, linkDistance: 80, linkForce: 0.15, damping: 0.7, centerForce: 0.1 }, - depthMode: false, - depthLimit: 2, - maxDepthLimit: 10, - legends: [], - optimisticLegendUpdates: {}, - optimisticUserLegends: null, - filterPatterns: [], - pluginFilterPatterns: [], - pluginFilterGroups: [], - disabledCustomFilterPatterns: [], - disabledPluginFilterPatterns: [], - dagMode: null, - pluginStatuses: [], - graphNodeTypes: [], - graphEdgeTypes: [], - nodeColors: {}, - nodeVisibility: {}, - edgeVisibility: {}, - nodeDecorations: {}, - edgeDecorations: {}, - pluginContextMenuItems: [], - pluginExporters: [], - pluginToolbarActions: [], - graphViewContributionStatuses: [], - activePanel: 'none', - maxFiles: 500, - verboseDiagnostics: false, - activeFilePath: null, - timelineActive: false, - timelineCommits: [], - currentCommitSha: null, - isIndexing: false, - indexProgress: null, - isPlaying: false, - playbackSpeed: 1, - ...overrides, - }; -} +import { createState } from './graph/fixture'; describe('webview/store/messageHandlers/graph', () => { it('maps graph payload updates into loading and indexing state', () => { @@ -104,6 +35,115 @@ describe('webview/store/messageHandlers/graph', () => { }); }); + it('applies node metric patches to the current graph data', () => { + const graphData = { + nodes: [ + { id: 'src/app.ts', label: 'App', color: '#fff', fileSize: 100, churn: 1 }, + { id: 'src/lib.ts', label: 'Lib', color: '#fff', fileSize: 50, churn: 3 }, + ], + edges: [{ id: 'src/app.ts->src/lib.ts', from: 'src/app.ts', to: 'src/lib.ts', kind: 'import' as const, sources: [] }], + }; + const state = createState({ + graphData, + graphIsIndexing: true, + graphIndexProgress: { phase: 'Updating Graph View', current: 0, total: 1 }, + isLoading: false, + nodeSizeMode: 'file-size', + }); + + expect(handleGraphNodeMetricsUpdated( + { + type: 'GRAPH_NODE_METRICS_UPDATED', + payload: { + nodes: [{ id: 'src/app.ts', fileSize: 120, churn: 2 }], + }, + }, + { getState: () => state }, + )).toEqual({ + graphData: { + nodes: [ + { id: 'src/app.ts', label: 'App', color: '#fff', fileSize: 120, churn: 2 }, + graphData.nodes[1], + ], + edges: graphData.edges, + }, + isLoading: false, + graphIsIndexing: false, + graphIndexProgress: null, + }); + }); + + it('keeps the graph data reference stable when metric patches do not affect node sizing', () => { + const graphData = { + nodes: [ + { id: 'src/app.ts', label: 'App', color: '#fff', fileSize: 100, churn: 1 }, + { id: 'src/lib.ts', label: 'Lib', color: '#fff', fileSize: 50, churn: 3 }, + ], + edges: [{ id: 'src/app.ts->src/lib.ts', from: 'src/app.ts', to: 'src/lib.ts', kind: 'import' as const, sources: [] }], + }; + const state = createState({ + graphData, + graphIsIndexing: true, + graphIndexProgress: { phase: 'Updating Graph View', current: 0, total: 1 }, + isLoading: false, + nodeSizeMode: 'connections', + }); + + expect(handleGraphNodeMetricsUpdated( + { + type: 'GRAPH_NODE_METRICS_UPDATED', + payload: { + nodes: [{ id: 'src/app.ts', fileSize: 120, churn: 2 }], + }, + }, + { getState: () => state }, + )).toEqual({ + isLoading: false, + graphIsIndexing: false, + graphIndexProgress: null, + }); + + expect(state.graphData).toBe(graphData); + expect(graphData.nodes[0]).toMatchObject({ fileSize: 120, churn: 2 }); + }); + + it('skips duplicate graph payloads after bootstrap has settled', () => { + const payload = { + nodes: [{ id: 'src/app.ts', label: 'App', color: '#fff' }], + edges: [{ id: 'src/app.ts->src/lib.ts', from: 'src/app.ts', to: 'src/lib.ts', kind: 'import' as const, sources: [] }], + }; + const state = createState({ + bootstrapComplete: true, + graphData: JSON.parse(JSON.stringify(payload)), + graphIsIndexing: false, + isLoading: false, + }); + + expect(handleGraphDataUpdated( + { type: 'GRAPH_DATA_UPDATED', payload }, + { getState: () => state }, + )).toBeUndefined(); + }); + + it('skips duplicate graph payloads while waiting for initial bootstrap completion', () => { + const payload = { + nodes: [{ id: 'src/app.ts', label: 'App', color: '#fff' }], + edges: [{ id: 'src/app.ts->src/lib.ts', from: 'src/app.ts', to: 'src/lib.ts', kind: 'import' as const, sources: [] }], + }; + const state = createState({ + awaitingInitialBootstrap: true, + bootstrapComplete: false, + graphData: JSON.parse(JSON.stringify(payload)), + graphIsIndexing: false, + isLoading: true, + }); + + expect(handleGraphDataUpdated( + { type: 'GRAPH_DATA_UPDATED', payload }, + { getState: () => state }, + )).toBeUndefined(); + }); + it('settles initial bootstrap when graph data arrives after bootstrap and plugin assets are ready', () => { const payload = { nodes: [{ id: 'src/app.ts', label: 'App', color: '#fff' }], edges: [] }; const state = createState({ @@ -213,6 +253,65 @@ describe('webview/store/messageHandlers/graph', () => { }); + it('skips graph controls updates when extension echoes the current controls', () => { + const controls: IGraphControlsSnapshot = { + nodeTypes: [{ id: 'file', label: 'File', defaultColor: '#A1A1AA', defaultVisible: true }], + edgeTypes: [{ id: 'import', label: 'Import', defaultColor: '#64748B', defaultVisible: true }], + nodeColors: { file: '#A1A1AA' }, + nodeVisibility: { file: true }, + edgeVisibility: { import: false }, + }; + const state = createState({ + graphNodeTypes: controls.nodeTypes, + graphEdgeTypes: controls.edgeTypes, + nodeColors: controls.nodeColors, + nodeVisibility: controls.nodeVisibility, + edgeVisibility: controls.edgeVisibility, + }); + const echoedControls: IGraphControlsSnapshot = { + nodeTypes: [...controls.nodeTypes], + edgeTypes: [...controls.edgeTypes], + nodeColors: { ...controls.nodeColors }, + nodeVisibility: { ...controls.nodeVisibility }, + edgeVisibility: { ...controls.edgeVisibility }, + }; + + expect(handleGraphControlsUpdated( + { type: 'GRAPH_CONTROLS_UPDATED', payload: echoedControls }, + { getState: () => state }, + )).toBeUndefined(); + }); + + it('returns only changed graph control fields when extension echoes partial changes', () => { + const controls: IGraphControlsSnapshot = { + nodeTypes: [{ id: 'file', label: 'File', defaultColor: '#A1A1AA', defaultVisible: true }], + edgeTypes: [{ id: 'import', label: 'Import', defaultColor: '#64748B', defaultVisible: true }], + nodeColors: { file: '#A1A1AA' }, + nodeVisibility: { file: true }, + edgeVisibility: { import: false }, + }; + const state = createState({ + graphNodeTypes: controls.nodeTypes, + graphEdgeTypes: controls.edgeTypes, + nodeColors: controls.nodeColors, + nodeVisibility: controls.nodeVisibility, + edgeVisibility: controls.edgeVisibility, + }); + const nextEdgeVisibility = { import: true }; + const echoedControls: IGraphControlsSnapshot = { + nodeTypes: [...controls.nodeTypes], + edgeTypes: [...controls.edgeTypes], + nodeColors: { ...controls.nodeColors }, + nodeVisibility: { ...controls.nodeVisibility }, + edgeVisibility: nextEdgeVisibility, + }; + + expect(handleGraphControlsUpdated( + { type: 'GRAPH_CONTROLS_UPDATED', payload: echoedControls }, + { getState: () => state }, + )).toEqual({ edgeVisibility: nextEdgeVisibility }); + }); + it('maps settings and filter payloads', () => { expect(handleSettingsUpdated({ type: 'SETTINGS_UPDATED', diff --git a/packages/extension/tests/webview/store/messageHandlers/graph/fixture.ts b/packages/extension/tests/webview/store/messageHandlers/graph/fixture.ts new file mode 100644 index 000000000..ae472a1e6 --- /dev/null +++ b/packages/extension/tests/webview/store/messageHandlers/graph/fixture.ts @@ -0,0 +1,71 @@ +import type { IStoreFields } from '../../../../../src/webview/store/messageTypes'; + +export function createState( + overrides: Partial = {}, +): IStoreFields { + return { + graphData: null, + graphHasIndex: false, + graphIndexFreshness: 'missing', + graphIndexDetail: null, + graphIsIndexing: false, + graphIndexProgress: null, + isLoading: true, + awaitingInitialBootstrap: false, + bootstrapComplete: false, + pendingPluginAssetLoads: 0, + searchQuery: '', + searchOptions: { matchCase: false, wholeWord: false, regex: false }, + favorites: new Set(), + pendingFavoriteSnapshot: null, + bidirectionalMode: 'separate', + showOrphans: true, + directionMode: 'none', + directionColor: '#ffffff', + particleSpeed: 0, + particleSize: 1, + physicsPaused: false, + showLabels: true, + cssSnippets: {}, + graphMode: '2d', + graphViewportScale: null, + nodeSizeMode: 'uniform', + physicsSettings: { repelForce: 10, linkDistance: 80, linkForce: 0.15, damping: 0.7, centerForce: 0.1 }, + depthMode: false, + depthLimit: 2, + maxDepthLimit: 10, + legends: [], + optimisticLegendUpdates: {}, + optimisticUserLegends: null, + filterPatterns: [], + pluginFilterPatterns: [], + pluginFilterGroups: [], + disabledCustomFilterPatterns: [], + disabledPluginFilterPatterns: [], + dagMode: null, + pluginStatuses: [], + graphNodeTypes: [], + graphEdgeTypes: [], + nodeColors: {}, + nodeVisibility: {}, + edgeVisibility: {}, + nodeDecorations: {}, + edgeDecorations: {}, + pluginContextMenuItems: [], + pluginExporters: [], + pluginToolbarActions: [], + graphViewContributionStatuses: [], + activePanel: 'none', + maxFiles: 500, + verboseDiagnostics: false, + activeFilePath: null, + timelineActive: false, + timelineCommits: [], + currentCommitSha: null, + isIndexing: false, + indexProgress: null, + isPlaying: false, + playbackSpeed: 1, + ...overrides, + }; +} diff --git a/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/bootstrap.test.ts b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/bootstrap.test.ts new file mode 100644 index 000000000..a9bfd322b --- /dev/null +++ b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/bootstrap.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { handleAppBootstrapComplete } from '../../../../../src/webview/store/messageHandlers/graphDataMessage/bootstrap'; +import { createState } from '../graph/fixture'; + +describe('webview/store/messageHandlers/graphDataMessage/bootstrap', () => { + it('settles loading when app bootstrap completes after graph data is ready', () => { + const state = createState({ + awaitingInitialBootstrap: true, + graphData: { + nodes: [{ id: 'src/app.ts', label: 'App', color: '#94a3b8' }], + edges: [], + }, + isLoading: true, + }); + + expect(handleAppBootstrapComplete( + { type: 'APP_BOOTSTRAP_COMPLETE' }, + { getState: () => state }, + )).toEqual({ + bootstrapComplete: true, + awaitingInitialBootstrap: false, + isLoading: false, + }); + }); + + it('preserves loading state when app bootstrap completes before graph data arrives', () => { + const state = createState({ + awaitingInitialBootstrap: true, + graphData: null, + isLoading: true, + }); + + expect(handleAppBootstrapComplete( + { type: 'APP_BOOTSTRAP_COMPLETE' }, + { getState: () => state }, + )).toEqual({ + bootstrapComplete: true, + awaitingInitialBootstrap: true, + isLoading: true, + }); + }); +}); diff --git a/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/duplicate.test.ts b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/duplicate.test.ts new file mode 100644 index 000000000..2da5bcbfa --- /dev/null +++ b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/duplicate.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; +import { shouldSkipDuplicateGraphData } from '../../../../../src/webview/store/messageHandlers/graphDataMessage/duplicate'; +import { createState } from '../graph/fixture'; + +describe('webview/store/messageHandlers/graphDataMessage/duplicate', () => { + it('skips an equal graph payload after bootstrap has settled', () => { + const payload = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: true, + graphData: cloneGraphData(payload), + isLoading: false, + }); + + expect(shouldSkipDuplicateGraphData(state, payload)).toBe(true); + }); + + it('skips an equal graph payload while waiting for initial bootstrap completion', () => { + const payload = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: true, + bootstrapComplete: false, + graphData: cloneGraphData(payload), + isLoading: true, + }); + + expect(shouldSkipDuplicateGraphData(state, payload)).toBe(true); + }); + + it('does not skip when there is no current graph data', () => { + const state = createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: true, + graphData: null, + isLoading: false, + }); + + expect(shouldSkipDuplicateGraphData(state, createGraphData())).toBe(false); + }); + + it('does not skip while graph indexing is active', () => { + const payload = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: true, + graphData: cloneGraphData(payload), + graphIsIndexing: true, + isLoading: false, + }); + + expect(shouldSkipDuplicateGraphData(state, payload)).toBe(false); + }); + + it('does not skip when bootstrap has not settled into a duplicate-safe state', () => { + const payload = createGraphData(); + + expect(shouldSkipDuplicateGraphData( + createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: true, + graphData: cloneGraphData(payload), + isLoading: true, + }), + payload, + )).toBe(false); + expect(shouldSkipDuplicateGraphData( + createState({ + awaitingInitialBootstrap: true, + bootstrapComplete: true, + graphData: cloneGraphData(payload), + isLoading: false, + }), + payload, + )).toBe(false); + expect(shouldSkipDuplicateGraphData( + createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: false, + graphData: cloneGraphData(payload), + isLoading: false, + }), + payload, + )).toBe(false); + }); + + it('does not skip when node counts differ', () => { + const payload = createGraphData(); + const currentGraphData = createGraphData(['src/app.ts', 'src/extra.ts']); + spoofSerializedGraphData(currentGraphData, payload); + + expect(shouldSkipDuplicateGraphData( + createSettledState(currentGraphData), + payload, + )).toBe(false); + }); + + it('does not skip when edge counts differ', () => { + const payload = createGraphData(); + const currentGraphData = createGraphData(['src/app.ts'], true); + spoofSerializedGraphData(currentGraphData, payload); + + expect(shouldSkipDuplicateGraphData( + createSettledState(currentGraphData), + payload, + )).toBe(false); + }); + + it('does not skip when graph payload content differs', () => { + const payload = createGraphData(); + const currentGraphData = createGraphData(['src/other.ts']); + + expect(shouldSkipDuplicateGraphData( + createSettledState(currentGraphData), + payload, + )).toBe(false); + }); + + it('does not skip when graph payload equality cannot be serialized', () => { + const payload = createGraphData(); + const currentGraphData = createGraphData(); + const circularMetadata: Record = {}; + circularMetadata.self = circularMetadata; + currentGraphData.nodes[0]!.metadata = circularMetadata as never; + + expect(shouldSkipDuplicateGraphData( + createSettledState(currentGraphData), + payload, + )).toBe(false); + }); +}); + +function createSettledState(graphData: IGraphData): ReturnType { + return createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: true, + graphData, + isLoading: false, + }); +} + +function createGraphData( + nodeIds: string[] = ['src/app.ts'], + includeEdge = false, +): IGraphData { + return { + nodes: nodeIds.map((id) => ({ + id, + label: id, + color: '#94a3b8', + })), + edges: includeEdge + ? [ + { + id: 'src/app.ts->src/lib.ts#import', + from: 'src/app.ts', + to: 'src/lib.ts', + kind: 'import', + sources: [], + }, + ] + : [], + }; +} + +function cloneGraphData(graphData: IGraphData): IGraphData { + return JSON.parse(JSON.stringify(graphData)) as IGraphData; +} + +function spoofSerializedGraphData(graphData: IGraphData, serializedAs: IGraphData): void { + (graphData as IGraphData & { toJSON: () => IGraphData }).toJSON = () => serializedAs; +} diff --git a/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/metricUpdates.test.ts b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/metricUpdates.test.ts new file mode 100644 index 000000000..074af0866 --- /dev/null +++ b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/metricUpdates.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import type { + GraphNode, + GraphNodeMetricsUpdate, +} from '../../../../../src/webview/store/messageHandlers/graphDataMessage/contracts'; +import { + applyMetricUpdates, + applyMetricUpdatesInPlace, + nodeSizeModeUsesNodeMetrics, +} from '../../../../../src/webview/store/messageHandlers/graphDataMessage/metricUpdates'; + +describe('webview/store/messageHandlers/graphDataMessage/metricUpdates', () => { + it('uses node metrics only for file size and churn node sizing modes', () => { + expect(nodeSizeModeUsesNodeMetrics('file-size')).toBe(true); + expect(nodeSizeModeUsesNodeMetrics('churn')).toBe(true); + expect(nodeSizeModeUsesNodeMetrics('connections')).toBe(false); + expect(nodeSizeModeUsesNodeMetrics('uniform')).toBe(false); + }); + + it('applies changed metric updates in place', () => { + const appNode = createNode('src/app.ts', { fileSize: 100, churn: 1 }); + const libNode = createNode('src/lib.ts', { fileSize: 50, churn: 3 }); + const graphData = { nodes: [appNode, libNode] }; + + const changed = applyMetricUpdatesInPlace(graphData, createUpdates([ + { id: 'src/app.ts', fileSize: 120, churn: 1 }, + { id: 'src/lib.ts', fileSize: 50, churn: 4 }, + ])); + + expect(changed).toBe(true); + expect(graphData.nodes[0]).toBe(appNode); + expect(graphData.nodes[0]).toMatchObject({ fileSize: 120, churn: 1 }); + expect(graphData.nodes[1]).toBe(libNode); + expect(graphData.nodes[1]).toMatchObject({ fileSize: 50, churn: 4 }); + }); + + it('does not change nodes in place when updates are missing or metrics already match', () => { + const appNode = createNode('src/app.ts', { fileSize: 100, churn: 1 }); + const graphData = { nodes: [appNode] }; + + const changed = applyMetricUpdatesInPlace(graphData, createUpdates([ + { id: 'src/app.ts', fileSize: 100, churn: 1 }, + { id: 'src/missing.ts', fileSize: 999, churn: 999 }, + ])); + + expect(changed).toBe(false); + expect(graphData.nodes).toEqual([appNode]); + }); + + it('returns new node objects only for changed immutable metric updates', () => { + const appNode = createNode('src/app.ts', { fileSize: 100, churn: 1 }); + const libNode = createNode('src/lib.ts', { fileSize: 50, churn: 3 }); + + const result = applyMetricUpdates([appNode, libNode], createUpdates([ + { id: 'src/app.ts', fileSize: 100, churn: 2 }, + ])); + + expect(result.changed).toBe(true); + expect(result.nodes[0]).not.toBe(appNode); + expect(result.nodes[0]).toMatchObject({ fileSize: 100, churn: 2 }); + expect(result.nodes[1]).toBe(libNode); + expect(appNode).toMatchObject({ fileSize: 100, churn: 1 }); + }); + + it('preserves immutable node objects when metric updates do not change values', () => { + const appNode = createNode('src/app.ts', { fileSize: 100, churn: 1 }); + + const result = applyMetricUpdates([appNode], createUpdates([ + { id: 'src/app.ts', fileSize: 100, churn: 1 }, + { id: 'src/missing.ts', fileSize: 999, churn: 999 }, + ])); + + expect(result.changed).toBe(false); + expect(result.nodes).toEqual([appNode]); + expect(result.nodes[0]).toBe(appNode); + }); +}); + +function createNode( + id: string, + metrics: Pick, +): GraphNode { + return { + id, + label: id, + color: '#94a3b8', + ...metrics, + }; +} + +function createUpdates( + updates: GraphNodeMetricsUpdate[], +): ReadonlyMap { + return new Map(updates.map((update) => [update.id, update])); +} diff --git a/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/metrics.test.ts b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/metrics.test.ts new file mode 100644 index 000000000..b51bfb042 --- /dev/null +++ b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/metrics.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; +import type { GraphNodeMetricsUpdateMessage } from '../../../../../src/webview/store/messageHandlers/graphDataMessage/contracts'; +import { handleGraphNodeMetricsUpdated } from '../../../../../src/webview/store/messageHandlers/graphDataMessage/metrics'; +import { createState } from '../graph/fixture'; + +describe('webview/store/messageHandlers/graphDataMessage/metrics', () => { + it('ignores metric updates when there is no current state context', () => { + expect(handleGraphNodeMetricsUpdated(createMetricsMessage([ + { id: 'src/app.ts', fileSize: 120, churn: 2 }, + ]))).toBeUndefined(); + }); + + it('ignores metric updates when graph data has not arrived', () => { + const state = createState({ graphData: null }); + + expect(handleGraphNodeMetricsUpdated( + createMetricsMessage([{ id: 'src/app.ts', fileSize: 120, churn: 2 }]), + { getState: () => state }, + )).toBeUndefined(); + }); + + it('applies metric updates in place when node sizing does not use metrics', () => { + const graphData = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: true, + bootstrapComplete: false, + graphData, + graphIndexProgress: { phase: 'Updating Graph View', current: 1, total: 2 }, + graphIsIndexing: true, + isLoading: true, + nodeSizeMode: 'connections', + }); + + const result = handleGraphNodeMetricsUpdated( + createMetricsMessage([{ id: 'src/app.ts', fileSize: 120, churn: 2 }]), + { getState: () => state }, + ); + + expect(result).toEqual({ + isLoading: true, + graphIsIndexing: false, + graphIndexProgress: null, + }); + expect(state.graphData).toBe(graphData); + expect(graphData.nodes[0]).toMatchObject({ fileSize: 120, churn: 2 }); + }); + + it('returns new graph data when metric sizing depends on changed node metrics', () => { + const graphData = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: true, + graphData, + graphIndexProgress: { phase: 'Updating Graph View', current: 1, total: 2 }, + graphIsIndexing: true, + isLoading: true, + nodeSizeMode: 'file-size', + }); + + const result = handleGraphNodeMetricsUpdated( + createMetricsMessage([{ id: 'src/app.ts', fileSize: 120, churn: 1 }]), + { getState: () => state }, + ); + + expect(result).toEqual({ + graphData: { + ...graphData, + nodes: [ + { id: 'src/app.ts', label: 'App', color: '#94a3b8', fileSize: 120, churn: 1 }, + graphData.nodes[1], + ], + }, + isLoading: false, + graphIsIndexing: false, + graphIndexProgress: null, + }); + expect(result?.graphData).not.toBe(graphData); + expect(graphData.nodes[0]).toMatchObject({ fileSize: 100, churn: 1 }); + }); + + it('settles indexing without replacing graph data when metric sizing values are unchanged', () => { + const graphData = createGraphData(); + const state = createState({ + graphData, + graphIndexProgress: { phase: 'Updating Graph View', current: 1, total: 2 }, + graphIsIndexing: true, + nodeSizeMode: 'churn', + }); + + expect(handleGraphNodeMetricsUpdated( + createMetricsMessage([{ id: 'src/app.ts', fileSize: 100, churn: 1 }]), + { getState: () => state }, + )).toEqual({ + graphIsIndexing: false, + graphIndexProgress: null, + }); + }); + + it('does not enter loading when bootstrap is incomplete but initial bootstrap is not pending', () => { + const graphData = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: false, + graphData, + graphIndexProgress: { phase: 'Updating Graph View', current: 1, total: 2 }, + graphIsIndexing: true, + isLoading: false, + nodeSizeMode: 'connections', + }); + + expect(handleGraphNodeMetricsUpdated( + createMetricsMessage([{ id: 'src/app.ts', fileSize: 120, churn: 2 }]), + { getState: () => state }, + )).toEqual({ + isLoading: false, + graphIsIndexing: false, + graphIndexProgress: null, + }); + }); +}); + +function createGraphData(): IGraphData { + return { + nodes: [ + { id: 'src/app.ts', label: 'App', color: '#94a3b8', fileSize: 100, churn: 1 }, + { id: 'src/lib.ts', label: 'Lib', color: '#94a3b8', fileSize: 50, churn: 3 }, + ], + edges: [ + { + id: 'src/app.ts->src/lib.ts#import', + from: 'src/app.ts', + to: 'src/lib.ts', + kind: 'import', + sources: [], + }, + ], + }; +} + +function createMetricsMessage( + nodes: GraphNodeMetricsUpdateMessage['payload']['nodes'], +): GraphNodeMetricsUpdateMessage { + return { + type: 'GRAPH_NODE_METRICS_UPDATED', + payload: { nodes }, + }; +} diff --git a/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/payload.test.ts b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/payload.test.ts new file mode 100644 index 000000000..146a1b571 --- /dev/null +++ b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/payload.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; +import { handleGraphDataUpdated } from '../../../../../src/webview/store/messageHandlers/graphDataMessage/payload'; +import { createState } from '../graph/fixture'; + +describe('webview/store/messageHandlers/graphDataMessage/payload', () => { + it('maps graph payload updates without requiring current state context', () => { + const payload = createGraphData(); + + expect(handleGraphDataUpdated({ + type: 'GRAPH_DATA_UPDATED', + payload, + })).toEqual({ + graphData: payload, + isLoading: false, + graphIsIndexing: false, + graphIndexProgress: null, + }); + }); + + it('skips duplicate graph payloads when duplicate-safe bootstrap state has settled', () => { + const payload = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: true, + graphData: cloneGraphData(payload), + graphIsIndexing: false, + isLoading: false, + }); + + expect(handleGraphDataUpdated( + { type: 'GRAPH_DATA_UPDATED', payload }, + { getState: () => state }, + )).toBeUndefined(); + }); + + it('keeps loading while initial bootstrap is still waiting for app bootstrap completion', () => { + const payload = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: true, + bootstrapComplete: false, + graphData: null, + graphIndexProgress: { phase: 'Updating Graph View', current: 1, total: 2 }, + graphIsIndexing: true, + isLoading: true, + }); + + expect(handleGraphDataUpdated( + { type: 'GRAPH_DATA_UPDATED', payload }, + { getState: () => state }, + )).toEqual({ + graphData: payload, + isLoading: true, + graphIsIndexing: false, + graphIndexProgress: null, + }); + }); + + it('settles initial bootstrap when graph data arrives after app bootstrap completes', () => { + const payload = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: true, + bootstrapComplete: true, + graphData: null, + graphIndexProgress: { phase: 'Updating Graph View', current: 1, total: 2 }, + graphIsIndexing: true, + isLoading: true, + }); + + expect(handleGraphDataUpdated( + { type: 'GRAPH_DATA_UPDATED', payload }, + { getState: () => state }, + )).toEqual({ + graphData: payload, + awaitingInitialBootstrap: false, + isLoading: false, + graphIsIndexing: false, + graphIndexProgress: null, + }); + }); +}); + +function createGraphData(): IGraphData { + return { + nodes: [{ id: 'src/app.ts', label: 'App', color: '#94a3b8' }], + edges: [], + }; +} + +function cloneGraphData(graphData: IGraphData): IGraphData { + return JSON.parse(JSON.stringify(graphData)) as IGraphData; +} diff --git a/packages/plugin-godot/src/gdscript/className.ts b/packages/plugin-godot/src/gdscript/className.ts index ae8951668..2541504a0 100644 --- a/packages/plugin-godot/src/gdscript/className.ts +++ b/packages/plugin-godot/src/gdscript/className.ts @@ -1,12 +1,6 @@ import type { IGDScriptReference } from './types'; import { stripGDScriptComment } from './comments'; -import { - findGDScriptSyntaxNodes, - parseGDScriptSyntaxTree, - readFirstDescendantText, - readGDScriptLineNumber, -} from './syntaxTree'; -import { isLeadingClassNameStatement } from './classNameLine'; +import { parseGDScriptDocument } from './document'; /** * Detect class_name declarations (not imports -- used for building the class_name map). @@ -26,27 +20,7 @@ export function detectClassNameDeclaration(line: string, lineNumber: number): IG } export function extractGDScriptClassNameDeclarations(content: string): IGDScriptReference[] { - const declarations: IGDScriptReference[] = []; - const tree = parseGDScriptSyntaxTree(content); - - for (const node of findGDScriptSyntaxNodes(tree, 'ClassNameStatement')) { - if (!isLeadingClassNameStatement(content, node.from)) { - continue; - } - - const className = readFirstDescendantText(node, 'Identifier'); - if (!className) { - continue; - } - - declarations.push({ - resPath: className, - referenceType: 'class_name', - importType: 'static', - line: readGDScriptLineNumber(content, node.from), - isDeclaration: true, - }); - } - - return declarations; + return parseGDScriptDocument(content).statements + .map(statement => detectClassNameDeclaration(statement.raw, statement.line)) + .filter((reference): reference is IGDScriptReference => Boolean(reference)); } diff --git a/packages/plugin-godot/src/gdscript/classNameLine.ts b/packages/plugin-godot/src/gdscript/classNameLine.ts deleted file mode 100644 index 49fe725fa..000000000 --- a/packages/plugin-godot/src/gdscript/classNameLine.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function isLeadingClassNameStatement(content: string, offset: number): boolean { - const beforeOffset = content.slice(0, offset); - const lineStart = beforeOffset.lastIndexOf('\n') + 1; - return content.slice(lineStart, offset).trim() === '' && content.startsWith('class_name', offset); -} diff --git a/packages/plugin-godot/src/plugin.ts b/packages/plugin-godot/src/plugin.ts index b0496012f..1c6cee513 100644 --- a/packages/plugin-godot/src/plugin.ts +++ b/packages/plugin-godot/src/plugin.ts @@ -23,7 +23,7 @@ import { extractSymbols } from './plugin/symbol/extract'; import { GODOT_SYMBOL_PLUGIN_KIND, GODOT_SYMBOL_SOURCE, -} from './plugin/symbol/godotKinds'; +} from './plugin/symbol/vocabulary'; import type { GodotWorkspaceFile, IGDScriptAnalyzeFilePlugin, diff --git a/packages/plugin-godot/src/plugin/symbol/className.ts b/packages/plugin-godot/src/plugin/symbol/className.ts index faf49af69..9c3c09819 100644 --- a/packages/plugin-godot/src/plugin/symbol/className.ts +++ b/packages/plugin-godot/src/plugin/symbol/className.ts @@ -6,7 +6,7 @@ import { GODOT_SCRIPT_LANGUAGE, GODOT_SYMBOL_PLUGIN_KIND, GODOT_SYMBOL_SOURCE, -} from './godotKinds'; +} from './vocabulary'; export function extractClassNameSymbols( content: string, diff --git a/packages/plugin-godot/src/plugin/symbol/declaration.ts b/packages/plugin-godot/src/plugin/symbol/declaration.ts index a61faa7b7..7014e11b2 100644 --- a/packages/plugin-godot/src/plugin/symbol/declaration.ts +++ b/packages/plugin-godot/src/plugin/symbol/declaration.ts @@ -8,7 +8,7 @@ import { GODOT_SCRIPT_LANGUAGE, GODOT_SYMBOL_PLUGIN_KIND, GODOT_SYMBOL_SOURCE, -} from './godotKinds'; +} from './vocabulary'; const EXPORT_DECORATOR_PATTERN = /^@export(?:_[A-Za-z_][A-Za-z0-9_]*)?(?:\([^)]*\))?(?:\s+|$)/; diff --git a/packages/plugin-godot/src/plugin/symbol/gdscriptSignals.ts b/packages/plugin-godot/src/plugin/symbol/gdscriptSignals.ts index 74438ad3b..070f2246e 100644 --- a/packages/plugin-godot/src/plugin/symbol/gdscriptSignals.ts +++ b/packages/plugin-godot/src/plugin/symbol/gdscriptSignals.ts @@ -4,7 +4,7 @@ import { GODOT_SCRIPT_LANGUAGE, GODOT_SYMBOL_PLUGIN_KIND, GODOT_SYMBOL_SOURCE, -} from './godotKinds'; +} from './vocabulary'; const SIGNAL_DECLARATION_PATTERN = /^signal\s+([A-Za-z_][A-Za-z0-9_]*)\b/; diff --git a/packages/plugin-godot/src/plugin/symbol/projectSettingsSymbols.ts b/packages/plugin-godot/src/plugin/symbol/projectSettingsSymbols.ts index 25089c55c..d424c91a3 100644 --- a/packages/plugin-godot/src/plugin/symbol/projectSettingsSymbols.ts +++ b/packages/plugin-godot/src/plugin/symbol/projectSettingsSymbols.ts @@ -5,7 +5,7 @@ import { GODOT_PROJECT_SETTINGS_LANGUAGE, GODOT_SYMBOL_PLUGIN_KIND, GODOT_SYMBOL_SOURCE, -} from './godotKinds'; +} from './vocabulary'; export function extractProjectSettingsSymbols( content: string, diff --git a/packages/plugin-godot/src/plugin/symbol/textResource/factory.ts b/packages/plugin-godot/src/plugin/symbol/textResource/factory.ts new file mode 100644 index 000000000..712d1897e --- /dev/null +++ b/packages/plugin-godot/src/plugin/symbol/textResource/factory.ts @@ -0,0 +1,31 @@ +import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; +import { + GODOT_SYMBOL_SOURCE, + GODOT_TEXT_RESOURCE_LANGUAGE, +} from '../vocabulary'; + +export function createTextResourceSymbol( + relativeFilePath: string, + filePath: string, + name: string, + kind: string, + line: number, + pluginKind: string, +): IAnalysisSymbol { + return { + id: `${relativeFilePath}#${name}:${kind}:${line}`, + name, + kind, + filePath, + range: { + startLine: line, + startColumn: 1, + endLine: line, + }, + metadata: { + language: GODOT_TEXT_RESOURCE_LANGUAGE, + source: GODOT_SYMBOL_SOURCE, + pluginKind, + }, + }; +} diff --git a/packages/plugin-godot/src/plugin/symbol/textResource/names.ts b/packages/plugin-godot/src/plugin/symbol/textResource/names.ts new file mode 100644 index 000000000..4d38cae17 --- /dev/null +++ b/packages/plugin-godot/src/plugin/symbol/textResource/names.ts @@ -0,0 +1,9 @@ +import * as path from 'path'; + +export function toPascalName(relativeFilePath: string): string { + return path.parse(relativeFilePath).name + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} diff --git a/packages/plugin-godot/src/plugin/symbol/textResource/scene.ts b/packages/plugin-godot/src/plugin/symbol/textResource/scene.ts new file mode 100644 index 000000000..ef3567a5a --- /dev/null +++ b/packages/plugin-godot/src/plugin/symbol/textResource/scene.ts @@ -0,0 +1,45 @@ +import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; +import type { GodotTextResourceTag } from '../../../textResource/types'; +import { GODOT_SYMBOL_PLUGIN_KIND } from '../vocabulary'; +import { toPascalName } from './names'; +import { createTextResourceSymbol } from './factory'; + +export function extractSceneSymbols( + filePath: string, + relativeFilePath: string, + tags: readonly GodotTextResourceTag[], +): IAnalysisSymbol[] { + const symbols: IAnalysisSymbol[] = []; + const rootNode = readRootSceneNode(tags); + const sceneName = rootNode?.fields.name ?? toPascalName(relativeFilePath); + + symbols.push(createTextResourceSymbol( + relativeFilePath, + filePath, + sceneName, + 'scene', + rootNode?.line ?? 1, + GODOT_SYMBOL_PLUGIN_KIND.scene, + )); + + for (const tag of tags) { + if (tag.name !== 'node' || !tag.fields.name) { + continue; + } + + symbols.push(createTextResourceSymbol( + relativeFilePath, + filePath, + tag.fields.name, + 'scene-node', + tag.line, + GODOT_SYMBOL_PLUGIN_KIND.sceneNode, + )); + } + + return symbols; +} + +function readRootSceneNode(tags: readonly GodotTextResourceTag[]): GodotTextResourceTag | undefined { + return tags.find(tag => tag.name === 'node' && !tag.fields.parent && tag.fields.name); +} diff --git a/packages/plugin-godot/src/plugin/symbol/textResource/standalone.ts b/packages/plugin-godot/src/plugin/symbol/textResource/standalone.ts new file mode 100644 index 000000000..f1c681274 --- /dev/null +++ b/packages/plugin-godot/src/plugin/symbol/textResource/standalone.ts @@ -0,0 +1,23 @@ +import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; +import type { GodotTextResourceTag } from '../../../textResource/types'; +import { GODOT_SYMBOL_PLUGIN_KIND } from '../vocabulary'; +import { toPascalName } from './names'; +import { createTextResourceSymbol } from './factory'; + +export function extractResourceSymbols( + filePath: string, + relativeFilePath: string, + tags: readonly GodotTextResourceTag[], +): IAnalysisSymbol[] { + const resourceTag = tags.find(tag => tag.name === 'gd_resource'); + return [ + createTextResourceSymbol( + relativeFilePath, + filePath, + toPascalName(relativeFilePath), + 'resource', + resourceTag?.line ?? 1, + GODOT_SYMBOL_PLUGIN_KIND.resource, + ), + ]; +} diff --git a/packages/plugin-godot/src/plugin/symbol/textResourceSymbols.ts b/packages/plugin-godot/src/plugin/symbol/textResourceSymbols.ts index 6ea07c7ef..07165b933 100644 --- a/packages/plugin-godot/src/plugin/symbol/textResourceSymbols.ts +++ b/packages/plugin-godot/src/plugin/symbol/textResourceSymbols.ts @@ -1,108 +1,12 @@ import * as path from 'path'; import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; import { parseGodotTextResourceDocument } from '../../textResource/parser'; -import type { GodotTextResourceTag } from '../../textResource/types'; -import { - GODOT_SYMBOL_PLUGIN_KIND, - GODOT_SYMBOL_SOURCE, - GODOT_TEXT_RESOURCE_LANGUAGE, -} from './godotKinds'; +import { extractResourceSymbols } from './textResource/standalone'; +import { extractSceneSymbols } from './textResource/scene'; const SCENE_EXTENSIONS = new Set(['.tscn']); const RESOURCE_EXTENSIONS = new Set(['.tres']); -function toPascalName(relativeFilePath: string): string { - return path.parse(relativeFilePath).name - .split(/[^A-Za-z0-9]+/) - .filter(Boolean) - .map(part => part.charAt(0).toUpperCase() + part.slice(1)) - .join(''); -} - -function createTextResourceSymbol( - relativeFilePath: string, - filePath: string, - name: string, - kind: string, - line: number, - pluginKind: string, -): IAnalysisSymbol { - return { - id: `${relativeFilePath}#${name}:${kind}:${line}`, - name, - kind, - filePath, - range: { - startLine: line, - startColumn: 1, - endLine: line, - }, - metadata: { - language: GODOT_TEXT_RESOURCE_LANGUAGE, - source: GODOT_SYMBOL_SOURCE, - pluginKind, - }, - }; -} - -function readRootSceneNode(tags: readonly GodotTextResourceTag[]): GodotTextResourceTag | undefined { - return tags.find(tag => tag.name === 'node' && !tag.fields.parent && tag.fields.name); -} - -function extractSceneSymbols( - filePath: string, - relativeFilePath: string, - tags: readonly GodotTextResourceTag[], -): IAnalysisSymbol[] { - const symbols: IAnalysisSymbol[] = []; - const rootNode = readRootSceneNode(tags); - const sceneName = rootNode?.fields.name ?? toPascalName(relativeFilePath); - - symbols.push(createTextResourceSymbol( - relativeFilePath, - filePath, - sceneName, - 'scene', - rootNode?.line ?? 1, - GODOT_SYMBOL_PLUGIN_KIND.scene, - )); - - for (const tag of tags) { - if (tag.name !== 'node' || !tag.fields.name) { - continue; - } - - symbols.push(createTextResourceSymbol( - relativeFilePath, - filePath, - tag.fields.name, - 'scene-node', - tag.line, - GODOT_SYMBOL_PLUGIN_KIND.sceneNode, - )); - } - - return symbols; -} - -function extractResourceSymbols( - filePath: string, - relativeFilePath: string, - tags: readonly GodotTextResourceTag[], -): IAnalysisSymbol[] { - const resourceTag = tags.find(tag => tag.name === 'gd_resource'); - return [ - createTextResourceSymbol( - relativeFilePath, - filePath, - toPascalName(relativeFilePath), - 'resource', - resourceTag?.line ?? 1, - GODOT_SYMBOL_PLUGIN_KIND.resource, - ), - ]; -} - export function extractTextResourceSymbols( content: string, filePath: string, diff --git a/packages/plugin-godot/src/plugin/symbol/godotKinds.ts b/packages/plugin-godot/src/plugin/symbol/vocabulary.ts similarity index 100% rename from packages/plugin-godot/src/plugin/symbol/godotKinds.ts rename to packages/plugin-godot/src/plugin/symbol/vocabulary.ts diff --git a/packages/plugin-godot/tests/gdscript/classNameFast.test.ts b/packages/plugin-godot/tests/gdscript/classNameFast.test.ts new file mode 100644 index 000000000..eb8af84ca --- /dev/null +++ b/packages/plugin-godot/tests/gdscript/classNameFast.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/gdscript/syntaxTree', () => ({ + findGDScriptSyntaxNodes: vi.fn(), + parseGDScriptSyntaxTree: vi.fn(() => { + throw new Error('syntax parser should not run for class_name extraction'); + }), + readFirstDescendantText: vi.fn(), + readGDScriptLineNumber: vi.fn(), +})); + +import { extractGDScriptClassNameDeclarations } from '../../src/gdscript/className'; + +describe('fast GDScript class_name extraction', () => { + it('extracts declarations without the syntax parser', () => { + expect(extractGDScriptClassNameDeclarations([ + '@icon("res://icon.svg")', + 'class_name Player # exported class', + 'extends Node2D', + ].join('\n'))).toEqual([ + { + resPath: 'Player', + referenceType: 'class_name', + importType: 'static', + line: 2, + isDeclaration: true, + }, + ]); + }); +}); diff --git a/packages/plugin-godot/tests/gdscript/classNameLine.test.ts b/packages/plugin-godot/tests/gdscript/classNameLine.test.ts deleted file mode 100644 index b05fc7b72..000000000 --- a/packages/plugin-godot/tests/gdscript/classNameLine.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { isLeadingClassNameStatement } from '../../src/gdscript/classNameLine'; - -describe('isLeadingClassNameStatement', () => { - it('accepts class_name at the start of a source line', () => { - const content = [ - 'extends Node2D', - ' class_name Player', - ].join('\n'); - - expect(isLeadingClassNameStatement(content, content.indexOf('class_name Player'))).toBe(true); - }); - - it('rejects class_name when earlier code appears on the same line', () => { - const content = [ - 'var class_name AlsoIgnored', - 'class_name LaterDeclaration', - ].join('\n'); - - expect(isLeadingClassNameStatement(content, content.indexOf('class_name AlsoIgnored'))).toBe(false); - }); - - it('rejects other text at a leading line offset', () => { - const content = ' not_class_name Player'; - - expect(isLeadingClassNameStatement(content, content.indexOf('not_class_name Player'))).toBe(false); - }); -}); diff --git a/packages/plugin-godot/tests/plugin/symbol/godotKinds.test.ts b/packages/plugin-godot/tests/plugin/symbol/vocabulary.test.ts similarity index 95% rename from packages/plugin-godot/tests/plugin/symbol/godotKinds.test.ts rename to packages/plugin-godot/tests/plugin/symbol/vocabulary.test.ts index 58dd2832d..854fa326a 100644 --- a/packages/plugin-godot/tests/plugin/symbol/godotKinds.test.ts +++ b/packages/plugin-godot/tests/plugin/symbol/vocabulary.test.ts @@ -5,7 +5,7 @@ import { GODOT_SYMBOL_PLUGIN_KIND, GODOT_SYMBOL_SOURCE, GODOT_TEXT_RESOURCE_LANGUAGE, -} from '../../../src/plugin/symbol/godotKinds'; +} from '../../../src/plugin/symbol/vocabulary'; describe('Godot symbol vocabulary', () => { it('uses stable graph scope metadata identifiers', () => { diff --git a/packages/plugin-typescript/src/aliasImport/compilerOptions.ts b/packages/plugin-typescript/src/aliasImport/compilerOptions.ts index 7266034e5..dba454e08 100644 --- a/packages/plugin-typescript/src/aliasImport/compilerOptions.ts +++ b/packages/plugin-typescript/src/aliasImport/compilerOptions.ts @@ -1,12 +1,16 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import ts from 'typescript'; -import { comparePathMappingSpecificity, type TypeScriptPathMapping } from './pathMapping'; +import type { TypeScriptPathMapping } from './pathMapping'; +import { clearCompilerOptionsCache, readCompilerOptions } from './config/cache'; +import { findNearestTypeScriptConfig } from './config/discovery'; +import { createPathMappings } from './config/pathMappings'; export type TypeScriptAliasConfig = { paths: TypeScriptPathMapping[]; }; +export function clearTypeScriptAliasConfigCache(): void { + clearCompilerOptionsCache(); +} + export function readTypeScriptAliasConfig(filePath: string, workspaceRoot: string): TypeScriptAliasConfig | null { const tsconfigPath = findNearestTypeScriptConfig(filePath, workspaceRoot); if (!tsconfigPath) { @@ -22,64 +26,3 @@ export function readTypeScriptAliasConfig(filePath: string, workspaceRoot: strin paths: createPathMappings(parsed.options.paths, parsed.options, tsconfigPath, workspaceRoot), }; } - -function findNearestTypeScriptConfig(filePath: string, workspaceRoot: string): string | null { - const realWorkspaceRoot = fs.realpathSync.native(workspaceRoot); - let currentDirectory = fs.realpathSync.native(path.dirname(filePath)); - - while (currentDirectory === realWorkspaceRoot || currentDirectory.startsWith(`${realWorkspaceRoot}${path.sep}`)) { - const tsconfigPath = path.join(currentDirectory, 'tsconfig.json'); - if (fs.existsSync(tsconfigPath)) { - return tsconfigPath; - } - - const parentDirectory = path.dirname(currentDirectory); - if (parentDirectory === currentDirectory) { - return null; - } - currentDirectory = parentDirectory; - } - - return null; -} - -function readCompilerOptions(tsconfigPath: string): ts.ParsedCommandLine | null { - const readResult = ts.readConfigFile(tsconfigPath, fileName => ts.sys.readFile(fileName)); - if (readResult.error) { - return null; - } - - return ts.parseJsonConfigFileContent( - readResult.config, - ts.sys, - path.dirname(tsconfigPath), - undefined, - tsconfigPath, - ); -} - -function createPathMappings( - paths: ts.MapLike, - options: ts.CompilerOptions, - tsconfigPath: string, - workspaceRoot: string, -): TypeScriptPathMapping[] { - const pathsBasePath = typeof options.pathsBasePath === 'string' - ? options.pathsBasePath - : undefined; - const baseUrl = toWorkspacePath(options.baseUrl ?? pathsBasePath ?? path.dirname(tsconfigPath), workspaceRoot); - return Object.entries(paths) - .map(([key, targets]) => ({ - baseUrl, - key, - targets, - })) - .sort(comparePathMappingSpecificity); -} - -function toWorkspacePath(candidate: string, workspaceRoot: string): string { - const realWorkspaceRoot = fs.realpathSync.native(workspaceRoot); - return candidate === realWorkspaceRoot || candidate.startsWith(`${realWorkspaceRoot}${path.sep}`) - ? path.join(workspaceRoot, path.relative(realWorkspaceRoot, candidate)) - : candidate; -} diff --git a/packages/plugin-typescript/src/aliasImport/config/cache.ts b/packages/plugin-typescript/src/aliasImport/config/cache.ts new file mode 100644 index 000000000..7f7af81de --- /dev/null +++ b/packages/plugin-typescript/src/aliasImport/config/cache.ts @@ -0,0 +1,45 @@ +import * as path from 'node:path'; +import ts from 'typescript'; +import { createCompilerOptionsParseHost, normalizeConfigFilePath } from './parseHost'; +import { areConfigFileStampsFresh, createConfigFileStamps } from './stamps'; +import type { FileStamp } from './stamps'; + +type CompilerOptionsCacheEntry = { + configFileStamps: Map; + parsed: ts.ParsedCommandLine | null; +}; + +const compilerOptionsCache = new Map(); + +export function clearCompilerOptionsCache(): void { + compilerOptionsCache.clear(); +} + +export function readCompilerOptions(tsconfigPath: string): ts.ParsedCommandLine | null { + const cached = compilerOptionsCache.get(tsconfigPath); + if (cached && areConfigFileStampsFresh(cached.configFileStamps)) { + return cached.parsed; + } + + const configFilePaths = new Set([normalizeConfigFilePath(tsconfigPath)]); + const readResult = ts.readConfigFile(tsconfigPath, fileName => { + configFilePaths.add(normalizeConfigFilePath(fileName)); + return ts.sys.readFile(fileName); + }); + const parsed = readResult.error + ? null + : ts.parseJsonConfigFileContent( + readResult.config, + createCompilerOptionsParseHost(configFilePaths), + path.dirname(tsconfigPath), + undefined, + tsconfigPath, + ); + + compilerOptionsCache.set(tsconfigPath, { + configFileStamps: createConfigFileStamps(configFilePaths), + parsed, + }); + + return parsed ?? null; +} diff --git a/packages/plugin-typescript/src/aliasImport/config/discovery.ts b/packages/plugin-typescript/src/aliasImport/config/discovery.ts new file mode 100644 index 000000000..2e81d13ff --- /dev/null +++ b/packages/plugin-typescript/src/aliasImport/config/discovery.ts @@ -0,0 +1,22 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export function findNearestTypeScriptConfig(filePath: string, workspaceRoot: string): string | null { + const realWorkspaceRoot = fs.realpathSync.native(workspaceRoot); + let currentDirectory = fs.realpathSync.native(path.dirname(filePath)); + + while (currentDirectory === realWorkspaceRoot || currentDirectory.startsWith(`${realWorkspaceRoot}${path.sep}`)) { + const tsconfigPath = path.join(currentDirectory, 'tsconfig.json'); + if (fs.existsSync(tsconfigPath)) { + return tsconfigPath; + } + + const parentDirectory = path.dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + return null; + } + currentDirectory = parentDirectory; + } + + return null; +} diff --git a/packages/plugin-typescript/src/aliasImport/config/parseHost.ts b/packages/plugin-typescript/src/aliasImport/config/parseHost.ts new file mode 100644 index 000000000..6cad4f2a3 --- /dev/null +++ b/packages/plugin-typescript/src/aliasImport/config/parseHost.ts @@ -0,0 +1,21 @@ +import * as path from 'node:path'; +import ts from 'typescript'; + +export function normalizeConfigFilePath(filePath: string): string { + return path.resolve(filePath); +} + +export function createCompilerOptionsParseHost(configFilePaths: Set): ts.ParseConfigHost { + return { + directoryExists: directoryName => ts.sys.directoryExists?.(directoryName) ?? false, + fileExists: fileName => ts.sys.fileExists(fileName), + getCurrentDirectory: () => ts.sys.getCurrentDirectory(), + readDirectory: () => [], + readFile: fileName => { + configFilePaths.add(normalizeConfigFilePath(fileName)); + return ts.sys.readFile(fileName); + }, + realpath: pathName => ts.sys.realpath?.(pathName) ?? pathName, + useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, + }; +} diff --git a/packages/plugin-typescript/src/aliasImport/config/pathMappings.ts b/packages/plugin-typescript/src/aliasImport/config/pathMappings.ts new file mode 100644 index 000000000..9661faf9b --- /dev/null +++ b/packages/plugin-typescript/src/aliasImport/config/pathMappings.ts @@ -0,0 +1,30 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import ts from 'typescript'; +import { comparePathMappingSpecificity, type TypeScriptPathMapping } from '../pathMapping'; + +export function createPathMappings( + paths: ts.MapLike, + options: ts.CompilerOptions, + tsconfigPath: string, + workspaceRoot: string, +): TypeScriptPathMapping[] { + const pathsBasePath = typeof options.pathsBasePath === 'string' + ? options.pathsBasePath + : undefined; + const baseUrl = toWorkspacePath(options.baseUrl ?? pathsBasePath ?? path.dirname(tsconfigPath), workspaceRoot); + return Object.entries(paths) + .map(([key, targets]) => ({ + baseUrl, + key, + targets, + })) + .sort(comparePathMappingSpecificity); +} + +function toWorkspacePath(candidate: string, workspaceRoot: string): string { + const realWorkspaceRoot = fs.realpathSync.native(workspaceRoot); + return candidate === realWorkspaceRoot || candidate.startsWith(`${realWorkspaceRoot}${path.sep}`) + ? path.join(workspaceRoot, path.relative(realWorkspaceRoot, candidate)) + : candidate; +} diff --git a/packages/plugin-typescript/src/aliasImport/config/stamps.ts b/packages/plugin-typescript/src/aliasImport/config/stamps.ts new file mode 100644 index 000000000..6271f1072 --- /dev/null +++ b/packages/plugin-typescript/src/aliasImport/config/stamps.ts @@ -0,0 +1,40 @@ +import * as fs from 'node:fs'; + +export type FileStamp = { + mtimeMs: number; + size: number; +} | null; + +export function createConfigFileStamps(filePaths: ReadonlySet): Map { + return new Map([...filePaths].map(filePath => [filePath, getFileStamp(filePath)])); +} + +export function areConfigFileStampsFresh(stamps: ReadonlyMap): boolean { + for (const [filePath, stamp] of stamps) { + if (!areFileStampsEqual(getFileStamp(filePath), stamp)) { + return false; + } + } + + return true; +} + +function getFileStamp(filePath: string): FileStamp { + try { + const stat = fs.statSync(filePath); + return { + mtimeMs: stat.mtimeMs, + size: stat.size, + }; + } catch { + return null; + } +} + +function areFileStampsEqual(left: FileStamp, right: FileStamp): boolean { + if (left === null || right === null) { + return left === right; + } + + return left.mtimeMs === right.mtimeMs && left.size === right.size; +} diff --git a/packages/plugin-typescript/src/aliasImport/model.ts b/packages/plugin-typescript/src/aliasImport/model.ts index 02ac25945..fcdda1494 100644 --- a/packages/plugin-typescript/src/aliasImport/model.ts +++ b/packages/plugin-typescript/src/aliasImport/model.ts @@ -1,5 +1,5 @@ import type { IFileAnalysisResult, IPluginEdgeType } from '@codegraphy-dev/plugin-api'; -import { readTypeScriptAliasConfig } from './compilerOptions'; +import { clearTypeScriptAliasConfigCache, readTypeScriptAliasConfig } from './compilerOptions'; import { collectTypeScriptFilePaths, isTypeScriptConfigFile, isTypeScriptSourceFile } from './files'; import { resolveAliasImport } from './resolve'; import { extractModuleSpecifiers } from './specifiers'; @@ -18,6 +18,7 @@ export const TYPESCRIPT_ALIAS_IMPORT_EDGE_TYPE: IPluginEdgeType = { const COMPILER_OPTIONS_PATHS_SOURCE_ID = 'compiler-options-paths'; export { + clearTypeScriptAliasConfigCache, collectTypeScriptFilePaths, isTypeScriptConfigFile, isTypeScriptSourceFile, diff --git a/packages/plugin-typescript/src/plugin.ts b/packages/plugin-typescript/src/plugin.ts index d2682c339..6e5f60d74 100644 --- a/packages/plugin-typescript/src/plugin.ts +++ b/packages/plugin-typescript/src/plugin.ts @@ -2,6 +2,7 @@ import type { IPlugin } from '@codegraphy-dev/plugin-api'; import manifest from '../codegraphy.json'; import { analyzeTypeScriptAliasImports, + clearTypeScriptAliasConfigCache, collectTypeScriptFilePaths, isTypeScriptConfigFile, TYPESCRIPT_ALIAS_IMPORT_EDGE_TYPE, @@ -33,9 +34,12 @@ export function createTypeScriptPlugin(): IPlugin { typeScriptFiles = collectTypeScriptFilePaths(files); }, async onFilesChanged(files) { - return files.some(file => isTypeScriptConfigFile(file.relativePath)) - ? typeScriptFiles - : undefined; + if (!files.some(file => isTypeScriptConfigFile(file.relativePath))) { + return undefined; + } + + clearTypeScriptAliasConfigCache(); + return typeScriptFiles; }, }; } diff --git a/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts b/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts index 7ce7438af..5a235e1ba 100644 --- a/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts +++ b/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import ts from 'typescript'; +import { describe, expect, it, vi } from 'vitest'; import { createTypeScriptPlugin } from '../../src/plugin'; import { createWorkspaceRoot, removeWorkspaceRoot, writeWorkspaceFile } from '../workspace'; @@ -233,6 +235,174 @@ describe('TypeScript Alias Import compiler options support', () => { } }); + it('reads path aliases without scanning project files', async () => { + const workspaceRoot = createWorkspaceRoot(); + const readDirectory = vi.spyOn(ts.sys, 'readDirectory') + .mockImplementation(() => { + throw new Error('project file scanning should not run for alias config'); + }); + + try { + writeWorkspaceFile( + workspaceRoot, + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { + '@/*': ['src/*'], + }, + }, + }), + ); + const sourcePath = writeWorkspaceFile( + workspaceRoot, + 'src/app.ts', + "import { token } from '@/token';\n", + ); + const targetPath = writeWorkspaceFile( + workspaceRoot, + 'src/token.ts', + 'export const token = Symbol();\n', + ); + + const plugin = createTypeScriptPlugin(); + const result = await plugin.analyzeFile?.( + sourcePath, + "import { token } from '@/token';\n", + workspaceRoot, + ); + + expect(result?.relations).toEqual([ + { + kind: 'codegraphy.typescript:alias-import', + sourceId: 'compiler-options-paths', + fromFilePath: sourcePath, + toFilePath: targetPath, + resolvedPath: targetPath, + specifier: '@/token', + }, + ]); + expect(readDirectory).not.toHaveBeenCalled(); + } finally { + readDirectory.mockRestore(); + removeWorkspaceRoot(workspaceRoot); + } + }); + + it('reuses parsed path aliases for files under the same tsconfig', async () => { + const workspaceRoot = createWorkspaceRoot(); + const readFile = vi.spyOn(ts.sys, 'readFile'); + + try { + const tsconfigPath = writeWorkspaceFile( + workspaceRoot, + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { + '@/*': ['src/*'], + }, + }, + }), + ); + const firstSourcePath = writeWorkspaceFile( + workspaceRoot, + 'src/first.ts', + "import { token } from '@/token';\n", + ); + const secondSourcePath = writeWorkspaceFile( + workspaceRoot, + 'src/second.ts', + "import { token } from '@/token';\n", + ); + writeWorkspaceFile( + workspaceRoot, + 'src/token.ts', + 'export const token = Symbol();\n', + ); + + const plugin = createTypeScriptPlugin(); + await plugin.analyzeFile?.( + firstSourcePath, + "import { token } from '@/token';\n", + workspaceRoot, + ); + await plugin.analyzeFile?.( + secondSourcePath, + "import { token } from '@/token';\n", + workspaceRoot, + ); + + const realTsconfigPath = fs.realpathSync.native(tsconfigPath); + const tsconfigReads = readFile.mock.calls.filter(([fileName]) => + fileName === realTsconfigPath, + ); + expect(tsconfigReads).toHaveLength(1); + } finally { + readFile.mockRestore(); + removeWorkspaceRoot(workspaceRoot); + } + }); + + it('invalidates parsed path aliases when an extended tsconfig changes on disk', async () => { + const workspaceRoot = createWorkspaceRoot(); + try { + const baseConfig = (target: string) => JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { + '#/*': [`${target}/*`], + }, + }, + }); + writeWorkspaceFile(workspaceRoot, 'tsconfig.base.json', baseConfig('src-a')); + writeWorkspaceFile( + workspaceRoot, + 'tsconfig.json', + JSON.stringify({ + extends: './tsconfig.base.json', + }), + ); + const sourcePath = writeWorkspaceFile( + workspaceRoot, + 'src/app.ts', + "import { token } from '#/token';\n", + ); + const firstTargetPath = writeWorkspaceFile( + workspaceRoot, + 'src-a/token.ts', + 'export const token = 1;\n', + ); + const secondTargetPath = writeWorkspaceFile( + workspaceRoot, + 'src-b/token.ts', + 'export const token = 2;\n', + ); + + const plugin = createTypeScriptPlugin(); + const firstResult = await plugin.analyzeFile?.( + sourcePath, + "import { token } from '#/token';\n", + workspaceRoot, + ); + + writeWorkspaceFile(workspaceRoot, 'tsconfig.base.json', baseConfig('src-b')); + + const secondResult = await plugin.analyzeFile?.( + sourcePath, + "import { token } from '#/token';\n", + workspaceRoot, + ); + + expect(firstResult?.relations?.[0]?.resolvedPath).toBe(firstTargetPath); + expect(secondResult?.relations?.[0]?.resolvedPath).toBe(secondTargetPath); + } finally { + removeWorkspaceRoot(workspaceRoot); + } + }); + it('emits no relationships when nearest tsconfig has no paths', async () => { const workspaceRoot = createWorkspaceRoot(); try { diff --git a/packages/plugin-typescript/tests/lifecycle.test.ts b/packages/plugin-typescript/tests/lifecycle.test.ts index 4908e8b6b..69bba43b2 100644 --- a/packages/plugin-typescript/tests/lifecycle.test.ts +++ b/packages/plugin-typescript/tests/lifecycle.test.ts @@ -77,4 +77,84 @@ describe('TypeScript plugin lifecycle', () => { removeWorkspaceRoot(workspaceRoot); } }); + + it('invalidates cached alias config when an extended tsconfig changes', async () => { + const workspaceRoot = createWorkspaceRoot(); + try { + const baseConfigPath = writeWorkspaceFile( + workspaceRoot, + 'tsconfig.base.json', + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { + '#/*': ['src-a/*'], + }, + }, + }), + ); + writeWorkspaceFile( + workspaceRoot, + 'tsconfig.json', + JSON.stringify({ + extends: './tsconfig.base.json', + }), + ); + const sourcePath = writeWorkspaceFile( + workspaceRoot, + 'src/app.ts', + "import { token } from '#/token';\n", + ); + const firstTargetPath = writeWorkspaceFile( + workspaceRoot, + 'src-a/token.ts', + 'export const token = 1;\n', + ); + const secondTargetPath = writeWorkspaceFile( + workspaceRoot, + 'src-b/token.ts', + 'export const token = 2;\n', + ); + + const plugin = createTypeScriptPlugin(); + await plugin.onPreAnalyze?.( + [{ absolutePath: sourcePath, relativePath: 'src/app.ts', content: 'export {};\n' }], + workspaceRoot, + ); + + const firstResult = await plugin.analyzeFile?.( + sourcePath, + "import { token } from '#/token';\n", + workspaceRoot, + ); + + writeWorkspaceFile( + workspaceRoot, + 'tsconfig.base.json', + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { + '#/*': ['src-b/*'], + }, + }, + }), + ); + await plugin.onFilesChanged?.( + [{ absolutePath: baseConfigPath, relativePath: 'tsconfig.base.json', content: '' }], + workspaceRoot, + ); + + const secondResult = await plugin.analyzeFile?.( + sourcePath, + "import { token } from '#/token';\n", + workspaceRoot, + ); + + expect(firstResult?.relations?.[0]?.resolvedPath).toBe(firstTargetPath); + expect(secondResult?.relations?.[0]?.resolvedPath).toBe(secondTargetPath); + } finally { + removeWorkspaceRoot(workspaceRoot); + } + }); }); diff --git a/packages/plugin-unity/package.json b/packages/plugin-unity/package.json index 9ce09aee1..12e4273f6 100644 --- a/packages/plugin-unity/package.json +++ b/packages/plugin-unity/package.json @@ -5,10 +5,10 @@ "license": "MIT", "type": "module", "main": "./dist/plugin.js", - "types": "./dist/plugin.d.ts", + "types": "./dist/lifecycle.d.ts", "exports": { ".": { - "types": "./dist/plugin.d.ts", + "types": "./dist/lifecycle.d.ts", "default": "./dist/plugin.js" } }, @@ -42,7 +42,7 @@ "LICENSE" ], "scripts": { - "build": "tsc -p tsconfig.build.json --emitDeclarationOnly && node ../../scripts/build-workspace-package.mjs src/plugin.ts dist/plugin.js", + "build": "tsc -p tsconfig.build.json --emitDeclarationOnly && node ../../scripts/build-workspace-package.mjs src/lifecycle.ts dist/plugin.js", "test": "vitest run", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\"", "typecheck": "tsc --noEmit -p tsconfig.json" diff --git a/packages/plugin-unity/src/plugin.ts b/packages/plugin-unity/src/lifecycle.ts similarity index 100% rename from packages/plugin-unity/src/plugin.ts rename to packages/plugin-unity/src/lifecycle.ts diff --git a/packages/plugin-unity/tests/plugin.test.ts b/packages/plugin-unity/tests/lifecycle.test.ts similarity index 98% rename from packages/plugin-unity/tests/plugin.test.ts rename to packages/plugin-unity/tests/lifecycle.test.ts index a60c28f38..2afbaa6c9 100644 --- a/packages/plugin-unity/tests/plugin.test.ts +++ b/packages/plugin-unity/tests/lifecycle.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { IPluginAnalysisContext } from '@codegraphy-dev/plugin-api'; -import { createUnityPlugin } from '../src/plugin'; +import { createUnityPlugin } from '../src/lifecycle'; const playerControllerGuid = '11111111111111111111111111111111'; diff --git a/quality.config.json b/quality.config.json index fe1c8ccc6..f878ef672 100644 --- a/quality.config.json +++ b/quality.config.json @@ -224,6 +224,39 @@ ] } }, + "codegraphy-svelte-example": { + "boundaries": { + "entrypoints": [ + "src/loadFeature.ts", + "src/main.ts", + "src/types.ts" + ] + } + }, + "codegraphy-typescript-example": { + "boundaries": { + "entrypoints": [ + "src/alias/themePack.ts", + "src/index.ts", + "src/lazyPreview.ts", + "src/palette.ts", + "src/paletteRunner.ts", + "src/registry.ts", + "src/scratchpad.ts", + "src/seedSettings.ts", + "src/themeLabels.ts" + ] + } + }, + "codegraphy-vue-example": { + "boundaries": { + "entrypoints": [ + "src/composables/useCounter.ts", + "src/data/users.ts", + "src/main.ts" + ] + } + }, "mcp": { "crap": { "coverage": { @@ -338,6 +371,13 @@ ] } }, + "plugin-svelte": { + "boundaries": { + "entrypoints": [ + "src/plugin.ts" + ] + } + }, "plugin-typescript": { "crap": { "coverage": { @@ -361,6 +401,20 @@ "src/plugin.ts" ] } + }, + "plugin-unity": { + "boundaries": { + "entrypoints": [ + "src/lifecycle.ts" + ] + } + }, + "plugin-vue": { + "boundaries": { + "entrypoints": [ + "src/plugin.ts" + ] + } } } } diff --git a/stryker.extension.config.cjs b/stryker.extension.config.cjs index 2dcc04db2..160643d16 100644 --- a/stryker.extension.config.cjs +++ b/stryker.extension.config.cjs @@ -1,9 +1 @@ -const base = require('@poleski/quality-tools/stryker.config.cjs'); - -module.exports = { - ...base, - vitest: { - ...base.vitest, - configFile: 'packages/extension/vitest.config.ts', - }, -}; +module.exports = require('./stryker.config.cjs');