From 56d0baa995333c9e5eae45738b3a1c4b82792955 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 18 May 2026 17:36:01 -0700 Subject: [PATCH 01/49] Document Extract Pro execution plan --- docs/plans/2026-05-18-extract-pro.md | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/plans/2026-05-18-extract-pro.md diff --git a/docs/plans/2026-05-18-extract-pro.md b/docs/plans/2026-05-18-extract-pro.md new file mode 100644 index 000000000..f26d05a8c --- /dev/null +++ b/docs/plans/2026-05-18-extract-pro.md @@ -0,0 +1,44 @@ +# Extract Pro + +## Trello + +- Parent roadmap: https://trello.com/c/3lNaOKjn/149-codegraphy-productization-roadmap +- Task card: https://trello.com/c/x2WvUEPs/141-extract-pro +- Prerequisite: https://trello.com/c/GUCBeHpV/139-extract-core-from-extension-package + +## Goal + +Extract paid Organize behavior out of the free/base extension path while expanding the public plugin API so Pro, Organize, and future plugins can integrate with Core and the Graph View without depending on VS Code-specific code. + +Free CodeGraphy keeps Relationship Graph inspection, Graph Scope, filters/search, Legend/theme editing, normal graph rendering/physics, Graph Cache, Graph Query, CLI/MCP access, and free first-party plugins. + +Paid Organize owns Collapse, Pinned Nodes, Graph Sections, Section Frames, Section Members, ownership, selected paid exports, and section physics. + +## Product Terms + +- Use **Access**, not entitlement. +- Use **CodeGraphy Workspace** for any folder CodeGraphy can analyze. +- Use **Graph Cache** for `/.codegraphy/graph.lbug`. +- Treat Pro as the optional account/access plugin. +- Treat Organize as the paid feature plugin. + +## Execution Slices + +1. Expand `@codegraphy/plugin-api` with public contracts for Core plugins, Access, plugin data, graph presentation metadata, Graph View runtime contributions, context menu contributions, UI slots, projections, and additive force adapters. +2. Add Core-owned plugin runtime and Access checks so Extension, CLI, and MCP can consume the same availability model. +3. Add plugin data `loadData` / `saveData` persistence scoped by plugin id under Workspace Settings. +4. Add Graph View contribution hosts for runtime nodes/edges, projection, context menus, named UI slots, and additive force adapters. +5. Create the optional public `@codegraphy/pro` package for account/status UI contribution and Access Provider registration. +6. Extract Organize features behind plugin contributions and remove free/base extension Organize behavior without legacy `graphLayout` migration. +7. Add focused red-green tests for each public behavior, then run the repo quality gates. +8. Update product docs, package docs, changesets, Trello checklist state, and the PR description after implementation is green. + +## Acceptance Test + +A plugin can contribute an additive D3 force adapter through the public plugin API, the extension installs it into the Graph View physics host, the force affects runtime graph nodes, and disabling/removing the plugin disposes the namespaced force without touching base graph forces. + +## Non-Goals For This PR + +- No billing provider implementation. Account, Stripe/Supabase/Vercel, and subscription mechanics belong to the follow-up Pro Account, Billing, and Access card. +- No Bookmarks implementation. Bookmarks build on the extracted Pro/Organize foundation. +- No Team Bookmark Sync implementation. From 143b3bf4d2b21b98bbe2c399d7fe23a316aa0ad4 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 18 May 2026 17:42:14 -0700 Subject: [PATCH 02/49] Add Extract Pro plugin API contracts --- packages/plugin-api/package.json | 12 +- packages/plugin-api/src/access.ts | 27 ++++ packages/plugin-api/src/data.ts | 13 ++ packages/plugin-api/src/graphView.ts | 123 +++++++++++++++ packages/plugin-api/src/index.ts | 37 +++++ packages/plugin-api/src/plugin.ts | 11 ++ .../plugin-api/tests/pluginContracts.test.ts | 142 ++++++++++++++++++ packages/plugin-api/tsconfig.json | 2 +- packages/plugin-api/vitest.config.ts | 8 + 9 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 packages/plugin-api/src/access.ts create mode 100644 packages/plugin-api/src/data.ts create mode 100644 packages/plugin-api/src/graphView.ts create mode 100644 packages/plugin-api/tests/pluginContracts.test.ts create mode 100644 packages/plugin-api/vitest.config.ts diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index ddc220b72..7ad59c61c 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -30,6 +30,15 @@ }, "./plugin": { "types": "./src/plugin.ts" + }, + "./access": { + "types": "./src/access.ts" + }, + "./data": { + "types": "./src/data.ts" + }, + "./graph-view": { + "types": "./src/graphView.ts" } }, "types": "src/index.ts", @@ -37,7 +46,8 @@ "access": "public" }, "scripts": { - "lint": "eslint src", + "lint": "eslint src tests vitest.config.ts", + "test": "vitest run --config vitest.config.ts", "typecheck": "tsc --noEmit -p tsconfig.json" }, "files": [ diff --git a/packages/plugin-api/src/access.ts b/packages/plugin-api/src/access.ts new file mode 100644 index 000000000..5ab6c1f95 --- /dev/null +++ b/packages/plugin-api/src/access.ts @@ -0,0 +1,27 @@ +/** + * @fileoverview Host-agnostic Access contracts for CodeGraphy plugins. + * @module @codegraphy/plugin-api/access + */ + +export type CodeGraphyAccessKey = string & {}; + +export type CodeGraphyAccessState = 'granted' | 'missing' | 'unknown'; + +export interface IAccessRequest { + access: CodeGraphyAccessKey; + workspaceRoot?: string; + pluginId?: string; +} + +export interface IAccessResult { + access: CodeGraphyAccessKey; + state: CodeGraphyAccessState; + reason?: string; + expiresAt?: string; +} + +export interface IAccessProvider { + id: string; + provides: readonly CodeGraphyAccessKey[]; + getAccess(request: IAccessRequest): IAccessResult | Promise; +} diff --git a/packages/plugin-api/src/data.ts b/packages/plugin-api/src/data.ts new file mode 100644 index 000000000..51a5c2361 --- /dev/null +++ b/packages/plugin-api/src/data.ts @@ -0,0 +1,13 @@ +/** + * @fileoverview Plugin-owned data persistence contracts. + * @module @codegraphy/plugin-api/data + */ + +export interface IPluginDataSaveOptions { + undoLabel?: string; +} + +export interface IPluginDataHost { + loadData(fallback: T): T; + saveData(data: T, options?: IPluginDataSaveOptions): Promise; +} diff --git a/packages/plugin-api/src/graphView.ts b/packages/plugin-api/src/graphView.ts new file mode 100644 index 000000000..49f19345d --- /dev/null +++ b/packages/plugin-api/src/graphView.ts @@ -0,0 +1,123 @@ +/** + * @fileoverview Graph View contribution contracts for CodeGraphy plugins. + * @module @codegraphy/plugin-api/graphView + */ + +import type { CodeGraphyAccessKey } from './access'; +import type { + GraphEdgeKind, + GraphMetadata, + IGraphData, + IGraphEdge, + IGraphNode, + NodeType, +} from './graph'; + +export type GraphViewAccessRequirement = + CodeGraphyAccessKey + | readonly CodeGraphyAccessKey[]; + +export interface IGraphViewContributionBase { + id: string; + label: string; + requiresAccess?: GraphViewAccessRequirement; + metadata?: GraphMetadata; +} + +export interface IGraphViewContributionContext { + visibleGraph: IGraphData; + workspaceRoot?: string; +} + +export interface IGraphViewRuntimeNode extends IGraphNode { + ownerPluginId?: string; + runtimeNodeType?: string; +} + +export interface IGraphViewRuntimeEdge extends IGraphEdge { + ownerPluginId?: string; + runtimeEdgeType?: string; +} + +export interface IGraphViewRuntimeNodeContribution extends IGraphViewContributionBase { + createNodes(context: IGraphViewContributionContext): readonly IGraphViewRuntimeNode[]; +} + +export interface IGraphViewRuntimeEdgeContribution extends IGraphViewContributionBase { + createEdges(context: IGraphViewContributionContext): readonly IGraphViewRuntimeEdge[]; +} + +export interface IGraphViewProjectionContribution extends IGraphViewContributionBase { + project(context: IGraphViewContributionContext): IGraphData; +} + +export interface IGraphViewForceAdapterContext extends IGraphViewContributionContext { + nodes: readonly IGraphViewRuntimeNode[]; + edges: readonly IGraphViewRuntimeEdge[]; +} + +export interface IGraphViewForceAdapter { + tick?(alpha?: number): void; + dispose(): void; +} + +export interface IGraphViewForceAdapterContribution extends IGraphViewContributionBase { + create(context: IGraphViewForceAdapterContext): IGraphViewForceAdapter; +} + +export type GraphViewUiSlot = + | 'graph.toolbar' + | 'graph.panelSlot' + | 'graph.stage.worldOverlay' + | 'graph.stage.viewportOverlay'; + +export type GraphViewUiContributionView = + | { kind: 'command'; command: string } + | { kind: 'panel'; panelId: string } + | { kind: 'webview'; viewId: string }; + +export interface IGraphViewUiSlotContribution extends IGraphViewContributionBase { + slot: GraphViewUiSlot; + view: GraphViewUiContributionView; + order?: number; +} + +export type GraphViewContextMenuTargetSelector = + | { kind: 'background' } + | { + kind: 'node'; + nodeTypes?: readonly NodeType[]; + runtimeNodeTypes?: readonly string[]; + } + | { + kind: 'edge'; + edgeKinds?: readonly GraphEdgeKind[]; + runtimeEdgeTypes?: readonly string[]; + } + | { + kind: 'multiSelection'; + nodeTypes?: readonly NodeType[]; + runtimeNodeTypes?: readonly string[]; + } + | { kind: 'runtimeNodeType'; runtimeNodeTypes: readonly string[] } + | { kind: 'runtimeEdgeType'; runtimeEdgeTypes: readonly string[] }; + +export interface IGraphViewContextMenuRunContext { + target: GraphViewContextMenuTargetSelector; + selectedNodeIds: readonly string[]; + selectedEdgeIds: readonly string[]; +} + +export interface IGraphViewContextMenuContribution extends IGraphViewContributionBase { + targets: readonly GraphViewContextMenuTargetSelector[]; + run(context: IGraphViewContextMenuRunContext): void | Promise; +} + +export interface IGraphViewContributions { + runtimeNodes?: readonly IGraphViewRuntimeNodeContribution[]; + runtimeEdges?: readonly IGraphViewRuntimeEdgeContribution[]; + projections?: readonly IGraphViewProjectionContribution[]; + forces?: readonly IGraphViewForceAdapterContribution[]; + contextMenu?: readonly IGraphViewContextMenuContribution[]; + ui?: readonly IGraphViewUiSlotContribution[]; +} diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index 31a06fdce..b306a4722 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -13,9 +13,24 @@ // Disposable export type { Disposable } from './disposable'; +// Access +export type { + CodeGraphyAccessKey, + CodeGraphyAccessState, + IAccessProvider, + IAccessRequest, + IAccessResult, +} from './access'; + // Connection source metadata export type { IConnectionSource } from './connection'; +// Plugin data +export type { + IPluginDataHost, + IPluginDataSaveOptions, +} from './data'; + // Analysis export type { IAnalysisNode, @@ -45,6 +60,28 @@ export type { IGraphNodeSymbolMetadata, } from './graph'; +// Graph View contributions +export type { + GraphViewAccessRequirement, + GraphViewContextMenuTargetSelector, + GraphViewUiContributionView, + GraphViewUiSlot, + IGraphViewContributionBase, + IGraphViewContributionContext, + IGraphViewContextMenuContribution, + IGraphViewContextMenuRunContext, + IGraphViewContributions, + IGraphViewForceAdapter, + IGraphViewForceAdapterContext, + IGraphViewForceAdapterContribution, + IGraphViewProjectionContribution, + IGraphViewRuntimeEdge, + IGraphViewRuntimeEdgeContribution, + IGraphViewRuntimeNode, + IGraphViewRuntimeNodeContribution, + IGraphViewUiSlotContribution, +} from './graphView'; + // Events export type { EventName, diff --git a/packages/plugin-api/src/plugin.ts b/packages/plugin-api/src/plugin.ts index 3e5aafaaf..d9e08888b 100644 --- a/packages/plugin-api/src/plugin.ts +++ b/packages/plugin-api/src/plugin.ts @@ -12,8 +12,10 @@ import type { IPluginEdgeType, IPluginNodeType, } from './analysis'; +import type { CodeGraphyAccessKey, IAccessProvider } from './access'; import type { IConnectionSource } from './connection'; import type { GraphNodeShape2D, GraphNodeShape3D, IGraphData } from './graph'; +import type { IGraphViewContributions } from './graphView'; /** * File metadata passed to bulk analysis hooks. @@ -87,6 +89,12 @@ export interface IPlugin { /** File extensions this plugin can handle (e.g., `['.ts', '.tsx']`, or `['*']` for all files). */ supportedExtensions: string[]; + /** Access required before this plugin's gated contributions can run. */ + requiresAccess?: CodeGraphyAccessKey | readonly CodeGraphyAccessKey[]; + + /** Optional Access Provider registered by account/status plugins such as Pro. */ + accessProvider?: IAccessProvider; + /** * Connection sources this plugin supports. * Each source describes a category of relations the plugin can emit. @@ -112,6 +120,9 @@ export interface IPlugin { */ defaultFilters?: string[]; + /** Optional Graph View runtime, UI, menu, projection, and force contributions. */ + graphView?: IGraphViewContributions; + // --------------------------------------------------------------------------- // Core analysis contract // --------------------------------------------------------------------------- diff --git a/packages/plugin-api/tests/pluginContracts.test.ts b/packages/plugin-api/tests/pluginContracts.test.ts new file mode 100644 index 000000000..a9ddf2896 --- /dev/null +++ b/packages/plugin-api/tests/pluginContracts.test.ts @@ -0,0 +1,142 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import type { + CodeGraphyAccessKey, + IAccessProvider, + IGraphViewContextMenuContribution, + IGraphViewForceAdapterContribution, + IGraphViewProjectionContribution, + IGraphViewRuntimeEdgeContribution, + IGraphViewRuntimeNodeContribution, + IGraphViewUiSlotContribution, + IPlugin, + IPluginDataHost, +} from '../src'; + +describe('plugin API contracts', () => { + it('lets Pro register access plumbing and contribute account UI without owning Organize behavior', () => { + const organizeAccess = 'organize' as CodeGraphyAccessKey; + + const plugin = { + id: 'codegraphy.pro', + name: 'CodeGraphy Pro', + version: '0.1.0', + apiVersion: '^2.0.0', + supportedExtensions: [], + accessProvider: { + id: 'codegraphy.pro.access', + provides: [organizeAccess], + async getAccess() { + return { + access: organizeAccess, + state: 'granted', + }; + }, + } satisfies IAccessProvider, + graphView: { + ui: [{ + id: 'codegraphy.pro.account', + slot: 'graph.toolbar', + label: 'Account', + view: { kind: 'command', command: 'codegraphy.pro.account' }, + } satisfies IGraphViewUiSlotContribution], + }, + } satisfies IPlugin; + + expectTypeOf(plugin.accessProvider).toMatchTypeOf(); + expectTypeOf(plugin.graphView.ui[0].slot).toEqualTypeOf<'graph.toolbar'>(); + }); + + it('lets Organize contribute gated runtime graph behavior through public Graph View contracts', () => { + const organizeAccess = 'organize' as CodeGraphyAccessKey; + + const runtimeNode = { + id: 'codegraphy.organize.section-node', + label: 'Section Node', + requiresAccess: organizeAccess, + createNodes() { + return [{ + id: 'section:frontend', + label: 'Frontend', + color: '#84cc16', + nodeType: 'organize:section', + }]; + }, + } satisfies IGraphViewRuntimeNodeContribution; + + const runtimeEdge = { + id: 'codegraphy.organize.section-member-edge', + label: 'Section Member Edge', + requiresAccess: organizeAccess, + createEdges() { + return [{ + id: 'section:frontend->src/App.tsx#organize:member', + from: 'section:frontend', + to: 'src/App.tsx', + kind: 'organize:member', + sources: [], + }]; + }, + } satisfies IGraphViewRuntimeEdgeContribution; + + const projection = { + id: 'codegraphy.organize.collapse', + label: 'Collapse Projection', + requiresAccess: organizeAccess, + project({ visibleGraph }) { + return visibleGraph; + }, + } satisfies IGraphViewProjectionContribution; + + const force = { + id: 'codegraphy.organize.section-physics', + label: 'Section Physics', + requiresAccess: organizeAccess, + create() { + return { + tick() {}, + dispose() {}, + }; + }, + } satisfies IGraphViewForceAdapterContribution; + + const contextMenu = { + id: 'codegraphy.organize.assign-section', + label: 'Assign to Section', + requiresAccess: organizeAccess, + targets: [{ kind: 'multiSelection' }], + run() {}, + } satisfies IGraphViewContextMenuContribution; + + const plugin = { + id: 'codegraphy.organize', + name: 'CodeGraphy Organize', + version: '0.1.0', + apiVersion: '^2.0.0', + supportedExtensions: ['*'], + requiresAccess: organizeAccess, + graphView: { + runtimeNodes: [runtimeNode], + runtimeEdges: [runtimeEdge], + projections: [projection], + forces: [force], + contextMenu: [contextMenu], + }, + } satisfies IPlugin; + + expectTypeOf(plugin.graphView.forces[0]).toMatchTypeOf(); + expectTypeOf(plugin.graphView.contextMenu[0].targets[0]).toMatchTypeOf<{ kind: 'multiSelection' }>(); + }); + + it('exposes Obsidian-style plugin-owned data persistence', async () => { + const host = { + loadData(fallback) { + return fallback; + }, + async saveData(_data, _options) {}, + } satisfies IPluginDataHost; + + expectTypeOf(host.loadData({ expanded: true })).toEqualTypeOf<{ expanded: boolean }>(); + await host.saveData({ expanded: false }, { undoLabel: 'Collapse section' }); + }); +}); diff --git a/packages/plugin-api/tsconfig.json b/packages/plugin-api/tsconfig.json index 590e9fa9a..a15c9eebc 100644 --- a/packages/plugin-api/tsconfig.json +++ b/packages/plugin-api/tsconfig.json @@ -12,6 +12,6 @@ "forceConsistentCasingInFileNames": true, "noEmit": true }, - "include": ["src"], + "include": ["src", "tests", "vitest.config.ts"], "exclude": ["node_modules", "dist"] } diff --git a/packages/plugin-api/vitest.config.ts b/packages/plugin-api/vitest.config.ts new file mode 100644 index 000000000..8363e1642 --- /dev/null +++ b/packages/plugin-api/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + }, +}); From 05eae743ae7e1a15a5d477e5e57a3723d83dcad1 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 18 May 2026 17:44:57 -0700 Subject: [PATCH 03/49] Add Core plugin access checks --- packages/core/src/index.ts | 6 + packages/core/src/plugins/access/checks.ts | 125 +++++++++++++++++ packages/core/src/plugins/registry.ts | 107 +++++++++++++++ .../core/tests/plugins/access/checks.test.ts | 128 ++++++++++++++++++ 4 files changed, 366 insertions(+) create mode 100644 packages/core/src/plugins/access/checks.ts create mode 100644 packages/core/tests/plugins/access/checks.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aee0d2194..2ac277e9f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -172,6 +172,12 @@ export { CorePluginRegistry, } from './plugins/registry'; export type { CorePluginInfo } from './plugins/registry'; +export type { + CoreGraphViewContributionEntry, + CoreGraphViewContributionSet, + CorePluginAccessCheck, + CorePluginAccessContext, +} from './plugins/access/checks'; export type { LoadedCodeGraphyWorkspacePluginPackage, LoadCodeGraphyWorkspacePluginPackagesOptions, diff --git a/packages/core/src/plugins/access/checks.ts b/packages/core/src/plugins/access/checks.ts new file mode 100644 index 000000000..cd9f54e30 --- /dev/null +++ b/packages/core/src/plugins/access/checks.ts @@ -0,0 +1,125 @@ +import type { + CodeGraphyAccessKey, + GraphViewAccessRequirement, + IAccessProvider, + IAccessResult, + IGraphViewContextMenuContribution, + IGraphViewForceAdapterContribution, + IGraphViewProjectionContribution, + IGraphViewRuntimeEdgeContribution, + IGraphViewRuntimeNodeContribution, + IGraphViewUiSlotContribution, + IPlugin, +} from '@codegraphy/plugin-api'; + +export interface CorePluginAccessContext { + workspaceRoot?: string; +} + +export interface CorePluginAccessCheck { + pluginId: string; + available: boolean; + access: IAccessResult[]; +} + +export interface CoreGraphViewContributionEntry { + pluginId: string; + contribution: TContribution; +} + +export interface CoreGraphViewContributionSet { + runtimeNodes: CoreGraphViewContributionEntry[]; + runtimeEdges: CoreGraphViewContributionEntry[]; + projections: CoreGraphViewContributionEntry[]; + forces: CoreGraphViewContributionEntry[]; + contextMenu: CoreGraphViewContributionEntry[]; + ui: CoreGraphViewContributionEntry[]; +} + +export function createEmptyGraphViewContributionSet(): CoreGraphViewContributionSet { + return { + runtimeNodes: [], + runtimeEdges: [], + projections: [], + forces: [], + contextMenu: [], + ui: [], + }; +} + +function normalizeAccessRequirement( + requirement: GraphViewAccessRequirement | undefined, +): CodeGraphyAccessKey[] { + if (!requirement) { + return []; + } + + return typeof requirement === 'string' + ? [requirement] + : [...requirement]; +} + +function findAccessProvider( + providers: readonly IAccessProvider[], + access: CodeGraphyAccessKey, +): IAccessProvider | undefined { + return providers.find(provider => provider.provides.includes(access)); +} + +async function readAccessResult( + provider: IAccessProvider, + access: CodeGraphyAccessKey, + pluginId: string, + context: CorePluginAccessContext, +): Promise { + try { + return await provider.getAccess({ + access, + pluginId, + ...(context.workspaceRoot ? { workspaceRoot: context.workspaceRoot } : {}), + }); + } catch (error) { + return { + access, + state: 'unknown', + reason: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function resolvePluginAccess( + plugin: IPlugin, + providers: readonly IAccessProvider[], + context: CorePluginAccessContext = {}, + requirement: GraphViewAccessRequirement | undefined = plugin.requiresAccess, +): Promise { + const requiredAccess = normalizeAccessRequirement(requirement); + if (requiredAccess.length === 0) { + return { + pluginId: plugin.id, + available: true, + access: [], + }; + } + + const accessResults: IAccessResult[] = []; + for (const access of requiredAccess) { + const provider = findAccessProvider(providers, access); + if (!provider) { + accessResults.push({ + access, + state: 'missing', + reason: `No Access Provider registered for '${access}'.`, + }); + continue; + } + + accessResults.push(await readAccessResult(provider, access, plugin.id, context)); + } + + return { + pluginId: plugin.id, + available: accessResults.every(result => result.state === 'granted'), + access: accessResults, + }; +} diff --git a/packages/core/src/plugins/registry.ts b/packages/core/src/plugins/registry.ts index 8f5263552..28309c26f 100644 --- a/packages/core/src/plugins/registry.ts +++ b/packages/core/src/plugins/registry.ts @@ -1,6 +1,13 @@ import type { IFileAnalysisResult, IGraphData, + IGraphViewContextMenuContribution, + IGraphViewForceAdapterContribution, + IGraphViewProjectionContribution, + IGraphViewRuntimeEdgeContribution, + IGraphViewRuntimeNodeContribution, + IGraphViewUiSlotContribution, + IAccessProvider, IPlugin, IPluginAnalysisContext, IPluginEdgeType, @@ -10,6 +17,14 @@ import type { IProjectedConnection } from '../analysis/projectedConnection'; import { initializeAll, initializePlugin } from './lifecycle/initialize'; import { notifyFilesChanged, type IPluginFilesChangedResult } from './lifecycle/notify/filesChanged'; import { notifyGraphRebuild, notifyPostAnalyze, notifyPreAnalyze } from './lifecycle/notify/analysis'; +import { + createEmptyGraphViewContributionSet, + resolvePluginAccess, + type CoreGraphViewContributionEntry, + type CoreGraphViewContributionSet, + type CorePluginAccessCheck, + type CorePluginAccessContext, +} from './access/checks'; import { normalizePluginExtension } from './routing/fileExtensions'; import { analyzeFile, analyzeFileResult, type CoreFileAnalysisResultProvider } from './routing/router/analyze'; import { @@ -231,6 +246,98 @@ export class CorePluginRegistry { return [...new Set(patterns)]; } + private listAccessProviders(): IAccessProvider[] { + return this.list() + .map(info => info.plugin.accessProvider) + .filter((provider): provider is IAccessProvider => provider !== undefined); + } + + async getPluginAvailability( + pluginId: string, + context: CorePluginAccessContext = {}, + ): Promise { + const info = this.plugins.get(pluginId); + if (!info) { + return undefined; + } + + return resolvePluginAccess(info.plugin, this.listAccessProviders(), context); + } + + private async pushAvailableGraphViewContributions( + plugin: IPlugin, + contributions: readonly TContribution[] | undefined, + target: CoreGraphViewContributionEntry[], + context: CorePluginAccessContext, + ): Promise { + for (const contribution of contributions ?? []) { + const contributionAccess = await resolvePluginAccess( + plugin, + this.listAccessProviders(), + context, + contribution.requiresAccess as never, + ); + if (contributionAccess.available) { + target.push({ + pluginId: plugin.id, + contribution, + }); + } + } + } + + async listAvailableGraphViewContributions( + context: CorePluginAccessContext = {}, + ): Promise { + const contributions = createEmptyGraphViewContributionSet(); + + for (const info of this.plugins.values()) { + const pluginAccess = await resolvePluginAccess(info.plugin, this.listAccessProviders(), context); + if (!pluginAccess.available) { + continue; + } + + await this.pushAvailableGraphViewContributions( + info.plugin, + info.plugin.graphView?.runtimeNodes, + contributions.runtimeNodes, + context, + ); + await this.pushAvailableGraphViewContributions( + info.plugin, + info.plugin.graphView?.runtimeEdges, + contributions.runtimeEdges, + context, + ); + await this.pushAvailableGraphViewContributions( + info.plugin, + info.plugin.graphView?.projections, + contributions.projections, + context, + ); + await this.pushAvailableGraphViewContributions( + info.plugin, + info.plugin.graphView?.forces, + contributions.forces, + context, + ); + await this.pushAvailableGraphViewContributions( + info.plugin, + info.plugin.graphView?.contextMenu, + contributions.contextMenu, + context, + ); + await this.pushAvailableGraphViewContributions( + info.plugin, + info.plugin.graphView?.ui, + contributions.ui, + context, + ); + } + + return contributions; + } + async analyzeFile( filePath: string, content: string, diff --git a/packages/core/tests/plugins/access/checks.test.ts b/packages/core/tests/plugins/access/checks.test.ts new file mode 100644 index 000000000..10202624f --- /dev/null +++ b/packages/core/tests/plugins/access/checks.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; + +import { CorePluginRegistry } from '../../../src'; +import type { CodeGraphyAccessKey, IPlugin } from '@codegraphy/plugin-api'; + +function createPlugin(overrides: Partial): IPlugin { + return { + id: 'codegraphy.test', + name: 'Test Plugin', + version: '1.0.0', + apiVersion: '^2.0.0', + supportedExtensions: [], + ...overrides, + }; +} + +describe('Core plugin Access checks', () => { + it('keeps Access Provider plugins available while hiding gated plugin contributions without granted Access', async () => { + const organizeAccess = 'organize' as CodeGraphyAccessKey; + const registry = new CorePluginRegistry(); + + registry.register(createPlugin({ + id: 'codegraphy.pro', + accessProvider: { + id: 'codegraphy.pro.access', + provides: [organizeAccess], + async getAccess() { + return { + access: organizeAccess, + state: 'missing', + reason: 'Sign in to CodeGraphy Pro.', + }; + }, + }, + graphView: { + ui: [{ + id: 'codegraphy.pro.account', + label: 'Account', + slot: 'graph.toolbar', + view: { kind: 'command', command: 'codegraphy.pro.account' }, + }], + }, + })); + + registry.register(createPlugin({ + id: 'codegraphy.organize', + requiresAccess: organizeAccess, + graphView: { + forces: [{ + id: 'codegraphy.organize.section-physics', + label: 'Section Physics', + create() { + return { dispose() {} }; + }, + }], + }, + })); + + await expect(registry.getPluginAvailability('codegraphy.pro')).resolves.toMatchObject({ + pluginId: 'codegraphy.pro', + available: true, + access: [], + }); + await expect(registry.getPluginAvailability('codegraphy.organize')).resolves.toMatchObject({ + pluginId: 'codegraphy.organize', + available: false, + access: [{ + access: organizeAccess, + state: 'missing', + }], + }); + await expect(registry.listAvailableGraphViewContributions()).resolves.toMatchObject({ + ui: [{ + pluginId: 'codegraphy.pro', + contribution: { id: 'codegraphy.pro.account' }, + }], + forces: [], + }); + }); + + it('exposes gated plugin contributions when an Access Provider grants Access', async () => { + const organizeAccess = 'organize' as CodeGraphyAccessKey; + const registry = new CorePluginRegistry(); + + registry.register(createPlugin({ + id: 'codegraphy.pro', + accessProvider: { + id: 'codegraphy.pro.access', + provides: [organizeAccess], + async getAccess() { + return { + access: organizeAccess, + state: 'granted', + }; + }, + }, + })); + + registry.register(createPlugin({ + id: 'codegraphy.organize', + requiresAccess: organizeAccess, + graphView: { + forces: [{ + id: 'codegraphy.organize.section-physics', + label: 'Section Physics', + create() { + return { dispose() {} }; + }, + }], + }, + })); + + await expect(registry.getPluginAvailability('codegraphy.organize')).resolves.toMatchObject({ + pluginId: 'codegraphy.organize', + available: true, + access: [{ + access: organizeAccess, + state: 'granted', + }], + }); + await expect(registry.listAvailableGraphViewContributions()).resolves.toMatchObject({ + forces: [{ + pluginId: 'codegraphy.organize', + contribution: { id: 'codegraphy.organize.section-physics' }, + }], + }); + }); +}); From fd9db7d10671ac070a93847414b0daee4dd6ea2b Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 18 May 2026 17:48:19 -0700 Subject: [PATCH 04/49] Add workspace plugin data host --- packages/core/src/index.ts | 1 + packages/core/src/plugins/data/host.ts | 28 +++++++++ packages/core/src/workspace/settings.ts | 7 +++ packages/core/tests/plugins/data/host.test.ts | 57 +++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 packages/core/src/plugins/data/host.ts create mode 100644 packages/core/tests/plugins/data/host.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2ac277e9f..209298498 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -178,6 +178,7 @@ export type { CorePluginAccessCheck, CorePluginAccessContext, } from './plugins/access/checks'; +export { createWorkspacePluginDataHost } from './plugins/data/host'; export type { LoadedCodeGraphyWorkspacePluginPackage, LoadCodeGraphyWorkspacePluginPackagesOptions, diff --git a/packages/core/src/plugins/data/host.ts b/packages/core/src/plugins/data/host.ts new file mode 100644 index 000000000..c4ba24004 --- /dev/null +++ b/packages/core/src/plugins/data/host.ts @@ -0,0 +1,28 @@ +import type { IPluginDataHost } from '@codegraphy/plugin-api'; +import { + readCodeGraphyWorkspaceSettingsOrInitial, + writeCodeGraphyWorkspaceSettings, +} from '../../workspace/settings'; + +export function createWorkspacePluginDataHost( + workspaceRoot: string, + pluginId: string, +): IPluginDataHost { + return { + loadData(fallback: T): T { + const settings = readCodeGraphyWorkspaceSettingsOrInitial(workspaceRoot); + const data = settings.pluginData?.[pluginId]; + return data === undefined ? fallback : data as T; + }, + async saveData(data: T): Promise { + const settings = readCodeGraphyWorkspaceSettingsOrInitial(workspaceRoot); + writeCodeGraphyWorkspaceSettings(workspaceRoot, { + ...settings, + pluginData: { + ...(settings.pluginData ?? {}), + [pluginId]: data, + }, + }); + }, + }; +} diff --git a/packages/core/src/workspace/settings.ts b/packages/core/src/workspace/settings.ts index c07f11873..0d20bb955 100644 --- a/packages/core/src/workspace/settings.ts +++ b/packages/core/src/workspace/settings.ts @@ -20,6 +20,7 @@ export interface CodeGraphyWorkspaceSettings { filterPatterns: string[]; disabledCustomFilterPatterns: string[]; plugins: CodeGraphyWorkspacePluginSettings[]; + pluginData?: Record; } function readStringArray(value: unknown): string[] { @@ -36,6 +37,10 @@ function readOptions(value: unknown): Record | undefined { return isRecord(value) ? { ...value } : undefined; } +function normalizePluginData(value: unknown): Record { + return isRecord(value) ? { ...value } : {}; +} + function normalizePluginSettings(value: unknown): CodeGraphyWorkspacePluginSettings[] { if (!Array.isArray(value)) { return []; @@ -77,6 +82,7 @@ export function createDefaultCodeGraphyWorkspaceSettings(): CodeGraphyWorkspaceS filterPatterns: [], disabledCustomFilterPatterns: [], plugins: [], + pluginData: {}, }; } @@ -114,6 +120,7 @@ export function normalizeCodeGraphyWorkspaceSettings( filterPatterns: [...new Set(readStringArray(value.filterPatterns))], disabledCustomFilterPatterns: [...new Set(readStringArray(value.disabledCustomFilterPatterns))], plugins: normalizePluginSettings(value.plugins), + pluginData: normalizePluginData(value.pluginData), }; } diff --git a/packages/core/tests/plugins/data/host.test.ts b/packages/core/tests/plugins/data/host.test.ts new file mode 100644 index 000000000..fdc562bf6 --- /dev/null +++ b/packages/core/tests/plugins/data/host.test.ts @@ -0,0 +1,57 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { + CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, + createWorkspacePluginDataHost, + getWorkspaceSettingsPath, + readCodeGraphyWorkspaceSettings, + writeCodeGraphyWorkspaceSettings, +} from '../../../src'; + +async function createWorkspace(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-plugin-data-')); +} + +describe('Workspace plugin data host', () => { + it('loads fallback data without writing settings before a plugin saves data', async () => { + const workspaceRoot = await createWorkspace(); + const host = createWorkspacePluginDataHost(workspaceRoot, 'codegraphy.organize'); + + expect(host.loadData({ sections: [] })).toEqual({ sections: [] }); + await expect(fs.access(getWorkspaceSettingsPath(workspaceRoot))).rejects.toThrow(); + }); + + it('saves and reloads plugin-owned data under plugin id while preserving workspace plugin settings', async () => { + const workspaceRoot = await createWorkspace(); + writeCodeGraphyWorkspaceSettings(workspaceRoot, { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + plugins: [{ + package: CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, + }], + }); + + const host = createWorkspacePluginDataHost(workspaceRoot, 'codegraphy.organize'); + await host.saveData({ sections: ['frontend'] }, { undoLabel: 'Save section' }); + + expect(createWorkspacePluginDataHost(workspaceRoot, 'codegraphy.organize').loadData({ + sections: [], + })).toEqual({ + sections: ['frontend'], + }); + expect(JSON.parse( + await fs.readFile(getWorkspaceSettingsPath(workspaceRoot), 'utf-8'), + )).toMatchObject({ + plugins: [{ + package: CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, + }], + pluginData: { + 'codegraphy.organize': { + sections: ['frontend'], + }, + }, + }); + }); +}); From c307619697a07c2c6dbb0090e7c419d6e019b543 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 18 May 2026 17:52:47 -0700 Subject: [PATCH 05/49] Add Graph View runtime contributions --- .../webview/components/graph/model/build.ts | 15 +++- .../components/graph/model/link/build.ts | 3 +- .../components/graph/model/node/build.ts | 1 + .../graph/model/runtimeContributions.ts | 70 +++++++++++++++ .../graph/model/runtimeContributions.test.ts | 88 +++++++++++++++++++ packages/plugin-api/src/graph.ts | 6 ++ 6 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 packages/extension/src/webview/components/graph/model/runtimeContributions.ts create mode 100644 packages/extension/tests/webview/graph/model/runtimeContributions.test.ts diff --git a/packages/extension/src/webview/components/graph/model/build.ts b/packages/extension/src/webview/components/graph/model/build.ts index 2494f975e..02bba61f2 100644 --- a/packages/extension/src/webview/components/graph/model/build.ts +++ b/packages/extension/src/webview/components/graph/model/build.ts @@ -1,5 +1,6 @@ import type { LinkObject, NodeObject } from 'react-force-graph-2d'; -import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { CoreGraphViewContributionSet } from '@codegraphy/core'; +import type { GraphMetadata, IGraphData } from '../../../../shared/graph/contracts'; import type { BidirectionalEdgeMode, NodeShape2D, NodeShape3D, NodeSizeMode } from '../../../../shared/settings/modes'; import type { GraphLayoutMode, GraphLayoutSettings } from '../../../../shared/settings/graphLayout'; import type { ThemeKind } from '../../../theme/useTheme'; @@ -8,6 +9,7 @@ import type { NodeType } from '../../../../shared/graph/contracts'; import { buildGraphLinks } from './link/build'; import { buildGraphNodes } from './node/build'; import { projectGraphSectionsForRendering } from './sectionProjection'; +import { applyGraphViewRuntimeContributions } from './runtimeContributions'; export { processEdges } from './edgeProcessing'; import { calculateNodeSizes } from './node/sizing'; export { DEFAULT_NODE_SIZE, FAVORITE_BORDER_COLOR, getDepthOpacity, getDepthSizeMultiplier, getNodeType, resolveDirectionColor } from './node/display'; @@ -28,6 +30,7 @@ export type FGNode = NodeObject & { shape2D?: NodeShape2D; shape3D?: NodeShape3D; imageUrl?: string; + metadata?: GraphMetadata; collapsedDescendantCount?: number; hiddenDescendantCount?: number; isCollapsible?: boolean; @@ -60,12 +63,14 @@ export type FGLink = LinkObject & { curvature?: number; curvatureGroupId?: string; kind?: string; + metadata?: GraphMetadata; projectedEdgeCount?: number; projectedEdgeIds?: string[]; }; export interface BuildGraphDataOptions { data: IGraphData; + graphViewContributions?: CoreGraphViewContributionSet; appearance?: GraphAppearance; nodeSizeMode: NodeSizeMode; theme: ThemeKind; @@ -81,15 +86,19 @@ export interface BuildGraphDataOptions { export function buildGraphData(options: BuildGraphDataOptions): { nodes: FGNode[]; links: FGLink[] } { const appearance = options.appearance ?? DEFAULT_GRAPH_APPEARANCE; const graphMode = options.graphMode ?? '2d'; + const runtimeData = applyGraphViewRuntimeContributions( + options.data, + options.graphViewContributions, + ); const projected = projectGraphSectionsForRendering({ - data: options.data, + data: runtimeData, graphLayout: options.graphLayout, graphMode, timelineActive: options.timelineActive, }); const nodeSizes = calculateNodeSizes(projected.data.nodes, projected.data.edges, options.nodeSizeMode); const nodes = buildGraphNodes({ - allNodeIds: options.data.nodes.map(node => node.id), + allNodeIds: runtimeData.nodes.map(node => node.id), nodes: projected.data.nodes, edges: projected.data.edges, appearance, diff --git a/packages/extension/src/webview/components/graph/model/link/build.ts b/packages/extension/src/webview/components/graph/model/link/build.ts index 9ba0a398e..dcc23ee5d 100644 --- a/packages/extension/src/webview/components/graph/model/link/build.ts +++ b/packages/extension/src/webview/components/graph/model/link/build.ts @@ -16,10 +16,11 @@ export function buildGraphLinks(edges: Array, m bidirectional: edge.bidirectional ?? false, baseColor: edge.color ?? (edge.bidirectional ? '#60a5fa' : undefined), curvatureGroupId: edge.kind, + kind: edge.kind, + metadata: edge.metadata, }; if (edge.projectedEdgeCount !== undefined || edge.projectedEdgeIds !== undefined) { - link.kind = edge.kind; link.projectedEdgeCount = edge.projectedEdgeCount; link.projectedEdgeIds = edge.projectedEdgeIds; } diff --git a/packages/extension/src/webview/components/graph/model/node/build.ts b/packages/extension/src/webview/components/graph/model/node/build.ts index c3fa48ad4..86f8e2ed6 100644 --- a/packages/extension/src/webview/components/graph/model/node/build.ts +++ b/packages/extension/src/webview/components/graph/model/node/build.ts @@ -237,6 +237,7 @@ function createGraphNode( shape2D: node.shape2D, shape3D: node.shape3D, imageUrl: node.imageUrl, + metadata: node.metadata, isCollapsible: node.isCollapsible, isCollapsed: node.isCollapsed, collapsedDescendantCount: node.collapsedDescendantCount, diff --git a/packages/extension/src/webview/components/graph/model/runtimeContributions.ts b/packages/extension/src/webview/components/graph/model/runtimeContributions.ts new file mode 100644 index 000000000..739aa8bee --- /dev/null +++ b/packages/extension/src/webview/components/graph/model/runtimeContributions.ts @@ -0,0 +1,70 @@ +import type { CoreGraphViewContributionSet } from '@codegraphy/core'; +import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../shared/graph/contracts'; + +function appendUniqueNodes( + target: IGraphNode[], + nodeIds: Set, + nodes: readonly IGraphNode[], +): void { + for (const node of nodes) { + if (nodeIds.has(node.id)) { + continue; + } + + target.push(node); + nodeIds.add(node.id); + } +} + +function appendUniqueEdges( + target: IGraphEdge[], + edgeIds: Set, + nodeIds: ReadonlySet, + edges: readonly IGraphEdge[], +): void { + for (const edge of edges) { + if (edgeIds.has(edge.id) || !nodeIds.has(edge.from) || !nodeIds.has(edge.to)) { + continue; + } + + target.push(edge); + edgeIds.add(edge.id); + } +} + +export function applyGraphViewRuntimeContributions( + data: IGraphData, + contributions: CoreGraphViewContributionSet | undefined, +): IGraphData { + if (!contributions) { + return data; + } + + const nodes = [...data.nodes]; + const edges = [...data.edges]; + const nodeIds = new Set(nodes.map(node => node.id)); + const edgeIds = new Set(edges.map(edge => edge.id)); + + for (const entry of contributions.runtimeNodes) { + appendUniqueNodes( + nodes, + nodeIds, + entry.contribution.createNodes({ + visibleGraph: { nodes, edges }, + }), + ); + } + + for (const entry of contributions.runtimeEdges) { + appendUniqueEdges( + edges, + edgeIds, + nodeIds, + entry.contribution.createEdges({ + visibleGraph: { nodes, edges }, + }), + ); + } + + return { nodes, edges }; +} diff --git a/packages/extension/tests/webview/graph/model/runtimeContributions.test.ts b/packages/extension/tests/webview/graph/model/runtimeContributions.test.ts new file mode 100644 index 000000000..de38afb70 --- /dev/null +++ b/packages/extension/tests/webview/graph/model/runtimeContributions.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import type { CoreGraphViewContributionSet } from '@codegraphy/core'; +import type { IGraphData } from '../../../../src/shared/graph/contracts'; +import { buildGraphData } from '../../../../src/webview/components/graph/model/build'; + +function createEmptyContributions(): CoreGraphViewContributionSet { + return { + runtimeNodes: [], + runtimeEdges: [], + projections: [], + forces: [], + contextMenu: [], + ui: [], + }; +} + +describe('graph/model/runtimeContributions', () => { + it('adds plugin runtime nodes and edges before Graph View model construction', () => { + const data: IGraphData = { + nodes: [ + { id: 'src/App.tsx', label: 'App.tsx', color: '#60a5fa' }, + ], + edges: [], + }; + const graphViewContributions: CoreGraphViewContributionSet = { + ...createEmptyContributions(), + runtimeNodes: [{ + pluginId: 'codegraphy.organize', + contribution: { + id: 'codegraphy.organize.section-node', + label: 'Section Node', + createNodes() { + return [{ + id: 'section:frontend', + label: 'Frontend', + color: '#84cc16', + nodeType: 'organize:section', + metadata: { owner: 'design' }, + }]; + }, + }, + }], + runtimeEdges: [{ + pluginId: 'codegraphy.organize', + contribution: { + id: 'codegraphy.organize.section-member-edge', + label: 'Section Member Edge', + createEdges() { + return [{ + id: 'section:frontend->src/App.tsx#organize:member', + from: 'section:frontend', + to: 'src/App.tsx', + kind: 'organize:member', + metadata: { role: 'member' }, + sources: [], + }]; + }, + }, + }], + }; + + const graphData = buildGraphData({ + data, + graphViewContributions, + nodeSizeMode: 'uniform', + theme: 'dark', + favorites: new Set(), + bidirectionalMode: 'separate', + timelineActive: false, + }); + + expect(graphData.nodes.find(node => node.id === 'section:frontend')).toMatchObject({ + id: 'section:frontend', + label: 'Frontend', + metadata: { owner: 'design' }, + nodeType: 'organize:section', + }); + expect(graphData.links).toEqual([ + expect.objectContaining({ + id: 'section:frontend->src/App.tsx#organize:member', + from: 'section:frontend', + kind: 'organize:member', + metadata: { role: 'member' }, + to: 'src/App.tsx', + }), + ]); + }); +}); diff --git a/packages/plugin-api/src/graph.ts b/packages/plugin-api/src/graph.ts index bcf91db99..e901da5c6 100644 --- a/packages/plugin-api/src/graph.ts +++ b/packages/plugin-api/src/graph.ts @@ -128,6 +128,9 @@ export interface IGraphNode { /** Symbol metadata when this node represents a code symbol. */ symbol?: IGraphNodeSymbolMetadata; + /** Optional plugin presentation metadata for details, popups, exports, and filters. */ + metadata?: GraphMetadata; + /** Whether this folder-like node can collapse descendant nodes in the graph view. */ isCollapsible?: boolean; @@ -196,6 +199,9 @@ export interface IGraphEdge { /** All contributing plugin sources merged into this edge. */ sources: IGraphEdgeSource[]; + + /** Optional plugin presentation metadata for details, popups, exports, and filters. */ + metadata?: GraphMetadata; } /** From 188c9bb1ec525b99aa4af083d0f85e9b7857f677 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 18 May 2026 17:55:14 -0700 Subject: [PATCH 06/49] Add Graph View projection contributions --- .../webview/components/graph/model/build.ts | 64 +++++++++++++++---- .../graph/model/runtimeContributions.ts | 14 ++++ .../graph/model/runtimeContributions.test.ts | 57 +++++++++++++++++ 3 files changed, 124 insertions(+), 11 deletions(-) diff --git a/packages/extension/src/webview/components/graph/model/build.ts b/packages/extension/src/webview/components/graph/model/build.ts index 02bba61f2..3601ab157 100644 --- a/packages/extension/src/webview/components/graph/model/build.ts +++ b/packages/extension/src/webview/components/graph/model/build.ts @@ -9,7 +9,10 @@ import type { NodeType } from '../../../../shared/graph/contracts'; import { buildGraphLinks } from './link/build'; import { buildGraphNodes } from './node/build'; import { projectGraphSectionsForRendering } from './sectionProjection'; -import { applyGraphViewRuntimeContributions } from './runtimeContributions'; +import { + applyGraphViewProjectionContributions, + applyGraphViewRuntimeContributions, +} from './runtimeContributions'; export { processEdges } from './edgeProcessing'; import { calculateNodeSizes } from './node/sizing'; export { DEFAULT_NODE_SIZE, FAVORITE_BORDER_COLOR, getDepthOpacity, getDepthSizeMultiplier, getNodeType, resolveDirectionColor } from './node/display'; @@ -83,6 +86,43 @@ export interface BuildGraphDataOptions { random?: () => number; } +function withGraphSectionProjectionContribution( + contributions: CoreGraphViewContributionSet | undefined, + options: Pick & { + graphMode: GraphLayoutMode; + }, +): CoreGraphViewContributionSet | undefined { + if (!options.graphLayout) { + return contributions; + } + + return { + runtimeNodes: contributions?.runtimeNodes ?? [], + runtimeEdges: contributions?.runtimeEdges ?? [], + projections: [ + ...(contributions?.projections ?? []), + { + pluginId: 'codegraphy.organize', + contribution: { + id: 'codegraphy.organize.graph-section-projection', + label: 'Graph Section Projection', + project({ visibleGraph }) { + return projectGraphSectionsForRendering({ + data: visibleGraph, + graphLayout: options.graphLayout, + graphMode: options.graphMode, + timelineActive: options.timelineActive, + }).data; + }, + }, + }, + ], + forces: contributions?.forces ?? [], + contextMenu: contributions?.contextMenu ?? [], + ui: contributions?.ui ?? [], + }; +} + export function buildGraphData(options: BuildGraphDataOptions): { nodes: FGNode[]; links: FGLink[] } { const appearance = options.appearance ?? DEFAULT_GRAPH_APPEARANCE; const graphMode = options.graphMode ?? '2d'; @@ -90,17 +130,19 @@ export function buildGraphData(options: BuildGraphDataOptions): { nodes: FGNode[ options.data, options.graphViewContributions, ); - const projected = projectGraphSectionsForRendering({ - data: runtimeData, - graphLayout: options.graphLayout, - graphMode, - timelineActive: options.timelineActive, - }); - const nodeSizes = calculateNodeSizes(projected.data.nodes, projected.data.edges, options.nodeSizeMode); + const projectedData = applyGraphViewProjectionContributions( + runtimeData, + withGraphSectionProjectionContribution(options.graphViewContributions, { + graphLayout: options.graphLayout, + graphMode, + timelineActive: options.timelineActive, + }), + ); + const nodeSizes = calculateNodeSizes(projectedData.nodes, projectedData.edges, options.nodeSizeMode); const nodes = buildGraphNodes({ allNodeIds: runtimeData.nodes.map(node => node.id), - nodes: projected.data.nodes, - edges: projected.data.edges, + nodes: projectedData.nodes, + edges: projectedData.edges, appearance, nodeSizes, theme: options.theme, @@ -111,7 +153,7 @@ export function buildGraphData(options: BuildGraphDataOptions): { nodes: FGNode[ previousNodes: options.previousNodes, random: options.random, }); - const links = buildGraphLinks(projected.data.edges, options.bidirectionalMode); + const links = buildGraphLinks(projectedData.edges, options.bidirectionalMode); return { nodes, links }; } diff --git a/packages/extension/src/webview/components/graph/model/runtimeContributions.ts b/packages/extension/src/webview/components/graph/model/runtimeContributions.ts index 739aa8bee..0e0b08773 100644 --- a/packages/extension/src/webview/components/graph/model/runtimeContributions.ts +++ b/packages/extension/src/webview/components/graph/model/runtimeContributions.ts @@ -68,3 +68,17 @@ export function applyGraphViewRuntimeContributions( return { nodes, edges }; } + +export function applyGraphViewProjectionContributions( + data: IGraphData, + contributions: CoreGraphViewContributionSet | undefined, +): IGraphData { + if (!contributions) { + return data; + } + + return contributions.projections.reduce( + (visibleGraph, entry) => entry.contribution.project({ visibleGraph }), + data, + ); +} diff --git a/packages/extension/tests/webview/graph/model/runtimeContributions.test.ts b/packages/extension/tests/webview/graph/model/runtimeContributions.test.ts index de38afb70..86fbe1fe2 100644 --- a/packages/extension/tests/webview/graph/model/runtimeContributions.test.ts +++ b/packages/extension/tests/webview/graph/model/runtimeContributions.test.ts @@ -85,4 +85,61 @@ describe('graph/model/runtimeContributions', () => { }), ]); }); + + it('applies plugin projection contributions before Graph View model construction', () => { + const data: IGraphData = { + nodes: [ + { id: 'src/App.tsx', label: 'App.tsx', color: '#60a5fa' }, + { id: 'src/Details.tsx', label: 'Details.tsx', color: '#60a5fa' }, + ], + edges: [{ + id: 'src/App.tsx->src/Details.tsx#import', + from: 'src/App.tsx', + to: 'src/Details.tsx', + kind: 'import', + sources: [], + }], + }; + const graphViewContributions: CoreGraphViewContributionSet = { + ...createEmptyContributions(), + projections: [{ + pluginId: 'codegraphy.organize', + contribution: { + id: 'codegraphy.organize.collapse', + label: 'Collapse', + project({ visibleGraph }) { + return { + nodes: [{ + id: 'section:frontend', + label: 'Frontend', + color: '#84cc16', + collapsedDescendantCount: visibleGraph.nodes.length, + nodeType: 'organize:section', + }], + edges: [], + }; + }, + }, + }], + }; + + const graphData = buildGraphData({ + data, + graphViewContributions, + nodeSizeMode: 'uniform', + theme: 'dark', + favorites: new Set(), + bidirectionalMode: 'separate', + timelineActive: false, + }); + + expect(graphData.nodes).toEqual([ + expect.objectContaining({ + id: 'section:frontend', + collapsedDescendantCount: 2, + nodeType: 'organize:section', + }), + ]); + expect(graphData.links).toEqual([]); + }); }); From 80ed2722efb72a316f525732efa23343a2a49b73 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 18 May 2026 18:06:09 -0700 Subject: [PATCH 07/49] Add Graph View force adapter host --- .../graph/runtime/physics/pluginForces.ts | 109 ++++++++++++++++++ .../graph/runtime/use/physics/hook.ts | 44 +++++++ .../components/graph/runtime/use/rendering.ts | 16 ++- .../components/graph/runtime/use/state.ts | 6 +- .../components/graph/view/component.tsx | 5 + .../components/graph/viewport/shell.tsx | 8 +- .../runtime/physics/pluginForces.test.ts | 105 +++++++++++++++++ packages/plugin-api/src/graphView.ts | 1 + 8 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 packages/extension/src/webview/components/graph/runtime/physics/pluginForces.ts create mode 100644 packages/extension/tests/webview/graph/runtime/physics/pluginForces.test.ts diff --git a/packages/extension/src/webview/components/graph/runtime/physics/pluginForces.ts b/packages/extension/src/webview/components/graph/runtime/physics/pluginForces.ts new file mode 100644 index 000000000..81925da2a --- /dev/null +++ b/packages/extension/src/webview/components/graph/runtime/physics/pluginForces.ts @@ -0,0 +1,109 @@ +import type { CoreGraphViewContributionSet } from '@codegraphy/core'; +import type { GraphEdgeKind, IGraphData } from '../../../../../shared/graph/contracts'; +import type { FGLink, FGNode } from '../../model/build'; +import type { GraphPhysicsControls } from './model'; + +interface GraphViewForceAdapter { + initialize?(nodes: FGNode[]): void; + tick?(alpha?: number): void; + dispose(): void; +} + +interface InstalledForceAdapter { + adapter: GraphViewForceAdapter; + contribution: CoreGraphViewContributionSet['forces'][number]['contribution']; + nodes: readonly FGNode[]; +} + +export interface GraphViewForceAdapterState { + installed: Map; +} + +export function createGraphViewForceAdapterState(): GraphViewForceAdapterState { + return { + installed: new Map(), + }; +} + +function createGraphViewForceNamespace(pluginId: string, contributionId: string): string { + return `plugin:${pluginId}:${contributionId}`; +} + +function createD3Force(adapter: GraphViewForceAdapter): ((alpha: number) => void) & { + initialize(nodes: FGNode[]): void; +} { + const force = (alpha: number): void => { + adapter.tick?.(alpha); + }; + force.initialize = (nodes: FGNode[]): void => { + adapter.initialize?.(nodes); + }; + return force; +} + +function getGraphEdgeKind(link: FGLink): GraphEdgeKind { + return (link.kind ?? 'reference') as GraphEdgeKind; +} + +function createVisibleGraph(graphData: { nodes: FGNode[]; links: FGLink[] }): IGraphData { + return { + nodes: graphData.nodes, + edges: graphData.links.map(link => ({ + id: link.id, + from: link.from, + to: link.to, + kind: getGraphEdgeKind(link), + metadata: link.metadata, + sources: [], + })), + }; +} + +export function syncGraphViewForceAdapters( + graph: GraphPhysicsControls, + state: GraphViewForceAdapterState, + contributions: CoreGraphViewContributionSet | undefined, + graphData: { nodes: FGNode[]; links: FGLink[] }, +): void { + const activeNamespaces = new Set(); + const visibleGraph = createVisibleGraph(graphData); + + for (const entry of contributions?.forces ?? []) { + const namespace = createGraphViewForceNamespace(entry.pluginId, entry.contribution.id); + activeNamespaces.add(namespace); + + const installed = state.installed.get(namespace); + if (installed?.contribution === entry.contribution && installed.nodes === graphData.nodes) { + continue; + } + + if (installed) { + installed.adapter.dispose(); + graph.d3Force(namespace, null); + } + + const adapter = entry.contribution.create({ + nodes: graphData.nodes, + edges: visibleGraph.edges, + visibleGraph, + }) as GraphViewForceAdapter; + state.installed.set(namespace, { + adapter, + contribution: entry.contribution, + nodes: graphData.nodes, + }); + graph.d3Force(namespace, createD3Force(adapter)); + graph.d3ReheatSimulation(); + } + + for (const [namespace, installed] of state.installed) { + if (activeNamespaces.has(namespace)) { + continue; + } + + installed.adapter.dispose(); + graph.d3Force(namespace, null); + state.installed.delete(namespace); + graph.d3ReheatSimulation(); + } +} diff --git a/packages/extension/src/webview/components/graph/runtime/use/physics/hook.ts b/packages/extension/src/webview/components/graph/runtime/use/physics/hook.ts index a35c78159..98d6b5ea1 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/physics/hook.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/physics/hook.ts @@ -1,10 +1,16 @@ import { useEffect, useRef, type MutableRefObject } from 'react'; +import type { CoreGraphViewContributionSet } from '@codegraphy/core'; import type { ForceGraphMethods as FG2DMethods } from 'react-force-graph-2d'; import type { ForceGraphMethods as FG3DMethods } from 'react-force-graph-3d'; import type { GraphLayoutSettings } from '../../../../../../shared/settings/graphLayout'; import type { IPhysicsSettings } from '../../../../../../shared/settings/physics'; import type { FGLink, FGNode } from '../../../model/build'; import { applyGraphSectionBoundsForce, applyPhysicsSettings } from '../../physics'; +import { + createGraphViewForceAdapterState, + syncGraphViewForceAdapters, +} from '../../physics/pluginForces'; +import type { GraphPhysicsControls } from '../../physics/model'; import { usePhysicsRuntimeInit } from './hook/init'; import { usePhysicsRuntimeLayoutKey, usePhysicsRuntimeLayoutReset } from './hook/layout'; import { usePhysicsRuntimePause } from './hook/pause'; @@ -22,6 +28,7 @@ interface UsePhysicsRuntimeProps { fg3dRef: MutableRefObject | undefined>; graphDataRef?: MutableRefObject<{ nodes: FGNode[]; links: FGLink[] }>; graphLayout?: GraphLayoutSettings; + graphViewContributions?: CoreGraphViewContributionSet; graphMode: '2d' | '3d'; layoutKey: string; physicsPaused?: boolean; @@ -33,6 +40,7 @@ export function usePhysicsRuntime({ fg3dRef, graphDataRef, graphLayout, + graphViewContributions, graphMode, layoutKey, physicsPaused = false, @@ -43,6 +51,7 @@ export function usePhysicsRuntime({ const pendingThreeDimensionalInitRef = useRef(graphMode === '3d'); const previousPhysicsRef = useRef(null); const previousLayoutKeyRef = useRef(null); + const forceAdapterStateRef = useRef(createGraphViewForceAdapterState()); physicsSettingsRef.current = physicsSettings; @@ -98,6 +107,41 @@ export function usePhysicsRuntime({ previousLayoutKeyRef, }); + useEffect(() => { + const graph = selectActivePhysicsGraph(graphMode, fg2dRef.current, fg3dRef.current); + if (!graph || !physicsInitialisedRef.current || typeof graph.d3Force !== 'function') { + return; + } + + syncGraphViewForceAdapters( + graph as GraphPhysicsControls, + forceAdapterStateRef.current, + graphViewContributions, + graphDataRef?.current ?? { nodes: [], links: [] }, + ); + }, [fg2dRef, fg3dRef, graphDataRef, graphMode, graphViewContributions, layoutKey, physicsInitialisedRef]); + + useEffect(() => { + const fg2d = fg2dRef.current; + const fg3d = fg3dRef.current; + const forceAdapterState = forceAdapterStateRef.current; + const graphData = graphDataRef?.current ?? { nodes: [], links: [] }; + + return () => { + const graph = selectActivePhysicsGraph(graphMode, fg2d, fg3d); + if (!graph || typeof graph.d3Force !== 'function') { + return; + } + + syncGraphViewForceAdapters( + graph as GraphPhysicsControls, + forceAdapterState, + undefined, + graphData, + ); + }; + }, [fg2dRef, fg3dRef, graphDataRef, graphMode]); + useEffect(() => { const graph = selectActivePhysicsGraph(graphMode, fg2dRef.current, fg3dRef.current); if (!graph || !physicsInitialisedRef.current) { 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 e764cd122..c18d30e76 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/rendering.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/rendering.ts @@ -1,6 +1,7 @@ import { type MutableRefObject, } from 'react'; +import type { CoreGraphViewContributionSet } from '@codegraphy/core'; import type { ForceGraphMethods as FG2DMethods, LinkObject, @@ -41,6 +42,7 @@ export interface UseGraphRenderingRuntimeOptions { getParticleColor: (this: void, link: LinkObject) => string; graphDataRef: MutableRefObject<{ nodes: FGNode[]; links: FGLink[] }>; graphLayout?: GraphLayoutSettings; + graphViewContributions?: CoreGraphViewContributionSet; graphLayoutKey: string; graphMode: '2d' | '3d'; highlightVersion: number; @@ -78,6 +80,7 @@ export function useGraphRenderingRuntime({ getParticleColor, graphDataRef, graphLayout, + graphViewContributions, graphLayoutKey, graphMode, highlightVersion, @@ -137,12 +140,13 @@ export function useGraphRenderingRuntime({ physicsPaused, }); - usePhysicsRuntime({ - fg2dRef, - fg3dRef, - graphDataRef, - graphLayout, - graphMode, + usePhysicsRuntime({ + fg2dRef, + fg3dRef, + graphDataRef, + graphLayout, + graphViewContributions, + graphMode, layoutKey: graphLayoutKey, physicsPaused, physicsSettings, 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 666a028e6..6669e1891 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/state.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/state.ts @@ -7,6 +7,7 @@ import { type MutableRefObject, type SetStateAction, } from 'react'; +import type { CoreGraphViewContributionSet } from '@codegraphy/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'; @@ -48,6 +49,7 @@ export interface UseGraphStateOptions { edgeDecorations?: Record; favorites: Set; graphLayout?: GraphLayoutSettings; + graphViewContributions?: CoreGraphViewContributionSet; graphMode?: GraphLayoutMode; nodeDecorations?: Record; nodeSizeMode: NodeSizeMode; @@ -132,6 +134,7 @@ export function useGraphState({ edgeDecorations, favorites, graphLayout, + graphViewContributions, graphMode, nodeDecorations, nodeSizeMode, @@ -200,6 +203,7 @@ export function useGraphState({ theme: themeRef.current, favorites: favoritesRef.current, graphLayout, + graphViewContributions, graphMode: resolvedGraphMode, bidirectionalMode, timelineActive, @@ -208,7 +212,7 @@ export function useGraphState({ graphDataRef.current = nextGraphData; return nextGraphData; - }, [appearance, bidirectionalMode, data, graphLayout, graphMode, timelineActive]); + }, [appearance, bidirectionalMode, data, graphLayout, graphMode, graphViewContributions, timelineActive]); useEffect(() => { if (!timelineActive) return; diff --git a/packages/extension/src/webview/components/graph/view/component.tsx b/packages/extension/src/webview/components/graph/view/component.tsx index cae0f94ab..e84f1d1a2 100644 --- a/packages/extension/src/webview/components/graph/view/component.tsx +++ b/packages/extension/src/webview/components/graph/view/component.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; +import type { CoreGraphViewContributionSet } from '@codegraphy/core'; import type { IGraphData } from '../../../../shared/graph/contracts'; import type { EdgeDecorationPayload, NodeDecorationPayload } from '../../../../shared/plugins/decorations'; import { @@ -31,6 +32,7 @@ interface GraphProps { theme?: ThemeKind; nodeDecorations?: Record; edgeDecorations?: Record; + graphViewContributions?: CoreGraphViewContributionSet; onAddFilterRequested?: (patterns: string[]) => void; onAddLegendRequested?: (rule: { pattern: string; color: string; target: 'node' | 'edge' }) => void; pluginHost?: WebviewPluginHost; @@ -41,6 +43,7 @@ export default function Graph({ theme = 'dark', nodeDecorations, edgeDecorations, + graphViewContributions, onAddFilterRequested = () => {}, onAddLegendRequested = () => {}, pluginHost, @@ -57,6 +60,7 @@ export default function Graph({ edgeDecorations, favorites: viewState.favorites, graphLayout: viewState.graphLayout, + graphViewContributions, graphMode: viewState.graphMode, nodeDecorations, nodeSizeMode: viewState.nodeSizeMode, @@ -128,6 +132,7 @@ export default function Graph({ callbacks={callbacks} graphLayoutKey={graphLayoutKey} graphState={graphState} + graphViewContributions={graphViewContributions} handleEngineStop={handleEngineStop} interactions={interactions} pluginHost={pluginHost} diff --git a/packages/extension/src/webview/components/graph/viewport/shell.tsx b/packages/extension/src/webview/components/graph/viewport/shell.tsx index 188ce51e1..206eccc7e 100644 --- a/packages/extension/src/webview/components/graph/viewport/shell.tsx +++ b/packages/extension/src/webview/components/graph/viewport/shell.tsx @@ -1,4 +1,5 @@ import { useRef, type ReactElement } from 'react'; +import type { CoreGraphViewContributionSet } from '@codegraphy/core'; import type { ThemeKind } from '../../../theme/useTheme'; import type { LegendIconImport } from '../../../../shared/protocol/webviewToExtension'; import { @@ -28,6 +29,7 @@ export interface GraphViewportShellProps { callbacks: UseGraphCallbacksResult; graphLayoutKey: string; graphState: UseGraphStateResult; + graphViewContributions?: CoreGraphViewContributionSet; handleEngineStop(this: void): void; interactions: UseGraphInteractionRuntimeResult; pluginHost?: WebviewPluginHost; @@ -40,10 +42,11 @@ function buildRenderingRuntimeOptions({ callbacks, graphLayoutKey, graphState, + graphViewContributions, pluginHost, theme, viewState, -}: Pick) { +}: Pick) { return { appearance, containerRef: graphState.containerRef, @@ -56,6 +59,7 @@ function buildRenderingRuntimeOptions({ getParticleColor: callbacks.getParticleColor, graphDataRef: graphState.graphDataRef, graphLayout: viewState.graphLayout, + graphViewContributions, graphLayoutKey, graphMode: viewState.graphMode, highlightVersion: graphState.highlightVersion, @@ -147,6 +151,7 @@ export function GraphViewportShell({ callbacks, graphLayoutKey, graphState, + graphViewContributions, handleEngineStop, interactions, pluginHost, @@ -159,6 +164,7 @@ export function GraphViewportShell({ callbacks, graphLayoutKey, graphState, + graphViewContributions, pluginHost, theme, viewState, diff --git a/packages/extension/tests/webview/graph/runtime/physics/pluginForces.test.ts b/packages/extension/tests/webview/graph/runtime/physics/pluginForces.test.ts new file mode 100644 index 000000000..ecdca5b40 --- /dev/null +++ b/packages/extension/tests/webview/graph/runtime/physics/pluginForces.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import type { CoreGraphViewContributionSet } from '@codegraphy/core'; +import type { FGLink, FGNode } from '../../../../../src/webview/components/graph/model/build'; +import { + createGraphViewForceAdapterState, + syncGraphViewForceAdapters, +} from '../../../../../src/webview/components/graph/runtime/physics/pluginForces'; + +function createEmptyContributions(): CoreGraphViewContributionSet { + return { + runtimeNodes: [], + runtimeEdges: [], + projections: [], + forces: [], + contextMenu: [], + ui: [], + }; +} + +function createFakePhysicsGraph() { + const forces = new Map([ + ['charge', { base: 'charge' }], + ['link', { base: 'link' }], + ]); + + return { + forces, + reheats: 0, + d3Force(name: string, force?: unknown) { + if (arguments.length === 1) { + return forces.get(name); + } + + if (force === null) { + forces.delete(name); + return undefined; + } + + forces.set(name, force); + return force; + }, + d3ReheatSimulation() { + this.reheats += 1; + }, + }; +} + +describe('Graph View plugin force adapters', () => { + it('installs additive namespaced forces and disposes them without touching base graph forces', () => { + const graph = createFakePhysicsGraph(); + const state = createGraphViewForceAdapterState(); + const sectionNode = { + id: 'section:frontend', + label: 'Frontend', + color: '#84cc16', + size: 16, + x: 0, + } as FGNode; + const graphData = { + nodes: [sectionNode], + links: [] as FGLink[], + }; + let disposeCount = 0; + const contributions: CoreGraphViewContributionSet = { + ...createEmptyContributions(), + forces: [{ + pluginId: 'codegraphy.organize', + contribution: { + id: 'codegraphy.organize.section-physics', + label: 'Section Physics', + create({ nodes }) { + return { + tick(alpha = 1) { + const node = nodes.find(candidate => candidate.id === 'section:frontend'); + if (node) { + node.x = (node.x ?? 0) + alpha; + } + }, + dispose() { + disposeCount += 1; + }, + }; + }, + }, + }], + }; + + syncGraphViewForceAdapters(graph, state, contributions, graphData); + + const namespace = 'plugin:codegraphy.organize:codegraphy.organize.section-physics'; + const installedForce = graph.d3Force(namespace) as (alpha: number) => void; + installedForce(2); + + expect(sectionNode.x).toBe(2); + expect(graph.d3Force('charge')).toEqual({ base: 'charge' }); + expect(graph.d3Force('link')).toEqual({ base: 'link' }); + + syncGraphViewForceAdapters(graph, state, createEmptyContributions(), graphData); + + expect(disposeCount).toBe(1); + expect(graph.d3Force(namespace)).toBeUndefined(); + expect(graph.d3Force('charge')).toEqual({ base: 'charge' }); + expect(graph.d3Force('link')).toEqual({ base: 'link' }); + }); +}); diff --git a/packages/plugin-api/src/graphView.ts b/packages/plugin-api/src/graphView.ts index 49f19345d..4650b891a 100644 --- a/packages/plugin-api/src/graphView.ts +++ b/packages/plugin-api/src/graphView.ts @@ -57,6 +57,7 @@ export interface IGraphViewForceAdapterContext extends IGraphViewContributionCon } export interface IGraphViewForceAdapter { + initialize?(nodes: IGraphViewRuntimeNode[]): void; tick?(alpha?: number): void; dispose(): void; } From 071d87fbf1c41d16cb8edaf335405b7c83c8f122 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 18 May 2026 18:15:46 -0700 Subject: [PATCH 08/49] Add Graph View context menu contributions --- .../graph/contextActions/effects.ts | 15 +- .../graph/contextMenu/build/entries.ts | 14 +- .../components/graph/contextMenu/contracts.ts | 22 +- .../graph/contextMenu/decision/targets.ts | 6 + .../graph/contextMenu/graphView/entries.ts | 149 ++++++++++++ .../components/graph/effects/contextMenu.ts | 3 + .../webview/components/graph/model/build.ts | 4 + .../components/graph/model/link/build.ts | 6 + .../components/graph/model/node/build.ts | 6 + .../components/graph/viewport/model.ts | 5 + .../components/graph/viewport/shell.tsx | 4 + .../graphViewContributions.test.ts | 212 ++++++++++++++++++ 12 files changed, 443 insertions(+), 3 deletions(-) create mode 100644 packages/extension/src/webview/components/graph/contextMenu/graphView/entries.ts create mode 100644 packages/extension/tests/webview/graph/contextMenu/graphViewContributions.test.ts diff --git a/packages/extension/src/webview/components/graph/contextActions/effects.ts b/packages/extension/src/webview/components/graph/contextActions/effects.ts index a7e15a076..67ba67b86 100644 --- a/packages/extension/src/webview/components/graph/contextActions/effects.ts +++ b/packages/extension/src/webview/components/graph/contextActions/effects.ts @@ -10,7 +10,12 @@ export type GraphContextEffect = | { kind: 'fitView' } | { kind: 'promptFilterPattern'; patterns: string[] } | { kind: 'promptLegendRule'; pattern: string; color: string; target: 'node' | 'edge' } - | { kind: 'postMessage'; message: WebviewToExtensionMessage }; + | { kind: 'postMessage'; message: WebviewToExtensionMessage } + | { + kind: 'runGraphViewContextMenuContribution'; + run: Extract['run']; + context: Extract['context']; + }; export function getBuiltInContextActionEffects( action: BuiltInContextMenuAction, @@ -27,5 +32,13 @@ export function getGraphContextActionEffects( return getBuiltInContextActionEffects(action.action, context); } + if (action.kind === 'graphViewPlugin') { + return [{ + kind: 'runGraphViewContextMenuContribution', + run: action.run, + context: action.context, + }]; + } + return createPluginContextActionEffects(action); } diff --git a/packages/extension/src/webview/components/graph/contextMenu/build/entries.ts b/packages/extension/src/webview/components/graph/contextMenu/build/entries.ts index 77bf091fa..c15fab532 100644 --- a/packages/extension/src/webview/components/graph/contextMenu/build/entries.ts +++ b/packages/extension/src/webview/components/graph/contextMenu/build/entries.ts @@ -12,6 +12,7 @@ import { buildSingleGraphSectionNodeEntries, buildSingleSymbolNodeEntries, } from '../node/entries'; +import { buildGraphViewContextMenuEntries } from '../graphView/entries'; import { buildPluginEntriesForDecision } from '../plugin/entries'; import type { GraphContextMenuDecision } from '../decision/model'; @@ -41,7 +42,9 @@ export function buildGraphContextMenuEntries( favorites, pinnedNodeIds = new Set(), pluginItems, + graphViewContributions, nodes, + edges, } = options; const mutationAvailability = options.mutationAvailability ?? DEFAULT_GRAPH_CONTEXT_MUTATION_AVAILABILITY; const decision = decideGraphContextMenu(selection, nodes); @@ -75,5 +78,14 @@ export function buildGraphContextMenuEntries( favorites, pinnedNodeIds, ); - return [...baseEntries, ...buildPluginEntriesForDecision(decision, pluginItems)]; + return [ + ...baseEntries, + ...buildPluginEntriesForDecision(decision, pluginItems), + ...buildGraphViewContextMenuEntries({ + decision, + edges, + graphViewContributions, + selection, + }), + ]; } diff --git a/packages/extension/src/webview/components/graph/contextMenu/contracts.ts b/packages/extension/src/webview/components/graph/contextMenu/contracts.ts index a6a91f772..52115ff12 100644 --- a/packages/extension/src/webview/components/graph/contextMenu/contracts.ts +++ b/packages/extension/src/webview/components/graph/contextMenu/contracts.ts @@ -1,7 +1,9 @@ import type { IPluginContextMenuItem } from '../../../../shared/plugins/contextMenu'; +import type { CoreGraphViewContributionSet } from '@codegraphy/core'; export type GraphContextTargetKind = 'background' | 'node' | 'edge'; export type GraphContextMutationAvailability = 'enabled' | 'disabled' | 'hidden'; +type GraphViewContextMenuContribution = CoreGraphViewContributionSet['contextMenu'][number]['contribution']; export const DEFAULT_GRAPH_CONTEXT_MUTATION_AVAILABILITY: GraphContextMutationAvailability = 'enabled'; @@ -38,7 +40,14 @@ export type BuiltInContextMenuAction = export type GraphContextMenuAction = | { kind: 'builtin'; action: BuiltInContextMenuAction } - | { kind: 'plugin'; pluginId: string; index: number; targetId: string; targetType: 'node' | 'edge' }; + | { kind: 'plugin'; pluginId: string; index: number; targetId: string; targetType: 'node' | 'edge' } + | { + kind: 'graphViewPlugin'; + pluginId: string; + contributionId: string; + context: Parameters[0]; + run: GraphViewContextMenuContribution['run']; + }; export type GraphContextMenuEntry = | { @@ -66,7 +75,9 @@ export interface GraphContextMenuNode { id: string; label?: string; color?: string; + ownerPluginId?: string; nodeType?: string; + runtimeNodeType?: string; symbol?: { id: string; name: string; @@ -77,6 +88,13 @@ export interface GraphContextMenuNode { isGraphSection?: boolean; } +export interface GraphContextMenuEdge { + id: string; + kind?: string; + ownerPluginId?: string; + runtimeEdgeType?: string; +} + export interface BuildGraphContextMenuOptions { selection: GraphContextSelection; timelineActive: boolean; @@ -84,5 +102,7 @@ export interface BuildGraphContextMenuOptions { favorites: ReadonlySet; pinnedNodeIds?: ReadonlySet; pluginItems: readonly IPluginContextMenuItem[]; + graphViewContributions?: CoreGraphViewContributionSet; nodes?: readonly GraphContextMenuNode[]; + edges?: readonly GraphContextMenuEdge[]; } diff --git a/packages/extension/src/webview/components/graph/contextMenu/decision/targets.ts b/packages/extension/src/webview/components/graph/contextMenu/decision/targets.ts index 8fd1e6e4a..8738caf37 100644 --- a/packages/extension/src/webview/components/graph/contextMenu/decision/targets.ts +++ b/packages/extension/src/webview/components/graph/contextMenu/decision/targets.ts @@ -6,6 +6,8 @@ export interface GraphContextNodeTarget { isCollapsedGraphSection?: boolean; nodeKind: GraphContextNodeKind; nodeType: string; + ownerPluginId?: string; + runtimeNodeType?: string; symbol?: { id: string; name: string; @@ -19,6 +21,8 @@ export interface GraphContextNodeSource { isCollapsedGraphSection?: boolean; isGraphSection?: boolean; nodeType?: string; + ownerPluginId?: string; + runtimeNodeType?: string; symbol?: { id: string; name: string; @@ -57,6 +61,8 @@ export function classifyGraphContextNodeTarget( ? 'symbol' : resolveNodeKind(resolvedNodeType), nodeType: resolvedNodeType, + ...(nodeSource?.ownerPluginId ? { ownerPluginId: nodeSource.ownerPluginId } : {}), + ...(nodeSource?.runtimeNodeType ? { runtimeNodeType: nodeSource.runtimeNodeType } : {}), ...(resolvedSymbol ? { symbol: resolvedSymbol } : {}), }; } diff --git a/packages/extension/src/webview/components/graph/contextMenu/graphView/entries.ts b/packages/extension/src/webview/components/graph/contextMenu/graphView/entries.ts new file mode 100644 index 000000000..3294fa75c --- /dev/null +++ b/packages/extension/src/webview/components/graph/contextMenu/graphView/entries.ts @@ -0,0 +1,149 @@ +import type { CoreGraphViewContributionSet } from '@codegraphy/core'; +import { separator } from '../common/entryFactories'; +import type { + GraphContextMenuAction, + GraphContextMenuEdge, + GraphContextMenuEntry, + GraphContextSelection, +} from '../contracts'; +import type { GraphContextMenuDecision } from '../decision/model'; +import type { GraphContextNodeTarget } from '../decision/targets'; + +type GraphViewContextMenuEntry = CoreGraphViewContributionSet['contextMenu'][number]; +type GraphViewContextMenuContribution = GraphViewContextMenuEntry['contribution']; +type GraphViewContextMenuTargetSelector = GraphViewContextMenuContribution['targets'][number]; + +function getSingleNodeTarget(decision: GraphContextMenuDecision): GraphContextNodeTarget | undefined { + return 'target' in decision ? decision.target : undefined; +} + +function getNodeTargets(decision: GraphContextMenuDecision): readonly GraphContextNodeTarget[] { + if ('target' in decision) { + return [decision.target]; + } + + return 'targets' in decision && decision.kind !== 'edge' + ? decision.targets + : []; +} + +function findEdge( + edgeId: string | undefined, + edges: readonly GraphContextMenuEdge[] | undefined, +): GraphContextMenuEdge | undefined { + return edgeId ? edges?.find(edge => edge.id === edgeId) : undefined; +} + +function listAllows( + allowed: readonly T[] | undefined, + value: string | undefined, +): boolean { + return !allowed?.length || (!!value && (allowed as readonly string[]).includes(value)); +} + +function nodeMatches( + node: GraphContextNodeTarget, + selector: Extract, +): boolean { + if (selector.kind === 'runtimeNodeType') { + return listAllows(selector.runtimeNodeTypes, node.runtimeNodeType); + } + + return listAllows(selector.nodeTypes, node.nodeType) && + listAllows(selector.runtimeNodeTypes, node.runtimeNodeType); +} + +function edgeMatches( + edge: GraphContextMenuEdge | undefined, + selector: Extract, +): boolean { + if (!edge) { + return false; + } + + if (selector.kind === 'runtimeEdgeType') { + return listAllows(selector.runtimeEdgeTypes, edge.runtimeEdgeType); + } + + return listAllows(selector.edgeKinds, edge.kind) && + listAllows(selector.runtimeEdgeTypes, edge.runtimeEdgeType); +} + +function selectorMatches( + selector: GraphViewContextMenuTargetSelector, + decision: GraphContextMenuDecision, + edges: readonly GraphContextMenuEdge[] | undefined, +): boolean { + if (selector.kind === 'background') { + return decision.kind === 'background'; + } + + if (selector.kind === 'node' || selector.kind === 'runtimeNodeType') { + const target = getSingleNodeTarget(decision); + return !!target && nodeMatches(target, selector); + } + + if (selector.kind === 'multiSelection') { + const targets = getNodeTargets(decision); + return targets.length > 1 && targets.every(target => nodeMatches(target, selector)); + } + + return decision.kind === 'edge' && + edgeMatches(findEdge(decision.edgeId, edges), selector); +} + +function createRunContext( + selector: GraphViewContextMenuTargetSelector, + selection: GraphContextSelection, +): Parameters[0] { + return { + target: selector, + selectedNodeIds: selection.kind === 'node' ? selection.targets : [], + selectedEdgeIds: selection.kind === 'edge' && selection.edgeId ? [selection.edgeId] : [], + }; +} + +function createGraphViewContextMenuAction( + entry: GraphViewContextMenuEntry, + selector: GraphViewContextMenuTargetSelector, + selection: GraphContextSelection, +): GraphContextMenuAction { + return { + kind: 'graphViewPlugin', + pluginId: entry.pluginId, + contributionId: entry.contribution.id, + context: createRunContext(selector, selection), + run: context => entry.contribution.run(context), + }; +} + +export function buildGraphViewContextMenuEntries( + options: { + decision: GraphContextMenuDecision; + edges?: readonly GraphContextMenuEdge[]; + graphViewContributions?: CoreGraphViewContributionSet; + selection: GraphContextSelection; + }, +): GraphContextMenuEntry[] { + const entries: GraphContextMenuEntry[] = []; + + for (const entry of options.graphViewContributions?.contextMenu ?? []) { + const selector = entry.contribution.targets.find(target => + selectorMatches(target, options.decision, options.edges) + ); + if (!selector) { + continue; + } + + entries.push({ + kind: 'item', + id: `graph-view-plugin-${entry.pluginId}-${entry.contribution.id}`, + label: entry.contribution.label, + action: createGraphViewContextMenuAction(entry, selector, options.selection), + }); + } + + return entries.length > 0 + ? [separator('graph-view-plugins-separator'), ...entries] + : []; +} diff --git a/packages/extension/src/webview/components/graph/effects/contextMenu.ts b/packages/extension/src/webview/components/graph/effects/contextMenu.ts index d52916e17..0b34fe7c8 100644 --- a/packages/extension/src/webview/components/graph/effects/contextMenu.ts +++ b/packages/extension/src/webview/components/graph/effects/contextMenu.ts @@ -38,6 +38,9 @@ export function applyContextEffects( case 'postMessage': handlers.postMessage(effect.message); break; + case 'runGraphViewContextMenuContribution': + void effect.run(effect.context); + break; } } } diff --git a/packages/extension/src/webview/components/graph/model/build.ts b/packages/extension/src/webview/components/graph/model/build.ts index 3601ab157..f9b96ef31 100644 --- a/packages/extension/src/webview/components/graph/model/build.ts +++ b/packages/extension/src/webview/components/graph/model/build.ts @@ -30,6 +30,8 @@ export type FGNode = NodeObject & { isPinned: boolean; icon?: string; nodeType?: NodeType; + ownerPluginId?: string; + runtimeNodeType?: string; shape2D?: NodeShape2D; shape3D?: NodeShape3D; imageUrl?: string; @@ -67,8 +69,10 @@ export type FGLink = LinkObject & { curvatureGroupId?: string; kind?: string; metadata?: GraphMetadata; + ownerPluginId?: string; projectedEdgeCount?: number; projectedEdgeIds?: string[]; + runtimeEdgeType?: string; }; export interface BuildGraphDataOptions { diff --git a/packages/extension/src/webview/components/graph/model/link/build.ts b/packages/extension/src/webview/components/graph/model/link/build.ts index dcc23ee5d..a01983a15 100644 --- a/packages/extension/src/webview/components/graph/model/link/build.ts +++ b/packages/extension/src/webview/components/graph/model/link/build.ts @@ -7,6 +7,10 @@ import type { ProjectedGraphEdge } from '../sectionProjection'; export function buildGraphLinks(edges: Array, mode: BidirectionalEdgeMode): FGLink[] { const links: FGLink[] = processEdges(edges, mode).map(edge => { + const runtimeEdge = edge as IGraphEdge & { + ownerPluginId?: string; + runtimeEdgeType?: string; + }; const link: FGLink = { id: edge.id, from: edge.from, @@ -18,6 +22,8 @@ export function buildGraphLinks(edges: Array, m curvatureGroupId: edge.kind, kind: edge.kind, metadata: edge.metadata, + ownerPluginId: runtimeEdge.ownerPluginId, + runtimeEdgeType: runtimeEdge.runtimeEdgeType, }; if (edge.projectedEdgeCount !== undefined || edge.projectedEdgeIds !== undefined) { diff --git a/packages/extension/src/webview/components/graph/model/node/build.ts b/packages/extension/src/webview/components/graph/model/node/build.ts index 86f8e2ed6..638047f6f 100644 --- a/packages/extension/src/webview/components/graph/model/node/build.ts +++ b/packages/extension/src/webview/components/graph/model/node/build.ts @@ -218,6 +218,10 @@ function createGraphNode( isLight: boolean, previousNodeStates: ReadonlyMap, ): FGNode { + const runtimeNode = node as IGraphNode & { + ownerPluginId?: string; + runtimeNodeType?: string; + }; const previous = previousNodeStates.get(node.id); const ownerSectionId = getGraphNodeOwnerSectionId(node.id, options.graphLayout, options.timelineActive); const pinCoordinate = resolveGraphNodePinCoordinate( @@ -234,6 +238,8 @@ function createGraphNode( ...style, isPinned: !!pinCoordinate, nodeType: node.nodeType, + ownerPluginId: runtimeNode.ownerPluginId, + runtimeNodeType: runtimeNode.runtimeNodeType, shape2D: node.shape2D, shape3D: node.shape3D, imageUrl: node.imageUrl, diff --git a/packages/extension/src/webview/components/graph/viewport/model.ts b/packages/extension/src/webview/components/graph/viewport/model.ts index 0e73da08f..a62e3c5e9 100644 --- a/packages/extension/src/webview/components/graph/viewport/model.ts +++ b/packages/extension/src/webview/components/graph/viewport/model.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import type { CoreGraphViewContributionSet } from '@codegraphy/core'; import type { GraphViewStoreState } from '../view/store'; import type { GraphContextMenuEntry, @@ -28,6 +29,7 @@ export interface GraphViewportModel { export interface GraphViewportModelOptions { graphState: Pick; + graphViewContributions?: CoreGraphViewContributionSet; interactions: UseGraphInteractionRuntimeResult; handleEngineStop(this: void): void; appearance?: GraphAppearance; @@ -64,6 +66,7 @@ function getActivePinnedNodeIds( export function useGraphViewportModel({ graphState, + graphViewContributions, interactions, handleEngineStop, appearance, @@ -98,7 +101,9 @@ export function useGraphViewportModel({ favorites: viewState.favorites, pinnedNodeIds: getActivePinnedNodeIds(viewState), pluginItems: viewState.pluginContextMenuItems, + graphViewContributions, nodes: graphState.graphData.nodes, + edges: graphState.graphData.links, }); const { canvasBackgroundColor, containerBackgroundColor, borderColor } = getGraphSurfaceColors(appearance); diff --git a/packages/extension/src/webview/components/graph/viewport/shell.tsx b/packages/extension/src/webview/components/graph/viewport/shell.tsx index 206eccc7e..baf0c36a4 100644 --- a/packages/extension/src/webview/components/graph/viewport/shell.tsx +++ b/packages/extension/src/webview/components/graph/viewport/shell.tsx @@ -84,6 +84,7 @@ function buildRenderingRuntimeOptions({ function useGraphViewportModelOptions({ appearance, graphState, + graphViewContributions, interactions, handleEngineStop, viewportRuntime, @@ -91,6 +92,7 @@ function useGraphViewportModelOptions({ }: { appearance?: GraphAppearance; graphState: UseGraphStateResult; + graphViewContributions?: CoreGraphViewContributionSet; interactions: UseGraphInteractionRuntimeResult; handleEngineStop(this: void): void; viewportRuntime: Pick; @@ -101,6 +103,7 @@ function useGraphViewportModelOptions({ contextSelection: graphState.contextSelection, graphData: graphState.graphData, }, + graphViewContributions, handleEngineStop, appearance, interactions, @@ -189,6 +192,7 @@ export function GraphViewportShell({ const viewportModel = useGraphViewportModelOptions({ appearance, graphState, + graphViewContributions, handleEngineStop, interactions, viewportRuntime, diff --git a/packages/extension/tests/webview/graph/contextMenu/graphViewContributions.test.ts b/packages/extension/tests/webview/graph/contextMenu/graphViewContributions.test.ts new file mode 100644 index 000000000..b365832ec --- /dev/null +++ b/packages/extension/tests/webview/graph/contextMenu/graphViewContributions.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { CoreGraphViewContributionSet } from '@codegraphy/core'; +import { buildGraphContextMenuEntries } from '../../../../src/webview/components/graph/contextMenu/build/entries'; +import type { GraphContextMenuEntry } from '../../../../src/webview/components/graph/contextMenu/contracts'; +import { getGraphContextActionEffects } from '../../../../src/webview/components/graph/contextActions/effects'; +import { resolveGraphContextActionContext } from '../../../../src/webview/components/graph/contextActions/context'; +import { applyContextEffects } from '../../../../src/webview/components/graph/effects/contextMenu'; + +function createEmptyContributions(): CoreGraphViewContributionSet { + return { + runtimeNodes: [], + runtimeEdges: [], + projections: [], + forces: [], + contextMenu: [], + ui: [], + }; +} + +function createContributions( + contextMenu: CoreGraphViewContributionSet['contextMenu'], +): CoreGraphViewContributionSet { + return { + ...createEmptyContributions(), + contextMenu, + }; +} + +function itemLabels(entries: readonly GraphContextMenuEntry[]): string[] { + return entries + .filter((entry): entry is Extract => entry.kind === 'item') + .map(entry => entry.label); +} + +function findItem(entries: readonly GraphContextMenuEntry[], label: string) { + return entries.find((entry): entry is Extract => + entry.kind === 'item' && entry.label === label + ); +} + +describe('Graph View context menu contributions', () => { + it('matches background, node, edge, and multi-selection selectors', () => { + const graphViewContributions = createContributions([ + { + pluginId: 'codegraphy.organize', + contribution: { + id: 'organize.background', + label: 'Create Section', + targets: [{ kind: 'background' }], + run: vi.fn(), + }, + }, + { + pluginId: 'codegraphy.organize', + contribution: { + id: 'organize.node', + label: 'Assign Owner', + targets: [{ kind: 'node', nodeTypes: ['file'] }], + run: vi.fn(), + }, + }, + { + pluginId: 'codegraphy.organize', + contribution: { + id: 'organize.edge', + label: 'Inspect Import', + targets: [{ kind: 'edge', edgeKinds: ['import'] }], + run: vi.fn(), + }, + }, + { + pluginId: 'codegraphy.organize', + contribution: { + id: 'organize.multi', + label: 'Group Selection', + targets: [{ kind: 'multiSelection', nodeTypes: ['file'] }], + run: vi.fn(), + }, + }, + ]); + + expect(itemLabels(buildGraphContextMenuEntries({ + selection: { kind: 'background', targets: [] }, + timelineActive: false, + favorites: new Set(), + pluginItems: [], + graphViewContributions, + }))).toContain('Create Section'); + + expect(itemLabels(buildGraphContextMenuEntries({ + selection: { kind: 'node', targets: ['src/app.ts'] }, + timelineActive: false, + favorites: new Set(), + pluginItems: [], + graphViewContributions, + nodes: [{ id: 'src/app.ts', nodeType: 'file' }], + }))).toContain('Assign Owner'); + + expect(itemLabels(buildGraphContextMenuEntries({ + selection: { kind: 'edge', edgeId: 'src/app.ts->src/util.ts#import', targets: ['src/app.ts', 'src/util.ts'] }, + timelineActive: false, + favorites: new Set(), + pluginItems: [], + graphViewContributions, + edges: [{ id: 'src/app.ts->src/util.ts#import', kind: 'import' }], + }))).toContain('Inspect Import'); + + expect(itemLabels(buildGraphContextMenuEntries({ + selection: { kind: 'node', targets: ['src/app.ts', 'src/util.ts'] }, + timelineActive: false, + favorites: new Set(), + pluginItems: [], + graphViewContributions, + nodes: [ + { id: 'src/app.ts', nodeType: 'file' }, + { id: 'src/util.ts', nodeType: 'file' }, + ], + }))).toContain('Group Selection'); + }); + + it('matches runtime node and runtime edge type selectors', () => { + const graphViewContributions = createContributions([ + { + pluginId: 'codegraphy.organize', + contribution: { + id: 'organize.section-node', + label: 'Section Settings', + targets: [{ kind: 'runtimeNodeType', runtimeNodeTypes: ['graph-section'] }], + run: vi.fn(), + }, + }, + { + pluginId: 'codegraphy.organize', + contribution: { + id: 'organize.member-edge', + label: 'Explain Membership', + targets: [{ kind: 'runtimeEdgeType', runtimeEdgeTypes: ['section-member'] }], + run: vi.fn(), + }, + }, + ]); + + expect(itemLabels(buildGraphContextMenuEntries({ + selection: { kind: 'node', targets: ['section:frontend'] }, + timelineActive: false, + favorites: new Set(), + pluginItems: [], + graphViewContributions, + nodes: [{ id: 'section:frontend', runtimeNodeType: 'graph-section' }], + }))).toContain('Section Settings'); + + expect(itemLabels(buildGraphContextMenuEntries({ + selection: { + kind: 'edge', + edgeId: 'section:frontend->src/app.ts#section-member', + targets: ['section:frontend', 'src/app.ts'], + }, + timelineActive: false, + favorites: new Set(), + pluginItems: [], + graphViewContributions, + edges: [{ + id: 'section:frontend->src/app.ts#section-member', + kind: 'reference', + runtimeEdgeType: 'section-member', + }], + }))).toContain('Explain Membership'); + }); + + it('runs matched graph view context menu contributions with selected ids', () => { + const run = vi.fn(); + const graphViewContributions = createContributions([{ + pluginId: 'codegraphy.organize', + contribution: { + id: 'organize.node', + label: 'Assign Owner', + targets: [{ kind: 'node', nodeTypes: ['file'] }], + run, + }, + }]); + const selection = { kind: 'node' as const, targets: ['src/app.ts'] }; + const nodes = [{ id: 'src/app.ts', nodeType: 'file' }]; + const entries = buildGraphContextMenuEntries({ + selection, + timelineActive: false, + favorites: new Set(), + pluginItems: [], + graphViewContributions, + nodes, + }); + + const action = findItem(entries, 'Assign Owner')?.action; + expect(action).toBeDefined(); + + const effects = getGraphContextActionEffects( + action!, + resolveGraphContextActionContext(selection, { nodes }), + ); + applyContextEffects(effects, { + clearCachedFile: vi.fn(), + fitView: vi.fn(), + focusNode: vi.fn(), + postMessage: vi.fn(), + }); + + expect(run).toHaveBeenCalledWith({ + target: { kind: 'node', nodeTypes: ['file'] }, + selectedNodeIds: ['src/app.ts'], + selectedEdgeIds: [], + }); + }); +}); From 730b4bd36c8d9383e4ca3ec40b032d3e37655c5a Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 18 May 2026 18:20:21 -0700 Subject: [PATCH 09/49] Add named Graph View UI slots --- .../src/webview/app/shell/panel/stack.tsx | 6 +++++ .../components/graph/viewport/view.tsx | 26 ++++++++++++++----- .../src/webview/components/toolbar/view.tsx | 6 +++++ .../pluginHost/api/contracts/webview.ts | 4 +++ .../extension/tests/webview/Toolbar.test.tsx | 14 ++++++++++ .../webview/app/shell/panel/stack.test.tsx | 21 ++++++++++++++- .../webview/graph/viewport/view.test.tsx | 18 +++++++++++++ 7 files changed, 88 insertions(+), 7 deletions(-) diff --git a/packages/extension/src/webview/app/shell/panel/stack.tsx b/packages/extension/src/webview/app/shell/panel/stack.tsx index 23a087973..a27e2347c 100644 --- a/packages/extension/src/webview/app/shell/panel/stack.tsx +++ b/packages/extension/src/webview/app/shell/panel/stack.tsx @@ -23,6 +23,12 @@ export function PanelStack({ }: PanelStackProps): React.ReactElement { return (
+ ): ReactElement | null { return pluginHost ? ( - + <> + + + + ) : null; } diff --git a/packages/extension/src/webview/components/toolbar/view.tsx b/packages/extension/src/webview/components/toolbar/view.tsx index af307bb20..338813328 100644 --- a/packages/extension/src/webview/components/toolbar/view.tsx +++ b/packages/extension/src/webview/components/toolbar/view.tsx @@ -19,6 +19,12 @@ export default function Toolbar({ pluginHost }: ToolbarProps): React.ReactElemen {pluginHost ? ( <>