@1agh/maude 1.2.0 → 1.3.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/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 +689 -36
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-edit.ts +391 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- 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 +686 -241
- package/apps/studio/client/apply-edit-request.ts +55 -0
- 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 +35 -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/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 +1586 -1554
- 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/http.ts +289 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +150 -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 +1078 -19
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +285 -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 +79 -1
- package/apps/studio/sync/transaction-client.ts +460 -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/whats-new.json +101 -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 +47 -0
- package/plugins/flow/dependencies.json +3 -3
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// A canvas body that is one whole module written again right after itself.
|
|
2
|
+
//
|
|
3
|
+
// How it happens: two replicas each seed the SAME file into a shared document
|
|
4
|
+
// independently (a desktop adopting its file while the hub's own copy was not
|
|
5
|
+
// visible yet), and the CRDT keeps both inserts — the text concatenates. Every
|
|
6
|
+
// later start that repeats the race doubles it again (2×, 4×, …), and a write
|
|
7
|
+
// cut short leaves a whole copy plus a prefix of itself. Reported live on the
|
|
8
|
+
// Alligators project, 2026-09-15: 38 canvases at exactly 4×, one at 1× + 879
|
|
9
|
+
// characters.
|
|
10
|
+
//
|
|
11
|
+
// A module has ONE default export, so a body carrying the same module twice is
|
|
12
|
+
// never something a person wrote. The repair keeps exactly one copy; anything
|
|
13
|
+
// that is not a clean repeat (a real edit in either copy) is left alone for the
|
|
14
|
+
// conflict machinery.
|
|
15
|
+
|
|
16
|
+
const DEFAULT_EXPORT = /\bexport\s+default\b/g;
|
|
17
|
+
|
|
18
|
+
export interface RepeatedModule {
|
|
19
|
+
/** The one copy to keep. */
|
|
20
|
+
unit: string;
|
|
21
|
+
/** Whole copies found (a trailing partial copy is counted as +0.5 → rounded up). */
|
|
22
|
+
times: number;
|
|
23
|
+
/** True when the tail was a cut-short copy (a prefix of the module). */
|
|
24
|
+
partialTail: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The single module a repeated canvas body is made of, or null when the body
|
|
29
|
+
* is not a clean repeat of one module.
|
|
30
|
+
*/
|
|
31
|
+
export function collapseRepeatedModule(body: string): RepeatedModule | null {
|
|
32
|
+
if (typeof body !== 'string' || body.length < 32) return null;
|
|
33
|
+
// Cheap gate: one module, one default export.
|
|
34
|
+
const exports = body.match(DEFAULT_EXPORT)?.length ?? 0;
|
|
35
|
+
if (exports < 2) return null;
|
|
36
|
+
// The second copy starts where the file's own opening appears again.
|
|
37
|
+
const probe = body.slice(0, Math.min(80, Math.floor(body.length / 2)));
|
|
38
|
+
const second = body.indexOf(probe, 1);
|
|
39
|
+
if (second <= 0) return null;
|
|
40
|
+
const unit = body.slice(0, second);
|
|
41
|
+
if ((unit.match(DEFAULT_EXPORT)?.length ?? 0) !== 1) return null;
|
|
42
|
+
let rest = body.slice(second);
|
|
43
|
+
let times = 1;
|
|
44
|
+
while (rest.startsWith(unit)) {
|
|
45
|
+
rest = rest.slice(unit.length);
|
|
46
|
+
times++;
|
|
47
|
+
}
|
|
48
|
+
if (rest === '') return times >= 2 ? { unit, times, partialTail: false } : null;
|
|
49
|
+
// A copy cut short: what is left must be the start of the module, and long
|
|
50
|
+
// enough that this is not a coincidence.
|
|
51
|
+
if (rest.length >= probe.length && unit.startsWith(rest)) {
|
|
52
|
+
return { unit, times: times + 1, partialTail: true };
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A stylesheet written k≥2 times in a row (the same race on a canvas's CSS
|
|
59
|
+
* lane). Exact repeats only — a real stylesheet is never itself twice over.
|
|
60
|
+
*/
|
|
61
|
+
export function collapseRepeatedText(
|
|
62
|
+
text: string,
|
|
63
|
+
minUnit = 32
|
|
64
|
+
): { unit: string; times: number } | null {
|
|
65
|
+
if (typeof text !== 'string' || text.length < minUnit * 2) return null;
|
|
66
|
+
for (let k = 8; k >= 2; k--) {
|
|
67
|
+
if (text.length % k) continue;
|
|
68
|
+
const unit = text.slice(0, text.length / k);
|
|
69
|
+
if (unit.length < minUnit || !unit.trim()) continue;
|
|
70
|
+
if (unit.repeat(k) === text) return { unit, times: k };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Revision visibility — plan T14 (DDR-241).
|
|
2
|
+
//
|
|
3
|
+
// One accepted action can change several canvases (a folder move, an agent's
|
|
4
|
+
// multi-file edit, a restore). The hub stamps every document it writes for a
|
|
5
|
+
// revision with `acceptedRevision` and the number of documents in that
|
|
6
|
+
// revision (`acceptedCohort`). A receiving studio must not leave its checkout —
|
|
7
|
+
// which is what the renderer and the file tree read — holding half of such a
|
|
8
|
+
// revision: canvas A at revision 12 importing a canvas B still at 11 is a
|
|
9
|
+
// state the project never had.
|
|
10
|
+
//
|
|
11
|
+
// So each document's projection ARRIVES here when it is ready to write the
|
|
12
|
+
// revision, and the barrier releases the whole cohort together, in one tick,
|
|
13
|
+
// once every document has arrived. A document this peer does not hold yet (one
|
|
14
|
+
// created in the same action arrives by pull) or will never hold (outside its
|
|
15
|
+
// groups) must not wedge the others: after `timeoutMs` the barrier releases
|
|
16
|
+
// what it has. A document that is already on disk at the revision (a fresh
|
|
17
|
+
// projection's first write) reports `present` and holds nothing.
|
|
18
|
+
|
|
19
|
+
export interface RevisionBarrier {
|
|
20
|
+
/**
|
|
21
|
+
* A projection is ready to write `revision`. Returns true when the write is
|
|
22
|
+
* HELD — `release` runs later (possibly synchronously, before this returns,
|
|
23
|
+
* when this arrival completes the cohort).
|
|
24
|
+
*/
|
|
25
|
+
arrive(revision: number, cohort: number, key: string, release: () => void): boolean;
|
|
26
|
+
/** A document already on disk at `revision` — counts, holds nothing. */
|
|
27
|
+
present(revision: number, cohort: number, key: string): void;
|
|
28
|
+
/** Revisions still waiting (tests/diagnostics). */
|
|
29
|
+
waiting(): number[];
|
|
30
|
+
stop(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Cohort {
|
|
34
|
+
expected: number;
|
|
35
|
+
arrived: Set<string>;
|
|
36
|
+
held: Map<string, () => void>;
|
|
37
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createRevisionBarrier(
|
|
41
|
+
opts: { timeoutMs?: number; log?: Pick<Console, 'warn'> } = {}
|
|
42
|
+
) {
|
|
43
|
+
const timeoutMs = opts.timeoutMs ?? 1500;
|
|
44
|
+
const log = opts.log ?? console;
|
|
45
|
+
const cohorts = new Map<number, Cohort>();
|
|
46
|
+
/** Revisions already released — a late arrival writes straight through. */
|
|
47
|
+
let releasedUpTo = 0;
|
|
48
|
+
const released = new Set<number>();
|
|
49
|
+
/**
|
|
50
|
+
* Documents that reported `present` before anyone waited on their revision
|
|
51
|
+
* (a pulled document can land before its sibling's update is flushed). Kept
|
|
52
|
+
* briefly so the cohort, when it opens, already counts them.
|
|
53
|
+
*/
|
|
54
|
+
const early = new Map<number, Set<string>>();
|
|
55
|
+
|
|
56
|
+
function release(revision: number, reason: 'complete' | 'timeout'): void {
|
|
57
|
+
const c = cohorts.get(revision);
|
|
58
|
+
if (!c) return;
|
|
59
|
+
cohorts.delete(revision);
|
|
60
|
+
if (c.timer) clearTimeout(c.timer);
|
|
61
|
+
released.add(revision);
|
|
62
|
+
if (released.size > 512) {
|
|
63
|
+
// Bounded memory: forget the oldest half; they are long settled.
|
|
64
|
+
const sorted = [...released].sort((a, b) => a - b);
|
|
65
|
+
for (const r of sorted.slice(0, 256)) released.delete(r);
|
|
66
|
+
releasedUpTo = Math.max(releasedUpTo, sorted[255] ?? 0);
|
|
67
|
+
}
|
|
68
|
+
if (reason === 'timeout') {
|
|
69
|
+
log.warn(
|
|
70
|
+
`[sync/revision] revision ${revision}: ${c.arrived.size} of ${c.expected} documents here — showing them without the rest.`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
// One tick: every held write runs back to back.
|
|
74
|
+
for (const fn of c.held.values()) {
|
|
75
|
+
try {
|
|
76
|
+
fn();
|
|
77
|
+
} catch (err) {
|
|
78
|
+
log.warn(`[sync/revision] a held write failed: ${(err as Error).message}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cohortFor(revision: number, expected: number): Cohort | null {
|
|
84
|
+
if (released.has(revision) || revision <= releasedUpTo) return null;
|
|
85
|
+
let c = cohorts.get(revision);
|
|
86
|
+
if (!c) {
|
|
87
|
+
c = { expected, arrived: new Set(early.get(revision) ?? []), held: new Map(), timer: null };
|
|
88
|
+
early.delete(revision);
|
|
89
|
+
c.timer = setTimeout(() => release(revision, 'timeout'), timeoutMs);
|
|
90
|
+
cohorts.set(revision, c);
|
|
91
|
+
}
|
|
92
|
+
return c;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const barrier: RevisionBarrier = {
|
|
96
|
+
arrive(revision, cohort, key, fn) {
|
|
97
|
+
if (!(cohort > 1) || !Number.isInteger(revision)) return false;
|
|
98
|
+
const c = cohortFor(revision, cohort);
|
|
99
|
+
if (!c) return false;
|
|
100
|
+
c.arrived.add(key);
|
|
101
|
+
c.held.set(key, fn);
|
|
102
|
+
if (c.arrived.size >= c.expected) release(revision, 'complete');
|
|
103
|
+
return true;
|
|
104
|
+
},
|
|
105
|
+
present(revision, cohort, key) {
|
|
106
|
+
if (!(cohort > 1) || !Number.isInteger(revision)) return;
|
|
107
|
+
// Counts toward a revision somebody is waiting on; never opens one — a
|
|
108
|
+
// document already on disk has nothing to wait for.
|
|
109
|
+
const c = cohorts.get(revision);
|
|
110
|
+
if (!c) {
|
|
111
|
+
if (released.has(revision) || revision <= releasedUpTo) return;
|
|
112
|
+
const set = early.get(revision) ?? new Set<string>();
|
|
113
|
+
set.add(key);
|
|
114
|
+
early.set(revision, set);
|
|
115
|
+
if (early.size > 64) early.delete(Math.min(...early.keys()));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
c.arrived.add(key);
|
|
119
|
+
if (c.arrived.size >= c.expected) release(revision, 'complete');
|
|
120
|
+
},
|
|
121
|
+
waiting: () => [...cohorts.keys()],
|
|
122
|
+
stop() {
|
|
123
|
+
for (const c of cohorts.values()) if (c.timer) clearTimeout(c.timer);
|
|
124
|
+
cohorts.clear();
|
|
125
|
+
early.clear();
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
return barrier;
|
|
129
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Both sync handlers must repair the live seed race before projecting (#121).
|
|
2
|
+
import type * as Y from 'yjs';
|
|
3
|
+
import {
|
|
4
|
+
applyCssToDoc,
|
|
5
|
+
applyHtmlToDoc,
|
|
6
|
+
cssFromDoc,
|
|
7
|
+
htmlFromDoc,
|
|
8
|
+
markSeeded,
|
|
9
|
+
seededByFromDoc,
|
|
10
|
+
stampBodyEdit,
|
|
11
|
+
} from './codec.ts';
|
|
12
|
+
import { isExactRepeat } from './cold-start.ts';
|
|
13
|
+
|
|
14
|
+
export const SEED_REPAIR_WINDOW_MS = 10_000;
|
|
15
|
+
type Seed = { body: string; css: string | null; until: number };
|
|
16
|
+
const seeds = new WeakMap<Y.Doc, Seed>();
|
|
17
|
+
|
|
18
|
+
export function rememberSeed(doc: Y.Doc, body: string, css: string | null, origin: unknown): void {
|
|
19
|
+
seeds.set(doc, { body, css, until: Date.now() + SEED_REPAIR_WINDOW_MS });
|
|
20
|
+
markSeeded(doc, origin);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Exact repeats only; divergent seeds are preserved for conflict recovery. */
|
|
24
|
+
export function repairSeedDuplication(doc: Y.Doc, origin: unknown): ('body' | 'css')[] {
|
|
25
|
+
const seed = seeds.get(doc);
|
|
26
|
+
if (!seed) return [];
|
|
27
|
+
if (Date.now() > seed.until) {
|
|
28
|
+
seeds.delete(doc);
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
if (seededByFromDoc(doc) !== doc.clientID) return [];
|
|
32
|
+
const repaired: ('body' | 'css')[] = [];
|
|
33
|
+
doc.transact(() => {
|
|
34
|
+
if (isExactRepeat(htmlFromDoc(doc), seed.body)) {
|
|
35
|
+
applyHtmlToDoc(doc, seed.body, origin);
|
|
36
|
+
stampBodyEdit(doc, origin);
|
|
37
|
+
repaired.push('body');
|
|
38
|
+
}
|
|
39
|
+
const css = cssFromDoc(doc);
|
|
40
|
+
if (css !== null && seed.css !== null && isExactRepeat(css, seed.css)) {
|
|
41
|
+
applyCssToDoc(doc, seed.css, origin);
|
|
42
|
+
repaired.push('css');
|
|
43
|
+
}
|
|
44
|
+
}, origin);
|
|
45
|
+
return repaired;
|
|
46
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Three-way merge of a canvas body — audit 2026-09-13 P0 #1 (plan T2).
|
|
2
|
+
//
|
|
3
|
+
// A local save authored on an older base used to be imported by diffing the
|
|
4
|
+
// file against the CURRENT doc, which turned every stale byte into an edit: a
|
|
5
|
+
// peer's newer title was "reverted" by a save that only changed a colour. The
|
|
6
|
+
// base is the body disk and doc last agreed on; the merge applies each side's
|
|
7
|
+
// edits relative to it and refuses the moment two edits touch.
|
|
8
|
+
//
|
|
9
|
+
// Character-level on purpose: canvases put many attributes on one line, and a
|
|
10
|
+
// line merge would call the audit's title/colour race a conflict. The price is
|
|
11
|
+
// that "independent" means "non-touching spans", not "semantically unrelated"
|
|
12
|
+
// — which is why the caller validates the merged source before importing it,
|
|
13
|
+
// and why anything ambiguous is preserved for a person rather than guessed at.
|
|
14
|
+
|
|
15
|
+
import { diffChars } from 'diff';
|
|
16
|
+
|
|
17
|
+
export type MergeResult =
|
|
18
|
+
| { ok: true; merged: string }
|
|
19
|
+
/** `overlap` — the edits touch; `budget` — too large to diff safely. */
|
|
20
|
+
| { ok: false; reason: 'overlap' | 'budget' };
|
|
21
|
+
|
|
22
|
+
/** One replacement of `base[start, end)` by `text`. `start === end` inserts. */
|
|
23
|
+
interface Edit {
|
|
24
|
+
start: number;
|
|
25
|
+
end: number;
|
|
26
|
+
text: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Same bounds the text lane uses for its import diff (codec applyTextLane). */
|
|
30
|
+
const DIFF_BUDGET = { maxEditLength: 4096, timeout: 50 };
|
|
31
|
+
|
|
32
|
+
function editsOf(base: string, next: string): Edit[] | null {
|
|
33
|
+
let prefix = 0;
|
|
34
|
+
const maxPrefix = Math.min(base.length, next.length);
|
|
35
|
+
while (prefix < maxPrefix && base.charCodeAt(prefix) === next.charCodeAt(prefix)) prefix++;
|
|
36
|
+
let suffix = 0;
|
|
37
|
+
const maxSuffix = Math.min(base.length - prefix, next.length - prefix);
|
|
38
|
+
while (
|
|
39
|
+
suffix < maxSuffix &&
|
|
40
|
+
base.charCodeAt(base.length - 1 - suffix) === next.charCodeAt(next.length - 1 - suffix)
|
|
41
|
+
) {
|
|
42
|
+
suffix++;
|
|
43
|
+
}
|
|
44
|
+
// Never split a surrogate pair across an edit boundary.
|
|
45
|
+
if (prefix > 0 && /[\uD800-\uDBFF]/.test(base[prefix - 1] ?? '')) prefix--;
|
|
46
|
+
if (suffix > 0 && /[\uDC00-\uDFFF]/.test(base[base.length - suffix] ?? '')) suffix--;
|
|
47
|
+
const from = base.slice(prefix, base.length - suffix);
|
|
48
|
+
const to = next.slice(prefix, next.length - suffix);
|
|
49
|
+
if (!from && !to) return [];
|
|
50
|
+
if (!from || !to) return [{ start: prefix, end: prefix + from.length, text: to }];
|
|
51
|
+
const changes = diffChars(from, to, DIFF_BUDGET);
|
|
52
|
+
if (changes === undefined) return null;
|
|
53
|
+
const edits: Edit[] = [];
|
|
54
|
+
let offset = prefix;
|
|
55
|
+
let open: Edit | null = null;
|
|
56
|
+
for (const change of changes) {
|
|
57
|
+
if (change.removed || change.added) {
|
|
58
|
+
open ??= { start: offset, end: offset, text: '' };
|
|
59
|
+
if (change.removed) {
|
|
60
|
+
open.end += change.value.length;
|
|
61
|
+
offset += change.value.length;
|
|
62
|
+
} else open.text += change.value;
|
|
63
|
+
} else {
|
|
64
|
+
if (open) edits.push(open);
|
|
65
|
+
open = null;
|
|
66
|
+
offset += change.value.length;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (open) edits.push(open);
|
|
70
|
+
return edits;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const same = (a: Edit, b: Edit) => a.start === b.start && a.end === b.end && a.text === b.text;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Merge `ours` and `theirs`, both derived from `base`. Edits that touch —
|
|
77
|
+
* overlapping, adjacent, or two insertions at one point — are an `overlap`
|
|
78
|
+
* unless they are the identical edit, which is applied once.
|
|
79
|
+
*/
|
|
80
|
+
export function mergeSource(base: string, ours: string, theirs: string): MergeResult {
|
|
81
|
+
if (ours === theirs || theirs === base) return { ok: true, merged: ours };
|
|
82
|
+
if (ours === base) return { ok: true, merged: theirs };
|
|
83
|
+
const a = editsOf(base, ours);
|
|
84
|
+
const b = editsOf(base, theirs);
|
|
85
|
+
if (!a || !b) return { ok: false, reason: 'budget' };
|
|
86
|
+
const all: Edit[] = [];
|
|
87
|
+
for (const edit of [...a, ...b].sort((x, y) => x.start - y.start || x.end - y.end)) {
|
|
88
|
+
const prev = all[all.length - 1];
|
|
89
|
+
if (prev && same(prev, edit)) continue;
|
|
90
|
+
if (prev && edit.start <= prev.end) return { ok: false, reason: 'overlap' };
|
|
91
|
+
all.push(edit);
|
|
92
|
+
}
|
|
93
|
+
let merged = '';
|
|
94
|
+
let cursor = 0;
|
|
95
|
+
for (const edit of all) {
|
|
96
|
+
merged += base.slice(cursor, edit.start) + edit.text;
|
|
97
|
+
cursor = edit.end;
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, merged: merged + base.slice(cursor) };
|
|
100
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// UI source operations that survive a race — plan T23/T24 (DDR-241).
|
|
2
|
+
//
|
|
3
|
+
// A change the person makes in the inspector or on the canvas (a text, a
|
|
4
|
+
// style property, an attribute) is saved as a whole-canvas proposal with the
|
|
5
|
+
// base it was made from; the hub merges it three-way. Two people changing the
|
|
6
|
+
// SAME property touch the same characters, and a character merge can only
|
|
7
|
+
// call that a conflict — a dialog for "we both picked a colour", which is not
|
|
8
|
+
// what a design tool does. Figma-class tools resolve it by acceptance order:
|
|
9
|
+
// the later assignment wins, and nothing else of either person's is lost.
|
|
10
|
+
//
|
|
11
|
+
// So such an edit also records WHAT it was — the operation, the element's
|
|
12
|
+
// position-independent print, and the value — and when its proposal loses a
|
|
13
|
+
// race the studio re-applies exactly that operation onto the version that won
|
|
14
|
+
// and proposes again. What the other person changed elsewhere survives (it is
|
|
15
|
+
// in the version the edit is re-applied to); the same property takes the
|
|
16
|
+
// later value; the history keeps both actions in the order they were accepted.
|
|
17
|
+
//
|
|
18
|
+
// It re-applies only when it can find THE element (canvas-edit
|
|
19
|
+
// `relocateElement`); a deleted or ambiguous target is a genuine conflict and
|
|
20
|
+
// goes to the person, never to a guess.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
applyDeleteElement,
|
|
24
|
+
applyDuplicateElement,
|
|
25
|
+
applyEdit,
|
|
26
|
+
applyMove,
|
|
27
|
+
applyRemove,
|
|
28
|
+
applyRemoveClip,
|
|
29
|
+
applyResizeArtboard,
|
|
30
|
+
applyRetimeSequenceByClip,
|
|
31
|
+
applySetArtboardGuides,
|
|
32
|
+
applySetArtboardHug,
|
|
33
|
+
applySetArtboardKind,
|
|
34
|
+
applySetArtboardLabel,
|
|
35
|
+
applySetArtboardStyle,
|
|
36
|
+
applyTextEdit,
|
|
37
|
+
type ElementPrint,
|
|
38
|
+
elementPrint,
|
|
39
|
+
relocateElement,
|
|
40
|
+
} from '../canvas-edit.ts';
|
|
41
|
+
|
|
42
|
+
/** An artboard is addressed by its authored id — stable by construction. */
|
|
43
|
+
export type ArtboardFn = 'resize' | 'hug' | 'style' | 'kind' | 'label' | 'guides';
|
|
44
|
+
|
|
45
|
+
export type SourceOp =
|
|
46
|
+
| {
|
|
47
|
+
kind: 'text';
|
|
48
|
+
id: string;
|
|
49
|
+
text: string;
|
|
50
|
+
occurrence?: number;
|
|
51
|
+
before?: string;
|
|
52
|
+
print: ElementPrint;
|
|
53
|
+
}
|
|
54
|
+
| {
|
|
55
|
+
kind: 'set';
|
|
56
|
+
id: string;
|
|
57
|
+
attr: string;
|
|
58
|
+
value: string;
|
|
59
|
+
occurrence?: number;
|
|
60
|
+
print: ElementPrint;
|
|
61
|
+
}
|
|
62
|
+
| { kind: 'remove'; id: string; attr: string; occurrence?: number; print: ElementPrint }
|
|
63
|
+
// T25 — structural element operations, re-found by print.
|
|
64
|
+
| { kind: 'delete'; id: string; occurrence?: number; print: ElementPrint }
|
|
65
|
+
| { kind: 'duplicate'; id: string; occurrence?: number; print: ElementPrint }
|
|
66
|
+
| {
|
|
67
|
+
kind: 'move';
|
|
68
|
+
id: string;
|
|
69
|
+
refId: string;
|
|
70
|
+
position: string;
|
|
71
|
+
idIndex?: number;
|
|
72
|
+
refIndex?: number;
|
|
73
|
+
print: ElementPrint;
|
|
74
|
+
refPrint: ElementPrint;
|
|
75
|
+
}
|
|
76
|
+
// Artboards and clips carry stable ids already (authored id; stableId +
|
|
77
|
+
// content hash) — no print needed; a changed clip fails its own hash check.
|
|
78
|
+
| { kind: 'artboard'; fn: ArtboardFn; artboardId: string; args: unknown[]; print?: undefined }
|
|
79
|
+
| {
|
|
80
|
+
kind: 'clip-retime' | 'clip-remove';
|
|
81
|
+
artboardId?: string;
|
|
82
|
+
stableId: string;
|
|
83
|
+
expectedHash?: string;
|
|
84
|
+
patch?: unknown;
|
|
85
|
+
print?: undefined;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type Describable =
|
|
89
|
+
| { kind: 'text'; id: string; text: string; occurrence?: number; before?: string }
|
|
90
|
+
| { kind: 'set'; id: string; attr: string; value: string; occurrence?: number }
|
|
91
|
+
| { kind: 'remove'; id: string; attr: string; occurrence?: number }
|
|
92
|
+
| { kind: 'delete'; id: string; occurrence?: number }
|
|
93
|
+
| { kind: 'duplicate'; id: string; occurrence?: number }
|
|
94
|
+
| {
|
|
95
|
+
kind: 'move';
|
|
96
|
+
id: string;
|
|
97
|
+
refId: string;
|
|
98
|
+
position: string;
|
|
99
|
+
idIndex?: number;
|
|
100
|
+
refIndex?: number;
|
|
101
|
+
}
|
|
102
|
+
| { kind: 'artboard'; fn: ArtboardFn; artboardId: string; args: unknown[] }
|
|
103
|
+
| {
|
|
104
|
+
kind: 'clip-retime' | 'clip-remove';
|
|
105
|
+
artboardId?: string;
|
|
106
|
+
stableId: string;
|
|
107
|
+
expectedHash?: string;
|
|
108
|
+
patch?: unknown;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
type StableOp = Extract<SourceOp, { kind: 'artboard' | 'clip-retime' | 'clip-remove' }>;
|
|
112
|
+
type ElementOp = Exclude<SourceOp, StableOp>;
|
|
113
|
+
const STABLE = new Set(['artboard', 'clip-retime', 'clip-remove']);
|
|
114
|
+
function isStable<T extends { kind: string }>(op: T): boolean {
|
|
115
|
+
return STABLE.has(op.kind);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The attribute a print must ignore for this op (it is the one changing). */
|
|
119
|
+
function editedAttr(op: { kind: string; attr?: string }): string | null {
|
|
120
|
+
if (op.kind !== 'set' && op.kind !== 'remove') return null;
|
|
121
|
+
const a = op.attr ?? '';
|
|
122
|
+
return a.startsWith('style.') ? 'style' : a;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Describe an operation against the source it is about to be applied to. */
|
|
126
|
+
export function describeSourceOp(
|
|
127
|
+
canvasAbsPath: string,
|
|
128
|
+
source: string,
|
|
129
|
+
op: Describable
|
|
130
|
+
): SourceOp | null {
|
|
131
|
+
try {
|
|
132
|
+
if (isStable(op)) return op as SourceOp;
|
|
133
|
+
const el = op as Exclude<Describable, { kind: 'artboard' | 'clip-retime' | 'clip-remove' }>;
|
|
134
|
+
// A text edit's own text is what changes, so it is not part of the print.
|
|
135
|
+
const print = elementPrint(canvasAbsPath, source, el.id, editedAttr(el), el.kind !== 'text');
|
|
136
|
+
if (!print) return null;
|
|
137
|
+
if (el.kind === 'move') {
|
|
138
|
+
const refPrint = elementPrint(canvasAbsPath, source, el.refId, null);
|
|
139
|
+
return refPrint ? { ...el, print, refPrint } : null;
|
|
140
|
+
}
|
|
141
|
+
return { ...el, print } as SourceOp;
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type ReplayResult =
|
|
148
|
+
| { ok: true; source: string }
|
|
149
|
+
| { ok: false; reason: 'target-missing' | 'failed' };
|
|
150
|
+
|
|
151
|
+
/** Re-apply `op` onto `head` — the version that won. Pure. */
|
|
152
|
+
export function replaySourceOp(canvasAbsPath: string, anyOp: SourceOp, head: string): ReplayResult {
|
|
153
|
+
if (isStable(anyOp)) return replayStable(canvasAbsPath, anyOp as StableOp, head);
|
|
154
|
+
return replayElement(canvasAbsPath, anyOp as ElementOp, head);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function replayStable(canvasAbsPath: string, op: StableOp, head: string): ReplayResult {
|
|
158
|
+
try {
|
|
159
|
+
if (op.kind === 'artboard')
|
|
160
|
+
return { ok: true, source: replayArtboard(canvasAbsPath, op, head) };
|
|
161
|
+
if (op.kind === 'clip-retime') {
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
source: applyRetimeSequenceByClip(
|
|
165
|
+
canvasAbsPath,
|
|
166
|
+
head,
|
|
167
|
+
op.artboardId,
|
|
168
|
+
op.stableId,
|
|
169
|
+
op.expectedHash,
|
|
170
|
+
op.patch as Parameters<typeof applyRetimeSequenceByClip>[5]
|
|
171
|
+
).source,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (op.kind === 'clip-remove') {
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
source: applyRemoveClip(canvasAbsPath, head, op.artboardId, op.stableId, op.expectedHash)
|
|
178
|
+
.source,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
return { ok: false, reason: 'failed' };
|
|
183
|
+
}
|
|
184
|
+
return { ok: false, reason: 'failed' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function replayElement(canvasAbsPath: string, op: ElementOp, head: string): ReplayResult {
|
|
188
|
+
let id: string | null;
|
|
189
|
+
try {
|
|
190
|
+
id = relocateElement(canvasAbsPath, head, op.id, op.print, editedAttr(op));
|
|
191
|
+
} catch {
|
|
192
|
+
return { ok: false, reason: 'failed' };
|
|
193
|
+
}
|
|
194
|
+
if (!id) return { ok: false, reason: 'target-missing' };
|
|
195
|
+
try {
|
|
196
|
+
switch (op.kind) {
|
|
197
|
+
case 'text':
|
|
198
|
+
return {
|
|
199
|
+
ok: true,
|
|
200
|
+
source: applyTextEdit(canvasAbsPath, head, id, op.text, {
|
|
201
|
+
...(op.occurrence !== undefined ? { occurrence: op.occurrence } : {}),
|
|
202
|
+
}).source,
|
|
203
|
+
};
|
|
204
|
+
case 'set':
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
source: applyEdit(canvasAbsPath, head, id, op.attr, op.value, op.occurrence).source,
|
|
208
|
+
};
|
|
209
|
+
case 'remove':
|
|
210
|
+
return {
|
|
211
|
+
ok: true,
|
|
212
|
+
source: applyRemove(canvasAbsPath, head, id, op.attr, op.occurrence).source,
|
|
213
|
+
};
|
|
214
|
+
case 'delete':
|
|
215
|
+
return {
|
|
216
|
+
ok: true,
|
|
217
|
+
source: applyDeleteElement(canvasAbsPath, head, id, op.occurrence).source,
|
|
218
|
+
};
|
|
219
|
+
case 'duplicate':
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
source: applyDuplicateElement(canvasAbsPath, head, id, op.occurrence).source,
|
|
223
|
+
};
|
|
224
|
+
case 'move': {
|
|
225
|
+
const refId = relocateElement(canvasAbsPath, head, op.refId, op.refPrint, null);
|
|
226
|
+
if (!refId) return { ok: false, reason: 'target-missing' };
|
|
227
|
+
return {
|
|
228
|
+
ok: true,
|
|
229
|
+
source: applyMove(
|
|
230
|
+
canvasAbsPath,
|
|
231
|
+
head,
|
|
232
|
+
id,
|
|
233
|
+
refId,
|
|
234
|
+
op.position as Parameters<typeof applyMove>[4],
|
|
235
|
+
op.idIndex,
|
|
236
|
+
op.refIndex
|
|
237
|
+
).source,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
return { ok: false, reason: 'failed' };
|
|
243
|
+
}
|
|
244
|
+
return { ok: false, reason: 'failed' };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function replayArtboard(
|
|
248
|
+
abs: string,
|
|
249
|
+
op: Extract<SourceOp, { kind: 'artboard' }>,
|
|
250
|
+
head: string
|
|
251
|
+
): string {
|
|
252
|
+
const a = op.args;
|
|
253
|
+
switch (op.fn) {
|
|
254
|
+
case 'resize':
|
|
255
|
+
return applyResizeArtboard(
|
|
256
|
+
abs,
|
|
257
|
+
head,
|
|
258
|
+
op.artboardId,
|
|
259
|
+
a[0] as number | undefined,
|
|
260
|
+
a[1] as number | undefined
|
|
261
|
+
).source;
|
|
262
|
+
case 'hug':
|
|
263
|
+
return applySetArtboardHug(
|
|
264
|
+
abs,
|
|
265
|
+
head,
|
|
266
|
+
op.artboardId,
|
|
267
|
+
a[0] === true,
|
|
268
|
+
a[1] as number | undefined
|
|
269
|
+
).source;
|
|
270
|
+
case 'style':
|
|
271
|
+
return applySetArtboardStyle(
|
|
272
|
+
abs,
|
|
273
|
+
head,
|
|
274
|
+
op.artboardId,
|
|
275
|
+
a[0] as Parameters<typeof applySetArtboardStyle>[3]
|
|
276
|
+
).source;
|
|
277
|
+
case 'kind':
|
|
278
|
+
return applySetArtboardKind(abs, head, op.artboardId, (a[0] as string | null) ?? null).source;
|
|
279
|
+
case 'label':
|
|
280
|
+
return applySetArtboardLabel(abs, head, op.artboardId, String(a[0] ?? '')).source;
|
|
281
|
+
case 'guides':
|
|
282
|
+
return applySetArtboardGuides(
|
|
283
|
+
abs,
|
|
284
|
+
head,
|
|
285
|
+
op.artboardId,
|
|
286
|
+
(a[0] as Record<string, unknown> | null) ?? null
|
|
287
|
+
).source;
|
|
288
|
+
}
|
|
289
|
+
}
|