From 9bd41c5982511aff9aef1cfbdc0bfd55fab83085 Mon Sep 17 00:00:00 2001 From: Andraxion Date: Fri, 24 Jul 2026 23:15:57 -0400 Subject: [PATCH] Redesign graph viewer navigation --- docs/CONTRACT.md | 34 ++-- docs/MCP_CONTRACT.md | 21 ++- docs/NEW_PROJECT_QUICKSTART.md | 9 +- pyproject.toml | 2 +- src/docforge/visualization.py | 323 ++++++++++++++++++++++++++++----- tests/test_mcp_server.py | 2 +- tests/test_visualization.py | 33 +++- uv.lock | 2 +- 8 files changed, 354 insertions(+), 72 deletions(-) diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index a10de25..9e914fe 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -23,7 +23,7 @@ approved contract and measured cross-project evidence; it is not an unimplemente - Result envelope: `schemas/result.schema.json`, version 1. - Changeset schema: `schemas/changeset.schema.json`, version 1. - Index schema: version 1, disposable and reproducible. -- Core, CLI, and MCP server: version 0.8.2. +- Core, CLI, and MCP server: version 0.9.0. Schema files describe the generic interchange contract. Runtime validation remains responsible for path confinement, source hashing, relationship resolution, dependency cycles, project limits, stale @@ -101,25 +101,35 @@ The HTTP listener binds to `127.0.0.1` on an operating-system-selected port. A c random token is part of every accepted URL path. Only `GET` and `HEAD` are supported. Responses use no-store caching, a restrictive content-security policy, frame denial, MIME sniffing protection, and no-referrer policy. The built-in template uses only same-origin JSON endpoints for graph -overview, bounded search, exact node content, bounded incoming-and-outgoing neighborhoods, and one -lease heartbeat. The heartbeat changes no project or index state. There is no write endpoint, -arbitrary query endpoint, static filesystem handler, external asset, or project-selection control. +overview, bounded search, exact descriptor-category filtering, exact node content, bounded +incoming-and-outgoing neighborhoods, and one lease heartbeat. Descriptor filtering accepts only +family, authority, status, or tag plus one exact value. The heartbeat changes no project or index +state. There is no write endpoint, arbitrary query endpoint, static filesystem handler, external +asset, or project-selection control. -The `graph-browser@6` template provides mouse-wheel zoom centered on the pointer, left-button drag +The `graph-browser@7` template provides mouse-wheel zoom centered on the pointer, left-button drag pan, explicit zoom-in and zoom-out buttons, a reset-view button, and a live zoom percentage. A four-pixel drag threshold defers pointer capture and preserves node activation for ordinary clicks. Loading another root node fits the viewport to the returned neighborhood, including a useful minimum scale for a single-node result. The current root begins selected, and activating another graph node moves the visible selection ring to it. Space centers the viewport on the selected node without changing zoom. Reset restores the fitted neighborhood view. Empty-canvas guidance is hidden -whenever a neighborhood is rendered. +whenever a neighborhood is rendered. The page is fixed to the browser viewport. Search and exact +filter results fill the left panel, neighborhood traversal fills the right panel, and only the +center SVG canvas pans or zooms. -Activating a graph node opens a modal inspector containing that node's complete validated metadata -and content. Inspection does not replace the current neighborhood or reset the viewport. The modal -supports keyboard activation, Escape, explicit close controls, and backdrop dismissal. Loading the -inspected node as the new root requires the separate Explore neighborhood action. Both side panels -support pointer and keyboard resizing. The unblurred modal supports native resizing and constrained -title-bar dragging. +Left-clicking or pressing Enter on a graph node opens a compact descriptor card containing the +validated metadata and content previously shown in the details panel. Its family, authority, +status, and tag pills are buttons that replace the left result list with exact matching nodes. +Right-clicking or pressing Shift+Enter opens the complete inspector. Inspection does not replace +the current neighborhood or reset the viewport. Both dialogs support Escape, explicit close +controls, and backdrop dismissal. Loading the inspected node as the new root requires the separate +Explore neighborhood action. Both side panels support pointer and keyboard resizing. The unblurred +full inspector supports native resizing and constrained title-bar dragging. + +The header exposes a Nodes/Flow segmented selector. Both positions intentionally retain the node +graph in this contract version. Flow traversal and layout semantics remain unclaimed until relation +direction and eligibility are defined. The browser derives presentation roles only from the returned bounded graph. The current root is the primary focus. Nodes reachable through outgoing edges are children. Remaining incoming and diff --git a/docs/MCP_CONTRACT.md b/docs/MCP_CONTRACT.md index a01318b..eea85b9 100644 --- a/docs/MCP_CONTRACT.md +++ b/docs/MCP_CONTRACT.md @@ -61,22 +61,25 @@ only through the explicit local CLI integration command. ## Visualization boundary -`docforge_visualize` starts the fixed built-in `graph-browser@6` template against the currently +`docforge_visualize` starts the fixed built-in `graph-browser@7` template against the currently validated derived index. It may focus one stable node, run one bounded lexical query, or open the project overview. The tool returns a loopback URL and exact snapshot identity. The tool cannot select a project, database, template, host, port, filesystem path, or SQL expression. Its HTTP surface is token-bound, read-only, same-origin, and limited to overview, -search/filter, node-neighborhood JSON, and a read-only browser-lease heartbeat. The browser exposes -an exact validated index snapshot. It rejects index replacement or alteration and requires another -MCP invocation to refresh. +search, exact family/authority/status/tag filtering, node-neighborhood JSON, and a read-only +browser-lease heartbeat. The browser exposes an exact validated index snapshot. It rejects index +replacement or alteration and requires another MCP invocation to refresh. Viewport interaction is entirely client-side: fitted neighborhood framing, wheel zoom, left-button drag pan, explicit zoom buttons, reset, and Space-to-center selection never request or mutate -project data. Graph-node activation visibly selects the node, fetches exact node data from the same -bounded read endpoint, and opens a client-side modal inspector. Replacing the current root requires -the modal's explicit Explore neighborhood action. The open browser renews a bounded lease in a -detached local worker, so standard-input transaction completion does not close the listener. The -worker tracks the longer-lived MCP client host and closes when that owner exits. +project data. Left activation visibly selects the node and opens a compact descriptor card. +Right-click opens the full inspector. Descriptor-pill activation fills the fixed left panel with an +exact bounded category result set. The fixed right panel contains neighborhood navigation. +Replacing the current root requires an explicit Explore neighborhood action. The Nodes/Flow +selector retains the same graph until a later contract defines flow semantics. The open browser +renews a bounded lease in a detached local worker, so standard-input transaction completion does +not close the listener. The worker tracks the longer-lived MCP client host and closes when that +owner exits. Explicit service shutdown closes its tracked worker, and abandoned pages expire. ## Excluded tools diff --git a/docs/NEW_PROJECT_QUICKSTART.md b/docs/NEW_PROJECT_QUICKSTART.md index 492e026..69b2d16 100644 --- a/docs/NEW_PROJECT_QUICKSTART.md +++ b/docs/NEW_PROJECT_QUICKSTART.md @@ -16,11 +16,12 @@ The DocForge repository contains the complete generic CLI and stdio MCP server. - Project identity, validation, exact-node retrieval, lexical search, and filtering. - Backlinks, dependency traversal, impact traversal, and bounded context profiles. -- `docforge_visualize`, which starts the token-protected, loopback-only `graph-browser@6` viewer. +- `docforge_visualize`, which starts the token-protected, loopback-only `graph-browser@7` viewer. The viewer supports search, family filtering, exact-node inspection, bounded neighborhoods, - modal inspection without losing the current neighborhood, explicit neighborhood exploration, - fitted single- and multi-node framing, visible node selection, Space-to-center, mouse-wheel zoom, - left-button drag panning, zoom controls, and viewport reset. + exact descriptor-pill filtering, compact left-click descriptors, full right-click inspection, + fixed search and neighborhood side panels, explicit neighborhood exploration, fitted single- and + multi-node framing, visible node selection, Space-to-center, mouse-wheel zoom, left-button drag + panning, zoom controls, viewport reset, and a reserved Nodes/Flow selector. - Isolated documentation changesets, proposal validation, diffs, and escaped HTML previews when a proposal writer and render view are configured. diff --git a/pyproject.toml b/pyproject.toml index 50c3633..0454017 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "docforge" -version = "0.8.2" +version = "0.9.0" description = "Project-scoped documentation indexing and context service" readme = "README.md" requires-python = ">=3.12" diff --git a/src/docforge/visualization.py b/src/docforge/visualization.py index 8af4bb0..52b9610 100644 --- a/src/docforge/visualization.py +++ b/src/docforge/visualization.py @@ -23,7 +23,7 @@ from typing import cast from .errors import DocForgeError from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize -VISUALIZATION_TEMPLATE = "graph-browser@6" +VISUALIZATION_TEMPLATE = "graph-browser@7" DEFAULT_EDGE_LIMIT = 100 MAX_EDGE_LIMIT = 400 DEFAULT_INITIAL_GRACE_SECONDS = 120.0 @@ -167,6 +167,42 @@ class VisualizationIndexSnapshot: snapshot=True, ) + def filter_nodes( + self, + *, + category: str, + value: str, + limit: int, + ) -> dict[str, object]: + bounded = self._bounded_limit(limit) + if category not in {"family", "authority", "status", "tag"}: + raise DocForgeError("invalid_filter", "Descriptor filter category is unsupported") + if not value or len(value) > self.max_query_chars: + raise DocForgeError("invalid_filter", "Descriptor filter value is invalid") + if category == "tag": + clause = "EXISTS (SELECT 1 FROM json_each(tags_json) WHERE value = ?)" + else: + clause = f"{category} = ?" + with self._connection() as connection: + total = connection.execute( + f"SELECT COUNT(*) FROM nodes WHERE {clause}", + (value,), + ).fetchone()[0] + rows = connection.execute( + f"SELECT * FROM nodes WHERE {clause} ORDER BY node_id LIMIT ?", + (value, bounded), + ).fetchall() + results = [_node_dict(row, include_content=False) for row in rows] + return self._result( + category=category, + value=value, + count=len(results), + total=total, + truncated=total > len(results), + results=results, + snapshot=True, + ) + def node(self, node_id: str, *, depth: int, limit: int) -> dict[str, object]: if type(depth) is not int or depth < 1 or depth > self.max_depth: raise DocForgeError("invalid_depth", "Traversal depth is outside the configured limit") @@ -539,6 +575,8 @@ class VisualizationRunner: ) elif parsed.path == f"{prefix}/api/search": payload = self._search(reader, params) + elif parsed.path == f"{prefix}/api/filter": + payload = self._filter(reader, params) elif parsed.path == f"{prefix}/api/node": payload = self._node(reader, params) else: @@ -558,6 +596,7 @@ class VisualizationRunner: "stale_adapter_source": HTTPStatus.CONFLICT, "visualization_stale": HTTPStatus.CONFLICT, "invalid_query": HTTPStatus.BAD_REQUEST, + "invalid_filter": HTTPStatus.BAD_REQUEST, "invalid_depth": HTTPStatus.BAD_REQUEST, "invalid_limit": HTTPStatus.BAD_REQUEST, }.get(error.code, HTTPStatus.SERVICE_UNAVAILABLE) @@ -607,6 +646,16 @@ class VisualizationRunner: ) return reader.node(node_id, depth=depth, limit=limit) + def _filter( + self, + reader: VisualizationIndexSnapshot, + params: dict[str, list[str]], + ) -> dict[str, object]: + category = _one(params, "category").strip() + value = _one(params, "value").strip() + limit = _integer(_one(params, "limit") or "50") + return reader.filter_nodes(category=category, value=value, limit=limit) + def _current_reader(self) -> VisualizationIndexSnapshot: with self._lock: reader = self._reader @@ -864,6 +913,7 @@ _GRAPH_BROWSER_HTML = r""" DocForge graph +
+
+ + +

DocForge graph

nodes @@ -1124,15 +1234,11 @@ _GRAPH_BROWSER_HTML = r""" -
-
+ +
+
+ Node descriptor + +
+
+
+ + +
+
+