@1agh/maude 1.4.5 → 1.5.0
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/apps/studio/annotations/ai-read.ts +288 -0
- package/apps/studio/annotations/ai-write.ts +533 -0
- package/apps/studio/annotations/board-io.ts +94 -0
- package/apps/studio/annotations/board-text.ts +45 -0
- package/apps/studio/annotations/constants.ts +58 -0
- package/apps/studio/annotations/elements/_shared.ts +125 -0
- package/apps/studio/annotations/elements/arrow.model.ts +196 -0
- package/apps/studio/annotations/elements/media.model.ts +79 -0
- package/apps/studio/annotations/elements/pen.model.ts +82 -0
- package/apps/studio/annotations/elements/section.model.ts +70 -0
- package/apps/studio/annotations/elements/shape.model.ts +141 -0
- package/apps/studio/annotations/elements/sticky.model.ts +48 -0
- package/apps/studio/annotations/elements/text.model.ts +52 -0
- package/apps/studio/annotations/fields.ts +347 -0
- package/apps/studio/annotations/fractional-index.ts +234 -0
- package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
- package/apps/studio/annotations/migrate-boot.ts +172 -0
- package/apps/studio/annotations/migrate-cli.ts +37 -0
- package/apps/studio/annotations/migrate-v1.ts +383 -0
- package/apps/studio/annotations/ops.ts +474 -0
- package/apps/studio/annotations/registry.ts +159 -0
- package/apps/studio/annotations/replica.ts +264 -0
- package/apps/studio/annotations/scene.ts +235 -0
- package/apps/studio/annotations/schema.ts +188 -0
- package/apps/studio/annotations/types.ts +77 -0
- package/apps/studio/annotations/ui/board.ts +126 -0
- package/apps/studio/annotations/ui/containment.ts +118 -0
- package/apps/studio/annotations/ui/edit-actions.ts +551 -0
- package/apps/studio/annotations/ui/editor-channel.ts +62 -0
- package/apps/studio/annotations/ui/element-node.tsx +993 -0
- package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
- package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
- package/apps/studio/annotations/ui/render-model.ts +264 -0
- package/apps/studio/annotations/ui/scene.tsx +63 -0
- package/apps/studio/annotations/ui/text-editor.tsx +420 -0
- package/apps/studio/annotations/ui/text-session.ts +140 -0
- package/apps/studio/annotations/ui/text-style.ts +111 -0
- package/apps/studio/annotations/ui/world.ts +47 -0
- package/apps/studio/annotations/v1-adapter.ts +428 -0
- package/apps/studio/annotations-align.ts +21 -6
- package/apps/studio/annotations-bindings.ts +2 -0
- package/apps/studio/annotations-context-toolbar.tsx +9 -13
- package/apps/studio/annotations-groups.ts +3 -0
- package/apps/studio/annotations-layer.tsx +923 -1925
- package/apps/studio/annotations-model.ts +97 -4
- package/apps/studio/annotations-sync.ts +4 -47
- package/apps/studio/api.ts +262 -68
- package/apps/studio/bin/_import-figma.mjs +22 -12
- package/apps/studio/bin/annotate.mjs +331 -838
- package/apps/studio/bin/annotate.sh +4 -4
- package/apps/studio/bin/perf.sh +21 -7
- package/apps/studio/bin/read-annotations.mjs +184 -666
- package/apps/studio/bin/read-annotations.sh +9 -5
- package/apps/studio/canvas-artifacts.ts +9 -0
- package/apps/studio/canvas-comment-mount.tsx +35 -2
- package/apps/studio/canvas-lib.tsx +18 -1
- package/apps/studio/canvas-shell.tsx +54 -1
- package/apps/studio/client/app.jsx +101 -35
- package/apps/studio/client/comments-overlay.css +10 -0
- package/apps/studio/client/hmr.mjs +1 -1
- package/apps/studio/client/panels/git-grouping.js +2 -2
- package/apps/studio/client/tree-expansion.js +217 -0
- package/apps/studio/collab/index.ts +58 -5
- package/apps/studio/collab/persistence.ts +80 -11
- package/apps/studio/collab/registry.ts +63 -26
- package/apps/studio/commands/annotation-ops-command.ts +72 -0
- package/apps/studio/comment-anchor.ts +116 -0
- package/apps/studio/comments-overlay.tsx +78 -35
- package/apps/studio/context.ts +1 -0
- package/apps/studio/cursors-overlay.tsx +337 -109
- package/apps/studio/dist/client.bundle.js +850 -850
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/figma/to-strokes.ts +31 -10
- package/apps/studio/git/endpoints.ts +1 -1
- package/apps/studio/git/service.ts +1 -1
- package/apps/studio/git/watch.ts +1 -1
- package/apps/studio/http.ts +90 -15
- package/apps/studio/server.ts +16 -0
- package/apps/studio/sync/accepted-cold-start.ts +72 -8
- package/apps/studio/sync/agent.ts +36 -6
- package/apps/studio/sync/codec.ts +95 -40
- package/apps/studio/sync/comment-ledger.ts +229 -0
- package/apps/studio/sync/file-membership.ts +12 -2
- package/apps/studio/sync/file-plane.ts +78 -2
- package/apps/studio/sync/index.ts +75 -16
- package/apps/studio/sync/journal-client.ts +5 -0
- package/apps/studio/sync/limits.ts +7 -2
- package/apps/studio/sync/migrate-seed.ts +17 -7
- package/apps/studio/sync/projection.ts +7 -2
- package/apps/studio/sync/remote-docs.ts +34 -0
- package/apps/studio/sync/writer-registry.ts +10 -0
- package/apps/studio/text-caret.ts +11 -2
- package/apps/studio/tree-state.ts +45 -0
- package/apps/studio/undo-stack.ts +2 -2
- package/apps/studio/use-annotation-resize.tsx +48 -23
- package/apps/studio/use-annotation-selection.tsx +9 -2
- package/apps/studio/use-collab.tsx +104 -1
- package/apps/studio/use-selection-set.tsx +4 -0
- package/apps/studio/whats-new.json +63 -0
- package/cli/lib/design-link.mjs +5 -1
- package/cli/lib/gitignore-block.mjs +1 -1
- package/cli/lib/gitignore-drift.mjs +2 -1
- package/package.json +9 -8
- package/plugins/design/templates/brief-board.tsx.template +1 -1
- package/apps/studio/annotation-edit-base.ts +0 -36
- package/apps/studio/commands/annotation-strokes-command.ts +0 -137
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// tree-expansion.js — which folders + sections of the Files panel are open
|
|
2
|
+
// (issue #124).
|
|
3
|
+
//
|
|
4
|
+
// Before this, every folder row owned its own `useState(defaultOpen=true)`:
|
|
5
|
+
// the tree started fully expanded and forgot every collapse the moment the row
|
|
6
|
+
// unmounted — a dock-tab switch, a section toggle, a reload. The state now
|
|
7
|
+
// lives ONCE at App level (so it outlives the rows) and is persisted per
|
|
8
|
+
// project through `/_api/tree-state` (so it outlives the app).
|
|
9
|
+
//
|
|
10
|
+
// Rule: anything not recorded is CLOSED — folders and sections alike. Nothing
|
|
11
|
+
// expands itself on boot; only the user does (a click, or explicitly opening a
|
|
12
|
+
// canvas, which reveals its row). Search force-opens without recording.
|
|
13
|
+
//
|
|
14
|
+
// The helpers below are pure (state in → state out) so they are unit-testable
|
|
15
|
+
// without a DOM; `useTreeExpansion` is the thin React wrapper App uses.
|
|
16
|
+
|
|
17
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
18
|
+
|
|
19
|
+
export const EMPTY_TREE_STATE = Object.freeze({ dirs: [], sections: {} });
|
|
20
|
+
|
|
21
|
+
// Pre-#124 section overrides lived only in origin-scoped localStorage. Read
|
|
22
|
+
// once as a seed so an existing user's choices survive the move to disk.
|
|
23
|
+
export const LEGACY_SECTIONS_STORE = 'mdcc-sections-expanded';
|
|
24
|
+
|
|
25
|
+
const PERSIST_DEBOUNCE_MS = 250;
|
|
26
|
+
const HYDRATE_TIMEOUT_MS = 3000;
|
|
27
|
+
|
|
28
|
+
export function isDirOpen(state, dirPath) {
|
|
29
|
+
return !!dirPath && state.dirs.includes(dirPath);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isSectionOpen(state, label) {
|
|
33
|
+
return state.sections[label] === true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function setDirOpen(state, dirPath, open) {
|
|
37
|
+
if (!dirPath) return state;
|
|
38
|
+
const has = state.dirs.includes(dirPath);
|
|
39
|
+
if (has === !!open) return state;
|
|
40
|
+
const dirs = open ? [...state.dirs, dirPath].sort() : state.dirs.filter((d) => d !== dirPath);
|
|
41
|
+
return { ...state, dirs };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function toggleDir(state, dirPath) {
|
|
45
|
+
return setDirOpen(state, dirPath, !isDirOpen(state, dirPath));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function setSectionOpen(state, label, open) {
|
|
49
|
+
if (!label || state.sections[label] === !!open) return state;
|
|
50
|
+
return { ...state, sections: { ...state.sections, [label]: !!open } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function toggleSection(state, label) {
|
|
54
|
+
return setSectionOpen(state, label, !isSectionOpen(state, label));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The group (section) a repo-relative path lives in — longest `fullPath` wins. */
|
|
58
|
+
export function groupForPath(groups, path) {
|
|
59
|
+
let best = null;
|
|
60
|
+
for (const g of groups || []) {
|
|
61
|
+
const root = g.fullPath;
|
|
62
|
+
if (!root || !(path === root || path.startsWith(root + '/'))) continue;
|
|
63
|
+
if (!best || root.length > best.fullPath.length) best = g;
|
|
64
|
+
}
|
|
65
|
+
return best;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Open everything needed to SEE `path`: its section, and every folder between
|
|
70
|
+
* the section root and the path. `includeSelf` also opens `path` itself (for a
|
|
71
|
+
* folder target — a new folder's parent, a move destination).
|
|
72
|
+
*/
|
|
73
|
+
export function revealPath(state, groups, path, { includeSelf = false } = {}) {
|
|
74
|
+
if (!path) return state;
|
|
75
|
+
const g = groupForPath(groups, path);
|
|
76
|
+
if (!g) return state;
|
|
77
|
+
let next = setSectionOpen(state, g.label, true);
|
|
78
|
+
const rest = path.slice(g.fullPath.length + 1);
|
|
79
|
+
if (!rest) return next;
|
|
80
|
+
const parts = rest.split('/');
|
|
81
|
+
const upto = includeSelf ? parts.length : parts.length - 1;
|
|
82
|
+
let cur = g.fullPath;
|
|
83
|
+
for (let i = 0; i < upto; i++) {
|
|
84
|
+
cur = `${cur}/${parts[i]}`;
|
|
85
|
+
next = setDirOpen(next, cur, true);
|
|
86
|
+
}
|
|
87
|
+
return next;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A folder moved/renamed `from` → `to`: carry its (and its subfolders') keys. */
|
|
91
|
+
export function remapDirPrefix(state, from, to) {
|
|
92
|
+
if (!from || !to || from === to) return state;
|
|
93
|
+
let changed = false;
|
|
94
|
+
const dirs = state.dirs.map((d) => {
|
|
95
|
+
if (d === from) {
|
|
96
|
+
changed = true;
|
|
97
|
+
return to;
|
|
98
|
+
}
|
|
99
|
+
if (d.startsWith(from + '/')) {
|
|
100
|
+
changed = true;
|
|
101
|
+
return to + d.slice(from.length);
|
|
102
|
+
}
|
|
103
|
+
return d;
|
|
104
|
+
});
|
|
105
|
+
return changed ? { ...state, dirs: [...new Set(dirs)].sort() } : state;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every folder path present in the loaded groups' trees. */
|
|
109
|
+
export function collectDirPaths(groups) {
|
|
110
|
+
const out = new Set();
|
|
111
|
+
const walk = (node, base) => {
|
|
112
|
+
for (const k of Object.keys(node || {})) {
|
|
113
|
+
if (k === '_files') continue;
|
|
114
|
+
const p = `${base}/${k}`;
|
|
115
|
+
out.add(p);
|
|
116
|
+
walk(node[k], p);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
for (const g of groups || []) if (g.fullPath) walk(g.tree, g.fullPath);
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Drop keys for folders that no longer exist (deleted, moved outside Maude). */
|
|
124
|
+
export function pruneDirs(state, known) {
|
|
125
|
+
const dirs = state.dirs.filter((d) => known.has(d));
|
|
126
|
+
return dirs.length === state.dirs.length ? state : { ...state, dirs };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function normalizeClientState(raw) {
|
|
130
|
+
const o = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
131
|
+
const dirs = Array.isArray(o.dirs) ? [...new Set(o.dirs.filter((d) => typeof d === 'string' && d))].sort() : [];
|
|
132
|
+
const sections = {};
|
|
133
|
+
if (o.sections && typeof o.sections === 'object' && !Array.isArray(o.sections)) {
|
|
134
|
+
for (const [k, v] of Object.entries(o.sections)) if (typeof v === 'boolean') sections[k] = v;
|
|
135
|
+
}
|
|
136
|
+
return { dirs, sections };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readLegacySections() {
|
|
140
|
+
try {
|
|
141
|
+
const v = JSON.parse(localStorage.getItem(LEGACY_SECTIONS_STORE) || 'null');
|
|
142
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* App-level owner of the tree state. `ready` flips once the stored state has
|
|
150
|
+
* been read (or the read failed) — App holds the tree back until then so the
|
|
151
|
+
* first paint is the remembered tree, not an all-closed flash. Writes are
|
|
152
|
+
* debounced and only start after hydration, so the initial empty state can
|
|
153
|
+
* never overwrite what is on disk.
|
|
154
|
+
*/
|
|
155
|
+
export function useTreeExpansion({ fetchImpl } = {}) {
|
|
156
|
+
const [state, setState] = useState(EMPTY_TREE_STATE);
|
|
157
|
+
const [ready, setReady] = useState(false);
|
|
158
|
+
const doFetch = fetchImpl || ((...a) => fetch(...a));
|
|
159
|
+
const fetchRef = useRef(doFetch);
|
|
160
|
+
fetchRef.current = doFetch;
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
let cancelled = false;
|
|
164
|
+
// A stalled read (cell cold start, a hung proxy) must not keep the Files
|
|
165
|
+
// panel empty: after HYDRATE_TIMEOUT_MS the tree renders all-closed.
|
|
166
|
+
const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
167
|
+
const timer = setTimeout(() => ctrl?.abort(), HYDRATE_TIMEOUT_MS);
|
|
168
|
+
fetchRef
|
|
169
|
+
.current('/_api/tree-state', ctrl ? { signal: ctrl.signal } : undefined)
|
|
170
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
171
|
+
.then((raw) => {
|
|
172
|
+
if (cancelled) return;
|
|
173
|
+
let next = normalizeClientState(raw);
|
|
174
|
+
const stored = raw && typeof raw === 'object' && raw.sections && Object.keys(raw.sections).length;
|
|
175
|
+
if (!stored) {
|
|
176
|
+
const legacy = readLegacySections();
|
|
177
|
+
if (legacy) next = normalizeClientState({ ...next, sections: legacy });
|
|
178
|
+
}
|
|
179
|
+
setState(next);
|
|
180
|
+
})
|
|
181
|
+
.catch(() => {})
|
|
182
|
+
.finally(() => {
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
if (!cancelled) setReady(true);
|
|
185
|
+
});
|
|
186
|
+
return () => {
|
|
187
|
+
cancelled = true;
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
};
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
const skipFirst = useRef(true);
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!ready) return;
|
|
195
|
+
// The hydrating setState lands in the same commit as `ready`; that value
|
|
196
|
+
// came FROM disk, so writing it back is pointless.
|
|
197
|
+
if (skipFirst.current) {
|
|
198
|
+
skipFirst.current = false;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const t = setTimeout(() => {
|
|
202
|
+
try {
|
|
203
|
+
fetchRef
|
|
204
|
+
.current('/_api/tree-state', {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: { 'content-type': 'text/plain' },
|
|
207
|
+
body: JSON.stringify(state),
|
|
208
|
+
})
|
|
209
|
+
.catch(() => {});
|
|
210
|
+
} catch {}
|
|
211
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
212
|
+
return () => clearTimeout(t);
|
|
213
|
+
}, [ready, state]);
|
|
214
|
+
|
|
215
|
+
const update = useCallback((fn) => setState((s) => fn(s)), []);
|
|
216
|
+
return { state, ready, update };
|
|
217
|
+
}
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
import { readFileSync, statSync } from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
+
import { canonicalAnnotations } from '../annotations/board-text.ts';
|
|
8
|
+
import { diffToOps } from '../annotations/ops.ts';
|
|
9
|
+
import { parseBoard } from '../annotations/schema.ts';
|
|
7
10
|
import type { Api } from '../api.ts';
|
|
8
11
|
import type { Context } from '../context.ts';
|
|
9
12
|
|
|
@@ -59,11 +62,37 @@ export function createCollab(ctx: Context, api: Api): Collab {
|
|
|
59
62
|
// (the predicate is only consulted at room-seed time, after `registry` is
|
|
60
63
|
// assigned). Flag-OFF → predicate returns true → seed unchanged.
|
|
61
64
|
let registryRef: Registry | null = null;
|
|
65
|
+
// Board texts this process's rooms projected to disk and whose watcher echo
|
|
66
|
+
// hasn't come back yet (a few per slug: flushes can outrun the watcher).
|
|
67
|
+
const ownProjections = new Map<string, string[]>();
|
|
68
|
+
// The board as disk last held it while the room agreed with it (the room's
|
|
69
|
+
// own projection, or an external write it imported). An external write is
|
|
70
|
+
// applied as the CHANGE from this base, never as a replacement: a room edit
|
|
71
|
+
// not yet flushed (a delete, a move) survives a writer that didn't know it.
|
|
72
|
+
const diskBase = new Map<string, string>();
|
|
62
73
|
const persistence = createPersistence({
|
|
63
74
|
ctx,
|
|
64
75
|
api,
|
|
65
76
|
fileForSlug,
|
|
77
|
+
onAnnotationsProjected: (slug, board) => {
|
|
78
|
+
const list = ownProjections.get(slug) ?? [];
|
|
79
|
+
list.push(board);
|
|
80
|
+
ownProjections.set(slug, list.slice(-4));
|
|
81
|
+
diskBase.set(slug, board);
|
|
82
|
+
},
|
|
83
|
+
onAnnotationsSeeded: (slug, board) => {
|
|
84
|
+
diskBase.set(slug, board);
|
|
85
|
+
},
|
|
66
86
|
shouldSeed: (slug) => !(ctx.sharedDoc && registryRef?.isPinned(slug)),
|
|
87
|
+
// Issue #133 — record comment ids as synced only once the hub holds them,
|
|
88
|
+
// and only for the room that IS the hub's doc (pinned). No hub linked:
|
|
89
|
+
// nothing is "synced" — recording then would label local comments with
|
|
90
|
+
// the last hub's identity and a relink would drop them (security review
|
|
91
|
+
// F1a). Linked but the runtime is not up yet: not confirmed.
|
|
92
|
+
commentsConfirmed: (slug) => {
|
|
93
|
+
if (!ctx.cfg?.linkedHub || !registryRef?.isPinned(slug)) return false;
|
|
94
|
+
return ctx.syncControl?.current?.()?.commentsConfirmedOnHub?.(slug) === true;
|
|
95
|
+
},
|
|
67
96
|
// A room restored from its own `.ydoc.bin` must not outrank a sidecar the
|
|
68
97
|
// hub (or an editor) wrote after that cache — see `reconcileAfterCache`.
|
|
69
98
|
// Only for rooms no hub provider owns: a pinned doc is the hub's replica
|
|
@@ -79,9 +108,9 @@ export function createCollab(ctx: Context, api: Api): Collab {
|
|
|
79
108
|
};
|
|
80
109
|
const file = await fileForSlug(slug);
|
|
81
110
|
if (!file) return;
|
|
82
|
-
if (newerThanCache(path.join(ctx.paths.designRoot, `${slug}.annotations.
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
111
|
+
if (newerThanCache(path.join(ctx.paths.designRoot, `${slug}.annotations.json`))) {
|
|
112
|
+
const board = await api.loadAnnotations(file);
|
|
113
|
+
if (board) applyAnnotationsToDoc(doc, board, 'seed');
|
|
85
114
|
}
|
|
86
115
|
if (newerThanCache(path.join(ctx.paths.commentsDir, `${slug}.json`))) {
|
|
87
116
|
applyCommentsToDoc(doc, await api.loadCommentsForFile(file), 'seed');
|
|
@@ -115,7 +144,9 @@ export function createCollab(ctx: Context, api: Api): Collab {
|
|
|
115
144
|
registry.peek(slug) !== null && !registry.isPinned(slug);
|
|
116
145
|
const reseedFromDisk = async (rel: string): Promise<void> => {
|
|
117
146
|
const cm = /^_comments\/(.+)\.json$/.exec(rel);
|
|
118
|
-
|
|
147
|
+
// DDR-242 — the board file. A legacy `.annotations.svg` reappearing on disk
|
|
148
|
+
// is NOT a board write (the boot migration quarantines it).
|
|
149
|
+
const am = /^(.+)\.annotations\.json$/.exec(rel);
|
|
119
150
|
const slug = cm?.[1] ?? am?.[1];
|
|
120
151
|
if (!slug) return;
|
|
121
152
|
const abs = path.join(ctx.paths.designRoot, rel);
|
|
@@ -140,7 +171,29 @@ export function createCollab(ctx: Context, api: Api): Collab {
|
|
|
140
171
|
if (ownsRoomFromDisk(slug)) registry.syncRoomFromComments(slug, parsed);
|
|
141
172
|
if (file) ctx.bus.emit('comments', { file, comments: parsed });
|
|
142
173
|
} else if (ownsRoomFromDisk(slug)) {
|
|
143
|
-
|
|
174
|
+
const text = readFileSync(abs, 'utf8');
|
|
175
|
+
// The room's own projection coming back: the room is at or AHEAD of
|
|
176
|
+
// it (an op may have landed since), so re-seeding from it would revert
|
|
177
|
+
// that op. Consumed once, so a later external write of the same bytes
|
|
178
|
+
// still re-seeds.
|
|
179
|
+
const own = ownProjections.get(slug);
|
|
180
|
+
const at = own ? own.indexOf(canonicalAnnotations(text) ?? '') : -1;
|
|
181
|
+
if (own && at >= 0) {
|
|
182
|
+
own.splice(0, at + 1);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const next = canonicalAnnotations(text);
|
|
186
|
+
const base = diskBase.get(slug);
|
|
187
|
+
if (next !== null && base !== undefined) {
|
|
188
|
+
const toMap = (t: string) => new Map(parseBoard(t).elements.map((e) => [e.id, e]));
|
|
189
|
+
const ops = diffToOps(toMap(base), toMap(next));
|
|
190
|
+
diskBase.set(slug, next);
|
|
191
|
+
if (!ops.length) return;
|
|
192
|
+
const applied = registry.applyOpsToRoom(slug, ops);
|
|
193
|
+
if (applied !== null && applied !== 'too-large') return;
|
|
194
|
+
}
|
|
195
|
+
if (next !== null) diskBase.set(slug, next);
|
|
196
|
+
registry.syncRoomFromAnnotations(slug, text);
|
|
144
197
|
}
|
|
145
198
|
} catch {
|
|
146
199
|
/* file vanished mid-flight or unreadable — leave state as-is */
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
// DDR-051 persistence wiring — bridges Room callbacks to the existing JSON
|
|
2
|
-
// snapshots (Phase 6 _comments/<slug>.json) +
|
|
2
|
+
// snapshots (Phase 6 _comments/<slug>.json) + the annotations board (DDR-242) + the
|
|
3
3
|
// new `.ydoc.bin` cache under _state/<slug>.ydoc.bin.
|
|
4
4
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
7
|
import * as Y from 'yjs';
|
|
8
8
|
|
|
9
|
+
import { noteAnnotationsOnDisk, replicaBoardText, writeReplica } from '../annotations/replica.ts';
|
|
10
|
+
import { parseBoard } from '../annotations/schema.ts';
|
|
9
11
|
import type { Api } from '../api.ts';
|
|
10
12
|
import type { Context } from '../context.ts';
|
|
11
13
|
// From the LEAF, never from `sync/codec.ts` — codec imports `Y_TYPES` from this
|
|
12
14
|
// file, so reaching for it here would close a cycle (see sync/limits.ts).
|
|
13
15
|
import { commentKey } from '../sync/comment-identity.ts';
|
|
16
|
+
import { type CommentLedger, commentLedgerFor } from '../sync/comment-ledger.ts';
|
|
14
17
|
import { MAX_ANNOTATIONS_BYTES, MAX_COMMENTS_BYTES, withinByteCap } from '../sync/limits.ts';
|
|
15
18
|
import { ensureStateDir, type RoomCallbacks } from './room.ts';
|
|
16
19
|
|
|
@@ -22,6 +25,8 @@ import { ensureStateDir, type RoomCallbacks } from './room.ts';
|
|
|
22
25
|
*/
|
|
23
26
|
export const Y_TYPES = {
|
|
24
27
|
comments: 'comments',
|
|
28
|
+
/** v1 annotations map (`svg` key) — read only for lazy migration; the v2
|
|
29
|
+
* replica lives in 'annotations2' (annotations/replica.ts, DDR-242). */
|
|
25
30
|
annotations: 'annotations',
|
|
26
31
|
presentation: 'presentation',
|
|
27
32
|
} as const;
|
|
@@ -60,6 +65,29 @@ export interface PersistenceDeps {
|
|
|
60
65
|
* seed). Absent → cache-only restore, the previous behavior.
|
|
61
66
|
*/
|
|
62
67
|
reconcileAfterCache?: (slug: string, doc: Y.Doc, cachedAtMs: number) => Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* Called with the board text a flush just projected to
|
|
70
|
+
* `<slug>.annotations.json`. The disk→room re-seed uses it to recognise the
|
|
71
|
+
* room's OWN write coming back through the file watcher: by then the room
|
|
72
|
+
* may be ahead (a later op), and re-seeding it from that echo reverted the
|
|
73
|
+
* later edit.
|
|
74
|
+
*/
|
|
75
|
+
onAnnotationsProjected?: (slug: string, board: string) => void;
|
|
76
|
+
/** The board a room was seeded from — the disk state it starts out agreeing with. */
|
|
77
|
+
onAnnotationsSeeded?: (slug: string, board: string) => void;
|
|
78
|
+
/**
|
|
79
|
+
* Issue #133 — which comment ids this machine synced before (see
|
|
80
|
+
* sync/comment-ledger.ts). Defaults to the process-wide ledger for the design
|
|
81
|
+
* root; tests inject a fresh one per simulated launch.
|
|
82
|
+
*/
|
|
83
|
+
commentLedger?: CommentLedger;
|
|
84
|
+
/**
|
|
85
|
+
* May the ledger record `slug`'s projected comments as synced? Only when the
|
|
86
|
+
* hub is known to hold them (see `commentsConfirmedOnHub`, sync/index.ts). A
|
|
87
|
+
* projection of a comment added offline would otherwise read as "synced" and
|
|
88
|
+
* the next cold start would drop it. Absent → always (tests, local projects).
|
|
89
|
+
*/
|
|
90
|
+
commentsConfirmed?: (slug: string) => boolean;
|
|
63
91
|
}
|
|
64
92
|
|
|
65
93
|
/**
|
|
@@ -93,6 +121,7 @@ function withinCap(slug: string, lane: string, value: string, max: number): bool
|
|
|
93
121
|
export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
94
122
|
const { ctx, api, fileForSlug } = deps;
|
|
95
123
|
const stateDir = ensureStateDir(ctx.paths.designRoot);
|
|
124
|
+
const ledger = deps.commentLedger ?? commentLedgerFor(ctx.paths.designRoot);
|
|
96
125
|
|
|
97
126
|
// Per-slug: every comment identity this doc has EVER carried (issue #111).
|
|
98
127
|
//
|
|
@@ -180,6 +209,27 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
|
180
209
|
return ids;
|
|
181
210
|
}
|
|
182
211
|
|
|
212
|
+
// Issue #133 (plan Task 8) — a comment that stays on this disk and out of the
|
|
213
|
+
// shared document is invisible to every other peer, and nothing said so: the
|
|
214
|
+
// report behind #133 had to be reconstructed from code. Say it once per
|
|
215
|
+
// change of the count, in the server log (which the in-app bug report
|
|
216
|
+
// attaches), and say when it clears.
|
|
217
|
+
const localOnlyBySlug = new Map<string, number>();
|
|
218
|
+
function reportLocalOnly(slug: string, n: number): void {
|
|
219
|
+
const prev = localOnlyBySlug.get(slug) ?? 0;
|
|
220
|
+
if (n === prev) return;
|
|
221
|
+
localOnlyBySlug.set(slug, n);
|
|
222
|
+
if (n > 0) {
|
|
223
|
+
console.warn(
|
|
224
|
+
`[collab/${slug}] comments: ${n} on this disk ${n === 1 ? 'is' : 'are'} not in the shared document yet — kept, not overwritten; other peers do not see ${n === 1 ? 'it' : 'them'} until ${n === 1 ? 'it arrives' : 'they arrive'}.`
|
|
225
|
+
);
|
|
226
|
+
} else {
|
|
227
|
+
console.log(
|
|
228
|
+
`[collab/${slug}] comments: every comment on this disk is in the shared document again.`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
183
233
|
function ydocBinPath(slug: string): string {
|
|
184
234
|
return path.join(stateDir, `${slug}.ydoc.bin`);
|
|
185
235
|
}
|
|
@@ -250,10 +300,12 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
|
250
300
|
if (missing.length > 0) arr.push(missing);
|
|
251
301
|
}
|
|
252
302
|
if (svg && typeof svg === 'string') {
|
|
253
|
-
|
|
254
|
-
|
|
303
|
+
// DDR-242 — `loadAnnotations` returns canonical board text (a legacy
|
|
304
|
+
// sidecar arrives already migrated); the replica takes it per element.
|
|
305
|
+
writeReplica(doc, parseBoard(svg).elements, 'seed');
|
|
255
306
|
}
|
|
256
307
|
}, 'seed');
|
|
308
|
+
if (svg && typeof svg === 'string') deps.onAnnotationsSeeded?.(slug, svg);
|
|
257
309
|
}
|
|
258
310
|
|
|
259
311
|
async function persistJson(slug: string, doc: Y.Doc): Promise<void> {
|
|
@@ -284,23 +336,40 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
|
284
336
|
// brings that id into the doc is itself a doc update, which re-arms the
|
|
285
337
|
// flush, and the next pass writes the merged state. A delete still
|
|
286
338
|
// materializes — its id IS in `everSeen`, so the write proceeds.
|
|
339
|
+
//
|
|
340
|
+
// Issue #133 — `everSeen` starts empty on every launch, so an id deleted
|
|
341
|
+
// by a peer while this machine was closed also reads as "never carried"
|
|
342
|
+
// and froze the file for good. The ledger knows it was synced from here
|
|
343
|
+
// before: an id in the ledger and absent from the doc is a delete.
|
|
287
344
|
const onDisk = await api.loadCommentsForFile(file);
|
|
288
|
-
const
|
|
345
|
+
const synced = ledger.get(slug);
|
|
346
|
+
const localOnly = onDisk.filter((c) => {
|
|
347
|
+
const k = commentKey(c);
|
|
348
|
+
return !everSeen.has(k) && !synced.has(k);
|
|
349
|
+
}).length;
|
|
350
|
+
const behind = localOnly > 0;
|
|
351
|
+
reportLocalOnly(slug, localOnly);
|
|
289
352
|
if (!behind && withinCap(slug, 'comments', JSON.stringify(list), MAX_COMMENTS_BYTES)) {
|
|
290
353
|
await api.saveCommentsForFile(file, list);
|
|
354
|
+
if (deps.commentsConfirmed?.(slug) ?? true) ledger.record(slug, list.map(commentKey));
|
|
291
355
|
}
|
|
292
356
|
}
|
|
293
357
|
|
|
294
|
-
// Annotations —
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
358
|
+
// Annotations — the replica → `<slug>.annotations.json` (DDR-242). A doc
|
|
359
|
+
// that was never populated (null) writes nothing, so a cold room can't
|
|
360
|
+
// clobber the file with emptiness (DDR-223).
|
|
361
|
+
const board = replicaBoardText(doc);
|
|
362
|
+
if (board !== null) {
|
|
363
|
+
if (withinCap(slug, 'annotations', board, MAX_ANNOTATIONS_BYTES)) {
|
|
299
364
|
// Projection must never re-enter onAnnotationsChanged: an old flush
|
|
300
|
-
// finishing after a new edit otherwise republishes the old
|
|
365
|
+
// finishing after a new edit otherwise republishes the old board and
|
|
301
366
|
// rolls back every peer. The API checks freshness after async IO and
|
|
302
367
|
// before its atomic rename; a later doc update schedules a new flush.
|
|
303
|
-
|
|
368
|
+
// Recorded BEFORE the write: the watcher event may be delivered before
|
|
369
|
+
// this await resumes.
|
|
370
|
+
deps.onAnnotationsProjected?.(slug, board);
|
|
371
|
+
noteAnnotationsOnDisk(doc, board);
|
|
372
|
+
await api.projectAnnotations(file, board, () => replicaBoardText(doc) === board);
|
|
304
373
|
}
|
|
305
374
|
}
|
|
306
375
|
}
|
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
import type { Awareness } from 'y-protocols/awareness';
|
|
10
10
|
import type * as Y from 'yjs';
|
|
11
11
|
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { canonicalAnnotations } from '../annotations/board-text.ts';
|
|
13
|
+
import { type ApplyResult, applyOps, type Op } from '../annotations/ops.ts';
|
|
14
|
+
import { readReplica, validActionId, writeReplica } from '../annotations/replica.ts';
|
|
15
|
+
import { parseBoard, serializeBoard } from '../annotations/schema.ts';
|
|
16
|
+
import { applyCommentsToDoc, stampAnnotationsEdit } from '../sync/codec.ts';
|
|
17
|
+
import { MAX_ANNOTATIONS_BYTES } from '../sync/limits.ts';
|
|
14
18
|
import { bridgeAwareness } from './awareness-bridge.ts';
|
|
15
|
-
import { Y_TYPES } from './persistence.ts';
|
|
16
19
|
import type { Room, RoomCallbacks } from './room.ts';
|
|
17
20
|
import { createRoom } from './room.ts';
|
|
18
21
|
|
|
@@ -49,12 +52,23 @@ export interface Registry {
|
|
|
49
52
|
*/
|
|
50
53
|
syncRoomFromComments(slug: string, comments: readonly unknown[]): void;
|
|
51
54
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* updated stroke set without waiting for a cold-open re-seed.
|
|
55
|
+
* Same shape as syncRoomFromComments but for the annotations replica
|
|
56
|
+
* (DDR-242). Every annotations write passes the post-write board; the room's
|
|
57
|
+
* replica is diffed to it, so collab peers receive only the changed elements.
|
|
56
58
|
*/
|
|
57
|
-
syncRoomFromAnnotations(slug: string,
|
|
59
|
+
syncRoomFromAnnotations(slug: string, text: string, actionId?: string): void;
|
|
60
|
+
/**
|
|
61
|
+
* The canvas op path while a room is live (code review H1): apply the batch
|
|
62
|
+
* to the room's replica — the freshest state, ahead of a debounced flush —
|
|
63
|
+
* instead of to disk, where a peer's not-yet-flushed edit would be reverted.
|
|
64
|
+
* `null` when no room (or a never-populated one) is live; `'too-large'` when
|
|
65
|
+
* the result would exceed the board cap (nothing written).
|
|
66
|
+
*/
|
|
67
|
+
applyOpsToRoom(
|
|
68
|
+
slug: string,
|
|
69
|
+
ops: readonly Op[],
|
|
70
|
+
actionId?: string
|
|
71
|
+
): ApplyResult | 'too-large' | null;
|
|
58
72
|
/**
|
|
59
73
|
* Phase 30 — project agent editing-presence onto a slug's room awareness so
|
|
60
74
|
* it crosses the hub (the loopback `ai-activity` bus event does not). `null`
|
|
@@ -110,27 +124,24 @@ export interface Registry {
|
|
|
110
124
|
}
|
|
111
125
|
|
|
112
126
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
127
|
+
* Make a doc's annotations replica hold `text` (canonical board JSON, or a
|
|
128
|
+
* legacy SVG that is converted) — the one write shape shared by the live
|
|
129
|
+
* disk→room bridge and the cache-restore reconcile (`collab/index.ts`). Only the
|
|
130
|
+
* elements / fields that differ become Yjs updates (DDR-242 §5); identical
|
|
131
|
+
* content is a no-op (no update → no persist loop). `actionId` lets the author's
|
|
132
|
+
* canvas recognise its own echo. Returns whether the doc changed.
|
|
118
133
|
*/
|
|
119
134
|
export function applyAnnotationsToDoc(
|
|
120
135
|
doc: Y.Doc,
|
|
121
|
-
|
|
136
|
+
text: string,
|
|
122
137
|
origin: unknown,
|
|
123
|
-
|
|
138
|
+
actionId?: string
|
|
124
139
|
): boolean {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (id) map.set(ANNOTATION_WRITE_ID, id);
|
|
131
|
-
else map.delete(ANNOTATION_WRITE_ID);
|
|
132
|
-
}, origin);
|
|
133
|
-
return true;
|
|
140
|
+
const board = canonicalAnnotations(text);
|
|
141
|
+
if (board === null) return false;
|
|
142
|
+
return writeReplica(doc, parseBoard(board).elements, origin, {
|
|
143
|
+
...(validActionId(actionId) ? { actionId } : {}),
|
|
144
|
+
});
|
|
134
145
|
}
|
|
135
146
|
|
|
136
147
|
export function createRegistry(callbacks: RoomCallbacks): Registry {
|
|
@@ -218,10 +229,35 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
|
|
|
218
229
|
applyCommentsToDoc(room.doc, comments as unknown[], 'inspector-write');
|
|
219
230
|
}
|
|
220
231
|
|
|
221
|
-
function syncRoomFromAnnotations(slug: string,
|
|
232
|
+
function syncRoomFromAnnotations(slug: string, text: string, actionId?: string): void {
|
|
222
233
|
const room = rooms.get(slug);
|
|
223
234
|
if (!room) return;
|
|
224
|
-
applyAnnotationsToDoc(room.doc,
|
|
235
|
+
applyAnnotationsToDoc(room.doc, text, 'inspector-write', actionId);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function applyOpsToRoom(
|
|
239
|
+
slug: string,
|
|
240
|
+
ops: readonly Op[],
|
|
241
|
+
actionId?: string
|
|
242
|
+
): ApplyResult | 'too-large' | null {
|
|
243
|
+
const room = rooms.get(slug);
|
|
244
|
+
// A replica that was never populated is not the board's truth — the
|
|
245
|
+
// caller takes the disk path instead (DDR-223: no value ≠ empty).
|
|
246
|
+
const cur = room ? readReplica(room.doc) : null;
|
|
247
|
+
if (!room || cur === null) return null;
|
|
248
|
+
const r = applyOps(new Map(cur.elements.map((e) => [e.id, e])), ops);
|
|
249
|
+
if (!r.touched.size) return r;
|
|
250
|
+
const next = [...r.state.values()];
|
|
251
|
+
if (serializeBoard(next).length > MAX_ANNOTATIONS_BYTES) return 'too-large';
|
|
252
|
+
// One transaction with the lane's newest-wins stamp, so peers get ONE
|
|
253
|
+
// update and the room's persistence projects it to disk.
|
|
254
|
+
room.doc.transact(() => {
|
|
255
|
+
writeReplica(room.doc, next, 'inspector-write', {
|
|
256
|
+
...(validActionId(actionId) ? { actionId } : {}),
|
|
257
|
+
});
|
|
258
|
+
stampAnnotationsEdit(room.doc, 'inspector-write');
|
|
259
|
+
}, 'inspector-write');
|
|
260
|
+
return r;
|
|
225
261
|
}
|
|
226
262
|
|
|
227
263
|
function setAgentEditing(slug: string, state: { name: string; since: number } | null): void {
|
|
@@ -275,6 +311,7 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
|
|
|
275
311
|
getDoc,
|
|
276
312
|
syncRoomFromComments,
|
|
277
313
|
syncRoomFromAnnotations,
|
|
314
|
+
applyOpsToRoom,
|
|
278
315
|
setAgentEditing,
|
|
279
316
|
attachHubAwareness,
|
|
280
317
|
pin,
|