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,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recall trail, decided (issue #78).
|
|
3
|
+
*
|
|
4
|
+
* `GET /api/trail` lists the notes the agent searched, read and wrote. This
|
|
5
|
+
* module turns that list into the two things the workspace draws:
|
|
6
|
+
*
|
|
7
|
+
* - {@link trailMarks}: which nodes glow right now, how, and how strongly.
|
|
8
|
+
* The glow fades over {@link TRAIL_FADE_MS}; a replayed turn relights its
|
|
9
|
+
* steps one after another from the moment it was clicked.
|
|
10
|
+
* - {@link trailTurns}: the "This turn" strip — each turn's steps in order,
|
|
11
|
+
* worded as `searched "auth" → read Auth migration → appended Auth migration`.
|
|
12
|
+
*
|
|
13
|
+
* Pure, DOM-free and clock-free (every function takes `now`), like the other
|
|
14
|
+
* `*.model.ts` files, so the whole feature is covered by unit tests.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { GraphPayload, TrailPayload, WireTrailEvent, WireTrailKind } from "../../shared/wire";
|
|
18
|
+
|
|
19
|
+
/** How long a glow takes to fade out completely. */
|
|
20
|
+
export const TRAIL_FADE_MS = 60_000;
|
|
21
|
+
|
|
22
|
+
/** How often the fade repaints while something is glowing. */
|
|
23
|
+
export const TRAIL_TICK_MS = 1_000;
|
|
24
|
+
|
|
25
|
+
/** In a replay, the gap between one step lighting and the next. */
|
|
26
|
+
export const REPLAY_STEP_MS = 700;
|
|
27
|
+
|
|
28
|
+
/** How many turns the strip lists. */
|
|
29
|
+
export const TRAIL_TURNS_SHOWN = 5;
|
|
30
|
+
|
|
31
|
+
/** One glowing node. `strength` runs 1 (just happened) → 0 (faded). */
|
|
32
|
+
export interface TrailMark {
|
|
33
|
+
kind: WireTrailKind;
|
|
34
|
+
strength: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A replay in progress: which turn, and when it was clicked. */
|
|
38
|
+
export interface TrailReplay {
|
|
39
|
+
turn: number;
|
|
40
|
+
startedAt: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A write outranks a read, which outranks a search hit. */
|
|
44
|
+
const RANK: Readonly<Record<WireTrailKind, number>> = { searched: 0, read: 1, wrote: 2 };
|
|
45
|
+
|
|
46
|
+
const NO_MARKS: ReadonlyMap<string, TrailMark> = new Map();
|
|
47
|
+
|
|
48
|
+
/** When each event lights, or `null` if it is not part of what is shown. */
|
|
49
|
+
function lightTimes(trail: TrailPayload, replay: TrailReplay | null): Array<{ event: WireTrailEvent; at: number }> {
|
|
50
|
+
if (replay === null) return trail.events.map((event) => ({ event, at: Date.parse(event.at) }));
|
|
51
|
+
return trail.events
|
|
52
|
+
.filter((event) => event.turn === replay.turn)
|
|
53
|
+
.map((event, index) => ({ event, at: replay.startedAt + index * REPLAY_STEP_MS }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Node id → glow. A node touched several ways in one turn shows the strongest
|
|
58
|
+
* kind (wrote > read > searched) at the freshest strength; a later turn's
|
|
59
|
+
* touch replaces an earlier turn's, so a note read now is not still shown as
|
|
60
|
+
* written a turn ago.
|
|
61
|
+
*
|
|
62
|
+
* Live, an event stamped slightly in the future (clock skew) glows at full
|
|
63
|
+
* strength; in a replay a step whose moment has not come yet stays dark.
|
|
64
|
+
*/
|
|
65
|
+
export function trailMarks(trail: TrailPayload | null, now: number, replay: TrailReplay | null = null): ReadonlyMap<string, TrailMark> {
|
|
66
|
+
if (trail === null) return NO_MARKS;
|
|
67
|
+
const marks = new Map<string, TrailMark>();
|
|
68
|
+
const turnOf = new Map<string, number>();
|
|
69
|
+
for (const { event, at } of lightTimes(trail, replay)) {
|
|
70
|
+
const age = now - at;
|
|
71
|
+
if (replay !== null && age < 0) continue;
|
|
72
|
+
const strength = Math.min(1, 1 - age / TRAIL_FADE_MS);
|
|
73
|
+
if (!(strength > 0)) continue;
|
|
74
|
+
for (const slug of event.slugs) {
|
|
75
|
+
const id = `note:${slug}`;
|
|
76
|
+
// A newer turn's touch replaces an older turn's; within a turn the strongest kind wins.
|
|
77
|
+
const existing = turnOf.get(id) === event.turn ? marks.get(id) : undefined;
|
|
78
|
+
const kind = existing === undefined || RANK[event.kind] > RANK[existing.kind] ? event.kind : existing.kind;
|
|
79
|
+
marks.set(id, { kind, strength: Math.max(strength, existing?.strength ?? 0) });
|
|
80
|
+
turnOf.set(id, event.turn);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return marks.size === 0 ? NO_MARKS : marks;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether anything is still glowing or still to light — i.e. whether the fade clock should run. */
|
|
87
|
+
export function trailLive(trail: TrailPayload | null, now: number, replay: TrailReplay | null = null): boolean {
|
|
88
|
+
if (trail === null) return false;
|
|
89
|
+
return lightTimes(trail, replay).some(({ at }) => now - at < TRAIL_FADE_MS);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One note named in a step, ready to be a link. */
|
|
93
|
+
export interface TrailNoteView {
|
|
94
|
+
id: string;
|
|
95
|
+
label: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** One step of a turn. */
|
|
99
|
+
export interface TrailStepView {
|
|
100
|
+
key: string;
|
|
101
|
+
kind: WireTrailKind;
|
|
102
|
+
/** `searched "auth"`, `read`, `appended`, … */
|
|
103
|
+
verb: string;
|
|
104
|
+
/** The notes to link. Empty for a search, which names its hit count in `detail` instead. */
|
|
105
|
+
notes: TrailNoteView[];
|
|
106
|
+
detail: string | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One turn in the strip, newest first. */
|
|
110
|
+
export interface TrailTurnView {
|
|
111
|
+
turn: number;
|
|
112
|
+
/** Local `HH:MM` of the turn's first step. */
|
|
113
|
+
time: string;
|
|
114
|
+
steps: TrailStepView[];
|
|
115
|
+
/** Distinct notes read or written. */
|
|
116
|
+
used: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const WROTE_VERB: Readonly<Record<string, string>> = {
|
|
120
|
+
add: "created",
|
|
121
|
+
append: "appended",
|
|
122
|
+
finalize: "finalized",
|
|
123
|
+
links: "fixed links in",
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function stepView(event: WireTrailEvent, labels: ReadonlyMap<string, string>): TrailStepView {
|
|
127
|
+
const notes = event.slugs.map((slug) => ({ id: `note:${slug}`, label: labels.get(`note:${slug}`) ?? slug }));
|
|
128
|
+
if (event.kind === "searched") {
|
|
129
|
+
const hits = event.slugs.length;
|
|
130
|
+
return {
|
|
131
|
+
key: `${event.seq}:s`,
|
|
132
|
+
kind: "searched",
|
|
133
|
+
verb: event.query === undefined ? "searched" : `searched "${event.query}"`,
|
|
134
|
+
notes: [],
|
|
135
|
+
detail: hits === 0 ? "no hits" : `${hits} hit${hits === 1 ? "" : "s"}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
key: `${event.seq}:${event.kind}`,
|
|
140
|
+
kind: event.kind,
|
|
141
|
+
verb: event.kind === "read" ? "read" : WROTE_VERB[event.action] ?? "wrote",
|
|
142
|
+
notes,
|
|
143
|
+
detail: null,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function clock(iso: string, seconds = false): string {
|
|
148
|
+
const at = new Date(iso);
|
|
149
|
+
if (Number.isNaN(at.getTime())) return "";
|
|
150
|
+
const parts = [at.getHours(), at.getMinutes(), ...(seconds ? [at.getSeconds()] : [])];
|
|
151
|
+
return parts.map((part) => String(part).padStart(2, "0")).join(":");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The strip: the newest {@link TRAIL_TURNS_SHOWN} turns, each with its steps in order.
|
|
156
|
+
*
|
|
157
|
+
* When the harness has started a turn that has not touched a note yet (Pi's
|
|
158
|
+
* `agent_start` moves `trail.turn` ahead of the newest event), that empty
|
|
159
|
+
* turn leads the list, so the previous turn is never presented as current.
|
|
160
|
+
*/
|
|
161
|
+
export function trailTurns(trail: TrailPayload | null, graph: GraphPayload | null): TrailTurnView[] {
|
|
162
|
+
if (trail === null || trail.events.length === 0) return [];
|
|
163
|
+
const labels = new Map<string, string>((graph?.model.nodes ?? []).map((node) => [node.id, node.label]));
|
|
164
|
+
const byTurn = new Map<number, WireTrailEvent[]>();
|
|
165
|
+
for (const event of trail.events) {
|
|
166
|
+
const events = byTurn.get(event.turn);
|
|
167
|
+
if (events === undefined) byTurn.set(event.turn, [event]);
|
|
168
|
+
else events.push(event);
|
|
169
|
+
}
|
|
170
|
+
const turns = [...byTurn.entries()].sort(([a], [b]) => b - a);
|
|
171
|
+
// Turns in the same minute would get identical chips, so those show seconds.
|
|
172
|
+
const minutes = turns.map(([, events]) => clock(events[0]!.at));
|
|
173
|
+
const views = turns.map(([turn, events], index) => {
|
|
174
|
+
const used = new Set(events.filter((event) => event.kind !== "searched").flatMap((event) => event.slugs));
|
|
175
|
+
const shared = minutes.filter((minute) => minute === minutes[index]).length > 1;
|
|
176
|
+
return { turn, time: clock(events[0]!.at, shared), steps: events.map((event) => stepView(event, labels)), used: used.size };
|
|
177
|
+
});
|
|
178
|
+
if (trail.turn > views[0]!.turn) views.unshift({ turn: trail.turn, time: "", steps: [], used: 0 });
|
|
179
|
+
return views.slice(0, TRAIL_TURNS_SHOWN);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The strip's heading for a turn: the latest is "This turn", older ones are their time. */
|
|
183
|
+
/** What the strip says for a turn with no steps yet. */
|
|
184
|
+
export const TRAIL_EMPTY_TURN = "Nothing recalled yet.";
|
|
185
|
+
|
|
186
|
+
export function turnHeading(view: TrailTurnView, latest: boolean): string {
|
|
187
|
+
return latest ? "This turn" : view.time === "" ? `Turn ${view.turn}` : view.time;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The tree row's class for a glow, or `""`. Strength is bucketed so the class list stays small. */
|
|
191
|
+
export function trailRowClass(mark: TrailMark | undefined): string {
|
|
192
|
+
if (mark === undefined) return "";
|
|
193
|
+
const level = mark.strength > 0.66 ? 3 : mark.strength > 0.33 ? 2 : 1;
|
|
194
|
+
return ` weave-row-trail weave-row-trail-${mark.kind} weave-row-trail-${level}`;
|
|
195
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Polling `GET /api/trail`, alongside (not inside) the graph poll in `../workspace.ts`. */
|
|
2
|
+
|
|
3
|
+
import type { TrailPayload } from "../../shared/wire";
|
|
4
|
+
import type { FetchLike } from "../api";
|
|
5
|
+
import { fetchTrail } from "../api";
|
|
6
|
+
import { POLL_MS } from "../workspace";
|
|
7
|
+
|
|
8
|
+
export interface TrailPollOptions {
|
|
9
|
+
fetch: FetchLike;
|
|
10
|
+
onTrail: (trail: TrailPayload) => void;
|
|
11
|
+
repeat?: (fn: () => void, ms: number) => () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Poll the trail every {@link POLL_MS}, revalidating on its stamp, and report
|
|
16
|
+
* only fresh payloads. A failed poll keeps what was there: the trail is an
|
|
17
|
+
* extra, and a blip must not blank it. Returns `stop`.
|
|
18
|
+
*/
|
|
19
|
+
export function startTrailPoll(opts: TrailPollOptions): () => void {
|
|
20
|
+
let previous: TrailPayload | null = null;
|
|
21
|
+
let stopped = false;
|
|
22
|
+
let polling = false;
|
|
23
|
+
const poll = async (): Promise<void> => {
|
|
24
|
+
if (stopped || polling) return;
|
|
25
|
+
polling = true;
|
|
26
|
+
try {
|
|
27
|
+
const result = await fetchTrail(opts.fetch, previous);
|
|
28
|
+
if (stopped || !result.ok || result.cached) return;
|
|
29
|
+
previous = result.data;
|
|
30
|
+
opts.onTrail(result.data);
|
|
31
|
+
} finally {
|
|
32
|
+
polling = false;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const repeat = opts.repeat ?? ((fn, ms) => {
|
|
36
|
+
const timer = setInterval(fn, ms);
|
|
37
|
+
return () => clearInterval(timer);
|
|
38
|
+
});
|
|
39
|
+
const cancel = repeat(() => void poll(), POLL_MS);
|
|
40
|
+
void poll();
|
|
41
|
+
return () => {
|
|
42
|
+
stopped = true;
|
|
43
|
+
cancel();
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** The vault and repository tree column. */
|
|
2
2
|
|
|
3
|
+
import type { TrailMark } from "../trail/trail.model";
|
|
4
|
+
import { trailRowClass } from "../trail/trail.model";
|
|
3
5
|
import { NOTE_DRAG_TYPE } from "../shell/shell.model";
|
|
4
6
|
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
|
5
7
|
import { createFolder, deleteFolder, deleteNote, moveNote, renameFolder, renameNote } from "../api";
|
|
@@ -46,6 +48,8 @@ export interface TreeProps {
|
|
|
46
48
|
graph: GraphPayload | null;
|
|
47
49
|
selectedId: string | null;
|
|
48
50
|
recentIds: ReadonlySet<string>;
|
|
51
|
+
/** Recall-trail glows by row id (`trail/trail.model.ts`). */
|
|
52
|
+
trail?: ReadonlyMap<string, TrailMark>;
|
|
49
53
|
onSelect: (id: string, newTab?: boolean, keepSidebar?: boolean) => void;
|
|
50
54
|
/**
|
|
51
55
|
* Ask before a mutation that would invalidate an open draft. `false` means
|
|
@@ -76,13 +80,13 @@ export function Icon({ name, class: className }: { name: IconName; class?: strin
|
|
|
76
80
|
);
|
|
77
81
|
}
|
|
78
82
|
|
|
79
|
-
function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMenu, onDrop }: { view: ReturnType<typeof rowViews>[number]; recentIds: ReadonlySet<string>; edit: string | null; onEdit: (value: string) => void; onRename: (value: string | null) => void; onSelect: (newTab: boolean) => void; onToggle: () => void; onMenu: (x: number, y: number) => void; onDrop: (id: string) => void }) {
|
|
83
|
+
function Row({ view, recentIds, trail, edit, onEdit, onRename, onSelect, onToggle, onMenu, onDrop }: { view: ReturnType<typeof rowViews>[number]; recentIds: ReadonlySet<string>; trail: TrailMark | undefined; edit: string | null; onEdit: (value: string) => void; onRename: (value: string | null) => void; onSelect: (newTab: boolean) => void; onToggle: () => void; onMenu: (x: number, y: number) => void; onDrop: (id: string) => void }) {
|
|
80
84
|
const folder = dropFolder(view.id);
|
|
81
85
|
return (
|
|
82
86
|
<li
|
|
83
87
|
id={view.domId}
|
|
84
88
|
data-row-id={view.id}
|
|
85
|
-
class={`weave-row weave-row-${view.kind}${view.selected ? " weave-row-on" : ""}${recentIds.has(view.id) ? " weave-row-new" : ""}`}
|
|
89
|
+
class={`weave-row weave-row-${view.kind}${view.selected ? " weave-row-on" : ""}${recentIds.has(view.id) ? " weave-row-new" : ""}${trailRowClass(trail)}`}
|
|
86
90
|
role="treeitem"
|
|
87
91
|
aria-level={view.level}
|
|
88
92
|
aria-posinset={view.posinset}
|
|
@@ -297,7 +301,7 @@ export function Tree(props: TreeProps) {
|
|
|
297
301
|
setMenu({ id: "vault", label: "Vault", x: event.clientX, y: event.clientY });
|
|
298
302
|
}}
|
|
299
303
|
>
|
|
300
|
-
{rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={(newTab) => { props.onSelect(view.id, newTab); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) { const target = mutableTreeRow(dragged); if (target !== null && !props.onMutate(affectedNoteSlugs(props.graph, target))) return; void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); } }} />)}
|
|
304
|
+
{rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} trail={props.trail?.get(view.id)} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={(newTab) => { props.onSelect(view.id, newTab); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) { const target = mutableTreeRow(dragged); if (target !== null && !props.onMutate(affectedNoteSlugs(props.graph, target))) return; void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); } }} />)}
|
|
301
305
|
</ul>
|
|
302
306
|
) : <p class="weave-tree-empty">{empty}</p>}
|
|
303
307
|
<p class="weave-tree-count">{rowCountLabel(rows)}</p>
|
|
@@ -49,11 +49,8 @@ export type TreeViewState = TreeState;
|
|
|
49
49
|
/**
|
|
50
50
|
* The provenance filter cycle: all → human → agent → generated → all.
|
|
51
51
|
*
|
|
52
|
-
* The
|
|
53
|
-
*
|
|
54
|
-
* order is not part of `src/core/view`. That is a real, if small, duplication
|
|
55
|
-
* — so it is one array with a test asserting its contents, rather than an
|
|
56
|
-
* `if/else` chain in two places. If a fourth provenance is ever added, the
|
|
52
|
+
* The order is not part of `src/core/view`, so it is one array with a test
|
|
53
|
+
* asserting its contents, rather than an `if/else` chain in two places. If a fourth provenance is ever added, the
|
|
57
54
|
* honest fix is to promote this into `src/core/view` and take it through the
|
|
58
55
|
* door; today it is four literals and a promotion would be ceremony.
|
|
59
56
|
*/
|
package/src/web/server/page.ts
CHANGED
|
@@ -243,7 +243,7 @@ export function renderPage(opts: RenderPageOptions): RenderedPage {
|
|
|
243
243
|
"</head>",
|
|
244
244
|
"<body>",
|
|
245
245
|
`<div id="app" data-cwd="${escapeAttr(opts.bootstrap.cwd)}">`,
|
|
246
|
-
"<noscript>pi-weave needs JavaScript
|
|
246
|
+
"<noscript>pi-weave needs JavaScript to show the workspace.</noscript>",
|
|
247
247
|
"</div>",
|
|
248
248
|
`<script type="application/json" id="${escapeAttr(BOOTSTRAP_ELEMENT_ID)}" nonce="${escapeAttr(nonce)}">`,
|
|
249
249
|
jsonScriptBody(opts.bootstrap),
|
package/src/web/server/routes.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* | GET | `/` | the HTML shell, with its per-response nonce |
|
|
7
7
|
* | GET | `/app.js` | the committed bundle, `Cache-Control: no-store` |
|
|
8
8
|
* | GET | `/api/graph` | {@link GraphPayload}, ETag'd on `stamp` |
|
|
9
|
+
* | GET | `/api/trail` | {@link TrailPayload}, ETag'd on `stamp` |
|
|
9
10
|
* | GET | `/api/note/:slug` | {@link NotePayload} |
|
|
10
11
|
* | POST | `/api/note/:slug` | save a note's Markdown body |
|
|
11
12
|
* | POST | `/api/notes` | create an empty human-authored vault note |
|
|
@@ -15,6 +16,9 @@
|
|
|
15
16
|
* | POST | `/api/folder/:path` | create a vault folder |
|
|
16
17
|
* | POST | `/api/folder/:path/rename` | rename a vault folder |
|
|
17
18
|
* | DELETE | `/api/folder/:path` | delete a vault folder |
|
|
19
|
+
* | GET | `/api/history/:slug` | {@link HistoryWirePayload} |
|
|
20
|
+
* | GET | `/api/history-settings` | {@link HistorySettingsPayload} |
|
|
21
|
+
* | POST | `/api/history-settings` | turn note history on or off |
|
|
18
22
|
* | GET | `/api/okf/:rel` | {@link OkfFilePayload} |
|
|
19
23
|
* | GET | `/api/artifact/:rel` | sandboxed HTML artifact |
|
|
20
24
|
* | GET | `/api/search?q=` | {@link SearchPayload} |
|
|
@@ -49,22 +53,28 @@ import { createReadStream } from "node:fs";
|
|
|
49
53
|
import { pipeline } from "node:stream/promises";
|
|
50
54
|
import { createVaultBackup } from "./backup";
|
|
51
55
|
import { readFile } from "node:fs/promises";
|
|
56
|
+
import { resolve as resolvePath } from "node:path";
|
|
52
57
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
53
58
|
import type { WorkspaceSnapshot } from "../../core/cache/workspace";
|
|
54
59
|
import { WorkspaceCache } from "../../core/cache/workspace";
|
|
55
60
|
import { readOkfFileForView } from "../../core/graph/current";
|
|
56
61
|
import type { GraphModel as CoreGraphModel } from "../../core/graph/model";
|
|
62
|
+
import { encodeRevisions, historyEnabled, historyTrimmed, setHistoryEnabled } from "../../core/history";
|
|
57
63
|
import { openNoteInEditor } from "../../core/openInEditor";
|
|
64
|
+
import { recallTrail, type RecallTrail } from "../../core/trail";
|
|
58
65
|
import type { Note } from "../../core/types";
|
|
59
66
|
import type { VaultMutationResult } from "../../core/vault";
|
|
60
|
-
import { addNote, createFolder, deleteFolder, deleteNote, getNote, moveNote, renameFolder, renameNote, resolveHtmlPath, searchNotes, setNoteBody } from "../../core/vault";
|
|
67
|
+
import { addNote, createFolder, deleteFolder, deleteNote, getNote, moveNote, renameFolder, readNoteHistory, renameNote, resolveHtmlPath, searchNotes, setNoteBody } from "../../core/vault";
|
|
61
68
|
import { deriveTagIndex, type TaggedNote } from "../../core/view/links";
|
|
62
69
|
import type {
|
|
63
70
|
GraphPayload,
|
|
71
|
+
HistoryWirePayload,
|
|
72
|
+
HistorySettingsPayload,
|
|
64
73
|
NotePayload,
|
|
65
74
|
OkfFilePayload,
|
|
66
75
|
OpenResult,
|
|
67
76
|
SearchPayload,
|
|
77
|
+
TrailPayload,
|
|
68
78
|
ViewNote,
|
|
69
79
|
} from "../shared/wire";
|
|
70
80
|
import { WIRE_MODEL_OMITTED_KEYS } from "../shared/wire";
|
|
@@ -85,6 +95,8 @@ export interface RouteDeps {
|
|
|
85
95
|
/** Test seam for `POST /api/open`; defaults to the real editor shell-out. */
|
|
86
96
|
openNote?: ((slug: string) => Promise<boolean>) | undefined;
|
|
87
97
|
workspaceState: WorkspaceStateStore;
|
|
98
|
+
/** The recall trail to serve. Defaults to the process-wide one the tools write. */
|
|
99
|
+
trail?: RecallTrail | undefined;
|
|
88
100
|
}
|
|
89
101
|
|
|
90
102
|
const JSON_TYPE = "application/json; charset=utf-8";
|
|
@@ -383,6 +395,7 @@ async function route(
|
|
|
383
395
|
if (method === "GET" && path === "/") return sendShell(deps, res);
|
|
384
396
|
if (method === "GET" && path === "/app.js") return sendBundle(deps, res);
|
|
385
397
|
if (method === "GET" && path === "/api/graph") return sendGraph(deps, req, res);
|
|
398
|
+
if (method === "GET" && path === "/api/trail") return sendTrail(deps, req, res);
|
|
386
399
|
if (method === "GET" && path === "/api/backup") {
|
|
387
400
|
const backup = await createVaultBackup(deps.vaultRoot);
|
|
388
401
|
try {
|
|
@@ -413,6 +426,8 @@ async function route(
|
|
|
413
426
|
const handled = await routeNote(deps, method, path.slice("/api/note/".length), req, res);
|
|
414
427
|
if (handled) return;
|
|
415
428
|
}
|
|
429
|
+
if (method === "GET" && path.startsWith("/api/history/")) return sendHistory(deps, path.slice("/api/history/".length), res);
|
|
430
|
+
if (path === "/api/history-settings" && (method === "GET" || method === "POST")) return historySettings(deps, method, req, res);
|
|
416
431
|
if (method === "GET" && path.startsWith("/api/okf/")) {
|
|
417
432
|
return sendOkf(deps, path.slice("/api/okf/".length), res);
|
|
418
433
|
}
|
|
@@ -558,6 +573,29 @@ async function sendGraph(deps: RouteDeps, req: IncomingMessage, res: ServerRespo
|
|
|
558
573
|
res.end(body);
|
|
559
574
|
}
|
|
560
575
|
|
|
576
|
+
/**
|
|
577
|
+
* `GET /api/trail`. The trail is process-wide, so it is filtered to this
|
|
578
|
+
* server's vault; the stamp is the buffer's own, which moves on every record.
|
|
579
|
+
*/
|
|
580
|
+
function sendTrail(deps: RouteDeps, req: IncomingMessage, res: ServerResponse): void {
|
|
581
|
+
const snapshot = (deps.trail ?? recallTrail).snapshot();
|
|
582
|
+
const etag = `"${snapshot.stamp}"`;
|
|
583
|
+
const conditional = req.headers["if-none-match"];
|
|
584
|
+
if (matchesEtag(etag, typeof conditional === "string" ? conditional : undefined)) {
|
|
585
|
+
res.writeHead(304, { ...baseHeaders(), etag, "cache-control": "no-cache" });
|
|
586
|
+
res.end();
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const payload: TrailPayload = {
|
|
590
|
+
stamp: snapshot.stamp,
|
|
591
|
+
turn: snapshot.turn,
|
|
592
|
+
events: snapshot.events
|
|
593
|
+
.filter((event) => resolvePath(event.vault) === resolvePath(deps.vaultRoot))
|
|
594
|
+
.map(({ vault: _vault, ...event }) => event),
|
|
595
|
+
};
|
|
596
|
+
sendJson(res, 200, payload, { etag, "cache-control": "no-cache" });
|
|
597
|
+
}
|
|
598
|
+
|
|
561
599
|
/**
|
|
562
600
|
* `If-None-Match` matching, weak-comparison style.
|
|
563
601
|
*
|
|
@@ -615,11 +653,17 @@ async function routeNote(
|
|
|
615
653
|
sendJson(res, 400, { error: "expected JSON object" });
|
|
616
654
|
return true;
|
|
617
655
|
}
|
|
618
|
-
const fields = body as { body?: unknown; name?: unknown; folder?: unknown };
|
|
656
|
+
const fields = body as { body?: unknown; raw?: unknown; name?: unknown; folder?: unknown };
|
|
619
657
|
// The payload decides, not the suffix: slugs nest, so `archive/rename` is
|
|
620
658
|
// a real note and only `{name}`/`{folder}` mean the action.
|
|
621
659
|
if (typeof fields.body === "string") {
|
|
622
|
-
|
|
660
|
+
// `raw: true` is the editor saying the person changed their own Raw
|
|
661
|
+
// words; without it the on-disk tail is kept, whatever the body says.
|
|
662
|
+
if (fields.raw !== undefined && typeof fields.raw !== "boolean") {
|
|
663
|
+
sendJson(res, 400, { error: "expected raw: boolean" });
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
sendMutation(res, await setNoteBody(deps.vaultRoot, target, fields.body, new Date(), "human", fields.raw === true ? "replace" : "keep"));
|
|
623
667
|
return true;
|
|
624
668
|
}
|
|
625
669
|
if (action === null) {
|
|
@@ -700,6 +744,42 @@ function notePayload(note: Note): NotePayload {
|
|
|
700
744
|
return { note: view };
|
|
701
745
|
}
|
|
702
746
|
|
|
747
|
+
/**
|
|
748
|
+
* Every saved revision of a note. The note's current body is passed in so an
|
|
749
|
+
* edit made outside pi-weave since the last write shows as the final step.
|
|
750
|
+
*/
|
|
751
|
+
async function sendHistory(deps: RouteDeps, slug: string, res: ServerResponse): Promise<void> {
|
|
752
|
+
const revisions = await readNoteHistory(deps.vaultRoot, slug);
|
|
753
|
+
if (revisions === null) {
|
|
754
|
+
sendJson(res, 404, { error: "no such note" });
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
// Deltas, as on disk: full bodies for every revision would grow with the
|
|
758
|
+
// square of the note's length.
|
|
759
|
+
const trimmed = await historyTrimmed(deps.vaultRoot, slug);
|
|
760
|
+
const payload: HistoryWirePayload = {
|
|
761
|
+
slug,
|
|
762
|
+
enabled: await historyEnabled(deps.vaultRoot),
|
|
763
|
+
trimmed,
|
|
764
|
+
revisions: encodeRevisions(revisions, trimmed),
|
|
765
|
+
};
|
|
766
|
+
sendJson(res, 200, payload, { "cache-control": "no-store" });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function historySettings(deps: RouteDeps, method: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
770
|
+
if (method === "POST") {
|
|
771
|
+
const body = await readJsonBody(req);
|
|
772
|
+
const enabled = typeof body === "object" && body !== null ? (body as { enabled?: unknown }).enabled : undefined;
|
|
773
|
+
if (typeof enabled !== "boolean") {
|
|
774
|
+
sendJson(res, 400, { error: "expected { enabled: boolean }" });
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
await setHistoryEnabled(deps.vaultRoot, enabled);
|
|
778
|
+
}
|
|
779
|
+
const payload: HistorySettingsPayload = { enabled: await historyEnabled(deps.vaultRoot) };
|
|
780
|
+
sendJson(res, 200, payload, { "cache-control": "no-store" });
|
|
781
|
+
}
|
|
782
|
+
|
|
703
783
|
async function sendOkf(deps: RouteDeps, rel: string, res: ServerResponse): Promise<void> {
|
|
704
784
|
// Anchored under `<cwd>/.okf` by `readOkfFileForView`, which resolves and
|
|
705
785
|
// then re-checks the prefix — the only correct order.
|
package/src/web/shared/logo.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* The web viewer is a single committed bundle behind a strict CSP
|
|
5
5
|
* (img-src 'self' data:), so the brand mark is an inlined 48×48 PNG —
|
|
6
6
|
* the transparent-background spider derived from docs/pi-weave-logo.png —
|
|
7
|
-
* rather than a new static route.
|
|
7
|
+
* rather than a new static route.
|
|
8
8
|
*/
|
|
9
9
|
export const LOGO_MARK_MIME = "image/png";
|
|
10
10
|
/** 48×48 transparent PNG, base64 — crisp at 2× for the 18px header mark. */
|
package/src/web/shared/themes.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* `light` and `dark` keep existing saved choices compatible.
|
|
4
4
|
*/
|
|
5
5
|
const latte = {
|
|
6
|
-
bg: "#eff1f5", panel: "#e6e9ef", page: "#
|
|
6
|
+
bg: "#eff1f5", panel: "#e6e9ef", page: "#ffffff", raise: "#ccd0da",
|
|
7
7
|
fg: "#4c4f69", dim: "#56586a", faint: "#606274", line: "#ccd0da", "line-strong": "#bcc0cc",
|
|
8
8
|
accent: "#7113ec", ok: "#28641b", warn: "#7c4f10", bad: "#b20d30",
|
|
9
9
|
};
|
package/src/web/shared/view.ts
CHANGED
|
@@ -61,10 +61,9 @@
|
|
|
61
61
|
* ## Why this is a small list
|
|
62
62
|
*
|
|
63
63
|
* Deliberately not `export * from "../../core/view"`. The barrel pulls in
|
|
64
|
-
* `
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* has to defend forever.
|
|
64
|
+
* `links.ts`'s tag index (derived server-side into `GraphPayload.tags`),
|
|
65
|
+
* which no browser column consumes today — and each module it pulled in
|
|
66
|
+
* would be one the purity guard then has to defend forever.
|
|
68
67
|
*
|
|
69
68
|
* So this is an allowlist, on the same principle as the `npm` column in
|
|
70
69
|
* `tests/web/tiers.test.ts`: the surface the columns actually use, and nothing
|
|
@@ -131,6 +130,13 @@ export type { TreeRow, TreeState } from "../../core/view/tree";
|
|
|
131
130
|
*/
|
|
132
131
|
export { formatTreeMeta, relTime } from "../../core/view/time";
|
|
133
132
|
|
|
133
|
+
/**
|
|
134
|
+
* The note editor holds only the head of a body; the `## Raw` tail is shown
|
|
135
|
+
* read-only beneath it and rejoined on save. The vault splits with the same
|
|
136
|
+
* function, so the editor can never hide text the server would overwrite.
|
|
137
|
+
*/
|
|
138
|
+
export { joinRawOpening, joinRawTail, splitRawOpening, splitRawTail } from "../../core/view/rawTail";
|
|
139
|
+
|
|
134
140
|
// --- context rail (§1.2, P2) --------------------------------------------------
|
|
135
141
|
|
|
136
142
|
/** Links, backlinks and the ordered meta block for the selected node. */
|
package/src/web/shared/wire.ts
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
* contract.
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
-
import type { WireGraphModel, WireNoteSearchHit,
|
|
43
|
+
import type { WireGraphModel, WireNoteSearchHit, WireViewNote } from "./graph";
|
|
44
44
|
import type { Point } from "./metrics";
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -140,10 +140,83 @@ export interface GraphPayload {
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/** `GET /api/note/:slug`. */
|
|
143
|
+
/** How the agent touched a note — see `src/core/trail.ts`. */
|
|
144
|
+
export type WireTrailKind = "searched" | "read" | "wrote";
|
|
145
|
+
|
|
146
|
+
/** One recall-trail step. `at` is ISO; `slugs` may be empty for a search with no hits. */
|
|
147
|
+
export interface WireTrailEvent {
|
|
148
|
+
seq: number;
|
|
149
|
+
turn: number;
|
|
150
|
+
at: string;
|
|
151
|
+
kind: WireTrailKind;
|
|
152
|
+
action: string;
|
|
153
|
+
slugs: string[];
|
|
154
|
+
query?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `GET /api/trail` — the notes the agent searched, read and wrote this
|
|
159
|
+
* session, oldest first, filtered to this workspace's vault. ETag'd on
|
|
160
|
+
* `stamp`, like the graph, so polling it is a `304` while the agent is idle.
|
|
161
|
+
*/
|
|
162
|
+
export interface TrailPayload {
|
|
163
|
+
stamp: string;
|
|
164
|
+
turn: number;
|
|
165
|
+
events: WireTrailEvent[];
|
|
166
|
+
}
|
|
167
|
+
|
|
143
168
|
export interface NotePayload {
|
|
144
169
|
note: ViewNote;
|
|
145
170
|
}
|
|
146
171
|
|
|
172
|
+
/** One saved revision of a note, body included. */
|
|
173
|
+
export interface HistoryRevision {
|
|
174
|
+
/** ISO-8601 time of the change. */
|
|
175
|
+
at: string;
|
|
176
|
+
/** Who made it: `outside` is any editor other than pi-weave. */
|
|
177
|
+
by: "human" | "agent" | "outside" | "system";
|
|
178
|
+
/** What kind of change: create, edit, append, raw, finalize, generate, repair, outside, baseline. */
|
|
179
|
+
op: string;
|
|
180
|
+
body: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A history payload as the client works with it: every body rebuilt. */
|
|
184
|
+
export interface HistoryPayload {
|
|
185
|
+
slug: string;
|
|
186
|
+
/** Whether new revisions are being recorded. */
|
|
187
|
+
enabled: boolean;
|
|
188
|
+
/** Older revisions were dropped to keep the note's history file small. */
|
|
189
|
+
trimmed: boolean;
|
|
190
|
+
revisions: HistoryRevision[];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* One revision on the wire: a full body, or a delta against the one before
|
|
195
|
+
* (keep `keep[0]` characters from the start and `keep[1]` from the end, and
|
|
196
|
+
* put `text` between them). The first revision is always full.
|
|
197
|
+
*/
|
|
198
|
+
export interface HistoryWireRevision {
|
|
199
|
+
at: string;
|
|
200
|
+
by: HistoryRevision["by"];
|
|
201
|
+
op: string;
|
|
202
|
+
full?: string;
|
|
203
|
+
keep?: [number, number];
|
|
204
|
+
text?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** `GET /api/history/:slug`. Oldest first; the last revision is the note as it is now. */
|
|
208
|
+
export interface HistoryWirePayload {
|
|
209
|
+
slug: string;
|
|
210
|
+
enabled: boolean;
|
|
211
|
+
trimmed: boolean;
|
|
212
|
+
revisions: HistoryWireRevision[];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** `GET` and `POST /api/history-settings`. */
|
|
216
|
+
export interface HistorySettingsPayload {
|
|
217
|
+
enabled: boolean;
|
|
218
|
+
}
|
|
219
|
+
|
|
147
220
|
/** `GET /api/okf/:rel`. */
|
|
148
221
|
export interface OkfFilePayload {
|
|
149
222
|
/** The requested path, relative to `<cwd>/.okf`. Echoed, never resolved. */
|
|
@@ -27,6 +27,11 @@ export interface WorkspaceLayout {
|
|
|
27
27
|
readonly ratio: number;
|
|
28
28
|
readonly treeVisible: boolean;
|
|
29
29
|
readonly contextVisible: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Context as a docked column (true) or a card floating over the page's
|
|
32
|
+
* right margin (false, the default since the refresh).
|
|
33
|
+
*/
|
|
34
|
+
readonly contextPinned: boolean;
|
|
30
35
|
readonly treeWidth: number;
|
|
31
36
|
readonly contextWidth: number;
|
|
32
37
|
readonly contextGraphHeight: number;
|
|
@@ -50,6 +55,7 @@ export function initialLayout(): WorkspaceLayout {
|
|
|
50
55
|
ratio: 0.5,
|
|
51
56
|
treeVisible: true,
|
|
52
57
|
contextVisible: true,
|
|
58
|
+
contextPinned: false,
|
|
53
59
|
treeWidth: 240,
|
|
54
60
|
contextWidth: 240,
|
|
55
61
|
contextGraphHeight: 260,
|
|
@@ -246,11 +252,13 @@ function boundedId(value: unknown, prefix?: "pane" | "tab"): value is string {
|
|
|
246
252
|
}
|
|
247
253
|
|
|
248
254
|
export function parseWorkspaceLayout(value: unknown): WorkspaceLayout | null {
|
|
249
|
-
if (!record(value) || !exactKeys(value, ["version", "panes", "activePane", "split", "ratio", "treeVisible", "contextVisible", "treeWidth", "contextWidth", "theme", ...["preferences", "contextGraphHeight", "contextTagsHeight"].filter(key => Object.hasOwn(value, key))])) return null;
|
|
255
|
+
if (!record(value) || !exactKeys(value, ["version", "panes", "activePane", "split", "ratio", "treeVisible", "contextVisible", "treeWidth", "contextWidth", "theme", ...["preferences", "contextGraphHeight", "contextTagsHeight", "contextPinned"].filter(key => Object.hasOwn(value, key))])) return null;
|
|
250
256
|
if (value["version"] !== 1 || !Array.isArray(value["panes"]) || value["panes"].length < 1 || value["panes"].length > 2 || !boundedId(value["activePane"], "pane")) return null;
|
|
251
257
|
if (value["split"] !== "right" && value["split"] !== "down") return null;
|
|
252
258
|
if (typeof value["ratio"] !== "number" || !Number.isFinite(value["ratio"]) || value["ratio"] < 0.1 || value["ratio"] > 0.9) return null;
|
|
253
259
|
if (typeof value["treeVisible"] !== "boolean" || typeof value["contextVisible"] !== "boolean") return null;
|
|
260
|
+
const contextPinned = Object.hasOwn(value, "contextPinned") ? value["contextPinned"] : false;
|
|
261
|
+
if (typeof contextPinned !== "boolean") return null;
|
|
254
262
|
if (typeof value["treeWidth"] !== "number" || !Number.isFinite(value["treeWidth"]) || value["treeWidth"] < 120 || value["treeWidth"] > 800) return null;
|
|
255
263
|
if (typeof value["contextWidth"] !== "number" || !Number.isFinite(value["contextWidth"]) || value["contextWidth"] < 120 || value["contextWidth"] > 800) return null;
|
|
256
264
|
const contextGraphHeight = Object.hasOwn(value, "contextGraphHeight") ? value["contextGraphHeight"] : 260;
|
|
@@ -292,7 +300,7 @@ export function parseWorkspaceLayout(value: unknown): WorkspaceLayout | null {
|
|
|
292
300
|
panes.push({ id: rawPane["id"], tabs, activeTab: rawPane["activeTab"], ...(typeof lastDocumentTab === "string" ? { lastDocumentTab } : {}) });
|
|
293
301
|
}
|
|
294
302
|
if (count > MAX_TABS || graphs > 1 || !panes.some((pane) => pane.id === value["activePane"])) return null;
|
|
295
|
-
return { version: 1, panes, activePane: value["activePane"], split: value["split"], ratio: value["ratio"], treeVisible: value["treeVisible"], contextVisible: value["contextVisible"], treeWidth: value["treeWidth"], contextWidth: value["contextWidth"], contextGraphHeight, contextTagsHeight, theme: value["theme"], preferences };
|
|
303
|
+
return { version: 1, panes, activePane: value["activePane"], split: value["split"], ratio: value["ratio"], treeVisible: value["treeVisible"], contextVisible: value["contextVisible"], contextPinned, treeWidth: value["treeWidth"], contextWidth: value["contextWidth"], contextGraphHeight, contextTagsHeight, theme: value["theme"], preferences };
|
|
296
304
|
}
|
|
297
305
|
|
|
298
306
|
/** Startup choice resets navigation only; appearance and sidebar preferences survive. */
|