@1agh/maude 1.2.0 → 1.3.1
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 +3 -1
- package/apps/studio/acp/index.ts +43 -7
- package/apps/studio/annotations-layer.tsx +191 -116
- package/apps/studio/annotations-model.ts +39 -0
- package/apps/studio/annotations-sync.ts +50 -0
- package/apps/studio/api.ts +732 -38
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-build.ts +49 -8
- package/apps/studio/canvas-edit.ts +468 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- package/apps/studio/canvas-list-watch.ts +33 -2
- package/apps/studio/canvas-notice-message.ts +16 -0
- package/apps/studio/canvas-notifications.tsx +19 -0
- package/apps/studio/canvas-shell.tsx +57 -6
- package/apps/studio/client/app.jsx +783 -245
- package/apps/studio/client/apply-edit-request.ts +55 -0
- package/apps/studio/client/canvas-url.js +39 -1
- package/apps/studio/client/export-center.jsx +111 -61
- package/apps/studio/client/github.js +38 -1
- package/apps/studio/client/index-loader.ts +76 -0
- package/apps/studio/client/panels/CloudBar.jsx +52 -6
- package/apps/studio/client/panels/GitPanel.jsx +103 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
- package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
- package/apps/studio/client/panels/SyncPanel.jsx +113 -0
- package/apps/studio/client/panels/TeamProjects.jsx +376 -0
- package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
- package/apps/studio/client/photo-knobs.jsx +3 -0
- package/apps/studio/client/share-dialog.jsx +91 -0
- package/apps/studio/client/share-link.js +94 -0
- package/apps/studio/client/styles/3-shell-maude.css +36 -18
- package/apps/studio/client/styles/4-components.css +18 -1
- package/apps/studio/client/tree-row-menu.jsx +48 -3
- package/apps/studio/client/whats-new.jsx +26 -32
- package/apps/studio/cloud/endpoints.ts +132 -0
- package/apps/studio/collab/awareness-bridge.ts +65 -14
- package/apps/studio/collab/index.ts +4 -0
- package/apps/studio/collab/persistence.ts +5 -1
- package/apps/studio/collab/registry.ts +9 -3
- package/apps/studio/collab/room.ts +13 -2
- package/apps/studio/context.ts +59 -0
- package/apps/studio/dist/client.bundle.js +1594 -1562
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/sonner.js +1 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/accepted-guard.ts +36 -0
- package/apps/studio/hmr-broadcast.ts +34 -1
- package/apps/studio/http.ts +300 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +172 -0
- package/apps/studio/notifications.tsx +292 -0
- package/apps/studio/runtime-bundle.ts +2 -0
- package/apps/studio/server.ts +59 -3
- package/apps/studio/sync/accepted-cold-start.ts +225 -0
- package/apps/studio/sync/accepted-link.ts +320 -0
- package/apps/studio/sync/action-stage.ts +343 -0
- package/apps/studio/sync/agent.ts +12 -60
- package/apps/studio/sync/cell-file-events.ts +3 -0
- package/apps/studio/sync/codec.ts +73 -6
- package/apps/studio/sync/ctl-provider.ts +15 -2
- package/apps/studio/sync/document-discovery.ts +81 -0
- package/apps/studio/sync/file-membership.ts +10 -2
- package/apps/studio/sync/file-plane.ts +309 -26
- package/apps/studio/sync/index.ts +1174 -21
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +315 -2
- package/apps/studio/sync/projection.ts +820 -23
- package/apps/studio/sync/repeated-module.ts +73 -0
- package/apps/studio/sync/revision-barrier.ts +129 -0
- package/apps/studio/sync/seed-repair.ts +46 -0
- package/apps/studio/sync/source-merge.ts +100 -0
- package/apps/studio/sync/source-ops.ts +289 -0
- package/apps/studio/sync/source-recovery.ts +70 -0
- package/apps/studio/sync/source-validation.ts +56 -0
- package/apps/studio/sync/status.ts +83 -1
- package/apps/studio/sync/transaction-client.ts +486 -0
- package/apps/studio/sync/writer-registry.ts +236 -0
- package/apps/studio/text-caret.ts +35 -0
- package/apps/studio/undo-hud.tsx +9 -87
- package/apps/studio/use-canvas-media-drop.tsx +4 -39
- package/apps/studio/use-tool-mode.tsx +44 -0
- package/apps/studio/whats-new.json +137 -0
- package/cli/lib/harness/codex-runtime.mjs +4 -1
- package/package.json +9 -8
- package/plugins/design/dependencies.json +3 -3
- package/plugins/design/templates/_shell.html +95 -7
- package/plugins/flow/dependencies.json +3 -3
|
@@ -37,14 +37,28 @@ import { parseSync } from 'oxc-parser';
|
|
|
37
37
|
export class CanvasEditError extends Error {
|
|
38
38
|
readonly canvas: string;
|
|
39
39
|
readonly id: string;
|
|
40
|
-
|
|
40
|
+
/** The target no longer holds the value the caller expected (a peer changed it). */
|
|
41
|
+
readonly conflict: boolean;
|
|
42
|
+
constructor(message: string, info: { canvas: string; id: string; conflict?: boolean }) {
|
|
41
43
|
super(message);
|
|
42
44
|
this.name = 'CanvasEditError';
|
|
43
45
|
this.canvas = info.canvas;
|
|
44
46
|
this.id = info.id;
|
|
47
|
+
this.conflict = info.conflict === true;
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Expected-current-value guard for a single-attribute write (audit 2026-09-13
|
|
53
|
+
* P1 #5). `expected` is what the caller believes the source holds now, `next`
|
|
54
|
+
* is what it is about to write; `null` means "attribute / style key absent".
|
|
55
|
+
* Values are the RAW strings the edit routes take (not JSON-encoded).
|
|
56
|
+
*/
|
|
57
|
+
export interface AttributePrecondition {
|
|
58
|
+
expected: string | null;
|
|
59
|
+
next: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
const PASCAL_CASE = /^[A-Z][A-Za-z0-9_]*$/;
|
|
49
63
|
// biome-ignore lint/suspicious/noExplicitAny: oxc-parser AST nodes are heterogeneous.
|
|
50
64
|
type AnyNode = any;
|
|
@@ -268,6 +282,12 @@ export interface EditResult {
|
|
|
268
282
|
* real write with delta 0 (RC1 rim-suppression finding).
|
|
269
283
|
*/
|
|
270
284
|
changed?: boolean;
|
|
285
|
+
/**
|
|
286
|
+
* What the target held BEFORE this write, read under the same lock. The
|
|
287
|
+
* value an undo must restore is what the write replaced — never what a
|
|
288
|
+
* client panel last believed was there (it can be a teammate's edit old).
|
|
289
|
+
*/
|
|
290
|
+
previous?: AttributeState;
|
|
271
291
|
}
|
|
272
292
|
|
|
273
293
|
/**
|
|
@@ -280,7 +300,8 @@ export async function editAttribute(
|
|
|
280
300
|
id: string,
|
|
281
301
|
attr: string,
|
|
282
302
|
value: string,
|
|
283
|
-
occurrence?: number
|
|
303
|
+
occurrence?: number,
|
|
304
|
+
precondition?: AttributePrecondition
|
|
284
305
|
): Promise<EditResult> {
|
|
285
306
|
return withLock(canvasAbsPath, async () => {
|
|
286
307
|
const file = Bun.file(canvasAbsPath);
|
|
@@ -291,13 +312,22 @@ export async function editAttribute(
|
|
|
291
312
|
});
|
|
292
313
|
}
|
|
293
314
|
const source = await file.text();
|
|
315
|
+
// Read under the same per-file lock as the write — no gap between the read
|
|
316
|
+
// that proves the precondition (and records what is replaced) and the rename.
|
|
317
|
+
const previous = readAttributeState(canvasAbsPath, source, id, attr, occurrence);
|
|
318
|
+
if (
|
|
319
|
+
precondition &&
|
|
320
|
+
checkPrecondition(previous, id, attr, canvasAbsPath, precondition) === 'already'
|
|
321
|
+
) {
|
|
322
|
+
return { source, delta: 0, changed: false, previous };
|
|
323
|
+
}
|
|
294
324
|
const next = applyEdit(canvasAbsPath, source, id, attr, value, occurrence);
|
|
295
|
-
if (next.source === source) return { source, delta: 0, changed: false };
|
|
325
|
+
if (next.source === source) return { source, delta: 0, changed: false, previous };
|
|
296
326
|
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
297
327
|
await Bun.write(tmp, next.source);
|
|
298
328
|
const { rename } = await import('node:fs/promises');
|
|
299
329
|
await rename(tmp, canvasAbsPath);
|
|
300
|
-
return { ...next, changed: true };
|
|
330
|
+
return { ...next, changed: true, previous };
|
|
301
331
|
});
|
|
302
332
|
}
|
|
303
333
|
|
|
@@ -313,7 +343,8 @@ export async function removeAttribute(
|
|
|
313
343
|
canvasAbsPath: string,
|
|
314
344
|
id: string,
|
|
315
345
|
attr: string,
|
|
316
|
-
occurrence?: number
|
|
346
|
+
occurrence?: number,
|
|
347
|
+
precondition?: AttributePrecondition
|
|
317
348
|
): Promise<EditResult> {
|
|
318
349
|
return withLock(canvasAbsPath, async () => {
|
|
319
350
|
const file = Bun.file(canvasAbsPath);
|
|
@@ -324,13 +355,124 @@ export async function removeAttribute(
|
|
|
324
355
|
});
|
|
325
356
|
}
|
|
326
357
|
const source = await file.text();
|
|
358
|
+
const previous = readAttributeState(canvasAbsPath, source, id, attr, occurrence);
|
|
359
|
+
if (
|
|
360
|
+
precondition &&
|
|
361
|
+
checkPrecondition(previous, id, attr, canvasAbsPath, precondition) === 'already'
|
|
362
|
+
) {
|
|
363
|
+
return { source, delta: 0, changed: false, previous };
|
|
364
|
+
}
|
|
327
365
|
const next = applyRemove(canvasAbsPath, source, id, attr, occurrence);
|
|
328
|
-
if (next.source === source) return { source, delta: 0, changed: false };
|
|
366
|
+
if (next.source === source) return { source, delta: 0, changed: false, previous };
|
|
329
367
|
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
330
368
|
await Bun.write(tmp, next.source);
|
|
331
369
|
const { rename } = await import('node:fs/promises');
|
|
332
370
|
await rename(tmp, canvasAbsPath);
|
|
333
|
-
return { ...next, changed: true };
|
|
371
|
+
return { ...next, changed: true, previous };
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** What one plain attribute or inline style key currently holds in source. */
|
|
376
|
+
export type AttributeState =
|
|
377
|
+
| { kind: 'absent' }
|
|
378
|
+
| { kind: 'literal'; value: string }
|
|
379
|
+
/** Present, but not a literal this module can compare (an expression, a spread). */
|
|
380
|
+
| { kind: 'expression' };
|
|
381
|
+
|
|
382
|
+
function literalText(node: AnyNode): string | null {
|
|
383
|
+
if (!node) return null;
|
|
384
|
+
if (node.type === 'Literal' || node.type === 'StringLiteral' || node.type === 'NumericLiteral') {
|
|
385
|
+
return typeof node.value === 'string' || typeof node.value === 'number'
|
|
386
|
+
? String(node.value)
|
|
387
|
+
: null;
|
|
388
|
+
}
|
|
389
|
+
if (node.type === 'TemplateLiteral' && node.expressions?.length === 0) {
|
|
390
|
+
return node.quasis?.[0]?.value?.cooked ?? null;
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Read the current value of `attr` (`style.<prop>` or a plain attribute name)
|
|
397
|
+
* on the element with `data-cd-id` `id`, with the same occurrence routing as
|
|
398
|
+
* `applyEdit` / `applyRemove`. Pure — exposed for tests and preconditions.
|
|
399
|
+
*/
|
|
400
|
+
export function readAttributeState(
|
|
401
|
+
canvasAbsPath: string,
|
|
402
|
+
source: string,
|
|
403
|
+
id: string,
|
|
404
|
+
attr: string,
|
|
405
|
+
occurrence?: number
|
|
406
|
+
): AttributeState {
|
|
407
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
408
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
409
|
+
throw new CanvasEditError(
|
|
410
|
+
`oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
|
|
411
|
+
{ canvas: canvasAbsPath, id }
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
if (typeof occurrence === 'number' && Number.isFinite(occurrence)) {
|
|
415
|
+
id = resolveUsageId(parsed.program, id, occurrence);
|
|
416
|
+
}
|
|
417
|
+
const hit = findOpening(parsed.program, id);
|
|
418
|
+
if (!hit) {
|
|
419
|
+
throw new CanvasEditError(`data-cd-id "${id}" not found in ${canvasAbsPath}`, {
|
|
420
|
+
canvas: canvasAbsPath,
|
|
421
|
+
id,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
if (attr.startsWith('style.')) {
|
|
425
|
+
const style = findAttribute(hit.opening, 'style');
|
|
426
|
+
if (!style) return { kind: 'absent' };
|
|
427
|
+
const obj = style.value?.type === 'JSXExpressionContainer' ? style.value.expression : null;
|
|
428
|
+
if (obj?.type !== 'ObjectExpression') return { kind: 'expression' };
|
|
429
|
+
const prop = attr.slice('style.'.length);
|
|
430
|
+
const propCamel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
431
|
+
for (const p of obj.properties as AnyNode[]) {
|
|
432
|
+
if (p?.type !== 'Property' && p?.type !== 'ObjectProperty') continue;
|
|
433
|
+
const k = p.key;
|
|
434
|
+
const kname =
|
|
435
|
+
k?.type === 'Identifier' ? k.name : k?.type === 'Literal' ? String(k.value) : null;
|
|
436
|
+
if (kname !== prop && kname !== propCamel) continue;
|
|
437
|
+
const text = literalText(p.value);
|
|
438
|
+
return text === null ? { kind: 'expression' } : { kind: 'literal', value: text };
|
|
439
|
+
}
|
|
440
|
+
// A spread may supply the key at runtime; "absent" would be a guess.
|
|
441
|
+
return (obj.properties as AnyNode[]).some((p) => p?.type === 'SpreadElement')
|
|
442
|
+
? { kind: 'expression' }
|
|
443
|
+
: { kind: 'absent' };
|
|
444
|
+
}
|
|
445
|
+
const found = findAttribute(hit.opening, attr);
|
|
446
|
+
if (!found) return { kind: 'absent' };
|
|
447
|
+
if (found.value == null) return { kind: 'literal', value: '' };
|
|
448
|
+
const direct = literalText(found.value);
|
|
449
|
+
if (direct !== null) return { kind: 'literal', value: direct };
|
|
450
|
+
const inner =
|
|
451
|
+
found.value.type === 'JSXExpressionContainer' ? literalText(found.value.expression) : null;
|
|
452
|
+
return inner === null ? { kind: 'expression' } : { kind: 'literal', value: inner };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* `'apply'` when the source still holds `expected`; `'already'` when it holds
|
|
457
|
+
* `next` (a retried or already-applied write — idempotent success). Anything
|
|
458
|
+
* else is a peer's newer value: refuse rather than overwrite it.
|
|
459
|
+
*/
|
|
460
|
+
function checkPrecondition(
|
|
461
|
+
state: AttributeState,
|
|
462
|
+
id: string,
|
|
463
|
+
attr: string,
|
|
464
|
+
canvasAbsPath: string,
|
|
465
|
+
pre: AttributePrecondition
|
|
466
|
+
): 'apply' | 'already' {
|
|
467
|
+
const holds = (value: string | null) =>
|
|
468
|
+
value === null ? state.kind === 'absent' : state.kind === 'literal' && state.value === value;
|
|
469
|
+
if (holds(pre.next)) return 'already';
|
|
470
|
+
if (holds(pre.expected)) return 'apply';
|
|
471
|
+
const name = attr.startsWith('style.') ? attr.slice('style.'.length) : attr;
|
|
472
|
+
throw new CanvasEditError(`${name} was changed by someone else — kept their value`, {
|
|
473
|
+
canvas: canvasAbsPath,
|
|
474
|
+
id,
|
|
475
|
+
conflict: true,
|
|
334
476
|
});
|
|
335
477
|
}
|
|
336
478
|
|
|
@@ -554,6 +696,7 @@ export function applyTextEdit(
|
|
|
554
696
|
const expr = (only as AnyNode).expression;
|
|
555
697
|
// A single `{'string literal'}` — rewrite the literal in place (DDR-150 P1).
|
|
556
698
|
if (isStringLit(expr)) {
|
|
699
|
+
if (expr.value === text) return { source, delta: 0 };
|
|
557
700
|
const s = new MagicString(source);
|
|
558
701
|
s.overwrite(expr.start as number, expr.end as number, JSON.stringify(text));
|
|
559
702
|
const out = s.toString();
|
|
@@ -585,6 +728,10 @@ export function applyTextEdit(
|
|
|
585
728
|
const start = only.start as number;
|
|
586
729
|
const end = only.end as number;
|
|
587
730
|
const raw = source.slice(start, end);
|
|
731
|
+
// The text it already shows is not an edit (plan T23): a multi-line JSX text
|
|
732
|
+
// renders as its lines joined by one space, and rewriting it with that same
|
|
733
|
+
// text used to fold the author's line breaks into one line.
|
|
734
|
+
if (jsxRenderedText(raw) === escapeJsxText(text)) return { source, delta: 0 };
|
|
588
735
|
// Preserve the original indentation/newline framing; swap only the visible text.
|
|
589
736
|
const lead = /^\s*/.exec(raw)?.[0] ?? '';
|
|
590
737
|
const trail = /\s*$/.exec(raw)?.[0] ?? '';
|
|
@@ -604,6 +751,26 @@ export function applyTextEdit(
|
|
|
604
751
|
// the index drifts (`.filter().map()`, reorders). Anything we can't target
|
|
605
752
|
// unambiguously returns null → the caller throws → routes to /design:edit.
|
|
606
753
|
|
|
754
|
+
/**
|
|
755
|
+
* JSX's own whitespace rule for a text child, on the raw source: each line is
|
|
756
|
+
* trimmed (inner lines on both sides), empty lines drop, and the rest join with
|
|
757
|
+
* one space. Entities stay encoded — the caller compares against escaped text.
|
|
758
|
+
*/
|
|
759
|
+
function jsxRenderedText(raw: string): string {
|
|
760
|
+
const lines = raw.split(/\r\n|\n|\r/);
|
|
761
|
+
if (lines.length === 1) return raw.trim();
|
|
762
|
+
return lines
|
|
763
|
+
.map((line, i) => {
|
|
764
|
+
let l = line;
|
|
765
|
+
if (i !== 0) l = l.replace(/^[ \t]+/, '');
|
|
766
|
+
if (i !== lines.length - 1) l = l.replace(/[ \t]+$/, '');
|
|
767
|
+
return l;
|
|
768
|
+
})
|
|
769
|
+
.filter((l) => l.length > 0)
|
|
770
|
+
.join(' ')
|
|
771
|
+
.trim();
|
|
772
|
+
}
|
|
773
|
+
|
|
607
774
|
function isStringLit(n: AnyNode): boolean {
|
|
608
775
|
return !!(
|
|
609
776
|
n &&
|
|
@@ -3930,6 +4097,86 @@ export function applySetArtboardKind(
|
|
|
3930
4097
|
return { source: out };
|
|
3931
4098
|
}
|
|
3932
4099
|
|
|
4100
|
+
/** Longest artboard name the label edit accepts (the chrome truncates long ones). */
|
|
4101
|
+
export const MAX_ARTBOARD_LABEL = 80;
|
|
4102
|
+
|
|
4103
|
+
/**
|
|
4104
|
+
* Rename an artboard — write its DCArtboard `label` string prop (plan T25/L08,
|
|
4105
|
+
* the double-click-the-name gesture). Addressed by the `id` prop like every
|
|
4106
|
+
* other artboard verb; the label is a JSX string attribute, so `editStringAttr`
|
|
4107
|
+
* escapes it. Pure; reparse-gated.
|
|
4108
|
+
*/
|
|
4109
|
+
export function applySetArtboardLabel(
|
|
4110
|
+
canvasAbsPath: string,
|
|
4111
|
+
source: string,
|
|
4112
|
+
artboardId: string,
|
|
4113
|
+
label: string
|
|
4114
|
+
): { source: string } {
|
|
4115
|
+
// Control characters never reach a JSX attribute.
|
|
4116
|
+
const clean = [...label]
|
|
4117
|
+
.filter((c) => c.charCodeAt(0) >= 0x20 && c.charCodeAt(0) !== 0x7f)
|
|
4118
|
+
.join('')
|
|
4119
|
+
.trim();
|
|
4120
|
+
if (!clean || clean.length > MAX_ARTBOARD_LABEL) {
|
|
4121
|
+
throw new CanvasEditError(`artboard name must be 1–${MAX_ARTBOARD_LABEL} characters`, {
|
|
4122
|
+
canvas: canvasAbsPath,
|
|
4123
|
+
id: artboardId,
|
|
4124
|
+
});
|
|
4125
|
+
}
|
|
4126
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
4127
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
4128
|
+
throw new CanvasEditError(
|
|
4129
|
+
`oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
|
|
4130
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
4131
|
+
);
|
|
4132
|
+
}
|
|
4133
|
+
const target = collectJsxByTag(parsed.program, 'DCArtboard').find(
|
|
4134
|
+
(a) => getStringAttr(a.openingElement, 'id') === artboardId
|
|
4135
|
+
);
|
|
4136
|
+
if (!target) {
|
|
4137
|
+
throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
|
|
4138
|
+
canvas: canvasAbsPath,
|
|
4139
|
+
id: artboardId,
|
|
4140
|
+
});
|
|
4141
|
+
}
|
|
4142
|
+
const s = new MagicString(source);
|
|
4143
|
+
editStringAttr(s, target.openingElement, 'label', clean, canvasAbsPath, artboardId);
|
|
4144
|
+
const out = s.toString();
|
|
4145
|
+
const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
|
|
4146
|
+
if (check.errors && check.errors.length > 0) {
|
|
4147
|
+
throw new CanvasEditError(
|
|
4148
|
+
`artboard rename produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
|
|
4149
|
+
{ canvas: canvasAbsPath, id: artboardId }
|
|
4150
|
+
);
|
|
4151
|
+
}
|
|
4152
|
+
return { source: out };
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4155
|
+
/** Rename an artboard on disk (atomic write + cross-process lock). */
|
|
4156
|
+
export async function setArtboardLabel(
|
|
4157
|
+
canvasAbsPath: string,
|
|
4158
|
+
artboardId: string,
|
|
4159
|
+
label: string
|
|
4160
|
+
): Promise<{ source: string }> {
|
|
4161
|
+
return withLock(canvasAbsPath, async () => {
|
|
4162
|
+
const file = Bun.file(canvasAbsPath);
|
|
4163
|
+
if (!(await file.exists())) {
|
|
4164
|
+
throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
|
|
4165
|
+
canvas: canvasAbsPath,
|
|
4166
|
+
id: artboardId,
|
|
4167
|
+
});
|
|
4168
|
+
}
|
|
4169
|
+
const source = await file.text();
|
|
4170
|
+
const next = applySetArtboardLabel(canvasAbsPath, source, artboardId, label);
|
|
4171
|
+
if (next.source === source) return next;
|
|
4172
|
+
const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
|
|
4173
|
+
await Bun.write(tmp, next.source);
|
|
4174
|
+
const { rename } = await import('node:fs/promises');
|
|
4175
|
+
await rename(tmp, canvasAbsPath);
|
|
4176
|
+
return next;
|
|
4177
|
+
});
|
|
4178
|
+
}
|
|
4179
|
+
|
|
3933
4180
|
/** Set an artboard's `kind` on disk (atomic write + cross-process lock). */
|
|
3934
4181
|
export async function setArtboardKind(
|
|
3935
4182
|
canvasAbsPath: string,
|
|
@@ -4649,6 +4896,28 @@ export function assembleCompSource(
|
|
|
4649
4896
|
// ---------------------------------------------------------------------------
|
|
4650
4897
|
// Edit shapes.
|
|
4651
4898
|
|
|
4899
|
+
/**
|
|
4900
|
+
* The literal an attribute expression IS — a string, number or boolean (a
|
|
4901
|
+
* negative number arrives as a unary minus) — or null for anything computed.
|
|
4902
|
+
*/
|
|
4903
|
+
function literalOfExpression(expr: AnyNode): string | number | boolean | null {
|
|
4904
|
+
if (!expr) return null;
|
|
4905
|
+
if (expr.type === 'Literal' || expr.type === 'StringLiteral') {
|
|
4906
|
+
const v = expr.value;
|
|
4907
|
+
return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : null;
|
|
4908
|
+
}
|
|
4909
|
+
if (expr.type === 'NumericLiteral') return typeof expr.value === 'number' ? expr.value : null;
|
|
4910
|
+
if (expr.type === 'BooleanLiteral') return typeof expr.value === 'boolean' ? expr.value : null;
|
|
4911
|
+
if (
|
|
4912
|
+
expr.type === 'UnaryExpression' &&
|
|
4913
|
+
expr.operator === '-' &&
|
|
4914
|
+
(expr.argument?.type === 'Literal' || expr.argument?.type === 'NumericLiteral') &&
|
|
4915
|
+
typeof expr.argument.value === 'number'
|
|
4916
|
+
)
|
|
4917
|
+
return -expr.argument.value;
|
|
4918
|
+
return null;
|
|
4919
|
+
}
|
|
4920
|
+
|
|
4652
4921
|
function editStringAttr(
|
|
4653
4922
|
s: MagicString,
|
|
4654
4923
|
opening: AnyNode,
|
|
@@ -4675,6 +4944,9 @@ function editStringAttr(
|
|
|
4675
4944
|
return;
|
|
4676
4945
|
}
|
|
4677
4946
|
if (v.type === 'Literal' || v.type === 'StringLiteral') {
|
|
4947
|
+
// Setting the value it already has is not an edit (plan T23): keep the
|
|
4948
|
+
// author's bytes, quote style included.
|
|
4949
|
+
if (typeof v.value === 'string' && v.value === value) return;
|
|
4678
4950
|
// Replace the whole `"value"` (quotes included) so we control the escaping.
|
|
4679
4951
|
// The value lands in a JSX *attribute*, where `"` must become `"` and
|
|
4680
4952
|
// `<`/`>` their entities — NOT JS backslash escaping. `JSON.stringify` would
|
|
@@ -4699,6 +4971,25 @@ function editStringAttr(
|
|
|
4699
4971
|
{ canvas: canvasAbsPath, id }
|
|
4700
4972
|
);
|
|
4701
4973
|
}
|
|
4974
|
+
// A NUMBER OR BOOLEAN STAYS ONE (plan T23). `height={400}`,
|
|
4975
|
+
// `durationInFrames={30}`, `aria-pressed={false}` used to come back as
|
|
4976
|
+
// `"400"` / `"30"` / `"false"` — a different type, which a component that
|
|
4977
|
+
// does arithmetic on its prop (every Remotion timing prop) silently
|
|
4978
|
+
// mis-reads — and even an edit to the value it already had rewrote the
|
|
4979
|
+
// bytes. The same value is now no edit; a new number or boolean is
|
|
4980
|
+
// written into the braces it came in.
|
|
4981
|
+
const lit = literalOfExpression(inner);
|
|
4982
|
+
if (lit !== null) {
|
|
4983
|
+
if (String(lit) === value) return;
|
|
4984
|
+
if (typeof lit === 'number' && /^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) {
|
|
4985
|
+
s.overwrite(inner.start, inner.end, value);
|
|
4986
|
+
return;
|
|
4987
|
+
}
|
|
4988
|
+
if (typeof lit === 'boolean' && (value === 'true' || value === 'false')) {
|
|
4989
|
+
s.overwrite(inner.start, inner.end, value);
|
|
4990
|
+
return;
|
|
4991
|
+
}
|
|
4992
|
+
}
|
|
4702
4993
|
// Replace the whole `{...}` with a plain quoted literal — keeps the
|
|
4703
4994
|
// resulting JSX readable. Same JSX-attribute escaping as above (NOT
|
|
4704
4995
|
// `JSON.stringify`, which would JS-escape a `"` and corrupt the source).
|
|
@@ -5354,3 +5645,173 @@ if (import.meta.main) {
|
|
|
5354
5645
|
process.exit(2);
|
|
5355
5646
|
}
|
|
5356
5647
|
}
|
|
5648
|
+
|
|
5649
|
+
// ---------------------------------------------------------------------------
|
|
5650
|
+
// Position-independent element identity — plan T23/T24 (DDR-241).
|
|
5651
|
+
//
|
|
5652
|
+
// `data-cd-id` is positional (component + pre-order index), so a sibling a
|
|
5653
|
+
// teammate inserted earlier in the same component renumbers everything after
|
|
5654
|
+
// it. An edit made through the UI is re-applied onto the project's newer
|
|
5655
|
+
// version when its own proposal lost a race (sync/source-ops.ts); before it
|
|
5656
|
+
// touches anything there, it has to find THE SAME element — by what the
|
|
5657
|
+
// element is, not where it was.
|
|
5658
|
+
//
|
|
5659
|
+
// A print is the enclosing component, the tag, the chain of ancestor tags and
|
|
5660
|
+
// the element's literal attributes (minus the one being edited, minus the
|
|
5661
|
+
// pipeline's own `data-cd-*`). It identifies an element when exactly one
|
|
5662
|
+
// element in the newer source carries it; anything else is ambiguous, and an
|
|
5663
|
+
// ambiguous edit is never guessed at.
|
|
5664
|
+
|
|
5665
|
+
export interface ElementPrint {
|
|
5666
|
+
component: string;
|
|
5667
|
+
tag: string;
|
|
5668
|
+
chain: string[];
|
|
5669
|
+
attrs: string;
|
|
5670
|
+
/** The element's own literal text (bounded) — absent for a text edit, whose text is what changes. */
|
|
5671
|
+
text?: string;
|
|
5672
|
+
}
|
|
5673
|
+
|
|
5674
|
+
function ownText(node: AnyNode): string {
|
|
5675
|
+
let t = '';
|
|
5676
|
+
for (const c of Array.isArray(node?.children) ? node.children : []) {
|
|
5677
|
+
if (c?.type === 'JSXText') t += String(c.value);
|
|
5678
|
+
}
|
|
5679
|
+
return t.replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
5680
|
+
}
|
|
5681
|
+
|
|
5682
|
+
function literalAttrs(opening: AnyNode, ignore: string | null): string {
|
|
5683
|
+
const out: string[] = [];
|
|
5684
|
+
for (const a of Array.isArray(opening?.attributes) ? opening.attributes : []) {
|
|
5685
|
+
if (a?.type !== 'JSXAttribute' || a.name?.type !== 'JSXIdentifier') continue;
|
|
5686
|
+
const name = String(a.name.name);
|
|
5687
|
+
if (name.startsWith('data-cd-') || name === 'style' || name === ignore || name === 'key')
|
|
5688
|
+
continue;
|
|
5689
|
+
const v = a.value;
|
|
5690
|
+
const lit =
|
|
5691
|
+
v?.type === 'Literal' || v?.type === 'StringLiteral'
|
|
5692
|
+
? String(v.value)
|
|
5693
|
+
: v?.type === 'JSXExpressionContainer' &&
|
|
5694
|
+
(v.expression?.type === 'Literal' || v.expression?.type === 'StringLiteral')
|
|
5695
|
+
? String(v.expression.value)
|
|
5696
|
+
: v === null || v === undefined
|
|
5697
|
+
? 'true'
|
|
5698
|
+
: '{expr}';
|
|
5699
|
+
out.push(`${name}=${lit}`);
|
|
5700
|
+
}
|
|
5701
|
+
return out.sort().join('|');
|
|
5702
|
+
}
|
|
5703
|
+
|
|
5704
|
+
function printAll(
|
|
5705
|
+
canvasAbsPath: string,
|
|
5706
|
+
source: string,
|
|
5707
|
+
ignoreAttr: string | null,
|
|
5708
|
+
withText = true
|
|
5709
|
+
): Array<{ id: string; print: ElementPrint }> {
|
|
5710
|
+
const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
|
|
5711
|
+
if (parsed.errors && parsed.errors.length > 0) return [];
|
|
5712
|
+
const frames: Array<{ componentName: string; jsxIndex: number }> = [
|
|
5713
|
+
{ componentName: '', jsxIndex: 0 },
|
|
5714
|
+
];
|
|
5715
|
+
const chain: string[] = [];
|
|
5716
|
+
const out: Array<{ id: string; print: ElementPrint }> = [];
|
|
5717
|
+
function visit(node: AnyNode): void {
|
|
5718
|
+
if (!node || typeof node !== 'object') return;
|
|
5719
|
+
if (Array.isArray(node)) {
|
|
5720
|
+
for (const c of node) visit(c);
|
|
5721
|
+
return;
|
|
5722
|
+
}
|
|
5723
|
+
if (typeof node.type !== 'string') return;
|
|
5724
|
+
const comp = componentNameOf(node);
|
|
5725
|
+
if (comp !== null) frames.push({ componentName: comp, jsxIndex: 0 });
|
|
5726
|
+
if (node.type === 'JSXElement') {
|
|
5727
|
+
const frame = frames[frames.length - 1] as { componentName: string; jsxIndex: number };
|
|
5728
|
+
const idx = frame.jsxIndex;
|
|
5729
|
+
frame.jsxIndex += 1;
|
|
5730
|
+
const tag = jsxTagName(node) ?? '?';
|
|
5731
|
+
out.push({
|
|
5732
|
+
id: authoredCdId(node.openingElement) ?? computeId(frame.componentName, idx),
|
|
5733
|
+
print: {
|
|
5734
|
+
component: frame.componentName,
|
|
5735
|
+
tag,
|
|
5736
|
+
chain: [...chain],
|
|
5737
|
+
attrs: literalAttrs(node.openingElement, ignoreAttr),
|
|
5738
|
+
...(withText ? { text: ownText(node) } : {}),
|
|
5739
|
+
},
|
|
5740
|
+
});
|
|
5741
|
+
chain.push(tag);
|
|
5742
|
+
if (node.openingElement) visit(node.openingElement.attributes);
|
|
5743
|
+
visit(node.children);
|
|
5744
|
+
chain.pop();
|
|
5745
|
+
if (comp !== null) frames.pop();
|
|
5746
|
+
return;
|
|
5747
|
+
}
|
|
5748
|
+
for (const k of Object.keys(node)) {
|
|
5749
|
+
if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
|
|
5750
|
+
visit(node[k]);
|
|
5751
|
+
}
|
|
5752
|
+
if (comp !== null) frames.pop();
|
|
5753
|
+
}
|
|
5754
|
+
visit(parsed.program);
|
|
5755
|
+
return out;
|
|
5756
|
+
}
|
|
5757
|
+
|
|
5758
|
+
const samePrint = (a: ElementPrint, b: ElementPrint) =>
|
|
5759
|
+
a.component === b.component &&
|
|
5760
|
+
a.tag === b.tag &&
|
|
5761
|
+
a.attrs === b.attrs &&
|
|
5762
|
+
(a.text ?? null) === (b.text ?? null) &&
|
|
5763
|
+
a.chain.length === b.chain.length &&
|
|
5764
|
+
a.chain.every((t, i) => t === b.chain[i]);
|
|
5765
|
+
|
|
5766
|
+
/** The print of the element `id` in `source`, or null. */
|
|
5767
|
+
export function elementPrint(
|
|
5768
|
+
canvasAbsPath: string,
|
|
5769
|
+
source: string,
|
|
5770
|
+
id: string,
|
|
5771
|
+
ignoreAttr: string | null = null,
|
|
5772
|
+
withText = true
|
|
5773
|
+
): ElementPrint | null {
|
|
5774
|
+
return (
|
|
5775
|
+
printAll(canvasAbsPath, source, ignoreAttr, withText).find((e) => e.id === id)?.print ?? null
|
|
5776
|
+
);
|
|
5777
|
+
}
|
|
5778
|
+
|
|
5779
|
+
/**
|
|
5780
|
+
* The id the element printed `print` has in `source` — `hint` first (the
|
|
5781
|
+
* common case: nothing moved), else the ONE element carrying the print. Null
|
|
5782
|
+
* when it is gone or ambiguous.
|
|
5783
|
+
*/
|
|
5784
|
+
export function relocateElement(
|
|
5785
|
+
canvasAbsPath: string,
|
|
5786
|
+
source: string,
|
|
5787
|
+
hint: string,
|
|
5788
|
+
print: ElementPrint,
|
|
5789
|
+
ignoreAttr: string | null = null
|
|
5790
|
+
): string | null {
|
|
5791
|
+
const all = printAll(canvasAbsPath, source, ignoreAttr, print.text !== undefined);
|
|
5792
|
+
const atHint = all.find((e) => e.id === hint);
|
|
5793
|
+
if (atHint && samePrint(atHint.print, print)) return hint;
|
|
5794
|
+
const matches = all.filter((e) => samePrint(e.print, print));
|
|
5795
|
+
return matches.length === 1 ? (matches[0] as { id: string }).id : null;
|
|
5796
|
+
}
|
|
5797
|
+
|
|
5798
|
+
/** T23 — every JSX element of `source` with its id and print (corpus checks). */
|
|
5799
|
+
export function listElements(
|
|
5800
|
+
canvasAbsPath: string,
|
|
5801
|
+
source: string
|
|
5802
|
+
): Array<{ id: string; print: ElementPrint }> {
|
|
5803
|
+
return printAll(canvasAbsPath, source, null);
|
|
5804
|
+
}
|
|
5805
|
+
|
|
5806
|
+
/** T23 — how many elements of `source` carry a print no other element shares. */
|
|
5807
|
+
export function printUniqueness(
|
|
5808
|
+
canvasAbsPath: string,
|
|
5809
|
+
source: string
|
|
5810
|
+
): { elements: number; unique: number } {
|
|
5811
|
+
const all = printAll(canvasAbsPath, source, null);
|
|
5812
|
+
const key = (p: ElementPrint) =>
|
|
5813
|
+
`${p.component}|${p.tag}|${p.chain.join('>')}|${p.attrs}|${p.text ?? ''}`;
|
|
5814
|
+
const counts = new Map<string, number>();
|
|
5815
|
+
for (const e of all) counts.set(key(e.print), (counts.get(key(e.print)) ?? 0) + 1);
|
|
5816
|
+
return { elements: all.length, unique: all.filter((e) => counts.get(key(e.print)) === 1).length };
|
|
5817
|
+
}
|