pi-weave 0.1.19 → 0.1.21
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 +22 -21
- package/package.json +1 -1
- package/skills/weave-explore/SKILL.md +2 -0
- package/skills/weave-notepad/SKILL.md +10 -0
- package/src/core/concurrency.ts +1 -1
- package/src/core/frontmatter.ts +30 -0
- package/src/core/index.ts +25 -1
- package/src/core/paths.ts +1 -0
- package/src/core/sessions.ts +963 -0
- package/src/core/vault.ts +93 -1
- package/src/pi/index.ts +108 -9
- package/src/pi/sessionScan.ts +105 -0
- package/src/pi/summarize.ts +48 -5
- package/src/pi/tools/noteTool.ts +1 -1
- package/src/web/client/dist/app.js +71 -41
- package/src/web/client/graph/ForceTuner.tsx +99 -0
- package/src/web/client/graph/Graph.tsx +77 -16
- package/src/web/client/graph/column.model.ts +35 -49
- package/src/web/client/graph/dynamics.ts +22 -1
- package/src/web/client/graph/graph.model.ts +63 -5
- package/src/web/client/graph/groups.ts +353 -0
- package/src/web/client/graph/positions.ts +14 -13
- package/src/web/client/graph/renderer.ts +6 -3
- package/src/web/client/graph/tuner.model.ts +210 -0
- package/src/web/client/main.tsx +12 -1
- package/src/web/client/shell/Columns.tsx +3 -0
- package/src/web/client/shell/Shell.tsx +7 -0
- package/src/web/client/shell/theme.ts +30 -1
- package/src/web/client/tree/Tree.tsx +126 -13
- package/src/web/client/tree/tree.model.ts +102 -1
- package/src/web/shared/layout.ts +76 -11
|
@@ -65,6 +65,8 @@ export interface ColumnsProps {
|
|
|
65
65
|
bootFailed: boolean;
|
|
66
66
|
/** Slot the graph column fills with its `fit`, for the global `g` key. */
|
|
67
67
|
fit: { current: (() => void) | null };
|
|
68
|
+
/** `?sliders=1` — the hidden force tuner (docs/weave-workspace.md §15.7). */
|
|
69
|
+
tuner: boolean;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/** One column: a titled region and whichever surface fills it. */
|
|
@@ -95,6 +97,7 @@ function Column({ id, props }: { id: ColumnId; props: ColumnsProps }) {
|
|
|
95
97
|
scheme={props.scheme}
|
|
96
98
|
bootFailed={props.bootFailed}
|
|
97
99
|
fit={props.fit}
|
|
100
|
+
tuner={props.tuner}
|
|
98
101
|
/>
|
|
99
102
|
) : null}
|
|
100
103
|
{id === "graph" ? <ContextRail graph={props.graph} selectedId={props.selectedId} onSelect={props.onSelect} /> : null}
|
|
@@ -60,6 +60,12 @@ export interface ShellProps {
|
|
|
60
60
|
initialWidth: number;
|
|
61
61
|
/** `navigator.platform`, for the `⌘K` vs `Ctrl K` hint. */
|
|
62
62
|
platform: string;
|
|
63
|
+
/**
|
|
64
|
+
* `location.search` carried `?sliders=1` — open the hidden force tuner over
|
|
65
|
+
* the graph column (docs/weave-workspace.md §15.7). Resolved by `slidersFlag`
|
|
66
|
+
* in `main.tsx`; optional so every existing caller and test keeps its shape.
|
|
67
|
+
*/
|
|
68
|
+
tuner?: boolean;
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
export function Shell(props: ShellProps) {
|
|
@@ -206,6 +212,7 @@ export function Shell(props: ShellProps) {
|
|
|
206
212
|
storage={localStorage}
|
|
207
213
|
host={window}
|
|
208
214
|
fit={fit}
|
|
215
|
+
tuner={props.tuner === true}
|
|
209
216
|
/>
|
|
210
217
|
{/*
|
|
211
218
|
`model.generatedAt`, not `stamp`. The two used to be the same string;
|
|
@@ -297,6 +297,9 @@ body{font-size:var(--weave-px-base)}
|
|
|
297
297
|
border:1px solid var(--weave-line-strong);border-radius:var(--weave-radius);
|
|
298
298
|
}
|
|
299
299
|
.weave-chip:hover{color:var(--weave-fg);border-color:var(--weave-accent)}
|
|
300
|
+
/* A chip that is a toggle, while it is on: the accent states it without a
|
|
301
|
+
second control or an icon. */
|
|
302
|
+
.weave-chip-on{color:var(--weave-accent);border-color:var(--weave-accent)}
|
|
300
303
|
.weave-rows{
|
|
301
304
|
flex:1;min-height:0;overflow:auto;margin:0;padding:3px 0;list-style:none;
|
|
302
305
|
}
|
|
@@ -511,7 +514,7 @@ body{font-size:var(--weave-px-base)}
|
|
|
511
514
|
the \`1fr\` row from \`.weave-col-graph\` above — a zero-height container makes
|
|
512
515
|
sigma refuse to render (we pass \`allowInvalidContainer\`, so it degrades to
|
|
513
516
|
blank rather than throwing). */
|
|
514
|
-
.weave-graph{display:grid;grid-template-rows:1fr auto auto;min-height:0;overflow:hidden}
|
|
517
|
+
.weave-graph{display:grid;grid-template-rows:1fr auto auto;min-height:0;overflow:hidden;position:relative}
|
|
515
518
|
.weave-graph-canvas{min-height:120px;min-width:0;position:relative;overflow:hidden}
|
|
516
519
|
/* A barely-there vignette, so the stage reads as a lit surface rather than a
|
|
517
520
|
flat void. Sigma clears its WebGL layers transparent (no background colour
|
|
@@ -560,6 +563,32 @@ body{font-size:var(--weave-px-base)}
|
|
|
560
563
|
border-top:1px solid var(--weave-line);
|
|
561
564
|
}
|
|
562
565
|
|
|
566
|
+
/* the hidden force tuner (?sliders=1, docs/weave-workspace.md 15.7) --------- */
|
|
567
|
+
/* Absolutely positioned over the canvas rather than given a grid row: the
|
|
568
|
+
panel is a debug overlay and must not change the layout the graph is being
|
|
569
|
+
judged in — moving the canvas while tuning it would falsify the very thing
|
|
570
|
+
being looked at. */
|
|
571
|
+
.weave-tuner{
|
|
572
|
+
position:absolute;top:8px;right:8px;z-index:3;width:232px;max-height:calc(100% - 16px);
|
|
573
|
+
overflow:auto;display:flex;flex-direction:column;gap:6px;padding:9px 10px;
|
|
574
|
+
font-size:var(--weave-px-caption);color:var(--weave-fg);
|
|
575
|
+
background:var(--weave-panel);border:1px solid var(--weave-line-strong);
|
|
576
|
+
border-radius:var(--weave-radius);
|
|
577
|
+
}
|
|
578
|
+
.weave-tuner-title{margin:0;color:var(--weave-faint);letter-spacing:.04em;text-transform:uppercase}
|
|
579
|
+
.weave-tuner-row{display:flex;flex-direction:column;gap:2px;cursor:pointer}
|
|
580
|
+
.weave-tuner-label{display:flex;justify-content:space-between;gap:8px;color:var(--weave-dim)}
|
|
581
|
+
.weave-tuner-label b{color:var(--weave-accent);font-weight:600;font-variant-numeric:tabular-nums}
|
|
582
|
+
.weave-tuner-row input{width:100%;margin:0;accent-color:var(--weave-accent)}
|
|
583
|
+
.weave-tuner-toggle{display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--weave-dim)}
|
|
584
|
+
.weave-tuner-toggle input{margin:0;accent-color:var(--weave-accent)}
|
|
585
|
+
.weave-tuner-actions{display:flex;gap:6px;margin-top:2px;flex-wrap:wrap}
|
|
586
|
+
.weave-tuner-snippet{
|
|
587
|
+
margin:0;padding:6px;overflow:auto;white-space:pre;color:var(--weave-faint);
|
|
588
|
+
background:var(--weave-bg);border:1px solid var(--weave-line);border-radius:var(--weave-radius);
|
|
589
|
+
font-size:var(--weave-px-prov);line-height:1.45;
|
|
590
|
+
}
|
|
591
|
+
|
|
563
592
|
/* context rail ---------------------------------------------------------- */
|
|
564
593
|
/* The \`.weave-empty\` block that used to sit above went with \`EmptyState.tsx\`
|
|
565
594
|
in P3: every column now has its own empty state (\`treeEmptyMessage\`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** The vault and repository tree column. */
|
|
2
2
|
|
|
3
|
-
import { useState } from "preact/hooks";
|
|
3
|
+
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
|
4
4
|
import { createFolder, deleteFolder, deleteNote, moveNote, renameFolder, renameNote } from "../api";
|
|
5
5
|
import { fetchJson } from "../api.dom";
|
|
6
6
|
import { isTextEntry, type KeyTarget } from "../shell/keys.model";
|
|
@@ -8,6 +8,8 @@ import { ICON_BOX, ICON_STROKE, ICONS } from "../shell/icons.model";
|
|
|
8
8
|
import type { IconName } from "../shell/icons.model";
|
|
9
9
|
import type { GraphPayload } from "../../shared/wire";
|
|
10
10
|
import {
|
|
11
|
+
DRAFT_FOLDER_ID,
|
|
12
|
+
deleteItemLabel,
|
|
11
13
|
FILTER_HINT,
|
|
12
14
|
FILTER_LABEL,
|
|
13
15
|
FILTER_PLACEHOLDER,
|
|
@@ -20,6 +22,9 @@ import {
|
|
|
20
22
|
dropFolder,
|
|
21
23
|
expand,
|
|
22
24
|
initialTreeView,
|
|
25
|
+
newFolderParent,
|
|
26
|
+
newFolderPath,
|
|
27
|
+
withDraftRow,
|
|
23
28
|
internalsHint,
|
|
24
29
|
internalsLabel,
|
|
25
30
|
mutableTreeRow,
|
|
@@ -80,6 +85,7 @@ function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMe
|
|
|
80
85
|
style={depthVar(view.depth)}
|
|
81
86
|
onClick={onSelect}
|
|
82
87
|
draggable={view.id.startsWith("note:")}
|
|
88
|
+
aria-label={view.id === DRAFT_FOLDER_ID ? "New folder" : undefined}
|
|
83
89
|
onDragStart={(event) => event.dataTransfer?.setData("text/plain", view.id)}
|
|
84
90
|
onDragOver={folder === undefined ? undefined : (event) => event.preventDefault()}
|
|
85
91
|
onDrop={folder === undefined ? undefined : (event) => { event.preventDefault(); onDrop(event.dataTransfer?.getData("text/plain") ?? ""); }}
|
|
@@ -90,17 +96,75 @@ function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMe
|
|
|
90
96
|
</span>
|
|
91
97
|
<span class="weave-kind" aria-hidden="true"><Icon name={view.kindIcon} class="weave-icon" /></span>
|
|
92
98
|
<span class={`weave-prov weave-prov-${view.provenance ?? "none"}`} title={view.provenanceTitle}>{view.provenanceGlyph}</span>
|
|
93
|
-
{edit === null ? <span class="weave-label">{view.label}</span> : <
|
|
99
|
+
{edit === null ? <span class="weave-label">{view.label}</span> : <RenameInput value={edit} draft={view.id === DRAFT_FOLDER_ID} onEdit={onEdit} onCommit={onRename} />}
|
|
94
100
|
<span class="weave-meta">{view.meta}</span>
|
|
95
101
|
</li>
|
|
96
102
|
);
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The inline editor, as its own component so it **mounts** when an edit
|
|
107
|
+
* starts.
|
|
108
|
+
*
|
|
109
|
+
* That is the whole reason it is not an `<input>` inline in {@link Row}: the
|
|
110
|
+
* existing name has to be selected once, when the editor opens, and neither
|
|
111
|
+
* obvious spelling does that. An inline `ref={(el) => el?.select()}` re-runs
|
|
112
|
+
* on every render — preact's children diff fires a ref whenever its
|
|
113
|
+
* *identity* changes (`oldVNode.ref != childVNode.ref` in
|
|
114
|
+
* `diff/children.js`), and an arrow literal is a new identity each time — so
|
|
115
|
+
* every keystroke would re-select the text being typed. `onFocus` re-selects
|
|
116
|
+
* whenever the tab regains focus mid-edit. A mount effect fires exactly once,
|
|
117
|
+
* which is the actual requirement.
|
|
118
|
+
*
|
|
119
|
+
* `useLayoutEffect`, not `useEffect`, so the selection is in place before
|
|
120
|
+
* paint — otherwise the fix itself flashes unselected text for a frame.
|
|
121
|
+
*/
|
|
122
|
+
function RenameInput({ value, draft, onEdit, onCommit }: { value: string; draft: boolean; onEdit: (value: string) => void; onCommit: (value: string | null) => void }) {
|
|
123
|
+
const input = useRef<HTMLInputElement | null>(null);
|
|
124
|
+
useLayoutEffect(() => {
|
|
125
|
+
const element = input.current;
|
|
126
|
+
if (element === null) return;
|
|
127
|
+
element.focus();
|
|
128
|
+
// A draft folder has no name yet, and `select()` on an empty input is a
|
|
129
|
+
// no-op — so this needs no branch.
|
|
130
|
+
element.select();
|
|
131
|
+
}, []);
|
|
132
|
+
return (
|
|
133
|
+
<input
|
|
134
|
+
ref={input}
|
|
135
|
+
class="weave-tree-rename"
|
|
136
|
+
value={value}
|
|
137
|
+
placeholder={draft ? "New folder name…" : undefined}
|
|
138
|
+
onClick={(event) => event.stopPropagation()}
|
|
139
|
+
onInput={(event) => onEdit(event.currentTarget.value)}
|
|
140
|
+
onBlur={(event) => onCommit(event.currentTarget.value)}
|
|
141
|
+
onKeyDown={(event) => {
|
|
142
|
+
event.stopPropagation();
|
|
143
|
+
if (event.key === "Enter") event.currentTarget.blur();
|
|
144
|
+
else if (event.key === "Escape") onCommit(null);
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
99
150
|
export function Tree(props: TreeProps) {
|
|
100
151
|
const [state, setState] = useState(initialTreeView);
|
|
101
|
-
|
|
152
|
+
/**
|
|
153
|
+
* The open context menu. `armed` is the folder-delete confirmation: the
|
|
154
|
+
* first click on `Delete…` sets it, and only then does the item delete —
|
|
155
|
+
* see `deleteItemLabel`. Held on the menu so closing it disarms, which is
|
|
156
|
+
* what makes a mis-click cheap.
|
|
157
|
+
*/
|
|
158
|
+
const [menu, setMenu] = useState<{ id: string; label: string; x: number; y: number; armed?: boolean } | null>(null);
|
|
102
159
|
const [editing, setEditing] = useState<{ id: string; label: string; value: string } | null>(null);
|
|
103
|
-
|
|
160
|
+
/**
|
|
161
|
+
* The parent path a pending "New folder" will be created under, or `null`.
|
|
162
|
+
*
|
|
163
|
+
* The folder is not created until the name is committed — see
|
|
164
|
+
* {@link withDraftRow}. Escaping leaves nothing behind.
|
|
165
|
+
*/
|
|
166
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
167
|
+
const rows = withDraftRow(rowsFor(props.graph, state), draft);
|
|
104
168
|
const empty = treeEmptyMessage(props.graph, rows, state);
|
|
105
169
|
const run = async (pending: ReturnType<typeof deleteNote>, select?: string): Promise<void> => {
|
|
106
170
|
const result = await pending;
|
|
@@ -117,18 +181,67 @@ export function Tree(props: TreeProps) {
|
|
|
117
181
|
setMenu(null);
|
|
118
182
|
if (target === null) return;
|
|
119
183
|
if (action === "newFolder") {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
if (parent) setState((current) => expand(current, `vfolder:${parent}`));
|
|
125
|
-
|
|
184
|
+
// A draft row with the cursor in it, not a modal: the same inline
|
|
185
|
+
// gesture `rename` already uses, and it shows *where* the folder will
|
|
186
|
+
// land, which a prompt cannot.
|
|
187
|
+
const parent = newFolderParent(target);
|
|
188
|
+
if (parent !== "") setState((current) => expand(current, `vfolder:${parent}`));
|
|
189
|
+
setDraft(parent);
|
|
190
|
+
setEditing({ id: DRAFT_FOLDER_ID, label: "", value: "" });
|
|
126
191
|
} else if (action === "rename") {
|
|
127
192
|
setEditing({ id: menu.id, label: menu.label, value: menu.label });
|
|
128
|
-
} else if (target.type !== "vault"
|
|
193
|
+
} else if (target.type !== "vault") {
|
|
129
194
|
await run(target.type === "note" ? deleteNote(fetchJson, target.path) : deleteFolder(fetchJson, target.path), deletesSelection(props.selectedId, target) ? "vault" : undefined);
|
|
130
195
|
}
|
|
131
196
|
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The delete item was clicked. Arms first for a folder, deletes second.
|
|
200
|
+
*
|
|
201
|
+
* A note deletes on the first click (§#37): it is one file whose name the
|
|
202
|
+
* user just read off the row. A folder is `fs.rm(recursive)` over a subtree
|
|
203
|
+
* the row shows no count for, so it takes two — and the second click's label
|
|
204
|
+
* names the folder, which is the chance to notice the wrong row was hit.
|
|
205
|
+
*/
|
|
206
|
+
const clickDelete = (target: { type: "note" | "folder" | "vault" }): void => {
|
|
207
|
+
if (menu === null) return;
|
|
208
|
+
if (deleteNeedsConfirmation(target) && menu.armed !== true) {
|
|
209
|
+
setMenu({ ...menu, armed: true });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
void act("delete");
|
|
213
|
+
};
|
|
214
|
+
/**
|
|
215
|
+
* The inline editor was committed (Enter or blur) or abandoned (Escape).
|
|
216
|
+
*
|
|
217
|
+
* Two gestures share one input, so this is where they part: the draft row
|
|
218
|
+
* creates a folder, every other row renames. Both clear the editor first,
|
|
219
|
+
* so an abandoned edit and a failed request leave the same clean state.
|
|
220
|
+
*/
|
|
221
|
+
const commitEdit = (value: string | null): void => {
|
|
222
|
+
const current = editing;
|
|
223
|
+
const parent = draft;
|
|
224
|
+
setEditing(null);
|
|
225
|
+
setDraft(null);
|
|
226
|
+
if (current === null) return;
|
|
227
|
+
const name = value?.trim();
|
|
228
|
+
if (current.id === DRAFT_FOLDER_ID) {
|
|
229
|
+
// Escape, or an empty name: the draft simply disappears. Nothing was
|
|
230
|
+
// created, so there is nothing to undo.
|
|
231
|
+
const path = parent === null || !name ? null : newFolderPath(parent, name);
|
|
232
|
+
if (path !== null) void run(createFolder(fetchJson, path));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const target = mutableTreeRow(current.id);
|
|
236
|
+
// No confirmation. A rename can break wiki-links, but the warning was
|
|
237
|
+
// unactionable — it named no link and offered no way to see them — and it
|
|
238
|
+
// fired on the *commit* of an edit the user had already typed out, which
|
|
239
|
+
// is the least useful moment to ask. Renaming back is one more rename.
|
|
240
|
+
if (target !== null && name && name !== current.label) {
|
|
241
|
+
void run(target.type === "note" ? renameNote(fetchJson, target.path, name) : renameFolder(fetchJson, target.path, name));
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
132
245
|
return (
|
|
133
246
|
<div class="weave-tree" onKeyDown={(event) => {
|
|
134
247
|
const target = event.target as KeyTarget;
|
|
@@ -155,7 +268,7 @@ export function Tree(props: TreeProps) {
|
|
|
155
268
|
setMenu({ id: "vault", label: "Vault", x: event.clientX, y: event.clientY });
|
|
156
269
|
}}
|
|
157
270
|
>
|
|
158
|
-
{rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={
|
|
271
|
+
{rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={() => { props.onSelect(view.id); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); }} />)}
|
|
159
272
|
</ul>
|
|
160
273
|
) : <p class="weave-tree-empty">{empty}</p>}
|
|
161
274
|
<p class="weave-tree-count">{rowCountLabel(rows)}</p>
|
|
@@ -169,7 +282,7 @@ export function Tree(props: TreeProps) {
|
|
|
169
282
|
{target.type !== "vault" ? (
|
|
170
283
|
<>
|
|
171
284
|
<button type="button" role="menuitem" onClick={() => void act("rename")}>Rename…</button>
|
|
172
|
-
<button type="button" role="menuitem" class="weave-menu-danger" onClick={() =>
|
|
285
|
+
<button type="button" role="menuitem" class="weave-menu-danger" onClick={() => clickDelete(target)}>{deleteItemLabel(target, menu.label, menu.armed === true)}</button>
|
|
173
286
|
</>
|
|
174
287
|
) : null}
|
|
175
288
|
</div>
|
|
@@ -104,6 +104,80 @@ export function rowsFor(payload: GraphPayload | null, state: TreeViewState): Tre
|
|
|
104
104
|
return treeRows(viewModel(payload), state);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// --- the draft folder row ------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The id of the placeholder row a pending "New folder" occupies.
|
|
111
|
+
*
|
|
112
|
+
* A real id from the same namespace as the rest of the tree, so the row is an
|
|
113
|
+
* ordinary `TreeRow` that renders, indents and takes the rename input with no
|
|
114
|
+
* special-casing anywhere below {@link withDraftRow}. It cannot collide with a
|
|
115
|
+
* folder the vault holds, because `slugify` strips the leading `\u0000`.
|
|
116
|
+
*/
|
|
117
|
+
export const DRAFT_FOLDER_ID = "vfolder:\u0000draft";
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Where a new folder created from `target` would go.
|
|
121
|
+
*
|
|
122
|
+
* A right-click on the vault makes a top-level folder; on a folder, a child of
|
|
123
|
+
* it; on a *note*, a sibling of that note — which is what "new folder here"
|
|
124
|
+
* means when "here" is a file. Returns the parent path, `""` for the root.
|
|
125
|
+
*/
|
|
126
|
+
export function newFolderParent(target: { type: "note" | "folder" | "vault"; path: string }): string {
|
|
127
|
+
if (target.type === "vault") return "";
|
|
128
|
+
if (target.type === "folder") return target.path;
|
|
129
|
+
return target.path.split("/").slice(0, -1).join("/");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The path `createFolder` should be asked for, given a parent and a typed name. */
|
|
133
|
+
export function newFolderPath(parent: string, name: string): string | null {
|
|
134
|
+
const trimmed = name.trim();
|
|
135
|
+
if (trimmed === "") return null;
|
|
136
|
+
return parent === "" ? trimmed : `${parent}/${trimmed}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Splice a draft folder row into the list, under its parent.
|
|
141
|
+
*
|
|
142
|
+
* This is what replaces the `window.prompt`. Renaming is already inline (click
|
|
143
|
+
* a row, type, Enter) and a modal for the adjacent gesture was the odd one
|
|
144
|
+
* out — it also blocks the thread, cannot be styled, and on a folder deep in
|
|
145
|
+
* the tree gives no clue *where* the folder will land. A draft row shows the
|
|
146
|
+
* answer: it sits at the position the real folder will occupy, indented under
|
|
147
|
+
* the right parent, with the cursor in it.
|
|
148
|
+
*
|
|
149
|
+
* Created only after the name is committed, so an abandoned draft leaves
|
|
150
|
+
* nothing behind — the alternative (create "untitled", then rename) litters
|
|
151
|
+
* the vault on Escape and fires two requests for one gesture.
|
|
152
|
+
*
|
|
153
|
+
* The draft goes **last among its parent's subtree** rather than in sort
|
|
154
|
+
* position, because it has no name yet to sort by and a row that jumps as you
|
|
155
|
+
* type is worse than one that appears at a predictable place.
|
|
156
|
+
*/
|
|
157
|
+
export function withDraftRow(rows: readonly TreeRow[], parent: string | null): TreeRow[] {
|
|
158
|
+
if (parent === null) return [...rows];
|
|
159
|
+
const draft: TreeRow = {
|
|
160
|
+
id: DRAFT_FOLDER_ID,
|
|
161
|
+
depth: parent === "" ? 1 : parent.split("/").length + 1,
|
|
162
|
+
hasKids: false,
|
|
163
|
+
expanded: false,
|
|
164
|
+
label: "",
|
|
165
|
+
kind: "module",
|
|
166
|
+
provenance: null,
|
|
167
|
+
meta: null,
|
|
168
|
+
};
|
|
169
|
+
// The root's draft goes after the whole tree; a folder's goes after the last
|
|
170
|
+
// row that is still inside that folder. `treeRows` emits a subtree
|
|
171
|
+
// contiguously, so "the last row at greater depth after the parent" is the
|
|
172
|
+
// end of it.
|
|
173
|
+
if (parent === "") return [...rows, draft];
|
|
174
|
+
const at = rows.findIndex((row) => row.id === `vfolder:${parent}`);
|
|
175
|
+
if (at === -1) return [...rows, draft];
|
|
176
|
+
let end = at + 1;
|
|
177
|
+
while (end < rows.length && (rows[end] as TreeRow).depth > (rows[at] as TreeRow).depth) end++;
|
|
178
|
+
return [...rows.slice(0, end), draft, ...rows.slice(end)];
|
|
179
|
+
}
|
|
180
|
+
|
|
107
181
|
// --- reducers -----------------------------------------------------------------------
|
|
108
182
|
|
|
109
183
|
/**
|
|
@@ -157,11 +231,38 @@ export function deletesSelection(selectedId: string | null, target: { type: "not
|
|
|
157
231
|
return false;
|
|
158
232
|
}
|
|
159
233
|
|
|
160
|
-
/**
|
|
234
|
+
/**
|
|
235
|
+
* Notes delete directly; folders ask once, **in the menu**.
|
|
236
|
+
*
|
|
237
|
+
* Not a `window.confirm` — no gesture in this workspace opens a browser
|
|
238
|
+
* dialog any more. But not nothing either: `deleteFolder` is
|
|
239
|
+
* `fs.rm(recursive: true)` on a directory that may hold hundreds of notes,
|
|
240
|
+
* there is no undo, and the tree row shows no count, so a single stray click
|
|
241
|
+
* on a 300-note folder is unrecoverable. A note is one file the user can see
|
|
242
|
+
* the name of, which is why §#37 dropped its confirmation and this keeps one.
|
|
243
|
+
*
|
|
244
|
+
* The confirmation is {@link deleteItemLabel}: the same menu item, clicked
|
|
245
|
+
* twice, with the second click's label saying exactly what it will destroy.
|
|
246
|
+
*/
|
|
161
247
|
export function deleteNeedsConfirmation(target: { type: "note" | "folder" | "vault" }): boolean {
|
|
162
248
|
return target.type === "folder";
|
|
163
249
|
}
|
|
164
250
|
|
|
251
|
+
/**
|
|
252
|
+
* The delete menu item's label — what pressing it *now* will do.
|
|
253
|
+
*
|
|
254
|
+
* One function so the label and the action cannot disagree: `Tree.tsx` reads
|
|
255
|
+
* the same `armed` flag to decide whether the next click deletes, and a menu
|
|
256
|
+
* item that says "Delete" but arms instead (or the reverse) is the classic
|
|
257
|
+
* version of this bug. Naming the folder in the armed state is the whole
|
|
258
|
+
* safety mechanism — it is the user's chance to notice they right-clicked the
|
|
259
|
+
* wrong row.
|
|
260
|
+
*/
|
|
261
|
+
export function deleteItemLabel(target: { type: "note" | "folder" | "vault" }, label: string, armed: boolean): string {
|
|
262
|
+
if (!deleteNeedsConfirmation(target)) return "Delete";
|
|
263
|
+
return armed ? `Delete “${label}” and everything in it` : "Delete…";
|
|
264
|
+
}
|
|
265
|
+
|
|
165
266
|
/**
|
|
166
267
|
* Set the substring filter.
|
|
167
268
|
*
|
package/src/web/shared/layout.ts
CHANGED
|
@@ -34,12 +34,75 @@ export function collideRadius(drawnSize: number): number {
|
|
|
34
34
|
const DEFAULT_TICKS = 300;
|
|
35
35
|
const DEFAULT_SEED = 1;
|
|
36
36
|
const ALPHA_MIN = 0.001;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The force constants, as one **mutable** record.
|
|
40
|
+
*
|
|
41
|
+
* Mutable because picking these numbers is an act of taste, not of
|
|
42
|
+
* derivation: the difference between "one hairball" and "legible groups" is a
|
|
43
|
+
* ratio between containment cohesion, charge and centre gravity that is far
|
|
44
|
+
* easier to *see* than to reason about. The hidden tuner (`?sliders=1`, see
|
|
45
|
+
* `client/graph/tuner.model.ts` and docs/weave-workspace.md §15.7) writes here
|
|
46
|
+
* and re-lays out live, so a human can find the values by eye and they then
|
|
47
|
+
* get frozen back into {@link FORCE_DEFAULTS}.
|
|
48
|
+
*
|
|
49
|
+
* Determinism is untouched: every production and test path only ever *reads*
|
|
50
|
+
* this, so `computeLayout(model, { seed })` stays byte-identical. The tuner is
|
|
51
|
+
* the one writer, and it is unreachable without the query flag.
|
|
52
|
+
*
|
|
53
|
+
* ponytail: module-level mutable state, fine at one graph per page — thread it
|
|
54
|
+
* as a `LayoutOptions` field if a second concurrent consumer ever appears.
|
|
55
|
+
*/
|
|
56
|
+
export interface ForceConstants {
|
|
57
|
+
/** Rest length of a `contains` / `anchored-at` spring: a rosette's radius. */
|
|
58
|
+
containsRest: number;
|
|
59
|
+
/** Stiffness of that spring. This is what makes a group *be* a group. */
|
|
60
|
+
containsStrength: number;
|
|
61
|
+
/** Rest length of a `links-to` / `mentions` spring: the inter-group reach. */
|
|
62
|
+
relationDistance: number;
|
|
63
|
+
/** Stiffness of that spring. Weak, so associations bend without merging. */
|
|
64
|
+
relationStrength: number;
|
|
65
|
+
/** `forceManyBody` strength. Negative is repulsion. */
|
|
66
|
+
charge: number;
|
|
67
|
+
/**
|
|
68
|
+
* Distance past which charge is ignored (`forceManyBody.distanceMax`).
|
|
69
|
+
*
|
|
70
|
+
* A cap rather than `Infinity` is what keeps a strong charge from simply
|
|
71
|
+
* inflating the whole graph uniformly: beyond it, neighbours stop pushing
|
|
72
|
+
* and only the springs and centre gravity speak, so groups separate at
|
|
73
|
+
* local scale without the picture growing without bound.
|
|
74
|
+
*/
|
|
75
|
+
chargeMax: number;
|
|
76
|
+
/** `forceX`/`forceY` pull toward the origin. The no-escape guarantee. */
|
|
77
|
+
center: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The live constants. See {@link ForceConstants} for why this is mutable.
|
|
82
|
+
*
|
|
83
|
+
* These values were **found by eye** through the `?sliders=1` tuner and then
|
|
84
|
+
* frozen here, which is the loop §15.7 describes working as intended. They are
|
|
85
|
+
* not derived and should not be "corrected" toward rounder numbers: the
|
|
86
|
+
* previous set (`containsStrength: 0.02`, `charge: -50`, `center: 0.09`) was
|
|
87
|
+
* defensible on paper and produced a single hairball on screen.
|
|
88
|
+
*/
|
|
89
|
+
export const FORCES: ForceConstants = {
|
|
90
|
+
containsRest: 55,
|
|
91
|
+
containsStrength: 0.12,
|
|
92
|
+
relationDistance: 50,
|
|
93
|
+
relationStrength: 0.07,
|
|
94
|
+
charge: -200,
|
|
95
|
+
chargeMax: 800,
|
|
96
|
+
center: 0.05,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The shipped values, for the tuner's reset and for a test to restore from. */
|
|
100
|
+
export const FORCE_DEFAULTS: Readonly<ForceConstants> = { ...FORCES };
|
|
101
|
+
|
|
102
|
+
/** Overwrite the live constants in place. The tuner's one write. */
|
|
103
|
+
export function setForces(next: Partial<ForceConstants>): void {
|
|
104
|
+
Object.assign(FORCES, next);
|
|
105
|
+
}
|
|
43
106
|
|
|
44
107
|
export function isContainment(kind: EdgeKind): boolean {
|
|
45
108
|
return kind === "contains" || kind === "anchored-at";
|
|
@@ -94,17 +157,19 @@ export function createForceSimulation<N extends SimulationNodeDatum & { id: stri
|
|
|
94
157
|
): Simulation<N, undefined> {
|
|
95
158
|
const link = forceLink<N, { source: string | N; target: string | N; kind: EdgeKind }>(opts.links)
|
|
96
159
|
.id((node) => node.id)
|
|
97
|
-
.distance((edge) => (isContainment(edge.kind) ?
|
|
98
|
-
.strength((edge) => (isContainment(edge.kind) ?
|
|
160
|
+
.distance((edge) => (isContainment(edge.kind) ? FORCES.containsRest : FORCES.relationDistance))
|
|
161
|
+
.strength((edge) => (isContainment(edge.kind) ? FORCES.containsStrength : FORCES.relationStrength))
|
|
99
162
|
.iterations(2);
|
|
100
163
|
|
|
164
|
+
// Read out of `FORCES` here, at construction: a simulation is built fresh
|
|
165
|
+
// per layout run and per live mount, so a tuner write lands on the next one.
|
|
101
166
|
return forceSimulation<N>(opts.nodes)
|
|
102
167
|
.randomSource(lcg(opts.seed ?? DEFAULT_SEED))
|
|
103
|
-
.force("charge", forceManyBody<N>().strength(
|
|
168
|
+
.force("charge", forceManyBody<N>().strength(FORCES.charge).distanceMax(FORCES.chargeMax))
|
|
104
169
|
.force("link", link)
|
|
105
170
|
.force("collide", forceCollide<N>((node) => node.r ?? COLLIDE_RADIUS).strength(1))
|
|
106
|
-
.force("x", forceX<N>(0).strength(
|
|
107
|
-
.force("y", forceY<N>(0).strength(
|
|
171
|
+
.force("x", forceX<N>(0).strength(FORCES.center))
|
|
172
|
+
.force("y", forceY<N>(0).strength(FORCES.center))
|
|
108
173
|
.stop();
|
|
109
174
|
}
|
|
110
175
|
|