navigation.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. /**
  2. * The navigation seam: where a click on a symbol, a file, a flow or the map
  3. * takes the reader (task CG-61).
  4. *
  5. * The standalone viewer is a hash app — `#/s/<id>`, `#/file/<path>`, `#/map` —
  6. * and that is the default driver below. A host embedding these components has
  7. * its own router and its own URL space (a review page, a PR, a workspace), so
  8. * it installs a {@link NavigationDriver} and every rail row, breadcrumb, chip
  9. * and card in the package addresses *its* app instead.
  10. *
  11. * Two reasons the components go through href builders rather than through a
  12. * single `onNavigate` callback:
  13. *
  14. * - **A row is a link.** Middle-click, cmd-click and "copy link address" are
  15. * how people read code, and they only work if the `<a>` really carries an
  16. * href. A callback-only design turns every row into a `<div>` with an
  17. * onclick, which is a worse screen.
  18. * - **The trail travels in the address.** The walk is part of the URL, so
  19. * building one is a thing the components must be able to do, not just ask
  20. * for.
  21. *
  22. * The live route (`router.svelte.ts`) is the *app's* half and is deliberately
  23. * not imported here: it attaches `hashchange`/`popstate` listeners at module
  24. * scope, which a host must never inherit just by rendering a Symbol view.
  25. */
  26. export interface SymbolHrefOptions {
  27. /** A line to highlight and scroll to in the destination. */
  28. line?: number;
  29. /** The encoded trail, so a reload or a shared link reproduces the walk. */
  30. trail?: string;
  31. }
  32. export interface FileHrefOptions {
  33. line?: number;
  34. /** The whole-file source view rather than the outline. */
  35. source?: boolean;
  36. }
  37. export interface MapHrefOptions {
  38. root?: string | null;
  39. /** Absent or null leaves the grouping to the answering side. */
  40. depth?: number | null;
  41. tests?: boolean;
  42. }
  43. export interface DeadCodeHrefOptions {
  44. /** Include symbols something outside the index could import. */
  45. exported?: boolean;
  46. }
  47. export interface FlowHrefOptions {
  48. from?: string;
  49. to?: string;
  50. symbols?: string;
  51. trail?: string;
  52. }
  53. export interface StepsHrefOptions {
  54. /** A node id — a screen's route, a handler, any symbol. */
  55. anchor?: string;
  56. /** A name, when no id is at hand; the answering side picks the most screen-like match. */
  57. symbol?: string;
  58. depth?: number;
  59. /** Enter the screens the walk reaches, instead of drawing them as boundaries. */
  60. through?: boolean;
  61. /**
  62. * Which reading: the code's `order` — the anchor's body as a rail — or the
  63. * `tree` of what it sets in motion. Absent takes the answer's own default:
  64. * the order for a handler or an endpoint, the tree for a screen.
  65. */
  66. view?: 'order' | 'tree';
  67. }
  68. /**
  69. * Where the components send the reader.
  70. *
  71. * Implement all of it: a half-implemented driver produces a screen where some
  72. * rows navigate the host and others silently jump to a hash the host does not
  73. * serve.
  74. */
  75. export interface NavigationDriver {
  76. /**
  77. * A symbol's page — or, with `null`, the Symbol tab with nothing chosen yet.
  78. *
  79. * The null case has to be addressable. Without it the tab had no href of its
  80. * own and fell back to the landing page, which on a project that HAS screens
  81. * is the Screens tab: clicking Symbol landed you on somebody else's view.
  82. */
  83. symbolHref(id: string | null, opts?: SymbolHrefOptions): string;
  84. fileHref(path: string, opts?: FileHrefOptions): string;
  85. mapHref(opts?: MapHrefOptions): string;
  86. flowHref(opts?: FlowHrefOptions): string;
  87. entryHref(): string;
  88. screensHref(): string;
  89. stepsHref(opts?: StepsHrefOptions): string;
  90. deadHref(opts?: DeadCodeHrefOptions): string;
  91. /** Go to an href this driver built. */
  92. navigate(href: string, opts?: { replace?: boolean }): void;
  93. /** Back one entry in the host's history. */
  94. back(): void;
  95. }
  96. /* ------------------------------------------------------- the hash driver -- */
  97. /**
  98. * Node ids are opaque engine strings shaped `<kind>:<hash>` or
  99. * `<kind>:<relative/path>`, so they can contain both ':' and '/'. Encoding per
  100. * slash-separated segment keeps the URL readable (`#/file/src/mcp/tools.ts`)
  101. * and still round-trips a segment that itself contains a reserved character.
  102. */
  103. function encodePath(value: string): string {
  104. return value.split('/').map(encodeURIComponent).join('/');
  105. }
  106. function query(params: URLSearchParams): string {
  107. const text = params.toString();
  108. return text ? `?${text}` : '';
  109. }
  110. /** The `codegraph ui` address space: the hash is the route. */
  111. export const hashNavigation: NavigationDriver = {
  112. symbolHref(id, opts = {}) {
  113. const params = new URLSearchParams();
  114. if (opts.trail) params.set('t', opts.trail);
  115. if (opts.line) params.set('hl', String(opts.line));
  116. // No id: the tab itself. `#/s` rather than `#/s/` so the segment filter
  117. // cannot read an empty id back out of it.
  118. if (!id) return `#/s${query(params)}`;
  119. return `#/s/${encodePath(id)}${query(params)}`;
  120. },
  121. fileHref(path, opts = {}) {
  122. const params = new URLSearchParams();
  123. // `src` before `hl` so the two file URLs a reader shares differ in their
  124. // first character after the path, not somewhere in the middle.
  125. if (opts.source) params.set('src', '1');
  126. if (opts.line) params.set('hl', String(opts.line));
  127. return `#/file/${encodePath(path)}${query(params)}`;
  128. },
  129. mapHref(opts = {}) {
  130. const params = new URLSearchParams();
  131. if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
  132. // Including 1: a reader who asked for top-level directories has said
  133. // something, and dropping it would hand the choice back to the answer.
  134. if (opts.depth) params.set('depth', String(opts.depth));
  135. if (opts.tests) params.set('tests', '1');
  136. return `#/map${query(params)}`;
  137. },
  138. flowHref(opts = {}) {
  139. const params = new URLSearchParams();
  140. if (opts.from) params.set('from', opts.from);
  141. if (opts.to) params.set('to', opts.to);
  142. if (opts.symbols) params.set('symbols', opts.symbols);
  143. // `t`, not `trail`: the trail already travels under that name everywhere
  144. // else, and a flow read from one is the same walk under a different lens.
  145. if (opts.trail) params.set('t', opts.trail);
  146. return `#/flow${query(params)}`;
  147. },
  148. entryHref() {
  149. return '#/entry';
  150. },
  151. screensHref() {
  152. return '#/screens';
  153. },
  154. stepsHref(opts = {}) {
  155. const params = new URLSearchParams();
  156. if (opts.anchor) params.set('anchor', opts.anchor);
  157. else if (opts.symbol) params.set('symbol', opts.symbol);
  158. if (opts.depth) params.set('depth', String(opts.depth));
  159. if (opts.through) params.set('through', '1');
  160. if (opts.view) params.set('view', opts.view);
  161. return `#/steps${query(params)}`;
  162. },
  163. deadHref(opts = {}) {
  164. const params = new URLSearchParams();
  165. if (opts.exported) params.set('exported', '1');
  166. return `#/dead${query(params)}`;
  167. },
  168. navigate(href, opts = {}) {
  169. const target = href.startsWith('#') ? href : `#${href}`;
  170. if (opts.replace) {
  171. history.replaceState(history.state, '', target);
  172. onHashWritten();
  173. return;
  174. }
  175. if (location.hash === target) return;
  176. location.hash = target;
  177. // hashchange fires asynchronously; the sync is idempotent, so calling it
  178. // now keeps a navigate() immediately followed by a read consistent.
  179. onHashWritten();
  180. },
  181. back() {
  182. history.back();
  183. },
  184. };
  185. /**
  186. * The live route's re-read hook, registered by `router.svelte.ts`.
  187. *
  188. * The driver has to tell the route store that the hash moved, and the store
  189. * has to attach window listeners — but a component importing the driver must
  190. * not drag those listeners in. So the dependency runs this way round: the store
  191. * registers itself with the driver, and a page that never loads the store gets
  192. * a driver that simply writes the hash.
  193. */
  194. let onHashWritten: () => void = () => {};
  195. export function registerHashSync(sync: () => void): void {
  196. onHashWritten = sync;
  197. }
  198. /* ------------------------------------------------------------- registry -- */
  199. let driver: NavigationDriver = hashNavigation;
  200. /**
  201. * Install the driver every link in the package is built with.
  202. *
  203. * Call once, before anything renders. Passing `null` restores the hash driver.
  204. */
  205. export function setNavigationDriver(next: NavigationDriver | null): void {
  206. driver = next ?? hashNavigation;
  207. }
  208. export function getNavigationDriver(): NavigationDriver {
  209. return driver;
  210. }
  211. /* --------------------------- what the components actually call ----------- */
  212. export function symbolHref(id: string | null, opts: SymbolHrefOptions = {}): string {
  213. return driver.symbolHref(id, opts);
  214. }
  215. export function fileHref(path: string, opts: FileHrefOptions = {}): string {
  216. return driver.fileHref(path, opts);
  217. }
  218. export function mapHref(opts: MapHrefOptions = {}): string {
  219. return driver.mapHref(opts);
  220. }
  221. export function flowHref(opts: FlowHrefOptions = {}): string {
  222. return driver.flowHref(opts);
  223. }
  224. export function entryHref(): string {
  225. return driver.entryHref();
  226. }
  227. export function screensHref(): string {
  228. return driver.screensHref();
  229. }
  230. export function stepsHref(opts: StepsHrefOptions = {}): string {
  231. return driver.stepsHref(opts);
  232. }
  233. export function deadHref(opts: DeadCodeHrefOptions = {}): string {
  234. return driver.deadHref(opts);
  235. }
  236. export function navigate(href: string, opts: { replace?: boolean } = {}): void {
  237. driver.navigate(href, opts);
  238. }
  239. export function back(): void {
  240. driver.back();
  241. }