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

Add gated changeset application and graph controls

This commit is contained in:
Andraxion 2026-07-25 16:00:19 -04:00
parent 3c15e26283
commit 78335c8973
20 changed files with 1813 additions and 453 deletions

View file

@ -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@11"
VISUALIZATION_TEMPLATE = "graph-browser@12"
DEFAULT_EDGE_LIMIT = 100
MAX_EDGE_LIMIT = 400
MAX_LINEAGE_EDGE_LIMIT = 1_000
@ -65,7 +65,9 @@ class VisualizationIndexSnapshot:
def __init__(self, index: ProjectIndex, checked: dict[str, object]) -> None:
self.path = index.path
self.project_root = index.project.descriptor.root
self.title = index.project.descriptor.title
self.max_source_bytes = index.project.descriptor.limits.max_source_bytes
self.max_query_chars = index.project.descriptor.limits.max_query_chars
self.max_results = index.project.descriptor.limits.max_results
self.max_depth = index.project.descriptor.limits.max_traversal_depth
@ -77,19 +79,30 @@ class VisualizationIndexSnapshot:
snapshot = cls.__new__(cls)
path = spec["path"]
title = spec["title"]
project_root = spec["project_root"]
max_source_bytes = spec["max_source_bytes"]
max_query_chars = spec["max_query_chars"]
max_results = spec["max_results"]
max_depth = spec["max_depth"]
if (
not isinstance(path, str)
or not isinstance(title, str)
or not isinstance(project_root, str)
or type(max_source_bytes) is not int
or type(max_query_chars) is not int
or type(max_results) is not int
or type(max_depth) is not int
):
raise DocForgeError("invalid_index", "Visualization snapshot is invalid")
snapshot.path = Path(path)
try:
snapshot.project_root = Path(project_root).resolve(strict=True)
except OSError as error:
raise DocForgeError("invalid_index", "Visualization project root is invalid") from error
if not snapshot.project_root.is_dir():
raise DocForgeError("invalid_index", "Visualization project root is invalid")
snapshot.title = title
snapshot.max_source_bytes = max_source_bytes
snapshot.max_query_chars = max_query_chars
snapshot.max_results = max_results
snapshot.max_depth = max_depth
@ -104,7 +117,9 @@ class VisualizationIndexSnapshot:
def spec(self) -> dict[str, object]:
return {
"path": str(self.path),
"project_root": str(self.project_root),
"title": self.title,
"max_source_bytes": self.max_source_bytes,
"max_query_chars": self.max_query_chars,
"max_results": self.max_results,
"max_depth": self.max_depth,
@ -288,6 +303,68 @@ class VisualizationIndexSnapshot:
snapshot=True,
)
def source(self, node_id: str) -> dict[str, object]:
"""Return one node's bounded, project-confined UTF-8 source file."""
with self._connection() as connection:
row = connection.execute(
"SELECT node_id, source_path, source_anchor FROM nodes WHERE node_id = ?",
(node_id,),
).fetchone()
if row is None:
raise DocForgeError(
"missing_node",
"No node has the requested stable ID",
node_id=node_id,
)
relative = Path(row["source_path"])
if relative.is_absolute() or ".." in relative.parts or not relative.parts:
raise DocForgeError("path_escape", "Node source path is unsafe", node_id=node_id)
source = self.project_root / relative
try:
resolved = source.resolve(strict=True)
except OSError as error:
raise DocForgeError(
"missing_source",
"Node source file is unavailable",
node_id=node_id,
) from error
if (
source.is_symlink()
or resolved != source
or not source.is_relative_to(self.project_root)
or not source.is_file()
):
raise DocForgeError("path_escape", "Node source file is unsafe", node_id=node_id)
if source.stat().st_size > self.max_source_bytes:
raise DocForgeError(
"source_too_large",
"Node source exceeds the configured source limit",
node_id=node_id,
)
raw = source.read_bytes()
if len(raw) > self.max_source_bytes:
raise DocForgeError(
"source_too_large",
"Node source exceeds the configured source limit",
node_id=node_id,
)
try:
content = raw.decode("utf-8")
except UnicodeDecodeError as error:
raise DocForgeError(
"invalid_source",
"Node source is not UTF-8",
node_id=node_id,
) from error
return self._result(
node_id=node_id,
source_path=row["source_path"],
source_anchor=row["source_anchor"],
content=content,
snapshot=True,
)
def lineage(self, node_id: str, *, limit: int) -> dict[str, object]:
"""Return every bounded, directed ancestry path terminating at ``node_id``.
@ -686,6 +763,9 @@ class VisualizationRunner:
elif parsed.path == f"{prefix}/api/node":
self._touch_lease()
payload = self._node(reader, params)
elif parsed.path == f"{prefix}/api/source":
self._touch_lease()
payload = self._source(reader, params)
elif parsed.path == f"{prefix}/api/lineage":
self._touch_lease()
payload = self._lineage(reader, params)
@ -709,6 +789,9 @@ class VisualizationRunner:
"invalid_filter": HTTPStatus.BAD_REQUEST,
"invalid_depth": HTTPStatus.BAD_REQUEST,
"invalid_limit": HTTPStatus.BAD_REQUEST,
"path_escape": HTTPStatus.FORBIDDEN,
"missing_source": HTTPStatus.NOT_FOUND,
"source_too_large": HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
}.get(error.code, HTTPStatus.SERVICE_UNAVAILABLE)
self._respond_json(
handler,
@ -756,6 +839,16 @@ class VisualizationRunner:
)
return reader.node(node_id, depth=depth, limit=limit)
@staticmethod
def _source(
reader: VisualizationIndexSnapshot,
params: dict[str, list[str]],
) -> dict[str, object]:
node_id = _one(params, "id").strip()
if not node_id:
raise DocForgeError("missing_node", "One exact node ID is required")
return reader.source(node_id)
def _lineage(
self,
reader: VisualizationIndexSnapshot,
@ -1428,7 +1521,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
.canvas.dragging svg { cursor: grabbing; }
.viewport-controls {
position: absolute; z-index: 2; top: 12px; right: 12px;
display: grid; grid-template-columns: repeat(3, 36px) auto;
display: grid; grid-template-columns: repeat(3, 36px) auto auto;
align-items: center; gap: 6px; padding: 6px;
border: 1px solid var(--line); border-radius: 10px;
background: rgba(7, 16, 26, .9); box-shadow: 0 5px 18px rgba(0, 0, 0, .28);
@ -1444,6 +1537,14 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
min-width: 48px; padding: 0 5px; color: var(--muted);
font-variant-numeric: tabular-nums; text-align: right;
}
.restore-hidden {
min-width: 92px; height: 34px; border: 1px solid #31526d; border-radius: 7px;
padding: 0 10px; background: #102b3d; color: var(--text); font-size: 11px;
}
.restore-hidden:hover, .restore-hidden:focus-visible {
border-color: var(--accent); outline: 2px solid transparent;
}
.restore-hidden[hidden] { display: none; }
.viewport-hint {
position: absolute; z-index: 1; left: 12px; bottom: 12px;
padding: 5px 8px; border: 1px solid var(--line); border-radius: 7px;
@ -1547,9 +1648,9 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
}
dialog::backdrop { background: rgba(2, 8, 14, .48); }
.dialog-shell {
display: grid; grid-template-rows: auto auto auto; width: fit-content;
display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: fit-content;
min-width: min(360px, calc(100vw - 20px)); max-width: min(760px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
height: 100%; max-height: calc(100vh - 32px); overflow: hidden;
}
.dialog-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
@ -1583,6 +1684,22 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
border-top: 1px solid var(--line); background: var(--panel-2);
}
.compact-dialog .dialog-actions { padding: 9px 12px; background: rgba(12, 35, 51, .72); }
.source-dialog {
width: min(920px, calc(100vw - 32px)); height: min(760px, calc(100vh - 32px));
}
.source-dialog .dialog-shell {
width: 100%; max-width: none; min-width: 0; height: 100%;
}
.source-code {
display: block; margin: 0; min-width: max-content; font: 12px/1.55 ui-monospace, monospace;
counter-reset: source-line;
}
.source-line { display: block; min-height: 1.55em; padding: 0 12px 0 58px; position: relative; }
.source-line::before {
position: absolute; left: 0; width: 46px; color: #63809a; text-align: right;
content: attr(data-line);
}
.source-line.target { background: rgba(81, 215, 255, .16); color: #fff; }
.error { color: #ff9aac; }
@media (max-width: 980px) {
:root { --left-width: 240px; --right-width: 260px; }
@ -1646,6 +1763,9 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
<button class="viewport-control" id="reset-view" type="button"
title="Reset view" aria-label="Reset graph view"></button>
<output class="zoom-level" id="zoom-level" aria-live="polite">100%</output>
<button class="restore-hidden" id="restore-hidden" type="button" hidden>
Restore hidden
</button>
</div>
<details class="relationship-key" id="relationship-key" open>
<summary>
@ -1691,6 +1811,8 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
</div>
<div class="dialog-body" id="node-dialog-details"></div>
<div class="dialog-actions">
<button class="button" id="open-node-source" type="button">Open source</button>
<button class="button" id="hide-node" type="button">Hide node</button>
<button class="button" id="explore-node" type="button">Explore neighborhood</button>
<button class="button" id="dismiss-node-dialog" type="button">Close</button>
</div>
@ -1700,10 +1822,25 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
<div class="dialog-shell">
<div class="dialog-body" id="node-card-details"></div>
<div class="dialog-actions">
<button class="button" id="open-card-source" type="button">Open source</button>
<button class="button" id="hide-card-node" type="button">Hide node</button>
<button class="button" id="explore-card-node" type="button">Explore neighborhood</button>
</div>
</div>
</dialog>
<dialog class="source-dialog" id="source-dialog" aria-labelledby="source-dialog-label">
<div class="dialog-shell">
<div class="dialog-head">
<strong id="source-dialog-label">Node source</strong>
<button class="dialog-close" id="close-source-dialog" type="button"
aria-label="Close source">×</button>
</div>
<div class="dialog-body"><code class="source-code" id="source-code"></code></div>
<div class="dialog-actions">
<button class="button" id="dismiss-source-dialog" type="button">Close</button>
</div>
</div>
</dialog>
<script>
const base = location.pathname.replace(/\/?$/, "/");
const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820});
@ -1722,6 +1859,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
suppressClick: false,
inspectedNode: null,
cardNode: null,
hiddenNodes: new Set(),
dialogDrag: null,
leaseTimer: null,
};
@ -2338,7 +2476,27 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
&& data.nodes.some((node) => node.node_id === state.selectedNode)
? state.selectedNode
: data.root;
const view = state.mode === "flow" ? buildFlowGraph(data) : data;
const completeView = state.mode === "flow" ? buildFlowGraph(data) : data;
const visibleIds = new Set(
completeView.nodes
.filter((node) => node.node_id === completeView.root
|| !state.hiddenNodes.has(node.node_id))
.map((node) => node.node_id),
);
const view = {
...completeView,
nodes: completeView.nodes.filter((node) => visibleIds.has(node.node_id)),
edges: completeView.edges.filter(
(edge) => visibleIds.has(edge.source_id) && visibleIds.has(edge.target_id),
),
};
const hiddenCount = completeView.nodes.length - view.nodes.length;
const restore = $("restore-hidden");
restore.hidden = state.hiddenNodes.size === 0;
restore.textContent = `Restore hidden (${state.hiddenNodes.size})`;
restore.title = hiddenCount
? `${hiddenCount} hidden in this view; restore all hidden nodes`
: "Restore hidden nodes from other views";
state.selectedNode = view.nodes.some((node) => node.node_id === selectedCandidate)
? selectedCandidate
: view.root;
@ -2447,6 +2605,72 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
}
svg.append(definitions, edgeLayer, nodeLayer);
}
function hideNode(nodeId) {
if (!state.graph || nodeId === state.root) {
setStatus("The focus node cannot be hidden. Focus another node first.", true);
return;
}
state.hiddenNodes.add(nodeId);
closeNodeCard();
closeNodeDialog();
renderGraph(state.graph, true);
setStatus(`Hidden ${nodeId}. Restore hidden nodes from the graph controls.`);
}
function restoreHiddenNodes() {
const count = state.hiddenNodes.size;
state.hiddenNodes.clear();
if (state.graph) renderGraph(state.graph, true);
setStatus(`Restored ${count} hidden node${count === 1 ? "" : "s"}.`);
}
function anchorLine(content, anchor) {
const lines = content.split(/\r?\n/);
if (!anchor) return 1;
const numeric = /^(?:L|line[-_: ]?)?(\d+)$/i.exec(anchor.trim());
if (numeric) return Math.min(lines.length, Math.max(1, Number(numeric[1])));
const nodeAnchor = /^node-(\d+)$/i.exec(anchor.trim());
if (nodeAnchor) {
const wanted = Number(nodeAnchor[1]);
let count = 0;
for (let index = 0; index < lines.length; index += 1) {
if (lines[index].trim() === "[[nodes]]") count += 1;
if (count === wanted) return index + 1;
}
}
const plain = anchor.replace(/^#/, "").trim().toLowerCase();
const slug = (value) => value.toLowerCase().trim()
.replace(/^#+\s*/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
const direct = lines.findIndex((line) => line.toLowerCase().includes(plain));
if (direct >= 0) return direct + 1;
const heading = lines.findIndex((line) => slug(line) === slug(plain));
return heading >= 0 ? heading + 1 : 1;
}
async function openSource(nodeId) {
try {
setStatus(`Opening source for ${nodeId}`);
const data = await api(`source?${new URLSearchParams({id: nodeId})}`);
const code = $("source-code");
code.replaceChildren();
const targetLine = anchorLine(data.content, data.source_anchor);
for (const [index, text] of data.content.split(/\r?\n/).entries()) {
const line = document.createElement("span");
line.className = `source-line${index + 1 === targetLine ? " target" : ""}`;
line.dataset.line = String(index + 1);
line.textContent = text || " ";
code.append(line);
}
$("source-dialog-label").textContent = data.source_anchor
? `${data.source_path} · ${data.source_anchor}`
: data.source_path;
const dialog = $("source-dialog");
if (!dialog.open) dialog.showModal();
requestAnimationFrame(() => {
code.querySelector(".target")?.scrollIntoView({block: "center"});
});
setStatus(`Opened ${data.source_path} at ${data.source_anchor || "the first line"}.`);
} catch (error) {
setStatus(error.message, true);
}
}
function renderDetails(details, node, data, interactiveBadges = false, includeContent = true) {
details.replaceChildren();
const heading = document.createElement("div");
@ -2491,7 +2715,17 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
const dt = document.createElement("dt");
const dd = document.createElement("dd");
dt.textContent = label;
dd.textContent = escapeText(value);
if (label === "Source") {
const source = document.createElement("button");
source.type = "button";
source.className = "badge badge-button";
source.textContent = escapeText(value);
source.title = `Open ${value} at ${node.source_anchor || "the first line"}`;
source.addEventListener("click", () => openSource(node.node_id));
dd.append(source);
} else {
dd.textContent = escapeText(value);
}
row.append(dt, dd);
dl.append(row);
}
@ -2536,6 +2770,10 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
const data = await api(`node?${params}`);
state.cardNode = nodeId;
renderDetails($("node-card-details"), data.node, data, true, false);
$("hide-card-node").disabled = nodeId === state.root;
$("hide-card-node").title = nodeId === state.root
? "Focus another node before hiding this one"
: "Hide this node from the current visualization";
const dialog = $("node-card");
if (!dialog.open) {
dialog.style.visibility = "hidden";
@ -2558,6 +2796,10 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
const data = await api(`node?${params}`);
state.inspectedNode = nodeId;
renderDetails($("node-dialog-details"), data.node, data);
$("hide-node").disabled = nodeId === state.root;
$("hide-node").title = nodeId === state.root
? "Focus another node before hiding this one"
: "Hide this node from the current visualization";
$("node-dialog-label").textContent = short(data.node.title, 72);
const dialog = $("node-dialog");
if (!dialog.open) dialog.showModal();
@ -2734,8 +2976,11 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
$("zoom-in").addEventListener("click", () => zoomAt(.8));
$("zoom-out").addEventListener("click", () => zoomAt(1.25));
$("reset-view").addEventListener("click", resetViewport);
$("restore-hidden").addEventListener("click", restoreHiddenNodes);
$("close-node-dialog").addEventListener("click", closeNodeDialog);
$("dismiss-node-dialog").addEventListener("click", closeNodeDialog);
$("close-source-dialog").addEventListener("click", () => $("source-dialog").close());
$("dismiss-source-dialog").addEventListener("click", () => $("source-dialog").close());
setupPanelResizer("left");
setupPanelResizer("right");
$("node-dialog").querySelector(".dialog-head").addEventListener("pointerdown", beginDialogDrag);
@ -2747,6 +2992,18 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
closeNodeDialog();
if (nodeId) await loadNode(nodeId);
});
$("open-node-source").addEventListener("click", () => {
if (state.inspectedNode) openSource(state.inspectedNode);
});
$("open-card-source").addEventListener("click", () => {
if (state.cardNode) openSource(state.cardNode);
});
$("hide-node").addEventListener("click", () => {
if (state.inspectedNode) hideNode(state.inspectedNode);
});
$("hide-card-node").addEventListener("click", () => {
if (state.cardNode) hideNode(state.cardNode);
});
$("explore-card-node").addEventListener("click", async () => {
const nodeId = state.cardNode;
closeNodeCard();
@ -2776,13 +3033,18 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && $("source-dialog").open) {
event.preventDefault();
$("source-dialog").close();
return;
}
if (event.key === "Escape" && $("node-card").open) {
event.preventDefault();
closeNodeCard();
return;
}
if (event.code !== "Space" || event.defaultPrevented
|| $("node-dialog").open || $("node-card").open) {
|| $("node-dialog").open || $("node-card").open || $("source-dialog").open) {
return;
}
const target = event.target;