pi-weave 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +8 -37
  2. package/package.json +1 -2
  3. package/src/core/concurrency.ts +3 -6
  4. package/src/core/frontmatter.ts +0 -53
  5. package/src/core/graph/build.ts +6 -7
  6. package/src/core/graph/current.ts +2 -4
  7. package/src/core/graph/model.ts +1 -1
  8. package/src/core/graph/wikilinks.ts +3 -3
  9. package/src/core/index.ts +26 -27
  10. package/src/core/paths.ts +0 -7
  11. package/src/core/vault.ts +16 -681
  12. package/src/core/view/detail.ts +1 -1
  13. package/src/core/view/health.ts +1 -1
  14. package/src/core/view/tree.ts +1 -1
  15. package/src/pi/index.ts +6 -85
  16. package/src/pi/summarize.ts +2 -2
  17. package/src/pi/viewer/tui/bodyStore.ts +4 -7
  18. package/src/pi/viewer/tui/branding.ts +7 -148
  19. package/src/pi/viewer/tui/run.ts +3 -17
  20. package/src/pi/viewer/tui/surface/base.ts +24 -3
  21. package/src/pi/viewer/tui/surface/explore.ts +41 -6
  22. package/src/pi/viewer/tui/workspace.ts +23 -351
  23. package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
  24. package/src/pi/viewer/web/run.ts +7 -117
  25. package/src/web/client/api.dom.ts +2 -2
  26. package/src/web/client/api.ts +14 -223
  27. package/src/web/client/bootstrap.ts +5 -14
  28. package/src/web/client/context/context.model.ts +9 -11
  29. package/src/web/client/dist/app.js +93 -219
  30. package/src/web/client/graph/dynamics.ts +5 -65
  31. package/src/web/client/graph/renderer.dom.ts +7 -8
  32. package/src/web/client/graph/renderer.ts +9 -35
  33. package/src/web/client/main.tsx +1 -1
  34. package/src/web/client/note/Note.tsx +21 -63
  35. package/src/web/client/search/SearchPalette.tsx +45 -36
  36. package/src/web/client/search/search.model.ts +33 -454
  37. package/src/web/client/shell/Columns.tsx +13 -83
  38. package/src/web/client/shell/Header.tsx +2 -10
  39. package/src/web/client/shell/Shell.tsx +50 -125
  40. package/src/web/client/shell/StatusBar.tsx +1 -4
  41. package/src/web/client/shell/icons.model.ts +4 -7
  42. package/src/web/client/shell/keys.model.ts +5 -42
  43. package/src/web/client/shell/keys.ts +2 -2
  44. package/src/web/client/shell/shell.model.ts +10 -133
  45. package/src/web/client/shell/theme.model.ts +2 -2
  46. package/src/web/client/shell/theme.ts +33 -157
  47. package/src/web/client/state.ts +9 -89
  48. package/src/web/client/tree/Tree.tsx +25 -575
  49. package/src/web/client/tree/tree.model.ts +8 -162
  50. package/src/web/client/workspace.ts +72 -242
  51. package/src/web/server/page.ts +8 -10
  52. package/src/web/server/routes.ts +30 -563
  53. package/src/web/server/server.ts +6 -145
  54. package/src/web/shared/layout.ts +72 -624
  55. package/src/web/shared/wire.ts +10 -196
  56. package/src/core/sessions.ts +0 -929
  57. package/src/pi/sessionScan.ts +0 -104
  58. package/src/pi/viewer/tui/explorer.ts +0 -586
  59. package/src/web/client/live.model.ts +0 -275
  60. package/src/web/client/live.ts +0 -151
  61. package/src/web/client/note/Editor.tsx +0 -109
  62. package/src/web/client/note/editor.controller.ts +0 -151
  63. package/src/web/client/note/editor.model.ts +0 -686
  64. package/src/web/client/search/search.ts +0 -107
  65. package/src/web/client/shell/Divider.tsx +0 -44
  66. package/src/web/client/shell/cssvars.ts +0 -70
  67. package/src/web/client/shell/drag.model.ts +0 -170
  68. package/src/web/client/shell/layout.model.ts +0 -500
  69. package/src/web/client/shell/viewport.ts +0 -29
  70. package/src/web/server/sse.ts +0 -321
  71. package/src/web/server/watcher.ts +0 -507
@@ -1,45 +1,7 @@
1
- /**
2
- * Live force dynamics for the graph's current shape — the Obsidian-style
3
- * "the graph is alive" feel while the user drags.
4
- *
5
- * ## One physics, two drivers
6
- *
7
- * The static layout (`shared/layout.ts`) and this module now share ONE force
8
- * configuration: {@link createForceSimulation}. They used to be two
9
- * independent physics — the static path ring-sized its link distances per
10
- * fan-out and anchored every node to its seed; this one pulled at a flat 90
11
- * units toward its own equilibrium. Every graph therefore *visibly re-laid
12
- * itself* after mount (a 215-node repo graph migrated from a ~1200-unit ring
13
- * to the bespoke sim's ~300-unit cloud over about thirty seconds), and the
14
- * constant-alpha loop ticked at 60 fps forever, because a bespoke integrator
15
- * has no alpha to decay. Sharing the factory fixes both by construction: the
16
- * warm start is already at the shared equilibrium, so there is nothing to
17
- * migrate to, and d3's alpha decay is what says "settled".
18
- *
19
- * ## The lifecycle
20
- *
21
- * A new engine starts at {@link SETTLE_ALPHA} (an expand/collapse lands new
22
- * nodes already laid out; the brief settle is the "alive" response, not a
23
- * re-layout) and decays to d3's alpha floor, at which point {@link awake}
24
- * turns false and the component's clock puts itself to sleep — no frames are
25
- * spent on a graph that is holding still. `pin` re-heats to
26
- * {@link DRAG_ALPHA_TARGET} (d3's own drag pattern), so neighbours make room
27
- * while the user drags; `release` clears the pin AND retargets the node's
28
- * anchor to its drop point, so a dragged node stays where it was dropped
29
- * instead of springing back to its seed.
30
- *
31
- * ## The §7.2 vertical-line freeze
32
- *
33
- * Inherited from d3 itself: `manyBody`, `collide` and `link` all inject a
34
- * seeded `jiggle` into a zero axis component, so coincident or collinear
35
- * nodes always acquire a direction. The seeded LCG (`seed: 1`) keeps the
36
- * whole thing deterministic — the same graph, the same gestures, byte-identical
37
- * positions — which is what the test gate asserts.
38
- */
1
+ /** Live d3-force dynamics used while a node is dragged. */
39
2
 
40
3
  import type { Point } from "../../shared/layout";
41
- import { branchAnchors, collideRadius, createForceSimulation, isContainment } from "../../shared/layout";
42
- import type { WireEdgeKind } from "../../shared/graph";
4
+ import { collideRadius, createForceSimulation } from "../../shared/layout";
43
5
  import type { RenderGraph } from "./graph.model";
44
6
 
45
7
  interface SimNode {
@@ -55,12 +17,6 @@ interface SimNode {
55
17
  r?: number;
56
18
  }
57
19
 
58
- interface SimLink {
59
- source: string | SimNode;
60
- target: string | SimNode;
61
- kind: WireEdgeKind;
62
- }
63
-
64
20
  export interface GraphSimulation {
65
21
  tick(): void;
66
22
  positions(): Map<string, Point>;
@@ -118,7 +74,7 @@ export function createGraphSimulation(graph: RenderGraph, initial?: ReadonlyMap<
118
74
  // would throw on them, so they are dropped here and the layout's own
119
75
  // `pathologicalGraph` fixture remains the gate for degenerate *outputs*.
120
76
  const seen = new Set<string>();
121
- const links: SimLink[] = [];
77
+ const links: { source: string; target: string; kind: RenderGraph["edges"][number]["kind"] }[] = [];
122
78
  for (const edge of graph.edges) {
123
79
  if (edge.source === edge.target) continue;
124
80
  if (!byId.has(edge.source) || !byId.has(edge.target)) continue;
@@ -128,18 +84,7 @@ export function createGraphSimulation(graph: RenderGraph, initial?: ReadonlyMap<
128
84
  links.push({ source: edge.source, target: edge.target, kind: edge.kind });
129
85
  }
130
86
 
131
- // No invented seeding; centre gravity lives in the factory (`forceX()`/
132
- // `forceY()`). The anchors start as the **branch ring** (`branchAnchors`),
133
- // so a released graph holds the separated equilibrium the static layout
134
- // settled into instead of gliding back toward one centre — the live and
135
- // static paths share one physics, and that includes its gravity targets.
136
- // `release` then overrides a dropped node's entry with its drop point, so a
137
- // placement sticks (the anchor is five times stronger than the leaf
138
- // spring, so the node rests where the user left it).
139
- const settled = new Map<string, Point>();
140
- for (const node of graph.nodes) settled.set(node.id, { x: node.x, y: node.y });
141
- const anchors = new Map<string, Point>(branchAnchors(graph, settled));
142
- const sim = createForceSimulation({ nodes, links, anchors, seed: 1 })
87
+ const sim = createForceSimulation({ nodes, links, seed: 1 })
143
88
  .alpha(SETTLE_ALPHA)
144
89
  .alphaMin(ALPHA_MIN);
145
90
 
@@ -174,14 +119,9 @@ export function createGraphSimulation(graph: RenderGraph, initial?: ReadonlyMap<
174
119
  release(id) {
175
120
  const node = byId.get(id);
176
121
  if (!node) return;
177
- // Unfix, like d3's `dragended` — and retarget the node's centre-gravity
178
- // anchor to the drop point, so the graph reads the drop as a placement:
179
- // the node rests where the user left it instead of gliding back into
180
- // the tree.
181
- anchors.set(id, { x: node.x ?? 0, y: node.y ?? 0 });
182
122
  node.fx = null;
183
123
  node.fy = null;
184
124
  sim.alphaTarget(0);
185
125
  },
186
126
  };
187
- }
127
+ }
@@ -13,7 +13,7 @@
13
13
  * whole renderer: `sigmaRenderer` holds the lifecycle, the reducers and the
14
14
  * delegation, and is unit-tested against `SigmaLike` with a recording fake.
15
15
  * Identical shape and identical reasoning to `api.dom.ts` for `fetch` and
16
- * `domEventSource` in `live.ts` for `EventSource`.
16
+ * the injected renderer port in the testable half of this module.
17
17
  *
18
18
  * This module is only reachable from a `.tsx` entry point, so it is compiled
19
19
  * exclusively by `tsconfig.web.json` (which has `DOM`) and never pulled into
@@ -29,24 +29,23 @@
29
29
 
30
30
  import Sigma from "sigma";
31
31
  import type { ColorScheme } from "./graph.model";
32
- import type { GraphRenderer, RendererFactory, SigmaFactory, SigmaLike } from "./renderer";
32
+ import type { GraphRenderer, RendererFactory, SigmaLike } from "./renderer";
33
33
  import { sigmaRenderer } from "./renderer";
34
34
 
35
35
  /** Sigma's own container parameter type, recovered without naming the DOM. */
36
36
  type SigmaContainer = ConstructorParameters<typeof Sigma>[1];
37
37
 
38
38
  /**
39
- * `new Sigma(graph, container, settings)`, as a {@link SigmaFactory}.
40
- *
41
39
  * The real `Sigma` satisfies `SigmaLike` structurally — `on`, `setSetting`,
42
40
  * `setGraph`, `refresh`, `getCamera` and `kill` are all present with
43
41
  * compatible shapes — so the only work here is the container cast, which is
44
42
  * checked against sigma's own declared parameter type rather than against an
45
43
  * `HTMLElement` asserted from memory.
46
44
  */
47
- const createSigma: SigmaFactory = (graph, container, settings) =>
48
- new Sigma(graph, container as unknown as SigmaContainer, settings) as unknown as SigmaLike;
49
-
50
45
  /** The browser's renderer factory. Passed to the graph column by the shell. */
51
46
  export const createSigmaRenderer: RendererFactory = (scheme: ColorScheme): GraphRenderer =>
52
- sigmaRenderer(createSigma, scheme);
47
+ sigmaRenderer(
48
+ (graph, container, settings) =>
49
+ new Sigma(graph, container as unknown as SigmaContainer, settings) as unknown as SigmaLike,
50
+ scheme,
51
+ );
@@ -23,20 +23,20 @@
23
23
  * line of ours runs.
24
24
  *
25
25
  * That is a worse outcome than it looks. This repository's rule (§10, and
26
- * `docs/testing.md` §L5.2) is that *untestable lines* are kept to a handful,
26
+ * the browser coverage policy) is that *untestable lines* are kept to a handful,
27
27
  * not that untestable *files* are excluded — the one coverage exclusion that
28
28
  * exists is a type-only module, and a blanket `src/web/client/**` exclude is
29
29
  * explicitly "not acceptable". A whole renderer sitting outside the gate would
30
30
  * be exactly the erosion that rule prevents.
31
31
  *
32
- * So the dependency is inverted. {@link SigmaLike} and {@link SigmaFactory}
32
+ * So the dependency is inverted. {@link SigmaLike}
33
33
  * are the two-and-a-half-method port sigma satisfies structurally;
34
34
  * {@link sigmaRenderer} is the entire renderer written against the port, and
35
35
  * it is covered by ordinary unit tests with a recording fake. The only thing
36
36
  * that genuinely cannot be tested is the four-line adapter in
37
37
  * `renderer.dom.ts` that says `new Sigma(graph, container, settings)` — the
38
38
  * same shape, and the same reasoning, as `api.dom.ts` for `fetch` and
39
- * `domEventSource` for `EventSource`.
39
+ * the DOM renderer seam for the browser canvas.
40
40
  *
41
41
  * §7.5's promise is unaffected. "One file changes" is still true, and it is
42
42
  * now true of a file with no branches in it.
@@ -111,7 +111,7 @@ export interface GraphRenderer {
111
111
  * importing it drags the module into the **root** `tsconfig.json` project
112
112
  * (`exclude` filters the initial glob, not what an included file imports), and
113
113
  * that project has no `DOM` lib. The structural stand-in is the same trick
114
- * `cssvars.ts`, `api.ts` and `live.ts` use; the one cast lives in
114
+ * `cssvars.ts` and `api.ts` use; the one cast lives in
115
115
  * `renderer.dom.ts`, which is compiled only by `tsconfig.web.json`.
116
116
  */
117
117
  export interface RenderContainer {
@@ -119,33 +119,6 @@ export interface RenderContainer {
119
119
  readonly clientHeight: number;
120
120
  }
121
121
 
122
- /**
123
- * A renderer that draws nothing.
124
- *
125
- * Not a test double — a production path. The graph column renders before its
126
- * container exists (the first pass), may never mount at all (the `medium`
127
- * breakpoint collapses the column), and must keep working when it does not. A
128
- * null object removes the `renderer === null` check from every call site,
129
- * which is the check that is always missing from exactly one of them.
130
- */
131
- export function nullRenderer(): GraphRenderer {
132
- return {
133
- mount() {},
134
- setGraph() {},
135
- setPositions() {},
136
- setHighlight() {},
137
- onSelect() {},
138
- fit() {},
139
- onDragStart() {},
140
- onDragMove() {},
141
- onDragEnd() {},
142
- positions() {
143
- return new Map();
144
- },
145
- destroy() {},
146
- };
147
- }
148
-
149
122
  /**
150
123
  * How a {@link GraphRenderer} is obtained.
151
124
  *
@@ -171,7 +144,7 @@ export interface CameraLike {
171
144
  * The slice of `Sigma` this renderer drives.
172
145
  *
173
146
  * Structural, so the real class satisfies it without a cast and a fake is an
174
- * object literal — the same reasoning `EventSourceLike` in `live.ts` records.
147
+ * object literal — the same reasoning the injected renderer port records.
175
148
  * Six methods, and every one of them is called below, so the port cannot grow
176
149
  * a member nothing uses.
177
150
  *
@@ -212,8 +185,6 @@ export interface SigmaLike {
212
185
  * everything *around* it can be. `renderer.dom.ts` is the four-line adapter
213
186
  * that supplies the real constructor.
214
187
  */
215
- export type SigmaFactory = (graph: ProjectedGraph, container: RenderContainer, settings: GraphSettings) => SigmaLike;
216
-
217
188
  // --- the implementation --------------------------------------------------------------
218
189
 
219
190
  /**
@@ -230,7 +201,10 @@ export type SigmaFactory = (graph: ProjectedGraph, container: RenderContainer, s
230
201
  * every node colour and re-projecting — a second code path for a case nobody
231
202
  * hits.
232
203
  */
233
- export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphRenderer {
204
+ export function sigmaRenderer(
205
+ create: (graph: ProjectedGraph, container: RenderContainer, settings: GraphSettings) => SigmaLike,
206
+ scheme: ColorScheme,
207
+ ): GraphRenderer {
234
208
  let sigma: SigmaLike | null = null;
235
209
  let graph: ProjectedGraph = project({ nodes: [], edges: [] });
236
210
  let highlight: ReadonlySet<string> | null = null;
@@ -28,7 +28,7 @@ if (host !== null) {
28
28
  // writes nothing — the media query already answered.
29
29
  applyPrePaintTheme();
30
30
  const boot = readBootstrap(document.getElementById(BOOTSTRAP_ELEMENT_ID)?.textContent ?? null);
31
- render(<Shell cwd={boot.cwd} initialWidth={window.innerWidth} platform={navigator.platform} />, host);
31
+ render(<Shell cwd={boot.cwd} platform={navigator.platform} />, host);
32
32
  }
33
33
 
34
34
  /** Apply the stored theme choice to `<html>` synchronously, pre-render. */
@@ -1,28 +1,16 @@
1
1
  /**
2
- * The note column (weave-workspace §1.2, P2.4, P5, P6.3).
2
+ * The read-only note column (weave-workspace §1.2, P2.4, P6.3).
3
3
  *
4
- * Props in, JSX out. The markdown pipeline, the sanitiser config, the
5
- * wikilink resolution and every string are `note.model.ts`; the editor's
6
- * dirty tracking, save lifecycle and conflict resolution are
7
- * `editor.model.ts`. This file wires a click handler, sets `innerHTML` with
8
- * content that has already been through all three of that module's layers,
9
- * and swaps the rendered body for a `<textarea>` when the editor is open.
4
+ * Props in, JSX out. The markdown pipeline, sanitiser config, wikilink
5
+ * resolution and every string are in `note.model.ts`. This file wires the
6
+ * rendered body, selection-safe wikilink navigation, and the Open in
7
+ * `$EDITOR` action.
10
8
  *
11
- * `dangerouslySetInnerHTML` is used deliberately and exactly twice. The
9
+ * `dangerouslySetInnerHTML` is used deliberately for sanitized note HTML. The
12
10
  * alternative is parsing marked's output into a Preact tree, which means a
13
11
  * second HTML parser in the bundle and a second place for a sanitisation
14
12
  * mistake to hide. One clearly-marked line whose input is
15
- * `renderNote(DOMPurify, …)` is easier to audit than a hundred lines that
16
- * avoid the word "dangerously". The second is the ⌘E control's icon — a
17
- * constant from `editor.model.ts`, not note content, and small enough that
18
- * a second site is cheaper than an icon component.
19
- *
20
- * The read view is rendered from the **note**, not from the draft: what the
21
- * `<textarea>` holds is unsaved and un-sanitised, and piping it through
22
- * `renderNote` on every keystroke would be a live preview paid for with a
23
- * markdown parse and a DOMPurify pass per character. §11 P5.4 mentions a live
24
- * preview; the honest version of it is `⌘E`, which is instant and shows the
25
- * text that actually exists.
13
+ * `renderNote(DOMPurify, …)` is easier to audit than a second HTML parser.
26
14
  *
27
15
  * ## Wikilink preview (P6.3)
28
16
  *
@@ -45,9 +33,6 @@
45
33
  import DOMPurify from "dompurify";
46
34
  import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
47
35
  import type { GraphPayload, NotePayload } from "../../shared/wire";
48
- import { Editor, EditorBar } from "./Editor";
49
- import type { EditorEvent, EditorPrompt, EditorToolbar } from "./editor.model";
50
- import { OPEN_HINT, OPEN_ICON, OPEN_LABEL } from "./editor.model";
51
36
  import {
52
37
  CREATED_WORD,
53
38
  EDITED_WORD,
@@ -82,14 +67,9 @@ export interface NoteProps {
82
67
  graph: GraphPayload | null;
83
68
  selectedId: string | null;
84
69
  onSelect: (id: string) => void;
70
+ onOpen: (slug: string) => void;
85
71
  /** Epoch ms for relative times. Injected so the render is deterministic. */
86
72
  now: number;
87
- /** The editor's view model, or `null` when the shell wired no editor. */
88
- toolbar: EditorToolbar | null;
89
- prompt: EditorPrompt | null;
90
- /** The `<textarea>`'s content. Rendered only while `toolbar.editing`. */
91
- draft: string;
92
- send: (event: EditorEvent) => void;
93
73
  }
94
74
 
95
75
  /**
@@ -97,27 +77,23 @@ export interface NoteProps {
97
77
  *
98
78
  * The hierarchy is the point: the title is the page's largest voice and the
99
79
  * meta line is its footnote — provenance glyph and word, edited, created.
100
- * `⌘E` sits at the end of that line as an icon, where a control about
101
- * *workflow* belongs in a column whose job is *reading*; its explanation
102
- * rides the `title` attribute so the icon never has to explain itself on
103
- * screen.
80
+ * The Open in `$EDITOR` action sits at the end of that line as an icon, where
81
+ * a workflow control belongs in a column whose job is reading.
104
82
  */
105
83
  function Header({
106
84
  view,
107
85
  open,
108
86
  }: {
109
87
  view: NoteHeaderView;
110
- open: (() => void) | null;
88
+ open: () => void;
111
89
  }) {
112
90
  return (
113
91
  <header class="weave-note-head">
114
92
  <h3 class="weave-note-title">{view.title}</h3>
115
93
  <p class="weave-note-meta">
116
- {open === null ? null : (
117
- <button type="button" class="weave-note-open" title={OPEN_HINT} aria-label={OPEN_LABEL} onClick={open}>
118
- <span class="weave-note-open-mark" aria-hidden="true" dangerouslySetInnerHTML={{ __html: OPEN_ICON }} />
119
- </button>
120
- )}
94
+ <button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
95
+ <span class="weave-note-open-mark" aria-hidden="true">↗</span>
96
+ </button>
121
97
  <span class={`weave-prov weave-prov-${view.provenance}`} title={view.provenanceTitle}>
122
98
  {view.provenanceGlyph} {view.provenance}
123
99
  </span>
@@ -145,9 +121,8 @@ export function Note(props: NoteProps) {
145
121
 
146
122
  // Both hook calls sit before the empty-return so the hook order cannot
147
123
  // depend on whether a note is loaded. The memoization is cheap insurance:
148
- // one marked parse + DOMPurify pass + O(nodes) index per *body or vault*
149
- // instead of per shell render (every editor keystroke and divider pixel
150
- // re-renders the shell). The instance the render builds —
124
+ // one marked parse + DOMPurify pass + O(nodes) index per body or vault
125
+ // instead of per shell render. The instance the render builds
151
126
  // `markdownRenderer(index)` inside `renderNote` — stays per-call by design;
152
127
  // only the *result* is memoized.
153
128
  const index = useMemo(() => (note === null ? null : wikiIndex(props.graph, note.slug)), [note, props.graph]);
@@ -212,11 +187,6 @@ export function Note(props: NoteProps) {
212
187
 
213
188
  if (note === null || index === null) return <p class="weave-note-empty">{empty}</p>;
214
189
 
215
- // Bound once so TypeScript narrows it for both uses below: `toolbar.editing`
216
- // is what decides whether the textarea renders, and reading it twice off
217
- // `props` would need a non-null assertion at the second read.
218
- const toolbar = props.toolbar;
219
- const shell = { draft: props.draft, prompt: props.prompt, send: props.send };
220
190
  // Keyed on the slug, not the body digest: the article is the scroll
221
191
  // container, so an in-place swap would open every note at the previous
222
192
  // note's scroll offset. A key change remounts it, and a fresh container
@@ -228,13 +198,9 @@ export function Note(props: NoteProps) {
228
198
  <article key={note.slug} class={`weave-note weave-note-${header.provenance}`}>
229
199
  <Header
230
200
  view={header}
231
- open={toolbar === null ? null : () => props.send({ type: "open" })}
201
+ open={() => props.onOpen(note.slug)}
232
202
  />
233
- {toolbar === null ? null : <EditorBar toolbar={toolbar} {...shell} />}
234
- {toolbar !== null && toolbar.editing ? (
235
- <Editor toolbar={toolbar} {...shell} />
236
- ) : (
237
- <div
203
+ <div
238
204
  ref={bodyRef}
239
205
  class="weave-note-body"
240
206
  // Programmatic focus target for `⌘2`: without a `tabindex`,
@@ -265,26 +231,19 @@ export function Note(props: NoteProps) {
265
231
  }}
266
232
  onBlur={() => dispatch({ type: "hide" })}
267
233
  onClick={(event) => {
268
- // Selecting text to copy (or mouse drag) must not flip into edit
269
- // mode or trigger wikilink navigation. Only a clean click with no
270
- // active selection routes the gesture.
234
+ // Selecting text to copy must not trigger wikilink navigation.
271
235
  const selection =
272
236
  typeof window !== "undefined" && typeof window.getSelection === "function"
273
237
  ? window.getSelection()
274
238
  : null;
275
239
  if (hasTextSelection(selection)) return;
276
240
 
277
- // A wikilink carries no href, so nothing is navigating; this only
278
- // has to route the click onto the §1.3 context bus. Anywhere else
279
- // on the page *is* the edit affordance: a click on the prose opens
280
- // the editor. The two never fight, because the link check reads
281
- // the exact element the click landed on.
241
+ // A wikilink carries no href, so route it onto the context bus.
282
242
  const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
283
243
  if (target !== null) {
284
244
  props.onSelect(target);
285
245
  return;
286
246
  }
287
- if (props.toolbar !== null) props.send({ type: "toggle" });
288
247
  }}
289
248
  onKeyDown={(event) => {
290
249
  // Escape is first so the card closes on the gesture a keyboard
@@ -306,7 +265,6 @@ export function Note(props: NoteProps) {
306
265
  // Sanitised by `renderNote`'s three layers — see note.model.ts.
307
266
  dangerouslySetInnerHTML={{ __html: html }}
308
267
  />
309
- )}
310
268
  {card === null ? null : (
311
269
  <div
312
270
  ref={cardRef}
@@ -321,4 +279,4 @@ export function Note(props: NoteProps) {
321
279
  )}
322
280
  </article>
323
281
  );
324
- }
282
+ }
@@ -1,27 +1,17 @@
1
- /**
2
- * The ⌘K palette (weave-workspace §1.2, P4).
3
- *
4
- * Props in, JSX out. Ranking, merging, debouncing, stale-response rejection,
5
- * cursor movement and every string are `search.model.ts` and `search.ts`; the
6
- * focus trap is `focus.model.ts` behind `useFocusTrap`. What is left is a
7
- * `useState`, one memo and three handlers. §10's rule.
8
- */
9
-
10
- import { useEffect, useMemo, useRef, useState } from "preact/hooks";
1
+ import { useEffect, useRef, useState } from "preact/hooks";
11
2
  import type { GraphPayload } from "../../shared/wire";
12
3
  import { useFocusTrap } from "../shell/FocusTrap";
4
+ import { fetchSearch } from "../api";
5
+ import type { FetchLike } from "../api";
13
6
  import type { SearchRowView } from "./search.model";
14
- import { PALETTE_HINT, PALETTE_PLACEHOLDER, PALETTE_TITLE, initialSearchState, paletteModel, resultIdAt, searchKey } from "./search.model";
15
- import type { SearchOptions } from "./search";
16
- import { createSearch } from "./search";
7
+ import { DEBOUNCE_MS, PALETTE_HINT, PALETTE_PLACEHOLDER, PALETTE_TITLE, initialSearchState, paletteModel, resultIdAt, searchKey } from "./search.model";
8
+ import type { SearchState } from "./search.model";
17
9
 
18
10
  export interface SearchPaletteProps {
19
11
  graph: GraphPayload | null;
20
- /** The §1.3 context bus. */
21
12
  onSelect: (id: string) => void;
22
13
  onClose: () => void;
23
- /** The controller's ports (§10). Supplied by the shell. */
24
- ports: Omit<SearchOptions, "onChange">;
14
+ ports: { fetch: FetchLike };
25
15
  }
26
16
 
27
17
  function Row({ row, onPick, onHover }: { row: SearchRowView; onPick: () => void; onHover: () => void }) {
@@ -42,24 +32,47 @@ function Row({ row, onPick, onHover }: { row: SearchRowView; onPick: () => void;
42
32
  }
43
33
 
44
34
  export function SearchPalette(props: SearchPaletteProps) {
45
- const [state, setState] = useState(initialSearchState);
46
- const search = useMemo(() => createSearch({ ...props.ports, onChange: setState }), []);
35
+ const [state, setState] = useState<SearchState>(initialSearchState);
36
+ const requestId = useRef(0);
37
+ const abort = useRef<AbortController | null>(null);
47
38
  const trap = useFocusTrap();
48
39
  const input = useRef<HTMLInputElement | null>(null);
49
- // Focus the input, not the container the trap starts on: the palette's
50
- // whole surface is its query, and opening it to a container that only
51
- // swallows (and does not forward) keystrokes makes the user click before
52
- // they can type. Runs after the trap's own effect — hooks run in order,
53
- // and the trap is created first — so this is the final word. The input
54
- // carries the dialog's `aria-label`, so the announcement the
55
- // container-focus exists for still happens.
40
+
56
41
  useEffect(() => void input.current?.focus(), []);
57
- const model = paletteModel(state, props.graph);
58
42
 
43
+ useEffect(() => {
44
+ const query = state.query.trim();
45
+ let active = true;
46
+ if (!query) {
47
+ abort.current?.abort();
48
+ setState((current) => ({ ...current, hits: [], answered: "", loading: false, failed: false }));
49
+ return () => { active = false; };
50
+ }
51
+
52
+ const timer = window.setTimeout(async () => {
53
+ const id = ++requestId.current;
54
+ abort.current?.abort();
55
+ const controller = new AbortController();
56
+ abort.current = controller;
57
+ setState((current) => ({ ...current, loading: true, failed: false }));
58
+ const result = await fetchSearch(props.ports.fetch, query, controller.signal);
59
+ if (!active || controller.signal.aborted || id !== requestId.current) return;
60
+ setState((current) => result.ok
61
+ ? { ...current, hits: result.data.hits, answered: query, loading: false, failed: false, cursor: 0 }
62
+ : { ...current, loading: false, failed: true });
63
+ }, DEBOUNCE_MS);
64
+
65
+ return () => {
66
+ active = false;
67
+ window.clearTimeout(timer);
68
+ abort.current?.abort();
69
+ };
70
+ }, [state.query, props.ports.fetch]);
71
+
72
+ const model = paletteModel(state, props.graph);
59
73
  const pick = (index: number | null): void => {
60
74
  const id = resultIdAt(model.rows, index);
61
75
  if (id !== null) props.onSelect(id);
62
- search.dismiss();
63
76
  props.onClose();
64
77
  };
65
78
 
@@ -75,10 +88,10 @@ export function SearchPalette(props: SearchPaletteProps) {
75
88
  onClick={(event) => event.stopPropagation()}
76
89
  onKeyDown={(event) => {
77
90
  if (trap.onKeyDown(event as unknown as KeyboardEvent)) return;
78
- const next = searchKey(state, event.key, model.count);
91
+ const next = searchKey(state.cursor, event.key, model.count);
79
92
  if (!next.handled) return;
80
93
  event.preventDefault();
81
- search.setCursor(next.state.cursor);
94
+ setState((current) => ({ ...current, cursor: next.cursor }));
82
95
  if (next.dismiss) pick(next.activate);
83
96
  }}
84
97
  >
@@ -91,18 +104,14 @@ export function SearchPalette(props: SearchPaletteProps) {
91
104
  aria-label={PALETTE_TITLE}
92
105
  aria-controls="weave-search-results"
93
106
  aria-activedescendant={model.activeDomId ?? undefined}
94
- onInput={(event) => search.setQuery(event.currentTarget.value)}
107
+ onInput={(event) => setState((current) => ({ ...current, query: event.currentTarget.value, cursor: 0 }))}
95
108
  />
96
109
  {model.status === null ? (
97
110
  <ul id="weave-search-results" class="weave-hits" role="listbox" aria-label={PALETTE_TITLE}>
98
- {model.rows.map((row, index) => (
99
- <Row key={row.id} row={row} onPick={() => pick(index)} onHover={() => search.setCursor(index)} />
100
- ))}
111
+ {model.rows.map((row, index) => <Row key={row.id} row={row} onPick={() => pick(index)} onHover={() => setState((current) => ({ ...current, cursor: index }))} />)}
101
112
  </ul>
102
113
  ) : (
103
- <p class="weave-palette-status" role="status">
104
- {model.status}
105
- </p>
114
+ <p class="weave-palette-status" role="status">{model.status}</p>
106
115
  )}
107
116
  <p class="weave-palette-foot">
108
117
  <span>{model.countLabel}</span>