diff --git a/pyproject.toml b/pyproject.toml index e55a45a..4772f47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "docforge" -version = "0.12.0" +version = "0.12.2" 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 af299fe..af8eb26 100644 --- a/src/docforge/visualization.py +++ b/src/docforge/visualization.py @@ -33,7 +33,7 @@ from .errors import DocForgeError from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize from .project import project_root_fingerprint -VISUALIZATION_TEMPLATE = "graph-browser@8" +VISUALIZATION_TEMPLATE = "graph-browser@10" DEFAULT_EDGE_LIMIT = 100 MAX_EDGE_LIMIT = 400 DEFAULT_INITIAL_GRACE_SECONDS = 120.0 @@ -1447,23 +1447,25 @@ _GRAPH_BROWSER_HTML = r""" background: #07111c; color: #d6e6f5; } dialog { - width: min(760px, calc(100vw - 32px)); height: min(680px, calc(100vh - 32px)); - min-width: min(360px, calc(100vw - 20px)); min-height: 280px; - max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); + width: fit-content; height: fit-content; + min-width: min(360px, calc(100vw - 20px)); min-height: 0; + max-width: min(760px, calc(100vw - 32px)); max-height: calc(100vh - 32px); padding: 0; overflow: hidden; border: 1px solid #36536e; border-radius: 14px; background: var(--panel); color: var(--text); box-shadow: 0 24px 80px rgba(0, 0, 0, .58); resize: both; } dialog::backdrop { background: rgba(2, 8, 14, .48); } .dialog-shell { - display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: 100%; height: 100%; + display: grid; grid-template-rows: auto auto auto; width: fit-content; + min-width: min(360px, calc(100vw - 20px)); max-width: min(760px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); } .dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel-2); cursor: move; touch-action: none; user-select: none; } - .dialog-head strong { font-size: 15px; } + .dialog-head strong { min-width: 0; font-size: 15px; overflow-wrap: anywhere; } .dialog-close { width: 34px; height: 34px; border: 1px solid var(--line); border-radius: 8px; background: #102b3d; color: var(--text); font-size: 21px; line-height: 1; @@ -1474,16 +1476,22 @@ _GRAPH_BROWSER_HTML = r""" .dialog-body { min-height: 0; overflow: auto; padding: 18px; } .dialog-body pre { max-height: none; } dialog.compact-dialog { - width: min(520px, calc(100vw - 32px)); height: min(620px, calc(100vh - 32px)); - resize: none; + position: fixed; inset: auto; margin: 0; width: min(400px, calc(100vw - 24px)); + height: auto; min-width: 0; min-height: 0; max-height: min(560px, calc(100vh - 24px)); + resize: none; border-color: rgba(91, 137, 169, .78); background: rgba(7, 18, 29, .82); + box-shadow: 0 14px 42px rgba(0, 0, 0, .42); } - .compact-dialog .dialog-head { cursor: default; } - .compact-dialog .dialog-body { padding: 16px; } - .compact-dialog .dialog-body pre { max-height: 230px; } + .compact-dialog .dialog-shell { grid-template-rows: auto auto; } + .compact-dialog .dialog-body { padding: 14px 16px; } + .compact-dialog .dialog-body pre { display: none; } + .compact-dialog .detail-head h2 { font-size: 16px; } + .compact-dialog .meta { margin: 12px 0 0; gap: 6px; } + .compact-dialog .meta div { grid-template-columns: 66px 1fr; } .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--line); background: var(--panel-2); } + .compact-dialog .dialog-actions { padding: 9px 12px; background: rgba(12, 35, 51, .72); } .error { color: #ff9aac; } @media (max-width: 980px) { :root { --left-width: 240px; --right-width: 260px; } @@ -1597,17 +1605,11 @@ _GRAPH_BROWSER_HTML = r""" - +
-
- Node descriptor - -
-
@@ -1635,7 +1637,7 @@ _GRAPH_BROWSER_HTML = r""" const relationStyles = Object.freeze({ contains: { family: "Structure", color: "#60a5fa", dash: "", marker: "diamond-arrow", - flow: null, + flow: "forward", }, defines: { family: "Structure", color: "#38bdf8", dash: "7 3", marker: "diamond-arrow", @@ -2345,10 +2347,10 @@ _GRAPH_BROWSER_HTML = r""" const family = svgElement("text", {y: 48, "text-anchor": "middle", class: "family"}); family.textContent = short(node.family, 22); group.append(title, family); - group.addEventListener("click", () => { + group.addEventListener("click", (event) => { if (!state.suppressClick) { selectNode(node.node_id); - showNodeCard(node.node_id); + showNodeCard(node.node_id, event); } }); group.addEventListener("contextmenu", (event) => { @@ -2368,7 +2370,7 @@ _GRAPH_BROWSER_HTML = r""" } svg.append(definitions, edgeLayer, nodeLayer); } - function renderDetails(details, node, data, interactiveBadges = false) { + function renderDetails(details, node, data, interactiveBadges = false, includeContent = true) { details.replaceChildren(); const heading = document.createElement("div"); heading.className = "detail-head"; @@ -2416,9 +2418,12 @@ _GRAPH_BROWSER_HTML = r""" row.append(dt, dd); dl.append(row); } - const content = document.createElement("pre"); - content.textContent = node.content; - details.append(heading, badges, summary, dl, content); + details.append(heading, badges, summary, dl); + if (includeContent) { + const content = document.createElement("pre"); + content.textContent = node.content; + details.append(content); + } } function closeNodeDialog() { const dialog = $("node-dialog"); @@ -2430,18 +2435,39 @@ _GRAPH_BROWSER_HTML = r""" if (dialog.open) dialog.close(); else state.cardNode = null; } - async function showNodeCard(nodeId) { + function positionNodeCard(dialog, event) { + const canvas = $("graph").getBoundingClientRect(); + const centerX = canvas.left + canvas.width / 2; + const centerY = canvas.top + canvas.height / 2; + const clientX = Number.isFinite(event?.clientX) ? event.clientX : centerX; + const clientY = Number.isFinite(event?.clientY) ? event.clientY : centerY; + const offset = 14; + const margin = 8; + const bounds = dialog.getBoundingClientRect(); + const maximumLeft = Math.max(margin, window.innerWidth - bounds.width - margin); + const maximumTop = Math.max(margin, window.innerHeight - bounds.height - margin); + const left = clamp(clientX + offset, margin, maximumLeft); + const top = clamp(clientY + offset, margin, maximumTop); + dialog.style.left = `${left}px`; + dialog.style.top = `${top}px`; + } + async function showNodeCard(nodeId, event) { try { selectNode(nodeId); setStatus(`Loading descriptor for ${nodeId}…`); const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"}); const data = await api(`node?${params}`); state.cardNode = nodeId; - renderDetails($("node-card-details"), data.node, data, true); - $("node-card-label").textContent = short(data.node.title, 72); + renderDetails($("node-card-details"), data.node, data, true, false); const dialog = $("node-card"); - if (!dialog.open) dialog.showModal(); - $("close-node-card").focus(); + if (!dialog.open) { + dialog.style.visibility = "hidden"; + dialog.show(); + positionNodeCard(dialog, event); + dialog.style.visibility = ""; + } else { + positionNodeCard(dialog, event); + } setStatus(`Selected ${nodeId}`); } catch (error) { setStatus(error.message, true); @@ -2635,8 +2661,6 @@ _GRAPH_BROWSER_HTML = r""" $("reset-view").addEventListener("click", resetViewport); $("close-node-dialog").addEventListener("click", closeNodeDialog); $("dismiss-node-dialog").addEventListener("click", closeNodeDialog); - $("close-node-card").addEventListener("click", closeNodeCard); - $("dismiss-node-card").addEventListener("click", closeNodeCard); setupPanelResizer("left"); setupPanelResizer("right"); $("node-dialog").querySelector(".dialog-head").addEventListener("pointerdown", beginDialogDrag); @@ -2668,13 +2692,6 @@ _GRAPH_BROWSER_HTML = r""" setStatus(`${nodeCount} nodes · ${edgeCount} edges in neighborhood`); } }); - $("node-card").addEventListener("click", (event) => { - if (event.target !== $("node-card")) return; - const bounds = $("node-card").getBoundingClientRect(); - const inside = event.clientX >= bounds.left && event.clientX <= bounds.right - && event.clientY >= bounds.top && event.clientY <= bounds.bottom; - if (!inside) closeNodeCard(); - }); $("node-card").addEventListener("close", () => { state.cardNode = null; if (state.graph) { @@ -2684,6 +2701,11 @@ _GRAPH_BROWSER_HTML = r""" } }); document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && $("node-card").open) { + event.preventDefault(); + closeNodeCard(); + return; + } if (event.code !== "Space" || event.defaultPrevented || $("node-dialog").open || $("node-card").open) { return; @@ -2695,6 +2717,12 @@ _GRAPH_BROWSER_HTML = r""" } if (centerSelectedNode()) event.preventDefault(); }); + document.addEventListener("pointerdown", (event) => { + const card = $("node-card"); + if (card.open && event.target instanceof Node && !card.contains(event.target)) { + closeNodeCard(); + } + }); $("graph").addEventListener("wheel", (event) => { event.preventDefault(); zoomAt(event.deltaY < 0 ? .85 : 1.18, event.clientX, event.clientY); diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 251be5c..d4cafc6 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -130,7 +130,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase): visualization = results[11].structuredContent["visualization"] self.assertTrue(visualization["read_only"]) self.assertTrue(visualization["project_bound"]) - self.assertEqual("graph-browser@8", visualization["template"]) + self.assertEqual("graph-browser@10", visualization["template"]) self.assertEqual("managed_idle", visualization["lifetime"]["policy"]) self.assertEqual("docforge_stop_visualization", visualization["lifetime"]["stop_tool"]) self.assertTrue(visualization["url"].startswith("http://127.0.0.1:")) diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 8494456..328fde3 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -177,6 +177,7 @@ if (centered.width !== 500 || centered.height !== 300) fail("center preserves zo if (centered.x !== positions.get("child-two").x - 250) fail("center x"); if (centered.y !== positions.get("child-two").y - 150) fail("center y"); if (relationStyle("calls").family !== "Execution") fail("calls family"); +if (relationStyle("contains").flow !== "forward") fail("contains flow direction"); if (relationStyle("reads").flow !== "reverse") fail("reads flow direction"); if (relationStyle("documents").flow !== null) fail("documents excluded from flow"); if (relationStyle("unknown_relation").family !== "Other") fail("fallback relation family"); @@ -188,18 +189,23 @@ const flowData = { {node_id: "caller"}, {node_id: "dependency"}, {node_id: "reader"}, + {node_id: "test-class"}, + {node_id: "test-file"}, {node_id: "document"}, ], edges: [ {source_id: "caller", relation: "calls", target_id: "primary"}, {source_id: "primary", relation: "depends_on", target_id: "dependency"}, {source_id: "primary", relation: "reads", target_id: "reader"}, + {source_id: "test-class", relation: "contains", target_id: "primary"}, + {source_id: "test-file", relation: "contains", target_id: "test-class"}, {source_id: "document", relation: "documents", target_id: "primary"}, ], }; const flow = buildFlowGraph(flowData); const flowIds = new Set(flow.nodes.map((node) => node.node_id)); -if (!flowIds.has("caller") || !flowIds.has("dependency") || !flowIds.has("reader")) { +if (!flowIds.has("caller") || !flowIds.has("dependency") || !flowIds.has("reader") + || !flowIds.has("test-class") || !flowIds.has("test-file")) { fail("upstream flow membership"); } if (flowIds.has("document")) fail("evidence leaked into flow"); @@ -212,6 +218,11 @@ const dependencyEdge = flow.edges.find((edge) => edge.relation === "depends_on") if (dependencyEdge.source_id !== "dependency" || dependencyEdge.target_id !== "primary") { fail("dependency semantic reversal"); } +const containmentEdge = flow.edges.find((edge) => edge.relation === "contains" + && edge.source_id === "test-class"); +if (!containmentEdge || containmentEdge.target_id !== "primary") { + fail("containment ancestry direction"); +} """ ) result = subprocess.run( @@ -254,6 +265,15 @@ if (dependencyEdge.source_id !== "dependency" || dependencyEdge.target_id !== "p self.assertIn('id="node-card"', html) self.assertIn('id="explore-node"', html) self.assertIn('id="explore-card-node"', html) + self.assertIn("positionNodeCard(dialog, event)", html) + self.assertIn( + 'renderDetails($("node-card-details"), data.node, data, true, false)', html + ) + self.assertIn("dialog.show();", html) + self.assertIn("position: fixed; inset: auto; margin: 0", html) + self.assertIn("background: rgba(7, 18, 29, .82)", html) + self.assertIn("width: fit-content; height: fit-content", html) + self.assertIn("max-width: min(760px, calc(100vw - 32px))", html) self.assertIn('id="left-resizer"', html) self.assertIn('id="right-resizer"', html) self.assertIn('id="view-nodes"', html) diff --git a/uv.lock b/uv.lock index cb79375..2db8ee3 100644 --- a/uv.lock +++ b/uv.lock @@ -206,7 +206,7 @@ wheels = [ [[package]] name = "docforge" -version = "0.12.0" +version = "0.12.2" source = { editable = "." } dependencies = [ { name = "markdown-it-py" },