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.
- package/README.md +8 -37
- package/package.json +1 -2
- package/src/core/concurrency.ts +3 -6
- package/src/core/frontmatter.ts +0 -53
- package/src/core/graph/build.ts +6 -7
- package/src/core/graph/current.ts +2 -4
- package/src/core/graph/model.ts +1 -1
- package/src/core/graph/wikilinks.ts +3 -3
- package/src/core/index.ts +26 -27
- package/src/core/paths.ts +0 -7
- package/src/core/vault.ts +16 -681
- package/src/core/view/detail.ts +1 -1
- package/src/core/view/health.ts +1 -1
- package/src/core/view/tree.ts +1 -1
- package/src/pi/index.ts +6 -85
- package/src/pi/summarize.ts +2 -2
- package/src/pi/viewer/tui/bodyStore.ts +4 -7
- package/src/pi/viewer/tui/branding.ts +7 -148
- package/src/pi/viewer/tui/run.ts +3 -17
- package/src/pi/viewer/tui/surface/base.ts +24 -3
- package/src/pi/viewer/tui/surface/explore.ts +41 -6
- package/src/pi/viewer/tui/workspace.ts +23 -351
- package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
- package/src/pi/viewer/web/run.ts +7 -117
- package/src/web/client/api.dom.ts +2 -2
- package/src/web/client/api.ts +14 -223
- package/src/web/client/bootstrap.ts +5 -14
- package/src/web/client/context/context.model.ts +9 -11
- package/src/web/client/dist/app.js +93 -219
- package/src/web/client/graph/dynamics.ts +5 -65
- package/src/web/client/graph/renderer.dom.ts +7 -8
- package/src/web/client/graph/renderer.ts +9 -35
- package/src/web/client/main.tsx +1 -1
- package/src/web/client/note/Note.tsx +21 -63
- package/src/web/client/search/SearchPalette.tsx +45 -36
- package/src/web/client/search/search.model.ts +33 -454
- package/src/web/client/shell/Columns.tsx +13 -83
- package/src/web/client/shell/Header.tsx +2 -10
- package/src/web/client/shell/Shell.tsx +50 -125
- package/src/web/client/shell/StatusBar.tsx +1 -4
- package/src/web/client/shell/icons.model.ts +4 -7
- package/src/web/client/shell/keys.model.ts +5 -42
- package/src/web/client/shell/keys.ts +2 -2
- package/src/web/client/shell/shell.model.ts +10 -133
- package/src/web/client/shell/theme.model.ts +2 -2
- package/src/web/client/shell/theme.ts +33 -157
- package/src/web/client/state.ts +9 -89
- package/src/web/client/tree/Tree.tsx +25 -575
- package/src/web/client/tree/tree.model.ts +8 -162
- package/src/web/client/workspace.ts +72 -242
- package/src/web/server/page.ts +8 -10
- package/src/web/server/routes.ts +30 -563
- package/src/web/server/server.ts +6 -145
- package/src/web/shared/layout.ts +72 -624
- package/src/web/shared/wire.ts +10 -196
- package/src/core/sessions.ts +0 -929
- package/src/pi/sessionScan.ts +0 -104
- package/src/pi/viewer/tui/explorer.ts +0 -586
- package/src/web/client/live.model.ts +0 -275
- package/src/web/client/live.ts +0 -151
- package/src/web/client/note/Editor.tsx +0 -109
- package/src/web/client/note/editor.controller.ts +0 -151
- package/src/web/client/note/editor.model.ts +0 -686
- package/src/web/client/search/search.ts +0 -107
- package/src/web/client/shell/Divider.tsx +0 -44
- package/src/web/client/shell/cssvars.ts +0 -70
- package/src/web/client/shell/drag.model.ts +0 -170
- package/src/web/client/shell/layout.model.ts +0 -500
- package/src/web/client/shell/viewport.ts +0 -29
- package/src/web/server/sse.ts +0 -321
- 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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
}
|