diff --git a/.changeset/bright-sections-resize.md b/.changeset/bright-sections-resize.md new file mode 100644 index 000000000..e953b5dcf --- /dev/null +++ b/.changeset/bright-sections-resize.md @@ -0,0 +1,6 @@ +--- +"@codegraphy/plugin-api": minor +"@codegraphy/extension": patch +--- + +Add sized 2D rectangle node presentation so plugin nodes can render, pick, and collide at their expanded visual bounds. diff --git a/.changeset/extract-pro-foundation.md b/.changeset/extract-pro-foundation.md new file mode 100644 index 000000000..7e3f13f53 --- /dev/null +++ b/.changeset/extract-pro-foundation.md @@ -0,0 +1,11 @@ +--- +"@codegraphy/plugin-api": minor +"@codegraphy/core": minor +"@codegraphy/extension": minor +"@codegraphy/mcp": minor +"@codegraphy/pro": minor +--- + +Add the Extract Pro foundation: Access Provider contracts, plugin-owned data persistence delivered to package plugin factories, Graph View runtime/projection/context-menu/UI/force-adapter contribution contracts and hosts, a Pro account/access plugin shell, and local plugin linking for private paid plugins. + +Graph View contribution callbacks receive live host context such as the current graph mode and timeline state. diff --git a/.changeset/live-graph-view-context.md b/.changeset/live-graph-view-context.md new file mode 100644 index 000000000..30b86e0b3 --- /dev/null +++ b/.changeset/live-graph-view-context.md @@ -0,0 +1,6 @@ +--- +"@codegraphy/extension": patch +"@codegraphy/plugin-api": patch +--- + +Expose graph mode and timeline state to Graph View context menu contributions so plugins can hide mode-specific actions. diff --git a/.changeset/live-section-node-updates.md b/.changeset/live-section-node-updates.md new file mode 100644 index 000000000..07cc68323 --- /dev/null +++ b/.changeset/live-section-node-updates.md @@ -0,0 +1,6 @@ +--- +"@codegraphy/plugin-api": minor +"@codegraphy/extension": patch +--- + +Expose live Graph View viewport node updates and per-node physics overrides so plugins can resize runtime nodes without restarting graph physics. diff --git a/.changeset/nice-runtime-node-undefined.md b/.changeset/nice-runtime-node-undefined.md new file mode 100644 index 000000000..c19a3b4af --- /dev/null +++ b/.changeset/nice-runtime-node-undefined.md @@ -0,0 +1,5 @@ +--- +"@codegraphy/plugin-api": patch +--- + +Allow runtime node coordinate fields to be explicitly cleared with `undefined` in exact-optional TypeScript projects. diff --git a/.changeset/plugin-webview-injections.md b/.changeset/plugin-webview-injections.md new file mode 100644 index 000000000..2d275ab9d --- /dev/null +++ b/.changeset/plugin-webview-injections.md @@ -0,0 +1,5 @@ +--- +"@codegraphy/extension": patch +--- + +Re-send linked package plugin webview assets after workspace plugin loading so enabled plugins can activate their graph UI contributions. diff --git a/.changeset/preserve-plugin-data-on-toggle.md b/.changeset/preserve-plugin-data-on-toggle.md new file mode 100644 index 000000000..13b7294c6 --- /dev/null +++ b/.changeset/preserve-plugin-data-on-toggle.md @@ -0,0 +1,5 @@ +--- +"@codegraphy/extension": patch +--- + +Preserve plugin-owned workspace data when package plugins are toggled off. diff --git a/.changeset/private-feature-plugin-boundary.md b/.changeset/private-feature-plugin-boundary.md new file mode 100644 index 000000000..fd68c049a --- /dev/null +++ b/.changeset/private-feature-plugin-boundary.md @@ -0,0 +1,5 @@ +--- +"@codegraphy/extension": patch +--- + +Keep feature-specific Graph View nodes, context menu actions, and physics package-owned so disabling or removing a plugin removes those contributions from the graph. diff --git a/.changeset/quiet-internal-plugins.md b/.changeset/quiet-internal-plugins.md new file mode 100644 index 000000000..c86982578 --- /dev/null +++ b/.changeset/quiet-internal-plugins.md @@ -0,0 +1,5 @@ +--- +"@codegraphy/extension": patch +--- + +Hide internal built-in runtime plugins from the Plugins panel. diff --git a/.changeset/quiet-pins-move.md b/.changeset/quiet-pins-move.md new file mode 100644 index 000000000..2015ada52 --- /dev/null +++ b/.changeset/quiet-pins-move.md @@ -0,0 +1,6 @@ +--- +"@codegraphy/plugin-api": minor +"@codegraphy/core": minor +--- + +Add a Graph View node drag-end contribution so plugins can own fixed-position drag behavior without hard-coding plugin features in the host graph. diff --git a/.changeset/silent-viewport-listeners.md b/.changeset/silent-viewport-listeners.md new file mode 100644 index 000000000..958e7e230 --- /dev/null +++ b/.changeset/silent-viewport-listeners.md @@ -0,0 +1,5 @@ +--- +"@codegraphy/extension": patch +--- + +Clean up plugin-scoped Graph View viewport listeners when a plugin is removed or toggled off. diff --git a/.changeset/slim-runtime-node-positions.md b/.changeset/slim-runtime-node-positions.md new file mode 100644 index 000000000..7ca0dc357 --- /dev/null +++ b/.changeset/slim-runtime-node-positions.md @@ -0,0 +1,5 @@ +--- +"@codegraphy/extension": patch +--- + +Respect plugin runtime node fixed coordinates when building graph physics state. diff --git a/.changeset/steady-package-plugin-toggle.md b/.changeset/steady-package-plugin-toggle.md new file mode 100644 index 000000000..db781dac0 --- /dev/null +++ b/.changeset/steady-package-plugin-toggle.md @@ -0,0 +1,6 @@ +--- +"@codegraphy/extension": patch +"@codegraphy/plugin-api": minor +--- + +Fix package plugin toggles so Graph View contributions are added and removed immediately, add create-menu placement for plugin context menu actions, and keep plugin contribution snapshots stable while rendering the graph. diff --git a/.changeset/tender-runtime-node-state.md b/.changeset/tender-runtime-node-state.md new file mode 100644 index 000000000..081776dd2 --- /dev/null +++ b/.changeset/tender-runtime-node-state.md @@ -0,0 +1,5 @@ +--- +"@codegraphy/plugin-api": patch +--- + +Expose runtime node coordinate, fixed-position, and velocity fields in the public Graph View API. diff --git a/.changeset/tidy-host-graph.md b/.changeset/tidy-host-graph.md new file mode 100644 index 000000000..57ee6f66a --- /dev/null +++ b/.changeset/tidy-host-graph.md @@ -0,0 +1,5 @@ +--- +"@codegraphy/plugin-api": patch +--- + +Expose the current graph snapshot on the plugin host API for plugin-owned exporters and host actions. diff --git a/.changeset/witty-sections-pull.md b/.changeset/witty-sections-pull.md new file mode 100644 index 000000000..82dc827f5 --- /dev/null +++ b/.changeset/witty-sections-pull.md @@ -0,0 +1,6 @@ +--- +"@codegraphy/plugin-api": minor +"@codegraphy/extension": patch +--- + +Add plugin runtime node pointer areas so custom-shaped nodes can use graph-owned pointer picking. diff --git a/.vscode/launch.json b/.vscode/launch.json index 9c77da9cc..121104bb0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,11 +6,7 @@ "type": "extensionHost", "request": "launch", "args": [ - "--extensionDevelopmentPath=${workspaceFolder}", - "--extensionDevelopmentPath=${workspaceFolder}/packages/plugin-typescript", - "--extensionDevelopmentPath=${workspaceFolder}/packages/plugin-python", - "--extensionDevelopmentPath=${workspaceFolder}/packages/plugin-csharp", - "--extensionDevelopmentPath=${workspaceFolder}/packages/plugin-godot" + "--extensionDevelopmentPath=${workspaceFolder}" ], "outFiles": ["${workspaceFolder}/dist/**/*.js"], "preLaunchTask": "npm: build:devhost" diff --git a/CONTEXT.md b/CONTEXT.md index 1e5ba5406..eec70cd84 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -168,28 +168,6 @@ _Avoid_: Permanent open, full open To open a file node as a persistent VS Code editor tab. _Avoid_: Preview file -### Layout Organization - -**Graph Section**: -A user-created graph organization area represented by a physics-participating **Section Node** that can expand to show members or collapse to a single node. -_Avoid_: Subgraph, group, container, compound node when speaking in product terms - -**Section Frame**: -The expanded visual form of a **Section Node**, including its resizable rectangle, label, color, edge resize handles, and selection chrome. -_Avoid_: Container - -**Section Member**: -A **Node** assigned to a **Graph Section** for user-controlled layout organization. -_Avoid_: Child node unless discussing prior-art parent-child graph models - -**Section Node**: -The **Graph View** node that represents a **Graph Section** in the force layout while expanded or collapsed. -_Avoid_: Folder Node, indexed Node when discussing graph analysis - -**Pinned Node**: -A **Node** with a persisted graph-space position that the graph layout treats as fixed until the pin is moved or removed. -_Avoid_: Favorite, locked node - ### Indexing And Cache **Indexing**: @@ -453,76 +431,11 @@ _Avoid_: Graph export - **Collapse** does not absorb a downstream node that is still related to by a visible node outside the collapsed subgraph. - **Boundary Paths** stay visible so collapse does not invent false direct edges or break existing relationships to shared relationship targets. - **Collapse Projection** runs after the **Visible Graph** exists because users need a rendered graph before deciding what to collapse. -- A **Graph Section** organizes **Section Members** on the **Graph Stage** and is represented by a **Section Node** in the force layout. -- A **Section Frame** is the editable expanded visual form of a **Section Node**. -- A **Section Node** can be expanded into its **Section Frame** or collapsed into a single node; it must not be confused with a **Folder Node**, which represents a real workspace directory. -- An expanded **Graph Section** shows its **Section Members**, and those members use section-local physics centered on that section's origin instead of participating directly in the root graph physics simulation. -- **Section Members** interact physically with other visible members in the same **Graph Section** and use hard visual bounds inside the **Section Frame**. -- **Section Members** remain renderable graph nodes on the single React Force Graph surface so they keep normal hit testing, labels, edges, and built-in node dragging. -- The root graph physics simulation can contain different node geometries, including circular ordinary nodes and rectangular expanded **Section Nodes**, and those visible root actors should collide according to their visible geometry. -- Moving an expanded **Section Frame** moves its visible **Section Members** by the same graph-space delta before physics resumes. -- Moving an expanded **Section Frame** also moves visible pinned **Section Members** and updates their persisted pin positions by the same graph-space delta. -- Moving a collapsed **Section Node** moves its hidden **Section Members** and any persisted member pin positions by the same graph-space delta for the next expansion. -- A pinned **Section Member** moves relative to its owning **Section Frame** when the section moves. -- Dragging a pinned **Section Member** outside its owning **Section Frame** updates the pin to the dropped graph-space position and removes that node from the **Graph Section**. -- A **Section Frame** has a content minimum size based on its members, chrome, and padding; resizing below that minimum collapses the **Graph Section**. -- A parent **Section Frame** includes child **Section Frames** in its content minimum size. -- Expanding a collapsed **Graph Section** restores a **Section Frame** at least as large as its current content minimum size. -- Collapsing a parent **Graph Section** hides its descendant sections without changing their own expanded or collapsed state. -- Expanding a parent **Graph Section** restores each descendant section to the expanded or collapsed state it had before the parent was collapsed. -- Collapsing a **Graph Section** stops its section-local simulation and preserves latest direct-child local positions. -- Expanding a **Graph Section** restores saved direct-child local positions and gently restarts its section-local simulation. -- A **Node** becomes a **Section Member** only through explicit user intent, such as dropping it into a **Section Frame** or using a Graph Context Menu action. -- A **Node** stops being a **Section Member** only through explicit user intent, such as dropping it outside its **Section Frame** or using a Graph Context Menu action. -- Dragging across **Section Frame** boundaries previews the candidate target, but membership changes only when the user drops the dragged item. -- Dragging any **Section Member** outside its owning **Section Frame** and dropping it removes the item from that **Graph Section**. -- Any visible **Node Type** can become a **Section Member**, including File Nodes, Folder Nodes, Package nodes, and Plugin Nodes. -- **Section Member** assignment survives temporary node visibility changes from **Graph Scope**, **Search**, **Filter**, **Show Orphans**, or similar view settings. -- If a hidden **Section Member** becomes visible again and its owning **Graph Section** still exists, it returns to that **Graph Section**. -- If a hidden **Section Member** becomes visible again after its owning **Graph Section** was deleted, it returns to the root graph. -- **Pinned Node** and **Section Member** records can remain dormant while their graph item is hidden or temporarily absent, and should apply again if that graph item returns. -- Explicitly deleting a graph item through CodeGraphy should remove that item's pin and section ownership records. -- **Depth Mode** does not mutate **Section Member** assignment and should preserve section context for visible **Section Members**. -- When **Depth Mode** shows a **Section Member**, its owning **Graph Section** should remain visible enough to show containment context even when the section itself is outside the hop depth. -- **Timeline Snapshots** do not show or allow editing **Pinned Nodes** or **Graph Sections** in v1. -- Physics drift across a **Section Frame** boundary must not add or remove **Section Member** assignment. -- Nested **Graph Sections** form an ownership hierarchy: a node in Section 2 inside Section 1 is directly a **Section Member** of Section 2, while Section 2 is the member within Section 1. -- Nested **Graph Section** support is recursive: a **Graph Section** can contain another **Graph Section**, which can contain its own **Section Members** or nested sections. -- Nested **Graph Section** coordinates are local to the direct parent, recursively, rather than flattened into root graph coordinates. -- A **Graph Section** or **Node** must have at most one direct section owner, and nested section ownership must not form cycles. -- **Section Member** ownership is persisted in a normalized ownership index, while **Graph Sections** store their own visual and layout state separately. -- Deleting only a **Graph Section** removes that section and promotes its direct child nodes and child sections to the deleted section's parent owner. -- Promoted children keep their current graph-space positions when their owning **Graph Section** is deleted. -- Deleting an explicit multi-selection that includes a **Graph Section** and its contents can delete the whole selected set. - Any delete action requires confirmation. -- An **Edge** to a visible **Section Member** renders to that member while its **Graph Section** is expanded. -- A cross-boundary **Edge** to a visible **Section Member** renders to and physically influences the real member endpoint, while that member remains scoped to its section-local physics and **Section Frame** bounds. -- Cross-boundary **Edge** physics is symmetric while a **Graph Section** is expanded: the outside endpoint and the **Section Member** endpoint both feel the relationship through coordinate conversion between root graph space and section-local graph space. -- Cross-boundary **Edge** physics must not pull the **Section Node** as a proxy for a visible **Section Member**. -- **Section Frame** bounds win over cross-boundary **Edge** pull. -- When a **Graph Section** is collapsed, cross-boundary **Edges** render to each endpoint's nearest visible representative as a projection of the original relationships. - Projected cross-boundary **Edges** with the same visible source, visible target, and **Edge Type** render as one aggregated edge that preserves the original edge list for inspection. - Projected cross-boundary **Edges** with different **Edge Types** remain visually distinct. -- A **Graph Section** membership relationship is layout state, not a rendered **Edge**; CodeGraphy should not render **Section Node** to **Section Member** edges. -- A **Graph Section** does not create, remove, or change **Relationships** in the **Relationship Graph**. -- A **Pinned Node** keeps a user-chosen graph-space position while preserving the node's normal **Relationships**. -- **Pinned Node** positions are stored in graph-space coordinates relative to the graph origin, not viewport pan or zoom. -- A **Pinned Node** is not moved by physics while the pin is active. -- A pinned **Section Member** or nested **Section Node** stores a direct-parent-local position and is not moved by its section-local physics while the pin is active. -- 2D and 3D **Pinned Node** positions are separate; a 2D pin applies only in 2D and a 3D pin applies only in 3D. -- **Pinned Nodes** show a small pin badge; collapsed **Section Nodes** show a collapsed-section badge and hidden-descendant count. -- A **Graph Section** has a required label and optional free-form color; the label appears on both the expanded **Section Frame** and collapsed **Section Node**. -- **Graph Section** identity comes from a generated stable id, not its editable label. -- **Graph Section** color tints the **Section Frame** border and header without flood-filling the whole section; users can choose any color from the color picker. -- A selected expanded **Section Frame** shows an accent border, subtly tinted header, and visible resize handles. - Active marquee selection shows a visible desktop-style selection rectangle while the user click-drags. -- A selected **Section Frame** can be a graph placement target for creating a nested **Graph Section** or placing a newly created File Node or Folder Node. - A selected **Folder Node** can be a filesystem destination for creating a new file or folder. -- When both a **Folder Node** and **Section Frame** are selected for file or folder creation, the **Folder Node** supplies the filesystem destination and the **Section Frame** supplies the graph placement owner. -- Creating a **Graph Section** from selection changes only graph ownership and layout; it must not create, remove, or change **Edges**. -- Renaming or moving a file or folder should preserve its **Section Member** assignment when CodeGraphy can identify the moved graph item as the same logical item. -- A **Section Node** can be a **Pinned Node**; pinning a **Section Node** fixes the section's graph-space position without automatically pinning its **Section Members**. -- **Graph Section** editing is 2D-only in v1; 3D does not render **Graph Sections** or **Section Nodes** in v1. - The force graph renderer handles layout, physics, and interaction for the graph produced by **Collapse Projection**. - **Filter** applies persistent include/exclude criteria to graph consideration; **Collapse** keeps important graph items available behind a collapsed node. - **Indexing** starts with **File Discovery**, then runs **Tree-sitter Analysis**, then **Plugin Analysis**, then **Graph Projection**. diff --git a/README.md b/README.md index 53c9de246..18c349d8b 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,6 @@ This repo is a work in progress and is being built through agentic engineering. | Symbol Nodes | Expand files into functions, classes, interfaces, types, variables, constants, and plugin-provided declarations when you need code-level context. | | Search and Filters | Search temporarily, then use persistent Filters to remove generated files, tests, docs, or any other noise from the Visible Graph. | | Graph Scope | Turn Node Types and Edge Types on or off so the graph matches the question you are asking. | -| Graph Sections | Organize related nodes into resizable 2D section frames that keep their own local physics while still showing cross-section edges. | | Material Icon Theme nodes | File and folder nodes use Material Icon Theme shapes and colors instead of generic dots. | | VS Code theme integration | Graph surfaces, panels, buttons, text, and directional arrows follow the active VS Code color theme. | | 2D and 3D renderers | Use the fast 2D canvas for everyday work or switch to 3D WebGL when the shape of the repo matters. | @@ -73,10 +72,6 @@ This repo is a work in progress and is being built through agentic engineering. |:--:|:--:| | ![2D Relationship Graph with Material Icon Theme nodes](./docs/media/readme/relationship-graph-2d.png) | ![3D Relationship Graph with file labels and depth](./docs/media/readme/relationship-graph-3d.png) | -| Graph Sections | -|:--:| -| ![CodeGraphy 2D graph organized into colored Graph Section frames with local node clusters](./docs/media/readme/graph-sections.png) | - | Timeline | |:--:| | ![Timeline panel showing commit playback controls](./docs/media/readme/timeline-panel.png) | @@ -91,9 +86,7 @@ This repo is a work in progress and is being built through agentic engineering. Workspace files, Git history, and workspace-local settings flow into `@codegraphy-dev/core`. The core package owns path-based Indexing, built-in Tree-sitter analysis, enabled plugin execution, Graph Cache reads/writes, and Graph Query. It has no VS Code dependency, so the same engine can run from the VS Code extension, the `codegraphy` CLI, or the local MCP server. -The VS Code extension uses `@codegraphy-dev/core` to build and refresh the workspace Graph Cache, then projects that data into the Visible Graph for the webview, exports, Graph Sections, Symbol Nodes, Timeline, and editor interactions. Language plugins are npm packages loaded through core from the user-level installed-plugin cache and the workspace-local `plugins` array; they are not activated as dependent VS Code extensions. `@codegraphy-dev/mcp` uses the same core APIs for headless agent access: `codegraphy index [workspace]` writes the Graph Cache, Graph Query tools read it, and neither path needs to open or focus VS Code. - -Graph Sections are saved in workspace-local Graph Layout settings. In 2D, add them from the toolbar or background context menu, drag nodes into a frame to make them Section Members, then resize, label, color, pin, collapse, or expand the section as you organize the graph. Expanded sections behave like large graph nodes in the root force layout, while their members run section-local physics inside the frame; collapsed sections become compact nodes with projected incoming and outgoing edges. +The VS Code extension uses `@codegraphy-dev/core` to build and refresh the workspace Graph Cache, then projects that data into the Visible Graph for the webview, exports, Symbol Nodes, Timeline, and editor interactions. Language and feature plugins are npm packages loaded through core from the user-level installed-plugin cache and the workspace-local `plugins` array; they are not activated as dependent VS Code extensions. `@codegraphy-dev/mcp` uses the same core APIs for headless agent access: `codegraphy index [workspace]` writes the Graph Cache, Graph Query tools read it, and neither path needs to open or focus VS Code. Symbol Nodes are built from indexed declarations and appear alongside file, folder, package, and plugin nodes when you need code-level context. Common kinds include Function, Class, Interface, Type, Struct, Enum, Variable, and Constant. `contains` Edges connect files to their declarations, and symbol-aware relationship Edges show calls, references, inheritance, overrides, imports, and plugin-provided links when analysis can resolve them. Legend defaults style common symbol kinds automatically, custom Legend Entries can target symbol names, kinds, plugin kinds, languages, or containing file paths, and Graph Query/MCP exposes the same symbol payloads to agents. diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 000000000..c3af7775e --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,14 @@ +# @codegraphy/web + +Public CodeGraphy web app target for account, subscription, billing, and Access flows. + +This first Extract Pro slice establishes the workspace package and route contract: + +- `/register` +- `/login` +- `/subscription` +- `/account` +- `/billing` +- `/access/:accessKey` + +The access route is the host callback surface for returning paid capability state to CodeGraphy hosts and paid plugins. Full UI, auth, and billing implementation belongs to the dedicated website and Pro account follow-up work. diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 000000000..d4df4c2d0 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "@codegraphy/web", + "version": "0.1.0", + "description": "CodeGraphy account, subscription, billing, and access web app", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "engines": { + "node": ">=22.22.0 <23" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "vitest run", + "lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\"", + "typecheck": "tsc --noEmit -p tsconfig.json" + } +} diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts new file mode 100644 index 000000000..c9c8846ef --- /dev/null +++ b/apps/web/src/index.ts @@ -0,0 +1,106 @@ +export const CODEGRAPHY_WEB_ROUTE_IDS = [ + 'register', + 'login', + 'subscription', + 'account', + 'billing', + 'access', +] as const; + +export type CodeGraphyWebRouteId = typeof CODEGRAPHY_WEB_ROUTE_IDS[number]; + +export interface CodeGraphyWebRoute { + id: CodeGraphyWebRouteId; + label: string; + path: string; + purpose: string; + requiresSignedInUser: boolean; + showInNavigation: boolean; +} + +export interface CodeGraphyWebApp { + name: 'CodeGraphy Web'; + routes: CodeGraphyWebRoute[]; +} + +export interface CodeGraphyWebNavigationItem { + id: Exclude; + label: string; + path: string; +} + +const ROUTES: CodeGraphyWebRoute[] = [ + { + id: 'register', + label: 'Register', + path: '/register', + purpose: 'Create a CodeGraphy account.', + requiresSignedInUser: false, + showInNavigation: true, + }, + { + id: 'login', + label: 'Login', + path: '/login', + purpose: 'Authenticate an existing CodeGraphy account.', + requiresSignedInUser: false, + showInNavigation: true, + }, + { + id: 'subscription', + label: 'Subscription', + path: '/subscription', + purpose: 'Choose or review a CodeGraphy subscription.', + requiresSignedInUser: true, + showInNavigation: true, + }, + { + id: 'account', + label: 'Account', + path: '/account', + purpose: 'Manage CodeGraphy account details.', + requiresSignedInUser: true, + showInNavigation: true, + }, + { + id: 'billing', + label: 'Billing', + path: '/billing', + purpose: 'Manage invoices, payment methods, and billing portal links.', + requiresSignedInUser: true, + showInNavigation: true, + }, + { + id: 'access', + label: 'Access', + path: '/access/:accessKey', + purpose: 'Return a signed access decision for CodeGraphy hosts and paid plugins.', + requiresSignedInUser: true, + showInNavigation: false, + }, +]; + +function isNavigationRoute( + route: CodeGraphyWebRoute, +): route is CodeGraphyWebRoute & { id: Exclude } { + return route.showInNavigation && route.id !== 'access'; +} + +export function createCodeGraphyWebApp(): CodeGraphyWebApp { + return { + name: 'CodeGraphy Web', + routes: ROUTES.map(route => ({ ...route })), + }; +} + +export function createCodeGraphyWebNavigation( + app: CodeGraphyWebApp, +): CodeGraphyWebNavigationItem[] { + return app.routes + .filter(isNavigationRoute) + .map(route => ({ + id: route.id, + label: route.label, + path: route.path, + })); +} diff --git a/apps/web/tests/app.test.ts b/apps/web/tests/app.test.ts new file mode 100644 index 000000000..b0a21903b --- /dev/null +++ b/apps/web/tests/app.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { + CODEGRAPHY_WEB_ROUTE_IDS, + createCodeGraphyWebApp, + createCodeGraphyWebNavigation, +} from '../src'; + +describe('@codegraphy/web app shell', () => { + it('declares the account, billing, subscription, and access flow routes', () => { + const app = createCodeGraphyWebApp(); + + expect(app.name).toBe('CodeGraphy Web'); + expect(app.routes.map(route => route.id)).toEqual(CODEGRAPHY_WEB_ROUTE_IDS); + expect(app.routes).toEqual([ + expect.objectContaining({ id: 'register', path: '/register' }), + expect.objectContaining({ id: 'login', path: '/login' }), + expect.objectContaining({ id: 'subscription', path: '/subscription' }), + expect.objectContaining({ id: 'account', path: '/account' }), + expect.objectContaining({ id: 'billing', path: '/billing' }), + expect.objectContaining({ id: 'access', path: '/access/:accessKey' }), + ]); + }); + + it('marks access as the callback that returns paid capability state to hosts', () => { + const app = createCodeGraphyWebApp(); + const accessRoute = app.routes.find(route => route.id === 'access'); + + expect(accessRoute).toMatchObject({ + id: 'access', + purpose: 'Return a signed access decision for CodeGraphy hosts and paid plugins.', + requiresSignedInUser: true, + }); + }); + + it('builds navigation without exposing access callback routes as normal nav items', () => { + const app = createCodeGraphyWebApp(); + + expect(createCodeGraphyWebNavigation(app)).toEqual([ + { id: 'register', label: 'Register', path: '/register' }, + { id: 'login', label: 'Login', path: '/login' }, + { id: 'subscription', label: 'Subscription', path: '/subscription' }, + { id: 'account', label: 'Account', path: '/account' }, + { id: 'billing', label: 'Billing', path: '/billing' }, + ]); + }); +}); diff --git a/apps/web/tsconfig.build.json b/apps/web/tsconfig.build.json new file mode 100644 index 000000000..ec3c4b723 --- /dev/null +++ b/apps/web/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 000000000..35af928bb --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.plugins.json", + "include": ["src/**/*.ts", "tests/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/docs/INTERACTIONS.md b/docs/INTERACTIONS.md index 0ba552b38..c901752e4 100644 --- a/docs/INTERACTIONS.md +++ b/docs/INTERACTIONS.md @@ -10,7 +10,7 @@ | Double-click | Select and focus the node; File Nodes also open the file as a persistent editor tab | | Right-click | Set Context Selection if needed and open the context menu without previewing or opening a file | | `Ctrl+Click` (macOS) | Open the context menu (same as right-click) | -| Drag | Reposition the node (position is saved) | +| Drag | Reposition the node for the current graph session | | Hover | Show tooltip with file details | | Hover cursor | Pointer cursor | | `Ctrl+Click` / `Cmd+Click` | Add or remove from selection | @@ -26,23 +26,6 @@ | Hover cursor | Default cursor | | Right-click | Open background context menu | -## Graph Sections - -Graph Sections are editable 2D frames for organizing nodes inside the Relationship Graph. - -| Action | Effect | -|--------|--------| -| Toolbar New menu | Create a root-level Graph Section after `New File...` and `New Folder...` | -| Background context menu | Create a Graph Section at the clicked graph position | -| Multi-selection context menu | Create a Graph Section around the selected nodes | -| Drag node into frame | Assign the node to the deepest expanded section under the drop point | -| Drag Section header | Move the whole Section Frame through the root graph layout | -| Resize handles | Resize the expanded Section Frame, respecting the minimum size | -| Header label / color / icon | Rename, recolor, or assign an icon to the Section | -| Collapse / Expand | Collapse the Section into one compact node or expand it back into its frame | - -Expanded Section Frames participate in the root force layout as rectangular graph nodes. Section Members run section-local force physics inside the frame, collide with one another and the section body, and can still keep edges to nodes outside the section. Collapsed Sections project member edges onto the compact section node. - ## Context menu Right-click background, nodes, multi-node selections, or edges to access context-specific actions: diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 483391447..ef990e24d 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -43,6 +43,7 @@ Installation and enablement are separate: - `npm i -g @codegraphy-dev/plugin-python` installs a plugin package for the developer's toolchain. - `codegraphy plugins refresh` records installed `@codegraphy-dev/*` plugin packages in `~/.codegraphy/plugins.json`. - `codegraphy plugins add ` records an explicitly named globally installed plugin package, including private or non-`@codegraphy` packages. +- `codegraphy plugins link ` records a local package checkout directly in `~/.codegraphy/plugins.json`, which is the preferred local-development path for private plugins. - `codegraphy plugins enable [workspace]` writes that plugin into the workspace-local `plugins` array. - `codegraphy plugins disable [workspace]` removes that plugin from the workspace-local enabled set. - Enabling and disabling plugins do not run Indexing automatically; run `codegraphy index [workspace]` to refresh the Graph Cache. @@ -74,9 +75,44 @@ Plugin packages declare CodeGraphy metadata in `package.json` so discovery can v } ``` -The npm package's normal `exports` field owns runtime import behavior. The `codegraphy` block is for identity, Plugin API compatibility, optional default options, and optional capability disclosures. Plugin runtime loading happens during explicit Indexing, not during install, refresh, list, enable, or disable commands. +The npm package's normal `exports` field owns runtime import behavior. The `codegraphy` block is for identity, Plugin API compatibility, optional default options, and optional capability disclosures. Plugin runtime loading happens during explicit Indexing, not during install, refresh, list, enable, disable, or link commands. -When Indexing loads an enabled package, `@codegraphy-dev/core` merges `codegraphy.defaultOptions` from the package manifest with the workspace entry's `options` object. Workspace options win. The merged object is passed to `initialize`, `onPreAnalyze`, `onFilesChanged`, and `analyzeFile` as `context.options`, so the same plugin package can run with different settings in different CodeGraphy Workspaces. +For local private plugin development, keep the private source outside this public monorepo and link its package root: + +```bash +codegraphy plugins link ~/src/acme-graph-tools +codegraphy plugins enable @acme/graph-tools /path/to/indexed-folder +codegraphy index /path/to/indexed-folder +``` + +When testing through F5, launch only the public CodeGraphy VS Code extension. Do not add headless plugin package folders to VS Code's `extensionDevelopmentPath`; the extension host loads linked packages from `~/.codegraphy/plugins.json` and the opened workspace's `.codegraphy/settings.json`. + +The Plugins panel is a package toggle surface. It shows package-backed plugins that can be enabled, disabled, and reordered for the current CodeGraphy Workspace. Core runtime internals such as Tree-sitter, and legacy VS Code extension plugin entries without a package backing, are not shown as plugin toggle rows. + +Disabling a package removes it from the workspace `plugins` array and reloads Graph View contributions. Package-owned persisted data may remain on disk, but its Graph View nodes, forces, context menu entries, toolbar create entries, webview injections, and UI slots only render while that package is enabled and loaded. The Graph View host broadcasts the refreshed plugin status and contribution state immediately after a package toggle, before the follow-up graph analysis finishes. + +When Indexing loads an enabled package, `@codegraphy-dev/core` merges `codegraphy.defaultOptions` from the package manifest with the workspace entry's `options` object. Workspace options win. The merged object is passed to package plugin factories as `factoryOptions.options`, and to `initialize`, `onPreAnalyze`, `onFilesChanged`, and `analyzeFile` as `context.options`, so the same plugin package can run with different settings in different CodeGraphy Workspaces. + +Package factories also receive a workspace-scoped `factoryOptions.dataHost` when the package is loaded for a concrete CodeGraphy Workspace: + +```ts +import type { IPluginFactory } from '@codegraphy-dev/plugin-api'; + +const createPlugin: IPluginFactory = ({ dataHost, options } = {}) => ({ + id: 'acme.graph-tools', + name: 'Acme Graph Tools', + version: '1.0.0', + apiVersion: '^2.0.0', + supportedExtensions: [], + async initialize() { + await dataHost?.saveData({ mode: options?.mode ?? 'default' }); + }, +}); + +export default createPlugin; +``` + +The data host persists under the plugin id returned by the factory, not under the npm package name. Use it from lifecycle hooks, analysis hooks, and Graph View contributions after the factory returns. Default options are copied into workspace settings when the plugin is enabled so the user can see and edit the starting values for that workspace. For example, enabling a Godot plugin whose package manifest contains: diff --git a/docs/plans/2026-04-18-extension-organize-followup.md b/docs/plans/2026-04-18-extension-structure-followup.md similarity index 93% rename from docs/plans/2026-04-18-extension-organize-followup.md rename to docs/plans/2026-04-18-extension-structure-followup.md index e20ea0613..e8bfddc72 100644 --- a/docs/plans/2026-04-18-extension-organize-followup.md +++ b/docs/plans/2026-04-18-extension-structure-followup.md @@ -1,10 +1,10 @@ -# Extension Organize Follow-up +# Extension Structure Follow-up ## Goal After the code-index rearchitecture PR lands, run a focused organization follow-up for `packages/extension`. -The goal is not to satisfy every mechanical organize hint. The goal is to make the extension package easier to navigate by moving obvious feature clusters into feature folders, mirroring source/test structure where useful, and replacing vague folder/file names with names that carry domain context. +The goal is not to satisfy every mechanical structure hint. The goal is to make the extension package easier to navigate by moving obvious feature clusters into feature folders, mirroring source/test structure where useful, and replacing vague folder/file names with names that carry domain context. ## Scope @@ -28,7 +28,7 @@ Out: - Prefer feature folders over technical layers. - Path carries feature context; filename carries role. - Do not create folders named `file`, `model`, `get`, `index`, `default`, `low`, `max`, `alt`, or `auto`. -- Use better names when organize suggests generic names: +- Use better names when structure analysis suggests generic names: - `auto` -> `autoFit` when the files are viewport fitting behavior. - `default` -> `dependencies` when the files wire default dependencies. - `single` -> `singleClick` when the files are click behavior. @@ -74,8 +74,8 @@ Mirror tests: | `packages/extension/tests/webview/graph/runtime/use/physics/hook/` | `hook.test.ts`, `hook.controlFlow.test.tsx`, `hook.integration.test.tsx` | Physics hook tests should live with the hook behavior. | Targeted verification: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graph/model tests/webview/graph/messages tests/webview/graph/runtime/use/physics` -- `pnpm --filter @codegraphy/extension typecheck` +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/graph/model tests/webview/graph/messages tests/webview/graph/runtime/use/physics` +- `pnpm --filter @codegraphy-dev/extension typecheck` ### Batch 2: Webview Graph Interaction And Menus @@ -99,8 +99,8 @@ Mirror tests: | `packages/extension/tests/webview/graph/rendering/image/` | `imageCache.test.ts`, `imageCache.mutations.test.ts`, `imageCache.deps.test.ts` | Groups image cache tests and removes repeated filename prefix. | Targeted verification: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenu tests/webview/graph/contextMenuRuntime tests/webview/graph/interaction tests/webview/graph/keyboard tests/webview/graph/rendering` -- `pnpm --filter @codegraphy/extension typecheck` +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenu tests/webview/graph/contextMenuRuntime tests/webview/graph/interaction tests/webview/graph/keyboard tests/webview/graph/rendering` +- `pnpm --filter @codegraphy-dev/extension typecheck` ### Batch 3: Webview UI Components @@ -134,8 +134,8 @@ Mirror tests: | `packages/extension/tests/webview/nodeTooltip/view/` | `view.test.tsx`, `view.mutations.test.tsx`, `view.css.test.tsx` | Multiple view tests cover one tooltip view behavior. | Targeted verification: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/components tests/webview/searchBar tests/webview/nodeTooltip tests/webview/toolbar` -- `pnpm --filter @codegraphy/extension typecheck` +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/components tests/webview/searchBar tests/webview/nodeTooltip tests/webview/toolbar` +- `pnpm --filter @codegraphy-dev/extension typecheck` ### Batch 4: Webview Export, Plugin Host, Store, Theme @@ -160,8 +160,8 @@ Mirror tests: | `packages/extension/tests/webview/store/optimistic/` | `optimisticGroups.test.ts`, `optimisticGroupsPending.test.ts`, `optimisticGroupsUser.test.ts` | Mirrors `store/optimistic/groups/`; multiple tests for the same behavior stay grouped. | Targeted verification: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/export tests/webview/pluginHost tests/webview/store tests/webview/theme` -- `pnpm --filter @codegraphy/extension typecheck` +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/export tests/webview/pluginHost tests/webview/store tests/webview/theme` +- `pnpm --filter @codegraphy-dev/extension typecheck` ### Batch 5: Extension Host Graph View @@ -200,8 +200,8 @@ Mirror tests: | `packages/extension/tests/extension/graphView/webview/providerMessages/plugin/` | `pluginApis.test.ts`, `pluginContext.test.ts`, `pluginState.test.ts` | Mirrors plugin provider-message behavior. | Targeted verification: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/extension/graphView tests/extension/graphViewProvider` -- `pnpm --filter @codegraphy/extension typecheck` +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/graphView tests/extension/graphViewProvider` +- `pnpm --filter @codegraphy-dev/extension typecheck` ### Batch 6: Extension Host Pipeline And Tree-sitter @@ -235,8 +235,8 @@ Mirror tests: | `packages/extension/tests/extension/pipeline/treesitter/projectRoots/workspace/` | `workspace.ts`, `workspaceBounds.test.ts` | Workspace-bound project-root behavior belongs together. | Targeted verification: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/extension/pipeline` -- `pnpm --filter @codegraphy/extension typecheck` +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/pipeline` +- `pnpm --filter @codegraphy-dev/extension typecheck` ### Batch 7: Core, Shared, Settings, Commands @@ -276,8 +276,8 @@ Mirror tests: | `packages/extension/tests/extension/workspaceFiles/watcher/` | `fileSystemListeners.test.ts`, `fileWatcherSetup.test.ts`, `fileWatcherSetupMutants.test.ts` | Use `watcher/`, not `file/`; these test watcher setup/listeners. | Targeted verification: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/core tests/extension/config tests/extension/export tests/extension/gitHistory tests/extension/workspaceFiles tests/shared` -- `pnpm --filter @codegraphy/extension typecheck` +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/core tests/extension/config tests/extension/export tests/extension/gitHistory tests/extension/workspaceFiles tests/shared` +- `pnpm --filter @codegraphy-dev/extension typecheck` ## Naming Fixes To Include @@ -327,7 +327,7 @@ These should be done as part of the move batches when touching the relevant file After each batch: - targeted Vitest command listed in the batch -- `pnpm --filter @codegraphy/extension typecheck` +- `pnpm --filter @codegraphy-dev/extension typecheck` - `pnpm run organize` Before each PR is done: diff --git a/docs/plans/2026-04-30-issue-130-mcp-graph-query.md b/docs/plans/2026-04-30-issue-130-mcp-graph-query.md index 13b7e2abc..7b666170d 100644 --- a/docs/plans/2026-04-30-issue-130-mcp-graph-query.md +++ b/docs/plans/2026-04-30-issue-130-mcp-graph-query.md @@ -580,7 +580,7 @@ Update: Changesets: - `@codegraphy-vscode/mcp`: major -- `@codegraphy/extension`: likely minor or major depending on exported user-facing behavior +- `@codegraphy-dev/extension`: likely minor or major depending on exported user-facing behavior - plugin API only if contracts change Docs should use: @@ -603,7 +603,7 @@ Docs should not use: Targeted tests while iterating: ```bash -pnpm --filter @codegraphy/extension test +pnpm --filter @codegraphy-dev/extension test pnpm --filter @codegraphy-vscode/mcp test pnpm --filter @codegraphy-vscode/mcp run typecheck pnpm --filter @codegraphy-vscode/mcp run lint diff --git a/docs/plans/2026-05-04-graph-context-menu-architecture.md b/docs/plans/2026-05-04-graph-context-menu-architecture.md index a045549e1..070ad8055 100644 --- a/docs/plans/2026-05-04-graph-context-menu-architecture.md +++ b/docs/plans/2026-05-04-graph-context-menu-architecture.md @@ -59,9 +59,9 @@ Status: - Background, File Node, Folder Node, and multi-node mutation entries consistently stay visible when disabled by a historical Timeline Snapshot. - `timelineActive` still drives timeline-specific non-mutation behavior such as hiding `Reveal in Explorer`. - Verified with: - - `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenu/model.test.ts` - - `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenu` - - `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graph/viewport/model.test.tsx` + - `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenu/model.test.ts` + - `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenu` + - `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/graph/viewport/model.test.tsx` ## Slice 1: Context Selection Classification @@ -83,9 +83,9 @@ Status: - Package id detection is centralized in the decision target facts and re-exported for existing node menu helpers. - Plugin menu eligibility now uses the same decision model in the real Graph Context Menu build path. - Verified with: - - `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenu/decision/model.test.ts` - - `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenu/decision/model.test.ts tests/webview/graph/contextMenu/targetClassification.test.ts tests/webview/graph/contextMenu/pluginEntries.test.ts tests/webview/graph/contextMenu/model.test.ts` - - `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenu` + - `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenu/decision/model.test.ts` + - `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenu/decision/model.test.ts tests/webview/graph/contextMenu/targetClassification.test.ts tests/webview/graph/contextMenu/pluginEntries.test.ts tests/webview/graph/contextMenu/model.test.ts` + - `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenu` ## Slice 3: Built-In Action Context @@ -107,10 +107,10 @@ Status: - The interaction runtime resolves the current `Context Selection` into action context before dispatching a Graph Context Menu action. - CI hardening follow-up: the 3D Graph surface now remounts when the viewport transitions from unmeasured to measured dimensions, fixing a Playwright failure where temporary zero-size layouts could leave WebGL blank in CI without changing existing 3D menu test assumptions. - Verified with: - - `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextActions/effects.test.ts tests/webview/graph/contextActions/builders.test.ts tests/webview/graph/contextMenuRuntime/effects.test.ts tests/webview/graph/contextMenuRuntime/controller.test.ts tests/webview/graph/runtime/use/interaction.test.tsx` - - `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextActions tests/webview/graph/contextMenuRuntime tests/webview/graph/contextMenu tests/webview/graph/runtime/use/interaction.test.tsx` - - `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graph/viewport/view.test.tsx tests/webview/graph/runtime/containerSize.test.tsx` - - `pnpm --filter @codegraphy/extension exec playwright test --config playwright.config.ts tests/playwright/depth-view.spec.ts:258 --project=chromium` + - `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextActions/effects.test.ts tests/webview/graph/contextActions/builders.test.ts tests/webview/graph/contextMenuRuntime/effects.test.ts tests/webview/graph/contextMenuRuntime/controller.test.ts tests/webview/graph/runtime/use/interaction.test.tsx` + - `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextActions tests/webview/graph/contextMenuRuntime tests/webview/graph/contextMenu tests/webview/graph/runtime/use/interaction.test.tsx` + - `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/graph/viewport/view.test.tsx tests/webview/graph/runtime/containerSize.test.tsx` + - `pnpm --filter @codegraphy-dev/extension exec playwright test --config playwright.config.ts tests/playwright/depth-view.spec.ts:258 --project=chromium` ## Slice 4: Opening Mechanics @@ -130,7 +130,7 @@ Status: - Added `contextMenuOpening/runtime.ts` as the module that owns Graph Context Menu opening handlers, pointer-event translation, graph right-click adapters, runtime dependency wiring, and action-context dispatch. - `useGraphInteractionRuntime` now composes that module instead of keeping Graph Context Menu opening mechanics inline with tooltip and cursor effects. - Verified with: - - `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenuOpening/runtime.test.ts tests/webview/graph/runtime/use/interaction.test.tsx tests/webview/graph/contextMenuRuntime/controller.test.ts tests/webview/graph/contextMenuRuntime/effects.test.ts` + - `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenuOpening/runtime.test.ts tests/webview/graph/runtime/use/interaction.test.tsx tests/webview/graph/contextMenuRuntime/controller.test.ts tests/webview/graph/contextMenuRuntime/effects.test.ts` ## Slice 5: Product Scenario Tests @@ -149,7 +149,7 @@ Status: - Done in this branch. - Added `contextMenu/scenarios.test.ts` with product scenarios for live background actions, historical Timeline Snapshot mutability, historical File Node inspection, current Graph Revision Folder Node mutation, Edge source/target effects, and mixed selection bulk safety. - Verified with: - - `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenu/scenarios.test.ts tests/webview/graph/contextMenu/model.test.ts tests/webview/graph/contextActions/effects.test.ts` + - `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/graph/contextMenu/scenarios.test.ts tests/webview/graph/contextMenu/model.test.ts tests/webview/graph/contextActions/effects.test.ts` ## Quality Gates diff --git a/docs/plans/2026-05-04-monorepo-quality-architecture.md b/docs/plans/2026-05-04-monorepo-quality-architecture.md index df6ba395f..0d5086e61 100644 --- a/docs/plans/2026-05-04-monorepo-quality-architecture.md +++ b/docs/plans/2026-05-04-monorepo-quality-architecture.md @@ -116,8 +116,8 @@ Retested after cleanup: - `pnpm run reachability -- extension/ --strict`: pass, 0 dead surfaces, 0 dead ends - `pnpm run reachability -- codegraphy-mcp/ --strict`: pass, 0 dead surfaces, 0 dead ends -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/shared/graphControls tests/shared/visibleGraph tests/webview/search tests/webview/settingsPanel`: pass, 42 files / 221 tests -- `pnpm --filter @codegraphy/quality-tools exec vitest run --config vitest.config.ts tests/boundaries/selection.test.ts`: pass +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/shared/graphControls tests/shared/visibleGraph tests/webview/search tests/webview/settingsPanel`: pass, 42 files / 221 tests +- `pnpm --filter @codegraphy-dev/quality-tools exec vitest run --config vitest.config.ts tests/boundaries/selection.test.ts`: pass - `pnpm run mutate -- quality-tools/src/boundaries/selection.ts`: pass, 100% mutation score, 0 survivors - `pnpm run lint`: pass - `pnpm run typecheck`: pass @@ -148,8 +148,8 @@ Changes made: Validation: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/core/graphQuery`: pass, 9 files / 54 tests -- `pnpm --filter @codegraphy/extension exec tsc --noEmit -p tsconfig.tests.json`: pass +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/core/graphQuery`: pass, 9 files / 54 tests +- `pnpm --filter @codegraphy-dev/extension exec tsc --noEmit -p tsconfig.tests.json`: pass - `pnpm run crap -- extension/src/core/graphQuery`: pass, all functions CRAP <= 8 - `pnpm run boundaries -- extension/src/core/graphQuery`: pass, 0 layer violations, 0 dead surfaces, 0 dead ends - `pnpm run reachability -- extension/ --strict`: pass, 0 dead surfaces, 0 dead ends @@ -183,8 +183,8 @@ Changes made: Validation: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graphControls/filtering`: pass, 2 files / 12 tests -- `pnpm --filter @codegraphy/extension exec tsc --noEmit -p tsconfig.tests.json`: pass +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/graphControls/filtering`: pass, 2 files / 12 tests +- `pnpm --filter @codegraphy-dev/extension exec tsc --noEmit -p tsconfig.tests.json`: pass - `pnpm run boundaries -- extension/src/webview/graphControls/filtering`: pass, 0 layer violations, 0 dead surfaces, 0 dead ends - `pnpm run reachability -- extension/ --strict`: pass, 0 dead surfaces, 0 dead ends - `pnpm run crap -- extension/src/webview/graphControls/filtering`: pass, all functions CRAP <= 8; `edges.ts` and `nodes.ts` at 100% statements, branches, functions, and lines @@ -214,9 +214,9 @@ Changes made: Validation: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/extension/pipeline/treesitter/javascript/typeImports tests/extension/pipeline/treesitter/javascript/imports.test.ts tests/extension/pipeline/treesitter/analyze.test.ts`: pass, 6 files / 37 tests -- `pnpm --filter @codegraphy/extension exec tsc --noEmit -p tsconfig.tests.json`: pass -- `pnpm --filter @codegraphy/extension exec eslint src/extension/pipeline/plugins/treesitter/runtime/analyzeJavaScript tests/extension/pipeline/treesitter/javascript/typeImports tests/extension/pipeline/treesitter/javascript/imports.test.ts`: pass +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/pipeline/treesitter/javascript/typeImports tests/extension/pipeline/treesitter/javascript/imports.test.ts tests/extension/pipeline/treesitter/analyze.test.ts`: pass, 6 files / 37 tests +- `pnpm --filter @codegraphy-dev/extension exec tsc --noEmit -p tsconfig.tests.json`: pass +- `pnpm --filter @codegraphy-dev/extension exec eslint src/extension/pipeline/plugins/treesitter/runtime/analyzeJavaScript tests/extension/pipeline/treesitter/javascript/typeImports tests/extension/pipeline/treesitter/javascript/imports.test.ts`: pass - `pnpm run boundaries -- extension/src/extension/pipeline/plugins/treesitter/runtime/analyzeJavaScript`: pass, 0 layer violations, 0 dead surfaces, 0 dead ends - `pnpm run reachability -- extension/ --strict`: pass, 0 dead surfaces, 0 dead ends - `pnpm run crap -- extension/src/extension/pipeline/plugins/treesitter/runtime/analyzeJavaScript`: pass, all functions CRAP <= 8 @@ -253,11 +253,11 @@ Changes made: Validation: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/extension/pipeline/treesitter/analyze.test.ts tests/extension/pipeline/treesitter/c/analyze.test.ts tests/extension/pipeline/treesitter/cpp/analyze.test.ts tests/extension/pipeline/treesitter/haskell/analyze.test.ts tests/extension/pipeline/treesitter/stringSpecifier.test.ts`: pass, 5 files / 18 tests -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/extension/pipeline/treesitter/cfamily`: pass, 4 files / 14 tests -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/extension/pipeline/treesitter/haskell/symbols.test.ts tests/extension/pipeline/treesitter/haskell/analyze.test.ts`: pass, 2 files / 5 tests -- `pnpm --filter @codegraphy/extension exec tsc --noEmit -p tsconfig.tests.json`: pass -- `pnpm --filter @codegraphy/extension exec eslint src/extension/pipeline/plugins/treesitter/runtime/analyze.ts src/extension/pipeline/plugins/treesitter/runtime/analyzeCFamily src/extension/pipeline/plugins/treesitter/runtime/analyzeHaskell/symbols.ts src/extension/pipeline/plugins/treesitter/runtime/analyze/stringSpecifier.ts tests/extension/pipeline/treesitter/analyze.test.ts tests/extension/pipeline/treesitter/cfamily tests/extension/pipeline/treesitter/haskell/symbols.test.ts`: pass +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/pipeline/treesitter/analyze.test.ts tests/extension/pipeline/treesitter/c/analyze.test.ts tests/extension/pipeline/treesitter/cpp/analyze.test.ts tests/extension/pipeline/treesitter/haskell/analyze.test.ts tests/extension/pipeline/treesitter/stringSpecifier.test.ts`: pass, 5 files / 18 tests +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/pipeline/treesitter/cfamily`: pass, 4 files / 14 tests +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/pipeline/treesitter/haskell/symbols.test.ts tests/extension/pipeline/treesitter/haskell/analyze.test.ts`: pass, 2 files / 5 tests +- `pnpm --filter @codegraphy-dev/extension exec tsc --noEmit -p tsconfig.tests.json`: pass +- `pnpm --filter @codegraphy-dev/extension exec eslint src/extension/pipeline/plugins/treesitter/runtime/analyze.ts src/extension/pipeline/plugins/treesitter/runtime/analyzeCFamily src/extension/pipeline/plugins/treesitter/runtime/analyzeHaskell/symbols.ts src/extension/pipeline/plugins/treesitter/runtime/analyze/stringSpecifier.ts tests/extension/pipeline/treesitter/analyze.test.ts tests/extension/pipeline/treesitter/cfamily tests/extension/pipeline/treesitter/haskell/symbols.test.ts`: pass - `pnpm run boundaries -- extension/src/extension/pipeline/plugins/treesitter/runtime`: pass, 0 layer violations, 0 dead surfaces, 0 dead ends - `pnpm run crap -- extension/src/extension/pipeline/plugins/treesitter/runtime`: pass, all functions CRAP <= 8 - `pnpm run crap -- extension/src/extension/pipeline/plugins/treesitter/runtime/analyzeCFamily`: pass, all functions CRAP <= 8 @@ -299,8 +299,8 @@ Changes made: Validation: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/shared/visibleGraph`: pass, 9 files / 50 tests -- `pnpm --filter @codegraphy/extension exec eslint src/shared/visibleGraph tests/shared/visibleGraph`: pass +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/shared/visibleGraph`: pass, 9 files / 50 tests +- `pnpm --filter @codegraphy-dev/extension exec eslint src/shared/visibleGraph tests/shared/visibleGraph`: pass - `pnpm run boundaries -- extension/src/shared/visibleGraph`: pass, 0 layer violations, 0 dead surfaces, 0 dead ends - `pnpm run reachability -- extension/ --strict`: pass, 0 dead surfaces, 0 dead ends - `pnpm run crap -- extension/src/shared/visibleGraph`: pass, all functions CRAP <= 8 @@ -338,9 +338,9 @@ Changes made: Validation: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/graphCornerControls`: pass, 5 files / 27 tests -- `pnpm --filter @codegraphy/extension exec eslint src/webview/components/graphCornerControls tests/webview/graphCornerControls`: pass -- `pnpm --filter @codegraphy/extension exec tsc --noEmit -p tsconfig.tests.json`: pass +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/graphCornerControls`: pass, 5 files / 27 tests +- `pnpm --filter @codegraphy-dev/extension exec eslint src/webview/components/graphCornerControls tests/webview/graphCornerControls`: pass +- `pnpm --filter @codegraphy-dev/extension exec tsc --noEmit -p tsconfig.tests.json`: pass - `pnpm run boundaries -- extension/src/webview/components/graphCornerControls`: pass, 0 layer violations, 0 dead surfaces, 0 dead ends - `pnpm run reachability -- extension/ --strict`: pass, 0 dead surfaces, 0 dead ends - `pnpm run crap -- extension/src/webview/components/graphCornerControls`: pass, all functions CRAP <= 8 @@ -374,9 +374,9 @@ Changes made: Validation: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/extension/repoSettings/freshness`: pass, 3 files / 18 tests -- `pnpm --filter @codegraphy/extension exec eslint src/extension/repoSettings/freshness tests/extension/repoSettings/freshness`: pass -- `pnpm --filter @codegraphy/extension exec tsc --noEmit -p tsconfig.tests.json`: pass +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/repoSettings/freshness`: pass, 3 files / 18 tests +- `pnpm --filter @codegraphy-dev/extension exec eslint src/extension/repoSettings/freshness tests/extension/repoSettings/freshness`: pass +- `pnpm --filter @codegraphy-dev/extension exec tsc --noEmit -p tsconfig.tests.json`: pass - `pnpm run boundaries -- extension/src/extension/repoSettings/freshness`: pass, 0 layer violations, 0 dead surfaces, 0 dead ends - `pnpm run reachability -- extension/ --strict`: pass, 0 dead surfaces, 0 dead ends - `pnpm run crap -- extension/src/extension/repoSettings/freshness`: pass, all functions CRAP <= 8 @@ -407,9 +407,9 @@ Changes made: Validation: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/extension/workspaceFiles/refresh`: pass, 4 files / 27 tests -- `pnpm --filter @codegraphy/extension exec eslint src/extension/workspaceFiles/refresh tests/extension/workspaceFiles/refresh`: pass -- `pnpm --filter @codegraphy/extension exec tsc --noEmit -p tsconfig.tests.json`: pass +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/workspaceFiles/refresh`: pass, 4 files / 27 tests +- `pnpm --filter @codegraphy-dev/extension exec eslint src/extension/workspaceFiles/refresh tests/extension/workspaceFiles/refresh`: pass +- `pnpm --filter @codegraphy-dev/extension exec tsc --noEmit -p tsconfig.tests.json`: pass - `pnpm run boundaries -- extension/src/extension/workspaceFiles/refresh`: pass, 0 layer violations, 0 dead surfaces, 0 dead ends - `pnpm run reachability -- extension/ --strict`: pass, 0 dead surfaces, 0 dead ends - `pnpm run crap -- extension/src/extension/workspaceFiles/refresh`: pass, all functions CRAP <= 8 @@ -443,9 +443,9 @@ Changes made: Validation: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/core/discovery/file/walk.test.ts`: pass, 1 file / 13 tests -- `pnpm --filter @codegraphy/extension exec eslint src/core/discovery/file/walk.ts tests/core/discovery/file/walk.test.ts`: pass -- `pnpm --filter @codegraphy/extension exec tsc --noEmit -p tsconfig.tests.json`: pass +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/core/discovery/file/walk.test.ts`: pass, 1 file / 13 tests +- `pnpm --filter @codegraphy-dev/extension exec eslint src/core/discovery/file/walk.ts tests/core/discovery/file/walk.test.ts`: pass +- `pnpm --filter @codegraphy-dev/extension exec tsc --noEmit -p tsconfig.tests.json`: pass - `pnpm run boundaries -- extension/src/core/discovery/file`: pass, 0 layer violations, 0 dead surfaces, 0 dead ends - `pnpm run reachability -- extension/ --strict`: pass, 0 dead surfaces, 0 dead ends - `pnpm run crap -- extension/src/core/discovery/file`: pass, all functions CRAP <= 8 @@ -483,9 +483,9 @@ Changes made: Validation: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/extension/pipeline/fileAnalysis`: pass, 5 files / 24 tests -- `pnpm --filter @codegraphy/extension exec eslint src/extension/pipeline/fileAnalysis.ts src/extension/pipeline/fileAnalysis tests/extension/pipeline/fileAnalysis`: pass -- `pnpm --filter @codegraphy/extension exec tsc --noEmit -p tsconfig.tests.json`: pass +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/pipeline/fileAnalysis`: pass, 5 files / 24 tests +- `pnpm --filter @codegraphy-dev/extension exec eslint src/extension/pipeline/fileAnalysis.ts src/extension/pipeline/fileAnalysis tests/extension/pipeline/fileAnalysis`: pass +- `pnpm --filter @codegraphy-dev/extension exec tsc --noEmit -p tsconfig.tests.json`: pass - `pnpm run boundaries -- extension/src/extension/pipeline/fileAnalysis`: pass, 0 layer violations, 0 dead surfaces, 0 dead ends - `pnpm run reachability -- extension/ --strict`: pass, 0 dead surfaces, 0 dead ends - `pnpm run crap -- extension/src/extension/pipeline/fileAnalysis`: pass, all functions CRAP <= 8 @@ -525,9 +525,9 @@ Changes made: Validation: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/extension/agentBridge/uri.test.ts tests/extension/agentBridge/uri`: pass, 9 files / 34 tests -- `pnpm --filter @codegraphy/extension exec eslint src/extension/agentBridge/uri.ts 'src/extension/agentBridge/uri/**/*.ts' tests/extension/agentBridge/uri.test.ts 'tests/extension/agentBridge/uri/**/*.ts'`: pass -- `pnpm --filter @codegraphy/extension exec tsc --noEmit -p tsconfig.tests.json`: pass +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/agentBridge/uri.test.ts tests/extension/agentBridge/uri`: pass, 9 files / 34 tests +- `pnpm --filter @codegraphy-dev/extension exec eslint src/extension/agentBridge/uri.ts 'src/extension/agentBridge/uri/**/*.ts' tests/extension/agentBridge/uri.test.ts 'tests/extension/agentBridge/uri/**/*.ts'`: pass +- `pnpm --filter @codegraphy-dev/extension exec tsc --noEmit -p tsconfig.tests.json`: pass - `pnpm run boundaries -- extension/src/extension/agentBridge/uri`: pass, 0 layer violations, 0 dead surfaces, 0 dead ends - `pnpm run reachability -- extension/ --strict`: pass, 0 dead surfaces, 0 dead ends - `pnpm run crap -- extension/src/extension/agentBridge/uri`: pass, all functions CRAP <= 8 diff --git a/docs/plans/2026-05-07-graph-sections-pinnable-nodes.md b/docs/plans/2026-05-07-graph-sections-pinnable-nodes.md deleted file mode 100644 index 82963963f..000000000 --- a/docs/plans/2026-05-07-graph-sections-pinnable-nodes.md +++ /dev/null @@ -1,942 +0,0 @@ -# Graph Sections Plan - -## Setup - -- PR base branch: `main` -- Domain source: `CONTEXT.md` -- Tracker: - - Trello #46: `graph node sub containers for organizing code sections` - - Trello #26: `Pinnable nodes` (split out and merged before PR #203 refresh) - - Trello #20: `Multi node selection` (split out before PR #203 refresh) - - Trello #25: `expandable nodes` (split out and merged before PR #203 refresh) -- Status: PR #203 refreshed against `main`; Graph Sections now build on mainline Graph Layout pins, folder collapse, and selection primitives instead of carrying parallel versions. - -## Goal - -Add user-controlled **Graph Sections** to the **Graph View** without changing what the **Relationship Graph** means: - -1. Users can create resizable visual sections that organize nodes inside the existing graph. -2. Nodes inside a section can still have normal edges to nodes outside the section. -3. A collapsed section can summarize edges crossing the section boundary without losing the original relationships. -4. Section behavior reuses the mainline Graph Layout pin/collapse state where relevant. - -## Main Integration Update - -As of the 2026-05-11 PR refresh, pinnable nodes, desktop-style selection, and folder collapse are no longer owned by PR #203. Graph Sections should extend the existing `graphLayout` contract from `main`: - -- Pins stay in `graphLayout.pinnedNodes` with `2D` and `3D` coordinate records. -- Folder collapse stays in `graphLayout.collapsedNodes`. -- Graph Sections add `graphLayout.sections` and `graphLayout.ownership`. -- Section Nodes may use the existing pin APIs, badges, and drag-end persistence, but PR #203 must not introduce a second pin persistence shape or a second pin badge implementation. - -## Research Baseline - -The current recommendation is to keep one world-space **Relationship Graph** and represent each **Graph Section** as a physics-participating **Section Node** in the rendered graph, not as a nested `react-force-graph` instance. - -Prior art points in the same direction: - -- React Flow subflows keep child nodes in one graph model with parent-aware positioning. -- yFiles grouped graphs and folding separate grouping from the underlying graph. -- fCoSE compound layout models parent/child graph structure while still solving layout as one compound graph. -- D3 force simulation already supports fixed coordinates through `fx`, `fy`, and `fz`, which maps cleanly to pinning. -- The force-graph multi-selection example linked from Trello #20 is a useful interaction reference for marquee-style multi-node selection. - -This matters because cross-section edges are ordinary CodeGraphy edges. If sections were separate graph renderers, every edge that crosses a section boundary would need coordinate conversion, event routing, draw ordering, and collapse semantics across renderer boundaries. - -Related tracker notes: - -- Trello #20, `Multi node selection`, points at `https://vasturiano.github.io/force-graph/example/multi-selection/`. -- Trello #25, `expandable nodes`, connects expandable/collapsible node behavior to folder structure, collapse, and Depth Mode. - -## Product Constraints - -- Graph Sections must not mutate the **Graph Cache**. They are user layout state, not indexed graph facts. -- A Graph Section is represented by a **Section Node** in the rendered force layout. -- A Section Node is affected by physics like other graph nodes while expanded or collapsed. -- An expanded Section Node renders as a **Section Frame** that can be moved and resized. -- Pins must not mutate the **Graph Cache**. They are persisted graph layout settings. -- A section member remains a normal **Node** in the **Visible Graph** while the section is expanded. -- An edge from an outside node to an inside node remains an ordinary **Edge** while the section is expanded. -- Section membership must be explicit user intent. A node drifting into a section because of physics should not silently become a member. -- The force layout should remain useful after adding sections. Sections should guide member layout without pulling every member against a boundary. -- Pinned nodes should keep their positions across reloads, refreshes, and re-renders when their node ids still exist. -- A missing node should not delete its pin or section membership immediately. Re-indexing, branch switches, filters, or timeline snapshots can temporarily hide nodes. -- Section and pin behavior must fit existing **Graph Scope**, **Filter**, **Search**, **Show Orphans**, **Depth Mode**, folder nodes, package nodes, and plugin nodes. - -## Resolved Decisions - -- 2026-05-07: Use **Graph Section** as the canonical product term for the expanded user-created organizing area. -- 2026-05-07: Use **Section Frame** for the visible rectangle, label, color, handles, and selection chrome. -- 2026-05-07: Use **Section Member** for a node assigned to a **Graph Section**. -- 2026-05-07: Use **Section Node** for the physics-participating graph node that represents a **Graph Section** while expanded or collapsed. -- 2026-05-07: Use **Pinned Node** for a node with persisted fixed graph-space position. -- 2026-05-07: Treat Graph Section as CodeGraphy's product term for the prior-art family usually called group nodes, compound nodes, or subflows. -- 2026-05-07: A Graph Section is real in the rendered physics graph but remains user layout state rather than indexed Relationship Graph data. -- 2026-05-07: The Graph View has one rendered physics graph containing ordinary nodes and Section Nodes. -- 2026-05-07: Every visible ordinary node and every Section Node participates in the same root force layout. -- 2026-05-08: Root force layout can include different visible node geometries: ordinary circular nodes and expanded rectangular Section Nodes should collide according to their visible geometry while still interacting under the same root physics settings. -- 2026-05-08: Expanded rectangular Section Nodes need a repel-aware edge cushion because D3 charge is center-point based and otherwise lets large Section Frames look edge-pressed even when the user maxes repel force. -- 2026-05-09: Expanded Section Node repel should scale with the visible Section Frame footprint. A fixed repel cushion makes the center force feel too strong on large sections compared with ordinary circular nodes. -- 2026-05-08: Expanded Section Members do not participate directly in the root force simulation. They participate in a section-local force simulation centered on their owning Graph Section's origin. -- 2026-05-08: Section-local member physics should use the same user-facing physics settings vocabulary as the root graph, but scoped to members inside the same Graph Section. -- 2026-05-07: While a Graph Section is expanded, outside edges can render directly to a visible Section Member. -- 2026-05-07: While a Graph Section is collapsed, cross-boundary edges render to the collapsed Section Node as a projection of the original edges. -- 2026-05-07: Never render membership as a Section Node to Section Member edge. -- 2026-05-07: A Section Frame is a resizable rectangle with resize handles on its edges. -- 2026-05-07: A Section Frame has a content minimum size based on members, chrome, and padding. -- 2026-05-07: Resizing a Section Frame below its content minimum collapses the Graph Section. -- 2026-05-07: Expanding a collapsed Graph Section restores the Section Frame at least to the current content minimum. -- 2026-05-07: Moving an expanded Section Frame moves its visible Section Members by the same graph-space delta before physics resumes. -- 2026-05-07: Moving an expanded Section Frame also moves visible pinned Section Members and updates their persisted pin coordinates by the same graph-space delta. -- 2026-05-07: Moving a collapsed Section Node also translates hidden Section Members and any persisted member pin coordinates by the same graph-space delta for the next expansion. -- 2026-05-07: Section membership changes only through explicit user intent, not physics drift across a boundary. -- 2026-05-07: Dragging a node into an expanded Section Frame previews membership, and dropping it adds the node as a Section Member. -- 2026-05-07: Dragging a Section Member out of its Section Frame and dropping outside removes membership. -- 2026-05-07: Context menu actions should later support precise `Add Selection to Section` and `Remove from Section` workflows. -- 2026-05-07: Nested sections form an ownership hierarchy. If Section 2 is inside Section 1 and node A is dropped into Section 2, node A belongs directly to Section 2; Section 2 belongs inside Section 1; Section 1 belongs to the root graph. -- 2026-05-07: Support nested Graph Sections out of the gate. -- 2026-05-07: Nesting is recursive: a Graph Section can contain another Graph Section, which can contain its own Section Members and nested Graph Sections. -- 2026-05-07: A node or Graph Section has at most one direct section owner. -- 2026-05-07: Section ownership must prevent cycles. -- 2026-05-07: A parent Section Frame's content minimum includes child Section Frames. -- 2026-05-07: Resizing a parent below the size needed to contain child Section Frames collapses the parent instead of allowing overflow. -- 2026-05-07: Collapsing a parent Graph Section hides descendant sections without changing their own expanded/collapsed state. -- 2026-05-07: Expanding a parent Graph Section restores descendant sections to the state they had before the parent collapse. -- 2026-05-07: Collapsed cross-boundary edges project each original endpoint to its nearest visible representative. -- 2026-05-07: If Section 2 is collapsed inside expanded Section 1, `A -> B inside Section 2` renders as `A -> Section 2`. -- 2026-05-07: If Section 1 is collapsed and hides Section 2, `A -> B inside Section 2` renders as `A -> Section 1`. -- 2026-05-07: Projected cross-boundary edges aggregate by visible source, visible target, and edge type. -- 2026-05-07: Aggregated projected edges preserve their original edge list for tooltip/details/context menu inspection. -- 2026-05-07: Different edge types stay visually distinct instead of merging into one generic edge. -- 2026-05-07: Graph Sections are pinnable because Section Nodes participate in the same force graph as ordinary nodes. -- 2026-05-07: Pinning a Section Node fixes the section's graph-space position without automatically pinning its Section Members. -- 2026-05-07: Moving a pinned Section Node or expanded Section Frame updates the section pin. -- 2026-05-07: Pin positions are stored as graph-space coordinates relative to the graph origin, not viewport pan or zoom. -- 2026-05-07: Viewport pan and zoom move the user's view of the graph but do not change stored graph-space pin coordinates. -- 2026-05-07: Refreshing the graph may recenter the viewport, but pinned nodes remain at their persisted graph-space coordinates. -- 2026-05-07: Pinned Nodes and collapsed Section Nodes need visible graph indicators. -- 2026-05-07: Pinned Nodes show a small pin badge. -- 2026-05-07: Collapsed Section Nodes show a collapsed-section badge plus a hidden-descendant count. -- 2026-05-07: Pinned collapsed Section Nodes show both pin and collapsed/count indicators without overlap. -- 2026-05-07: Expanded Section Frames show a collapse chevron in the header, edge resize handles, and a pin badge in the header when pinned. -- 2026-05-07: Graph Sections have required labels. -- 2026-05-07: New Graph Sections default to generated labels like `Section 1`, `Section 2`, etc. -- 2026-05-07: The label appears in the expanded Section Frame header and on the collapsed Section Node. -- 2026-05-07: Graph Sections support an optional free-form color picker. -- 2026-05-07: New Graph Sections may default to a color derived from the active VS Code theme, but users can choose any color from the color picker. -- 2026-05-07: Section color tints the border/header subtly instead of flood-filling the whole section. -- 2026-05-09: Graph Sections support an optional short icon string. Expanded Section Frames show it beside the label, and collapsed Section Nodes show it centered inside the square. -- 2026-05-07: Graph Sections can be created from the Graph Toolbar `New...` menu, the Graph Stage background context menu, or a selected-node context menu. -- 2026-05-07: The Graph Toolbar `New...` menu should expose New File, New Folder, and New Graph Section from one creation affordance. -- 2026-05-07: `Create Graph Section from Selection` wraps selected nodes or sections with padding and assigns them as members. -- 2026-05-07: Add desktop-style marquee selection to support selecting multiple nodes by dragging a selection rectangle. -- 2026-05-07: Left-click drag on empty Graph Stage should become marquee selection. -- 2026-05-07: Plain right-click should open the Graph Context Menu on release only when movement stays below the drag threshold. -- 2026-05-07: Right-click drag and middle-click drag pan the 2D graph. -- 2026-05-07: Shift-click toggles or extends node selection one item at a time. -- 2026-05-07: Shift plus left-click drag extends marquee selection additively. -- 2026-05-07: Do not use Space plus left-click drag as the pan fallback. -- 2026-05-07: Do not use Shift plus left-click drag as the pan fallback because Shift is reserved for additive selection. -- 2026-05-07: Avoid Ctrl plus left-click drag for panning because macOS Ctrl-click commonly maps to context menu behavior. -- 2026-05-07: Graph Section editing is 2D-only in v1. -- 2026-05-07: 3D should not support editable Section Frames, resize handles, marquee section creation, or nested frame manipulation in v1. -- 2026-05-07: Graph Sections and Section Nodes are not rendered in 3D in v1. -- 2026-05-07: Any visible node type can be pinned and can become a Section Member, including File Nodes, Folder Nodes, Package nodes, and Plugin Nodes. -- 2026-05-07: Section membership does not replace folder/package/plugin semantics or structural relationships. -- 2026-05-07: Section membership survives temporary node visibility changes from Graph Scope, Search, Filter, Show Orphans, or similar view settings. -- 2026-05-07: If a hidden member becomes visible again and its owning Graph Section still exists, it returns to that section. -- 2026-05-07: If a hidden member becomes visible again after its owning Graph Section was deleted, it returns to the root graph. -- 2026-05-07: Pin and ownership records can remain dormant while their graph item is hidden or temporarily absent. -- 2026-05-07: If a graph item returns after filtering, search, scope changes, branch changes, or reindexing, its dormant pin and ownership records apply again. -- 2026-05-07: Explicitly deleting a graph item through CodeGraphy removes that item's pin and ownership records. -- 2026-05-07: Section Members use hard visual bounds inside the Section Frame with gentle physics correction away from frame edges. -- 2026-05-07: Members should not render outside their Section Frame after physics ticks settle. -- 2026-05-07: A pinned Section Member moves relative to its owning Section Frame when the section moves. -- 2026-05-07: Dragging a pinned Section Member outside its owning Section Frame updates its pin to the dropped graph-space position and removes it from that Graph Section. -- 2026-05-07: Dragging any Section Member outside its owning Section Frame and dropping it removes the item from that Graph Section. -- 2026-05-11: After the feature split, PR #203 implementation order is mainline Graph Layout integration, basic expanded Graph Sections, membership/nesting, section-aware physics, collapse projection, then 2D polish/manual validation. -- 2026-05-07: Graph Toolbar `New...` menu should offer New Graph Section, New File, and New Folder. -- 2026-05-07: A selected Section Frame is a graph placement target for New Graph Section, New File, and New Folder. -- 2026-05-07: A selected Folder Node is a filesystem target for New File and New Folder. -- 2026-05-07: If New File or New Folder is created while a Section Frame is selected, the filesystem destination defaults to root unless a Folder Node target is also selected, and the created node is placed into the selected section after it appears. -- 2026-05-07: If both a Folder Node and Section Frame are selected, New File/New Folder uses the Folder Node as filesystem destination and the Section Frame as graph placement owner. -- 2026-05-07: Renaming or moving a file/folder should preserve its Section Member assignment when CodeGraphy can identify the moved graph item as the same logical item. -- 2026-05-07: `Create Graph Section from Selection` changes only graph ownership/layout. It does not create, remove, or change edges. -- 2026-05-07: Graph Section ids are generated stable ids, not label-derived slugs. -- 2026-05-07: Graph Section labels are editable presentation text and must not be used as identity. -- 2026-05-07: 2D and 3D pins are separate layout records. -- 2026-05-07: A 2D pin stores a 2D graph coordinate and applies only in 2D. -- 2026-05-07: A 3D pin stores a 3D graph coordinate and applies only in 3D. -- 2026-05-07: A normal graph node can have both a 2D pin location and a 3D pin location. -- 2026-05-07: Section Nodes do not support 3D pins in v1 because Graph Sections and Section Nodes are not rendered in 3D. -- 2026-05-07: Selected normal nodes keep the existing selected-node treatment. -- 2026-05-07: Selected collapsed Section Nodes use the selected-node treatment plus collapsed/count badge. -- 2026-05-07: Selected expanded Section Frames show an accent border, subtly tinted header, and visible resize handles. -- 2026-05-07: Active marquee selection shows a visible desktop-style selection rectangle while the user click-drags. -- 2026-05-07: Multi-selected items each show their own selection state; no persistent giant combined bounding box is shown after selection completes. -- 2026-05-07: Depth Mode does not mutate Section Member assignment. -- 2026-05-07: If Depth Mode includes a Section Member, its owning Graph Section should remain visible enough to show containment context even if the section itself is outside hop depth. -- 2026-05-07: For collapsed Graph Sections, Depth Mode uses the collapsed Section Node as the visible representative according to the same nearest-visible projection rules. -- 2026-05-07: Timeline Snapshots do not show Pinned Nodes or Graph Sections in v1. -- 2026-05-07: Timeline Snapshots do not allow creating, editing, resizing, moving, pinning, unpinning, collapsing, expanding, or deleting Graph Sections in v1. -- 2026-05-07: Persist section ownership in a normalized ownership index instead of storing member ids inside each section. -- 2026-05-07: Sections store visual/layout state separately from ownership. -- 2026-05-07: Deleting only a Graph Section removes that section and promotes its direct children to the deleted section's parent owner. -- 2026-05-07: Promoted children keep their current graph-space positions when their owning Graph Section is deleted. -- 2026-05-07: Deleting an explicit multi-selection that includes a Graph Section and its contents can delete the entire selected set. -- 2026-05-07: Any delete action requires confirmation. -- 2026-05-07: If a hidden node returns after its owning section was deleted, it returns to the nearest surviving owner; if no owner survives, it returns to root. -- 2026-05-07: Marquee selection selects visible graph items that intersect the marquee rectangle. - -**Graph Section**: -A user-created graph organization area represented by a physics-participating **Section Node** that can expand to show members or collapse to a single node. -_Avoid_: Subgraph, group, container, compound node when speaking in product terms. - -**Section Member**: -A node assigned to a Graph Section. -_Avoid_: Child node unless discussing prior-art parent-child graph models. - -**Section Frame**: -The expanded visual form of a Section Node, including its resizable rectangle, label, color, edge resize handles, and selection chrome. -_Avoid_: Container. - -**Section Node**: -The Graph View node that represents a Graph Section in the force layout while expanded or collapsed. -_Avoid_: Folder Node, indexed Node when discussing graph analysis. - -**Pinned Node**: -A node with a persisted graph-space position that the layout simulation treats as fixed until the user unpins it or drags it to a new pinned position. -_Avoid_: Favorite, locked node. - -## Working Architecture Recommendation - -Keep Section Nodes in the same graph coordinate space and force simulation as every other rendered graph node. - -```mermaid -flowchart LR - cache["Graph Cache
Relationship Graph"] --> pipeline["Graph Scope / Filter / Search
Visible Graph"] - settings["Repo Settings
pins + graph sections"] --> model["Graph Runtime Model"] - pipeline --> model - model --> physics["One force simulation
normal nodes + Section Nodes + pins"] - physics --> render["Graph Stage rendering
Section Frames, edges, nodes"] -``` - -Expanded section behavior: - -- A Section Node participates in force physics. -- The expanded Section Node is rendered as a Section Frame instead of a simple circle. -- The Section Frame's position comes from the Section Node's graph-space position. -- Member nodes are still part of the visible graph model and can still render ordinary edges. -- Member nodes inside an expanded Graph Section run in section-local force physics centered on that section's origin, not directly in the root force simulation. -- Member nodes physically interact with other visible members in the same Graph Section and are bounded to the Section Frame. -- Cross-section edges are still part of the main `graphData.links`. -- An outside node can have an edge directly to a member node while the member's Graph Section is expanded. -- Section membership is not rendered as an edge from the Section Node to the Section Member. -- Internal section-member constraints may exist for physics, but they are not **Edges** and should not appear in edge lists, tooltips, Graph Query results, or the Graph Context Menu as relationships. -- The Section Node contributes forces: - - normal root graph forces acting on the Section Node itself, - - D3 charge contribution in the root graph, - - root graph collision against ordinary circular nodes and other rectangular Section Nodes using the Section Frame's visible geometry. -- The section-local simulation contributes forces: - - section-origin centering for member nodes, - - member-to-member collision within the section, - - section-local charge/repel and link behavior where applicable, - - hard bounds to keep members inside the frame with padding. -- Pinned nodes override normal simulation targets while still respecting Section Frame ownership rules. -- A pinned Section Member moves with its owning Section Frame. Dragging it outside the frame updates the pin to the dropped graph-space position and removes section membership. - -Collapsed section behavior: - -- A section collapse is a projection over the current **Visible Graph**. -- The same Section Node renders as a single ordinary node shape instead of its expanded Section Frame. -- Member nodes are hidden behind the Section Node. -- Descendant sections are hidden behind the collapsed ancestor without losing their own expanded/collapsed state. -- Expanding the ancestor restores descendant sections to their previous states. -- Edges wholly inside the section are hidden. -- Edges crossing a collapsed section boundary are rendered by projecting each original endpoint to its nearest visible representative. -- If outside node A has an edge to member node B while Section 1 is expanded, A renders an edge to the collapsed Section Node while Section 1 is collapsed. -- If Section 2 is collapsed inside expanded Section 1, an edge from outside A to node B inside Section 2 renders as A to Section 2. -- If Section 1 is collapsed and hides Section 2, an edge from outside A to node B inside Section 2 renders as A to Section 1. -- Duplicate projected edges are merged by visible source, visible target, and edge type. -- Aggregated projected edges preserve the original edge list for tooltip, details, and Graph Context Menu inspection. -- Different edge types remain separate projected edges even when they share the same visible source and target. -- An aggregated edge should render with a count or weight indicator when it represents more than one original edge. -- Member pins persist but are dormant while the section is collapsed. - -## Repo Integration Points - -Current graph data and layout hooks already give us useful entry points: - -- `packages/plugin-api/src/graph.ts` - - `IGraphNode` already has optional `x` and `y`. - - `IGraphEdge` already stores `from`, `to`, `kind`, and `sources`. -- `packages/extension/src/webview/components/graph/model/build.ts` - - Builds `FGNode` / `FGLink` for the renderer. - - `FGNode` already includes `fx`, `fy`, and `fz`. -- `packages/extension/src/webview/components/graph/model/node/build.ts` - - Preserves previous node positions and fixed positions. - - Seeds `x` and `y` from graph node data. -- `packages/extension/src/webview/components/graph/model/link/build.ts` - - Builds links directly from edge source and target ids. -- `packages/extension/src/webview/components/graph/runtime/physics.ts` - - Owns force simulation configuration. - - Today it centers nodes with `forceX(0)` and `forceY(0)`. -- `packages/extension/src/webview/components/graph/rendering/surface/view/twoDimensional.tsx` - - Owns the 2D force graph surface and canvas render callbacks. -- `packages/extension/src/webview/components/graph/contextMenu/*` - - Existing right-click plumbing can host Pin, Unpin, and section actions. -- `packages/extension/src/extension/repoSettings/*` - - Repo-local settings persistence already exists and has an allowlisted persisted shape. - -## Candidate Settings Shape - -The candidate shape should keep layout settings repo-local and separate from graph analysis: - -```ts -interface GraphLayoutSettings { - collapsedNodes: Record; - pinnedNodes: Record; - sections: Record; - ownership: Record; -} - -interface PinnedNodeSetting { - nodeId: string; - "2D"?: { - x: number; - y: number; - }; - "3D"?: { - x: number; - y: number; - z: number; - }; -} - -interface GraphSectionSetting { - id: string; - label: string; - icon?: string; - color: string; - x: number; - y: number; - width: number; - height: number; - collapsed: boolean; - updatedAt: string; -} - -interface GraphSectionOwnershipSetting { - itemId: string; - itemKind: "node" | "section"; - ownerSectionId: string | null; - updatedAt: string; -} -``` - -Open data-model questions: - -- Done: section ownership lives in a normalized `itemId -> ownerSectionId | root` ownership index that can include both nodes and sections. -- Done: a node or Graph Section can have at most one direct section owner. -- Done: section ids are generated stable ids, not label-derived slugs. Labels are editable presentation text and do not carry identity. -- Done: pin positions are stored as graph-space coordinates relative to the graph origin, not relative to viewport pan or zoom. -- Done: 2D and 3D pins are separate mainline layout records using `2D` and `3D` keys. A normal graph node can have both a 2D pin and a 3D pin. -- Done: missing node ids can remain as dormant layout records unless explicitly deleted through CodeGraphy. - -## Interaction Model Draft - -Pinning: - -- Right-click a node and choose `Pin Node`. -- Pinning stores the node's current graph-space coordinates relative to the graph origin. -- Viewport pan and zoom do not change stored pin coordinates. -- Refreshing the graph may recenter the viewport, but pinned nodes remain at their persisted graph-space coordinates. -- Pinning means physics cannot move the pinned item at all. -- A pinned node gets `fx` and `fy` in 2D when it has a 2D pin. -- A pinned node gets `fx`, `fy`, and `fz` in 3D when it has a 3D pin. -- A 2D pin does not apply in 3D. -- A 3D pin does not apply in 2D. -- Dragging a pinned node moves it live in the renderer and writes the final position on drag end. -- Unpinning clears the persisted pin and releases `fx`, `fy`, and `fz`, while keeping the current transient position as the simulation restart point. -- Multi-select pinning is desirable later, but single-node pinning is the first acceptance slice. -- Section Nodes are pinnable because they participate in the same force layout as ordinary nodes. -- Pinning an expanded Graph Section fixes the Section Frame's graph-space position. -- Pinning a collapsed Graph Section fixes the collapsed Section Node's graph-space position. -- Pinning a Graph Section does not automatically pin its Section Members. -- Moving a pinned Graph Section updates the section pin. If expanded, visible Section Members still move by the same delta according to the section movement rule. - -Visual state indicators: - -- Pinned Nodes show a small pin icon badge at the node's top-right with tooltip `Pinned`. -- Collapsed Section Nodes show a collapsed-section badge plus a hidden-descendant count. -- A pinned collapsed Section Node shows both states: pin badge top-right, collapsed/count badge bottom-right. -- Expanded Section Frames show a subtle collapse chevron in the header plus resize handles on edges. -- Pinned expanded Section Frames show the pin badge in the header. -- Collapsed Section Nodes with an icon show that icon in the square center while keeping the expand affordance and hidden-descendant count in the corners. -- Badges must not overlap each other or obscure node labels. -- Indicators should be visual affordances, not extra rendered edges. - -Selection styling: - -- Selected normal nodes keep the existing selected-node ring/glow treatment. -- Selected collapsed Section Nodes use the selected-node treatment plus collapsed/count badge. -- Selected expanded Section Frames show an accent border, subtly tinted header, and visible resize handles. -- Active marquee selection shows a visible desktop-style selection rectangle while the user click-drags. -- The marquee rectangle should use translucent fill with a dashed or low-contrast border. -- Multi-selected items each show their own selection state after selection completes. -- Do not show a persistent giant combined bounding box after selection completes unless a later multi-item transform mode explicitly needs it. - -Labels, icons, and colors: - -- Every Graph Section has a label. -- New Graph Sections default to generated labels like `Section 1`, `Section 2`, etc. -- Empty labels should fall back to the generated section label instead of rendering blank chrome. -- Labels appear in the expanded Section Frame header and on the collapsed Section Node. -- Labels should fit inside the Section Frame header and collapsed Section Node without overlapping state indicators. -- Long labels should truncate or elide professionally rather than resizing the section unexpectedly. -- Section icons are optional short free-form strings. -- Section icons appear beside the expanded Section Frame label and centered in the collapsed Section Node. -- Clearing the icon removes the icon field from persisted Graph Layout settings. -- Section color is optional and selected with a free-form color picker. -- New Graph Sections may default to a color derived from the active VS Code theme, but users can choose any color from the color picker. -- Section colors tint the border/header and may lightly tint the background only if readability stays high for nested sections. -- Section color must remain legible against the active VS Code theme. -- The renderer should derive any needed text, outline, or support treatment from the selected color and active VS Code theme so arbitrary user colors stay readable. - -Section creation: - -- The Graph Toolbar `New...` menu can create a new empty Graph Section near the current viewport center. -- The Graph Toolbar `New...` menu should offer New Graph Section, New File, and New Folder. -- If a Section Frame is selected, New Graph Section creates the new nested Graph Section inside that selected section. -- If no Section Frame is selected, New Graph Section creates the new Graph Section in the root graph near the current viewport center. -- The Graph Toolbar `New...` menu should distinguish filesystem destination from graph placement: - - New File/New Folder filesystem destination comes from a selected Folder Node when one is selected. - - Without a selected Folder Node, New File/New Folder creates at the workspace root. - - If a Section Frame is selected, the created File/Folder Node is placed into that Graph Section after it appears in the graph. - - If both a Folder Node and Section Frame are selected, New File/New Folder creates inside the folder and then places the created node into the selected Graph Section. -- The Graph Stage background context menu can create a new empty Graph Section at the clicked graph-space point. -- The selection context menu can create a Graph Section from selected nodes or sections. -- Creating a Graph Section from selection wraps the selected bounds with padding and assigns the selected items as members. -- Creating a Graph Section from selection does not change edges: - - edges between selected members still draw normally while the section is expanded, - - edges from selected members to unselected outside nodes become cross-section edges, - - edge projection changes only when the new section is collapsed. -- A new empty section appears at the graph-space pointer location with a default size. -- Section labels are required and editable. -- New section labels default to generated labels like `Section 1`, `Section 2`, etc. -- The section label appears in the expanded Section Frame header and on the collapsed Section Node. -- Section color is optional and selected with a free-form color picker. -- Section color should tint the Section Frame border and header subtly, not flood-fill the whole section. - -Multi-selection: - -- Add desktop-style marquee selection for selecting multiple graph items by dragging a selection rectangle. -- Show a visible desktop-style selection rectangle while the user is click-dragging to marquee select. -- Marquee selection should support creating Graph Sections from a spatial selection, matching the desktop workflow of selecting multiple files before grouping or moving them. -- Left-click drag on empty Graph Stage should create a marquee selection rectangle. -- Marquee selection selects visible graph items that intersect the marquee rectangle. -- Plain right-click opens the Graph Context Menu on release only when movement stays below the drag threshold. -- Right-click drag pans after a movement threshold so context menus do not become flaky. -- Middle-click drag can pan where available. -- Shift-click toggles or extends selection one node at a time. -- Shift-left-drag creates an additive marquee selection that combines with the existing selected set. -- Do not use Space plus left-click drag as the pan fallback. -- Do not use Shift plus left-click drag as the pan fallback because Shift is reserved for additive selection. -- Avoid Ctrl plus left-click drag for panning because macOS Ctrl-click commonly maps to context menu behavior. - -Moving and resizing: - -- Dragging the section header/body moves the section. -- Moving an expanded Section Frame moves visible member nodes by the same graph-space delta immediately, then physics resumes from the new positions. -- If a visible member is pinned, moving the expanded Section Frame shifts the pinned node and its persisted pin coordinates by the same delta. -- If a pinned Section Member is dragged and dropped outside its owning Section Frame, update the pin to the dropped graph-space position and remove the node from that Graph Section. -- Moving a collapsed Section Node shifts hidden member layout positions and any persisted member pin coordinates by the same graph-space delta so expansion opens at the moved location. -- Edge resize handles change section bounds without changing node ids or edges. -- Minimum section size is computed from the current member bounds, section chrome, resize handles, and padding. -- For nested sections, a parent Section Frame's content minimum includes child Section Frames and their chrome. -- If the user resizes smaller than the computed content minimum, the Graph Section collapses. -- When the user expands a collapsed Graph Section, it expands to at least the current content minimum size. -- If a section has no visible members, its content minimum is the chrome minimum: label, handles, and empty-section padding. - -Moving nodes in and out: - -- Dropping a node into a section assigns membership. -- Dropping a section member outside the frame removes membership. -- Dropping a pinned section member outside the frame removes membership and keeps the pin at the dropped graph-space position. -- Dropping an unpinned section member outside the frame removes membership and leaves the node at the dropped graph-space position for physics to resume from there. -- A node drifting across a section boundary because of physics does not change membership. -- A context menu action like `Add Selection to Section` may be needed for precise membership changes. -- A context menu action like `Remove from Section` should exist for section members. -- Dragging over a Section Frame should preview the target membership before drop. -- For nested sections, the drop target is the deepest expanded Section Frame under the pointer unless a modifier or explicit menu action says otherwise. -- Example: if Section 2 is inside Section 1 and node A is dropped into Section 2, node A's direct owner is Section 2. Section 2's direct owner is Section 1. Section 1's direct owner is the root graph. -- Nested Graph Sections are supported out of the gate. -- A node or Graph Section has at most one direct section owner. -- Section ownership changes must reject cycles, such as moving Section 1 into its own descendant Section 2. - -Deleting sections: - -- Deleting only a Graph Section removes the section, not its contents. -- Direct child nodes and direct child Graph Sections move up to the deleted section's parent owner. -- Promoted children keep their current graph-space positions; deletion should not auto-pack or recenter them. -- Example: `root -> Section 1 -> Section 2 -> node A`. Deleting Section 2 moves node A to Section 1. -- Example: `root -> Section 1 -> Section 2 -> node A`. Deleting Section 1 moves Section 2 to root, with node A still inside Section 2. -- If a user explicitly multi-selects a Graph Section plus its contents, the Graph Context Menu can delete the whole selected set. -- Bulk deletion should be based on explicit selection, not implicit containment. Selecting a section alone is not enough to delete its contents. -- Any delete action requires confirmation, including layout-only section deletion and real file/folder deletion. - -Expanded cross-section edges: - -- Edges draw from the real source node position to the real target node position. -- Edges may cross section frames. -- Edge hit testing and context menus should still target the original edge. -- A Section Node never draws membership edges to its Section Members. -- We may later clip or route edges visually at section boundaries, but that should not change edge identity. - -Collapsed cross-section edges: - -- Incoming edge to member becomes incoming edge to Section Node. -- Outgoing edge from member becomes outgoing edge from Section Node. -- Edge from outside member A to inside member B becomes outside A to Section Node. -- Edge from inside member A to outside member B becomes Section Node to outside B. -- Edge from member A to member B inside the same section disappears while collapsed. -- Edge from member of section A to member of section B becomes Section Node A to Section Node B if both are collapsed. - -## Physics Draft - -Baseline force priorities: - -1. Pinned node fixed position wins. -2. Root graph physics pulls ordinary nodes and Section Nodes toward the graph origin. -3. Root graph physics applies charge/repel to ordinary nodes and Section Nodes. -4. Root graph collision resolves circular ordinary nodes and rectangular expanded Section Nodes by their visible geometry. -5. Section-local physics pulls Section Members toward their owning section origin. -6. Section-local physics applies member-to-member collision and section-local charge/repel inside the owning Section Frame. -7. Hard visual bounds keep members rendered inside the Section Frame padded bounds. -8. Link, charge, collision, and zoom behavior should stay recognizable from today's graph. - -Important edge cases: - -- A pinned member cannot remain outside its section. Dragging and dropping a pinned member outside the frame removes section membership and updates the pin at the dropped graph-space position. -- A section with many incoming outside edges should let those edges influence the real member endpoints inside section-local physics while the member remains bounded to its Section Frame. -- Cross-simulation edge forces must be implemented smoothly, preferably by composing D3 force primitives or stable custom D3-style forces, so cross-boundary edges do not introduce graph or node jitter. -- A tiny section with many nodes cannot satisfy collision and bounds. The content minimum and collapse threshold prevent the section from remaining expanded below the size needed for members, child sections, chrome, and padding. -- If all members are pinned, section forces should not fight them. -- Moving a section with pinned members updates their pin coordinates by the same delta. -- Resizing a section with pinned members should preserve their pins while they remain in bounds. If resizing below content minimum would invalidate member positions, the section collapses according to the resize rule. - -## Physics Alignment Audit - -This section records the current PR #203 physics behavior before the next round of decisions, then records the target model chosen during the follow-up physics grill. - -Current implementation: - -- All rendered items still live in one `react-force-graph` / D3 simulation in 2D. This is the known mismatch the next physics pass needs to correct for expanded Section Members. -- Ordinary root nodes use graph-origin center force, charge/repel, native circular collision, link force, and pins. -- Collapsed Section Nodes behave closest to ordinary nodes: graph-origin center force, charge/repel, native circular collision based on collapsed node size, projected link force, and pins. -- Expanded Section Nodes are in the same simulation, but use a specialized force profile: - - graph-origin center force applies, - - native circular collision is disabled, - - charge/repel contribution applies with a footprint-scaled strength for expanded Section Frames, - - custom rectangle collision uses the Section Frame width and height, - - custom rectangle collision expands the effective rectangle by a user-repel and Section Frame-size-scaled edge cushion so max repel leaves visible space between Section Frames, - - projected/member edges do not connect the expanded Section Node to its members. -- Section Members inside an expanded Graph Section currently remain graph nodes in the same root simulation, but use a mixed force profile: - - root link force is disabled for links involving expanded Section Members, - - bridge link forces apply to their real graph edges in world space, - - root charge/repel is disabled, - - graph-origin center force is disabled while the member has an expanded owner section, - - native circular collision is disabled while the member has an expanded owner section, - - custom local member collision, section-center correction, section-local charge/repel, and hard bounds keep members inside the Section Frame. -- Moving a Section Frame carries visible members by the same graph-space delta before physics resumes. -- Pinning still uses D3 fixed coordinates (`fx`, `fy`, `fz`), with Section Nodes limited to 2D because Graph Sections do not render in 3D v1. - -Target physics model: - -- Keep one visible React Force Graph surface for rendering, camera, pointer hit testing, and built-in node dragging. -- Use multiple D3 force simulations: - - one root simulation for ordinary root nodes and root-owned Section Nodes, - - one section-local simulation for each expanded Graph Section's visible direct children. -- Expanded Section Members stay in `graphData.nodes` as renderable React Force Graph nodes, but the root simulation should not apply root center, root charge, root link, or root collision forces to them. -- Section-local simulations own the member's physics state in local coordinates. Each tick derives the render node's world position from the owning Section Node plus the member's local position. -- React Force Graph built-in member dragging remains enabled. Drag callbacks convert the dragged member's world-space pointer movement into section-local coordinates, update the local simulation node, and apply ownership or pin rules on drop. - -Resolved alignment decisions: - -- 2026-05-08: Section Nodes should be root graph physics actors. Expanded Section Nodes should contribute D3 charge/repel like ordinary root nodes. -- 2026-05-08: Root graph physics should support heterogeneous visible node geometry: circular ordinary nodes and rectangular expanded Section Nodes collide in the same root simulation. -- 2026-05-08: D3 `forceCollide` is circular. Rectangular Section Node collision must use a custom D3 force installed with `simulation.force(name, customForce)`, supporting circle-circle, circle-rectangle, and rectangle-rectangle collisions in the root simulation. Do not approximate expanded Section Frames as enclosing circles. -- 2026-05-08: Section Members should move in a section-local force simulation, not directly in the root force simulation. -- 2026-05-08: A Graph Section behaves like a graph-like area with its own origin. Members assigned to it use the same conceptual physics settings around the section origin and interact physically only with other visible members in that same Graph Section. -- 2026-05-08: Cross-boundary edges render to their real visible endpoints and should physically influence the real Section Member endpoint inside section-local physics. Do not replace `A -> member B` physics with `A -> Section 1` root proxy physics while the section is expanded. -- 2026-05-08: A Section Member that is pulled by a cross-boundary edge remains scoped to its owning Section Frame and still interacts with other visible members in the same section, including member-to-member links such as `C -> B` inside Section 1. -- 2026-05-08: Smooth D3 integration is a core requirement. Section-local simulations and cross-boundary forces should avoid jitter by using stable D3-style force updates, bounded velocities, and deterministic coordinate conversion between root graph space and section-local space. -- 2026-05-08: Cross-boundary edge physics should be symmetric across simulations while the Graph Section is expanded. The outside endpoint and the Section Member endpoint both feel the relationship, with the implementation converting coordinates between root graph space and section-local graph space. -- 2026-05-08: Section Members should continue to use React Force Graph's built-in node hit testing and drag callbacks. Dragging a Section Member updates that member's section-local physics coordinates and ownership/pin rules, while rendering and pointer interaction still flow through the single React Force Graph surface. -- 2026-05-08: Cross-boundary edges should use bridge-force physics, not the normal root `forceLink`. For `A -> B` where `B` is a Section Member, the bridge force compares `A` and `B` in world space, nudges `A` in the root simulation, nudges `B` in its section-local simulation, and never pulls the Section Node as a proxy for `B`. -- 2026-05-08: Section Frame bounds win over cross-boundary link pull. If a bridge force would pull a Section Member outside its Section Frame, the member remains bounded inside the section. -- 2026-05-08: Dragging across Section Frame boundaries should preview the target while dragging, but membership changes only on drop. Do not reparent nodes between simulations during hover or mid-drag. -- 2026-05-08: Nested layout coordinates are local to the direct parent, recursively. A Section Node inside another Graph Section stores coordinates local to its parent Section Frame, and its own Section Members store coordinates local to that child Section Frame. -- 2026-05-08: Collapsing a Graph Section hides descendants, stops that section's local simulation, and persists latest direct-child local positions. Hidden members do not keep simulating while collapsed. -- 2026-05-08: Expanding a collapsed Graph Section restores direct children from saved local positions and gently reheats that section-local simulation. If the collapsed Section Node moved while collapsed, descendants keep the same local arrangement because their coordinates are relative to the section. -- 2026-05-08: Pinning means physics cannot move the pinned graph item at all. Root pins are fixed in root graph coordinates; Section Member pins and nested Section Node pins are fixed in direct-parent-local coordinates. - -## Nested Simulation Architecture Candidate - -The preferred architecture is to fake nested force graphs while keeping one visible React Force Graph surface: - -- Keep one `react-force-graph` instance for the 2D canvas, camera, zoom, pan, coordinate conversion, and root graph rendering. -- Maintain multiple independent D3 force simulations: - - one root simulation owned by the React Force Graph instance, - - one section-local D3 simulation for each expanded Graph Section that has visible members. -- The root simulation contains ordinary root nodes and Section Nodes. -- Expanded Section Members remain in the React Force Graph `graphData.nodes` so core node rendering, labels, hover affordances, edges, screenshots, and export paths can continue to use the same graph surface. -- Expanded Section Members do not participate in root physics even though React Force Graph can still render them. -- Expanded Section Members are render mirrors in root graph data: their world-space `x`/`y` are copied from their owning section-local simulation each tick. -- Each expanded Graph Section owns local member nodes in local coordinates relative to the Section Node / Section Frame origin. -- A rendered Section Member world position is derived recursively: - - `worldX = sectionWorldX + childLocalX` - - `worldY = sectionWorldY + childLocalY` -- Nested sections follow the same rule recursively: a child Section Node belongs to its parent section-local simulation, and that child section owns its own local simulation for its members. -- Nested coordinates are not flattened to root graph space for persistence. World render positions are derived by recursively summing direct-parent local coordinates from the root to the rendered item. -- Sections render as resizable container rectangles. -- Dragging a node into a Section Frame reparents it from its previous simulation into that section-local simulation. -- Dragging a Section Member out reparents it back to the root simulation, or into the deepest target section under the drop point. -- Reparenting happens only on drop. During drag, CodeGraphy may highlight the candidate target Section Frame, but the dragged node remains owned by its current simulation until release. -- Section-local simulations use the same user-facing physics settings as the root graph, scoped to their own origin and visible local members. -- Boundary constraints keep local members inside the visible Section Frame. -- Collapsing a Graph Section stops its local simulation and persists latest direct-child local positions. -- Expanding a Graph Section restores the saved local positions and gently restarts that section-local simulation instead of recomputing positions from scratch. - -Implementation implications: - -- Root forces must explicitly skip Section Members for center, charge, root collision, and root link force participation while still allowing React Force Graph to render those nodes. -- Links involving Section Members should remain in rendered `graphData.links`, but root D3 link force should not directly apply to member endpoints. Cross-boundary link physics is handled by bridge forces that apply equal conceptual pressure to the outside root endpoint and the section-local member endpoint through world/local coordinate conversion. -- Local simulation ticks should copy derived world coordinates back to the React Force Graph render nodes before redraw. -- Local simulation ticks should update mutable runtime positions and request graph redraws without writing React state every tick. -- Smoothness is a hard requirement: bridge forces, boundary constraints, and coordinate conversion must avoid simulation feedback loops that produce jitter. -- Section Members should still use React Force Graph's built-in hit testing and drag callbacks. Drag handling should convert the dragged world-space pointer position into the member's section-local coordinates and update the section-local simulation state, rather than letting the root simulation own Section Member physics. - -Research notes: - -- D3 `forceCollide` treats nodes as circles with a radius, so it is not enough for exact expanded Section Frame collisions: . -- D3 simulations invoke every registered force each tick, and custom forces can mutate node velocities/positions inside that lifecycle: . -- D3 quadtree is the native broad-phase tool for efficient spatial queries and collision detection: . -- React Force Graph exposes `d3Force(name, force)` so CodeGraphy can install custom D3 forces into the root simulation: . -- React Force Graph exposes `onNodeDrag`, `onNodeDragEnd`, `nodePointerAreaPaint`, and pointer interaction support, so Section Members can keep built-in hit testing and drag callbacks while CodeGraphy owns the section-local physics update path: . - -Root geometry collision strategy: - -- Use a custom D3 force for visible root actors. Expanded Section Frames must collide by their visible rectangular geometry, not by enclosing circles. -- Support three unrotated geometry pairs: - - circle-circle: same overlap math as native collision, using visible node radius, - - rectangle-rectangle: AABB overlap using the visible Section Frame width and height, resolving along the smallest penetration axis, - - circle-rectangle: closest-point test from circle center to rectangle bounds; when the circle center is inside the rectangle, resolve toward the nearest rectangle face. -- Use a quadtree broad phase keyed by actor center and a conservative search radius based on actor half-diagonal so the expensive pair test only runs for nearby actors. -- Apply collision as velocity changes inside the D3 force lifecycle, with strength and iterations mapped from the existing collision settings so it feels like the current graph rather than a post-tick clamp. - -## 3D, Folder, Package, And Plugin Node Draft - -Pinning: - -- Pinning should work for every node type. -- In 2D mode, pin a 2D graph coordinate. -- In 3D mode, pin a 3D graph coordinate. -- A pinned item is not moved by physics. In 2D, root-owned pins fix root graph coordinates; Section Member pins and nested Section Node pins fix direct-parent-local coordinates. -- 2D and 3D pins are independent; switching renderer modes does not translate one pin into the other. -- A normal graph node can have both a 2D pin location and a 3D pin location. -- Section Nodes do not support 3D pins in v1 because Graph Sections and Section Nodes are not rendered in 3D. - -Sections: - -- Graph Section editing is 2D-only in v1. -- 3D should not support editable Section Frames, resize handles, marquee section creation, or nested frame manipulation in v1. -- Graph Sections and Section Nodes are not rendered in 3D in v1. -- Section Nodes do not support 3D pins in v1. -- Folder, package, and plugin nodes can be pinned or section members if they are visible nodes. -- Section membership does not replace folder/package nesting. Folder/package nodes still mean structural codebase concepts; sections mean user layout organization. -- Section membership survives temporary node visibility changes from Graph Scope, Search, Filter, Show Orphans, or similar view settings. -- Example: if a Folder Node is placed inside a Graph Section, then Folder Nodes are hidden and later shown again, the Folder Node returns to the same Graph Section as long as that section still exists. -- Search should never rewrite Section Member ownership. Clearing Search returns visible nodes to their owning sections when those sections still exist. -- Filter and Show Orphans should follow the same temporary-visibility behavior. -- Depth Mode should not rewrite Section Member ownership. -- If Depth Mode includes a Section Member, its owning Graph Section should remain visible enough to preserve containment context, even when the section itself is outside the configured hop depth. -- Section membership should not count as a graph relationship for depth-hop calculation. -- For collapsed Graph Sections, Depth Mode should treat the collapsed Section Node as the visible representative and use the same projected-edge behavior as normal collapse. -- Timeline Snapshots do not show Pinned Nodes or Graph Sections in v1. -- Timeline Snapshots do not allow creating, editing, resizing, moving, pinning, unpinning, collapsing, expanding, or deleting Graph Sections in v1. -- If the owning Graph Section is deleted while the node is hidden, the node returns to the root graph when it becomes visible again. -- Pin and ownership records can remain dormant while the target graph item is hidden or temporarily absent. -- If the item returns after Filter, Search, Graph Scope, branch changes, or reindexing, its dormant pin and ownership records apply again. -- If a file or folder is renamed or moved and CodeGraphy can identify the moved graph item as the same logical item, its pin and section ownership records should follow it. -- Explicitly deleting a graph item through CodeGraphy removes that item's pin and ownership records. -- Section collapse should be ordered after the **Visible Graph** exists, similar to existing **Collapse Projection** language. - -## Nested Sections Draft - -Resolved direction: - -- Support nested Graph Sections out of the gate. -- Nesting is recursive. -- Keep all section and node coordinates in world space. -- A node or Graph Section has one direct owner: the root graph or one Graph Section. -- Moving a parent Section Frame moves child Section Nodes, child Section Frames, descendant nodes, and descendant pins by the same graph-space delta. -- Moving a collapsed parent Section Node moves the hidden descendant layout by the same graph-space delta. -- Collapse hides the entire descendant subtree behind the collapsed Section Node without mutating descendant expanded/collapsed state. -- Expanding the parent restores each descendant section to the state it had before the parent was collapsed. -- Section ownership changes must prevent cycles. -- The drop target is the deepest expanded Section Frame under the pointer unless a modifier or explicit menu action chooses a parent section. -- A node belongs to its nearest direct section owner; ancestor membership is derived from the section hierarchy, not duplicated on the node. -- A parent Section Frame's content minimum includes child Section Frames. -- Resizing a parent below the size needed to contain child Section Frames collapses the parent instead of allowing child overflow. - -## Decision Backlog - -We will resolve these one at a time and update this file as decisions land. - -1. Done: Canonical terms are **Graph Section**, **Section Frame**, **Section Member**, **Section Node**, and **Pinned Node**. -2. Done: Graph Sections are represented by physics-participating Section Nodes while expanded and collapsed. -3. Done: Graph Section editing is 2D-only in v1. 3D does not render Graph Sections or Section Nodes and does not support editable Section Frames, resize handles, marquee section creation, nested frame manipulation, or Section Node pins. -4. Done: Users can create Graph Sections from the Graph Toolbar `New...` menu, Graph Stage background context menu, or selected-node context menu. The toolbar creation menu also exposes New File and New Folder. -5. Done: Section membership changes only through explicit user intent: drag/drop with preview or later context menu actions. Physics drift never changes membership. -6. Done: A node or Graph Section has at most one direct owner. Ancestor membership is derived from the hierarchy, not duplicated. -7. Done: Nested Graph Sections are supported out of the gate with recursive ownership, one direct owner per node or section, and cycle prevention. -8. Done: A pinned Section Member moves with its owning Section Frame. Dragging it outside the frame updates the pin to the dropped graph-space position and removes section membership. -9. Done: Moving an expanded Section Frame moves visible Section Members by the same delta before physics resumes. If a visible member is pinned, the persisted pin coordinates move by the same delta. Moving a collapsed Section Node applies the same delta to hidden member layout and persisted member pins for the next expansion. -10. Done: Resizing below the content minimum collapses the Graph Section; expanding restores at least the current content minimum. For nested sections, the content minimum includes child Section Frames. -11. Done: Section Members use hard visual bounds inside the Section Frame with gentle physics correction away from frame edges. Members should not render outside the frame after physics ticks settle. -12. Done: Collapse projects each original endpoint to its nearest visible representative. Projected cross-boundary edges aggregate by visible source, visible target, and edge type while preserving original edges for inspection. -13. Done: Pinned Nodes, collapsed Section Nodes, pinned collapsed Section Nodes, and expanded Section Frames have state indicators. Graph Sections have required labels and optional free-form colors. Selected normal nodes keep existing selected treatment; selected collapsed Section Nodes use selected-node treatment plus collapsed/count badge; selected expanded Section Frames show accent border, tinted header, and resize handles; active marquee selection shows a desktop-style rectangle while dragging. -14. Done: Section membership survives temporary visibility changes from Graph Scope, Search, Filter, Show Orphans, Depth Mode, or similar view settings. Depth Mode preserves section context for visible Section Members and does not count membership as depth hops. Timeline Snapshots do not show or edit pins/sections in v1. -15. Done: Pin and ownership records can remain dormant while graph items are hidden or temporarily absent. Returning graph items regain dormant pin/ownership records. Explicit graph item deletion through CodeGraphy removes that item's pin and ownership records. -16. Done: Left-click drag on empty Graph Stage becomes marquee selection; Shift-click extends selection; Shift-left-drag extends marquee selection additively; right-click drag and middle-click drag pan where available; plain right-click opens the Graph Context Menu only on release with no meaningful movement; Space-left-drag and Ctrl-left-drag are rejected for panning. - -## Acceptance Slices - -Potential implementation order after the design is settled: - -1. Persisted layout settings model: Done - - pins, - - sections, - - normalized ownership index, - - dormant records, - - validation, - - cycle prevention. -2. Pinnable nodes: Done - - Pin/Unpin context menu, - - graph-space persistence, - - visual badges, - - 2D pin coordinates, - - 3D pin coordinate support for ordinary nodes only. -3. Multi-selection and marquee: Done - - left-drag marquee selection, - - Shift-click additive node selection, - - Shift-left-drag additive marquee selection, - - right-click and middle-click drag panning, - - selected-node context menu integration, - - force-graph multi-selection reference from Trello #20. -4. Basic expanded Graph Sections: Done - - create from Graph Toolbar `New...` menu, - - create from Graph Stage background context menu, - - create from selection context menu, - - generated labels, - - editable labels, - - free-form color picker, - - Section Frame rendering, - - move and resize. -5. Membership and nested ownership: Done - - drag/drop into sections, - - drag/drop out of sections, - - deepest-frame targeting, - - recursive nesting, - - parent/child movement, - - delete/unpack rules. -6. Section-aware physics: Done - - Section Nodes participate in the same simulation, - - Section Members remain bounded, - - gentle bounds correction, - - pinned Section Member behavior. -7. Collapse projection: Done - - collapsed Section Node rendering, - - hidden descendant counts, - - nearest-visible edge projection, - - aggregated projected edges. -8. 2D polish and manual validation: Done - - tooltips, - - indicators, - - selection states, - - theme/readability checks, - - manual graph interaction smoke pass. - -## Implementation Progress - -- 2026-05-07: Slice 1 started with repo-local `graphLayout` settings persisted under `.codegraphy/settings.json`. -- 2026-05-07: Added a graph layout settings model for `pinnedNodes`, `sections`, and normalized `ownership`. -- 2026-05-07: Added validation for finite graph-space coordinates, required section identity/label/color/chrome fields, dormant node pin records, dormant node ownership records, owner section existence, and Section ownership cycle prevention. -- 2026-05-07: Wired `graphLayout` into repo settings defaults, serialization, and persisted-shape normalization. -- 2026-05-07: Verified slice 1 with targeted repo-settings tests, full repo-settings tests, extension lint, and extension typecheck. -- 2026-05-07: Slice 2 added `GRAPH_LAYOUT_UPDATED`, `UPDATE_GRAPH_LAYOUT_PIN`, and `CLEAR_GRAPH_LAYOUT_PIN` protocol messages so the extension host persists active-mode pins and echoes the updated Graph Layout to the webview. -- 2026-05-07: The Graph View store now keeps Graph Layout state, sends it into runtime node building, and applies only the active renderer mode's pin coordinates. Timeline Snapshots ignore pins and do not show Pin/Unpin actions. -- 2026-05-07: Live single-node and folder-node context menus now expose Pin/Unpin, and pinned nodes render a small top-right pin badge in the 2D canvas. -- 2026-05-07: Dragging a pinned node writes the final graph-space position back to the active-mode pin when the drag ends. -- 2026-05-07: Verified slice 2 with targeted pin/menu/store/render/dispatch tests, extension typecheck, and a broader graph/store/graphView webview sweep: `261` files and `1728` tests passed. -- 2026-05-07: Slice 3 added 2D desktop-style marquee selection on left-drag from empty Graph Stage space. -- 2026-05-07: Active marquee selection renders a transient desktop-style rectangle, clears on mouse up or pointer leave, and selects visible nodes by their projected screen position. -- 2026-05-07: Multi-selected nodes flow into the existing selected-node context menu integration, including the `Open N Files` behavior. -- 2026-05-07: Verified slice 3 with focused marquee model/view tests, adjacent selection/context-menu tests (`55` tests passed), extension typecheck, extension lint, and `git diff --check`. -- 2026-05-07: Slice 4 added `CREATE_GRAPH_LAYOUT_SECTION` and `UPDATE_GRAPH_LAYOUT_SECTION` messages so the extension host can persist generated Graph Sections, editable labels/colors, Section Frame movement, and Section Frame resizing. -- 2026-05-07: The 2D Graph View now offers New Graph Section from the Graph Toolbar `New...` menu, live Graph Stage background context menu, and live selected-node context menu. Timeline Snapshots and 3D mode hide the new section creation/editing affordances in v1. -- 2026-05-07: PR #203 fixup exposed the user-facing creation and pinning paths: the toolbar `New...` menu now offers New File, New Folder, and New Graph Section; selected-node context menus expose Create Graph Section from Selection and Pin/Unpin for single or multiple selected nodes; 2D gestures now use left-drag marquee, Shift-click additive selection, Shift-left-drag additive marquee, and right/middle drag panning. -- 2026-05-07: Expanded Section Frames render over the 2D Graph Stage with editable label/color controls, graph-space move updates, and southeast resize updates. -- 2026-05-07: Verified slice 4 with an initial red focused suite, then focused section/model/dispatch/menu/toolbar/frame tests (`70` tests passed), a broader graph/menu/toolbar/viewport sweep (`413` tests passed), extension typecheck, extension lint, and `git diff --check`. -- 2026-05-07: Slice 5 added explicit Graph Layout ownership updates, nested section creation, Graph Section deletion that promotes direct children, and shared deepest-frame hit testing. -- 2026-05-07: Dragging a node in 2D now assigns it to the deepest expanded Section Frame under the drop point, or returns it to the root graph when dropped outside every expanded frame. -- 2026-05-07: Moving a parent Section Frame now translates descendant Section Frames and persisted 2D pins for descendant members by the same graph-space delta. -- 2026-05-07: Expanded Section Frames render parents before children and hide descendant frames while an ancestor Graph Section is collapsed. -- 2026-05-07: Verified slice 5 with an initial red focused suite, then focused graph-layout model/dispatch/runtime/frame tests (`29` tests passed), an adjacent viewport/runtime/section/context sweep (`104` tests passed), extension typecheck, extension lint, and `git diff --check`. -- 2026-05-07: Slice 6 added 2D-only Section Nodes to the runtime force graph. PR #203 follow-up made expanded Section Nodes render their colored, labeled Section Frame on the canvas as a fallback-visible graph object, while the HTML Section Frame overlay continues to own editing controls. -- 2026-05-08: PR #203 follow-up also changed default Graph Section creation to preserve a roughly 280 x 180 screen-pixel starting size at the current 2D zoom, so context-menu and toolbar creation remain visibly large after the graph auto-fits a broad repo. -- 2026-05-07: Runtime graph nodes now carry direct `ownerSectionId` metadata, and Section Nodes carry their frame width/height so physics can reason about ownership bounds. -- 2026-05-07: The physics runtime now installs a `sectionBounds` force in 2D that clamps Section Members inside their owner frame and applies a gentle center correction. Pinned members that are already inside their owner frame remain fixed. -- 2026-05-07: Verified slice 6 with an initial red focused suite, then focused section-node/physics/rendering tests (`28` tests passed), a broader graph model/physics/rendering/viewport sweep (`538` tests passed), extension typecheck, extension lint, and `git diff --check`. -- 2026-05-07: Slice 7 added a Graph Layout projection pass before runtime graph building so collapsed Graph Sections hide descendant nodes and descendant Section Nodes behind the nearest visible collapsed Section Node. -- 2026-05-07: Projected cross-boundary edges now retarget to visible representatives, drop internal collapsed-section edges, aggregate by visible source, visible target, and edge kind, and keep original projected edge ids for inspection. -- 2026-05-07: Collapsed Section Nodes stay renderable as normal 2D graph nodes, and expanded Section Nodes render as visible colored Section Frames. Runtime Section Nodes also carry hidden descendant counts. -- 2026-05-07: Verified slice 7 with an initial red focused suite, then focused projection/rendering tests (`17` tests passed), an adjacent graph model/rendering/viewport sweep (`503` tests passed), extension typecheck, extension lint, and `git diff --check`. -- 2026-05-07: Slice 8 added the expanded Section Frame collapse control, pinned Section Frame indicator, collapsed Section Node hidden-descendant count badge, and marquee selection theme-token cleanup. -- 2026-05-07: Verified slice 8 with an initial red focused suite, then focused Section Frame/rendering/theme tests (`24` tests passed), extension typecheck, extension lint, and `git diff --check`. -- 2026-05-07: Final quality refactor split high-CRAP graph layout, Section Frame, runtime physics, viewport, and rendering branches into feature-owned helpers while preserving the implemented behavior. -- 2026-05-07: Added direct tests for shared Graph Layout visibility helpers, 3D pin context actions, collapsed Section Node badge rendering, Section Frame geometry/drag helpers, and Section Frame interaction edge cases. -- 2026-05-07: Final focused graph-section sweep passed: `13` files and `117` tests. -- 2026-05-07: Final local standard gates passed: `pnpm run lint`, `pnpm run typecheck`, `pnpm run test` (`17` Turbo tasks successful; extension `968` files and `5756` tests; release `11` tests). -- 2026-05-07: Final quality gates passed: `pnpm run crap -- extension/` (`All functions have CRAP score <= 8`), `pnpm run boundaries -- extension/` (`996` files, `0` layer violations, `0` dead surfaces, `0` dead ends), `pnpm run reachability -- extension/` (`996` files, `0` dead surfaces, `0` dead ends), and `git diff --check`. -- 2026-05-07: Scoped mutation passed for changed graph-section modules: `collapsedSectionBadge.ts` killed `39/39`, `sectionFrames/view.tsx` killed `36/36`, `sectionFrames/model.ts` killed `50/50`, and `sectionFrames/drag.ts` killed `11/11`. - -## Codex CLI Handoff - -Use Codex CLI goal mode to carry this plan through implementation, verification, and delivery. The goal is not only to land code, but to keep the work observable, reviewed, and held to CodeGraphy's quality bar. - -Operating rules: - -- Follow the acceptance slices in order unless implementation evidence shows a dependency needs to move. -- Keep the branch/worktree isolated from the protected main worktree. -- Open a draft PR early so development progress is trackable. -- Commit and push frequently, at least after each coherent slice and after meaningful test/quality fixes. -- Keep the PR description and this plan updated as implementation decisions change or edge cases are discovered. -- Add changesets for user-facing behavior changes. -- Update docs alongside code when product behavior, terminology, settings shape, or testing instructions change. -- Do not sit idle waiting for long checks. Start useful non-overlapping work while CI, test, mutation, or quality-tool runs are in progress. -- Keep code readable, maintainable, and scalable. Prefer feature-owned modules, explicit models, small mutation sites, and focused tests over broad helper buckets. -- Preserve the settled domain language from `CONTEXT.md`. - -Implementation expectations: - -- Write tests with each behavior slice, especially settings persistence, pin application, ownership validation, nested section operations, drag/drop membership, and collapsed edge projection. -- Keep local lint, typecheck, and tests passing before declaring a slice done. -- Verify CI after pushes and fix failures promptly. -- Use scoped mutation runs for changed modules instead of broad full-repo mutation by default. -- Re-run the relevant tests and quality tools after mutation or quality-tool fixes to prove the fixes hold. -- Use manual/rendered graph validation for interaction-heavy behavior that unit tests cannot fully prove. - -Final quality sweep: - -- Run the standard local gates: - - `pnpm run lint` - - `pnpm run typecheck` - - `pnpm run test` -- Run internal quality tools after development is complete and fix actionable findings: - - CRAP checks, - - SCRAP checks, - - scoped mutation testing, - - organization checks, - - reachability checks, - - any other repo-standard CodeGraphy quality tools relevant to the changed modules. -- Run mutation as scoped tests because full mutation is time-expensive. -- After fixing quality findings, re-run the affected tests and quality-tool checks to validate the fixes. -- Finish with a concise PR-ready summary of implemented behavior, docs/changesets, test evidence, quality-tool evidence, and known follow-up risks. - -## Additional Edge Cases To Validate - -These are not new product decisions, but implementation should explicitly test or manually validate them. - -Identity and persistence: - -- A node id changes because a file is renamed or moved. If CodeGraphy can identify the graph item as the same logical item, pin and section ownership should follow it. If it cannot, the old record should remain dormant rather than being guessed onto an unrelated new node. -- Two graph items briefly map to the same path/id during live update or reindex churn. Layout persistence should not duplicate ownership or create cycles. -- A Graph Section id should never be derived from its label. Renaming `Section 1` to `UI Layer`, then back to `Section 1`, must not affect ownership, pins, collapse state, or nested children. -- A section is deleted while some direct or descendant members are hidden by Graph Scope, Search, Filter, or Show Orphans. Hidden children should be promoted or returned to root according to the same rules as visible children. -- A hidden node returns after its owning section was deleted and the deleted section's parent was also deleted. The node should fall back to the nearest surviving owner, or root if none survive. - -Nested sections: - -- Moving a parent section with multiple nested levels should translate every descendant Section Node, visible node, hidden layout coordinate, and relevant pin by the same graph-space delta exactly once. -- Moving a child section out of a parent should reject cycles and update direct ownership without duplicating ancestor ownership. -- Dropping an item where nested Section Frames overlap should choose the deepest expanded Section Frame under the pointer unless an explicit modifier or context action chooses another owner. -- Collapsing a parent, moving it, then expanding it should restore descendant expanded/collapsed state and translated descendant positions. -- Deleting a parent section while a child section is collapsed should promote the collapsed child section without expanding it. - -Pins: - -- A normal node with both 2D and 3D pins should apply only the pin for the active renderer mode. -- A normal node pinned in 3D should not become pinned in 2D after switching renderers. -- A Section Node should not receive a 3D pin because sections are not rendered in 3D in v1. -- Dragging a pinned Section Member outside the frame should both remove section ownership and update the active-mode pin at the dropped graph-space position. -- Moving a pinned section should update the section pin and any pinned descendant member pins by the same delta. The implementation must not update those descendant pins twice through recursive traversal. - -Collapse and edges: - -- Multiple original edges with the same visible projected endpoints but different Edge Types should remain separate rendered edges. -- Multiple original edges with the same visible projected endpoints and same Edge Type should aggregate into one rendered edge with inspectable original edge evidence. -- Edges wholly inside a collapsed section should hide, but they must reappear unchanged when the section expands. -- An edge from a node inside collapsed Section A to a node inside collapsed Section B should project to Section A -> Section B. -- An edge from a node inside a collapsed child section to a visible node inside the expanded parent should project from the child Section Node to the visible node. -- Collapsed edge tooltips/context menus should expose original endpoints and edge evidence, not only the projected endpoints. - -Selection and gestures: - -- Plain right-click without movement should always open the Graph Context Menu, even though right-click drag can pan after a movement threshold. -- Left-click drag starting on empty Graph Stage should marquee select. Left-click drag starting on a node should drag/select the node according to existing graph interaction rules, not accidentally start marquee selection. -- Shift-left-drag should create an additive marquee selection, not pan. -- The marquee rectangle should select only visible graph items, not hidden descendants behind collapsed sections. -- Marquee selection should include visible graph items that intersect the marquee rectangle, not only items fully enclosed by it. -- Creating a Graph Section from marquee selection should not mutate edges and should not implicitly include hidden descendants of selected collapsed sections unless those collapsed Section Nodes were explicitly selected. -- Multi-select delete should delete only explicitly selected items. Selecting a section alone should not delete its contents. - -Creation and filesystem actions: - -- New File/New Folder with both a Folder Node and Section Frame selected should create in the folder on disk and place the created graph node in the selected section after it appears. -- New File/New Folder with a Section Frame selected but no Folder Node selected should create at workspace root and place the created graph node in the selected section after it appears. -- If a created file/folder is filtered out immediately by Filter Settings or Graph Scope, the intended section ownership should persist dormantly and apply when the node becomes visible. -- If file creation fails, no section ownership record should be created for a nonexistent node. - -Sizing and rendering: - -- A section with no visible members should still respect chrome minimum size for label, badges, edge handles, and empty padding. -- A section whose only members are hidden should use a minimum that preserves section chrome, and should restore member-aware minimum when members become visible again. -- Long labels should not overlap pin/collapse/count badges or resize handles. -- Free-form section colors must remain legible against light, dark, high-contrast, and accent-heavy VS Code themes. -- Bad-contrast user colors should be allowed, but the renderer should add support treatments such as readable text, outlines, or backing surfaces instead of rejecting the color. -- A pinned collapsed Section Node should show both pin and collapsed/count indicators without overlap. -- Resize handles should remain usable at high zoom, low zoom, and after viewport resize. - -## Quality Gates - -Planning docs: - -- Update this file after every resolved decision. -- Update `CONTEXT.md` only for settled domain terms. -- Create an ADR only if the design choice is hard to reverse, surprising, and a real trade-off. - -Implementation later: - -- Unit tests for layout settings normalization and persistence. -- Unit tests for pin application to runtime nodes. -- Unit tests for section membership and collapse projection. -- Unit tests for cross-boundary edge aggregation. -- Webview tests for context menu actions and section interactions. -- Playwright/manual rendered-graph checks for: - - pin persists after reload, - - outside-to-inside edge still draws, - - section move keeps members organized, - - resize handles do not overlap labels, - - 2D/3D mode behavior matches the chosen contract. diff --git a/docs/plans/2026-05-13-extract-core-from-extension-package.md b/docs/plans/2026-05-13-extract-core-from-extension-package.md index 719ff4dea..c34a6aa78 100644 --- a/docs/plans/2026-05-13-extract-core-from-extension-package.md +++ b/docs/plans/2026-05-13-extract-core-from-extension-package.md @@ -11,7 +11,7 @@ Move CodeGraphy's engine out of the VS Code extension package and into shared np The intended product split is: -- `@codegraphy/core` processes folders and owns the engine. +- `@codegraphy-dev/core` processes folders and owns the engine. - The VS Code extension visualizes and integrates with VS Code. - MCP/CLI operates on CodeGraphy Workspaces, runs explicit commands, and returns Graph Query results to agents. - MCP/CLI commands operate on the current folder or an explicit path instead of requiring prior selection. @@ -26,7 +26,7 @@ CodeGraphy now has more than one access path: The current architecture still treats the VS Code extension as the owner of the core engine. `docs/MCP.md` currently says MCP asks the Core Extension to run Indexing and Graph Query. That makes MCP depend on VS Code even when the agent only needs local graph data. -This also causes user-facing friction: MCP can focus or open a VS Code window just to make the extension do engine work. The core extraction should remove that focus-jank by letting MCP talk directly to `@codegraphy/core`. +This also causes user-facing friction: MCP can focus or open a VS Code window just to make the extension do engine work. The core extraction should remove that focus-jank by letting MCP talk directly to `@codegraphy-dev/core`. ## Existing Evidence @@ -43,9 +43,9 @@ This also causes user-facing friction: MCP can focus or open a VS Code window ju ### 1. Core Owns The Full Indexing Pipeline -`@codegraphy/core` should own the full Indexing pipeline, not only Graph Query over an existing Graph Cache. +`@codegraphy-dev/core` should own the full Indexing pipeline, not only Graph Query over an existing Graph Cache. -`@codegraphy/core` owns: +`@codegraphy-dev/core` owns: - File Discovery contracts that are not tied to VS Code APIs - Tree-sitter Analysis @@ -71,7 +71,7 @@ The extension should not be the place where general CodeGraphy engine behavior l ### 3. MCP/CLI Can Explicitly Invoke Indexing -MCP/CLI should be able to tell `@codegraphy/core` to run Indexing and Graph Query operations for the current folder or an explicit CodeGraphy Workspace path. +MCP/CLI should be able to tell `@codegraphy-dev/core` to run Indexing and Graph Query operations for the current folder or an explicit CodeGraphy Workspace path. Indexing must be explicit, not hidden inside every query. @@ -93,7 +93,7 @@ VS Code, MCP, and CLI should all read and write the same Graph Cache: Do not create a separate MCP cache or snapshot. A separate cache would recreate split-brain behavior between VS Code and MCP. -The same workspace-local behavior must work with only `@codegraphy/core` and the VS Code extension installed. MCP is optional. +The same workspace-local behavior must work with only `@codegraphy-dev/core` and the VS Code extension installed. MCP is optional. ### 5. Use CodeGraphy Workspace As The Core Term @@ -114,9 +114,9 @@ Git-aware behavior such as timeline/history can still talk about repos when the ### 6. Bundle Core Into The VSIX -The VS Code extension should get `@codegraphy/core` as a normal npm dependency at build/package time and ship it inside the published VSIX. +The VS Code extension should get `@codegraphy-dev/core` as a normal npm dependency at build/package time and ship it inside the published VSIX. -Do not use VS Code `extensionDependencies` for `@codegraphy/core`. That manifest field is for other VS Code extensions, not npm packages. +Do not use VS Code `extensionDependencies` for `@codegraphy-dev/core`. That manifest field is for other VS Code extensions, not npm packages. Do not run `npm install` during extension activation. A Marketplace install should not depend on: @@ -128,7 +128,7 @@ Do not run `npm install` during extension activation. A Marketplace install shou Implementation direction: -- add `@codegraphy/core` as a workspace dependency of the extension package +- add `@codegraphy-dev/core` as a workspace dependency of the extension package - bundle/import the core entrypoints the extension needs - keep explicit vendoring/copy behavior only for native/runtime packages that cannot be bundled cleanly - verify the final VSIX works from a clean install without package-manager commands @@ -147,12 +147,12 @@ Scope: language plugins only. Target model: -- language plugin implementations ship as npm packages consumed by `@codegraphy/core` +- language plugin implementations ship as npm packages consumed by `@codegraphy-dev/core` - MCP/CLI can load the same language plugins without launching VS Code - the VS Code extension gets language plugin behavior through bundled/default npm dependencies - separate first-party language extensions leave the normal Marketplace install story -This is especially important because plugins are headless analysis packages. VS Code-specific UI, commands, menus, and webviews belong in `@codegraphy/extension`, not in plugin packages. +This is especially important because plugins are headless analysis packages. VS Code-specific UI, commands, menus, and webviews belong in `@codegraphy-dev/extension`, not in plugin packages. ### 8. Standardize Package Names Under `@codegraphy/*` @@ -160,24 +160,24 @@ Use the npm scope `@codegraphy/*` for product packages. Target names: -- `@codegraphy/core` -- `@codegraphy/mcp` -- `@codegraphy/plugin-api` -- `@codegraphy/plugin-typescript` -- `@codegraphy/plugin-python` -- `@codegraphy/plugin-godot` -- `@codegraphy/plugin-csharp` -- `@codegraphy/plugin-markdown` +- `@codegraphy-dev/core` +- `@codegraphy-dev/mcp` +- `@codegraphy-dev/plugin-api` +- `@codegraphy-dev/plugin-typescript` +- `@codegraphy-dev/plugin-python` +- `@codegraphy-dev/plugin-godot` +- `@codegraphy-dev/plugin-csharp` +- `@codegraphy-dev/plugin-markdown` Keep the Marketplace extension id as `codegraphy.codegraphy`. -`@codegraphy/extension` can remain the private workspace package used to build the VS Code extension. It should stay private unless there is a concrete npm-consumer use case. The public install surface for that package is the VS Code Marketplace, not npm. +`@codegraphy-dev/extension` can remain the private workspace package used to build the VS Code extension. It should stay private unless there is a concrete npm-consumer use case. The public install surface for that package is the VS Code Marketplace, not npm. Avoid `@codegraphy-vscode/*` for packages that are no longer VS Code-specific. ### 9. Plugins Are Enabled Per CodeGraphy Workspace -Do not create a "default plugins" bundle or have `@codegraphy/core` auto-load first-party language plugins by magic. +Do not create a "default plugins" bundle or have `@codegraphy-dev/core` auto-load first-party language plugins by magic. Instead, treat plugin installation and plugin enablement as separate states: @@ -204,8 +204,8 @@ Suggested CodeGraphy model: - user-level installed plugin cache: which plugin packages are available to CodeGraphy on the machine - workspace-local enabled plugin set: which installed plugins are active for that CodeGraphy Workspace - workspace-local plugin configuration: plugin-specific settings for that CodeGraphy Workspace -- core plugin runtime: `@codegraphy/core` defines how plugins integrate with analysis, events, signals, and Graph Query behavior through `@codegraphy/plugin-api` -- core load plan: `@codegraphy/core` reads the enabled plugin set for the CodeGraphy Workspace, resolves installed plugin packages, loads them, and runs Indexing +- core plugin runtime: `@codegraphy-dev/core` defines how plugins integrate with analysis, events, signals, and Graph Query behavior through `@codegraphy-dev/plugin-api` +- core load plan: `@codegraphy-dev/core` reads the enabled plugin set for the CodeGraphy Workspace, resolves installed plugin packages, loads them, and runs Indexing - CodeGraphy packages and plugins should not be installed into the user's source project by default - the user's project code should not depend on CodeGraphy packages @@ -255,10 +255,10 @@ Target shape: { "plugins": [ { - "package": "@codegraphy/plugin-markdown" + "package": "@codegraphy-dev/plugin-markdown" }, { - "package": "@codegraphy/plugin-python", + "package": "@codegraphy-dev/plugin-python", "disabledFilterPatterns": [], "options": {} } @@ -293,14 +293,14 @@ Do not rely on npm `postinstall` scripts or plugin packages self-registering dur Installing a plugin package is passive. CodeGraphy records available plugin packages in a user-level installed-plugin cache under `~/.codegraphy/`. -`@codegraphy/core` owns plugin integration. It should work with the VS Code extension even when MCP is not installed. MCP and CLI are consumers of core plugin behavior, not the owners of it. +`@codegraphy-dev/core` owns plugin integration. It should work with the VS Code extension even when MCP is not installed. MCP and CLI are consumers of core plugin behavior, not the owners of it. CLI command model: - `codegraphy plugins install ` can be a convenience wrapper around npm install plus cache update - plain npm global install should also be supported: - - `npm i -g @codegraphy/core` - - `npm i -g @codegraphy/plugin-python` + - `npm i -g @codegraphy-dev/core` + - `npm i -g @codegraphy-dev/plugin-python` - after a plain npm global install, CodeGraphy should be able to record or refresh the installed plugin cache without requiring a path-based manual registration command - `codegraphy plugins add ` resolves a named globally installed plugin package, reads its plugin metadata, and writes it to `~/.codegraphy/plugins.json` - `codegraphy plugins refresh` scans known global package roots for `@codegraphy/*` packages, keeps the packages that expose CodeGraphy plugin metadata, and updates `~/.codegraphy/plugins.json` @@ -333,14 +333,14 @@ This mirrors the useful VS Code split between user settings and workspace settin Primary install path: ```bash -npm i -g @codegraphy/core -npm i -g @codegraphy/plugin-python +npm i -g @codegraphy-dev/core +npm i -g @codegraphy-dev/plugin-python ``` Optional convenience path: ```bash -codegraphy plugins install @codegraphy/plugin-python +codegraphy plugins install @codegraphy-dev/plugin-python ``` The convenience path can wrap npm install and cache update, but plain npm global install should stay valid. @@ -348,15 +348,15 @@ The convenience path can wrap npm install and cache update, but plain npm global Manual npm path: ```bash -npm i -g @codegraphy/plugin-python -codegraphy plugins add @codegraphy/plugin-python +npm i -g @codegraphy-dev/plugin-python +codegraphy plugins add @codegraphy-dev/plugin-python ``` Bulk cache refresh path: ```bash -npm i -g @codegraphy/plugin-python -npm i -g @codegraphy/plugin-markdown +npm i -g @codegraphy-dev/plugin-python +npm i -g @codegraphy-dev/plugin-markdown codegraphy plugins refresh ``` @@ -372,7 +372,7 @@ Add a `codegraphy` field to plugin `package.json` files. The npm package's norma ```json { - "name": "@codegraphy/plugin-python", + "name": "@codegraphy-dev/plugin-python", "version": "1.0.0", "type": "module", "exports": { @@ -393,7 +393,7 @@ Add a `codegraphy` field to plugin `package.json` files. The npm package's norma The manifest should tell CodeGraphy: - this package is a CodeGraphy plugin -- which `@codegraphy/plugin-api` version the plugin was built for +- which `@codegraphy-dev/plugin-api` version the plugin was built for - what default plugin `options` the plugin starts with This enables proper plugin versioning, compatibility checks, default option materialization, and safer plugin discovery. `plugins refresh` can filter candidates by metadata before importing anything. @@ -522,7 +522,7 @@ The plugin model should make it easy for analyzers to use the right relationship Recommended paths: 1. Core Structured Analysis - - owned by `@codegraphy/core` + - owned by `@codegraphy-dev/core` - implemented as the core-owned built-in `codegraphy.treesitter` plugin - uses bundled Tree-sitter grammars and CodeGraphy-owned extractors - produces baseline relationships for broadly useful languages @@ -553,7 +553,7 @@ Do not add an `analysisTier` field to the plugin manifest. The manifest should d Godot is the best current showcase: -- current `@codegraphy/plugin-godot` behavior is mostly Plugin Text Analysis +- current `@codegraphy-dev/plugin-godot` behavior is mostly Plugin Text Analysis - it line-scans GDScript for `preload`, `load`, `extends`, `class_name`, type usage, and static access - it line-scans Godot text resources such as `.tscn`, `.tres`, and `project.godot` - it maintains in-memory maps for class names, resource UIDs, and project roots @@ -561,20 +561,20 @@ Godot is the best current showcase: Potential Godot upgrade paths: -- `@codegraphy/plugin-godot` could become a Plugin Structured Analysis package by using a GDScript parser and a Godot resource parser +- `@codegraphy-dev/plugin-godot` could become a Plugin Structured Analysis package by using a GDScript parser and a Godot resource parser - `@gdquest/lezer-gdscript` exists as a JavaScript GDScript parser option - `@fernforestgames/godot-resource-parser` exists as a JavaScript parser for Godot 4 `.tscn` and `.tres` files - Godot exposes a GDScript language server; using it would be a deeper semantic path, but it would likely require `externalProcesses` and possibly local `network` disclosures because the plugin would connect to or start Godot's language-server process Recommended Godot showcase plugin: -- keep `@codegraphy/plugin-godot` as the first showcase for Plugin Structured Analysis +- keep `@codegraphy-dev/plugin-godot` as the first showcase for Plugin Structured Analysis - first move most GDScript relationship sources to parser-backed Plugin Structured Analysis - keep Plugin Text Analysis fallbacks for anything the structured parser path cannot support yet - then move `.tscn`, `.tres`, and `project.godot` relationship sources to parser-backed resource extraction where practical - keep relationship outputs identical first, then add deeper Godot-specific relationships once the parser-backed path is stable - avoid Godot LSP in the first structured rewrite because it changes runtime requirements and forces external-process/local-network UX immediately -- consider a later optional `@codegraphy/plugin-godot-lsp` or `@codegraphy/plugin-godot-semantic` package if compiler-accurate relationships become worth the extra dependency +- consider a later optional `@codegraphy-dev/plugin-godot-lsp` or `@codegraphy-dev/plugin-godot-semantic` package if compiler-accurate relationships become worth the extra dependency How disclosures apply to these paths: @@ -617,7 +617,7 @@ Current lifecycle order in practice: Lifecycle constraints from the core extraction: -- analysis hooks must work in `@codegraphy/core` without VS Code, MCP, or a webview +- analysis hooks must work in `@codegraphy-dev/core` without VS Code, MCP, or a webview - VS Code UI hooks must not be required for CLI or MCP indexing/querying - MCP should be able to call core directly without selecting or focusing a VS Code window - plugins may combine core structured results, plugin structured analysis, and plugin text analysis inside one package @@ -633,13 +633,13 @@ Decision: - no; that still gives plugins a VS Code-aware surface they do not need - keep plugins headless - keep one npm plugin package and one plugin manifest -- `@codegraphy/core` owns its own lifecycle and plugin runtime -- `@codegraphy/extension` hooks into the core lifecycle while also participating in the VS Code extension lifecycle +- `@codegraphy-dev/core` owns its own lifecycle and plugin runtime +- `@codegraphy-dev/extension` hooks into the core lifecycle while also participating in the VS Code extension lifecycle - plugin packages communicate with core only - plugin packages do not communicate with VS Code and should not know whether the caller is the VS Code extension, CLI, or MCP - the VS Code extension communicates with both VS Code and core - MCP and CLI communicate with core only -- define a core plugin lifecycle that always works in `@codegraphy/core`: settings, discovery metadata, `initialize`, `onPreAnalyze`, `analyzeFile`, `onFilesChanged`, `onPostAnalyze`, graph/source/type contributions, and unload +- define a core plugin lifecycle that always works in `@codegraphy-dev/core`: settings, discovery metadata, `initialize`, `onPreAnalyze`, `analyzeFile`, `onFilesChanged`, `onPostAnalyze`, graph/source/type contributions, and unload - move VS Code-specific toolbar actions, context menu items, decorations, webview contributions, and webview readiness out of the core plugin API - preserve visualization customization as extension-owned behavior, not plugin-owned behavior, unless a future separate extension API is deliberately designed - make non-baseline plugin capabilities visible through disclosures, especially `workspaceWrites`, `outsideWorkspaceWrites`, `externalProcesses`, `network`, `secrets`, and `extraFileReads` @@ -711,7 +711,7 @@ Graph Cache staleness decision: - Graph Cache should store or be paired with an analysis fingerprint - stale status should be based on the analysis inputs that affect graph output - fingerprint inputs should include: - - `@codegraphy/core` package version + - `@codegraphy-dev/core` package version - Graph Cache schema version - enabled plugin package names and versions - enabled plugin array order @@ -779,7 +779,7 @@ Workspace status decision: - stale reason when available - enabled plugin count and names - enabled plugin compatibility problems when present - - `@codegraphy/core` version + - `@codegraphy-dev/core` version - Graph Cache schema version - last indexed time - file count and relationship count when cache exists @@ -821,14 +821,14 @@ Default plugin enablement decision: Markdown bootstrap exception: - Markdown should still be its own npm plugin package -- `@codegraphy/core` installation should include/install `@codegraphy/plugin-markdown` +- `@codegraphy-dev/core` installation should include/install `@codegraphy-dev/plugin-markdown` - CodeGraphy should enable the Markdown plugin by default for new CodeGraphy Workspaces - this gives a new CodeGraphy user one useful plugin-backed relationship source immediately - other plugin packages remain disabled by default until explicitly enabled for a workspace - Markdown can still be enabled or disabled like any other plugin; it is just installed and enabled by default - if a workspace explicitly disables Markdown by removing it from the workspace `plugins` array, that workspace setting should win - Markdown's default enablement should be represented as a core-provided default workspace setting, not as implicit absence behavior -- on first Indexing of a workspace with no settings file, core should materialize effective settings that enable `@codegraphy/plugin-markdown` +- on first Indexing of a workspace with no settings file, core should materialize effective settings that enable `@codegraphy-dev/plugin-markdown` - absent entries still mean disabled for normal plugins - plugin order should be represented by the `plugins` array order, not by a numeric `order` field - avoid a top-level `disabledPlugins` model; disabled plugin state is represented by absence from the `plugins` array @@ -839,7 +839,7 @@ Markdown bootstrap exception: { "plugins": [ { - "package": "@codegraphy/plugin-markdown" + "package": "@codegraphy-dev/plugin-markdown" } ] } @@ -886,10 +886,10 @@ Diagram: codegraphy.codegraphy VSIX | v - packages/extension imports @codegraphy/core + packages/extension imports @codegraphy-dev/core | v - @codegraphy/core + @codegraphy-dev/core / | \ v v v File Discovery Plugin Analysis Graph Query @@ -900,7 +900,7 @@ Diagram: /.codegraphy/graph.lbug ^ | - @codegraphy/mcp and CLI + @codegraphy-dev/mcp and CLI ``` ## Naming Corrections Needed @@ -909,11 +909,11 @@ Replace or narrow these phrases where they are inaccurate: - repo-local Graph Cache -> workspace-local Graph Cache - repo -> CodeGraphy Workspace, unless Git behavior is required -- Core Extension owns Indexing -> `@codegraphy/core` owns Indexing -- Core Extension owns Graph Query -> `@codegraphy/core` owns Graph Query +- Core Extension owns Indexing -> `@codegraphy-dev/core` owns Indexing +- Core Extension owns Graph Query -> `@codegraphy-dev/core` owns Graph Query - top-level `pluginOrder`, `disabledPlugins`, and `disabledPluginFilterPatterns` -> workspace-local `plugins` entries -- `@codegraphy-vscode/mcp` -> `@codegraphy/mcp` -- `@codegraphy-vscode/plugin-api` -> `@codegraphy/plugin-api` +- `@codegraphy-vscode/mcp` -> `@codegraphy-dev/mcp` +- `@codegraphy-vscode/plugin-api` -> `@codegraphy-dev/plugin-api` Keep these terms: @@ -932,7 +932,7 @@ Keep these terms: - change Graph Cache definition from repo-local to workspace-local - replace Core Extension ownership language with core package ownership language - `docs/MCP.md` - - rewrite package roles around `@codegraphy/core` and `@codegraphy/mcp` + - rewrite package roles around `@codegraphy-dev/core` and `@codegraphy-dev/mcp` - remove VS Code focus/open requirement from normal query/indexing flow - make explicit indexing tools call core directly - rename repo-centric tool language where appropriate @@ -940,7 +940,7 @@ Keep these terms: - `docs/PLUGINS.md` - describe language plugin npm packages - describe plugins as headless core analysis packages - - make clear plugins communicate with `@codegraphy/core`, not VS Code + - make clear plugins communicate with `@codegraphy-dev/core`, not VS Code - update first-party plugin publishing story - `docs/SETTINGS.md` - replace top-level plugin settings with the consolidated `plugins` section @@ -959,69 +959,69 @@ Run the goal as a sequence of small PRs. Each step should leave the repo in a sh - 2026-05-14: Draft PR opened from `codex/core-package-extraction` with this runbook as the tracking artifact. - 2026-05-14: Step 1 package identity groundwork completed. - - Added public `@codegraphy/core` workspace package with build, lint, typecheck, test, package exports, README, and LICENSE. - - Renamed public package metadata from `@codegraphy-vscode/plugin-api` to `@codegraphy/plugin-api`. - - Renamed public package metadata from `@codegraphy-vscode/mcp` to `@codegraphy/mcp`. - - Repointed workspace package dependencies and imports to `@codegraphy/plugin-api`. - - Updated release target discovery so `core` resolves to the npm `@codegraphy/core` package and `extension` / `vsix` / `marketplace` resolves to the VSIX release. - - Validation: `node --test tests/release/releaseScript.test.mjs`, `pnpm --filter @codegraphy/core lint`, `pnpm --filter @codegraphy/core test`, `pnpm --filter @codegraphy/core build`, `pnpm run typecheck:plugins`, `pnpm --filter @codegraphy/mcp test`, and targeted extension import-analysis tests. + - Added public `@codegraphy-dev/core` workspace package with build, lint, typecheck, test, package exports, README, and LICENSE. + - Renamed public package metadata from `@codegraphy-vscode/plugin-api` to `@codegraphy-dev/plugin-api`. + - Renamed public package metadata from `@codegraphy-vscode/mcp` to `@codegraphy-dev/mcp`. + - Repointed workspace package dependencies and imports to `@codegraphy-dev/plugin-api`. + - Updated release target discovery so `core` resolves to the npm `@codegraphy-dev/core` package and `extension` / `vsix` / `marketplace` resolves to the VSIX release. + - Validation: `node --test tests/release/releaseScript.test.mjs`, `pnpm --filter @codegraphy-dev/core lint`, `pnpm --filter @codegraphy-dev/core test`, `pnpm --filter @codegraphy-dev/core build`, `pnpm run typecheck:plugins`, `pnpm --filter @codegraphy-dev/mcp test`, and targeted extension import-analysis tests. - 2026-05-14: Step 2 core Graph Cache and Graph Query API completed. - - Moved Graph Query execution into `@codegraphy/core` and removed the duplicate extension-local Graph Query implementation. - - Moved workspace Graph Cache path/status/storage contracts into `@codegraphy/core`. + - Moved Graph Query execution into `@codegraphy-dev/core` and removed the duplicate extension-local Graph Query implementation. + - Moved workspace Graph Cache path/status/storage contracts into `@codegraphy-dev/core`. - Moved LadybugDB Graph Cache unit coverage from the extension package to the core package. - - Kept the VS Code extension as an adapter over `@codegraphy/core` for Graph Query and Graph Cache storage. - - Validation: `pnpm --filter @codegraphy/core test`, `pnpm --filter @codegraphy/core lint`, `pnpm --filter @codegraphy/core build`, `pnpm --filter @codegraphy/extension typecheck`, targeted extension Graph Query/agent bridge/public API tests, and targeted extension pipeline cache/lifecycle tests. -- 2026-05-14: Step 3 first slice completed: File Discovery moved into `@codegraphy/core`. + - Kept the VS Code extension as an adapter over `@codegraphy-dev/core` for Graph Query and Graph Cache storage. + - Validation: `pnpm --filter @codegraphy-dev/core test`, `pnpm --filter @codegraphy-dev/core lint`, `pnpm --filter @codegraphy-dev/core build`, `pnpm --filter @codegraphy-dev/extension typecheck`, targeted extension Graph Query/agent bridge/public API tests, and targeted extension pipeline cache/lifecycle tests. +- 2026-05-14: Step 3 first slice completed: File Discovery moved into `@codegraphy-dev/core`. - Moved discovery contracts, path matching, gitignore loading, directory walking, and `FileDiscovery` into the core package. - Moved discovery unit coverage from the extension package to the core package. - - Repointed VS Code pipeline and workspace watcher imports to `@codegraphy/core`. - - Validation: `pnpm --filter @codegraphy/core exec vitest run --config vitest.config.ts tests/discovery`, `pnpm --filter @codegraphy/core test`, `pnpm --filter @codegraphy/core lint`, `pnpm --filter @codegraphy/core build`, `pnpm --filter @codegraphy/extension typecheck`, `pnpm --filter @codegraphy/extension lint`, targeted extension workspace-file and pipeline tests, and `pnpm run test:release`. -- 2026-05-14: Step 3 second slice completed: cache-aware File Analysis moved into `@codegraphy/core`. + - Repointed VS Code pipeline and workspace watcher imports to `@codegraphy-dev/core`. + - Validation: `pnpm --filter @codegraphy-dev/core exec vitest run --config vitest.config.ts tests/discovery`, `pnpm --filter @codegraphy-dev/core test`, `pnpm --filter @codegraphy-dev/core lint`, `pnpm --filter @codegraphy-dev/core build`, `pnpm --filter @codegraphy-dev/extension typecheck`, `pnpm --filter @codegraphy-dev/extension lint`, targeted extension workspace-file and pipeline tests, and `pnpm run test:release`. +- 2026-05-14: Step 3 second slice completed: cache-aware File Analysis moved into `@codegraphy-dev/core`. - Moved workspace analysis abort handling, per-file analysis orchestration, symbol enrichment, target symbol resolution, and relationship projection helpers into the core package. - Moved File Analysis unit coverage from the extension package to the core package. - - Kept the VS Code extension import surface as adapter exports over `@codegraphy/core` so the remaining extension pipeline can migrate incrementally. - - Validation: `pnpm --filter @codegraphy/core exec vitest run --config vitest.config.ts tests/analysis`, `pnpm --filter @codegraphy/core typecheck`, `pnpm --filter @codegraphy/core build`, `pnpm --filter @codegraphy/extension typecheck`, and targeted extension pipeline tests. -- 2026-05-14: Step 3 third slice completed: Graph Projection moved into `@codegraphy/core`. + - Kept the VS Code extension import surface as adapter exports over `@codegraphy-dev/core` so the remaining extension pipeline can migrate incrementally. + - Validation: `pnpm --filter @codegraphy-dev/core exec vitest run --config vitest.config.ts tests/analysis`, `pnpm --filter @codegraphy-dev/core typecheck`, `pnpm --filter @codegraphy-dev/core build`, `pnpm --filter @codegraphy-dev/extension typecheck`, and targeted extension pipeline tests. +- 2026-05-14: Step 3 third slice completed: Graph Projection moved into `@codegraphy-dev/core`. - Moved file/package/folder/symbol graph node and edge builders into the core package. - Moved graph projection unit coverage from the extension package to the core package. - Added core graph color and edge identity helpers so Relationship Graph construction no longer depends on extension shared modules. - - Kept the VS Code extension graph import surface as adapter exports over `@codegraphy/core`. - - Validation: `pnpm --filter @codegraphy/core typecheck`, `pnpm --filter @codegraphy/core exec vitest run --config vitest.config.ts tests/graph`, `pnpm --filter @codegraphy/core build`, `pnpm --filter @codegraphy/extension typecheck`, and targeted extension pipeline adapter/service tests. -- 2026-05-14: Step 3 fourth slice completed: workspace analysis orchestration moved into `@codegraphy/core`. + - Kept the VS Code extension graph import surface as adapter exports over `@codegraphy-dev/core`. + - Validation: `pnpm --filter @codegraphy-dev/core typecheck`, `pnpm --filter @codegraphy-dev/core exec vitest run --config vitest.config.ts tests/graph`, `pnpm --filter @codegraphy-dev/core build`, `pnpm --filter @codegraphy-dev/extension typecheck`, and targeted extension pipeline adapter/service tests. +- 2026-05-14: Step 3 fourth slice completed: workspace analysis orchestration moved into `@codegraphy-dev/core`. - Moved discovery filter merging, workspace analysis orchestration, plugin pre-analysis, file-analysis delegation, rebuild state, and analysis cache helpers into the core package. - Moved orchestration unit coverage from the extension package to the core package. - Kept VS Code-specific warning UI, workspace root lookup, and persistence wiring in extension adapters. - - Validation: `pnpm --filter @codegraphy/core typecheck`, targeted core workspace analysis tests, `pnpm --filter @codegraphy/core build`, `pnpm --filter @codegraphy/extension typecheck`, and targeted extension analysis/service tests. -- 2026-05-14: Step 3 fifth slice completed: Tree-sitter Analysis moved into `@codegraphy/core`. + - Validation: `pnpm --filter @codegraphy-dev/core typecheck`, targeted core workspace analysis tests, `pnpm --filter @codegraphy-dev/core build`, `pnpm --filter @codegraphy-dev/extension typecheck`, and targeted extension analysis/service tests. +- 2026-05-14: Step 3 fifth slice completed: Tree-sitter Analysis moved into `@codegraphy-dev/core`. - Moved the Tree-sitter plugin wrapper, parser runtime, language catalog, C# pre-analysis index, path host, and per-language analyzers into the core package. - Moved Tree-sitter runtime unit coverage from the extension package to the core package. - - Added Tree-sitter parser packages to `@codegraphy/core` dependencies while keeping VSIX vendoring in the extension build scripts. - - Kept the VS Code extension Tree-sitter plugin and Git history path-host imports as adapter exports over `@codegraphy/core`. - - Validation: `pnpm --filter @codegraphy/core typecheck`, `pnpm --filter @codegraphy/core exec vitest run --config vitest.config.ts tests/treeSitter`, `pnpm --filter @codegraphy/core build`, and `pnpm --filter @codegraphy/extension typecheck`. -- 2026-05-14: Step 3 sixth slice completed: headless plugin runtime and explicit workspace Indexing added to `@codegraphy/core`. + - Added Tree-sitter parser packages to `@codegraphy-dev/core` dependencies while keeping VSIX vendoring in the extension build scripts. + - Kept the VS Code extension Tree-sitter plugin and Git history path-host imports as adapter exports over `@codegraphy-dev/core`. + - Validation: `pnpm --filter @codegraphy-dev/core typecheck`, `pnpm --filter @codegraphy-dev/core exec vitest run --config vitest.config.ts tests/treeSitter`, `pnpm --filter @codegraphy-dev/core build`, and `pnpm --filter @codegraphy-dev/extension typecheck`. +- 2026-05-14: Step 3 sixth slice completed: headless plugin runtime and explicit workspace Indexing added to `@codegraphy-dev/core`. - Moved plugin routing, workspace analysis context, pre-analysis lifecycle hooks, file-change hooks, and file-analysis result merging into the core package. - Added a core-owned `CorePluginRegistry` for headless analysis plugins without VS Code/webview dependencies. - Added `indexCodeGraphyWorkspace(...)` so core can index exactly the requested CodeGraphy Workspace path and write `/.codegraphy/graph.lbug`. - - Kept the VS Code extension import surface as adapter exports over `@codegraphy/core` for the moved headless plugin modules. - - Validation: `pnpm --filter @codegraphy/core exec vitest run --config vitest.config.ts tests/indexing/workspace.test.ts`, `pnpm --filter @codegraphy/core typecheck`, `pnpm --filter @codegraphy/core lint`, `pnpm --filter @codegraphy/core build`, and `pnpm --filter @codegraphy/extension typecheck`. + - Kept the VS Code extension import surface as adapter exports over `@codegraphy-dev/core` for the moved headless plugin modules. + - Validation: `pnpm --filter @codegraphy-dev/core exec vitest run --config vitest.config.ts tests/indexing/workspace.test.ts`, `pnpm --filter @codegraphy-dev/core typecheck`, `pnpm --filter @codegraphy-dev/core lint`, `pnpm --filter @codegraphy-dev/core build`, and `pnpm --filter @codegraphy-dev/extension typecheck`. - 2026-05-14: Step 4 first slice completed: core Workspace Settings and freshness status added. - Added core-owned Workspace Settings read/write/normalization for `/.codegraphy/settings.json`, including ordered `plugins` entries with `package`, `disabledFilterPatterns`, and `options`. - Added workspace metadata, plugin/settings fingerprints, analysis-version fingerprints, and `readCodeGraphyWorkspaceStatus(...)` for fresh/stale/missing Graph Cache state. - Updated `indexCodeGraphyWorkspace(...)` to materialize Workspace Settings, use them for Indexing, and persist matching metadata after writing the Graph Cache. - - Validation: `pnpm --filter @codegraphy/core exec vitest run --config vitest.config.ts tests/workspace/settings.test.ts tests/workspace/status.test.ts tests/indexing/workspace.test.ts`, `pnpm --filter @codegraphy/core typecheck`, `pnpm --filter @codegraphy/core lint`, and `pnpm --filter @codegraphy/core build`. + - Validation: `pnpm --filter @codegraphy-dev/core exec vitest run --config vitest.config.ts tests/workspace/settings.test.ts tests/workspace/status.test.ts tests/indexing/workspace.test.ts`, `pnpm --filter @codegraphy-dev/core typecheck`, `pnpm --filter @codegraphy-dev/core lint`, and `pnpm --filter @codegraphy-dev/core build`. - 2026-05-14: Step 5 first slice completed: installed plugin metadata cache and CLI plugin commands added. - Added core-owned parsing for `package.json#codegraphy` plugin metadata, including Plugin API compatibility, default options, and capability disclosures without importing runtime code. - Added user-level installed plugin cache helpers for `~/.codegraphy/plugins.json` plus the user settings path at `~/.codegraphy/settings.json`. - Added metadata-only plugin cache operations for `plugins refresh` over global `@codegraphy/*` packages and `plugins add ` for explicitly named global packages. - Added workspace-local plugin enable/disable helpers that mutate only `/.codegraphy/settings.json` and preserve plugin order as array order. - Added CLI commands for `codegraphy plugins refresh`, `add`, `list`, `enable`, and `disable`; enable fails from the installed-plugin cache instead of scanning global npm roots. - - Validation: `pnpm --filter @codegraphy/core exec vitest run --config vitest.config.ts tests/plugins/packageManifest.test.ts tests/plugins/installedCache.test.ts tests/workspace/settings.test.ts`, `pnpm --filter @codegraphy/core test`, `pnpm --filter @codegraphy/core typecheck`, `pnpm --filter @codegraphy/core lint`, `pnpm --filter @codegraphy/core build`, `pnpm --filter @codegraphy/mcp exec vitest run --config vitest.config.ts tests/run/parse.test.ts tests/plugins/command.test.ts`, `pnpm --filter @codegraphy/mcp test`, `pnpm --filter @codegraphy/mcp typecheck`, `pnpm --filter @codegraphy/mcp lint`, `pnpm --filter @codegraphy/mcp build`, `pnpm --filter @codegraphy/extension typecheck`, `pnpm run test:release`, and `git diff --check`. + - Validation: `pnpm --filter @codegraphy-dev/core exec vitest run --config vitest.config.ts tests/plugins/packageManifest.test.ts tests/plugins/installedCache.test.ts tests/workspace/settings.test.ts`, `pnpm --filter @codegraphy-dev/core test`, `pnpm --filter @codegraphy-dev/core typecheck`, `pnpm --filter @codegraphy-dev/core lint`, `pnpm --filter @codegraphy-dev/core build`, `pnpm --filter @codegraphy-dev/mcp exec vitest run --config vitest.config.ts tests/run/parse.test.ts tests/plugins/command.test.ts`, `pnpm --filter @codegraphy-dev/mcp test`, `pnpm --filter @codegraphy-dev/mcp typecheck`, `pnpm --filter @codegraphy-dev/mcp lint`, `pnpm --filter @codegraphy-dev/mcp build`, `pnpm --filter @codegraphy-dev/extension typecheck`, `pnpm run test:release`, and `git diff --check`. - 2026-05-14: Step 6 completed: Markdown bootstrap added as an explicit plugin package and default workspace setting. - - Made `@codegraphy/plugin-markdown` a public npm workspace package with package exports, build output, publish metadata, and `package.json#codegraphy` metadata. - - Added `@codegraphy/plugin-markdown` as a dependency of `@codegraphy/core` so core installs Markdown transitively. - - Added first-workspace settings materialization so the first Indexing of a workspace with no settings file writes `plugins: [{ package: "@codegraphy/plugin-markdown" }]`. + - Made `@codegraphy-dev/plugin-markdown` a public npm workspace package with package exports, build output, publish metadata, and `package.json#codegraphy` metadata. + - Added `@codegraphy-dev/plugin-markdown` as a dependency of `@codegraphy-dev/core` so core installs Markdown transitively. + - Added first-workspace settings materialization so the first Indexing of a workspace with no settings file writes `plugins: [{ package: "@codegraphy-dev/plugin-markdown" }]`. - Registered the Markdown plugin from core only when the workspace settings include the Markdown package, so removing the entry disables Markdown for that workspace. - - Validation: `pnpm --filter @codegraphy/core exec vitest run --config vitest.config.ts tests/workspace/settings.test.ts tests/indexing/workspace.test.ts`, `pnpm --filter @codegraphy/core test`, `pnpm --filter @codegraphy/plugin-markdown build`, `pnpm --filter @codegraphy/plugin-markdown typecheck`, `pnpm --filter @codegraphy/plugin-markdown lint`, `pnpm --filter @codegraphy/plugin-markdown test`, `pnpm --filter @codegraphy/core typecheck`, `pnpm --filter @codegraphy/core lint`, `pnpm --filter @codegraphy/core build`, `pnpm --filter @codegraphy/extension typecheck`, `pnpm run test:release`, and `git diff --check`. + - Validation: `pnpm --filter @codegraphy-dev/core exec vitest run --config vitest.config.ts tests/workspace/settings.test.ts tests/indexing/workspace.test.ts`, `pnpm --filter @codegraphy-dev/core test`, `pnpm --filter @codegraphy-dev/plugin-markdown build`, `pnpm --filter @codegraphy-dev/plugin-markdown typecheck`, `pnpm --filter @codegraphy-dev/plugin-markdown lint`, `pnpm --filter @codegraphy-dev/plugin-markdown test`, `pnpm --filter @codegraphy-dev/core typecheck`, `pnpm --filter @codegraphy-dev/core lint`, `pnpm --filter @codegraphy-dev/core build`, `pnpm --filter @codegraphy-dev/extension typecheck`, `pnpm run test:release`, and `git diff --check`. ### Step 1: Package Identity Groundwork @@ -1032,10 +1032,10 @@ Goal: Changes: - add `packages/core` -- name it `@codegraphy/core` -- rename MCP package metadata to `@codegraphy/mcp` -- rename Plugin API package metadata to `@codegraphy/plugin-api` -- keep `@codegraphy/extension` private as the workspace package for the VS Code extension +- name it `@codegraphy-dev/core` +- rename MCP package metadata to `@codegraphy-dev/mcp` +- rename Plugin API package metadata to `@codegraphy-dev/plugin-api` +- keep `@codegraphy-dev/extension` private as the workspace package for the VS Code extension - keep the Marketplace extension id as `codegraphy.codegraphy` - establish package exports, build scripts, typecheck scripts, and release discovery @@ -1049,7 +1049,7 @@ Done when: Goal: -- make `@codegraphy/core` the owner of Graph Cache read/write contracts and Graph Query execution +- make `@codegraphy-dev/core` the owner of Graph Cache read/write contracts and Graph Query execution Changes: @@ -1068,7 +1068,7 @@ Done when: Goal: -- make `@codegraphy/core` own the full Indexing pipeline +- make `@codegraphy-dev/core` own the full Indexing pipeline Changes: @@ -1082,7 +1082,7 @@ Changes: Done when: -- `@codegraphy/core` can index a plain folder without VS Code +- `@codegraphy-dev/core` can index a plain folder without VS Code - Graph Cache is written to `/.codegraphy/graph.lbug` - existing VS Code graph behavior still works through the extension adapter @@ -1141,15 +1141,15 @@ Goal: Changes: -- publish/package Markdown as `@codegraphy/plugin-markdown` -- make `@codegraphy/core` install/include Markdown by default +- publish/package Markdown as `@codegraphy-dev/plugin-markdown` +- make `@codegraphy-dev/core` install/include Markdown by default - materialize first workspace settings with Markdown enabled: ```json { "plugins": [ { - "package": "@codegraphy/plugin-markdown" + "package": "@codegraphy-dev/plugin-markdown" } ] } @@ -1187,8 +1187,8 @@ Done when: Implementation progress: -- Added core-backed path resolution, status, indexing, and Graph Query helpers under `@codegraphy/mcp`. -- Changed `codegraphy index [workspace]` to index the current folder or explicit CodeGraphy Workspace path through `@codegraphy/core`. +- Added core-backed path resolution, status, indexing, and Graph Query helpers under `@codegraphy-dev/mcp`. +- Changed `codegraphy index [workspace]` to index the current folder or explicit CodeGraphy Workspace path through `@codegraphy-dev/core`. - Added `codegraphy status [workspace]` with JSON workspace status, stale reasons, and enabled plugin package names. - Replaced normal MCP open/index-repo tools with path-first `codegraphy_status`, `codegraphy_index`, and query tools that accept optional `path`. - Confirmed MCP query tools can report a missing Graph Cache without opening or focusing VS Code. @@ -1196,18 +1196,18 @@ Implementation progress: Validation: -- `pnpm --filter @codegraphy/mcp exec vitest run --config vitest.config.ts tests/run/parse.test.ts tests/index/command.test.ts tests/status/command.test.ts tests/mcp/server.test.ts` -- `pnpm --filter @codegraphy/mcp exec vitest run --config vitest.config.ts tests/workspace/coreBacked.test.ts` -- `pnpm --filter @codegraphy/mcp test` -- `pnpm --filter @codegraphy/mcp typecheck` -- `pnpm --filter @codegraphy/mcp lint` -- `pnpm --filter @codegraphy/mcp build` -- `pnpm --filter @codegraphy/plugin-markdown build` -- `pnpm --filter @codegraphy/core exec vitest run --config vitest.config.ts tests/workspace/status.test.ts tests/indexing/workspace.test.ts` -- `pnpm --filter @codegraphy/core typecheck` -- `pnpm --filter @codegraphy/core lint` -- `pnpm --filter @codegraphy/core build` -- `pnpm --filter @codegraphy/extension typecheck` +- `pnpm --filter @codegraphy-dev/mcp exec vitest run --config vitest.config.ts tests/run/parse.test.ts tests/index/command.test.ts tests/status/command.test.ts tests/mcp/server.test.ts` +- `pnpm --filter @codegraphy-dev/mcp exec vitest run --config vitest.config.ts tests/workspace/coreBacked.test.ts` +- `pnpm --filter @codegraphy-dev/mcp test` +- `pnpm --filter @codegraphy-dev/mcp typecheck` +- `pnpm --filter @codegraphy-dev/mcp lint` +- `pnpm --filter @codegraphy-dev/mcp build` +- `pnpm --filter @codegraphy-dev/plugin-markdown build` +- `pnpm --filter @codegraphy-dev/core exec vitest run --config vitest.config.ts tests/workspace/status.test.ts tests/indexing/workspace.test.ts` +- `pnpm --filter @codegraphy-dev/core typecheck` +- `pnpm --filter @codegraphy-dev/core lint` +- `pnpm --filter @codegraphy-dev/core build` +- `pnpm --filter @codegraphy-dev/extension typecheck` - `pnpm run test:release` - `git diff --check` @@ -1219,7 +1219,7 @@ Goal: Changes: -- make the extension import/use `@codegraphy/core` +- make the extension import/use `@codegraphy-dev/core` - keep VS Code lifecycle, commands, webviews, context menus, and editor integration inside the extension - remove plugin-to-VS-Code communication from the plugin API - render plugin toggles/options from core workspace state @@ -1234,8 +1234,8 @@ Done when: Implementation progress: -- Kept `@codegraphy/core` as an npm dependency of `@codegraphy/extension`, not a VS Code `extensionDependencies` entry. -- Split the extension build external list into an explicit package contract so `@codegraphy/core` and the Markdown plugin bundle into `dist/extension.js`, while native/runtime packages remain external and vendored into `dist/node_modules`. +- Kept `@codegraphy-dev/core` as an npm dependency of `@codegraphy-dev/extension`, not a VS Code `extensionDependencies` entry. +- Split the extension build external list into an explicit package contract so `@codegraphy-dev/core` and the Markdown plugin bundle into `dist/extension.js`, while native/runtime packages remain external and vendored into `dist/node_modules`. - Routed the VS Code extension's index-status adapter through `readCodeGraphyWorkspaceStatus(...)` so the extension and MCP report fresh/stale/missing Graph Cache state from the same core status model. - Persisted extension indexing metadata through core workspace metadata persistence so analysis-version and pending-change state stay compatible with MCP/CLI status. - Preserved VS Code lifecycle, webview, editor commands, graph rendering, and stale-dot presentation in the extension adapter. @@ -1243,15 +1243,15 @@ Implementation progress: Validation: -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/extension/build/runtimePackages.test.ts` -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/extension/pipeline/service/cache/index.test.ts tests/extension/pipeline/lifecycle.test.ts tests/extension/pipeline/service/base/internal.test.ts tests/extension/build/runtimePackages.test.ts` -- `pnpm --filter @codegraphy/core exec vitest run --config vitest.config.ts tests/workspace/status.test.ts` -- `pnpm --filter @codegraphy/core build` -- `pnpm --filter @codegraphy/core typecheck` -- `pnpm --filter @codegraphy/extension typecheck` -- `pnpm --filter @codegraphy/core lint` -- `pnpm --filter @codegraphy/extension lint` -- `pnpm --filter @codegraphy/extension build:extension` +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/build/runtimePackages.test.ts` +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/pipeline/service/cache/index.test.ts tests/extension/pipeline/lifecycle.test.ts tests/extension/pipeline/service/base/internal.test.ts tests/extension/build/runtimePackages.test.ts` +- `pnpm --filter @codegraphy-dev/core exec vitest run --config vitest.config.ts tests/workspace/status.test.ts` +- `pnpm --filter @codegraphy-dev/core build` +- `pnpm --filter @codegraphy-dev/core typecheck` +- `pnpm --filter @codegraphy-dev/extension typecheck` +- `pnpm --filter @codegraphy-dev/core lint` +- `pnpm --filter @codegraphy-dev/extension lint` +- `pnpm --filter @codegraphy-dev/extension build:extension` - `pnpm run test:release` - `pnpm run package:vsix` - `git diff --check` @@ -1279,7 +1279,7 @@ Done when: Implementation progress: -- Renamed the TypeScript, Python, C#, and Godot package manifests to `@codegraphy/plugin-typescript`, `@codegraphy/plugin-python`, `@codegraphy/plugin-csharp`, and `@codegraphy/plugin-godot`. +- Renamed the TypeScript, Python, C#, and Godot package manifests to `@codegraphy-dev/plugin-typescript`, `@codegraphy-dev/plugin-python`, `@codegraphy-dev/plugin-csharp`, and `@codegraphy-dev/plugin-godot`. - Converted those language plugins from VS Code companion extensions to headless npm plugin packages with `type: "module"`, `dist/plugin.js` exports, declaration output, public publish metadata, and `package.json#codegraphy` metadata. - Removed the language-plugin VS Code activation entrypoints, VSCE publish/package scripts, and `extensionDependencies`. - Kept plugin runtime behavior in the existing `src/plugin.ts` entrypoints and preserved plugin unit coverage, including Godot relationship/symbol analysis tests. @@ -1288,23 +1288,23 @@ Implementation progress: Validation: -- `pnpm --filter @codegraphy/plugin-typescript test` -- `pnpm --filter @codegraphy/plugin-python test` -- `pnpm --filter @codegraphy/plugin-csharp test` -- `pnpm --filter @codegraphy/plugin-godot test` +- `pnpm --filter @codegraphy-dev/plugin-typescript test` +- `pnpm --filter @codegraphy-dev/plugin-python test` +- `pnpm --filter @codegraphy-dev/plugin-csharp test` +- `pnpm --filter @codegraphy-dev/plugin-godot test` - `pnpm run typecheck:plugins` -- `pnpm --filter @codegraphy/plugin-typescript lint` -- `pnpm --filter @codegraphy/plugin-python lint` -- `pnpm --filter @codegraphy/plugin-csharp lint` -- `pnpm --filter @codegraphy/plugin-godot lint` -- `pnpm --filter @codegraphy/plugin-typescript build` -- `pnpm --filter @codegraphy/plugin-python build` -- `pnpm --filter @codegraphy/plugin-csharp build` -- `pnpm --filter @codegraphy/plugin-godot build` -- `pnpm --filter @codegraphy/core exec vitest run --config vitest.config.ts tests/plugins/packageManifest.test.ts tests/plugins/installedCache.test.ts tests/workspace/settings.test.ts` -- `pnpm --filter @codegraphy/mcp exec vitest run --config vitest.config.ts tests/plugins/command.test.ts tests/run/parse.test.ts` -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/extension/pluginIntegration/installed/activation.test.ts tests/extension/pluginIntegration/installed/statuses.test.ts tests/extension/pluginActivation/installed.test.ts` -- `pnpm --filter @codegraphy/extension typecheck` +- `pnpm --filter @codegraphy-dev/plugin-typescript lint` +- `pnpm --filter @codegraphy-dev/plugin-python lint` +- `pnpm --filter @codegraphy-dev/plugin-csharp lint` +- `pnpm --filter @codegraphy-dev/plugin-godot lint` +- `pnpm --filter @codegraphy-dev/plugin-typescript build` +- `pnpm --filter @codegraphy-dev/plugin-python build` +- `pnpm --filter @codegraphy-dev/plugin-csharp build` +- `pnpm --filter @codegraphy-dev/plugin-godot build` +- `pnpm --filter @codegraphy-dev/core exec vitest run --config vitest.config.ts tests/plugins/packageManifest.test.ts tests/plugins/installedCache.test.ts tests/workspace/settings.test.ts` +- `pnpm --filter @codegraphy-dev/mcp exec vitest run --config vitest.config.ts tests/plugins/command.test.ts tests/run/parse.test.ts` +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/pluginIntegration/installed/activation.test.ts tests/extension/pluginIntegration/installed/statuses.test.ts tests/extension/pluginActivation/installed.test.ts` +- `pnpm --filter @codegraphy-dev/extension typecheck` - `pnpm run test:release` - `pnpm run release:package plugin-typescript` - `pnpm run release:package plugin-python` @@ -1320,7 +1320,7 @@ Goal: Changes: -- keep `@codegraphy/plugin-godot` as one package +- keep `@codegraphy-dev/plugin-godot` as one package - replace most GDScript line scanning with parser-backed extraction - keep Plugin Text Analysis fallbacks where parser support is incomplete - move `.tscn`, `.tres`, and `project.godot` parsing toward structured resource parsing where practical @@ -1347,10 +1347,10 @@ Implementation progress: Validation: -- `pnpm --filter @codegraphy/plugin-godot test` -- `pnpm --filter @codegraphy/plugin-godot typecheck` -- `pnpm --filter @codegraphy/plugin-godot lint` -- `pnpm --filter @codegraphy/plugin-godot build` +- `pnpm --filter @codegraphy-dev/plugin-godot test` +- `pnpm --filter @codegraphy-dev/plugin-godot typecheck` +- `pnpm --filter @codegraphy-dev/plugin-godot lint` +- `pnpm --filter @codegraphy-dev/plugin-godot build` - `pnpm run boundaries -- plugin-godot --strict` - `pnpm run crap -- plugin-godot` - `pnpm run mutate -- --mutate packages/plugin-godot/src/gdscript/className.ts` @@ -1367,10 +1367,10 @@ Validation: - `pnpm run mutate -- --mutate packages/plugin-godot/src/plugin/metadata.ts` - `pnpm run mutate -- --mutate packages/plugin-godot/src/plugin/symbol/className.ts` - `pnpm run mutate -- --mutate packages/plugin-godot/src/plugin/symbol/extract.ts` -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/extension/packageIcons.test.ts tests/integration/pluginActivationEvents.test.ts tests/extension/pipeline/adapters.test.ts tests/extension/pipeline/analysis/delegates.test.ts` -- `pnpm --filter @codegraphy/extension typecheck` -- `pnpm --filter @codegraphy/quality-tools exec vitest run tests/crap/coverage/profileFactories.test.ts tests/crap/coverage/profiles.test.ts tests/crap/command.test.ts` -- `pnpm --filter @codegraphy/quality-tools typecheck` +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/packageIcons.test.ts tests/integration/pluginActivationEvents.test.ts tests/extension/pipeline/adapters.test.ts tests/extension/pipeline/analysis/delegates.test.ts` +- `pnpm --filter @codegraphy-dev/extension typecheck` +- `pnpm --filter @codegraphy-dev/quality-tools exec vitest run tests/crap/coverage/profileFactories.test.ts tests/crap/coverage/profiles.test.ts tests/crap/command.test.ts` +- `pnpm --filter @codegraphy-dev/quality-tools typecheck` - `pnpm run lint` - `pnpm run typecheck` - `pnpm run test` @@ -1398,8 +1398,8 @@ Done when: Implementation progress: -- Updated `CONTEXT.md`, `README.md`, `docs/MCP.md`, `docs/PLUGINS.md`, `docs/SETTINGS.md`, `docs/RELEASING.md`, `docs/INTERACTIONS.md`, and `docs/TIMELINE.md` to use CodeGraphy Workspace, workspace-local Graph Cache, `@codegraphy/core`, and path-first MCP/CLI language. -- Added a package-name migration note for `@codegraphy-vscode/plugin-api` -> `@codegraphy/plugin-api` and `@codegraphy-vscode/mcp` -> `@codegraphy/mcp`. +- Updated `CONTEXT.md`, `README.md`, `docs/MCP.md`, `docs/PLUGINS.md`, `docs/SETTINGS.md`, `docs/RELEASING.md`, `docs/INTERACTIONS.md`, and `docs/TIMELINE.md` to use CodeGraphy Workspace, workspace-local Graph Cache, `@codegraphy-dev/core`, and path-first MCP/CLI language. +- Added a package-name migration note for `@codegraphy-vscode/plugin-api` -> `@codegraphy-dev/plugin-api` and `@codegraphy-vscode/mcp` -> `@codegraphy-dev/mcp`. - Documented recommended `.gitignore` entries for generated Graph Cache output while leaving workspace settings commit-friendly. - Updated VS Code toolbar and index-status copy from repo-centric labels to workspace-centric labels. - Confirmed changesets cover the public package split, Markdown bootstrap, path-first MCP, extension/core packaging, first-party plugin npm packages, and Godot structured parsing. @@ -1408,11 +1408,11 @@ Validation: - `rg "core extension|Core Extension|Plugin Extension|repo-local|repo-locally|@codegraphy-vscode|extensionDependencies|pluginOrder|disabledPlugins|disabledPluginFilterPatterns|Index Repo|Re-index Repo|Reindex Repo|Indexing Repo|Index the repo|Reindex the repo|repository into" README.md CONTEXT.md docs/MCP.md docs/PLUGINS.md docs/SETTINGS.md docs/RELEASING.md docs/INTERACTIONS.md docs/TIMELINE.md packages/*/README.md -n` - `rg "Index Repo|Re-index Repo|Reindex Repo|Indexing Repo|Index the repo|Reindex the repo|repo now has a commit" packages/extension/src packages/extension/tests -n` -- `pnpm --filter @codegraphy/extension exec vitest run --config vitest.config.ts tests/webview/toolbar/actions/refresh.test.ts tests/webview/toolbar/actions/indexAction.test.tsx tests/webview/toolbar/actions/view.test.tsx tests/webview/Toolbar.test.tsx tests/webview/graphIndexStatus/view.test.tsx tests/extension/graphView/analysis/execution/progress.test.ts tests/extension/graphView/analysis/execution/publish.test.ts tests/extension/repoSettings/freshness/details.test.ts tests/extension/repoSettings/freshness/index.test.ts tests/extension/pipeline/service/discoveryFacade.test.ts` +- `pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/toolbar/actions/refresh.test.ts tests/webview/toolbar/actions/indexAction.test.tsx tests/webview/toolbar/actions/view.test.tsx tests/webview/Toolbar.test.tsx tests/webview/graphIndexStatus/view.test.tsx tests/extension/graphView/analysis/execution/progress.test.ts tests/extension/graphView/analysis/execution/publish.test.ts tests/extension/repoSettings/freshness/details.test.ts tests/extension/repoSettings/freshness/index.test.ts tests/extension/pipeline/service/discoveryFacade.test.ts` ## Validation Ideas -- Index a folder that is not a Git repo through `@codegraphy/core`. +- Index a folder that is not a Git repo through `@codegraphy-dev/core`. - Index the same CodeGraphy Workspace through VS Code and through MCP, then confirm both produce/read the same Graph Cache path. - Run `codegraphy index` from a plain folder and confirm it indexes `process.cwd()`. - Run `codegraphy index /tmp/example-folder` and confirm it indexes the explicit path. 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..09a8f9775 --- /dev/null +++ b/docs/plans/2026-05-18-extract-pro.md @@ -0,0 +1,42 @@ +# 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 feature behavior out of the free/base extension path while expanding the public plugin API so private first-party and future community 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. + +## 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 paid feature behavior as private package-owned behavior that integrates through the public plugin API. + +## Execution Slices + +1. Expand `@codegraphy-dev/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 paid feature behavior behind plugin contributions and remove free/base extension ownership without legacy feature-specific 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 private-plugin foundation. +- No Team Bookmark Sync implementation. diff --git a/docs/plans/2026-05-19-private-feature-plugin-boundary.md b/docs/plans/2026-05-19-private-feature-plugin-boundary.md new file mode 100644 index 000000000..641409363 --- /dev/null +++ b/docs/plans/2026-05-19-private-feature-plugin-boundary.md @@ -0,0 +1,25 @@ +# Private Feature Plugin Boundary + +The public monorepo keeps generalized plugin infrastructure: + +- package plugin discovery, install, enable, disable, and asset loading +- plugin-owned data host APIs +- Graph View runtime node, runtime edge, projection, force, context menu, UI, and toolbar contribution plumbing +- generic webview-to-plugin messaging + +Private feature packages own feature-specific behavior: + +- feature-specific runtime nodes, ownership models, and graph layout data +- feature-specific context menu actions +- plugin-owned persistence and mutations +- feature-specific Graph View projection, physics, exports, and webview assets + +Current removal checklist: + +- [x] Remove public toolbar feature-specific creation entries. +- [x] Remove public built-in feature-specific context menu actions. +- [x] Remove public webview feature-specific runtime/projection synthesis. +- [x] Remove public feature-specific UI and physics modules. +- [x] Remove public feature-specific settings, protocol messages, and extension dispatch. +- [x] Keep public Graph View plugin APIs generic enough for private and community plugins. +- [ ] Prove a linked private package contributes runtime nodes, actions, and physics only while enabled. diff --git a/docs/plans/2026-05-20-test-suite-cleanup.md b/docs/plans/2026-05-20-test-suite-cleanup.md index 7a5b9a37f..2e27634ab 100644 --- a/docs/plans/2026-05-20-test-suite-cleanup.md +++ b/docs/plans/2026-05-20-test-suite-cleanup.md @@ -31,10 +31,10 @@ Root scripts: Package scripts: -- Keep `test` for Vitest in every package except `@codegraphy/plugin-api`. +- Keep `test` for Vitest in every package except `@codegraphy-dev/plugin-api`. - Keep `test:playwright` only where browser tests exist. -- Keep `test:vscode` only in `@codegraphy/extension`. -- Keep mutation and architecture-analysis tools under `@codegraphy/quality-tools`. +- Keep `test:vscode` only in `@codegraphy-dev/extension`. +- Keep mutation and architecture-analysis tools under `@codegraphy-dev/quality-tools`. ## First Slice @@ -51,15 +51,15 @@ Package scripts: - The root release checks and `tests/release` suite are removed. The release workflow publishes artifacts; there is no public package-without-publish workflow. - CI runs separate lint, typecheck, unit-test, Playwright, and build lanes so independent work is not serialized behind the slowest suite. - Turbo caches package builds and test logs/results through task outputs and GitHub Actions restores `.turbo` between CI runs. -- `@codegraphy/extension` owns all three test lanes because it has Vitest, browser, and VS Code behavior. +- `@codegraphy-dev/extension` owns all three test lanes because it has Vitest, browser, and VS Code behavior. - Other packages expose only Vitest through `test` unless they grow a real browser or VS Code test surface. -- Mutation tooling stays in `@codegraphy/quality-tools` and continues to target Vitest, not Playwright or VS Code E2E. +- Mutation tooling stays in `@codegraphy-dev/quality-tools` and continues to target Vitest, not Playwright or VS Code E2E. ## Current CI Shape CI runs build, lint, typecheck, Playwright, and unit tests as independent jobs. Unit tests use a matrix with human-readable check names: -- `Unit tests / Packages` runs all package Vitest suites except `@codegraphy/extension`. +- `Unit tests / Packages` runs all package Vitest suites except `@codegraphy-dev/extension`. - `Unit tests / Extension node` runs the extension Vitest `node` project. - `Unit tests / Extension webview graph interaction and rendering` runs graph model, interaction, rendering, controls, and Graph Scope webview tests. - `Unit tests / Extension webview app shell and plugins` runs webview app shell, store, plugin host/runtime, plugin panel, theme, VS Code API bridge, and webview-extension integration tests. diff --git a/docs/plugin-api/TYPES.md b/docs/plugin-api/TYPES.md index 42215177a..e04534336 100644 --- a/docs/plugin-api/TYPES.md +++ b/docs/plugin-api/TYPES.md @@ -128,9 +128,22 @@ interface IAnalysisFile { } ``` -## Extension-Owned Surfaces +## Graph View and Host Surfaces -The public npm Plugin API does not expose `CodeGraphyAPI`, webview contracts, decorations, commands, context menus, exporters, toolbar actions, or plugin-defined graph views. Those contracts are extension-owned and remain inside `packages/extension` because they are visualization and VS Code integration surfaces. +The public npm Plugin API exposes host-agnostic Graph View contribution contracts for paid and third-party plugins: + +- Access Provider checks through `IAccessProvider` +- plugin-owned data through `loadData` / `saveData` +- package factory host services through `IPluginFactoryOptions` +- plugin host access to the current graph snapshot through `IPluginHostApi.getGraph()`, so host actions such as exporters can read the rendered Relationship Graph when they run +- runtime Graph View nodes and edges +- graph projections that run after the free Visible Graph exists +- additive D3 force adapters +- node drag-end policies for plugin-owned fixed-position behavior +- context-menu target selectors for background, node, edge, multi-selection, runtime node type, and runtime edge type +- named UI slots: `graph.toolbar`, `graph.panelSlot`, `graph.stage.worldOverlay`, and `graph.stage.viewportOverlay` + +The public API still does not expose VS Code-specific `CodeGraphyAPI`, decorations, or the raw force-graph instance. Webview-facing contracts are host-agnostic and scoped to plugin-owned assets, messages, slots, and Graph View contributions. Headless plugins should express analysis through `IPlugin` hooks and `IFileAnalysisResult`. The CLI and MCP consume the same core analysis path without installing VS Code or webview dependencies. @@ -171,6 +184,44 @@ If you see projected file-to-file edges inside the extension codebase, those are - `GraphEdgeKind` = reserved core kinds plus namespaced custom kinds (`pluginId:kind`) - External Package nodes let plugins and host views include unresolved external imports like `fs` or `react` without pretending they are workspace files. +### Access (`access.ts`) + +- `IAccessProvider` +- `IAccessRequest` +- `IAccessResult` +- `CodeGraphyAccessKey` +- `CodeGraphyAccessState` + +Use Access Provider plugins, such as `@codegraphy/pro`, to report whether paid capabilities are available. Paid plugins declare `requiresAccess` on the plugin or on individual Graph View contributions. + +### Plugin Data (`data.ts`) + +- `IPluginDataHost` +- `loadData(fallback: T): T` +- `saveData(data: T, options?: { undoLabel?: string }): Promise` + +Plugin id implies storage ownership. Hosts persist plugin data under the plugin id instead of asking plugins to register separate namespaces. + +### Graph View (`graphView.ts`) + +- `IGraphViewRuntimeNodeContribution` +- `IGraphViewRuntimeEdgeContribution` +- `IGraphViewProjectionContribution` +- `IGraphViewForceAdapterContribution` +- `IGraphViewNodeDragEndContribution` +- `IGraphViewContextMenuContribution` +- `IGraphViewUiSlotContribution` + +Graph View runtime nodes and edges are display artifacts. They do not become Graph Cache facts and are not exposed as Graph Query relationships unless a plugin also contributes analysis data through Core. + +Graph View contributions run from a live host context. `visibleGraph` is the current rendered graph, `graphMode` reports the current `2d` or `3d` view, `timelineActive` reports whether the user is inspecting a historical timeline snapshot, and `workspaceRoot` is supplied when the host can resolve the current Indexed Folder. Contributions should use these context values at execution time rather than capturing creation-time defaults. + +Runtime node contributions may supply D3 coordinate state (`x`/`y`/`z`), fixed coordinate state (`fx`/`fy`/`fz`), and velocity state (`vx`/`vy`/`vz`) when a plugin owns its node layout. Core treats those fields like normal graph node physics state, so plugins can keep a runtime node fixed, release it, or hand it back to the force simulation without inventing a separate layout channel. + +Node drag-end contributions let a plugin decide whether a dragged node should keep its fixed `fx`/`fy`/`fz` coordinates after release. Core still owns the graph node coordinate fields; feature-specific behavior such as pinned-node release semantics should live in the plugin that owns that feature. + +Context menu contributions render in the normal graph context menu by default. Contributions that set `placement: { menu: 'create' }` join the graph background create actions instead, so the same action appears beside `New File...` and `New Folder...` in the background context menu and in the toolbar `New...` popup while the plugin is enabled. + ## Theme-Style Plugins The current public API already supports a file-theme style plugin through `fileColors`: @@ -188,7 +239,11 @@ Current limitation: folder icon theming is still core-only. The API does not yet `@codegraphy-dev/plugin-api` is currently a type-definition package with `types` exports for: - `@codegraphy-dev/plugin-api` +- `@codegraphy-dev/plugin-api/access` +- `@codegraphy-dev/plugin-api/data` - `@codegraphy-dev/plugin-api/events` +- `@codegraphy-dev/plugin-api/graph-view` - `@codegraphy-dev/plugin-api/plugin` +- `@codegraphy-dev/plugin-api/webview` Use `import type` for these symbols in plugin code. diff --git a/examples/.codegraphy/settings.json b/examples/.codegraphy/settings.json index 16f5b1235..cf18df72c 100644 --- a/examples/.codegraphy/settings.json +++ b/examples/.codegraphy/settings.json @@ -6,303 +6,15 @@ ], "respectGitignore": true, "showOrphans": true, - "pluginOrder": [], - "disabledPlugins": [], + "filterPatterns": [], + "disabledCustomFilterPatterns": [], "plugins": [ { "package": "@codegraphy-dev/plugin-markdown" - } - ], - "nodeColors": { - "file": "#A1A1AA", - "folder": "#A1A1AA", - "package": "#F59E0B" - }, - "nodeColorEnabled": { - "file": true, - "folder": true, - "package": true - }, - "nodeVisibility": { - "file": true, - "folder": false, - "package": false - }, - "edgeVisibility": { - "nests": true, - "import": true, - "type-import": true, - "reexport": true, - "call": true, - "inherit": true, - "reference": true, - "test": true, - "load": true - }, - "favorites": [], - "bidirectionalEdges": "separate", - "legend": [], - "legendVisibility": {}, - "legendOrder": [], - "filterPatterns": [], - "disabledCustomFilterPatterns": [], - "disabledPluginFilterPatterns": [], - "showLabels": true, - "directionMode": "arrows", - "directionColor": "#475569", - "particleSpeed": 0.005, - "particleSize": 7.5, - "depthMode": false, - "depthLimit": 1, - "dagMode": null, - "nodeSizeMode": "connections", - "physics": { - "repelForce": 20, - "linkDistance": 210, - "linkForce": 0.22, - "damping": 0.7, - "centerForce": 0.1, - "chargeRange": 200 - }, - "timeline": { - "maxCommits": 500, - "playbackSpeed": 1 - }, - "graphLayout": { - "collapsedNodes": { - "example-go": true, - "example-markdown": true, - "example-php/src": true, - "example-typescript": true - }, - "pinnedNodes": { - "section-typescript": { - "2D": { - "x": 243.9328448088707, - "y": -441.50789311330345 - } - }, - "section-godot": { - "2D": { - "x": -430, - "y": 220 - } - } - }, - "sections": { - "section-systems": { - "color": "#f97316", - "icon": "mdi:package-variant", - "label": "Systems", - "updatedAt": "2026-05-13T10:08:00.000Z", - "height": 260, - "width": 390, - "x": -620, - "y": -270, - "collapsed": false - }, - "section-typescript": { - "color": "#3b82f6", - "icon": "mdi:code-braces", - "label": "TypeScript", - "updatedAt": "2026-05-13T17:12:32.320Z", - "height": 260, - "width": 360, - "x": 63.9328448088707, - "y": -571.5078931133035, - "collapsed": false - }, - "section-jvm": { - "color": "#a855f7", - "icon": "mdi:puzzle-outline", - "label": "JVM + .NET", - "updatedAt": "2026-05-13T10:08:00.000Z", - "height": 250, - "width": 360, - "x": 210, - "y": -260, - "collapsed": false - }, - "section-scripting": { - "color": "#06b6d4", - "icon": "mdi:code-braces", - "label": "Scripting", - "updatedAt": "2026-05-13T10:08:00.000Z", - "height": 300, - "width": 420, - "x": 160, - "y": 80, - "collapsed": false - }, - "section-godot": { - "color": "#22c55e", - "icon": "mdi:shape-outline", - "label": "Godot", - "updatedAt": "2026-05-13T17:12:14.116Z", - "height": 752.0671745152354, - "width": 548.7950138504154, - "x": -640, - "y": 40, - "collapsed": false - }, - "section-godot-ui": { - "color": "#84cc16", - "icon": "mdi:view-grid-outline", - "label": "UI Scenes", - "updatedAt": "2026-05-13T10:08:00.000Z", - "height": 130, - "width": 160, - "x": 230, - "y": 190, - "collapsed": false - }, - "section-docs": { - "color": "#94a3b8", - "icon": "mdi:folder", - "label": "Docs", - "updatedAt": "2026-05-13T10:08:00.000Z", - "height": 160, - "width": 220, - "x": -40, - "y": 430, - "collapsed": true - } }, - "ownership": { - "section-systems": [ - "example-c/Makefile", - "example-c/src/main.c", - "example-c/src/math/add.c", - "example-c/src/math/add.h", - "example-cpp/CMakeLists.txt", - "example-cpp/src/app.cpp", - "example-cpp/src/lib/widget.cpp", - "example-cpp/src/lib/widget.hpp", - "example-rust/Cargo.toml", - "example-rust/src/inner.rs", - "example-rust/src/main.rs", - "example-rust/src/util.rs", - "example-go/go.mod", - "example-go/main.go", - "example-go/internal/service/service.go", - "example-swift/Package.swift", - "example-swift/Sources/RunnerSupport/Worker.swift", - "example-swift/Sources/SwiftExample/main.swift", - "example-haskell/example-haskell.cabal", - "example-haskell/src/App/Feature/Runner.hs", - "example-haskell/src/App/Model/User.hs", - "example-haskell/src/Main.hs", - "example-dart/bin/sample_app.dart", - "example-dart/lib/app/runner.dart", - "example-dart/lib/model/profile.dart", - "example-dart/lib/model/user.dart", - "example-dart/pubspec.yaml" - ], - "section-typescript": [ - "example-typescript/package.json", - "example-typescript/packages/app/package.json", - "example-typescript/packages/app/src/index.ts", - "example-typescript/packages/app/src/orphan.ts", - "example-typescript/packages/app/src/utils.ts", - "example-typescript/packages/feature-depth/package.json", - "example-typescript/packages/feature-depth/src/deep.ts", - "example-typescript/packages/feature-depth/src/leaf.ts", - "example-typescript/packages/shared/package.json", - "example-typescript/packages/shared/src/types.ts", - "example-typescript/tsconfig.json" - ], - "section-jvm": [ - "example-java/src/com/example/app/App.java", - "example-java/src/com/example/app/BaseService.java", - "example-java/src/com/example/app/Helper.java", - "example-kotlin/build.gradle.kts", - "example-kotlin/settings.gradle.kts", - "example-kotlin/src/main/kotlin/com/example/app/AppRunner.kt", - "example-kotlin/src/main/kotlin/com/example/app/Main.kt", - "example-kotlin/src/main/kotlin/com/example/base/BaseRunner.kt", - "example-kotlin/src/main/kotlin/com/example/base/RunnableThing.kt", - "example-kotlin/src/main/kotlin/com/example/model/User.kt", - "example-csharp/src/Config.cs", - "example-csharp/src/Orphan.cs", - "example-csharp/src/Program.cs", - "example-csharp/src/Services/ApiService.cs", - "example-csharp/src/Utils/Formatter.cs", - "example-csharp/src/Utils/Helpers.cs" - ], - "section-scripting": [ - "example-python/pyproject.toml", - "example-python/src/config.py", - "example-python/src/main.py", - "example-python/src/member_imports.py", - "example-python/src/namespace_consumer.py", - "example-python/src/ns_pkg/member.py", - "example-python/src/orphan.py", - "example-python/src/services/__init__.py", - "example-python/src/services/api.py", - "example-python/src/utils/__init__.py", - "example-python/src/utils/format.py", - "example-python/src/utils/helpers.py", - "example-ruby/Gemfile", - "example-ruby/example_ruby.gemspec", - "example-ruby/lib/app/runner.rb", - "example-ruby/lib/base/base_runner.rb", - "example-ruby/lib/example_ruby.rb", - "example-ruby/lib/model/user.rb", - "example-lua/app/model/user.lua", - "example-lua/app/runner.lua", - "example-lua/main.lua", - "example-php/composer.json", - "example-php/src/App/Base/BaseRunner.php", - "example-php/src/App/Contracts/Runnable.php", - "example-php/src/App/Feature/Runner.php", - "example-php/src/App/Model/User.php" - ], - "section-godot": [ - "section-godot-ui", - "example-godot/project.godot", - "example-godot/resources/player_loadout.tres", - "example-godot/scenes/enemy.tscn", - "example-godot/scenes/main.tscn", - "example-godot/scenes/player.tscn", - "example-godot/scripts/base/entity.gd", - "example-godot/scripts/data/player_loadout.gd", - "example-godot/scripts/enemy.gd", - "example-godot/scripts/game_manager.gd", - "example-godot/scripts/orphan.gd", - "example-godot/scripts/player.gd", - "example-godot/scripts/utils/math_helpers.gd", - "example-godot/textures/player_card.png" - ], - "section-godot-ui": [ - "example-godot/scenes/ui/game_ui.tscn", - "example-godot/scenes/ui/loadout_preview.tscn", - "example-godot/scripts/ui/loadout_preview.gd" - ], - "section-docs": [ - "README.md", - "example-c/README.md", - "example-cpp/README.md", - "example-csharp/README.md", - "example-dart/README.md", - "example-go/README.md", - "example-godot/README.md", - "example-haskell/README.md", - "example-java/README.md", - "example-kotlin/README.md", - "example-lua/README.md", - "example-markdown/README.md", - "example-markdown/notes/Architecture.md", - "example-markdown/notes/Home.md", - "example-markdown/notes/assets/Diagram.md", - "example-markdown/notes/guides/Setup.md", - "example-markdown/src/commented.ts", - "example-php/README.md", - "example-python/README.md", - "example-ruby/README.md", - "example-rust/README.md", - "example-swift/README.md", - "example-typescript/README.md" - ] + { + "package": "@codegraphy/organize" } - } + ], + "pluginData": {} } diff --git a/packages/core/README.md b/packages/core/README.md index 58644f3c4..66687ab20 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -35,7 +35,8 @@ Plugin installation and workspace enablement are separate: - `plugins refresh` scans global npm roots for `@codegraphy-dev/*` packages with CodeGraphy plugin metadata. - `plugins add ` records an explicitly named globally installed package, including non-`@codegraphy` packages. - Enabling or disabling a plugin changes workspace settings only; plugin runtime loading still waits for explicit Indexing. -- Indexing imports enabled npm plugin packages through their normal package `exports`, merges manifest `defaultOptions` with workspace-local `options`, and delivers the result to plugin lifecycle and analysis hooks as `context.options`. +- Indexing imports enabled npm plugin packages through their normal package `exports`, merges manifest `defaultOptions` with workspace-local `options`, delivers the result to package factories as `factoryOptions.options`, and delivers the same result to plugin lifecycle and analysis hooks as `context.options`. +- Package factories loaded for a concrete CodeGraphy Workspace also receive `factoryOptions.dataHost`, a plugin-owned persistence host bound to the plugin id returned by the factory. Plugin npm packages identify themselves with package metadata: diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aee0d2194..8c3bef528 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -172,6 +172,17 @@ export { CorePluginRegistry, } from './plugins/registry'; export type { CorePluginInfo } from './plugins/registry'; +export type { + CoreGraphViewContributionEntry, + CoreGraphViewContributionSet, + CorePluginAccessCheck, + CorePluginAccessContext, +} from './plugins/access/checks'; +export { + createEmptyGraphViewContributionSet, + resolvePluginAccess, +} from './plugins/access/checks'; +export { createWorkspacePluginDataHost } from './plugins/data/host'; export type { LoadedCodeGraphyWorkspacePluginPackage, LoadCodeGraphyWorkspacePluginPackagesOptions, @@ -187,6 +198,7 @@ export type { CodeGraphyInstalledPluginCache, CodeGraphyInstalledPluginRecord, CodeGraphyUserStateOptions, + LinkCodeGraphyInstalledPluginPackageOptions, RefreshCodeGraphyInstalledPluginsOptions, } from './plugins/installedCache'; export { @@ -197,6 +209,7 @@ export { getCodeGraphyUserDirectoryPath, getCodeGraphyUserSettingsPath, getInstalledPluginsCachePath, + linkCodeGraphyInstalledPluginPackage, readCodeGraphyInstalledPluginCache, refreshCodeGraphyInstalledPlugins, writeCodeGraphyInstalledPluginCache, diff --git a/packages/core/src/indexing/workspace.ts b/packages/core/src/indexing/workspace.ts index a13e2c1f1..bdd1e59cc 100644 --- a/packages/core/src/indexing/workspace.ts +++ b/packages/core/src/indexing/workspace.ts @@ -95,6 +95,7 @@ function getDefaultMarkdownPluginOptions( async function createRegistry( options: IndexCodeGraphyWorkspaceOptions, settings: CodeGraphyWorkspaceSettings, + workspaceRoot: string, ): Promise<{ registry: CorePluginRegistry; loadedPackagePlugins: LoadedCodeGraphyWorkspacePluginPackage[]; @@ -102,6 +103,7 @@ async function createRegistry( const registry = new CorePluginRegistry(); const loadedPackagePlugins = await loadCodeGraphyWorkspacePluginPackages({ settings, + workspaceRoot, ...(options.userHomeDir ? { homeDir: options.userHomeDir } : {}), ...(options.warn ? { warn: options.warn } : {}), }); @@ -161,7 +163,7 @@ export async function indexCodeGraphyWorkspace( const discovery = new FileDiscovery(); const cache = createEmptyWorkspaceAnalysisCache(); const settings = createEffectiveIndexSettings(workspaceRoot, options); - const { registry, loadedPackagePlugins } = await createRegistry(options, settings); + const { registry, loadedPackagePlugins } = await createRegistry(options, settings, workspaceRoot); const disabledPlugins = new Set(options.disabledPlugins ?? []); const disabledPluginPatterns = getDisabledPluginFilterPatterns(settings); const logInfo = options.logInfo ?? (() => undefined); diff --git a/packages/core/src/plugins/access/checks.ts b/packages/core/src/plugins/access/checks.ts new file mode 100644 index 000000000..386f15b07 --- /dev/null +++ b/packages/core/src/plugins/access/checks.ts @@ -0,0 +1,128 @@ +import type { + CodeGraphyAccessKey, + GraphViewAccessRequirement, + IAccessProvider, + IAccessResult, + IGraphViewContextMenuContribution, + IGraphViewForceAdapterContribution, + IGraphViewNodeDragEndContribution, + IGraphViewProjectionContribution, + IGraphViewRuntimeEdgeContribution, + IGraphViewRuntimeNodeContribution, + IGraphViewUiSlotContribution, + IPlugin, +} from '@codegraphy-dev/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[]; + nodeDragEnd: CoreGraphViewContributionEntry[]; + contextMenu: CoreGraphViewContributionEntry[]; + ui: CoreGraphViewContributionEntry[]; +} + +export function createEmptyGraphViewContributionSet(): CoreGraphViewContributionSet { + return { + runtimeNodes: [], + runtimeEdges: [], + projections: [], + forces: [], + nodeDragEnd: [], + 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/data/host.ts b/packages/core/src/plugins/data/host.ts new file mode 100644 index 000000000..38a0f5851 --- /dev/null +++ b/packages/core/src/plugins/data/host.ts @@ -0,0 +1,28 @@ +import type { IPluginDataHost } from '@codegraphy-dev/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/plugins/installedCache.ts b/packages/core/src/plugins/installedCache.ts index 779b870c0..72f123204 100644 --- a/packages/core/src/plugins/installedCache.ts +++ b/packages/core/src/plugins/installedCache.ts @@ -36,6 +36,10 @@ export interface AddCodeGraphyInstalledPluginOptions extends CodeGraphyUserState globalPackageRoots: string[]; } +export interface LinkCodeGraphyInstalledPluginPackageOptions extends CodeGraphyUserStateOptions { + packageRoot: string; +} + export function getCodeGraphyUserDirectoryPath(homeDir: string = os.homedir()): string { return path.join(homeDir, '.codegraphy'); } @@ -283,6 +287,21 @@ export async function addCodeGraphyInstalledPlugin( ); } +export async function linkCodeGraphyInstalledPluginPackage( + options: LinkCodeGraphyInstalledPluginPackageOptions, +): Promise { + const record = await readPackageManifest(options.packageRoot); + if (!record) { + throw new Error(`Package at '${options.packageRoot}' is not a CodeGraphy plugin.`); + } + + writeCodeGraphyInstalledPluginCache( + upsertInstalledPluginRecord(readCodeGraphyInstalledPluginCache({ homeDir: options.homeDir }), record), + { homeDir: options.homeDir }, + ); + return record; +} + export function enableCodeGraphyWorkspacePlugin( workspaceRoot: string, plugin: CodeGraphyInstalledPluginRecord, diff --git a/packages/core/src/plugins/packageRuntime.ts b/packages/core/src/plugins/packageRuntime.ts index 16962b510..85201e365 100644 --- a/packages/core/src/plugins/packageRuntime.ts +++ b/packages/core/src/plugins/packageRuntime.ts @@ -1,11 +1,17 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { IPlugin } from '@codegraphy-dev/plugin-api'; +import type { + IPlugin, + IPluginDataHost, + IPluginDataSaveOptions, + IPluginFactoryOptions, +} from '@codegraphy-dev/plugin-api'; import { type CodeGraphyInstalledPluginRecord, readCodeGraphyInstalledPluginCache, } from './installedCache'; +import { createWorkspacePluginDataHost } from './data/host'; import { CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, type CodeGraphyWorkspacePluginSettings, @@ -17,7 +23,12 @@ interface PackageJsonWithEntrypoint { main?: unknown; } -type UnknownPluginFactory = () => unknown; +interface PackagePluginFactoryInvocation { + options?: IPluginFactoryOptions; + bindPluginId?(pluginId: string): void; +} + +type UnknownPluginFactory = (options?: IPluginFactoryOptions) => unknown; export interface LoadedCodeGraphyWorkspacePluginPackage { plugin: IPlugin; @@ -30,6 +41,7 @@ export interface LoadCodeGraphyWorkspacePluginPackagesOptions { settings: CodeGraphyWorkspaceSettings; homeDir?: string; warn?: (message: string) => void; + workspaceRoot?: string; } function isRecord(value: unknown): value is Record { @@ -52,6 +64,62 @@ function mergePluginOptions( return Object.keys(merged).length > 0 ? merged : undefined; } +function createDeferredWorkspacePluginDataHost( + workspaceRoot: string, +): { + dataHost: IPluginDataHost; + bindPluginId(pluginId: string): void; +} { + let boundDataHost: IPluginDataHost | undefined; + const getBoundDataHost = (): IPluginDataHost => { + if (!boundDataHost) { + throw new Error('CodeGraphy plugin data host is not bound to a plugin yet.'); + } + + return boundDataHost; + }; + + return { + dataHost: { + loadData(fallback: T): T { + return getBoundDataHost().loadData(fallback); + }, + async saveData(data: T, options?: IPluginDataSaveOptions): Promise { + await getBoundDataHost().saveData(data, options); + }, + }, + bindPluginId(pluginId: string): void { + boundDataHost = createWorkspacePluginDataHost(workspaceRoot, pluginId); + }, + }; +} + +function createPackagePluginFactoryInvocation( + record: CodeGraphyInstalledPluginRecord, + settings: CodeGraphyWorkspacePluginSettings, + workspaceRoot: string | undefined, +): { + invocation: PackagePluginFactoryInvocation; + options?: Record; +} { + const options = mergePluginOptions(record, settings); + const dataHost = workspaceRoot + ? createDeferredWorkspacePluginDataHost(workspaceRoot) + : undefined; + const factoryOptions: IPluginFactoryOptions = { + ...(options ? { options } : {}), + ...(dataHost ? { dataHost: dataHost.dataHost } : {}), + }; + + return { + invocation: { + ...(Object.keys(factoryOptions).length > 0 ? { options: factoryOptions } : {}), + ...(dataHost ? { bindPluginId: (pluginId: string) => dataHost.bindPluginId(pluginId) } : {}), + }, + ...(options ? { options } : {}), + }; +} + function getEntrypointFromExports(exportsValue: unknown): string | undefined { if (typeof exportsValue === 'string') { return exportsValue; @@ -94,34 +162,41 @@ function resolvePackageEntrypoint( return path.resolve(packageRoot, entrypoint); } -async function createPluginFromModule(moduleNamespace: unknown, packageName: string): Promise { +async function createPluginFromModule( + moduleNamespace: unknown, + packageName: string, + invocation: PackagePluginFactoryInvocation = {}, +): Promise { if (!isRecord(moduleNamespace)) { throw new Error(`CodeGraphy plugin package '${packageName}' did not export a module object.`); } const exportedPlugin: unknown = moduleNamespace.default ?? moduleNamespace.createPlugin ?? moduleNamespace.plugin; const plugin: unknown = isPluginFactory(exportedPlugin) - ? await exportedPlugin() + ? await exportedPlugin(invocation.options) : exportedPlugin; if (!isRecord(plugin) || typeof plugin.id !== 'string') { throw new Error(`CodeGraphy plugin package '${packageName}' did not export a plugin factory or plugin object.`); } + invocation.bindPluginId?.(plugin.id); + return plugin as unknown as IPlugin; } async function loadCodeGraphyWorkspacePluginPackage( settings: CodeGraphyWorkspacePluginSettings, record: CodeGraphyInstalledPluginRecord, + workspaceRoot: string | undefined, ): Promise { const packageJson = JSON.parse( await fs.readFile(path.join(record.packageRoot, 'package.json'), 'utf-8'), ) as PackageJsonWithEntrypoint; const modulePath = resolvePackageEntrypoint(record.packageRoot, packageJson); const moduleNamespace: unknown = await import(pathToFileURL(modulePath).href); - const plugin = await createPluginFromModule(moduleNamespace, record.package); - const options = mergePluginOptions(record, settings); + const { invocation, options } = createPackagePluginFactoryInvocation(record, settings, workspaceRoot); + const plugin = await createPluginFromModule(moduleNamespace, record.package, invocation); return { plugin, @@ -154,7 +229,7 @@ export async function loadCodeGraphyWorkspacePluginPackages( } try { - loaded.push(await loadCodeGraphyWorkspacePluginPackage(pluginSettings, record)); + loaded.push(await loadCodeGraphyWorkspacePluginPackage(pluginSettings, record, options.workspaceRoot)); } catch (error) { const message = error instanceof Error ? error.message : String(error); warn(`CodeGraphy plugin package '${pluginSettings.package}' could not be loaded: ${message}`); diff --git a/packages/core/src/plugins/registry.ts b/packages/core/src/plugins/registry.ts index b2cb582fb..10182652a 100644 --- a/packages/core/src/plugins/registry.ts +++ b/packages/core/src/plugins/registry.ts @@ -1,6 +1,14 @@ import type { IFileAnalysisResult, IGraphData, + IGraphViewContextMenuContribution, + IGraphViewForceAdapterContribution, + IGraphViewNodeDragEndContribution, + IGraphViewProjectionContribution, + IGraphViewRuntimeEdgeContribution, + IGraphViewRuntimeNodeContribution, + IGraphViewUiSlotContribution, + IAccessProvider, IPlugin, IPluginAnalysisContext, IPluginEdgeType, @@ -10,6 +18,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 +247,104 @@ 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?.nodeDragEnd, + contributions.nodeDragEnd, + 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/src/workspace/settings.ts b/packages/core/src/workspace/settings.ts index e95ee3eb9..286d84dc0 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/access/checks.test.ts b/packages/core/tests/plugins/access/checks.test.ts new file mode 100644 index 000000000..918778390 --- /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-dev/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 paidFeatureAccess = 'premium-layout' as CodeGraphyAccessKey; + const registry = new CorePluginRegistry(); + + registry.register(createPlugin({ + id: 'codegraphy.pro', + accessProvider: { + id: 'codegraphy.pro.access', + provides: [paidFeatureAccess], + async getAccess() { + return { + access: paidFeatureAccess, + 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: 'acme.premium-layout', + requiresAccess: paidFeatureAccess, + graphView: { + forces: [{ + id: 'acme.premium-layout.force', + label: 'Premium Layout Force', + create() { + return { dispose() {} }; + }, + }], + }, + })); + + await expect(registry.getPluginAvailability('codegraphy.pro')).resolves.toMatchObject({ + pluginId: 'codegraphy.pro', + available: true, + access: [], + }); + await expect(registry.getPluginAvailability('acme.premium-layout')).resolves.toMatchObject({ + pluginId: 'acme.premium-layout', + available: false, + access: [{ + access: paidFeatureAccess, + 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 paidFeatureAccess = 'premium-layout' as CodeGraphyAccessKey; + const registry = new CorePluginRegistry(); + + registry.register(createPlugin({ + id: 'codegraphy.pro', + accessProvider: { + id: 'codegraphy.pro.access', + provides: [paidFeatureAccess], + async getAccess() { + return { + access: paidFeatureAccess, + state: 'granted', + }; + }, + }, + })); + + registry.register(createPlugin({ + id: 'acme.premium-layout', + requiresAccess: paidFeatureAccess, + graphView: { + forces: [{ + id: 'acme.premium-layout.force', + label: 'Premium Layout Force', + create() { + return { dispose() {} }; + }, + }], + }, + })); + + await expect(registry.getPluginAvailability('acme.premium-layout')).resolves.toMatchObject({ + pluginId: 'acme.premium-layout', + available: true, + access: [{ + access: paidFeatureAccess, + state: 'granted', + }], + }); + await expect(registry.listAvailableGraphViewContributions()).resolves.toMatchObject({ + forces: [{ + pluginId: 'acme.premium-layout', + contribution: { id: 'acme.premium-layout.force' }, + }], + }); + }); +}); 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..a6ff0e9be --- /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, 'acme.workspace-notes'); + + expect(host.loadData({ notes: [] })).toEqual({ notes: [] }); + 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, 'acme.workspace-notes'); + await host.saveData({ notes: ['frontend'] }, { undoLabel: 'Save plugin data' }); + + expect(createWorkspacePluginDataHost(workspaceRoot, 'acme.workspace-notes').loadData({ + notes: [], + })).toEqual({ + notes: ['frontend'], + }); + expect(JSON.parse( + await fs.readFile(getWorkspaceSettingsPath(workspaceRoot), 'utf-8'), + )).toMatchObject({ + plugins: [{ + package: CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, + }], + pluginData: { + 'acme.workspace-notes': { + notes: ['frontend'], + }, + }, + }); + }); +}); diff --git a/packages/core/tests/plugins/installedCache.test.ts b/packages/core/tests/plugins/installedCache.test.ts index c86e48b8f..260b5608f 100644 --- a/packages/core/tests/plugins/installedCache.test.ts +++ b/packages/core/tests/plugins/installedCache.test.ts @@ -8,6 +8,7 @@ import { addCodeGraphyInstalledPlugin, enableCodeGraphyWorkspacePlugin, getInstalledPluginsCachePath, + linkCodeGraphyInstalledPluginPackage, readCodeGraphyInstalledPluginCache, refreshCodeGraphyInstalledPlugins, } from '../../src'; @@ -130,6 +131,38 @@ describe('CodeGraphy installed plugin cache', () => { expect(readCodeGraphyInstalledPluginCache({ homeDir }).plugins).toEqual([record]); }); + it('links a private local plugin package root into the user-level cache', async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); + const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-private-package-')); + await fs.writeFile( + path.join(packageRoot, 'package.json'), + `${JSON.stringify({ + name: '@acme/codegraphy-private-plugin', + version: '0.1.0', + codegraphy: { + type: 'plugin', + apiVersion: '^2.0.0', + disclosures: ['workspaceWrites'], + }, + }, null, 2)}\n`, + 'utf-8', + ); + + const record = await linkCodeGraphyInstalledPluginPackage({ + homeDir, + packageRoot, + }); + + expect(record).toEqual({ + package: '@acme/codegraphy-private-plugin', + version: '0.1.0', + apiVersion: '^2.0.0', + disclosures: ['workspaceWrites'], + packageRoot, + }); + expect(readCodeGraphyInstalledPluginCache({ homeDir }).plugins).toEqual([record]); + }); + it('enables a cached plugin for one workspace without installing or importing it', async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-')); diff --git a/packages/core/tests/plugins/packageRuntime.test.ts b/packages/core/tests/plugins/packageRuntime.test.ts new file mode 100644 index 000000000..e66daa793 --- /dev/null +++ b/packages/core/tests/plugins/packageRuntime.test.ts @@ -0,0 +1,111 @@ +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 { + loadCodeGraphyWorkspacePluginPackages, + readCodeGraphyWorkspaceSettings, + writeCodeGraphyInstalledPluginCache, + writeCodeGraphyWorkspaceSettings, +} from '../../src'; + +async function createWorkspace(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-runtime-workspace-')); +} + +async function createPluginPackage(packageRoot: string): Promise { + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile( + path.join(packageRoot, 'package.json'), + `${JSON.stringify({ + name: '@acme/codegraphy-plugin-data-host', + version: '1.0.0', + type: 'module', + exports: './plugin.js', + codegraphy: { + type: 'plugin', + apiVersion: '^2.0.0', + defaultOptions: { + marker: 'from-default-options', + }, + }, + }, null, 2)}\n`, + 'utf-8', + ); + await fs.writeFile( + path.join(packageRoot, 'plugin.js'), + ` +export default function createPlugin(factoryOptions = {}) { + const dataHost = factoryOptions.dataHost; + const marker = factoryOptions.options?.marker ?? 'missing-options'; + + return { + id: 'acme.data-host', + name: 'Data Host Plugin', + version: '1.0.0', + apiVersion: '^2.0.0', + supportedExtensions: [], + async initialize() { + if (!dataHost) { + throw new Error('Expected factory dataHost.'); + } + await dataHost.saveData({ marker }); + } + }; +} +`, + 'utf-8', + ); +} + +describe('CodeGraphy package runtime', () => { + it('passes workspace plugin data host and options to package plugin factories', async () => { + const workspaceRoot = await createWorkspace(); + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-runtime-home-')); + const packageRoot = path.join( + await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-runtime-package-')), + 'node_modules', + '@acme', + 'codegraphy-plugin-data-host', + ); + + await createPluginPackage(packageRoot); + writeCodeGraphyInstalledPluginCache({ + version: 1, + plugins: [{ + package: '@acme/codegraphy-plugin-data-host', + version: '1.0.0', + apiVersion: '^2.0.0', + disclosures: ['workspaceWrites'], + packageRoot, + defaultOptions: { + marker: 'from-default-options', + }, + }], + }, { homeDir }); + writeCodeGraphyWorkspaceSettings(workspaceRoot, { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + plugins: [{ + package: '@acme/codegraphy-plugin-data-host', + options: { + marker: 'from-workspace-options', + }, + }], + }); + + const [loadedPlugin] = await loadCodeGraphyWorkspacePluginPackages({ + settings: readCodeGraphyWorkspaceSettings(workspaceRoot), + homeDir, + workspaceRoot, + }); + + await loadedPlugin?.plugin.initialize?.(workspaceRoot); + + expect(readCodeGraphyWorkspaceSettings(workspaceRoot).pluginData).toEqual({ + 'acme.data-host': { + marker: 'from-workspace-options', + }, + }); + }); +}); diff --git a/packages/extension/src/core/plugins/registry/manager.ts b/packages/extension/src/core/plugins/registry/manager.ts index 97ca9812a..456bb6f8c 100644 --- a/packages/extension/src/core/plugins/registry/manager.ts +++ b/packages/extension/src/core/plugins/registry/manager.ts @@ -24,6 +24,7 @@ export class PluginRegistry extends PluginRegistryLifecycle { builtIn?: boolean; sourceExtension?: string; sourcePackage?: string; + sourcePackageRoot?: string; options?: Record; deferReadinessReplay?: boolean; } = {}, diff --git a/packages/extension/src/core/plugins/registry/runtime/registration/register.ts b/packages/extension/src/core/plugins/registry/runtime/registration/register.ts index b08b982de..90d7cd59d 100644 --- a/packages/extension/src/core/plugins/registry/runtime/registration/register.ts +++ b/packages/extension/src/core/plugins/registry/runtime/registration/register.ts @@ -36,6 +36,7 @@ export function validateAndCreatePluginInfo( builtIn?: boolean; sourceExtension?: string; sourcePackage?: string; + sourcePackageRoot?: string; options?: Record; }, config: RegistryV2Config, @@ -55,6 +56,7 @@ export function validateAndCreatePluginInfo( builtIn: options.builtIn ?? false, ...(options.sourceExtension ? { sourceExtension: options.sourceExtension } : {}), ...(options.sourcePackage ? { sourcePackage: options.sourcePackage } : {}), + ...(options.sourcePackageRoot ? { sourcePackageRoot: options.sourcePackageRoot } : {}), ...(options.options ? { options: { ...options.options } } : {}), }; diff --git a/packages/extension/src/core/plugins/registry/runtime/state/collection.ts b/packages/extension/src/core/plugins/registry/runtime/state/collection.ts index d40fd3916..762a6ffcb 100644 --- a/packages/extension/src/core/plugins/registry/runtime/state/collection.ts +++ b/packages/extension/src/core/plugins/registry/runtime/state/collection.ts @@ -1,5 +1,13 @@ import type { + IAccessProvider, IFileAnalysisResult, + IGraphViewContextMenuContribution, + IGraphViewForceAdapterContribution, + IGraphViewNodeDragEndContribution, + IGraphViewProjectionContribution, + IGraphViewRuntimeEdgeContribution, + IGraphViewRuntimeNodeContribution, + IGraphViewUiSlotContribution, IPlugin, IPluginAnalysisContext, IPluginEdgeType, @@ -7,6 +15,14 @@ import type { IPluginNodeType, IProjectedConnection, } from '../../../types/contracts'; +import { + createEmptyGraphViewContributionSet, + resolvePluginAccess, + type CoreGraphViewContributionEntry, + type CoreGraphViewContributionSet, + type CorePluginAccessCheck, + type CorePluginAccessContext, +} from '@codegraphy-dev/core'; import { analyzeFile, analyzeFileResult, @@ -25,6 +41,104 @@ export abstract class PluginRegistryCollection extends PluginRegistryState { return this._plugins.get(pluginId); } + 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?.nodeDragEnd, + contributions.nodeDragEnd, + 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; + } + getPluginForFile(filePath: string): IPlugin | undefined { return getPluginForFile(filePath, this._plugins, this._extensionMap); } diff --git a/packages/extension/src/core/plugins/types/contracts.ts b/packages/extension/src/core/plugins/types/contracts.ts index 905867718..747f9aa3c 100644 --- a/packages/extension/src/core/plugins/types/contracts.ts +++ b/packages/extension/src/core/plugins/types/contracts.ts @@ -11,6 +11,7 @@ export type { GraphMetadataValue, GraphNodeShape2D, GraphNodeShape3D, + IAccessProvider, IAnalysisFile, IAnalysisNode, IAnalysisRange, @@ -22,6 +23,15 @@ export type { IGraphEdge, IGraphEdgeSource, IGraphNode, + IGraphViewContributions, + IGraphViewContextMenuContribution, + IGraphViewForceAdapterContribution, + IGraphViewNodeDragEndContribution, + IGraphViewNodeDragState, + IGraphViewProjectionContribution, + IGraphViewRuntimeEdgeContribution, + IGraphViewRuntimeNodeContribution, + IGraphViewUiSlotContribution, IPluginAnalysisContext, IPluginAnalysisFileSystem, IPluginEdgeType, @@ -66,6 +76,8 @@ export interface IPluginInfo { sourceExtension?: string; /** Source npm package for package-installed plugins */ sourcePackage?: string; + /** Root directory for package-installed plugin assets */ + sourcePackageRoot?: string; /** Workspace-specific plugin options */ options?: Record; } diff --git a/packages/extension/src/e2e/fixtures/package-graph-view-plugin/package.json b/packages/extension/src/e2e/fixtures/package-graph-view-plugin/package.json new file mode 100644 index 000000000..10ef3994e --- /dev/null +++ b/packages/extension/src/e2e/fixtures/package-graph-view-plugin/package.json @@ -0,0 +1,11 @@ +{ + "name": "@codegraphy/e2e-graph-view-plugin", + "version": "1.0.0", + "type": "module", + "exports": "./plugin.js", + "codegraphy": { + "type": "plugin", + "apiVersion": "^2.0.0", + "disclosures": [] + } +} diff --git a/packages/extension/src/e2e/fixtures/package-graph-view-plugin/plugin.js b/packages/extension/src/e2e/fixtures/package-graph-view-plugin/plugin.js new file mode 100644 index 000000000..e0c5d93ed --- /dev/null +++ b/packages/extension/src/e2e/fixtures/package-graph-view-plugin/plugin.js @@ -0,0 +1,13 @@ +export default function createPlugin() { + return { + id: 'e2e.graph-view-plugin', + name: 'E2E Graph View Plugin', + version: '1.0.0', + apiVersion: '^2.0.0', + supportedExtensions: [], + webviewApiVersion: '^1.0.0', + webviewContributions: { + scripts: ['webview.js'], + }, + }; +} diff --git a/packages/extension/src/e2e/fixtures/package-graph-view-plugin/webview.js b/packages/extension/src/e2e/fixtures/package-graph-view-plugin/webview.js new file mode 100644 index 000000000..fea3f909e --- /dev/null +++ b/packages/extension/src/e2e/fixtures/package-graph-view-plugin/webview.js @@ -0,0 +1,41 @@ +export function activate(api) { + api.registerGraphViewContributions({ + contextMenu: [ + { + id: 'e2e.graph-view-plugin.create-item', + label: 'New Plugin Item...', + placement: { menu: 'create' }, + targets: [{ kind: 'background' }], + run(context) { + api.sendMessage({ + type: 'createItem', + data: { + position: context.graphPosition ?? { x: 0, y: 0 }, + selectedNodeIds: context.selectedNodeIds, + }, + }); + }, + }, + { + id: 'e2e.graph-view-plugin.node-action', + label: 'Plugin Node Action', + targets: [{ kind: 'node' }], + isVisible(context) { + return context.selectedNodeIds.length === 1; + }, + run(context) { + api.sendMessage({ + type: 'nodeAction', + data: { + nodeId: context.selectedNodeIds[0], + position: context.selectedNodePositions?.[context.selectedNodeIds[0]] + ?? context.graphPosition + ?? { x: 0, y: 0 }, + }, + }); + }, + }, + ], + }); + api.sendMessage({ type: 'activated', data: { ok: true } }); +} diff --git a/packages/extension/src/e2e/runTest.ts b/packages/extension/src/e2e/runTest.ts index 25c382b42..8f36e05d2 100644 --- a/packages/extension/src/e2e/runTest.ts +++ b/packages/extension/src/e2e/runTest.ts @@ -10,8 +10,34 @@ import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; -import { runTests } from '@vscode/test-electron'; -import { e2eScenarios } from './scenarios'; +import { createRequire } from 'module'; +import { e2eScenarios, type E2EScenario } from './scenarios'; +import type { runTests as runVSCodeTests } from '@vscode/test-electron'; + +interface CodeGraphyInstalledPluginRecord { + package: string; + version: string; + apiVersion: string; + packageRoot: string; + defaultOptions?: Record; + disclosures: string[]; +} + +interface CodeGraphyWorkspacePluginSettings { + package: string; + options?: Record; +} + +interface E2EWorkspaceSettings { + version: 1; + maxFiles: number; + include: string[]; + respectGitignore: boolean; + showOrphans: boolean; + filterPatterns: string[]; + disabledCustomFilterPatterns: string[]; + plugins: CodeGraphyWorkspacePluginSettings[]; +} const CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME = '@codegraphy-dev/plugin-markdown'; const DEFAULT_MAX_FILES = 1000; @@ -43,54 +69,54 @@ function cleanupScenarioArtifacts( } } -interface CodeGraphyPluginPackageJson { - name?: unknown; - version?: unknown; - codegraphy?: { - apiVersion?: unknown; - disclosures?: unknown; - }; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); } -type CodeGraphyPluginDisclosure = - | 'network' - | 'secrets' - | 'externalProcesses' - | 'workspaceWrites' - | 'outsideWorkspaceWrites' - | 'extraFileReads'; - -interface CodeGraphyInstalledPluginRecord { - package: string; - version: string; - apiVersion: string; - disclosures: CodeGraphyPluginDisclosure[]; - packageRoot: string; +function readStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === 'string') + : []; } -interface CodeGraphyWorkspacePluginSettings { - package: string; -} +function readScenarioPackageRecord(packageRoot: string): CodeGraphyInstalledPluginRecord { + const packageJsonPath = path.join(packageRoot, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as unknown; + if (!isRecord(packageJson) || !isRecord(packageJson.codegraphy)) { + throw new Error(`E2E scenario package is not a CodeGraphy plugin: ${packageRoot}`); + } -interface CodeGraphyWorkspaceSettings { - version: 1; - maxFiles: number; - include: string[]; - respectGitignore: boolean; - showOrphans: boolean; - filterPatterns: string[]; - disabledCustomFilterPatterns: string[]; - plugins: CodeGraphyWorkspacePluginSettings[]; -} + const packageName = typeof packageJson.name === 'string' ? packageJson.name : ''; + const version = typeof packageJson.version === 'string' ? packageJson.version : ''; + const apiVersion = typeof packageJson.codegraphy.apiVersion === 'string' + ? packageJson.codegraphy.apiVersion + : ''; + if ( + packageName.length === 0 + || version.length === 0 + || packageJson.codegraphy.type !== 'plugin' + || apiVersion.length === 0 + ) { + throw new Error(`E2E scenario package is not a CodeGraphy plugin: ${packageRoot}`); + } + + const plugin: CodeGraphyInstalledPluginRecord = { + package: packageName, + version, + apiVersion, + packageRoot, + disclosures: readStringArray(packageJson.codegraphy.disclosures), + }; + if (isRecord(packageJson.codegraphy.defaultOptions)) { + plugin.defaultOptions = { ...packageJson.codegraphy.defaultOptions }; + } -function writeJsonFile(filePath: string, value: unknown): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); + return plugin; } function createInitialWorkspaceSettings( - pluginRecords: readonly CodeGraphyInstalledPluginRecord[], -): CodeGraphyWorkspaceSettings { + plugins: readonly CodeGraphyInstalledPluginRecord[], +): E2EWorkspaceSettings { return { version: 1, maxFiles: DEFAULT_MAX_FILES, @@ -101,84 +127,114 @@ function createInitialWorkspaceSettings( disabledCustomFilterPatterns: [], plugins: [ { package: CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME }, - ...pluginRecords.map(plugin => ({ package: plugin.package })), + ...plugins.map(createWorkspacePluginSettings), ], }; } -function writeScenarioInstalledPluginCache( +function writeInstalledPluginCache( homeDir: string, - pluginRecords: readonly CodeGraphyInstalledPluginRecord[], + plugins: CodeGraphyInstalledPluginRecord[], ): void { - writeJsonFile(path.join(homeDir, '.codegraphy', 'plugins.json'), { - version: 1, - plugins: pluginRecords, - }); -} - -function writeScenarioWorkspaceSettings( - workspacePath: string, - pluginRecords: readonly CodeGraphyInstalledPluginRecord[], -): void { - writeJsonFile( - path.join(workspacePath, '.codegraphy', 'settings.json'), - createInitialWorkspaceSettings(pluginRecords), + const userDirectoryPath = path.join(homeDir, '.codegraphy'); + fs.mkdirSync(userDirectoryPath, { recursive: true }); + fs.writeFileSync( + path.join(userDirectoryPath, 'plugins.json'), + `${JSON.stringify({ version: 1, plugins }, null, 2)}\n`, ); } -function readScenarioPluginRecord( - repoRoot: string, - packageRelativePath: string, -): CodeGraphyInstalledPluginRecord { - const packageRoot = path.resolve(repoRoot, packageRelativePath); - const packageJson = JSON.parse( - fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'), - ) as CodeGraphyPluginPackageJson; - const packageName = typeof packageJson.name === 'string' ? packageJson.name : ''; - const version = typeof packageJson.version === 'string' ? packageJson.version : ''; - const apiVersion = typeof packageJson.codegraphy?.apiVersion === 'string' - ? packageJson.codegraphy.apiVersion - : ''; - const disclosures = Array.isArray(packageJson.codegraphy?.disclosures) - ? packageJson.codegraphy.disclosures.filter((entry): entry is CodeGraphyInstalledPluginRecord['disclosures'][number] => - entry === 'network' - || entry === 'secrets' - || entry === 'externalProcesses' - || entry === 'workspaceWrites' - || entry === 'outsideWorkspaceWrites' - || entry === 'extraFileReads', - ) - : []; +function readWorkspaceSettingsOrInitial(workspacePath: string): E2EWorkspaceSettings { + const settingsPath = path.join(workspacePath, '.codegraphy/settings.json'); + if (!fs.existsSync(settingsPath)) { + return createInitialWorkspaceSettings([]); + } - if (!packageName || !version || !apiVersion) { - throw new Error(`Invalid CodeGraphy plugin package fixture at ${packageRoot}`); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as unknown; + if (!isRecord(settings) || !Array.isArray(settings.plugins)) { + return createInitialWorkspaceSettings([]); } + const include = readStringArray(settings.include); return { - package: packageName, - version, - apiVersion, - disclosures, - packageRoot, + version: 1, + maxFiles: typeof settings.maxFiles === 'number' ? settings.maxFiles : DEFAULT_MAX_FILES, + include: include.length > 0 ? include : DEFAULT_INCLUDE, + respectGitignore: typeof settings.respectGitignore === 'boolean' ? settings.respectGitignore : true, + showOrphans: typeof settings.showOrphans === 'boolean' ? settings.showOrphans : true, + filterPatterns: readStringArray(settings.filterPatterns), + disabledCustomFilterPatterns: readStringArray(settings.disabledCustomFilterPatterns), + plugins: settings.plugins + .filter(isRecord) + .map((plugin): CodeGraphyWorkspacePluginSettings | null => { + const packageName = typeof plugin.package === 'string' ? plugin.package.trim() : ''; + if (packageName.length === 0) { + return null; + } + + const normalized: CodeGraphyWorkspacePluginSettings = { package: packageName }; + if (isRecord(plugin.options)) { + normalized.options = { ...plugin.options }; + } + return normalized; + }) + .filter((plugin): plugin is CodeGraphyWorkspacePluginSettings => plugin !== null), }; } -function writeScenarioPluginState( +function writeWorkspaceSettings(workspacePath: string, settings: E2EWorkspaceSettings): void { + const settingsPath = path.join(workspacePath, '.codegraphy/settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`); +} + +function createWorkspacePluginSettings( + plugin: CodeGraphyInstalledPluginRecord, +): CodeGraphyWorkspacePluginSettings { + const settings: CodeGraphyWorkspacePluginSettings = { package: plugin.package }; + if (plugin.defaultOptions && Object.keys(plugin.defaultOptions).length > 0) { + settings.options = { ...plugin.defaultOptions }; + } + + return settings; +} + +function prepareScenarioWorkspacePlugins( + scenario: E2EScenario, repoRoot: string, workspacePath: string, homeDir: string, - packageRelativePaths: readonly string[], ): void { - const pluginRecords = packageRelativePaths.map(relativePath => - readScenarioPluginRecord(repoRoot, relativePath), - ); + const plugins = scenario.workspacePluginPackageRelativePaths + .map(relativePath => readScenarioPackageRecord(path.resolve(repoRoot, relativePath))); + + writeInstalledPluginCache(homeDir, plugins); + + if (plugins.length === 0) { + return; + } - writeScenarioInstalledPluginCache(homeDir, pluginRecords); - writeScenarioWorkspaceSettings(workspacePath, pluginRecords); + const settings = readWorkspaceSettingsOrInitial(workspacePath); + const enabledPackages = new Set(settings.plugins.map(plugin => plugin.package)); + writeWorkspaceSettings(workspacePath, { + ...settings, + plugins: [ + ...settings.plugins, + ...plugins + .filter(plugin => !enabledPackages.has(plugin.package)) + .map(createWorkspacePluginSettings), + ], + }); } async function main(): Promise { const repoRoot = findRepoRoot(__dirname); + const requireFromExtension = createRequire( + path.join(repoRoot, 'packages/extension/package.json'), + ); + const { runTests } = requireFromExtension('@vscode/test-electron') as { + runTests: typeof runVSCodeTests; + }; const extensionTestsPath = path.resolve( repoRoot, 'packages/extension/dist-e2e/extension/src/e2e/suite/run', @@ -188,22 +244,25 @@ async function main(): Promise { const vscodeProfilePath = fs.mkdtempSync( path.join(os.tmpdir(), `codegraphy-e2e-${scenario.name.replace(/[^a-z0-9-]/gi, '-')}-`), ); - const homeDir = path.join(vscodeProfilePath, 'home'); const userDataPath = path.join(vscodeProfilePath, 'u'); const extensionsPath = path.join(vscodeProfilePath, 'e'); + const homeDir = path.join(vscodeProfilePath, 'home'); + const extensionDevelopmentPath = [ + repoRoot, + ...scenario.pluginDevelopmentRelativePaths.map((relativePath) => + path.resolve(repoRoot, relativePath), + ), + ]; const workspacePath = path.resolve(repoRoot, scenario.workspaceRelativePath); const hadGitignore = fs.existsSync(path.join(workspacePath, '.gitignore')); + const originalHome = process.env.HOME; try { cleanupScenarioArtifacts(workspacePath, hadGitignore); - writeScenarioPluginState( - repoRoot, - workspacePath, - homeDir, - scenario.pluginDevelopmentRelativePaths, - ); + prepareScenarioWorkspacePlugins(scenario, repoRoot, workspacePath, homeDir); + process.env.HOME = homeDir; await runTests({ - extensionDevelopmentPath: repoRoot, + extensionDevelopmentPath, extensionTestsPath, extensionTestsEnv: { CODEGRAPHY_E2E_SCENARIO: scenario.name, @@ -229,6 +288,11 @@ async function main(): Promise { ], }); } finally { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } cleanupScenarioArtifacts(workspacePath, hadGitignore); fs.rmSync(vscodeProfilePath, { recursive: true, force: true }); } diff --git a/packages/extension/src/e2e/scenarios.ts b/packages/extension/src/e2e/scenarios.ts index be7e49a80..4492cfaf0 100644 --- a/packages/extension/src/e2e/scenarios.ts +++ b/packages/extension/src/e2e/scenarios.ts @@ -18,6 +18,7 @@ export interface E2EScenario { name: E2EScenarioName; workspaceRelativePath: string; pluginDevelopmentRelativePaths: string[]; + workspacePluginPackageRelativePaths: string[]; graphNodeExtension: string; expectedNodeIds: string[]; minimumExpectedEdgeIds: string[]; @@ -32,7 +33,11 @@ export const e2eScenarios: E2EScenario[] = [ { name: 'typescript', workspaceRelativePath: 'examples/example-typescript', - pluginDevelopmentRelativePaths: ['packages/plugin-typescript'], + pluginDevelopmentRelativePaths: [], + workspacePluginPackageRelativePaths: [ + 'packages/plugin-typescript', + 'packages/extension/src/e2e/fixtures/package-graph-view-plugin', + ], graphNodeExtension: '.ts', expectedNodeIds: [ 'src/index.ts', @@ -94,7 +99,11 @@ export const e2eScenarios: E2EScenario[] = [ { name: 'godot', workspaceRelativePath: 'examples/example-godot', - pluginDevelopmentRelativePaths: ['packages/plugin-godot'], + pluginDevelopmentRelativePaths: [], + workspacePluginPackageRelativePaths: [ + 'packages/plugin-godot', + 'packages/extension/src/e2e/fixtures/package-graph-view-plugin', + ], graphNodeExtension: '.gd', expectedNodeIds: [ 'scripts/player.gd', diff --git a/packages/extension/src/e2e/suite/package-graph-view-plugin.test.ts b/packages/extension/src/e2e/suite/package-graph-view-plugin.test.ts new file mode 100644 index 000000000..b5e5b7c30 --- /dev/null +++ b/packages/extension/src/e2e/suite/package-graph-view-plugin.test.ts @@ -0,0 +1,107 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; + +interface CodeGraphyAPI { + getGraphData(): import('../../shared/graph/contracts').IGraphData; + sendToWebview(message: unknown): void; + onWebviewMessage(handler: (message: unknown) => void): vscode.Disposable; + dispatchWebviewMessage(message: unknown): Promise; + onExtensionMessage(handler: (message: unknown) => void): vscode.Disposable; +} + +const PACKAGE_NAME = '@codegraphy/e2e-graph-view-plugin'; +const PLUGIN_ID = 'e2e.graph-view-plugin'; + +async function getAPI(): Promise { + const ext = vscode.extensions.getExtension('codegraphy.codegraphy'); + assert.ok(ext, 'Extension not found'); + return ext.activate(); +} + +function waitForExtensionMessageWhere( + api: CodeGraphyAPI, + type: string, + predicate: (message: TMessage) => boolean, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Timed out waiting for extension message: ${type}`)), + timeoutMs, + ); + const disposable = api.onExtensionMessage((msg: unknown) => { + const message = msg as TMessage & { type?: string }; + if (message.type !== type || !predicate(message)) { + return; + } + + clearTimeout(timer); + disposable.dispose(); + resolve(message); + }); + }); +} + +suite('Package Graph View plugin lifecycle', function () { + this.timeout(60_000); + + test('injects, disables, cleans up, and re-injects package webview contributions', async function() { + const api = await getAPI(); + + const injected = waitForExtensionMessageWhere<{ + type: 'PLUGIN_WEBVIEW_INJECT'; + payload: { pluginId: string }; + }>( + api, + 'PLUGIN_WEBVIEW_INJECT', + message => message.payload.pluginId === PLUGIN_ID, + 30_000, + ); + await vscode.commands.executeCommand('codegraphy.open'); + await injected; + + const disabled = waitForExtensionMessageWhere<{ + type: 'PLUGINS_UPDATED'; + payload: { plugins: Array<{ enabled: boolean; packageName?: string }> }; + }>( + api, + 'PLUGINS_UPDATED', + message => message.payload.plugins.some(plugin => + plugin.packageName === PACKAGE_NAME && plugin.enabled === false + ), + 30_000, + ); + + await api.dispatchWebviewMessage({ + type: 'TOGGLE_PLUGIN', + payload: { + pluginId: PLUGIN_ID, + packageName: PACKAGE_NAME, + enabled: false, + }, + }); + + await disabled; + + const reinjected = waitForExtensionMessageWhere<{ + type: 'PLUGIN_WEBVIEW_INJECT'; + payload: { pluginId: string }; + }>( + api, + 'PLUGIN_WEBVIEW_INJECT', + message => message.payload.pluginId === PLUGIN_ID, + 30_000, + ); + + await api.dispatchWebviewMessage({ + type: 'TOGGLE_PLUGIN', + payload: { + pluginId: PLUGIN_ID, + packageName: PACKAGE_NAME, + enabled: true, + }, + }); + + await reinjected; + }); +}); diff --git a/packages/extension/src/e2e/suite/run.ts b/packages/extension/src/e2e/suite/run.ts index eec26c31a..cd2da0121 100644 --- a/packages/extension/src/e2e/suite/run.ts +++ b/packages/extension/src/e2e/suite/run.ts @@ -3,10 +3,34 @@ * Called by @vscode/test-electron after VS Code starts. */ import * as path from 'path'; +import * as fs from 'fs'; +import { createRequire } from 'module'; import { glob } from 'glob'; +import type MochaConstructor from 'mocha'; + +function findRepoRoot(startDir: string): string { + let currentDir = startDir; + + while (currentDir !== path.dirname(currentDir)) { + if (fs.existsSync(path.join(currentDir, 'pnpm-workspace.yaml'))) { + return currentDir; + } + + currentDir = path.dirname(currentDir); + } + + throw new Error(`Unable to locate repo root from ${startDir}`); +} export async function run(): Promise { - const { default: Mocha } = await import('mocha'); + const repoRoot = findRepoRoot(__dirname); + const requireFromExtension = createRequire( + path.join(repoRoot, 'packages/extension/package.json'), + ); + const mochaModule = requireFromExtension('mocha') as + | typeof MochaConstructor + | { default: typeof MochaConstructor }; + const Mocha = 'default' in mochaModule ? mochaModule.default : mochaModule; const grep = process.env.CODEGRAPHY_E2E_GREP ?? (process.env.CODEGRAPHY_E2E_FULL === '1' ? undefined diff --git a/packages/extension/src/e2e/suite/settings.test.ts b/packages/extension/src/e2e/suite/settings.test.ts index db436f4b5..67bf8d330 100644 --- a/packages/extension/src/e2e/suite/settings.test.ts +++ b/packages/extension/src/e2e/suite/settings.test.ts @@ -24,7 +24,6 @@ interface CodeGraphyAPI { interface RepoSettingsFile { legend?: IGroup[]; filterPatterns?: string[]; - graphLayout?: { collapsedNodes?: Record }; showOrphans?: boolean; directionMode?: string; } @@ -164,39 +163,6 @@ suite('Settings: Direction Mode', function () { }); }); -suite('Settings: Graph Layout', function () { - this.timeout(30_000); - - test('UPDATE_GRAPH_LAYOUT_COLLAPSE persists and echoes GRAPH_LAYOUT_UPDATED', async function() { - const api = await getAPI(); - await vscode.commands.executeCommand('codegraphy.open'); - await sleep(1_000); - - const echo = waitForMessage(api, 'GRAPH_LAYOUT_UPDATED'); - await api.dispatchWebviewMessage({ - type: 'UPDATE_GRAPH_LAYOUT_COLLAPSE', - payload: { nodeId: 'packages/app', collapsed: true }, - }); - - const msg = (await echo) as { - type: string; - payload: { collapsedNodes: Record }; - }; - assert.strictEqual(msg.payload.collapsedNodes['packages/app'], true); - - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - assert.ok(workspaceFolder, 'Expected an open workspace folder'); - const settingsPath = path.join(workspaceFolder.uri.fsPath, '.codegraphy', 'settings.json'); - const stored = readRepoSettingsFile(settingsPath).graphLayout?.collapsedNodes ?? {}; - assert.strictEqual(stored['packages/app'], true, 'collapsed folder state should be persisted'); - - await api.dispatchWebviewMessage({ - type: 'UPDATE_GRAPH_LAYOUT_COLLAPSE', - payload: { nodeId: 'packages/app', collapsed: false }, - }); - }); -}); - suite('Settings: Legends', function () { this.timeout(30_000); diff --git a/packages/extension/src/extension/config/actions.ts b/packages/extension/src/extension/config/actions.ts index 9aff5e620..fc726e8cf 100644 --- a/packages/extension/src/extension/config/actions.ts +++ b/packages/extension/src/extension/config/actions.ts @@ -65,9 +65,6 @@ export function executeConfigAction( case 'legend': scheduleGroupSettingsRefresh(provider); break; - case 'layout': - provider.sendGraphLayout(); - break; case 'general': executeGeneralConfigAction(event, provider); break; diff --git a/packages/extension/src/extension/config/classify.ts b/packages/extension/src/extension/config/classify.ts index 320427a09..8a3dc0a92 100644 --- a/packages/extension/src/extension/config/classify.ts +++ b/packages/extension/src/extension/config/classify.ts @@ -3,7 +3,6 @@ export type ConfigCategory = | 'toggles' | 'display' | 'legend' - | 'layout' | 'general'; interface CodeGraphyConfigurationChangeLike { @@ -41,10 +40,6 @@ export function classifyConfigChange(event: CodeGraphyConfigurationChangeLike): return 'legend'; } - if (affectsAny('codegraphy.graphLayout')) { - return 'layout'; - } - if (event.affectsConfiguration('codegraphy')) { return 'general'; } diff --git a/packages/extension/src/extension/graphView/analysis/execution.ts b/packages/extension/src/extension/graphView/analysis/execution.ts index e25fc99f0..2b23fc915 100644 --- a/packages/extension/src/extension/graphView/analysis/execution.ts +++ b/packages/extension/src/extension/graphView/analysis/execution.ts @@ -73,6 +73,8 @@ export interface GraphViewAnalysisExecutionHandlers { sendIndexProgress?(progress: GraphViewIndexingProgress): void; sendPluginExporters?(): void; sendPluginToolbarActions?(): void; + sendGraphViewContributionStatuses?(): void; + sendPluginWebviewInjections?(): void; markWorkspaceReady(graphData: IGraphData): void; isAbortError(error: unknown): boolean; logError(message: string, error: unknown): void; diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish.ts b/packages/extension/src/extension/graphView/analysis/execution/publish.ts index cc42c5069..0b9979dd6 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish.ts @@ -59,6 +59,8 @@ export function publishAnalyzedGraph( handlers.sendContextMenuItems(); handlers.sendPluginExporters?.(); handlers.sendPluginToolbarActions?.(); + handlers.sendGraphViewContributionStatuses?.(); + handlers.sendPluginWebviewInjections?.(); state.analyzer?.registry.notifyPostAnalyze(graphData); handlers.markWorkspaceReady(graphData); } @@ -70,5 +72,7 @@ export function publishAnalysisFailure( handlers.sendPluginStatuses(); handlers.sendPluginExporters?.(); handlers.sendPluginToolbarActions?.(); + handlers.sendGraphViewContributionStatuses?.(); + handlers.sendPluginWebviewInjections?.(); handlers.markWorkspaceReady(graphData); } diff --git a/packages/extension/src/extension/graphView/graphLayout/message.ts b/packages/extension/src/extension/graphView/graphLayout/message.ts deleted file mode 100644 index 906cf7352..000000000 --- a/packages/extension/src/extension/graphView/graphLayout/message.ts +++ /dev/null @@ -1,78 +0,0 @@ -import * as vscode from 'vscode'; -import type { ExtensionToWebviewMessage } from '../../../shared/protocol/extensionToWebview'; -import type { GraphLayoutSettings, GraphLayoutSection } from '../../../shared/settings/graphLayout'; -import { getCodeGraphyConfiguration } from '../../repoSettings/current'; -import { - createDefaultGraphLayoutSettings, - normalizeGraphLayoutSettings, -} from '../../repoSettings/graphLayout/model'; - -export interface GraphLayoutUpdatedMessageOptions { - workspaceFolder?: { uri: vscode.Uri }; - asWebviewUri?(uri: vscode.Uri): { toString(): string }; - iconUrls?: ReadonlyMap; -} - -function isWorkspaceGraphSectionIcon(icon: string | undefined): icon is string { - return !!icon - && icon.startsWith('.codegraphy/icons/') - && !icon.includes('..') - && /\.(svg|png)$/i.test(icon); -} - -function resolveGraphSectionIconUrl( - section: GraphLayoutSection, - options: GraphLayoutUpdatedMessageOptions, -): string | undefined { - const icon = section.icon; - if (icon && options.iconUrls?.has(icon)) { - return options.iconUrls.get(icon); - } - - if ( - !isWorkspaceGraphSectionIcon(icon) - || !options.workspaceFolder - || !options.asWebviewUri - ) { - return undefined; - } - - return options.asWebviewUri( - vscode.Uri.joinPath(options.workspaceFolder.uri, icon), - ).toString(); -} - -export function addGraphSectionIconUrls( - graphLayout: GraphLayoutSettings, - options: GraphLayoutUpdatedMessageOptions = {}, -): GraphLayoutSettings { - const sectionEntries = Object.entries(graphLayout.sections).map(([sectionId, section]) => { - const iconUrl = resolveGraphSectionIconUrl(section, options); - return [ - sectionId, - iconUrl ? { ...section, iconUrl } : section, - ] as const; - }); - - return { - ...graphLayout, - sections: Object.fromEntries(sectionEntries), - }; -} - -export function createGraphLayoutUpdatedMessage( - options: GraphLayoutUpdatedMessageOptions = {}, -): Extract< - ExtensionToWebviewMessage, - { type: 'GRAPH_LAYOUT_UPDATED' } -> { - const configuration = getCodeGraphyConfiguration(); - const graphLayout = normalizeGraphLayoutSettings( - configuration.get('graphLayout', createDefaultGraphLayoutSettings()), - ); - - return { - type: 'GRAPH_LAYOUT_UPDATED', - payload: addGraphSectionIconUrls(graphLayout, options), - }; -} diff --git a/packages/extension/src/extension/graphView/groups/defaults/pluginRoots.ts b/packages/extension/src/extension/graphView/groups/defaults/pluginRoots.ts index d03cbc758..1983d04b0 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/pluginRoots.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/pluginRoots.ts @@ -1,5 +1,20 @@ import * as vscode from 'vscode'; +interface PackagePluginRootInfo { + plugin: { + id: string; + }; + sourcePackageRoot?: string; +} + +interface PackagePluginRootRegistry { + list(): PackagePluginRootInfo[]; +} + +interface PackagePluginRootAnalyzer { + registry: PackagePluginRootRegistry; +} + function getBuiltInGraphViewPluginDirEntries(): Array { return [ ['codegraphy.godot', 'plugin-godot'], @@ -23,3 +38,19 @@ export function registerBuiltInGraphViewPluginRoots( } } } + +export function registerPackageGraphViewPluginRoots( + analyzer: PackagePluginRootAnalyzer | undefined, + pluginExtensionUris: Map, +): void { + for (const pluginInfo of analyzer?.registry.list() ?? []) { + if (!pluginInfo.sourcePackageRoot || pluginExtensionUris.has(pluginInfo.plugin.id)) { + continue; + } + + pluginExtensionUris.set( + pluginInfo.plugin.id, + vscode.Uri.file(pluginInfo.sourcePackageRoot), + ); + } +} diff --git a/packages/extension/src/extension/graphView/provider/analysis/handlers.ts b/packages/extension/src/extension/graphView/provider/analysis/handlers.ts index a7f736aaf..81cda7570 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/handlers.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/handlers.ts @@ -62,6 +62,8 @@ export function createGraphViewProviderAnalysisHandlers( }, sendPluginExporters: () => source._sendPluginExporters?.(), sendPluginToolbarActions: () => source._sendPluginToolbarActions?.(), + sendGraphViewContributionStatuses: () => source._sendGraphViewContributionStatuses?.(), + sendPluginWebviewInjections: () => source._sendPluginWebviewInjections?.(), markWorkspaceReady: graphData => callbacks.markWorkspaceReady(graphData), isAbortError: error => callbacks.isAbortError(error), logError: (message, error) => { diff --git a/packages/extension/src/extension/graphView/provider/analysis/methods.ts b/packages/extension/src/extension/graphView/provider/analysis/methods.ts index de8d3ba3c..05ebdbb82 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/methods.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/methods.ts @@ -52,6 +52,8 @@ export interface GraphViewProviderAnalysisMethodsSource { _sendContextMenuItems(): void; _sendPluginExporters?(): void; _sendPluginToolbarActions?(): void; + _sendGraphViewContributionStatuses?(): void; + _sendPluginWebviewInjections?(): void; _loadAndSendData?(this: void): Promise; _doAnalyzeAndSendData?(this: void, signal: AbortSignal, requestId: number): Promise; _markWorkspaceReady?(this: void, graph: IGraphData): void; diff --git a/packages/extension/src/extension/graphView/provider/plugin/broadcasts.ts b/packages/extension/src/extension/graphView/provider/plugin/broadcasts.ts index b4d5706d3..5c0afa66a 100644 --- a/packages/extension/src/extension/graphView/provider/plugin/broadcasts.ts +++ b/packages/extension/src/extension/graphView/provider/plugin/broadcasts.ts @@ -3,6 +3,7 @@ import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/exte import { getCodeGraphyConfiguration } from '../../../repoSettings/current'; import { sendGraphControlsUpdated } from '../../controls/send'; import { + sendGraphViewContributionStatuses, sendGraphViewContextMenuItems, sendGraphViewPluginToolbarActions, sendGraphViewPluginWebviewInjections, @@ -23,6 +24,7 @@ export interface GraphViewProviderPluginBroadcastMethods { _sendContextMenuItems(): void; _sendPluginExporters(): void; _sendPluginToolbarActions(): void; + _sendGraphViewContributionStatuses(): void; _sendPluginWebviewInjections(): void; _sendGroupsUpdated(): void; } @@ -34,6 +36,7 @@ export interface GraphViewProviderPluginBroadcastDependencies { sendContextMenuItems?: typeof sendGraphViewContextMenuItems; sendPluginExporters?: typeof sendGraphViewPluginExporters; sendPluginToolbarActions?: typeof sendGraphViewPluginToolbarActions; + sendGraphViewContributionStatuses?: typeof sendGraphViewContributionStatuses; sendPluginWebviewInjections?: typeof sendGraphViewPluginWebviewInjections; sendGroupsUpdated?: typeof sendGraphViewLegendsUpdated; getWorkspaceFolders?(): readonly vscode.WorkspaceFolder[] | undefined; @@ -47,6 +50,7 @@ export const DEFAULT_GRAPH_VIEW_PROVIDER_PLUGIN_BROADCAST_DEPENDENCIES: sendContextMenuItems: sendGraphViewContextMenuItems, sendPluginExporters: sendGraphViewPluginExporters, sendPluginToolbarActions: sendGraphViewPluginToolbarActions, + sendGraphViewContributionStatuses, sendPluginWebviewInjections: sendGraphViewPluginWebviewInjections, sendGroupsUpdated: sendGraphViewLegendsUpdated, getWorkspaceFolders: () => vscode.workspace.workspaceFolders, @@ -104,7 +108,18 @@ export function createGraphViewProviderPluginBroadcastMethods( _sendPluginToolbarActions: () => { resolved.sendPluginToolbarActions(source._analyzer, send); }, + _sendGraphViewContributionStatuses: () => { + void resolved.sendGraphViewContributionStatuses( + source._analyzer, + { + workspaceRoot: resolved.getWorkspaceFolders()?.[0]?.uri.fsPath, + }, + send, + ); + }, _sendPluginWebviewInjections: () => { + source._registerBuiltInPluginRoots(); + source._refreshWebviewResourceRoots(); resolved.sendPluginWebviewInjections( source._analyzer, (assetPath, pluginId) => source._resolveWebviewAssetPath(assetPath, pluginId), diff --git a/packages/extension/src/extension/graphView/provider/plugin/externalRegistration.ts b/packages/extension/src/extension/graphView/provider/plugin/externalRegistration.ts index ac956707e..8c48a94bc 100644 --- a/packages/extension/src/extension/graphView/provider/plugin/externalRegistration.ts +++ b/packages/extension/src/extension/graphView/provider/plugin/externalRegistration.ts @@ -56,6 +56,7 @@ export function createGraphViewProviderExternalPluginRegistration( sendContextMenuItems: () => broadcasts._sendContextMenuItems(), sendPluginExporters: () => broadcasts._sendPluginExporters(), sendPluginToolbarActions: () => broadcasts._sendPluginToolbarActions(), + sendGraphViewContributionStatuses: () => broadcasts._sendGraphViewContributionStatuses(), sendPluginWebviewInjections: () => broadcasts._sendPluginWebviewInjections(), invalidateTimelineCache: () => source._invalidateTimelineCache(), analyzeAndSendData: () => source._analyzeAndSendData(), diff --git a/packages/extension/src/extension/graphView/provider/plugin/methods.ts b/packages/extension/src/extension/graphView/provider/plugin/methods.ts index 844c97812..c8e8a1509 100644 --- a/packages/extension/src/extension/graphView/provider/plugin/methods.ts +++ b/packages/extension/src/extension/graphView/provider/plugin/methods.ts @@ -8,6 +8,7 @@ import { type GraphViewExternalPluginRegistrationOptions, } from '../../webview/plugins/registration/register'; import { + sendGraphViewContributionStatuses, sendGraphViewContextMenuItems, } from '../../webview/plugins/contributionDispatch'; import { @@ -31,6 +32,7 @@ type GraphViewPluginAnalyzerLike = Parameters[2]['analyzer'] > & NonNullable[0]> + & NonNullable[0]> & NonNullable[0]>; type GraphViewDecorationManagerLike = @@ -73,6 +75,8 @@ export interface GraphViewProviderPluginMethods { _sendContextMenuItems: GraphViewProviderPluginBroadcastMethods['_sendContextMenuItems']; _sendPluginExporters: GraphViewProviderPluginBroadcastMethods['_sendPluginExporters']; _sendPluginToolbarActions: GraphViewProviderPluginBroadcastMethods['_sendPluginToolbarActions']; + _sendGraphViewContributionStatuses: + GraphViewProviderPluginBroadcastMethods['_sendGraphViewContributionStatuses']; _sendPluginWebviewInjections: GraphViewProviderPluginBroadcastMethods['_sendPluginWebviewInjections']; _sendGroupsUpdated: GraphViewProviderPluginBroadcastMethods['_sendGroupsUpdated']; registerExternalPlugin( diff --git a/packages/extension/src/extension/graphView/provider/plugin/resources.ts b/packages/extension/src/extension/graphView/provider/plugin/resources.ts index 09bd233eb..af155677f 100644 --- a/packages/extension/src/extension/graphView/provider/plugin/resources.ts +++ b/packages/extension/src/extension/graphView/provider/plugin/resources.ts @@ -3,7 +3,10 @@ import type { IGraphData } from '../../../../shared/graph/contracts'; import type { IGroup } from '../../../../shared/settings/groups'; import { getCodeGraphyConfiguration } from '../../../repoSettings/current'; import { getBuiltInGraphViewDefaultGroups } from '../../groups/defaults/builtIn'; -import { registerBuiltInGraphViewPluginRoots } from '../../groups/defaults/pluginRoots'; +import { + registerBuiltInGraphViewPluginRoots, + registerPackageGraphViewPluginRoots, +} from '../../groups/defaults/pluginRoots'; import { buildGraphViewMergedGroups } from '../../groups/merged'; import { getGraphViewPluginDefaultGroups } from '../../groups/defaults/plugin'; import { @@ -41,6 +44,7 @@ export interface GraphViewProviderPluginResourceMethods { export interface GraphViewProviderPluginResourceMethodDependencies { registerBuiltInPluginRoots: typeof registerBuiltInGraphViewPluginRoots; + registerPackagePluginRoots: typeof registerPackageGraphViewPluginRoots; getPluginDefaultGroups: typeof getGraphViewPluginDefaultGroups; getBuiltInDefaultGroups: typeof getBuiltInGraphViewDefaultGroups; buildMergedGroups: typeof buildGraphViewMergedGroups; @@ -56,6 +60,7 @@ export interface GraphViewProviderPluginResourceMethodDependencies { function createDefaultGraphViewProviderPluginResourceMethodDependencies(): GraphViewProviderPluginResourceMethodDependencies { return { registerBuiltInPluginRoots: registerBuiltInGraphViewPluginRoots, + registerPackagePluginRoots: registerPackageGraphViewPluginRoots, getPluginDefaultGroups: getGraphViewPluginDefaultGroups, getBuiltInDefaultGroups: getBuiltInGraphViewDefaultGroups, buildMergedGroups: buildGraphViewMergedGroups, @@ -79,6 +84,7 @@ export function createGraphViewProviderPluginResourceMethods( dependencies ?? createDefaultGraphViewProviderPluginResourceMethodDependencies(); const _registerBuiltInPluginRoots = (): void => { resolvedDependencies.registerBuiltInPluginRoots(source._extensionUri, source._pluginExtensionUris); + resolvedDependencies.registerPackagePluginRoots(source._analyzer, source._pluginExtensionUris); }; const _getPluginDefaultGroups = (): IGroup[] => diff --git a/packages/extension/src/extension/graphView/provider/source/delegates/analysis.ts b/packages/extension/src/extension/graphView/provider/source/delegates/analysis.ts index a1c77862c..75a1e406e 100644 --- a/packages/extension/src/extension/graphView/provider/source/delegates/analysis.ts +++ b/packages/extension/src/extension/graphView/provider/source/delegates/analysis.ts @@ -18,6 +18,7 @@ export function createGraphViewProviderAnalysisMethodDelegates( | '_sendContextMenuItems' | '_sendPluginExporters' | '_sendPluginToolbarActions' + | '_sendGraphViewContributionStatuses' | '_sendPluginWebviewInjections' | '_loadAndSendData' | '_indexAndSendData' @@ -41,6 +42,8 @@ export function createGraphViewProviderAnalysisMethodDelegates( _sendContextMenuItems: () => owner._methodContainers.plugin._sendContextMenuItems(), _sendPluginExporters: () => owner._methodContainers.plugin._sendPluginExporters(), _sendPluginToolbarActions: () => owner._methodContainers.plugin._sendPluginToolbarActions(), + _sendGraphViewContributionStatuses: () => + owner._methodContainers.plugin._sendGraphViewContributionStatuses(), _sendPluginWebviewInjections: () => owner._methodContainers.plugin._sendPluginWebviewInjections(), _loadAndSendData: () => owner._methodContainers.analysis._loadAndSendData(), _indexAndSendData: () => owner._methodContainers.analysis._indexAndSendData(), diff --git a/packages/extension/src/extension/graphView/provider/source/delegates/plugin.ts b/packages/extension/src/extension/graphView/provider/source/delegates/plugin.ts index e5faf4d4f..5f80ca8de 100644 --- a/packages/extension/src/extension/graphView/provider/source/delegates/plugin.ts +++ b/packages/extension/src/extension/graphView/provider/source/delegates/plugin.ts @@ -10,6 +10,7 @@ export function createGraphViewProviderPluginMethodDelegates( | '_sendContextMenuItems' | '_sendPluginExporters' | '_sendPluginToolbarActions' + | '_sendGraphViewContributionStatuses' | '_sendPluginWebviewInjections' | '_sendGroupsUpdated' | 'registerExternalPlugin' @@ -21,6 +22,8 @@ export function createGraphViewProviderPluginMethodDelegates( _sendContextMenuItems: () => owner._methodContainers.plugin._sendContextMenuItems(), _sendPluginExporters: () => owner._methodContainers.plugin._sendPluginExporters(), _sendPluginToolbarActions: () => owner._methodContainers.plugin._sendPluginToolbarActions(), + _sendGraphViewContributionStatuses: () => + owner._methodContainers.plugin._sendGraphViewContributionStatuses(), _sendPluginWebviewInjections: () => owner._methodContainers.plugin._sendPluginWebviewInjections(), _sendGroupsUpdated: () => owner._methodContainers.plugin._sendGroupsUpdated(), registerExternalPlugin: (plugin, options) => diff --git a/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts b/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts index dc56d20a9..5445c198b 100644 --- a/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts +++ b/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts @@ -7,7 +7,6 @@ import type { GraphViewExternalPluginRegistrationOptions } from '../../webview/p import { dispatchGraphViewProviderMessage } from '../../webview/providerMessages/dispatch'; import type { GraphViewProviderMethodContainers } from './methodContainers'; import type { GraphViewProviderMessageListenerSource } from '../../webview/providerMessages/listener'; -import { createGraphLayoutUpdatedMessage } from '../../graphLayout/message'; import { createGraphViewProviderMethodSource, type GraphViewProviderMethodSourceOwner, @@ -66,7 +65,6 @@ export interface GraphViewProviderPublicMethods { updateGraphData: (data: IGraphData) => void; getGraphData: () => IGraphData; sendPlaybackSpeed: () => void; - sendGraphLayout: () => void; invalidateTimelineCache: () => Promise; registerExternalPlugin: ( plugin: unknown, @@ -118,8 +116,6 @@ export function assignGraphViewProviderPublicMethods( target.updateGraphData = data => target._methodContainers.viewContext.updateGraphData(data); target.getGraphData = () => target._methodContainers.viewContext.getGraphData(); target.sendPlaybackSpeed = () => target._methodContainers.timeline.sendPlaybackSpeed(); - target.sendGraphLayout = () => - target._methodContainers.webview.sendToWebview(createGraphLayoutUpdatedMessage()); target.invalidateTimelineCache = () => target._methodContainers.timeline.invalidateTimelineCache(); target.registerExternalPlugin = (plugin, options) => target._methodContainers.plugin.registerExternalPlugin(plugin, options); diff --git a/packages/extension/src/extension/graphView/webview/dispatch/plugin.ts b/packages/extension/src/extension/graphView/webview/dispatch/plugin.ts index bfe057a1b..18eb20bf9 100644 --- a/packages/extension/src/extension/graphView/webview/dispatch/plugin.ts +++ b/packages/extension/src/extension/graphView/webview/dispatch/plugin.ts @@ -31,7 +31,6 @@ export interface GraphViewPluginMessageContext { analyzeAndSendData(): Promise; sendFavorites(): void; sendSettings(): void; - sendGraphLayout?(): void; sendPhysicsSettings(): void; sendGroupsUpdated(): void; sendMessage(message: unknown): void; @@ -40,6 +39,7 @@ export interface GraphViewPluginMessageContext { sendContextMenuItems(): void; sendPluginExporters?(): void; sendPluginToolbarActions?(): void; + sendGraphViewContributionStatuses?(): void; sendPluginWebviewInjections(): void; sendActiveFile(): void; waitForFirstWorkspaceReady(): PromiseLike; diff --git a/packages/extension/src/extension/graphView/webview/dispatch/pluginReady.ts b/packages/extension/src/extension/graphView/webview/dispatch/pluginReady.ts index 84417c3f1..7fde2ae4f 100644 --- a/packages/extension/src/extension/graphView/webview/dispatch/pluginReady.ts +++ b/packages/extension/src/extension/graphView/webview/dispatch/pluginReady.ts @@ -26,7 +26,6 @@ export interface GraphViewPluginReadyContext { loadAndSendData(): Promise; sendFavorites(): void; sendSettings(): void; - sendGraphLayout?(): void; sendPhysicsSettings(): void; sendGroupsUpdated(): void; sendMessage(message: unknown): void; @@ -35,6 +34,7 @@ export interface GraphViewPluginReadyContext { sendContextMenuItems(): void; sendPluginExporters?(): void; sendPluginToolbarActions?(): void; + sendGraphViewContributionStatuses?(): void; sendPluginWebviewInjections(): void; sendActiveFile(): void; waitForFirstWorkspaceReady(): PromiseLike; @@ -69,7 +69,6 @@ export async function dispatchGraphViewPluginReadyMessage( loadAndSendData: () => void context.loadAndSendData(), sendFavorites: () => context.sendFavorites(), sendSettings: () => context.sendSettings(), - sendGraphLayout: () => context.sendGraphLayout?.(), sendPhysicsSettings: () => context.sendPhysicsSettings(), sendGroupsUpdated: () => context.sendGroupsUpdated(), sendMessage: message => context.sendMessage(message), @@ -78,6 +77,7 @@ export async function dispatchGraphViewPluginReadyMessage( sendContextMenuItems: () => context.sendContextMenuItems(), sendPluginExporters: () => context.sendPluginExporters?.(), sendPluginToolbarActions: () => context.sendPluginToolbarActions?.(), + sendGraphViewContributionStatuses: () => context.sendGraphViewContributionStatuses?.(), sendPluginWebviewInjections: () => context.sendPluginWebviewInjections(), sendActiveFile: () => context.sendActiveFile(), waitForFirstWorkspaceReady: () => context.waitForFirstWorkspaceReady(), diff --git a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts index 048c9c126..8875c82e4 100644 --- a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts +++ b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts @@ -86,6 +86,11 @@ export interface GraphViewPrimaryMessageContext { updateConfig(key: string, value: unknown): Promise; getInstalledPluginDefaultOptions?(packageName: string): Record | undefined; reloadWorkspacePlugins(): Promise; + sendPluginStatuses?(): void; + sendContextMenuItems(): void; + sendPluginToolbarActions?(): void; + sendGraphViewContributionStatuses?(): void; + sendPluginWebviewInjections(): void; sendGraphControls(): void; reprocessPluginFiles(pluginIds: readonly string[]): Promise; getPluginFilterPatterns(): string[]; diff --git a/packages/extension/src/extension/graphView/webview/dispatch/routed.ts b/packages/extension/src/extension/graphView/webview/dispatch/routed.ts index a1929d960..fe562aa80 100644 --- a/packages/extension/src/extension/graphView/webview/dispatch/routed.ts +++ b/packages/extension/src/extension/graphView/webview/dispatch/routed.ts @@ -5,7 +5,6 @@ import { applyNodeFileMessage } from '../nodeFile/router'; import { applyPhysicsMessage } from '../messages/physics'; import { applySurfaceMessage } from '../messages/surface'; import { applyTimelineMessage } from '../messages/timeline'; -import { applyGraphLayoutMessage } from '../messages/graphLayout'; import { createGraphViewPrimaryExportHandlers } from './exportHandlers'; import type { GraphViewPrimaryMessageContext, GraphViewPrimaryMessageResult } from './primary'; import { createGraphViewPrimaryNodeFileHandlers } from './primaryState'; @@ -34,10 +33,6 @@ export async function dispatchGraphViewPrimaryRouteMessage( return { handled: true }; } - if (await applyGraphLayoutMessage(message, context)) { - return { handled: true }; - } - if (await applySurfaceMessage(message)) { return { handled: true }; } diff --git a/packages/extension/src/extension/graphView/webview/messages/graphLayout.ts b/packages/extension/src/extension/graphView/webview/messages/graphLayout.ts deleted file mode 100644 index 0cb9efb71..000000000 --- a/packages/extension/src/extension/graphView/webview/messages/graphLayout.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { WebviewToExtensionMessage } from '../../../../shared/protocol/webviewToExtension'; -import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; -import { - assignGraphLayoutOwner, - clearGraphLayoutNodePin, - createGraphLayoutSection, - createDefaultGraphLayoutSettings, - deleteGraphLayoutSection, - normalizeGraphLayoutSettings, - setGraphLayoutNodeCollapsed, - setGraphLayoutNodePin, - updateGraphLayoutSection, - type GraphLayoutSettings, -} from '../../../repoSettings/graphLayout/model'; -import { getUndoManager, type IUndoableAction } from '../../../undoManager'; -import { addGraphSectionIconUrls } from '../../graphLayout/message'; -import { writeIconImports, type IconImportMessageHandlers } from './iconImports'; - -export interface GraphLayoutMessageHandlers extends IconImportMessageHandlers { - asWebviewUri?(uri: import('vscode').Uri): { toString(): string }; - getConfig(key: string, defaultValue: T): T; - showWarningMessage?( - message: string, - options: { modal: boolean }, - deleteAction: string, - ): Thenable<'Delete' | undefined>; - updateConfig(key: string, value: unknown): Promise; - sendMessage(message: ExtensionToWebviewMessage): void; -} - -type GraphLayoutPersistenceHandlers = Pick< - GraphLayoutMessageHandlers, - 'asWebviewUri' | 'getConfig' | 'sendMessage' | 'updateConfig' | 'workspaceFolder' ->; - -function readCurrentGraphLayout(handlers: Pick): GraphLayoutSettings { - return normalizeGraphLayoutSettings( - handlers.getConfig('graphLayout', createDefaultGraphLayoutSettings()), - ); -} - -async function persistAndSendGraphLayout( - handlers: GraphLayoutPersistenceHandlers, - graphLayout: GraphLayoutSettings, - options: { iconUrls?: ReadonlyMap } = {}, -): Promise { - await handlers.updateConfig('graphLayout', graphLayout); - handlers.sendMessage({ - type: 'GRAPH_LAYOUT_UPDATED', - payload: addGraphSectionIconUrls(graphLayout, { - asWebviewUri: handlers.asWebviewUri - ? uri => handlers.asWebviewUri?.(uri) ?? uri - : undefined, - iconUrls: options.iconUrls, - workspaceFolder: handlers.workspaceFolder, - }), - }); -} - -export class UpdateGraphLayoutAction implements IUndoableAction { - constructor( - readonly description: string, - private readonly handlers: GraphLayoutPersistenceHandlers, - private readonly beforeLayout: GraphLayoutSettings, - private readonly afterLayout: GraphLayoutSettings, - ) {} - - async execute(): Promise { - await persistAndSendGraphLayout(this.handlers, this.afterLayout); - } - - async undo(): Promise { - await persistAndSendGraphLayout(this.handlers, this.beforeLayout); - } -} - -export async function applyGraphLayoutMessage( - message: WebviewToExtensionMessage, - handlers: GraphLayoutMessageHandlers, -): Promise { - switch (message.type) { - case 'UPDATE_GRAPH_LAYOUT_PIN': { - const nextLayout = setGraphLayoutNodePin(readCurrentGraphLayout(handlers), message.payload); - - await persistAndSendGraphLayout(handlers, nextLayout); - return true; - } - - case 'CLEAR_GRAPH_LAYOUT_PIN': { - const nextLayout = clearGraphLayoutNodePin( - readCurrentGraphLayout(handlers), - message.payload.nodeId, - message.payload.graphMode, - ); - - await persistAndSendGraphLayout(handlers, nextLayout); - return true; - } - - case 'UPDATE_GRAPH_LAYOUT_COLLAPSE': { - const nextLayout = setGraphLayoutNodeCollapsed( - readCurrentGraphLayout(handlers), - message.payload.nodeId, - message.payload.collapsed, - ); - - await persistAndSendGraphLayout(handlers, nextLayout); - return true; - } - - case 'CREATE_GRAPH_LAYOUT_SECTION': { - const nextLayout = createGraphLayoutSection(readCurrentGraphLayout(handlers), { - ...message.payload, - updatedAt: new Date().toISOString(), - }); - - await persistAndSendGraphLayout(handlers, nextLayout); - return true; - } - - case 'UPDATE_GRAPH_LAYOUT_SECTION': { - const { iconImports: _iconImports, ...patch } = message.payload; - await writeIconImports(_iconImports, handlers); - const iconUrls = new Map( - (_iconImports ?? []).map(iconImport => [ - iconImport.imagePath, - `data:image/${iconImport.imagePath.endsWith('.svg') ? 'svg+xml' : 'png'};base64,${iconImport.contentsBase64}`, - ]), - ); - const nextLayout = updateGraphLayoutSection(readCurrentGraphLayout(handlers), { - ...patch, - updatedAt: new Date().toISOString(), - }); - - await persistAndSendGraphLayout(handlers, nextLayout, { iconUrls }); - return true; - } - - case 'UPDATE_GRAPH_LAYOUT_OWNER': { - const currentLayout = readCurrentGraphLayout(handlers); - const nextLayout = assignGraphLayoutOwner(currentLayout, { - ...message.payload, - updatedAt: new Date().toISOString(), - }); - - await getUndoManager().execute(new UpdateGraphLayoutAction( - 'Move Graph Item', - handlers, - currentLayout, - nextLayout, - )); - return true; - } - - case 'DELETE_GRAPH_LAYOUT_SECTION': { - const currentLayout = readCurrentGraphLayout(handlers); - const section = currentLayout.sections[message.payload.sectionId]; - const label = section?.label || message.payload.sectionId; - const confirm = await handlers.showWarningMessage?.( - `Are you sure you want to delete Graph Section "${label}"?`, - { modal: true }, - 'Delete', - ); - if (confirm !== 'Delete') { - return true; - } - - const nextLayout = deleteGraphLayoutSection(currentLayout, { - ...message.payload, - updatedAt: new Date().toISOString(), - }); - - await getUndoManager().execute(new UpdateGraphLayoutAction( - 'Delete Graph Section', - handlers, - currentLayout, - nextLayout, - )); - return true; - } - - default: - return false; - } -} diff --git a/packages/extension/src/extension/graphView/webview/messages/ready.ts b/packages/extension/src/extension/graphView/webview/messages/ready.ts index 33be7dbfb..5380491fa 100644 --- a/packages/extension/src/extension/graphView/webview/messages/ready.ts +++ b/packages/extension/src/extension/graphView/webview/messages/ready.ts @@ -25,7 +25,6 @@ export interface GraphViewReadyHandlers { loadAndSendData(): void; sendFavorites(): void; sendSettings(): void; - sendGraphLayout?(): void; sendPhysicsSettings(): void; sendGroupsUpdated(): void; sendMessage(message: { type: string; payload: unknown }): void; @@ -34,6 +33,7 @@ export interface GraphViewReadyHandlers { sendContextMenuItems(): void; sendPluginExporters?(): void; sendPluginToolbarActions?(): void; + sendGraphViewContributionStatuses?(): void; sendPluginWebviewInjections(): void; sendActiveFile(): void; waitForFirstWorkspaceReady(): PromiseLike; @@ -51,7 +51,6 @@ export async function applyWebviewReady( handlers.loadAndSendData(); handlers.sendFavorites(); handlers.sendSettings(); - handlers.sendGraphLayout?.(); handlers.sendPhysicsSettings(); handlers.sendGroupsUpdated(); handlers.sendMessage({ @@ -89,11 +88,13 @@ export async function applyWebviewReady( handlers.sendContextMenuItems(); handlers.sendPluginExporters?.(); handlers.sendPluginToolbarActions?.(); + handlers.sendGraphViewContributionStatuses?.(); handlers.sendPluginWebviewInjections(); handlers.sendActiveFile(); if (state.hasWorkspace && state.firstAnalysis) { await handlers.waitForFirstWorkspaceReady(); + handlers.sendGraphViewContributionStatuses?.(); } if (state.readyNotified) { diff --git a/packages/extension/src/extension/graphView/webview/nodeFile/edit.ts b/packages/extension/src/extension/graphView/webview/nodeFile/edit.ts index d1758fff9..1d57c0c5c 100644 --- a/packages/extension/src/extension/graphView/webview/nodeFile/edit.ts +++ b/packages/extension/src/extension/graphView/webview/nodeFile/edit.ts @@ -1,12 +1,4 @@ import type { WebviewToExtensionMessage } from '../../../../shared/protocol/webviewToExtension'; -import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; -import { - assignGraphLayoutOwner, - createDefaultGraphLayoutSettings, - normalizeGraphLayoutSettings, -} from '../../../repoSettings/graphLayout/model'; -import { getUndoManager } from '../../../undoManager'; -import { UpdateGraphLayoutAction } from '../messages/graphLayout'; export interface GraphViewNodeFileEditHandlers { timelineActive: boolean; @@ -17,11 +9,6 @@ export interface GraphViewNodeFileEditHandlers { createFolder(directory: string): Promise; toggleFavorites(paths: string[]): Promise; addToExclude(patterns: string[]): Promise; - asWebviewUri?(uri: import('vscode').Uri): { toString(): string }; - getConfig?(key: string, defaultValue: T): T; - sendMessage?(message: ExtensionToWebviewMessage): void; - updateConfig?(key: string, value: unknown): Promise; - workspaceFolder?: { uri: import('vscode').Uri }; } function isTimelineBoundEditMessage(message: WebviewToExtensionMessage): boolean { @@ -50,9 +37,9 @@ function applyTimelineBoundEditMessage( void handlers.renameFile(message.payload.path); return true; case 'CREATE_FILE': - return createGraphItemInContext(message.payload, directory => handlers.createFile(directory), handlers); + return createGraphItemInContext(message.payload, directory => handlers.createFile(directory)); case 'CREATE_FOLDER': - return createGraphItemInContext(message.payload, directory => handlers.createFolder(directory), handlers); + return createGraphItemInContext(message.payload, directory => handlers.createFolder(directory)); case 'ADD_TO_EXCLUDE': void handlers.addToExclude(message.payload.patterns); return true; @@ -62,50 +49,13 @@ function applyTimelineBoundEditMessage( } async function createGraphItemInContext( - payload: { directory: string; ownerSectionId?: string }, + payload: { directory: string }, createGraphItem: (directory: string) => Promise, - handlers: GraphViewNodeFileEditHandlers, ): Promise { - const itemId = await createGraphItem(payload.directory); - if (typeof itemId === 'string' && payload.ownerSectionId) { - await assignCreatedGraphItemOwner(itemId, payload.ownerSectionId, handlers); - } + await createGraphItem(payload.directory); return true; } -async function assignCreatedGraphItemOwner( - itemId: string, - ownerSectionId: string, - handlers: GraphViewNodeFileEditHandlers, -): Promise { - if (!handlers.getConfig || !handlers.sendMessage || !handlers.updateConfig) { - return; - } - - const currentLayout = normalizeGraphLayoutSettings( - handlers.getConfig('graphLayout', createDefaultGraphLayoutSettings()), - ); - const nextLayout = assignGraphLayoutOwner(currentLayout, { - itemId, - itemKind: 'node', - ownerSectionId, - updatedAt: new Date().toISOString(), - }); - - await getUndoManager().execute(new UpdateGraphLayoutAction( - 'Place Graph Item', - { - asWebviewUri: handlers.asWebviewUri ? uri => handlers.asWebviewUri?.(uri) ?? uri : undefined, - getConfig: (key, defaultValue) => handlers.getConfig?.(key, defaultValue) ?? defaultValue, - sendMessage: message => handlers.sendMessage?.(message), - updateConfig: (key, value) => handlers.updateConfig?.(key, value) ?? Promise.resolve(), - workspaceFolder: handlers.workspaceFolder, - }, - currentLayout, - nextLayout, - )); -} - export async function applyNodeFileEditMessage( message: WebviewToExtensionMessage, handlers: GraphViewNodeFileEditHandlers, diff --git a/packages/extension/src/extension/graphView/webview/plugins/contributionDispatch.ts b/packages/extension/src/extension/graphView/webview/plugins/contributionDispatch.ts index 17d416146..8cbb801c6 100644 --- a/packages/extension/src/extension/graphView/webview/plugins/contributionDispatch.ts +++ b/packages/extension/src/extension/graphView/webview/plugins/contributionDispatch.ts @@ -1,4 +1,8 @@ import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; +import type { + GraphViewContributionStatusKind, + IGraphViewContributionStatus, +} from '../../../../shared/protocol/extensionToWebview'; import { collectGraphViewContextMenuItems, collectGraphViewExporters, @@ -50,6 +54,71 @@ interface GraphViewPluginAnalyzer { registry: GraphViewPluginRegistry; } +interface GraphViewContributionSet { + runtimeNodes: Array<{ pluginId: string; contribution: { id: string; label: string } }>; + runtimeEdges: Array<{ pluginId: string; contribution: { id: string; label: string } }>; + projections: Array<{ pluginId: string; contribution: { id: string; label: string } }>; + forces: Array<{ pluginId: string; contribution: { id: string; label: string } }>; + nodeDragEnd: Array<{ pluginId: string; contribution: { id: string; label: string } }>; + contextMenu: Array<{ pluginId: string; contribution: { id: string; label: string } }>; + ui: Array<{ pluginId: string; contribution: { id: string; label: string } }>; +} + +interface GraphViewContributionAnalyzer { + registry: { + listAvailableGraphViewContributions?( + context?: { workspaceRoot?: string }, + ): Promise; + }; +} + +function collectContributionStatuses( + contributionSet: GraphViewContributionSet, +): IGraphViewContributionStatus[] { + const statuses: IGraphViewContributionStatus[] = []; + + for (const kind of [ + 'runtimeNodes', + 'runtimeEdges', + 'projections', + 'forces', + 'nodeDragEnd', + 'contextMenu', + 'ui', + ] as const satisfies readonly GraphViewContributionStatusKind[]) { + for (const entry of contributionSet[kind]) { + statuses.push({ + kind, + pluginId: entry.pluginId, + contributionId: entry.contribution.id, + label: entry.contribution.label, + }); + } + } + + return statuses; +} + +export async function sendGraphViewContributionStatuses( + analyzer: GraphViewContributionAnalyzer | undefined, + context: { workspaceRoot?: string }, + sendMessage: ( + message: Extract + ) => void, +): Promise { + if (!analyzer?.registry.listAvailableGraphViewContributions) { + return; + } + + const contributions = await analyzer.registry.listAvailableGraphViewContributions(context); + sendMessage({ + type: 'GRAPH_VIEW_CONTRIBUTIONS_UPDATED', + payload: { + contributions: collectContributionStatuses(contributions), + }, + }); +} + export function sendGraphViewContextMenuItems( analyzer: Pick | undefined, sendMessage: ( diff --git a/packages/extension/src/extension/graphView/webview/plugins/registration/followUp.ts b/packages/extension/src/extension/graphView/webview/plugins/registration/followUp.ts index 857ff42fa..2601bd6a5 100644 --- a/packages/extension/src/extension/graphView/webview/plugins/registration/followUp.ts +++ b/packages/extension/src/extension/graphView/webview/plugins/registration/followUp.ts @@ -12,6 +12,7 @@ export function sendExternalPluginRegistrationUpdates( handlers.sendContextMenuItems(); handlers.sendPluginExporters?.(); handlers.sendPluginToolbarActions?.(); + handlers.sendGraphViewContributionStatuses?.(); handlers.sendPluginWebviewInjections(); } diff --git a/packages/extension/src/extension/graphView/webview/plugins/registration/register.ts b/packages/extension/src/extension/graphView/webview/plugins/registration/register.ts index 882792c5e..1fb12c4cc 100644 --- a/packages/extension/src/extension/graphView/webview/plugins/registration/register.ts +++ b/packages/extension/src/extension/graphView/webview/plugins/registration/register.ts @@ -42,6 +42,7 @@ export interface GraphViewExternalPluginRegistrationHandlers { sendContextMenuItems(): void; sendPluginExporters?(): void; sendPluginToolbarActions?(): void; + sendGraphViewContributionStatuses?(): void; sendPluginWebviewInjections(): void; invalidateTimelineCache?(): Promise; analyzeAndSendData(): Promise; diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts b/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts index 92a2592d9..e4bff5c29 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts @@ -157,8 +157,10 @@ export interface GraphViewProviderMessageListenerSource { _sendCachedTimeline(): Promise; _sendDecorations(): void; _sendContextMenuItems(): void; + _sendPluginStatuses(): void; _sendPluginExporters?(): void; _sendPluginToolbarActions?(): void; + _sendGraphViewContributionStatuses?(): void; _sendPluginWebviewInjections(): void; _sendGraphControls?(): void; invalidatePluginFiles(pluginIds: readonly string[]): string[]; diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/pluginContext.ts b/packages/extension/src/extension/graphView/webview/providerMessages/pluginContext.ts index 4e4ead0ad..3f024b7eb 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/pluginContext.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/pluginContext.ts @@ -9,24 +9,6 @@ import { setPluginUserGroups, setPluginWebviewReadyNotified, } from './pluginState'; -import { createGraphLayoutUpdatedMessage } from '../../graphLayout/message'; - -type GraphLayoutWebviewSource = GraphViewProviderMessageListenerSource & { - _view?: { webview: { asWebviewUri(uri: import('vscode').Uri): { toString(): string } } }; - _panels?: ReadonlyArray<{ webview: { asWebviewUri(uri: import('vscode').Uri): { toString(): string } } }>; -}; - -function createGraphLayoutMessage( - source: GraphViewProviderMessageListenerSource, - dependencies: GraphViewProviderMessageListenerDependencies, -) { - const webviewSource = source as GraphLayoutWebviewSource; - const webview = webviewSource._view?.webview ?? webviewSource._panels?.[0]?.webview; - return createGraphLayoutUpdatedMessage({ - workspaceFolder: dependencies.workspace.workspaceFolders?.[0], - asWebviewUri: webview ? uri => webview.asWebviewUri(uri) : undefined, - }); -} type GraphViewProviderPluginContext = Pick< GraphViewMessageListenerContext, @@ -40,12 +22,12 @@ type GraphViewProviderPluginContext = Pick< | 'sendGraphControls' | 'sendFavorites' | 'sendSettings' - | 'sendGraphLayout' | 'sendCachedTimeline' | 'sendDecorations' | 'sendContextMenuItems' | 'sendPluginExporters' | 'sendPluginToolbarActions' + | 'sendGraphViewContributionStatuses' | 'sendPluginWebviewInjections' | 'sendActiveFile' | 'waitForFirstWorkspaceReady' @@ -81,12 +63,12 @@ export function createGraphViewProviderMessagePluginContext( sendGraphControls: () => source._sendGraphControls?.(), sendFavorites: () => source._sendFavorites(), sendSettings: () => source._sendSettings(), - sendGraphLayout: () => source._sendMessage(createGraphLayoutMessage(source, dependencies)), sendCachedTimeline: () => source._sendCachedTimeline(), sendDecorations: () => source._sendDecorations(), sendContextMenuItems: () => source._sendContextMenuItems(), sendPluginExporters: () => source._sendPluginExporters?.(), sendPluginToolbarActions: () => source._sendPluginToolbarActions?.(), + sendGraphViewContributionStatuses: () => source._sendGraphViewContributionStatuses?.(), sendPluginWebviewInjections: () => source._sendPluginWebviewInjections(), sendActiveFile: () => source._sendMessage({ type: 'ACTIVE_FILE_UPDATED', diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts b/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts index 4baae7336..e8753efa0 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts @@ -17,6 +17,11 @@ type GraphViewProviderSettingsContext = Pick< | 'updateConfig' | 'getInstalledPluginDefaultOptions' | 'reloadWorkspacePlugins' + | 'sendPluginStatuses' + | 'sendContextMenuItems' + | 'sendPluginToolbarActions' + | 'sendGraphViewContributionStatuses' + | 'sendPluginWebviewInjections' | 'sendGraphControls' | 'analyzeAndSendData' | 'reprocessPluginFiles' @@ -57,6 +62,24 @@ export function createGraphViewProviderMessageSettingsContext( reloadWorkspacePlugins: async () => { source._analyzerInitialized = false; await source._analyzer?.reloadWorkspacePlugins?.(); + if (source._analyzer?.reloadWorkspacePlugins) { + source._analyzerInitialized = true; + } + }, + sendPluginStatuses: () => { + source._sendPluginStatuses(); + }, + sendContextMenuItems: () => { + source._sendContextMenuItems(); + }, + sendPluginToolbarActions: () => { + source._sendPluginToolbarActions?.(); + }, + sendGraphViewContributionStatuses: () => { + source._sendGraphViewContributionStatuses?.(); + }, + sendPluginWebviewInjections: () => { + source._sendPluginWebviewInjections(); }, sendGraphControls: () => { source._sendGraphControls?.(); diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/persistence.ts b/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/persistence.ts index d5ba00cf3..018289bd1 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/persistence.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/persistence.ts @@ -11,7 +11,6 @@ export const SILENT_CONFIG_KEYS = new Set([ 'disabledPluginFilterPatterns', 'edgeVisibility', 'filterPatterns', - 'graphLayout', 'maxFiles', 'nodeColors', 'nodeVisibility', diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts index 29c05b6f9..4e420c9ed 100644 --- a/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts @@ -14,6 +14,11 @@ export interface GraphViewSettingsMessageHandlers { updateConfig(key: string, value: unknown): Promise; getInstalledPluginDefaultOptions?(packageName: string): Record | undefined; reloadWorkspacePlugins(): Promise; + sendPluginStatuses?(): void; + sendContextMenuItems?(): void; + sendPluginToolbarActions?(): void; + sendGraphViewContributionStatuses?(): void; + sendPluginWebviewInjections?(): void; recomputeGroups(): void; sendGroupsUpdated(): void; smartRebuild(id: string): void; diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts index f693fb10c..42598b924 100644 --- a/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts @@ -47,6 +47,11 @@ export async function applySettingsToggleMessage( ), ); await handlers.reloadWorkspacePlugins(); + handlers.sendPluginStatuses?.(); + handlers.sendContextMenuItems?.(); + handlers.sendPluginToolbarActions?.(); + handlers.sendGraphViewContributionStatuses?.(); + handlers.sendPluginWebviewInjections?.(); await handlers.analyzeAndSendData(); return true; } diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/updates/apply.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/updates/apply.ts index b2cee6a9b..e245ba81f 100644 --- a/packages/extension/src/extension/graphView/webview/settingsMessages/updates/apply.ts +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/updates/apply.ts @@ -1,9 +1,4 @@ import type { WebviewToExtensionMessage } from '../../../../../shared/protocol/webviewToExtension'; -import { - DEFAULT_GRAPH_LAYOUT_SETTINGS, - setGraphLayoutNodeCollapsed, - type GraphLayoutSettings, -} from '../../../../../shared/settings/graphLayout'; import type { GraphViewSettingsMessageHandlers, GraphViewSettingsMessageState, @@ -153,28 +148,6 @@ async function applyParticleSettingMessage( return true; } -async function applyGraphLayoutCollapseMessage( - message: WebviewToExtensionMessage, - handlers: GraphViewSettingsMessageHandlers, -): Promise { - if (message.type !== 'UPDATE_GRAPH_LAYOUT_COLLAPSE') { - return false; - } - - const currentLayout = handlers.getConfig( - 'graphLayout', - DEFAULT_GRAPH_LAYOUT_SETTINGS, - ); - const nextLayout = setGraphLayoutNodeCollapsed( - currentLayout, - message.payload.nodeId, - message.payload.collapsed, - ); - await handlers.updateConfig('graphLayout', nextLayout); - handlers.sendMessage({ type: 'GRAPH_LAYOUT_UPDATED', payload: nextLayout }); - return true; -} - async function applyDirectSettingsUpdateMessage( message: WebviewToExtensionMessage, state: GraphViewSettingsMessageState, @@ -209,7 +182,6 @@ const statefulSettingsMessageAppliers = [ const statelessSettingsMessageAppliers = [ applySimpleSettingsUpdate, applyParticleSettingMessage, - applyGraphLayoutCollapseMessage, applyGraphControlMessage, ] as const satisfies ReadonlyArray<( message: WebviewToExtensionMessage, diff --git a/packages/extension/src/extension/pipeline/plugins/bootstrap.ts b/packages/extension/src/extension/pipeline/plugins/bootstrap.ts index 501a918fe..542967a99 100644 --- a/packages/extension/src/extension/pipeline/plugins/bootstrap.ts +++ b/packages/extension/src/extension/pipeline/plugins/bootstrap.ts @@ -97,6 +97,7 @@ export async function initializeWorkspacePipeline( if (workspaceRoot && settings) { const loadedPackagePlugins = await loadCodeGraphyWorkspacePluginPackages({ settings, + workspaceRoot, ...(dependencies.userHomeDir ? { homeDir: dependencies.userHomeDir } : {}), ...(dependencies.warn ? { warn: dependencies.warn } : {}), }); @@ -104,6 +105,7 @@ export async function initializeWorkspacePipeline( for (const loadedPlugin of loadedPackagePlugins) { registry.register(loadedPlugin.plugin, { sourcePackage: loadedPlugin.packageName, + sourcePackageRoot: loadedPlugin.record.packageRoot, ...(loadedPlugin.options ? { options: loadedPlugin.options } : {}), }); } diff --git a/packages/extension/src/extension/pipeline/plugins/statusBuilder.ts b/packages/extension/src/extension/pipeline/plugins/statusBuilder.ts index 4b3b579d7..1b733058c 100644 --- a/packages/extension/src/extension/pipeline/plugins/statusBuilder.ts +++ b/packages/extension/src/extension/pipeline/plugins/statusBuilder.ts @@ -66,6 +66,10 @@ function getPluginWorkspaceStatus( return totalConnections > 0 ? 'active' : 'installed'; } +function isUserFacingPlugin(pluginInfo: IPluginInfo): boolean { + return !pluginInfo.builtIn || !!pluginInfo.sourcePackage; +} + export function buildWorkspacePluginStatuses(options: IWorkspacePluginStatusOptions): IPluginStatus[] { const { disabledPlugins, @@ -79,7 +83,7 @@ export function buildWorkspacePluginStatuses(options: IWorkspacePluginStatusOpti const statuses: IPluginStatus[] = []; const registeredPackageNames = new Set(); - for (const pluginInfo of pluginInfos) { + for (const pluginInfo of pluginInfos.filter(isUserFacingPlugin)) { const plugin = pluginInfo.plugin; const matchingFiles = getPluginMatchingFiles(pluginInfo, discoveredFiles); const totalConnections = countPluginConnections(pluginInfo, fileConnections); diff --git a/packages/extension/src/extension/repoSettings/defaults.ts b/packages/extension/src/extension/repoSettings/defaults.ts index ddbb84407..e1ef5b1a0 100644 --- a/packages/extension/src/extension/repoSettings/defaults.ts +++ b/packages/extension/src/extension/repoSettings/defaults.ts @@ -11,10 +11,6 @@ import { createDefaultNodeColors, createDefaultNodeVisibility, } from '../../shared/graphControls/defaults/maps'; -import { - createDefaultGraphLayoutSettings, - type GraphLayoutSettings, -} from '../../shared/settings/graphLayout'; export interface ICodeGraphyRepoSettings { version: 1; @@ -23,6 +19,7 @@ export interface ICodeGraphyRepoSettings { respectGitignore: boolean; showOrphans: boolean; plugins: CodeGraphyWorkspacePluginSettings[]; + pluginData: Record; nodeColors: Record; nodeVisibility: Record; edgeVisibility: Record; @@ -55,7 +52,6 @@ export interface ICodeGraphyRepoSettings { maxCommits: number; playbackSpeed: number; }; - graphLayout: GraphLayoutSettings; } export function createDefaultCodeGraphyRepoSettings(): ICodeGraphyRepoSettings { @@ -68,6 +64,7 @@ export function createDefaultCodeGraphyRepoSettings(): ICodeGraphyRepoSettings { plugins: [{ package: CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, }], + pluginData: {}, nodeColors: createDefaultNodeColors(), nodeVisibility: createDefaultNodeVisibility(), edgeVisibility: createDefaultEdgeVisibility(), @@ -100,6 +97,5 @@ export function createDefaultCodeGraphyRepoSettings(): ICodeGraphyRepoSettings { maxCommits: 500, playbackSpeed: 1, }, - graphLayout: createDefaultGraphLayoutSettings(), }; } diff --git a/packages/extension/src/extension/repoSettings/graphLayout/model.ts b/packages/extension/src/extension/repoSettings/graphLayout/model.ts deleted file mode 100644 index 1dbb863a4..000000000 --- a/packages/extension/src/extension/repoSettings/graphLayout/model.ts +++ /dev/null @@ -1,893 +0,0 @@ -import { isPlainObject } from '../store/model/plainObject'; -import { - createDefaultGraphLayoutSettings, - DEFAULT_GRAPH_SECTION_COLOR, - setGraphLayoutNodeCollapsed, - type GraphLayoutCoordinate2D, - type GraphLayoutCoordinate3D, - type GraphLayoutMode, - type GraphLayoutOwnership, - type GraphLayoutOwnershipUpdate, - type GraphLayoutPinnedNode, - type GraphLayoutSection, - type GraphLayoutSectionCreate, - type GraphLayoutSectionUpdate, - type GraphLayoutSettings, -} from '../../../shared/settings/graphLayout'; - -export { createDefaultGraphLayoutSettings }; -export { setGraphLayoutNodeCollapsed }; -export type { - GraphLayoutCoordinate2D, - GraphLayoutCoordinate3D, - GraphLayoutMode, - GraphLayoutOwnership, - GraphLayoutOwnershipUpdate, - GraphLayoutPinnedNode, - GraphLayoutSection, - GraphLayoutSectionCreate, - GraphLayoutSectionUpdate, - GraphLayoutSettings, -}; - -function readRequiredString(value: unknown): string | undefined { - return typeof value === 'string' && value.length > 0 - ? value - : undefined; -} - -function readString(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; -} - -function readFiniteNumber(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) - ? value - : undefined; -} - -function readPositiveNumber(value: unknown): number | undefined { - const numberValue = readFiniteNumber(value); - return numberValue !== undefined && numberValue > 0 - ? numberValue - : undefined; -} - -function readCoordinate2D(value: unknown): GraphLayoutCoordinate2D | undefined { - if (!isPlainObject(value)) { - return undefined; - } - - const x = readFiniteNumber(value.x); - const y = readFiniteNumber(value.y); - if (x === undefined || y === undefined) { - return undefined; - } - - return { x, y }; -} - -function readCoordinate3D(value: unknown): GraphLayoutCoordinate3D | undefined { - const coordinate2D = readCoordinate2D(value); - if (!coordinate2D || !isPlainObject(value)) { - return undefined; - } - - const z = readFiniteNumber(value.z); - if (z === undefined) { - return undefined; - } - - return { ...coordinate2D, z }; -} - -interface MatchingRecordIdentity { - id: string; - updatedAt: string; -} - -function readMatchingRecordIdentity( - value: Record, - key: string, - idField: 'id' | 'itemId' | 'nodeId', -): MatchingRecordIdentity | undefined { - const id = readRequiredString(value[idField]); - const updatedAt = readRequiredString(value.updatedAt); - if (id !== key || !updatedAt) { - return undefined; - } - - return { id, updatedAt }; -} - -function readKeyedRecordIdentity( - value: Record, - key: string, - idField: 'id' | 'nodeId', -): MatchingRecordIdentity | undefined { - const explicitId = readString(value[idField]); - const id = explicitId ?? key; - const updatedAt = readRequiredString(value.updatedAt); - if (id !== key || !updatedAt) { - return undefined; - } - - return { id, updatedAt }; -} - -function readKeyedRecordId( - value: Record, - key: string, - idField: 'id' | 'nodeId', -): string | undefined { - const explicitId = readString(value[idField]); - const id = explicitId ?? key; - return id === key ? id : undefined; -} - -function readPinnedNodeCoordinates( - value: Record, -): Pick | undefined { - const twoDimensional = readCoordinate2D(value['2D']) - ?? readCoordinate2D(value.twoDimensional); - const threeDimensional = readCoordinate3D(value['3D']) - ?? readCoordinate3D(value.threeDimensional); - if (!twoDimensional && !threeDimensional) { - return undefined; - } - - return { - ...(twoDimensional ? { '2D': twoDimensional } : {}), - ...(threeDimensional ? { '3D': threeDimensional } : {}), - }; -} - -function normalizePinnedNode( - key: string, - value: unknown, -): GraphLayoutPinnedNode | undefined { - if (!isPlainObject(value)) { - return undefined; - } - - const nodeId = readKeyedRecordId(value, key, 'nodeId'); - const coordinates = readPinnedNodeCoordinates(value); - if (!nodeId || !coordinates) { - return undefined; - } - - return { - nodeId, - ...coordinates, - }; -} - -function normalizePinnedNodes(value: unknown): Record { - if (!isPlainObject(value)) { - return {}; - } - - const pinnedNodes: Record = {}; - for (const [key, entryValue] of Object.entries(value)) { - const pinnedNode = normalizePinnedNode(key, entryValue); - if (pinnedNode) { - pinnedNodes[key] = pinnedNode; - } - } - - return pinnedNodes; -} - -function normalizeCollapsedNodes(value: unknown): Record { - if (!isPlainObject(value)) { - return {}; - } - - const collapsedNodes: Record = {}; - for (const [key, entryValue] of Object.entries(value)) { - if (typeof entryValue === 'boolean') { - collapsedNodes[key] = entryValue; - } - } - - return collapsedNodes; -} - -interface SectionTextFields { - color: string; - icon?: string; - id: string; - label: string; - updatedAt: string; -} - -interface SectionBounds { - height: number; - width: number; - x: number; - y: number; -} - -function readSectionTextFields( - value: Record, - key: string, -): SectionTextFields | undefined { - const identity = readKeyedRecordIdentity(value, key, 'id'); - const label = readString(value.label); - const color = readRequiredString(value.color); - const icon = readOptionalSectionString(value.icon); - if (!identity || label === undefined || !color) { - return undefined; - } - - return { - color, - ...(icon ? { icon } : {}), - id: identity.id, - label, - updatedAt: identity.updatedAt, - }; -} - -function readSectionBounds(value: Record): SectionBounds | undefined { - const x = readFiniteNumber(value.x); - const y = readFiniteNumber(value.y); - const width = readPositiveNumber(value.width); - const height = readPositiveNumber(value.height); - if (x === undefined || y === undefined || width === undefined || height === undefined) { - return undefined; - } - - return { height, width, x, y }; -} - -function readSectionCollapsed(value: Record): boolean | undefined { - return typeof value.collapsed === 'boolean' ? value.collapsed : undefined; -} - -function readOptionalSectionString(value: unknown): string | undefined { - return typeof value === 'string' && value.length > 0 ? value : undefined; -} - -function normalizeSection( - key: string, - value: unknown, -): GraphLayoutSection | undefined { - if (!isPlainObject(value)) { - return undefined; - } - - const text = readSectionTextFields(value, key); - const bounds = readSectionBounds(value); - const collapsed = readSectionCollapsed(value); - if (!text || !bounds || collapsed === undefined) { - return undefined; - } - - return { - ...text, - ...bounds, - collapsed, - }; -} - -function normalizeSections(value: unknown): Record { - if (!isPlainObject(value)) { - return {}; - } - - const sections: Record = {}; - for (const [key, entryValue] of Object.entries(value)) { - const section = normalizeSection(key, entryValue); - if (section) { - sections[key] = section; - } - } - - return sections; -} - -function normalizeOwnerSectionId( - itemId: string, - itemKind: GraphLayoutOwnership['itemKind'], - ownerSectionId: unknown, - sections: Record, -): string | null | undefined { - if (ownerSectionId === null) { - return null; - } - - if (typeof ownerSectionId !== 'string' || !(ownerSectionId in sections)) { - return undefined; - } - - if (itemKind === 'section' && ownerSectionId === itemId) { - return undefined; - } - - return ownerSectionId; -} - -function readOwnershipItemKind(value: unknown): GraphLayoutOwnership['itemKind'] | undefined { - return value === 'node' || value === 'section' ? value : undefined; -} - -function ownsKnownSection( - itemId: string, - itemKind: GraphLayoutOwnership['itemKind'], - sections: Record, -): boolean { - return itemKind === 'node' || itemId in sections; -} - -function inferOwnershipItemKind( - itemId: string, - sections: Record, -): GraphLayoutOwnership['itemKind'] { - return itemId in sections ? 'section' : 'node'; -} - -function normalizeCompactOwnershipRecord( - key: string, - ownerSectionIdValue: unknown, - sections: Record, -): GraphLayoutOwnership | undefined { - const itemKind = inferOwnershipItemKind(key, sections); - const ownerSectionId = normalizeOwnerSectionId( - key, - itemKind, - ownerSectionIdValue, - sections, - ); - if (ownerSectionId === undefined || ownerSectionId === null) { - return undefined; - } - - return { - itemId: key, - itemKind, - ownerSectionId, - updatedAt: sections[ownerSectionId].updatedAt, - }; -} - -function normalizeGroupedOwnershipRecords( - ownerSectionId: string, - value: unknown, - sections: Record, -): GraphLayoutOwnership[] { - if (!(ownerSectionId in sections) || !Array.isArray(value)) { - return []; - } - - const records: GraphLayoutOwnership[] = []; - const seenItemIds = new Set(); - for (const itemId of value) { - if (typeof itemId !== 'string' || itemId.length === 0 || seenItemIds.has(itemId)) { - continue; - } - - seenItemIds.add(itemId); - const itemKind = inferOwnershipItemKind(itemId, sections); - const normalizedOwnerSectionId = normalizeOwnerSectionId( - itemId, - itemKind, - ownerSectionId, - sections, - ); - if (normalizedOwnerSectionId === undefined || normalizedOwnerSectionId === null) { - continue; - } - - records.push({ - itemId, - itemKind, - ownerSectionId: normalizedOwnerSectionId, - updatedAt: sections[normalizedOwnerSectionId].updatedAt, - }); - } - - return records; -} - -function normalizeOwnershipRecord( - key: string, - value: unknown, - sections: Record, -): GraphLayoutOwnership | undefined { - if (typeof value === 'string') { - return normalizeCompactOwnershipRecord(key, value, sections); - } - - if (!isPlainObject(value)) { - return undefined; - } - - const identity = readMatchingRecordIdentity(value, key, 'itemId'); - const itemKind = readOwnershipItemKind(value.itemKind); - if (!identity || !itemKind || !ownsKnownSection(identity.id, itemKind, sections)) { - return undefined; - } - - const ownerSectionId = normalizeOwnerSectionId( - identity.id, - itemKind, - value.ownerSectionId, - sections, - ); - if (ownerSectionId === undefined || ownerSectionId === null) { - return undefined; - } - - return { - itemId: identity.id, - itemKind, - ownerSectionId, - updatedAt: identity.updatedAt, - }; -} - -function normalizeOwnershipRecords( - key: string, - value: unknown, - sections: Record, -): GraphLayoutOwnership[] { - if (Array.isArray(value)) { - return normalizeGroupedOwnershipRecords(key, value, sections); - } - - const record = normalizeOwnershipRecord(key, value, sections); - return record ? [record] : []; -} - -export function wouldCreateGraphLayoutOwnershipCycle( - ownership: Readonly>, - sectionId: string, - ownerSectionId: string | null, -): boolean { - let currentOwnerId = ownerSectionId; - const visited = new Set([sectionId]); - - while (currentOwnerId) { - if (visited.has(currentOwnerId)) { - return true; - } - - visited.add(currentOwnerId); - const ownerRecord = ownership[currentOwnerId]; - currentOwnerId = ownerRecord?.itemKind === 'section' - ? ownerRecord.ownerSectionId - : null; - } - - return false; -} - -function normalizeOwnership( - value: unknown, - sections: Record, -): Record { - if (!isPlainObject(value)) { - return {}; - } - - const ownership: Record = {}; - for (const [key, entryValue] of Object.entries(value)) { - const records = normalizeOwnershipRecords(key, entryValue, sections); - for (const record of records) { - if (record.itemId in ownership) { - continue; - } - - if ( - record.itemKind === 'section' - && wouldCreateGraphLayoutOwnershipCycle( - ownership, - record.itemId, - record.ownerSectionId, - ) - ) { - continue; - } - - ownership[record.itemId] = record; - } - } - - return ownership; -} - -export function normalizeGraphLayoutSettings(value: unknown): GraphLayoutSettings { - if (!isPlainObject(value)) { - return createDefaultGraphLayoutSettings(); - } - - const sections = normalizeSections(value.sections); - - return { - collapsedNodes: normalizeCollapsedNodes(value.collapsedNodes), - pinnedNodes: normalizePinnedNodes(value.pinnedNodes), - sections, - ownership: normalizeOwnership(value.ownership, sections), - }; -} - -export function assignGraphLayoutOwner( - layout: GraphLayoutSettings, - ownership: GraphLayoutOwnership, -): GraphLayoutSettings { - if (ownership.ownerSectionId === null) { - if (!ownsKnownSection(ownership.itemId, ownership.itemKind, layout.sections)) { - throw new Error('Graph Section ownership record is invalid.'); - } - - const nextOwnership = { ...layout.ownership }; - delete nextOwnership[ownership.itemId]; - return { - ...layout, - ownership: nextOwnership, - }; - } - - const normalizedRecord = normalizeOwnershipRecord( - ownership.itemId, - ownership, - layout.sections, - ); - if (!normalizedRecord) { - throw new Error('Graph Section ownership record is invalid.'); - } - - if ( - normalizedRecord.itemKind === 'section' - && wouldCreateGraphLayoutOwnershipCycle( - layout.ownership, - normalizedRecord.itemId, - normalizedRecord.ownerSectionId, - ) - ) { - throw new Error('Graph Section ownership cannot create a cycle.'); - } - - return { - ...layout, - ownership: { - ...layout.ownership, - [normalizedRecord.itemId]: normalizedRecord, - }, - }; -} - -export interface GraphLayoutNodePinUpdate { - graphMode: GraphLayoutMode; - nodeId: string; - position: GraphLayoutCoordinate2D | GraphLayoutCoordinate3D; -} - -function isCoordinate3D( - position: GraphLayoutCoordinate2D | GraphLayoutCoordinate3D, -): position is GraphLayoutCoordinate3D { - return 'z' in position; -} - -export function setGraphLayoutNodePin( - layout: GraphLayoutSettings, - update: GraphLayoutNodePinUpdate, -): GraphLayoutSettings { - const existing = layout.pinnedNodes[update.nodeId]; - const nextPinnedNode: GraphLayoutPinnedNode = { - nodeId: update.nodeId, - '2D': update.graphMode === '2d' - ? { x: update.position.x, y: update.position.y } - : existing?.['2D'], - '3D': update.graphMode === '3d' && isCoordinate3D(update.position) - ? { x: update.position.x, y: update.position.y, z: update.position.z } - : existing?.['3D'], - }; - - return normalizeGraphLayoutSettings({ - ...layout, - pinnedNodes: { - ...layout.pinnedNodes, - [update.nodeId]: nextPinnedNode, - }, - }); -} - -export function clearGraphLayoutNodePin( - layout: GraphLayoutSettings, - nodeId: string, - graphMode: GraphLayoutMode, -): GraphLayoutSettings { - const existing = layout.pinnedNodes[nodeId]; - if (!existing) { - return layout; - } - - const nextPinnedNodes = { ...layout.pinnedNodes }; - const nextPinnedNode: GraphLayoutPinnedNode = { - ...existing, - ...(graphMode === '2d' ? { '2D': undefined } : {}), - ...(graphMode === '3d' ? { '3D': undefined } : {}), - }; - - if (!nextPinnedNode['2D'] && !nextPinnedNode['3D']) { - delete nextPinnedNodes[nodeId]; - } else { - nextPinnedNodes[nodeId] = nextPinnedNode; - } - - return normalizeGraphLayoutSettings({ - ...layout, - pinnedNodes: nextPinnedNodes, - }); -} - -export interface GraphLayoutSectionCreateUpdate extends GraphLayoutSectionCreate { - updatedAt: string; -} - -export interface GraphLayoutSectionPatch { - sectionId: string; - updates: GraphLayoutSectionUpdate; - updatedAt: string; -} - -function getNextGraphLayoutSectionNumber( - sections: Readonly>, -): number { - let nextNumber = 1; - for (const sectionId of Object.keys(sections)) { - const match = /^section-(\d+)$/.exec(sectionId); - if (!match) { - continue; - } - - nextNumber = Math.max(nextNumber, Number(match[1]) + 1); - } - - return nextNumber; -} - -function getUniqueMemberNodeIds(memberNodeIds: readonly string[] | undefined): string[] { - return [...new Set((memberNodeIds ?? []).filter(nodeId => nodeId.length > 0))]; -} - -function getUniqueMemberSectionIds(memberSectionIds: readonly string[] | undefined): string[] { - return [...new Set((memberSectionIds ?? []).filter(sectionId => sectionId.length > 0))]; -} - -function assertGraphLayoutOwnerExists( - sections: Readonly>, - ownerSectionId: string | null | undefined, -): string | null { - if (ownerSectionId === undefined || ownerSectionId === null) { - return null; - } - - if (!(ownerSectionId in sections)) { - throw new Error('Graph Section owner does not exist.'); - } - - return ownerSectionId; -} - -export function createGraphLayoutSection( - layout: GraphLayoutSettings, - create: GraphLayoutSectionCreateUpdate, -): GraphLayoutSettings { - const sectionNumber = getNextGraphLayoutSectionNumber(layout.sections); - const sectionId = `section-${sectionNumber}`; - const ownerSectionId = assertGraphLayoutOwnerExists(layout.sections, create.ownerSectionId); - const icon = readOptionalSectionString(create.icon); - const section: GraphLayoutSection = { - id: sectionId, - label: readOptionalSectionString(create.label) ?? `Section ${sectionNumber}`, - ...(icon ? { icon } : {}), - color: readOptionalSectionString(create.color) ?? DEFAULT_GRAPH_SECTION_COLOR, - x: create.x, - y: create.y, - width: create.width, - height: create.height, - collapsed: false, - updatedAt: create.updatedAt, - }; - const sectionOwnership: Record = ownerSectionId === null - ? {} - : { - [sectionId]: { - itemId: sectionId, - itemKind: 'section', - ownerSectionId, - updatedAt: create.updatedAt, - }, - }; - const ownership: Record = { - ...layout.ownership, - ...sectionOwnership, - }; - - let nextLayout = normalizeGraphLayoutSettings({ - ...layout, - sections: { - ...layout.sections, - [sectionId]: section, - }, - ownership, - }); - - for (const nodeId of getUniqueMemberNodeIds(create.memberNodeIds)) { - nextLayout = assignGraphLayoutOwner(nextLayout, { - itemId: nodeId, - itemKind: 'node', - ownerSectionId: sectionId, - updatedAt: create.updatedAt, - }); - } - - for (const memberSectionId of getUniqueMemberSectionIds(create.memberSectionIds)) { - if (!(memberSectionId in nextLayout.sections)) { - throw new Error('Graph Section member does not exist.'); - } - - nextLayout = assignGraphLayoutOwner(nextLayout, { - itemId: memberSectionId, - itemKind: 'section', - ownerSectionId: sectionId, - updatedAt: create.updatedAt, - }); - } - - return nextLayout; -} - -function readOptionalNumberUpdate( - nextValue: number | undefined, - currentValue: number, -): number { - return nextValue === undefined ? currentValue : nextValue; -} - -function buildUpdatedGraphLayoutSection( - existing: GraphLayoutSection, - patch: GraphLayoutSectionPatch, -): GraphLayoutSection { - const icon = patch.updates.icon === undefined - ? existing.icon - : readOptionalSectionString(patch.updates.icon); - - return { - ...existing, - collapsed: patch.updates.collapsed ?? existing.collapsed, - color: readOptionalSectionString(patch.updates.color) ?? existing.color, - height: readOptionalNumberUpdate(patch.updates.height, existing.height), - ...(icon ? { icon } : { icon: undefined }), - label: patch.updates.label === undefined ? existing.label : patch.updates.label, - width: readOptionalNumberUpdate(patch.updates.width, existing.width), - x: readOptionalNumberUpdate(patch.updates.x, existing.x), - y: readOptionalNumberUpdate(patch.updates.y, existing.y), - updatedAt: patch.updatedAt, - }; -} - -function getSectionMoveDelta( - existing: Pick, - nextSection: Pick, -): GraphLayoutCoordinate2D { - return { - x: nextSection.x - existing.x, - y: nextSection.y - existing.y, - }; -} - -function hasSectionMoveDelta(delta: GraphLayoutCoordinate2D): boolean { - return delta.x !== 0 || delta.y !== 0; -} - -function shouldMovePinnedNodeWithSection( - itemId: string, - pinnedNode: GraphLayoutPinnedNode, - sectionId: string, -): boolean { - return !!pinnedNode['2D'] && itemId === sectionId; -} - -function movePinnedGraphLayoutNodes( - layout: GraphLayoutSettings, - sectionId: string, - delta: GraphLayoutCoordinate2D, -): Record { - const nextPinnedNodes: Record = { ...layout.pinnedNodes }; - if (!hasSectionMoveDelta(delta)) { - return nextPinnedNodes; - } - - for (const [itemId, pinnedNode] of Object.entries(layout.pinnedNodes)) { - if (!shouldMovePinnedNodeWithSection(itemId, pinnedNode, sectionId)) { - continue; - } - - nextPinnedNodes[itemId] = { - ...pinnedNode, - '2D': { - x: pinnedNode['2D']!.x + delta.x, - y: pinnedNode['2D']!.y + delta.y, - }, - }; - } - - return nextPinnedNodes; -} - -export function updateGraphLayoutSection( - layout: GraphLayoutSettings, - patch: GraphLayoutSectionPatch, -): GraphLayoutSettings { - const existing = layout.sections[patch.sectionId]; - if (!existing) { - throw new Error('Graph Section does not exist.'); - } - - const nextSection = buildUpdatedGraphLayoutSection(existing, patch); - const delta = getSectionMoveDelta(existing, nextSection); - const nextSections: Record = { ...layout.sections }; - nextSections[patch.sectionId] = nextSection; - - return normalizeGraphLayoutSettings({ - ...layout, - pinnedNodes: movePinnedGraphLayoutNodes(layout, patch.sectionId, delta), - sections: nextSections, - }); -} - -export interface GraphLayoutSectionDelete { - sectionId: string; - updatedAt: string; -} - -export function deleteGraphLayoutSection( - layout: GraphLayoutSettings, - deletion: GraphLayoutSectionDelete, -): GraphLayoutSettings { - if (!(deletion.sectionId in layout.sections)) { - throw new Error('Graph Section does not exist.'); - } - - const deletedOwnerId = layout.ownership[deletion.sectionId]?.ownerSectionId ?? null; - const nextSections = { ...layout.sections }; - delete nextSections[deletion.sectionId]; - - const nextPinnedNodes = { ...layout.pinnedNodes }; - delete nextPinnedNodes[deletion.sectionId]; - - const nextOwnership: Record = {}; - for (const [itemId, record] of Object.entries(layout.ownership)) { - if (itemId === deletion.sectionId) { - continue; - } - - if (record.ownerSectionId !== deletion.sectionId) { - nextOwnership[itemId] = record; - continue; - } - - if (deletedOwnerId) { - nextOwnership[itemId] = { - ...record, - ownerSectionId: deletedOwnerId, - updatedAt: deletion.updatedAt, - }; - } - } - - return normalizeGraphLayoutSettings({ - ...layout, - ownership: nextOwnership, - pinnedNodes: nextPinnedNodes, - sections: nextSections, - }); -} - -export interface GraphLayoutOwnershipPatch extends GraphLayoutOwnershipUpdate { - updatedAt: string; -} diff --git a/packages/extension/src/extension/repoSettings/store/model/persistedShape.ts b/packages/extension/src/extension/repoSettings/store/model/persistedShape.ts index b3115a8e5..5f687c1cb 100644 --- a/packages/extension/src/extension/repoSettings/store/model/persistedShape.ts +++ b/packages/extension/src/extension/repoSettings/store/model/persistedShape.ts @@ -1,5 +1,4 @@ import { isPlainObject } from './plainObject'; -import { normalizeGraphLayoutSettings } from '../../graphLayout/model'; import { pruneGraphControlConfigMap, type GraphControlConfigKey } from '../../../../shared/graphControls/settings'; const TOP_LEVEL_SETTINGS_KEYS = new Set([ @@ -9,6 +8,7 @@ const TOP_LEVEL_SETTINGS_KEYS = new Set([ 'respectGitignore', 'showOrphans', 'plugins', + 'pluginData', 'nodeColors', 'nodeVisibility', 'edgeVisibility', @@ -31,7 +31,6 @@ const TOP_LEVEL_SETTINGS_KEYS = new Set([ 'nodeSizeMode', 'physics', 'timeline', - 'graphLayout', ]); const PHYSICS_SETTINGS_KEYS = new Set([ @@ -180,15 +179,19 @@ function normalizePersistedPlugins(normalized: Record): void { normalized.plugins = plugins; } -function normalizePersistedLegend(normalized: Record): void { - if ('legend' in normalized) { - normalized.legend = normalizePersistedLegendRules(normalized.legend); +function normalizePersistedPluginData(normalized: Record): void { + if (!('pluginData' in normalized)) { + return; + } + + if (!isPlainObject(normalized.pluginData)) { + delete normalized.pluginData; } } -function normalizePersistedGraphLayout(normalized: Record): void { - if ('graphLayout' in normalized) { - normalized.graphLayout = normalizeGraphLayoutSettings(normalized.graphLayout); +function normalizePersistedLegend(normalized: Record): void { + if ('legend' in normalized) { + normalized.legend = normalizePersistedLegendRules(normalized.legend); } } @@ -218,9 +221,9 @@ export function normalizePersistedSettingsShape( const normalized = pickTopLevelSettings(value); normalizePersistedPlugins(normalized); + normalizePersistedPluginData(normalized); normalizePersistedFilterPatterns(normalized); normalizePersistedLegend(normalized); - normalizePersistedGraphLayout(normalized); normalizePersistedGraphControls(normalized); return normalized; } diff --git a/packages/extension/src/extension/repoSettings/store/persistence/serialization.ts b/packages/extension/src/extension/repoSettings/store/persistence/serialization.ts index ef83d38c6..9c645a1c7 100644 --- a/packages/extension/src/extension/repoSettings/store/persistence/serialization.ts +++ b/packages/extension/src/extension/repoSettings/store/persistence/serialization.ts @@ -2,52 +2,8 @@ import type { ICodeGraphyRepoSettings } from '../../defaults'; import { isPlainObject } from '../model/plainObject'; import { normalizePersistedSettingsShape } from '../model/persistedShape'; -function dropDuplicateRecordIdentity( - records: unknown, - identityKey: 'id' | 'nodeId', -): void { - if (!isPlainObject(records)) { - return; - } - - for (const [recordKey, value] of Object.entries(records)) { - if (!isPlainObject(value) || value[identityKey] !== recordKey) { - continue; - } - - delete value[identityKey]; - } -} - -function groupGraphLayoutOwnership(graphLayout: Record): void { - if (!isPlainObject(graphLayout.ownership)) { - return; - } - - const ownership: Record = {}; - for (const [itemId, record] of Object.entries(graphLayout.ownership)) { - if (isPlainObject(record) && typeof record.ownerSectionId === 'string') { - ownership[record.ownerSectionId] = ownership[record.ownerSectionId] ?? []; - ownership[record.ownerSectionId].push(itemId); - } - } - - graphLayout.ownership = ownership; -} - -function compactGraphLayoutSettings(persisted: Record): void { - if (!isPlainObject(persisted.graphLayout)) { - return; - } - - dropDuplicateRecordIdentity(persisted.graphLayout.pinnedNodes, 'nodeId'); - dropDuplicateRecordIdentity(persisted.graphLayout.sections, 'id'); - groupGraphLayoutOwnership(persisted.graphLayout); -} - export function serializeSettings(value: ICodeGraphyRepoSettings): string { const persisted = normalizePersistedSettingsShape(value); - compactGraphLayoutSettings(persisted); if (Array.isArray(persisted.legend)) { persisted.legend = persisted.legend diff --git a/packages/extension/src/shared/protocol/extensionToWebview.ts b/packages/extension/src/shared/protocol/extensionToWebview.ts index 593a23cfa..1fd2f4b0d 100644 --- a/packages/extension/src/shared/protocol/extensionToWebview.ts +++ b/packages/extension/src/shared/protocol/extensionToWebview.ts @@ -14,7 +14,6 @@ import type { } from '../settings/modes'; import type { IPhysicsSettings } from '../settings/physics'; import type { IGroup } from '../settings/groups'; -import type { GraphLayoutSettings } from '../settings/graphLayout'; import type { ITimelineData } from '../timeline/contracts'; export interface IPluginFilterPatternGroup { @@ -23,6 +22,22 @@ export interface IPluginFilterPatternGroup { patterns: string[]; } +export type GraphViewContributionStatusKind = + | 'runtimeNodes' + | 'runtimeEdges' + | 'projections' + | 'forces' + | 'nodeDragEnd' + | 'contextMenu' + | 'ui'; + +export interface IGraphViewContributionStatus { + kind: GraphViewContributionStatusKind; + pluginId: string; + contributionId: string; + label: string; +} + export type ExtensionToWebviewMessage = | { type: 'GRAPH_DATA_UPDATED'; payload: IGraphData } | { @@ -35,7 +50,6 @@ export type ExtensionToWebviewMessage = } | { type: 'GRAPH_INDEX_PROGRESS'; payload: { phase: string; current: number; total: number } } | { type: 'GRAPH_CONTROLS_UPDATED'; payload: IGraphControlsSnapshot } - | { type: 'GRAPH_LAYOUT_UPDATED'; payload: GraphLayoutSettings } | { type: 'FIT_VIEW' } | { type: 'ZOOM_IN' } | { type: 'ZOOM_OUT' } @@ -96,6 +110,7 @@ export type ExtensionToWebviewMessage = }; } | { type: 'CONTEXT_MENU_ITEMS'; payload: { items: IPluginContextMenuItem[] } } + | { type: 'GRAPH_VIEW_CONTRIBUTIONS_UPDATED'; payload: { contributions: IGraphViewContributionStatus[] } } | { type: 'PLUGIN_EXPORTERS_UPDATED'; payload: { items: IPluginExporterItem[] } } | { type: 'PLUGIN_TOOLBAR_ACTIONS_UPDATED'; payload: { items: IPluginToolbarAction[] } } | { type: 'PLUGIN_WEBVIEW_INJECT'; payload: { pluginId: string; scripts: string[]; styles: string[] } } diff --git a/packages/extension/src/shared/protocol/webviewToExtension.ts b/packages/extension/src/shared/protocol/webviewToExtension.ts index fff54275f..6fd439fa1 100644 --- a/packages/extension/src/shared/protocol/webviewToExtension.ts +++ b/packages/extension/src/shared/protocol/webviewToExtension.ts @@ -6,25 +6,16 @@ import type { NodeSizeMode, } from '../settings/modes'; import type { IPhysicsSettings } from '../settings/physics'; -import type { - GraphLayoutCoordinate2D, - GraphLayoutCoordinate3D, - GraphLayoutMode, - GraphLayoutOwnershipUpdate, - GraphLayoutSectionCreate, - GraphLayoutSectionUpdate, -} from '../settings/graphLayout'; + +export interface GraphItemCreatePayload { + directory: string; +} export interface LegendIconImport { imagePath: string; contentsBase64: string; } -export interface GraphItemCreatePayload { - directory: string; - ownerSectionId?: string; -} - export type WebviewToExtensionMessage = | { type: 'NODE_SELECTED'; payload: { nodeId: string } } | { type: 'NODE_DOUBLE_CLICKED'; payload: { nodeId: string } } @@ -39,47 +30,6 @@ export type WebviewToExtensionMessage = | { type: 'CREATE_FILE'; payload: GraphItemCreatePayload } | { type: 'CREATE_FOLDER'; payload: GraphItemCreatePayload } | { type: 'TOGGLE_FAVORITE'; payload: { paths: string[] } } - | { - type: 'UPDATE_GRAPH_LAYOUT_PIN'; - payload: { - graphMode: GraphLayoutMode; - nodeId: string; - position: GraphLayoutCoordinate2D | GraphLayoutCoordinate3D; - }; - } - | { - type: 'CLEAR_GRAPH_LAYOUT_PIN'; - payload: { - graphMode: GraphLayoutMode; - nodeId: string; - }; - } - | { - type: 'UPDATE_GRAPH_LAYOUT_COLLAPSE'; - payload: { nodeId: string; collapsed: boolean }; - } - | { - type: 'CREATE_GRAPH_LAYOUT_SECTION'; - payload: GraphLayoutSectionCreate; - } - | { - type: 'UPDATE_GRAPH_LAYOUT_SECTION'; - payload: { - iconImports?: LegendIconImport[]; - sectionId: string; - updates: GraphLayoutSectionUpdate; - }; - } - | { - type: 'UPDATE_GRAPH_LAYOUT_OWNER'; - payload: GraphLayoutOwnershipUpdate; - } - | { - type: 'DELETE_GRAPH_LAYOUT_SECTION'; - payload: { - sectionId: string; - }; - } | { type: 'ADD_TO_EXCLUDE'; payload: { patterns: string[] } } | { type: 'REFRESH_GRAPH' } | { type: 'INDEX_GRAPH' } diff --git a/packages/extension/src/shared/settings/graphLayout.ts b/packages/extension/src/shared/settings/graphLayout.ts deleted file mode 100644 index e9a0cf367..000000000 --- a/packages/extension/src/shared/settings/graphLayout.ts +++ /dev/null @@ -1,395 +0,0 @@ -export type GraphLayoutMode = '2d' | '3d'; - -export const DEFAULT_GRAPH_SECTION_COLOR = '#60a5fa'; -export const DEFAULT_GRAPH_SECTION_WIDTH = 280; -export const DEFAULT_GRAPH_SECTION_HEIGHT = 180; -export const GRAPH_SECTION_SELECTION_PADDING = 64; - -export interface GraphLayoutCoordinate2D { - x: number; - y: number; -} - -export interface GraphLayoutCoordinate3D extends GraphLayoutCoordinate2D { - z: number; -} - -export interface GraphLayoutPinnedNode { - nodeId: string; - '2D'?: GraphLayoutCoordinate2D; - '3D'?: GraphLayoutCoordinate3D; -} - -export interface GraphLayoutSection { - id: string; - label: string; - icon?: string; - iconUrl?: string; - color: string; - x: number; - y: number; - width: number; - height: number; - collapsed: boolean; - updatedAt: string; -} - -export interface GraphLayoutOwnership { - itemId: string; - itemKind: 'node' | 'section'; - ownerSectionId: string | null; - updatedAt: string; -} - -export type GraphLayoutOwnershipUpdate = Omit; - -export interface GraphLayoutSettings { - collapsedNodes: Record; - pinnedNodes: Record; - sections: Record; - ownership: Record; -} - -export interface GraphLayoutSectionCreate { - color?: string; - height: number; - icon?: string; - label?: string; - memberNodeIds?: string[]; - memberSectionIds?: string[]; - ownerSectionId?: string | null; - width: number; - x: number; - y: number; -} - -export function getDefaultGraphSectionSize( - _graphViewportScale?: number | null, -): { height: number; width: number } { - return { - height: DEFAULT_GRAPH_SECTION_HEIGHT, - width: DEFAULT_GRAPH_SECTION_WIDTH, - }; -} - -export interface GraphLayoutSectionUpdate { - collapsed?: boolean; - color?: string; - height?: number; - icon?: string; - label?: string; - width?: number; - x?: number; - y?: number; -} - -export function createDefaultGraphLayoutSettings(): GraphLayoutSettings { - return { - collapsedNodes: {}, - pinnedNodes: {}, - sections: {}, - ownership: {}, - }; -} - -export const DEFAULT_GRAPH_LAYOUT_SETTINGS: GraphLayoutSettings = createDefaultGraphLayoutSettings(); - -export function getCollapsedGraphNodeIds(graphLayout: GraphLayoutSettings): string[] { - return Object.entries(graphLayout.collapsedNodes) - .filter(([, collapsed]) => collapsed) - .map(([nodeId]) => nodeId); -} - -export function setGraphLayoutNodeCollapsed( - graphLayout: GraphLayoutSettings, - nodeId: string, - collapsed: boolean, -): GraphLayoutSettings { - const collapsedNodes = { ...graphLayout.collapsedNodes }; - if (collapsed) { - collapsedNodes[nodeId] = true; - } else { - delete collapsedNodes[nodeId]; - } - - return { ...graphLayout, collapsedNodes }; -} - -export function getGraphLayoutPinCoordinate( - pinnedNode: GraphLayoutPinnedNode | undefined, - graphMode: GraphLayoutMode, -): GraphLayoutCoordinate2D | GraphLayoutCoordinate3D | undefined { - return graphMode === '2d' - ? pinnedNode?.['2D'] - : pinnedNode?.['3D']; -} - -export function isGraphLayoutPointInsideSection( - point: GraphLayoutCoordinate2D, - section: Pick, -): boolean { - return point.x >= section.x - && point.x <= section.x + section.width - && point.y >= section.y - && point.y <= section.y + section.height; -} - -interface GraphLayoutSectionAncestorWalk { - ancestorIds: string[]; - cycleDetected: boolean; -} - -function getNestedOwnerSectionId( - ownership: Readonly>, - sectionId: string, -): string | null { - const record = ownership[sectionId]; - return record?.itemKind === 'section' ? record.ownerSectionId : null; -} - -function walkGraphLayoutSectionAncestors( - ownership: Readonly>, - sectionId: string, -): GraphLayoutSectionAncestorWalk { - const ancestorIds: string[] = []; - const visited = new Set([sectionId]); - let currentOwnerId = ownership[sectionId]?.ownerSectionId ?? null; - - while (currentOwnerId) { - if (visited.has(currentOwnerId)) { - return { ancestorIds, cycleDetected: true }; - } - - visited.add(currentOwnerId); - ancestorIds.push(currentOwnerId); - currentOwnerId = getNestedOwnerSectionId(ownership, currentOwnerId); - } - - return { ancestorIds, cycleDetected: false }; -} - -function isExpandedGraphLayoutSection(section: GraphLayoutSection | undefined): section is GraphLayoutSection { - return !!section && !section.collapsed; -} - -export function isGraphLayoutSectionDescendant( - ownership: Readonly>, - sectionId: string, - ancestorSectionId: string, -): boolean { - const walk = walkGraphLayoutSectionAncestors(ownership, sectionId); - return walk.ancestorIds.includes(ancestorSectionId); -} - -export function isGraphLayoutItemOwnedBySection( - ownership: Readonly>, - itemId: string, - ownerSectionId: string, -): boolean { - const record = ownership[itemId]; - if (!record?.ownerSectionId) { - return false; - } - - return record.ownerSectionId === ownerSectionId - || isGraphLayoutSectionDescendant(ownership, record.ownerSectionId, ownerSectionId); -} - -export function getGraphLayoutSectionDepth( - ownership: Readonly>, - sectionId: string, -): number { - return walkGraphLayoutSectionAncestors(ownership, sectionId).ancestorIds.length; -} - -export function isGraphLayoutSectionVisible( - sections: Readonly>, - ownership: Readonly>, - sectionId: string, -): boolean { - const section = sections[sectionId]; - if (!isExpandedGraphLayoutSection(section)) { - return false; - } - - const walk = walkGraphLayoutSectionAncestors(ownership, sectionId); - return !walk.cycleDetected - && walk.ancestorIds.every(ancestorId => isExpandedGraphLayoutSection(sections[ancestorId])); -} - -export function getGraphLayoutSectionWorldTopLeft( - layout: Pick, - sectionId: string, - visited = new Set(), -): GraphLayoutCoordinate2D | undefined { - const section = layout.sections[sectionId]; - if (!section) { - return undefined; - } - - if (visited.has(sectionId)) { - return { x: section.x, y: section.y }; - } - - const ownerSectionId = layout.ownership[sectionId]?.ownerSectionId ?? null; - if (!ownerSectionId) { - return { x: section.x, y: section.y }; - } - - visited.add(sectionId); - const ownerTopLeft = getGraphLayoutSectionWorldTopLeft(layout, ownerSectionId, visited); - return ownerTopLeft - ? { x: ownerTopLeft.x + section.x, y: ownerTopLeft.y + section.y } - : { x: section.x, y: section.y }; -} - -export function getGraphLayoutSectionWorldBounds( - layout: Pick, - sectionId: string, -): GraphLayoutSection | undefined { - const section = layout.sections[sectionId]; - const worldTopLeft = getGraphLayoutSectionWorldTopLeft(layout, sectionId); - return section && worldTopLeft - ? { ...section, x: worldTopLeft.x, y: worldTopLeft.y } - : undefined; -} - -export function getGraphLayoutCollapsedRepresentative( - layout: Pick, - itemId: string, -): string | null { - const visited = new Set([itemId]); - let representative = layout.sections[itemId]?.collapsed ? itemId : null; - let currentOwnerId = layout.ownership[itemId]?.ownerSectionId ?? null; - - while (currentOwnerId) { - if (visited.has(currentOwnerId)) { - return representative; - } - - visited.add(currentOwnerId); - if (layout.sections[currentOwnerId]?.collapsed) { - representative = currentOwnerId; - } - - currentOwnerId = layout.ownership[currentOwnerId]?.itemKind === 'section' - ? layout.ownership[currentOwnerId].ownerSectionId - : null; - } - - return representative; -} - -export function isGraphLayoutSectionNodeVisible( - layout: Pick, - sectionId: string, -): boolean { - const representative = getGraphLayoutCollapsedRepresentative(layout, sectionId); - return representative === null || representative === sectionId; -} - -export function isGraphLayoutItemHiddenByCollapsedSection( - layout: Pick, - itemId: string, -): boolean { - return getGraphLayoutCollapsedRepresentative(layout, itemId) !== null; -} - -export function countGraphLayoutHiddenDescendants( - layout: Pick, - sectionId: string, - nodeIds: readonly string[], -): number { - let count = 0; - - for (const nodeId of nodeIds) { - if (getGraphLayoutCollapsedRepresentative(layout, nodeId) === sectionId) { - count += 1; - } - } - - for (const descendantSectionId of Object.keys(layout.sections)) { - if ( - descendantSectionId !== sectionId - && getGraphLayoutCollapsedRepresentative(layout, descendantSectionId) === sectionId - ) { - count += 1; - } - } - - return count; -} - -export function sortGraphLayoutSectionsForRendering( - sections: readonly GraphLayoutSection[], - ownership: Readonly>, -): GraphLayoutSection[] { - return sections - .map((section, index) => ({ index, section })) - .sort((left, right) => { - const depthDifference = getGraphLayoutSectionDepth(ownership, left.section.id) - - getGraphLayoutSectionDepth(ownership, right.section.id); - return depthDifference === 0 - ? left.index - right.index - : depthDifference; - }) - .map(entry => entry.section); -} - -export function findDeepestGraphLayoutSectionAtPoint( - layout: Pick, - point: GraphLayoutCoordinate2D, -): string | null { - let selectedSectionId: string | null = null; - let selectedDepth = -1; - let selectedArea = Number.POSITIVE_INFINITY; - - for (const section of Object.values(layout.sections)) { - if ( - !isGraphLayoutSectionVisible(layout.sections, layout.ownership, section.id) - || !isGraphLayoutPointInsideSection(point, section) - ) { - continue; - } - - const depth = getGraphLayoutSectionDepth(layout.ownership, section.id); - const area = section.width * section.height; - if (depth > selectedDepth || (depth === selectedDepth && area < selectedArea)) { - selectedSectionId = section.id; - selectedDepth = depth; - selectedArea = area; - } - } - - return selectedSectionId; -} - -export function findDeepestGraphLayoutSectionAtWorldPoint( - layout: Pick, - point: GraphLayoutCoordinate2D, -): string | null { - let selectedSectionId: string | null = null; - let selectedDepth = -1; - let selectedArea = Number.POSITIVE_INFINITY; - - for (const sectionId of Object.keys(layout.sections)) { - const section = getGraphLayoutSectionWorldBounds(layout, sectionId); - if ( - !section - || !isGraphLayoutSectionVisible(layout.sections, layout.ownership, section.id) - || !isGraphLayoutPointInsideSection(point, section) - ) { - continue; - } - - const depth = getGraphLayoutSectionDepth(layout.ownership, section.id); - const area = section.width * section.height; - if (depth > selectedDepth || (depth === selectedDepth && area < selectedArea)) { - selectedSectionId = section.id; - selectedDepth = depth; - selectedArea = area; - } - } - - return selectedSectionId; -} diff --git a/packages/extension/src/shared/settings/modes.ts b/packages/extension/src/shared/settings/modes.ts index 85f0b8804..98cae2c8d 100644 --- a/packages/extension/src/shared/settings/modes.ts +++ b/packages/extension/src/shared/settings/modes.ts @@ -1,6 +1,6 @@ export type NodeSizeMode = 'connections' | 'file-size' | 'churn' | 'uniform'; -export type NodeShape2D = 'circle' | 'square' | 'diamond' | 'triangle' | 'hexagon' | 'star'; +export type NodeShape2D = 'circle' | 'square' | 'rectangle' | 'diamond' | 'triangle' | 'hexagon' | 'star'; export type NodeShape3D = | 'sphere' diff --git a/packages/extension/src/webview/app/shell/messageListener.ts b/packages/extension/src/webview/app/shell/messageListener.ts index bd55da07d..a09935ea0 100644 --- a/packages/extension/src/webview/app/shell/messageListener.ts +++ b/packages/extension/src/webview/app/shell/messageListener.ts @@ -19,13 +19,73 @@ export interface InjectAssetsParams { styles: string[]; } +export type ResetPluginAssets = (pluginId: string) => void; + +function removePluginRuntime( + pluginId: string, + pluginHost: WebviewPluginHost, + resetPluginAssets?: ResetPluginAssets, +): void { + pluginHost.removePlugin(pluginId); + resetPluginAssets?.(pluginId); +} + +function removeDisabledPluginRegistrations( + raw: { type?: unknown; payload?: unknown }, + pluginHost: WebviewPluginHost, + packagePluginIdsByPackageName: Map, + resetPluginAssets?: ResetPluginAssets, +): void { + if (raw.type !== 'PLUGINS_UPDATED' || !raw.payload || typeof raw.payload !== 'object') { + return; + } + + const plugins = (raw.payload as { plugins?: unknown }).plugins; + if (!Array.isArray(plugins)) { + return; + } + + for (const plugin of plugins) { + if (!plugin || typeof plugin !== 'object') { + continue; + } + + const candidate = plugin as { enabled?: unknown; id?: unknown; packageName?: unknown }; + if (typeof candidate.id !== 'string') { + continue; + } + + const packageName = typeof candidate.packageName === 'string' + ? candidate.packageName + : undefined; + if (candidate.enabled !== false && packageName) { + packagePluginIdsByPackageName.set(packageName, candidate.id); + continue; + } + + if (candidate.enabled === false) { + removePluginRuntime(candidate.id, pluginHost, resetPluginAssets); + if (packageName) { + const runtimePluginId = packagePluginIdsByPackageName.get(packageName); + if (runtimePluginId && runtimePluginId !== candidate.id) { + removePluginRuntime(runtimePluginId, pluginHost, resetPluginAssets); + } + packagePluginIdsByPackageName.delete(packageName); + } + } + } +} + /** * Create the message event handler for the App's window listener. */ export function createMessageHandler( injectPluginAssets: (params: InjectAssetsParams) => Promise, pluginHost: WebviewPluginHost, + resetPluginAssets?: ResetPluginAssets, ): (event: MessageEvent) => void { + const packagePluginIdsByPackageName = new Map(); + return (event: MessageEvent) => { const raw = event.data as { type?: unknown; payload?: unknown; data?: unknown }; if (!raw || typeof raw !== 'object' || typeof raw.type !== 'string') { @@ -50,6 +110,7 @@ export function createMessageHandler( return; } + removeDisabledPluginRegistrations(raw, pluginHost, packagePluginIdsByPackageName, resetPluginAssets); graphStore.getState().handleExtensionMessage(raw as ExtensionToWebviewMessage); }; } @@ -61,8 +122,9 @@ export function createMessageHandler( export function setupMessageListener( injectPluginAssets: (params: InjectAssetsParams) => Promise, pluginHost: WebviewPluginHost, + resetPluginAssets?: ResetPluginAssets, ): () => void { - const handleMessage = createMessageHandler(injectPluginAssets, pluginHost); + const handleMessage = createMessageHandler(injectPluginAssets, pluginHost, resetPluginAssets); window.addEventListener('message', handleMessage); const codeGraphyWindow = window as WindowWithCodeGraphyReadyFlag; // Keep the ready handshake single-shot for one webview page load. This avoids 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 (
+ s.isLoading); const graphIsIndexing = useGraphStore(s => s.graphIsIndexing); const graphIndexProgress = useGraphStore(s => s.graphIndexProgress); - const graphLayout = useGraphStore(s => s.graphLayout); const searchQuery = useGraphStore(s => s.searchQuery); const searchOptions = useGraphStore(s => s.searchOptions); const legends = useGraphStore(s => s.legends); @@ -35,7 +34,6 @@ export function useAppState() { isLoading, graphIsIndexing, graphIndexProgress, - graphLayout, searchQuery, searchOptions, legends, diff --git a/packages/extension/src/webview/app/shell/view.tsx b/packages/extension/src/webview/app/shell/view.tsx index 3c302a79b..e6ec23c6b 100644 --- a/packages/extension/src/webview/app/shell/view.tsx +++ b/packages/extension/src/webview/app/shell/view.tsx @@ -22,7 +22,7 @@ import { toFilterGlob } from '../../components/searchBar/filters/model'; import { buildVisibleGraphConfig } from '../../search/visibleGraphConfig'; export default function App(): React.ReactElement { - const { pluginHost, injectPluginAssets } = usePluginManager(); + const { pluginHost, injectPluginAssets, resetPluginAssets } = usePluginManager(); const { graphData, isLoading, @@ -38,7 +38,6 @@ export default function App(): React.ReactElement { timelineActive, activePanel, depthMode, - graphLayout, nodeColors, nodeVisibility, edgeVisibility, @@ -75,26 +74,24 @@ export default function App(): React.ReactElement { edgeTypes: graphEdgeTypes, edgeVisibility, filterPatterns: [], - graphLayout, nodeVisibility, searchOptions, searchQuery: '', showOrphans, })).graphData, - [edgeVisibility, graphData, graphEdgeTypes, graphLayout, nodeVisibility, searchOptions, showOrphans], + [edgeVisibility, graphData, graphEdgeTypes, nodeVisibility, searchOptions, showOrphans], ); const filterVisibleData = useMemo( () => deriveVisibleGraph(graphData, buildVisibleGraphConfig({ edgeTypes: graphEdgeTypes, edgeVisibility, filterPatterns: activeFilterPatterns, - graphLayout, nodeVisibility, searchOptions, searchQuery: '', showOrphans, })).graphData, - [activeFilterPatterns, edgeVisibility, graphData, graphEdgeTypes, graphLayout, nodeVisibility, searchOptions, showOrphans], + [activeFilterPatterns, edgeVisibility, graphData, graphEdgeTypes, nodeVisibility, searchOptions, showOrphans], ); const { filteredData, @@ -113,7 +110,6 @@ export default function App(): React.ReactElement { edgeDecorations, activeFilterPatterns, showOrphans, - graphLayout, ); const { @@ -129,8 +125,8 @@ export default function App(): React.ReactElement { }); useEffect(() => { - return setupMessageListener(injectPluginAssets, pluginHost); - }, [injectPluginAssets, pluginHost]); + return setupMessageListener(injectPluginAssets, pluginHost, resetPluginAssets); + }, [injectPluginAssets, pluginHost, resetPluginAssets]); if (isLoading) return ; diff --git a/packages/extension/src/webview/components/graph/contextActions/builtin/effects.ts b/packages/extension/src/webview/components/graph/contextActions/builtin/effects.ts index 75d72d315..43a30eafa 100644 --- a/packages/extension/src/webview/components/graph/contextActions/builtin/effects.ts +++ b/packages/extension/src/webview/components/graph/contextActions/builtin/effects.ts @@ -5,15 +5,9 @@ import { createClipboardEffects, createCreateFileEffects, createCreateFolderEffects, - createGraphLayoutCollapseEffects, - createGraphSectionEffects, - createGraphSectionCollapseEffects, - createGraphSectionDeleteEffects, - createClearPinNodeEffects, createOptionalClipboardEffects, createOptionalSinglePathMessageEffects, createPathListMessageEffects, - createPinNodeEffects, createRefreshEffects, } from '../messages'; import { @@ -48,8 +42,6 @@ const BUILT_IN_CONTEXT_ACTION_EFFECTS = { createClipboardEffects(context.targetIds.join('\n')), toggleFavorite: (context: GraphContextActionContext) => createPathListMessageEffects('TOGGLE_FAVORITE', context.targetIds), - pinNode: (context: GraphContextActionContext) => createPinNodeEffects(context), - unpinNode: (context: GraphContextActionContext) => createClearPinNodeEffects(context), focus: (context: GraphContextActionContext) => createFocusEffects(context.primaryTargetId), addToFilter: (context: GraphContextActionContext) => createPatternPromptEffects(context.targetIds), @@ -62,21 +54,9 @@ const BUILT_IN_CONTEXT_ACTION_EFFECTS = { refresh: () => createRefreshEffects(), fitView: () => createFitViewEffects(), createFile: (context: GraphContextActionContext) => - createCreateFileEffects(context.mutationDirectory, context.ownerSectionId), + createCreateFileEffects(context.mutationDirectory), createFolder: (context: GraphContextActionContext) => - createCreateFolderEffects(context.mutationDirectory, context.ownerSectionId), - collapseNode: (context: GraphContextActionContext) => - createGraphLayoutCollapseEffects(context.primaryTargetId, true), - expandNode: (context: GraphContextActionContext) => - createGraphLayoutCollapseEffects(context.primaryTargetId, false), - createGraphSection: (context: GraphContextActionContext) => - createGraphSectionEffects(context), - expandGraphSection: (context: GraphContextActionContext) => - createGraphSectionCollapseEffects(context, false), - collapseGraphSection: (context: GraphContextActionContext) => - createGraphSectionCollapseEffects(context, true), - deleteGraphSection: (context: GraphContextActionContext) => - createGraphSectionDeleteEffects(context), + createCreateFolderEffects(context.mutationDirectory), } satisfies Record GraphContextEffect[]>; export function getBuiltInContextActionEffectsImpl( diff --git a/packages/extension/src/webview/components/graph/contextActions/context.ts b/packages/extension/src/webview/components/graph/contextActions/context.ts index 8f314e95c..a19d25def 100644 --- a/packages/extension/src/webview/components/graph/contextActions/context.ts +++ b/packages/extension/src/webview/components/graph/contextActions/context.ts @@ -1,21 +1,8 @@ import type { GraphContextMenuNode, GraphContextSelection } from '../contextMenu/contracts'; -import type { GraphLayoutMode, GraphLayoutSettings } from '../../../../shared/settings/graphLayout'; - -export interface GraphContextNodePosition2D { - x: number; - y: number; -} - -export interface GraphContextNodePosition3D extends GraphContextNodePosition2D { - z: number; -} export interface ResolveGraphContextActionOptions { - graphMode?: GraphLayoutMode; - graphLayout?: Pick; graphViewportScale?: number | null; nodes?: readonly GraphContextMenuNode[]; - nodePositions?: ReadonlyMap; } export interface GraphContextActionContext { @@ -25,14 +12,9 @@ export interface GraphContextActionContext { edgeSourceId?: string; edgeTargetId?: string; primaryNode?: GraphContextMenuNode; - graphMode: GraphLayoutMode; - graphLayout?: Pick; - graphPosition?: GraphContextNodePosition2D; + graphPosition?: { x: number; y: number }; graphViewportScale?: number | null; mutationDirectory: string; - nodePositions: ReadonlyMap; - ownerSectionId?: string; - wrapOwnerSectionId?: string; } export function resolveGraphContextActionContext( @@ -41,8 +23,6 @@ export function resolveGraphContextActionContext( ): GraphContextActionContext { const [primaryTargetId, secondaryTargetId] = selection.targets; const isEdgeSelection = selection.kind === 'edge'; - const ownerSectionId = resolveContextOwnerSectionId(selection, options.graphLayout); - const wrapOwnerSectionId = resolveCommonSelectionOwnerSectionId(selection, options.graphLayout); return { selectionKind: selection.kind, @@ -51,74 +31,14 @@ export function resolveGraphContextActionContext( primaryNode: options.nodes?.find(node => node.id === primaryTargetId), edgeSourceId: isEdgeSelection ? primaryTargetId : undefined, edgeTargetId: isEdgeSelection ? secondaryTargetId : undefined, - graphMode: options.graphMode ?? '2d', - ...(options.graphLayout ? { graphLayout: options.graphLayout } : {}), graphPosition: selection.graphPosition, graphViewportScale: options.graphViewportScale, - mutationDirectory: resolveMutationDirectory(primaryTargetId, options.graphLayout), - nodePositions: options.nodePositions ?? new Map(), - ...(ownerSectionId ? { ownerSectionId } : {}), - ...(wrapOwnerSectionId ? { wrapOwnerSectionId } : {}), + mutationDirectory: resolveMutationDirectory(primaryTargetId), }; } -function resolveContextOwnerSectionId( - selection: GraphContextSelection, - graphLayout: Pick | undefined, -): string | undefined { - if (!graphLayout || selection.kind !== 'node' || selection.targets.length !== 1) { - return undefined; - } - - const [targetId] = selection.targets; - if (!targetId) { - return undefined; - } - - if (graphLayout.sections[targetId]) { - return targetId; - } - - return graphLayout.ownership[targetId]?.ownerSectionId ?? undefined; -} - -function resolveSelectionItemOwnerSectionId( - targetId: string, - graphLayout: Pick, -): string | null { - return graphLayout.ownership[targetId]?.ownerSectionId ?? null; -} - -function resolveCommonSelectionOwnerSectionId( - selection: GraphContextSelection, - graphLayout: Pick | undefined, -): string | undefined { - if (!graphLayout || selection.kind !== 'node' || selection.targets.length === 0) { - return undefined; - } - - const [firstTargetId] = selection.targets; - if (!firstTargetId) { - return undefined; - } - - const firstOwnerSectionId = resolveSelectionItemOwnerSectionId(firstTargetId, graphLayout); - if (!firstOwnerSectionId) { - return undefined; - } - - return selection.targets.every(targetId => - resolveSelectionItemOwnerSectionId(targetId, graphLayout) === firstOwnerSectionId - ) - ? firstOwnerSectionId - : undefined; -} - -function resolveMutationDirectory( - primaryTargetId: string | undefined, - graphLayout: Pick | undefined, -): string { - if (!primaryTargetId || primaryTargetId === '(root)' || graphLayout?.sections[primaryTargetId]) { +function resolveMutationDirectory(primaryTargetId: string | undefined): string { + if (!primaryTargetId || primaryTargetId === '(root)') { return '.'; } 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/contextActions/messages.ts b/packages/extension/src/webview/components/graph/contextActions/messages.ts index 9c0546310..9b57ec45b 100644 --- a/packages/extension/src/webview/components/graph/contextActions/messages.ts +++ b/packages/extension/src/webview/components/graph/contextActions/messages.ts @@ -1,15 +1,4 @@ import type { WebviewToExtensionMessage } from '../../../../shared/protocol/webviewToExtension'; -import { - DEFAULT_GRAPH_SECTION_COLOR, - getGraphLayoutSectionWorldTopLeft, - getDefaultGraphSectionSize, - GRAPH_SECTION_SELECTION_PADDING, -} from '../../../../shared/settings/graphLayout'; -import type { - GraphContextActionContext, - GraphContextNodePosition2D, - GraphContextNodePosition3D, -} from './context'; import type { GraphContextEffect } from './effects'; type SinglePathMessageType = 'REVEAL_IN_EXPLORER' | 'RENAME_FILE'; @@ -52,243 +41,16 @@ export function createRefreshEffects(): GraphContextEffect[] { return [createPostMessageEffect({ type: 'REFRESH_GRAPH' })]; } -export function createCreateFileEffects( - directory = '.', - ownerSectionId?: string, -): GraphContextEffect[] { +export function createCreateFileEffects(directory = '.'): GraphContextEffect[] { return [createPostMessageEffect({ type: 'CREATE_FILE', - payload: { - directory, - ...(ownerSectionId ? { ownerSectionId } : {}), - }, + payload: { directory }, })]; } -export function createCreateFolderEffects( - directory = '.', - ownerSectionId?: string, -): GraphContextEffect[] { +export function createCreateFolderEffects(directory = '.'): GraphContextEffect[] { return [createPostMessageEffect({ type: 'CREATE_FOLDER', - payload: { - directory, - ...(ownerSectionId ? { ownerSectionId } : {}), - }, - })]; -} - -export function createGraphLayoutCollapseEffects( - nodeId: string | undefined, - collapsed: boolean, -): GraphContextEffect[] { - return nodeId - ? [createPostMessageEffect({ - type: 'UPDATE_GRAPH_LAYOUT_COLLAPSE', - payload: { nodeId, collapsed }, - })] - : []; -} - -interface SectionBounds { - height: number; - width: number; - x: number; - y: number; -} - -function isFiniteNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value); -} - -function readPinNodePosition( - context: GraphContextActionContext, - nodeId: string, -): GraphContextNodePosition2D | GraphContextNodePosition3D | undefined { - const position = context.nodePositions.get(nodeId); - if (!position || !isFiniteNumber(position.x) || !isFiniteNumber(position.y)) { - return undefined; - } - - if (context.graphMode === '3d') { - return 'z' in position && isFiniteNumber(position.z) - ? { x: position.x, y: position.y, z: position.z } - : undefined; - } - - return { x: position.x, y: position.y }; -} - -function createPinNodeEffect( - context: GraphContextActionContext, - nodeId: string, - position: GraphContextNodePosition2D | GraphContextNodePosition3D, -): GraphContextEffect { - return createPostMessageEffect({ - type: 'UPDATE_GRAPH_LAYOUT_PIN', - payload: { - graphMode: context.graphMode, - nodeId, - position, - }, - }); -} - -export function createPinNodeEffects(context: GraphContextActionContext): GraphContextEffect[] { - return context.targetIds.flatMap(nodeId => { - const position = readPinNodePosition(context, nodeId); - return position ? [createPinNodeEffect(context, nodeId, position)] : []; - }); -} - -export function createClearPinNodeEffects(context: GraphContextActionContext): GraphContextEffect[] { - return context.targetIds.map(nodeId => createPostMessageEffect({ - type: 'CLEAR_GRAPH_LAYOUT_PIN', - payload: { graphMode: context.graphMode, nodeId }, - })); -} - -export function createGraphSectionCollapseEffects( - context: GraphContextActionContext, - collapsed: boolean, -): GraphContextEffect[] { - if (!context.primaryTargetId) { - return []; - } - - return [createPostMessageEffect({ - type: 'UPDATE_GRAPH_LAYOUT_SECTION', - payload: { - sectionId: context.primaryTargetId, - updates: { collapsed }, - }, - })]; -} - -export function createGraphSectionDeleteEffects( - context: GraphContextActionContext, -): GraphContextEffect[] { - if (!context.primaryTargetId) { - return []; - } - - return [createPostMessageEffect({ - type: 'DELETE_GRAPH_LAYOUT_SECTION', - payload: { - sectionId: context.primaryTargetId, - }, - })]; -} - -function getDefaultSectionBounds(context: GraphContextActionContext): SectionBounds { - const center = context.graphPosition ?? { x: 0, y: 0 }; - const size = getDefaultGraphSectionSize(); - return { - height: size.height, - width: size.width, - x: center.x - (size.width / 2), - y: center.y - (size.height / 2), - }; -} - -function convertSectionBoundsToOwnerLocal( - context: GraphContextActionContext, - bounds: SectionBounds, - ownerSectionId: string | undefined, -): SectionBounds { - if (!context.graphLayout || !ownerSectionId) { - return bounds; - } - - const ownerTopLeft = getGraphLayoutSectionWorldTopLeft( - context.graphLayout, - ownerSectionId, - ); - if (!ownerTopLeft) { - return bounds; - } - - return { - ...bounds, - x: bounds.x - ownerTopLeft.x, - y: bounds.y - ownerTopLeft.y, - }; -} - -function isSingleGraphSectionContext(context: GraphContextActionContext): boolean { - return context.selectionKind === 'node' - && context.targetIds.length === 1 - && !!context.primaryTargetId - && !!context.graphLayout?.sections[context.primaryTargetId] - && context.ownerSectionId === context.primaryTargetId; -} - -function getSelectedGraphSectionIds(context: GraphContextActionContext): string[] { - if (!context.graphLayout) { - return []; - } - - return context.targetIds.filter(targetId => !!context.graphLayout?.sections[targetId]); -} - -function getSelectedGraphNodeIds(context: GraphContextActionContext): string[] { - const sectionIds = new Set(getSelectedGraphSectionIds(context)); - return context.targetIds.filter(targetId => !sectionIds.has(targetId)); -} - -function getSelectionSectionBounds(context: GraphContextActionContext): SectionBounds | undefined { - const positions = context.targetIds - .map(nodeId => context.nodePositions.get(nodeId)) - .filter((position): position is { x: number; y: number } => - !!position && isFiniteNumber(position.x) && isFiniteNumber(position.y), - ); - if (positions.length === 0) { - return undefined; - } - - const xs = positions.map(position => position.x); - const ys = positions.map(position => position.y); - const minX = Math.min(...xs); - const maxX = Math.max(...xs); - const minY = Math.min(...ys); - const maxY = Math.max(...ys); - - return { - height: (maxY - minY) + (GRAPH_SECTION_SELECTION_PADDING * 2), - width: (maxX - minX) + (GRAPH_SECTION_SELECTION_PADDING * 2), - x: minX - GRAPH_SECTION_SELECTION_PADDING, - y: minY - GRAPH_SECTION_SELECTION_PADDING, - }; -} - -export function createGraphSectionEffects(context: GraphContextActionContext): GraphContextEffect[] { - if (context.graphMode !== '2d') { - return []; - } - - const shouldWrapSelectedNodes = context.selectionKind === 'node' - && context.targetIds.length > 0 - && !isSingleGraphSectionContext(context); - const ownerSectionId = shouldWrapSelectedNodes - ? context.wrapOwnerSectionId - : context.ownerSectionId; - const bounds = shouldWrapSelectedNodes - ? getSelectionSectionBounds(context) ?? getDefaultSectionBounds(context) - : getDefaultSectionBounds(context); - const localBounds = convertSectionBoundsToOwnerLocal(context, bounds, ownerSectionId); - const memberSectionIds = shouldWrapSelectedNodes ? getSelectedGraphSectionIds(context) : []; - - return [createPostMessageEffect({ - type: 'CREATE_GRAPH_LAYOUT_SECTION', - payload: { - color: DEFAULT_GRAPH_SECTION_COLOR, - height: localBounds.height, - memberNodeIds: shouldWrapSelectedNodes ? getSelectedGraphNodeIds(context) : [], - ...(memberSectionIds.length > 0 ? { memberSectionIds } : {}), - ...(ownerSectionId ? { ownerSectionId } : {}), - width: localBounds.width, - x: localBounds.x, - y: localBounds.y, - }, + payload: { directory }, })]; } diff --git a/packages/extension/src/webview/components/graph/contextMenu/background/entries.ts b/packages/extension/src/webview/components/graph/contextMenu/background/entries.ts index 13f2d67bc..54ab0a29c 100644 --- a/packages/extension/src/webview/components/graph/contextMenu/background/entries.ts +++ b/packages/extension/src/webview/components/graph/contextMenu/background/entries.ts @@ -3,16 +3,12 @@ import type { GraphContextMenuEntry, GraphContextMutationAvailability } from '.. export function buildBackgroundEntries( mutationAvailability: GraphContextMutationAvailability, - options: { includeGraphSection?: boolean } = {}, ): GraphContextMenuEntry[] { const entries: GraphContextMenuEntry[] = []; if (mutationAvailability !== 'hidden') { const disabled = mutationAvailability === 'disabled'; entries.push(builtInItem('background-create-file', 'New File...', 'createFile', { disabled })); entries.push(builtInItem('background-create-folder', 'New Folder...', 'createFolder', { disabled })); - if (options.includeGraphSection !== false) { - entries.push(builtInItem('background-create-section', 'New Graph Section', 'createGraphSection', { disabled })); - } entries.push(separator('background-separator-primary')); } entries.push( 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..bdf772b4e 100644 --- a/packages/extension/src/webview/components/graph/contextMenu/build/entries.ts +++ b/packages/extension/src/webview/components/graph/contextMenu/build/entries.ts @@ -9,9 +9,10 @@ import { buildEdgeEntries } from '../edge/entries'; import { buildNodeEntries, buildSingleFolderNodeEntries, - buildSingleGraphSectionNodeEntries, + buildSinglePluginNodeEntries, buildSingleSymbolNodeEntries, } from '../node/entries'; +import { buildGraphViewContextMenuEntries } from '../graphView/entries'; import { buildPluginEntriesForDecision } from '../plugin/entries'; import type { GraphContextMenuDecision } from '../decision/model'; @@ -32,16 +33,38 @@ function getNodeTargetIds( : decision.targets.map(target => target.id); } +function insertCreateMenuEntries( + baseEntries: GraphContextMenuEntry[], + createEntries: GraphContextMenuEntry[], +): GraphContextMenuEntry[] { + if (createEntries.length === 0) { + return baseEntries; + } + + const separatorIndex = baseEntries.findIndex(entry => entry.id === 'background-separator-primary'); + if (separatorIndex === -1) { + return [...baseEntries, ...createEntries]; + } + + return [ + ...baseEntries.slice(0, separatorIndex), + ...createEntries, + ...baseEntries.slice(separatorIndex), + ]; +} + export function buildGraphContextMenuEntries( options: BuildGraphContextMenuOptions ): GraphContextMenuEntry[] { const { selection, + graphMode = '2d', timelineActive, favorites, - pinnedNodeIds = new Set(), pluginItems, + graphViewContributions, nodes, + edges, } = options; const mutationAvailability = options.mutationAvailability ?? DEFAULT_GRAPH_CONTEXT_MUTATION_AVAILABILITY; const decision = decideGraphContextMenu(selection, nodes); @@ -50,20 +73,13 @@ export function buildGraphContextMenuEntries( : decision.kind === 'singleFolderNode' ? buildSingleFolderNodeEntries( decision.target, - timelineActive, mutationAvailability, favorites, - pinnedNodeIds, ) - : decision.kind === 'singleGraphSectionNode' - ? buildSingleGraphSectionNodeEntries( - decision.target.id, - !!decision.target.isCollapsedGraphSection, - mutationAvailability, - pinnedNodeIds, - ) - : decision.kind === 'singleSymbolNode' + : decision.kind === 'singleSymbolNode' ? buildSingleSymbolNodeEntries(decision.target.id, favorites) + : decision.kind === 'singlePluginNode' + ? buildSinglePluginNodeEntries() : decision.kind === 'edge' ? buildEdgeEntries(decision.targets) : decision.kind === 'emptyNodeSelection' @@ -73,7 +89,32 @@ export function buildGraphContextMenuEntries( timelineActive, mutationAvailability, favorites, - pinnedNodeIds, ); - return [...baseEntries, ...buildPluginEntriesForDecision(decision, pluginItems)]; + const graphViewCreateEntries = decision.kind === 'background' + ? buildGraphViewContextMenuEntries({ + decision, + edges, + graphMode, + graphViewContributions, + includeSeparator: false, + nodes, + placement: 'create', + selection, + timelineActive, + }) + : []; + const positionedBaseEntries = insertCreateMenuEntries(baseEntries, graphViewCreateEntries); + return [ + ...positionedBaseEntries, + ...buildPluginEntriesForDecision(decision, pluginItems), + ...buildGraphViewContextMenuEntries({ + decision, + edges, + graphMode, + graphViewContributions, + nodes, + selection, + timelineActive, + }), + ]; } diff --git a/packages/extension/src/webview/components/graph/contextMenu/contracts.ts b/packages/extension/src/webview/components/graph/contextMenu/contracts.ts index a6a91f772..116ae6496 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-dev/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'; @@ -18,14 +20,6 @@ export type BuiltInContextMenuAction = | 'copyEdgeTarget' | 'copyEdgeBoth' | 'toggleFavorite' - | 'pinNode' - | 'unpinNode' - | 'collapseNode' - | 'expandNode' - | 'createGraphSection' - | 'expandGraphSection' - | 'collapseGraphSection' - | 'deleteGraphSection' | 'focus' | 'addToFilter' | 'addNodeLegend' @@ -38,7 +32,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,23 +67,35 @@ export interface GraphContextMenuNode { id: string; label?: string; color?: string; + x?: number; + y?: number; + z?: number; + ownerPluginId?: string; nodeType?: string; + runtimeNodeType?: string; symbol?: { id: string; name: string; filePath: string; }; isCollapsed?: boolean; - isCollapsedGraphSection?: boolean; - isGraphSection?: boolean; +} + +export interface GraphContextMenuEdge { + id: string; + kind?: string; + ownerPluginId?: string; + runtimeEdgeType?: string; } export interface BuildGraphContextMenuOptions { selection: GraphContextSelection; + graphMode?: '2d' | '3d'; timelineActive: boolean; mutationAvailability?: GraphContextMutationAvailability; 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/model.ts b/packages/extension/src/webview/components/graph/contextMenu/decision/model.ts index 729b753d0..3af1d2866 100644 --- a/packages/extension/src/webview/components/graph/contextMenu/decision/model.ts +++ b/packages/extension/src/webview/components/graph/contextMenu/decision/model.ts @@ -11,7 +11,6 @@ export type GraphContextMenuDecision = | { kind: 'emptyNodeSelection' } | { kind: 'singleFileNode'; target: GraphContextNodeTarget } | { kind: 'singleFolderNode'; target: GraphContextNodeTarget } - | { kind: 'singleGraphSectionNode'; target: GraphContextNodeTarget } | { kind: 'singlePackageNode'; target: GraphContextNodeTarget } | { kind: 'singleSymbolNode'; target: GraphContextNodeTarget } | { kind: 'singlePluginNode'; target: GraphContextNodeTarget } diff --git a/packages/extension/src/webview/components/graph/contextMenu/decision/single.ts b/packages/extension/src/webview/components/graph/contextMenu/decision/single.ts index b3457f08b..299e4bac5 100644 --- a/packages/extension/src/webview/components/graph/contextMenu/decision/single.ts +++ b/packages/extension/src/webview/components/graph/contextMenu/decision/single.ts @@ -7,8 +7,6 @@ export function classifySingleNodeDecision(target: GraphContextNodeTarget): Grap return { kind: 'singleFileNode', target }; case 'folder': return { kind: 'singleFolderNode', target }; - case 'graph-section': - return { kind: 'singleGraphSectionNode', target }; case 'package': return { kind: 'singlePackageNode', target }; case 'symbol': 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..33739942d 100644 --- a/packages/extension/src/webview/components/graph/contextMenu/decision/targets.ts +++ b/packages/extension/src/webview/components/graph/contextMenu/decision/targets.ts @@ -1,11 +1,12 @@ -export type GraphContextNodeKind = 'file' | 'folder' | 'package' | 'plugin' | 'symbol' | 'graph-section'; +export type GraphContextNodeKind = 'file' | 'folder' | 'package' | 'plugin' | 'symbol'; export interface GraphContextNodeTarget { id: string; isCollapsed?: boolean; - isCollapsedGraphSection?: boolean; nodeKind: GraphContextNodeKind; nodeType: string; + ownerPluginId?: string; + runtimeNodeType?: string; symbol?: { id: string; name: string; @@ -16,9 +17,9 @@ export interface GraphContextNodeTarget { export interface GraphContextNodeSource { id: string; isCollapsed?: boolean; - isCollapsedGraphSection?: boolean; - isGraphSection?: boolean; nodeType?: string; + ownerPluginId?: string; + runtimeNodeType?: string; symbol?: { id: string; name: string; @@ -43,20 +44,15 @@ export function classifyGraphContextNodeTarget( ? 'package' : nodeSource?.nodeType ?? 'file'; const resolvedSymbol = nodeSource?.symbol; - const isGraphSection = nodeSource?.isGraphSection || resolvedNodeType === 'graph-section'; - return { id: nodeId, isCollapsed: nodeSource?.isCollapsed, - isCollapsedGraphSection: isGraphSection - ? !!nodeSource?.isCollapsedGraphSection - : undefined, - nodeKind: isGraphSection - ? 'graph-section' - : resolvedSymbol || resolvedNodeType === 'symbol' || resolvedNodeType === 'variable' + nodeKind: resolvedSymbol || resolvedNodeType === 'symbol' || resolvedNodeType === 'variable' ? '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..243dc8ed0 --- /dev/null +++ b/packages/extension/src/webview/components/graph/contextMenu/graphView/entries.ts @@ -0,0 +1,200 @@ +import type { CoreGraphViewContributionSet } from '@codegraphy-dev/core'; +import { separator } from '../common/entryFactories'; +import type { + GraphContextMenuEdge, + GraphContextMenuEntry, + GraphContextMenuNode, + 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]; +type GraphViewContextMenuPlacement = NonNullable['menu']; + +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 isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function createSelectedNodePositions( + selection: GraphContextSelection, + nodes: readonly GraphContextMenuNode[] | undefined, +): Readonly> | undefined { + if (selection.kind !== 'node' || !nodes?.length) { + return undefined; + } + + const nodesById = new Map(nodes.map(node => [node.id, node])); + const positions: Record = {}; + for (const nodeId of selection.targets) { + const node = nodesById.get(nodeId); + if (!isFiniteNumber(node?.x) || !isFiniteNumber(node.y)) { + continue; + } + + positions[nodeId] = isFiniteNumber(node.z) + ? { x: node.x, y: node.y, z: node.z } + : { x: node.x, y: node.y }; + } + + return Object.keys(positions).length > 0 ? positions : undefined; +} + +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, + graphMode: '2d' | '3d', + timelineActive: boolean, + nodes: readonly GraphContextMenuNode[] | undefined, +): Parameters[0] { + const selectedNodePositions = createSelectedNodePositions(selection, nodes); + return { + target: selector, + graphMode, + timelineActive, + selectedNodeIds: selection.kind === 'node' ? selection.targets : [], + selectedEdgeIds: selection.kind === 'edge' && selection.edgeId ? [selection.edgeId] : [], + ...(selection.graphPosition ? { graphPosition: selection.graphPosition } : {}), + ...(selectedNodePositions ? { selectedNodePositions } : {}), + }; +} + +export function buildGraphViewContextMenuEntries( + options: { + decision: GraphContextMenuDecision; + edges?: readonly GraphContextMenuEdge[]; + graphMode?: '2d' | '3d'; + graphViewContributions?: CoreGraphViewContributionSet; + includeSeparator?: boolean; + placement?: GraphViewContextMenuPlacement | 'default'; + nodes?: readonly GraphContextMenuNode[]; + selection: GraphContextSelection; + timelineActive: boolean; + }, +): GraphContextMenuEntry[] { + const entries: GraphContextMenuEntry[] = []; + const placement = options.placement ?? 'default'; + const graphMode = options.graphMode ?? '2d'; + + for (const entry of options.graphViewContributions?.contextMenu ?? []) { + const contributionPlacement = entry.contribution.placement?.menu ?? 'default'; + if (contributionPlacement !== placement) { + continue; + } + + const selector = entry.contribution.targets.find(target => + selectorMatches(target, options.decision, options.edges) + ); + if (!selector) { + continue; + } + const context = createRunContext( + selector, + options.selection, + graphMode, + options.timelineActive, + options.nodes, + ); + if (entry.contribution.isVisible && !entry.contribution.isVisible(context)) { + continue; + } + + entries.push({ + kind: 'item', + id: `graph-view-plugin-${entry.pluginId}-${entry.contribution.id}`, + label: entry.contribution.getLabel?.(context) ?? entry.contribution.label, + action: { + kind: 'graphViewPlugin', + pluginId: entry.pluginId, + contributionId: entry.contribution.id, + context, + run: nextContext => entry.contribution.run(nextContext), + }, + }); + } + + return entries.length > 0 && options.includeSeparator !== false + ? [separator('graph-view-plugins-separator'), ...entries] + : entries; +} diff --git a/packages/extension/src/webview/components/graph/contextMenu/node/entries.ts b/packages/extension/src/webview/components/graph/contextMenu/node/entries.ts index 8b24207e8..0d1db69a1 100644 --- a/packages/extension/src/webview/components/graph/contextMenu/node/entries.ts +++ b/packages/extension/src/webview/components/graph/contextMenu/node/entries.ts @@ -6,7 +6,6 @@ import { buildCopyBlock, } from './openCopyBlocks'; import { buildFavoriteBlock } from './destructive/favoritesBlocks'; -import { buildPinBlock } from './pin/block'; import { buildDestructiveBlock, buildFilterBlock, @@ -18,25 +17,14 @@ export function buildNodeEntries( timelineActive: boolean, mutationAvailability: GraphContextMutationAvailability, favorites: ReadonlySet, - pinnedNodeIds: ReadonlySet = new Set(), ): GraphContextMenuEntry[] { const entries: GraphContextMenuEntry[] = [ ...buildOpenBlock(targets, timelineActive), ...buildCopyBlock(targets), ...buildFavoriteBlock(targets, favorites), - ...(mutationAvailability === 'enabled' && !timelineActive ? buildPinBlock(targets, pinnedNodeIds) : []), ...buildFilterBlock(targets), ]; - if (mutationAvailability !== 'hidden' && targets.length > 0) { - entries.push( - builtInItem('node-create-section-from-selection', 'Wrap Selected in Graph Section', 'createGraphSection', { - disabled: mutationAvailability === 'disabled', - }), - separator('node-separator-section'), - ); - } - if (mutationAvailability !== 'hidden') { entries.push(...buildDestructiveBlock(targets, mutationAvailability === 'disabled')); } @@ -59,12 +47,16 @@ export function buildSingleSymbolNodeEntries( ]; } +export function buildSinglePluginNodeEntries(): GraphContextMenuEntry[] { + return [ + builtInItem('node-focus', 'Focus Node', 'focus'), + ]; +} + export function buildSingleFolderNodeEntries( target: GraphContextNodeTarget, - timelineActive: boolean, mutationAvailability: GraphContextMutationAvailability, favorites: ReadonlySet, - pinnedNodeIds: ReadonlySet = new Set(), ): GraphContextMenuEntry[] { const targets = [target.id]; const entries: GraphContextMenuEntry[] = []; @@ -79,15 +71,9 @@ export function buildSingleFolderNodeEntries( } entries.push( - builtInItem( - 'node-collapse-toggle', - target.isCollapsed ? 'Expand Folder' : 'Collapse Folder', - target.isCollapsed ? 'expandNode' : 'collapseNode', - ), builtInItem('node-reveal', 'Reveal in Explorer', 'reveal'), ...buildCopyBlock(targets), ...buildFavoriteBlock(targets, favorites), - ...(mutationAvailability === 'enabled' && !timelineActive ? buildPinBlock(targets, pinnedNodeIds) : []), ...buildFilterBlock(targets), ); @@ -97,47 +83,3 @@ export function buildSingleFolderNodeEntries( return entries; } - -export function buildSingleGraphSectionNodeEntries( - target: string, - collapsed: boolean, - mutationAvailability: GraphContextMutationAvailability, - pinnedNodeIds: ReadonlySet = new Set(), -): GraphContextMenuEntry[] { - const entries: GraphContextMenuEntry[] = []; - - if (mutationAvailability !== 'hidden') { - const disabled = mutationAvailability === 'disabled'; - entries.push( - builtInItem('node-create-file', 'New File...', 'createFile', { disabled }), - builtInItem('node-create-folder', 'New Folder...', 'createFolder', { disabled }), - builtInItem('node-create-graph-section', 'New Graph Section', 'createGraphSection', { disabled }), - separator('node-separator-create'), - ); - } - - entries.push( - builtInItem( - 'graph-section-toggle-collapse', - collapsed ? 'Expand Graph Section' : 'Collapse Graph Section', - collapsed ? 'expandGraphSection' : 'collapseGraphSection', - { disabled: mutationAvailability === 'disabled' }, - ), - builtInItem('node-focus', 'Focus Node', 'focus'), - ); - - if (mutationAvailability === 'enabled') { - entries.push(...buildPinBlock([target], pinnedNodeIds)); - } - - if (mutationAvailability !== 'hidden') { - entries.push( - builtInItem('graph-section-delete', 'Delete Graph Section', 'deleteGraphSection', { - destructive: true, - disabled: mutationAvailability === 'disabled', - }), - ); - } - - return entries; -} diff --git a/packages/extension/src/webview/components/graph/contextMenu/node/pin/block.ts b/packages/extension/src/webview/components/graph/contextMenu/node/pin/block.ts deleted file mode 100644 index dab9219ad..000000000 --- a/packages/extension/src/webview/components/graph/contextMenu/node/pin/block.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { builtInItem } from '../../common/entryFactories'; -import type { GraphContextMenuEntry } from '../../contracts'; - -export function buildPinBlock( - targets: readonly string[], - pinnedNodeIds: ReadonlySet, -): GraphContextMenuEntry[] { - if (targets.length === 0) { - return []; - } - - const pinned = targets.every(target => pinnedNodeIds.has(target)); - const plural = targets.length > 1; - - return [ - builtInItem( - 'node-toggle-pin', - pinned - ? (plural ? 'Unpin Nodes' : 'Unpin Node') - : (plural ? 'Pin Nodes' : 'Pin Node'), - pinned ? 'unpinNode' : 'pinNode', - ), - ]; -} diff --git a/packages/extension/src/webview/components/graph/debug/api.ts b/packages/extension/src/webview/components/graph/debug/api.ts index 4b0daa02c..49f68a72c 100644 --- a/packages/extension/src/webview/components/graph/debug/api.ts +++ b/packages/extension/src/webview/components/graph/debug/api.ts @@ -10,6 +10,7 @@ export function useGraphDebugApi({ fg3dRef, graphDataRef, graphMode, + openNodeContextMenu, win, }: { containerRef: RefObject; @@ -18,6 +19,7 @@ export function useGraphDebugApi({ fg3dRef: MutableRefObject; graphDataRef: MutableRefObject<{ nodes: DebugNode[] }>; graphMode: '2d' | '3d'; + openNodeContextMenu?(this: void, nodeId: string, event: MouseEvent): void; win?: Window; }): void { useEffect(() => { @@ -32,7 +34,8 @@ export function useGraphDebugApi({ fg3dRef, graphDataRef, graphMode, + openNodeContextMenu, win, }); - }, [containerRef, fg2dRef, fg3dRef, fitView, graphDataRef, graphMode, win]); + }, [containerRef, fg2dRef, fg3dRef, fitView, graphDataRef, graphMode, openNodeContextMenu, win]); } diff --git a/packages/extension/src/webview/components/graph/debug/contracts/protocol.ts b/packages/extension/src/webview/components/graph/debug/contracts/protocol.ts index 70c5d462f..029948a33 100644 --- a/packages/extension/src/webview/components/graph/debug/contracts/protocol.ts +++ b/packages/extension/src/webview/components/graph/debug/contracts/protocol.ts @@ -5,6 +5,10 @@ export interface GraphDebugSnapshot { nodes: Array<{ id: string; screenX: number; + shapeSize2D?: { + height: number; + width: number; + }; screenY: number; size: number; x: number; @@ -18,3 +22,10 @@ export interface GraphDebugControls { zoom?(this: void): number; zoomToFit?(this: void, durationMs?: number, padding?: number): void; } + +export interface GraphDebugApi { + fitView(this: void): void; + fitViewWithPadding(this: void, padding: number): void; + getSnapshot(this: void): GraphDebugSnapshot; + openNodeContextMenu(this: void, nodeId: string): void; +} diff --git a/packages/extension/src/webview/components/graph/debug/install.ts b/packages/extension/src/webview/components/graph/debug/install.ts index b2897f0ce..dc1d572be 100644 --- a/packages/extension/src/webview/components/graph/debug/install.ts +++ b/packages/extension/src/webview/components/graph/debug/install.ts @@ -10,6 +10,7 @@ export function installGraphDebugApi({ fg3dRef, graphDataRef, graphMode, + openNodeContextMenu, win, }: { containerRef: RefObject; @@ -18,6 +19,7 @@ export function installGraphDebugApi({ fg3dRef: MutableRefObject; graphDataRef: MutableRefObject<{ nodes: DebugNode[] }>; graphMode: '2d' | '3d'; + openNodeContextMenu?(this: void, nodeId: string, event: MouseEvent): void; win: Window; }): (() => void) | undefined { if (win.__CODEGRAPHY_ENABLE_GRAPH_DEBUG__ !== true) { @@ -36,6 +38,33 @@ export function installGraphDebugApi({ graphMode, nodes: graphDataRef.current.nodes, }), + openNodeContextMenu: (nodeId: string) => { + const node = graphDataRef.current.nodes.find(entry => entry.id === nodeId); + if (!node || !openNodeContextMenu) { + return; + } + + const graph = graphMode === '2d' ? fg2dRef.current : fg3dRef.current; + const screen = graph?.graph2ScreenCoords?.( + node.x ?? 0, + node.y ?? 0, + typeof node.z === 'number' ? node.z : 0, + ) ?? { + x: node.x ?? 0, + y: node.y ?? 0, + }; + const rect = containerRef.current?.getBoundingClientRect(); + const event = new MouseEvent('contextmenu', { + bubbles: true, + button: 2, + buttons: 2, + cancelable: true, + clientX: (rect?.left ?? 0) + screen.x, + clientY: (rect?.top ?? 0) + screen.y, + }); + + openNodeContextMenu(nodeId, event); + }, }; return () => { diff --git a/packages/extension/src/webview/components/graph/debug/options.ts b/packages/extension/src/webview/components/graph/debug/options.ts index 2474a1fc6..cbf2411c8 100644 --- a/packages/extension/src/webview/components/graph/debug/options.ts +++ b/packages/extension/src/webview/components/graph/debug/options.ts @@ -19,6 +19,7 @@ export function buildGraphDebugOptions({ fg3dRef: { current: GraphDebugControls | undefined }; graphDataRef: UseGraphStateResult['graphDataRef']; graphMode: '2d' | '3d'; + openNodeContextMenu: UseGraphInteractionRuntimeResult['handleNodeContextMenuById']; win?: Window; } { return { @@ -28,6 +29,7 @@ export function buildGraphDebugOptions({ fg3dRef: graphState.fg3dRef, graphDataRef: graphState.graphDataRef, graphMode, + openNodeContextMenu: interactions.handleNodeContextMenuById, win, }; } diff --git a/packages/extension/src/webview/components/graph/debug/snapshot.ts b/packages/extension/src/webview/components/graph/debug/snapshot.ts index ce9d8142b..74dd912b6 100644 --- a/packages/extension/src/webview/components/graph/debug/snapshot.ts +++ b/packages/extension/src/webview/components/graph/debug/snapshot.ts @@ -3,6 +3,10 @@ import type { GraphDebugControls, GraphDebugSnapshot } from './contracts/protoco export interface DebugNode { id: string; + shapeSize2D?: { + height: number; + width: number; + }; size: number; x?: number; y?: number; @@ -33,6 +37,7 @@ function buildDebugNodeSnapshot( return { id: node.id, screenX: screen.x, + ...(node.shapeSize2D ? { shapeSize2D: node.shapeSize2D } : {}), screenY: screen.y, size: node.size, x, diff --git a/packages/extension/src/webview/components/graph/debug/window.ts b/packages/extension/src/webview/components/graph/debug/window.ts index ecbda3dce..f13d20973 100644 --- a/packages/extension/src/webview/components/graph/debug/window.ts +++ b/packages/extension/src/webview/components/graph/debug/window.ts @@ -1,13 +1,9 @@ -import type { GraphDebugSnapshot } from './contracts/protocol'; +import type { GraphDebugApi } from './contracts/protocol'; declare global { interface Window { __CODEGRAPHY_ENABLE_GRAPH_DEBUG__?: boolean; - __CODEGRAPHY_GRAPH_DEBUG__?: { - fitView(): void; - fitViewWithPadding(padding: number): void; - getSnapshot(): GraphDebugSnapshot; - }; + __CODEGRAPHY_GRAPH_DEBUG__?: GraphDebugApi; } } 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/effects/interaction.ts b/packages/extension/src/webview/components/graph/effects/interaction.ts index 9ee49ddf2..ac721b8a6 100644 --- a/packages/extension/src/webview/components/graph/effects/interaction.ts +++ b/packages/extension/src/webview/components/graph/effects/interaction.ts @@ -16,7 +16,6 @@ export interface GraphInteractionEffectHandlers { previewNode(nodeId: string): void; openNode(nodeId: string): void; focusNode(nodeId: string): void; - setGraphSectionCollapsed(sectionId: string, collapsed: boolean): void; sendInteraction( event: 'graph:nodeClick' | 'graph:nodeDoubleClick' | 'graph:backgroundClick', payload: unknown @@ -35,7 +34,6 @@ type SetSelectionEffect = Extract; type OpenNodeEffect = Extract; type FocusNodeEffect = Extract; -type SetGraphSectionCollapsedEffect = Extract; type SendInteractionEffect = Extract; const INTERACTION_EFFECT_HANDLERS = { @@ -75,10 +73,6 @@ const INTERACTION_EFFECT_HANDLERS = { focusNode: (effect, handlers) => { handlers.focusNode((effect as FocusNodeEffect).nodeId); }, - setGraphSectionCollapsed: (effect, handlers) => { - const collapseEffect = effect as SetGraphSectionCollapsedEffect; - handlers.setGraphSectionCollapsed(collapseEffect.sectionId, collapseEffect.collapsed); - }, sendInteraction: (effect, handlers) => { const interactionEffect = effect as SendInteractionEffect; handlers.sendInteraction(interactionEffect.event, interactionEffect.payload); diff --git a/packages/extension/src/webview/components/graph/interaction/model.ts b/packages/extension/src/webview/components/graph/interaction/model.ts index 3d39a56a0..aa8dac8a8 100644 --- a/packages/extension/src/webview/components/graph/interaction/model.ts +++ b/packages/extension/src/webview/components/graph/interaction/model.ts @@ -19,7 +19,6 @@ export type GraphInteractionEffect = | { kind: 'previewNode'; nodeId: string } | { kind: 'openNode'; nodeId: string } | { kind: 'focusNode'; nodeId: string } - | { kind: 'setGraphSectionCollapsed'; sectionId: string; collapsed: boolean } | { kind: 'sendInteraction'; event: 'graph:nodeClick' | 'graph:nodeDoubleClick' | 'graph:backgroundClick'; @@ -39,8 +38,7 @@ export interface GraphNodeClickOptions { metaKey: boolean; clientX: number; clientY: number; - isCollapsedGraphSection?: boolean; - isGraphSection?: boolean; + isRuntimeNode?: boolean; isMacPlatform: boolean; selectedNodeIds: Iterable; lastClick: GraphLastClickState | null; diff --git a/packages/extension/src/webview/components/graph/interaction/node/doubleClick.ts b/packages/extension/src/webview/components/graph/interaction/node/doubleClick.ts index 5afc698ac..3925c1614 100644 --- a/packages/extension/src/webview/components/graph/interaction/node/doubleClick.ts +++ b/packages/extension/src/webview/components/graph/interaction/node/doubleClick.ts @@ -7,8 +7,7 @@ export interface GraphNodeDoubleClickOptions { clientX: number; clientY: number; doubleClickThresholdMs: number; - isCollapsedGraphSection?: boolean; - isGraphSection?: boolean; + isRuntimeNode?: boolean; label: string; lastClick: GraphLastClickState | null; nodeId: string; @@ -32,7 +31,7 @@ function createNodeDoubleClickInteractionEffect( }; } -function getGraphSectionDoubleClickEffects( +function getRuntimeNodeDoubleClickEffects( options: GraphNodeDoubleClickOptions, ): GraphNodeClickCommand['effects'] { return [ @@ -54,10 +53,10 @@ export function isDoubleNodeClick( export function getNodeDoubleClickCommand( options: GraphNodeDoubleClickOptions, ): GraphNodeClickCommand { - if (options.isGraphSection) { + if (options.isRuntimeNode) { return { nextLastClick: null, - effects: getGraphSectionDoubleClickEffects(options), + effects: getRuntimeNodeDoubleClickEffects(options), }; } diff --git a/packages/extension/src/webview/components/graph/interaction/node/singleClick/command.ts b/packages/extension/src/webview/components/graph/interaction/node/singleClick/command.ts index d3a934579..533ed630f 100644 --- a/packages/extension/src/webview/components/graph/interaction/node/singleClick/command.ts +++ b/packages/extension/src/webview/components/graph/interaction/node/singleClick/command.ts @@ -6,7 +6,6 @@ export interface GraphNodeSingleClickOptions { clientX: number; clientY: number; ctrlKey: boolean; - isCollapsedGraphSection?: boolean; label: string; metaKey: boolean; nodeId: string; @@ -18,16 +17,6 @@ export interface GraphNodeSingleClickOptions { export function getNodeSingleClickCommand( options: GraphNodeSingleClickOptions, ): GraphNodeClickCommand { - if (options.isCollapsedGraphSection && !options.ctrlKey && !options.shiftKey && !options.metaKey) { - return { - nextLastClick: null, - effects: [ - { kind: 'setGraphSectionCollapsed', sectionId: options.nodeId, collapsed: false }, - buildNodeSingleClickInteractionEffect(options), - ], - }; - } - const { effects, nextLastClick } = buildNodeSingleClickSelectionResult(options); effects.push(buildNodeSingleClickInteractionEffect(options)); diff --git a/packages/extension/src/webview/components/graph/interactionRuntime/fit/bounds.ts b/packages/extension/src/webview/components/graph/interactionRuntime/fit/bounds.ts index b03489638..70f024791 100644 --- a/packages/extension/src/webview/components/graph/interactionRuntime/fit/bounds.ts +++ b/packages/extension/src/webview/components/graph/interactionRuntime/fit/bounds.ts @@ -11,19 +11,6 @@ export interface FitBounds2d { } function getNodeRadius(node: FGNode): number { - const sectionHeight = node.sectionHeight; - const sectionWidth = node.sectionWidth; - if ( - node.isGraphSection - && !node.isCollapsedGraphSection - && typeof sectionHeight === 'number' - && Number.isFinite(sectionHeight) - && typeof sectionWidth === 'number' - && Number.isFinite(sectionWidth) - ) { - return Math.max(sectionWidth / 2, sectionHeight / 2); - } - const size = node.size ?? Number.NaN; if (Number.isFinite(size)) { @@ -34,22 +21,6 @@ function getNodeRadius(node: FGNode): number { } function getNodeFitExtents(node: FGNode): { x: number; y: number } { - const sectionHeight = node.sectionHeight; - const sectionWidth = node.sectionWidth; - if ( - node.isGraphSection - && !node.isCollapsedGraphSection - && typeof sectionHeight === 'number' - && Number.isFinite(sectionHeight) - && typeof sectionWidth === 'number' - && Number.isFinite(sectionWidth) - ) { - return { - x: sectionWidth / 2, - y: sectionHeight / 2, - }; - } - const radius = getNodeRadius(node); return { x: radius, y: radius }; } diff --git a/packages/extension/src/webview/components/graph/interactionRuntime/fit/padding.ts b/packages/extension/src/webview/components/graph/interactionRuntime/fit/padding.ts index 44f0e2662..2bcad62c8 100644 --- a/packages/extension/src/webview/components/graph/interactionRuntime/fit/padding.ts +++ b/packages/extension/src/webview/components/graph/interactionRuntime/fit/padding.ts @@ -3,17 +3,6 @@ import type { FGNode } from '../../model/build'; export const MIN_FIT_VIEW_PADDING = 20; function getNodeFitPaddingSize(node: FGNode): number { - if ( - node.isGraphSection - && !node.isCollapsedGraphSection - && typeof node.sectionHeight === 'number' - && Number.isFinite(node.sectionHeight) - && typeof node.sectionWidth === 'number' - && Number.isFinite(node.sectionWidth) - ) { - return Math.max(node.sectionHeight, node.sectionWidth) / 2; - } - return typeof node.size === 'number' && Number.isFinite(node.size) ? node.size : 0; } diff --git a/packages/extension/src/webview/components/graph/interactionRuntime/handlers.ts b/packages/extension/src/webview/components/graph/interactionRuntime/handlers.ts index ae5721a25..413f2257c 100644 --- a/packages/extension/src/webview/components/graph/interactionRuntime/handlers.ts +++ b/packages/extension/src/webview/components/graph/interactionRuntime/handlers.ts @@ -7,7 +7,6 @@ import type { } from 'react-force-graph-3d'; import type { IFileInfo } from '../../../../shared/files/info'; import type { IGraphData } from '../../../../shared/graph/contracts'; -import type { GraphLayoutSettings } from '../../../../shared/settings/graphLayout'; import type { GraphContextSelection } from '../contextMenu/contracts'; import { type GraphInteractionEffect, @@ -28,7 +27,6 @@ export interface GraphInteractionHandlersDependencies { fg2dRef: MutableRefObject | undefined>; fg3dRef: MutableRefObject | undefined>; fileInfoCacheRef: MutableRefObject>; - graphLayout?: GraphLayoutSettings; graphCursorRef: MutableRefObject; graphDataRef: MutableRefObject<{ nodes: FGNode[]; links: FGLink[] }>; graphMode: '2d' | '3d'; @@ -42,7 +40,6 @@ export interface GraphInteractionHandlersDependencies { setContextSelection(selection: GraphContextSelection): void; setHighlightVersion(updater: (previous: number) => number): void; setSelectedNodes(nodeIds: string[]): void; - toggleFolderCollapse?(nodeId: string, collapsed: boolean): void; } export interface GraphInteractionHandlers { diff --git a/packages/extension/src/webview/components/graph/interactionRuntime/handlers/click.ts b/packages/extension/src/webview/components/graph/interactionRuntime/handlers/click.ts index f21466362..f38f1eecb 100644 --- a/packages/extension/src/webview/components/graph/interactionRuntime/handlers/click.ts +++ b/packages/extension/src/webview/components/graph/interactionRuntime/handlers/click.ts @@ -4,10 +4,6 @@ import { getNodeClickCommand, } from '../../interaction/model'; import type { FGLink, FGNode } from '../../model/build'; -import { - getNodeCollapseIndicatorCenter, - shouldRenderNodeCollapseIndicator, -} from '../../rendering/node/collapseIndicator'; import type { GraphCursorStyle } from '../../support/dom'; import type { GraphInteractionHandlersDependencies } from '../handlers'; @@ -42,34 +38,6 @@ function stopSuppressedContextClick(event: MouseEvent): void { event.stopPropagation(); } -function isFolderCollapseIndicatorClick( - node: FGNode, - event: MouseEvent, - dependencies: GraphInteractionHandlersDependencies, -): boolean { - if ( - dependencies.graphMode !== '2d' - || !dependencies.toggleFolderCollapse - || !shouldRenderNodeCollapseIndicator(node) - ) { - return false; - } - - const graph = dependencies.fg2dRef.current; - const toScreen = graph?.graph2ScreenCoords?.bind(graph); - if (!toScreen) { - return false; - } - - const indicatorCenter = getNodeCollapseIndicatorCenter(node); - const screenCenter = toScreen(indicatorCenter.x, indicatorCenter.y); - const rect = dependencies.containerRef.current?.getBoundingClientRect(); - const dx = event.clientX - ((rect?.left ?? 0) + screenCenter.x); - const dy = event.clientY - ((rect?.top ?? 0) + screenCenter.y); - - return Math.hypot(dx, dy) <= 12; -} - export function createClickHandlers( dependencies: GraphInteractionHandlersDependencies, callbacks: ClickHandlerCallbacks, @@ -80,13 +48,6 @@ export function createClickHandlers( return; } - if (isFolderCollapseIndicatorClick(node, event, dependencies)) { - event.preventDefault(); - event.stopPropagation(); - dependencies.toggleFolderCollapse?.(node.id, !node.isCollapsed); - return; - } - const command = getNodeClickCommand({ nodeId: node.id, label: node.label, @@ -95,8 +56,7 @@ export function createClickHandlers( metaKey: event.metaKey, clientX: event.clientX, clientY: event.clientY, - isCollapsedGraphSection: node.isCollapsedGraphSection, - isGraphSection: node.isGraphSection, + isRuntimeNode: !!node.ownerPluginId || !!node.runtimeNodeType, isMacPlatform: dependencies.isMacPlatform, selectedNodeIds: dependencies.selectedNodesSetRef.current, lastClick: dependencies.lastClickRef.current, diff --git a/packages/extension/src/webview/components/graph/interactionRuntime/handlers/contextMenu.ts b/packages/extension/src/webview/components/graph/interactionRuntime/handlers/contextMenu.ts index 7c50c67f6..2956e81e3 100644 --- a/packages/extension/src/webview/components/graph/interactionRuntime/handlers/contextMenu.ts +++ b/packages/extension/src/webview/components/graph/interactionRuntime/handlers/contextMenu.ts @@ -4,7 +4,6 @@ import { makeEdgeContextSelection, makeNodeContextSelection, } from '../../contextMenu/selection'; -import { findDeepestGraphLayoutSectionAtWorldPoint } from '../../../../../shared/settings/graphLayout'; import { getNodeContextMenuSelection } from '../../interaction/model'; import type { FGLink } from '../../model/build'; import { resolveEdgeActionTargetId, resolveLinkEndpointId } from '../../support/linkTargets'; @@ -145,14 +144,6 @@ export function createContextMenuHandlers( const openBackgroundContextMenu = (event: MouseEvent): void => { const graphPosition = getBackgroundGraphPosition(dependencies, event); - const sectionId = dependencies.graphMode === '2d' && dependencies.graphLayout && graphPosition - ? findDeepestGraphLayoutSectionAtWorldPoint(dependencies.graphLayout, graphPosition) - : null; - - if (sectionId) { - openNodeContextMenuAtPosition(sectionId, event, graphPosition, true); - return; - } flushSync(() => { dependencies.setContextSelection(makeBackgroundContextSelection(graphPosition)); diff --git a/packages/extension/src/webview/components/graph/interactionRuntime/handlers/effects.ts b/packages/extension/src/webview/components/graph/interactionRuntime/handlers/effects.ts index ad536259a..829adc068 100644 --- a/packages/extension/src/webview/components/graph/interactionRuntime/handlers/effects.ts +++ b/packages/extension/src/webview/components/graph/interactionRuntime/handlers/effects.ts @@ -46,16 +46,6 @@ export function createEffectHandlers( postMessage({ type: 'NODE_DOUBLE_CLICKED', payload: { nodeId } }); }; - const setGraphSectionCollapsed = (sectionId: string, collapsed: boolean): void => { - postMessage({ - type: 'UPDATE_GRAPH_LAYOUT_SECTION', - payload: { - sectionId, - updates: { collapsed }, - }, - }); - }; - const applyGraphInteractionEffects = ( effects: GraphInteractionEffect[], options: GraphInteractionOptions = {}, @@ -73,7 +63,6 @@ export function createEffectHandlers( previewNode, selectOnlyNode: handlers.selectOnlyNode, sendInteraction: sendGraphInteraction, - setGraphSectionCollapsed, setSelection: handlers.setSelection, }, options, diff --git a/packages/extension/src/webview/components/graph/keyboard/command/builders.ts b/packages/extension/src/webview/components/graph/keyboard/command/builders.ts index 8806956c9..928af66d0 100644 --- a/packages/extension/src/webview/components/graph/keyboard/command/builders.ts +++ b/packages/extension/src/webview/components/graph/keyboard/command/builders.ts @@ -32,20 +32,6 @@ export function createSelectAllCommand(nodeIds: string[]): GraphKeyboardCommand return createCommand({ kind: 'selectAll', nodeIds }); } -export function createDeleteGraphSectionsCommand(sectionIds: string[]): GraphKeyboardCommand { - return { - preventDefault: true, - stopPropagation: false, - effects: sectionIds.map(sectionId => ({ - kind: 'postMessage', - message: { - type: 'DELETE_GRAPH_LAYOUT_SECTION', - payload: { sectionId }, - }, - })), - }; -} - export function createZoomCommand(factor: number): GraphKeyboardCommand { return createCommand({ kind: 'zoom', factor }); } diff --git a/packages/extension/src/webview/components/graph/keyboard/command/lookup.ts b/packages/extension/src/webview/components/graph/keyboard/command/lookup.ts index 86830c834..2d39df8f8 100644 --- a/packages/extension/src/webview/components/graph/keyboard/command/lookup.ts +++ b/packages/extension/src/webview/components/graph/keyboard/command/lookup.ts @@ -1,7 +1,6 @@ import type { GraphKeyboardCommand, GraphKeyboardOptions } from '../effects'; import { createClearSelectionCommand, - createDeleteGraphSectionsCommand, createFitViewCommand, createOpenSelectedNodesCommand, createSelectAllCommand, @@ -26,12 +25,6 @@ function getEnterCommand(selectedNodeIds: readonly string[]): GraphKeyboardComma ); } -function getDeleteCommand(selectedGraphSectionIds: readonly string[]): GraphKeyboardCommand | null { - return selectedGraphSectionIds.length === 0 - ? null - : createDeleteGraphSectionsCommand([...selectedGraphSectionIds]); -} - function getShortcutCommand(options: GraphKeyboardOptions): GraphKeyboardCommand | null { return ( getZoomShortcutCommand(options.key, options.isMod, options.graphMode) ?? @@ -49,7 +42,6 @@ export function getGraphKeyboardCommandImpl( shiftKey, graphMode, selectedNodeIds, - selectedGraphSectionIds = [], allNodeIds, targetIsEditable, } = options; @@ -67,7 +59,7 @@ export function getGraphKeyboardCommandImpl( return getEnterCommand(selectedNodeIds); case 'Delete': case 'Backspace': - return getDeleteCommand(selectedGraphSectionIds); + return null; case 'a': return isMod ? createSelectAllCommand(allNodeIds) : null; default: diff --git a/packages/extension/src/webview/components/graph/keyboard/effects.ts b/packages/extension/src/webview/components/graph/keyboard/effects.ts index 007a8c37c..a3a29e887 100644 --- a/packages/extension/src/webview/components/graph/keyboard/effects.ts +++ b/packages/extension/src/webview/components/graph/keyboard/effects.ts @@ -23,7 +23,6 @@ export interface GraphKeyboardOptions { shiftKey: boolean; graphMode: '2d' | '3d'; selectedNodeIds: string[]; - selectedGraphSectionIds?: string[]; allNodeIds: string[]; targetIsEditable: boolean; } diff --git a/packages/extension/src/webview/components/graph/keyboard/listener.ts b/packages/extension/src/webview/components/graph/keyboard/listener.ts index f14f46cae..eeb7b4c4c 100644 --- a/packages/extension/src/webview/components/graph/keyboard/listener.ts +++ b/packages/extension/src/webview/components/graph/keyboard/listener.ts @@ -15,7 +15,6 @@ export interface GraphKeyboardListenerOptions { handlers: GraphKeyboardEffectHandlers, ) => void; selectedNodeIds: string[]; - selectedGraphSectionIds?: string[]; setSelection: (nodeIds: string[]) => void; zoomGraphView: (factor: number) => void; } @@ -33,7 +32,6 @@ export function createGraphKeyboardListener({ postMessage, runEffects, selectedNodeIds, - selectedGraphSectionIds = [], setSelection, zoomGraphView, }: GraphKeyboardListenerOptions): (event: KeyboardEvent) => void { @@ -44,7 +42,6 @@ export function createGraphKeyboardListener({ shiftKey: event.shiftKey, graphMode, selectedNodeIds, - selectedGraphSectionIds, allNodeIds: getAllNodeIds(), targetIsEditable: isEditableTarget(event.target), }); diff --git a/packages/extension/src/webview/components/graph/marqueeSelection/model.ts b/packages/extension/src/webview/components/graph/marqueeSelection/model.ts index 6d448c028..1102cde2f 100644 --- a/packages/extension/src/webview/components/graph/marqueeSelection/model.ts +++ b/packages/extension/src/webview/components/graph/marqueeSelection/model.ts @@ -53,10 +53,6 @@ function containsPoint(bounds: MarqueeBounds, point: MarqueePoint): boolean { && point.y <= bounds.top + bounds.height; } -function isSelectableByMarquee(node: FGNode): boolean { - return !node.isGraphSection || !!node.isCollapsedGraphSection; -} - export function getMarqueeSelectedNodeIds({ bounds, graphToScreen, @@ -65,10 +61,6 @@ export function getMarqueeSelectedNodeIds({ const selectedNodeIds: string[] = []; for (const node of nodes) { - if (!isSelectableByMarquee(node)) { - continue; - } - if (!isFiniteNumber(node.x) || !isFiniteNumber(node.y)) { continue; } diff --git a/packages/extension/src/webview/components/graph/model/build.ts b/packages/extension/src/webview/components/graph/model/build.ts index 2494f975e..c41000695 100644 --- a/packages/extension/src/webview/components/graph/model/build.ts +++ b/packages/extension/src/webview/components/graph/model/build.ts @@ -1,19 +1,22 @@ import type { LinkObject, NodeObject } from 'react-force-graph-2d'; -import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { CoreGraphViewContributionSet } from '@codegraphy-dev/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'; import { DEFAULT_GRAPH_APPEARANCE, type GraphAppearance } from '../appearance/model'; import type { NodeType } from '../../../../shared/graph/contracts'; import { buildGraphLinks } from './link/build'; import { buildGraphNodes } from './node/build'; -import { projectGraphSectionsForRendering } from './sectionProjection'; +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'; export { calculateNodeSizes, toD3Repel } from './node/sizing'; -export type FGNode = NodeObject & { +export type FGNode = NodeObject & Record & { id: string; label: string; size: number; @@ -25,22 +28,30 @@ export type FGNode = NodeObject & { isPinned: boolean; icon?: string; nodeType?: NodeType; + ownerPluginId?: string; + runtimeNodeType?: string; + fillOpacity2D?: number; + shapeSize2D?: { + height: number; + width: number; + }; + chargeStrengthMultiplier2D?: number; + collisionRadius2D?: number; + pointerArea2D?: { + height: number; + width: number; + }; shape2D?: NodeShape2D; shape3D?: NodeShape3D; imageUrl?: string; + metadata?: GraphMetadata; collapsedDescendantCount?: number; - hiddenDescendantCount?: number; isCollapsible?: boolean; isCollapsed?: boolean; - isCollapsedGraphSection?: boolean; isDragging?: boolean; - isGraphSection?: boolean; fx?: number; fy?: number; fz?: number; - ownerSectionId?: string | null; - sectionHeight?: number; - sectionWidth?: number; vx?: number; vy?: number; vz?: number; @@ -60,18 +71,21 @@ export type FGLink = LinkObject & { curvature?: number; curvatureGroupId?: string; kind?: string; + metadata?: GraphMetadata; + ownerPluginId?: string; projectedEdgeCount?: number; projectedEdgeIds?: string[]; + runtimeEdgeType?: string; }; export interface BuildGraphDataOptions { data: IGraphData; + graphViewContributions?: CoreGraphViewContributionSet; appearance?: GraphAppearance; nodeSizeMode: NodeSizeMode; theme: ThemeKind; favorites: Set; - graphLayout?: GraphLayoutSettings; - graphMode?: GraphLayoutMode; + graphMode?: '2d' | '3d'; bidirectionalMode: BidirectionalEdgeMode; timelineActive: boolean; previousNodes?: Array>; @@ -81,28 +95,36 @@ 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 projected = projectGraphSectionsForRendering({ - data: options.data, - graphLayout: options.graphLayout, - graphMode, - timelineActive: options.timelineActive, - }); - const nodeSizes = calculateNodeSizes(projected.data.nodes, projected.data.edges, options.nodeSizeMode); + const runtimeData = applyGraphViewRuntimeContributions( + options.data, + options.graphViewContributions, + { + graphMode, + timelineActive: options.timelineActive, + }, + ); + const projectedData = applyGraphViewProjectionContributions( + runtimeData, + options.graphViewContributions, + { + graphMode, + timelineActive: options.timelineActive, + }, + ); + const nodeSizes = calculateNodeSizes(projectedData.nodes, projectedData.edges, options.nodeSizeMode); const nodes = buildGraphNodes({ - allNodeIds: options.data.nodes.map(node => node.id), - nodes: projected.data.nodes, - edges: projected.data.edges, + nodes: projectedData.nodes, + edges: projectedData.edges, appearance, nodeSizes, theme: options.theme, favorites: options.favorites, - graphLayout: options.graphLayout, graphMode, timelineActive: options.timelineActive, 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/link/build.ts b/packages/extension/src/webview/components/graph/model/link/build.ts index 9ba0a398e..276f601af 100644 --- a/packages/extension/src/webview/components/graph/model/link/build.ts +++ b/packages/extension/src/webview/components/graph/model/link/build.ts @@ -3,10 +3,18 @@ import type { BidirectionalEdgeMode } from '../../../../../shared/settings/modes import { computeLinkCurvature } from './curvature'; import type { FGLink } from '../build'; import { processEdges } from '../edgeProcessing'; -import type { ProjectedGraphEdge } from '../sectionProjection'; + +interface ProjectedGraphEdge extends IGraphEdge { + projectedEdgeCount?: number; + projectedEdgeIds?: string[]; +} 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, @@ -16,10 +24,13 @@ 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, + ownerPluginId: runtimeEdge.ownerPluginId, + runtimeEdgeType: runtimeEdge.runtimeEdgeType, }; 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..8aaa4f8ca 100644 --- a/packages/extension/src/webview/components/graph/model/node/build.ts +++ b/packages/extension/src/webview/components/graph/model/node/build.ts @@ -1,15 +1,4 @@ import type { IGraphEdge, IGraphNode } from '../../../../../shared/graph/contracts'; -import { - createDefaultGraphLayoutSettings, - countGraphLayoutHiddenDescendants, - getGraphLayoutPinCoordinate, - type GraphLayoutCoordinate2D, - type GraphLayoutCoordinate3D, - isGraphLayoutSectionNodeVisible, - type GraphLayoutMode, - type GraphLayoutSection, - type GraphLayoutSettings, -} from '../../../../../shared/settings/graphLayout'; import type { ThemeKind } from '../../../../theme/useTheme'; import { adjustColorForLightTheme } from '../../../../theme/useTheme'; import { DEFAULT_GRAPH_APPEARANCE, type GraphAppearance } from '../../appearance/model'; @@ -23,15 +12,13 @@ import { import { seedTimelinePositions } from '../timeline/seeding'; export interface BuildGraphNodesOptions { - allNodeIds?: readonly string[]; nodes: IGraphNode[]; edges: IGraphEdge[]; appearance?: GraphAppearance; nodeSizes: Map; theme: ThemeKind; favorites: Set; - graphLayout?: GraphLayoutSettings; - graphMode?: GraphLayoutMode; + graphMode?: '2d' | '3d'; timelineActive: boolean; previousNodes?: Array>; random?: () => number; @@ -65,6 +52,10 @@ function createPreviousNodeStateMap( }])); } +function readFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + function getNodeBorderColor( isFocused: boolean, isFavorite: boolean, @@ -86,8 +77,6 @@ function getNodeBorderWidth(isFocused: boolean, isFavorite: boolean): number { return isFavorite ? 3 : 2; } -type GraphNodePinCoordinate = GraphLayoutCoordinate2D | GraphLayoutCoordinate3D; - interface GraphNodeStyle { baseOpacity: number; borderColor: string; @@ -109,23 +98,20 @@ interface GraphNodePositionState { z: number | undefined; } -function getActiveGraphNodePinCoordinate( - nodeId: string, - options: { - graphLayout: GraphLayoutSettings; - graphMode: GraphLayoutMode; - timelineActive: boolean; - }, -): GraphNodePinCoordinate | undefined { - return options.timelineActive - ? undefined - : getGraphLayoutPinCoordinate(options.graphLayout.pinnedNodes[nodeId], options.graphMode); +interface RuntimeGraphNodePositionState { + fx?: unknown; + fy?: unknown; + fz?: unknown; + vx?: unknown; + vy?: unknown; + vz?: unknown; } -function read3DCoordinate( - coordinate: GraphNodePinCoordinate | undefined, -): GraphLayoutCoordinate3D | undefined { - return coordinate && 'z' in coordinate ? coordinate : undefined; +interface RuntimeGraphNodePresentation { + [key: string]: unknown; + ownerPluginId?: string; + runtimeNodeType?: string; + size?: number; } function createGraphNodeStyle( @@ -140,7 +126,10 @@ function createGraphNodeStyle( const rawColor = isLight ? adjustColorForLightTheme(node.color) : node.color; const isFavorite = options.favorites.has(node.id); const isFocused = node.depthLevel === 0; - const size = (options.nodeSizes.get(node.id) ?? DEFAULT_NODE_SIZE) * getDepthSizeMultiplier(node.depthLevel); + const runtimePresentation = node as IGraphNode & RuntimeGraphNodePresentation; + const size = typeof runtimePresentation.size === 'number' + ? runtimePresentation.size + : (options.nodeSizes.get(node.id) ?? DEFAULT_NODE_SIZE) * getDepthSizeMultiplier(node.depthLevel); return { baseOpacity: getDepthOpacity(node.depthLevel), @@ -155,53 +144,19 @@ function createGraphNodeStyle( function createGraphNodePositionState( node: IGraphNode, previous: PreviousNodeState | undefined, - pinCoordinate: GraphNodePinCoordinate | undefined, - graphMode: GraphLayoutMode, ): GraphNodePositionState { - const pinCoordinate3D = graphMode === '3d' ? read3DCoordinate(pinCoordinate) : undefined; - + const runtimePosition = node as RuntimeGraphNodePositionState; + const z = (node as { z?: unknown }).z; return { - fx: pinCoordinate?.x, - fy: pinCoordinate?.y, - fz: pinCoordinate3D?.z, - vx: previous?.vx, - vy: previous?.vy, - vz: previous?.vz, - x: pinCoordinate?.x ?? node.x ?? previous?.x, - y: pinCoordinate?.y ?? node.y ?? previous?.y, - z: pinCoordinate3D?.z ?? previous?.z, - }; -} - -function getGraphNodeOwnerSectionId( - nodeId: string, - graphLayout: GraphLayoutSettings, - timelineActive: boolean, -): string | null { - return timelineActive ? null : (graphLayout.ownership[nodeId]?.ownerSectionId ?? null); -} - -function resolveGraphNodePinCoordinate( - pinCoordinate: GraphNodePinCoordinate | undefined, - ownerSectionId: string | null, - options: { - graphLayout: GraphLayoutSettings; - graphMode: GraphLayoutMode; - }, -): GraphNodePinCoordinate | undefined { - if (!pinCoordinate || options.graphMode !== '2d' || !ownerSectionId) { - return pinCoordinate; - } - - const ownerSection = options.graphLayout.sections[ownerSectionId]; - if (!ownerSection) { - return pinCoordinate; - } - - const ownerTopLeft = getSectionWorldTopLeft(ownerSection, options.graphLayout); - return { - x: ownerTopLeft.x + pinCoordinate.x, - y: ownerTopLeft.y + pinCoordinate.y, + fx: readFiniteNumber(runtimePosition.fx) ?? previous?.fx, + fy: readFiniteNumber(runtimePosition.fy) ?? previous?.fy, + fz: readFiniteNumber(runtimePosition.fz) ?? previous?.fz, + vx: readFiniteNumber(runtimePosition.vx) ?? previous?.vx, + vy: readFiniteNumber(runtimePosition.vy) ?? previous?.vy, + vz: readFiniteNumber(runtimePosition.vz) ?? previous?.vz, + x: node.x ?? previous?.x, + y: node.y ?? previous?.y, + z: typeof z === 'number' ? z : previous?.z, }; } @@ -210,150 +165,44 @@ function createGraphNode( options: { appearance: GraphAppearance; favorites: ReadonlySet; - graphLayout: GraphLayoutSettings; - graphMode: GraphLayoutMode; nodeSizes: ReadonlyMap; - timelineActive: boolean; }, isLight: boolean, previousNodeStates: ReadonlyMap, ): FGNode { + const runtimeNode = node as IGraphNode & RuntimeGraphNodePresentation; const previous = previousNodeStates.get(node.id); - const ownerSectionId = getGraphNodeOwnerSectionId(node.id, options.graphLayout, options.timelineActive); - const pinCoordinate = resolveGraphNodePinCoordinate( - getActiveGraphNodePinCoordinate(node.id, options), - ownerSectionId, - options, - ); const style = createGraphNodeStyle(node, options, isLight); - const position = createGraphNodePositionState(node, previous, pinCoordinate, options.graphMode); + const position = createGraphNodePositionState(node, previous); return { + ...runtimeNode, id: node.id, label: node.label, ...style, - isPinned: !!pinCoordinate, + isPinned: runtimeNode.isPinned === true, nodeType: node.nodeType, + ownerPluginId: runtimeNode.ownerPluginId, + runtimeNodeType: runtimeNode.runtimeNodeType, shape2D: node.shape2D, shape3D: node.shape3D, imageUrl: node.imageUrl, + metadata: node.metadata, isCollapsible: node.isCollapsible, isCollapsed: node.isCollapsed, collapsedDescendantCount: node.collapsedDescendantCount, - ownerSectionId, ...position, } as FGNode; } -function getSectionNodeSize(section: Pick): number { - return Math.max(24, Math.min(48, Math.sqrt(section.width * section.height) / 12)); -} - -function getSectionWorldTopLeft( - section: GraphLayoutSection, - graphLayout: GraphLayoutSettings, - visited = new Set(), -): GraphLayoutCoordinate2D { - if (visited.has(section.id)) { - return { x: section.x, y: section.y }; - } - - const ownerSectionId = graphLayout.ownership[section.id]?.ownerSectionId ?? null; - const ownerSection = ownerSectionId ? graphLayout.sections[ownerSectionId] : undefined; - if (!ownerSection) { - return { x: section.x, y: section.y }; - } - - visited.add(section.id); - const ownerTopLeft = getSectionWorldTopLeft(ownerSection, graphLayout, visited); - return { - x: ownerTopLeft.x + section.x, - y: ownerTopLeft.y + section.y, - }; -} - -function createGraphSectionNode( - section: GraphLayoutSection, - options: { - allNodeIds: readonly string[]; - graphLayout: GraphLayoutSettings; - }, - previousNodeStates: ReadonlyMap, -): FGNode { - const previous = previousNodeStates.get(section.id); - const ownerSectionId = options.graphLayout.ownership[section.id]?.ownerSectionId ?? null; - const rawPinCoordinate = getGraphLayoutPinCoordinate(options.graphLayout.pinnedNodes[section.id], '2d'); - const ownerSection = ownerSectionId ? options.graphLayout.sections[ownerSectionId] : undefined; - const ownerTopLeft = ownerSection ? getSectionWorldTopLeft(ownerSection, options.graphLayout) : undefined; - const pinCoordinate = rawPinCoordinate && ownerTopLeft - ? { x: ownerTopLeft.x + rawPinCoordinate.x, y: ownerTopLeft.y + rawPinCoordinate.y } - : rawPinCoordinate; - const worldTopLeft = getSectionWorldTopLeft(section, options.graphLayout); - const centerX = worldTopLeft.x + (section.width / 2); - const centerY = worldTopLeft.y + (section.height / 2); - const x = pinCoordinate?.x ?? previous?.x ?? centerX; - const y = pinCoordinate?.y ?? previous?.y ?? centerY; - - return { - id: section.id, - label: section.label, - icon: section.icon, - size: getSectionNodeSize(section), - color: section.color, - borderColor: section.color, - borderWidth: 2, - baseOpacity: 0.35, - hiddenDescendantCount: section.collapsed - ? countGraphLayoutHiddenDescendants(options.graphLayout, section.id, options.allNodeIds) - : 0, - isCollapsedGraphSection: section.collapsed, - isFavorite: false, - isGraphSection: true, - isPinned: !!pinCoordinate, - nodeType: 'graph-section', - ownerSectionId, - sectionHeight: section.height, - sectionWidth: section.width, - shape2D: 'square', - fx: pinCoordinate?.x, - fy: pinCoordinate?.y, - vx: previous?.vx, - vy: previous?.vy, - x, - y, - } as FGNode; -} - -function buildGraphSectionNodes( - allNodeIds: readonly string[], - graphLayout: GraphLayoutSettings, - graphMode: GraphLayoutMode, - timelineActive: boolean, - previousNodeStates: ReadonlyMap, -): FGNode[] { - if (graphMode !== '2d' || timelineActive) { - return []; - } - - return Object.values(graphLayout.sections) - .filter(section => isGraphLayoutSectionNodeVisible(graphLayout, section.id)) - .map(section => createGraphSectionNode(section, { - allNodeIds, - graphLayout, - }, previousNodeStates)); -} - export function buildGraphNodes(options: BuildGraphNodesOptions): FGNode[] { const { nodes, - allNodeIds = nodes.map(node => node.id), edges, appearance = DEFAULT_GRAPH_APPEARANCE, nodeSizes, theme, favorites, - graphLayout = createDefaultGraphLayoutSettings(), - graphMode = '2d', timelineActive, previousNodes = [], random = Math.random, @@ -362,17 +211,10 @@ export function buildGraphNodes(options: BuildGraphNodesOptions): FGNode[] { const previousNodeStates = createPreviousNodeStateMap(previousNodes); const graphNodes = nodes.map(node => createGraphNode( node, - { appearance, nodeSizes, favorites, graphLayout, graphMode, timelineActive }, + { appearance, nodeSizes, favorites }, isLight, previousNodeStates, )); - graphNodes.push(...buildGraphSectionNodes( - allNodeIds, - graphLayout, - graphMode, - timelineActive, - previousNodeStates, - )); seedTimelinePositions(graphNodes, edges, timelineActive ? previousNodeStates : null, random); diff --git a/packages/extension/src/webview/components/graph/model/node/rectangularArea.ts b/packages/extension/src/webview/components/graph/model/node/rectangularArea.ts new file mode 100644 index 000000000..be0896002 --- /dev/null +++ b/packages/extension/src/webview/components/graph/model/node/rectangularArea.ts @@ -0,0 +1,28 @@ +export interface RectangularNodeArea2D { + height: number; + width: number; +} + +function isFinitePositiveNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +export function getRectangularNodeArea2D(area: unknown): RectangularNodeArea2D | undefined { + if ( + !area + || typeof area !== 'object' + || !isFinitePositiveNumber((area as RectangularNodeArea2D).height) + || !isFinitePositiveNumber((area as RectangularNodeArea2D).width) + ) { + return undefined; + } + + return { + height: (area as RectangularNodeArea2D).height, + width: (area as RectangularNodeArea2D).width, + }; +} + +export function getRectangularNodeAreaRadius(area: RectangularNodeArea2D): number { + return Math.hypot(area.height, area.width) / 2; +} 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..e8c779fbf --- /dev/null +++ b/packages/extension/src/webview/components/graph/model/runtimeContributions.ts @@ -0,0 +1,93 @@ +import type { CoreGraphViewContributionSet } from '@codegraphy-dev/core'; +import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../shared/graph/contracts'; + +export interface GraphViewRuntimeContributionContext { + graphMode?: '2d' | '3d'; + timelineActive?: boolean; +} + +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, + context: GraphViewRuntimeContributionContext = {}, +): 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 }, + ...context, + }), + ); + } + + for (const entry of contributions.runtimeEdges) { + appendUniqueEdges( + edges, + edgeIds, + nodeIds, + entry.contribution.createEdges({ + visibleGraph: { nodes, edges }, + ...context, + }), + ); + } + + return { nodes, edges }; +} + +export function applyGraphViewProjectionContributions( + data: IGraphData, + contributions: CoreGraphViewContributionSet | undefined, + context: GraphViewRuntimeContributionContext = {}, +): IGraphData { + if (!contributions) { + return data; + } + + return contributions.projections.reduce( + (visibleGraph, entry) => entry.contribution.project({ visibleGraph, ...context }), + data, + ); +} diff --git a/packages/extension/src/webview/components/graph/model/sectionProjection.ts b/packages/extension/src/webview/components/graph/model/sectionProjection.ts deleted file mode 100644 index c68480d83..000000000 --- a/packages/extension/src/webview/components/graph/model/sectionProjection.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../shared/graph/contracts'; -import type { GraphLayoutMode, GraphLayoutSettings } from '../../../../shared/settings/graphLayout'; -import { - getGraphLayoutCollapsedRepresentative, - isGraphLayoutItemHiddenByCollapsedSection, -} from '../../../../shared/settings/graphLayout'; - -export interface ProjectedGraphEdge extends IGraphEdge { - projectedEdgeCount?: number; - projectedEdgeIds?: string[]; -} - -export interface ProjectGraphSectionsResult { - data: { - edges: ProjectedGraphEdge[]; - nodes: IGraphNode[]; - }; -} - -function shouldProjectGraphSections( - graphLayout: GraphLayoutSettings | undefined, - graphMode: GraphLayoutMode, - timelineActive: boolean, -): graphLayout is GraphLayoutSettings { - return !!graphLayout - && graphMode === '2d' - && !timelineActive - && Object.keys(graphLayout.sections).some(sectionId => graphLayout.sections[sectionId].collapsed); -} - -function getVisibleNodeId( - graphLayout: GraphLayoutSettings, - itemId: string, -): string { - return getGraphLayoutCollapsedRepresentative(graphLayout, itemId) ?? itemId; -} - -function projectGraphNodes( - nodes: readonly IGraphNode[], - graphLayout: GraphLayoutSettings, -): IGraphNode[] { - return nodes.filter(node => !isGraphLayoutItemHiddenByCollapsedSection(graphLayout, node.id)); -} - -function getProjectedEdgeKey( - from: string, - to: string, - edge: Pick, -): string { - return `${from}->${to}#${edge.kind}`; -} - -function createProjectedEdge( - edge: IGraphEdge, - from: string, - to: string, -): ProjectedGraphEdge { - return { - ...edge, - from, - id: getProjectedEdgeKey(from, to, edge), - projectedEdgeCount: 1, - projectedEdgeIds: [edge.id], - to, - }; -} - -function mergeProjectedEdge( - existing: ProjectedGraphEdge, - edge: IGraphEdge, -): ProjectedGraphEdge { - return { - ...existing, - projectedEdgeCount: (existing.projectedEdgeCount ?? 1) + 1, - projectedEdgeIds: [...(existing.projectedEdgeIds ?? [existing.id]), edge.id], - sources: [...existing.sources, ...edge.sources], - }; -} - -function projectGraphEdges( - edges: readonly IGraphEdge[], - graphLayout: GraphLayoutSettings, -): ProjectedGraphEdge[] { - const projectedEdges = new Map(); - - for (const edge of edges) { - const from = getVisibleNodeId(graphLayout, edge.from); - const to = getVisibleNodeId(graphLayout, edge.to); - if (from === to) { - continue; - } - - const key = getProjectedEdgeKey(from, to, edge); - const existing = projectedEdges.get(key); - projectedEdges.set( - key, - existing ? mergeProjectedEdge(existing, edge) : createProjectedEdge(edge, from, to), - ); - } - - return [...projectedEdges.values()]; -} - -export function projectGraphSectionsForRendering({ - data, - graphLayout, - graphMode = '2d', - timelineActive, -}: { - data: IGraphData; - graphLayout?: GraphLayoutSettings; - graphMode?: GraphLayoutMode; - timelineActive: boolean; -}): ProjectGraphSectionsResult { - if (!shouldProjectGraphSections(graphLayout, graphMode, timelineActive)) { - return { data }; - } - - return { - data: { - edges: projectGraphEdges(data.edges, graphLayout), - nodes: projectGraphNodes(data.nodes, graphLayout), - }, - }; -} diff --git a/packages/extension/src/webview/components/graph/rendering/node/body.ts b/packages/extension/src/webview/components/graph/rendering/node/body.ts index 5e51f6d1d..baa4f977b 100644 --- a/packages/extension/src/webview/components/graph/rendering/node/body.ts +++ b/packages/extension/src/webview/components/graph/rendering/node/body.ts @@ -24,6 +24,7 @@ export function renderNodeBody({ }: RenderNodeBodyOptions): void { drawNodeBodyPath(ctx, node); ctx.fillStyle = getNodeFillColor(node, decoration); + ctx.globalAlpha = opacity * getNodeFillOpacity(node); ctx.fill(); ctx.strokeStyle = getNodeBorderColor(node, isSelected, appearance); @@ -33,39 +34,14 @@ export function renderNodeBody({ } function drawNodeBodyPath(ctx: CanvasRenderingContext2D, node: FGNode): void { - if (node.isCollapsedGraphSection) { - drawRoundedSectionSquare(ctx, node.x!, node.y!, node.size); + if (node.shapeSize2D) { + drawShape(ctx, node.shape2D ?? 'circle', node.x!, node.y!, node.size, node.shapeSize2D); return; } drawShape(ctx, node.shape2D ?? 'circle', node.x!, node.y!, node.size); } -function drawRoundedSectionSquare( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - const left = x - size; - const top = y - size; - const right = x + size; - const bottom = y + size; - const radius = Math.min(size * 0.5, 8); - - ctx.beginPath(); - ctx.moveTo(left + radius, top); - ctx.lineTo(right - radius, top); - ctx.quadraticCurveTo(right, top, right, top + radius); - ctx.lineTo(right, bottom - radius); - ctx.quadraticCurveTo(right, bottom, right - radius, bottom); - ctx.lineTo(left + radius, bottom); - ctx.quadraticCurveTo(left, bottom, left, bottom - radius); - ctx.lineTo(left, top + radius); - ctx.quadraticCurveTo(left, top, left + radius, top); - ctx.closePath(); -} - function getNodeFillColor( node: FGNode, decoration: NodeDecorationPayload | undefined, @@ -75,6 +51,12 @@ function getNodeFillColor( : (decoration?.color ?? node.color); } +function getNodeFillOpacity(node: FGNode): number { + return typeof node.fillOpacity2D === 'number' && Number.isFinite(node.fillOpacity2D) + ? Math.min(1, Math.max(0, node.fillOpacity2D)) + : 1; +} + function getNodeBorderColor( node: FGNode, isSelected: boolean, diff --git a/packages/extension/src/webview/components/graph/rendering/node/collapsedSectionBadge.ts b/packages/extension/src/webview/components/graph/rendering/node/collapsedSectionBadge.ts deleted file mode 100644 index d54da0e3a..000000000 --- a/packages/extension/src/webview/components/graph/rendering/node/collapsedSectionBadge.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { mdiChevronUp } from '@mdi/js'; -import type { GraphAppearance } from '../../appearance/model'; -import type { FGNode } from '../../model/build'; -import { getImage } from '../imageCache'; -import { - getGraphSectionMaterialIconPath, - isGraphSectionUploadedIcon, -} from '../../sectionFrames/icons'; - -export interface RenderCollapsedSectionBadgeOptions { - appearance: Pick; - ctx: CanvasRenderingContext2D; - globalScale: number; - node: FGNode; -} - -function formatHiddenDescendantCount(count: number): string { - return count > 99 ? '99+' : String(count); -} - -export function renderCollapsedSectionBadge({ - appearance, - ctx, - globalScale, - node, -}: RenderCollapsedSectionBadgeOptions): void { - const hiddenDescendantCount = node.hiddenDescendantCount ?? 0; - if ( - !node.isCollapsedGraphSection - || node.x === undefined - || node.y === undefined - ) { - return; - } - - ctx.save(); - ctx.globalAlpha = 1; - renderExpandChevron({ - appearance, - ctx, - globalScale, - node, - x: node.x, - y: node.y, - }); - - if (node.icon) { - renderSectionIcon({ - appearance, - ctx, - globalScale, - node, - x: node.x, - y: node.y, - }); - } - - if (hiddenDescendantCount > 0) { - renderHiddenDescendantCount({ - appearance, - ctx, - globalScale, - hiddenDescendantCount, - node, - x: node.x, - y: node.y, - }); - } - - ctx.restore(); -} - -function renderSectionIcon({ - appearance, - ctx, - node, - x, - y, -}: RenderCollapsedSectionBadgeOptions & { x: number; y: number }): void { - const icon = node.icon; - if (isGraphSectionUploadedIcon(icon)) { - const image = getImage(icon); - if (image) { - const imageSize = node.size * 0.95; - ctx.drawImage(image, x - imageSize / 2, y - imageSize / 2, imageSize, imageSize); - } - return; - } - - const materialPath = getGraphSectionMaterialIconPath(icon); - if (materialPath && typeof Path2D !== 'undefined') { - const iconSize = node.size * 0.85; - const iconPath = new Path2D(materialPath); - ctx.save(); - ctx.translate(x - iconSize / 2, y - iconSize / 2); - ctx.scale(iconSize / 24, iconSize / 24); - ctx.fillStyle = appearance.labelForeground; - ctx.fill(iconPath); - ctx.restore(); - return; - } - - ctx.fillStyle = appearance.labelForeground; - ctx.font = `${Math.max(10, node.size * 0.9)}px sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(node.icon!, x, y); -} - -function renderExpandChevron({ - appearance, - ctx, - globalScale, - node, - x, - y, -}: RenderCollapsedSectionBadgeOptions & { x: number; y: number }): void { - if (typeof Path2D === 'undefined') { - return; - } - - const iconSize = Math.max(10 / globalScale, node.size * 0.7); - const centerX = x - node.size * 0.7; - const centerY = y - node.size * 0.7; - const iconPath = new Path2D(mdiChevronUp); - - ctx.save(); - ctx.translate(centerX - iconSize / 2, centerY - iconSize / 2); - ctx.scale(iconSize / 24, iconSize / 24); - ctx.fillStyle = appearance.labelForeground; - ctx.fill(iconPath); - ctx.restore(); -} - -function renderHiddenDescendantCount({ - appearance, - ctx, - globalScale, - hiddenDescendantCount, - node, - x, - y, -}: RenderCollapsedSectionBadgeOptions & { - hiddenDescendantCount: number; - x: number; - y: number; -}): void { - const centerX = x + node.size * 0.7; - const centerY = y + node.size * 0.7; - - ctx.fillStyle = appearance.labelForeground; - ctx.font = `${Math.max(8, 8 / globalScale)}px sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(formatHiddenDescendantCount(hiddenDescendantCount), centerX, centerY); -} diff --git a/packages/extension/src/webview/components/graph/rendering/node/media.ts b/packages/extension/src/webview/components/graph/rendering/node/media.ts index 26cb30c50..7352528d4 100644 --- a/packages/extension/src/webview/components/graph/rendering/node/media.ts +++ b/packages/extension/src/webview/components/graph/rendering/node/media.ts @@ -42,20 +42,28 @@ export function renderNodePluginOverlay( return; } - const renderer = pluginHost.getNodeRenderer(getNodeType(node.id)) - ?? pluginHost.getNodeRenderer('*'); - if (!renderer) { + const renderers = typeof pluginHost.getNodeRenderers === 'function' + ? pluginHost.getNodeRenderers(getNodeType(node.id)) + : [ + pluginHost.getNodeRenderer(getNodeType(node.id)) + ?? pluginHost.getNodeRenderer('*'), + ].filter((renderer): renderer is NonNullable> => + renderer !== undefined + ); + if (renderers.length === 0) { return; } - try { - renderer({ - node, - ctx, - globalScale, - decoration, - }); - } catch (error) { - console.error('[CodeGraphy] Plugin node renderer error:', error); + for (const renderer of renderers) { + try { + renderer({ + node, + ctx, + globalScale, + decoration, + }); + } catch (error) { + console.error('[CodeGraphy] Plugin node renderer error:', error); + } } } diff --git a/packages/extension/src/webview/components/graph/rendering/node/pinBadge.ts b/packages/extension/src/webview/components/graph/rendering/node/pinBadge.ts deleted file mode 100644 index 3badb9dbd..000000000 --- a/packages/extension/src/webview/components/graph/rendering/node/pinBadge.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { mdiPin } from '@mdi/js'; -import type { GraphAppearance } from '../../appearance/model'; -import type { FGNode } from '../../model/build'; - -const MATERIAL_ICON_VIEWBOX_SIZE = 24; -const PIN_BADGE_HIDDEN_NODE_RADIUS_PX = 5; -const PIN_BADGE_FULL_OPACITY_NODE_RADIUS_PX = 9; -let pinIconPath: Path2D | undefined; - -function getPinIconPath(): Path2D { - pinIconPath ??= new Path2D(mdiPin); - return pinIconPath; -} - -function getPinnedNodeBadgeOpacity(node: FGNode, globalScale: number): number { - const nodeRadiusPx = node.size * globalScale; - const fadeDistance = PIN_BADGE_FULL_OPACITY_NODE_RADIUS_PX - PIN_BADGE_HIDDEN_NODE_RADIUS_PX; - - return Math.min( - 1, - Math.max(0, (nodeRadiusPx - PIN_BADGE_HIDDEN_NODE_RADIUS_PX) / fadeDistance), - ); -} - -export interface RenderNodePinBadgeOptions { - appearance: Pick; - ctx: CanvasRenderingContext2D; - globalScale: number; - node: FGNode; -} - -export function renderNodePinBadge({ - appearance, - ctx, - globalScale, - node, -}: RenderNodePinBadgeOptions): void { - if (!node.isPinned || node.x === undefined || node.y === undefined) { - return; - } - - const badgeOpacity = getPinnedNodeBadgeOpacity(node, globalScale); - if (badgeOpacity <= 0.01) { - return; - } - - const radius = Math.max(7 / globalScale, node.size * 0.18); - const centerX = node.x + node.size * 0.7; - const centerY = node.y - node.size * 0.7; - const iconSize = radius * 1.55; - const iconScale = iconSize / MATERIAL_ICON_VIEWBOX_SIZE; - - ctx.save(); - ctx.globalAlpha *= badgeOpacity; - ctx.translate(centerX - iconSize / 2, centerY - iconSize / 2); - ctx.scale(iconScale, iconScale); - ctx.fillStyle = appearance.labelForeground; - ctx.fill(getPinIconPath()); - ctx.restore(); -} diff --git a/packages/extension/src/webview/components/graph/rendering/node/pointer.ts b/packages/extension/src/webview/components/graph/rendering/node/pointer.ts index 7da75063a..7df092239 100644 --- a/packages/extension/src/webview/components/graph/rendering/node/pointer.ts +++ b/packages/extension/src/webview/components/graph/rendering/node/pointer.ts @@ -1,8 +1,24 @@ import { drawShape } from '../shapes/draw/twoDimensional'; import type { FGNode } from '../../model/build'; +import { getRectangularNodeArea2D } from '../../model/node/rectangularArea'; -function isExpandedGraphSectionNode(node: FGNode): boolean { - return !!node.isGraphSection && !node.isCollapsedGraphSection; +function paintRectangularPointerArea( + node: FGNode, + ctx: CanvasRenderingContext2D, +): boolean { + const area = getRectangularNodeArea2D(node.pointerArea2D) + ?? getRectangularNodeArea2D(node.shapeSize2D); + if (!area) { + return false; + } + + ctx.fillRect( + node.x! - (area.width / 2), + node.y! - (area.height / 2), + area.width, + area.height, + ); + return true; } export function paintNodePointerArea( @@ -10,11 +26,11 @@ export function paintNodePointerArea( color: string, ctx: CanvasRenderingContext2D, ): void { - if (isExpandedGraphSectionNode(node)) { + ctx.fillStyle = color; + if (paintRectangularPointerArea(node, ctx)) { return; } - ctx.fillStyle = color; drawShape(ctx, node.shape2D ?? 'circle', node.x!, node.y!, node.size + 2); ctx.fill(); } diff --git a/packages/extension/src/webview/components/graph/rendering/nodes/canvas2d.ts b/packages/extension/src/webview/components/graph/rendering/nodes/canvas2d.ts index 804507fd1..8517dec12 100644 --- a/packages/extension/src/webview/components/graph/rendering/nodes/canvas2d.ts +++ b/packages/extension/src/webview/components/graph/rendering/nodes/canvas2d.ts @@ -1,18 +1,12 @@ import { renderNodeBody } from '../node/body'; -import { renderCollapsedSectionBadge } from '../node/collapsedSectionBadge'; import { renderNodeCollapseIndicator } from '../node/collapseIndicator'; import { renderNodeLabel } from '../node/label'; import { renderNodeImageOverlay, renderNodePluginOverlay } from '../node/media'; -import { renderNodePinBadge } from '../node/pinBadge'; import { paintNodePointerArea } from '../node/pointer'; import type { NodeCanvasRendererDependencies } from '../node/canvasShared'; import { type FGNode } from '../../model/build'; import { DEFAULT_GRAPH_APPEARANCE } from '../../appearance/model'; -function shouldRenderNodeCanvas(node: FGNode): boolean { - return !node.isGraphSection || !!node.isCollapsedGraphSection; -} - function isNodeHighlighted( dependencies: Pick, nodeId: string, @@ -42,10 +36,6 @@ export function renderNodeCanvas( ctx: CanvasRenderingContext2D, globalScale: number, ): void { - if (!shouldRenderNodeCanvas(node)) { - return; - } - const isHighlighted = isNodeHighlighted(dependencies, node.id); const isSelected = dependencies.selectedNodesSetRef.current.has(node.id); const decoration = dependencies.nodeDecorationsRef.current?.[node.id]; @@ -66,18 +56,6 @@ export function renderNodeCanvas( }); renderNodeImageOverlay(ctx, node, dependencies.triggerImageRerender); renderNodeCollapseIndicator(ctx, node, globalScale, appearance); - renderNodePinBadge({ - appearance, - ctx, - globalScale, - node, - }); - renderCollapsedSectionBadge({ - appearance, - ctx, - globalScale, - node, - }); renderNodeCanvasLabel(dependencies, { appearance, ctx, diff --git a/packages/extension/src/webview/components/graph/rendering/shapes/draw/twoDimensional.ts b/packages/extension/src/webview/components/graph/rendering/shapes/draw/twoDimensional.ts index 376984069..d829a52d0 100644 --- a/packages/extension/src/webview/components/graph/rendering/shapes/draw/twoDimensional.ts +++ b/packages/extension/src/webview/components/graph/rendering/shapes/draw/twoDimensional.ts @@ -1,6 +1,7 @@ import { drawTriangle, drawHexagon } from '../regularPolygons'; import type { NodeShape2D } from '../../../../../../shared/settings/modes'; import { drawStar } from '../starPolygon'; +import type { RectangularNodeArea2D } from '../../../model/node/rectangularArea'; export { drawTriangle, drawHexagon } from '../regularPolygons'; export { drawStar } from '../starPolygon'; @@ -17,6 +18,7 @@ export function drawShape( x: number, y: number, size: number, + shapeSize?: RectangularNodeArea2D, ): void { switch (shape) { case 'circle': @@ -25,6 +27,9 @@ export function drawShape( case 'square': drawSquare(ctx, x, y, size); break; + case 'rectangle': + drawRectangle(ctx, x, y, size, shapeSize); + break; case 'diamond': drawDiamond(ctx, x, y, size); break; @@ -61,6 +66,20 @@ function drawSquare( ctx.closePath(); } +function drawRectangle( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + shapeSize: RectangularNodeArea2D | undefined, +): void { + const width = shapeSize?.width ?? size * 2; + const height = shapeSize?.height ?? size * 2; + ctx.beginPath(); + ctx.rect(x - (width / 2), y - (height / 2), width, height); + ctx.closePath(); +} + function drawDiamond( ctx: CanvasRenderingContext2D, x: number, diff --git a/packages/extension/src/webview/components/graph/rendering/surface/view/nodeValue.ts b/packages/extension/src/webview/components/graph/rendering/surface/view/nodeValue.ts new file mode 100644 index 000000000..4612b241a --- /dev/null +++ b/packages/extension/src/webview/components/graph/rendering/surface/view/nodeValue.ts @@ -0,0 +1,13 @@ +import type { FGNode } from '../../../model/build'; +import { getRectangularNodeArea2D, getRectangularNodeAreaRadius } from '../../../model/node/rectangularArea'; + +function getPointerAreaRadius(node: FGNode): number | undefined { + const area = getRectangularNodeArea2D(node.shapeSize2D) + ?? getRectangularNodeArea2D(node.pointerArea2D); + return area ? getRectangularNodeAreaRadius(area) : undefined; +} + +export function getGraphNodeValue(node: FGNode): number { + const radius = getPointerAreaRadius(node) ?? (node.size ?? 16); + return Math.max(1, radius * radius); +} diff --git a/packages/extension/src/webview/components/graph/rendering/surface/view/twoDimensional.tsx b/packages/extension/src/webview/components/graph/rendering/surface/view/twoDimensional.tsx index ba78ca919..e6eb8f7eb 100644 --- a/packages/extension/src/webview/components/graph/rendering/surface/view/twoDimensional.tsx +++ b/packages/extension/src/webview/components/graph/rendering/surface/view/twoDimensional.tsx @@ -12,6 +12,7 @@ import { DIRECTIONAL_ARROW_LENGTH_2D, } from '../../link/contracts'; import { getLinkCanvasObjectMode } from '../../link/metrics'; +import { getGraphNodeValue } from './nodeValue'; type ForceGraph2DRef = MutableRefObject | undefined>; @@ -60,10 +61,7 @@ export function Surface2d({ nodeCanvasObject={nodeCanvasObject} nodeCanvasObjectMode={() => 'replace'} nodePointerAreaPaint={nodePointerAreaPaint} - nodeVal={(node: NodeObject) => { - const radius = (node as FGNode).size ?? 16; - return Math.max(1, radius * radius); - }} + nodeVal={(node: NodeObject) => getGraphNodeValue(node as FGNode)} nodeRelSize={1} linkColor={getLinkColor} linkWidth={getLinkWidth} diff --git a/packages/extension/src/webview/components/graph/runtime/physics.ts b/packages/extension/src/webview/components/graph/runtime/physics.ts index 662100842..3c12dd4fd 100644 --- a/packages/extension/src/webview/components/graph/runtime/physics.ts +++ b/packages/extension/src/webview/components/graph/runtime/physics.ts @@ -1,11 +1,8 @@ export type { GraphPhysicsInstance, - GraphSectionBoundsForce, } from './physics/model'; export { getGraphCollisionRadius } from './physics/root/collision'; export { applyPhysicsSettings } from './physics/root/settings/apply'; export { havePhysicsSettingsChanged } from './physics/root/settings/changed'; export { initPhysics } from './physics/root/settings/init'; -export { applyGraphSectionBoundsForce } from './physics/section/binding'; -export { createGraphSectionBoundsForce } from './physics/section/force'; export { syncPhysicsAnimation } from './use/physics/hook'; diff --git a/packages/extension/src/webview/components/graph/runtime/physics/bridge/endpoints.ts b/packages/extension/src/webview/components/graph/runtime/physics/bridge/endpoints.ts deleted file mode 100644 index af950b41d..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/bridge/endpoints.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../shared/settings/graphLayout'; -import type { FGLink, FGNode } from '../../../model/build'; -import type { GraphLinkEndpoint, GraphLinkLike } from '../model'; -import { hasExpandedOwnerSectionById } from '../expandedOwnership'; - -export function getLinkEndpointId(endpoint: GraphLinkEndpoint): string | undefined { - if (typeof endpoint === 'string') { - return endpoint; - } - - if (typeof endpoint === 'number') { - return String(endpoint); - } - - const id = endpoint?.id; - return typeof id === 'number' ? String(id) : id; -} - -export function getGraphLinkStrength( - linkForce: number, - graphLayout: GraphLayoutSettings | undefined, -): (link: GraphLinkLike) => number { - return (link: GraphLinkLike) => { - const sourceId = getLinkEndpointId(link.source); - const targetId = getLinkEndpointId(link.target); - return hasExpandedOwnerSectionById(sourceId, graphLayout) - || hasExpandedOwnerSectionById(targetId, graphLayout) - ? 0 - : linkForce; - }; -} - -export function getLinkEndpointNode(endpoint: FGLink['source'], nodeMap: Map): FGNode | undefined { - return typeof endpoint === 'string' - ? nodeMap.get(endpoint) - : endpoint; -} - -export function getLinkNodePair(link: FGLink, nodeMap: Map): [FGNode, FGNode] | undefined { - const source = getLinkEndpointNode(link.source, nodeMap); - const target = getLinkEndpointNode(link.target, nodeMap); - return source && target ? [source, target] : undefined; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/bridge/forces.ts b/packages/extension/src/webview/components/graph/runtime/physics/bridge/forces.ts deleted file mode 100644 index d5a2c8d40..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/bridge/forces.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { IPhysicsSettings } from '../../../../../../shared/settings/physics'; -import type { GraphLayoutSettings } from '../../../../../../shared/settings/graphLayout'; -import type { FGLink, FGNode } from '../../../model/build'; -import { SECTION_BRIDGE_LINK_MAX_IMPULSE } from '../model'; -import { isMemberPressedTowardSectionEdge } from './memberEdge'; -import { getExternalBridgeEndpoint, touchesExpandedSectionMember } from './sectionEndpoint'; -import { applyLinkVelocity, getBridgeLinkNodePair, hasBridgePhysicsSettings } from './linkForces'; -import { getNodeDelta } from '../motion'; -import { getSectionBounds } from '../section/bounds'; -import { createNodeMap } from '../nodeLookup'; - -function getBridgeSectionLinkDistance( - sectionNode: FGNode, - memberNode: FGNode, - linkDistance: number, -): number { - const centerToMemberDistance = getNodeDelta(sectionNode, memberNode).distance; - return linkDistance + centerToMemberDistance; -} - -function applyExpandedSectionBridgePull( - source: FGNode, - target: FGNode, - nodeMap: Map, - graphLayout: GraphLayoutSettings, - linkDistance: number, - linkForce: number, - alpha: number, -): void { - const bridge = getExternalBridgeEndpoint(source, target, graphLayout); - if (!bridge) { - return; - } - - const ownerSectionNode = nodeMap.get(bridge.ownerSectionId); - const ownerSectionBounds = getSectionBounds(ownerSectionNode, bridge.ownerSectionId, graphLayout); - if (!ownerSectionNode || !ownerSectionBounds || !isMemberPressedTowardSectionEdge(bridge.memberNode, bridge.externalNode, ownerSectionBounds)) { - return; - } - - applyLinkVelocity( - ownerSectionNode, - bridge.externalNode, - getBridgeSectionLinkDistance(ownerSectionNode, bridge.memberNode, linkDistance), - linkForce, - alpha, - SECTION_BRIDGE_LINK_MAX_IMPULSE, - ); -} - -export function applySectionBridgeLinkForces( - nodes: readonly FGNode[], - graphLayout: GraphLayoutSettings, - links: readonly FGLink[], - settings: Pick | undefined, - alpha: number, -): void { - if (!hasBridgePhysicsSettings(links, settings)) { - return; - } - - const nodeMap = createNodeMap(nodes); - for (const link of links) { - const pair = getBridgeLinkNodePair(link, nodeMap); - if (!pair) { - continue; - } - - const [source, target] = pair; - if (!touchesExpandedSectionMember(source, target, graphLayout)) { - continue; - } - - applyLinkVelocity(source, target, settings.linkDistance, settings.linkForce, alpha, SECTION_BRIDGE_LINK_MAX_IMPULSE); - applyExpandedSectionBridgePull(source, target, nodeMap, graphLayout, settings.linkDistance, settings.linkForce, alpha); - } -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/bridge/linkForces.ts b/packages/extension/src/webview/components/graph/runtime/physics/bridge/linkForces.ts deleted file mode 100644 index 83f74c12a..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/bridge/linkForces.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { IPhysicsSettings } from '../../../../../../shared/settings/physics'; -import type { FGLink, FGNode } from '../../../model/build'; -import type { CollisionWeightShares } from '../model'; -import { getLinkNodePair } from './endpoints'; -import { addCappedNodeVelocity, getNodeDelta } from '../motion'; - -function getBridgeMoveWeight(node: FGNode): number { - return node.isDragging || node.isPinned ? 0 : 1; -} - -function getBridgeWeightShares(source: FGNode, target: FGNode): CollisionWeightShares | undefined { - const sourceWeight = getBridgeMoveWeight(source); - const targetWeight = getBridgeMoveWeight(target); - const totalWeight = sourceWeight + targetWeight; - return totalWeight === 0 - ? undefined - : { - left: sourceWeight / totalWeight, - right: targetWeight / totalWeight, - }; -} - -export function applyLinkVelocity( - source: FGNode, - target: FGNode, - linkDistance: number, - linkForce: number, - alpha: number, - maxImpulse = Number.POSITIVE_INFINITY, -): void { - const weights = getBridgeWeightShares(source, target); - if (!weights) { - return; - } - - const delta = getNodeDelta(source, target); - const force = ((delta.distance - linkDistance) / delta.distance) * linkForce * alpha; - addCappedNodeVelocity(source, delta.x * force * weights.left, delta.y * force * weights.left, maxImpulse); - addCappedNodeVelocity(target, -delta.x * force * weights.right, -delta.y * force * weights.right, maxImpulse); -} - -export function hasBridgePhysicsSettings( - links: readonly FGLink[], - settings: Pick | undefined, -): settings is Pick { - return !!settings && links.length > 0 && settings.linkForce !== 0; -} - -export function getBridgeLinkNodePair( - link: FGLink, - nodeMap: Map, -): [FGNode, FGNode] | undefined { - return getLinkNodePair(link, nodeMap); -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/bridge/memberEdge.ts b/packages/extension/src/webview/components/graph/runtime/physics/bridge/memberEdge.ts deleted file mode 100644 index 06bf7c771..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/bridge/memberEdge.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { FGNode } from '../../../model/build'; -import type { BoundsRect, SectionEdge } from '../model'; -import { getMemberBoundsMargin } from '../member/bounds/margin'; -import { getSectionMemberBounds } from '../section/bounds'; -import { getBridgePressEdge, hasBridgeEndpointPositions } from './pressDirection'; - -function isMemberAtSectionEdge( - memberNode: FGNode, - sectionBounds: BoundsRect, - edge: SectionEdge, -): boolean { - const memberBounds = getSectionMemberBounds(sectionBounds); - const margin = getMemberBoundsMargin(memberNode); - const minX = memberBounds.x + margin.x; - const maxX = memberBounds.x + memberBounds.width - margin.x; - const minY = memberBounds.y + margin.y; - const maxY = memberBounds.y + memberBounds.height - margin.y; - const tolerance = Math.max(1, Math.min(margin.x, margin.y) / 2); - - switch (edge) { - case 'bottom': - return memberNode.y! >= maxY - tolerance; - case 'left': - return memberNode.x! <= minX + tolerance; - case 'right': - return memberNode.x! >= maxX - tolerance; - case 'top': - return memberNode.y! <= minY + tolerance; - } -} - -export function isMemberPressedTowardSectionEdge( - memberNode: FGNode, - externalNode: FGNode, - sectionBounds: BoundsRect, -): boolean { - if (!hasBridgeEndpointPositions(memberNode, externalNode)) { - return false; - } - - return isMemberAtSectionEdge(memberNode, sectionBounds, getBridgePressEdge(memberNode, externalNode)); -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/bridge/pressDirection.ts b/packages/extension/src/webview/components/graph/runtime/physics/bridge/pressDirection.ts deleted file mode 100644 index 12762797f..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/bridge/pressDirection.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { FGNode } from '../../../model/build'; -import type { SectionEdge } from '../model'; -import { isFiniteNumber } from '../numeric'; - -export function hasBridgeEndpointPositions(memberNode: FGNode, externalNode: FGNode): boolean { - return isFiniteNumber(memberNode.x) - && isFiniteNumber(memberNode.y) - && isFiniteNumber(externalNode.x) - && isFiniteNumber(externalNode.y); -} - -export function getBridgePressEdge(memberNode: FGNode, externalNode: FGNode): SectionEdge { - const deltaX = externalNode.x! - memberNode.x!; - const deltaY = externalNode.y! - memberNode.y!; - if (Math.abs(deltaX) >= Math.abs(deltaY)) { - return deltaX >= 0 ? 'right' : 'left'; - } - - return deltaY >= 0 ? 'bottom' : 'top'; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/bridge/sectionEndpoint.ts b/packages/extension/src/webview/components/graph/runtime/physics/bridge/sectionEndpoint.ts deleted file mode 100644 index 521e58504..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/bridge/sectionEndpoint.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../model/build'; -import { getExpandedOwnerSectionId } from '../expandedOwnership'; - -export function touchesExpandedSectionMember( - source: FGNode, - target: FGNode, - graphLayout: GraphLayoutSettings, -): boolean { - return !!getExpandedOwnerSectionId(source, graphLayout) - || !!getExpandedOwnerSectionId(target, graphLayout); -} - -export function getExternalBridgeEndpoint( - source: FGNode, - target: FGNode, - graphLayout: GraphLayoutSettings, -): { externalNode: FGNode; memberNode: FGNode; ownerSectionId: string } | undefined { - const sourceOwnerSectionId = getExpandedOwnerSectionId(source, graphLayout); - const targetOwnerSectionId = getExpandedOwnerSectionId(target, graphLayout); - if (sourceOwnerSectionId && sourceOwnerSectionId !== targetOwnerSectionId) { - return { externalNode: target, memberNode: source, ownerSectionId: sourceOwnerSectionId }; - } - - if (targetOwnerSectionId && targetOwnerSectionId !== sourceOwnerSectionId) { - return { externalNode: source, memberNode: target, ownerSectionId: targetOwnerSectionId }; - } - - return undefined; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/expandedOwnership.ts b/packages/extension/src/webview/components/graph/runtime/physics/expandedOwnership.ts deleted file mode 100644 index 541cb2fd8..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/expandedOwnership.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../model/build'; -import { getOwnerSectionId } from './ownership'; - -export function hasExpandedOwnerSection( - node: FGNode, - graphLayout: GraphLayoutSettings, -): boolean { - let ownerSectionId = getOwnerSectionId(node, graphLayout); - const visited = new Set(); - while (ownerSectionId) { - if (visited.has(ownerSectionId)) { - return false; - } - - visited.add(ownerSectionId); - const ownerSection = graphLayout.sections[ownerSectionId]; - if (ownerSection && !ownerSection.collapsed) { - return true; - } - - ownerSectionId = graphLayout.ownership[ownerSectionId]?.ownerSectionId ?? null; - } - - return false; -} - -export function getExpandedOwnerSectionId( - node: FGNode, - graphLayout: GraphLayoutSettings, -): string | null { - let ownerSectionId = getOwnerSectionId(node, graphLayout); - const visited = new Set(); - while (ownerSectionId) { - if (visited.has(ownerSectionId)) { - return null; - } - - visited.add(ownerSectionId); - const ownerSection = graphLayout.sections[ownerSectionId]; - if (ownerSection && !ownerSection.collapsed) { - return ownerSectionId; - } - - ownerSectionId = graphLayout.ownership[ownerSectionId]?.ownerSectionId ?? null; - } - - return null; -} - -export function hasExpandedOwnerSectionById( - nodeId: string | undefined, - graphLayout: GraphLayoutSettings | undefined, -): boolean { - if (!nodeId || !graphLayout) { - return false; - } - - return hasExpandedOwnerSection({ id: nodeId } as FGNode, graphLayout); -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/member/bounds/constrain.ts b/packages/extension/src/webview/components/graph/runtime/physics/member/bounds/constrain.ts deleted file mode 100644 index a9e89af83..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/member/bounds/constrain.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { FGNode } from '../../../../model/build'; -import type { BoundsRect } from '../../model'; -import { clamp } from '../../numeric'; -import { getSectionMemberBounds } from '../../section/bounds'; -import { applyConstrainedMemberCoordinates, applyMemberCenterVelocity } from './correction'; -import { getConstrainedMemberPosition } from './position'; - -export function constrainMemberNode( - node: FGNode, - bounds: BoundsRect, - alpha: number, - centerStrength: number, -): void { - const memberBounds = getSectionMemberBounds(bounds); - const position = getConstrainedMemberPosition(node, memberBounds); - if (!position) { - return; - } - - const nextX = clamp(position.x, memberBounds.x + position.margin.x, memberBounds.x + memberBounds.width - position.margin.x); - const nextY = clamp(position.y, memberBounds.y + position.margin.y, memberBounds.y + memberBounds.height - position.margin.y); - - applyConstrainedMemberCoordinates(node, nextX, nextY); - applyMemberCenterVelocity(node, bounds, alpha, nextX, nextY, centerStrength); -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/member/bounds/correction.ts b/packages/extension/src/webview/components/graph/runtime/physics/member/bounds/correction.ts deleted file mode 100644 index c162d2f78..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/member/bounds/correction.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { FGNode } from '../../../../model/build'; -import type { BoundsRect } from '../../model'; -import { isFiniteNumber } from '../../numeric'; - -export function applyConstrainedMemberCoordinates( - node: FGNode, - x: number, - y: number, -): void { - node.x = x; - node.y = y; - - if (isFiniteNumber(node.fx)) { - node.fx = x; - } - - if (isFiniteNumber(node.fy)) { - node.fy = y; - } -} - -export function applyMemberCenterVelocity( - node: FGNode, - bounds: BoundsRect, - alpha: number, - x: number, - y: number, - centerStrength: number, -): void { - if (centerStrength === 0) { - return; - } - - const centerX = bounds.x + bounds.width / 2; - const centerY = bounds.y + bounds.height / 2; - node.vx = (node.vx ?? 0) + (centerX - x) * centerStrength * alpha; - node.vy = (node.vy ?? 0) + (centerY - y) * centerStrength * alpha; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/member/bounds/margin.ts b/packages/extension/src/webview/components/graph/runtime/physics/member/bounds/margin.ts deleted file mode 100644 index 4671cf6b4..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/member/bounds/margin.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { FGNode } from '../../../../model/build'; -import { SECTION_MEMBER_PADDING, type NodeBoundsMargin } from '../../model'; -import { isFiniteNumber } from '../../numeric'; -import { isExpandedGraphSection } from '../../section/state'; - -export function getMemberBoundsMargin(node: FGNode): NodeBoundsMargin { - if ( - isExpandedGraphSection(node) - && isFiniteNumber(node.sectionHeight) - && isFiniteNumber(node.sectionWidth) - ) { - return { - x: (node.sectionWidth / 2) + SECTION_MEMBER_PADDING, - y: (node.sectionHeight / 2) + SECTION_MEMBER_PADDING, - }; - } - - const margin = Math.max(1, node.size ?? 1) + SECTION_MEMBER_PADDING; - return { x: margin, y: margin }; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/member/bounds/position.ts b/packages/extension/src/webview/components/graph/runtime/physics/member/bounds/position.ts deleted file mode 100644 index 4c22b35cb..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/member/bounds/position.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { FGNode } from '../../../../model/build'; -import type { BoundsRect, NodeBoundsMargin } from '../../model'; -import { isFiniteNumber, resolveNodeCoordinate } from '../../numeric'; -import { getMemberBoundsMargin } from './margin'; - -function isPinnedInsideBounds( - node: FGNode, - bounds: BoundsRect, - margin: NodeBoundsMargin, -): boolean { - return isFiniteNumber(node.fx) - && isFiniteNumber(node.fy) - && node.fx >= bounds.x + margin.x - && node.fx <= bounds.x + bounds.width - margin.x - && node.fy >= bounds.y + margin.y - && node.fy <= bounds.y + bounds.height - margin.y; -} - -export function getConstrainedMemberPosition( - node: FGNode, - bounds: BoundsRect, -): { margin: NodeBoundsMargin; x: number; y: number } | undefined { - const margin = getMemberBoundsMargin(node); - if (isPinnedInsideBounds(node, bounds, margin)) { - return undefined; - } - - const fallbackX = bounds.x + bounds.width / 2; - const fallbackY = bounds.y + bounds.height / 2; - return { - margin, - x: resolveNodeCoordinate(node.x, fallbackX), - y: resolveNodeCoordinate(node.y, fallbackY), - }; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/member/movement/carry.ts b/packages/extension/src/webview/components/graph/runtime/physics/member/movement/carry.ts deleted file mode 100644 index 4def67faf..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/member/movement/carry.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../../model/build'; -import type { SectionCenter } from '../../model'; -import { translateNodePosition } from '../../motion'; -import { createNodeMap } from '../../nodeLookup'; -import { getOwnerSectionId } from '../../ownership'; -import { getSectionCenter } from '../../section/bounds'; -import { getSectionIdsByDepth } from '../../section/hierarchy'; - -function getSectionCenterDelta( - sectionId: string, - nodeMap: Map, - previousSectionCenters: Map, -): SectionCenter | undefined { - const currentCenter = getSectionCenter(nodeMap.get(sectionId)); - const previousCenter = previousSectionCenters.get(sectionId); - if (!currentCenter || !previousCenter) { - return undefined; - } - - const delta = { - x: currentCenter.x - previousCenter.x, - y: currentCenter.y - previousCenter.y, - }; - return delta.x === 0 && delta.y === 0 ? undefined : delta; -} - -function carryDirectSectionMembers( - nodes: readonly FGNode[], - graphLayout: GraphLayoutSettings, - sectionId: string, - delta: SectionCenter, -): void { - for (const node of nodes) { - if (node.id !== sectionId && getOwnerSectionId(node, graphLayout) === sectionId) { - translateNodePosition(node, delta.x, delta.y); - } - } -} - -export function carrySectionMembersWithFrames( - nodes: FGNode[], - graphLayout: GraphLayoutSettings, - previousSectionCenters: Map, -): void { - const nodeMap = createNodeMap(nodes); - for (const sectionId of getSectionIdsByDepth(graphLayout)) { - const delta = getSectionCenterDelta(sectionId, nodeMap, previousSectionCenters); - if (delta) carryDirectSectionMembers(nodes, graphLayout, sectionId, delta); - } -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/member/movement/velocity.ts b/packages/extension/src/webview/components/graph/runtime/physics/member/movement/velocity.ts deleted file mode 100644 index bfb936286..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/member/movement/velocity.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../../model/build'; -import { hasExpandedOwnerSection } from '../../expandedOwnership'; -import type { SectionCenter, SectionMemberPosition } from '../../model'; -import { createNodeMap } from '../../nodeLookup'; -import { isFiniteNumber } from '../../numeric'; -import { getSectionCenter } from '../../section/bounds'; - -function isExpandedSectionMember(node: FGNode, graphLayout: GraphLayoutSettings): boolean { - return !node.isGraphSection && hasExpandedOwnerSection(node, graphLayout); -} - -function isolateSectionMemberVelocity( - node: FGNode, - previousSectionMemberPositions: Map, -): void { - if (!isFiniteNumber(node.x) || !isFiniteNumber(node.y)) { - node.vx = 0; - node.vy = 0; - return; - } - - if (node.isDragging) { - node.vx = 0; - node.vy = 0; - return; - } - - const previousPosition = previousSectionMemberPositions.get(node.id); - if (!previousPosition) { - node.vx = 0; - node.vy = 0; - return; - } - - node.vx = node.x - previousPosition.x; - node.vy = node.y - previousPosition.y; -} - -export function isolateSectionMemberVelocities( - nodes: readonly FGNode[], - graphLayout: GraphLayoutSettings, - previousSectionMemberPositions: Map, -): void { - for (const node of nodes) { - if (isExpandedSectionMember(node, graphLayout)) { - isolateSectionMemberVelocity(node, previousSectionMemberPositions); - } - } -} - -export function rememberSectionCenters( - nodes: readonly FGNode[], - graphLayout: GraphLayoutSettings, - previousSectionCenters: Map, -): void { - const nodeMap = createNodeMap(nodes); - previousSectionCenters.clear(); - for (const sectionId of Object.keys(graphLayout.sections)) { - const center = getSectionCenter(nodeMap.get(sectionId)); - if (center) { - previousSectionCenters.set(sectionId, center); - } - } -} - -export function rememberSectionMemberPositions( - nodes: readonly FGNode[], - graphLayout: GraphLayoutSettings, - previousSectionMemberPositions: Map, -): void { - previousSectionMemberPositions.clear(); - for (const node of nodes) { - if (isExpandedSectionMember(node, graphLayout) && isFiniteNumber(node.x) && isFiniteNumber(node.y)) { - previousSectionMemberPositions.set(node.id, { x: node.x, y: node.y }); - } - } -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/charge.ts b/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/charge.ts deleted file mode 100644 index 717f8b916..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/charge.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../../model/build'; -import type { GraphSectionBoundsForceOptions } from '../../model'; -import { addNodeVelocity, getNodeDelta } from '../../motion'; -import { collectSectionMemberGroups } from './groups'; -import { getSectionMemberRepelStrength } from './settings'; -import { getMemberCollisionWeightShares } from './weights'; - -function applyMemberChargeForce( - left: FGNode, - right: FGNode, - repelStrength: number, - alpha: number, -): void { - const weights = getMemberCollisionWeightShares(left, right); - if (!weights || repelStrength === 0) { - return; - } - - const delta = getNodeDelta(left, right); - const distanceSquared = Math.max(delta.x * delta.x + delta.y * delta.y, 1); - const force = (repelStrength * alpha) / distanceSquared; - addNodeVelocity(left, delta.x * force * weights.left, delta.y * force * weights.left); - addNodeVelocity(right, -delta.x * force * weights.right, -delta.y * force * weights.right); -} - -export function applySectionMemberChargeForces( - nodes: readonly FGNode[], - graphLayout: GraphLayoutSettings, - settings: GraphSectionBoundsForceOptions['settings'], - alpha: number, -): void { - const repelStrength = getSectionMemberRepelStrength(settings); - if (repelStrength === 0) { - return; - } - - for (const members of collectSectionMemberGroups(nodes, graphLayout).values()) { - for (let leftIndex = 0; leftIndex < members.length; leftIndex += 1) { - for (let rightIndex = leftIndex + 1; rightIndex < members.length; rightIndex += 1) { - applyMemberChargeForce(members[leftIndex], members[rightIndex], repelStrength, alpha); - } - } - } -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/circleCollision.ts b/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/circleCollision.ts deleted file mode 100644 index dcd7804c0..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/circleCollision.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { FGNode } from '../../../../model/build'; -import { addNodeVelocity, getNodeDelta, moveNodePosition } from '../../motion'; -import { getMemberCollisionRadius } from './settings'; -import { getMemberCollisionWeightShares } from './weights'; - -function getActiveCollisionVelocityShare(weightShare: number): number { - return weightShare > 0 ? 0.5 : 0; -} - -export function applyMemberCircleCollision( - left: FGNode, - right: FGNode, - alpha: number, -): void { - const delta = getNodeDelta(left, right); - const minimumDistance = getMemberCollisionRadius(left) + getMemberCollisionRadius(right); - if (delta.distance >= minimumDistance) { - return; - } - - const weights = getMemberCollisionWeightShares(left, right); - if (!weights) { - return; - } - - const normalX = delta.x / delta.distance; - const normalY = delta.y / delta.distance; - const overlap = minimumDistance - delta.distance; - const leftVelocityShare = getActiveCollisionVelocityShare(weights.left); - const rightVelocityShare = getActiveCollisionVelocityShare(weights.right); - moveNodePosition(left, -normalX * overlap * weights.left, -normalY * overlap * weights.left); - moveNodePosition(right, normalX * overlap * weights.right, normalY * overlap * weights.right); - addNodeVelocity(left, -normalX * overlap * alpha * leftVelocityShare, -normalY * overlap * alpha * leftVelocityShare); - addNodeVelocity(right, normalX * overlap * alpha * rightVelocityShare, normalY * overlap * alpha * rightVelocityShare); -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/collision.ts b/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/collision.ts deleted file mode 100644 index fd56eead4..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/collision.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../../model/build'; -import { applyMemberCircleCollision } from './circleCollision'; -import { collectSectionMemberGroups } from './groups'; - -export function applySectionMemberCollisions( - nodes: readonly FGNode[], - graphLayout: GraphLayoutSettings, - alpha: number, -): void { - for (const members of collectSectionMemberGroups(nodes, graphLayout).values()) { - for (let leftIndex = 0; leftIndex < members.length; leftIndex += 1) { - for (let rightIndex = leftIndex + 1; rightIndex < members.length; rightIndex += 1) { - applyMemberCircleCollision(members[leftIndex], members[rightIndex], alpha); - } - } - } -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/groups.ts b/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/groups.ts deleted file mode 100644 index cea36aced..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/groups.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../../model/build'; -import { getOwnerSectionId } from '../../ownership'; - -export function collectSectionMemberGroups( - nodes: readonly FGNode[], - graphLayout: GraphLayoutSettings, -): Map { - const membersBySection = new Map(); - for (const node of nodes) { - if (node.isGraphSection) { - continue; - } - - const ownerSectionId = getOwnerSectionId(node, graphLayout); - if (!ownerSectionId || !graphLayout.sections[ownerSectionId] || graphLayout.sections[ownerSectionId].collapsed) { - continue; - } - - const members = membersBySection.get(ownerSectionId) ?? []; - members.push(node); - membersBySection.set(ownerSectionId, members); - } - - return membersBySection; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/settings.ts b/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/settings.ts deleted file mode 100644 index 1531649a0..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/settings.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { toD3Repel } from '../../../../model/build'; -import { - COLLISION_PADDING, - MAX_NORMALIZED_REPEL_FORCE, - SECTION_MEMBER_CENTER_STRENGTH, - type GraphSectionBoundsForceOptions, -} from '../../model'; -import { clamp } from '../../numeric'; - -export function getMemberCollisionRadius(node: { size?: number }): number { - return Math.max(1, node.size ?? 1) + COLLISION_PADDING; -} - -export function getSectionMemberCenterStrength( - settings: GraphSectionBoundsForceOptions['settings'], -): number { - return settings?.centerForce ?? SECTION_MEMBER_CENTER_STRENGTH; -} - -export function getSectionMemberRepelStrength( - settings: GraphSectionBoundsForceOptions['settings'], -): number { - return settings ? toD3Repel(settings.repelForce) : 0; -} - -export function getNormalizedRepelScale(settings: GraphSectionBoundsForceOptions['settings']): number { - const normalizedRepel = clamp(settings?.repelForce ?? 0, 0, MAX_NORMALIZED_REPEL_FORCE); - return normalizedRepel / MAX_NORMALIZED_REPEL_FORCE; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/weights.ts b/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/weights.ts deleted file mode 100644 index 70a870df8..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/member/simulation/weights.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { FGNode } from '../../../../model/build'; -import type { CollisionWeightShares } from '../../model'; - -function getMemberCollisionWeight(node: FGNode): number { - return node.isDragging || node.isPinned ? 0 : 1; -} - -export function getMemberCollisionWeightShares(left: FGNode, right: FGNode): CollisionWeightShares | undefined { - const leftWeight = getMemberCollisionWeight(left); - const rightWeight = getMemberCollisionWeight(right); - const totalWeight = leftWeight + rightWeight; - return totalWeight === 0 - ? undefined - : { - left: leftWeight / totalWeight, - right: rightWeight / totalWeight, - }; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/model.ts b/packages/extension/src/webview/components/graph/runtime/physics/model.ts index 3e9fdac5d..6802de577 100644 --- a/packages/extension/src/webview/components/graph/runtime/physics/model.ts +++ b/packages/extension/src/webview/components/graph/runtime/physics/model.ts @@ -1,7 +1,6 @@ import type { ForceGraphMethods as FG2DMethods } from 'react-force-graph-2d'; import type { ForceGraphMethods as FG3DMethods } from 'react-force-graph-3d'; import type { IPhysicsSettings } from '../../../../../shared/settings/physics'; -import type { GraphLayoutSettings } from '../../../../../shared/settings/graphLayout'; import type { FGLink, FGNode } from '../../model/build'; export type GraphPhysicsInstance = FG2DMethods | FG3DMethods; @@ -9,17 +8,6 @@ export type GraphPhysicsInstance = FG2DMethods | FG3DMethods number): unknown; } -export interface GraphPhysicsSectionOptions { - graphLayout?: GraphLayoutSettings; +export interface GraphPhysicsOptions { graphMode: '2d' | '3d'; - links?: readonly FGLink[]; settings?: IPhysicsSettings; } -export interface GraphSectionBoundsForce { - (alpha: number): void; - initialize(nodes: FGNode[]): void; -} - -export interface GraphSectionBoundsForceOptions { - links?: readonly FGLink[]; - settings?: Pick; -} - -export interface BoundsRect { - height: number; - width: number; - x: number; - y: number; -} - -export interface RectangleCollisionRect extends BoundsRect { - centerX: number; - centerY: number; -} - -export interface SectionCenter { - x: number; - y: number; -} - -export interface SectionMemberPosition { - x: number; - y: number; -} - export type GraphLinkEndpoint = string | number | { id?: string | number } | undefined; export interface GraphLinkLike { @@ -79,28 +33,10 @@ export interface GraphLinkLike { target?: GraphLinkEndpoint; } -export interface NodeBoundsMargin { - x: number; - y: number; -} - export interface NodeDelta { distance: number; x: number; y: number; } -export interface CollisionWeightShares { - left: number; - right: number; -} - -export interface RectangleCollisionOverlap { - leftRect: RectangleCollisionRect; - overlapX: number; - overlapY: number; - rightRect: RectangleCollisionRect; -} - export type CollisionAxis = 'x' | 'y'; -export type SectionEdge = 'bottom' | 'left' | 'right' | 'top'; diff --git a/packages/extension/src/webview/components/graph/runtime/physics/ownership.ts b/packages/extension/src/webview/components/graph/runtime/physics/ownership.ts deleted file mode 100644 index 615742c03..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/ownership.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../model/build'; - -export function getOwnerSectionId( - node: FGNode, - graphLayout: GraphLayoutSettings, -): string | null { - return node.ownerSectionId !== undefined - ? node.ownerSectionId - : graphLayout.ownership[node.id]?.ownerSectionId ?? null; -} - -export function isOwnedBySection( - node: FGNode, - sectionId: string, - graphLayout: GraphLayoutSettings, -): boolean { - let ownerSectionId = getOwnerSectionId(node, graphLayout); - const visited = new Set(); - while (ownerSectionId) { - if (ownerSectionId === sectionId) { - return true; - } - - if (visited.has(ownerSectionId)) { - return false; - } - - visited.add(ownerSectionId); - ownerSectionId = graphLayout.ownership[ownerSectionId]?.ownerSectionId ?? null; - } - - return false; -} 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..3bd83a1ae --- /dev/null +++ b/packages/extension/src/webview/components/graph/runtime/physics/pluginForces.ts @@ -0,0 +1,116 @@ +import type { CoreGraphViewContributionSet } from '@codegraphy-dev/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 interface GraphViewForceSyncContext { + graphMode?: '2d' | '3d'; + timelineActive?: boolean; +} + +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[] }, + context: GraphViewForceSyncContext = {}, +): 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, + ...context, + }) 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/physics/rectangle/collision/apply.ts b/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/apply.ts deleted file mode 100644 index bc5ac3dbc..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/apply.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../../model/build'; -import type { GraphSectionBoundsForceOptions } from '../../model'; -import { applyRectangleCollisionVelocity, getCollisionDirection } from './velocity'; -import { isExpandedGraphSection } from '../../section/state'; -import { getRectangleCollisionOverlap } from './overlap'; - -function applyRectangleCollision( - left: FGNode, - right: FGNode, - graphLayout: GraphLayoutSettings, - settings: GraphSectionBoundsForceOptions['settings'], -): void { - const overlap = getRectangleCollisionOverlap(left, right, graphLayout, settings); - if (!overlap) { - return; - } - - if (overlap.overlapX <= overlap.overlapY) { - const direction = getCollisionDirection(left, right, overlap.leftRect.centerX, overlap.rightRect.centerX); - applyRectangleCollisionVelocity(left, right, overlap.leftRect, overlap.rightRect, 'x', direction, overlap.overlapX); - return; - } - - const direction = getCollisionDirection(left, right, overlap.leftRect.centerY, overlap.rightRect.centerY); - applyRectangleCollisionVelocity(left, right, overlap.leftRect, overlap.rightRect, 'y', direction, overlap.overlapY); -} - -export function applyRectangleCollisions( - nodes: readonly FGNode[], - graphLayout: GraphLayoutSettings, - settings: GraphSectionBoundsForceOptions['settings'], -): void { - for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) { - if (!isExpandedGraphSection(nodes[leftIndex])) { - continue; - } - - for (let rightIndex = 0; rightIndex < nodes.length; rightIndex += 1) { - if ( - rightIndex === leftIndex - || (isExpandedGraphSection(nodes[rightIndex]) && rightIndex < leftIndex) - ) { - continue; - } - - applyRectangleCollision(nodes[leftIndex], nodes[rightIndex], graphLayout, settings); - } - } -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/eligibility.ts b/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/eligibility.ts deleted file mode 100644 index 23769ae39..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/eligibility.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../../model/build'; -import { hasExpandedSectionMemberCollision, hasOwnedSectionMemberCollision } from './sectionMember'; -import { hasExpandedSectionCollisionParticipant, isDraggingCircleAgainstExpandedSection } from './participants'; - -export function shouldApplyRectangleCollision( - left: FGNode, - right: FGNode, - graphLayout: GraphLayoutSettings, -): boolean { - if (!hasExpandedSectionCollisionParticipant(left, right)) { - return false; - } - - if (left.isDragging || right.isDragging) { - return false; - } - - if (isDraggingCircleAgainstExpandedSection(left, right)) { - return false; - } - - if (hasOwnedSectionMemberCollision(left, right, graphLayout)) { - return false; - } - - if (hasExpandedSectionMemberCollision(left, right, graphLayout)) { - return false; - } - - return true; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/overlap.ts b/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/overlap.ts deleted file mode 100644 index 8f3c786fb..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/overlap.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../../model/build'; -import type { GraphSectionBoundsForceOptions, RectangleCollisionOverlap } from '../../model'; -import { shouldApplyRectangleCollision } from './eligibility'; -import { hasActualCircleRectangleOverlap } from '../geometry/circleOverlap'; -import { getNodeRectangleCollisionRect } from '../geometry/nodeRect'; -import { getRepelAwareCollisionRect } from './repel'; - -export function getRectangleCollisionOverlap( - left: FGNode, - right: FGNode, - graphLayout: GraphLayoutSettings, - settings: GraphSectionBoundsForceOptions['settings'], -): RectangleCollisionOverlap | undefined { - if (!shouldApplyRectangleCollision(left, right, graphLayout)) { - return undefined; - } - - const leftRect = getNodeRectangleCollisionRect(left, true); - const rightRect = getNodeRectangleCollisionRect(right, true); - if (!leftRect || !rightRect || !hasActualCircleRectangleOverlap(left, right, leftRect, rightRect)) { - return undefined; - } - - const leftCollisionRect = getRepelAwareCollisionRect(left, leftRect, settings); - const rightCollisionRect = getRepelAwareCollisionRect(right, rightRect, settings); - const overlapX = Math.min(leftCollisionRect.x + leftCollisionRect.width, rightCollisionRect.x + rightCollisionRect.width) - - Math.max(leftCollisionRect.x, rightCollisionRect.x); - const overlapY = Math.min(leftCollisionRect.y + leftCollisionRect.height, rightCollisionRect.y + rightCollisionRect.height) - - Math.max(leftCollisionRect.y, rightCollisionRect.y); - return overlapX <= 0 || overlapY <= 0 - ? undefined - : { leftRect, overlapX, overlapY, rightRect }; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/participants.ts b/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/participants.ts deleted file mode 100644 index c8978a4dd..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/participants.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { FGNode } from '../../../../model/build'; -import { isExpandedGraphSection } from '../../section/state'; - -export function hasExpandedSectionCollisionParticipant(left: FGNode, right: FGNode): boolean { - return isExpandedGraphSection(left) || isExpandedGraphSection(right); -} - -export function isDraggingCircleAgainstExpandedSection(left: FGNode, right: FGNode): boolean { - const leftIsExpandedSection = isExpandedGraphSection(left); - const rightIsExpandedSection = isExpandedGraphSection(right); - if (leftIsExpandedSection && !rightIsExpandedSection) { - return !!right.isDragging; - } - - if (rightIsExpandedSection && !leftIsExpandedSection) { - return !!left.isDragging; - } - - return false; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/repel.ts b/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/repel.ts deleted file mode 100644 index 7ecb35202..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/repel.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { DEFAULT_PHYSICS_SETTINGS } from '../../../../../../../shared/settings/physics'; -import type { FGNode } from '../../../../model/build'; -import { - MIN_VELOCITY_INTEGRATION_DECAY, - SECTION_RECTANGLE_MAX_REPEL_GAP, - SECTION_RECTANGLE_REPEL_PADDING_RATIO, - type GraphSectionBoundsForceOptions, - type RectangleCollisionRect, -} from '../../model'; -import { getNormalizedRepelScale } from '../../member/simulation/settings'; -import { clamp } from '../../numeric'; -import { isExpandedGraphSection } from '../../section/state'; - -export function getSectionRectangleRepelPadding( - node: FGNode, - rect: RectangleCollisionRect, - settings: GraphSectionBoundsForceOptions['settings'], -): number { - if (!isExpandedGraphSection(node)) { - return 0; - } - - const sizeAwarePadding = Math.min(rect.width, rect.height) * SECTION_RECTANGLE_REPEL_PADDING_RATIO; - const maximumPadding = Math.max(SECTION_RECTANGLE_MAX_REPEL_GAP / 2, sizeAwarePadding); - return getNormalizedRepelScale(settings) * maximumPadding; -} - -export function inflateRectangleCollisionRect( - rect: RectangleCollisionRect, - padding: number, -): RectangleCollisionRect { - if (padding <= 0) { - return rect; - } - - return { - centerX: rect.centerX, - centerY: rect.centerY, - height: rect.height + padding * 2, - width: rect.width + padding * 2, - x: rect.x - padding, - y: rect.y - padding, - }; -} - -export function getRepelAwareCollisionRect( - node: FGNode, - rect: RectangleCollisionRect, - settings: GraphSectionBoundsForceOptions['settings'], -): RectangleCollisionRect { - return inflateRectangleCollisionRect(rect, getSectionRectangleRepelPadding(node, rect, settings)); -} - -export function getVelocityIntegrationDecay(settings: GraphSectionBoundsForceOptions['settings']): number { - const damping = settings?.damping ?? DEFAULT_PHYSICS_SETTINGS.damping; - return Math.max(MIN_VELOCITY_INTEGRATION_DECAY, 1 - clamp(damping, 0, 1)); -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/sectionMember.ts b/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/sectionMember.ts deleted file mode 100644 index 5d468bf58..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/sectionMember.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../../model/build'; -import { hasExpandedOwnerSection } from '../../expandedOwnership'; -import { isOwnedBySection } from '../../ownership'; - -export function hasOwnedSectionMemberCollision( - left: FGNode, - right: FGNode, - graphLayout: GraphLayoutSettings, -): boolean { - if (left.isGraphSection && isOwnedBySection(right, left.id, graphLayout)) { - return true; - } - - return !!right.isGraphSection && isOwnedBySection(left, right.id, graphLayout); -} - -export function hasExpandedSectionMemberCollision( - left: FGNode, - right: FGNode, - graphLayout: GraphLayoutSettings, -): boolean { - if (left.isGraphSection && hasExpandedOwnerSection(right, graphLayout)) { - return true; - } - - return !!right.isGraphSection && hasExpandedOwnerSection(left, graphLayout); -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/velocity.ts b/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/velocity.ts deleted file mode 100644 index a97940e4b..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/velocity.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { FGNode } from '../../../../model/build'; -import { - SECTION_RECTANGLE_MAX_COLLISION_IMPULSE, - SECTION_RECTANGLE_MAX_SECTION_IMPULSE, - type CollisionAxis, - type RectangleCollisionRect, -} from '../../model'; -import { addBoundedAxisVelocity } from '../../axisMotion'; -import { isExpandedGraphSection } from '../../section/state'; -import { getRectangleCollisionMoveShares } from './weights'; - -export function getCollisionDirection(left: FGNode, right: FGNode, leftCenter: number, rightCenter: number): number { - if (leftCenter < rightCenter) { - return -1; - } - - if (leftCenter > rightCenter) { - return 1; - } - - return left.id < right.id ? -1 : 1; -} - -function getRectangleCollisionMaxImpulse(left: FGNode, right: FGNode): number { - return isExpandedGraphSection(left) && isExpandedGraphSection(right) - ? SECTION_RECTANGLE_MAX_SECTION_IMPULSE - : SECTION_RECTANGLE_MAX_COLLISION_IMPULSE; -} - -export function applyRectangleCollisionVelocity( - left: FGNode, - right: FGNode, - leftRect: RectangleCollisionRect, - rightRect: RectangleCollisionRect, - axis: CollisionAxis, - direction: number, - overlap: number, -): void { - const shares = getRectangleCollisionMoveShares(left, right, leftRect, rightRect); - if (!shares) { - return; - } - - const maxImpulse = getRectangleCollisionMaxImpulse(left, right); - addBoundedAxisVelocity(left, axis, direction * overlap * shares.left, maxImpulse); - addBoundedAxisVelocity(right, axis, -direction * overlap * shares.right, maxImpulse); -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/weights.ts b/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/weights.ts deleted file mode 100644 index b033bc6b0..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/collision/weights.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { FGNode } from '../../../../model/build'; -import type { CollisionWeightShares, RectangleCollisionRect } from '../../model'; -import { isExpandedGraphSection } from '../../section/state'; - -function getCollisionMoveWeight(node: FGNode): number { - if (node.isDragging || node.isPinned) { - return 0; - } - - return 1; -} - -function getRectangleCollisionMass(node: FGNode, rect: RectangleCollisionRect): number { - if (isExpandedGraphSection(node)) { - return Math.max(1, rect.width * rect.height); - } - - const radius = Math.max(1, Math.min(rect.width, rect.height) / 2); - return radius * radius; -} - -export function getRectangleCollisionMoveShares( - left: FGNode, - right: FGNode, - leftRect: RectangleCollisionRect, - rightRect: RectangleCollisionRect, -): CollisionWeightShares | undefined { - const leftMoveWeight = getCollisionMoveWeight(left); - const rightMoveWeight = getCollisionMoveWeight(right); - if (leftMoveWeight === 0 && rightMoveWeight === 0) { - return undefined; - } - - if (leftMoveWeight === 0) { - return { left: 0, right: 1 }; - } - - if (rightMoveWeight === 0) { - return { left: 1, right: 0 }; - } - - const leftMass = getRectangleCollisionMass(left, leftRect); - const rightMass = getRectangleCollisionMass(right, rightRect); - const totalMass = leftMass + rightMass; - return { - left: rightMass / totalMass, - right: leftMass / totalMass, - }; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/geometry/circleOverlap.ts b/packages/extension/src/webview/components/graph/runtime/physics/rectangle/geometry/circleOverlap.ts deleted file mode 100644 index e1862615d..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/geometry/circleOverlap.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { FGNode } from '../../../../model/build'; -import type { BoundsRect, RectangleCollisionRect } from '../../model'; -import { clamp } from '../../numeric'; -import { isExpandedGraphSection } from '../../section/state'; - -export function circleRectOverlapsRectangle( - circleRect: RectangleCollisionRect, - rectangle: BoundsRect, -): boolean { - const radius = Math.min(circleRect.width, circleRect.height) / 2; - if (radius <= 0) { - return false; - } - - const closestX = clamp(circleRect.centerX, rectangle.x, rectangle.x + rectangle.width); - const closestY = clamp(circleRect.centerY, rectangle.y, rectangle.y + rectangle.height); - const deltaX = circleRect.centerX - closestX; - const deltaY = circleRect.centerY - closestY; - return deltaX * deltaX + deltaY * deltaY < radius * radius; -} - -export function hasActualCircleRectangleOverlap( - left: FGNode, - right: FGNode, - leftRect: RectangleCollisionRect, - rightRect: RectangleCollisionRect, -): boolean { - if (isExpandedGraphSection(left) && !isExpandedGraphSection(right)) { - return circleRectOverlapsRectangle(rightRect, leftRect); - } - - if (isExpandedGraphSection(right) && !isExpandedGraphSection(left)) { - return circleRectOverlapsRectangle(leftRect, rightRect); - } - - return true; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/geometry/nodeRect.ts b/packages/extension/src/webview/components/graph/runtime/physics/rectangle/geometry/nodeRect.ts deleted file mode 100644 index 275fb81e6..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/rectangle/geometry/nodeRect.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { FGNode } from '../../../../model/build'; -import type { RectangleCollisionRect, SectionCenter } from '../../model'; -import { getFiniteVelocity, isFiniteNumber } from '../../numeric'; -import { getGraphCollisionRadius } from '../../root/collision'; -import { hasExpandedSectionCollisionSize } from '../../section/state'; - -function hasNodePosition(node: FGNode): node is FGNode & { x: number; y: number } { - return isFiniteNumber(node.x) && isFiniteNumber(node.y); -} - -function getProjectedNodeCenter(node: FGNode, projected: boolean): SectionCenter | undefined { - if (!hasNodePosition(node)) { - return undefined; - } - - if (!projected) { - return { x: node.x, y: node.y }; - } - - return { x: node.x + getFiniteVelocity(node.vx), y: node.y + getFiniteVelocity(node.vy) }; -} - -export function createRectangleCollisionRect(center: SectionCenter, width: number, height: number): RectangleCollisionRect { - return { - centerX: center.x, - centerY: center.y, - height, - width, - x: center.x - (width / 2), - y: center.y - (height / 2), - }; -} - -export function getNodeRectangleCollisionRect(node: FGNode, projected = false): RectangleCollisionRect | undefined { - const center = getProjectedNodeCenter(node, projected); - if (!center) { - return undefined; - } - - if (hasExpandedSectionCollisionSize(node)) { - return createRectangleCollisionRect(center, node.sectionWidth, node.sectionHeight); - } - - const radius = getGraphCollisionRadius(node); - return createRectangleCollisionRect(center, radius * 2, radius * 2); -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/root/boundary/constrain.ts b/packages/extension/src/webview/components/graph/runtime/physics/root/boundary/constrain.ts deleted file mode 100644 index d00c245e9..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/root/boundary/constrain.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../../model/build'; -import { addAxisVelocity } from '../../axisMotion'; -import type { BoundsRect } from '../../model'; -import { getSectionBoundaryCorrection } from './correction'; -import { getPostIntegrationCircleRect, isPassiveRootCircleNode } from './postIntegration'; - -function constrainPassiveRootNodeOutsideSectionBounds( - node: FGNode, - sectionBounds: Iterable, - velocityIntegrationDecay: number, -): void { - for (const bounds of sectionBounds) { - const circleRect = getPostIntegrationCircleRect(node, velocityIntegrationDecay); - if (!circleRect) { - return; - } - - const correction = getSectionBoundaryCorrection(circleRect, bounds); - if (!correction) { - continue; - } - - addAxisVelocity(node, correction.axis, correction.delta / velocityIntegrationDecay); - } -} - -export function constrainPassiveRootNodesOutsideSections( - nodes: readonly FGNode[], - graphLayout: GraphLayoutSettings, - sectionBounds: Map, - velocityIntegrationDecay: number, -): void { - for (const node of nodes) { - if (isPassiveRootCircleNode(node, graphLayout)) { - constrainPassiveRootNodeOutsideSectionBounds(node, sectionBounds.values(), velocityIntegrationDecay); - } - } -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/root/boundary/correction.ts b/packages/extension/src/webview/components/graph/runtime/physics/root/boundary/correction.ts deleted file mode 100644 index 8f190e150..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/root/boundary/correction.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - SECTION_BOUNDARY_EPSILON, - type BoundsRect, - type CollisionAxis, - type RectangleCollisionRect, -} from '../../model'; -import { circleRectOverlapsRectangle } from '../../rectangle/geometry/circleOverlap'; - -export function getSectionBoundaryCorrection( - circleRect: RectangleCollisionRect, - sectionBounds: BoundsRect, -): { axis: CollisionAxis; delta: number } | undefined { - const radius = Math.min(circleRect.width, circleRect.height) / 2; - if (!circleRectOverlapsRectangle(circleRect, sectionBounds)) { - return undefined; - } - - const candidates = [ - { axis: 'x' as const, delta: (sectionBounds.x - radius - SECTION_BOUNDARY_EPSILON) - circleRect.centerX }, - { axis: 'x' as const, delta: (sectionBounds.x + sectionBounds.width + radius + SECTION_BOUNDARY_EPSILON) - circleRect.centerX }, - { axis: 'y' as const, delta: (sectionBounds.y - radius - SECTION_BOUNDARY_EPSILON) - circleRect.centerY }, - { axis: 'y' as const, delta: (sectionBounds.y + sectionBounds.height + radius + SECTION_BOUNDARY_EPSILON) - circleRect.centerY }, - ].sort((left, right) => Math.abs(left.delta) - Math.abs(right.delta)); - return candidates[0]; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/root/boundary/postIntegration.ts b/packages/extension/src/webview/components/graph/runtime/physics/root/boundary/postIntegration.ts deleted file mode 100644 index 0be3bc4a4..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/root/boundary/postIntegration.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../../model/build'; -import { hasExpandedOwnerSection } from '../../expandedOwnership'; -import type { RectangleCollisionRect } from '../../model'; -import { getNodeRectangleCollisionRect } from '../../rectangle/geometry/nodeRect'; - -export function isPassiveRootCircleNode(node: FGNode, graphLayout: GraphLayoutSettings): boolean { - return !node.isGraphSection && !node.isDragging && !hasExpandedOwnerSection(node, graphLayout); -} - -export function getPostIntegrationCircleRect( - node: FGNode, - velocityIntegrationDecay: number, -): RectangleCollisionRect | undefined { - const rect = getNodeRectangleCollisionRect(node); - if (!rect) { - return undefined; - } - - const centerX = rect.centerX + ((node.vx ?? 0) * velocityIntegrationDecay); - const centerY = rect.centerY + ((node.vy ?? 0) * velocityIntegrationDecay); - return { - ...rect, - centerX, - centerY, - x: centerX - (rect.width / 2), - y: centerY - (rect.height / 2), - }; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/root/collision.ts b/packages/extension/src/webview/components/graph/runtime/physics/root/collision.ts index 19e18c2f4..ca3620971 100644 --- a/packages/extension/src/webview/components/graph/runtime/physics/root/collision.ts +++ b/packages/extension/src/webview/components/graph/runtime/physics/root/collision.ts @@ -1,40 +1,32 @@ -import type { GraphLayoutSettings } from '../../../../../../shared/settings/graphLayout'; import type { FGNode } from '../../../model/build'; -import { hasExpandedOwnerSection } from '../expandedOwnership'; +import { getRectangularNodeArea2D, getRectangularNodeAreaRadius } from '../../../model/node/rectangularArea'; import { COLLISION_PADDING } from '../model'; -import { hasExpandedGraphSection, isExpandedGraphSection } from '../section/state'; -export function getGraphCollisionRadius(node: FGNode): number { - if (isExpandedGraphSection(node)) { - return 0; - } - - return (node.size ?? 0) + COLLISION_PADDING; +function readCollisionRadiusOverride(node: FGNode): number | undefined { + const radius = node.collisionRadius2D; + return typeof radius === 'number' && Number.isFinite(radius) && radius >= 0 + ? radius + : undefined; } -export function getRootGraphCollisionRadius( - node: FGNode, - graphLayout: GraphLayoutSettings | undefined, -): number { - if (node.isDragging && graphLayout && hasExpandedGraphSection(graphLayout) && !node.isGraphSection) { - return 0; +export function getGraphCollisionRadius(node: FGNode): number { + const collisionRadius = readCollisionRadiusOverride(node); + if (collisionRadius !== undefined) { + return collisionRadius + COLLISION_PADDING; } - if (graphLayout && hasExpandedOwnerSection(node, graphLayout)) { - return 0; + const visualArea = getRectangularNodeArea2D(node.shapeSize2D); + if (visualArea) { + return getRectangularNodeAreaRadius(visualArea) + COLLISION_PADDING; } - return getGraphCollisionRadius(node); + return (node.size ?? 0) + COLLISION_PADDING; } -export function getRootGraphCenterStrength( - node: FGNode, - centerForce: number, - graphLayout: GraphLayoutSettings | undefined, -): number { - if (graphLayout && hasExpandedOwnerSection(node, graphLayout)) { - return 0; - } +export function getRootGraphCollisionRadius(node: FGNode): number { + return getGraphCollisionRadius(node); +} +export function getRootGraphCenterStrength(centerForce: number): number { return centerForce; } diff --git a/packages/extension/src/webview/components/graph/runtime/physics/root/settings/apply.ts b/packages/extension/src/webview/components/graph/runtime/physics/root/settings/apply.ts index 2adb4c012..a4e174739 100644 --- a/packages/extension/src/webview/components/graph/runtime/physics/root/settings/apply.ts +++ b/packages/extension/src/webview/components/graph/runtime/physics/root/settings/apply.ts @@ -1,5 +1,5 @@ import type { IPhysicsSettings } from '../../../../../../../shared/settings/physics'; -import type { GraphPhysicsControls, GraphPhysicsInstance, GraphPhysicsSectionOptions } from '../../model'; +import type { GraphPhysicsControls, GraphPhysicsInstance, GraphPhysicsOptions } from '../../model'; import { applyCenterSettings, removeCentroidCenterForce } from './centerForce'; import { applyChargeSettings } from './chargeForce'; import { applyCollisionSettings } from './collisionForce'; @@ -8,14 +8,13 @@ import { applyLinkSettings } from './linkForce'; export function applyPhysicsSettings( instance: GraphPhysicsInstance, settings: IPhysicsSettings, - options: GraphPhysicsSectionOptions = { graphMode: '2d' }, + _options: GraphPhysicsOptions = { graphMode: '2d' }, ): void { const graph = instance as GraphPhysicsControls; - const graphLayout = options.graphMode === '2d' ? options.graphLayout : undefined; removeCentroidCenterForce(graph); - applyChargeSettings(graph, settings, graphLayout); - applyLinkSettings(graph, settings, graphLayout); - applyCenterSettings(graph, settings, graphLayout); - applyCollisionSettings(graph, graphLayout); + applyChargeSettings(graph, settings); + applyLinkSettings(graph, settings); + applyCenterSettings(graph, settings); + applyCollisionSettings(graph); graph.d3ReheatSimulation(); } diff --git a/packages/extension/src/webview/components/graph/runtime/physics/root/settings/centerForce.ts b/packages/extension/src/webview/components/graph/runtime/physics/root/settings/centerForce.ts index 343c9673b..33f849b22 100644 --- a/packages/extension/src/webview/components/graph/runtime/physics/root/settings/centerForce.ts +++ b/packages/extension/src/webview/components/graph/runtime/physics/root/settings/centerForce.ts @@ -1,6 +1,4 @@ import type { IPhysicsSettings } from '../../../../../../../shared/settings/physics'; -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../../model/build'; import { hasStrength } from '../../../../support/guards'; import type { GraphPhysicsControls } from '../../model'; import { getRootGraphCenterStrength } from '../collision'; @@ -8,9 +6,8 @@ import { getRootGraphCenterStrength } from '../collision'; export function applyCenterSettings( graph: GraphPhysicsControls, settings: IPhysicsSettings, - graphLayout: GraphLayoutSettings | undefined, ): void { - const strength = (node: FGNode): number => getRootGraphCenterStrength(node, settings.centerForce, graphLayout); + const strength = (): number => getRootGraphCenterStrength(settings.centerForce); const forceXInstance = graph.d3Force('forceX'); if (hasStrength(forceXInstance)) forceXInstance.strength(strength); diff --git a/packages/extension/src/webview/components/graph/runtime/physics/root/settings/chargeForce.ts b/packages/extension/src/webview/components/graph/runtime/physics/root/settings/chargeForce.ts index 2c4a484aa..be355464a 100644 --- a/packages/extension/src/webview/components/graph/runtime/physics/root/settings/chargeForce.ts +++ b/packages/extension/src/webview/components/graph/runtime/physics/root/settings/chargeForce.ts @@ -1,28 +1,30 @@ import type { IPhysicsSettings } from '../../../../../../../shared/settings/physics'; -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; import { toD3Repel, type FGNode } from '../../../../model/build'; import { hasDistanceMax, hasStrength } from '../../../../support/guards'; -import { hasExpandedOwnerSection } from '../../expandedOwnership'; import { DEFAULT_CHARGE_RANGE, type GraphPhysicsControls } from '../../model'; -import { getSectionChargeMultiplier } from '../../section/charge'; -function getGraphChargeStrength( - repelForce: number, - graphLayout: GraphLayoutSettings | undefined, -): (node: FGNode) => number { +function getGraphChargeStrength(repelForce: number): (node: FGNode) => number { const defaultStrength = toD3Repel(repelForce); - return (node: FGNode) => node.isDragging || (graphLayout && hasExpandedOwnerSection(node, graphLayout)) - ? 0 - : defaultStrength * getSectionChargeMultiplier(node); + return (node: FGNode) => { + if (node.isDragging) { + return 0; + } + + const multiplier = node.chargeStrengthMultiplier2D; + if (typeof multiplier !== 'number' || !Number.isFinite(multiplier) || multiplier < 0) { + return defaultStrength; + } + + return multiplier === 0 ? 0 : defaultStrength * multiplier; + }; } export function applyChargeSettings( graph: GraphPhysicsControls, settings: IPhysicsSettings, - graphLayout: GraphLayoutSettings | undefined, ): void { const chargeForce = graph.d3Force('charge'); - if (hasStrength(chargeForce)) chargeForce.strength(getGraphChargeStrength(settings.repelForce, graphLayout)); + if (hasStrength(chargeForce)) chargeForce.strength(getGraphChargeStrength(settings.repelForce)); if (hasDistanceMax(chargeForce)) { chargeForce.distanceMax(DEFAULT_CHARGE_RANGE); } diff --git a/packages/extension/src/webview/components/graph/runtime/physics/root/settings/collisionForce.ts b/packages/extension/src/webview/components/graph/runtime/physics/root/settings/collisionForce.ts index 7c9bf36e0..252f07efe 100644 --- a/packages/extension/src/webview/components/graph/runtime/physics/root/settings/collisionForce.ts +++ b/packages/extension/src/webview/components/graph/runtime/physics/root/settings/collisionForce.ts @@ -1,4 +1,3 @@ -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; import type { FGNode } from '../../../../model/build'; import type { GraphPhysicsControls } from '../../model'; import { getRootGraphCollisionRadius } from '../collision'; @@ -6,10 +5,9 @@ import { hasRadius } from './guards'; export function applyCollisionSettings( graph: GraphPhysicsControls, - graphLayout: GraphLayoutSettings | undefined, ): void { const collisionForce = graph.d3Force('collision'); if (hasRadius(collisionForce)) { - collisionForce.radius((node: FGNode) => getRootGraphCollisionRadius(node, graphLayout)); + collisionForce.radius((node: FGNode) => getRootGraphCollisionRadius(node)); } } diff --git a/packages/extension/src/webview/components/graph/runtime/physics/root/settings/init.ts b/packages/extension/src/webview/components/graph/runtime/physics/root/settings/init.ts index 3cde7a3eb..dc48a214b 100644 --- a/packages/extension/src/webview/components/graph/runtime/physics/root/settings/init.ts +++ b/packages/extension/src/webview/components/graph/runtime/physics/root/settings/init.ts @@ -1,8 +1,7 @@ import { forceCollide, forceX, forceY } from 'd3-force'; import type { IPhysicsSettings } from '../../../../../../../shared/settings/physics'; import type { FGNode } from '../../../../model/build'; -import { COLLISION_ITERATIONS, type GraphPhysicsControls, type GraphPhysicsInstance, type GraphPhysicsSectionOptions } from '../../model'; -import { applyGraphSectionBoundsForce } from '../../section/binding'; +import { COLLISION_ITERATIONS, type GraphPhysicsControls, type GraphPhysicsInstance, type GraphPhysicsOptions } from '../../model'; import { applyPhysicsSettings } from './apply'; import { removeCentroidCenterForce } from './centerForce'; import { getRootGraphCenterStrength, getRootGraphCollisionRadius } from '../collision'; @@ -10,26 +9,22 @@ import { getRootGraphCenterStrength, getRootGraphCollisionRadius } from '../coll export function initPhysics( instance: GraphPhysicsInstance, settings: IPhysicsSettings, - options: GraphPhysicsSectionOptions = { graphMode: '2d' }, + options: GraphPhysicsOptions = { graphMode: '2d' }, ): void { const graph = instance as GraphPhysicsControls; - const graphLayout = options.graphMode === '2d' ? options.graphLayout : undefined; applyPhysicsSettings(instance, settings, options); removeCentroidCenterForce(graph); graph.d3Force( 'forceX', - forceX(0).strength(node => getRootGraphCenterStrength(node, settings.centerForce, graphLayout)), + forceX(0).strength(() => getRootGraphCenterStrength(settings.centerForce)), ); graph.d3Force( 'forceY', - forceY(0).strength(node => getRootGraphCenterStrength(node, settings.centerForce, graphLayout)), + forceY(0).strength(() => getRootGraphCenterStrength(settings.centerForce)), ); graph.d3Force( 'collision', - forceCollide(node => getRootGraphCollisionRadius(node, graphLayout)).iterations(COLLISION_ITERATIONS), + forceCollide(node => getRootGraphCollisionRadius(node)).iterations(COLLISION_ITERATIONS), ); - if (options.graphLayout || options.graphMode !== '2d') { - applyGraphSectionBoundsForce(instance, { ...options, settings }); - } graph.d3ReheatSimulation(); } diff --git a/packages/extension/src/webview/components/graph/runtime/physics/root/settings/linkForce.ts b/packages/extension/src/webview/components/graph/runtime/physics/root/settings/linkForce.ts index 056c75ff9..590de0a56 100644 --- a/packages/extension/src/webview/components/graph/runtime/physics/root/settings/linkForce.ts +++ b/packages/extension/src/webview/components/graph/runtime/physics/root/settings/linkForce.ts @@ -1,17 +1,14 @@ import type { IPhysicsSettings } from '../../../../../../../shared/settings/physics'; -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; import { hasDistanceAndStrength } from '../../../../support/guards'; -import { getGraphLinkStrength } from '../../bridge/endpoints'; import type { GraphPhysicsControls } from '../../model'; export function applyLinkSettings( graph: GraphPhysicsControls, settings: IPhysicsSettings, - graphLayout: GraphLayoutSettings | undefined, ): void { const linkForce = graph.d3Force('link'); if (hasDistanceAndStrength(linkForce)) { linkForce.distance(settings.linkDistance); - linkForce.strength(getGraphLinkStrength(settings.linkForce, graphLayout)); + linkForce.strength(settings.linkForce); } } diff --git a/packages/extension/src/webview/components/graph/runtime/physics/section/binding.ts b/packages/extension/src/webview/components/graph/runtime/physics/section/binding.ts deleted file mode 100644 index 47242efe6..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/section/binding.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { GraphPhysicsControls, GraphPhysicsInstance, GraphPhysicsSectionOptions } from '../model'; -import { createGraphSectionBoundsForce } from './force'; - -export function applyGraphSectionBoundsForce( - instance: GraphPhysicsInstance, - options: GraphPhysicsSectionOptions, -): void { - const graph = instance as GraphPhysicsControls; - const force = options.graphMode === '2d' && options.graphLayout - ? createGraphSectionBoundsForce(options.graphLayout, { - links: options.links, - settings: options.settings, - }) - : null; - - graph.d3Force('sectionBounds', force); - graph.d3ReheatSimulation(); -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/section/bounds.ts b/packages/extension/src/webview/components/graph/runtime/physics/section/bounds.ts deleted file mode 100644 index 01e188ad2..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/section/bounds.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../model/build'; -import { SECTION_FRAME_HEADER_HEIGHT } from '../../../sectionFrames/model'; -import type { BoundsRect, SectionCenter } from '../model'; -import { createNodeMap } from '../nodeLookup'; -import { isFiniteNumber } from '../numeric'; - -export function getSectionBounds( - sectionNode: FGNode | undefined, - sectionId: string, - graphLayout: GraphLayoutSettings, -): BoundsRect | undefined { - const section = graphLayout.sections[sectionId]; - if (!section || section.collapsed) { - return undefined; - } - - const height = isFiniteNumber(sectionNode?.sectionHeight) ? sectionNode.sectionHeight : section.height; - const width = isFiniteNumber(sectionNode?.sectionWidth) ? sectionNode.sectionWidth : section.width; - const centerX = sectionNode?.x; - const centerY = sectionNode?.y; - return { - height, - width, - x: isFiniteNumber(centerX) ? centerX - (width / 2) : section.x, - y: isFiniteNumber(centerY) ? centerY - (height / 2) : section.y, - }; -} - -export function getSectionMemberBounds(bounds: BoundsRect): BoundsRect { - return { - height: Math.max(1, bounds.height - SECTION_FRAME_HEADER_HEIGHT), - width: bounds.width, - x: bounds.x, - y: bounds.y + SECTION_FRAME_HEADER_HEIGHT, - }; -} - -export function createSectionBoundsMap( - nodes: readonly FGNode[], - graphLayout: GraphLayoutSettings, -): Map { - const nodeMap = createNodeMap(nodes); - const bounds = new Map(); - - for (const sectionId of Object.keys(graphLayout.sections)) { - const sectionBounds = getSectionBounds(nodeMap.get(sectionId), sectionId, graphLayout); - if (sectionBounds) { - bounds.set(sectionId, sectionBounds); - } - } - - return bounds; -} - -export function getSectionCenter(node: FGNode | undefined): SectionCenter | undefined { - if (!node || !isFiniteNumber(node.x) || !isFiniteNumber(node.y)) { - return undefined; - } - - return { x: node.x, y: node.y }; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/section/charge.ts b/packages/extension/src/webview/components/graph/runtime/physics/section/charge.ts deleted file mode 100644 index d24ca9fad..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/section/charge.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { FGNode } from '../../../model/build'; -import { DEFAULT_NODE_SIZE } from '../../../model/build'; -import { - COLLISION_PADDING, - SECTION_CHARGE_MULTIPLIER_CAP, -} from '../model'; -import { clamp, isFiniteNumber } from '../numeric'; -import { isExpandedGraphSection } from './state'; - -export function getSectionChargeMultiplier(node: FGNode): number { - if ( - !isExpandedGraphSection(node) - || !isFiniteNumber(node.sectionHeight) - || !isFiniteNumber(node.sectionWidth) - ) { - return 1; - } - - const equivalentRadius = Math.sqrt((node.sectionWidth * node.sectionHeight) / Math.PI); - const referenceRadius = DEFAULT_NODE_SIZE + COLLISION_PADDING; - return clamp(equivalentRadius / referenceRadius, 1, SECTION_CHARGE_MULTIPLIER_CAP); -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/section/force.ts b/packages/extension/src/webview/components/graph/runtime/physics/section/force.ts deleted file mode 100644 index 290f17224..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/section/force.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../model/build'; -import { applySectionBridgeLinkForces } from '../bridge/forces'; -import { carrySectionMembersWithFrames } from '../member/movement/carry'; -import { isolateSectionMemberVelocities, rememberSectionCenters, rememberSectionMemberPositions } from '../member/movement/velocity'; -import { getSectionMemberCenterStrength } from '../member/simulation/settings'; -import type { GraphSectionBoundsForce, GraphSectionBoundsForceOptions, SectionCenter, SectionMemberPosition } from '../model'; -import { createSectionBoundsMap } from './bounds'; -import { applyRectangleCollisions } from '../rectangle/collision/apply'; -import { getVelocityIntegrationDecay } from '../rectangle/collision/repel'; -import { constrainPassiveRootNodesOutsideSections } from '../root/boundary/constrain'; -import { applyLocalSectionMemberForces, constrainSectionMembers } from './memberForces'; - -function applyGraphSectionBoundsTick( - nodes: FGNode[], - graphLayout: GraphLayoutSettings, - options: GraphSectionBoundsForceOptions, - previousSectionCenters: Map, - previousSectionMemberPositions: Map, - alpha: number, -): void { - isolateSectionMemberVelocities(nodes, graphLayout, previousSectionMemberPositions); - applySectionBridgeLinkForces(nodes, graphLayout, options.links ?? [], options.settings, alpha); - applyRectangleCollisions(nodes, graphLayout, options.settings); - carrySectionMembersWithFrames(nodes, graphLayout, previousSectionCenters); - const sectionBounds = createSectionBoundsMap(nodes, graphLayout); - constrainPassiveRootNodesOutsideSections(nodes, graphLayout, sectionBounds, getVelocityIntegrationDecay(options.settings)); - const sectionMemberCenterStrength = getSectionMemberCenterStrength(options.settings); - constrainSectionMembers(nodes, graphLayout, sectionBounds, alpha, sectionMemberCenterStrength); - applyLocalSectionMemberForces(nodes, graphLayout, options.settings, alpha); - constrainSectionMembers(nodes, graphLayout, sectionBounds, alpha, sectionMemberCenterStrength); - rememberSectionCenters(nodes, graphLayout, previousSectionCenters); - rememberSectionMemberPositions(nodes, graphLayout, previousSectionMemberPositions); -} - -export function createGraphSectionBoundsForce( - graphLayout: GraphLayoutSettings, - options: GraphSectionBoundsForceOptions = {}, -): GraphSectionBoundsForce { - let nodes: FGNode[] = []; - const previousSectionCenters = new Map(); - const previousSectionMemberPositions = new Map(); - - const force = ((alpha: number): void => { - applyGraphSectionBoundsTick(nodes, graphLayout, options, previousSectionCenters, previousSectionMemberPositions, alpha); - }) as GraphSectionBoundsForce; - - force.initialize = (nextNodes: FGNode[]): void => { - nodes = nextNodes; - previousSectionCenters.clear(); - previousSectionMemberPositions.clear(); - }; - - return force; -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/section/hierarchy.ts b/packages/extension/src/webview/components/graph/runtime/physics/section/hierarchy.ts deleted file mode 100644 index c7059a0f1..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/section/hierarchy.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../shared/settings/graphLayout'; - -export function getSectionDepth( - sectionId: string, - graphLayout: GraphLayoutSettings, -): number { - let depth = 0; - let ownerSectionId = graphLayout.ownership[sectionId]?.ownerSectionId ?? null; - const visited = new Set([sectionId]); - - while (ownerSectionId) { - if (visited.has(ownerSectionId)) { - return depth; - } - - visited.add(ownerSectionId); - depth += 1; - ownerSectionId = graphLayout.ownership[ownerSectionId]?.ownerSectionId ?? null; - } - - return depth; -} - -export function getSectionIdsByDepth(graphLayout: GraphLayoutSettings): string[] { - return Object.keys(graphLayout.sections) - .sort((left, right) => getSectionDepth(left, graphLayout) - getSectionDepth(right, graphLayout)); -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/section/memberForces.ts b/packages/extension/src/webview/components/graph/runtime/physics/section/memberForces.ts deleted file mode 100644 index 7a9acb741..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/section/memberForces.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../model/build'; -import { constrainMemberNode } from '../member/bounds/constrain'; -import { applySectionMemberChargeForces } from '../member/simulation/charge'; -import { applySectionMemberCollisions } from '../member/simulation/collision'; -import type { BoundsRect, GraphSectionBoundsForceOptions } from '../model'; -import { getOwnerSectionId } from '../ownership'; - -export function constrainSectionMembers( - nodes: readonly FGNode[], - graphLayout: GraphLayoutSettings, - sectionBounds: Map, - alpha: number, - sectionMemberCenterStrength: number, -): void { - for (const node of nodes) { - const ownerSectionId = getOwnerSectionId(node, graphLayout); - if (node.isDragging || !ownerSectionId) { - continue; - } - - const bounds = sectionBounds.get(ownerSectionId); - if (bounds) { - constrainMemberNode(node, bounds, alpha, sectionMemberCenterStrength); - } - } -} - -export function applyLocalSectionMemberForces( - nodes: readonly FGNode[], - graphLayout: GraphLayoutSettings, - settings: GraphSectionBoundsForceOptions['settings'], - alpha: number, -): void { - applySectionMemberChargeForces(nodes, graphLayout, settings, alpha); - applySectionMemberCollisions(nodes, graphLayout, alpha); -} diff --git a/packages/extension/src/webview/components/graph/runtime/physics/section/state.ts b/packages/extension/src/webview/components/graph/runtime/physics/section/state.ts deleted file mode 100644 index 118987600..000000000 --- a/packages/extension/src/webview/components/graph/runtime/physics/section/state.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { GraphLayoutSettings } from '../../../../../../shared/settings/graphLayout'; -import type { FGNode } from '../../../model/build'; -import { isFiniteNumber } from '../numeric'; - -export function isExpandedGraphSection(node: FGNode): boolean { - return !!node.isGraphSection && !node.isCollapsedGraphSection; -} - -export function hasExpandedGraphSection(graphLayout: GraphLayoutSettings): boolean { - return Object.values(graphLayout.sections).some(section => !section.collapsed); -} - -export function hasExpandedSectionCollisionSize( - node: FGNode, -): node is FGNode & { sectionHeight: number; sectionWidth: number } { - return !!node.isGraphSection - && !node.isCollapsedGraphSection - && isFiniteNumber(node.sectionHeight) - && isFiniteNumber(node.sectionWidth); -} diff --git a/packages/extension/src/webview/components/graph/runtime/use/events/effects.ts b/packages/extension/src/webview/components/graph/runtime/use/events/effects.ts index fa3fdfe83..d0f4d97e3 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/events/effects.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/events/effects.ts @@ -92,13 +92,9 @@ export function useGraphEventEffects({ }, [applyWebviewMessageEffects, graphDataRef, graphMode, tooltipPath]); useEffect(() => { - const selectedNodeSet = new Set(selectedNodes); const handleKeyDown = createGraphKeyboardListener({ graphMode, selectedNodeIds: selectedNodes, - selectedGraphSectionIds: graphDataRef.current.nodes - .filter(node => !!node.isGraphSection && selectedNodeSet.has(node.id)) - .map(node => node.id), getAllNodeIds: () => graphDataRef.current.nodes.map(node => node.id), fitView: () => interactionHandlers.fitView(), setSelection: nodeIds => interactionHandlers.setSelection(nodeIds), diff --git a/packages/extension/src/webview/components/graph/runtime/use/interaction/contracts.ts b/packages/extension/src/webview/components/graph/runtime/use/interaction/contracts.ts index 1f53007d5..deac5b3d2 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/interaction/contracts.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/interaction/contracts.ts @@ -4,8 +4,8 @@ import type { MutableRefObject, SetStateAction, } from 'react'; +import type { CoreGraphViewContributionSet } from '@codegraphy-dev/core'; import type { IGraphData } from '../../../../../../shared/graph/contracts'; -import type { GraphLayoutSettings } from '../../../../../../shared/settings/graphLayout'; import type { GraphContextMenuAction, GraphContextSelection } from '../../../contextMenu/contracts'; import type { createGraphInteractionHandlers } from '../../../interactionRuntime/handlers'; import type { FGNode } from '../../../model/build'; @@ -23,7 +23,7 @@ export interface UseGraphInteractionRuntimeOptions { graphContextSelection: GraphContextSelection; graphCursorRef: MutableRefObject; graphDataRef: UseGraphStateResult['graphDataRef']; - graphLayout?: GraphLayoutSettings; + graphViewContributions?: CoreGraphViewContributionSet; graphMode: '2d' | '3d'; highlightedNeighborsRef: UseGraphStateResult['highlightedNeighborsRef']; highlightedNodeRef: UseGraphStateResult['highlightedNodeRef']; diff --git a/packages/extension/src/webview/components/graph/runtime/use/interaction/hook.ts b/packages/extension/src/webview/components/graph/runtime/use/interaction/hook.ts index 3b8266b15..e6e85b2ad 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/interaction/hook.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/interaction/hook.ts @@ -28,11 +28,9 @@ import { useGraphMarqueeSelectionRuntime } from './marquee/hook'; import { applyNodeDrag, postDraggedNodesDragEndMessages, - updateNodeDragOwnerPreview, type NodeDragGroupSession, type NodeDragTranslate, } from './nodeDrag'; -import { createGraphNodePositionMap } from './positions'; import { useGraphViewportPanRuntime } from './viewportPan/hook'; function buildTooltipInteractionHandlers( @@ -73,7 +71,7 @@ export function useGraphInteractionRuntime({ graphContextSelection, graphCursorRef, graphDataRef, - graphLayout, + graphViewContributions, graphMode, highlightedNeighborsRef, highlightedNodeRef, @@ -109,7 +107,6 @@ export function useGraphInteractionRuntime({ fg2dRef: refs.fg2dRef, fg3dRef: refs.fg3dRef, fileInfoCacheRef, - graphLayout, graphCursorRef, graphDataRef, graphMode, @@ -123,16 +120,12 @@ export function useGraphInteractionRuntime({ setContextSelection: setLiveContextSelection, setHighlightVersion, setSelectedNodes, - toggleFolderCollapse: (nodeId, collapsed) => { - postMessage({ type: 'UPDATE_GRAPH_LAYOUT_COLLAPSE', payload: { nodeId, collapsed } }); - }, }), [ dataRef, depthMode, fileInfoCacheRef, graphCursorRef, - graphLayout, graphDataRef, graphMode, highlightedNeighborsRef, @@ -187,19 +180,16 @@ export function useGraphInteractionRuntime({ const getActionContext = useCallback( () => resolveGraphContextActionContext(graphContextSelectionRef.current, { - graphLayout, - graphMode, graphViewportScale: readGraphViewportScale(graphMode, refs.fg2dRef.current), nodes: graphDataRef.current.nodes, - nodePositions: createGraphNodePositionMap(graphDataRef.current.nodes, graphMode), }), - [graphDataRef, graphLayout, graphMode, refs.fg2dRef], + [graphDataRef, graphMode, refs.fg2dRef], ); function handleNodeDragEnd(node: FGNode): void { postDraggedNodesDragEndMessages(node, nodeDragGroupRef.current, { graphData: graphDataRef.current, - graphLayout, + graphViewContributions, graphMode, timelineActive, }); @@ -213,20 +203,6 @@ export function useGraphInteractionRuntime({ selectedNodeIds: refs.selectedNodesSetRef.current, }, nodeDragGroupRef.current); - const draggedNodeIds = nodeDragGroupRef.current?.draggedNodeIds ?? new Set([node.id]); - const nodesById = new Map(graphDataRef.current.nodes.map(graphNode => [graphNode.id, graphNode])); - for (const nodeId of draggedNodeIds) { - const draggedNode = nodeId === node.id ? node : nodesById.get(nodeId); - if (draggedNode) { - updateNodeDragOwnerPreview(draggedNode, { - graphData: graphDataRef.current, - graphLayout, - graphMode, - timelineActive, - }); - } - } - marqueeRuntime.clearMarqueeSelection(); } diff --git a/packages/extension/src/webview/components/graph/runtime/use/interaction/marquee/state.ts b/packages/extension/src/webview/components/graph/runtime/use/interaction/marquee/state.ts index c19714b80..82a41bad9 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/interaction/marquee/state.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/interaction/marquee/state.ts @@ -2,7 +2,6 @@ import type { MouseEvent as ReactMouseEvent, MutableRefObject, } from 'react'; -import type { GraphLayoutMode } from '../../../../../../../shared/settings/graphLayout'; import type { FGNode } from '../../../../model/build'; import { isMarqueePastThreshold, @@ -26,7 +25,7 @@ export interface GraphMarqueeSelectionRuntimeOptions { containerRef: UseGraphStateResult['containerRef']; fg2dRef: UseGraphStateResult['fg2dRef']; graphDataRef: UseGraphStateResult['graphDataRef']; - graphMode: GraphLayoutMode; + graphMode: '2d' | '3d'; hoveredNodeRef: MutableRefObject; interactionHandlers: GraphInteractionHandlersRuntime; selectedNodesSetRef: UseGraphStateResult['selectedNodesSetRef']; @@ -42,7 +41,7 @@ export interface GraphMarqueeSelectionRuntime { export function canStartMarqueeSelection( event: ReactMouseEvent, - graphMode: GraphLayoutMode, + graphMode: '2d' | '3d', hoveredNode: FGNode | null, ): boolean { return event.button === 0 diff --git a/packages/extension/src/webview/components/graph/runtime/use/interaction/nodeDrag.ts b/packages/extension/src/webview/components/graph/runtime/use/interaction/nodeDrag.ts index 93176c062..6a0bd9d94 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/interaction/nodeDrag.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/interaction/nodeDrag.ts @@ -1,19 +1,7 @@ -import type { WebviewToExtensionMessage } from '../../../../../../shared/protocol/webviewToExtension'; -import { - findDeepestGraphLayoutSectionAtWorldPoint, - isGraphLayoutSectionDescendant, - type GraphLayoutMode, - type GraphLayoutOwnershipUpdate, - type GraphLayoutSettings, -} from '../../../../../../shared/settings/graphLayout'; +import type { CoreGraphViewContributionSet } from '@codegraphy-dev/core'; import type { FGNode } from '../../../model/build'; -import { postMessage } from '../../../../../vscodeApi'; -import { readNodePosition } from './positions'; -type GraphLayoutOwnerDragMessage = Extract< - WebviewToExtensionMessage, - { type: 'UPDATE_GRAPH_LAYOUT_OWNER' } ->; +type GraphMode = '2d' | '3d'; export interface NodeDragTranslate { x: number; @@ -31,15 +19,22 @@ interface NodeDragGraphData { interface ApplyNodeDragOptions { graphData: NodeDragGraphData; - graphMode: GraphLayoutMode; + graphMode: GraphMode; selectedNodeIds: ReadonlySet; } interface NodeDragEndOptions { graphData: NodeDragGraphData; - graphLayout: GraphLayoutSettings | undefined; - graphMode: GraphLayoutMode; - timelineActive: boolean; + graphViewContributions?: Pick; + graphMode: GraphMode; + timelineActive?: boolean; +} + +interface NodeDragPolicyContext { + graphData?: NodeDragGraphData; + graphViewContributions?: Pick; + graphMode: GraphMode; + timelineActive?: boolean; } function isFiniteNumber(value: unknown): value is number { @@ -54,185 +49,41 @@ function createNodeMap(nodes: readonly FGNode[]): Map { return new Map(nodes.map(node => [node.id, node])); } -function readLiveSectionDimension( - value: unknown, - fallback: number, -): number { - return isFiniteNumber(value) ? value : fallback; -} - -function getSectionWorldTopLeft( - graphLayout: GraphLayoutSettings, - sectionId: string, - visited = new Set(), -): { x: number; y: number } | undefined { - const section = graphLayout.sections[sectionId]; - if (!section) { - return undefined; - } - - if (visited.has(sectionId)) { - return { x: section.x, y: section.y }; - } - - const ownerSectionId = graphLayout.ownership[sectionId]?.ownerSectionId ?? null; - if (!ownerSectionId) { - return { x: section.x, y: section.y }; - } - - visited.add(sectionId); - const ownerTopLeft = getSectionWorldTopLeft(graphLayout, ownerSectionId, visited); - return ownerTopLeft - ? { x: ownerTopLeft.x + section.x, y: ownerTopLeft.y + section.y } - : { x: section.x, y: section.y }; -} - -function createLiveGraphLayout( - graphLayout: GraphLayoutSettings, - graphNodes: readonly FGNode[] | undefined, -): GraphLayoutSettings { - if (!graphNodes || graphNodes.length === 0) { - return graphLayout; - } - - const sections = { ...graphLayout.sections }; - for (const node of graphNodes) { - if (!node.isGraphSection || node.isCollapsedGraphSection) { - continue; - } - - const section = sections[node.id]; - if (!section) { - continue; - } - - const height = readLiveSectionDimension(node.sectionHeight, section.height); - const width = readLiveSectionDimension(node.sectionWidth, section.width); - const centerX = isFiniteNumber(node.x) ? node.x : undefined; - const centerY = isFiniteNumber(node.y) ? node.y : undefined; - sections[node.id] = { - ...section, - height, - width, - x: centerX === undefined ? section.x : centerX - (width / 2), - y: centerY === undefined ? section.y : centerY - (height / 2), - }; - } - - return { ...graphLayout, sections }; -} - -function getGraphLayoutItemKind(node: FGNode): GraphLayoutOwnershipUpdate['itemKind'] { - return node.isGraphSection ? 'section' : 'node'; +export function markNodeDragging(node: FGNode): void { + node.isDragging = true; } -function createOwnershipCandidateGraphLayout( - graphLayout: GraphLayoutSettings, +function shouldKeepFixedPosition( node: FGNode, -): GraphLayoutSettings { - if (!node.isGraphSection) { - return graphLayout; - } - - const sections = { ...graphLayout.sections }; - for (const sectionId of Object.keys(sections)) { - if ( - sectionId === node.id - || isGraphLayoutSectionDescendant(graphLayout.ownership, sectionId, node.id) - ) { - delete sections[sectionId]; + options: NodeDragPolicyContext, +): boolean { + for (const entry of options.graphViewContributions?.nodeDragEnd ?? []) { + try { + const result = entry.contribution.onNodeDragEnd({ + graphMode: options.graphMode, + node, + nodes: options.graphData?.nodes ?? [node], + timelineActive: options.timelineActive ?? false, + }); + if (result?.keepFixedPosition === true) { + return true; + } + } catch (error) { + console.error('[CodeGraphy] Plugin node drag end contribution error:', error); } } - return { ...graphLayout, sections }; + return false; } -function createPinnedNodeDragMessage( +function releaseNodeDrag( node: FGNode, - graphMode: GraphLayoutMode, - graphLayout: GraphLayoutSettings | undefined, - graphNodes: readonly FGNode[] | undefined, -): WebviewToExtensionMessage | undefined { - if (!node.isPinned) { - return undefined; - } - - const position = readNodePosition(node, graphMode); - if (!position) { - return undefined; - } - - const liveGraphLayout = graphLayout ? createLiveGraphLayout(graphLayout, graphNodes) : undefined; - const ownerSectionId = liveGraphLayout - ? (node.ownerSectionId ?? liveGraphLayout.ownership[node.id]?.ownerSectionId ?? null) - : null; - const ownerTopLeft = graphMode === '2d' && liveGraphLayout && ownerSectionId - ? getSectionWorldTopLeft(liveGraphLayout, ownerSectionId) - : undefined; - const persistedPosition = ownerTopLeft - ? { x: position.x - ownerTopLeft.x, y: position.y - ownerTopLeft.y } - : position; - - return { - type: 'UPDATE_GRAPH_LAYOUT_PIN', - payload: { - graphMode, - nodeId: node.id, - position: persistedPosition, - }, - }; -} - -function canUpdateGraphLayoutOwnerOnDrag( - graphLayout: GraphLayoutSettings | undefined, - graphMode: GraphLayoutMode, - timelineActive: boolean, -): graphLayout is GraphLayoutSettings { - return !!graphLayout && graphMode === '2d' && !timelineActive; -} - -function createGraphLayoutOwnerDragMessage( - node: FGNode, - graphLayout: GraphLayoutSettings | undefined, - graphMode: GraphLayoutMode, - timelineActive: boolean, - graphNodes?: readonly FGNode[], -): GraphLayoutOwnerDragMessage | undefined { - if (!canUpdateGraphLayoutOwnerOnDrag(graphLayout, graphMode, timelineActive)) { - return undefined; - } - - const position = readNodePosition(node, graphMode); - if (!position) { - return undefined; - } - - const liveGraphLayout = createLiveGraphLayout(graphLayout, graphNodes); - const ownerCandidateGraphLayout = createOwnershipCandidateGraphLayout(liveGraphLayout, node); - const ownerSectionId = findDeepestGraphLayoutSectionAtWorldPoint(ownerCandidateGraphLayout, position); - const currentOwnerSectionId = graphLayout.ownership[node.id]?.ownerSectionId ?? null; - if (ownerSectionId === currentOwnerSectionId) { - return undefined; - } - - return { - type: 'UPDATE_GRAPH_LAYOUT_OWNER', - payload: { - itemId: node.id, - itemKind: getGraphLayoutItemKind(node), - ownerSectionId, - }, - }; -} - -export function markNodeDragging(node: FGNode): void { - node.isDragging = true; -} - -function releaseNodeDrag(node: FGNode, graphMode: GraphLayoutMode): void { + graphMode: GraphMode, + options: Omit = {}, +): void { node.isDragging = false; - if (node.isPinned) { + if (shouldKeepFixedPosition(node, { ...options, graphMode })) { return; } @@ -338,51 +189,17 @@ function getDragEndNodes( export function postNodeDragEndMessages( node: FGNode, - graphLayout: GraphLayoutSettings | undefined, - graphMode: GraphLayoutMode, - timelineActive: boolean, - graphNodes?: readonly FGNode[], + graphMode: GraphMode, + graphViewContributions?: Pick, + options: { + graphData?: NodeDragGraphData; + timelineActive?: boolean; + } = {}, ): void { - const ownerMessage = createGraphLayoutOwnerDragMessage( - node, - graphLayout, - graphMode, - timelineActive, - graphNodes, - ); - if (ownerMessage) { - node.ownerSectionId = ownerMessage.payload.ownerSectionId; - } - releaseNodeDrag(node, graphMode); - - const messages = [ - createPinnedNodeDragMessage(node, graphMode, graphLayout, graphNodes), - ownerMessage, - ]; - - for (const message of messages) { - if (message) { - postMessage(message); - } - } -} - -export function updateNodeDragOwnerPreview( - node: FGNode, - options: NodeDragEndOptions, -): string | null { - if (!canUpdateGraphLayoutOwnerOnDrag(options.graphLayout, options.graphMode, options.timelineActive)) { - return null; - } - - const position = readNodePosition(node, options.graphMode); - if (!position) { - return null; - } - - const liveGraphLayout = createLiveGraphLayout(options.graphLayout, options.graphData.nodes); - const ownerCandidateGraphLayout = createOwnershipCandidateGraphLayout(liveGraphLayout, node); - return findDeepestGraphLayoutSectionAtWorldPoint(ownerCandidateGraphLayout, position); + releaseNodeDrag(node, graphMode, { + ...options, + graphViewContributions, + }); } export function postDraggedNodesDragEndMessages( @@ -393,10 +210,12 @@ export function postDraggedNodesDragEndMessages( for (const node of getDragEndNodes(primaryNode, session, options.graphData)) { postNodeDragEndMessages( node, - options.graphLayout, options.graphMode, - options.timelineActive, - options.graphData.nodes, + options.graphViewContributions, + { + graphData: options.graphData, + timelineActive: options.timelineActive, + }, ); } } diff --git a/packages/extension/src/webview/components/graph/runtime/use/interaction/positions.ts b/packages/extension/src/webview/components/graph/runtime/use/interaction/positions.ts index b1e339ae4..e1c83b440 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/interaction/positions.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/interaction/positions.ts @@ -1,18 +1,24 @@ -import type { GraphLayoutMode } from '../../../../../../shared/settings/graphLayout'; -import type { - GraphContextNodePosition2D, - GraphContextNodePosition3D, -} from '../../../contextActions/context'; import type { FGNode } from '../../../model/build'; +type GraphMode = '2d' | '3d'; + +export interface GraphNodePosition2D { + x: number; + y: number; +} + +export interface GraphNodePosition3D extends GraphNodePosition2D { + z: number; +} + export function isFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } export function readNodePosition( node: FGNode, - graphMode: GraphLayoutMode, -): GraphContextNodePosition2D | GraphContextNodePosition3D | undefined { + graphMode: GraphMode, +): GraphNodePosition2D | GraphNodePosition3D | undefined { if (!isFiniteNumber(node.x) || !isFiniteNumber(node.y)) { return undefined; } @@ -28,9 +34,9 @@ export function readNodePosition( export function createGraphNodePositionMap( nodes: readonly FGNode[], - graphMode: GraphLayoutMode, -): Map { - const positions = new Map(); + graphMode: GraphMode, +): Map { + const positions = new Map(); for (const node of nodes) { const position = readNodePosition(node, graphMode); diff --git a/packages/extension/src/webview/components/graph/runtime/use/interaction/viewportPan/state.ts b/packages/extension/src/webview/components/graph/runtime/use/interaction/viewportPan/state.ts index 63d1d964b..615ead2a1 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/interaction/viewportPan/state.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/interaction/viewportPan/state.ts @@ -2,7 +2,6 @@ import type { MouseEvent as ReactMouseEvent, MutableRefObject, } from 'react'; -import type { GraphLayoutMode } from '../../../../../../../shared/settings/graphLayout'; import { isMarqueePastThreshold, type MarqueePoint, @@ -27,7 +26,7 @@ export interface ViewportPanDragState { export interface GraphViewportPanRuntimeOptions { containerRef: UseGraphStateResult['containerRef']; fg2dRef: UseGraphStateResult['fg2dRef']; - graphMode: GraphLayoutMode; + graphMode: '2d' | '3d'; rightMouseDownRef: UseGraphStateResult['rightMouseDownRef']; suppressContextMenu(this: void): void; } 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..e6ce7a2cc 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,14 @@ import { useEffect, useRef, type MutableRefObject } from 'react'; +import type { CoreGraphViewContributionSet } from '@codegraphy-dev/core'; import type { ForceGraphMethods as FG2DMethods } from 'react-force-graph-2d'; import type { ForceGraphMethods as FG3DMethods } from 'react-force-graph-3d'; -import 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'; @@ -21,36 +25,37 @@ interface UsePhysicsRuntimeProps { fg2dRef: MutableRefObject | undefined>; fg3dRef: MutableRefObject | undefined>; graphDataRef?: MutableRefObject<{ nodes: FGNode[]; links: FGLink[] }>; - graphLayout?: GraphLayoutSettings; + graphViewContributions?: CoreGraphViewContributionSet; graphMode: '2d' | '3d'; layoutKey: string; physicsPaused?: boolean; physicsSettings: IPhysicsSettings; + timelineActive?: boolean; } export function usePhysicsRuntime({ fg2dRef, fg3dRef, graphDataRef, - graphLayout, + graphViewContributions, graphMode, layoutKey, physicsPaused = false, physicsSettings, + timelineActive = false, }: UsePhysicsRuntimeProps): void { const physicsInitialisedRef = useRef(false); const physicsSettingsRef = useRef(physicsSettings); const pendingThreeDimensionalInitRef = useRef(graphMode === '3d'); const previousPhysicsRef = useRef(null); const previousLayoutKeyRef = useRef(null); + const forceAdapterStateRef = useRef(createGraphViewForceAdapterState()); physicsSettingsRef.current = physicsSettings; usePhysicsRuntimeUpdates({ fg2dRef, fg3dRef, - graphDataRef, - graphLayout, graphMode, physicsInitialisedRef, physicsSettings, @@ -76,9 +81,7 @@ export function usePhysicsRuntime({ usePhysicsRuntimeInit({ fg2dRef, fg3dRef, - graphDataRef, graphMode, - graphLayout, physicsInitialisedRef, physicsPaused, physicsSettingsRef, @@ -89,7 +92,6 @@ export function usePhysicsRuntime({ usePhysicsRuntimeLayoutKey({ fg2dRef, fg3dRef, - graphLayout, graphMode, layoutKey, physicsPaused, @@ -100,22 +102,40 @@ export function usePhysicsRuntime({ useEffect(() => { const graph = selectActivePhysicsGraph(graphMode, fg2dRef.current, fg3dRef.current); - if (!graph || !physicsInitialisedRef.current) { + if (!graph || !physicsInitialisedRef.current || typeof graph.d3Force !== 'function') { return; } - if (!graphLayout) { - return; - } + syncGraphViewForceAdapters( + graph as GraphPhysicsControls, + forceAdapterStateRef.current, + graphViewContributions, + graphDataRef?.current ?? { nodes: [], links: [] }, + { graphMode, timelineActive }, + ); + }, [fg2dRef, fg3dRef, graphDataRef, graphMode, graphViewContributions, layoutKey, physicsInitialisedRef, timelineActive]); + + 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]); - applyGraphSectionBoundsForce(graph, { - graphLayout, - graphMode, - links: graphDataRef?.current.links, - settings: physicsSettingsRef.current, - }); - applyPhysicsSettings(graph, physicsSettingsRef.current, { graphLayout, graphMode }); - }, [fg2dRef, fg3dRef, graphDataRef, graphLayout, graphMode, physicsInitialisedRef, physicsSettingsRef]); } export function syncPhysicsAnimation( diff --git a/packages/extension/src/webview/components/graph/runtime/use/physics/hook/init.ts b/packages/extension/src/webview/components/graph/runtime/use/physics/hook/init.ts index 5a4be288a..98e197245 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/physics/hook/init.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/physics/hook/init.ts @@ -1,15 +1,11 @@ import { useEffect, type MutableRefObject } from 'react'; -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; import type { IPhysicsSettings } from '../../../../../../../shared/settings/physics'; -import type { FGLink, FGNode } from '../../../../model/build'; import { initPhysics, syncPhysicsAnimation } from '../../../physics'; import { resolvePhysicsInitAction } from '../../../physicsLifecycle/init/action'; import type { PhysicsRuntimeRefs } from './refs'; interface UsePhysicsRuntimeInitOptions extends PhysicsRuntimeRefs { - graphDataRef?: MutableRefObject<{ nodes: FGNode[]; links: FGLink[] }>; graphMode: '2d' | '3d'; - graphLayout?: GraphLayoutSettings; physicsPaused: boolean; physicsInitialisedRef: MutableRefObject; physicsSettingsRef: MutableRefObject; @@ -20,9 +16,7 @@ interface UsePhysicsRuntimeInitOptions extends PhysicsRuntimeRefs { export function usePhysicsRuntimeInit({ fg2dRef, fg3dRef, - graphDataRef, graphMode, - graphLayout, pendingThreeDimensionalInitRef, physicsInitialisedRef, physicsPaused, @@ -54,15 +48,7 @@ export function usePhysicsRuntimeInit({ physicsInitialisedRef.current = true; previousPhysicsRef.current = { ...physicsSettingsRef.current }; - if (graphLayout) { - initPhysics(action.instance, physicsSettingsRef.current, { - graphLayout, - graphMode, - links: graphDataRef?.current.links, - }); - } else { - initPhysics(action.instance, physicsSettingsRef.current); - } + initPhysics(action.instance, physicsSettingsRef.current); if (physicsPaused) { syncPhysicsAnimation(action.instance, true); } @@ -80,9 +66,7 @@ export function usePhysicsRuntimeInit({ }, [ fg2dRef, fg3dRef, - graphDataRef, graphMode, - graphLayout, pendingThreeDimensionalInitRef, physicsInitialisedRef, physicsPaused, diff --git a/packages/extension/src/webview/components/graph/runtime/use/physics/hook/layout.ts b/packages/extension/src/webview/components/graph/runtime/use/physics/hook/layout.ts index d0c979749..40d8f0737 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/physics/hook/layout.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/physics/hook/layout.ts @@ -1,5 +1,4 @@ import { useEffect, type MutableRefObject } from 'react'; -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; import type { IPhysicsSettings } from '../../../../../../../shared/settings/physics'; import type { PhysicsRuntimeRefs } from './refs'; import { applyPhysicsSettings, syncPhysicsAnimation } from '../../../physics'; @@ -14,7 +13,6 @@ interface UsePhysicsRuntimeLayoutResetOptions { } interface UsePhysicsRuntimeLayoutKeyOptions extends PhysicsRuntimeRefs { - graphLayout?: GraphLayoutSettings; graphMode: '2d' | '3d'; layoutKey: string; physicsPaused: boolean; @@ -41,7 +39,6 @@ export function usePhysicsRuntimeLayoutReset({ export function usePhysicsRuntimeLayoutKey({ fg2dRef, fg3dRef, - graphLayout, graphMode, layoutKey, physicsPaused, @@ -65,18 +62,13 @@ export function usePhysicsRuntimeLayoutKey({ } previousLayoutKeyRef.current = layoutKey; - if (graphLayout) { - applyPhysicsSettings(graph, physicsSettingsRef.current, { graphLayout, graphMode }); - } else { - applyPhysicsSettings(graph, physicsSettingsRef.current); - } + applyPhysicsSettings(graph, physicsSettingsRef.current); if (physicsPaused) { syncPhysicsAnimation(graph, true); } }, [ fg2dRef, fg3dRef, - graphLayout, graphMode, layoutKey, physicsInitialisedRef, diff --git a/packages/extension/src/webview/components/graph/runtime/use/physics/hook/updates.ts b/packages/extension/src/webview/components/graph/runtime/use/physics/hook/updates.ts index 1043c8b42..fee189d0b 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/physics/hook/updates.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/physics/hook/updates.ts @@ -1,15 +1,11 @@ import { useEffect, type MutableRefObject } from 'react'; -import type { GraphLayoutSettings } from '../../../../../../../shared/settings/graphLayout'; import type { IPhysicsSettings } from '../../../../../../../shared/settings/physics'; -import type { FGLink, FGNode } from '../../../../model/build'; import type { PhysicsRuntimeRefs } from './refs'; -import { applyGraphSectionBoundsForce, applyPhysicsSettings } from '../../../physics'; +import { applyPhysicsSettings } from '../../../physics'; import { selectActivePhysicsGraph } from '../../../physicsLifecycle/readiness'; import { shouldApplyPhysicsUpdate } from '../../../physicsLifecycle/updates'; interface UsePhysicsRuntimeUpdatesOptions extends PhysicsRuntimeRefs { - graphDataRef?: MutableRefObject<{ nodes: FGNode[]; links: FGLink[] }>; - graphLayout?: GraphLayoutSettings; graphMode: '2d' | '3d'; physicsSettings: IPhysicsSettings; physicsInitialisedRef: MutableRefObject; @@ -19,8 +15,6 @@ interface UsePhysicsRuntimeUpdatesOptions extends PhysicsRuntimeRefs { export function usePhysicsRuntimeUpdates({ fg2dRef, fg3dRef, - graphDataRef, - graphLayout, graphMode, physicsInitialisedRef, physicsSettings, @@ -36,22 +30,10 @@ export function usePhysicsRuntimeUpdates({ })) return; previousPhysicsRef.current = { ...physicsSettings }; - if (graphLayout) { - applyGraphSectionBoundsForce(graph, { - graphLayout, - graphMode, - links: graphDataRef?.current.links, - settings: physicsSettings, - }); - applyPhysicsSettings(graph, physicsSettings, { graphLayout, graphMode }); - } else { - applyPhysicsSettings(graph, physicsSettings); - } + applyPhysicsSettings(graph, physicsSettings); }, [ fg2dRef, fg3dRef, - graphDataRef, - graphLayout, graphMode, physicsInitialisedRef, physicsSettings, 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..859f6a598 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-dev/core'; import type { ForceGraphMethods as FG2DMethods, LinkObject, @@ -11,7 +12,6 @@ import type { import * as THREE from 'three'; import SpriteText from 'three-spritetext'; import type { IGraphData } from '../../../../../shared/graph/contracts'; -import type { GraphLayoutSettings } from '../../../../../shared/settings/graphLayout'; import type { IPhysicsSettings } from '../../../../../shared/settings/physics'; import { ThemeKind } from '../../../../theme/useTheme'; import { DEFAULT_GRAPH_APPEARANCE, type GraphAppearance } from '../../appearance/model'; @@ -40,8 +40,8 @@ export interface UseGraphRenderingRuntimeOptions { getLinkParticles: (this: void, link: LinkObject) => number; getParticleColor: (this: void, link: LinkObject) => string; graphDataRef: MutableRefObject<{ nodes: FGNode[]; links: FGLink[] }>; - graphLayout?: GraphLayoutSettings; - graphLayoutKey: string; + graphViewContributions?: CoreGraphViewContributionSet; + graphDataLayoutKey: string; graphMode: '2d' | '3d'; highlightVersion: number; highlightedNeighborsRef: MutableRefObject>; @@ -57,6 +57,7 @@ export interface UseGraphRenderingRuntimeOptions { showLabels: boolean; spritesRef: MutableRefObject>; theme: ThemeKind; + timelineActive: boolean; favorites: Set; directionMode: 'arrows' | 'particles' | 'none'; } @@ -77,8 +78,8 @@ export function useGraphRenderingRuntime({ getLinkParticles, getParticleColor, graphDataRef, - graphLayout, - graphLayoutKey, + graphViewContributions, + graphDataLayoutKey, graphMode, highlightVersion, highlightedNeighborsRef, @@ -94,6 +95,7 @@ export function useGraphRenderingRuntime({ showLabels, spritesRef, theme, + timelineActive, favorites, directionMode, }: UseGraphRenderingRuntimeOptions): UseGraphRenderingRuntimeResult { @@ -137,15 +139,16 @@ export function useGraphRenderingRuntime({ physicsPaused, }); - usePhysicsRuntime({ - fg2dRef, - fg3dRef, - graphDataRef, - graphLayout, - graphMode, - layoutKey: graphLayoutKey, + usePhysicsRuntime({ + fg2dRef, + fg3dRef, + graphDataRef, + graphViewContributions, + graphMode, + layoutKey: graphDataLayoutKey, physicsPaused, physicsSettings, + timelineActive, }); return { 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..8f7639581 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-dev/core'; import type { ForceGraphMethods as FG2DMethods } from 'react-force-graph-2d'; import type { ForceGraphMethods as FG3DMethods } from 'react-force-graph-3d'; import * as THREE from 'three'; @@ -15,7 +16,6 @@ import type { IFileInfo } from '../../../../../shared/files/info'; import type { IGraphData } from '../../../../../shared/graph/contracts'; import type { EdgeDecorationPayload, NodeDecorationPayload } from '../../../../../shared/plugins/decorations'; import type { BidirectionalEdgeMode, DirectionMode, NodeSizeMode } from '../../../../../shared/settings/modes'; -import type { GraphLayoutMode, GraphLayoutSettings } from '../../../../../shared/settings/graphLayout'; import type { GraphContextSelection, } from '../../contextMenu/contracts'; @@ -47,8 +47,8 @@ export interface UseGraphStateOptions { directionMode: DirectionMode; edgeDecorations?: Record; favorites: Set; - graphLayout?: GraphLayoutSettings; - graphMode?: GraphLayoutMode; + graphViewContributions?: CoreGraphViewContributionSet; + graphMode?: '2d' | '3d'; nodeDecorations?: Record; nodeSizeMode: NodeSizeMode; showLabels: boolean; @@ -131,7 +131,7 @@ export function useGraphState({ directionMode, edgeDecorations, favorites, - graphLayout, + graphViewContributions, graphMode, nodeDecorations, nodeSizeMode, @@ -199,7 +199,7 @@ export function useGraphState({ nodeSizeMode: nodeSizeModeRef.current, theme: themeRef.current, favorites: favoritesRef.current, - graphLayout, + graphViewContributions, graphMode: resolvedGraphMode, bidirectionalMode, timelineActive, @@ -208,7 +208,7 @@ export function useGraphState({ graphDataRef.current = nextGraphData; return nextGraphData; - }, [appearance, bidirectionalMode, data, graphLayout, graphMode, timelineActive]); + }, [appearance, bidirectionalMode, data, graphMode, graphViewContributions, timelineActive]); useEffect(() => { if (!timelineActive) return; diff --git a/packages/extension/src/webview/components/graph/sectionFrames/drag.ts b/packages/extension/src/webview/components/graph/sectionFrames/drag.ts deleted file mode 100644 index ffbe1a95d..000000000 --- a/packages/extension/src/webview/components/graph/sectionFrames/drag.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { GraphLayoutSectionUpdate } from '../../../../shared/settings/graphLayout'; -import type { LegendIconImport } from '../../../../shared/protocol/webviewToExtension'; -import { - getSectionFrameDragUpdate, - type SectionFrameDragUpdate, - type SectionFrameDragState, - type SectionFrameGraph, -} from './model'; - -export type SectionFrameUpdateHandler = ( - this: void, - sectionId: string, - updates: GraphLayoutSectionUpdate, - iconImports?: LegendIconImport[], -) => void; - -export type SectionFrameDragEndHandler = ( - this: void, - sectionId: string, -) => void; - -function applyLiveNodePosition( - drag: SectionFrameDragState, - update: SectionFrameDragUpdate, -): void { - if (!drag.nodePosition) { - return; - } - - if (drag.type === 'move') { - const { x, y } = update.updates; - const width = drag.nodePosition.sectionWidth ?? drag.section.width; - const height = drag.nodePosition.sectionHeight ?? drag.section.height; - if (typeof x === 'number' && Number.isFinite(x)) { - const centerX = x + (width / 2); - drag.nodePosition.x = centerX; - drag.nodePosition.fx = centerX; - drag.nodePosition.vx = 0; - } - if (typeof y === 'number' && Number.isFinite(y)) { - const centerY = y + (height / 2); - drag.nodePosition.y = centerY; - drag.nodePosition.fy = centerY; - drag.nodePosition.vy = 0; - } - return; - } - - const { height, width } = update.updates; - const nextHeight = typeof height === 'number' && Number.isFinite(height) - ? height - : drag.nodePosition.sectionHeight ?? drag.section.height; - const nextWidth = typeof width === 'number' && Number.isFinite(width) - ? width - : drag.nodePosition.sectionWidth ?? drag.section.width; - if (typeof height === 'number' && Number.isFinite(height)) { - drag.nodePosition.sectionHeight = nextHeight; - } - if (typeof width === 'number' && Number.isFinite(width)) { - drag.nodePosition.sectionWidth = nextWidth; - } - const nextX = typeof update.updates.x === 'number' && Number.isFinite(update.updates.x) - ? update.updates.x - : drag.section.x; - const nextY = typeof update.updates.y === 'number' && Number.isFinite(update.updates.y) - ? update.updates.y - : drag.section.y; - const centerX = nextX + (nextWidth / 2); - const centerY = nextY + (nextHeight / 2); - drag.nodePosition.x = centerX; - drag.nodePosition.y = centerY; - drag.nodePosition.fx = centerX; - drag.nodePosition.fy = centerY; - drag.nodePosition.vx = 0; - drag.nodePosition.vy = 0; -} - -function releaseLiveNodePosition(drag: SectionFrameDragState): void { - if (!drag.nodePosition) { - return; - } - - drag.nodePosition.isDragging = false; - if (drag.nodePosition.isPinned) { - return; - } - - drag.nodePosition.fx = undefined; - drag.nodePosition.fy = undefined; -} - -function markLiveNodeDragging(drag: SectionFrameDragState): void { - if (drag.nodePosition) { - drag.nodePosition.isDragging = true; - } -} - -function wakeSectionFramePhysics(graph: SectionFrameGraph | undefined): void { - graph?.resumeAnimation?.(); - graph?.d3ReheatSimulation?.(); -} - -function applyLiveDragUpdate( - graph: SectionFrameGraph | undefined, - drag: SectionFrameDragState, - event: Pick, -): SectionFrameDragUpdate { - const update = getSectionFrameDragUpdate(graph, drag, event); - applyLiveNodePosition(drag, update); - wakeSectionFramePhysics(graph); - return update; -} - -export function beginSectionFrameWindowDrag( - graph: SectionFrameGraph | undefined, - drag: SectionFrameDragState, - onUpdateSection: SectionFrameUpdateHandler, - onDragEnd?: SectionFrameDragEndHandler, -): void { - markLiveNodeDragging(drag); - wakeSectionFramePhysics(graph); - - function handleMouseMove(event: MouseEvent): void { - applyLiveDragUpdate(graph, drag, event); - } - - function handleMouseUp(event: MouseEvent): void { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - const update = applyLiveDragUpdate(graph, drag, event); - releaseLiveNodePosition(drag); - onUpdateSection(update.sectionId, update.updates); - if (drag.type === 'move') { - onDragEnd?.(update.sectionId); - } - } - - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); -} - -export function isSectionFrameControl(target: EventTarget | null): boolean { - return target instanceof Element && !!target.closest('[data-graph-section-control="true"]'); -} diff --git a/packages/extension/src/webview/components/graph/sectionFrames/icons.ts b/packages/extension/src/webview/components/graph/sectionFrames/icons.ts deleted file mode 100644 index b04dba018..000000000 --- a/packages/extension/src/webview/components/graph/sectionFrames/icons.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - mdiCodeBraces, - mdiFolder, - mdiImage, - mdiPackageVariant, - mdiPuzzleOutline, - mdiShapeOutline, - mdiViewGridOutline, -} from '@mdi/js'; -import type { LegendIconImport } from '../../../../shared/protocol/webviewToExtension'; - -export interface GraphSectionMaterialIconOption { - id: string; - label: string; - path: string; -} - -export const GRAPH_SECTION_MATERIAL_ICONS: GraphSectionMaterialIconOption[] = [ - { id: 'mdi:folder', label: 'Folder', path: mdiFolder }, - { id: 'mdi:code-braces', label: 'Code', path: mdiCodeBraces }, - { id: 'mdi:package-variant', label: 'Package', path: mdiPackageVariant }, - { id: 'mdi:puzzle-outline', label: 'Plugin', path: mdiPuzzleOutline }, - { id: 'mdi:shape-outline', label: 'Shapes', path: mdiShapeOutline }, - { id: 'mdi:view-grid-outline', label: 'Grid', path: mdiViewGridOutline }, - { id: 'mdi:image', label: 'Image', path: mdiImage }, -]; - -export function getGraphSectionMaterialIconPath(icon: string | undefined): string | undefined { - return GRAPH_SECTION_MATERIAL_ICONS.find(option => option.id === icon)?.path; -} - -export function isGraphSectionUploadedIcon(icon: string | undefined): icon is string { - return !!icon && ( - /^data:image\/(?:png|svg\+xml);base64,/.test(icon) - || /^\.codegraphy\/icons\/[^/]+?\.(?:svg|png)$/i.test(icon) - ); -} - -function fileToBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ''; - - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - - return btoa(binary); -} - -function readFileAsArrayBuffer(file: File): Promise { - if (typeof file.arrayBuffer === 'function') { - return file.arrayBuffer(); - } - - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener('load', () => { - if (reader.result instanceof ArrayBuffer) { - resolve(reader.result); - return; - } - - reject(new Error('Unable to read graph section icon file.')); - }); - reader.addEventListener('error', () => reject(reader.error ?? new Error('Unable to read graph section icon file.'))); - reader.readAsArrayBuffer(file); - }); -} - -function getIconMimeType(file: File): string { - if (file.type === 'image/png' || file.type === 'image/svg+xml') { - return file.type; - } - - return file.name.toLowerCase().endsWith('.png') ? 'image/png' : 'image/svg+xml'; -} - -function sanitizeSegment(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') || 'icon'; -} - -function getIconExtension(file: File): string { - const extension = file.name.split('.').pop()?.toLowerCase(); - return extension === 'png' ? 'png' : 'svg'; -} - -export async function readGraphSectionIconUpload( - sectionId: string, - file: File, -): Promise<{ imageUrl: string; importPayload: LegendIconImport }> { - const extension = getIconExtension(file); - const baseName = file.name.replace(/\.[^.]+$/, ''); - const imagePath = `.codegraphy/icons/${sanitizeSegment(sectionId)}-${sanitizeSegment(baseName)}.${extension}`; - const contentsBase64 = fileToBase64(await readFileAsArrayBuffer(file)); - - return { - imageUrl: `data:${getIconMimeType(file)};base64,${contentsBase64}`, - importPayload: { - imagePath, - contentsBase64, - }, - }; -} diff --git a/packages/extension/src/webview/components/graph/sectionFrames/model.ts b/packages/extension/src/webview/components/graph/sectionFrames/model.ts deleted file mode 100644 index 1cf996cf6..000000000 --- a/packages/extension/src/webview/components/graph/sectionFrames/model.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { - isGraphLayoutSectionVisible, - sortGraphLayoutSectionsForRendering, - type GraphLayoutOwnership, - type GraphLayoutSection, - type GraphLayoutSectionUpdate, -} from '../../../../shared/settings/graphLayout'; - -export interface SectionFrameGraph { - d3ReheatSimulation?(): void; - graph2ScreenCoords?(x: number, y: number): { x: number; y: number }; - resumeAnimation?(): void; - screen2GraphCoords?(x: number, y: number): { x: number; y: number }; -} - -export interface SectionFrameRect { - height: number; - left: number; - scale: number; - top: number; - width: number; -} - -export interface SectionFrameNodePosition { - fx?: number; - fy?: number; - id: string; - isDragging?: boolean; - isPinned?: boolean; - sectionHeight?: number; - sectionWidth?: number; - vx?: number; - vy?: number; - x?: number; - y?: number; -} - -export type SectionFrameResizeCorner = 'northwest' | 'northeast' | 'southwest' | 'southeast'; -export type SectionFrameDragType = 'move' | `resize:${SectionFrameResizeCorner}`; - -export interface SectionFrameDragState { - clientX: number; - clientY: number; - nodePosition?: SectionFrameNodePosition; - section: GraphLayoutSection; - type: SectionFrameDragType; -} - -export interface SectionFrameDragUpdate { - sectionId: string; - updates: GraphLayoutSectionUpdate; -} - -export const SECTION_FRAME_HEADER_HEIGHT = 28; - -const MIN_SECTION_SIZE = 80; - -function graphToScreen( - graph: SectionFrameGraph | undefined, - x: number, - y: number, -): { x: number; y: number } { - return graph?.graph2ScreenCoords?.(x, y) ?? { x, y }; -} - -function screenToGraph( - graph: SectionFrameGraph | undefined, - x: number, - y: number, -): { x: number; y: number } { - return graph?.screen2GraphCoords?.(x, y) ?? { x, y }; -} - -function readFiniteNumber(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined; -} - -export function getSectionFrameDisplaySection( - section: GraphLayoutSection, - nodePosition: SectionFrameNodePosition | undefined, -): GraphLayoutSection { - if (!nodePosition) { - return section; - } - - const height = readFiniteNumber(nodePosition.sectionHeight) ?? section.height; - const width = readFiniteNumber(nodePosition.sectionWidth) ?? section.width; - const centerX = readFiniteNumber(nodePosition.x); - const centerY = readFiniteNumber(nodePosition.y); - return { - ...section, - height, - width, - x: centerX === undefined ? section.x : centerX - (width / 2), - y: centerY === undefined ? section.y : centerY - (height / 2), - }; -} - -export function getSectionFrameRect( - graph: SectionFrameGraph | undefined, - section: GraphLayoutSection, -): SectionFrameRect { - const topLeft = graphToScreen(graph, section.x, section.y); - const bottomRight = graphToScreen(graph, section.x + section.width, section.y + section.height); - const height = Math.abs(bottomRight.y - topLeft.y); - const width = Math.abs(bottomRight.x - topLeft.x); - return { - height, - left: Math.min(topLeft.x, bottomRight.x), - scale: height / Math.max(1, Math.abs(section.height)), - top: Math.min(topLeft.y, bottomRight.y), - width, - }; -} - -function getDragDelta( - graph: SectionFrameGraph | undefined, - drag: SectionFrameDragState, - event: Pick, -): { x: number; y: number } { - const start = screenToGraph(graph, drag.clientX, drag.clientY); - const current = screenToGraph(graph, event.clientX, event.clientY); - return { - x: current.x - start.x, - y: current.y - start.y, - }; -} - -export function getSectionFrameDragUpdate( - graph: SectionFrameGraph | undefined, - drag: SectionFrameDragState, - event: Pick, -): SectionFrameDragUpdate { - const delta = getDragDelta(graph, drag, event); - - if (drag.type === 'move') { - return { - sectionId: drag.section.id, - updates: { - x: drag.section.x + delta.x, - y: drag.section.y + delta.y, - }, - }; - } - - if (drag.type === 'resize:southeast') { - return { - sectionId: drag.section.id, - updates: { - height: Math.max(MIN_SECTION_SIZE, drag.section.height + delta.y), - width: Math.max(MIN_SECTION_SIZE, drag.section.width + delta.x), - }, - }; - } - - if (drag.type === 'resize:southwest') { - const nextWidth = Math.max(MIN_SECTION_SIZE, drag.section.width - delta.x); - return { - sectionId: drag.section.id, - updates: { - height: Math.max(MIN_SECTION_SIZE, drag.section.height + delta.y), - width: nextWidth, - x: drag.section.x + drag.section.width - nextWidth, - }, - }; - } - - if (drag.type === 'resize:northeast') { - const nextHeight = Math.max(MIN_SECTION_SIZE, drag.section.height - delta.y); - return { - sectionId: drag.section.id, - updates: { - height: nextHeight, - width: Math.max(MIN_SECTION_SIZE, drag.section.width + delta.x), - y: drag.section.y + drag.section.height - nextHeight, - }, - }; - } - - if (drag.type === 'resize:northwest') { - const nextHeight = Math.max(MIN_SECTION_SIZE, drag.section.height - delta.y); - const nextWidth = Math.max(MIN_SECTION_SIZE, drag.section.width - delta.x); - return { - sectionId: drag.section.id, - updates: { - height: nextHeight, - width: nextWidth, - x: drag.section.x + drag.section.width - nextWidth, - y: drag.section.y + drag.section.height - nextHeight, - }, - }; - } - - throw new Error('Unknown Section Frame drag type.'); -} - -function createSectionMap( - sections: readonly GraphLayoutSection[], -): Record { - return Object.fromEntries(sections.map(section => [section.id, section])); -} - -export function getVisibleSectionFrames( - sections: readonly GraphLayoutSection[], - ownership: Readonly>, -): GraphLayoutSection[] { - const sectionMap = createSectionMap(sections); - return sortGraphLayoutSectionsForRendering(sections, ownership) - .filter(section => isGraphLayoutSectionVisible(sectionMap, ownership, section.id)); -} diff --git a/packages/extension/src/webview/components/graph/sectionFrames/view.tsx b/packages/extension/src/webview/components/graph/sectionFrames/view.tsx deleted file mode 100644 index a215f9b70..000000000 --- a/packages/extension/src/webview/components/graph/sectionFrames/view.tsx +++ /dev/null @@ -1,424 +0,0 @@ -import { - useEffect, - useRef, - useState, - type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent as ReactMouseEvent, - type ReactElement, -} from 'react'; -import { - mdiChevronUp, - mdiImagePlus, - mdiPin, -} from '@mdi/js'; -import { MdiIcon } from '../../icons/MdiIcon'; -import type { GraphLayoutOwnership, GraphLayoutSection } from '../../../../shared/settings/graphLayout'; -import { - beginSectionFrameWindowDrag, - isSectionFrameControl, - type SectionFrameDragEndHandler, - type SectionFrameUpdateHandler, -} from './drag'; -import { - getSectionFrameDisplaySection, - getSectionFrameRect, - getVisibleSectionFrames, - type SectionFrameResizeCorner, - type SectionFrameDragType, - type SectionFrameGraph, - type SectionFrameNodePosition, - type SectionFrameRect, -} from './model'; -import { - getGraphSectionMaterialIconPath, - isGraphSectionUploadedIcon, - readGraphSectionIconUpload, -} from './icons'; - -interface SectionFramesProps { - graph?: SectionFrameGraph; - ownership?: Readonly>; - pinnedSectionIds?: ReadonlySet; - sectionNodePositions?: ReadonlyMap; - sections: readonly GraphLayoutSection[]; - onOpenSectionContextMenu?: (this: void, sectionId: string, event: ReactMouseEvent) => void; - onSectionDragEnd?: SectionFrameDragEndHandler; - onUpdateSection: SectionFrameUpdateHandler; -} - -const TOPBAR_FADE_OUT_SCALE = 0.45; -const TOPBAR_FULL_SCALE = 1; - -function getTopbarOpacity(rect: SectionFrameRect): number { - if (rect.scale <= TOPBAR_FADE_OUT_SCALE) { - return 0; - } - - if (rect.scale >= TOPBAR_FULL_SCALE) { - return 1; - } - - return (rect.scale - TOPBAR_FADE_OUT_SCALE) / (TOPBAR_FULL_SCALE - TOPBAR_FADE_OUT_SCALE); -} - -function isTopbarVisible(opacity: number): boolean { - return opacity > 0.01; -} - -interface SectionFrameLabelInputProps { - label: string; - sectionId: string; - showTopbar: boolean; - onUpdateSection: SectionFrameUpdateHandler; -} - -function SectionFrameLabelInput({ - label, - sectionId, - showTopbar, - onUpdateSection, -}: SectionFrameLabelInputProps): ReactElement { - const [draft, setDraft] = useState(label); - const shouldCommitOnBlurRef = useRef(true); - - useEffect(() => { - setDraft(label); - }, [label, sectionId]); - - function commitDraft(): void { - if (!shouldCommitOnBlurRef.current) { - shouldCommitOnBlurRef.current = true; - return; - } - - if (draft !== label) { - onUpdateSection(sectionId, { label: draft }); - } - } - - function handleKeyDown(event: ReactKeyboardEvent): void { - if (event.key === 'Enter') { - event.currentTarget.blur(); - return; - } - - if (event.key === 'Escape') { - shouldCommitOnBlurRef.current = false; - setDraft(label); - event.currentTarget.blur(); - } - } - - return ( - setDraft(event.target.value)} - onFocus={() => { - shouldCommitOnBlurRef.current = true; - }} - onKeyDown={handleKeyDown} - tabIndex={showTopbar ? 0 : -1} - value={draft} - /> - ); -} - -interface SectionFrameIconInputProps { - icon: string | undefined; - iconUrl: string | undefined; - sectionId: string; - showTopbar: boolean; - onUpdateSection: SectionFrameUpdateHandler; -} - -function SectionFrameIconInput({ - icon, - iconUrl, - sectionId, - showTopbar, - onUpdateSection, -}: SectionFrameIconInputProps): ReactElement { - const fileInputRef = useRef(null); - const materialIconPath = getGraphSectionMaterialIconPath(icon); - const uploadedIcon = isGraphSectionUploadedIcon(icon) ? iconUrl ?? icon : undefined; - - return ( -
- - { - const file = event.currentTarget.files?.[0]; - if (!file) { - return; - } - - void readGraphSectionIconUpload(sectionId, file).then(({ importPayload }) => { - onUpdateSection(sectionId, { icon: importPayload.imagePath }, [importPayload]); - }); - }} - tabIndex={-1} - /> -
- ); -} - -const RESIZE_HANDLES: Array<{ - corner: SectionFrameResizeCorner; - className: string; - cursor: string; -}> = [ - { corner: 'northwest', className: 'left-0 top-0 border-l-2 border-t-2', cursor: 'cursor-nw-resize' }, - { corner: 'northeast', className: 'right-0 top-0 border-r-2 border-t-2', cursor: 'cursor-ne-resize' }, - { corner: 'southwest', className: 'bottom-0 left-0 border-b-2 border-l-2', cursor: 'cursor-sw-resize' }, - { corner: 'southeast', className: 'bottom-0 right-0 border-b-2 border-r-2', cursor: 'cursor-se-resize' }, -]; - -function applySectionFrameElementRect( - element: HTMLDivElement, - graph: SectionFrameGraph | undefined, - section: GraphLayoutSection, -): void { - const rect = getSectionFrameRect(graph, section); - element.style.height = `${rect.height}px`; - element.style.left = `${rect.left}px`; - element.style.top = `${rect.top}px`; - element.style.width = `${rect.width}px`; - - const header = element.querySelector('[data-graph-section-header="true"]'); - if (!header) { - return; - } - - const opacity = getTopbarOpacity(rect); - const visible = isTopbarVisible(opacity); - header.style.opacity = `${opacity}`; - header.dataset.sectionFrameHeader = visible ? 'visible' : 'hidden'; - header.setAttribute('aria-hidden', visible ? 'false' : 'true'); - header.classList.toggle('pointer-events-auto', visible); - header.classList.toggle('pointer-events-none', !visible); -} - -export function SectionFrames({ - graph, - ownership = {}, - pinnedSectionIds = new Set(), - sectionNodePositions = new Map(), - sections, - onOpenSectionContextMenu, - onSectionDragEnd, - onUpdateSection, -}: SectionFramesProps): ReactElement | null { - const frameElementsRef = useRef(new Map()); - const visibleSections = getVisibleSectionFrames( - sections, - ownership, - ); - - useEffect(() => { - if (visibleSections.length === 0 || sectionNodePositions.size === 0) { - return undefined; - } - - let frame = 0; - const updateFrameRects = (): void => { - for (const section of visibleSections) { - const element = frameElementsRef.current.get(section.id); - if (!element) { - continue; - } - - applySectionFrameElementRect( - element, - graph, - getSectionFrameDisplaySection(section, sectionNodePositions.get(section.id)), - ); - } - frame = requestAnimationFrame(updateFrameRects); - }; - - updateFrameRects(); - - return () => cancelAnimationFrame(frame); - }, [graph, sectionNodePositions, visibleSections]); - - if (visibleSections.length === 0) { - return null; - } - - function beginDrag( - event: ReactMouseEvent, - section: GraphLayoutSection, - type: SectionFrameDragType, - ): void { - if (event.button !== 0 || (type === 'move' && isSectionFrameControl(event.target))) { - return; - } - - event.preventDefault(); - beginSectionFrameWindowDrag(graph, { - clientX: event.clientX, - clientY: event.clientY, - nodePosition: sectionNodePositions.get(section.id), - section, - type, - }, onUpdateSection, onSectionDragEnd); - } - - function registerFrameElement(sectionId: string, element: HTMLDivElement | null): void { - if (element) { - frameElementsRef.current.set(sectionId, element); - return; - } - - frameElementsRef.current.delete(sectionId); - } - - function getDisplaySection(section: GraphLayoutSection): GraphLayoutSection { - return getSectionFrameDisplaySection(section, sectionNodePositions.get(section.id)); - } - - function handleHeaderContextMenu( - event: ReactMouseEvent, - sectionId: string, - ): void { - event.preventDefault(); - event.stopPropagation(); - onOpenSectionContextMenu?.(sectionId, event); - } - - return ( -
- {visibleSections.map(section => { - const displaySection = getDisplaySection(section); - const rect = getSectionFrameRect(graph, displaySection); - const topbarOpacity = getTopbarOpacity(rect); - const showTopbar = isTopbarVisible(topbarOpacity); - return ( -
registerFrameElement(section.id, element)} - data-graph-marquee-ignore="true" - data-testid={`graph-section-frame-${section.id}`} - className="pointer-events-none absolute overflow-hidden rounded-md border bg-[rgba(59,130,246,0.08)] shadow-sm" - onMouseDown={(event) => beginDrag(event, getDisplaySection(section), 'move')} - style={{ - borderColor: section.color, - height: rect.height, - left: rect.left, - top: rect.top, - width: rect.width, - }} - > -
handleHeaderContextMenu(event, section.id)} - style={{ - backgroundColor: `${section.color}22`, - borderColor: section.color, - opacity: topbarOpacity, - }} - > - - - - onUpdateSection(section.id, { color: event.target.value })} - tabIndex={showTopbar ? 0 : -1} - type="color" - value={section.color} - /> - {pinnedSectionIds.has(section.id) ? ( - - - - ) : null} -
- {RESIZE_HANDLES.map(handle => ( -
beginDrag(event, getDisplaySection(section), `resize:${handle.corner}`)} - style={{ borderColor: section.color }} - /> - ))} -
- ); - })} -
- ); -} diff --git a/packages/extension/src/webview/components/graph/view/component.tsx b/packages/extension/src/webview/components/graph/view/component.tsx index cae0f94ab..11a96ce09 100644 --- a/packages/extension/src/webview/components/graph/view/component.tsx +++ b/packages/extension/src/webview/components/graph/view/component.tsx @@ -4,7 +4,8 @@ * @module webview/components/Graph */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import type { CoreGraphViewContributionSet } from '@codegraphy-dev/core'; import type { IGraphData } from '../../../../shared/graph/contracts'; import type { EdgeDecorationPayload, NodeDecorationPayload } from '../../../../shared/plugins/decorations'; import { @@ -14,7 +15,7 @@ import { getGraphNavigator, getGraphWindow } from '../environment/browser'; import { buildGraphCallbackOptions } from './callbackOptions'; import { useGraphDebugApi } from '../debug/api'; import { buildGraphDebugOptions } from '../debug/options'; -import { buildGraphLayoutKey } from './layoutKey'; +import { buildGraphDataLayoutKey } from './layoutKey'; import { detectMacPlatform } from '../environment/platform'; import { useGraphViewStoreState } from './store'; import { useGraphCallbacks } from '../rendering/useGraphCallbacks'; @@ -31,22 +32,71 @@ 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; } +function hasGraphViewContributions( + contributions: CoreGraphViewContributionSet | undefined, +): contributions is CoreGraphViewContributionSet { + return !!contributions + && ( + contributions.runtimeNodes.length > 0 + || contributions.runtimeEdges.length > 0 + || contributions.projections.length > 0 + || contributions.forces.length > 0 + || contributions.nodeDragEnd.length > 0 + || contributions.contextMenu.length > 0 + || contributions.ui.length > 0 + ); +} + +function useResolvedGraphViewContributions( + graphViewContributions: CoreGraphViewContributionSet | undefined, + pluginHost: WebviewPluginHost | undefined, +): CoreGraphViewContributionSet | undefined { + const [contributionVersion, setContributionVersion] = useState(0); + + useEffect(() => { + if (!pluginHost || graphViewContributions) { + return undefined; + } + + const subscription = pluginHost.subscribeGraphViewContributions(() => { + setContributionVersion(version => version + 1); + }); + return () => subscription.dispose(); + }, [graphViewContributions, pluginHost]); + + void contributionVersion; + if (graphViewContributions) { + return graphViewContributions; + } + + const pluginContributions = pluginHost?.getGraphViewContributions(); + return hasGraphViewContributions(pluginContributions) + ? pluginContributions + : undefined; +} + export default function Graph({ data, theme = 'dark', nodeDecorations, edgeDecorations, + graphViewContributions, onAddFilterRequested = () => {}, onAddLegendRequested = () => {}, pluginHost, }: GraphProps): React.ReactElement { const viewState = useGraphViewStoreState(); const appearance = useGraphAppearance(theme); + const resolvedGraphViewContributions = useResolvedGraphViewContributions( + graphViewContributions, + pluginHost, + ); const graphState = useGraphState({ appearance, @@ -56,7 +106,7 @@ export default function Graph({ directionMode: viewState.directionMode, edgeDecorations, favorites: viewState.favorites, - graphLayout: viewState.graphLayout, + graphViewContributions: resolvedGraphViewContributions, graphMode: viewState.graphMode, nodeDecorations, nodeSizeMode: viewState.nodeSizeMode, @@ -64,7 +114,7 @@ export default function Graph({ theme, timelineActive: viewState.timelineActive, }); - const graphLayoutKey = buildGraphLayoutKey(graphState.graphData, viewState.nodeSizeMode); + const graphDataLayoutKey = buildGraphDataLayoutKey(graphState.graphData, viewState.nodeSizeMode); const isMacPlatform = detectMacPlatform(getGraphNavigator()); const interactions = useGraphInteractionRuntime({ @@ -74,7 +124,7 @@ export default function Graph({ graphContextSelection: graphState.contextSelection, graphCursorRef: graphState.graphCursorRef, graphDataRef: graphState.graphDataRef, - graphLayout: viewState.graphLayout, + graphViewContributions: resolvedGraphViewContributions, graphMode: viewState.graphMode, highlightedNeighborsRef: graphState.highlightedNeighborsRef, highlightedNodeRef: graphState.highlightedNodeRef, @@ -126,8 +176,9 @@ export default function Graph({ state.depthMode), directionMode: useGraphStore(state => state.directionMode), favorites: useGraphStore(state => state.favorites), - graphLayout: useGraphStore(state => state.graphLayout), + graphViewContributionStatuses: useGraphStore(state => state.graphViewContributionStatuses), graphMode: useGraphStore(state => state.graphMode), nodeSizeMode: useGraphStore(state => state.nodeSizeMode), particleSize: useGraphStore(state => state.particleSize), diff --git a/packages/extension/src/webview/components/graph/viewport/model.ts b/packages/extension/src/webview/components/graph/viewport/model.ts index 0e73da08f..c960359ab 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-dev/core'; import type { GraphViewStoreState } from '../view/store'; import type { GraphContextMenuEntry, @@ -15,7 +16,6 @@ import { getGraphSurfaceColors } from '../rendering/surface/colors'; import type { ThemeKind } from '../../../theme/useTheme'; import type { GraphAppearance } from '../appearance/model'; import { postMessage } from '../../../vscodeApi'; -import { getGraphLayoutPinCoordinate } from '../../../../shared/settings/graphLayout'; export interface GraphViewportModel { canvasBackgroundColor: string; @@ -28,6 +28,7 @@ export interface GraphViewportModel { export interface GraphViewportModelOptions { graphState: Pick; + graphViewContributions?: CoreGraphViewContributionSet; interactions: UseGraphInteractionRuntimeResult; handleEngineStop(this: void): void; appearance?: GraphAppearance; @@ -38,7 +39,6 @@ export interface GraphViewportModelOptions { | 'currentCommitSha' | 'dagMode' | 'favorites' - | 'graphLayout' | 'graphMode' | 'physicsSettings' | 'pluginContextMenuItems' @@ -48,22 +48,9 @@ export interface GraphViewportModelOptions { >; } -function getActivePinnedNodeIds( - viewState: Pick, -): Set { - const pinnedNodeIds = new Set(); - - for (const [nodeId, pinnedNode] of Object.entries(viewState.graphLayout.pinnedNodes)) { - if (getGraphLayoutPinCoordinate(pinnedNode, viewState.graphMode)) { - pinnedNodeIds.add(nodeId); - } - } - - return pinnedNodeIds; -} - export function useGraphViewportModel({ graphState, + graphViewContributions, interactions, handleEngineStop, appearance, @@ -93,12 +80,14 @@ export function useGraphViewportModel({ const menuEntries = buildGraphContextMenuEntries({ selection: graphState.contextSelection, + graphMode: viewState.graphMode, timelineActive: viewState.timelineActive, mutationAvailability: getGraphContextMutationAvailability(viewState), 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 188ce51e1..4e09c027a 100644 --- a/packages/extension/src/webview/components/graph/viewport/shell.tsx +++ b/packages/extension/src/webview/components/graph/viewport/shell.tsx @@ -1,11 +1,7 @@ -import { useRef, type ReactElement } from 'react'; +import { useEffect, useRef, type ReactElement } from 'react'; +import type { GraphViewViewportNode } from '../../../pluginHost/api/contracts/webview'; +import type { CoreGraphViewContributionSet } from '@codegraphy-dev/core'; import type { ThemeKind } from '../../../theme/useTheme'; -import type { LegendIconImport } from '../../../../shared/protocol/webviewToExtension'; -import { - getGraphLayoutPinCoordinate, - type GraphLayoutSection, - type GraphLayoutSectionUpdate, -} from '../../../../shared/settings/graphLayout'; import type { GraphAppearance } from '../appearance/model'; import type { WebviewPluginHost } from '../../../pluginHost/manager'; import type { GraphViewStoreState } from '../view/store'; @@ -13,21 +9,26 @@ import type { UseGraphCallbacksResult } from '../rendering/useGraphCallbacks'; import type { UseGraphInteractionRuntimeResult } from '../runtime/use/interaction'; import type { UseGraphRenderingRuntimeResult } from '../runtime/use/rendering'; import type { UseGraphStateResult } from '../runtime/use/state'; -import type { FGNode } from '../model/build'; -import type { SectionFrameNodePosition } from '../sectionFrames/model'; import { useGraphRenderingRuntime } from '../runtime/use/rendering'; import { useGraphEventEffects } from '../runtime/use/events/effects'; -import { postNodeDragEndMessages } from '../runtime/use/interaction/nodeDrag'; import { Viewport } from './view'; import { useGraphViewportModel } from './model'; -import { postMessage } from '../../../vscodeApi'; import { graphStore } from '../../../store/state'; +interface GraphViewport2dControls { + d3ReheatSimulation?(): void; + graph2ScreenCoords?(x: number, y: number): { x: number; y: number }; + resumeAnimation?(): void; + screen2GraphCoords?(x: number, y: number): { x: number; y: number }; + zoom?(): number; +} + export interface GraphViewportShellProps { appearance?: GraphAppearance; callbacks: UseGraphCallbacksResult; - graphLayoutKey: string; + graphDataLayoutKey: string; graphState: UseGraphStateResult; + graphViewContributions?: CoreGraphViewContributionSet; handleEngineStop(this: void): void; interactions: UseGraphInteractionRuntimeResult; pluginHost?: WebviewPluginHost; @@ -38,12 +39,13 @@ export interface GraphViewportShellProps { function buildRenderingRuntimeOptions({ appearance, callbacks, - graphLayoutKey, + graphDataLayoutKey, graphState, + graphViewContributions, pluginHost, theme, viewState, -}: Pick) { +}: Pick) { return { appearance, containerRef: graphState.containerRef, @@ -55,8 +57,8 @@ function buildRenderingRuntimeOptions({ getLinkParticles: callbacks.getLinkParticles, getParticleColor: callbacks.getParticleColor, graphDataRef: graphState.graphDataRef, - graphLayout: viewState.graphLayout, - graphLayoutKey, + graphViewContributions, + graphDataLayoutKey, graphMode: viewState.graphMode, highlightVersion: graphState.highlightVersion, highlightedNeighborsRef: graphState.highlightedNeighborsRef, @@ -72,6 +74,7 @@ function buildRenderingRuntimeOptions({ showLabels: viewState.showLabels, spritesRef: graphState.spritesRef, theme, + timelineActive: viewState.timelineActive, favorites: viewState.favorites, directionMode: viewState.directionMode, }; @@ -80,6 +83,7 @@ function buildRenderingRuntimeOptions({ function useGraphViewportModelOptions({ appearance, graphState, + graphViewContributions, interactions, handleEngineStop, viewportRuntime, @@ -87,6 +91,7 @@ function useGraphViewportModelOptions({ }: { appearance?: GraphAppearance; graphState: UseGraphStateResult; + graphViewContributions?: CoreGraphViewContributionSet; interactions: UseGraphInteractionRuntimeResult; handleEngineStop(this: void): void; viewportRuntime: Pick; @@ -97,6 +102,7 @@ function useGraphViewportModelOptions({ contextSelection: graphState.contextSelection, graphData: graphState.graphData, }, + graphViewContributions, handleEngineStop, appearance, interactions, @@ -105,48 +111,64 @@ function useGraphViewportModelOptions({ }); } -function getPinnedSectionIds( - sections: readonly GraphLayoutSection[], - pinnedNodes: GraphViewStoreState['graphLayout']['pinnedNodes'], -): Set { - const pinnedSectionIds = new Set(); - for (const section of sections) { - if (getGraphLayoutPinCoordinate(pinnedNodes[section.id], '2d')) { - pinnedSectionIds.add(section.id); - } - } - - return pinnedSectionIds; +function shouldPublishGraphViewportScale( + previous: number | null, + next: number, +): boolean { + return previous === null || Math.abs(previous - next) >= 0.01; } -function getSectionFrameNodePositions( - nodes: readonly FGNode[], -): Map { - const positions = new Map(); +function readViewportBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} - for (const node of nodes) { - if (!node.isGraphSection || node.isCollapsedGraphSection) { - continue; - } +function readViewportNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} - positions.set(node.id, node); - } +function toGraphViewViewportNodes(nodes: UseGraphStateResult['graphData']['nodes']): GraphViewViewportNode[] { + return nodes.map(node => { + const viewportNode = node as GraphViewViewportNode; - return positions; + return { + ...viewportNode, + fx: readViewportNumber(node.fx), + fy: readViewportNumber(node.fy), + fz: readViewportNumber(node.fz), + id: node.id, + isDragging: readViewportBoolean(node.isDragging), + isPinned: readViewportBoolean(node.isPinned), + size: readViewportNumber(node.size), + vx: readViewportNumber(node.vx), + vy: readViewportNumber(node.vy), + vz: readViewportNumber(node.vz), + x: readViewportNumber(node.x), + y: readViewportNumber(node.y), + z: readViewportNumber(node.z), + }; + }); } -function shouldPublishGraphViewportScale( - previous: number | null, - next: number, +function updateGraphViewViewportNode( + nodes: UseGraphStateResult['graphData']['nodes'], + nodeId: string, + updates: Record, ): boolean { - return previous === null || Math.abs(previous - next) >= 0.01; + const node = nodes.find(candidate => candidate.id === nodeId); + if (!node) { + return false; + } + + Object.assign(node, updates); + return true; } export function GraphViewportShell({ appearance, callbacks, - graphLayoutKey, + graphDataLayoutKey, graphState, + graphViewContributions, handleEngineStop, interactions, pluginHost, @@ -157,8 +179,9 @@ export function GraphViewportShell({ const viewportRuntime = useGraphRenderingRuntime(buildRenderingRuntimeOptions({ appearance, callbacks, - graphLayoutKey, + graphDataLayoutKey, graphState, + graphViewContributions, pluginHost, theme, viewState, @@ -183,45 +206,13 @@ export function GraphViewportShell({ const viewportModel = useGraphViewportModelOptions({ appearance, graphState, + graphViewContributions, handleEngineStop, interactions, viewportRuntime, viewState, }); - function handleUpdateSection( - sectionId: string, - updates: GraphLayoutSectionUpdate, - iconImports?: LegendIconImport[], - ): void { - postMessage({ - type: 'UPDATE_GRAPH_LAYOUT_SECTION', - payload: iconImports?.length - ? { sectionId, updates, iconImports } - : { sectionId, updates }, - }); - } - - function handleSectionDragEnd(sectionId: string): void { - const node = graphState.graphDataRef.current.nodes.find(candidate => candidate.id === sectionId); - if (!node) { - return; - } - - postNodeDragEndMessages( - node, - viewState.graphLayout, - viewState.graphMode, - viewState.timelineActive, - graphState.graphDataRef.current.nodes, - ); - } - - const sectionFrames = viewState.graphMode === '2d' && !viewState.timelineActive - ? Object.values(viewState.graphLayout.sections) - : []; - const pinnedSectionIds = getPinnedSectionIds(sectionFrames, viewState.graphLayout.pinnedNodes); - const sectionFrameNodePositions = getSectionFrameNodePositions(graphState.graphData.nodes); const publishGraphViewportScale = (globalScale: number): void => { if (viewState.graphMode !== '2d' || !Number.isFinite(globalScale) || globalScale <= 0) { return; @@ -235,6 +226,32 @@ export function GraphViewportShell({ graphStore.getState().setGraphViewportScale(globalScale); }; + const publishGraphViewViewportState = (globalScale: number): void => { + if (!pluginHost) { + return; + } + + const graph = graphState.fg2dRef.current as GraphViewport2dControls | undefined; + pluginHost.setGraphViewViewportState({ + graphMode: viewState.graphMode, + graphToScreen: (x, y) => graph?.graph2ScreenCoords?.(x, y) ?? { x, y }, + nodes: toGraphViewViewportNodes(graphState.graphDataRef.current.nodes), + reheatSimulation: () => graph?.d3ReheatSimulation?.(), + resumeAnimation: () => graph?.resumeAnimation?.(), + screenToGraph: (x, y) => graph?.screen2GraphCoords?.(x, y) ?? { x, y }, + timelineActive: viewState.timelineActive, + updateNode: (nodeId, updates) => + updateGraphViewViewportNode(graphState.graphDataRef.current.nodes, nodeId, updates), + zoom: graph?.zoom?.() ?? globalScale, + }); + }; + + useEffect(() => { + return () => { + pluginHost?.setGraphViewViewportState(null); + }; + }, [pluginHost]); + return ( { - interactions.handleNodeContextMenuById(sectionId, event.nativeEvent); - }} - onSectionDragEnd={handleSectionDragEnd} - onUpdateSection={handleUpdateSection} surface2dProps={{ fg2dRef: graphState.fg2dRef, getArrowColor: callbacks.getArrowColor, @@ -274,6 +281,7 @@ export function GraphViewportShell({ nodePointerAreaPaint: callbacks.nodePointerAreaPaint, onRenderFramePost: (ctx, globalScale) => { publishGraphViewportScale(globalScale); + publishGraphViewViewportState(globalScale); viewportRuntime.renderPluginOverlays(ctx, globalScale); }, particleSize: viewState.particleSize, diff --git a/packages/extension/src/webview/components/graph/viewport/view.tsx b/packages/extension/src/webview/components/graph/viewport/view.tsx index 4799412b2..099d75830 100644 --- a/packages/extension/src/webview/components/graph/viewport/view.tsx +++ b/packages/extension/src/webview/components/graph/viewport/view.tsx @@ -1,7 +1,5 @@ import type { MouseEvent as ReactMouseEvent, ReactElement, Ref } from 'react'; import type { DirectionMode } from '../../../../shared/settings/modes'; -import type { GraphLayoutOwnership, GraphLayoutSection, GraphLayoutSectionUpdate } from '../../../../shared/settings/graphLayout'; -import type { LegendIconImport } from '../../../../shared/protocol/webviewToExtension'; import type { GraphMarqueeSelectionState } from '../marqueeSelection/model'; import type { GraphTooltipState } from '../tooltip/model'; import { @@ -28,8 +26,6 @@ import { import { SurfaceFallbackBoundary } from '../rendering/surface/view/fallbackBoundary'; import type { WebviewPluginHost } from '../../../pluginHost/manager'; import { SlotHost } from '../../../pluginHost/slotHost/view'; -import { SectionFrames } from '../sectionFrames/view'; -import type { SectionFrameNodePosition } from '../sectionFrames/model'; export interface ViewportProps { canvasBackgroundColor: string; @@ -46,22 +42,6 @@ export interface ViewportProps { handleMouseUpCapture: (this: void, event: ReactMouseEvent) => void; marqueeSelection?: GraphMarqueeSelectionState | null; menuEntries: GraphContextMenuEntry[]; - sectionFrameGraph?: { - graph2ScreenCoords?(x: number, y: number): { x: number; y: number }; - screen2GraphCoords?(x: number, y: number): { x: number; y: number }; - }; - sectionFrameOwnership?: Readonly>; - sectionNodePositions?: ReadonlyMap; - pinnedSectionIds?: ReadonlySet; - sectionFrames?: readonly GraphLayoutSection[]; - onUpdateSection?( - this: void, - sectionId: string, - updates: GraphLayoutSectionUpdate, - iconImports?: LegendIconImport[], - ): void; - onOpenSectionContextMenu?(this: void, sectionId: string, event: ReactMouseEvent): void; - onSectionDragEnd?(this: void, sectionId: string): void; surface2dProps: Omit; surface3dProps: Omit; tooltipData: GraphTooltipState; @@ -69,8 +49,6 @@ export interface ViewportProps { pluginHost?: WebviewPluginHost; } -const EMPTY_PINNED_SECTION_IDS = new Set(); - interface ViewportSurfaceProps { canvasBackgroundColor: string; directionMode: DirectionMode; @@ -126,12 +104,26 @@ function ViewportPluginOverlay({ pluginHost, }: Pick): ReactElement | null { return pluginHost ? ( - + <> + + + + ) : null; } @@ -194,18 +186,10 @@ export function Viewport({ handleMouseUpCapture, marqueeSelection, menuEntries, - sectionFrameGraph, - sectionFrameOwnership = {}, - sectionNodePositions, - pinnedSectionIds = EMPTY_PINNED_SECTION_IDS, - sectionFrames = [], surface2dProps, surface3dProps, tooltipData, onSurface3dError, - onOpenSectionContextMenu, - onSectionDragEnd, - onUpdateSection = () => {}, pluginHost, }: ViewportProps): ReactElement { return ( @@ -231,16 +215,6 @@ export function Viewport({ surface3dProps={surface3dProps} /> -
diff --git a/packages/extension/src/webview/components/plugins/Panel.tsx b/packages/extension/src/webview/components/plugins/Panel.tsx index 58d764363..ca81f09da 100644 --- a/packages/extension/src/webview/components/plugins/Panel.tsx +++ b/packages/extension/src/webview/components/plugins/Panel.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { postMessage } from '../../vscodeApi'; import { graphStore, useGraphStore } from '../../store/state'; import { mdiClose } from '@mdi/js'; @@ -18,7 +18,11 @@ interface PluginsPanelProps { } export default function PluginsPanel({ isOpen, onClose }: PluginsPanelProps): React.ReactElement | null { - const plugins = useGraphStore(s => s.pluginStatuses); + const pluginStatuses = useGraphStore(s => s.pluginStatuses); + const plugins = useMemo( + () => pluginStatuses.filter(plugin => plugin.packageName), + [pluginStatuses], + ); const [dragIndex, setDragIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); diff --git a/packages/extension/src/webview/components/toolbar/actions/create.tsx b/packages/extension/src/webview/components/toolbar/actions/create.tsx index 776d4a722..a7a63c886 100644 --- a/packages/extension/src/webview/components/toolbar/actions/create.tsx +++ b/packages/extension/src/webview/components/toolbar/actions/create.tsx @@ -3,12 +3,9 @@ import { mdiFilePlusOutline, mdiFolderPlusOutline, mdiPlusBoxOutline, - mdiVectorSquarePlus, + mdiShapeSquarePlus, } from '@mdi/js'; -import { - DEFAULT_GRAPH_SECTION_COLOR, - getDefaultGraphSectionSize, -} from '../../../../shared/settings/graphLayout'; +import type { CoreGraphViewContributionSet } from '@codegraphy-dev/core'; import { MdiIcon } from '../../icons/MdiIcon'; import { Button } from '../../ui/button'; import { @@ -19,27 +16,10 @@ import { } from '../../ui/menus/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/overlay/tooltip'; import { postMessage } from '../../../vscodeApi'; -import type { GraphContextMutationAvailability } from '../../graph/contextMenu/contracts'; - -interface CreateToolbarActionProps { - graphMode: '2d' | '3d'; - mutationAvailability: GraphContextMutationAvailability; -} -function postRootGraphSectionCreation(): void { - const size = getDefaultGraphSectionSize(); - postMessage({ - type: 'CREATE_GRAPH_LAYOUT_SECTION', - payload: { - color: DEFAULT_GRAPH_SECTION_COLOR, - height: size.height, - memberNodeIds: [], - width: size.width, - x: -(size.width / 2), - y: -(size.height / 2), - }, - }); -} +type GraphViewCreateContribution = CoreGraphViewContributionSet['contextMenu'][number]; +type GraphViewCreateContext = Parameters[0]; +type GraphMode = '2d' | '3d'; function postRootFileCreation(): void { postMessage({ type: 'CREATE_FILE', payload: { directory: '.' } }); @@ -49,13 +29,73 @@ function postRootFolderCreation(): void { postMessage({ type: 'CREATE_FOLDER', payload: { directory: '.' } }); } +export interface ResolvedGraphViewCreateContribution { + context: GraphViewCreateContext; + entry: GraphViewCreateContribution; + label: string; +} + +function isGraphViewCreateContribution( + entry: GraphViewCreateContribution, + context: GraphViewCreateContext, +): boolean { + return entry.contribution.placement?.menu === 'create' + && entry.contribution.targets.some(target => target.kind === 'background') + && (entry.contribution.isVisible?.(context) ?? true); +} + +function createGraphViewCreateContext( + graphMode: GraphMode, + timelineActive: boolean, +): GraphViewCreateContext { + return { + target: { kind: 'background' }, + graphMode, + timelineActive, + selectedNodeIds: [], + selectedEdgeIds: [], + }; +} + +export function resolveGraphViewCreateContributions({ + graphMode, + graphViewContributions, + timelineActive, +}: { + graphMode: GraphMode; + graphViewContributions?: CoreGraphViewContributionSet; + timelineActive: boolean; +}): ResolvedGraphViewCreateContribution[] { + const context = createGraphViewCreateContext(graphMode, timelineActive); + return graphViewContributions?.contextMenu + .filter(entry => isGraphViewCreateContribution(entry, context)) + .map(entry => ({ + context, + entry, + label: entry.contribution.getLabel?.(context) ?? entry.contribution.label, + })) ?? []; +} + +function runGraphViewCreateContribution( + contribution: ResolvedGraphViewCreateContribution, +): void { + void contribution.entry.contribution.run(contribution.context); +} + export function CreateToolbarAction({ graphMode, - mutationAvailability, -}: CreateToolbarActionProps): React.ReactElement { - const sectionCreationAvailable = graphMode === '2d' - && mutationAvailability !== 'hidden'; - const sectionCreationDisabled = mutationAvailability === 'disabled'; + graphViewContributions, + timelineActive, +}: { + graphMode: GraphMode; + graphViewContributions?: CoreGraphViewContributionSet; + timelineActive: boolean; +}): React.ReactElement { + const graphViewCreateContributions = resolveGraphViewCreateContributions({ + graphMode, + graphViewContributions, + timelineActive, + }); return ( @@ -84,16 +124,16 @@ export function CreateToolbarAction({ New Folder... - {sectionCreationAvailable ? ( + {graphViewCreateContributions.map(contribution => ( runGraphViewCreateContribution(contribution)} > - - New Graph Section + + {contribution.label} - ) : null} + ))} ); diff --git a/packages/extension/src/webview/components/toolbar/actions/view.tsx b/packages/extension/src/webview/components/toolbar/actions/view.tsx index b281a714c..893d4c0d6 100644 --- a/packages/extension/src/webview/components/toolbar/actions/view.tsx +++ b/packages/extension/src/webview/components/toolbar/actions/view.tsx @@ -1,4 +1,6 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import type { CoreGraphViewContributionSet } from '@codegraphy-dev/core'; +import type { WebviewPluginHost } from '../../../pluginHost/manager'; import { useGraphStore } from '../../../store/state'; import { IndexToolbarAction } from './indexAction'; import { ToolbarPanelButtons } from './panelButtons'; @@ -7,7 +9,6 @@ import { PluginToolbarActions } from '../plugin/Actions'; import { LayoutModePopover } from '../LayoutModePopover'; import { NodeSizeModePopover } from '../NodeSizeModePopover'; import { CreateToolbarAction } from './create'; -import { getGraphContextMutationAvailability } from '../../graph/contextMenu/mutationAvailability'; export { getToolbarActionIconPath, @@ -15,23 +16,46 @@ export { getToolbarActionKey, } from './model'; -export function ToolbarActions(): React.ReactElement { +function useGraphViewContributions( + pluginHost: WebviewPluginHost | undefined, +): CoreGraphViewContributionSet | undefined { + const [contributionVersion, setContributionVersion] = useState(0); + const canReadGraphViewContributions = + typeof pluginHost?.getGraphViewContributions === 'function' + && typeof pluginHost.subscribeGraphViewContributions === 'function'; + + useEffect(() => { + if (!canReadGraphViewContributions) { + return undefined; + } + + const subscription = pluginHost.subscribeGraphViewContributions(() => { + setContributionVersion(version => version + 1); + }); + return () => subscription.dispose(); + }, [canReadGraphViewContributions, pluginHost]); + + void contributionVersion; + return canReadGraphViewContributions + ? pluginHost.getGraphViewContributions() + : undefined; +} + +export function ToolbarActions({ + pluginHost, +}: { + pluginHost?: WebviewPluginHost; +}): React.ReactElement { const activePanel = useGraphStore(s => s.activePanel); const setActivePanel = useGraphStore(s => s.setActivePanel); const pluginToolbarActions = useGraphStore(s => s.pluginToolbarActions); + const graphViewContributions = useGraphViewContributions(pluginHost); + const graphMode = useGraphStore(s => s.graphMode); const graphHasIndex = useGraphStore(s => s.graphHasIndex); const graphIndexFreshness = useGraphStore(s => s.graphIndexFreshness); const graphIndexDetail = useGraphStore(s => s.graphIndexDetail); const graphIsIndexing = useGraphStore(s => s.graphIsIndexing); - const graphMode = useGraphStore(s => s.graphMode); - const currentCommitSha = useGraphStore(s => s.currentCommitSha); const timelineActive = useGraphStore(s => s.timelineActive); - const timelineCommits = useGraphStore(s => s.timelineCommits); - const mutationAvailability = getGraphContextMutationAvailability({ - currentCommitSha, - timelineActive, - timelineCommits, - }); return (
@@ -49,7 +73,8 @@ export function ToolbarActions(): React.ReactElement { - + {pluginHost ? ( <>