1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Fix graph flow ancestry and modal sizing

This commit is contained in:
Andraxion 2026-07-25 00:49:17 -04:00
parent c22ae01fdf
commit 984a4fd993
5 changed files with 91 additions and 43 deletions

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "docforge" name = "docforge"
version = "0.12.0" version = "0.12.2"
description = "Project-scoped documentation indexing and context service" description = "Project-scoped documentation indexing and context service"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

View file

@ -33,7 +33,7 @@ from .errors import DocForgeError
from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize
from .project import project_root_fingerprint from .project import project_root_fingerprint
VISUALIZATION_TEMPLATE = "graph-browser@8" VISUALIZATION_TEMPLATE = "graph-browser@10"
DEFAULT_EDGE_LIMIT = 100 DEFAULT_EDGE_LIMIT = 100
MAX_EDGE_LIMIT = 400 MAX_EDGE_LIMIT = 400
DEFAULT_INITIAL_GRACE_SECONDS = 120.0 DEFAULT_INITIAL_GRACE_SECONDS = 120.0
@ -1447,23 +1447,25 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
background: #07111c; color: #d6e6f5; background: #07111c; color: #d6e6f5;
} }
dialog { dialog {
width: min(760px, calc(100vw - 32px)); height: min(680px, calc(100vh - 32px)); width: fit-content; height: fit-content;
min-width: min(360px, calc(100vw - 20px)); min-height: 280px; min-width: min(360px, calc(100vw - 20px)); min-height: 0;
max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); max-width: min(760px, calc(100vw - 32px)); max-height: calc(100vh - 32px);
padding: 0; overflow: hidden; border: 1px solid #36536e; border-radius: 14px; padding: 0; overflow: hidden; border: 1px solid #36536e; border-radius: 14px;
background: var(--panel); color: var(--text); background: var(--panel); color: var(--text);
box-shadow: 0 24px 80px rgba(0, 0, 0, .58); resize: both; box-shadow: 0 24px 80px rgba(0, 0, 0, .58); resize: both;
} }
dialog::backdrop { background: rgba(2, 8, 14, .48); } dialog::backdrop { background: rgba(2, 8, 14, .48); }
.dialog-shell { .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 { .dialog-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px; display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel-2); padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel-2);
cursor: move; touch-action: none; user-select: none; 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 { .dialog-close {
width: 34px; height: 34px; border: 1px solid var(--line); border-radius: 8px; width: 34px; height: 34px; border: 1px solid var(--line); border-radius: 8px;
background: #102b3d; color: var(--text); font-size: 21px; line-height: 1; background: #102b3d; color: var(--text); font-size: 21px; line-height: 1;
@ -1474,16 +1476,22 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
.dialog-body { min-height: 0; overflow: auto; padding: 18px; } .dialog-body { min-height: 0; overflow: auto; padding: 18px; }
.dialog-body pre { max-height: none; } .dialog-body pre { max-height: none; }
dialog.compact-dialog { dialog.compact-dialog {
width: min(520px, calc(100vw - 32px)); height: min(620px, calc(100vh - 32px)); position: fixed; inset: auto; margin: 0; width: min(400px, calc(100vw - 24px));
resize: none; 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-shell { grid-template-rows: auto auto; }
.compact-dialog .dialog-body { padding: 16px; } .compact-dialog .dialog-body { padding: 14px 16px; }
.compact-dialog .dialog-body pre { max-height: 230px; } .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 { .dialog-actions {
display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px;
border-top: 1px solid var(--line); background: var(--panel-2); 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; } .error { color: #ff9aac; }
@media (max-width: 980px) { @media (max-width: 980px) {
:root { --left-width: 240px; --right-width: 260px; } :root { --left-width: 240px; --right-width: 260px; }
@ -1597,17 +1605,11 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
</div> </div>
</div> </div>
</dialog> </dialog>
<dialog class="compact-dialog" id="node-card" aria-labelledby="node-card-label"> <dialog class="compact-dialog" id="node-card" aria-label="Selected node descriptor">
<div class="dialog-shell"> <div class="dialog-shell">
<div class="dialog-head">
<strong id="node-card-label">Node descriptor</strong>
<button class="dialog-close" id="close-node-card" type="button"
aria-label="Close node descriptor">×</button>
</div>
<div class="dialog-body" id="node-card-details"></div> <div class="dialog-body" id="node-card-details"></div>
<div class="dialog-actions"> <div class="dialog-actions">
<button class="button" id="explore-card-node" type="button">Explore neighborhood</button> <button class="button" id="explore-card-node" type="button">Explore neighborhood</button>
<button class="button" id="dismiss-node-card" type="button">Close</button>
</div> </div>
</div> </div>
</dialog> </dialog>
@ -1635,7 +1637,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
const relationStyles = Object.freeze({ const relationStyles = Object.freeze({
contains: { contains: {
family: "Structure", color: "#60a5fa", dash: "", marker: "diamond-arrow", family: "Structure", color: "#60a5fa", dash: "", marker: "diamond-arrow",
flow: null, flow: "forward",
}, },
defines: { defines: {
family: "Structure", color: "#38bdf8", dash: "7 3", marker: "diamond-arrow", family: "Structure", color: "#38bdf8", dash: "7 3", marker: "diamond-arrow",
@ -2345,10 +2347,10 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
const family = svgElement("text", {y: 48, "text-anchor": "middle", class: "family"}); const family = svgElement("text", {y: 48, "text-anchor": "middle", class: "family"});
family.textContent = short(node.family, 22); family.textContent = short(node.family, 22);
group.append(title, family); group.append(title, family);
group.addEventListener("click", () => { group.addEventListener("click", (event) => {
if (!state.suppressClick) { if (!state.suppressClick) {
selectNode(node.node_id); selectNode(node.node_id);
showNodeCard(node.node_id); showNodeCard(node.node_id, event);
} }
}); });
group.addEventListener("contextmenu", (event) => { group.addEventListener("contextmenu", (event) => {
@ -2368,7 +2370,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
} }
svg.append(definitions, edgeLayer, nodeLayer); svg.append(definitions, edgeLayer, nodeLayer);
} }
function renderDetails(details, node, data, interactiveBadges = false) { function renderDetails(details, node, data, interactiveBadges = false, includeContent = true) {
details.replaceChildren(); details.replaceChildren();
const heading = document.createElement("div"); const heading = document.createElement("div");
heading.className = "detail-head"; heading.className = "detail-head";
@ -2416,9 +2418,12 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
row.append(dt, dd); row.append(dt, dd);
dl.append(row); dl.append(row);
} }
const content = document.createElement("pre"); details.append(heading, badges, summary, dl);
content.textContent = node.content; if (includeContent) {
details.append(heading, badges, summary, dl, content); const content = document.createElement("pre");
content.textContent = node.content;
details.append(content);
}
} }
function closeNodeDialog() { function closeNodeDialog() {
const dialog = $("node-dialog"); const dialog = $("node-dialog");
@ -2430,18 +2435,39 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
if (dialog.open) dialog.close(); if (dialog.open) dialog.close();
else state.cardNode = null; 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 { try {
selectNode(nodeId); selectNode(nodeId);
setStatus(`Loading descriptor for ${nodeId}`); setStatus(`Loading descriptor for ${nodeId}`);
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"}); const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
const data = await api(`node?${params}`); const data = await api(`node?${params}`);
state.cardNode = nodeId; state.cardNode = nodeId;
renderDetails($("node-card-details"), data.node, data, true); renderDetails($("node-card-details"), data.node, data, true, false);
$("node-card-label").textContent = short(data.node.title, 72);
const dialog = $("node-card"); const dialog = $("node-card");
if (!dialog.open) dialog.showModal(); if (!dialog.open) {
$("close-node-card").focus(); dialog.style.visibility = "hidden";
dialog.show();
positionNodeCard(dialog, event);
dialog.style.visibility = "";
} else {
positionNodeCard(dialog, event);
}
setStatus(`Selected ${nodeId}`); setStatus(`Selected ${nodeId}`);
} catch (error) { } catch (error) {
setStatus(error.message, true); setStatus(error.message, true);
@ -2635,8 +2661,6 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
$("reset-view").addEventListener("click", resetViewport); $("reset-view").addEventListener("click", resetViewport);
$("close-node-dialog").addEventListener("click", closeNodeDialog); $("close-node-dialog").addEventListener("click", closeNodeDialog);
$("dismiss-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("left");
setupPanelResizer("right"); setupPanelResizer("right");
$("node-dialog").querySelector(".dialog-head").addEventListener("pointerdown", beginDialogDrag); $("node-dialog").querySelector(".dialog-head").addEventListener("pointerdown", beginDialogDrag);
@ -2668,13 +2692,6 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
setStatus(`${nodeCount} nodes · ${edgeCount} edges in neighborhood`); 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", () => { $("node-card").addEventListener("close", () => {
state.cardNode = null; state.cardNode = null;
if (state.graph) { if (state.graph) {
@ -2684,6 +2701,11 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
} }
}); });
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && $("node-card").open) {
event.preventDefault();
closeNodeCard();
return;
}
if (event.code !== "Space" || event.defaultPrevented if (event.code !== "Space" || event.defaultPrevented
|| $("node-dialog").open || $("node-card").open) { || $("node-dialog").open || $("node-card").open) {
return; return;
@ -2695,6 +2717,12 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
} }
if (centerSelectedNode()) event.preventDefault(); 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) => { $("graph").addEventListener("wheel", (event) => {
event.preventDefault(); event.preventDefault();
zoomAt(event.deltaY < 0 ? .85 : 1.18, event.clientX, event.clientY); zoomAt(event.deltaY < 0 ? .85 : 1.18, event.clientX, event.clientY);

View file

@ -130,7 +130,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
visualization = results[11].structuredContent["visualization"] visualization = results[11].structuredContent["visualization"]
self.assertTrue(visualization["read_only"]) self.assertTrue(visualization["read_only"])
self.assertTrue(visualization["project_bound"]) 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("managed_idle", visualization["lifetime"]["policy"])
self.assertEqual("docforge_stop_visualization", visualization["lifetime"]["stop_tool"]) self.assertEqual("docforge_stop_visualization", visualization["lifetime"]["stop_tool"])
self.assertTrue(visualization["url"].startswith("http://127.0.0.1:")) self.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))

View file

@ -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.x !== positions.get("child-two").x - 250) fail("center x");
if (centered.y !== positions.get("child-two").y - 150) fail("center y"); if (centered.y !== positions.get("child-two").y - 150) fail("center y");
if (relationStyle("calls").family !== "Execution") fail("calls family"); 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("reads").flow !== "reverse") fail("reads flow direction");
if (relationStyle("documents").flow !== null) fail("documents excluded from flow"); if (relationStyle("documents").flow !== null) fail("documents excluded from flow");
if (relationStyle("unknown_relation").family !== "Other") fail("fallback relation family"); if (relationStyle("unknown_relation").family !== "Other") fail("fallback relation family");
@ -188,18 +189,23 @@ const flowData = {
{node_id: "caller"}, {node_id: "caller"},
{node_id: "dependency"}, {node_id: "dependency"},
{node_id: "reader"}, {node_id: "reader"},
{node_id: "test-class"},
{node_id: "test-file"},
{node_id: "document"}, {node_id: "document"},
], ],
edges: [ edges: [
{source_id: "caller", relation: "calls", target_id: "primary"}, {source_id: "caller", relation: "calls", target_id: "primary"},
{source_id: "primary", relation: "depends_on", target_id: "dependency"}, {source_id: "primary", relation: "depends_on", target_id: "dependency"},
{source_id: "primary", relation: "reads", target_id: "reader"}, {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"}, {source_id: "document", relation: "documents", target_id: "primary"},
], ],
}; };
const flow = buildFlowGraph(flowData); const flow = buildFlowGraph(flowData);
const flowIds = new Set(flow.nodes.map((node) => node.node_id)); 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"); fail("upstream flow membership");
} }
if (flowIds.has("document")) fail("evidence leaked into flow"); 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") { if (dependencyEdge.source_id !== "dependency" || dependencyEdge.target_id !== "primary") {
fail("dependency semantic reversal"); 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( 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="node-card"', html)
self.assertIn('id="explore-node"', html) self.assertIn('id="explore-node"', html)
self.assertIn('id="explore-card-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="left-resizer"', html)
self.assertIn('id="right-resizer"', html) self.assertIn('id="right-resizer"', html)
self.assertIn('id="view-nodes"', html) self.assertIn('id="view-nodes"', html)

2
uv.lock generated
View file

@ -206,7 +206,7 @@ wheels = [
[[package]] [[package]]
name = "docforge" name = "docforge"
version = "0.12.0" version = "0.12.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "markdown-it-py" }, { name = "markdown-it-py" },