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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Quilt's side of seam: live squares in a Quilt session. Seam (src/quilts/) never imports this.
|
|
2
|
-
// The folder side of a live square (plans/quilt-source-control/05): a block whose files mirror the
|
|
2
|
+
// The folder side of a live square (Seam's plans/quilt-source-control/05): a block whose files mirror the
|
|
3
3
|
// square's live document on the relay (live-square.js). Disk under the block is the snapshot at
|
|
4
4
|
// `block.at` plus the overlay; every edit seen on disk goes into the live document at once, so the
|
|
5
5
|
// relay always knows the square's uncommitted work, and every change another member of the square
|
|
@@ -9,18 +9,41 @@
|
|
|
9
9
|
// It reuses the session's sync engine: the relay connection (connection.js: sign-in, passes, the
|
|
10
10
|
// branch-document protocol), the relay's branch documents themselves, and the text diff into a
|
|
11
11
|
// Y.Text (textdiff.js). There is no git: every change on disk is an edit (decision 18).
|
|
12
|
+
//
|
|
13
|
+
// In place (`inPlace`): a session's own folder becomes the block at the layout-3 switch (Seam's
|
|
14
|
+
// plans/quilt-source-control/16: the first rebuild is a no-op, because the folder already equals
|
|
15
|
+
// the square's live document). Nothing is checked out: the record is written beside the session's
|
|
16
|
+
// state (.quilt/block.json), what disk holds is compared with the live document file by file, and
|
|
17
|
+
// a difference is the person's edit (in it goes) unless the session says the folder was not
|
|
18
|
+
// touched since it last synced (then the live document's newer version comes to disk). Paths the
|
|
19
|
+
// session never syncs (`skip`) are never read or written. Reads can go through the relay
|
|
20
|
+
// (`reader`), so a member needs no Seam sign-in to follow; uploads and commits are the member's.
|
|
12
21
|
import fs from 'node:fs'
|
|
13
22
|
import path from 'node:path'
|
|
14
23
|
import * as Y from 'yjs'
|
|
15
24
|
import { Connection, REMOTE } from '../connection.js'
|
|
25
|
+
import { FEATURES } from '../protocol.js'
|
|
16
26
|
import { applyTextDiff } from '../textdiff.js'
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
import {
|
|
27
|
+
import { mergeAuto, mergeSmart } from '../merge3.js'
|
|
28
|
+
// Seam's block folders and ids come from Seam's client package (@heyquilt/seam), never Seam's server code.
|
|
29
|
+
import { blobId, under } from '@heyquilt/seam/lib/quilts/records.js'
|
|
30
|
+
import { isText } from '@heyquilt/seam/lib/quilts/textdiff.js'
|
|
31
|
+
import { checkout, findBlock, saveBlock, localChanges, conflictStyle, BLOCK_DIR } from '@heyquilt/seam/lib/quilts/block.js'
|
|
20
32
|
import { liveKey, liveMaps, liveHead, entryOf, entryId, overlay } from './live-square.js'
|
|
21
33
|
|
|
22
34
|
export const MAX_LIVE_TEXT = 2 * 1024 * 1024
|
|
23
35
|
const SCAN_MS = 40
|
|
36
|
+
// On macOS, Node's fs.watch shares one FSEvents stream per process and rebuilds it whenever a watch
|
|
37
|
+
// is added or removed (another folder's block opening, this folder's next square after a merge), so
|
|
38
|
+
// an edit in that gap is never delivered. The block looks at the folder this often too, as the
|
|
39
|
+
// session does before the switch (session.js RECONCILE_MS).
|
|
40
|
+
export const RECONCILE_MS = 1000
|
|
41
|
+
// After a watcher starts, the folder is scanned again at these times (watch).
|
|
42
|
+
const WATCH_SETTLE_MS = [250, 1000, 3000]
|
|
43
|
+
// And this often the folder is brought in step with the square even with no news from the relay (a
|
|
44
|
+
// write-out that failed, or one that never ran, is tried again): disk and square never disagree for
|
|
45
|
+
// long, nor without a word (2026-11-01: a folder kept a file the square had deleted).
|
|
46
|
+
export const STEP_MS = 15000
|
|
24
47
|
|
|
25
48
|
const waitUntil = async (fn, ms = 10000, what = 'the relay') => {
|
|
26
49
|
const t = Date.now()
|
|
@@ -40,10 +63,23 @@ export class LiveBlock {
|
|
|
40
63
|
* @param {string[]} [o.paths]
|
|
41
64
|
* @param {object} o.relay { server, room, secret, name, identity, kind?, passes?, tool? }: the session
|
|
42
65
|
*/
|
|
43
|
-
|
|
44
|
-
|
|
66
|
+
/**
|
|
67
|
+
* In place, also: `inPlace` { edited(path) }: whether the session saw the folder's copy change
|
|
68
|
+
* since it last synced it; `skip(path)`: the session never syncs it (`skipDir(path)`: nor
|
|
69
|
+
* anything in that folder); `record`: where block.json
|
|
70
|
+
* goes; `reader`: a client for reads (square, tree, blobs, diff), else `client`; `log`;
|
|
71
|
+
* `onEdits(list, doc, held)`: the edits this folder made, as each scan takes them into the live
|
|
72
|
+
* document `doc` ([{ path, kind: created|edited|deleted, detail, before?, after? }]), so the session
|
|
73
|
+
* keeps them on record (who changed what) as it did on its branch; `held()` resolves once the relay
|
|
74
|
+
* holds them.
|
|
75
|
+
*/
|
|
76
|
+
constructor ({ dir, client, quilt, square, paths = [''], relay, api = '', inPlace = null, skip = null, skipDir = null, record = null, reader = null, log = null, onEdits = null, started = null, stepMs = STEP_MS }) {
|
|
77
|
+
Object.assign(this, { dir: path.resolve(dir), client, quilt, squareRef: square, paths, relayOpts: relay, api, inPlace, skip, skipDir, record, reader: reader || client, logFn: log, onEdits, stepMs })
|
|
78
|
+
// `started(block)`: told as it starts to open, so whoever waits for it can stop it part-way (abandon).
|
|
79
|
+
if (started) started(this)
|
|
45
80
|
this.known = {} // path -> { blob, mode, size, mtimeMs }: what is on disk as this block last read or wrote it
|
|
46
81
|
this.cache = new Map() // blob id -> bytes (the snapshot's files this block wrote out)
|
|
82
|
+
this.lastText = new Map() // path -> { id, text }: a live text as disk last had it (bytes no server holds), for combining (scan)
|
|
47
83
|
this.queue = Promise.resolve()
|
|
48
84
|
this.closed = false
|
|
49
85
|
this.errors = []
|
|
@@ -57,6 +93,15 @@ export class LiveBlock {
|
|
|
57
93
|
}
|
|
58
94
|
|
|
59
95
|
async open () {
|
|
96
|
+
// A block that fails part-way lets go of what it opened: its relay connection would otherwise
|
|
97
|
+
// reconnect for good, one more with each try the folder makes to follow its square.
|
|
98
|
+
try { return await (this.inPlace ? this.openInPlace() : this.openCheckout()) } catch (err) {
|
|
99
|
+
this.abandon()
|
|
100
|
+
throw err
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async openCheckout () {
|
|
60
105
|
const found = findBlock(this.dir)
|
|
61
106
|
if (found && found.dir === this.dir) this.block = found.block
|
|
62
107
|
else {
|
|
@@ -67,29 +112,302 @@ export class LiveBlock {
|
|
|
67
112
|
if (!b.square) throw new Error('a live block needs a square')
|
|
68
113
|
b.live = liveKey(b.square)
|
|
69
114
|
for (const [p, f] of Object.entries(b.files)) this.known[p] = { ...f }
|
|
70
|
-
|
|
115
|
+
this.save()
|
|
116
|
+
await this.connect()
|
|
117
|
+
await this.run(async () => { await this.scan(); await this.pull() })
|
|
118
|
+
this.watch()
|
|
119
|
+
return this
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The relay connection on the square's live document, with its head set. */
|
|
123
|
+
async connect () {
|
|
124
|
+
const b = this.block
|
|
71
125
|
this.roomDoc = new Y.Doc()
|
|
72
126
|
this.doc = new Y.Doc()
|
|
73
127
|
const r = this.relayOpts
|
|
74
|
-
|
|
128
|
+
// quilt-block: the relay counts it as part of the app's visit, not a visit of its own (the audit trail).
|
|
129
|
+
// Only the square's live document: the app's own connection has the room's (chat, tasks, activity).
|
|
130
|
+
this.conn = new Connection({ ...r, kind: r.kind || 'human', features: `${r.features || FEATURES},quilt-block`, roomDoc: false, doc: this.roomDoc, branch: { key: b.live, doc: this.doc } })
|
|
75
131
|
this.conn.on('fatal', (err) => this.errors.push(err))
|
|
76
|
-
|
|
77
|
-
|
|
132
|
+
// Stopped while it opens (the session stopped, or the folder moved on): its waits for the relay end with it.
|
|
133
|
+
const stopped = new Promise((resolve, reject) => { this.stopOpening = () => reject(new Error('the block was closed while it opened')) })
|
|
134
|
+
stopped.catch(() => {})
|
|
135
|
+
if (this.closed) this.stopOpening()
|
|
136
|
+
await Promise.race([this.conn.waitForSync(), stopped])
|
|
137
|
+
await Promise.race([this.conn.waitForBranchSync(), stopped])
|
|
78
138
|
if (!liveHead(this.doc)) {
|
|
79
|
-
const sq = await this.
|
|
139
|
+
const sq = await this.reader.square(b.quilt, b.square)
|
|
80
140
|
await this.conn.branchRequest({ op: 'start', branch: b.live, head: sq.head || sq.base })
|
|
81
141
|
await waitUntil(() => liveHead(this.doc), 10000, 'the live head')
|
|
82
142
|
}
|
|
83
143
|
this.doc.on('update', (u, origin) => { if (origin === REMOTE && !this.closed) this.run(() => this.pull()) })
|
|
84
|
-
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
watch () {
|
|
85
147
|
this.watcher = fs.watch(this.dir, { recursive: true }, (ev, name) => {
|
|
86
|
-
if (this.closed || !name
|
|
148
|
+
if (this.closed || !name) return
|
|
149
|
+
const rel = String(name).split(path.sep).join('/')
|
|
150
|
+
if (rel.split('/')[0] === BLOCK_DIR || (this.skip && this.skip(rel))) return
|
|
87
151
|
clearTimeout(this.scanTimer)
|
|
88
152
|
this.scanTimer = setTimeout(() => this.run(() => this.scan()), SCAN_MS)
|
|
89
153
|
})
|
|
154
|
+
this.reconcileTimer = setInterval(() => {
|
|
155
|
+
if (this.closed || this.reconciling) return
|
|
156
|
+
this.reconciling = true
|
|
157
|
+
this.run(() => this.scan()).catch(() => {}).finally(() => { this.reconciling = false })
|
|
158
|
+
}, RECONCILE_MS)
|
|
159
|
+
this.reconcileTimer.unref?.()
|
|
160
|
+
this.stepTimer = setInterval(() => { if (!this.closed) this.run(() => this.pull()).catch(() => {}) }, this.stepMs)
|
|
161
|
+
this.stepTimer.unref?.()
|
|
162
|
+
// A watcher just started does not hear everything at once (macOS's FSEvents takes a moment): a
|
|
163
|
+
// save in that moment would wait for the next change in the folder. Looked at again, to be sure.
|
|
164
|
+
this.settleTimers = WATCH_SETTLE_MS.map((ms) => {
|
|
165
|
+
const t = setTimeout(() => { if (!this.closed) this.run(() => this.scan()) }, ms)
|
|
166
|
+
t.unref?.()
|
|
167
|
+
return t
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The block's record: Seam's .seam/block.json, or (in place) the file the session names, which
|
|
173
|
+
* also keeps what disk held as the block last read or wrote it (`known`): after a restart, a file
|
|
174
|
+
* that differs from it is an edit made while the app was closed, and any other difference is the
|
|
175
|
+
* square's newer version.
|
|
176
|
+
*/
|
|
177
|
+
save () {
|
|
178
|
+
clearTimeout(this.saveTimer)
|
|
179
|
+
if (!this.record) return saveBlock(this.dir, this.block)
|
|
180
|
+
fs.mkdirSync(path.dirname(this.record), { recursive: true })
|
|
181
|
+
fs.writeFileSync(this.record + '.tmp', JSON.stringify({ ...this.block, known: this.known }))
|
|
182
|
+
fs.renameSync(this.record + '.tmp', this.record)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
saveSoon () {
|
|
186
|
+
if (!this.record || this.saveTimer || this.closed) return
|
|
187
|
+
this.saveTimer = setTimeout(() => { this.saveTimer = null; try { this.save() } catch {} }, 2000)
|
|
188
|
+
this.saveTimer.unref?.()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The in-place record of an earlier run in this quilt: its `known`, or null. On this square (the
|
|
193
|
+
* app restarted), or on the branch's earlier square (a merge moved the folder on to the next one).
|
|
194
|
+
*/
|
|
195
|
+
priorKnown () {
|
|
196
|
+
try {
|
|
197
|
+
const r = JSON.parse(fs.readFileSync(this.record, 'utf8'))
|
|
198
|
+
return r && r.inPlace && r.quilt === this.quilt && r.known && typeof r.known === 'object' ? r.known : null
|
|
199
|
+
} catch { return null }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The session's own folder as the block, nothing checked out (see the top of this file). What
|
|
204
|
+
* happened is logged: { same, taken, brought }, the files that matched, the person's edits that
|
|
205
|
+
* went in, and the live document's newer versions written to disk.
|
|
206
|
+
*/
|
|
207
|
+
async openInPlace () {
|
|
208
|
+
const sq = await this.reader.square(this.quilt, this.squareRef)
|
|
209
|
+
const prior = this.inPlace.fresh ? null : this.priorKnown()
|
|
210
|
+
// Followed before (the app restarted, or the folder moved on to its branch's next square): edits
|
|
211
|
+
// are what changed since the block last saw the folder, not since the session's last sync, and
|
|
212
|
+
// a file only the square changed (its new head: what a merge took in from main) is the square's.
|
|
213
|
+
if (prior) this.inPlace = { ...this.inPlace, edited: (p, blob) => (blob ?? null) !== ((prior[p] && prior[p].blob) ?? null), base: null, baseBlob: (p) => (prior[p] && prior[p].blob) || null, seen: true }
|
|
214
|
+
this.block = { api: this.api, quilt: this.quilt, square: sq.id, square_short: sq.short || null, line: sq.line || null, paths: [''], at: null, head: null, files: {}, inPlace: true, live: liveKey(sq.id) }
|
|
215
|
+
await this.connect()
|
|
216
|
+
const head = this.head
|
|
217
|
+
const t = await this.reader.tree(this.quilt, `c:${head}`)
|
|
218
|
+
const b = this.block
|
|
219
|
+
for (const e of (t && t.entries) || []) b.files[e.path] = { blob: e.blob || null, mode: e.mode, size: e.size, ...(e.term ? { term: e.term } : {}) }
|
|
220
|
+
b.at = head
|
|
221
|
+
b.head = sq.head || null // a commit's expect_head: none while the square has no commit of its own (its live head is then its base)
|
|
222
|
+
this.save()
|
|
223
|
+
this.summary = await this.run(() => this.adopt())
|
|
224
|
+
this.watch()
|
|
90
225
|
return this
|
|
91
226
|
}
|
|
92
227
|
|
|
228
|
+
/** What the live document says `p` holds, as a blob id (null: absent), and whether it says so itself (not only by its head). */
|
|
229
|
+
wantedId (p) {
|
|
230
|
+
const e = entryOf(this.doc, p)
|
|
231
|
+
if (e) return { id: entryId(e), mode: e.op === 'delete' ? null : e.mode, named: true }
|
|
232
|
+
const s = this.block.files[p]
|
|
233
|
+
const m = liveMaps(this.doc)
|
|
234
|
+
return { id: s ? s.blob : null, mode: s ? s.mode : null, named: m.clean.has(p) }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* In place, once: each file on disk against the live document. Equal: nothing to do. Different
|
|
239
|
+
* and the session saw the folder's copy change since it last synced, or, the first time the
|
|
240
|
+
* folder follows a square, a path the live document does not name that the folder's branch
|
|
241
|
+
* changed since it left main (`inPlace.own`, from git: in its commits, or not committed and not
|
|
242
|
+
* synced by the session, whose own changes are others' work too; without git: a file the folder
|
|
243
|
+
* has that the session never synced): the person's, which the scan after this takes in;
|
|
244
|
+
* when the square changed the same text since the session last synced it, both changes are
|
|
245
|
+
* combined first (combineAway). Otherwise the live document's version (another member's, since
|
|
246
|
+
* the folder's last sync, or the square's head) is written: a file the folder lacks, or has as git
|
|
247
|
+
* left it, is never taken for the person's deletion or edit (a folder whose git was behind main,
|
|
248
|
+
* a branch that left main before main's newer work, or a member back after the switch would
|
|
249
|
+
* undo main's work in the square, and on main at the next merge).
|
|
250
|
+
*/
|
|
251
|
+
async adopt () {
|
|
252
|
+
const m = liveMaps(this.doc)
|
|
253
|
+
const firstLook = !this.inPlace.seen && !this.inPlace.fresh
|
|
254
|
+
const own = firstLook && this.inPlace.own ? await this.inPlace.own() : null
|
|
255
|
+
const disk = new Map(this.diskChanges({}).filter((c) => !this.skipped(c.path)).map((c) => [c.path, c]))
|
|
256
|
+
const paths = new Set([...disk.keys(), ...Object.keys(this.block.files), ...m.files.keys(), ...m.blobs.keys(), ...m.gone.keys(), ...m.clean.keys()])
|
|
257
|
+
const out = { same: 0, taken: [], brought: [], combined: [] }
|
|
258
|
+
for (const p of [...paths].sort()) {
|
|
259
|
+
if (this.skipped(p) || !this.block.paths.some((x) => under(p, x)) || (this.block.files[p] || {}).mode === 'gitlink' || (this.inPlace && this.nested(p))) continue
|
|
260
|
+
const d = disk.get(p)
|
|
261
|
+
const w = this.wantedId(p)
|
|
262
|
+
if (entryOf(this.doc, p) && this.staleCopy(p, d, own, firstLook)) {
|
|
263
|
+
await this.headStands(p, m)
|
|
264
|
+
out.brought.push(p)
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
if ((d ? d.blob : null) === w.id) {
|
|
268
|
+
// The same bytes: a different mode on disk (made executable here) goes in as an edit too.
|
|
269
|
+
if (d) { const st = fs.lstatSync(path.join(this.dir, ...p.split('/'))); this.known[p] = w.mode && w.mode !== d.mode ? { blob: d.blob, mode: w.mode, size: -1, mtimeMs: -1 } : { blob: d.blob, mode: d.mode, size: st.size, mtimeMs: st.mtimeMs } }
|
|
270
|
+
out.same++
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
// A file the session held back from this folder (someone else's box, or a change waiting for
|
|
274
|
+
// whoever held the file): the folder never had the session's copy, so lacking it is no deletion.
|
|
275
|
+
// The square's copy comes to disk (2026-11-01: a folder's first look deleted such a file).
|
|
276
|
+
if (firstLook && !d && w.id && this.inPlace.heldBack && this.inPlace.heldBack(p)) {
|
|
277
|
+
this.write(p, await this.wanted(p))
|
|
278
|
+
out.brought.push(p)
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
const synced = !!(this.inPlace.synced && this.inPlace.synced(p))
|
|
282
|
+
const branchs = firstLook && !w.named && (own ? own.committed.has(p) || (own.uncommitted.has(p) && !synced) : !!d && !synced)
|
|
283
|
+
if (this.inPlace.edited(p, d ? d.blob : null) || branchs) {
|
|
284
|
+
// The square changed it too since the folder last had it (a merge while the app was closed):
|
|
285
|
+
// only the folder's own edit goes in, combined with the square's, never its old copy over it.
|
|
286
|
+
const merged = d && w.id ? await this.combineAway(p, d) : null
|
|
287
|
+
if (merged) { this.write(p, { bytes: merged, mode: d.mode }); out.combined.push(p) }
|
|
288
|
+
// The person's: the scan sees disk differ from this and takes it in (a deletion when disk has nothing).
|
|
289
|
+
if (w.id) this.known[p] = { blob: w.id, mode: w.mode, size: -1, mtimeMs: -1 }
|
|
290
|
+
out.taken.push(p)
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
this.write(p, await this.wanted(p))
|
|
294
|
+
out.brought.push(p)
|
|
295
|
+
}
|
|
296
|
+
await this.scan()
|
|
297
|
+
this.save()
|
|
298
|
+
if (this.logFn && (out.taken.length || out.brought.length)) {
|
|
299
|
+
const list = (a) => `${a.slice(0, 5).join(', ')}${a.length > 5 ? `, and ${a.length - 5} more` : ''}`
|
|
300
|
+
this.logFn(`${out.same} file(s) already matched the square${out.taken.length ? `; ${out.taken.length} edit(s) of yours went in (${list(out.taken)})` : ''}${out.combined.length ? `, combined with the square's changes to them since this folder last synced (${list(out.combined)})` : ''}${out.brought.length ? `; ${out.brought.length} newer version(s) came from the square (${list(out.brought)})` : ''}.`)
|
|
301
|
+
}
|
|
302
|
+
return out
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Whether the live document's entry for `p` is the branch's older copy of main's file, not work: in
|
|
307
|
+
* a square the move started at main's tip for want of the branch's own commit (`inPlace.atTip`), a
|
|
308
|
+
* full branch document names every file as the branch has it, and a file main changed (or deleted,
|
|
309
|
+
* or added) after the branch left it, that the branch never changed (git: not in its commits since
|
|
310
|
+
* it left main, nothing uncommitted here, no edit the session saw), would undo main's change at the
|
|
311
|
+
* square's merge. Only at the first look, with git to ask.
|
|
312
|
+
*/
|
|
313
|
+
staleCopy (p, d, own, firstLook) {
|
|
314
|
+
if (!firstLook || !this.inPlace.atTip || !own) return false
|
|
315
|
+
if (own.committed.has(p) || own.uncommitted.has(p)) return false
|
|
316
|
+
return !this.inPlace.edited(p, d ? d.blob : null)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The square's head version of `p` stands: the live document's entry goes, and disk gets the head's file (or loses it). */
|
|
320
|
+
async headStands (p, m) {
|
|
321
|
+
const s = this.block.files[p]
|
|
322
|
+
this.write(p, s ? await this.headFile(p, s) : null)
|
|
323
|
+
this.doc.transact(() => {
|
|
324
|
+
m.files.delete(p); m.blobs.delete(p); m.gone.delete(p); m.modes.delete(p)
|
|
325
|
+
if (s) m.clean.set(p, { at: this.block.at }); else m.clean.delete(p)
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The folder's copy of `p` (disk entry `d`) combined with the square's, against what the folder last
|
|
331
|
+
* had (the session's last sync of it, `inPlace.base`; after a restart, the blob the block last saw):
|
|
332
|
+
* the bytes to write, or null when there is nothing to combine (the square did not change it since,
|
|
333
|
+
* or that base is not known, or a side is not text). Where both changed the same lines, the folder's
|
|
334
|
+
* edit is kept (mergeAuto, the folder's side newer) and the square's lines it replaced are said.
|
|
335
|
+
*/
|
|
336
|
+
async combineAway (p, d) {
|
|
337
|
+
if (!d.bytes || d.mode === 'symlink' || !isText(d.bytes)) return null
|
|
338
|
+
let base = this.inPlace.base ? await this.inPlace.base(p) : null
|
|
339
|
+
if (typeof base !== 'string' && this.inPlace.baseBlob) {
|
|
340
|
+
const id = this.inPlace.baseBlob(p)
|
|
341
|
+
if (id) { try { const b = await this.bytesOf(id); if (isText(b)) base = b.toString('utf8') } catch {} }
|
|
342
|
+
}
|
|
343
|
+
if (typeof base !== 'string') return null
|
|
344
|
+
const ours = d.bytes.toString('utf8')
|
|
345
|
+
// Someone else's box on it before the move (inPlace.boxed): the folder kept its own copy (git's,
|
|
346
|
+
// moved on by pulls) while the session held theirs, and `base` is what everyone had when the box
|
|
347
|
+
// began. Their change goes onto the folder's copy, never their whole older file over it.
|
|
348
|
+
const boxed = !!(this.inPlace.boxed && this.inPlace.boxed(p))
|
|
349
|
+
// The folder's copy is what it last had: nothing of its own to combine. It is taken as it is (an
|
|
350
|
+
// edit the session took in that the relay never held, a path the square does not name at the switch).
|
|
351
|
+
if (base === ours && !boxed) return null
|
|
352
|
+
const want = await this.wanted(p)
|
|
353
|
+
if (!want || want.mode === 'symlink' || !isText(want.bytes)) return null
|
|
354
|
+
const theirs = want.bytes.toString('utf8')
|
|
355
|
+
if (theirs === ours) return null
|
|
356
|
+
// A box on a file the folder never moved past: the box's work is the file now.
|
|
357
|
+
if (base === ours) return want.bytes
|
|
358
|
+
if (theirs === base) return null
|
|
359
|
+
const r = mergeAuto(base, ours, theirs, { oursNewer: true })
|
|
360
|
+
if (r.lost.length && this.logFn) this.logFn(`${p}: your edit made while away and the square's both changed the same lines; yours were kept, and the square's (${r.lost.reduce((n, x) => n + x.lines.length, 0)} line(s)) are in its history`)
|
|
361
|
+
return r.text === ours ? null : Buffer.from(r.text, 'utf8')
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
skipped (p) { return !!(this.skip && this.skip(p)) }
|
|
365
|
+
|
|
366
|
+
/** Whether `p` is inside a folder with a .git of its own (a submodule): never this block's to read or write. */
|
|
367
|
+
nested (p) {
|
|
368
|
+
const parts = p.split('/')
|
|
369
|
+
for (let i = 1; i < parts.length; i++) if (fs.existsSync(path.join(this.dir, ...parts.slice(0, i), '.git'))) return true
|
|
370
|
+
return false
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* What changed on disk against `files` (path -> { blob, mode, size, mtimeMs }), as Seam's
|
|
375
|
+
* localChanges answers. In place, the walk never enters a folder the session never syncs
|
|
376
|
+
* (`skipDir`: .git, .quilt with the git folder set aside in it, node_modules, ignored ones), so a
|
|
377
|
+
* big folder is not read on every scan, nor one that is a repository of its own (a submodule).
|
|
378
|
+
*/
|
|
379
|
+
diskChanges (files) {
|
|
380
|
+
if (!this.inPlace) return localChanges(this.dir, { paths: this.block.paths, files })
|
|
381
|
+
const out = []
|
|
382
|
+
const seen = new Set()
|
|
383
|
+
const visit = (rel) => {
|
|
384
|
+
let names
|
|
385
|
+
try { names = fs.readdirSync(path.join(this.dir, rel), { withFileTypes: true }) } catch { return }
|
|
386
|
+
for (const d of names) {
|
|
387
|
+
const r = rel ? `${rel}/${d.name}` : d.name
|
|
388
|
+
if (d.name === '.git' || d.name === BLOCK_DIR) continue
|
|
389
|
+
// A folder with a .git of its own (a submodule, a repository inside this one) is its own: the square holds it as a gitlink at most.
|
|
390
|
+
if (d.isDirectory()) { if (!(this.skipDir && this.skipDir(r)) && !fs.existsSync(path.join(this.dir, r, '.git'))) visit(r); continue }
|
|
391
|
+
if (!(d.isFile() || d.isSymbolicLink()) || this.skipped(r)) continue
|
|
392
|
+
const abs = path.join(this.dir, r)
|
|
393
|
+
let st
|
|
394
|
+
try { st = fs.lstatSync(abs) } catch { continue }
|
|
395
|
+
seen.add(r)
|
|
396
|
+
const k = files[r]
|
|
397
|
+
if (k && k.size === st.size && k.mtimeMs === st.mtimeMs) continue
|
|
398
|
+
let bytes
|
|
399
|
+
try { bytes = st.isSymbolicLink() ? Buffer.from(fs.readlinkSync(abs)) : fs.readFileSync(abs) } catch { continue }
|
|
400
|
+
const mode = st.isSymbolicLink() ? 'symlink' : (st.mode & 0o111) && process.platform !== 'win32' ? 'exec' : 'file'
|
|
401
|
+
const id = blobId(bytes)
|
|
402
|
+
if (k && k.blob === id && k.mode === mode) continue
|
|
403
|
+
out.push({ path: r, status: k ? 'modified' : 'added', blob: id, mode, size: bytes.length, bytes })
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
visit('')
|
|
407
|
+
for (const p of Object.keys(files)) if (!seen.has(p) && !this.skipped(p) && !this.nested(p)) out.push({ path: p, status: 'deleted' })
|
|
408
|
+
return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
409
|
+
}
|
|
410
|
+
|
|
93
411
|
get head () { return liveHead(this.doc) }
|
|
94
412
|
get by () { return this.relayOpts.name || '' }
|
|
95
413
|
|
|
@@ -98,48 +416,142 @@ export class LiveBlock {
|
|
|
98
416
|
if (this.block.at !== this.head) return // 05: no ingest against the wrong base; pull re-projects first
|
|
99
417
|
const m = liveMaps(this.doc)
|
|
100
418
|
const snap = this.block.files
|
|
101
|
-
|
|
419
|
+
let changes = this.diskChanges(this.known)
|
|
420
|
+
// What the session never syncs is never taken in; noting its size and time spares reading it again.
|
|
421
|
+
for (const c of changes) {
|
|
422
|
+
if (!this.skipped(c.path)) continue
|
|
423
|
+
if (c.status === 'deleted') { delete this.known[c.path]; continue }
|
|
424
|
+
try { const st = fs.lstatSync(path.join(this.dir, ...c.path.split('/'))); this.known[c.path] = { blob: c.blob, mode: c.mode, size: st.size, mtimeMs: st.mtimeMs } } catch {}
|
|
425
|
+
}
|
|
426
|
+
changes = changes.filter((c) => !this.skipped(c.path))
|
|
102
427
|
if (!changes.length) return
|
|
103
|
-
|
|
428
|
+
let uploads = []
|
|
104
429
|
for (const c of changes) {
|
|
105
430
|
if (c.status === 'deleted') continue
|
|
106
431
|
if (c.mode === 'symlink' || !isText(c.bytes) || c.bytes.length > MAX_LIVE_TEXT) uploads.push({ kind: 'blob', id: c.blob, bytes: c.bytes })
|
|
107
432
|
}
|
|
108
|
-
if (uploads.length)
|
|
433
|
+
if (uploads.length) {
|
|
434
|
+
// A binary needs Seam to hold its bytes first, as this member. Without that, the texts still go in, and the binaries wait for the next scan.
|
|
435
|
+
try {
|
|
436
|
+
if (!this.client) throw new Error('sign in to Seam (quilt login) so binary files reach the square')
|
|
437
|
+
await this.client.upload(this.block.quilt, uploads)
|
|
438
|
+
this.problem = null
|
|
439
|
+
} catch (err) {
|
|
440
|
+
const ids = new Set(uploads.map((u) => u.id))
|
|
441
|
+
const waiting = changes.filter((c) => c.status !== 'deleted' && ids.has(c.blob)).map((c) => c.path)
|
|
442
|
+
this.problem = `${waiting.length} binary file(s) are not in the square yet (${waiting.slice(0, 3).join(', ')}): ${err.message}`
|
|
443
|
+
if (this.logFn) this.logFn(this.problem)
|
|
444
|
+
changes = changes.filter((c) => !waiting.includes(c.path))
|
|
445
|
+
uploads = []
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// What each file held before, for the record of who changed what (onEdits): the live document's text, else the head's.
|
|
449
|
+
const before = new Map()
|
|
450
|
+
if (this.onEdits) {
|
|
451
|
+
for (const c of changes) {
|
|
452
|
+
const t = m.files.get(c.path)
|
|
453
|
+
if (t) { before.set(c.path, t.toString()); continue }
|
|
454
|
+
const b = snap[c.path]
|
|
455
|
+
if (!b || !b.blob || m.blobs.has(c.path) || m.gone.has(c.path) || (b.size || 0) > MAX_LIVE_TEXT) continue
|
|
456
|
+
try { const bytes = await this.bytesOf(b.blob); if (isText(bytes)) before.set(c.path, bytes.toString('utf8')) } catch {}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// A file changed on disk and in the square since disk last had it (someone's edit, or main brought in,
|
|
460
|
+
// reached the live document or the head before this edit was read): what disk last had is the base
|
|
461
|
+
// the two are combined against, so the edit goes in as a change and nothing the square has is undone.
|
|
462
|
+
const both = new Map()
|
|
463
|
+
for (const c of changes) {
|
|
464
|
+
if (c.status === 'deleted' || c.mode === 'symlink' || c.bytes.length > MAX_LIVE_TEXT || !isText(c.bytes)) continue
|
|
465
|
+
const k = this.known[c.path]
|
|
466
|
+
if (!k || !k.blob || k.blob === c.blob) continue
|
|
467
|
+
const e = entryOf(this.doc, c.path)
|
|
468
|
+
const s = snap[c.path]
|
|
469
|
+
const now = e ? entryId(e) : s && s.mode !== 'conflict' ? s.blob : null
|
|
470
|
+
if (!now || now === k.blob) continue
|
|
471
|
+
try {
|
|
472
|
+
const last = this.lastText.get(c.path)
|
|
473
|
+
const base = last && last.id === k.blob ? last.text : (await this.bytesOf(k.blob)).toString('utf8')
|
|
474
|
+
const head = e ? null : (await this.bytesOf(s.blob))
|
|
475
|
+
if (head && !isText(head)) continue
|
|
476
|
+
both.set(c.path, { base, head: head ? head.toString('utf8') : null })
|
|
477
|
+
} catch {}
|
|
478
|
+
}
|
|
479
|
+
const taken = []
|
|
480
|
+
const rewrite = []
|
|
109
481
|
this.doc.transact(() => {
|
|
110
482
|
for (const c of changes) {
|
|
111
483
|
const p = c.path
|
|
112
484
|
const base = snap[p]
|
|
485
|
+
const had = !!(base && !m.gone.has(p)) || m.files.has(p) || m.blobs.has(p)
|
|
486
|
+
// Whose work this path is now (on going back to git, its box is theirs: Session.leaveSquare), and the
|
|
487
|
+
// head it was made on: the relay combines an edit made on a head it has moved on from (bring-in.js lateEdits).
|
|
488
|
+
m.writers.set(p, { by: this.by, ts: Date.now(), at: this.block.at })
|
|
113
489
|
if (c.status === 'deleted') {
|
|
114
490
|
if (base) { m.clean.delete(p); m.gone.set(p, { by: this.by, ts: Date.now() }) } else m.clean.set(p, { at: this.block.at })
|
|
115
491
|
delete this.known[p]
|
|
492
|
+
if (had) taken.push({ path: p, kind: 'deleted', detail: '', ...(before.has(p) ? { before: before.get(p), after: '' } : {}) })
|
|
116
493
|
continue
|
|
117
494
|
}
|
|
118
|
-
|
|
495
|
+
const kind = had ? 'edited' : 'created'
|
|
496
|
+
if (base && base.blob === c.blob && base.mode === c.mode) {
|
|
497
|
+
m.clean.set(p, { at: this.block.at }); m.gone.delete(p)
|
|
498
|
+
// Back as the head has it: an edit too (undoing one), unless it never left.
|
|
499
|
+
if (before.has(p) && before.get(p) !== c.bytes.toString('utf8')) taken.push({ path: p, kind, detail: '', before: before.get(p), after: c.bytes.toString('utf8') })
|
|
500
|
+
} else if (uploads.some((u) => u.id === c.blob)) {
|
|
119
501
|
m.clean.delete(p); m.gone.delete(p)
|
|
120
502
|
m.blobs.set(p, { blob: c.blob, size: c.bytes.length, mode: c.mode })
|
|
503
|
+
taken.push({ path: p, kind, detail: `${c.bytes.length} bytes` })
|
|
121
504
|
} else {
|
|
122
505
|
m.clean.delete(p); m.gone.delete(p); m.blobs.delete(p)
|
|
123
506
|
let t = m.files.get(p)
|
|
507
|
+
const fresh = !t
|
|
124
508
|
if (!t) { t = new Y.Text(); m.files.set(p, t) }
|
|
125
|
-
|
|
509
|
+
let after = c.bytes.toString('utf8')
|
|
510
|
+
const w = both.get(p)
|
|
511
|
+
const cur = w ? (fresh ? w.head : t.toString()) : null
|
|
512
|
+
if (w && cur !== null && cur !== w.base && cur !== after) {
|
|
513
|
+
const r = mergeSmart(w.base, after, cur)
|
|
514
|
+
// The same lines changed on both sides: the newer edit, this one, wins them (as everywhere in Quilt).
|
|
515
|
+
after = r.conflicts.length ? mergeAuto(w.base, after, cur, { oursNewer: true }).text : r.text
|
|
516
|
+
if (after !== c.bytes.toString('utf8')) rewrite.push({ path: p, bytes: Buffer.from(after, 'utf8'), mode: c.mode })
|
|
517
|
+
}
|
|
518
|
+
let detail = applyTextDiff(t, after)
|
|
519
|
+
this.lastText.set(p, { id: blobId(Buffer.from(after, 'utf8')), text: after })
|
|
520
|
+
// A file new to the live document took its whole text: what changed is measured from the head's.
|
|
521
|
+
if (fresh && before.has(p)) { const probe = new Y.Doc().getText(); probe.insert(0, before.get(p)); detail = applyTextDiff(probe, after) }
|
|
126
522
|
if (c.mode === 'exec') m.modes.set(p, 'exec'); else if (m.modes.has(p)) m.modes.delete(p)
|
|
523
|
+
if (detail || !had) taken.push({ path: p, kind, detail, before: before.get(p) ?? '', after })
|
|
127
524
|
}
|
|
128
525
|
const st = fs.lstatSync(path.join(this.dir, ...p.split('/')))
|
|
129
526
|
this.known[p] = { blob: c.blob, mode: c.mode, size: st.size, mtimeMs: st.mtimeMs }
|
|
130
527
|
}
|
|
131
528
|
})
|
|
529
|
+
// Disk gets the combination: what the square had that this edit was not made on.
|
|
530
|
+
for (const x of rewrite) this.write(x.path, x)
|
|
531
|
+
this.saveSoon()
|
|
532
|
+
if (this.onEdits && taken.length) { try { this.onEdits(taken, this.doc, () => this.conn.confirmBranch()) } catch (err) { if (this.logFn) this.logFn(`could not record ${taken.length} edit(s): ${err.message}`) } }
|
|
132
533
|
}
|
|
133
534
|
|
|
134
535
|
/** Bytes of a blob, from this block's cache or the server. */
|
|
135
536
|
async bytesOf (id) {
|
|
136
537
|
if (!this.cache.has(id)) {
|
|
137
|
-
const { blobs } = await this.
|
|
538
|
+
const { blobs } = await this.reader.blobs(this.block.quilt, [id])
|
|
138
539
|
this.cache.set(id, Buffer.from(blobs[id], 'base64'))
|
|
139
540
|
}
|
|
140
541
|
return this.cache.get(id)
|
|
141
542
|
}
|
|
142
543
|
|
|
544
|
+
/**
|
|
545
|
+
* The head's file `s` (block.files) at `p` as disk holds it: { bytes, mode }. A conflict Seam keeps in
|
|
546
|
+
* the square (a term: a rebase or merge that did not combine) has no bytes of its own: it is written
|
|
547
|
+
* with its markers, as Seam's own folders write it, and settled by editing them out and committing.
|
|
548
|
+
*/
|
|
549
|
+
async headFile (p, s, rev = `c:${this.block.at}`) {
|
|
550
|
+
if (s.mode !== 'conflict') return { bytes: await this.bytesOf(s.blob), mode: s.mode }
|
|
551
|
+
const r = await this.reader.cat(this.block.quilt, rev, p, { format: conflictStyle() })
|
|
552
|
+
return { bytes: Buffer.from((r && r.content) || '', 'base64'), mode: 'file' }
|
|
553
|
+
}
|
|
554
|
+
|
|
143
555
|
/** What disk should hold at `p`: { bytes, mode } or null (absent). */
|
|
144
556
|
async wanted (p) {
|
|
145
557
|
const e = entryOf(this.doc, p)
|
|
@@ -147,12 +559,12 @@ export class LiveBlock {
|
|
|
147
559
|
if (e && e.bytes) return { bytes: e.bytes, mode: e.mode }
|
|
148
560
|
if (e && e.blob) return { bytes: await this.bytesOf(e.blob), mode: e.mode }
|
|
149
561
|
const s = this.block.files[p]
|
|
150
|
-
return s ?
|
|
562
|
+
return s ? this.headFile(p, s) : null
|
|
151
563
|
}
|
|
152
564
|
|
|
153
565
|
write (p, want) {
|
|
154
566
|
const abs = path.join(this.dir, ...p.split('/'))
|
|
155
|
-
if (!want) { fs.rmSync(abs, { force: true }); delete this.known[p]; return }
|
|
567
|
+
if (!want) { fs.rmSync(abs, { force: true }); delete this.known[p]; this.lastText.delete(p); return }
|
|
156
568
|
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
157
569
|
const tmp = path.join(path.dirname(abs), `.${path.basename(abs)}.quilt-tmp`)
|
|
158
570
|
if (want.mode === 'symlink') { fs.rmSync(abs, { force: true }); fs.symlinkSync(want.bytes.toString('utf8'), abs) } else {
|
|
@@ -161,6 +573,9 @@ export class LiveBlock {
|
|
|
161
573
|
}
|
|
162
574
|
const st = fs.lstatSync(abs)
|
|
163
575
|
this.known[p] = { blob: blobId(want.bytes), mode: want.mode, size: st.size, mtimeMs: st.mtimeMs }
|
|
576
|
+
if (this.doc && liveMaps(this.doc).files.has(p) && want.bytes.length <= MAX_LIVE_TEXT) this.lastText.set(p, { id: this.known[p].blob, text: want.bytes.toString('utf8') })
|
|
577
|
+
else this.lastText.delete(p)
|
|
578
|
+
this.saveSoon()
|
|
164
579
|
}
|
|
165
580
|
|
|
166
581
|
/** Moves the block to the document's head (a commit by anyone in the square), then writes the overlay out. */
|
|
@@ -170,32 +585,51 @@ export class LiveBlock {
|
|
|
170
585
|
await this.scan() // an edit not yet ingested is merged in before a remote change is written over it
|
|
171
586
|
const m = liveMaps(this.doc)
|
|
172
587
|
const paths = new Set([...m.files.keys(), ...m.blobs.keys(), ...m.gone.keys(), ...m.clean.keys()])
|
|
588
|
+
// One file that can't be written out (its bytes not readable, the disk refusing) never holds up the
|
|
589
|
+
// others, and is said (outOfStep, in the folder's status), then tried again (STEP_MS).
|
|
590
|
+
const failed = []
|
|
173
591
|
for (const p of paths) {
|
|
174
|
-
if (!this.block.paths.some((x) => under(p, x))) continue
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
592
|
+
if (!this.block.paths.some((x) => under(p, x)) || this.skipped(p) || (this.inPlace && this.nested(p))) continue
|
|
593
|
+
try {
|
|
594
|
+
const want = await this.wanted(p)
|
|
595
|
+
const id = want ? blobId(want.bytes) : null
|
|
596
|
+
const have = this.known[p] ? this.known[p].blob : null
|
|
597
|
+
if (id !== have || (want && this.known[p].mode !== want.mode)) this.write(p, want)
|
|
598
|
+
} catch (err) { failed.push({ path: p, err }) }
|
|
179
599
|
}
|
|
600
|
+
this.noteOutOfStep(failed)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** What a write-out could not bring in step, said once while it lasts (and when it is over). */
|
|
604
|
+
noteOutOfStep (failed) {
|
|
605
|
+
const was = this.outOfStep || null
|
|
606
|
+
this.outOfStep = failed.length ? `${failed.length} file(s) here are not as the square has them yet (${failed.slice(0, 3).map((f) => f.path).join(', ')}${failed.length > 3 ? ', …' : ''}): ${String(failed[0].err && failed[0].err.message || failed[0].err).slice(0, 200)}` : null
|
|
607
|
+
if (this.logFn && this.outOfStep && this.outOfStep !== was) this.logFn(this.outOfStep)
|
|
608
|
+
if (this.logFn && was && !this.outOfStep) this.logFn('every file here is as the square has it again.')
|
|
180
609
|
}
|
|
181
610
|
|
|
182
611
|
/** 05 "Moving block.at": rewrites the files that are Quilt's; files in the overlay are the holder's and stay. */
|
|
183
612
|
async reproject (to) {
|
|
184
613
|
const b = this.block
|
|
185
|
-
const d = await this.
|
|
186
|
-
const t = d.files.length ? await this.
|
|
614
|
+
const d = await this.reader.diff(b.quilt, { from: `c:${b.at}`, to: `c:${to}`, paths: b.paths, format: 'names' })
|
|
615
|
+
const t = d.files.length ? await this.reader.tree(b.quilt, `c:${to}`, b.paths) : { entries: [] }
|
|
187
616
|
const byPath = new Map(t.entries.map((e) => [e.path, e]))
|
|
188
|
-
const local = new Set(
|
|
617
|
+
const local = new Set(this.diskChanges(this.known).map((c) => c.path))
|
|
189
618
|
for (const f of d.files) {
|
|
190
619
|
const e = byPath.get(f.path)
|
|
191
|
-
if (e) b.files[f.path] = { blob: e.blob, mode: e.mode, size: e.size }
|
|
620
|
+
if (e) b.files[f.path] = { blob: e.mode === 'conflict' ? null : e.blob, mode: e.mode, size: e.size, ...(e.term ? { term: e.term } : {}) }
|
|
192
621
|
else delete b.files[f.path]
|
|
193
|
-
if (entryOf(this.doc, f.path) || local.has(f.path)) continue
|
|
194
|
-
|
|
622
|
+
if (entryOf(this.doc, f.path) || local.has(f.path) || this.skipped(f.path) || (this.inPlace && this.nested(f.path))) continue
|
|
623
|
+
// Already on disk as the new head has it (the commit was made from this very work): left alone.
|
|
624
|
+
const k = this.known[f.path]
|
|
625
|
+
if (e ? k && e.mode !== 'conflict' && k.blob === e.blob && k.mode === e.mode : !k) continue
|
|
626
|
+
this.write(f.path, e ? await this.headFile(f.path, b.files[f.path], `c:${to}`) : null)
|
|
195
627
|
}
|
|
196
628
|
b.at = to
|
|
197
|
-
|
|
198
|
-
|
|
629
|
+
// A square with no commit of its own (a rebase only moved its base): a commit's expect_head is none.
|
|
630
|
+
const rec = this.doc.getMap('live').get('head') || {}
|
|
631
|
+
b.head = rec.commit === to && rec.base ? null : to
|
|
632
|
+
this.save()
|
|
199
633
|
}
|
|
200
634
|
|
|
201
635
|
/** Every disk change in, and the relay holding all of it. */
|
|
@@ -216,10 +650,13 @@ export class LiveBlock {
|
|
|
216
650
|
* then the relay moves the head and drops what was committed (live-square.js `seal`).
|
|
217
651
|
*/
|
|
218
652
|
async commit ({ message = '' } = {}) {
|
|
653
|
+
if (!this.client) throw new Error('sign in to Seam first (quilt login, or set SEAM_TOKEN): a commit is made as you')
|
|
219
654
|
await this.flush()
|
|
220
655
|
return this.run(async () => {
|
|
221
656
|
const head = this.head
|
|
222
|
-
|
|
657
|
+
// An untracked file this folder never syncs (ignored, or a Quilt built-in) is never committed, even if
|
|
658
|
+
// the live document names it (a square from before 0.3.135's switch took agents' scratch files in).
|
|
659
|
+
const list = overlay(this.doc).filter((e) => !(this.skipped(e.path) && !this.block.files[e.path]))
|
|
223
660
|
if (!list.length) return { nothing: true }
|
|
224
661
|
const uploads = list.filter((e) => e.bytes).map((e) => ({ kind: 'blob', id: blobId(e.bytes), bytes: e.bytes }))
|
|
225
662
|
if (uploads.length) await this.client.upload(this.block.quilt, uploads)
|
|
@@ -233,17 +670,52 @@ export class LiveBlock {
|
|
|
233
670
|
})
|
|
234
671
|
}
|
|
235
672
|
|
|
673
|
+
/**
|
|
674
|
+
* The square's head moved on Seam itself (a rebase, a settled conflict, a merge that kept its
|
|
675
|
+
* conflict in the square): the relay moves the live document onto it, each live file the move
|
|
676
|
+
* changed combined with it (source-live/bring-in.js), and the folder is re-projected onto it.
|
|
677
|
+
* `base`: the square has no commit of its own, and `head` is its new base.
|
|
678
|
+
*/
|
|
679
|
+
async moveTo (head, { base = false } = {}) {
|
|
680
|
+
await this.flush()
|
|
681
|
+
return this.run(async () => {
|
|
682
|
+
const was = this.head
|
|
683
|
+
if (!head || head === was) return { moved: false }
|
|
684
|
+
await this.conn.branchRequest({ op: 'live-move', branch: this.block.live, prev: was, head, ...(base ? { base: true } : {}) })
|
|
685
|
+
await waitUntil(() => this.head === head, 10000, 'the moved head')
|
|
686
|
+
await this.reproject(head)
|
|
687
|
+
return { moved: true, head }
|
|
688
|
+
})
|
|
689
|
+
}
|
|
690
|
+
|
|
236
691
|
/** Merges the square into a line (07), after sealing anything uncommitted. */
|
|
237
692
|
async merge ({ into = 'main', message } = {}) {
|
|
238
693
|
await this.commit({ message: message || 'Work in progress' })
|
|
239
694
|
return this.client.merge(this.block.quilt, into, { square: this.block.square, expect_head: this.block.head, ...(message ? { message } : {}) })
|
|
240
695
|
}
|
|
241
696
|
|
|
697
|
+
/** Stops the block without saving its record: it never opened (open failed part-way). */
|
|
698
|
+
abandon () {
|
|
699
|
+
this.closed = true
|
|
700
|
+
if (this.stopOpening) this.stopOpening()
|
|
701
|
+
clearTimeout(this.scanTimer)
|
|
702
|
+
clearTimeout(this.saveTimer)
|
|
703
|
+
clearInterval(this.reconcileTimer)
|
|
704
|
+
clearInterval(this.stepTimer)
|
|
705
|
+
for (const t of this.settleTimers || []) clearTimeout(t)
|
|
706
|
+
if (this.watcher) this.watcher.close()
|
|
707
|
+
if (this.conn) this.conn.close()
|
|
708
|
+
}
|
|
709
|
+
|
|
242
710
|
async close () {
|
|
243
711
|
this.closed = true
|
|
244
712
|
clearTimeout(this.scanTimer)
|
|
713
|
+
clearInterval(this.reconcileTimer)
|
|
714
|
+
clearInterval(this.stepTimer)
|
|
715
|
+
for (const t of this.settleTimers || []) clearTimeout(t)
|
|
245
716
|
if (this.watcher) this.watcher.close()
|
|
246
717
|
await this.queue.catch(() => {})
|
|
718
|
+
if (this.record && this.block && this.block.at) { try { this.save() } catch {} }
|
|
247
719
|
if (this.conn) this.conn.close()
|
|
248
720
|
}
|
|
249
721
|
}
|