heyquilt 0.3.121 → 0.3.138
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/RELEASES.md +292 -17
- package/bin/quilt.js +72 -5
- package/desktop/main.js +16 -2
- package/package.json +3 -1
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +21 -10
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/github.js +0 -13
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +39 -0
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +46 -12
- package/src/api/supabase-store.js +53 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +39 -9
- package/src/control.js +139 -13
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +25 -1
- package/src/mcp.js +93 -7
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-commit.js +5 -0
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +172 -8
- package/src/relay-outbound.js +1 -1
- package/src/relay-prune.js +4 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1767 -33
- package/src/session-follow.js +515 -0
- package/src/session-layout3.js +466 -0
- package/src/session-migrate.js +123 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +284 -0
- package/src/session.js +899 -46
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +507 -35
- package/src/source-live/live-square.js +17 -6
- package/src/status.js +5 -0
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/chat.js +9 -3
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -2
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -0
- package/src/ui/home.js +0 -147
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +413 -31
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui-server.js +80 -28
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/git.js +0 -147
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -449
- package/src/quilts/client.js +0 -128
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -266
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -160
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-git.js +0 -291
- package/src/quilts/import.js +0 -134
- package/src/quilts/index.js +0 -72
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -486
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -195
- package/src/quilts/routes.js +0 -126
- package/src/quilts/service.js +0 -923
- package/src/quilts/setup.js +0 -34
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -683
- package/src/quilts/store-rpc.js +0 -79
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -375
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -119
- package/src/quilts/xdiff.js +0 -435
- package/src/ui/branches.js +0 -111
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Quilt's side of seam: live squares in a Quilt session. Seam (src/quilts/) never imports this.
|
|
2
|
+
// Bringing main in. Seam's server never moves an open square's base (Seam's plans/quilt-source-control/
|
|
3
|
+
// 02 step 6, 06 open question 3): a member rebases. In git mode Quilt brought outside commits into
|
|
4
|
+
// everyone's folder by itself, into their uncommitted work, and it does the same in Seam: when main
|
|
5
|
+
// moves past a square's base, a member's app asks the relay (server.js quiltCatchUp, as that member)
|
|
6
|
+
// and the square takes main's new patches. Seam rebases its commits (a conflict is kept as a term in
|
|
7
|
+
// its head, as at any rebase); then the square's live document (live-square.js) moves onto the new
|
|
8
|
+
// head here, in one transaction on the relay, so every folder on it sees one change:
|
|
9
|
+
//
|
|
10
|
+
// a file the square never changed nothing to do: its folders take the new head's file
|
|
11
|
+
// a file the new head has as it is the live entry leaves (nothing left to commit there)
|
|
12
|
+
// a file both changed combined three ways: the head the live work was made on,
|
|
13
|
+
// the live text as the relay holds it now (with everything
|
|
14
|
+
// typed meanwhile), and the new head's
|
|
15
|
+
//
|
|
16
|
+
// A file whose live work would not combine with main is committed first, as the member, so Seam's
|
|
17
|
+
// rebase keeps it as a conflict in the square, the same as a merge does with live work (session-seam.js
|
|
18
|
+
// seam_merge): members see it marked in their folders and settle it, and nothing typed is lost.
|
|
19
|
+
import { liveMaps, liveHead, entryOf, overlay, seal } from './live-square.js'
|
|
20
|
+
import { mergeSmart, withMarkers } from '../merge3.js'
|
|
21
|
+
import { applyTextDiff } from '../textdiff.js'
|
|
22
|
+
import { blobId } from '@heyquilt/seam/lib/quilts/records.js'
|
|
23
|
+
import { isText } from '@heyquilt/seam/lib/quilts/textdiff.js'
|
|
24
|
+
|
|
25
|
+
// A writer's app brings main in for the holder's square once the holder's app has not asked for this
|
|
26
|
+
// long (offline, or failing): a little over two of the apps' checks (session-follow.js CATCH_UP_MS).
|
|
27
|
+
export const HOLDER_WAIT_MS = 150 * 1000
|
|
28
|
+
// After a move, an edit a folder made on the old head that reaches the relay late (it had not heard
|
|
29
|
+
// of the move yet) is combined with main's changes too, for this long (lateEdits).
|
|
30
|
+
export const LATE_MS = 10 * 60 * 1000
|
|
31
|
+
const MAX_TEXT = 2 * 1024 * 1024
|
|
32
|
+
const CHUNK = 100
|
|
33
|
+
|
|
34
|
+
/** The paths changed between two revisions, of `paths` when given (null: all): a Set. */
|
|
35
|
+
async function changedBetween (reader, q, from, to, paths = null) {
|
|
36
|
+
const out = new Set()
|
|
37
|
+
if (from === to || (paths && !paths.length)) return out
|
|
38
|
+
for (let i = 0; i < (paths ? paths.length : 1); i += CHUNK) {
|
|
39
|
+
const d = await reader.diff(q, { from, to, format: 'names', ...(paths ? { paths: paths.slice(i, i + CHUNK) } : {}) })
|
|
40
|
+
for (const f of (d && d.files) || []) if (!paths || paths.includes(f.path)) out.add(f.path)
|
|
41
|
+
}
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Each of `paths` at `rev` as a side ({ id, text, mode }; absent: { id: null, text: '' }), reading text blobs up to 2 MiB. */
|
|
46
|
+
async function sidesAt (reader, q, rev, paths) {
|
|
47
|
+
const entries = new Map()
|
|
48
|
+
for (let i = 0; i < paths.length; i += CHUNK) {
|
|
49
|
+
const t = await reader.tree(q, rev, paths.slice(i, i + CHUNK))
|
|
50
|
+
for (const e of (t && t.entries) || []) entries.set(e.path, e)
|
|
51
|
+
}
|
|
52
|
+
const ids = [...new Set([...entries.values()].filter((e) => e.blob && e.mode !== 'conflict' && e.mode !== 'symlink' && (e.size ?? 0) <= MAX_TEXT).map((e) => e.blob))]
|
|
53
|
+
const bytes = {}
|
|
54
|
+
for (let i = 0; i < ids.length; i += CHUNK) Object.assign(bytes, (await reader.blobs(q, ids.slice(i, i + CHUNK))).blobs || {})
|
|
55
|
+
const out = new Map()
|
|
56
|
+
for (const p of paths) {
|
|
57
|
+
const e = entries.get(p)
|
|
58
|
+
if (!e) { out.set(p, { id: null, text: '' }); continue }
|
|
59
|
+
if (e.mode === 'conflict') { out.set(p, { id: `term:${e.term}`, text: null, term: true }); continue }
|
|
60
|
+
const b = bytes[e.blob] ? Buffer.from(bytes[e.blob], 'base64') : null
|
|
61
|
+
out.set(p, { id: e.blob, mode: e.mode, text: b && isText(b) ? b.toString('utf8') : null })
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What the live document holds at `p` as a side, or null (nothing). */
|
|
67
|
+
function liveSide (doc, p) {
|
|
68
|
+
const e = entryOf(doc, p)
|
|
69
|
+
if (!e) return null
|
|
70
|
+
if (e.op === 'delete') return { id: null, text: '' }
|
|
71
|
+
if (e.blob) return { id: e.blob, mode: e.mode, text: null }
|
|
72
|
+
return { id: blobId(e.bytes), mode: e.mode, text: e.mode !== 'symlink' && isText(e.bytes) ? e.bytes.toString('utf8') : null }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Three sides of one file: `o` the head the work was made on, `l` the work, `n` the new head. { same }
|
|
77
|
+
* when the work is what the new head has, { text } when they combine (text, both changed), or
|
|
78
|
+
* { conflict } (the same lines changed differently, or a binary, deletion or term on both sides).
|
|
79
|
+
*/
|
|
80
|
+
export function combine (o, l, n) {
|
|
81
|
+
if (l.id === n.id) return { same: true }
|
|
82
|
+
if (o.id === n.id) return { keep: true }
|
|
83
|
+
if (o.id === l.id) return { same: true, theirs: true }
|
|
84
|
+
if (o.text === null || l.text === null || n.text === null || l.id === null || n.id === null) return { conflict: true }
|
|
85
|
+
const m = mergeSmart(o.text, l.text, n.text)
|
|
86
|
+
return m.conflicts.length ? { conflict: true } : m.text === n.text ? { same: true } : { text: m.text }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Before the rebase: the square's live files that would not combine with main (`sq` Seam's square,
|
|
91
|
+
* `tip` main's revision now). Its new head will have, for each, main's file where the square's commits
|
|
92
|
+
* did not change it, else Seam's combination of both; one that does not combine there is a conflict
|
|
93
|
+
* in the square already, and its live work goes with it.
|
|
94
|
+
*/
|
|
95
|
+
export async function conflictsAhead (reader, q, sq, doc, tip) {
|
|
96
|
+
const paths = overlay(doc).map((x) => x.path)
|
|
97
|
+
const changed = [...await changedBetween(reader, q, `c:${sq.base}`, tip, paths)]
|
|
98
|
+
if (!changed.length) return []
|
|
99
|
+
const base = await sidesAt(reader, q, `c:${sq.base}`, changed)
|
|
100
|
+
const head = sq.head ? await sidesAt(reader, q, `c:${sq.head}`, changed) : base
|
|
101
|
+
const now = await sidesAt(reader, q, tip, changed)
|
|
102
|
+
const out = []
|
|
103
|
+
for (const p of changed) {
|
|
104
|
+
const l = liveSide(doc, p)
|
|
105
|
+
if (!l) continue
|
|
106
|
+
const [b, h, t] = [base.get(p), head.get(p), now.get(p)]
|
|
107
|
+
let theirs = t
|
|
108
|
+
if (h.id !== b.id) {
|
|
109
|
+
const c = combine(b, h, t)
|
|
110
|
+
if (c.conflict) { out.push(p); continue }
|
|
111
|
+
if (c.text !== undefined) theirs = { id: blobId(Buffer.from(c.text, 'utf8')), text: c.text }
|
|
112
|
+
else if (!c.theirs) theirs = h
|
|
113
|
+
}
|
|
114
|
+
if (combine(h, l, theirs).conflict) out.push(p)
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// How many of main's versions of a file staleAhead reads; a path with more is never taken for an older copy by its deletion.
|
|
120
|
+
const HISTORY_LIMIT = 100
|
|
121
|
+
// How many live files a bring-in looks up in main's history (staleAhead), the ones main changed first.
|
|
122
|
+
const STALE_LOOKS = 200
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Before the rebase: the square's live files main changed since the square's base whose live side is
|
|
126
|
+
* only an older copy of main's own file, never anyone's edit (2026-11-01: a folder at main:984 put 16
|
|
127
|
+
* of main's older files and 27 deletions of files main added since into a square, and the bring-in
|
|
128
|
+
* committed them as "conflicts" with main). A put is an older copy when its bytes are one of main's
|
|
129
|
+
* earlier versions of that path (Seam's path history); a deletion, when main added the path only after
|
|
130
|
+
* the square opened (a folder can't delete a file it never had). A file the square's own commits
|
|
131
|
+
* changed is the square's, whatever its bytes. `reader` needs pathHistory (else nothing is stale).
|
|
132
|
+
*/
|
|
133
|
+
export async function staleAhead (reader, q, sq, doc, tip) {
|
|
134
|
+
if (!reader || typeof reader.pathHistory !== 'function') return []
|
|
135
|
+
const paths = overlay(doc).map((x) => x.path)
|
|
136
|
+
const changed = [...await changedBetween(reader, q, `c:${sq.base}`, tip, paths)].slice(0, STALE_LOOKS)
|
|
137
|
+
if (!changed.length) return []
|
|
138
|
+
const base = await sidesAt(reader, q, `c:${sq.base}`, changed)
|
|
139
|
+
const head = sq.head ? await sidesAt(reader, q, `c:${sq.head}`, changed) : base
|
|
140
|
+
const out = []
|
|
141
|
+
for (const p of changed) {
|
|
142
|
+
const l = liveSide(doc, p)
|
|
143
|
+
if (!l || head.get(p).id !== base.get(p).id) continue
|
|
144
|
+
let h
|
|
145
|
+
try { h = await reader.pathHistory(q, p, { rev: tip, limit: HISTORY_LIMIT }) } catch { continue }
|
|
146
|
+
const entries = (h && h.entries) || []
|
|
147
|
+
if (!entries.length) continue
|
|
148
|
+
if (l.id === null) {
|
|
149
|
+
// Main's history of the path, newest first: its first put is when main added it.
|
|
150
|
+
const first = entries[entries.length - 1]
|
|
151
|
+
const opened = Number(sq.opened_at) || 0
|
|
152
|
+
if (entries.length < HISTORY_LIMIT && !(h.cursor || h.next) && first.op === 'put' && opened && Number(first.at) > opened) out.push(p)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
// Every version main had before its newest: the live bytes are one of them.
|
|
156
|
+
const older = new Set(entries.slice(1).filter((e) => e.op === 'put' && e.blob).map((e) => e.blob))
|
|
157
|
+
if (older.has(l.id) && entries[0].blob !== l.id) out.push(p)
|
|
158
|
+
}
|
|
159
|
+
return out
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The square's head stands for `paths` (staleAhead's): their live entries leave and are marked clean,
|
|
164
|
+
* so every folder on the square writes the head's file (or loses it), and the move after the rebase
|
|
165
|
+
* brings main's. Done by the relay (`origin` relay-quilt), in one transaction.
|
|
166
|
+
*/
|
|
167
|
+
export function standHead (doc, paths, head) {
|
|
168
|
+
const m = liveMaps(doc)
|
|
169
|
+
doc.transact(() => {
|
|
170
|
+
for (const p of paths) {
|
|
171
|
+
for (const k of ['files', 'blobs', 'gone', 'modes', 'writers']) m[k].delete(p)
|
|
172
|
+
m.clean.set(p, { at: head })
|
|
173
|
+
}
|
|
174
|
+
}, 'relay-quilt')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Commits the live work of `paths` in the square as the member (`seam`), and seals it out of the live
|
|
179
|
+
* document: Seam's rebase then keeps each as a conflict in the square. Returns the square's new head.
|
|
180
|
+
*/
|
|
181
|
+
export async function commitAhead (seam, q, sq, doc, paths, by) {
|
|
182
|
+
const list = overlay(doc).filter((x) => paths.includes(x.path))
|
|
183
|
+
if (!list.length) return sq.head || null
|
|
184
|
+
const uploads = list.filter((x) => x.bytes).map((x) => ({ kind: 'blob', id: blobId(x.bytes), bytes: x.bytes }))
|
|
185
|
+
if (uploads.length) await seam.upload(q, uploads)
|
|
186
|
+
const changes = list.map((x) => x.op === 'delete' ? { path: x.path, op: 'delete' } : { path: x.path, op: 'put', blob: x.blob || blobId(x.bytes), mode: x.mode })
|
|
187
|
+
const names = list.map((x) => x.path)
|
|
188
|
+
const message = `Work on ${names.slice(0, 5).join(', ')}${names.length > 5 ? ` and ${names.length - 5} more` : ''}, kept as it was when main's newer patches came in (they changed the same lines)`
|
|
189
|
+
const prev = liveHead(doc)
|
|
190
|
+
const r = await seam.commit(q, sq.id, { expect_head: sq.head || null, message, changes })
|
|
191
|
+
seal(doc, { prev, head: r.square.head, sealed: Object.fromEntries(list.map((x) => [x.path, x.blob || blobId(x.bytes)])), by })
|
|
192
|
+
return r.square.head
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Moves the square's live document from `prev` onto `head` (a rebase's new head, or the square's base
|
|
197
|
+
* when it has no commit: `base`), combining each live file the move changed (see the top of this file).
|
|
198
|
+
* Reads with `reader` (a Seam client: tree, blobs, diff); the move itself is one transaction, with the
|
|
199
|
+
* live text as the relay holds it then, so an edit that reached the relay meanwhile is in it. `brought`
|
|
200
|
+
* is kept in the document for everyone's status. { combined, conflicts, dropped, late } or throws when
|
|
201
|
+
* the live head is no longer `prev`.
|
|
202
|
+
*/
|
|
203
|
+
export async function moveLive (reader, q, doc, { prev, head, base = false, by = '', brought = null }) {
|
|
204
|
+
const moved = await changedBetween(reader, q, `c:${prev}`, `c:${head}`)
|
|
205
|
+
const old = new Map()
|
|
206
|
+
const now = new Map()
|
|
207
|
+
// The live files the move changed, read; again for any made while reading (a folder's first edit of one).
|
|
208
|
+
for (let i = 0; i < 3; i++) {
|
|
209
|
+
const want = overlay(doc).map((x) => x.path).filter((p) => moved.has(p) && !old.has(p))
|
|
210
|
+
if (!want.length) break
|
|
211
|
+
for (const [p, s] of await sidesAt(reader, q, `c:${prev}`, want)) old.set(p, s)
|
|
212
|
+
for (const [p, s] of await sidesAt(reader, q, `c:${head}`, want)) now.set(p, s)
|
|
213
|
+
}
|
|
214
|
+
if (liveHead(doc) !== prev) throw new Error(`the square's live head is ${String(liveHead(doc) || 'not set').slice(0, 12)}, not ${String(prev).slice(0, 12)}: read it again`)
|
|
215
|
+
const m = liveMaps(doc)
|
|
216
|
+
const out = { combined: [], conflicts: [], dropped: [] }
|
|
217
|
+
doc.transact(() => {
|
|
218
|
+
const drop = {}
|
|
219
|
+
for (const p of [...old.keys()].sort()) {
|
|
220
|
+
const l = liveSide(doc, p)
|
|
221
|
+
if (!l) continue
|
|
222
|
+
const [o, n] = [old.get(p), now.get(p)]
|
|
223
|
+
const c = combine(o, l, n)
|
|
224
|
+
if (c.same) { drop[p] = l.id; out.dropped.push(p); continue }
|
|
225
|
+
if (c.keep) continue
|
|
226
|
+
const t = m.files.get(p)
|
|
227
|
+
if (c.text !== undefined && t) { applyTextDiff(t, c.text); out.combined.push(p); continue }
|
|
228
|
+
// Not expected (conflictsAhead committed it first): an edit made while the rebase ran. Both sides stay in the file, marked.
|
|
229
|
+
if (t && l.text !== null && o.text !== null && n.text !== null && !n.term) applyTextDiff(t, withMarkers(o.text, l.text, n.text, { mine: 'this square', theirs: 'main' }))
|
|
230
|
+
out.conflicts.push(p)
|
|
231
|
+
}
|
|
232
|
+
seal(doc, { prev, head, sealed: drop, by })
|
|
233
|
+
if (base) m.live.set('head', { commit: head, by, ts: Date.now(), base: true })
|
|
234
|
+
if (brought) m.live.set('brought', { ...brought, at: Date.now(), by, combined: out.combined, conflicts: [...new Set([...(brought.conflicts || []), ...out.conflicts])] })
|
|
235
|
+
}, 'seal')
|
|
236
|
+
return out
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* After a move from `from` to `to`: an edit a folder made on `from` that reaches the relay late (it had
|
|
241
|
+
* not heard of the move) lacks main's change, and taken as it is would undo it. Each folder's block
|
|
242
|
+
* says which head every edit was made on (live-block.js scan: `writers` path -> { at }); a file written
|
|
243
|
+
* on `from` is combined three ways: `from`'s copy (what that folder last had under its work), the
|
|
244
|
+
* text as the relay holds it, and `to`'s copy. An edit made on `to` (one that takes main's new lines
|
|
245
|
+
* out on purpose, say) is never touched. `read(rev, paths)` reads sides (sidesAt). For LATE_MS (an app
|
|
246
|
+
* offline for longer takes the square in again as it opens). Returns a function that stops it.
|
|
247
|
+
*/
|
|
248
|
+
export function lateEdits (doc, { from, to, read }, { ms = LATE_MS, origin = 'relay-quilt' } = {}) {
|
|
249
|
+
const m = liveMaps(doc)
|
|
250
|
+
let queue = Promise.resolve()
|
|
251
|
+
const look = (ev, tr) => {
|
|
252
|
+
if (tr.origin === 'seal' || tr.origin === origin) return
|
|
253
|
+
const paths = [...ev.keysChanged].filter((p) => { const w = m.writers.get(p); return w && w.at === from && m.files.has(p) })
|
|
254
|
+
if (!paths.length) return
|
|
255
|
+
queue = queue.then(async () => {
|
|
256
|
+
const [o, n] = [await read(`c:${from}`, paths), await read(`c:${to}`, paths)]
|
|
257
|
+
if (liveHead(doc) !== to) return
|
|
258
|
+
doc.transact(() => {
|
|
259
|
+
for (const p of paths) {
|
|
260
|
+
const t = m.files.get(p)
|
|
261
|
+
const w = m.writers.get(p)
|
|
262
|
+
const [a, b] = [o.get(p), n.get(p)]
|
|
263
|
+
if (!t || !w || w.at !== from || !a || !b || a.text === null || b.text === null || b.term) continue
|
|
264
|
+
const cur = t.toString()
|
|
265
|
+
const r = mergeSmart(a.text, cur, b.text)
|
|
266
|
+
if (!r.conflicts.length && r.text !== cur) applyTextDiff(t, r.text)
|
|
267
|
+
// Combined: it is on `to` now, so a later edit of the same file is not combined again.
|
|
268
|
+
m.writers.set(p, { ...w, at: to })
|
|
269
|
+
}
|
|
270
|
+
}, origin)
|
|
271
|
+
}).catch(() => {})
|
|
272
|
+
}
|
|
273
|
+
m.writers.observe(look)
|
|
274
|
+
const timer = setTimeout(() => m.writers.unobserve(look), ms)
|
|
275
|
+
timer.unref?.()
|
|
276
|
+
return () => { clearTimeout(timer); m.writers.unobserve(look) }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** sidesAt for lateEdits: `read(rev, paths)` with `reader` (a Seam client) in quilt `q`. */
|
|
280
|
+
export const sideReader = (reader, q) => (rev, paths) => sidesAt(reader, q, rev, paths)
|
|
281
|
+
|
|
282
|
+
/** What the live document says of the last time main came in: { at, by, patches, combined, conflicts }, or null. */
|
|
283
|
+
export const broughtOf = (doc) => (doc && doc.getMap('live').get('brought')) || null
|