pi-weave 0.1.7 → 0.1.9
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 +123 -37
- package/package.json +17 -5
- package/skills/weave-notepad/SKILL.md +13 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/concurrency.ts +36 -0
- package/src/core/frontmatter.ts +270 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +97 -5
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +39 -0
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +14 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/paths.ts +7 -0
- package/src/core/sessions.ts +929 -0
- package/src/core/summaries.ts +1 -19
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +739 -57
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/core/workspace.ts +3 -3
- package/src/pi/index.ts +248 -48
- package/src/pi/sessionScan.ts +104 -0
- package/src/pi/summarize.ts +24 -4
- package/src/pi/tools/noteTool.ts +13 -4
- package/src/pi/viewer/tui/branding.ts +8 -7
- package/src/pi/viewer/tui/explorer.ts +5 -3
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +7 -56
- package/src/pi/viewer/tui/surface/explore.ts +4 -2
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +764 -0
- package/src/web/client/graph/Graph.tsx +209 -0
- package/src/web/client/graph/column.model.ts +372 -0
- package/src/web/client/graph/dynamics.ts +176 -0
- package/src/web/client/graph/graph.model.ts +546 -0
- package/src/web/client/graph/positions.ts +380 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +339 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/selection.storage.ts +69 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +64 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +210 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +490 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +89 -0
- package/src/web/client/tree/Tree.tsx +141 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +278 -0
- package/src/web/server/page.ts +258 -0
- package/src/web/server/routes.ts +987 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +213 -0
- package/src/web/shared/layout.ts +314 -0
- package/src/web/shared/logo.ts +11 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- package/src/web/shared/wire.ts +358 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SSE socket, wired to {@link ./live.model} (weave-workspace §6).
|
|
3
|
+
*
|
|
4
|
+
* Everything that decides anything lives in `live.model.ts`. This file owns
|
|
5
|
+
* exactly one thing the model cannot: a real `EventSource`, its three
|
|
6
|
+
* listeners, and the fact that it must be closed. Keeping that separation
|
|
7
|
+
* sharp is what lets the whole liveness layer be unit-tested with no DOM —
|
|
8
|
+
* §10's constraint, and the reason `.model.ts` files exist at all.
|
|
9
|
+
*
|
|
10
|
+
* ## The socket is injected
|
|
11
|
+
*
|
|
12
|
+
* {@link startLive} takes a factory rather than calling `new EventSource`.
|
|
13
|
+
* The DOM one is the default at the call site in the shell, and a test passes
|
|
14
|
+
* a fake with the same four members. This is the same port-shaped injection
|
|
15
|
+
* `api.ts` uses for `fetch` and for the same reason: without it, the only way
|
|
16
|
+
* to reach the reconnect path would be a browser.
|
|
17
|
+
*
|
|
18
|
+
* ## Why the connection state is written here and not in the model
|
|
19
|
+
*
|
|
20
|
+
* The model is pure and returns a next state; something has to publish it to
|
|
21
|
+
* the `connection` signal that the status bar reads. That publication is this
|
|
22
|
+
* file's other job, and it is one line — which is the correct amount of logic
|
|
23
|
+
* for a file with no test harness behind it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ChangeScope } from "../shared/wire";
|
|
27
|
+
import { CHANGE_EVENT_NAME } from "../shared/wire";
|
|
28
|
+
import type { LiveEvent, LiveState, RefetchPlan } from "./live.model";
|
|
29
|
+
import { EVENTS_PATH, initialLiveState, isNoop, parseFrame, reduceLive, withStamp } from "./live.model";
|
|
30
|
+
import { connection } from "./state";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The slice of `EventSource` this module uses.
|
|
34
|
+
*
|
|
35
|
+
* Structural, so the platform's satisfies it without a cast and a fake is an
|
|
36
|
+
* object literal. Two details are load-bearing:
|
|
37
|
+
*
|
|
38
|
+
* **`addEventListener` for all three events, not `onopen`/`onerror`.** The
|
|
39
|
+
* handler properties are the more obvious spelling and they do not typecheck:
|
|
40
|
+
* a property of function type is checked *contravariantly* under
|
|
41
|
+
* `strictFunctionTypes`, so a port declaring `onopen: ((e: unknown) => void)`
|
|
42
|
+
* rejects the platform's `(ev: Event) => any` — `unknown` is not assignable
|
|
43
|
+
* to `Event`. Method declarations are compared bivariantly, so the single
|
|
44
|
+
* `addEventListener` form accepts the real `EventSource` without a cast and
|
|
45
|
+
* without this module ever naming a DOM type.
|
|
46
|
+
*
|
|
47
|
+
* **`{data: string}` rather than `MessageEvent`.** This file is compiled by
|
|
48
|
+
* `tsconfig.web.json` for the bundle *and* pulled into the root project when
|
|
49
|
+
* a test imports it, and the root project has no `DOM` lib. The narrow shape
|
|
50
|
+
* is the only one both projects can resolve. `open` and `error` carry no
|
|
51
|
+
* payload this module reads, so they receive it and ignore it.
|
|
52
|
+
*/
|
|
53
|
+
export interface EventSourceLike {
|
|
54
|
+
/** `0` connecting, `1` open, `2` closed. See `live.model.ts`'s constants. */
|
|
55
|
+
readonly readyState: number;
|
|
56
|
+
addEventListener(type: string, listener: (event: { data: string }) => void): void;
|
|
57
|
+
close(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Creates a stream for a URL. The DOM's `EventSource` constructor fits. */
|
|
61
|
+
export type EventSourceFactory = (url: string) => EventSourceLike;
|
|
62
|
+
|
|
63
|
+
/** What {@link startLive} needs from its host. */
|
|
64
|
+
export interface LiveOptions {
|
|
65
|
+
/** Injected socket constructor. */
|
|
66
|
+
open: EventSourceFactory;
|
|
67
|
+
/**
|
|
68
|
+
* Run a refetch plan. Async and awaited nowhere — a fetch that outlives the
|
|
69
|
+
* socket is the caller's problem to make idempotent, and the shell's is.
|
|
70
|
+
*/
|
|
71
|
+
refetch: (plan: RefetchPlan) => void;
|
|
72
|
+
/** Whether a note is open, read fresh per event. See `reduceLive`. */
|
|
73
|
+
hasSelection: () => boolean;
|
|
74
|
+
/** The stream URL. Defaults to {@link EVENTS_PATH}. */
|
|
75
|
+
path?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A running stream. */
|
|
79
|
+
export interface LiveHandle {
|
|
80
|
+
/** Close the socket and mark the connection offline. Idempotent. */
|
|
81
|
+
stop(): void;
|
|
82
|
+
/** Force a full refetch — the header's `⟳`. */
|
|
83
|
+
refresh(): void;
|
|
84
|
+
/** Record a stamp the client now holds, so the next frame can dedupe it. */
|
|
85
|
+
seen(stamp: string): void;
|
|
86
|
+
/** Current state. For tests and the status bar's tooltip. */
|
|
87
|
+
state(): LiveState;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Attach to the event stream.
|
|
92
|
+
*
|
|
93
|
+
* Note what this does *not* do: reconnect. `EventSource` reconnects natively
|
|
94
|
+
* with its own backoff, which is most of why §6 chose it over a WebSocket, so
|
|
95
|
+
* a retry loop here would be a second one racing the browser's.
|
|
96
|
+
*/
|
|
97
|
+
export function startLive(opts: LiveOptions): LiveHandle {
|
|
98
|
+
let state = initialLiveState();
|
|
99
|
+
let stopped = false;
|
|
100
|
+
|
|
101
|
+
const source = opts.open(opts.path ?? EVENTS_PATH);
|
|
102
|
+
|
|
103
|
+
/** The single path from a socket event to a state change and a refetch. */
|
|
104
|
+
const dispatch = (event: LiveEvent): void => {
|
|
105
|
+
const next = reduceLive(state, event, opts.hasSelection());
|
|
106
|
+
state = next.state;
|
|
107
|
+
connection.value = state.connection;
|
|
108
|
+
if (!isNoop(next.plan)) opts.refetch(next.plan);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
source.addEventListener("open", () => dispatch({ type: "open" }));
|
|
112
|
+
source.addEventListener("error", () => dispatch({ type: "error", readyState: source.readyState }));
|
|
113
|
+
source.addEventListener(CHANGE_EVENT_NAME, (event) => {
|
|
114
|
+
const frame = parseFrame(event.data);
|
|
115
|
+
// A frame we cannot read is dropped rather than escalated: the next one
|
|
116
|
+
// carries the same "something moved" meaning, and the heartbeat proves
|
|
117
|
+
// the socket is still alive meanwhile.
|
|
118
|
+
if (frame !== null) dispatch({ type: "frame", event: frame });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
stop() {
|
|
123
|
+
if (stopped) return;
|
|
124
|
+
stopped = true;
|
|
125
|
+
source.close();
|
|
126
|
+
dispatch({ type: "closed" });
|
|
127
|
+
},
|
|
128
|
+
refresh() {
|
|
129
|
+
dispatch({ type: "refresh" });
|
|
130
|
+
},
|
|
131
|
+
seen(stamp: string) {
|
|
132
|
+
state = withStamp(state, stamp);
|
|
133
|
+
},
|
|
134
|
+
state() {
|
|
135
|
+
return state;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `new EventSource(url)`, as an {@link EventSourceFactory}.
|
|
142
|
+
*
|
|
143
|
+
* The one place in the liveness layer that names a DOM global, isolated here
|
|
144
|
+
* so that everything else in it compiles and runs under Node.
|
|
145
|
+
*/
|
|
146
|
+
export function domEventSource(url: string): EventSourceLike {
|
|
147
|
+
return new EventSource(url);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Re-exported so the shell imports its liveness vocabulary from one module. */
|
|
151
|
+
export type { ChangeScope, LiveState, RefetchPlan };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client entry point (weave-workspace §2, §9).
|
|
3
|
+
*
|
|
4
|
+
* Reads the bootstrap block the HTML shell embedded, then mounts
|
|
5
|
+
* {@link Shell}. Everything else — layout, liveness, fetching — is reached
|
|
6
|
+
* from there.
|
|
7
|
+
*
|
|
8
|
+
* The three DOM reads that cannot be injected any further up (`getElementById`,
|
|
9
|
+
* `innerWidth`, `navigator.platform`) happen here and are passed down as
|
|
10
|
+
* props, so `Shell` and everything below it takes its world as parameters.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { render } from "preact";
|
|
14
|
+
import { BOOTSTRAP_ELEMENT_ID } from "../shared/wire";
|
|
15
|
+
import { readBootstrap } from "./bootstrap";
|
|
16
|
+
import { Shell } from "./shell/Shell";
|
|
17
|
+
import { installTheme } from "./shell/theme";
|
|
18
|
+
|
|
19
|
+
const host = document.getElementById("app");
|
|
20
|
+
if (host !== null) {
|
|
21
|
+
// Before the first render: the sheet carries the grid and the palette, and
|
|
22
|
+
// installing it after would show one unstyled frame. It copies the
|
|
23
|
+
// per-response CSP nonce off the server's own `<style>` — see `theme.ts`.
|
|
24
|
+
installTheme(document);
|
|
25
|
+
const boot = readBootstrap(document.getElementById(BOOTSTRAP_ELEMENT_ID)?.textContent ?? null);
|
|
26
|
+
render(<Shell cwd={boot.cwd} initialWidth={window.innerWidth} platform={navigator.platform} />, host);
|
|
27
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `<textarea>` editor and its toolbar (weave-workspace §0 V10, §11 P5.4).
|
|
3
|
+
*
|
|
4
|
+
* Props in, JSX out. Dirty tracking, the save lifecycle, conflict resolution
|
|
5
|
+
* and every string are `editor.model.ts`; the requests are `editor.ts`. What
|
|
6
|
+
* is left here is three elements and three handlers.
|
|
7
|
+
*
|
|
8
|
+
* A `<textarea>`, not CodeMirror 6, and §0 V10 settles it with a number:
|
|
9
|
+
* CM6 is 118 KB gzip — more than the entire rest of the client — against a
|
|
10
|
+
* 150 KB budget already 62 % spent. The textarea is what proves the *save
|
|
11
|
+
* path* is correct, which is the part P5 is actually gated on. Syntax
|
|
12
|
+
* highlighting can arrive later against a round trip that is already known
|
|
13
|
+
* to be lossless; the reverse order would be building an editor over a bug.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { EditorEvent, EditorPrompt, EditorToolbar } from "./editor.model";
|
|
17
|
+
import { OPEN_HINT, OPEN_LABEL } from "./editor.model";
|
|
18
|
+
|
|
19
|
+
export interface EditorProps {
|
|
20
|
+
toolbar: EditorToolbar;
|
|
21
|
+
/** The `<textarea>`'s content — `state.draft`. */
|
|
22
|
+
draft: string;
|
|
23
|
+
/** The one prompt to show, or `null`. Ordered by `editorPrompt`. */
|
|
24
|
+
prompt: EditorPrompt | null;
|
|
25
|
+
/** Dispatch into the controller. The only way anything here changes state. */
|
|
26
|
+
send: (event: EditorEvent) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The conflict / discard / external-change prompt. */
|
|
30
|
+
function Prompt({ prompt, send }: { prompt: EditorPrompt; send: (event: EditorEvent) => void }) {
|
|
31
|
+
return (
|
|
32
|
+
<div class={`weave-note-prompt weave-note-prompt-${prompt.kind}`} role="alert">
|
|
33
|
+
<p class="weave-note-prompt-text">{prompt.message}</p>
|
|
34
|
+
<p class="weave-note-prompt-actions">
|
|
35
|
+
{prompt.actions.map((action) => (
|
|
36
|
+
<button key={action.label} type="button" class="weave-note-action" onClick={() => send(action.event)}>
|
|
37
|
+
{action.label}
|
|
38
|
+
</button>
|
|
39
|
+
))}
|
|
40
|
+
</p>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The toolbar: edit/done, save, open-in-$EDITOR, and the status word. */
|
|
46
|
+
export function EditorBar(props: EditorProps) {
|
|
47
|
+
const { toolbar, send } = props;
|
|
48
|
+
return (
|
|
49
|
+
<div class="weave-note-bar">
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
class="weave-note-toggle"
|
|
53
|
+
aria-pressed={toolbar.editing}
|
|
54
|
+
onClick={() => send({ type: "toggle" })}
|
|
55
|
+
>
|
|
56
|
+
{toolbar.toggleLabel}
|
|
57
|
+
</button>
|
|
58
|
+
{toolbar.editing ? (
|
|
59
|
+
<button type="button" class="weave-note-save" disabled={!toolbar.canSave} onClick={() => send({ type: "save" })}>
|
|
60
|
+
{toolbar.saveLabel}
|
|
61
|
+
</button>
|
|
62
|
+
) : null}
|
|
63
|
+
<button type="button" class="weave-note-open" title={OPEN_HINT} onClick={() => send({ type: "open" })}>
|
|
64
|
+
{OPEN_LABEL}
|
|
65
|
+
</button>
|
|
66
|
+
{toolbar.dirty ? (
|
|
67
|
+
<span class="weave-note-dirty" title="unsaved changes" aria-hidden="true">
|
|
68
|
+
•
|
|
69
|
+
</span>
|
|
70
|
+
) : null}
|
|
71
|
+
{toolbar.message === null ? null : <span class={`weave-note-status weave-note-status-${toolbar.tone}`}>{toolbar.message}</span>}
|
|
72
|
+
{props.prompt === null ? null : <Prompt prompt={props.prompt} send={send} />}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The editing surface.
|
|
79
|
+
*
|
|
80
|
+
* `⌘S` is handled here as well as globally, and deliberately: the global
|
|
81
|
+
* `keydown` listener sees every keystroke in the workspace, so claiming a
|
|
82
|
+
* modifier combination there is a workspace-wide claim. Handling it on the
|
|
83
|
+
* textarea too means the save fires from the element that owns the text even
|
|
84
|
+
* if the global map is later narrowed — and `keys.model.ts` returns `null`
|
|
85
|
+
* for anything it does not claim, so the two never fight over one event.
|
|
86
|
+
*/
|
|
87
|
+
export function Editor(props: EditorProps) {
|
|
88
|
+
return (
|
|
89
|
+
<textarea
|
|
90
|
+
class="weave-note-editor"
|
|
91
|
+
aria-label="Note body"
|
|
92
|
+
spellcheck
|
|
93
|
+
value={props.draft}
|
|
94
|
+
onInput={(event) => props.send({ type: "draft", text: (event.target as HTMLTextAreaElement).value })}
|
|
95
|
+
onKeyDown={(event) => {
|
|
96
|
+
if (event.key.toLowerCase() !== "s" || !(event.metaKey || event.ctrlKey)) return;
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
props.send({ type: "save" });
|
|
99
|
+
}}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The note column (weave-workspace §1.2, P2.4, P5).
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* `dangerouslySetInnerHTML` is used deliberately and exactly once. The
|
|
12
|
+
* alternative is parsing marked's output into a Preact tree, which means a
|
|
13
|
+
* second HTML parser in the bundle and a second place for a sanitisation
|
|
14
|
+
* 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".
|
|
17
|
+
*
|
|
18
|
+
* The read view is rendered from the **note**, not from the draft: what the
|
|
19
|
+
* `<textarea>` holds is unsaved and un-sanitised, and piping it through
|
|
20
|
+
* `renderNote` on every keystroke would be a live preview paid for with a
|
|
21
|
+
* markdown parse and a DOMPurify pass per character. §11 P5.4 mentions a live
|
|
22
|
+
* preview; the honest version of it is `⌘E`, which is instant and shows the
|
|
23
|
+
* text that actually exists.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import DOMPurify from "dompurify";
|
|
27
|
+
import type { GraphPayload, NotePayload } from "../../shared/wire";
|
|
28
|
+
import type { EditorEvent, EditorPrompt, EditorToolbar } from "./editor.model";
|
|
29
|
+
import { Editor, EditorBar } from "./Editor";
|
|
30
|
+
import type { NoteHeaderView } from "./note.model";
|
|
31
|
+
import { noteEmptyMessage, noteHeader, renderNote, tagLabel, wikiIndex, wikilinkTargetOf } from "./note.model";
|
|
32
|
+
|
|
33
|
+
export interface NoteProps {
|
|
34
|
+
note: NotePayload | null;
|
|
35
|
+
graph: GraphPayload | null;
|
|
36
|
+
selectedId: string | null;
|
|
37
|
+
onSelect: (id: string) => void;
|
|
38
|
+
/** Epoch ms for relative times. Injected so the render is deterministic. */
|
|
39
|
+
now: number;
|
|
40
|
+
/** The editor's view model, or `null` when the shell wired no editor. */
|
|
41
|
+
toolbar: EditorToolbar | null;
|
|
42
|
+
prompt: EditorPrompt | null;
|
|
43
|
+
/** The `<textarea>`'s content. Rendered only while `toolbar.editing`. */
|
|
44
|
+
draft: string;
|
|
45
|
+
send: (event: EditorEvent) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function Header({ view }: { view: NoteHeaderView }) {
|
|
49
|
+
return (
|
|
50
|
+
<header class="weave-note-head">
|
|
51
|
+
<h3 class="weave-note-title">{view.title}</h3>
|
|
52
|
+
<p class="weave-note-meta">
|
|
53
|
+
<span class={`weave-prov weave-prov-${view.provenance}`} title={view.provenanceTitle}>
|
|
54
|
+
{view.provenanceGlyph} {view.provenance}
|
|
55
|
+
</span>
|
|
56
|
+
<span class="weave-note-time" title={view.updatedIso}>
|
|
57
|
+
updated {view.updated}
|
|
58
|
+
</span>
|
|
59
|
+
<span class="weave-note-time" title={view.createdIso}>
|
|
60
|
+
created {view.created}
|
|
61
|
+
</span>
|
|
62
|
+
</p>
|
|
63
|
+
<p class="weave-note-tags">
|
|
64
|
+
{view.tags.map((tag) => (
|
|
65
|
+
<span key={tag} class="weave-tag">
|
|
66
|
+
{tagLabel(tag)}
|
|
67
|
+
</span>
|
|
68
|
+
))}
|
|
69
|
+
</p>
|
|
70
|
+
</header>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function Note(props: NoteProps) {
|
|
75
|
+
const note = props.note?.note ?? null;
|
|
76
|
+
const empty = noteEmptyMessage(props.selectedId, note);
|
|
77
|
+
if (note === null) return <p class="weave-note-empty">{empty}</p>;
|
|
78
|
+
|
|
79
|
+
const index = wikiIndex(props.graph, note.slug);
|
|
80
|
+
// Bound once so TypeScript narrows it for both uses below: `toolbar.editing`
|
|
81
|
+
// is what decides whether the textarea renders, and reading it twice off
|
|
82
|
+
// `props` would need a non-null assertion at the second read.
|
|
83
|
+
const toolbar = props.toolbar;
|
|
84
|
+
const shell = { draft: props.draft, prompt: props.prompt, send: props.send };
|
|
85
|
+
return (
|
|
86
|
+
<article class="weave-note">
|
|
87
|
+
<Header view={noteHeader(note, props.now)} />
|
|
88
|
+
{toolbar === null ? null : <EditorBar toolbar={toolbar} {...shell} />}
|
|
89
|
+
{toolbar !== null && toolbar.editing ? (
|
|
90
|
+
<Editor toolbar={toolbar} {...shell} />
|
|
91
|
+
) : (
|
|
92
|
+
<div
|
|
93
|
+
class="weave-note-body"
|
|
94
|
+
onClick={(event) => {
|
|
95
|
+
// A wikilink carries no href, so nothing is navigating; this only
|
|
96
|
+
// has to route the click onto the §1.3 context bus.
|
|
97
|
+
const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
|
|
98
|
+
if (target !== null) props.onSelect(target);
|
|
99
|
+
}}
|
|
100
|
+
onKeyDown={(event) => {
|
|
101
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
102
|
+
const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
|
|
103
|
+
if (target === null) return;
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
props.onSelect(target);
|
|
106
|
+
}}
|
|
107
|
+
// Sanitised by `renderNote`'s three layers — see note.model.ts.
|
|
108
|
+
dangerouslySetInnerHTML={{ __html: renderNote(DOMPurify, note.body, index) }}
|
|
109
|
+
/>
|
|
110
|
+
)}
|
|
111
|
+
</article>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor's effects: three requests and an unload listener
|
|
3
|
+
* (weave-workspace §10, §11 P5).
|
|
4
|
+
*
|
|
5
|
+
* `editor.model.ts` decides *whether* to save, *what* to send and *what a
|
|
6
|
+
* response means*; it cannot issue a request or subscribe to `beforeunload`
|
|
7
|
+
* without ceasing to be pure. This is the other half — a controller whose
|
|
8
|
+
* every capability is injected, in the shape `search.ts` established.
|
|
9
|
+
*
|
|
10
|
+
* ## Why `editor.controller.ts` and not `editor.ts`
|
|
11
|
+
*
|
|
12
|
+
* The pairing everywhere else in the client is `x.model.ts` + `x.ts`
|
|
13
|
+
* (`search.model.ts`/`search.ts`, `live.model.ts`/`live.ts`), and that is the
|
|
14
|
+
* name this file wanted. It cannot have it: the component beside it is
|
|
15
|
+
* `Editor.tsx`, and on a case-insensitive filesystem — which is the macOS
|
|
16
|
+
* default — `editor.ts` and `Editor.tsx` are the same path prefix. TypeScript
|
|
17
|
+
* says so directly (`TS1149: File name … differs from already included file
|
|
18
|
+
* name … only in casing`) and the build is a hard error rather than a
|
|
19
|
+
* warning. Renaming the *component* instead would break the `.tsx` = view
|
|
20
|
+
* convention the whole client is read by, so the controller takes the longer
|
|
21
|
+
* name. The capabilities it needs are:
|
|
22
|
+
*
|
|
23
|
+
* - a **`FetchLike`**, the same port `api.ts` takes, so no DOM is involved;
|
|
24
|
+
* - a **`select`**, so completing a parked navigation goes through §1.3's
|
|
25
|
+
* context bus rather than this module knowing what a selection is.
|
|
26
|
+
*
|
|
27
|
+
* The result is that the editor's whole asynchronous behaviour — the save
|
|
28
|
+
* round trip, the `409`, the stale-response guard, the unload block — is
|
|
29
|
+
* covered by ordinary unit tests, and `Editor.tsx` is a `<textarea>` and
|
|
30
|
+
* three handlers.
|
|
31
|
+
*
|
|
32
|
+
* ## Why the controller owns the dispatch loop
|
|
33
|
+
*
|
|
34
|
+
* Every event goes through {@link EditorHandle.send}, which reduces,
|
|
35
|
+
* publishes, and then performs whichever effect the transition asked for.
|
|
36
|
+
* There is no other path that mutates state. That is what makes "a save
|
|
37
|
+
* cannot start while a conflict is unresolved" and "a stale response cannot
|
|
38
|
+
* overwrite the draft" enforceable: the checks live in the reducer, and every
|
|
39
|
+
* response arrives back through here.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { FetchLike } from "../api";
|
|
43
|
+
import { openNote, saveNote } from "../api";
|
|
44
|
+
import type { EditorEffect, EditorEvent, EditorState } from "./editor.model";
|
|
45
|
+
import { initialEditorState, reduceEditor, shouldBlockUnload } from "./editor.model";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The slice of `beforeunload` this module touches.
|
|
49
|
+
*
|
|
50
|
+
* Structural, and `returnValue` is included because that is the only form
|
|
51
|
+
* every browser still honours: `preventDefault()` alone is the spec's answer
|
|
52
|
+
* and Chrome ignored it for years. Setting both is the portable spelling.
|
|
53
|
+
*/
|
|
54
|
+
export interface BeforeUnloadEventLike {
|
|
55
|
+
preventDefault(): void;
|
|
56
|
+
returnValue?: unknown;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The slice of `window` the unload guard subscribes to. */
|
|
60
|
+
export interface UnloadHost {
|
|
61
|
+
addEventListener(type: "beforeunload", listener: (event: BeforeUnloadEventLike) => void): void;
|
|
62
|
+
removeEventListener(type: "beforeunload", listener: (event: BeforeUnloadEventLike) => void): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What {@link createEditor} needs. Every capability injected. */
|
|
66
|
+
export interface EditorOptions {
|
|
67
|
+
fetch: FetchLike;
|
|
68
|
+
/** The §1.3 context bus. Called to complete a navigation the editor held. */
|
|
69
|
+
select: (id: string | null) => void;
|
|
70
|
+
/** Called after every state change, so the component can re-render. */
|
|
71
|
+
onChange: (state: EditorState) => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A running editor. */
|
|
75
|
+
export interface EditorHandle {
|
|
76
|
+
/** Current state. Read at render. */
|
|
77
|
+
state(): EditorState;
|
|
78
|
+
/** Dispatch an event. The only way state moves. */
|
|
79
|
+
send(event: EditorEvent): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createEditor(opts: EditorOptions): EditorHandle {
|
|
83
|
+
let state = initialEditorState();
|
|
84
|
+
|
|
85
|
+
const send = (event: EditorEvent): void => {
|
|
86
|
+
const next = reduceEditor(state, event);
|
|
87
|
+
state = next.state;
|
|
88
|
+
opts.onChange(state);
|
|
89
|
+
if (next.effect !== null) perform(next.effect);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const perform = (effect: EditorEffect): void => {
|
|
93
|
+
if (effect.type === "select") {
|
|
94
|
+
opts.select(effect.id);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Fire and forget: `api.ts` returns failures as values, so there is
|
|
98
|
+
// nothing to reject and nothing to catch. `void` documents the floating
|
|
99
|
+
// promise rather than hiding it — the same shape `workspace.ts` and
|
|
100
|
+
// `search.ts` use.
|
|
101
|
+
if (effect.type === "open") {
|
|
102
|
+
void (async () => {
|
|
103
|
+
const result = await openNote(opts.fetch, effect.slug);
|
|
104
|
+
send({ type: "opened", ok: result.ok && result.data.opened });
|
|
105
|
+
})();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
void (async () => {
|
|
109
|
+
const result = await saveNote(opts.fetch, effect.slug, effect.input);
|
|
110
|
+
if (result.ok) {
|
|
111
|
+
send({ type: "saved", payload: result.data });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// The `409` is not an error in the sense the other five kinds are —
|
|
115
|
+
// it is the server handing back the information the user needs in
|
|
116
|
+
// order to choose. `api.ts` gives it its own arm for exactly this
|
|
117
|
+
// branch.
|
|
118
|
+
send(result.kind === "conflict" ? { type: "conflicted", conflict: result.conflict } : { type: "failed", message: result.message });
|
|
119
|
+
})();
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return { state: () => state, send };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Block `beforeunload` while the draft is dirty. Returns an unsubscribe.
|
|
127
|
+
*
|
|
128
|
+
* The predicate is a thunk, not a value: a listener registered at mount
|
|
129
|
+
* outlives every render, so a captured state would answer with the editor as
|
|
130
|
+
* it was when the tab opened — which is always clean, making the guard a
|
|
131
|
+
* no-op that looks installed.
|
|
132
|
+
*
|
|
133
|
+
* No custom message: every browser has ignored the string since 2017 and
|
|
134
|
+
* shows its own wording. Returning one would be writing code whose only
|
|
135
|
+
* effect is to suggest, to the next reader, that it does something.
|
|
136
|
+
*/
|
|
137
|
+
export function watchUnload(host: UnloadHost, dirty: () => boolean): () => void {
|
|
138
|
+
const listener = (event: BeforeUnloadEventLike): void => {
|
|
139
|
+
if (!dirty()) return;
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
// The legacy half. Chrome required a truthy `returnValue` long after the
|
|
142
|
+
// spec settled on `preventDefault`, and setting both is the only
|
|
143
|
+
// spelling that works everywhere.
|
|
144
|
+
event.returnValue = "";
|
|
145
|
+
};
|
|
146
|
+
host.addEventListener("beforeunload", listener);
|
|
147
|
+
return () => host.removeEventListener("beforeunload", listener);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** {@link shouldBlockUnload}, re-exported so the shell imports one module. */
|
|
151
|
+
export { shouldBlockUnload };
|