Bug fixes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
5
ac-ui/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
.vite
|
||||
*.local
|
||||
16
ac-ui/Dockerfile
Normal file
16
ac-ui/Dockerfile
Normal 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
|
||||
@@ -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
36
ac-ui/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
216
ac-ui/src/components/GraphView.tsx
Normal file
216
ac-ui/src/components/GraphView.tsx
Normal 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}}/>;
|
||||
}
|
||||
295
ac-ui/src/components/MigrationDossier.tsx
Normal file
295
ac-ui/src/components/MigrationDossier.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user