rapier-html 1.2.8 → 1.2.10
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/BUILD.json +54 -12
- package/README.md +9 -9
- package/dist/cli.mjs +0 -1
- package/dist/ledger/authored-origin.mjs +0 -5
- package/dist/ledger/authorship.mjs +0 -2
- package/dist/ledger/carried.mjs +4 -7
- package/dist/ledger/data.mjs +0 -8
- package/dist/ledger/diff/concerns.mjs +0 -16
- package/dist/ledger/diff/diff.mjs +0 -7
- package/dist/ledger/diff/index.mjs +0 -1
- package/dist/ledger/diff/matching.mjs +0 -19
- package/dist/ledger/diff/merge.mjs +0 -9
- package/dist/ledger/diff/moves.mjs +0 -7
- package/dist/ledger/diff/structure.mjs +0 -2
- package/dist/ledger/diff/tokens.mjs +0 -6
- package/dist/ledger/diff/walk.mjs +0 -12
- package/dist/ledger/format.mjs +0 -25
- package/dist/ledger/hash.mjs +11 -7
- package/dist/ledger/history.mjs +10 -29
- package/dist/ledger/inverse-source.mjs +0 -46
- package/dist/ledger/journal-records.mjs +24 -56
- package/dist/ledger/transport.mjs +0 -47
- package/dist/page.mjs +0 -6
- package/dist/rapier.html +186 -100
- package/dist/return-address.mjs +0 -4
- package/dist/wrap.mjs +32 -22
- package/package.json +1 -1
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
// Space-free scripts (Thai, Lao, Myanmar, Khmer, kana, Han) have no word separator; the platform dictionary finds words.
|
|
3
2
|
export const SPACELESS = /[\u0e00-\u0eff\u1000-\u109f\u1780-\u17ff\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/;
|
|
4
3
|
export const words = (segmenter, text) => [...text.matchAll(/[\p{L}\p{N}\p{M}_]+/gu)].flatMap(word => SPACELESS.test(word[0])
|
|
5
4
|
? [...segmenter().segment(word[0])].map(s => [word.index + s.index, word.index + s.index + s.segment.length])
|
|
6
5
|
: [[word.index, word.index + word[0].length]]);
|
|
7
6
|
export const wordSegmenter = () => { let segmenter; return () => segmenter ||= new Intl.Segmenter('en', {granularity: 'word'}); };
|
|
8
7
|
|
|
9
|
-
// Segment only requested non-ASCII boundaries; observations never share a global document cache.
|
|
10
8
|
export function boundaries() {
|
|
11
9
|
const segments = Object.create(null);
|
|
12
10
|
let segmenter, last = [];
|
|
@@ -15,7 +13,6 @@ export function boundaries() {
|
|
|
15
13
|
const a = text.charCodeAt(at - 1), b = text.charCodeAt(at);
|
|
16
14
|
if ((a & 0xfc00) === 0xd800 && (b & 0xfc00) === 0xdc00 || a === 13 && b === 10) return false;
|
|
17
15
|
if (a < 0x300 && b < 0x300) return true;
|
|
18
|
-
// Retreats probe one long cluster many times; answer inside the last cluster without the segmenter.
|
|
19
16
|
if (last[0] === text && at > last[1] && at < last[2]) return false;
|
|
20
17
|
const found = (segments[text] ||= (segmenter ||= new Intl.Segmenter('en', {granularity: 'grapheme'})).segment(text)).containing(at);
|
|
21
18
|
last = [text, found.index, found.index + found.segment.length];
|
|
@@ -23,16 +20,13 @@ export function boundaries() {
|
|
|
23
20
|
}
|
|
24
21
|
}
|
|
25
22
|
|
|
26
|
-
// Letters are code points: an astral letter is one letter, never two lone surrogates.
|
|
27
23
|
const pair = (text, at) => (text.charCodeAt(at - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(at) & 0xfc00) === 0xdc00;
|
|
28
24
|
export const prev = (text, at) => text.slice(Math.max(0, at - 1 - pair(text, at - 1)), at), next = (text, at) => text.slice(at, at + 1 + pair(text, at + 1));
|
|
29
25
|
export const letter = c => c && /[\p{L}\p{M}\p{N}_]/u.test(c), within = (text, at) => letter(prev(text, at)) && letter(next(text, at));
|
|
30
26
|
|
|
31
|
-
// Tokens at a level: 0 parts, 1 lines, 2 words, spaces and marks, 3 characters.
|
|
32
27
|
export function tokenizer(c) {
|
|
33
28
|
const {spend, safe, segmenter, raw, aliases} = c;
|
|
34
29
|
return function tokens(text, start, end, level, parts) {
|
|
35
|
-
// Each source boundary is stored once, alongside comparison keys and optional owners.
|
|
36
30
|
const keys = [], at = [start], owners = [];
|
|
37
31
|
const out = [keys, at, owners];
|
|
38
32
|
const add = (a, b, part, value) => {
|
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
import {words, letter, within, prev, next} from './tokens.mjs';
|
|
3
3
|
|
|
4
|
-
// Exact source records for a range: lines, then words, then characters, or structural parts when supplied.
|
|
5
4
|
export function walker(c) {
|
|
6
5
|
const {before, after, spend, safe, budget, raw, chars, code, segmenter, tokens, matches} = c;
|
|
7
6
|
function record(a0, a1, b0, b1, unit, part, coarse = false) {
|
|
8
7
|
if (a1 - a0 === b1 - b0 && before.slice(a0, a1) === after.slice(b0, b1)) return;
|
|
9
|
-
// Internal records: before, after, coarse, concern, transported fragments.
|
|
10
8
|
c.result.push([[a0, a1], [b0, b1], coarse, [a0 === a1 ? 2 : b0 === b1 ? 0 : 1, part?.kind || unit, part?.name]]);
|
|
11
9
|
}
|
|
12
10
|
|
|
13
|
-
// A line deletion or insertion holding whole named parts and only blank text besides is one record per part,
|
|
14
|
-
// cut at line starts, so each keeps its own name (and its callers' breaks).
|
|
15
11
|
function name(from, ownersA, ownersB) {
|
|
16
12
|
for (let r = from; r < c.result.length; r++) {
|
|
17
13
|
const [[a0, a1], [b0, b1]] = c.result[r], side = a0 === a1 ? 1 : b0 === b1 ? 0 : -1;
|
|
@@ -34,7 +30,6 @@ export function walker(c) {
|
|
|
34
30
|
}
|
|
35
31
|
}
|
|
36
32
|
|
|
37
|
-
// A search step scans at most one fixed-size source chunk.
|
|
38
33
|
function common(a, b, end, step) {
|
|
39
34
|
let n = 0;
|
|
40
35
|
while (n < end && spend()) {
|
|
@@ -95,7 +90,6 @@ export function walker(c) {
|
|
|
95
90
|
}
|
|
96
91
|
} else if (level < 2 && raw !== 'line') lines(x.slice(ai, i + 1), y.slice(bi, j + 1), context);
|
|
97
92
|
else if (level === 2 && chars && !raw && i - ai <= 1 && j - bi <= 1 && x1 - x0 + y1 - y0 < 100) {
|
|
98
|
-
// A changed token is replaced whole unless the caller asks for the characters inside it.
|
|
99
93
|
walk(x0, x1, y0, y1, 3, null, null, context);
|
|
100
94
|
} else record(x0, x1, y0, y1, unit, context);
|
|
101
95
|
}
|
|
@@ -108,12 +102,9 @@ export function walker(c) {
|
|
|
108
102
|
}
|
|
109
103
|
}
|
|
110
104
|
|
|
111
|
-
// Unequal lines pair in order by shared words. A line sharing under a quarter of its words with every candidate is
|
|
112
|
-
// replaced whole: an inserted line never borrows a neighbor's words, and a rewrite never reads as stray fragments.
|
|
113
105
|
function lines(x, y, context) {
|
|
114
106
|
const n = x.length - 1, m = y.length - 1;
|
|
115
107
|
if (n * m > 4096 || !spend(n * m + x[n] - x[0] + y[m] - y[0])) return row(x[0], x[n], y[0], y[m], context);
|
|
116
|
-
// A renamed name's sites count as shared words.
|
|
117
108
|
if (!c.renames) {
|
|
118
109
|
c.renames = [[], []];
|
|
119
110
|
for (const [p, old] of c.aliases || []) if (before.slice(...old.nameAt) !== after.slice(...p.nameAt)) {
|
|
@@ -141,7 +132,6 @@ export function walker(c) {
|
|
|
141
132
|
}
|
|
142
133
|
let k = 0, l = 0;
|
|
143
134
|
for (const [p, q] of [...pairs, [n, m]]) {
|
|
144
|
-
// A merge compares one line against one line by character, whatever they share.
|
|
145
135
|
if (raw === 'char' && p - k === 1 && q - l === 1) row(x[k], x[p], y[l], y[q], context);
|
|
146
136
|
else if (k < p || l < q) record(x[k], x[p], y[l], y[q], 'line', context);
|
|
147
137
|
if (p < n) row(x[p], x[p + 1], y[q], y[q + 1], context);
|
|
@@ -149,7 +139,6 @@ export function walker(c) {
|
|
|
149
139
|
}
|
|
150
140
|
}
|
|
151
141
|
|
|
152
|
-
// A table row with the same number of cells on both sides changes cell by cell; a character diff stays in its line.
|
|
153
142
|
function row(a0, a1, b0, b1, context) {
|
|
154
143
|
if (raw === 'char') return walk(a0, a1, b0, b1, 3, null, null, context);
|
|
155
144
|
const cells = (text, a, b) => /^[ \t]*\|/.test(text.slice(a, b)) && [...text.slice(a, b).matchAll(/(?<!\\)\|/g)].map(m => a + m.index);
|
|
@@ -158,7 +147,6 @@ export function walker(c) {
|
|
|
158
147
|
for (let k = 0; k <= p.length; k++) walk(k ? p[k - 1] + 1 : a0, p[k] ?? a1, k ? q[k - 1] + 1 : b0, q[k] ?? b1, 2, null, null, context);
|
|
159
148
|
}
|
|
160
149
|
|
|
161
|
-
// Surviving source pieces keep the occurrence witnessed by the host.
|
|
162
150
|
function operations(rows) {
|
|
163
151
|
if (!Array.isArray(rows)) return null;
|
|
164
152
|
const pieces = [[0, before.length]];
|
package/dist/ledger/format.mjs
CHANGED
|
@@ -8,8 +8,6 @@ import {canonicalJSON, canonicalParts, plainData, documentText, utf8Length, exac
|
|
|
8
8
|
export {textRoot, rootAfter};
|
|
9
9
|
const digest = value => sha256Parts(canonicalParts(value));
|
|
10
10
|
const rootShape = value => typeof value === 'string' && /^\d+:[a-z0-9]+:[a-z0-9]+$/.test(value);
|
|
11
|
-
// Structural/history admission is shared by the reader and the producer. The producer
|
|
12
|
-
// proves source replay by reversing exact admitted splices; it need not replay its output.
|
|
13
11
|
function* checkedRecords(ledger, prepared) {
|
|
14
12
|
if (!prepared.ok || ledger.records.length !== prepared.records.length) throw fault('record chain differs');
|
|
15
13
|
let root = ledger.start.root, revision = ledger.start.revision;
|
|
@@ -27,16 +25,9 @@ function* checkedRecords(ledger, prepared) {
|
|
|
27
25
|
}
|
|
28
26
|
if (ledger.head.revision !== revision || ledger.head.root !== root) throw fault('history does not describe this document');
|
|
29
27
|
}
|
|
30
|
-
// A ledger object read again is proved once: a boot reads one stored history through several records. The proof holds only
|
|
31
|
-
// while that object still carries the exact proved data (its start text compared whole, the rest as canonical JSON), so a
|
|
32
|
-
// changed byte is proved again, and refused. The proof lives as long as the object does.
|
|
33
28
|
const proofs = new WeakMap();
|
|
34
29
|
function readLedgerState(input, expectedText, expectedMetadata) {
|
|
35
30
|
const ledger = plainData(input);
|
|
36
|
-
// The identifier rapier-ledger/2 is unchanged and is not backward compatible. This reader
|
|
37
|
-
// admits an empty physical splice whose authored rows name the same replacement. A reader
|
|
38
|
-
// from before that rule rejects the chain. Mixed-version sync throws notes_history_unavailable
|
|
39
|
-
// and leaves the older store unchanged; that refusal is not a held or caughtUp result.
|
|
40
31
|
if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
|
|
41
32
|
typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
|
|
42
33
|
typeof ledger.complete !== 'boolean' || !Array.isArray(ledger.records) ||
|
|
@@ -81,7 +72,6 @@ function proveLedger(ledger) {
|
|
|
81
72
|
if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text)) throw fault('history does not describe this document');
|
|
82
73
|
return {text, metadata, root, revision, prepared, source};
|
|
83
74
|
}
|
|
84
|
-
// A stored copy may hold the head text alone. Its start is that text with every record reversed; readLedger proves it.
|
|
85
75
|
export function ledgerWithStart(input, text) {
|
|
86
76
|
let source = new HistorySource(documentText(text));
|
|
87
77
|
for (let i = input.records.length - 1; i >= 0; i--) {
|
|
@@ -129,11 +119,8 @@ function* sourceFacts(text) {
|
|
|
129
119
|
}
|
|
130
120
|
function* produceLedger({text, metadata, records, documentAuthority, revision, root, complete = false, earlier = records} = {}) {
|
|
131
121
|
if (!_rapierValidMetadata(metadata)) throw fault('document metadata required');
|
|
132
|
-
// Metadata has two admitted scalar fields. Keep its insertion order in the
|
|
133
|
-
// emitted envelope while capturing it before the first cooperative boundary.
|
|
134
122
|
metadata = {...metadata};
|
|
135
123
|
if (!Array.isArray(records) || !Array.isArray(earlier)) throw fault('records required');
|
|
136
|
-
// Inspect before touching properties: exporting must not call accessors on an alleged record.
|
|
137
124
|
const prepared = _rapierPrepareHistoryRecords(records, earlier);
|
|
138
125
|
if (!prepared.ok) throw fault('navigation target was not retained');
|
|
139
126
|
const headFacts = yield* sourceFacts(text);
|
|
@@ -153,8 +140,6 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
|
|
|
153
140
|
startSource = startSource.apply(expanded[i].splices, true);
|
|
154
141
|
startMetadata = _rapierTransformMetadata(startMetadata, expanded[i].metadata, true);
|
|
155
142
|
if (startSource === null || !startMetadata) throw fault('history does not reverse from this document');
|
|
156
|
-
// Whole scalars and exact splice boundaries were just checked. Count the changed
|
|
157
|
-
// bytes, rather than re-encoding every reconstructed whole-document revision.
|
|
158
143
|
for (const row of expanded[i].splices) bytes += utf8Length(row.removed) - utf8Length(row.inserted);
|
|
159
144
|
if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
|
|
160
145
|
yield;
|
|
@@ -174,8 +159,6 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
|
|
|
174
159
|
const payload = {format: 'rapier-ledger/2', documentAuthority,
|
|
175
160
|
start: {text: startText, revision: startRevision, root: startRoot, sha256: startSHA, metadata: startMetadata},
|
|
176
161
|
records: expanded, head: {revision, root, sha256: headSHA, metadata: {...metadata}}, complete: complete === true && startRevision === 0 && expanded.every((row, i) => !i || row.transaction.baseRevision === expanded[i - 1].transaction.revision)};
|
|
177
|
-
// Expanded rows are the exact canonical projection of this owned preparation.
|
|
178
|
-
// Keep its navigation/effect proof instead of admitting another copied graph.
|
|
179
162
|
for (const _record of checkedRecords(payload, prepared)) yield;
|
|
180
163
|
if (expanded.some(row => Object.hasOwn(row, 'authored'))) {
|
|
181
164
|
try { authoredHistory(startText, expanded); } catch (_) { throw fault('authored origin differs'); }
|
|
@@ -191,7 +174,6 @@ function finish(producer) {
|
|
|
191
174
|
return step.value;
|
|
192
175
|
}
|
|
193
176
|
export function exportLedger(input) { return finish(produceLedger(input)).ledger; }
|
|
194
|
-
// Its returned facts were computed while admitting/hashing the exact head, never supplied.
|
|
195
177
|
export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {}) {
|
|
196
178
|
const producer = produceLedger(input);
|
|
197
179
|
let step;
|
|
@@ -199,7 +181,6 @@ export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {})
|
|
|
199
181
|
signal?.throwIfAborted();
|
|
200
182
|
step = producer.next();
|
|
201
183
|
if (!step.done && yieldWork) {
|
|
202
|
-
// The caller owns scheduling and its clock; ledger admission reads neither.
|
|
203
184
|
const pending = yieldWork();
|
|
204
185
|
if (pending) await pending;
|
|
205
186
|
}
|
|
@@ -207,7 +188,6 @@ export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {})
|
|
|
207
188
|
signal?.throwIfAborted();
|
|
208
189
|
return step.value;
|
|
209
190
|
}
|
|
210
|
-
// Derived navigation, never a second history. A target older than start is intentionally unavailable.
|
|
211
191
|
export function historyEnvelope(input, segmentIdentity = [], expectedText, expectedMetadata) {
|
|
212
192
|
const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input, expectedText, expectedMetadata);
|
|
213
193
|
const {byId, effects: {pictures}} = prepared;
|
|
@@ -235,8 +215,6 @@ export function historyEnvelope(input, segmentIdentity = [], expectedText, expec
|
|
|
235
215
|
}
|
|
236
216
|
if (ledger.complete) {
|
|
237
217
|
if (ledger.records.length && !branch.length) throw fault('complete history lost its branch');
|
|
238
|
-
// Literal admitted splices are reversible. Proving the actual head back to
|
|
239
|
-
// the exact start also proves its forward active branch, without replaying it.
|
|
240
218
|
if (traversed.length !== ledger.start.text.length || !traversed.matches(0, ledger.start.text) ||
|
|
241
219
|
canonicalJSON(traversedMetadata) !== canonicalJSON(ledger.start.metadata)) throw fault('complete branch differs');
|
|
242
220
|
}
|
|
@@ -246,8 +224,6 @@ export function historyEnvelope(input, segmentIdentity = [], expectedText, expec
|
|
|
246
224
|
traversedMetadata = _rapierTransformMetadata(traversedMetadata, byId.get(branch[i]).metadata);
|
|
247
225
|
if (traversed === null || !traversedMetadata) throw fault('redo branch does not replay');
|
|
248
226
|
}
|
|
249
|
-
// Picture folding may relocate finished definitions while navigating. Keep its
|
|
250
|
-
// distinct forward proof; the literal-only case above needs no second traversal.
|
|
251
227
|
if (ledger.complete && pictures.size) {
|
|
252
228
|
let active = new HistorySource(ledger.start.text), activeMetadata = {...ledger.start.metadata};
|
|
253
229
|
for (let i = 0; i < branch.length; i++) {
|
|
@@ -270,7 +246,6 @@ export function agentActorId(door, agent) {
|
|
|
270
246
|
return door + '/' + agent.name;
|
|
271
247
|
}
|
|
272
248
|
|
|
273
|
-
// A writer changing representation records that change, rather than attaching an unrelated head.
|
|
274
249
|
export function replaceLedgerText(input, text, {actor = {kind: 'system', id: 'rapier'}, operation = 'document.replace', at} = {}) {
|
|
275
250
|
const current = readLedger(input); documentText(text);
|
|
276
251
|
if (current.text === text) return current.ledger;
|
package/dist/ledger/hash.mjs
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
// The editor's path-dependent root, plus synchronous SHA-256 for file readers (including offline wrappers).
|
|
3
2
|
export function textRoot(text) {
|
|
4
3
|
return textRootParts([text]);
|
|
5
4
|
}
|
|
6
|
-
// The same UTF-16 root over shared source pieces, without joining a historical body.
|
|
7
5
|
export function textRootParts(parts) {
|
|
8
6
|
let fnv = 0x811c9dc5, a = 1, b = 0, length = 0;
|
|
9
7
|
for (const text of parts) {
|
|
@@ -30,7 +28,6 @@ const K = new Uint32Array([
|
|
|
30
28
|
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
|
|
31
29
|
]);
|
|
32
30
|
const rotate = (x, n) => (x >>> n) | (x << (32 - n));
|
|
33
|
-
// One bounded SHA-256 compression state for file bytes and encoded source text.
|
|
34
31
|
export function sha256State() {
|
|
35
32
|
const h = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
|
|
36
33
|
const w = new Uint32Array(64), block = new Uint8Array(64);
|
|
@@ -82,14 +79,11 @@ export function sha256State() {
|
|
|
82
79
|
get bytes() { return bytes; },
|
|
83
80
|
};
|
|
84
81
|
}
|
|
85
|
-
// UTF-16 chunks share that byte state. Retain a trailing high surrogate so a
|
|
86
|
-
// caller can split a character between updates without changing its UTF-8 digest.
|
|
87
82
|
export function createSHA256() {
|
|
88
83
|
const hash = sha256State(), encoded = new Uint8Array(65536), encoder = new TextEncoder();
|
|
89
84
|
let high = '', finished = false;
|
|
90
85
|
const write = text => {
|
|
91
86
|
for (let at = 0; at < text.length;) {
|
|
92
|
-
// Bound both the substring and byte allocation; encodeInto never cuts a scalar.
|
|
93
87
|
let end = Math.min(text.length, at + 32768);
|
|
94
88
|
if (end < text.length && (text.charCodeAt(end - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(end) & 0xfc00) === 0xdc00) end--;
|
|
95
89
|
const part = encoder.encodeInto(text.slice(at, end), encoded);
|
|
@@ -117,4 +111,14 @@ export function sha256Parts(parts) {
|
|
|
117
111
|
for (const part of parts) hash.update(part);
|
|
118
112
|
return hash.digest();
|
|
119
113
|
}
|
|
120
|
-
|
|
114
|
+
const LONG_TEXT = 65536, DIGESTS = new Map();
|
|
115
|
+
export function sha256(text) {
|
|
116
|
+
if (typeof text !== 'string' || text.length < LONG_TEXT) return createSHA256().update(text).digest();
|
|
117
|
+
let digest = DIGESTS.get(text);
|
|
118
|
+
if (digest === undefined) {
|
|
119
|
+
digest = createSHA256().update(text).digest();
|
|
120
|
+
if (DIGESTS.size >= 4) DIGESTS.delete(DIGESTS.keys().next().value);
|
|
121
|
+
} else DIGESTS.delete(text);
|
|
122
|
+
DIGESTS.set(text, digest);
|
|
123
|
+
return digest;
|
|
124
|
+
}
|
package/dist/ledger/history.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
// Transient projections and selective inverses of the existing canonical journal.
|
|
3
2
|
import {_rapierTransformSplices, _rapierValidMetadata, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState, _rapierPrepareHistoryRecords} from './journal-records.mjs';
|
|
4
3
|
import {projectInverseSource, HistorySource} from './inverse-source.mjs';
|
|
5
4
|
import {textRootParts, rootAfter} from './hash.mjs';
|
|
@@ -41,16 +40,12 @@ export function groupHistoryActs(acts) {
|
|
|
41
40
|
return groups;
|
|
42
41
|
}
|
|
43
42
|
|
|
44
|
-
// A picture's codec replacement travels with its original navigation step.
|
|
45
|
-
// Match every complete replacement exactly before moving any source bytes.
|
|
46
43
|
export function pictureHistorySplices(input, target, redo) {
|
|
47
44
|
const {records} = input;
|
|
48
45
|
const id = target?.transaction?.id, at = records.indexOf(target);
|
|
49
46
|
if (!id || at < 0 || target.transaction.operation === 'document.finish-picture' || !Array.isArray(target.splices)) return null;
|
|
50
47
|
const later = records.slice(at + 1).filter(row => row?.transaction?.operation === 'document.finish-picture' && Array.isArray(row.splices) && row.splices.length);
|
|
51
48
|
if (!later.length) return null;
|
|
52
|
-
// A caller may hold a persistent source tree: request its text only when
|
|
53
|
-
// a later picture replacement can actually affect this navigation step.
|
|
54
49
|
const source = input.source;
|
|
55
50
|
const own = new Set(later.filter(row => row.transaction.sourceTransactionId === id));
|
|
56
51
|
const step = redo ? target.splices : target.splices.slice().reverse().map(row => ({pos: row.pos, removed: row.inserted, inserted: row.removed}));
|
|
@@ -78,9 +73,6 @@ export function pictureHistorySplices(input, target, redo) {
|
|
|
78
73
|
try { return plan(true); } catch (_) { return null; }
|
|
79
74
|
}
|
|
80
75
|
|
|
81
|
-
// Keep only the latest source observation. Inactive live documents must not retain
|
|
82
|
-
// every revision's source tree. The weak key also lets the latest input be collected.
|
|
83
|
-
// Mutable caller data is compared/captured before this cache can be consulted.
|
|
84
76
|
let preparedHistories = new WeakMap();
|
|
85
77
|
function prepareHistory(input) {
|
|
86
78
|
try {
|
|
@@ -123,7 +115,6 @@ function prepareHistory(input) {
|
|
|
123
115
|
row.beforeSource = initial; row.beforeMetadata = initialMetadata;
|
|
124
116
|
}
|
|
125
117
|
const earliestRevision = records[0]?.transaction.baseRevision ?? revision;
|
|
126
|
-
// The window's owner may name its root (the source store mints it once per text); otherwise hash the initial text.
|
|
127
118
|
let root = earliestRevision === 0 ? (typeof input.earliestRoot === 'string' && input.earliestRoot || textRootParts(initial.parts())) : null,
|
|
128
119
|
rootRevision = earliestRevision;
|
|
129
120
|
for (const {record, splices} of rows) {
|
|
@@ -131,8 +122,6 @@ function prepareHistory(input) {
|
|
|
131
122
|
if (hashed && (![record.beforeHash, record.afterHash].every(value =>
|
|
132
123
|
typeof value === 'string' && /^\d+:[a-z0-9]+:[a-z0-9]+$/.test(value)) ||
|
|
133
124
|
(splices.length ? record.beforeHash === record.afterHash : record.beforeHash !== record.afterHash))) return historyFailure('history_invalid');
|
|
134
|
-
// Revision gaps begin a new path root; a continuous revision-zero prefix
|
|
135
|
-
// still binds every untouched source byte through the initial text root.
|
|
136
125
|
if (record.transaction.baseRevision !== rootRevision) root = null;
|
|
137
126
|
if (hashed) {
|
|
138
127
|
if (root !== null && record.beforeHash !== root) return historyFailure('history_invalid');
|
|
@@ -155,12 +144,18 @@ function prepareHistory(input) {
|
|
|
155
144
|
} catch (_) { return historyFailure('history_invalid'); }
|
|
156
145
|
}
|
|
157
146
|
|
|
147
|
+
function settle(result, value) {
|
|
148
|
+
Object.defineProperty(result, 'initialSource', {value, writable: true, enumerable: true, configurable: true});
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
|
|
158
152
|
export function replayHistory(input) {
|
|
159
153
|
const prepared = prepareHistory(input);
|
|
160
154
|
if (!prepared.ok) return prepared;
|
|
161
|
-
const {source,
|
|
155
|
+
const {source, metadata, initialMetadata, revision, earliestRevision, complete, rows, effects} = prepared;
|
|
162
156
|
const acts = rows.filter(({record}) => !effects.pictures.has(record.transaction.id)).map(({record}) => historyAct(record, effects));
|
|
163
|
-
return {ok: true, source, initialSource,
|
|
157
|
+
return {ok: true, source, get initialSource() { return settle(this, prepared.initialSource); }, set initialSource(value) { settle(this, value); },
|
|
158
|
+
metadata: {...metadata}, initialMetadata: {...initialMetadata}, revision, earliestRevision, complete, acts,
|
|
164
159
|
effectiveActIds: effects.effectiveActIds.slice(), groups: groupHistoryActs(acts)};
|
|
165
160
|
}
|
|
166
161
|
|
|
@@ -218,7 +213,6 @@ export function historyProjection(input, {from = 0, to = input?.source?.length,
|
|
|
218
213
|
return projectHistoryRange(prepared, from, to, actId);
|
|
219
214
|
}
|
|
220
215
|
|
|
221
|
-
// Several ranges share one proved source snapshot for this call.
|
|
222
216
|
export function historyProjections(input, ranges) {
|
|
223
217
|
const prepared = prepareHistory(input);
|
|
224
218
|
if (!prepared.ok) return prepared;
|
|
@@ -227,8 +221,6 @@ export function historyProjections(input, ranges) {
|
|
|
227
221
|
projectHistoryRange(prepared, from, to, actId))};
|
|
228
222
|
}
|
|
229
223
|
|
|
230
|
-
// Block intervals stay ordered under reverse splices. A fixed balanced tree can
|
|
231
|
-
// shift an untouched suffix lazily and visit only intervals crossing the edit.
|
|
232
224
|
function historyBlockIndex(projected) {
|
|
233
225
|
const items = projected.map(block => ({block, range: {from: block.from, to: block.to}, shift: 0}))
|
|
234
226
|
.sort((a, b) => a.range.from - b.range.from);
|
|
@@ -278,8 +270,6 @@ export function historyPlaces(input, blocks) {
|
|
|
278
270
|
const {record, splices, beforeSource, afterSource} = prepared.rows[index], touched = new Map();
|
|
279
271
|
for (let at = splices.length - 1; at >= 0; at--) ranges.move(splices[at], node => {
|
|
280
272
|
if (touched.has(node) || prepared.effects.pictures.has(record.transaction.id)) return;
|
|
281
|
-
// Earlier splices in this reverse walk only shifted this interval. Recover
|
|
282
|
-
// its coordinates at the record's after-state before recording the first touch.
|
|
283
273
|
let after = {...node.range};
|
|
284
274
|
for (let prior = at + 1; prior < splices.length; prior++) {
|
|
285
275
|
const row = splices[prior];
|
|
@@ -290,37 +280,31 @@ export function historyPlaces(input, blocks) {
|
|
|
290
280
|
}
|
|
291
281
|
touched.set(node, after);
|
|
292
282
|
});
|
|
293
|
-
// Blocks one splice covers share its range (a whole-document delete maps every block to all it removed): slice each range once.
|
|
294
283
|
const slices = new Map(), cut = (source, side, from, to) => {
|
|
295
284
|
const key = side + from + ':' + to;
|
|
296
285
|
if (!slices.has(key)) slices.set(key, source.slice(from, to));
|
|
297
286
|
return slices.get(key);
|
|
298
287
|
};
|
|
299
|
-
// One record's act is read once; the blocks it touched share its fields (a whole-document delete touches every block).
|
|
300
288
|
let act = null;
|
|
301
289
|
for (const [node, afterRange] of touched) {
|
|
302
290
|
const beforeRange = ranges.range(node), previous = {...beforeRange, source: cut(beforeSource, 'b', beforeRange.from, beforeRange.to)},
|
|
303
291
|
after = {...afterRange, source: cut(afterSource, 'a', afterRange.from, afterRange.to)};
|
|
304
|
-
// A point (where words were deleted) has no words to compare: the act that touched it wrote there.
|
|
305
292
|
if (previous.source !== after.source || afterRange.from === afterRange.to) node.block.acts.push({...(act ||= historyAct(record)), before: previous, after});
|
|
306
293
|
}
|
|
307
294
|
}
|
|
308
295
|
|
|
309
296
|
const acts = prepared.rows.filter(({record}) => !prepared.effects.pictures.has(record.transaction.id)).map(({record}) => historyAct(record));
|
|
310
|
-
// Native Redo consumes its Undo as navigation while keeping that receipt in
|
|
311
|
-
// the reading history. All material withdrawals use the canonical blockers,
|
|
312
|
-
// including independent inverses holding the same original act suppressed.
|
|
313
297
|
const reversed = new Set();
|
|
314
298
|
for (const {record} of prepared.rows) if (prepared.effects.active.has(record.transaction.id) && !record.transaction.reapplies)
|
|
315
299
|
for (const id of prepared.effects.references.get(record.transaction.id) || []) reversed.add(id);
|
|
316
300
|
|
|
317
301
|
for (const block of projected) block.acts.reverse();
|
|
318
|
-
return {ok: true, source: prepared.source, initialSource
|
|
302
|
+
return {ok: true, source: prepared.source, get initialSource() { return settle(this, prepared.initialSource); },
|
|
303
|
+
set initialSource(value) { settle(this, value); }, revision: prepared.revision,
|
|
319
304
|
earliestRevision: prepared.earliestRevision, complete: prepared.complete, acts, blocks: projected,
|
|
320
305
|
reversed: acts.filter(act => reversed.has(act.id)).map(act => act.id)};
|
|
321
306
|
}
|
|
322
307
|
|
|
323
|
-
// Runs retain only exact replacement ancestry. A later replacement owns an ambiguous hunk whole.
|
|
324
308
|
export function selectiveUndo(input, actIds, options = {}) {
|
|
325
309
|
const prepared = prepareHistory(input);
|
|
326
310
|
if (!prepared.ok) return prepared;
|
|
@@ -340,9 +324,6 @@ export function selectiveUndo(input, actIds, options = {}) {
|
|
|
340
324
|
const metadata = _rapierMetadataDelta(prepared.metadata, afterMetadata), effectChanged = selected.size > 0;
|
|
341
325
|
const sourceEffectChanged = records.some(record => record.splices.length && !prepared.effects.references.get(record.transaction.id)?.length &&
|
|
342
326
|
prepared.effects.active.has(record.transaction.id) !== projectedEffects.active.has(record.transaction.id));
|
|
343
|
-
// One shared replacement is visible while any of its authors is active. That
|
|
344
|
-
// includes an author stored with no second physical splice, and an inverse
|
|
345
|
-
// whose only effect is to make such an author active again.
|
|
346
327
|
let coverVisibilityChanged = false;
|
|
347
328
|
if (!sourceEffectChanged && selected.size) {
|
|
348
329
|
let origins = null;
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
// Exact source projection shared by canonical history and causal transport.
|
|
3
|
-
// Callers admit and order the records first; this projection retains no state.
|
|
4
2
|
import {_rapierTransformSplices, _rapierHistoryEffects} from './journal-records.mjs';
|
|
5
3
|
import {authoredPlacements} from './authored-origin.mjs';
|
|
6
4
|
import {canonicalJSON} from './data.mjs';
|
|
@@ -11,8 +9,6 @@ const historyBoundary = (source, at) => Number.isSafeInteger(at) && at >= 0 && !
|
|
|
11
9
|
!(at > 0 && at < source.length && (source.charCodeAt(at - 1) & 0xFC00) === 0xD800 &&
|
|
12
10
|
(source.charCodeAt(at) & 0xFC00) === 0xDC00);
|
|
13
11
|
|
|
14
|
-
// Persistent AVL pieces retain original strings and coordinates. A revision costs
|
|
15
|
-
// only the edited paths; reading a range visits its leaves, never earlier bodies.
|
|
16
12
|
const sourceLeaf = (text, from = 0, to = text.length) => to > from
|
|
17
13
|
? {text, from, length: to - from, height: 1} : null;
|
|
18
14
|
const sourceHeight = node => node?.height || 0;
|
|
@@ -101,8 +97,6 @@ export class HistorySource {
|
|
|
101
97
|
for (const text of parts) for (let at = 0; at < text.length;) {
|
|
102
98
|
if (!leaf) return false;
|
|
103
99
|
const length = Math.min(leaf.length - offset, text.length - at), from = leaf.from + offset;
|
|
104
|
-
// Untouched runs share their string and coordinates. Only changed fragments
|
|
105
|
-
// require a text comparison; no full-body join is needed to prove the plan.
|
|
106
100
|
if ((leaf.text !== text || from !== at) && leaf.text.slice(from, from + length) !== text.slice(at, at + length)) return false;
|
|
107
101
|
offset += length; at += length;
|
|
108
102
|
if (offset === leaf.length) { leaf = leaves.next().value; offset = 0; }
|
|
@@ -113,8 +107,6 @@ export class HistorySource {
|
|
|
113
107
|
const [left, rest] = sourceSplit(this.#root, pos), [, right] = sourceSplit(rest, length);
|
|
114
108
|
return new HistorySource('', sourceJoin(sourceJoin(left, sourceLeaf(text)), right));
|
|
115
109
|
}
|
|
116
|
-
// Undoing an admitted repair is observation: its after-state can join a scalar
|
|
117
|
-
// across the deleted run. Prove the forward boundaries on the reconstructed view.
|
|
118
110
|
before(splices) {
|
|
119
111
|
let source = this;
|
|
120
112
|
for (let index = splices.length - 1; index >= 0; index--) {
|
|
@@ -151,8 +143,6 @@ function minimalHistorySplice(before, after, pos = 0) {
|
|
|
151
143
|
return {pos: pos + first, removed: before.slice(first, a), inserted: after.slice(first, b)};
|
|
152
144
|
}
|
|
153
145
|
|
|
154
|
-
// Split recorded replacements only at exact unchanged word or line tokens.
|
|
155
|
-
// Shared letters inside changed words do not establish surviving ownership.
|
|
156
146
|
function historyHunks(row) {
|
|
157
147
|
if (row.removed === row.inserted) return [];
|
|
158
148
|
if (!row.removed || !row.inserted) return [{...row}];
|
|
@@ -166,8 +156,6 @@ function historyHunks(row) {
|
|
|
166
156
|
}
|
|
167
157
|
|
|
168
158
|
|
|
169
|
-
// Rows are already proved source placements, optionally carrying sourceIndices from
|
|
170
|
-
// causal projection. Multiple inverse blockers can change history with no source edit.
|
|
171
159
|
export function projectInverseSource(prepared, selected, projectedEffects, options = {}) {
|
|
172
160
|
const records = prepared.records || prepared.rows.map(({record, splices}) => record.splices === splices ? record : {...record, splices});
|
|
173
161
|
if (!records.some(record => !prepared.effects.references.get(record.transaction.id)?.length &&
|
|
@@ -179,8 +167,6 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
179
167
|
const recordsForCover = prepared.records || prepared.rows.map(({record, splices}) => record.splices === splices ? record : {...record, splices});
|
|
180
168
|
const coverGroups = new Map();
|
|
181
169
|
let coverOrigins = null;
|
|
182
|
-
// An empty document is a real causal source. Fresh notes start there, and a
|
|
183
|
-
// shared replacement authored against that source still has to stay covered.
|
|
184
170
|
if (typeof prepared.initialSource === 'string' && recordsForCover.some(record => record.authored?.splices?.some(row => row.removed.length))) {
|
|
185
171
|
try { coverOrigins = authoredPlacements(prepared.initialSource, recordsForCover); } catch { coverOrigins = null; }
|
|
186
172
|
}
|
|
@@ -254,9 +240,6 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
254
240
|
return next && effects.active.has(next.actId) && !prepared.effects.references.get(next.actId)?.length;
|
|
255
241
|
});
|
|
256
242
|
const reactivate = (actIds, effects) => {
|
|
257
|
-
// Any author of one shared removal or replacement restores that one physical
|
|
258
|
-
// hunk, including an author who stored no splice. A later independent act
|
|
259
|
-
// keeps the bytes it wrote until that act itself is withdrawn.
|
|
260
243
|
const wanted = new Set(actIds);
|
|
261
244
|
for (const id of actIds) for (const hunk of all) {
|
|
262
245
|
if (hunk.reversedHunk || hunk.derived || !hunk.coverIds?.includes(id) || wanted.has(hunk.actId)) continue;
|
|
@@ -269,8 +252,6 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
269
252
|
(!hunk.inserted && hunk.active)) continue;
|
|
270
253
|
const marker = pieces.indexOf(hunk.marker);
|
|
271
254
|
if (marker < 0) {
|
|
272
|
-
// A zero-row withdrawal can release source still carried by another
|
|
273
|
-
// inverse's marker. Its exact ancestry also preserves later inserted runs.
|
|
274
255
|
const inverse = all.findLast(prior => prior.reversedHunk === hunk.id && pieces.includes(prior.marker));
|
|
275
256
|
if (!inverse) throw Error('reactivation');
|
|
276
257
|
const runs = all.filter(prior => prior.actId === inverse.actId && prior.reversedHunk === hunk.id && pieces.includes(prior.marker));
|
|
@@ -302,8 +283,6 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
302
283
|
processed.push(record);
|
|
303
284
|
const picture = prepared.effects.pictures.get(tx.id);
|
|
304
285
|
if (picture) {
|
|
305
|
-
// The new codec bytes keep the exact inserted run's ownership. Do not
|
|
306
|
-
// cross a later edit, combine authors, or create another reversible act.
|
|
307
286
|
for (const row of splices) {
|
|
308
287
|
boundary(row.pos + row.removed.length);
|
|
309
288
|
const start = boundary(row.pos), end = boundary(row.pos + row.removed.length);
|
|
@@ -318,15 +297,11 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
318
297
|
continue;
|
|
319
298
|
}
|
|
320
299
|
if (references.size) {
|
|
321
|
-
// Exact inverse rows restore the previous run identities. Retain the inverse's
|
|
322
|
-
// own transient marker too, so reversing it restores that same ancestry again.
|
|
323
300
|
const invert = targets => {
|
|
324
301
|
const restored = pieces.slice(), inverses = [], priorSplices = options.keepHunk ? [] : null;
|
|
325
302
|
for (let index = targets.length - 1; index >= 0; index--) {
|
|
326
303
|
const target = targets[index], first = restored.indexOf(target.marker);
|
|
327
304
|
if (first < 0) return null;
|
|
328
|
-
// Each run of the target's own pieces inverts alone: a later author's pieces between
|
|
329
|
-
// its runs stay in place, outside every inverse, so reversing it cannot move them.
|
|
330
305
|
const runs = [];
|
|
331
306
|
for (let at = first; at < restored.length; at++) if (owns(restored[at], target))
|
|
332
307
|
if (runs.at(-1)?.[1] === at) runs.at(-1)[1]++; else runs.push([at, at + 1]);
|
|
@@ -346,8 +321,6 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
346
321
|
hunk.chosen = !keep;
|
|
347
322
|
}
|
|
348
323
|
hunk.kept = selected.has(tx.id) && !hunk.chosen;
|
|
349
|
-
// A semantic owner retaining this inverse also retains the exact ancestors
|
|
350
|
-
// it restored; effect reactivation must not change them behind that owner.
|
|
351
324
|
if (hunk.kept) for (let prior = target; prior; prior = byId.get(prior.reversedHunk)) keptInverseAncestors.add(prior.id);
|
|
352
325
|
hunk.marker = {mark: hunk};
|
|
353
326
|
const insertion = replacement.map(piece => piece.text ? {...piece,
|
|
@@ -359,10 +332,6 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
359
332
|
}
|
|
360
333
|
return replays(pieces, restored, splices) ? {restored, inverses} : null;
|
|
361
334
|
};
|
|
362
|
-
// An inverse taken while a later edit had replaced some of the act's hunks restored only
|
|
363
|
-
// the rest. Those replaced hunks survive it: no later projection treats them as undone.
|
|
364
|
-
// Recorded splices decide. An author who stored no splice still inverts the one
|
|
365
|
-
// shared hunk; an empty inverse does not match that text change and leaves it.
|
|
366
335
|
const direct = all.filter(hunk => hunk.active && references.has(hunk.actId));
|
|
367
336
|
const targets = direct.length ? direct : all.filter(hunk => hunk.active && !hunk.reversedHunk && !hunk.derived &&
|
|
368
337
|
hunk.coverIds?.some(id => references.has(id)));
|
|
@@ -399,16 +368,12 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
399
368
|
}
|
|
400
369
|
if (!references.size) coverIdsFor(tx.id);
|
|
401
370
|
const priorSplices = options.keepHunk ? [] : null;
|
|
402
|
-
// An admitted codec finish replaces the complete embedded definition/reference.
|
|
403
|
-
// Keep its inserted run whole, including byte-identical prefixes of a re-edit;
|
|
404
|
-
// the previous picture stays in `before` for Undo, not as mixed live ownership.
|
|
405
371
|
const hunks = row => pictureInsertions.get(tx.id)?.some(text => row.inserted.includes(text)) ? [{...row}] : historyHunks(row);
|
|
406
372
|
for (let rowIndex = 0; rowIndex < splices.length; rowIndex++) {
|
|
407
373
|
const original = splices[rowIndex];
|
|
408
374
|
let rows;
|
|
409
375
|
if (options.splitSplice) {
|
|
410
376
|
const beforeSource = textOf(pieces), parts = options.splitSplice({actId: tx.id, beforeSource, splice: original});
|
|
411
|
-
// Semantic boundaries only refine this act's exact bytes, never its history.
|
|
412
377
|
if (!Array.isArray(parts) || _rapierTransformSplices(beforeSource, parts) !== _rapierTransformSplices(beforeSource, [original]))
|
|
413
378
|
throw Error('hunk_projection');
|
|
414
379
|
rows = parts.flatMap(hunks);
|
|
@@ -421,14 +386,11 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
421
386
|
const start = boundary(row.pos), end = boundary(row.pos + row.removed.length);
|
|
422
387
|
let at = start.last, count = Math.max(0, end.first - at);
|
|
423
388
|
if (!row.removed.length) {
|
|
424
|
-
// A new insertion at a replacement's left boundary stays before that replacement.
|
|
425
389
|
const marker = pieces.slice(start.first, start.last).findIndex(item => item.mark?.inserted.length);
|
|
426
390
|
at = marker < 0 ? start.last : start.first + marker; count = 0;
|
|
427
391
|
}
|
|
428
392
|
const removed = pieces.slice(at, at + count);
|
|
429
393
|
if (textOf(removed) !== row.removed) throw Error('replay');
|
|
430
|
-
// A semantic owner may retain a drawing hunk while prose in that same act undoes.
|
|
431
|
-
// The predicate only selects transient hunks; every original row still replays.
|
|
432
394
|
if (chosen && options.keepHunk) {
|
|
433
395
|
const keep = options.keepHunk({actId: tx.id, spliceIndex: rowIndex, hunkIndex: index,
|
|
434
396
|
splice: {pos: row.pos, removed: row.removed, inserted: row.inserted}, beforeSource: textOf(pieces), actSource, priorSplices});
|
|
@@ -459,10 +421,6 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
459
421
|
let offset = 0;
|
|
460
422
|
for (const piece of pieces) if (piece.text) { origins.set(piece, offset); offset += piece.text.length; }
|
|
461
423
|
const superseded = [];
|
|
462
|
-
// Survival means an inverse left this hunk in place because a later independent
|
|
463
|
-
// edit still covered it. Once that edit is gone, deactivating its last owner through
|
|
464
|
-
// any inverse path withdraws every exposed hunk. Undoing only the later
|
|
465
|
-
// edit reveals the survived insertion and does not discard it.
|
|
466
424
|
for (const hunk of all) if (hunk.survived) {
|
|
467
425
|
const covered = [...hunk.supersededBy].some(id => {
|
|
468
426
|
const next = byId.get(id);
|
|
@@ -486,12 +444,8 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
486
444
|
reason: hunk.active ? 'later_edit' : 'already_undone'});
|
|
487
445
|
continue;
|
|
488
446
|
}
|
|
489
|
-
// Restoring an inverse can restore a deletion marker without any owned text.
|
|
490
447
|
restoreHunk(hunk);
|
|
491
448
|
}
|
|
492
|
-
// A hidden withdrawal can have no splice of its own. Reversing that withdrawal
|
|
493
|
-
// reactivates the shared hunk through the inverse that still carries it.
|
|
494
|
-
// Withdrawing a later act reveals that same hunk while any original author remains.
|
|
495
449
|
const reactivated = new Set(records.filter(record => !prepared.effects.active.has(record.transaction.id) &&
|
|
496
450
|
projectedEffects.active.has(record.transaction.id)).map(record => record.transaction.id));
|
|
497
451
|
for (const hunk of all) if (!hunk.reversedHunk && !hunk.derived && hunk.coverIds?.length > 1 &&
|