Bug fixes

This commit is contained in:
Ingo Schnabel
2026-07-14 09:59:06 +02:00
parent 438b0841d4
commit ebf25ad773
12 changed files with 701 additions and 13 deletions

View File

@@ -4,4 +4,4 @@
server.url=http://localhost:8787
# Stamped by deploy.sh (stamp_cli_version) from ac-code-server's agenticcode.version
# at build time. "dev" means this jar wasn't built via deploy.sh.
version=23
version=26

View File

@@ -3,7 +3,7 @@ quarkus.http.port=8787
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
# release. Single source of truth for the startup log line, GET /api/version, and the MCP
# 'version' tool/server-info (referenced below via property expression, not duplicated).
agenticcode.version=23
agenticcode.version=26
# MCP server (HTTP/SSE transport) — tools exposed at http://<host>:8787/mcp/sse
quarkus.mcp.server.server-info.name=agenticcode

5
ac-ui/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
dist-ssr
.vite
*.local

16
ac-ui/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# Multi-stage build: compile the SPA with Node, serve the static bundle with nginx.
# The build uses the committed src/api/schema.ts + package-lock.json, so it needs no
# running server. nginx serves the SPA and reverse-proxies /api and /q to ac-code-server
# (single browser origin -> no CORS needed), mirroring the Vite dev proxy.
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

View File

@@ -16,7 +16,12 @@ React/TypeScript web UI for browsing and navigating the AgenticCode code-graph
layout, expand-on-demand, direction/depth/limit controls, and
resolved/`unresolved`/dispatch/inheritance styling with a legend.
Data-flow & impact (M4) and the migration dossier (M3) come later.
- **M3 · Migration dossier** — a lazy **Dossier** tab (per-module "porting profile"):
payload (I/O contract), data structures (expand → fields), DB-access matrix
(READ/WRITE badges), SQL statements, and the dynamic-`CALLNAT` dispatch table. Line
numbers deep-link into the Source tab.
Data-flow & impact (M4) comes later.
## Stack

36
ac-ui/nginx.conf Normal file
View File

@@ -0,0 +1,36 @@
# SPA host for ac-ui. Serves the static bundle with a React-Router fallback and
# reverse-proxies the API (/api) + management (/q) endpoints to ac-code-server, so the
# browser only ever talks to this single origin (no CORS). Mirrors the Vite dev proxy.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Docker's embedded DNS; re-resolve so a recreated ac-code-server container
# (new IP after deploy.sh rm -f) is picked up without restarting nginx.
resolver 127.0.0.11 valid=10s;
# SPA deep-links: serve real files, otherwise fall back to index.html.
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
set $upstream http://ac-code-server:8787;
proxy_pass $upstream$request_uri;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /q/ {
set $upstream http://ac-code-server:8787;
proxy_pass $upstream$request_uri;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -107,6 +107,93 @@ export function useIdentifierSearch(project: string | undefined, name: string |
});
}
/** Migration-dossier sources (M3). Each is a per-module "porting profile" section. */
export function useModulePayload(project: string | undefined, name: string | undefined) {
return useQuery({
enabled: !!project && !!name,
queryKey: ["payload", project, name],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/payload", {
params: {path: {project: project!, name: name!}},
});
if (error) throw new Error("Failed to load payload");
return data;
},
});
}
export function useModuleDataStructures(project: string | undefined, name: string | undefined) {
return useQuery({
enabled: !!project && !!name,
queryKey: ["data-structures", project, name],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/data-structures", {
params: {path: {project: project!, name: name!}},
});
if (error) throw new Error("Failed to load data structures");
return data;
},
});
}
/** Fields of one data structure — enabled only when a structure row is expanded. */
export function useDataStructureFields(project: string | undefined, name: string | undefined, enabled: boolean) {
return useQuery({
enabled: enabled && !!project && !!name,
queryKey: ["ds-fields", project, name],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/data-structures/{name}/fields", {
params: {path: {project: project!, name: name!}},
});
if (error) throw new Error("Failed to load fields");
return data;
},
});
}
export function useDbAccesses(project: string | undefined, name: string | undefined) {
return useQuery({
enabled: !!project && !!name,
queryKey: ["db-accesses", project, name],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/db-accesses", {
params: {path: {project: project!, name: name!}},
});
if (error) throw new Error("Failed to load DB accesses");
return data;
},
});
}
export function useSqlStatements(project: string | undefined, name: string | undefined) {
return useQuery({
enabled: !!project && !!name,
queryKey: ["sql-statements", project, name],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/sql-statements", {
params: {path: {project: project!, name: name!}},
});
if (error) throw new Error("Failed to load SQL statements");
return data;
},
});
}
export function useDispatchTable(project: string | undefined, name: string | undefined) {
return useQuery({
enabled: !!project && !!name,
queryKey: ["dispatch-table", project, name],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/dispatch-table", {
params: {path: {project: project!, name: name!}},
});
if (error) throw new Error("Failed to load dispatch table");
return data;
},
});
}
/** Whole-project refresh (Tier-1 rescan). Slow on large projects — callers show a pending state. */
export function useRefreshProject(project: string | undefined) {
const qc = useQueryClient();

View File

@@ -0,0 +1,216 @@
import {useCallback, useEffect, useRef, useState} from "react";
import Graph from "graphology";
import Sigma from "sigma";
import forceAtlas2 from "graphology-layout-forceatlas2";
import {fetchEgoGraph} from "../api/hooks";
import type {EgoGraph} from "../api/client";
type Direction = "in" | "out" | "both";
interface Props {
project: string;
moduleName: string;
/** Open a module in the surrounding module view. */
onOpen: (name: string) => void;
}
const COLOR = {
root: "#2563eb",
resolved: "#64748b",
unresolved: "#d97706",
};
function edgeColor(edgeKind?: string): string {
if (edgeKind === "CALLNAT_DYNAMIC") return "#d97706";
if (edgeKind === "EXTENDS" || edgeKind === "IMPLEMENTS") return "#7c3aed";
return "#94a3b8";
}
/** Merges an ego-graph response into a graphology graph (idempotent), keyed on module name. */
function mergeEgo(graph: Graph, ego: EgoGraph, rootName: string) {
for (const n of ego.nodes ?? []) {
if (!n.name) continue;
const isRoot = n.name === rootName;
graph.mergeNode(n.name, {
label: n.name,
sourceFile: n.sourceFile ?? "",
unresolved: !!n.unresolved,
color: isRoot ? COLOR.root : n.unresolved ? COLOR.unresolved : COLOR.resolved,
// Seed positions so ForceAtlas2 doesn't collapse to NaN.
x: graph.hasNode(n.name) ? graph.getNodeAttribute(n.name, "x") : Math.random(),
y: graph.hasNode(n.name) ? graph.getNodeAttribute(n.name, "y") : Math.random(),
size: 4,
});
}
for (const e of ego.edges ?? []) {
if (!e.from || !e.to || !graph.hasNode(e.from) || !graph.hasNode(e.to)) continue;
const key = `${e.from}|${e.to}|${e.edgeKind ?? ""}`;
if (!graph.hasEdge(key)) {
graph.addDirectedEdgeWithKey(key, e.from, e.to, {
color: edgeColor(e.edgeKind),
size: 1.5,
edgeKind: e.edgeKind ?? "",
});
}
}
// Size nodes by degree.
graph.forEachNode((node) => {
graph.setNodeAttribute(node, "size", 4 + Math.sqrt(graph.degree(node)) * 1.6);
});
}
function layout(graph: Graph) {
if (graph.order > 1) {
forceAtlas2.assign(graph, {iterations: 150, settings: forceAtlas2.inferSettings(graph)});
}
}
export default function GraphView({project, moduleName, onOpen}: Props) {
const hostRef = useRef<HTMLDivElement>(null);
const sigmaRef = useRef<Sigma | null>(null);
const graphRef = useRef<Graph | null>(null);
const [direction, setDirection] = useState<Direction>("both");
const [depth, setDepth] = useState(1);
const [limit, setLimit] = useState(40);
const [selected, setSelected] = useState<string | null>(null);
const [truncated, setTruncated] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expanding, setExpanding] = useState(false);
// (Re)seed the graph when the module or controls change.
useEffect(() => {
let disposed = false;
setError(null);
setSelected(null);
fetchEgoGraph(project, moduleName, direction, depth, limit)
.then((ego) => {
if (disposed || !hostRef.current) return;
const graph = new Graph({multi: true, type: "directed"});
mergeEgo(graph, ego, moduleName);
layout(graph);
setTruncated(!!ego.truncated);
sigmaRef.current?.kill();
graphRef.current = graph;
const sigma = new Sigma(graph, hostRef.current, {
defaultEdgeType: "arrow",
renderEdgeLabels: false,
labelDensity: 0.6,
});
sigma.on("clickNode", ({node}) => setSelected(node));
sigma.on("clickStage", () => setSelected(null));
sigmaRef.current = sigma;
})
.catch(() => !disposed && setError("failed to load graph"));
return () => {
disposed = true;
sigmaRef.current?.kill();
sigmaRef.current = null;
};
}, [project, moduleName, direction, depth, limit]);
const expand = useCallback(
async (node: string) => {
const graph = graphRef.current;
if (!graph) return;
setExpanding(true);
try {
const ego = await fetchEgoGraph(project, node, "out", 1, limit);
mergeEgo(graph, ego, moduleName);
layout(graph);
if (ego.truncated) setTruncated(true);
sigmaRef.current?.refresh();
} catch {
setError("expand failed");
} finally {
setExpanding(false);
}
},
[project, moduleName, limit],
);
const selectedUnresolved =
selected != null && graphRef.current?.getNodeAttribute(selected, "unresolved") === true;
return (
<div className="relative h-full">
<div ref={hostRef} className="absolute inset-0"/>
{/* Controls */}
<div
className="absolute left-2 top-2 z-10 flex flex-wrap items-center gap-2 rounded border border-neutral-300 bg-white/90 px-2 py-1 text-xs shadow dark:border-neutral-700 dark:bg-neutral-900/90">
<label className="flex items-center gap-1">
dir
<select
value={direction}
onChange={(e) => setDirection(e.target.value as Direction)}
className="rounded border border-neutral-300 bg-transparent px-1 dark:border-neutral-700"
>
<option value="both">both</option>
<option value="out">out</option>
<option value="in">in</option>
</select>
</label>
<label className="flex items-center gap-1">
depth
<input
type="number"
min={1}
max={5}
value={depth}
onChange={(e) => setDepth(Math.max(1, Number(e.target.value) || 1))}
className="w-12 rounded border border-neutral-300 bg-transparent px-1 dark:border-neutral-700"
/>
</label>
<label className="flex items-center gap-1">
limit
<input
type="number"
min={5}
max={300}
value={limit}
onChange={(e) => setLimit(Math.max(5, Number(e.target.value) || 5))}
className="w-16 rounded border border-neutral-300 bg-transparent px-1 dark:border-neutral-700"
/>
</label>
{truncated && <span className="text-amber-600 dark:text-amber-400">truncated</span>}
{expanding && <span className="text-neutral-400">expanding…</span>}
{error && <span className="text-red-500">{error}</span>}
</div>
{/* Legend */}
<div
className="absolute bottom-2 left-2 z-10 flex flex-col gap-0.5 rounded border border-neutral-300 bg-white/90 px-2 py-1 text-[10px] text-neutral-500 shadow dark:border-neutral-700 dark:bg-neutral-900/90">
<span><Dot c={COLOR.root}/> root</span>
<span><Dot c={COLOR.resolved}/> module</span>
<span><Dot c={COLOR.unresolved}/> unresolved / dynamic</span>
<span><Dot c="#7c3aed"/> inheritance edge</span>
</div>
{/* Selected-node actions */}
{selected && (
<div
className="absolute right-2 top-2 z-10 flex items-center gap-2 rounded border border-neutral-300 bg-white/95 px-2 py-1 text-xs shadow dark:border-neutral-700 dark:bg-neutral-900/95">
<span className="max-w-40 truncate font-medium">{selected}</span>
<button
onClick={() => expand(selected)}
className="rounded border border-neutral-300 px-1.5 py-0.5 hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-800"
>
expand
</button>
<button
onClick={() => onOpen(selected)}
disabled={selectedUnresolved}
className="rounded border border-neutral-300 px-1.5 py-0.5 hover:bg-neutral-100 disabled:opacity-40 dark:border-neutral-700 dark:hover:bg-neutral-800"
title={selectedUnresolved ? "unresolved target has no module" : "open module"}
>
open
</button>
</div>
)}
</div>
);
}
function Dot({c}: { c: string }) {
return <span className="mr-1 inline-block h-2 w-2 rounded-full align-middle" style={{backgroundColor: c}}/>;
}

View File

@@ -0,0 +1,295 @@
import {useState} from "react";
import {
useDataStructureFields,
useDbAccesses,
useDispatchTable,
useModuleDataStructures,
useModulePayload,
useSqlStatements,
} from "../api/hooks";
interface Props {
project: string;
moduleName: string;
/** Jump to a 1-based line in the Source tab (same module). */
onOpenLine: (line: number) => void;
}
/**
* Migration dossier (M3): a per-module "porting profile" bundling the module's I/O contract
* (payload), data structures/fields, DB access, SQL and dispatch table — everything needed to
* port a Natural module to Java. Read-only consumer of existing endpoints.
*/
export function MigrationDossier({project, moduleName, onOpenLine}: Props) {
return (
<div className="space-y-6 p-4 text-sm">
<PayloadSection project={project} moduleName={moduleName} onOpenLine={onOpenLine}/>
<DataStructuresSection project={project} moduleName={moduleName} onOpenLine={onOpenLine}/>
<DbAccessSection project={project} moduleName={moduleName} onOpenLine={onOpenLine}/>
<SqlSection project={project} moduleName={moduleName} onOpenLine={onOpenLine}/>
<DispatchSection project={project} moduleName={moduleName} onOpenLine={onOpenLine}/>
</div>
);
}
/** A 1-based line number rendered as a jump-to-source link. */
function LineLink({line, onOpenLine}: { line?: number; onOpenLine: (l: number) => void }) {
if (!line || line <= 0) return <span className="text-neutral-300 dark:text-neutral-600">—</span>;
return (
<button
onClick={() => onOpenLine(line)}
className="tabular-nums text-blue-600 hover:underline dark:text-blue-400"
title="Open in Source"
>
{line}
</button>
);
}
function Section({title, count, children}: { title: string; count: number; children: React.ReactNode }) {
return (
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">
{title} <span className="font-normal">({count})</span>
</h3>
{count === 0 ? <p className="text-xs text-neutral-400">none</p> : children}
</section>
);
}
function TableWrap({children}: { children: React.ReactNode }) {
return (
<div className="overflow-x-auto rounded border border-neutral-200 dark:border-neutral-800">
<table className="w-full text-left text-xs">{children}</table>
</div>
);
}
const TH = "px-2 py-1 font-medium text-neutral-500";
const TD = "px-2 py-1 align-top";
// ── Payload ────────────────────────────────────────────────────────────────
function PayloadSection({project, moduleName, onOpenLine}: Props) {
const {data, isLoading, isError} = useModulePayload(project, moduleName);
const rows = data ?? [];
if (isLoading) return <p className="text-xs text-neutral-400">loading payload…</p>;
if (isError) return <p className="text-xs text-red-500">failed to load payload</p>;
return (
<Section title="Payload (I/O contract)" count={rows.length}>
<TableWrap>
<thead className="border-b border-neutral-200 dark:border-neutral-800">
<tr>
<th className={TH}>tag</th>
<th className={TH}>field</th>
<th className={TH}>direction</th>
<th className={TH}>source</th>
<th className={TH}>line</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={`${r.tag}-${r.field}-${i}`}
className="border-b border-neutral-100 last:border-0 dark:border-neutral-900">
<td className={`${TD} font-mono`}>{r.tag}</td>
<td className={`${TD} font-mono`}>{r.field}</td>
<td className={TD}>{r.direction}</td>
<td className={TD}>{r.source}</td>
<td className={TD}><LineLink line={r.lineNo} onOpenLine={onOpenLine}/></td>
</tr>
))}
</tbody>
</TableWrap>
</Section>
);
}
// ── Data structures + fields ───────────────────────────────────────────────
function DataStructuresSection({project, moduleName, onOpenLine}: Props) {
const {data, isLoading, isError} = useModuleDataStructures(project, moduleName);
const rows = data ?? [];
if (isLoading) return <p className="text-xs text-neutral-400">loading data structures…</p>;
if (isError) return <p className="text-xs text-red-500">failed to load data structures</p>;
return (
<Section title="Data structures" count={rows.length}>
<ul className="space-y-1">
{rows.map((r, i) => (
<DataStructureRow key={`${r.name}-${i}`} project={project} onOpenLine={onOpenLine}
name={r.name} relationship={r.relationship} area={r.area}
fieldCount={r.fieldCount}/>
))}
</ul>
</Section>
);
}
function DataStructureRow({project, name, relationship, area, fieldCount, onOpenLine}: {
project: string;
name?: string;
relationship?: string;
area?: string;
fieldCount?: number;
onOpenLine: (l: number) => void;
}) {
const [open, setOpen] = useState(false);
const {data, isLoading} = useDataStructureFields(project, name, open);
const fields = data ?? [];
return (
<li className="rounded border border-neutral-200 dark:border-neutral-800">
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 px-2 py-1.5 text-left hover:bg-neutral-50 dark:hover:bg-neutral-900"
>
<span className="text-neutral-400">{open ? "▾" : "▸"}</span>
<span className="font-mono text-xs">{name}</span>
{relationship && <span
className="rounded bg-neutral-100 px-1 text-[10px] text-neutral-500 dark:bg-neutral-800">{relationship}</span>}
{area && <span className="text-[10px] text-neutral-400">{area}</span>}
{typeof fieldCount === "number" &&
<span className="ml-auto text-[10px] text-neutral-400">{fieldCount} fields</span>}
</button>
{open && (
<div className="border-t border-neutral-100 px-2 py-1 dark:border-neutral-900">
{isLoading && <p className="text-xs text-neutral-400">loading fields…</p>}
{!isLoading && fields.length === 0 && <p className="text-xs text-neutral-400">no fields</p>}
{fields.length > 0 && (
<TableWrap>
<thead className="border-b border-neutral-200 dark:border-neutral-800">
<tr>
<th className={TH}>field</th>
<th className={TH}>type</th>
<th className={TH}>value</th>
<th className={TH}>line</th>
</tr>
</thead>
<tbody>
{fields.map((f, i) => (
<tr key={`${f.name}-${i}`}
className="border-b border-neutral-100 last:border-0 dark:border-neutral-900">
<td className={`${TD} font-mono`}
style={{paddingLeft: f.parent ? 16 : undefined}}>{f.name}</td>
<td className={TD}>{f.dataType ?? f.type}</td>
<td className={`${TD} font-mono`}>{f.value}</td>
<td className={TD}><LineLink line={f.startLine} onOpenLine={onOpenLine}/></td>
</tr>
))}
</tbody>
</TableWrap>
)}
</div>
)}
</li>
);
}
// ── DB access ──────────────────────────────────────────────────────────────
function DbAccessSection({project, moduleName, onOpenLine}: Props) {
const {data, isLoading, isError} = useDbAccesses(project, moduleName);
const rows = data ?? [];
if (isLoading) return <p className="text-xs text-neutral-400">loading DB access…</p>;
if (isError) return <p className="text-xs text-red-500">failed to load DB access</p>;
return (
<Section title="DB access" count={rows.length}>
<TableWrap>
<thead className="border-b border-neutral-200 dark:border-neutral-800">
<tr>
<th className={TH}>table / view</th>
<th className={TH}>mode</th>
<th className={TH}>via</th>
<th className={TH}>lines</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={`${r.name}-${i}`}
className="border-b border-neutral-100 last:border-0 dark:border-neutral-900">
<td className={`${TD} font-mono`}>{r.name}</td>
<td className={TD}><ModeBadge mode={r.mode}/></td>
<td className={TD}>{r.via}</td>
<td className={`${TD} space-x-1`}>
{(r.lineNos ?? []).map((l, j) => (
<span key={j}><LineLink line={l} onOpenLine={onOpenLine}/></span>
))}
</td>
</tr>
))}
</tbody>
</TableWrap>
</Section>
);
}
function ModeBadge({mode}: { mode?: string }) {
if (!mode) return null;
const write = /WRITE|STORE|UPDATE|DELETE|INSERT/i.test(mode);
return (
<span className={`rounded px-1 text-[10px] ${
write
? "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
}`}>{mode}</span>
);
}
// ── SQL ────────────────────────────────────────────────────────────────────
function SqlSection({project, moduleName, onOpenLine}: Props) {
const {data, isLoading, isError} = useSqlStatements(project, moduleName);
const rows = data ?? [];
if (isLoading) return <p className="text-xs text-neutral-400">loading SQL…</p>;
if (isError) return <p className="text-xs text-red-500">failed to load SQL</p>;
return (
<Section title="SQL statements" count={rows.length}>
<ul className="space-y-1">
{rows.map((r, i) => (
<li key={i} className="rounded border border-neutral-200 p-2 dark:border-neutral-800">
<div className="mb-1 flex items-center gap-2 text-xs">
<ModeBadge mode={r.mode}/>
<span className="font-mono">{r.table ?? r.view}</span>
{r.via && <span className="text-[10px] text-neutral-400">via {r.via}</span>}
<span className="ml-auto text-[10px] text-neutral-400">
<LineLink line={r.startLine} onOpenLine={onOpenLine}/>
</span>
</div>
{r.statement && (
<pre
className="overflow-x-auto whitespace-pre-wrap rounded bg-neutral-50 p-2 font-mono text-[11px] dark:bg-neutral-900">{r.statement}</pre>
)}
</li>
))}
</ul>
</Section>
);
}
// ── Dispatch table ─────────────────────────────────────────────────────────
function DispatchSection({project, moduleName, onOpenLine}: Props) {
const {data, isLoading, isError} = useDispatchTable(project, moduleName);
const rows = data ?? [];
if (isLoading) return <p className="text-xs text-neutral-400">loading dispatch table…</p>;
if (isError) return <p className="text-xs text-red-500">failed to load dispatch table</p>;
return (
<Section title="Dispatch table" count={rows.length}>
<TableWrap>
<thead className="border-b border-neutral-200 dark:border-neutral-800">
<tr>
<th className={TH}>guard field</th>
<th className={TH}>guard value</th>
<th className={TH}>assigned field</th>
<th className={TH}>assigned value</th>
<th className={TH}>line</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-b border-neutral-100 last:border-0 dark:border-neutral-900">
<td className={`${TD} font-mono`}>{r.guardField}</td>
<td className={`${TD} font-mono`}>{r.guardValue}</td>
<td className={`${TD} font-mono`}>{r.assignedField}</td>
<td className={`${TD} font-mono`}>{r.assignedValue}</td>
<td className={TD}><LineLink line={r.lineNo} onOpenLine={onOpenLine}/></td>
</tr>
))}
</tbody>
</TableWrap>
</Section>
);
}

View File

@@ -7,11 +7,12 @@ import {SourceView} from "./SourceView";
import {CallPanel} from "./CallPanel";
import {CallTree} from "./CallTree";
import {IdentifierPopover} from "./IdentifierPopover";
import {MigrationDossier} from "./MigrationDossier";
// Lazy-loaded (M2): keeps Sigma/WebGL out of the initial bundle.
const GraphView = lazy(() => import("./GraphView"));
type Tab = "overview" | "source" | "calls" | "tree" | "graph";
type Tab = "overview" | "source" | "dossier" | "calls" | "tree" | "graph";
interface Props {
project: string;
@@ -20,19 +21,22 @@ interface Props {
tab: Tab;
onTab: (tab: Tab) => void;
onOpen: (name: string) => void;
/** Jump to a line in the Source tab of the current module (M3 dossier line-links). */
onOpenLine: (line: number) => void;
onClose: () => void;
}
const TABS: { id: Tab; label: string }[] = [
{id: "overview", label: "Overview"},
{id: "source", label: "Source"},
{id: "dossier", label: "Dossier"},
{id: "overview", label: "Overview"},
{id: "calls", label: "Calls"},
{id: "tree", label: "Call-tree"},
{id: "graph", label: "Graph"},
];
/** Tabbed module view (M1): Overview / Source (CodeMirror) / Calls / lazy Call-tree, with identify. */
export function ModuleView({project, module, line, tab, onTab, onOpen, onClose}: Props) {
export function ModuleView({project, module, line, tab, onTab, onOpen, onOpenLine, onClose}: Props) {
const refresh = useRefreshModule(project);
const [identify, setIdentify] = useState<string | null>(null);
@@ -85,6 +89,9 @@ export function ModuleView({project, module, line, tab, onTab, onOpen, onClose}:
onIdentify={setIdentify}
/>
)}
{tab === "dossier" && (
<MigrationDossier project={project} moduleName={module.name!} onOpenLine={onOpenLine}/>
)}
{tab === "calls" && <CallPanel project={project} moduleName={module.name!} onOpen={onOpen}/>}
{tab === "tree" && <CallTree project={project} moduleName={module.name!} onOpen={onOpen}/>}
{tab === "graph" && (

View File

@@ -5,7 +5,7 @@ import {ModuleTable} from "../components/ModuleTable";
import {ModuleView, type Tab} from "../components/ModuleView";
import type {ModuleInfo} from "../api/client";
const TAB_IDS = new Set<Tab>(["overview", "source", "calls", "tree", "graph"]);
const TAB_IDS = new Set<Tab>(["overview", "source", "dossier", "calls", "tree", "graph"]);
/** Module explorer: filterable/searchable virtualized list + a tabbed module view. URL-driven. */
export function Explorer() {
@@ -32,7 +32,7 @@ export function Explorer() {
const selected = modules.find((m) => m.name === name);
const tabParam = searchParams.get("tab") as Tab | null;
const tab: Tab = tabParam && TAB_IDS.has(tabParam) ? tabParam : "overview";
const tab: Tab = tabParam && TAB_IDS.has(tabParam) ? tabParam : "source";
const lineParam = Number(searchParams.get("line"));
const line = Number.isFinite(lineParam) && lineParam > 0 ? lineParam : undefined;
@@ -52,13 +52,23 @@ export function Explorer() {
});
}
/** Dossier line-links: switch to the Source tab of the same module, scrolled to the line. */
function openLine(l: number) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", "source");
next.set("line", String(l));
return next;
});
}
function closeModule() {
navigate(`/p/${project}`);
}
return (
<div className="flex h-full min-h-0">
<div className="flex min-h-0 flex-1 flex-col">
<div className={`flex min-h-0 flex-col ${selected ? "w-80 shrink-0 xl:w-96" : "flex-1"}`}>
<div
className="flex flex-wrap items-center gap-2 border-b border-neutral-200 px-3 py-2 dark:border-neutral-800">
<input
@@ -97,7 +107,7 @@ export function Explorer() {
</div>
{selected && (
<div className="hidden w-[32rem] shrink-0 lg:block xl:w-[40rem]">
<div className="hidden min-w-0 flex-1 lg:block">
<ModuleView
project={project!}
module={selected}
@@ -105,6 +115,7 @@ export function Explorer() {
line={line}
onTab={setTab}
onOpen={openByName}
onOpenLine={openLine}
onClose={closeModule}
/>
</div>

View File

@@ -60,10 +60,20 @@ Frontend (detail per milestone in `x-docs/ui-proposal.md`):
expand-on-demand (one hop, merged), direction/depth/limit controls + `truncated`
badge, `unresolved`/dispatch (`CALLNAT_DYNAMIC`)/inheritance edge styling + legend,
open-module (disabled for unresolved). No backend change (pure consumer).
- **M3 Migration dossier** — data structures/fields, DB-access matrix, dispatch
tables, SQL — bundled per module as a "porting profile".
- **M3 Migration dossier** — **DONE 2026-07-14.** A lazy per-module **Dossier** tab
(`MigrationDossier.tsx`) bundling the "porting profile": payload (I/O contract),
data structures (expand-on-demand → fields), DB-access matrix (READ/WRITE badges),
SQL statements, and the dynamic-`CALLNAT` dispatch table. Line numbers deep-link into
the Source tab (`?tab=source&line=`). Pure consumer of existing endpoints
(`payload`, `data-structures`/`data-structures/{name}/fields`, `db-accesses`,
`sql-statements`, `dispatch-table`) — no backend change.
- **M4 Data-flow & impact** — flow-forward/backward / field-flow visualisation,
"what breaks?" impact analysis.
"what breaks?" impact analysis. **Includes: make identifier-popover VARIABLE matches
actionable** — deferred from M1 (only MODULE matches navigate today). Clicking a
variable should surface its reads/writes and data-flow (`variable_reads` /
`variable_writes` / `field_flow` / `flow_forward`/`flow_backward`) and jump-to-definition
(`IdentifierMatch` already carries `sourceFile` + `startLine`); design the whole variable
interaction here rather than as a one-off popover action.
- **M5 Understanding boosters** — source + parsed structure side by side,
notes/saved views, diff after refresh, optional LLM summaries from
`module_context`.