@1agh/maude 0.58.0 → 0.58.2
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/.ai/cache/_stats.json +1 -1
- package/apps/studio/acp/bridge.ts +165 -15
- package/apps/studio/artboard-hug-commit.ts +30 -0
- package/apps/studio/client/app.jsx +107 -33
- package/apps/studio/client/panels/ChatPanel.jsx +25 -2
- package/apps/studio/client/panels/timeline-media-cache.js +38 -2
- package/apps/studio/client/styles/6-acp-chat.css +12 -0
- package/apps/studio/clip-ops.ts +84 -35
- package/apps/studio/dist/client.bundle.js +899 -899
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/sync/canvas-path.ts +329 -0
- package/apps/studio/sync/codec.ts +42 -0
- package/apps/studio/sync/index.ts +490 -17
- package/apps/studio/sync/projection.ts +11 -1
- package/apps/studio/sync/remote-docs.ts +122 -15
- package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
- package/apps/studio/test/artboard-hug-commit.test.ts +36 -0
- package/apps/studio/test/clip-ops.test.ts +84 -2
- package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
- package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
- package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
- package/apps/studio/test/sync-canvas-path.test.ts +200 -0
- package/apps/studio/test/sync-path-pull.test.ts +402 -0
- package/apps/studio/test/sync-remote-docs.test.ts +55 -3
- package/apps/studio/test/sync-runtime.test.ts +118 -0
- package/apps/studio/test/timeline-media-cache.test.ts +41 -0
- package/apps/studio/whats-new.json +9 -0
- package/package.json +8 -8
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// Where a synced canvas belongs on disk — the one answer both receivers use.
|
|
2
|
+
//
|
|
3
|
+
// A document name carries a FLATTENED slug (`ui/2026/social/summer-camp.tsx` →
|
|
4
|
+
// `ui-2026-social-summer-camp`). `/`→`-` is not reversible, so neither receiver
|
|
5
|
+
// could reconstruct the folder and both wrote the body flat at the design root.
|
|
6
|
+
// A file there is inside no `canvasGroups` entry, which means the file tree does
|
|
7
|
+
// not list it and `scanCanvases` does not sync it onward: the canvas arrives and
|
|
8
|
+
// is invisible.
|
|
9
|
+
//
|
|
10
|
+
// So the path travels WITH the document, in `syncMeta.path` — an existing,
|
|
11
|
+
// already-synced, never-materialized lane. This module is the receiver's half:
|
|
12
|
+
// it decides whether to believe it.
|
|
13
|
+
//
|
|
14
|
+
// WHY THE RECEIVER CHECKS RATHER THAN TRUSTS. DDR-054 treats hub-pushed content
|
|
15
|
+
// as untrusted, and a path is a strictly more dangerous input than a slug — a
|
|
16
|
+
// slug can only name a file, a path chooses a directory. The load-bearing rule
|
|
17
|
+
// is #7 below: the path must slug BACK to the document carrying it. That makes
|
|
18
|
+
// a hostile path self-defeating, because a path pointing somewhere else no
|
|
19
|
+
// longer addresses this document. The value is not believed because the sender
|
|
20
|
+
// is trusted; it is believed because it was checked against a value the
|
|
21
|
+
// receiver derived independently.
|
|
22
|
+
//
|
|
23
|
+
// A rejected path is never fatal. It degrades to `fallbackCanvasPath`, which is
|
|
24
|
+
// today's behaviour made VISIBLE — flat, but inside a canvas group.
|
|
25
|
+
//
|
|
26
|
+
// ONE COPY, TWO RUNTIMES (the `apps/hub/Dockerfile` rule). The hub imports this
|
|
27
|
+
// file rather than re-typing it in `.mjs`, exactly as it does for
|
|
28
|
+
// `sync/autocommit.ts` and `cloud/mirror.mjs`: re-typing a guarantee is
|
|
29
|
+
// re-typing it without its tests. Hence dependency-free — no `node:path`, no
|
|
30
|
+
// `node:fs`, nothing a plain-Node hub or a bun-compiled sidecar has to resolve.
|
|
31
|
+
// Callers that need real disk resolution pass `join`/`resolve`/`sep` in, the
|
|
32
|
+
// way `pullTargets` already does.
|
|
33
|
+
|
|
34
|
+
import { canvasSlugFromRel } from '../canvas-slug.ts';
|
|
35
|
+
|
|
36
|
+
/** A `canvasGroups[]` entry, as loose as the config actually is. */
|
|
37
|
+
export interface CanvasGroupLike {
|
|
38
|
+
path?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type CanvasPathVerdict = { ok: true; rel: string } | { ok: false; reason: string };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Cap on a wire path. Generous next to any real project tree and far below the
|
|
45
|
+
* point where a path becomes an amplification vector.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_CANVAS_PATH_LEN = 400;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Cap on path DEPTH, which the length cap does not imply.
|
|
51
|
+
*
|
|
52
|
+
* The hub's `slugFromDocName` accepts any slash-free tail, so a 400-character
|
|
53
|
+
* path can still be 200 single-character components — and the receivers create
|
|
54
|
+
* parent directories with `mkdirSync(recursive: true)`. Eight is past anything
|
|
55
|
+
* a real project nests.
|
|
56
|
+
*/
|
|
57
|
+
export const MAX_CANVAS_PATH_DEPTH = 8;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The charset one path component may use.
|
|
61
|
+
*
|
|
62
|
+
* `slugFromDocName`'s explicit-charset style, widened by exactly one character:
|
|
63
|
+
* a space, because canvas filenames legitimately contain them (`Kanban App.tsx`
|
|
64
|
+
* — the slug transform maps whitespace to `_`, so such files exist and sync
|
|
65
|
+
* today). No dot: that is what keeps `.`/`..` and extension smuggling out of
|
|
66
|
+
* every component but the last, which is handled separately.
|
|
67
|
+
*/
|
|
68
|
+
// NOTE the `(?![\s\S])` terminator rather than `$`: in JavaScript `$` ALSO
|
|
69
|
+
// matches immediately before a trailing newline, so `/^[a-z]+$/.test('ui\n')`
|
|
70
|
+
// is true. Rule 1's control-character check catches that today, but these
|
|
71
|
+
// regexes are documented as the charset boundary and must hold on their own.
|
|
72
|
+
const COMPONENT = /^[A-Za-z0-9_-][A-Za-z0-9 _-]*(?![\s\S])/;
|
|
73
|
+
|
|
74
|
+
/** The final component: the same charset plus exactly one `.tsx` suffix. */
|
|
75
|
+
const FINAL_COMPONENT = /^[A-Za-z0-9_-][A-Za-z0-9 _-]*\.tsx(?![\s\S])/;
|
|
76
|
+
|
|
77
|
+
/** A component may not end in a space — trailing-space filenames are a mess on
|
|
78
|
+
* every platform and a rename hazard on Windows. */
|
|
79
|
+
const TRAILING_SPACE = / $/;
|
|
80
|
+
|
|
81
|
+
export interface ValidateCanvasPathArgs {
|
|
82
|
+
/** The wire value. Anything at all — this is untrusted input. */
|
|
83
|
+
path: unknown;
|
|
84
|
+
/** The slug of the document that carried it. Rule 7 checks the path against it. */
|
|
85
|
+
slug: string;
|
|
86
|
+
/** Design root, relative to the repo root (`.design`). Rule 7 passes it through. */
|
|
87
|
+
designRel?: string;
|
|
88
|
+
/** Declared groups. A path outside every group is refused (rule 8). */
|
|
89
|
+
canvasGroups?: readonly CanvasGroupLike[];
|
|
90
|
+
/**
|
|
91
|
+
* Accept a group this project has not declared (rule 8 relaxed to "the first
|
|
92
|
+
* component is group-shaped").
|
|
93
|
+
*
|
|
94
|
+
* ONLY for a genuinely fresh link — a design root with no canvases and no
|
|
95
|
+
* `config.json` of its own. Such a project has declared nothing, so refusing
|
|
96
|
+
* every path against a DEFAULT group list would reject the whole of a project
|
|
97
|
+
* whose author simply calls their group `screens`, and the empty-folder case
|
|
98
|
+
* would arrive invisible. The caller is expected to then WRITE the groups it
|
|
99
|
+
* learned, so this relaxation applies once and never again.
|
|
100
|
+
*
|
|
101
|
+
* It widens which DIRECTORY a path may name; it does not weaken rules 1-7,
|
|
102
|
+
* and rule 7 still ties every path to its own document.
|
|
103
|
+
*/
|
|
104
|
+
allowUndeclaredGroup?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Believe a `syncMeta.path`, or say why not.
|
|
109
|
+
*
|
|
110
|
+
* Every rule exists because its absence lets a path do something a slug cannot.
|
|
111
|
+
* Ordered cheapest-first, and each returns a reason rather than a boolean so a
|
|
112
|
+
* rejection is debuggable from a log line.
|
|
113
|
+
*/
|
|
114
|
+
export function validateCanvasPath(args: ValidateCanvasPathArgs): CanvasPathVerdict {
|
|
115
|
+
const { path, slug, designRel = '.design', canvasGroups, allowUndeclaredGroup } = args;
|
|
116
|
+
|
|
117
|
+
// 1. A string, bounded, with no NUL or control characters. A NUL truncates
|
|
118
|
+
// the name at the syscall boundary on some platforms, so a path that
|
|
119
|
+
// validates as one thing can create another.
|
|
120
|
+
if (typeof path !== 'string' || path.length === 0) return no('not a non-empty string');
|
|
121
|
+
if (path.length > MAX_CANVAS_PATH_LEN) return no(`longer than ${MAX_CANVAS_PATH_LEN} characters`);
|
|
122
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: refusing them is the point.
|
|
123
|
+
if (/[\u0000-\u001f\u007f]/.test(path)) return no('contains a control character');
|
|
124
|
+
|
|
125
|
+
// 2. Relative. Rejects `/etc/...`, a UNC `\\host\share`, and `C:\...`.
|
|
126
|
+
if (path.startsWith('/')) return no('absolute');
|
|
127
|
+
if (/^[A-Za-z]:/.test(path)) return no('carries a drive letter');
|
|
128
|
+
|
|
129
|
+
// 3. `/` is the only separator. A backslash is a LEGAL filename character on
|
|
130
|
+
// POSIX, so accepting it would let `a\../b` read as one component here and
|
|
131
|
+
// as a traversal on a receiver that normalises separators.
|
|
132
|
+
if (path.includes('\\')) return no('contains a backslash');
|
|
133
|
+
|
|
134
|
+
// 4 + 5. Componentwise. No empty component (`a//b`, a trailing `/`), no `.`
|
|
135
|
+
// or `..`, and nothing outside the charset. Note this runs on the RAW
|
|
136
|
+
// string: `canvasSlugFromRel` below percent-decodes, and a check that
|
|
137
|
+
// ran after decoding could be walked past with `%2e%2e`.
|
|
138
|
+
const parts = path.split('/');
|
|
139
|
+
if (parts.length < 2) return no('not inside a canvas group');
|
|
140
|
+
if (parts.length > MAX_CANVAS_PATH_DEPTH) return no('nested deeper than a project ever is');
|
|
141
|
+
for (let i = 0; i < parts.length; i++) {
|
|
142
|
+
const part = parts[i];
|
|
143
|
+
if (part.length === 0) return no('has an empty path component');
|
|
144
|
+
if (part === '.' || part === '..') return no('has a dot component');
|
|
145
|
+
if (TRAILING_SPACE.test(part)) return no('has a component ending in a space');
|
|
146
|
+
const re = i === parts.length - 1 ? FINAL_COMPONENT : COMPONENT;
|
|
147
|
+
if (!re.test(part)) return no(`component ${i} is outside the canvas charset`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 6. Covered by FINAL_COMPONENT — stated separately because it is a rule, not
|
|
151
|
+
// an implementation detail: the sync body lane is `.tsx` (Phase 3.6).
|
|
152
|
+
|
|
153
|
+
// 6b. THE ONE PLACE RULE 7 IS NOT A BIJECTION. `canvasSlugFromRel` strips an
|
|
154
|
+
// optional leading `<designRel>/`, so with `designRoot: "mocks"` the path
|
|
155
|
+
// `mocks/ui/card.tsx` slugs to `ui-card` — the same slug as `ui/card.tsx`,
|
|
156
|
+
// one component the check cannot see. Contained either way, but "the path
|
|
157
|
+
// slugs back to its own document" has to be true without an asterisk, so
|
|
158
|
+
// the redundant prefix is refused outright rather than silently stripped.
|
|
159
|
+
const designPrefix = String(designRel ?? '').replace(/^\/+|\/+$/g, '');
|
|
160
|
+
if (designPrefix && (path === designPrefix || path.startsWith(`${designPrefix}/`))) {
|
|
161
|
+
return no('repeats the design-root prefix');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 7. THE ONE THAT MAKES THIS SAFE. The path must slug back to the document
|
|
165
|
+
// that carried it. `canvasSlugFromRel` is IMPORTED, never re-typed: a
|
|
166
|
+
// second copy that drifts turns the whole check into decoration.
|
|
167
|
+
const derived = canvasSlugFromRel(path, designRel);
|
|
168
|
+
if (derived !== String(slug ?? '').toLowerCase()) {
|
|
169
|
+
return no(`slugs to "${derived}", not to this document`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 8. Inside a declared canvas group. Containment against the design root
|
|
173
|
+
// follows from rules 2-4 (relative, no `..`, no backslash), and this is
|
|
174
|
+
// the stronger statement anyway: a path outside every group is a path the
|
|
175
|
+
// tree would not list, which is the failure this whole module exists for.
|
|
176
|
+
// Callers keep their own resolve()-based check as well — belt and braces
|
|
177
|
+
// at a create.
|
|
178
|
+
// Case-INSENSITIVE, because rule 7 above lowercases: `ui/CARD.tsx` satisfies
|
|
179
|
+
// rule 7 against slug `ui-card`, and a case-sensitive group check would then
|
|
180
|
+
// accept or refuse the same document depending on the peer's filesystem.
|
|
181
|
+
const groups = normalizedGroups(canvasGroups);
|
|
182
|
+
const lower = path.toLowerCase();
|
|
183
|
+
if (!groups.some((g) => lower.startsWith(`${g.toLowerCase()}/`))) {
|
|
184
|
+
// Rules 4-5 already proved every component is group-shaped, so under the
|
|
185
|
+
// fresh-link relaxation there is nothing further to check — the path is
|
|
186
|
+
// inside SOME group, just not one this project has heard of yet.
|
|
187
|
+
if (!allowUndeclaredGroup) return no('outside every declared canvas group');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { ok: true, rel: path };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Where a canvas goes when no path arrived, or the one that did was refused.
|
|
195
|
+
*
|
|
196
|
+
* Flat — a slug cannot be un-flattened without guessing — but placed INSIDE a
|
|
197
|
+
* canvas group wherever that is possible, because the design root is not inside
|
|
198
|
+
* one: the file tree and `scanCanvases` enumerate `canvasGroups`, so the old
|
|
199
|
+
* design-root fallback produced a file nobody could see and that never synced
|
|
200
|
+
* onward. A flat file in `ui/` is untidy; a flat file at the root is lost.
|
|
201
|
+
*
|
|
202
|
+
* THE CONSTRAINT THAT SHAPES THIS. Moving a canvas is already a new document
|
|
203
|
+
* (the slug derives from the path), so the fallback may not put the body
|
|
204
|
+
* anywhere that re-slugs. Writing `ui-legacy` to `ui/ui-legacy.tsx` would slug
|
|
205
|
+
* to `ui-ui-legacy` on the receiver's next scan: a SECOND document, syncing the
|
|
206
|
+
* same bytes under a different name, with the original left orphaned on the hub
|
|
207
|
+
* — a duplicate is strictly worse than an untidy file.
|
|
208
|
+
*
|
|
209
|
+
* So the group is chosen by the slug's own prefix, which is where the slug came
|
|
210
|
+
* from in the first place: `ui-legacy` → `ui/legacy.tsx`, which slugs back to
|
|
211
|
+
* `ui-legacy`. Visible AND the same document. A slug matching no declared group
|
|
212
|
+
* (a canvas that was already outside every group at its author) keeps today's
|
|
213
|
+
* design-root behaviour — identity preserved, still invisible, and no worse
|
|
214
|
+
* than before. That case cannot be fixed without either a path or a rename, and
|
|
215
|
+
* a path is exactly what `syncMeta.path` supplies.
|
|
216
|
+
*/
|
|
217
|
+
export function fallbackCanvasPath(
|
|
218
|
+
slug: string,
|
|
219
|
+
canvasGroups?: readonly CanvasGroupLike[]
|
|
220
|
+
): string {
|
|
221
|
+
const safeSlug = sanitizeSlug(slug);
|
|
222
|
+
// LONGEST match wins: `ui/social` is a better home than `ui` for
|
|
223
|
+
// `ui-social-x`, and declaration order should not decide that. No other
|
|
224
|
+
// tie-break is needed — two groups of equal length that both prefix one slug
|
|
225
|
+
// are the same group.
|
|
226
|
+
const matching = normalizedGroups(canvasGroups)
|
|
227
|
+
.filter((g) => safeSlug.startsWith(`${prefixOf(g)}-`))
|
|
228
|
+
.sort((a, b) => b.length - a.length);
|
|
229
|
+
for (const group of matching) {
|
|
230
|
+
const rest = safeSlug.slice(prefixOf(group).length + 1);
|
|
231
|
+
if (rest.length > 0) return `${group}/${rest}.tsx`;
|
|
232
|
+
}
|
|
233
|
+
return `${safeSlug}.tsx`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The slug prefix a group path contributes (`ui/social` → `ui-social`). */
|
|
237
|
+
function prefixOf(group: string): string {
|
|
238
|
+
return group.replace(/\//g, '-').replace(/\s+/g, '_').toLowerCase();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A slug reduced to something that can only ever be ONE filename.
|
|
243
|
+
*
|
|
244
|
+
* Both `slugFromDocName`s already constrain the charset upstream, so this is
|
|
245
|
+
* the second line rather than the first — but it is the line standing between
|
|
246
|
+
* a bad slug and a directory of the sender's choosing, and it is cheap.
|
|
247
|
+
*/
|
|
248
|
+
function sanitizeSlug(slug: unknown): string {
|
|
249
|
+
const s = String(slug ?? '')
|
|
250
|
+
.toLowerCase()
|
|
251
|
+
.replace(/[^a-z0-9 ._-]/g, '-')
|
|
252
|
+
// No `..` — contained either way once the separators are gone, but a
|
|
253
|
+
// filename containing it invites a later reader to normalise it.
|
|
254
|
+
.replace(/\.{2,}/g, '-')
|
|
255
|
+
// No dotfile, and no leading separator-ish character.
|
|
256
|
+
.replace(/^[.\s-]+/, '');
|
|
257
|
+
return s.length > 0 ? s : 'canvas';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Declared group paths, normalised and stripped of anything that escapes. */
|
|
261
|
+
function normalizedGroups(canvasGroups?: readonly CanvasGroupLike[]): string[] {
|
|
262
|
+
const out: string[] = [];
|
|
263
|
+
for (const g of canvasGroups ?? []) {
|
|
264
|
+
const p = normalizeGroup(g?.path);
|
|
265
|
+
if (p && !out.includes(p)) out.push(p);
|
|
266
|
+
}
|
|
267
|
+
return out.length > 0 ? out : ['system', 'ui'];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* One group path, or null when it is not usable as a containment prefix.
|
|
272
|
+
*
|
|
273
|
+
* The same shape as `isContainedRel` (context.ts) and deliberately not a call
|
|
274
|
+
* to it — that function is in the dev-server's config module, which pulls
|
|
275
|
+
* `node:path`, and this file has to load in the hub's plain-Node runtime.
|
|
276
|
+
* Stricter here, which is the safe direction: a group path that is not a plain
|
|
277
|
+
* relative directory name is dropped rather than clamped.
|
|
278
|
+
*/
|
|
279
|
+
function normalizeGroup(raw: unknown): string | null {
|
|
280
|
+
if (typeof raw !== 'string') return null;
|
|
281
|
+
const p = raw.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
282
|
+
if (!p) return null;
|
|
283
|
+
if (/^[A-Za-z]:/.test(p)) return null;
|
|
284
|
+
const parts = p.split('/');
|
|
285
|
+
for (const part of parts) {
|
|
286
|
+
if (!part || part === '.' || part === '..') return null;
|
|
287
|
+
if (!COMPONENT.test(part)) return null;
|
|
288
|
+
}
|
|
289
|
+
return p;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function no(reason: string): CanvasPathVerdict {
|
|
293
|
+
return { ok: false, reason };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The receiver's whole decision in one call: believe the wire path, or fall
|
|
298
|
+
* back — and either way return a design-root-relative body path.
|
|
299
|
+
*
|
|
300
|
+
* Both receivers call THIS rather than composing the two above, so "what does a
|
|
301
|
+
* refused path do" has one answer instead of two that can drift.
|
|
302
|
+
*/
|
|
303
|
+
export function resolveCanvasBodyRel(args: {
|
|
304
|
+
path: unknown;
|
|
305
|
+
slug: string;
|
|
306
|
+
designRel?: string;
|
|
307
|
+
canvasGroups?: readonly CanvasGroupLike[];
|
|
308
|
+
allowUndeclaredGroup?: boolean;
|
|
309
|
+
/** Called with the reason when a PRESENT path is refused. Absent is not a
|
|
310
|
+
* refusal — an older peer omits the field and that is the normal case. */
|
|
311
|
+
onRefused?: (reason: string) => void;
|
|
312
|
+
}): { rel: string; fromPath: boolean } {
|
|
313
|
+
const { path, slug, designRel, canvasGroups, allowUndeclaredGroup, onRefused } = args;
|
|
314
|
+
if (path !== undefined && path !== null) {
|
|
315
|
+
const verdict = validateCanvasPath({
|
|
316
|
+
path,
|
|
317
|
+
slug,
|
|
318
|
+
designRel,
|
|
319
|
+
canvasGroups,
|
|
320
|
+
allowUndeclaredGroup,
|
|
321
|
+
});
|
|
322
|
+
if (verdict.ok) return { rel: verdict.rel, fromPath: true };
|
|
323
|
+
onRefused?.(verdict.reason);
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
rel: fallbackCanvasPath(slug, canvasGroups),
|
|
327
|
+
fromPath: false,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
@@ -368,6 +368,48 @@ export function seededByFromDoc(doc: Y.Doc): number | null {
|
|
|
368
368
|
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Record WHERE this canvas lives, design-root-relative (`syncMeta.path`).
|
|
373
|
+
*
|
|
374
|
+
* The document name carries only the flattened slug, and `/`→`-` is not
|
|
375
|
+
* reversible — so a receiver that has never seen this canvas cannot know which
|
|
376
|
+
* folder it belongs in, and wrote it flat. This is the lane that fixes that.
|
|
377
|
+
*
|
|
378
|
+
* `syncMeta` is the right home rather than a new one: it is already per
|
|
379
|
+
* document, already synced, already optional on the wire (an older peer simply
|
|
380
|
+
* omits it), and — the part that matters here — NEVER MATERIALIZED TO DISK.
|
|
381
|
+
* `.meta.json` could not carry this: a canvas's own path is per-machine-ish
|
|
382
|
+
* bookkeeping about the sync lane, and putting it in the sidecar would both
|
|
383
|
+
* commit a redundant fact to the tenant's repo and put it under `.meta.json`'s
|
|
384
|
+
* shared-subset rules (META_LOCAL_KEYS governs a DIFFERENT lane and would not
|
|
385
|
+
* keep it off disk).
|
|
386
|
+
*
|
|
387
|
+
* Only ever called with a path the caller DERIVED from a real local file — not
|
|
388
|
+
* with a value read off the wire. Re-stamping something a receiver refused
|
|
389
|
+
* would launder an invalid path into the project on the next hop.
|
|
390
|
+
*
|
|
391
|
+
* Idempotent: an unchanged value writes nothing, so this can sit in the same
|
|
392
|
+
* transaction as every body apply without churning the wire.
|
|
393
|
+
*/
|
|
394
|
+
export function stampCanvasPath(doc: Y.Doc, rel: string, origin?: unknown): boolean {
|
|
395
|
+
const next = String(rel ?? '').replace(/\\/g, '/');
|
|
396
|
+
if (!next) return false;
|
|
397
|
+
const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
|
|
398
|
+
if (map.get('path') === next) return false;
|
|
399
|
+
doc.transact(() => {
|
|
400
|
+
map.set('path', next);
|
|
401
|
+
}, origin);
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The doc-side path stamp, or null when no peer ever stamped one (an older
|
|
406
|
+
* peer, or a document nobody has opened from a real local file). UNTRUSTED —
|
|
407
|
+
* every caller must put it through `validateCanvasPath` (canvas-path.ts). */
|
|
408
|
+
export function canvasPathFromDoc(doc: Y.Doc): string | null {
|
|
409
|
+
const v = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('path');
|
|
410
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
411
|
+
}
|
|
412
|
+
|
|
371
413
|
/* ---------------------------------------------------------------- css */
|
|
372
414
|
|
|
373
415
|
/** The synced canvas CSS string held in the doc, or null when unset/empty. */
|