SavedTrails.svelte 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. <script lang="ts">
  2. /**
  3. * The trails somebody kept — the fifth answer to "where do I start".
  4. *
  5. * The other four (routes, executable files, tests, hubs) are derived from the
  6. * graph and describe the project. This one is written by hand and describes
  7. * what a person thought was worth explaining, which is why it sits above them
  8. * on the empty screen: a named walk beats a ranked list every time there is
  9. * one.
  10. *
  11. * Rows follow the search-result grid (18px glyph · name · meta) so the empty
  12. * screen reads as one list rather than two lists in one column. What they add
  13. * is the honesty line: a trail is a claim about code that has since moved, and
  14. * every row says what became of its hops.
  15. */
  16. import KindGlyph from './KindGlyph.svelte';
  17. import { symbolHref, navigate } from '../lib/navigation';
  18. import { trails } from '../lib/trails.svelte';
  19. import { trail } from '../lib/trail.svelte';
  20. import { decodeTrail } from '../lib/trail-codec';
  21. import {
  22. isOpenable,
  23. trailDecay,
  24. trailExport,
  25. trailMeta,
  26. trailOpens,
  27. trailTitle,
  28. } from '../lib/trails-model';
  29. import type { WireTrail } from '../lib/api';
  30. interface Props {
  31. /** Heading text. The empty screen and the entry-points panel word it alike. */
  32. title?: string;
  33. /** Render nothing at all when there are no saved trails (the empty screen). */
  34. hideWhenEmpty?: boolean;
  35. }
  36. let { title = 'Saved trails', hideWhenEmpty = true }: Props = $props();
  37. $effect(() => {
  38. void trails.ensure();
  39. });
  40. /** Which row is asking to be confirmed before it is deleted. */
  41. let confirming = $state<string | null>(null);
  42. let list = $derived(trails.list);
  43. /**
  44. * Open a trail: adopt its hops, then navigate to the one it ends on.
  45. *
  46. * The store is primed BEFORE the URL changes so the bar draws named hops
  47. * immediately rather than a row of hashes that resolve a moment later — the
  48. * encoded trail carries ids and nothing else, and every name is already here.
  49. */
  50. function open(saved: WireTrail) {
  51. if (!isOpenable(saved)) return;
  52. const hops = decodeTrail(saved.encoded);
  53. trail.clear();
  54. const resolved = saved.hops.filter((hop) => hop.id !== null);
  55. for (const hop of hops) {
  56. const known = resolved.find((h) => h.id === hop.id);
  57. trail.push({ id: hop.id, name: known?.name ?? null, kind: known?.kind ?? null, dir: hop.dir });
  58. }
  59. navigate(symbolHref(saved.openId as string, { trail: saved.encoded as string }));
  60. }
  61. async function remove(saved: WireTrail) {
  62. if (confirming !== saved.id) {
  63. confirming = saved.id;
  64. return;
  65. }
  66. confirming = null;
  67. await trails.remove(saved.id);
  68. }
  69. /**
  70. * Hand the trail over as the file it is.
  71. *
  72. * `.codegraph/` is gitignored wholesale, which is right for a scratch walk
  73. * and wrong for a tour worth committing — so exporting is a copy the reader
  74. * makes deliberately, and lands wherever their browser puts downloads.
  75. */
  76. function download(saved: WireTrail) {
  77. const blob = new Blob([trailExport(saved)], { type: 'application/json' });
  78. const url = URL.createObjectURL(blob);
  79. const link = document.createElement('a');
  80. link.href = url;
  81. link.download = `${saved.id}.json`;
  82. link.click();
  83. URL.revokeObjectURL(url);
  84. }
  85. </script>
  86. {#if !(hideWhenEmpty && list.length === 0 && trails.failure === null)}
  87. <section class="trails" aria-label={title}>
  88. <div class="head">
  89. <h3>{title}</h3>
  90. {#if trails.directory}
  91. <span class="where">{trails.directory}</span>
  92. {/if}
  93. </div>
  94. {#if trails.failure}
  95. <p class="msg err">{trails.failure}</p>
  96. {:else if !trails.settled}
  97. <p class="msg">Reading saved trails…</p>
  98. {:else if list.length === 0}
  99. <p class="msg">
  100. No saved trails yet. Walk a path through the code, then press
  101. <strong>Save trail</strong> on the trail bar to keep it.
  102. {#if trails.readOnlyReason}
  103. <br />{trails.readOnlyReason}
  104. {/if}
  105. </p>
  106. {:else}
  107. <div class="rows">
  108. {#each list as saved (saved.id)}
  109. {@const decay = trailDecay(saved)}
  110. {@const opens = trailOpens(saved)}
  111. <div class="row" class:dead={!isOpenable(saved)}>
  112. <button
  113. type="button"
  114. class="pick"
  115. title={trailTitle(saved)}
  116. disabled={!isOpenable(saved)}
  117. onclick={() => open(saved)}
  118. >
  119. <KindGlyph kind={saved.hops[0]?.kind ?? null} />
  120. <span class="mid">
  121. <span class="nm">{saved.name}</span>
  122. {#if saved.note}<span class="note">{saved.note}</span>{/if}
  123. </span>
  124. <span class="meta">{trailMeta(saved)}</span>
  125. </button>
  126. <div class="acts">
  127. <button type="button" class="act" onclick={() => download(saved)}>Export</button>
  128. {#if trails.canSave}
  129. <button
  130. type="button"
  131. class="act"
  132. class:armed={confirming === saved.id}
  133. disabled={trails.busy}
  134. onclick={() => remove(saved)}
  135. onblur={() => (confirming = confirming === saved.id ? null : confirming)}
  136. >
  137. {confirming === saved.id ? 'Delete?' : 'Delete'}
  138. </button>
  139. {/if}
  140. </div>
  141. <!-- The honesty line. A saved trail is a claim about code that has
  142. since moved; this is where the graph gets to say so. -->
  143. {#if decay || opens}
  144. <p class="decay" class:warn={decay?.tone === 'warn'}>
  145. {[decay?.text, opens].filter(Boolean).join(' ')}
  146. </p>
  147. {/if}
  148. </div>
  149. {/each}
  150. </div>
  151. {#if trails.payload?.bounded}
  152. <p class="msg">Only the first trails in the directory are listed.</p>
  153. {/if}
  154. {/if}
  155. </section>
  156. {/if}
  157. <style>
  158. .trails {
  159. max-width: 720px;
  160. }
  161. .head {
  162. display: flex;
  163. align-items: baseline;
  164. justify-content: space-between;
  165. gap: 12px;
  166. margin-bottom: 8px;
  167. }
  168. .trails h3 {
  169. margin: 0;
  170. font-size: 14px;
  171. font-weight: 600;
  172. }
  173. .where {
  174. color: var(--ink-4);
  175. font-family: var(--mono);
  176. font-size: 11px;
  177. }
  178. .msg {
  179. margin: 0;
  180. padding: 8px 0 0;
  181. color: var(--ink-3);
  182. font-size: 12px;
  183. }
  184. .msg.err {
  185. color: var(--accent);
  186. }
  187. .rows {
  188. border: 1px solid var(--rule-soft);
  189. }
  190. .row {
  191. position: relative;
  192. border-bottom: 1px solid var(--rule-faint);
  193. }
  194. .row:last-child {
  195. border-bottom: 0;
  196. }
  197. .pick {
  198. display: grid;
  199. width: 100%;
  200. align-items: baseline;
  201. padding: 6px 10px;
  202. color: var(--ink);
  203. gap: 10px;
  204. grid-template-columns: 18px 1fr auto;
  205. text-align: left;
  206. }
  207. .pick:hover:not(:disabled) {
  208. background: var(--press);
  209. }
  210. .pick:disabled {
  211. color: var(--ink-3);
  212. cursor: default;
  213. }
  214. .mid {
  215. overflow: hidden;
  216. text-overflow: ellipsis;
  217. white-space: nowrap;
  218. }
  219. .nm {
  220. font-family: var(--mono);
  221. font-size: 12.5px;
  222. }
  223. .note {
  224. margin-left: 6px;
  225. color: var(--ink-3);
  226. font-size: 11.5px;
  227. }
  228. /* Room for the actions, which overlay the row's right edge. */
  229. .meta {
  230. padding-right: 96px;
  231. color: var(--ink-3);
  232. font-family: var(--mono);
  233. font-size: 11px;
  234. white-space: nowrap;
  235. }
  236. /* Always drawn, never revealed on hover: a control that appears when the
  237. pointer arrives is one a keyboard reader has to guess at. It recedes to
  238. ink-3 instead, which is the same thing done with ink. */
  239. .acts {
  240. position: absolute;
  241. top: 4px;
  242. right: 8px;
  243. display: flex;
  244. gap: 4px;
  245. }
  246. .act {
  247. padding: 2px 6px;
  248. color: var(--ink-3);
  249. background: var(--paper);
  250. border: 1px solid var(--rule-soft);
  251. font-family: var(--sans);
  252. font-size: 11px;
  253. }
  254. .act:hover:not(:disabled) {
  255. color: var(--ink);
  256. border-color: var(--ink);
  257. }
  258. .act.armed {
  259. color: var(--accent);
  260. border-color: var(--accent-line);
  261. background: var(--accent-soft);
  262. }
  263. .decay {
  264. margin: 0;
  265. padding: 0 10px 6px 38px;
  266. color: var(--ink-3);
  267. font-size: 11.5px;
  268. }
  269. .decay.warn {
  270. color: var(--amber);
  271. }
  272. </style>