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,42 +1,21 @@
1
- /**
2
- * The tree column (weave-workspace §1.2, P2.3).
3
- *
4
- * Props in, JSX out. Every string, glyph, branch and key binding comes from
5
- * `tree.model.ts`; what is left here is a `useState`, a `map` and four
6
- * handlers that forward into it. §10's rule, and the reason the 95 % gate
7
- * survives a UI phase with no DOM test environment.
8
- */
1
+ /** The read-only tree column. */
9
2
 
10
- import { useLayoutEffect, useRef, useState } from "preact/hooks";
11
- import { recentIds } from "../state";
3
+ import { useState } from "preact/hooks";
12
4
  import { isTextEntry, type KeyTarget } from "../shell/keys.model";
13
5
  import { ICON_BOX, ICON_STROKE, ICONS } from "../shell/icons.model";
14
6
  import type { IconName } from "../shell/icons.model";
15
- import type { GraphPayload, WireNodeKind } from "../../shared/wire";
16
- import type { TreeContextMenuState, TreeRowView, TreeViewState } from "./tree.model";
17
- import type { FetchLike } from "../api";
18
- import { fetchJson } from "../api.dom";
19
- import { createFolder, deleteFolder, deleteNote, moveNote, renameFolder, renameNote } from "../api";
7
+ import type { GraphPayload } from "../../shared/wire";
20
8
  import {
21
9
  FILTER_HINT,
22
10
  FILTER_LABEL,
23
11
  FILTER_PLACEHOLDER,
24
- FOLDER_BTN_HINT,
25
- FOLDER_PLACEHOLDER,
26
12
  TREE_LABEL,
27
13
  treeActiveDescendant,
28
- contextMenuItemsForRow,
29
- contextMenuPlacement,
30
14
  cycleProvenance,
31
- deletableTarget,
32
15
  depthVar,
33
- expand,
34
- folderPathFromId,
35
16
  initialTreeView,
36
17
  internalsHint,
37
18
  internalsLabel,
38
- isDraggableNote,
39
- isDropTarget,
40
19
  provenanceHint,
41
20
  provenanceLabel,
42
21
  rowCountLabel,
@@ -52,23 +31,11 @@ import {
52
31
  export interface TreeProps {
53
32
  graph: GraphPayload | null;
54
33
  selectedId: string | null;
34
+ recentIds: ReadonlySet<string>;
55
35
  onSelect: (id: string) => void;
56
- /** Epoch ms for relative times. Injected so the render is deterministic. */
57
36
  now: number;
58
- fetch?: FetchLike | undefined;
59
37
  }
60
38
 
61
- /**
62
- * One sprite glyph, as a real `<svg>`.
63
- *
64
- * The element is built from {@link ICONS}' path data rather than injected as
65
- * an HTML string — CSP-identical (neither path touches a `script-src` hook),
66
- * but the string form would carry a whole `<svg>` per row and Preact can
67
- * branch the two paint modes with a spread and no `if`. Every attribute here
68
- * is a presentation *attribute*, not a `style` one: `style-src` never sees it.
69
- * Sizing rides the width/height attributes rather than CSS for the same
70
- * reason — the box is part of the icon, not of its context.
71
- */
72
39
  export function Icon({ name, class: className }: { name: IconName; class?: string }) {
73
40
  const def = ICONS[name];
74
41
  return (
@@ -84,43 +51,17 @@ export function Icon({ name, class: className }: { name: IconName; class?: strin
84
51
  stroke-linejoin="round"
85
52
  aria-hidden="true"
86
53
  >
87
- {def.d.map((d) => (
88
- <path d={d} />
89
- ))}
54
+ {def.d.map((d) => <path d={d} />)}
90
55
  </svg>
91
56
  );
92
57
  }
93
58
 
94
- function Row({
95
- view,
96
- onSelect,
97
- onToggle,
98
- onDelete,
99
- onDragStart,
100
- onDragOver,
101
- onDragLeave,
102
- onDrop,
103
- isDragOver,
104
- }: {
105
- view: TreeRowView;
106
- onSelect: () => void;
107
- onToggle: () => void;
108
- onDelete?: (() => void) | undefined;
109
- onDragStart?: (event: DragEvent) => void;
110
- onDragOver?: (event: DragEvent) => void;
111
- onDragLeave?: (event: DragEvent) => void;
112
- onDrop?: (event: DragEvent) => void;
113
- isDragOver?: boolean;
114
- }) {
59
+ function Row({ view, recentIds, onSelect, onToggle }: { view: ReturnType<typeof rowViews>[number]; recentIds: ReadonlySet<string>; onSelect: () => void; onToggle: () => void }) {
115
60
  return (
116
61
  <li
117
62
  id={view.domId}
118
63
  data-row-id={view.id}
119
- data-row-label={view.label}
120
- data-row-kind={view.kind}
121
- class={`weave-row weave-row-${view.kind}${view.selected ? " weave-row-on" : ""}${view.muted ? " weave-row-muted" : ""}${
122
- recentIds.value.has(view.id) ? " weave-row-new" : ""
123
- }${isDragOver ? " weave-row-droptarget" : ""}`}
64
+ class={`weave-row weave-row-${view.kind}${view.selected ? " weave-row-on" : ""}${recentIds.has(view.id) ? " weave-row-new" : ""}`}
124
65
  role="treeitem"
125
66
  aria-level={view.level}
126
67
  aria-posinset={view.posinset}
@@ -129,533 +70,42 @@ function Row({
129
70
  aria-expanded={view.hasKids ? view.expanded : undefined}
130
71
  style={depthVar(view.depth)}
131
72
  onClick={onSelect}
132
- draggable={isDraggableNote(view.kind, view.id)}
133
- onDragStart={onDragStart}
134
- onDragOver={onDragOver}
135
- onDragLeave={onDragLeave}
136
- onDrop={onDrop}
137
73
  >
138
- {/* The glyphs are decoration: the twisty duplicates `aria-expanded`,
139
- the kind glyph duplicates nothing a screen reader needs, and the
140
- provenance shape is announced through its `title` instead. The
141
- chevron rotates through CSS, so "open" is a class, not a different
142
- sprite. */}
143
- <span
144
- class="weave-twisty"
145
- aria-hidden="true"
146
- onClick={(event) => {
147
- event.stopPropagation();
148
- onToggle();
149
- }}
150
- >
74
+ <span class="weave-twisty" aria-hidden="true" onClick={(event) => { event.stopPropagation(); onToggle(); }}>
151
75
  {view.hasKids ? <Icon name="chevron" class={view.expanded ? "weave-icon weave-icon-open" : "weave-icon"} /> : null}
152
76
  </span>
153
- <span class="weave-kind" aria-hidden="true">
154
- <Icon name={view.kindIcon} class="weave-icon" />
155
- </span>
156
- <span class={`weave-prov weave-prov-${view.provenance ?? "none"}`} title={view.provenanceTitle}>
157
- {view.provenanceGlyph}
158
- </span>
77
+ <span class="weave-kind" aria-hidden="true"><Icon name={view.kindIcon} class="weave-icon" /></span>
78
+ <span class={`weave-prov weave-prov-${view.provenance ?? "none"}`} title={view.provenanceTitle}>{view.provenanceGlyph}</span>
159
79
  <span class="weave-label">{view.label}</span>
160
80
  <span class="weave-meta">{view.meta}</span>
161
- {onDelete ? (
162
- <button
163
- type="button"
164
- class="weave-row-del"
165
- title={`Delete ${view.label}`}
166
- aria-label={`Delete ${view.label}`}
167
- onClick={(event) => {
168
- event.stopPropagation();
169
- onDelete();
170
- }}
171
- >
172
- ×
173
- </button>
174
- ) : null}
175
81
  </li>
176
82
  );
177
83
  }
178
84
 
179
85
  export function Tree(props: TreeProps) {
180
- const [state, setState] = useState<TreeViewState>(initialTreeView);
181
- const [creatingFolder, setCreatingFolder] = useState(false);
182
- const [folderName, setFolderName] = useState("");
183
- const [dragOverId, setDragOverId] = useState<string | null>(null);
184
- const [menu, setMenu] = useState<TreeContextMenuState | null>(null);
185
- const [pendingDelete, setPendingDelete] = useState<{ id: string; label: string; isFolder: boolean } | null>(null);
186
- const [pendingRename, setPendingRename] = useState<{ slug: string; currentName: string; value: string } | null>(null);
187
- const [pendingRenameFolder, setPendingRenameFolder] = useState<{ oldPath: string; currentName: string; value: string } | null>(null);
188
- const [pendingSubfolder, setPendingSubfolder] = useState<{ parentPath: string; value: string } | null>(null);
189
- const menuRef = useRef<HTMLDivElement | null>(null);
190
-
86
+ const [state, setState] = useState(initialTreeView);
191
87
  const rows = rowsFor(props.graph, state);
192
88
  const empty = treeEmptyMessage(props.graph, rows, state);
193
- const fetcher = props.fetch ?? fetchJson;
194
-
195
- useLayoutEffect(() => {
196
- const el = menuRef.current;
197
- if (!el || !menu) return;
198
- const placement = contextMenuPlacement(
199
- menu.x,
200
- menu.y,
201
- el.offsetWidth || 170,
202
- el.offsetHeight || 130,
203
- window.innerWidth,
204
- window.innerHeight,
205
- );
206
- el.style.setProperty("--weave-menu-x", `${placement.x}px`);
207
- el.style.setProperty("--weave-menu-y", `${placement.y}px`);
208
- }, [menu]);
209
-
210
- const confirmDeleteRow = (id: string, label: string) => {
211
- const target = deletableTarget(id);
212
- if (!target) return;
213
- setPendingDelete({ id, label, isFolder: target.type === "folder" });
214
- };
215
-
216
- const executeDelete = async (target: { id: string; label: string; isFolder: boolean }) => {
217
- const desc = deletableTarget(target.id);
218
- if (!desc) return;
219
- if (desc.type === "note") {
220
- await deleteNote(fetcher, desc.slug);
221
- if (props.selectedId === target.id) {
222
- props.onSelect("");
223
- }
224
- } else {
225
- await deleteFolder(fetcher, desc.path);
226
- if (props.selectedId && props.selectedId.startsWith(`note:${desc.path}/`)) {
227
- props.onSelect("");
228
- }
229
- }
230
- };
231
-
232
- const handleMenuAction = async (actionId: string) => {
233
- if (!menu) return;
234
- const current = menu;
235
- setMenu(null);
236
- switch (actionId) {
237
- case "open":
238
- props.onSelect(current.rowId);
239
- break;
240
- case "new-folder":
241
- setCreatingFolder(true);
242
- break;
243
- case "new-subfolder": {
244
- const parentPath = current.rowId.slice("vfolder:".length);
245
- setPendingSubfolder({ parentPath, value: "" });
246
- break;
247
- }
248
- case "rename": {
249
- if (!current.rowId.startsWith("note:")) break;
250
- const currentSlug = current.rowId.slice("note:".length);
251
- setPendingRename({ slug: currentSlug, currentName: current.rowLabel, value: current.rowLabel });
252
- break;
253
- }
254
- case "rename-folder": {
255
- if (!current.rowId.startsWith("vfolder:")) break;
256
- const folderPath = current.rowId.slice("vfolder:".length);
257
- const currentLeaf = folderPath.split("/").pop() ?? current.rowLabel;
258
- setPendingRenameFolder({ oldPath: folderPath, currentName: currentLeaf, value: currentLeaf });
259
- break;
260
- }
261
- case "delete-note":
262
- case "delete-folder":
263
- confirmDeleteRow(current.rowId, current.rowLabel);
264
- break;
265
- case "collapse-all":
266
- setState((curr) => ({ ...curr, expanded: new Set() }));
267
- break;
268
- case "copy-id":
269
- if (typeof navigator !== "undefined" && navigator.clipboard) {
270
- await navigator.clipboard.writeText(current.rowId);
271
- }
272
- break;
273
- }
274
- };
275
-
276
89
  return (
277
- <div
278
- class="weave-tree"
279
- onKeyDown={(event) => {
280
- // The filter box sits inside this listener, so its keystrokes arrive
281
- // here too: a `j` meant for the query must stay a character, not an
282
- // alias the tree consumes. The model refuses when `typing` is true.
283
- const target = event.target as KeyTarget;
284
- const typing = isTextEntry(target?.tagName ?? null, target?.isContentEditable === true);
285
- const next = treeKey(rows, state, props.selectedId, event.key, typing);
286
- if (!next.handled) return;
287
- event.preventDefault();
288
- setState(next.state);
289
- if (next.selectedId !== null) props.onSelect(next.selectedId);
290
- }}
291
- >
90
+ <div class="weave-tree" onKeyDown={(event) => {
91
+ const target = event.target as KeyTarget;
92
+ const next = treeKey(rows, state, props.selectedId, event.key, isTextEntry(target?.tagName ?? null, target?.isContentEditable === true));
93
+ if (!next.handled) return;
94
+ event.preventDefault();
95
+ setState(next.state);
96
+ if (next.selectedId !== null) props.onSelect(next.selectedId);
97
+ }}>
292
98
  <div class="weave-tree-controls">
293
- <input
294
- type="search"
295
- class="weave-filter"
296
- value={state.query}
297
- placeholder={FILTER_PLACEHOLDER}
298
- aria-label={FILTER_LABEL}
299
- title={FILTER_HINT}
300
- onInput={(event) => setState(setQuery(state, event.currentTarget.value))}
301
- />
302
- <button type="button" class="weave-chip" title={provenanceHint(state.provFilter)} onClick={() => setState(cycleProvenance(state))}>
303
- ◧ {provenanceLabel(state.provFilter)}
304
- </button>
305
- <button type="button" class="weave-chip" title={internalsHint(state.showInternals)} onClick={() => setState(toggleInternals(state))}>
306
- ◧ {internalsLabel(state.showInternals)}
307
- </button>
308
- <button
309
- type="button"
310
- class="weave-chip"
311
- title={FOLDER_BTN_HINT}
312
- onClick={() => setCreatingFolder((prev) => !prev)}
313
- >
314
- + folder
315
- </button>
99
+ <input type="search" class="weave-filter" value={state.query} placeholder={FILTER_PLACEHOLDER} aria-label={FILTER_LABEL} title={FILTER_HINT} onInput={(event) => setState(setQuery(state, event.currentTarget.value))} />
100
+ <button type="button" class="weave-chip" title={provenanceHint(state.provFilter)} onClick={() => setState(cycleProvenance(state))}>◧ {provenanceLabel(state.provFilter)}</button>
101
+ <button type="button" class="weave-chip" title={internalsHint(state.showInternals)} onClick={() => setState(toggleInternals(state))}>◧ {internalsLabel(state.showInternals)}</button>
316
102
  </div>
317
- {creatingFolder ? (
318
- <form
319
- class="weave-tree-new-folder"
320
- onSubmit={async (event) => {
321
- event.preventDefault();
322
- const name = folderName.trim();
323
- if (name) {
324
- await createFolder(fetcher, name);
325
- setCreatingFolder(false);
326
- setFolderName("");
327
- }
328
- }}
329
- >
330
- <input
331
- type="text"
332
- class="weave-filter"
333
- placeholder={FOLDER_PLACEHOLDER}
334
- value={folderName}
335
- onInput={(event) => setFolderName(event.currentTarget.value)}
336
- onKeyDown={(event) => {
337
- if (event.key === "Escape") {
338
- event.stopPropagation();
339
- setCreatingFolder(false);
340
- setFolderName("");
341
- }
342
- }}
343
- autoFocus
344
- />
345
- <button type="submit" class="weave-chip">Create</button>
346
- <button
347
- type="button"
348
- class="weave-chip"
349
- onClick={() => {
350
- setCreatingFolder(false);
351
- setFolderName("");
352
- }}
353
- >
354
- Cancel
355
- </button>
356
- </form>
357
- ) : null}
358
103
  {empty === null ? (
359
- <ul
360
- class="weave-rows"
361
- role="tree"
362
- tabIndex={0}
363
- aria-label={TREE_LABEL}
364
- onContextMenu={(event: MouseEvent) => {
365
- event.preventDefault();
366
- const target = event.target as HTMLElement | null;
367
- const rowEl = target?.closest(".weave-row") as HTMLElement | null;
368
- if (rowEl) {
369
- const rowId = rowEl.getAttribute("data-row-id");
370
- const rowLabel = rowEl.getAttribute("data-row-label") ?? "";
371
- const rowKind = (rowEl.getAttribute("data-row-kind") ?? "note") as WireNodeKind;
372
- if (rowId) {
373
- setMenu({
374
- x: event.clientX,
375
- y: event.clientY,
376
- rowId,
377
- rowLabel,
378
- kind: rowKind,
379
- });
380
- return;
381
- }
382
- }
383
- setMenu({
384
- x: event.clientX,
385
- y: event.clientY,
386
- rowId: "vault",
387
- rowLabel: "Vault",
388
- kind: "vault",
389
- });
390
- }}
391
- // Focus stays on the `<ul>` and the *active* row is named by
392
- // reference — the alternative, a roving `tabindex`, would put every
393
- // row in the Tab order and make Tab a fourth way to walk the tree.
394
- // `null` when the selection is not visible; see the model.
395
- aria-activedescendant={treeActiveDescendant(rows, props.selectedId) ?? undefined}
396
- >
397
- {rowViews(rows, props.selectedId, props.now).map((view) => (
398
- <Row
399
- key={view.id}
400
- view={view}
401
- onSelect={() => {
402
- props.onSelect(view.id);
403
- if (view.hasKids || view.id.startsWith("vfolder:")) {
404
- setState((curr) => toggleExpanded(curr, view.id));
405
- }
406
- }}
407
- onToggle={() => setState(toggleExpanded(state, view.id))}
408
- onDelete={deletableTarget(view.id) ? () => confirmDeleteRow(view.id, view.label) : undefined}
409
- isDragOver={dragOverId === view.id}
410
- onDragStart={(event) => {
411
- event.dataTransfer?.setData("text/plain", view.id);
412
- if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
413
- }}
414
- onDragOver={(event) => {
415
- if (isDropTarget(view.id)) {
416
- event.preventDefault();
417
- if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
418
- setDragOverId(view.id);
419
- }
420
- }}
421
- onDragLeave={() => {
422
- if (dragOverId === view.id) setDragOverId(null);
423
- }}
424
- onDrop={async (event) => {
425
- event.preventDefault();
426
- setDragOverId(null);
427
- const draggedId = event.dataTransfer?.getData("text/plain");
428
- if (!draggedId || !draggedId.startsWith("note:")) return;
429
- const noteSlug = draggedId.slice("note:".length);
430
- const targetFolder = folderPathFromId(view.id);
431
- await moveNote(fetcher, noteSlug, targetFolder);
432
- if (view.id.startsWith("vfolder:")) {
433
- setState((current) => expand(current, view.id));
434
- }
435
- }}
436
- />
437
- ))}
104
+ <ul class="weave-rows" role="tree" tabIndex={0} aria-label={TREE_LABEL} aria-activedescendant={treeActiveDescendant(rows, props.selectedId) ?? undefined}>
105
+ {rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} onSelect={() => { props.onSelect(view.id); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} />)}
438
106
  </ul>
439
- ) : (
440
- <p class="weave-tree-empty">{empty}</p>
441
- )}
107
+ ) : <p class="weave-tree-empty">{empty}</p>}
442
108
  <p class="weave-tree-count">{rowCountLabel(rows)}</p>
443
- {menu !== null ? (
444
- <>
445
- <div
446
- class="weave-menu-backdrop"
447
- onClick={() => setMenu(null)}
448
- onContextMenu={(e: MouseEvent) => {
449
- e.preventDefault();
450
- setMenu(null);
451
- }}
452
- />
453
- <div
454
- ref={menuRef}
455
- class="weave-menu"
456
- role="menu"
457
- aria-label={`Context menu for ${menu.rowLabel}`}
458
- >
459
- {contextMenuItemsForRow(menu.rowId, menu.kind).map((item, idx) =>
460
- item.kind === "separator" ? (
461
- <hr key={idx} class="weave-menu-sep" />
462
- ) : (
463
- <button
464
- key={item.id}
465
- type="button"
466
- class={`weave-menu-item${item.destructive ? " weave-menu-item-bad" : ""}`}
467
- role="menuitem"
468
- onClick={() => void handleMenuAction(item.id)}
469
- >
470
- {item.icon ? <span aria-hidden="true">{item.icon}</span> : null}
471
- <span>{item.label}</span>
472
- </button>
473
- ),
474
- )}
475
- </div>
476
- </>
477
- ) : null}
478
- {pendingDelete !== null ? (
479
- <div class="weave-scrim" onClick={() => setPendingDelete(null)}>
480
- <div
481
- class="weave-dialog"
482
- role="alertdialog"
483
- aria-modal="true"
484
- aria-label="Confirm deletion"
485
- onClick={(e) => e.stopPropagation()}
486
- onKeyDown={(e) => {
487
- if (e.key === "Escape") setPendingDelete(null);
488
- }}
489
- >
490
- <h3 class="weave-dialog-title">Delete {pendingDelete.isFolder ? "folder" : "note"}?</h3>
491
- <p class="weave-dialog-body">
492
- Are you sure you want to delete <strong>“{pendingDelete.label}”</strong>?
493
- {pendingDelete.isFolder ? " All notes inside will be permanently deleted." : " This cannot be undone."}
494
- </p>
495
- <div class="weave-dialog-actions">
496
- <button
497
- type="button"
498
- class="weave-chip"
499
- onClick={() => setPendingDelete(null)}
500
- autoFocus
501
- >
502
- Cancel
503
- </button>
504
- <button
505
- type="button"
506
- class="weave-chip weave-chip-bad"
507
- onClick={async () => {
508
- const target = pendingDelete;
509
- setPendingDelete(null);
510
- await executeDelete(target);
511
- }}
512
- >
513
- Delete
514
- </button>
515
- </div>
516
- </div>
517
- </div>
518
- ) : null}
519
- {pendingRename !== null ? (
520
- <div class="weave-scrim" onClick={() => setPendingRename(null)}>
521
- <div
522
- class="weave-dialog"
523
- role="dialog"
524
- aria-modal="true"
525
- aria-label="Rename note"
526
- onClick={(e) => e.stopPropagation()}
527
- >
528
- <h3 class="weave-dialog-title">Rename note</h3>
529
- <form
530
- onSubmit={async (e) => {
531
- e.preventDefault();
532
- const name = pendingRename.value.trim();
533
- if (name && name !== pendingRename.currentName) {
534
- await renameNote(fetcher, pendingRename.slug, name, name);
535
- }
536
- setPendingRename(null);
537
- }}
538
- >
539
- <input
540
- type="text"
541
- class="weave-filter weave-dialog-input"
542
- value={pendingRename.value}
543
- onInput={(e) => setPendingRename({ ...pendingRename, value: e.currentTarget.value })}
544
- onKeyDown={(e) => {
545
- if (e.key === "Escape") {
546
- e.stopPropagation();
547
- setPendingRename(null);
548
- }
549
- }}
550
- autoFocus
551
- />
552
- <div class="weave-dialog-actions">
553
- <button type="button" class="weave-chip" onClick={() => setPendingRename(null)}>
554
- Cancel
555
- </button>
556
- <button type="submit" class="weave-chip">
557
- Rename
558
- </button>
559
- </div>
560
- </form>
561
- </div>
562
- </div>
563
- ) : null}
564
- {pendingRenameFolder !== null ? (
565
- <div class="weave-scrim" onClick={() => setPendingRenameFolder(null)}>
566
- <div
567
- class="weave-dialog"
568
- role="dialog"
569
- aria-modal="true"
570
- aria-label="Rename folder"
571
- onClick={(e) => e.stopPropagation()}
572
- >
573
- <h3 class="weave-dialog-title">Rename folder</h3>
574
- <form
575
- onSubmit={async (e) => {
576
- e.preventDefault();
577
- const name = pendingRenameFolder.value.trim();
578
- if (name && name !== pendingRenameFolder.currentName) {
579
- const prefix = pendingRenameFolder.oldPath.includes("/")
580
- ? pendingRenameFolder.oldPath.split("/").slice(0, -1).join("/") + "/"
581
- : "";
582
- await renameFolder(fetcher, pendingRenameFolder.oldPath, prefix + name);
583
- }
584
- setPendingRenameFolder(null);
585
- }}
586
- >
587
- <input
588
- type="text"
589
- class="weave-filter weave-dialog-input"
590
- value={pendingRenameFolder.value}
591
- onInput={(e) => setPendingRenameFolder({ ...pendingRenameFolder, value: e.currentTarget.value })}
592
- onKeyDown={(e) => {
593
- if (e.key === "Escape") {
594
- e.stopPropagation();
595
- setPendingRenameFolder(null);
596
- }
597
- }}
598
- autoFocus
599
- />
600
- <div class="weave-dialog-actions">
601
- <button type="button" class="weave-chip" onClick={() => setPendingRenameFolder(null)}>
602
- Cancel
603
- </button>
604
- <button type="submit" class="weave-chip">
605
- Rename
606
- </button>
607
- </div>
608
- </form>
609
- </div>
610
- </div>
611
- ) : null}
612
- {pendingSubfolder !== null ? (
613
- <div class="weave-scrim" onClick={() => setPendingSubfolder(null)}>
614
- <div
615
- class="weave-dialog"
616
- role="dialog"
617
- aria-modal="true"
618
- aria-label="New subfolder"
619
- onClick={(e) => e.stopPropagation()}
620
- >
621
- <h3 class="weave-dialog-title">New subfolder under “{pendingSubfolder.parentPath}”</h3>
622
- <form
623
- onSubmit={async (e) => {
624
- e.preventDefault();
625
- const name = pendingSubfolder.value.trim();
626
- if (name) {
627
- await createFolder(fetcher, `${pendingSubfolder.parentPath}/${name}`);
628
- setState((curr) => expand(curr, `vfolder:${pendingSubfolder.parentPath}`));
629
- }
630
- setPendingSubfolder(null);
631
- }}
632
- >
633
- <input
634
- type="text"
635
- class="weave-filter weave-dialog-input"
636
- placeholder="Subfolder name…"
637
- value={pendingSubfolder.value}
638
- onInput={(e) => setPendingSubfolder({ ...pendingSubfolder, value: e.currentTarget.value })}
639
- onKeyDown={(e) => {
640
- if (e.key === "Escape") {
641
- e.stopPropagation();
642
- setPendingSubfolder(null);
643
- }
644
- }}
645
- autoFocus
646
- />
647
- <div class="weave-dialog-actions">
648
- <button type="button" class="weave-chip" onClick={() => setPendingSubfolder(null)}>
649
- Cancel
650
- </button>
651
- <button type="submit" class="weave-chip">
652
- Create
653
- </button>
654
- </div>
655
- </form>
656
- </div>
657
- </div>
658
- ) : null}
659
109
  </div>
660
110
  );
661
111
  }