pi-weave 0.3.8 → 0.3.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 +17 -16
- 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/dist/app.js +183 -52
- package/src/web/client/graph/Graph.tsx +23 -2
- package/src/web/client/graph/column.model.ts +1 -1
- package/src/web/client/graph/graph.model.ts +69 -7
- package/src/web/client/graph/renderer.ts +19 -3
- package/src/web/client/note/Note.tsx +171 -20
- package/src/web/client/note/Replay.tsx +128 -0
- package/src/web/client/note/drafts.ts +50 -9
- package/src/web/client/note/note.model.ts +17 -0
- package/src/web/client/note/replay.model.ts +173 -0
- 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 +34 -10
- package/src/web/client/shell/keys.model.ts +20 -7
- package/src/web/client/shell/theme.ts +118 -1
- package/src/web/client/shell/workspace.css.ts +4 -0
- 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/view.ts +10 -4
- package/src/web/shared/wire.ts +74 -1
- 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,128 @@
|
|
|
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, 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 track shows no per-version dots: they would touch. */
|
|
56
|
+
const MAX_TICKS = 80;
|
|
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
|
+
onIndex: (index: number) => void;
|
|
69
|
+
onPlay: () => void;
|
|
70
|
+
}) {
|
|
71
|
+
const { revisions, index, playing, enabled, trimmed, error } = props.state;
|
|
72
|
+
const track = useRef<HTMLDivElement>(null);
|
|
73
|
+
const last = revisions === null ? 0 : revisions.length - 1;
|
|
74
|
+
// CSSOM, not a style attribute (the CSP forbids those): how far along the
|
|
75
|
+
// track the filled part reaches.
|
|
76
|
+
useLayoutEffect(() => {
|
|
77
|
+
track.current?.style.setProperty("--weave-replay-at", `${last === 0 ? 100 : (index / last) * 100}%`);
|
|
78
|
+
}, [index, last]);
|
|
79
|
+
if (error !== null) return <div class="weave-replay weave-replay-note" role="status">{error}</div>;
|
|
80
|
+
if (revisions === null) return <div class="weave-replay weave-replay-note" role="status">Loading history…</div>;
|
|
81
|
+
const step = replayStep(revisions, index);
|
|
82
|
+
if (step === null) return <div class="weave-replay weave-replay-note" role="status">{enabled ? HISTORY_EMPTY : HISTORY_OFF_HINT}</div>;
|
|
83
|
+
return (
|
|
84
|
+
<div class="weave-replay" role="group" aria-label="Note history">
|
|
85
|
+
<p class="weave-replay-caption" aria-live="polite">
|
|
86
|
+
<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>
|
|
87
|
+
<span class="weave-replay-position">Version {step.position}</span>
|
|
88
|
+
<span class={`weave-replay-who weave-replay-${step.by}`}>{step.action}</span>
|
|
89
|
+
<span class="weave-replay-time" title={step.atIso}>{step.at}</span>
|
|
90
|
+
{step.change === "" ? null : <span class="weave-replay-change">{step.change}</span>}
|
|
91
|
+
{trimmed && index === 0 ? <span class="weave-replay-time">{HISTORY_TRIMMED}</span> : null}
|
|
92
|
+
</p>
|
|
93
|
+
<div class="weave-replay-controls">
|
|
94
|
+
<button type="button" aria-label="Previous version" disabled={index <= 0} onClick={() => props.onIndex(index - 1)}>
|
|
95
|
+
<Icon d="m11 4-5 5 5 5" />
|
|
96
|
+
</button>
|
|
97
|
+
<button type="button" class="weave-replay-play" aria-label={playing ? "Pause replay" : "Play replay"} disabled={last === 0} onClick={props.onPlay}>
|
|
98
|
+
{playing ? <Icon d="M5 4h3v10H5zM10 4h3v10h-3z" fill /> : <Icon d="M6 4v10l8-5z" fill />}
|
|
99
|
+
</button>
|
|
100
|
+
<button type="button" aria-label="Next version" disabled={index >= last} onClick={() => props.onIndex(index + 1)}>
|
|
101
|
+
<Icon d="m7 4 5 5-5 5" />
|
|
102
|
+
</button>
|
|
103
|
+
<div class="weave-replay-track" ref={track}>
|
|
104
|
+
{revisions.length > MAX_TICKS || last === 0 ? null : (
|
|
105
|
+
<span class="weave-replay-ticks" aria-hidden="true">
|
|
106
|
+
{revisions.map((revision, at) => (
|
|
107
|
+
<span key={at} class={`weave-replay-tick weave-replay-${revision.by}${at > index ? " weave-replay-ahead" : ""}`} />
|
|
108
|
+
))}
|
|
109
|
+
</span>
|
|
110
|
+
)}
|
|
111
|
+
<input
|
|
112
|
+
class="weave-replay-slider"
|
|
113
|
+
type="range"
|
|
114
|
+
aria-label="Version"
|
|
115
|
+
aria-valuetext={`${step.position}: ${step.action}`}
|
|
116
|
+
min={0}
|
|
117
|
+
max={last}
|
|
118
|
+
step={1}
|
|
119
|
+
value={index}
|
|
120
|
+
disabled={last === 0}
|
|
121
|
+
onInput={(event) => props.onIndex(Number(event.currentTarget.value))}
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
{enabled ? null : <p class="weave-replay-hint">{HISTORY_OFF_HINT}</p>}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -1,11 +1,29 @@
|
|
|
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;
|
|
7
18
|
}
|
|
8
19
|
|
|
20
|
+
/** How a save should treat the `## Raw` tail; `raw` means the person edited it. */
|
|
21
|
+
export interface SaveOptions {
|
|
22
|
+
raw?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type SaveNote = (slug: string, body: string, options?: SaveOptions) => Promise<boolean>;
|
|
26
|
+
|
|
9
27
|
interface TaskSource {
|
|
10
28
|
body: string;
|
|
11
29
|
version: number;
|
|
@@ -16,17 +34,20 @@ interface TaskSource {
|
|
|
16
34
|
export interface DraftStore {
|
|
17
35
|
get(slug: string): Readonly<NoteDraft> | null;
|
|
18
36
|
subscribe(listener: () => void): () => void;
|
|
37
|
+
/** Open an editor on a full note body; the `## Raw` tail is split off. */
|
|
19
38
|
open(slug: string, body: string): void;
|
|
20
39
|
edit(slug: string, body: string): void;
|
|
40
|
+
/** Edit the `## Raw` entries. */
|
|
41
|
+
editRaw(slug: string, raw: string): void;
|
|
21
42
|
discard(slug: string): void;
|
|
22
43
|
isDirty(slug: string): boolean;
|
|
23
44
|
dirtySlugs(): string[];
|
|
24
|
-
save(slug: string, onSave:
|
|
45
|
+
save(slug: string, onSave: SaveNote): Promise<void>;
|
|
25
46
|
nextLoadVersion(): number;
|
|
26
47
|
getTaskBody(slug: string, fallback: string): string;
|
|
27
48
|
taskBody(slug: string, serverBody: string, loadVersion: number): string;
|
|
28
49
|
isTaskSaving(slug: string): boolean;
|
|
29
|
-
saveTask(slug: string, body: string, onSave:
|
|
50
|
+
saveTask(slug: string, body: string, onSave: SaveNote): Promise<boolean>;
|
|
30
51
|
clearTaskSources(slugs: readonly string[]): void;
|
|
31
52
|
}
|
|
32
53
|
|
|
@@ -36,6 +57,7 @@ export function createDraftStore(): DraftStore {
|
|
|
36
57
|
const listeners = new Set<() => void>();
|
|
37
58
|
let loadVersion = 0;
|
|
38
59
|
const notify = (): void => listeners.forEach((listener) => listener());
|
|
60
|
+
const dirty = (draft: NoteDraft): boolean => draft.body !== draft.baseline || draft.raw !== draft.rawBaseline;
|
|
39
61
|
|
|
40
62
|
return {
|
|
41
63
|
get: (slug) => drafts.get(slug) ?? null,
|
|
@@ -45,7 +67,9 @@ export function createDraftStore(): DraftStore {
|
|
|
45
67
|
},
|
|
46
68
|
open(slug, body) {
|
|
47
69
|
if (drafts.has(slug)) return;
|
|
48
|
-
|
|
70
|
+
const { head, tail } = splitRawTail(body);
|
|
71
|
+
const { entries } = splitRawOpening(tail);
|
|
72
|
+
drafts.set(slug, { body: head, baseline: head, tail, raw: entries, rawBaseline: entries, saving: false });
|
|
49
73
|
notify();
|
|
50
74
|
},
|
|
51
75
|
edit(slug, body) {
|
|
@@ -54,37 +78,54 @@ export function createDraftStore(): DraftStore {
|
|
|
54
78
|
draft.body = body;
|
|
55
79
|
notify();
|
|
56
80
|
},
|
|
81
|
+
editRaw(slug, raw) {
|
|
82
|
+
const draft = drafts.get(slug);
|
|
83
|
+
if (draft === undefined || draft.raw === raw) return;
|
|
84
|
+
draft.raw = raw;
|
|
85
|
+
notify();
|
|
86
|
+
},
|
|
57
87
|
discard(slug) {
|
|
58
88
|
if (drafts.delete(slug)) notify();
|
|
59
89
|
},
|
|
60
90
|
isDirty(slug) {
|
|
61
91
|
const draft = drafts.get(slug);
|
|
62
|
-
return draft !== undefined && draft
|
|
92
|
+
return draft !== undefined && dirty(draft);
|
|
63
93
|
},
|
|
64
94
|
dirtySlugs() {
|
|
65
|
-
return [...drafts].filter(([, draft]) => draft
|
|
95
|
+
return [...drafts].filter(([, draft]) => dirty(draft)).map(([slug]) => slug);
|
|
66
96
|
},
|
|
67
97
|
async save(slug, onSave) {
|
|
68
98
|
const draft = drafts.get(slug);
|
|
69
99
|
if (draft === undefined || draft.saving || taskSources.get(slug)?.saving) return;
|
|
70
100
|
const sent = draft.body;
|
|
101
|
+
const sentRaw = draft.raw;
|
|
102
|
+
// An untouched Raw is sent as it was and kept by the server, so an
|
|
103
|
+
// entry dictated while this editor was open survives the save. An
|
|
104
|
+
// edited one is sent as the person left it, and replaces the tail.
|
|
105
|
+
const rawEdited = sentRaw !== draft.rawBaseline;
|
|
106
|
+
const tail = rawEdited ? joinRawOpening(splitRawOpening(draft.tail).opening, sentRaw) : draft.tail;
|
|
107
|
+
const full = joinRawTail(sent, tail);
|
|
71
108
|
draft.saving = true;
|
|
72
109
|
notify();
|
|
73
110
|
let ok = false;
|
|
74
111
|
try {
|
|
75
|
-
ok = await onSave(slug,
|
|
112
|
+
ok = await onSave(slug, full, rawEdited ? { raw: true } : undefined);
|
|
76
113
|
} finally {
|
|
77
114
|
// Discard/reopen during an in-flight request gives the new draft a new identity.
|
|
78
115
|
if (drafts.get(slug) === draft) {
|
|
79
116
|
const source = taskSources.get(slug);
|
|
80
117
|
if (ok && source !== undefined) {
|
|
81
|
-
source.body =
|
|
118
|
+
source.body = full;
|
|
82
119
|
source.version = ++loadVersion;
|
|
83
120
|
delete source.pending;
|
|
84
121
|
}
|
|
85
|
-
if (ok && draft.body === sent) drafts.delete(slug);
|
|
122
|
+
if (ok && draft.body === sent && draft.raw === sentRaw) drafts.delete(slug);
|
|
86
123
|
else {
|
|
87
|
-
if (ok)
|
|
124
|
+
if (ok) {
|
|
125
|
+
draft.baseline = sent;
|
|
126
|
+
draft.rawBaseline = sentRaw;
|
|
127
|
+
draft.tail = tail;
|
|
128
|
+
}
|
|
88
129
|
draft.saving = false;
|
|
89
130
|
}
|
|
90
131
|
notify();
|
|
@@ -845,6 +845,23 @@ export const DONE_LABEL = "Done";
|
|
|
845
845
|
export const SAVE_LABEL = "Save";
|
|
846
846
|
export const SAVING_LABEL = "Saving…";
|
|
847
847
|
export const EDITOR_ARIA_LABEL = "Note body";
|
|
848
|
+
export const EDITOR_PLACEHOLDER = "Start writing…";
|
|
849
|
+
/** Names the `## Raw` section beneath the editor, and its own text box. */
|
|
850
|
+
export const RAW_TAIL_ARIA_LABEL = "Raw notes";
|
|
851
|
+
export const RAW_EDITOR_ARIA_LABEL = "Raw notes text";
|
|
852
|
+
export const RAW_TITLE = "Raw";
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* The quiet line beside the Raw toggle. Closed, it says how much is folded
|
|
856
|
+
* away; open, it says what saving does with the words.
|
|
857
|
+
*/
|
|
858
|
+
export function rawSummary(entries: string, open: boolean): string {
|
|
859
|
+
if (open) return "Your words as captured. Edits here are saved with the note.";
|
|
860
|
+
const count = entries.match(/^<!-- appended\b/gm)?.length ?? 0;
|
|
861
|
+
if (count > 0) return `${count} ${count === 1 ? "entry" : "entries"} captured`;
|
|
862
|
+
const lines = entries === "" ? 0 : entries.split("\n").length;
|
|
863
|
+
return `${lines} ${lines === 1 ? "line" : "lines"}`;
|
|
864
|
+
}
|
|
848
865
|
export const DISCARD_PROMPT = "Discard unsaved changes to this note?";
|
|
849
866
|
|
|
850
867
|
/** The note header: title, provenance badge, relative time, tags. */
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay: the pure half of the note history slider.
|
|
3
|
+
*
|
|
4
|
+
* The server sends every saved revision of a note, oldest first (see
|
|
5
|
+
* `src/core/history.ts`). This module decides what one step of the slider
|
|
6
|
+
* says ("Agent rewrote the summary · 10:05 · +120 −40") and which text that
|
|
7
|
+
* step added, so the column can highlight it.
|
|
8
|
+
*
|
|
9
|
+
* ## Highlighting through Markdown
|
|
10
|
+
*
|
|
11
|
+
* The added text is found in the Markdown source, but it has to be shown in
|
|
12
|
+
* the rendered HTML. So the source is marked with two private-use characters,
|
|
13
|
+
* {@link MARK_OPEN} and {@link MARK_CLOSE}, before it is rendered, and the
|
|
14
|
+
* column turns the marked text nodes into `<mark>` elements afterwards
|
|
15
|
+
* ({@link splitMarked}). The markers are characters, not tags, so they pass
|
|
16
|
+
* through the renderer and the sanitiser as plain text and cannot introduce
|
|
17
|
+
* markup of their own.
|
|
18
|
+
*
|
|
19
|
+
* A marker must not change what the Markdown means, so {@link markChange}
|
|
20
|
+
* puts one pair on each changed line, after any block syntax (`## `, `- `,
|
|
21
|
+
* `> `), skips fence and rule lines, and widens a range to cover a whole link
|
|
22
|
+
* rather than splitting `[[target]]` or `(url)`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { HistoryRevision } from "../../shared/wire";
|
|
26
|
+
|
|
27
|
+
export const MARK_OPEN = "";
|
|
28
|
+
export const MARK_CLOSE = "";
|
|
29
|
+
|
|
30
|
+
/** Milliseconds between steps while playing. */
|
|
31
|
+
export const PLAY_INTERVAL_MS = 900;
|
|
32
|
+
|
|
33
|
+
/** The smallest prefix/suffix-preserving edit from `from` to `to` (same rule as core's log). */
|
|
34
|
+
export function changedRange(from: string, to: string): { start: number; end: number; removed: number } {
|
|
35
|
+
const max = Math.min(from.length, to.length);
|
|
36
|
+
let head = 0;
|
|
37
|
+
while (head < max && from.charCodeAt(head) === to.charCodeAt(head)) head++;
|
|
38
|
+
let tail = 0;
|
|
39
|
+
while (tail < max - head && from.charCodeAt(from.length - 1 - tail) === to.charCodeAt(to.length - 1 - tail)) tail++;
|
|
40
|
+
return { start: head, end: to.length - tail, removed: from.length - tail - head };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const BLOCK_PREFIX_RE = /^[ \t]*(?:#{1,6}[ \t]+|>[ \t]?|(?:[*+-]|\d{1,9}[.)])[ \t]+(?:\[[ xX]\][ \t]+)?)*/;
|
|
44
|
+
const SKIP_LINE_RE = /^[ \t]*(?:`{3,}|~{3,}|<!--|(?:[-*_][ \t]*){3,}$)/;
|
|
45
|
+
const LINK_RE = /!?\[\[[^\]\n]*\]\]|!?\[[^\]\n]*\]\([^)\n]*\)|<[a-z][a-z0-9+.-]*:[^>\s]*>/gi;
|
|
46
|
+
|
|
47
|
+
/** Mark the text `body` gained over `previous` (the whole body for the first revision). */
|
|
48
|
+
export function markChange(previous: string | null, body: string): string {
|
|
49
|
+
const { start, end } = changedRange(previous ?? "", body);
|
|
50
|
+
if (start >= end) return body;
|
|
51
|
+
const ranges: Array<[number, number]> = [];
|
|
52
|
+
let lineStart = body.lastIndexOf("\n", start - 1) + 1;
|
|
53
|
+
while (lineStart < end) {
|
|
54
|
+
const newline = body.indexOf("\n", lineStart);
|
|
55
|
+
const lineEnd = newline === -1 ? body.length : newline;
|
|
56
|
+
const line = body.slice(lineStart, lineEnd);
|
|
57
|
+
if (!SKIP_LINE_RE.test(line)) {
|
|
58
|
+
const prefix = (BLOCK_PREFIX_RE.exec(line) as RegExpExecArray)[0].length;
|
|
59
|
+
let from = Math.max(start - lineStart, prefix);
|
|
60
|
+
let to = Math.min(end, lineEnd) - lineStart;
|
|
61
|
+
for (const link of line.matchAll(LINK_RE)) {
|
|
62
|
+
const linkEnd = (link.index as number) + link[0].length;
|
|
63
|
+
if (from > (link.index as number) && from < linkEnd) from = link.index as number;
|
|
64
|
+
if (to > (link.index as number) && to < linkEnd) to = linkEnd;
|
|
65
|
+
}
|
|
66
|
+
if (line.slice(from, to).trim() !== "") ranges.push([lineStart + from, lineStart + to]);
|
|
67
|
+
}
|
|
68
|
+
lineStart = lineEnd + 1;
|
|
69
|
+
}
|
|
70
|
+
let marked = body;
|
|
71
|
+
for (const [from, to] of ranges.reverse()) {
|
|
72
|
+
marked = marked.slice(0, from) + MARK_OPEN + marked.slice(from, to) + MARK_CLOSE + marked.slice(to);
|
|
73
|
+
}
|
|
74
|
+
return marked;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Split one text node's text at the markers. `inside` carries over from the
|
|
79
|
+
* previous text node, because a marked range can span elements
|
|
80
|
+
* (`plain **bold** plain`).
|
|
81
|
+
*/
|
|
82
|
+
export function splitMarked(text: string, inside: boolean): { parts: Array<{ text: string; marked: boolean }>; inside: boolean } {
|
|
83
|
+
const parts: Array<{ text: string; marked: boolean }> = [];
|
|
84
|
+
let current = "";
|
|
85
|
+
let state = inside;
|
|
86
|
+
for (const char of text) {
|
|
87
|
+
if (char === MARK_OPEN || char === MARK_CLOSE) {
|
|
88
|
+
if (current !== "") parts.push({ text: current, marked: state });
|
|
89
|
+
current = "";
|
|
90
|
+
state = char === MARK_OPEN;
|
|
91
|
+
} else {
|
|
92
|
+
current += char;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (current !== "") parts.push({ text: current, marked: state });
|
|
96
|
+
return { parts, inside: state };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const WHO: Record<HistoryRevision["by"], string> = {
|
|
100
|
+
human: "You",
|
|
101
|
+
agent: "Agent",
|
|
102
|
+
outside: "Outside edit",
|
|
103
|
+
system: "pi-weave",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const WHAT: Record<string, string> = {
|
|
107
|
+
create: "created the note",
|
|
108
|
+
edit: "edited",
|
|
109
|
+
append: "added to the note",
|
|
110
|
+
raw: "added words to Raw",
|
|
111
|
+
finalize: "rewrote the summary",
|
|
112
|
+
generate: "generated the note",
|
|
113
|
+
repair: "fixed links",
|
|
114
|
+
outside: "changed outside pi-weave",
|
|
115
|
+
baseline: "earliest saved version",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** One step of the slider, as the column shows it. */
|
|
119
|
+
export interface ReplayStep {
|
|
120
|
+
/** "3 of 12". */
|
|
121
|
+
position: string;
|
|
122
|
+
/** "Agent rewrote the summary". */
|
|
123
|
+
action: string;
|
|
124
|
+
/** Short local time; the full ISO string goes in `atIso`. */
|
|
125
|
+
at: string;
|
|
126
|
+
atIso: string;
|
|
127
|
+
/** "+120 −40", or "" when nothing changed in the text. */
|
|
128
|
+
change: string;
|
|
129
|
+
/** The body to render, with the added text marked. */
|
|
130
|
+
marked: string;
|
|
131
|
+
by: HistoryRevision["by"];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function shortTime(iso: string, previous: string | null): string {
|
|
135
|
+
const date = new Date(iso);
|
|
136
|
+
if (Number.isNaN(date.getTime())) return iso;
|
|
137
|
+
const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
138
|
+
const sameDay = previous !== null && new Date(previous).toDateString() === date.toDateString();
|
|
139
|
+
return sameDay ? time : `${date.toLocaleDateString(undefined, { month: "short", day: "numeric" })}, ${time}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Describe revision `index`. Clamped, so a stale index after a refetch still shows something. */
|
|
143
|
+
export function replayStep(revisions: readonly HistoryRevision[], index: number): ReplayStep | null {
|
|
144
|
+
if (revisions.length === 0) return null;
|
|
145
|
+
const i = Math.max(0, Math.min(revisions.length - 1, index));
|
|
146
|
+
const revision = revisions[i] as HistoryRevision;
|
|
147
|
+
const previous = i === 0 ? null : (revisions[i - 1] as HistoryRevision);
|
|
148
|
+
const range = changedRange(previous?.body ?? "", revision.body);
|
|
149
|
+
const added = range.end - range.start;
|
|
150
|
+
const change = [added > 0 ? `+${added}` : "", range.removed > 0 ? `−${range.removed}` : ""].filter(Boolean).join(" ");
|
|
151
|
+
const what = WHAT[revision.op] ?? revision.op;
|
|
152
|
+
return {
|
|
153
|
+
position: `${i + 1} of ${revisions.length}`,
|
|
154
|
+
action: revision.op === "baseline" ? what[0]!.toUpperCase() + what.slice(1) : `${WHO[revision.by]} ${what}`,
|
|
155
|
+
at: shortTime(revision.at, previous?.at ?? null),
|
|
156
|
+
atIso: revision.at,
|
|
157
|
+
change,
|
|
158
|
+
marked: markChange(previous?.body ?? null, revision.body),
|
|
159
|
+
by: revision.by,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The next index while playing, or null at the end. */
|
|
164
|
+
export function nextStep(index: number, count: number): number | null {
|
|
165
|
+
return index + 1 < count ? index + 1 : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const HISTORY_LABEL = "History";
|
|
169
|
+
export const HISTORY_CLOSE_LABEL = "Close history";
|
|
170
|
+
export const HISTORY_OFF_HINT = "History is off, so new changes are not being saved. Turn it on in Settings → Editor.";
|
|
171
|
+
/** Shown on the oldest version kept, once older ones were dropped (the file holds at most 500). */
|
|
172
|
+
export const HISTORY_TRIMMED = "Older versions were trimmed";
|
|
173
|
+
export const HISTORY_EMPTY = "No saved versions yet. Changes are saved from now on.";
|
|
@@ -19,6 +19,8 @@ import type { GraphPayload } from "../../shared/wire";
|
|
|
19
19
|
import { CONTEXT_EMPTY } from "./shell.model";
|
|
20
20
|
import { Icon } from "../tree/Tree";
|
|
21
21
|
import { ResizeHandle } from "./ResizeHandle";
|
|
22
|
+
import { TrailStrip } from "../trail/TrailStrip";
|
|
23
|
+
import type { TrailTurnView } from "../trail/trail.model";
|
|
22
24
|
|
|
23
25
|
export interface ContextRailProps {
|
|
24
26
|
graph: GraphPayload | null;
|
|
@@ -26,6 +28,10 @@ export interface ContextRailProps {
|
|
|
26
28
|
onSelect: (id: string, newTab?: boolean) => void;
|
|
27
29
|
tagHeight: number;
|
|
28
30
|
onTagResize: (height: number) => void;
|
|
31
|
+
/** The recall trail's turns, newest first. Omitted means no strip. */
|
|
32
|
+
trail?: readonly TrailTurnView[];
|
|
33
|
+
trailTurn?: number | null;
|
|
34
|
+
onReplay?: (turn: number) => void;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
function Row({ row, onSelect }: { row: ContextRow; onSelect: (newTab: boolean) => void }) {
|
|
@@ -106,6 +112,7 @@ export function ContextRail(props: ContextRailProps) {
|
|
|
106
112
|
const tags = railTagsView(model.tags, toggles);
|
|
107
113
|
return (
|
|
108
114
|
<div class="weave-rail" aria-label={CONTEXT_EMPTY.title}>
|
|
115
|
+
{props.trail === undefined ? null : <TrailStrip turns={props.trail} shownTurn={props.trailTurn ?? null} onSelect={props.onSelect} onReplay={(turn) => props.onReplay?.(turn)} />}
|
|
109
116
|
{model.empty === null ? null : <p class="weave-ctx-empty">{model.empty}</p>}
|
|
110
117
|
{model.groups.map((group) => {
|
|
111
118
|
const view = railSectionView(group, toggles);
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { useState } from "preact/hooks";
|
|
1
|
+
import { useEffect, useState } from "preact/hooks";
|
|
2
2
|
import type { ComponentChildren } from "preact";
|
|
3
3
|
import type { WorkspaceLayout } from "../../shared/workspace";
|
|
4
4
|
import type { Preferences } from "../../shared/preferences";
|
|
5
5
|
import { ACCENTS, THEMES } from "../../shared/themes";
|
|
6
6
|
import type { AccentChoice, PaletteChoice } from "../../shared/themes";
|
|
7
|
+
import { historySettings } from "../api";
|
|
8
|
+
import { fetchJson } from "../api.dom";
|
|
7
9
|
import { ForceTuner } from "../graph/ForceTuner";
|
|
8
10
|
import { useFocusTrap } from "./FocusTrap";
|
|
9
11
|
import { keyHelp } from "./keys.model";
|
|
@@ -20,6 +22,23 @@ export function Settings(props: {
|
|
|
20
22
|
const [category, setCategory] = useState<typeof CATEGORIES[number]>("General");
|
|
21
23
|
const [refreshCount, setRefreshCount] = useState(0);
|
|
22
24
|
const trap = useFocusTrap();
|
|
25
|
+
// Note history lives in the vault, not in workspace preferences, so the
|
|
26
|
+
// agent's writes respect it too. `null` until the server has answered.
|
|
27
|
+
const [history, setHistory] = useState<boolean | null>(null);
|
|
28
|
+
// One change at a time: the control is disabled until the server answers,
|
|
29
|
+
// so a late response can never overwrite a newer choice.
|
|
30
|
+
const [historySaving, setHistorySaving] = useState(false);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
void historySettings(fetchJson).then((result) => { if (result.ok) setHistory(result.data.enabled); });
|
|
33
|
+
}, []);
|
|
34
|
+
const toggleHistory = (enabled: boolean): void => {
|
|
35
|
+
setHistory(enabled);
|
|
36
|
+
setHistorySaving(true);
|
|
37
|
+
void historySettings(fetchJson, enabled).then((result) => {
|
|
38
|
+
setHistory(result.ok ? result.data.enabled : !enabled);
|
|
39
|
+
setHistorySaving(false);
|
|
40
|
+
});
|
|
41
|
+
};
|
|
23
42
|
const p = props.layout.preferences;
|
|
24
43
|
const update = (values: Partial<Preferences>): void => props.onChange({ ...props.layout, preferences: { ...p, ...values } });
|
|
25
44
|
return <div class="weave-scrim weave-settings-scrim" onClick={props.onClose}>
|
|
@@ -55,6 +74,7 @@ export function Settings(props: {
|
|
|
55
74
|
<Row name="Spellcheck" hint="Use your browser or device’s spellchecker while editing."><input type="checkbox" checked={p.spellcheck} onChange={(event) => update({ spellcheck: event.currentTarget.checked })} /></Row>
|
|
56
75
|
<Row name="Readable line length" hint="Keep note text in a comfortable reading column."><input type="checkbox" checked={p.readable} onChange={(event) => update({ readable: event.currentTarget.checked })} /></Row>
|
|
57
76
|
<Row name="Open notes in editing mode" hint="Newly opened notes start in the editor. Existing drafts are preserved."><input type="checkbox" checked={p.defaultEdit} onChange={(event) => update({ defaultEdit: event.currentTarget.checked })} /></Row>
|
|
77
|
+
<Row name="Note history" hint="Save a copy of a note after every change, by you or your agent, so History can replay how it was written. Turning it off keeps what is already saved."><input type="checkbox" disabled={history === null || historySaving} checked={history ?? false} onChange={(event) => toggleHistory(event.currentTarget.checked)} /></Row>
|
|
58
78
|
</> : null}
|
|
59
79
|
{category === "Files" ? <>
|
|
60
80
|
<Row name="On startup" hint="Choose which tabs appear when you reopen this workspace."><select aria-label="On startup" value={p.startup} onChange={(event) => update({ startup: event.currentTarget.value as Preferences["startup"] })}><option value="restore">Restore open tabs</option><option value="empty">Start with an empty tab</option></select></Row>
|