mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
fix(ui): one trail length for the bar, the store and /api/flow (#1976)
The trail store saves up to 64 hops, /api/flow read at most 24, the in-memory trail had no limit, and /api/nodes answers 60 ids per request. So "Read as flow" failed on any walk of 25 or more hops, including trails the store had just saved, and a cold load of a 61-64 hop link never got its names back. /api/flow now reads up to the store's MAX_TRAIL_HOPS. The trail keeps the same 64 hops, dropping the oldest on push and on hydrate. resolveTrailNames asks /api/nodes in batches of 60 and merges the answers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -266,6 +266,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
#### Symbols, tests and the viewer
|
||||
|
||||
- In `codegraph ui`, "Read as flow" now works on any trail the viewer can save, up to 64 hops, and a shared link to a long trail gets all its hop names back. A trail longer than that keeps its most recent 64 hops. (#1976)
|
||||
- Kotlin functions and methods now carry their signature — `(params): ReturnType` — in `codegraph_explore`, `node` and the viewer, instead of no signature at all. Re-index Kotlin projects after upgrading. (#1495)
|
||||
- TypeScript/JavaScript value aliases — `export const alias = fn`, `export { fn as alias }`, object-literal `api = { run: fn }`, and same-file `const local = fn` — now forward calls edges to the aliased function, so callers and impact on the implementation include consumers that call through the alias instead of stopping at the binding. Genuine wrappers (`() => fn()`) are unchanged. Re-index after upgrading. Thanks @valkyriweb. (#1482, #1485)
|
||||
- `codegraph affected` now finds Go, Python and JVM test files that previously went unreported, while preserving custom `--filter` behavior (thanks @danusha2345; #1507, #1688).
|
||||
|
||||
@@ -29,6 +29,7 @@ import * as path from 'path';
|
||||
import CodeGraph from '../src/index';
|
||||
import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
|
||||
import { flowEdgeLabel, parseFlowQuery } from '../src/ui-server/api/flow';
|
||||
import { MAX_TRAIL_HOPS } from '../src/ui-server/api/trail-store';
|
||||
import { resolveNamedSymbolFlow } from '../src/graph/named-symbol-flow';
|
||||
import { ToolHandler } from '../src/mcp/tools';
|
||||
import { continuationsFrom } from '../src/graph/dynamic-boundary-report';
|
||||
@@ -600,11 +601,14 @@ describe('GET /api/flow — refusals', () => {
|
||||
expect(payload.hint).toMatch(/\?from=/);
|
||||
});
|
||||
|
||||
it('caps the number of trail hops it will read', async () => {
|
||||
const query = Array.from({ length: 40 }, (_, i) => `hop=s${i}xx`).join('&');
|
||||
const payload = await getFlow(`?${query}`, 400);
|
||||
it('caps the number of trail hops it will read at what the trail store saves (#1976)', async () => {
|
||||
const hopsQuery = (n: number) => Array.from({ length: n }, (_, i) => `hop=s${i}xx`).join('&');
|
||||
// A 64-hop trail is one the store saves, so it must be readable as a flow.
|
||||
const saved = await request(`/api/flow?${hopsQuery(MAX_TRAIL_HOPS)}`);
|
||||
expect(saved.status).toBe(200);
|
||||
const payload = await getFlow(`?${hopsQuery(MAX_TRAIL_HOPS + 1)}`, 400);
|
||||
expect(payload.code).toBe('bad-request');
|
||||
expect(payload.error).toMatch(/longer than this endpoint reads/);
|
||||
expect(payload.error).toMatch(/longer than this endpoint reads \(64\)/);
|
||||
});
|
||||
|
||||
it('is listed on the API index', async () => {
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
setNavigationDriver,
|
||||
symbolHref,
|
||||
trail,
|
||||
resolveTrailNames,
|
||||
type GraphAdapter,
|
||||
type NavigationDriver,
|
||||
type WireFlowPayload,
|
||||
@@ -784,3 +785,39 @@ describe('@colbymchenry/codegraph-ui — the published shape', () => {
|
||||
expect(manifest.dependencies['@xyflow/svelte']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a long trail (#1976)', () => {
|
||||
it('keeps at most the 64 hops the store saves and /api/flow reads, dropping the oldest', () => {
|
||||
for (let i = 0; i < 70; i++) trail.push({ id: `function:h${i}` });
|
||||
expect(trail.hops).toHaveLength(64);
|
||||
expect(trail.hops[0]?.id).toBe('function:h6');
|
||||
expect(trail.hops[63]?.id).toBe('function:h69');
|
||||
});
|
||||
|
||||
it('asks for its names in batches /api/nodes accepts', async () => {
|
||||
const { adapter } = mockAdapter();
|
||||
const batches: number[] = [];
|
||||
adapter.nodes = (ids) => {
|
||||
batches.push(ids.length);
|
||||
const items = ids.map(
|
||||
(id) =>
|
||||
({
|
||||
id,
|
||||
kind: 'function',
|
||||
name: id.slice('function:'.length),
|
||||
qualifiedName: id,
|
||||
file: 'src/a.ts',
|
||||
line: 1,
|
||||
endLine: 1,
|
||||
language: 'typescript',
|
||||
}) as WireNodeRef
|
||||
);
|
||||
return Promise.resolve({ items, missing: [] });
|
||||
};
|
||||
setGraphAdapter(adapter);
|
||||
for (let i = 0; i < 64; i++) trail.push({ id: `function:h${i}` });
|
||||
await resolveTrailNames();
|
||||
expect(batches).toEqual([60, 4]);
|
||||
expect(trail.hops.every((hop) => hop.name)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,9 @@ import {
|
||||
} from '../../graph/dynamic-boundary-report';
|
||||
import { highlightLines, type HighlightResult } from '../highlight';
|
||||
import { badRequest, intParam } from './respond';
|
||||
// Hops accepted from a trail: the same cap the trail store saves under, so a
|
||||
// trail that can be saved can also be read as a flow (#1976).
|
||||
import { MAX_TRAIL_HOPS } from './trail-store';
|
||||
import { findIndexedFile, hasDriftedOnDisk, splitLines, toRequestPath } from './source';
|
||||
import { resolveProjectFile } from '../security';
|
||||
import {
|
||||
@@ -80,8 +83,6 @@ const HIGHLIGHT_LEAD_MAX = 200;
|
||||
/** Distinct paths returned. The header's flow picker is a short list or nothing. */
|
||||
export const MAX_FLOWS = 4;
|
||||
|
||||
/** Hops accepted from a trail. The trail bar itself is not much longer than this. */
|
||||
const MAX_TRAIL_HOPS = 24;
|
||||
|
||||
// =============================================================================
|
||||
// Wire shapes
|
||||
|
||||
+16
-2
@@ -65,8 +65,22 @@ export function fetchSearch(
|
||||
}
|
||||
|
||||
/** Names and locations for ids you already have — what the trail redraws with. */
|
||||
export function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs> {
|
||||
return getGraphAdapter().nodes(ids, signal);
|
||||
/** Ids `/api/nodes` answers per request (`MAX_NODE_REFS` on the server). */
|
||||
const NODE_REFS_PER_REQUEST = 60;
|
||||
|
||||
/**
|
||||
* Names and kinds for a set of ids. A trail can hold more hops than one request
|
||||
* answers, so the ids go out in batches and the answers are merged (#1976).
|
||||
*/
|
||||
export async function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs> {
|
||||
if (ids.length <= NODE_REFS_PER_REQUEST) return getGraphAdapter().nodes(ids, signal);
|
||||
const merged: WireNodeRefs = { items: [], missing: [] };
|
||||
for (let i = 0; i < ids.length; i += NODE_REFS_PER_REQUEST) {
|
||||
const batch = await getGraphAdapter().nodes(ids.slice(i, i + NODE_REFS_PER_REQUEST), signal);
|
||||
merged.items.push(...batch.items);
|
||||
merged.missing.push(...batch.missing);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function fetchEntryPoints(
|
||||
|
||||
@@ -16,6 +16,13 @@ export type { HopDirection, TrailHop } from './trail-codec';
|
||||
|
||||
let hops = $state<TrailHop[]>([]);
|
||||
|
||||
/**
|
||||
* Hops one trail keeps — the trail store's `MAX_TRAIL_HOPS`, which `/api/flow`
|
||||
* reads up to as well, so any trail on the bar can be saved and read as a flow.
|
||||
* Past it the oldest hop falls off the front (#1976).
|
||||
*/
|
||||
const MAX_TRAIL_HOPS = 64;
|
||||
|
||||
/**
|
||||
* Every name this session has learned, by id.
|
||||
*
|
||||
@@ -70,7 +77,7 @@ export const trail = {
|
||||
kind: hop.kind ?? null,
|
||||
dir: hop.dir ?? (hops.length === 0 ? 'start' : 'down'),
|
||||
},
|
||||
];
|
||||
].slice(-MAX_TRAIL_HOPS);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -119,7 +126,7 @@ export const trail = {
|
||||
if (encodeTrail(decoded) === encodeTrail(hops)) return;
|
||||
// Names survive the change — including for hops this trail dropped earlier
|
||||
// and history has just brought back.
|
||||
hops = decoded.map((h) => {
|
||||
hops = decoded.slice(-MAX_TRAIL_HOPS).map((h) => {
|
||||
const seen = known.get(h.id);
|
||||
return seen ? { ...h, name: seen.name, kind: seen.kind } : h;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user