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=27
|
||||
version=32
|
||||
|
||||
@@ -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=27
|
||||
agenticcode.version=32
|
||||
|
||||
# MCP server (HTTP/SSE transport) — tools exposed at http://<host>:8787/mcp/sse
|
||||
quarkus.mcp.server.server-info.name=agenticcode
|
||||
|
||||
5
ac-ui/.gitignore
vendored
5
ac-ui/.gitignore
vendored
@@ -4,6 +4,11 @@ dist-ssr
|
||||
*.local
|
||||
.vite
|
||||
|
||||
# Playwright e2e
|
||||
test-results
|
||||
playwright-report
|
||||
.playwright
|
||||
|
||||
# NOT ignored (committed on purpose):
|
||||
# src/api/schema.ts — generated OpenAPI types (npm run gen:api), committed so the app
|
||||
# builds without the server running
|
||||
|
||||
109
ac-ui/e2e/identifier-popover.spec.ts
Normal file
109
ac-ui/e2e/identifier-popover.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {expect, Page, test} from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Ctrl-clicks a specific identifier *inside the CodeMirror editor* (not the outline or any other UI),
|
||||
* at the exact character rectangle, reproducing the source viewer's identify gesture. CodeMirror
|
||||
* renders a line as a single text node, so we locate the substring with a DOM Range to get real
|
||||
* coordinates.
|
||||
*/
|
||||
async function ctrlClickToken(page: Page, text: string): Promise<void> {
|
||||
const box = await page.evaluate((needle) => {
|
||||
const content = document.querySelector(".cm-content");
|
||||
if (!content) return null;
|
||||
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
const idx = (node.textContent ?? "").indexOf(needle);
|
||||
if (idx >= 0) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, idx + needle.length);
|
||||
const r = range.getBoundingClientRect();
|
||||
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, text);
|
||||
if (!box) throw new Error(`token "${text}" not found in the editor viewport`);
|
||||
await page.keyboard.down("Control");
|
||||
await page.mouse.click(box.x, box.y);
|
||||
await page.keyboard.up("Control");
|
||||
}
|
||||
|
||||
async function openSource(page: Page, project: string, module: string, nearLine: number, token: string): Promise<void> {
|
||||
await page.goto(`/p/${project}/m/${module}?tab=source&line=${nearLine}`);
|
||||
await expect(page.locator(".cm-content")).toBeVisible();
|
||||
// Wait until the target token is actually rendered in the viewport.
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate((t) => (document.querySelector(".cm-content")?.textContent ?? "").includes(t), token),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* REFERENCE click → definition. Clicking a PERFORM of ESCAPE-LOOP (line 721) jumps to the subroutine's
|
||||
* definition (323). Resolved via the scoped function list, so it works even in `upms` where 80+ modules
|
||||
* define their own ESCAPE-LOOP and WGEAGB0S's one falls outside the capped global search — and no
|
||||
* cross-module "N matches" list appears (the originally-reported bug).
|
||||
*/
|
||||
test("Ctrl-click a PERFORM reference jumps to the definition, scoped to this module (upms/WGEAGB0S)", async ({page}) => {
|
||||
await openSource(page, "upms", "WGEAGB0S", 715, "ESCAPE-LOOP");
|
||||
await ctrlClickToken(page, "ESCAPE-LOOP");
|
||||
|
||||
await expect(page).toHaveURL(/[?&]line=323\b/);
|
||||
await expect(page.getByText(/\d+ matches/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
/** Same reference→definition jump in the single-match fixture project. */
|
||||
test("Ctrl-click a PERFORM reference jumps to the definition (ac/WGEAGB0S)", async ({page}) => {
|
||||
await openSource(page, "ac", "WGEAGB0S", 715, "ESCAPE-LOOP");
|
||||
await ctrlClickToken(page, "ESCAPE-LOOP");
|
||||
|
||||
await expect(page).toHaveURL(/[?&]line=323\b/);
|
||||
await expect(page.getByText(/\d+ matches/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* DEFINITION click, single caller → jump to it. INITIALIZATION is defined at 383 and PERFORMed exactly
|
||||
* once (line 311), so clicking the DEFINE line jumps straight to 311.
|
||||
*/
|
||||
test("Ctrl-click a subroutine's definition jumps to its single PERFORM site (ac/WGEAGB0S)", async ({page}) => {
|
||||
await openSource(page, "ac", "WGEAGB0S", 383, "INITIALIZATION");
|
||||
await ctrlClickToken(page, "INITIALIZATION");
|
||||
|
||||
await expect(page).toHaveURL(/[?&]line=311\b/);
|
||||
await expect(page.getByText(/PERFORM sites/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* DEFINITION click, multiple callers → a picker (no auto-jump). ADD-XML-LINE (def 645) is PERFORMed
|
||||
* many times; picking a site navigates there.
|
||||
*/
|
||||
test("Ctrl-click a subroutine's definition with multiple PERFORM sites opens a picker (ac/WGEAGB0S)", async ({page}) => {
|
||||
await openSource(page, "ac", "WGEAGB0S", 645, "ADD-XML-LINE");
|
||||
await ctrlClickToken(page, "ADD-XML-LINE");
|
||||
|
||||
await expect(page.getByText(/PERFORM sites \(\d+\)/)).toBeVisible();
|
||||
const sites = page.getByRole("button", {name: /^PERFORM line \d+$/});
|
||||
await expect.poll(async () => sites.count()).toBeGreaterThan(1);
|
||||
|
||||
await sites.first().click();
|
||||
await expect(page).toHaveURL(/[?&]line=\d+/);
|
||||
});
|
||||
|
||||
/**
|
||||
* The user's exact reported case in the real project: clicking ESCAPE-LOOP on its DEFINE line (323) in
|
||||
* upms/WGEAGB0S must show its local PERFORM-site picker — NOT a list of the 80+ other modules'
|
||||
* ESCAPE-LOOP. It has 4 PERFORM sites here.
|
||||
*/
|
||||
test("Ctrl-click a subroutine's definition shows local PERFORM sites, not a cross-module list (upms/WGEAGB0S)", async ({page}) => {
|
||||
await openSource(page, "upms", "WGEAGB0S", 323, "ESCAPE-LOOP");
|
||||
await ctrlClickToken(page, "ESCAPE-LOOP");
|
||||
|
||||
await expect(page.getByText(/PERFORM sites \(\d+\)/)).toBeVisible();
|
||||
await expect(page.getByText(/\d+ matches/)).toHaveCount(0);
|
||||
await expect(page.getByText(/in other modules/)).toHaveCount(0);
|
||||
const sites = page.getByRole("button", {name: /^PERFORM line \d+$/});
|
||||
await expect.poll(async () => sites.count()).toBeGreaterThan(1);
|
||||
});
|
||||
18
ac-ui/e2e/module-explorer.spec.ts
Normal file
18
ac-ui/e2e/module-explorer.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
|
||||
/**
|
||||
* The module explorer's search box accepts a regex, matched (case-insensitively) against module
|
||||
* name and source file. `^WGEAGB0S$` anchors to exactly one module.
|
||||
*/
|
||||
test("module list filters by regex", async ({page}) => {
|
||||
await page.goto("/p/upms");
|
||||
// Wait for the list to load (the count badge shows "<n> modules").
|
||||
await expect(page.getByText(/\d{2,} modules/)).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder(/Search name or file/).fill("^WGEAGB0S$");
|
||||
await expect(page.getByText(/^1 modules$/)).toBeVisible();
|
||||
|
||||
// A broader anchored pattern matches several (WACOMB2S, WACOMB3S, …).
|
||||
await page.getByPlaceholder(/Search name or file/).fill("^WACOMB.S$");
|
||||
await expect(page.getByText(/^[2-9]\d* modules$/)).toBeVisible();
|
||||
});
|
||||
64
ac-ui/package-lock.json
generated
64
ac-ui/package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"sigma": "^3.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
@@ -941,6 +942,22 @@
|
||||
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/ajv": {
|
||||
"version": "8.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
||||
@@ -2679,6 +2696,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pluralize": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"gen:api": "openapi-typescript http://localhost:8787/q/openapi -o src/api/schema.ts"
|
||||
"gen:api": "openapi-typescript http://localhost:8787/q/openapi -o src/api/schema.ts",
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-java": "^6.0.1",
|
||||
@@ -28,6 +29,7 @@
|
||||
"sigma": "^3.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
|
||||
19
ac-ui/playwright.config.ts
Normal file
19
ac-ui/playwright.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {defineConfig, devices} from "@playwright/test";
|
||||
|
||||
/**
|
||||
* E2E tests run against the deployed stack (nginx UI container + ac-code-server), i.e.
|
||||
* `./deploy.sh up` must be running and the `ac` project ingested. The UI is served at :5174 and
|
||||
* proxies /api to the server, so tests exercise the real API — no mocking.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
timeout: 30_000,
|
||||
expect: {timeout: 10_000},
|
||||
fullyParallel: true,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: process.env.AC_UI_URL ?? "http://localhost:5174",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [{name: "chromium", use: {...devices["Desktop Chrome"]}}],
|
||||
});
|
||||
@@ -128,6 +128,25 @@ export function useCalls(project: string | undefined, name: string | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal (intra-module) callees of a module — the FUNCTION→FUNCTION PERFORM edges with their
|
||||
* call-site line numbers. Used to resolve "where is this subroutine PERFORMed" (the callee entry's
|
||||
* lineNos are its PERFORM sites). Enabled lazily.
|
||||
*/
|
||||
export function useInternalCallees(project: string | undefined, module: string | undefined, enabled: boolean) {
|
||||
return useQuery({
|
||||
enabled: enabled && !!project && !!module,
|
||||
queryKey: ["callees-internal", project, module],
|
||||
queryFn: async () => {
|
||||
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/callees", {
|
||||
params: {path: {project: project!, name: module!}, query: {scope: "internal"}},
|
||||
});
|
||||
if (error) throw new Error("Failed to load internal callees");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Name-based identifier lookup (M1 click-to-identify). Disabled until a name is given. */
|
||||
export function useIdentifierSearch(project: string | undefined, name: string | undefined) {
|
||||
return useQuery({
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import {useState} from "react";
|
||||
import {useIdentifierSearch, useVariableAccess} from "../api/hooks";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {useIdentifierSearch, useInternalCallees, useModuleFunctions, useVariableAccess} from "../api/hooks";
|
||||
|
||||
interface Props {
|
||||
project: string;
|
||||
name: string;
|
||||
/** The 1-based source line the click came from — distinguishes a definition click from a reference. */
|
||||
clickLine?: number;
|
||||
/** The module the click came from — used to resolve a module-local subroutine reliably. */
|
||||
currentModuleName?: string;
|
||||
/** The source file the click came from — used to scope module-local subroutines/fields. */
|
||||
currentSourceFile?: string;
|
||||
onOpenModule: (name: string) => void;
|
||||
/** Open a file (by relative path) at a 1-based line — for jump-to-definition and access sites. */
|
||||
onOpenFileLine: (file: string, line: number) => void;
|
||||
@@ -12,44 +18,6 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Name-based identifier lookup results. MODULE matches navigate to the module; VARIABLE (and other
|
||||
* non-module) matches expand (M4) to jump-to-definition and list where the field is read/written.
|
||||
*/
|
||||
export function IdentifierPopover({project, name, onOpenModule, onOpenFileLine, onOpenFlow, onClose}: Props) {
|
||||
const {data, isLoading, isError} = useIdentifierSearch(project, name);
|
||||
const matches = data ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute right-3 top-3 z-10 w-96 rounded border border-neutral-300 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-900">
|
||||
<div className="flex items-center gap-2 border-b border-neutral-200 px-3 py-2 dark:border-neutral-800">
|
||||
<span className="truncate text-sm font-medium">{name}</span>
|
||||
<span className="text-xs text-neutral-400">{matches.length} matches</span>
|
||||
<button onClick={onClose} className="ml-auto text-neutral-400 hover:text-neutral-700"
|
||||
aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-auto p-1">
|
||||
{isLoading && <p className="p-2 text-xs text-neutral-400">looking up…</p>}
|
||||
{isError && <p className="p-2 text-xs text-red-500">lookup failed</p>}
|
||||
{!isLoading && matches.length === 0 && <p className="p-2 text-xs text-neutral-400">no matches</p>}
|
||||
{matches.map((m, i) => (
|
||||
<MatchRow
|
||||
key={`${m.name}-${m.sourceFile}-${i}`}
|
||||
project={project}
|
||||
match={m}
|
||||
onOpenModule={onOpenModule}
|
||||
onOpenFileLine={onOpenFileLine}
|
||||
onOpenFlow={onOpenFlow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Match = {
|
||||
type?: string;
|
||||
name?: string;
|
||||
@@ -60,9 +28,172 @@ type Match = {
|
||||
unresolved?: boolean;
|
||||
};
|
||||
|
||||
function MatchRow({project, match, onOpenModule, onOpenFileLine, onOpenFlow}: {
|
||||
/**
|
||||
* Name-based identifier lookup. Because Natural subroutines/fields are module-scoped and the same
|
||||
* name recurs across modules, results are scoped to the click's source file: a single local FUNCTION
|
||||
* auto-jumps to its definition (no popover); otherwise local matches lead and cross-module matches
|
||||
* fold away. MODULE matches navigate; VARIABLE matches expand to reads/writes + data-flow.
|
||||
*/
|
||||
export function IdentifierPopover({
|
||||
project,
|
||||
name,
|
||||
clickLine,
|
||||
currentModuleName,
|
||||
currentSourceFile,
|
||||
onOpenModule,
|
||||
onOpenFileLine,
|
||||
onOpenFlow,
|
||||
onClose
|
||||
}: Props) {
|
||||
const {data, isLoading, isError} = useIdentifierSearch(project, name);
|
||||
const matches = useMemo(() => data ?? [], [data]);
|
||||
const [autoJumped, setAutoJumped] = useState(false);
|
||||
|
||||
// Scoped lookup of a subroutine defined in THIS module — reliable regardless of how many modules
|
||||
// share the name (the global identifier search is capped, so the local one can fall off the page).
|
||||
const {data: localFns, isLoading: fnsLoading} = useModuleFunctions(project, currentModuleName);
|
||||
const localFn = useMemo(
|
||||
() => (localFns ?? []).find((f) => f.name?.toUpperCase() === name.toUpperCase()),
|
||||
[localFns, name],
|
||||
);
|
||||
// Clicked on the subroutine's own DEFINE line → the user wants its callers (PERFORM sites), not
|
||||
// to jump to itself. Otherwise a click on the name is a reference (PERFORM) → jump to the def.
|
||||
const isDefinitionClick = !!localFn?.startLine && localFn.startLine === clickLine;
|
||||
|
||||
// PERFORM sites of this subroutine = its entry's call-site lineNos among the module's internal callees.
|
||||
const {
|
||||
data: internalCallees,
|
||||
isLoading: calleesLoading
|
||||
} = useInternalCallees(project, currentModuleName, isDefinitionClick);
|
||||
const performSites = useMemo(() => {
|
||||
const entry = (internalCallees?.items ?? []).find((i) => i.name?.toUpperCase() === name.toUpperCase());
|
||||
return (entry?.lineNos ?? []).filter((n): n is number => typeof n === "number").sort((a, b) => a - b);
|
||||
}, [internalCallees, name]);
|
||||
|
||||
const {local, others} = useMemo(() => {
|
||||
const local: Match[] = [];
|
||||
const others: Match[] = [];
|
||||
for (const m of matches) {
|
||||
(m.sourceFile && m.sourceFile === currentSourceFile ? local : others).push(m);
|
||||
}
|
||||
return {local, others};
|
||||
}, [matches, currentSourceFile]);
|
||||
|
||||
// Reference click on a local subroutine → jump straight to its definition (no popover).
|
||||
useEffect(() => {
|
||||
if (autoJumped || isDefinitionClick) return;
|
||||
if (localFn?.startLine && currentSourceFile) {
|
||||
setAutoJumped(true);
|
||||
onOpenFileLine(currentSourceFile, localFn.startLine);
|
||||
onClose();
|
||||
}
|
||||
}, [localFn, isDefinitionClick, currentSourceFile, autoJumped, onOpenFileLine, onClose]);
|
||||
|
||||
// Definition click with exactly one PERFORM site → jump to it; multiple → show the picker below.
|
||||
useEffect(() => {
|
||||
if (autoJumped || !isDefinitionClick || calleesLoading) return;
|
||||
if (performSites.length === 1 && currentSourceFile) {
|
||||
setAutoJumped(true);
|
||||
onOpenFileLine(currentSourceFile, performSites[0]);
|
||||
onClose();
|
||||
}
|
||||
}, [isDefinitionClick, calleesLoading, performSites, currentSourceFile, autoJumped, onOpenFileLine, onClose]);
|
||||
|
||||
if (autoJumped) return null;
|
||||
|
||||
// Definition click → show its PERFORM sites (the picker for multiple; a note if none).
|
||||
if (isDefinitionClick) {
|
||||
return (
|
||||
<div
|
||||
className="absolute right-3 top-3 z-10 w-80 rounded border border-neutral-300 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-900">
|
||||
<div className="flex items-center gap-2 border-b border-neutral-200 px-3 py-2 dark:border-neutral-800">
|
||||
<span className="truncate text-sm font-medium">{name}</span>
|
||||
<span className="text-xs text-neutral-400">PERFORM sites ({performSites.length})</span>
|
||||
<button onClick={onClose} className="ml-auto text-neutral-400 hover:text-neutral-700"
|
||||
aria-label="Close">✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-auto p-1">
|
||||
{calleesLoading && <p className="p-2 text-xs text-neutral-400">looking up callers…</p>}
|
||||
{!calleesLoading && performSites.length === 0 && (
|
||||
<p className="p-2 text-xs text-neutral-400">No PERFORM of this subroutine found in the
|
||||
module.</p>
|
||||
)}
|
||||
<ul className="space-y-0.5">
|
||||
{performSites.map((ln) => (
|
||||
<li key={ln}>
|
||||
<button
|
||||
onClick={() => currentSourceFile && onOpenFileLine(currentSourceFile, ln)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<span>PERFORM</span>
|
||||
<span
|
||||
className="ml-auto tabular-nums text-blue-600 dark:text-blue-400">line {ln}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute right-3 top-3 z-10 w-96 rounded border border-neutral-300 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-900">
|
||||
<div className="flex items-center gap-2 border-b border-neutral-200 px-3 py-2 dark:border-neutral-800">
|
||||
<span className="truncate text-sm font-medium">{name}</span>
|
||||
<span className="text-xs text-neutral-400">{matches.length} matches</span>
|
||||
<button onClick={onClose} className="ml-auto text-neutral-400 hover:text-neutral-700"
|
||||
aria-label="Close">✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-auto p-1">
|
||||
{/* Hold until the scoped function lookup settles too, so a local subroutine auto-jumps
|
||||
instead of briefly flashing the cross-module list. */}
|
||||
{(isLoading || fnsLoading) && <p className="p-2 text-xs text-neutral-400">looking up…</p>}
|
||||
{isError && <p className="p-2 text-xs text-red-500">lookup failed</p>}
|
||||
{!isLoading && !fnsLoading && matches.length === 0 &&
|
||||
<p className="p-2 text-xs text-neutral-400">no matches</p>}
|
||||
|
||||
{!isLoading && !fnsLoading && local.map((m, i) => (
|
||||
<MatchRow key={`local-${m.name}-${i}`} project={project} match={m} inThisModule
|
||||
onOpenModule={onOpenModule} onOpenFileLine={onOpenFileLine} onOpenFlow={onOpenFlow}/>
|
||||
))}
|
||||
|
||||
{!isLoading && !fnsLoading && others.length > 0 && (
|
||||
<OtherModules count={others.length}>
|
||||
{others.map((m, i) => (
|
||||
<MatchRow key={`other-${m.name}-${m.sourceFile}-${i}`} project={project} match={m}
|
||||
onOpenModule={onOpenModule} onOpenFileLine={onOpenFileLine}
|
||||
onOpenFlow={onOpenFlow}/>
|
||||
))}
|
||||
</OtherModules>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OtherModules({count, children}: { count: number; children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="mt-1 border-t border-neutral-100 pt-1 dark:border-neutral-900">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-[11px] text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<span>{open ? "▾" : "▸"}</span> {count} in other modules
|
||||
</button>
|
||||
{open && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchRow({project, match, inThisModule, onOpenModule, onOpenFileLine, onOpenFlow}: {
|
||||
project: string;
|
||||
match: Match;
|
||||
inThisModule?: boolean;
|
||||
onOpenModule: (name: string) => void;
|
||||
onOpenFileLine: (file: string, line: number) => void;
|
||||
onOpenFlow: (name: string) => void;
|
||||
@@ -78,29 +209,32 @@ function MatchRow({project, match, onOpenModule, onOpenFileLine, onOpenFlow}: {
|
||||
>
|
||||
{!isModule && <span className="text-neutral-400">{open ? "▾" : "▸"}</span>}
|
||||
<span className="truncate font-medium">{match.name}</span>
|
||||
<span className="shrink-0 rounded bg-neutral-100 px-1 text-[10px] text-neutral-500 dark:bg-neutral-800">
|
||||
{match.type}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 rounded bg-neutral-100 px-1 text-[10px] text-neutral-500 dark:bg-neutral-800">{match.type}</span>
|
||||
{inThisModule && (
|
||||
<span
|
||||
className="shrink-0 rounded bg-blue-100 px-1 text-[10px] text-blue-700 dark:bg-blue-900/50 dark:text-blue-300">this module</span>
|
||||
)}
|
||||
{match.sourceFile && (
|
||||
<span className="ml-auto truncate text-neutral-400">{match.sourceFile.split("/").pop()}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && !isModule && (
|
||||
<VariableActions project={project} match={match} onOpenFileLine={onOpenFileLine}
|
||||
onOpenFlow={onOpenFlow}/>
|
||||
<MatchActions project={project} match={match} onOpenFileLine={onOpenFileLine} onOpenFlow={onOpenFlow}/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VariableActions({project, match, onOpenFileLine, onOpenFlow}: {
|
||||
function MatchActions({project, match, onOpenFileLine, onOpenFlow}: {
|
||||
project: string;
|
||||
match: Match;
|
||||
onOpenFileLine: (file: string, line: number) => void;
|
||||
onOpenFlow: (name: string) => void;
|
||||
}) {
|
||||
const canJump = !!match.sourceFile && !!match.startLine && !match.unresolved;
|
||||
const isFunction = match.type === "FUNCTION";
|
||||
return (
|
||||
<div className="ml-4 space-y-2 border-l border-neutral-200 py-1 pl-2 dark:border-neutral-800">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-neutral-500">
|
||||
@@ -110,23 +244,20 @@ function VariableActions({project, match, onOpenFileLine, onOpenFlow}: {
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5">
|
||||
{canJump && (
|
||||
<button
|
||||
onClick={() => onOpenFileLine(match.sourceFile!, match.startLine!)}
|
||||
className="text-[11px] text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
open definition →
|
||||
<button onClick={() => onOpenFileLine(match.sourceFile!, match.startLine!)}
|
||||
className="text-[11px] text-blue-600 hover:underline dark:text-blue-400">
|
||||
{isFunction ? "go to subroutine →" : "open definition →"}
|
||||
</button>
|
||||
)}
|
||||
{match.name && (
|
||||
<button
|
||||
onClick={() => onOpenFlow(match.name!)}
|
||||
className="text-[11px] text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{!isFunction && match.name && (
|
||||
<button onClick={() => onOpenFlow(match.name!)}
|
||||
className="text-[11px] text-blue-600 hover:underline dark:text-blue-400">
|
||||
data-flow →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{match.name && (
|
||||
{/* reads/writes are variable concepts — not shown for FUNCTION (subroutine) matches. */}
|
||||
{!isFunction && match.name && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<AccessList project={project} name={match.name} dir="writes" onOpenFileLine={onOpenFileLine}/>
|
||||
<AccessList project={project} name={match.name} dir="reads" onOpenFileLine={onOpenFileLine}/>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function ModuleView({
|
||||
}: Props) {
|
||||
const refresh = useRefreshModule(project);
|
||||
const ingestNeighborhood = useIngestNeighborhood(project);
|
||||
const [identify, setIdentify] = useState<string | null>(null);
|
||||
const [identify, setIdentify] = useState<{ name: string; line: number } | null>(null);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col border-l border-neutral-200 dark:border-neutral-800">
|
||||
@@ -131,7 +131,7 @@ export function ModuleView({
|
||||
moduleName={module.name!}
|
||||
sourceFile={module.sourceFile}
|
||||
line={line}
|
||||
onIdentify={setIdentify}
|
||||
onIdentify={(n, l) => setIdentify({name: n, line: l})}
|
||||
overrideFile={srcFile}
|
||||
onBackToModule={onBackToModule}
|
||||
/>
|
||||
@@ -168,7 +168,10 @@ export function ModuleView({
|
||||
{identify && (
|
||||
<IdentifierPopover
|
||||
project={project}
|
||||
name={identify}
|
||||
name={identify.name}
|
||||
clickLine={identify.line}
|
||||
currentModuleName={module.name!}
|
||||
currentSourceFile={module.sourceFile}
|
||||
onOpenModule={(n) => {
|
||||
setIdentify(null);
|
||||
onOpen(n);
|
||||
|
||||
@@ -11,8 +11,8 @@ interface Props {
|
||||
sourceFile?: string;
|
||||
/** Optional 1-based line to scroll to (deep-link). */
|
||||
line?: number;
|
||||
/** Ctrl/Cmd-click on an identifier → name-based lookup. */
|
||||
onIdentify: (name: string) => void;
|
||||
/** Ctrl/Cmd-click on an identifier → name-based lookup, with the 1-based click line. */
|
||||
onIdentify: (name: string, line: number) => void;
|
||||
/**
|
||||
* Show an included file (e.g. a USING data area) by its relative path instead of the module's
|
||||
* own source. When set, the file is loaded by path and a "back to module" banner is shown.
|
||||
@@ -68,7 +68,7 @@ export function SourceView({project, moduleName, sourceFile, line, onIdentify, o
|
||||
const w = wordAt(v.state.doc.toString(), pos, natural);
|
||||
if (w) {
|
||||
event.preventDefault();
|
||||
onIdentify(w);
|
||||
onIdentify(w, v.state.doc.lineAt(pos).number);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -22,12 +22,24 @@ export function Explorer() {
|
||||
|
||||
const modules = useMemo(() => {
|
||||
const all = data ?? [];
|
||||
const q = search.trim().toLowerCase();
|
||||
return q
|
||||
? all.filter(
|
||||
(m) => m.name?.toLowerCase().includes(q) || m.sourceFile?.toLowerCase().includes(q),
|
||||
)
|
||||
: all;
|
||||
const q = search.trim();
|
||||
if (!q) return all;
|
||||
// Treat the query as a case-insensitive regex over name/sourceFile; while it's an incomplete
|
||||
// (invalid) pattern, fall back to a plain substring match so the list still filters as you type.
|
||||
let re: RegExp | null = null;
|
||||
try {
|
||||
re = new RegExp(q, "i");
|
||||
} catch {
|
||||
re = null;
|
||||
}
|
||||
const lower = q.toLowerCase();
|
||||
return all.filter((m) => {
|
||||
const name = m.name ?? "";
|
||||
const file = m.sourceFile ?? "";
|
||||
return re
|
||||
? re.test(name) || re.test(file)
|
||||
: name.toLowerCase().includes(lower) || file.toLowerCase().includes(lower);
|
||||
});
|
||||
}, [data, search]);
|
||||
|
||||
const selected = modules.find((m) => m.name === name);
|
||||
@@ -116,7 +128,7 @@ export function Explorer() {
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search name or file…"
|
||||
placeholder="Search name or file (regex)…"
|
||||
className="w-56 rounded border border-neutral-300 bg-transparent px-2 py-1 text-sm dark:border-neutral-700"
|
||||
/>
|
||||
<select
|
||||
|
||||
@@ -79,6 +79,12 @@ Frontend (detail per milestone in `x-docs/ui-proposal.md`):
|
||||
Source tab gains a `?src=<file>` mode with an "included file … back to module" banner.
|
||||
- **M4 Data-flow & impact — DONE 2026-07-14** (M4.1–M4.3). flow-forward/backward /
|
||||
field-flow visualisation + "what breaks?" impact analysis.
|
||||
- **M4.1a subroutine navigation — DONE 2026-07-14.** Ctrl/⌘-click a subroutine name resolves via
|
||||
the *scoped* `module_functions` (reliable despite dozens of same-named subroutines across
|
||||
modules — the global search is capped): clicking a **reference (PERFORM)** jumps to the
|
||||
definition; clicking the **definition** goes to its caller(s) — the PERFORM sites from
|
||||
`callees?scope=internal` `lineNos` (one → jump, many → a picker). Covered by
|
||||
`e2e/identifier-popover.spec.ts` (Playwright; 5 cases incl. the upms 60-match reproduction).
|
||||
- **M4.1 actionable variable matches — DONE 2026-07-14.** Identifier-popover
|
||||
non-`MODULE` matches now expand: show `dataType`/`scope`, a **jump-to-definition**
|
||||
(opens `sourceFile:startLine`, via the M3 source-by-path infra so a field defined in a
|
||||
@@ -111,6 +117,9 @@ Frontend (detail per milestone in `x-docs/ui-proposal.md`):
|
||||
`module_context.description` is shown in Overview as the current summary).
|
||||
- **M6 Scale & polish** — virtualisation/large-graph performance, multi-project,
|
||||
auth, theming, export (SVG/PNG/report).
|
||||
- **Explorer regex filter — DONE 2026-07-14.** The module-list search box accepts a
|
||||
case-insensitive **regex** over name/sourceFile (falls back to substring while the pattern is
|
||||
incomplete). Covered by `e2e/module-explorer.spec.ts`.
|
||||
|
||||
Out of scope: parser/ingest semantic changes (the UI is a consumer) and write
|
||||
access to code (read-only; notes are UI-side). Key risks: auto-ingest latency on
|
||||
@@ -161,6 +170,22 @@ open item in this track:
|
||||
|
||||
## Agent API / MCP tooling gaps
|
||||
|
||||
- [ ] **52. Function-level callers ("who PERFORMs this subroutine")** (2026-07-14) — the
|
||||
`callers`/`callees` endpoints are **module-granularity**; there's no way to ask "which
|
||||
subroutines/PERFORM sites call this internal `FUNCTION`". The intra-module
|
||||
`FUNCTION→FUNCTION` `CALLS` edges already exist in the graph (see `CypherQueries` intra-class
|
||||
calls), so this is a query + surface addition: `GET /modules/{name}/functions/{fn}/callers`
|
||||
(+ MCP tool + `ac-cli`). Surfaced by the Web-UI identifier popover, which can jump to a
|
||||
subroutine's definition but can't list its callers. Deferred (2026-07-14).
|
||||
|
||||
- [ ] **53. `search_identifier` scoping (sourceFile/module filter)** (2026-07-14) — the name search
|
||||
has no scope filter and is paginated, so in a project where a name recurs in dozens of modules the
|
||||
*local* definition can fall outside the returned page. The Web-UI popover works around this **for
|
||||
subroutines** by resolving the click via the scoped `module_functions` endpoint, but the same cap
|
||||
affects **variables** (a module-local field named like 80 others may not appear as "this module").
|
||||
Add an optional `sourceFile=`/`module=` filter to `search_identifier` (REST + MCP + CLI) so a caller
|
||||
can ask "this name, in this module" directly. Deferred (2026-07-14).
|
||||
|
||||
- [ ] **26. MCP session reliability (POSTPONED 2026-07-13)** (investigated 2026-07-07, not fixed —
|
||||
see below) — `mcp__agenticcode__module_functions` (and potentially other MCP
|
||||
tools) intermittently failed with `"the first message from the client must be
|
||||
|
||||
Reference in New Issue
Block a user