@1agh/maude 1.4.4 → 1.4.6
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-model.ts +33 -1
- package/apps/studio/api.ts +19 -0
- package/apps/studio/canvas-comment-mount.tsx +48 -3
- package/apps/studio/canvas-lib.tsx +5 -0
- package/apps/studio/canvas-shell.tsx +110 -14
- package/apps/studio/canvas-source-memo.ts +39 -0
- package/apps/studio/canvas-text-patch.ts +117 -0
- package/apps/studio/client/app.jsx +108 -60
- package/apps/studio/client/comments-overlay.css +10 -0
- package/apps/studio/client/file-tree.jsx +157 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/DiffView.jsx +37 -8
- package/apps/studio/client/panels/GitPanel.jsx +2 -1
- package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
- package/apps/studio/client/styles/3-shell-maude.css +21 -9
- package/apps/studio/client/styles/4-components.css +4 -2
- package/apps/studio/collab/index.ts +9 -0
- package/apps/studio/collab/persistence.ts +49 -1
- 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 +181 -107
- package/apps/studio/dist/client.bundle.js +1086 -1086
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/hmr-broadcast.ts +59 -3
- package/apps/studio/http.ts +3 -0
- package/apps/studio/sync/accepted-cold-start.ts +95 -7
- package/apps/studio/sync/accepted-link.ts +165 -29
- package/apps/studio/sync/agent.ts +28 -2
- package/apps/studio/sync/comment-ledger.ts +229 -0
- package/apps/studio/sync/file-plane.ts +101 -2
- package/apps/studio/sync/index.ts +124 -17
- package/apps/studio/sync/journal-client.ts +9 -3
- package/apps/studio/sync/migrate-seed.ts +9 -2
- package/apps/studio/sync/projection.ts +114 -11
- package/apps/studio/sync/remote-docs.ts +3 -2
- package/apps/studio/sync/source-recovery.ts +67 -0
- package/apps/studio/sync/status.ts +11 -3
- package/apps/studio/sync/transaction-client.ts +49 -4
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/use-selection-set.tsx +13 -3
- package/apps/studio/use-undo-stack.tsx +22 -2
- package/apps/studio/whats-new.json +45 -0
- package/cli/lib/workspace-plan.mjs +10 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +21 -0
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
unionCommentsById,
|
|
59
59
|
} from './cold-start.ts';
|
|
60
60
|
import { applyColdStart, type ColdStartSnapshotReason } from './cold-start-apply.ts';
|
|
61
|
+
import { type CommentLedger, withoutRemotelyDeleted } from './comment-ledger.ts';
|
|
61
62
|
import { hashBytes } from './echo-guard.ts';
|
|
62
63
|
import type { SyncJournal } from './journal.ts';
|
|
63
64
|
import { ORIGINS } from './origins.ts';
|
|
@@ -85,6 +86,8 @@ export interface MigrateSeedOptions {
|
|
|
85
86
|
historyDir?: string;
|
|
86
87
|
/** DDR-102 — per-machine journal; gates fast-forward vs conflict. */
|
|
87
88
|
journal?: SyncJournal;
|
|
89
|
+
/** Issue #133 — comment ids synced from here before (sync/comment-ledger.ts). */
|
|
90
|
+
commentLedger?: CommentLedger;
|
|
88
91
|
/** DDR-102 — body snapshot writer (history.ts), same contract as the agent's. */
|
|
89
92
|
snapshot?: (content: string, reason: ColdStartSnapshotReason) => Promise<string | null>;
|
|
90
93
|
/**
|
|
@@ -414,9 +417,13 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
|
|
|
414
417
|
// delete-then-insert codec — same-id entries keep the doc's version, so the
|
|
415
418
|
// duplication trap stays closed; local-only comments survive.
|
|
416
419
|
if (localComments) {
|
|
417
|
-
const
|
|
420
|
+
const docList = doc.getArray(Y_TYPES.comments).toArray();
|
|
421
|
+
const read = tryParseJsonArray(localComments);
|
|
422
|
+
// Issue #133 — never union back a comment a peer deleted while we were away.
|
|
423
|
+
const parsed = read
|
|
424
|
+
? withoutRemotelyDeleted(read, docList, opts.commentLedger?.get(slug))
|
|
425
|
+
: null;
|
|
418
426
|
if (parsed && parsed.length > 0) {
|
|
419
|
-
const docList = doc.getArray(Y_TYPES.comments).toArray();
|
|
420
427
|
const merged = unionCommentsById(docList, parsed);
|
|
421
428
|
if (merged.length !== docList.length) {
|
|
422
429
|
doc.transact(() => {
|
|
@@ -54,7 +54,12 @@ import type { RevisionBarrier } from './revision-barrier.ts';
|
|
|
54
54
|
import { repairSeedDuplication } from './seed-repair.ts';
|
|
55
55
|
import { mergeSource } from './source-merge.ts';
|
|
56
56
|
import type { SourceOp } from './source-ops.ts';
|
|
57
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
preserveRecoveryCandidate,
|
|
59
|
+
readRecoveryCandidate,
|
|
60
|
+
resolveRecoveryCandidate,
|
|
61
|
+
saveRecoveryBody,
|
|
62
|
+
} from './source-recovery.ts';
|
|
58
63
|
import { sourceError } from './source-validation.ts';
|
|
59
64
|
import { laneHash } from './transaction-client.ts';
|
|
60
65
|
|
|
@@ -222,6 +227,13 @@ export interface DocProjection {
|
|
|
222
227
|
* write stays blocked with a visible conflict and a later save can merge.
|
|
223
228
|
*/
|
|
224
229
|
adoptBase(body: string): void;
|
|
230
|
+
/**
|
|
231
|
+
* Accepted mode, cold start: `value` is this disk's own html proposal that a
|
|
232
|
+
* previous run left in the outbox and the drain just had accepted. It is
|
|
233
|
+
* what the disk was saved as — the base of whatever it holds next — even
|
|
234
|
+
* while this replica still shows the value before it.
|
|
235
|
+
*/
|
|
236
|
+
adoptOwnAccepted(value: string): void;
|
|
225
237
|
/**
|
|
226
238
|
* Accepted-revisions mode: propose one lane value that did not come through
|
|
227
239
|
* the watcher (a comment/annotation API write). Resolves with the outcome;
|
|
@@ -240,8 +252,13 @@ export interface DocProjection {
|
|
|
240
252
|
* recovery slots. Returns false when nothing is held.
|
|
241
253
|
*/
|
|
242
254
|
takeAccepted(): boolean;
|
|
243
|
-
/** T28 —
|
|
244
|
-
conflictSides(): {
|
|
255
|
+
/** T28 — current sides plus the first candidate and proven base (null when unknown). */
|
|
256
|
+
conflictSides(): {
|
|
257
|
+
mine: string | null;
|
|
258
|
+
theirs: string;
|
|
259
|
+
original: string | null;
|
|
260
|
+
base: string | null;
|
|
261
|
+
} | null;
|
|
245
262
|
/** Re-deliver file changes held while the document was not writable. */
|
|
246
263
|
retryDeferred(): void;
|
|
247
264
|
/**
|
|
@@ -290,6 +307,22 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
290
307
|
let validationCache: { body: string; error: string | null } | null = null;
|
|
291
308
|
|
|
292
309
|
function recovered(): void {
|
|
310
|
+
// An older accepted proposal is not resolution of a later pending/held one.
|
|
311
|
+
if (pending.has('html') || held.has('html')) return;
|
|
312
|
+
try {
|
|
313
|
+
if (opts.historyDir) resolveRecoveryCandidate(opts.historyDir, paths.html);
|
|
314
|
+
} catch {
|
|
315
|
+
// The accepted action is real even if updating our local recovery record
|
|
316
|
+
// fails. Keep the original pinned and the storage problem visible.
|
|
317
|
+
rejectedKey ??= 'recovery-failed';
|
|
318
|
+
opts.onConflict?.({
|
|
319
|
+
slug,
|
|
320
|
+
kind: 'body-rejected',
|
|
321
|
+
reason: 'history-failed',
|
|
322
|
+
snapshotFailed: true,
|
|
323
|
+
});
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
293
326
|
if (rejectedKey !== null) opts.onRecovered?.();
|
|
294
327
|
rejectedKey = null;
|
|
295
328
|
}
|
|
@@ -325,13 +358,16 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
325
358
|
reason: BodyRejection['reason'],
|
|
326
359
|
local: string | null,
|
|
327
360
|
incoming: string,
|
|
328
|
-
file = paths.html
|
|
361
|
+
file = paths.html,
|
|
362
|
+
base: string | null = lastHtml
|
|
329
363
|
): void {
|
|
330
364
|
const key = `${file}:${reason}:${hashBytes(local ?? '')}:${hashBytes(incoming)}`;
|
|
331
365
|
if (key === rejectedKey) return;
|
|
332
366
|
rejectedKey = key;
|
|
333
367
|
let snapshotFailed = false;
|
|
334
368
|
try {
|
|
369
|
+
if (file === paths.html && opts.historyDir)
|
|
370
|
+
preserveRecoveryCandidate(opts.historyDir, file, local, base);
|
|
335
371
|
if (file === paths.html) preserveLocal(local);
|
|
336
372
|
if (opts.historyDir) {
|
|
337
373
|
if (local !== null) saveRecoveryBody(opts.historyDir, file, 'local', local);
|
|
@@ -461,11 +497,29 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
461
497
|
next = repeated.unit;
|
|
462
498
|
}
|
|
463
499
|
}
|
|
500
|
+
if (acceptedOwn !== null && next !== lastHtml) {
|
|
501
|
+
// The replica moved. When it moved to exactly our accepted value and the
|
|
502
|
+
// disk already holds a newer save, that save is ours on top of it: agree
|
|
503
|
+
// on the accepted value and leave the file for the watcher to propose.
|
|
504
|
+
const own = next === acceptedOwn;
|
|
505
|
+
acceptedOwn = null;
|
|
506
|
+
if (own) {
|
|
507
|
+
const local = readLocal(paths.html);
|
|
508
|
+
if (local !== null && local !== next && local !== observedBody) {
|
|
509
|
+
lastHtml = next;
|
|
510
|
+
rememberBase(next);
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
464
515
|
if (next === lastHtml) return true;
|
|
465
516
|
// Don't clobber a non-empty local body with an empty doc (cold-start before
|
|
466
517
|
// the doc is seeded — the safe-reconcile invariant; full adopt is Phase E).
|
|
467
518
|
if (next === '') {
|
|
468
|
-
|
|
519
|
+
// Accepted mode: an empty replica is one the project's publication has
|
|
520
|
+
// not reached yet, never a value this disk agreed on — a base already
|
|
521
|
+
// known (a canvas this disk just added, adopted on acceptance) stays.
|
|
522
|
+
if (lastHtml === null || !acceptedOn()) lastHtml = next;
|
|
469
523
|
return true;
|
|
470
524
|
}
|
|
471
525
|
if (!withinCap(paths.html, next, MAX_HTML_BYTES)) return false;
|
|
@@ -675,14 +729,29 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
675
729
|
return lane === 'comments' ? paths.comments : paths.annotations;
|
|
676
730
|
}
|
|
677
731
|
|
|
732
|
+
/**
|
|
733
|
+
* Our own html proposal the hub accepted, whose publication has not reached
|
|
734
|
+
* this replica yet. Until it does, it — not the older replica value — is
|
|
735
|
+
* what the next save on this disk was made on top of (F3 S14, 2026-09-23:
|
|
736
|
+
* a save in that window was proposed on the value before the first save and
|
|
737
|
+
* refused as a base conflict, and the echo itself read as "a local edit
|
|
738
|
+
* overlaps an incoming change").
|
|
739
|
+
*/
|
|
740
|
+
let acceptedOwn: string | null = null;
|
|
741
|
+
|
|
678
742
|
/** The value disk and the accepted replica last agreed on for `lane`. */
|
|
679
743
|
function agreedValue(lane: ProposalLane): string {
|
|
680
|
-
if (lane === 'html') return lastHtml ?? htmlFromDoc(doc);
|
|
744
|
+
if (lane === 'html') return acceptedOwn ?? lastHtml ?? htmlFromDoc(doc);
|
|
681
745
|
if (lane === 'css') return lastCss ?? cssFromDoc(doc) ?? '';
|
|
682
746
|
return readLaneFromDoc(doc, lane);
|
|
683
747
|
}
|
|
684
748
|
|
|
685
|
-
function onRejected(
|
|
749
|
+
function onRejected(
|
|
750
|
+
lane: ProposalLane,
|
|
751
|
+
local: string,
|
|
752
|
+
outcome: ProposalOutcome,
|
|
753
|
+
base: string
|
|
754
|
+
): void {
|
|
686
755
|
held.add(lane);
|
|
687
756
|
// The candidate stays on disk: the projection's local-edit guard protects
|
|
688
757
|
// it (html), `held` blocks the lane writer (css/meta), and the recovery
|
|
@@ -704,7 +773,8 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
704
773
|
REJECTION_REASON[outcome.code ?? ''] ?? 'local-edit',
|
|
705
774
|
local,
|
|
706
775
|
readLaneFromDoc(doc, lane),
|
|
707
|
-
pathOfLane(lane)
|
|
776
|
+
pathOfLane(lane),
|
|
777
|
+
base
|
|
708
778
|
);
|
|
709
779
|
}
|
|
710
780
|
|
|
@@ -772,6 +842,15 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
772
842
|
}
|
|
773
843
|
if (outcome.status === 'accepted') {
|
|
774
844
|
if (!pending.has(lane)) held.delete(lane);
|
|
845
|
+
if (lane === 'html' && !pending.has(lane)) {
|
|
846
|
+
// Our accepted value is the base of whatever this disk holds next —
|
|
847
|
+
// whichever arrives first, the answer or the publication.
|
|
848
|
+
// Persisted too: a cold start judges the disk against this base.
|
|
849
|
+
if (readLaneFromDoc(doc, 'html') === value) {
|
|
850
|
+
lastHtml = value;
|
|
851
|
+
rememberBase(value);
|
|
852
|
+
} else acceptedOwn = value;
|
|
853
|
+
}
|
|
775
854
|
if (lane === 'html') recovered();
|
|
776
855
|
if (outcome.actionId) {
|
|
777
856
|
try {
|
|
@@ -793,7 +872,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
793
872
|
lastMeta = null;
|
|
794
873
|
}
|
|
795
874
|
} else {
|
|
796
|
-
onRejected(lane, local, outcome);
|
|
875
|
+
onRejected(lane, local, outcome, baseContent);
|
|
797
876
|
}
|
|
798
877
|
scheduleFlush();
|
|
799
878
|
return outcome;
|
|
@@ -864,6 +943,14 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
864
943
|
// 2026-09-15, L09 delete). Every edit to these lanes arrives through the
|
|
865
944
|
// API with the base it was made from (`proposeLane`).
|
|
866
945
|
if (lane === 'comments' || lane === 'annotations') return false;
|
|
946
|
+
// A STALE EVENT PROPOSES NOTHING. The reader took these bytes before a
|
|
947
|
+
// later write reached the file — typically this projection materializing a
|
|
948
|
+
// newer accepted value — and delivered them after. Proposing them would
|
|
949
|
+
// put an older body back over a teammate's accepted edit (F3 S15, cloud
|
|
950
|
+
// cell, 2026-09-24: v13 over v14). What the disk holds now arrives with its
|
|
951
|
+
// own event.
|
|
952
|
+
const onDisk = readLocal(evt.path);
|
|
953
|
+
if (onDisk !== null && onDisk !== str) return false;
|
|
867
954
|
if (lane === 'html') {
|
|
868
955
|
if (str === lastHtml && !held.has('html')) return false; // a redelivered projection
|
|
869
956
|
if (!withinCap(paths.html, str, MAX_HTML_BYTES)) return false;
|
|
@@ -1127,6 +1214,17 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
1127
1214
|
lastHtml = body;
|
|
1128
1215
|
observedBody = body;
|
|
1129
1216
|
},
|
|
1217
|
+
adoptOwnAccepted(value: string) {
|
|
1218
|
+
// Exactly what an answer in this process does (see `submit`): the
|
|
1219
|
+
// replica either holds it already, or its publication is still coming.
|
|
1220
|
+
const replica = htmlFromDoc(doc);
|
|
1221
|
+
if (replica === value) lastHtml = value;
|
|
1222
|
+
else {
|
|
1223
|
+
lastHtml = replica;
|
|
1224
|
+
acceptedOwn = value;
|
|
1225
|
+
}
|
|
1226
|
+
rememberBase(value);
|
|
1227
|
+
},
|
|
1130
1228
|
hold(lane, base, local) {
|
|
1131
1229
|
held.add(lane);
|
|
1132
1230
|
if (lane === 'html') {
|
|
@@ -1146,12 +1244,17 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
1146
1244
|
lastHtml = null;
|
|
1147
1245
|
dirty = true;
|
|
1148
1246
|
void flush();
|
|
1149
|
-
recovered();
|
|
1150
1247
|
return true;
|
|
1151
1248
|
},
|
|
1152
1249
|
conflictSides() {
|
|
1153
1250
|
if (!held.has('html') && rejectedKey === null) return null;
|
|
1154
|
-
|
|
1251
|
+
const candidate = opts.historyDir ? readRecoveryCandidate(opts.historyDir, paths.html) : null;
|
|
1252
|
+
return {
|
|
1253
|
+
mine: readLocal(paths.html),
|
|
1254
|
+
theirs: htmlFromDoc(doc),
|
|
1255
|
+
original: candidate?.original ?? null,
|
|
1256
|
+
base: candidate?.base ?? null,
|
|
1257
|
+
};
|
|
1155
1258
|
},
|
|
1156
1259
|
proposeLane(lane, value, o) {
|
|
1157
1260
|
if (!acceptedOn() || stopped) return null;
|
|
@@ -341,8 +341,8 @@ export function pullTargets(
|
|
|
341
341
|
* readable. Both hops go through the same function, so "where does this canvas
|
|
342
342
|
* go" cannot have two answers.
|
|
343
343
|
*
|
|
344
|
-
* Returns null when
|
|
345
|
-
*
|
|
344
|
+
* Returns null when a present path is refused or containment fails. Only a
|
|
345
|
+
* document with no path may use the legacy slug-derived fallback.
|
|
346
346
|
*/
|
|
347
347
|
export function resolvePulledTarget(args: {
|
|
348
348
|
slug: string;
|
|
@@ -376,6 +376,7 @@ export function resolvePulledTarget(args: {
|
|
|
376
376
|
allowUndeclaredGroup: args.allowUndeclaredGroup,
|
|
377
377
|
onRefused: args.onRefused,
|
|
378
378
|
});
|
|
379
|
+
if (args.path !== undefined && args.path !== null && !fromPath) return null;
|
|
379
380
|
const bodyAbs = args.join(args.designRoot, rel);
|
|
380
381
|
// Belt and braces at a create. The validator already refuses everything that
|
|
381
382
|
// could escape lexically; this catches whatever a platform's own `resolve`
|
|
@@ -12,6 +12,73 @@ import { sourceError } from './source-validation.ts';
|
|
|
12
12
|
*/
|
|
13
13
|
export type RecoverySlot = 'last-valid' | 'local' | 'incoming' | 'base';
|
|
14
14
|
|
|
15
|
+
/** The first rejected draft and its proven base, not the mutable latest draft. */
|
|
16
|
+
export interface RecoveryCandidate {
|
|
17
|
+
version: 1;
|
|
18
|
+
original: string | null;
|
|
19
|
+
base: string | null;
|
|
20
|
+
resolved: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function candidatePath(historyDir: string, file: string): string {
|
|
24
|
+
return path.join(historyDir, 'sync-recovery', `candidate${path.extname(file)}.json`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function boundedBody(body: unknown): body is string | null {
|
|
28
|
+
return (
|
|
29
|
+
body === null || (typeof body === 'string' && Buffer.byteLength(body, 'utf8') <= MAX_HTML_BYTES)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Corrupt/inaccessible records fail closed: never replace an unreadable draft. */
|
|
34
|
+
export function readRecoveryCandidate(historyDir: string, file: string): RecoveryCandidate | null {
|
|
35
|
+
const target = candidatePath(historyDir, file);
|
|
36
|
+
let size: number;
|
|
37
|
+
try {
|
|
38
|
+
size = statSync(target).size;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
// Two byte-capped strings, each potentially JSON-escaped as six bytes/char.
|
|
44
|
+
if (size > 12 * MAX_HTML_BYTES + 256) throw new Error('Recovery candidate exceeds size limit');
|
|
45
|
+
const value: unknown = JSON.parse(readFileSync(target, 'utf8'));
|
|
46
|
+
if (!value || typeof value !== 'object') throw new Error('Invalid recovery candidate');
|
|
47
|
+
const record = value as Record<string, unknown>;
|
|
48
|
+
if (
|
|
49
|
+
record.version !== 1 ||
|
|
50
|
+
typeof record.resolved !== 'boolean' ||
|
|
51
|
+
!boundedBody(record.original) ||
|
|
52
|
+
!boundedBody(record.base)
|
|
53
|
+
)
|
|
54
|
+
throw new Error('Invalid recovery candidate');
|
|
55
|
+
return { version: 1, original: record.original, base: record.base, resolved: record.resolved };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One bounded, atomic record per source file; retries/restarts cannot evict it. */
|
|
59
|
+
export function preserveRecoveryCandidate(
|
|
60
|
+
historyDir: string,
|
|
61
|
+
file: string,
|
|
62
|
+
original: string | null,
|
|
63
|
+
base: string | null
|
|
64
|
+
): void {
|
|
65
|
+
if (!boundedBody(original) || !boundedBody(base))
|
|
66
|
+
throw new Error('Recovery source exceeds size limit');
|
|
67
|
+
const previous = readRecoveryCandidate(historyDir, file);
|
|
68
|
+
if (previous && !previous.resolved) return;
|
|
69
|
+
atomicWrite(
|
|
70
|
+
candidatePath(historyDir, file),
|
|
71
|
+
JSON.stringify({ version: 1, original, base, resolved: false })
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Retain the bytes after resolution, allowing replacement only by a new episode. */
|
|
76
|
+
export function resolveRecoveryCandidate(historyDir: string, file: string): void {
|
|
77
|
+
const previous = readRecoveryCandidate(historyDir, file);
|
|
78
|
+
if (!previous || previous.resolved) return;
|
|
79
|
+
atomicWrite(candidatePath(historyDir, file), JSON.stringify({ ...previous, resolved: true }));
|
|
80
|
+
}
|
|
81
|
+
|
|
15
82
|
export function saveRecoveryBody(
|
|
16
83
|
historyDir: string,
|
|
17
84
|
file: string,
|
|
@@ -447,9 +447,17 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
|
|
|
447
447
|
clearSourceConflict(slug) {
|
|
448
448
|
const id = `source-conflict-${slug}`;
|
|
449
449
|
const index = notices.findIndex((n) => n.id === id);
|
|
450
|
-
|
|
451
|
-
notices.splice(index, 1);
|
|
452
|
-
|
|
450
|
+
let changed = index >= 0;
|
|
451
|
+
if (index >= 0) notices.splice(index, 1);
|
|
452
|
+
// The presentation reads these facts, not the dismissible notice. Clear
|
|
453
|
+
// every rejection of this source while keeping other sources and notes.
|
|
454
|
+
for (let i = conflicts.length - 1; i >= 0; i--) {
|
|
455
|
+
if (conflicts[i].slug === slug && conflicts[i].kind === 'body-rejected') {
|
|
456
|
+
conflicts.splice(i, 1);
|
|
457
|
+
changed = true;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (changed) flush(true);
|
|
453
461
|
},
|
|
454
462
|
get: payload,
|
|
455
463
|
};
|
|
@@ -337,9 +337,33 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
337
337
|
* process knew the project is bound first — it was never sent, so its bytes
|
|
338
338
|
* may still change; once sent, they never do.
|
|
339
339
|
*/
|
|
340
|
+
/**
|
|
341
|
+
* Learn the project (id + epoch) before binding or rebasing an entry. A
|
|
342
|
+
* transport failure is WAITED OUT like an unknown delivery — a desktop that
|
|
343
|
+
* restarts offline drains its outbox before the hub is reachable, and a
|
|
344
|
+
* throw here was an unhandled rejection that ended the process (F3/S06).
|
|
345
|
+
* An answer — a legacy hub (`absent`), a refused sign-in — is still thrown.
|
|
346
|
+
*/
|
|
347
|
+
async function bootstrapWhenReachable(): Promise<void> {
|
|
348
|
+
for (let attempt = 1; ; attempt++) {
|
|
349
|
+
if (stopped) throw new TransactionError('client stopped', 'stopped');
|
|
350
|
+
try {
|
|
351
|
+
await bootstrap();
|
|
352
|
+
return;
|
|
353
|
+
} catch (err) {
|
|
354
|
+
if (err instanceof TransactionError) throw err;
|
|
355
|
+
if (attempt === 1 || attempt % 10 === 0)
|
|
356
|
+
log.warn(
|
|
357
|
+
`[sync/tx] the project is not reachable yet (${(err as Error).message}); waiting`
|
|
358
|
+
);
|
|
359
|
+
await sleep(Math.min(retryMs * 2 ** Math.min(attempt - 1, 4), 30_000));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
340
364
|
async function settle(file: string, entry: OutboxEntry): Promise<ProposalResult> {
|
|
341
365
|
if (entry.unbound || projectId === null) {
|
|
342
|
-
if (projectId === null) await
|
|
366
|
+
if (projectId === null) await bootstrapWhenReachable();
|
|
343
367
|
if (entry.unbound && entry.action) {
|
|
344
368
|
entry = { ...entry, bytes: envelope(entry.action, entry.transactionId) };
|
|
345
369
|
delete entry.unbound;
|
|
@@ -351,7 +375,7 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
351
375
|
if (result.status === 'rejected' && result.code === 'epoch-stale' && entry.action) {
|
|
352
376
|
// A rebase is a NEW transaction under the current epoch, never a mutated
|
|
353
377
|
// retry — and anything that depended on the old id now depends on this.
|
|
354
|
-
await
|
|
378
|
+
await bootstrapWhenReachable();
|
|
355
379
|
const action = {
|
|
356
380
|
...entry.action,
|
|
357
381
|
...(entry.action.dependsOn
|
|
@@ -433,7 +457,9 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
433
457
|
* Resend what a previous process left in the outbox — in creation order, the
|
|
434
458
|
* same bytes, each resolved first in case its answer was simply lost.
|
|
435
459
|
*/
|
|
436
|
-
function drainOutbox(
|
|
460
|
+
function drainOutbox(
|
|
461
|
+
onEach?: (result: ProposalResult, operations: Operation[]) => void
|
|
462
|
+
): Promise<ProposalResult[]> {
|
|
437
463
|
return enqueue(async () => {
|
|
438
464
|
const entries = readOutbox().filter(({ file }) => !owned.has(file));
|
|
439
465
|
const results: ProposalResult[] = [];
|
|
@@ -441,7 +467,9 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
441
467
|
waiting.set(entry.transactionId, entry.createdAt);
|
|
442
468
|
setPending(1);
|
|
443
469
|
try {
|
|
444
|
-
|
|
470
|
+
const result = await settle(file, entry);
|
|
471
|
+
results.push(result);
|
|
472
|
+
onEach?.(result, entry.action?.operations ?? []);
|
|
445
473
|
} finally {
|
|
446
474
|
waiting.delete(entry.transactionId);
|
|
447
475
|
setPending(-1);
|
|
@@ -451,6 +479,22 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
451
479
|
});
|
|
452
480
|
}
|
|
453
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Is `content` a value the project store holds — i.e. one it accepted at
|
|
484
|
+
* some revision? `null` when it cannot tell (unreachable, older hub).
|
|
485
|
+
*/
|
|
486
|
+
async function holdsValue(content: string): Promise<boolean | null> {
|
|
487
|
+
try {
|
|
488
|
+
if (projectId === null) await bootstrap();
|
|
489
|
+
const hash = createHash('sha256').update(content, 'utf8').digest('hex');
|
|
490
|
+
const { status, json } = await request('GET', `blobs/${hash}`);
|
|
491
|
+
if (status === 200) return (json as { body?: unknown } | null)?.body === content;
|
|
492
|
+
return status === 404 ? false : null;
|
|
493
|
+
} catch {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
454
498
|
/** A read route (`history`, `lane`, `revisions`) — no retry, bounded. */
|
|
455
499
|
async function read(route: string, params: Record<string, string | number>): Promise<unknown> {
|
|
456
500
|
if (projectId === null) await bootstrap();
|
|
@@ -468,6 +512,7 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
468
512
|
read,
|
|
469
513
|
newTransactionId,
|
|
470
514
|
drainOutbox,
|
|
515
|
+
holdsValue,
|
|
471
516
|
/** The epoch proposals are currently made under. */
|
|
472
517
|
get epoch() {
|
|
473
518
|
return epoch;
|
|
@@ -323,6 +323,27 @@ export function useCollab(): CollabValue | null {
|
|
|
323
323
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
324
324
|
// Hook: foreign awareness peers (the cursor overlay subscribes to this).
|
|
325
325
|
|
|
326
|
+
export interface AwarenessChanges {
|
|
327
|
+
added: number[];
|
|
328
|
+
updated: number[];
|
|
329
|
+
removed: number[];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Could this awareness `change` alter the foreign peers we show? False only
|
|
334
|
+
* when every touched client is the local one (our own cursor publish — #131).
|
|
335
|
+
* Unknown shape → true, the safe direction.
|
|
336
|
+
*/
|
|
337
|
+
export function touchesForeignClient(changes: AwarenessChanges | undefined, myId: number): boolean {
|
|
338
|
+
if (!changes) return true;
|
|
339
|
+
const touched = [
|
|
340
|
+
...(changes.added ?? []),
|
|
341
|
+
...(changes.updated ?? []),
|
|
342
|
+
...(changes.removed ?? []),
|
|
343
|
+
];
|
|
344
|
+
return touched.length === 0 || touched.some((id) => id !== myId);
|
|
345
|
+
}
|
|
346
|
+
|
|
326
347
|
/**
|
|
327
348
|
* Returns the current set of foreign peers (excludes the local client). The
|
|
328
349
|
* returned array is stable-reference between awareness updates — useful for
|
|
@@ -351,7 +372,13 @@ export function useForeignAwareness(): ForeignAwareness[] {
|
|
|
351
372
|
return out;
|
|
352
373
|
}
|
|
353
374
|
setPeers(compute());
|
|
354
|
-
|
|
375
|
+
// Issue #131 — `change` fires for OUR OWN publishes too (the 30 Hz cursor),
|
|
376
|
+
// and each one rebuilt every peer object and re-rendered every consumer.
|
|
377
|
+
// Only a change that touches another client can change what we show.
|
|
378
|
+
const onChange = (changes?: AwarenessChanges) => {
|
|
379
|
+
if (!touchesForeignClient(changes, awareness.clientID)) return;
|
|
380
|
+
setPeers(compute());
|
|
381
|
+
};
|
|
355
382
|
awareness.on('change', onChange);
|
|
356
383
|
return () => {
|
|
357
384
|
awareness.off('change', onChange);
|
|
@@ -67,6 +67,10 @@ export interface Selection {
|
|
|
67
67
|
worldW?: number;
|
|
68
68
|
worldH?: number;
|
|
69
69
|
html?: string;
|
|
70
|
+
/** Comment drops only — the annotation the click landed on, and the click's
|
|
71
|
+
* world point (comment-anchor.ts). */
|
|
72
|
+
annotationId?: string;
|
|
73
|
+
world?: { x: number; y: number };
|
|
70
74
|
/** Phase 12.2 — authored inline-style values (knob pre-fill) + resolved computed (placeholder hint). */
|
|
71
75
|
authored?: Record<string, string>;
|
|
72
76
|
computed?: Record<string, string>;
|
|
@@ -152,12 +156,14 @@ export function SelectionSetProvider({
|
|
|
152
156
|
}) {
|
|
153
157
|
const [selected, setSelected] = useState<Selection[]>([]);
|
|
154
158
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
159
|
+
const pendingPostRef = useRef<(() => void) | null>(null);
|
|
155
160
|
|
|
156
161
|
const post = useCallback(
|
|
157
162
|
(next: Selection[]) => {
|
|
158
163
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
159
|
-
|
|
164
|
+
pendingPostRef.current = () => {
|
|
160
165
|
timerRef.current = null;
|
|
166
|
+
pendingPostRef.current = null;
|
|
161
167
|
const target = postTarget ?? (typeof window !== 'undefined' ? window.parent : null);
|
|
162
168
|
if (!target) return;
|
|
163
169
|
// Wire shape: single object for N=1 (back-compat), array for N>1, null for empty.
|
|
@@ -168,15 +174,19 @@ export function SelectionSetProvider({
|
|
|
168
174
|
} catch {
|
|
169
175
|
/* iframe likely cross-origin or detached */
|
|
170
176
|
}
|
|
171
|
-
}
|
|
177
|
+
};
|
|
178
|
+
timerRef.current = setTimeout(() => pendingPostRef.current?.(), POST_DEBOUNCE_MS);
|
|
172
179
|
},
|
|
173
180
|
[postTarget]
|
|
174
181
|
);
|
|
175
182
|
|
|
176
|
-
//
|
|
183
|
+
// Soft HMR replaces this provider too. Deliver the last local selection
|
|
184
|
+
// before the replacement's loaded handshake, so the shell can reselect it.
|
|
185
|
+
// Cancelling silently loses a click made inside the 50 ms debounce window.
|
|
177
186
|
useEffect(
|
|
178
187
|
() => () => {
|
|
179
188
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
189
|
+
pendingPostRef.current?.();
|
|
180
190
|
},
|
|
181
191
|
[]
|
|
182
192
|
);
|
|
@@ -99,6 +99,11 @@ const UndoSinksContext = createContext<UndoSinksValue | null>(null);
|
|
|
99
99
|
|
|
100
100
|
const NOOP_SINKS: UndoSinksValue = { setSink: () => {} };
|
|
101
101
|
|
|
102
|
+
// The iframe survives a soft HMR swap, but its Provider does not. Keep pending
|
|
103
|
+
// operations serialized across that handoff; remove idle queues so this map
|
|
104
|
+
// retains neither closed canvases nor completed command closures.
|
|
105
|
+
const canvasFlights = new Map<string, Promise<void>>();
|
|
106
|
+
|
|
102
107
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
103
108
|
// Provider
|
|
104
109
|
|
|
@@ -185,10 +190,25 @@ export function UndoStackProvider({
|
|
|
185
190
|
}, []);
|
|
186
191
|
|
|
187
192
|
const enqueue = useCallback((task: () => Promise<void>): Promise<void> => {
|
|
188
|
-
const
|
|
189
|
-
|
|
193
|
+
const file = fileRef.current;
|
|
194
|
+
const previous = (file && canvasFlights.get(file)) || inFlightRef.current;
|
|
195
|
+
const run = async () => {
|
|
196
|
+
// The previous mount may have persisted its ACK after this mount read
|
|
197
|
+
// the initial state. Re-read only at the serialized operation boundary.
|
|
198
|
+
if (file) stateRef.current = loadStackState(file);
|
|
199
|
+
await task();
|
|
200
|
+
};
|
|
201
|
+
const next = previous.then(run, run);
|
|
202
|
+
const settled = next.catch(() => {
|
|
190
203
|
/* swallow — per-op error already reported */
|
|
191
204
|
});
|
|
205
|
+
inFlightRef.current = settled;
|
|
206
|
+
if (file) {
|
|
207
|
+
canvasFlights.set(file, settled);
|
|
208
|
+
void settled.then(() => {
|
|
209
|
+
if (canvasFlights.get(file) === settled) canvasFlights.delete(file);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
192
212
|
return next;
|
|
193
213
|
}, []);
|
|
194
214
|
|
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "comments-stay-where-you-put-them",
|
|
6
|
+
"version": "1.4.6",
|
|
7
|
+
"date": "2026-09-30",
|
|
8
|
+
"kind": "fix",
|
|
9
|
+
"title": "Comments stay where you put them",
|
|
10
|
+
"summary": "A comment on a sticky, a drawing or empty canvas no longer disappears a few seconds after you save it. A comment on a sticky moves with it, and if the thing it points at is deleted the comment stays, marked as detached, until you remove it.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "comments-reach-every-peer",
|
|
15
|
+
"version": "1.4.6",
|
|
16
|
+
"date": "2026-09-30",
|
|
17
|
+
"kind": "fix",
|
|
18
|
+
"title": "Web comments reach the desktop app",
|
|
19
|
+
"summary": "Comments added on the web while your desktop app was closed now show up when you reopen it, and a comment deleted elsewhere no longer comes back.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "large-boards-pan-smoothly",
|
|
24
|
+
"version": "1.4.6",
|
|
25
|
+
"date": "2026-09-30",
|
|
26
|
+
"kind": "improvement",
|
|
27
|
+
"title": "Large boards pan smoothly again",
|
|
28
|
+
"summary": "Panning a board with many artboards stays smooth while something is selected or a teammate is on the canvas — on the desktop app it could drop to a frame a second.",
|
|
29
|
+
"surface": "design-ui"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "sync-no-phantom-waiting-files",
|
|
33
|
+
"version": "1.4.6",
|
|
34
|
+
"date": "2026-09-30",
|
|
35
|
+
"kind": "fix",
|
|
36
|
+
"title": "Sync stops counting files that are done",
|
|
37
|
+
"summary": "The Sync panel no longer shows files as waiting or stuck when there is nothing left to deliver, and Resync now brings down a file the project changed while you were uploading it.",
|
|
38
|
+
"surface": "design-ui"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "team-projects-hold-up",
|
|
42
|
+
"version": "1.4.5",
|
|
43
|
+
"date": "2026-09-25",
|
|
44
|
+
"kind": "fix",
|
|
45
|
+
"title": "Team projects that hold up under failure",
|
|
46
|
+
"summary": "Edits made in the browser on a self-hosted workspace now reach the project, switching a project to saved revisions finishes on its own even if the connection drops for a moment, and removing a teammate ends their access at once. Cloud projects also keep work written just before a server crash.",
|
|
47
|
+
"surface": "design-ui"
|
|
48
|
+
},
|
|
4
49
|
{
|
|
5
50
|
"id": "steadier-team-projects",
|
|
6
51
|
"version": "1.4.2",
|