pi-weave 0.3.8 → 0.3.10
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 +21 -17
- package/package.json +4 -3
- package/skills/weave-explore/SKILL.md +3 -5
- package/skills/weave-notepad/SKILL.md +22 -2
- package/skills/weave-notepad/references/second-thoughts.md +57 -0
- package/src/core/graph/build.ts +1 -1
- package/src/core/graph/current.ts +10 -8
- package/src/core/history.ts +417 -0
- package/src/core/index.ts +19 -0
- package/src/core/links/repair.ts +5 -68
- package/src/core/noteAction.ts +72 -2
- package/src/core/openInEditor.ts +3 -8
- package/src/core/repoAction.ts +20 -61
- package/src/core/repoIndex.ts +38 -0
- package/src/core/trail.ts +124 -0
- package/src/core/types.ts +1 -3
- package/src/core/vault.ts +116 -29
- package/src/core/view/focus.ts +2 -2
- package/src/core/view/index.ts +2 -3
- package/src/core/view/rawTail.ts +153 -0
- package/src/core/workspace.ts +20 -26
- package/src/core/workspaceCommands.ts +14 -14
- package/src/opencode/tools.ts +3 -3
- package/src/opencode/tui.ts +1 -7
- package/src/pi/index.ts +68 -80
- package/src/pi/tools/noteTool.ts +1 -1
- package/src/pi/tools/repoTool.ts +3 -6
- package/src/pi/viewer/web/run.ts +6 -8
- package/src/web/client/api.ts +103 -5
- package/src/web/client/context/context.model.ts +5 -0
- package/src/web/client/dist/app.js +499 -129
- package/src/web/client/graph/Graph.tsx +166 -14
- package/src/web/client/graph/column.model.ts +59 -2
- package/src/web/client/graph/graph.model.ts +183 -7
- package/src/web/client/graph/renderer.ts +39 -9
- package/src/web/client/graph/spotlight.model.ts +472 -0
- package/src/web/client/note/Note.tsx +370 -66
- package/src/web/client/note/Replay.tsx +139 -0
- package/src/web/client/note/drafts.ts +115 -11
- package/src/web/client/note/editbar.model.ts +153 -0
- package/src/web/client/note/note.model.ts +82 -1
- package/src/web/client/note/replay.model.ts +248 -0
- package/src/web/client/search/SearchPalette.tsx +103 -34
- package/src/web/client/search/search.model.ts +533 -26
- package/src/web/client/shell/ContextRail.tsx +7 -0
- package/src/web/client/shell/Settings.tsx +21 -1
- package/src/web/client/shell/Shell.tsx +93 -27
- package/src/web/client/shell/StatusBar.tsx +1 -0
- package/src/web/client/shell/icons.model.ts +68 -1
- package/src/web/client/shell/keys.model.ts +31 -8
- package/src/web/client/shell/shell.model.ts +48 -1
- package/src/web/client/shell/theme.model.ts +3 -0
- package/src/web/client/shell/theme.ts +354 -32
- package/src/web/client/shell/workspace.css.ts +40 -9
- package/src/web/client/trail/TrailStrip.tsx +55 -0
- package/src/web/client/trail/trail.model.ts +195 -0
- package/src/web/client/trail/trail.ts +45 -0
- package/src/web/client/tree/Tree.tsx +7 -3
- package/src/web/client/tree/tree.model.ts +2 -5
- package/src/web/server/page.ts +1 -1
- package/src/web/server/routes.ts +83 -3
- package/src/web/shared/logo.ts +1 -1
- package/src/web/shared/themes.ts +1 -1
- package/src/web/shared/view.ts +10 -4
- package/src/web/shared/wire.ts +74 -1
- package/src/web/shared/workspace.ts +10 -2
- package/src/core/view/health.ts +0 -160
- package/src/pi/viewer/tui/bodyStore.ts +0 -95
- package/src/pi/viewer/tui/branding.ts +0 -14
- package/src/pi/viewer/tui/model.ts +0 -390
- package/src/pi/viewer/tui/openNote.ts +0 -11
- package/src/pi/viewer/tui/run.ts +0 -71
- package/src/pi/viewer/tui/surface/base.ts +0 -203
- package/src/pi/viewer/tui/surface/detail.ts +0 -315
- package/src/pi/viewer/tui/surface/explore.ts +0 -241
- package/src/pi/viewer/tui/surface/focus.ts +0 -139
- package/src/pi/viewer/tui/surface/health.ts +0 -102
- package/src/pi/viewer/tui/theme.ts +0 -90
- package/src/pi/viewer/tui/workspace.ts +0 -53
- package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The history slider's controls, and the one DOM step the replay needs.
|
|
3
|
+
*
|
|
4
|
+
* Everything that can be decided without a DOM is in `replay.model.ts`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useLayoutEffect, useRef } from "preact/hooks";
|
|
8
|
+
import type { HistoryRevision } from "../../shared/wire";
|
|
9
|
+
import { HISTORY_EMPTY, HISTORY_OFF_HINT, HISTORY_TRIMMED, MARK_CLOSE, MARK_OPEN, changeBars, dayMarks, replayStep, splitMarked } from "./replay.model";
|
|
10
|
+
|
|
11
|
+
/** What the column holds while replaying. `null` revisions means still loading. */
|
|
12
|
+
export interface ReplayState {
|
|
13
|
+
revisions: HistoryRevision[] | null;
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
/** Older revisions were dropped to keep the history file small. */
|
|
16
|
+
trimmed: boolean;
|
|
17
|
+
index: number;
|
|
18
|
+
playing: boolean;
|
|
19
|
+
error: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Turn the marker characters in rendered note HTML into `<mark>` elements.
|
|
24
|
+
* Text nodes are created with `createTextNode`, so nothing here parses HTML.
|
|
25
|
+
*/
|
|
26
|
+
export function wrapMarks(root: HTMLElement): HTMLElement | null {
|
|
27
|
+
const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
28
|
+
const nodes: Text[] = [];
|
|
29
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) nodes.push(node as Text);
|
|
30
|
+
let inside = false;
|
|
31
|
+
let first: HTMLElement | null = null;
|
|
32
|
+
for (const node of nodes) {
|
|
33
|
+
const text = node.data;
|
|
34
|
+
if (!inside && !text.includes(MARK_OPEN) && !text.includes(MARK_CLOSE)) continue;
|
|
35
|
+
const split = splitMarked(text, inside);
|
|
36
|
+
inside = split.inside;
|
|
37
|
+
const fragment = root.ownerDocument.createDocumentFragment();
|
|
38
|
+
for (const part of split.parts) {
|
|
39
|
+
const textNode = root.ownerDocument.createTextNode(part.text);
|
|
40
|
+
if (!part.marked) {
|
|
41
|
+
fragment.append(textNode);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const mark = root.ownerDocument.createElement("mark");
|
|
45
|
+
mark.className = "weave-replay-mark";
|
|
46
|
+
mark.append(textNode);
|
|
47
|
+
fragment.append(mark);
|
|
48
|
+
first ??= mark;
|
|
49
|
+
}
|
|
50
|
+
node.replaceWith(fragment);
|
|
51
|
+
}
|
|
52
|
+
return first;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Past this many versions the band shows no per-version bars: they would be hairlines. */
|
|
56
|
+
const MAX_BARS = 160;
|
|
57
|
+
|
|
58
|
+
const Icon = (props: { d: string; fill?: boolean }) => (
|
|
59
|
+
<svg width="14" height="14" viewBox="0 0 18 18" aria-hidden="true"
|
|
60
|
+
fill={props.fill ? "currentColor" : "none"} stroke={props.fill ? "none" : "currentColor"}
|
|
61
|
+
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
62
|
+
<path d={props.d} />
|
|
63
|
+
</svg>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
export function ReplayBar(props: {
|
|
67
|
+
state: ReplayState;
|
|
68
|
+
/** Epoch ms, for the "Today" / "Yesterday" day labels. */
|
|
69
|
+
now: number;
|
|
70
|
+
onIndex: (index: number) => void;
|
|
71
|
+
onPlay: () => void;
|
|
72
|
+
}) {
|
|
73
|
+
const { revisions, index, playing, enabled, trimmed, error } = props.state;
|
|
74
|
+
const track = useRef<HTMLDivElement>(null);
|
|
75
|
+
const last = revisions === null ? 0 : revisions.length - 1;
|
|
76
|
+
// CSSOM, not a style attribute (the CSP forbids those): how far along the
|
|
77
|
+
// track the filled part reaches.
|
|
78
|
+
useLayoutEffect(() => {
|
|
79
|
+
track.current?.style.setProperty("--weave-replay-at", `${last === 0 ? 100 : (index / last) * 100}%`);
|
|
80
|
+
}, [index, last]);
|
|
81
|
+
if (error !== null) return <div class="weave-replay weave-replay-note" role="status">{error}</div>;
|
|
82
|
+
if (revisions === null) return <div class="weave-replay weave-replay-note" role="status">Loading history…</div>;
|
|
83
|
+
const step = replayStep(revisions, index);
|
|
84
|
+
if (step === null) return <div class="weave-replay weave-replay-note" role="status">{enabled ? HISTORY_EMPTY : HISTORY_OFF_HINT}</div>;
|
|
85
|
+
return (
|
|
86
|
+
<div class="weave-replay" role="group" aria-label="Note history">
|
|
87
|
+
<p class="weave-replay-caption" aria-live="polite">
|
|
88
|
+
<span class="weave-replay-clock"><Icon d="M9 3a6 6 0 1 0 0 12A6 6 0 0 0 9 3zM9 5.5V9l2.5 1.5" /></span>
|
|
89
|
+
<span class="weave-replay-position">Version {step.position}</span>
|
|
90
|
+
<span class={`weave-replay-who weave-replay-${step.by}`}>{step.action}</span>
|
|
91
|
+
<span class="weave-replay-time" title={step.atIso}>{step.at}</span>
|
|
92
|
+
{step.change === "" ? null : <span class="weave-replay-change">{step.change}</span>}
|
|
93
|
+
{trimmed && index === 0 ? <span class="weave-replay-time">{HISTORY_TRIMMED}</span> : null}
|
|
94
|
+
</p>
|
|
95
|
+
<div class="weave-replay-controls">
|
|
96
|
+
<button type="button" aria-label="Previous version" disabled={index <= 0} onClick={() => props.onIndex(index - 1)}>
|
|
97
|
+
<Icon d="m11 4-5 5 5 5" />
|
|
98
|
+
</button>
|
|
99
|
+
<button type="button" class="weave-replay-play" aria-label={playing ? "Pause replay" : "Play replay"} disabled={last === 0} onClick={props.onPlay}>
|
|
100
|
+
{playing ? <Icon d="M5 4h3v10H5zM10 4h3v10h-3z" fill /> : <Icon d="M6 4v10l8-5z" fill />}
|
|
101
|
+
</button>
|
|
102
|
+
<button type="button" aria-label="Next version" disabled={index >= last} onClick={() => props.onIndex(index + 1)}>
|
|
103
|
+
<Icon d="m7 4 5 5-5 5" />
|
|
104
|
+
</button>
|
|
105
|
+
<div class="weave-replay-track" ref={track}>
|
|
106
|
+
{revisions.length > MAX_BARS || last === 0 ? null : (
|
|
107
|
+
<span class="weave-replay-bars" aria-hidden="true" style={{ "--weave-bars": `${revisions.length}` }}>
|
|
108
|
+
{changeBars(revisions).map((bar, at) => (
|
|
109
|
+
// Placed where the slider's thumb sits for this version, so a bar and its stop line up.
|
|
110
|
+
<span key={at} style={{ "--weave-bar": `${Math.round(bar.height * 100)}%`, "--weave-bar-at": `${(at / last) * 100}%` }}
|
|
111
|
+
class={`weave-replay-bar weave-replay-${bar.by}${at > index ? " weave-replay-ahead" : ""}${at === index ? " weave-replay-current" : ""}`} />
|
|
112
|
+
))}
|
|
113
|
+
</span>
|
|
114
|
+
)}
|
|
115
|
+
{last === 0 ? null : (
|
|
116
|
+
<span class="weave-replay-days" aria-hidden="true">
|
|
117
|
+
{dayMarks(revisions, props.now).map((mark) => (
|
|
118
|
+
<span key={mark.at} class="weave-replay-day" style={{ "--weave-day-at": `${mark.at * 100}%` }}>{mark.label}</span>
|
|
119
|
+
))}
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
<input
|
|
123
|
+
class="weave-replay-slider"
|
|
124
|
+
type="range"
|
|
125
|
+
aria-label="Version"
|
|
126
|
+
aria-valuetext={`${step.position}: ${step.action}`}
|
|
127
|
+
min={0}
|
|
128
|
+
max={last}
|
|
129
|
+
step={1}
|
|
130
|
+
value={index}
|
|
131
|
+
disabled={last === 0}
|
|
132
|
+
onInput={(event) => props.onIndex(Number(event.currentTarget.value))}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
{enabled ? null : <p class="weave-replay-hint">{HISTORY_OFF_HINT}</p>}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -1,11 +1,47 @@
|
|
|
1
|
+
import { joinRawOpening, joinRawTail, splitRawOpening, splitRawTail } from "../../shared/view";
|
|
1
2
|
import { tabSelection, type WorkspaceLayout } from "../../shared/workspace";
|
|
2
3
|
|
|
4
|
+
/**
|
|
5
|
+
* An open editor. `body` is only the part above the `## Raw` tail, and the
|
|
6
|
+
* tail's entries get their own box (`raw`): one textarea holding both used
|
|
7
|
+
* to drop anything typed below the heading on save, without a word.
|
|
8
|
+
*/
|
|
3
9
|
export interface NoteDraft {
|
|
4
10
|
body: string;
|
|
5
11
|
baseline: string;
|
|
12
|
+
/** The verbatim `## Raw` tail as it was when the editor opened; "" when none. */
|
|
13
|
+
tail: string;
|
|
14
|
+
/** The tail's entries, below its heading and notice, as edited. */
|
|
15
|
+
raw: string;
|
|
16
|
+
rawBaseline: string;
|
|
6
17
|
saving: boolean;
|
|
18
|
+
/** The body and Raw entries as they were when the editor opened: what Discard returns to. */
|
|
19
|
+
original: string;
|
|
20
|
+
originalRaw: string;
|
|
21
|
+
/** When an autosave last landed (epoch ms); null until one has. */
|
|
22
|
+
savedAt: number | null;
|
|
23
|
+
/** The last save attempt failed; cleared by the next one that lands. */
|
|
24
|
+
failed: boolean;
|
|
7
25
|
}
|
|
8
26
|
|
|
27
|
+
/** How a save should treat the `## Raw` tail; `raw` means the person edited it. */
|
|
28
|
+
export interface SaveOptions {
|
|
29
|
+
raw?: boolean;
|
|
30
|
+
/** An autosave: the shell reports a failure in the edit bar, not with an alert. */
|
|
31
|
+
quiet?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How a draft save behaves. */
|
|
35
|
+
export interface DraftSaveOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Keep the editor open after the write lands (autosave). Without it a save
|
|
38
|
+
* that catches up with the editor closes it, which is what Done does.
|
|
39
|
+
*/
|
|
40
|
+
keepOpen?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type SaveNote = (slug: string, body: string, options?: SaveOptions) => Promise<boolean>;
|
|
44
|
+
|
|
9
45
|
interface TaskSource {
|
|
10
46
|
body: string;
|
|
11
47
|
version: number;
|
|
@@ -16,26 +52,44 @@ interface TaskSource {
|
|
|
16
52
|
export interface DraftStore {
|
|
17
53
|
get(slug: string): Readonly<NoteDraft> | null;
|
|
18
54
|
subscribe(listener: () => void): () => void;
|
|
55
|
+
/** Open an editor on a full note body; the `## Raw` tail is split off. */
|
|
19
56
|
open(slug: string, body: string): void;
|
|
20
57
|
edit(slug: string, body: string): void;
|
|
58
|
+
/** Edit the `## Raw` entries. */
|
|
59
|
+
editRaw(slug: string, raw: string): void;
|
|
60
|
+
/**
|
|
61
|
+
* Catch an open editor's Raw box up with the note on disk. Entries an
|
|
62
|
+
* agent appended while the editor was open (and survived an autosave,
|
|
63
|
+
* which keeps the server's tail) would otherwise be missing from it, and
|
|
64
|
+
* the next save of an edited Raw would write them away.
|
|
65
|
+
*/
|
|
66
|
+
syncTail(slug: string, serverBody: string): void;
|
|
21
67
|
discard(slug: string): void;
|
|
22
68
|
isDirty(slug: string): boolean;
|
|
23
69
|
dirtySlugs(): string[];
|
|
24
|
-
save(slug: string, onSave:
|
|
70
|
+
save(slug: string, onSave: SaveNote, options?: DraftSaveOptions): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Put the body and Raw entries back to how they were when the editor
|
|
73
|
+
* opened. Returns whether that differs from what is on disk now (an
|
|
74
|
+
* autosave already wrote the edits), in which case the caller saves.
|
|
75
|
+
*/
|
|
76
|
+
revert(slug: string): boolean;
|
|
25
77
|
nextLoadVersion(): number;
|
|
26
78
|
getTaskBody(slug: string, fallback: string): string;
|
|
27
79
|
taskBody(slug: string, serverBody: string, loadVersion: number): string;
|
|
28
80
|
isTaskSaving(slug: string): boolean;
|
|
29
|
-
saveTask(slug: string, body: string, onSave:
|
|
81
|
+
saveTask(slug: string, body: string, onSave: SaveNote): Promise<boolean>;
|
|
30
82
|
clearTaskSources(slugs: readonly string[]): void;
|
|
31
83
|
}
|
|
32
84
|
|
|
33
|
-
|
|
85
|
+
/** `now` is the clock behind "Saved 12:04"; tests pass a fixed one. */
|
|
86
|
+
export function createDraftStore(now: () => number = Date.now): DraftStore {
|
|
34
87
|
const drafts = new Map<string, NoteDraft>();
|
|
35
88
|
const taskSources = new Map<string, TaskSource>();
|
|
36
89
|
const listeners = new Set<() => void>();
|
|
37
90
|
let loadVersion = 0;
|
|
38
91
|
const notify = (): void => listeners.forEach((listener) => listener());
|
|
92
|
+
const dirty = (draft: NoteDraft): boolean => draft.body !== draft.baseline || draft.raw !== draft.rawBaseline;
|
|
39
93
|
|
|
40
94
|
return {
|
|
41
95
|
get: (slug) => drafts.get(slug) ?? null,
|
|
@@ -45,13 +99,41 @@ export function createDraftStore(): DraftStore {
|
|
|
45
99
|
},
|
|
46
100
|
open(slug, body) {
|
|
47
101
|
if (drafts.has(slug)) return;
|
|
48
|
-
|
|
102
|
+
const { head, tail } = splitRawTail(body);
|
|
103
|
+
const { entries } = splitRawOpening(tail);
|
|
104
|
+
drafts.set(slug, { body: head, baseline: head, tail, raw: entries, rawBaseline: entries, saving: false, original: head, originalRaw: entries, savedAt: null, failed: false });
|
|
49
105
|
notify();
|
|
50
106
|
},
|
|
51
107
|
edit(slug, body) {
|
|
52
108
|
const draft = drafts.get(slug);
|
|
53
109
|
if (draft === undefined || draft.body === body) return;
|
|
54
110
|
draft.body = body;
|
|
111
|
+
// New words get a fresh autosave attempt; a failure says so until then.
|
|
112
|
+
draft.failed = false;
|
|
113
|
+
notify();
|
|
114
|
+
},
|
|
115
|
+
editRaw(slug, raw) {
|
|
116
|
+
const draft = drafts.get(slug);
|
|
117
|
+
if (draft === undefined || draft.raw === raw) return;
|
|
118
|
+
draft.raw = raw;
|
|
119
|
+
draft.failed = false;
|
|
120
|
+
notify();
|
|
121
|
+
},
|
|
122
|
+
syncTail(slug, serverBody) {
|
|
123
|
+
const draft = drafts.get(slug);
|
|
124
|
+
// A write in flight answers for itself, and Raw edits not yet saved are
|
|
125
|
+
// the person's to keep: only an untouched box follows the disk.
|
|
126
|
+
if (draft === undefined || draft.saving || draft.raw !== draft.rawBaseline) return;
|
|
127
|
+
const { tail } = splitRawTail(serverBody);
|
|
128
|
+
if (tail === draft.tail) return;
|
|
129
|
+
const { entries } = splitRawOpening(tail);
|
|
130
|
+
// Discard returns to how the note was when the editor opened, plus what
|
|
131
|
+
// was appended since: an agent's entry is not the person's edit to undo.
|
|
132
|
+
if (draft.originalRaw === draft.rawBaseline) draft.originalRaw = entries;
|
|
133
|
+
else if (entries.startsWith(draft.rawBaseline)) draft.originalRaw += entries.slice(draft.rawBaseline.length);
|
|
134
|
+
draft.tail = tail;
|
|
135
|
+
draft.raw = entries;
|
|
136
|
+
draft.rawBaseline = entries;
|
|
55
137
|
notify();
|
|
56
138
|
},
|
|
57
139
|
discard(slug) {
|
|
@@ -59,32 +141,54 @@ export function createDraftStore(): DraftStore {
|
|
|
59
141
|
},
|
|
60
142
|
isDirty(slug) {
|
|
61
143
|
const draft = drafts.get(slug);
|
|
62
|
-
return draft !== undefined && draft
|
|
144
|
+
return draft !== undefined && dirty(draft);
|
|
63
145
|
},
|
|
64
146
|
dirtySlugs() {
|
|
65
|
-
return [...drafts].filter(([, draft]) => draft
|
|
147
|
+
return [...drafts].filter(([, draft]) => dirty(draft)).map(([slug]) => slug);
|
|
148
|
+
},
|
|
149
|
+
revert(slug) {
|
|
150
|
+
const draft = drafts.get(slug);
|
|
151
|
+
if (draft === undefined) return false;
|
|
152
|
+
draft.body = draft.original;
|
|
153
|
+
draft.raw = draft.originalRaw;
|
|
154
|
+
notify();
|
|
155
|
+
return dirty(draft);
|
|
66
156
|
},
|
|
67
|
-
async save(slug, onSave) {
|
|
157
|
+
async save(slug, onSave, options) {
|
|
68
158
|
const draft = drafts.get(slug);
|
|
69
159
|
if (draft === undefined || draft.saving || taskSources.get(slug)?.saving) return;
|
|
70
160
|
const sent = draft.body;
|
|
161
|
+
const sentRaw = draft.raw;
|
|
162
|
+
// An untouched Raw is sent as it was and kept by the server, so an
|
|
163
|
+
// entry dictated while this editor was open survives the save. An
|
|
164
|
+
// edited one is sent as the person left it, and replaces the tail.
|
|
165
|
+
const rawEdited = sentRaw !== draft.rawBaseline;
|
|
166
|
+
const tail = rawEdited ? joinRawOpening(splitRawOpening(draft.tail).opening, sentRaw) : draft.tail;
|
|
167
|
+
const full = joinRawTail(sent, tail);
|
|
71
168
|
draft.saving = true;
|
|
72
169
|
notify();
|
|
73
170
|
let ok = false;
|
|
74
171
|
try {
|
|
75
|
-
|
|
172
|
+
const saveOptions: SaveOptions = { ...(rawEdited ? { raw: true } : {}), ...(options?.keepOpen ? { quiet: true } : {}) };
|
|
173
|
+
ok = await onSave(slug, full, Object.keys(saveOptions).length === 0 ? undefined : saveOptions);
|
|
76
174
|
} finally {
|
|
77
175
|
// Discard/reopen during an in-flight request gives the new draft a new identity.
|
|
78
176
|
if (drafts.get(slug) === draft) {
|
|
79
177
|
const source = taskSources.get(slug);
|
|
80
178
|
if (ok && source !== undefined) {
|
|
81
|
-
source.body =
|
|
179
|
+
source.body = full;
|
|
82
180
|
source.version = ++loadVersion;
|
|
83
181
|
delete source.pending;
|
|
84
182
|
}
|
|
85
|
-
if (ok && draft.body === sent) drafts.delete(slug);
|
|
183
|
+
if (ok && !options?.keepOpen && draft.body === sent && draft.raw === sentRaw) drafts.delete(slug);
|
|
86
184
|
else {
|
|
87
|
-
if (ok)
|
|
185
|
+
if (ok) {
|
|
186
|
+
draft.baseline = sent;
|
|
187
|
+
draft.rawBaseline = sentRaw;
|
|
188
|
+
draft.tail = tail;
|
|
189
|
+
draft.savedAt = now();
|
|
190
|
+
}
|
|
191
|
+
draft.failed = !ok;
|
|
88
192
|
draft.saving = false;
|
|
89
193
|
}
|
|
90
194
|
notify();
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The edit bar: the pure half (the refresh's E1).
|
|
3
|
+
*
|
|
4
|
+
* While a note is being edited, one bar under the title says so, carries the
|
|
5
|
+
* formatting buttons, shows whether the edits are on disk yet, and offers
|
|
6
|
+
* Discard and Done. Saving is automatic: a short pause in typing writes the
|
|
7
|
+
* draft (see `drafts.ts`, `keepOpen`), so Done only has to catch up and close.
|
|
8
|
+
*
|
|
9
|
+
* Everything that can be decided without a DOM is here: what the status says,
|
|
10
|
+
* and how a formatting button rewrites the text around the selection.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { IconName } from "../shell/icons.model";
|
|
14
|
+
|
|
15
|
+
/** How long typing has to pause before the draft is written. */
|
|
16
|
+
export const AUTOSAVE_MS = 2500;
|
|
17
|
+
|
|
18
|
+
export const EDITING_LABEL = "Editing";
|
|
19
|
+
export const DISCARD_EDITS_LABEL = "Discard changes";
|
|
20
|
+
export const RETRY_LABEL = "Retry";
|
|
21
|
+
/** Asked before Discard writes the opening version back over autosaved edits. */
|
|
22
|
+
export const REVERT_PROMPT = "Put this note back the way it was when you started editing?";
|
|
23
|
+
|
|
24
|
+
/** What the status slot shows. */
|
|
25
|
+
export type EditStatusKind = "saving" | "failed" | "pending" | "saved" | "clean";
|
|
26
|
+
|
|
27
|
+
export interface EditStatus {
|
|
28
|
+
kind: EditStatusKind;
|
|
29
|
+
label: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The parts of a draft the status depends on. */
|
|
33
|
+
export interface EditStatusInput {
|
|
34
|
+
saving: boolean;
|
|
35
|
+
failed: boolean;
|
|
36
|
+
/** Edits not yet on disk. */
|
|
37
|
+
dirty: boolean;
|
|
38
|
+
savedAt: number | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clock(at: number): string {
|
|
42
|
+
return new Date(at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The status slot, in priority order: a write in flight, a failed one, waiting edits, the last save. */
|
|
46
|
+
export function editStatus(input: EditStatusInput): EditStatus {
|
|
47
|
+
if (input.saving) return { kind: "saving", label: "Saving…" };
|
|
48
|
+
if (input.failed) return { kind: "failed", label: "Not saved" };
|
|
49
|
+
if (input.dirty) return { kind: "pending", label: "Unsaved changes" };
|
|
50
|
+
if (input.savedAt !== null) return { kind: "saved", label: `Saved ${clock(input.savedAt)}` };
|
|
51
|
+
return { kind: "clean", label: "No changes yet" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The formatting buttons, in bar order. */
|
|
55
|
+
export type FormatKind = "heading" | "bold" | "italic" | "link" | "wikilink" | "task";
|
|
56
|
+
|
|
57
|
+
export interface FormatTool {
|
|
58
|
+
kind: FormatKind;
|
|
59
|
+
icon: IconName;
|
|
60
|
+
label: string;
|
|
61
|
+
/** The key that applies it with ⌘/Ctrl, if any. */
|
|
62
|
+
key: string | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const FORMAT_TOOLS: readonly FormatTool[] = [
|
|
66
|
+
{ kind: "heading", icon: "heading", label: "Heading", key: null },
|
|
67
|
+
{ kind: "bold", icon: "bold", label: "Bold", key: "b" },
|
|
68
|
+
{ kind: "italic", icon: "italic", label: "Italic", key: "i" },
|
|
69
|
+
// No ⌘K: that is search, everywhere in the workspace, the editor included.
|
|
70
|
+
{ kind: "link", icon: "link", label: "Link", key: null },
|
|
71
|
+
{ kind: "wikilink", icon: "wikilink", label: "Link to a note", key: null },
|
|
72
|
+
{ kind: "task", icon: "task", label: "Task", key: null },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** The tool bound to a ⌘/Ctrl key, if any. */
|
|
76
|
+
export function formatForKey(key: string): FormatKind | null {
|
|
77
|
+
return FORMAT_TOOLS.find((tool) => tool.key === key.toLowerCase())?.kind ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A textarea's value and selection. */
|
|
81
|
+
export interface TextEdit {
|
|
82
|
+
value: string;
|
|
83
|
+
start: number;
|
|
84
|
+
end: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Wrap the selection, or insert a placeholder and select it so typing replaces it. */
|
|
88
|
+
function wrap(edit: TextEdit, before: string, after: string, placeholder: string): TextEdit {
|
|
89
|
+
const selected = edit.value.slice(edit.start, edit.end);
|
|
90
|
+
const inner = selected === "" ? placeholder : selected;
|
|
91
|
+
const value = edit.value.slice(0, edit.start) + before + inner + after + edit.value.slice(edit.end);
|
|
92
|
+
return { value, start: edit.start + before.length, end: edit.start + before.length + inner.length };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Rewrite every line the selection touches. A selection that ends just after
|
|
97
|
+
* a line break (a triple-clicked line) does not touch the line below it.
|
|
98
|
+
*/
|
|
99
|
+
function eachLine(edit: TextEdit, rewrite: (line: string) => string): TextEdit {
|
|
100
|
+
const from = edit.value.lastIndexOf("\n", edit.start - 1) + 1;
|
|
101
|
+
const last = edit.end > edit.start && edit.value[edit.end - 1] === "\n" ? edit.end - 1 : edit.end;
|
|
102
|
+
const newline = edit.value.indexOf("\n", last);
|
|
103
|
+
const to = newline === -1 ? edit.value.length : newline;
|
|
104
|
+
const lines = edit.value.slice(from, to).split("\n").map(rewrite).join("\n");
|
|
105
|
+
const value = edit.value.slice(0, from) + lines + edit.value.slice(to);
|
|
106
|
+
return { value, start: from, end: from + lines.length };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const HEADING_RE = /^#{1,6}[ \t]+/;
|
|
110
|
+
const TASK_RE = /^([ \t]*)[-*+][ \t]+\[[ xX]\][ \t]+/;
|
|
111
|
+
const BULLET_RE = /^([ \t]*)[-*+][ \t]+/;
|
|
112
|
+
|
|
113
|
+
/** Apply one formatting button to the text around the selection. */
|
|
114
|
+
export function applyFormat(edit: TextEdit, kind: FormatKind): TextEdit {
|
|
115
|
+
switch (kind) {
|
|
116
|
+
case "bold":
|
|
117
|
+
return wrap(edit, "**", "**", "bold text");
|
|
118
|
+
case "italic":
|
|
119
|
+
return wrap(edit, "_", "_", "italic text");
|
|
120
|
+
case "wikilink":
|
|
121
|
+
return wrap(edit, "[[", "]]", "note title");
|
|
122
|
+
case "link": {
|
|
123
|
+
const selected = edit.value.slice(edit.start, edit.end);
|
|
124
|
+
if (selected === "") return wrap(edit, "[", "](https://)", "link text");
|
|
125
|
+
// With text selected, the URL is what is left to type, so select that.
|
|
126
|
+
const value = `${edit.value.slice(0, edit.start)}[${selected}](https://)${edit.value.slice(edit.end)}`;
|
|
127
|
+
const url = edit.start + selected.length + 3;
|
|
128
|
+
return { value, start: url, end: url + "https://".length };
|
|
129
|
+
}
|
|
130
|
+
case "heading":
|
|
131
|
+
return eachLine(edit, (line) => (HEADING_RE.test(line) ? line.replace(HEADING_RE, "") : `## ${line}`));
|
|
132
|
+
case "task":
|
|
133
|
+
return eachLine(edit, (line) => {
|
|
134
|
+
if (TASK_RE.test(line)) return line.replace(TASK_RE, "$1");
|
|
135
|
+
if (BULLET_RE.test(line)) return line.replace(BULLET_RE, "$1- [ ] ");
|
|
136
|
+
return `- [ ] ${line}`;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The one span a formatting change replaced: `before[start, end)` became
|
|
143
|
+
* `text`. The editor inserts just that span through the browser, so ⌘Z
|
|
144
|
+
* undoes the formatting; assigning the whole value would wipe the undo stack.
|
|
145
|
+
*/
|
|
146
|
+
export function replacedSpan(before: string, after: string): { start: number; end: number; text: string } {
|
|
147
|
+
const max = Math.min(before.length, after.length);
|
|
148
|
+
let head = 0;
|
|
149
|
+
while (head < max && before.charCodeAt(head) === after.charCodeAt(head)) head++;
|
|
150
|
+
let tail = 0;
|
|
151
|
+
while (tail < max - head && before.charCodeAt(before.length - 1 - tail) === after.charCodeAt(after.length - 1 - tail)) tail++;
|
|
152
|
+
return { start: head, end: before.length - tail, text: after.slice(head, after.length - tail) };
|
|
153
|
+
}
|
|
@@ -678,7 +678,51 @@ export const SANITIZE_CONFIG: SanitizeConfig = frozen({
|
|
|
678
678
|
* what gets handed over, with {@link SANITIZE_CONFIG}.
|
|
679
679
|
*/
|
|
680
680
|
export function renderNote(purify: Purifier, body: string, index: WikiIndex): string {
|
|
681
|
-
return purify.sanitize(renderMarkdown(body, index), SANITIZE_CONFIG);
|
|
681
|
+
return tldrCard(purify.sanitize(renderMarkdown(body, index), SANITIZE_CONFIG));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// `TLDR`, `TL;DR`, either with the colon inside or after the bold, as search and the graph sheet read it.
|
|
685
|
+
const TLDR_OPEN_RE = /^(\s*<h1>[\s\S]*?<\/h1>)?\s*<p><strong>TL;?DR[ \t]*:?<\/strong>[ \t]*:?[ \t\n]*/i;
|
|
686
|
+
const LIST_TAG_RE = /<(\/?)(?:ul|ol)\b[^>]*>/g;
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Lift a note's opening TLDR into a card.
|
|
690
|
+
*
|
|
691
|
+
* Finalized notes open with a `**TLDR:**` paragraph, often followed by a list
|
|
692
|
+
* of `- [ ]` tasks (the notepad skill's format). The reading view sets that
|
|
693
|
+
* block apart so the summary is the first thing the eye lands on. Only the
|
|
694
|
+
* opening TLDR counts (optionally under a repeated `# Title`); a TLDR further
|
|
695
|
+
* down is ordinary prose.
|
|
696
|
+
*
|
|
697
|
+
* It runs on the *sanitised* HTML and only moves markup that is already
|
|
698
|
+
* there, adding one `div` and one label `span` of its own, so it opens no
|
|
699
|
+
* hole in the three layers above. The task checkboxes keep their order, and
|
|
700
|
+
* with it their `data-weave-task` indices.
|
|
701
|
+
*/
|
|
702
|
+
export function tldrCard(html: string): string {
|
|
703
|
+
const open = TLDR_OPEN_RE.exec(html);
|
|
704
|
+
if (open === null) return html;
|
|
705
|
+
const close = html.indexOf("</p>", open[0].length);
|
|
706
|
+
if (close === -1) return html;
|
|
707
|
+
const lead = open[1] ?? "";
|
|
708
|
+
const summary = html.slice(open[0].length, close);
|
|
709
|
+
let end = close + "</p>".length;
|
|
710
|
+
const list = /^\s*<(?:ul|ol)\b/.exec(html.slice(end));
|
|
711
|
+
if (list !== null) {
|
|
712
|
+
// Balanced, so a nested list stays inside the card with its parent.
|
|
713
|
+
LIST_TAG_RE.lastIndex = end;
|
|
714
|
+
let depth = 0;
|
|
715
|
+
for (let tag = LIST_TAG_RE.exec(html); tag !== null; tag = LIST_TAG_RE.exec(html)) {
|
|
716
|
+
depth += tag[1] === "/" ? -1 : 1;
|
|
717
|
+
if (depth === 0) {
|
|
718
|
+
end = tag.index + tag[0].length;
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (depth !== 0) return html;
|
|
723
|
+
}
|
|
724
|
+
const items = html.slice(close + "</p>".length, end);
|
|
725
|
+
return `${lead}\n<div class="weave-tldr"><p><span class="weave-tldr-label">TLDR</span>${summary}</p>${items}\n</div>${html.slice(end)}`;
|
|
682
726
|
}
|
|
683
727
|
|
|
684
728
|
// --- reading a click back out of the DOM --------------------------------------------------
|
|
@@ -845,6 +889,23 @@ export const DONE_LABEL = "Done";
|
|
|
845
889
|
export const SAVE_LABEL = "Save";
|
|
846
890
|
export const SAVING_LABEL = "Saving…";
|
|
847
891
|
export const EDITOR_ARIA_LABEL = "Note body";
|
|
892
|
+
export const EDITOR_PLACEHOLDER = "Start writing…";
|
|
893
|
+
/** Names the `## Raw` section beneath the editor, and its own text box. */
|
|
894
|
+
export const RAW_TAIL_ARIA_LABEL = "Raw notes";
|
|
895
|
+
export const RAW_EDITOR_ARIA_LABEL = "Raw notes text";
|
|
896
|
+
export const RAW_TITLE = "Raw";
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* The quiet line beside the Raw toggle. Closed, it says how much is folded
|
|
900
|
+
* away; open, it says what saving does with the words.
|
|
901
|
+
*/
|
|
902
|
+
export function rawSummary(entries: string, open: boolean): string {
|
|
903
|
+
if (open) return "Your words as captured. Edits here are saved with the note.";
|
|
904
|
+
const count = entries.match(/^<!-- appended\b/gm)?.length ?? 0;
|
|
905
|
+
if (count > 0) return `${count} ${count === 1 ? "entry" : "entries"} captured`;
|
|
906
|
+
const lines = entries === "" ? 0 : entries.split("\n").length;
|
|
907
|
+
return `${lines} ${lines === 1 ? "line" : "lines"}`;
|
|
908
|
+
}
|
|
848
909
|
export const DISCARD_PROMPT = "Discard unsaved changes to this note?";
|
|
849
910
|
|
|
850
911
|
/** The note header: title, provenance badge, relative time, tags. */
|
|
@@ -860,6 +921,24 @@ export interface NoteHeaderView {
|
|
|
860
921
|
readonly created: string;
|
|
861
922
|
readonly createdIso: string;
|
|
862
923
|
readonly tags: readonly string[];
|
|
924
|
+
/** Who wrote it, as a sentence: "You wrote this". */
|
|
925
|
+
readonly byline: string;
|
|
926
|
+
/** Where it lives: "Vault / projects", or "Vault" at the root. */
|
|
927
|
+
readonly folder: string;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** The byline under the title, in plain words rather than a provenance code. */
|
|
931
|
+
export function noteByline(source: WireNoteSource): string {
|
|
932
|
+
if (source === "human") return "You wrote this";
|
|
933
|
+
if (source === "agent") return "Agent wrote this";
|
|
934
|
+
return "Generated by pi-weave";
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
export const VAULT_LABEL = "Vault";
|
|
938
|
+
|
|
939
|
+
/** The folder crumb above the title: the slug's directories under the vault. */
|
|
940
|
+
export function noteFolder(slug: string): string {
|
|
941
|
+
return [VAULT_LABEL, ...slug.split("/").slice(0, -1)].join(" / ");
|
|
863
942
|
}
|
|
864
943
|
|
|
865
944
|
/**
|
|
@@ -885,6 +964,8 @@ export function noteHeader(note: ViewNote, now: number): NoteHeaderView {
|
|
|
885
964
|
created: relTime(note.created, now),
|
|
886
965
|
createdIso: note.created,
|
|
887
966
|
tags: note.tags,
|
|
967
|
+
byline: noteByline(note.source),
|
|
968
|
+
folder: noteFolder(note.slug),
|
|
888
969
|
};
|
|
889
970
|
}
|
|
890
971
|
|