trails.svelte.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. /**
  2. * The saved trails, as live state.
  3. *
  4. * Everything that decides what a row *says* is in `trails-model.ts`; this owns
  5. * the parts that need time — one fetch shared by every screen that lists them,
  6. * and the two writes.
  7. *
  8. * Two things it does deliberately:
  9. *
  10. * - **A write answers with the whole list, and the whole list is adopted.**
  11. * Saving does not patch one row in place. The server re-resolves every hop of
  12. * every trail on the way out, so a save is also the cheapest moment to learn
  13. * that a trail saved last week has decayed — and patching locally would show
  14. * a screen that had quietly stopped agreeing with the files on disk.
  15. * - **Failures are kept, not thrown away.** The one place in the viewer that
  16. * can fail because of the *filesystem* (a read-only checkout, a full disk) is
  17. * here, and "nothing happened" is the worst possible answer to a reader who
  18. * just pressed Save.
  19. */
  20. import { canWriteTrails, deleteTrail, fetchTrails, saveTrail, type WireTrail, type WireTrails } from './api';
  21. import type { TrailHop } from './trail-codec';
  22. let payload = $state<WireTrails | null>(null);
  23. /** Null until the first attempt settles — the section says "reading" until then. */
  24. let settled = $state(false);
  25. let failure = $state<string | null>(null);
  26. let busy = $state(false);
  27. let inflight: Promise<void> | null = null;
  28. function load(): Promise<void> {
  29. if (inflight) return inflight;
  30. inflight = fetchTrails()
  31. .then((value) => {
  32. payload = value;
  33. failure = null;
  34. })
  35. .catch((cause: unknown) => {
  36. // A viewer whose trails cannot be listed still works; the section is the
  37. // only thing that has to know, and it prints the reason rather than an
  38. // empty box that looks like "you have never saved one".
  39. payload = null;
  40. failure = cause instanceof Error ? cause.message : String(cause);
  41. })
  42. .finally(() => {
  43. settled = true;
  44. });
  45. return inflight;
  46. }
  47. function adopt(next: WireTrails): void {
  48. payload = next;
  49. failure = null;
  50. settled = true;
  51. // The in-flight promise is the *load*; replacing the payload out from under
  52. // it is fine, but a later `ensure()` must not resolve to the stale one.
  53. inflight = Promise.resolve();
  54. }
  55. export const trails = {
  56. get list(): readonly WireTrail[] {
  57. return payload?.trails ?? [];
  58. },
  59. get payload(): WireTrails | null {
  60. return payload;
  61. },
  62. /** False until the first fetch settles, however it settled. */
  63. get settled(): boolean {
  64. return settled;
  65. },
  66. get failure(): string | null {
  67. return failure;
  68. },
  69. /** A save or a delete is in flight — the form disables itself. */
  70. get busy(): boolean {
  71. return busy;
  72. },
  73. /**
  74. * Whether the viewer offers to save at all.
  75. *
  76. * Two independent reasons it might not, and the screens distinguish them:
  77. * the adapter never offered a write ({@link canWriteTrails}), or the
  78. * answering side declined this one (`readOnly` on the payload). Until the
  79. * first fetch settles we assume it can, so the Save button does not flicker
  80. * into existence a moment after the trail bar draws.
  81. */
  82. get canSave(): boolean {
  83. if (!canWriteTrails()) return false;
  84. return payload === null || !payload.readOnly;
  85. },
  86. /**
  87. * Why saving is off, when it is.
  88. *
  89. * The answering side's own sentence wins when there is one — it is the more
  90. * specific truth, and it is the one that names the flag or the mount that
  91. * caused it. The generic line is only for an adapter that never offered a
  92. * write at all, which has nothing to say for itself.
  93. */
  94. get readOnlyReason(): string | null {
  95. if (payload?.readOnly) return payload.readOnlyReason ?? 'This viewer is running read-only.';
  96. if (!canWriteTrails()) return 'This viewer cannot save trails.';
  97. return null;
  98. },
  99. /** Where the files live, project-relative. Null until known. */
  100. get directory(): string | null {
  101. return payload?.directory ?? null;
  102. },
  103. /** Load once. Every screen that lists trails calls this. */
  104. ensure: load,
  105. /** Ask again, because the index moved or a file changed underneath us. */
  106. reload(): Promise<void> {
  107. inflight = null;
  108. return load();
  109. },
  110. /**
  111. * Save the walk under a name.
  112. *
  113. * Hops travel as ids and directions only — the answering side reads each
  114. * symbol's name, kind and file out of the graph, so a saved trail is always
  115. * something the index itself said.
  116. *
  117. * @returns the id written, or null when the save failed (see `failure`).
  118. */
  119. async save(name: string, note: string, hops: readonly TrailHop[]): Promise<string | null> {
  120. busy = true;
  121. try {
  122. const answer = await saveTrail({
  123. name,
  124. note,
  125. hops: hops.map((hop) => ({ dir: hop.dir, id: hop.id })),
  126. });
  127. adopt(answer);
  128. return answer.saved ?? null;
  129. } catch (cause) {
  130. failure = cause instanceof Error ? cause.message : String(cause);
  131. return null;
  132. } finally {
  133. busy = false;
  134. }
  135. },
  136. /** Remove a saved trail. Returns whether it went. */
  137. async remove(id: string): Promise<boolean> {
  138. busy = true;
  139. try {
  140. adopt(await deleteTrail(id));
  141. return true;
  142. } catch (cause) {
  143. failure = cause instanceof Error ? cause.message : String(cause);
  144. return false;
  145. } finally {
  146. busy = false;
  147. }
  148. },
  149. /** Drop the last failure, so a retry starts from a clean screen. */
  150. clearFailure(): void {
  151. failure = null;
  152. },
  153. };