| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278 |
- /**
- * The navigation seam: where a click on a symbol, a file, a flow or the map
- * takes the reader (task CG-61).
- *
- * The standalone viewer is a hash app — `#/s/<id>`, `#/file/<path>`, `#/map` —
- * and that is the default driver below. A host embedding these components has
- * its own router and its own URL space (a review page, a PR, a workspace), so
- * it installs a {@link NavigationDriver} and every rail row, breadcrumb, chip
- * and card in the package addresses *its* app instead.
- *
- * Two reasons the components go through href builders rather than through a
- * single `onNavigate` callback:
- *
- * - **A row is a link.** Middle-click, cmd-click and "copy link address" are
- * how people read code, and they only work if the `<a>` really carries an
- * href. A callback-only design turns every row into a `<div>` with an
- * onclick, which is a worse screen.
- * - **The trail travels in the address.** The walk is part of the URL, so
- * building one is a thing the components must be able to do, not just ask
- * for.
- *
- * The live route (`router.svelte.ts`) is the *app's* half and is deliberately
- * not imported here: it attaches `hashchange`/`popstate` listeners at module
- * scope, which a host must never inherit just by rendering a Symbol view.
- */
- export interface SymbolHrefOptions {
- /** A line to highlight and scroll to in the destination. */
- line?: number;
- /** The encoded trail, so a reload or a shared link reproduces the walk. */
- trail?: string;
- }
- export interface FileHrefOptions {
- line?: number;
- /** The whole-file source view rather than the outline. */
- source?: boolean;
- }
- export interface MapHrefOptions {
- root?: string | null;
- /** Absent or null leaves the grouping to the answering side. */
- depth?: number | null;
- tests?: boolean;
- }
- export interface DeadCodeHrefOptions {
- /** Include symbols something outside the index could import. */
- exported?: boolean;
- }
- export interface FlowHrefOptions {
- from?: string;
- to?: string;
- symbols?: string;
- trail?: string;
- }
- export interface StepsHrefOptions {
- /** A node id — a screen's route, a handler, any symbol. */
- anchor?: string;
- /** A name, when no id is at hand; the answering side picks the most screen-like match. */
- symbol?: string;
- depth?: number;
- /** Enter the screens the walk reaches, instead of drawing them as boundaries. */
- through?: boolean;
- /**
- * Which reading: the code's `order` — the anchor's body as a rail — or the
- * `tree` of what it sets in motion. Absent takes the answer's own default:
- * the order for a handler or an endpoint, the tree for a screen.
- */
- view?: 'order' | 'tree';
- }
- /**
- * Where the components send the reader.
- *
- * Implement all of it: a half-implemented driver produces a screen where some
- * rows navigate the host and others silently jump to a hash the host does not
- * serve.
- */
- export interface NavigationDriver {
- /**
- * A symbol's page — or, with `null`, the Symbol tab with nothing chosen yet.
- *
- * The null case has to be addressable. Without it the tab had no href of its
- * own and fell back to the landing page, which on a project that HAS screens
- * is the Screens tab: clicking Symbol landed you on somebody else's view.
- */
- symbolHref(id: string | null, opts?: SymbolHrefOptions): string;
- fileHref(path: string, opts?: FileHrefOptions): string;
- mapHref(opts?: MapHrefOptions): string;
- flowHref(opts?: FlowHrefOptions): string;
- entryHref(): string;
- screensHref(): string;
- stepsHref(opts?: StepsHrefOptions): string;
- deadHref(opts?: DeadCodeHrefOptions): string;
- /** Go to an href this driver built. */
- navigate(href: string, opts?: { replace?: boolean }): void;
- /** Back one entry in the host's history. */
- back(): void;
- }
- /* ------------------------------------------------------- the hash driver -- */
- /**
- * Node ids are opaque engine strings shaped `<kind>:<hash>` or
- * `<kind>:<relative/path>`, so they can contain both ':' and '/'. Encoding per
- * slash-separated segment keeps the URL readable (`#/file/src/mcp/tools.ts`)
- * and still round-trips a segment that itself contains a reserved character.
- */
- function encodePath(value: string): string {
- return value.split('/').map(encodeURIComponent).join('/');
- }
- function query(params: URLSearchParams): string {
- const text = params.toString();
- return text ? `?${text}` : '';
- }
- /** The `codegraph ui` address space: the hash is the route. */
- export const hashNavigation: NavigationDriver = {
- symbolHref(id, opts = {}) {
- const params = new URLSearchParams();
- if (opts.trail) params.set('t', opts.trail);
- if (opts.line) params.set('hl', String(opts.line));
- // No id: the tab itself. `#/s` rather than `#/s/` so the segment filter
- // cannot read an empty id back out of it.
- if (!id) return `#/s${query(params)}`;
- return `#/s/${encodePath(id)}${query(params)}`;
- },
- fileHref(path, opts = {}) {
- const params = new URLSearchParams();
- // `src` before `hl` so the two file URLs a reader shares differ in their
- // first character after the path, not somewhere in the middle.
- if (opts.source) params.set('src', '1');
- if (opts.line) params.set('hl', String(opts.line));
- return `#/file/${encodePath(path)}${query(params)}`;
- },
- mapHref(opts = {}) {
- const params = new URLSearchParams();
- if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
- // Including 1: a reader who asked for top-level directories has said
- // something, and dropping it would hand the choice back to the answer.
- if (opts.depth) params.set('depth', String(opts.depth));
- if (opts.tests) params.set('tests', '1');
- return `#/map${query(params)}`;
- },
- flowHref(opts = {}) {
- const params = new URLSearchParams();
- if (opts.from) params.set('from', opts.from);
- if (opts.to) params.set('to', opts.to);
- if (opts.symbols) params.set('symbols', opts.symbols);
- // `t`, not `trail`: the trail already travels under that name everywhere
- // else, and a flow read from one is the same walk under a different lens.
- if (opts.trail) params.set('t', opts.trail);
- return `#/flow${query(params)}`;
- },
- entryHref() {
- return '#/entry';
- },
- screensHref() {
- return '#/screens';
- },
- stepsHref(opts = {}) {
- const params = new URLSearchParams();
- if (opts.anchor) params.set('anchor', opts.anchor);
- else if (opts.symbol) params.set('symbol', opts.symbol);
- if (opts.depth) params.set('depth', String(opts.depth));
- if (opts.through) params.set('through', '1');
- if (opts.view) params.set('view', opts.view);
- return `#/steps${query(params)}`;
- },
- deadHref(opts = {}) {
- const params = new URLSearchParams();
- if (opts.exported) params.set('exported', '1');
- return `#/dead${query(params)}`;
- },
- navigate(href, opts = {}) {
- const target = href.startsWith('#') ? href : `#${href}`;
- if (opts.replace) {
- history.replaceState(history.state, '', target);
- onHashWritten();
- return;
- }
- if (location.hash === target) return;
- location.hash = target;
- // hashchange fires asynchronously; the sync is idempotent, so calling it
- // now keeps a navigate() immediately followed by a read consistent.
- onHashWritten();
- },
- back() {
- history.back();
- },
- };
- /**
- * The live route's re-read hook, registered by `router.svelte.ts`.
- *
- * The driver has to tell the route store that the hash moved, and the store
- * has to attach window listeners — but a component importing the driver must
- * not drag those listeners in. So the dependency runs this way round: the store
- * registers itself with the driver, and a page that never loads the store gets
- * a driver that simply writes the hash.
- */
- let onHashWritten: () => void = () => {};
- export function registerHashSync(sync: () => void): void {
- onHashWritten = sync;
- }
- /* ------------------------------------------------------------- registry -- */
- let driver: NavigationDriver = hashNavigation;
- /**
- * Install the driver every link in the package is built with.
- *
- * Call once, before anything renders. Passing `null` restores the hash driver.
- */
- export function setNavigationDriver(next: NavigationDriver | null): void {
- driver = next ?? hashNavigation;
- }
- export function getNavigationDriver(): NavigationDriver {
- return driver;
- }
- /* --------------------------- what the components actually call ----------- */
- export function symbolHref(id: string | null, opts: SymbolHrefOptions = {}): string {
- return driver.symbolHref(id, opts);
- }
- export function fileHref(path: string, opts: FileHrefOptions = {}): string {
- return driver.fileHref(path, opts);
- }
- export function mapHref(opts: MapHrefOptions = {}): string {
- return driver.mapHref(opts);
- }
- export function flowHref(opts: FlowHrefOptions = {}): string {
- return driver.flowHref(opts);
- }
- export function entryHref(): string {
- return driver.entryHref();
- }
- export function screensHref(): string {
- return driver.screensHref();
- }
- export function stepsHref(opts: StepsHrefOptions = {}): string {
- return driver.stepsHref(opts);
- }
- export function deadHref(opts: DeadCodeHrefOptions = {}): string {
- return driver.deadHref(opts);
- }
- export function navigate(href: string, opts: { replace?: boolean } = {}): void {
- driver.navigate(href, opts);
- }
- export function back(): void {
- driver.back();
- }
|