heyquilt 0.3.90 → 0.3.106
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -11
- package/RELEASES.md +131 -2
- package/bin/quilt.js +45 -12
- package/package.json +3 -3
- package/src/account.js +28 -0
- package/src/agents/claude-code.js +11 -4
- package/src/agents/cursor-transcripts.js +11 -4
- package/src/ai-vm-signin.js +14 -2
- package/src/ai-vm.js +150 -8
- package/src/api/agent-auth.js +9 -3
- package/src/api/fly-machines.js +72 -15
- package/src/api/memory-store.js +141 -5
- package/src/api/relay-cluster.js +337 -43
- package/src/api/relay-machines.js +35 -3
- package/src/api/releases.js +87 -0
- package/src/api/routes/agents.js +111 -16
- package/src/api/routes/ai-vm.js +143 -17
- package/src/api/routes/orgs.js +3 -1
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/workspaces.js +8 -1
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +40 -9
- package/src/api/supabase-store.js +120 -10
- package/src/autowork.js +3 -15
- package/src/catchup.js +1 -1
- package/src/clash.js +10 -22
- package/src/control.js +18 -15
- package/src/fsutil.js +11 -1
- package/src/gitstate.js +29 -5
- package/src/hooks.js +1 -1
- package/src/mcp-proxy.js +7 -2
- package/src/mcp.js +22 -25
- package/src/merge3.js +94 -12
- package/src/pass-source.js +9 -2
- package/src/pathrules.js +7 -0
- package/src/relay-clash.js +101 -484
- package/src/relay-githttp.js +36 -7
- package/src/relay-mcp.js +19 -7
- package/src/relay-metrics.js +66 -1
- package/src/relay-outbound.js +154 -25
- package/src/relay-sync.js +133 -136
- package/src/relay-turn-sync.js +250 -51
- package/src/relay-upstream.js +49 -179
- package/src/releases.js +16 -9
- package/src/runner.js +3 -2
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +71 -44
- package/src/session.js +347 -262
- package/src/status.js +3 -5
- package/src/sync-words.js +1 -1
- package/src/turn-sync-plan.js +13 -10
- package/src/turn-sync.js +32 -11
- package/src/ui/agent-guide.js +4 -4
- package/src/ui/app.css +208 -23
- package/src/ui/app.js +4 -0
- package/src/ui/cloud-ai-model.js +3 -2
- package/src/ui/common.js +2 -0
- package/src/ui/fileview.js +28 -1
- package/src/ui/home.js +32 -33
- package/src/ui/loom.css +18 -2
- package/src/ui/loom.js +41 -8
- package/src/ui/rail-model.js +6 -9
- package/src/ui/releases.js +1 -1
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +47 -149
- package/src/ui/tree.js +23 -18
- package/src/ui/workspaces.js +5 -0
- package/src/ui-server.js +136 -27
- package/src/update-check.js +1 -1
- package/src/upstream.js +75 -38
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- package/src/ui/merges.js +0 -158
package/src/session.js
CHANGED
|
@@ -6,7 +6,7 @@ import fs from 'node:fs'
|
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import crypto from 'node:crypto'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
|
-
import { execFile } from 'node:child_process'
|
|
9
|
+
import { execFile, execFileSync } from 'node:child_process'
|
|
10
10
|
import { EventEmitter } from 'node:events'
|
|
11
11
|
import * as Y from 'yjs'
|
|
12
12
|
import { watch } from 'chokidar'
|
|
@@ -17,7 +17,7 @@ import { MAX_SHARED_FILE_BYTES, FEATURES } from './protocol.js'
|
|
|
17
17
|
import { formatBytes, pullAdvice } from './status.js'
|
|
18
18
|
import {
|
|
19
19
|
loadIgnore, IGNORE_FILES, isIgnored, isSafeRelPath, resolveInside, looksBinary, sha1, walk,
|
|
20
|
-
toPosix, globMatcher, MAX_TEXT_BYTES, MAX_BINARY_BYTES, LARGE_FILE_BYTES, MAX_STORED_BINARY_BYTES
|
|
20
|
+
toPosix, globMatcher, MAX_TEXT_BYTES, MAX_BINARY_BYTES, LARGE_FILE_BYTES, MAX_STORED_BINARY_BYTES, CHAT_FILES, closeWatcher
|
|
21
21
|
} from './fsutil.js'
|
|
22
22
|
import { wrapForRelay, relayKeyId, deriveWrapKey, newFileKey, wrapKey, unwrapKey, encryptBlob, decryptBlob, blobId } from './largefiles.js'
|
|
23
23
|
import { applyTextDiff } from './textdiff.js'
|
|
@@ -41,11 +41,11 @@ import { cleanCommitFiles, ownerName, commitRequestMessage, MAX_COMMIT_DESCRIPTI
|
|
|
41
41
|
import { cleanFiles, uncommitted, lastCommitted, commitFiles, commitMessage, editorsSince, uncommittedByPerson, requestWarning, REQUEST_SETTLE_MS, MAX_REQUEST_FILES, MAX_MESSAGE } from './commit.js'
|
|
42
42
|
import { changeRefusal, TALK_REFUSED } from './session-access.js'
|
|
43
43
|
import { canAdmit } from './admit-policy.js'
|
|
44
|
-
import { merge3,
|
|
44
|
+
import { merge3, mergeAuto, hasMarkers } from './merge3.js'
|
|
45
45
|
import { sameEditClient, asClient } from './same-edit.js'
|
|
46
46
|
import { openMerge, updateMerge, readMerges, pruneMerges, cleanName } from './merges.js'
|
|
47
47
|
import { ensureQuiltIgnored } from './gitignore.js'
|
|
48
|
-
import { urlOrigin, gitDir, headKey, headRef, gitRuns, askTwice, lastCallTimedOut, busy as gitBusy, leftoverLock, STALE_LOCK_MS, indexStamp, classify, filesAt, changesBetween, commitsBetween, treeState, branchTip, watchGit, unmergedPaths, stashStamp, upstreamAdds, pullState, SETTLE_MS, BURST_PATHS, upstreamOf, fetchUpstream, isAncestor, stagedAgainst, fastForward, resetIndex, blobAt, hasFilesUnder, repoBranches, remoteHost, backWithGit, installGitHelpers, setPersonGit, hasCommit, pushLocal, bundleOf, adoptPublished } from './gitstate.js'
|
|
48
|
+
import { urlOrigin, gitDir, headKey, headRef, gitRuns, askTwice, lastCallTimedOut, busy as gitBusy, leftoverLock, STALE_LOCK_MS, indexStamp, classify, filesAt, changesBetween, commitsBetween, treeState, branchTip, watchGit, unmergedPaths, stashStamp, upstreamAdds, pullState, SETTLE_MS, BURST_PATHS, upstreamOf, fetchUpstream, isAncestor, stagedAgainst, fastForward, resetIndex, blobAt, hasFilesUnder, repoBranches, remoteHost, backWithGit, installGitHelpers, setPersonGit, hasCommit, pushLocal, bundleOf, adoptPublished, commitTimes } from './gitstate.js'
|
|
49
49
|
import { planCatchUp, catchUpAdvice, publishedOf, notePublished } from './upstream.js'
|
|
50
50
|
import { agentPrefix, noreply, slug, gitForEveryoneWords } from './github-commit.js'
|
|
51
51
|
import { parseRepo, upstreamRef } from './relay-upstream.js'
|
|
@@ -76,9 +76,6 @@ const CLASH_SETTLE_MS = 2000 // a clash task this member wrote is told to its AI
|
|
|
76
76
|
const CLASH_KEEP_MS = 3 * 24 * 60 * 60 * 1000 // a finished clash record is kept this long
|
|
77
77
|
const CLASH_RECORDS = 100
|
|
78
78
|
const KEEP_DONE_COMMITS = 100 // finished commit requests kept, newest (the board shows which task's work was committed)
|
|
79
|
-
const STILL_MARKED = 'this file has conflict markers in it; finish editing it (or choose Keep mine) first'
|
|
80
|
-
// The note on a claim taken to settle a merge (takeMerge).
|
|
81
|
-
const MERGE_CLAIM_NOTE = 'settling the merge of'
|
|
82
79
|
const COLORS = ['#4f4fa0', '#3b6a9a', '#4a7a45', '#855a9c', '#a8701c', '#2e7a80', '#9c4f6b']
|
|
83
80
|
const RECENT_MS = 2 * 60 * 1000
|
|
84
81
|
const AGENT_FEED_CAP = 300
|
|
@@ -241,7 +238,6 @@ export class Session extends EventEmitter {
|
|
|
241
238
|
// Our boxes: what the session had before the edit that opened each (null: no file). Back to that, the box holds
|
|
242
239
|
// nothing of ours (our edit undone, or taken back) and opens like a committed one.
|
|
243
240
|
this.boxFrom = new Map()
|
|
244
|
-
this.mergeClaims = new Set() // files taken to settle a merge (takeMerge), let go once it is settled
|
|
245
241
|
this.queueNoticed = new Map() // claim pattern -> when our AI was last told someone waits for it
|
|
246
242
|
this.autoClaimQuietMs = autoClaimQuietMs
|
|
247
243
|
this.handoffGraceMs = handoffGraceMs
|
|
@@ -832,14 +828,20 @@ export class Session extends EventEmitter {
|
|
|
832
828
|
if (this.kind !== 'agent' || !this.git || !this.git.branch || !up || !up.sha) return false
|
|
833
829
|
const url = await this.gitOut(['config', '--get', `remote.${up.remote || 'origin'}.url`])
|
|
834
830
|
if (!url || url !== this.relayGitUrl()) return false
|
|
835
|
-
|
|
831
|
+
// The commit the relay put the session at, when GitHub has moved past it since (publishLocal says why): the rest comes in as any of GitHub's commits do.
|
|
832
|
+
const rs = this.relaySha()
|
|
833
|
+
const to = !this.memberAt(up.sha) && rs && rs !== at && await isAncestor(this.root, rs, up.sha) ? rs : up.sha
|
|
834
|
+
const cherry = await this.gitOut(['cherry', to, at])
|
|
836
835
|
if (cherry === null || !cherry || cherry.split('\n').some((l) => !l.startsWith('-'))) return false
|
|
837
|
-
|
|
836
|
+
if (!(await this.sessionOnDisk(at, to))) { this.askRelayFor(up.sha); this.checkUpstreamSoon({ delay: 500 }); return false }
|
|
837
|
+
const r = await adoptPublished(this.root, this.git.branch, at, to)
|
|
838
838
|
if (!r.moved) return false
|
|
839
|
-
this.gitSeen = { key: this.git.branch, branch: this.git.branch, sha:
|
|
839
|
+
this.gitSeen = { key: this.git.branch, branch: this.git.branch, sha: to }
|
|
840
840
|
this.gitIndex = indexStamp(this.root)
|
|
841
|
-
this.log(`⬆️ the relay put your commits on GitHub's newer ${this.git.branch} (${
|
|
842
|
-
|
|
841
|
+
this.log(`⬆️ the relay put your commits on GitHub's newer ${this.git.branch} (${to.slice(0, 7)}); this folder follows it (yours are kept under refs/quilt/original/${this.git.branch}/)`)
|
|
842
|
+
const behind = to === up.sha ? 0 : ((await commitsBetween(this.root, to, up.sha)) ?? 1)
|
|
843
|
+
this.setUpstream({ ...up, behind, ahead: 0 })
|
|
844
|
+
if (behind) this.checkUpstreamSoon()
|
|
843
845
|
return true
|
|
844
846
|
}
|
|
845
847
|
|
|
@@ -871,7 +873,7 @@ export class Session extends EventEmitter {
|
|
|
871
873
|
const wrong = list.filter((r) => !r.remoteRef.startsWith(`refs/heads/${prefix}`))
|
|
872
874
|
if (wrong.length) {
|
|
873
875
|
const name = (r) => r.remoteRef.replace(/^refs\/heads\//, '')
|
|
874
|
-
return { ok: false, why: `The session owner lets agents push only to branches of their own here: ${prefix}<topic> (not ${wrong.map(name).join(', ')}). Push to one (git push origin HEAD:${prefix}<topic>) and open a pull request to the session's branch
|
|
876
|
+
return { ok: false, why: `The session owner lets agents push only to branches of their own here: ${prefix}<topic> (not ${wrong.map(name).join(', ')}). Push to one (git push origin HEAD:${prefix}<topic>) and ask a person to open a pull request from it to the session's branch, or ask the owner to allow any branch (quilt_agent_commits any).` }
|
|
875
877
|
}
|
|
876
878
|
}
|
|
877
879
|
// Pushing to GitHub needs credentials this folder gets from the session: when there are none, say why now, instead of git's "could not read Username".
|
|
@@ -1632,7 +1634,7 @@ export class Session extends EventEmitter {
|
|
|
1632
1634
|
}
|
|
1633
1635
|
const binary = [base, ours, theirs].some((k) => typeof k === 'string' && k.startsWith('bin:'))
|
|
1634
1636
|
if (claimedByOther) return this.openConflict({ rel, base, ours, theirs, theirsBy, disk, kind: 'claimed', claimedBy: claim.by, binary, via })
|
|
1635
|
-
if (binary || ours === null || theirs === undefined) return this.
|
|
1637
|
+
if (binary || ours === null || theirs === undefined) return this.autoSettle({ rel, base, ours, theirs, theirsBy, disk, binary, via })
|
|
1636
1638
|
const { text, conflicts } = merge3(base || '', ours, theirs)
|
|
1637
1639
|
// Ours already has the session's change in it (git combined them here: a stash popped over a pull):
|
|
1638
1640
|
// only one version is new, so it goes in. Nothing is combined.
|
|
@@ -1656,9 +1658,111 @@ export class Session extends EventEmitter {
|
|
|
1656
1658
|
release()
|
|
1657
1659
|
return 'merged'
|
|
1658
1660
|
}
|
|
1659
|
-
// Otherwise (offline, a pull) both sides changed it
|
|
1660
|
-
|
|
1661
|
-
|
|
1661
|
+
// Otherwise (offline, a pull) both sides changed it: Quilt combines them itself.
|
|
1662
|
+
return this.autoSettle({ rel, base, ours, theirs, theirsBy, disk, binary: false, via })
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/** When the folder's HEAD commit last changed rel, in ms, or 0 (not a git folder, or git can't say). */
|
|
1666
|
+
committedAt (rel) {
|
|
1667
|
+
try {
|
|
1668
|
+
const out = execFileSync('git', ['--no-optional-locks', 'log', '-1', '--format=%ct', 'HEAD', '--', rel], { cwd: this.root, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
1669
|
+
const s = Number(out.trim())
|
|
1670
|
+
return s > 0 ? s * 1000 + 999 : 0 // whole seconds: the end of its second, so an edit in the same second is older
|
|
1671
|
+
} catch { return 0 }
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/** When the session last changed rel (its chronology, then the room's activity), or 0. */
|
|
1675
|
+
sessionEditedAt (rel) {
|
|
1676
|
+
const h = this.history.array
|
|
1677
|
+
for (let i = h.length - 1; i >= 0; i--) {
|
|
1678
|
+
const e = h.get(i)
|
|
1679
|
+
if (e && e.path === rel && typeof e.ts === 'number') return e.ts
|
|
1680
|
+
}
|
|
1681
|
+
for (let i = this.activity.length - 1; i >= 0; i--) {
|
|
1682
|
+
const a = this.activity.get(i)
|
|
1683
|
+
if (a && a.path === rel && typeof a.ts === 'number') return a.ts
|
|
1684
|
+
}
|
|
1685
|
+
return 0
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* Both the folder (ours) and the session (theirs) changed rel since base: Quilt combines them
|
|
1690
|
+
* itself, and nobody is asked (docs/superpowers/specs/2026-10-18-automatic-merges.md). Lines only
|
|
1691
|
+
* one side changed are kept; lines both changed go to the newer edit; a binary file is the newer
|
|
1692
|
+
* version; an edit beats a deletion. What lost is kept (the record, .quilt/merges/<id>/, the
|
|
1693
|
+
* file's history) and whoever wrote it is told. Returns 'merged'.
|
|
1694
|
+
*/
|
|
1695
|
+
autoSettle ({ rel, base, ours, theirs, theirsBy, disk, binary, via = null }) {
|
|
1696
|
+
const release = () => this.merging.delete(rel)
|
|
1697
|
+
const pulled = via === 'pull'
|
|
1698
|
+
const by = theirsBy ? cleanName(theirsBy) : null
|
|
1699
|
+
// When ours was written: a pull's file is as new as the commit that last changed it (not the
|
|
1700
|
+
// moment git wrote it), anything else as its last save.
|
|
1701
|
+
const oursAt = (pulled && this.committedAt(rel)) || (disk && Number.isFinite(disk.mtime) ? disk.mtime : Date.now())
|
|
1702
|
+
const oursNewer = oursAt >= this.sessionEditedAt(rel)
|
|
1703
|
+
const text = (k) => (typeof k === 'string' && !k.startsWith('bin:') ? k : null)
|
|
1704
|
+
const keep = ({ how, reason, lost = [] }) => {
|
|
1705
|
+
try {
|
|
1706
|
+
const rec = openMerge(this.bdoc, this.merges, {
|
|
1707
|
+
path: rel, by: this.name, byId: this.myKey(), others: by ? [by] : [], kind: 'conflict',
|
|
1708
|
+
ours: binary ? null : text(ours), oursDeleted: ours === null, via,
|
|
1709
|
+
base: binary ? null : text(base), theirsHash: theirs === undefined ? null : sha1(theirs), binary,
|
|
1710
|
+
reason: reason.replace(/[\u0000-\u001f\u007f]+/g, ' ').slice(0, 500)
|
|
1711
|
+
}, LOCAL)
|
|
1712
|
+
this.writeMergeFiles(rec.id, { base, ours, theirs, disk })
|
|
1713
|
+
updateMerge(this.bdoc, this.merges, rec.id, { state: 'done', how, resolvedBy: 'Quilt', reason: rec.reason }, LOCAL)
|
|
1714
|
+
} catch {}
|
|
1715
|
+
this.noteLostLines(rel, { by, lost, oursWon: how === 'mine' })
|
|
1716
|
+
}
|
|
1717
|
+
// An edit beats a deletion: nothing anyone wrote is thrown away.
|
|
1718
|
+
if (ours === null) {
|
|
1719
|
+
release()
|
|
1720
|
+
this.lastKnown.delete(rel)
|
|
1721
|
+
this.tryWrite(rel)
|
|
1722
|
+
keep({ how: 'theirs', reason: `Deleted here, but ${by || 'the session'} changed it: it stays` })
|
|
1723
|
+
this.log(`🧵 ${rel}: you deleted it, but ${by || 'the session'} changed it, so it stays`)
|
|
1724
|
+
return 'merged'
|
|
1725
|
+
}
|
|
1726
|
+
if (theirs === undefined) {
|
|
1727
|
+
release()
|
|
1728
|
+
this.ingest(rel, { pulled })
|
|
1729
|
+
keep({ how: 'mine', reason: `${by || 'The session'} deleted it, but it was changed here: it stays` })
|
|
1730
|
+
this.log(`🧵 ${rel}: ${by || 'the session'} deleted it, but you changed it, so it stays`)
|
|
1731
|
+
return 'merged'
|
|
1732
|
+
}
|
|
1733
|
+
if (binary) {
|
|
1734
|
+
release()
|
|
1735
|
+
if (oursNewer) this.ingest(rel, { pulled }); else { this.lastKnown.set(rel, ours); this.tryWrite(rel) }
|
|
1736
|
+
keep({ how: oursNewer ? 'mine' : 'theirs', reason: `Both changed it: the newer version (${oursNewer ? 'yours' : `${by || 'the session'}'s`}) stays`, lost: [{ side: oursNewer ? 'theirs' : 'ours', lines: [], kept: [], at: 0 }] })
|
|
1737
|
+
this.log(`🧵 ${rel}: both changed it; the newer version (${oursNewer ? 'yours' : `${by || 'the session'}'s`}) stays`)
|
|
1738
|
+
return 'merged'
|
|
1739
|
+
}
|
|
1740
|
+
// No common version (git couldn't read it, or both made the file): nothing lines up to combine,
|
|
1741
|
+
// so the newer version is the file, as for a binary.
|
|
1742
|
+
const r = typeof base === 'string' ? mergeAuto(base, ours, theirs, { oursNewer })
|
|
1743
|
+
: { text: oursNewer ? ours : theirs, lost: [{ side: oursNewer ? 'theirs' : 'ours', lines: (oursNewer ? theirs : ours).split('\n').filter(Boolean), kept: [], at: 0 }] }
|
|
1744
|
+
this.applyMerged(rel, r.text, `with ${by || 'the session'}'s changes${r.lost.length ? ' (the newer edit kept where both changed the same lines)' : ''}`, { pulled })
|
|
1745
|
+
release()
|
|
1746
|
+
if (r.lost.length) keep({ how: oursNewer ? 'mine' : 'theirs', reason: `Merged by Quilt: where both changed the same lines, the newer edit (${oursNewer ? 'yours' : `${by || 'the session'}'s`}) stays`, lost: r.lost })
|
|
1747
|
+
return 'merged'
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Tells whoever's lines lost an automatic merge (autoSettle): this person in the log and the
|
|
1752
|
+
* catch-up, someone else in a direct message. A notice, never a task.
|
|
1753
|
+
*/
|
|
1754
|
+
noteLostLines (rel, { by, lost, oursWon }) {
|
|
1755
|
+
if (!lost.length) return
|
|
1756
|
+
const shown = lost.flatMap((l) => l.lines).slice(0, 12)
|
|
1757
|
+
const lines = shown.length ? `\n${shown.map((l) => `- ${l}`).join('\n')}` : ''
|
|
1758
|
+
if (!oursWon) {
|
|
1759
|
+
this.log(`🧵 ${rel}: Quilt merged your changes with ${by || 'the session'}'s; where you both changed the same lines, their newer edit stays. Your lines are in the file's history and in .quilt/merges/.${lines}`)
|
|
1760
|
+
return
|
|
1761
|
+
}
|
|
1762
|
+
if (!by || by === this.name) return
|
|
1763
|
+
try {
|
|
1764
|
+
this.say(`Quilt merged ${rel}: where we both changed the same lines, my newer edit stays. Your lines it replaced are in the file's history (Changes):${lines}`, { to: by })
|
|
1765
|
+
} catch {}
|
|
1662
1766
|
}
|
|
1663
1767
|
|
|
1664
1768
|
/** Writes a merged text to the shared doc and the disk as one edit of ours (`pulled`: one a git pull brought). */
|
|
@@ -1715,19 +1819,14 @@ export class Session extends EventEmitter {
|
|
|
1715
1819
|
reason: reason ? String(reason).replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 500) || null : null
|
|
1716
1820
|
}, LOCAL)
|
|
1717
1821
|
this.writeMergeFiles(rec.id, { base, ours, theirs, disk })
|
|
1718
|
-
// Live sync with GitHub: the relay holds the file and has the two parties merge it (relay-clash.js), never markers in the session.
|
|
1719
|
-
if (this.branchLive() && this.conn && this.conn.adminRequest) {
|
|
1720
|
-
this.conn.adminRequest({ op: 'offlineClash', path: rel, mergeId: rec.id, ...(binary ? { binary: true } : {}), ...(text(base) !== null ? { base: text(base) } : {}), ...(ours === null ? { oursDeleted: true } : text(ours) !== null ? { ours: text(ours) } : {}) })
|
|
1721
|
-
.catch((err) => this.log(`could not hand ${rel}'s merge to the session: ${err.message}`))
|
|
1722
|
-
}
|
|
1723
1822
|
// The session's version goes back on disk (or the file goes, if the session
|
|
1724
1823
|
// deleted it). lastKnown is set to ours first so writeOut doesn't also copy
|
|
1725
1824
|
// it to .quilt/conflicts: the merge folder already has it.
|
|
1726
1825
|
this.merging.delete(rel)
|
|
1727
1826
|
if (ours === null) this.lastKnown.delete(rel); else this.lastKnown.set(rel, ours)
|
|
1728
1827
|
this.tryWrite(rel)
|
|
1729
|
-
|
|
1730
|
-
this.log(
|
|
1828
|
+
// Only a file someone holds comes here now (autoSettle combines the rest): it waits for them.
|
|
1829
|
+
this.log(`⏳ ${rel}: ${claimant || by || 'someone'} has it claimed. Your changes are kept and go in, merged by Quilt, once they let go of it.`)
|
|
1731
1830
|
this.emit('file-changed', { path: rel, by: by || 'partner' })
|
|
1732
1831
|
return 'conflict'
|
|
1733
1832
|
}
|
|
@@ -1852,11 +1951,11 @@ export class Session extends EventEmitter {
|
|
|
1852
1951
|
if (looksBinary(buf)) {
|
|
1853
1952
|
if (buf.length > MAX_BINARY_BYTES && this.largeFilesOff) return { tooLarge: true }
|
|
1854
1953
|
const hash = sha1(buf)
|
|
1855
|
-
return { binary: true, buf, hash, key: `bin:${hash}`, exec }
|
|
1954
|
+
return { binary: true, buf, hash, key: `bin:${hash}`, exec, mtime: st.mtimeMs }
|
|
1856
1955
|
}
|
|
1857
1956
|
if (buf.length > MAX_TEXT_BYTES) return { tooLarge: true }
|
|
1858
1957
|
const text = buf.toString('utf8')
|
|
1859
|
-
return { text, key: text, exec }
|
|
1958
|
+
return { text, key: text, exec, mtime: st.mtimeMs }
|
|
1860
1959
|
}
|
|
1861
1960
|
|
|
1862
1961
|
// ------------------------------------------------------- local -> shared --
|
|
@@ -2385,9 +2484,17 @@ export class Session extends EventEmitter {
|
|
|
2385
2484
|
const theirs = inDoc ? this.sharedKey(rel) : head // null: neither this branch nor its commit has it
|
|
2386
2485
|
if (ours === theirs) { if (ours === null) this.lastKnown.delete(rel); else this.lastKnown.set(rel, ours); return null }
|
|
2387
2486
|
if (ours === base) { this.putBranchVersion(rel, { inDoc, head, headBuf }); return null } // not edited after all
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2487
|
+
if (theirs === null) { this.setAsideCarried(rel, { inDoc, head, headBuf, clash: false }); return null } // not on this branch
|
|
2488
|
+
// Edited here and changed on this branch too: combined by Quilt, as mergeOne does.
|
|
2489
|
+
const binary = [base, ours, theirs].some((k) => typeof k === 'string' && k.startsWith('bin:'))
|
|
2490
|
+
if (inDoc) return this.autoSettle({ rel, base, ours, theirs, theirsBy: this.lastEditorOf(rel), disk, binary })
|
|
2491
|
+
// This branch's commit has it, its document doesn't yet: the merge goes onto the disk, and is shared from there.
|
|
2492
|
+
if (ours === null) { this.putHeadVersion(rel, head, headBuf); return null }
|
|
2493
|
+
if (binary) return null // ours, edited after the checkout, is the newer version: it stays, and is shared from the disk
|
|
2494
|
+
const r = mergeAuto(base || '', ours, theirs, { oursNewer: true })
|
|
2495
|
+
this.writeFile(rel, resolveInside(this.root, rel), r.text)
|
|
2496
|
+
this.log(`🧵 ${rel}: your edit after switching to ${this.branch} is merged with ${this.branch}'s own change to it`)
|
|
2497
|
+
return 'merged'
|
|
2391
2498
|
}
|
|
2392
2499
|
|
|
2393
2500
|
/** This branch's version of rel onto the disk: its document's, else HEAD's file, else none. */
|
|
@@ -2787,7 +2894,7 @@ export class Session extends EventEmitter {
|
|
|
2787
2894
|
const has = () => atHead && changed.every((rel) => this.sharedKey(rel) === (atHead.get(rel) ?? undefined))
|
|
2788
2895
|
if (atHead && !atHead.failed && (has() || await this.relayHasPull(head.sha, has))) old = atHead
|
|
2789
2896
|
}
|
|
2790
|
-
if (old.failed) this.log(`⚠️ git could not read ${old.failed} file${old.failed === 1 ? '' : 's'} at the old commit; merged without a base
|
|
2897
|
+
if (old.failed) this.log(`⚠️ git could not read ${old.failed} file${old.failed === 1 ? '' : 's'} at the old commit; merged without a base, so where they differ the newer version stays`)
|
|
2791
2898
|
// A file git made the person resolve (stash pop, rebase) holds their merge of the commits and the
|
|
2792
2899
|
// session's work: merged against the session's version this disk had, it is taken as resolved.
|
|
2793
2900
|
const advance = changed.map((rel) => ({ rel, base: this.heldConflicts.has(rel) && this.lastKnown.has(rel) ? this.lastKnown.get(rel) : old.get(rel) ?? undefined, via: 'pull' }))
|
|
@@ -3049,6 +3156,11 @@ export class Session extends EventEmitter {
|
|
|
3049
3156
|
if (!behind) { this.setUpstream({ ...up, behind, ahead }); if (ahead) await this.publishLocal({ up, at, behind, ahead }); return this.upstream }
|
|
3050
3157
|
if (ahead) {
|
|
3051
3158
|
if (await this.followReplayed({ up, at })) return this.upstream
|
|
3159
|
+
// The relay has these commits on GitHub already: the branch follows once GitHub's files are here (followPublished).
|
|
3160
|
+
if (this.adoptLater && this.adoptLater.at === at) {
|
|
3161
|
+
if (await this.followPublished(up)) return this.upstream
|
|
3162
|
+
if (this.adoptLater) { this.setUpstream({ ...up, behind, ahead, diverged: true }); return this.upstream }
|
|
3163
|
+
}
|
|
3052
3164
|
this.setUpstream({ ...up, behind, ahead, diverged: true }); await this.publishLocal({ up, at, behind, ahead }); return this.upstream
|
|
3053
3165
|
}
|
|
3054
3166
|
await this.bringIn(at, up.sha, { moveRef: true, up: { ...up, behind, ahead }, now })
|
|
@@ -3093,17 +3205,67 @@ export class Session extends EventEmitter {
|
|
|
3093
3205
|
let out
|
|
3094
3206
|
try { out = (await this.conn.adminRequest({ op: 'bundle', bundle: bundle.toString('base64'), head: at, ref: `refs/heads/${branch}` }, { timeoutMs: PUSH_REQUEST_TIMEOUT_MS })).bundle } catch (err) { this.published = null; this.log(`could not hand your local commits to the relay: ${err.message}`); return }
|
|
3095
3207
|
if (!out || !['pushed', 'nothing', 'clash'].includes(out.state)) { this.log(`your local commits on ${branch} stay here for now: ${(out && out.why) || 'the relay could not take them'}`); return }
|
|
3096
|
-
if (out.state === 'clash') this.notice(`Your local commit on ${branch} clashes with GitHub: ${out.why}. Your files are in the session as they are; the clash is merged there, and this folder follows GitHub's branch (your commits are kept under refs/quilt/original/${branch}/).`)
|
|
3097
3208
|
const f = await fetchUpstream(this.root, up.remote, up.url, { token: this.gitTokenNow() })
|
|
3098
3209
|
if (!f.ok) return
|
|
3099
3210
|
const now = await upstreamOf(this.root)
|
|
3100
|
-
if (!now || !now.sha
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3211
|
+
if (!now || !now.sha) return
|
|
3212
|
+
// The folder follows the commit the relay put the session at (its push, or GitHub's head it brought in), never a
|
|
3213
|
+
// newer one of GitHub's the fetch just found: the session has yet to bring that in, and a branch moved there with
|
|
3214
|
+
// git alone would lack its files, so a commit made then would delete them. GitHub's newer commits come in as any do.
|
|
3215
|
+
// (A relay before 0.3.96 says no `head` for 'nothing' and 'clash': GitHub's head then.)
|
|
3216
|
+
const to = out.sha || out.head || now.sha
|
|
3217
|
+
if (to === at) return
|
|
3218
|
+
this.adoptLater = { at, to, count: out.published ? out.published.length : 0 }
|
|
3219
|
+
await this.followPublished(now)
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
/**
|
|
3223
|
+
* The relay put this folder's local commits on GitHub (publishLocal): the branch follows the commit the relay
|
|
3224
|
+
* put the session at (adoptLater.to) once the session's files are on disk (sessionOnDisk), else at a later look.
|
|
3225
|
+
* `now` is the upstream as the folder last fetched it. Resolves whether it moved.
|
|
3226
|
+
*/
|
|
3227
|
+
async followPublished (now) {
|
|
3228
|
+
const later = this.adoptLater
|
|
3229
|
+
const branch = this.git && this.git.branch
|
|
3230
|
+
// Not on GitHub's branch any more (rewritten meanwhile): the commits are handed over again (publishLocal).
|
|
3231
|
+
if (!later || !branch || !now || !now.sha || (now.sha !== later.to && !(await isAncestor(this.root, later.to, now.sha)))) { this.adoptLater = null; this.published = null; return false }
|
|
3232
|
+
if (!(await this.sessionOnDisk(later.at, later.to))) {
|
|
3233
|
+
this.askRelayFor(later.to)
|
|
3234
|
+
this.checkUpstreamSoon({ delay: 500 })
|
|
3235
|
+
return false
|
|
3236
|
+
}
|
|
3237
|
+
this.adoptLater = null
|
|
3238
|
+
const r = await adoptPublished(this.root, branch, later.at, later.to)
|
|
3239
|
+
if (!r.moved) { this.log(`your local commits went to GitHub; ${branch} follows it once ${r.why}`); this.published = null; return false }
|
|
3240
|
+
this.gitSeen = { key: branch, branch, sha: later.to }
|
|
3104
3241
|
this.gitIndex = indexStamp(this.root)
|
|
3105
|
-
this.log(`⬆️ the relay put your local commit${
|
|
3106
|
-
|
|
3242
|
+
this.log(`⬆️ the relay put your local commit${later.count === 1 ? '' : 's'} on ${now.name} (${later.to.slice(0, 7)}); ${branch} follows it (yours are kept under refs/quilt/original/${branch}/)`)
|
|
3243
|
+
const behind = now.sha === later.to ? 0 : ((await commitsBetween(this.root, later.to, now.sha)) ?? 1)
|
|
3244
|
+
this.setUpstream({ ...now, behind, ahead: 0 })
|
|
3245
|
+
if (behind) this.checkUpstreamSoon()
|
|
3246
|
+
return true
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
/** What the folder holds at rel, as lastKnown keys it: null when nothing is there, undefined for a file never synced (a link, too large). */
|
|
3250
|
+
diskKey (rel) {
|
|
3251
|
+
const d = this.readDisk(rel)
|
|
3252
|
+
if (!d) return null
|
|
3253
|
+
return d.skip || d.tooLarge ? undefined : d.key
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
/**
|
|
3257
|
+
* Whether this folder's branch may move from `from` to `to` with git alone, every file left as it is: the
|
|
3258
|
+
* session is at `to` (the relay brought it in, or a member's folder is there), and each file the commits
|
|
3259
|
+
* between them change is on disk as the session has it. A file of GitHub's still on its way here (one
|
|
3260
|
+
* written only once the folder's hold ends) would be missing under an index that has it, and a
|
|
3261
|
+
* `git commit -a` just after the move would take it for a deletion and undo it on GitHub.
|
|
3262
|
+
*/
|
|
3263
|
+
async sessionOnDisk (from, to) {
|
|
3264
|
+
if (!this.memberAt(to) && !(await this.relayPast(to))) return false
|
|
3265
|
+
const changes = await changesBetween(this.root, from, to)
|
|
3266
|
+
if (!changes) return false
|
|
3267
|
+
for (const rel of changes.keys()) if (this.syncable(rel) && (this.diskKey(rel) ?? undefined) !== this.sharedKey(rel)) return false
|
|
3268
|
+
return true
|
|
3107
3269
|
}
|
|
3108
3270
|
|
|
3109
3271
|
/**
|
|
@@ -3173,11 +3335,7 @@ export class Session extends EventEmitter {
|
|
|
3173
3335
|
for (const rel of paths) {
|
|
3174
3336
|
if (this.syncable(rel) && this.writeRefusal(rel)) { state({ waiting: `you may not change ${rel} in this session` }); return }
|
|
3175
3337
|
}
|
|
3176
|
-
const diskKey = (rel) =>
|
|
3177
|
-
const d = this.readDisk(rel)
|
|
3178
|
-
if (!d) return null
|
|
3179
|
-
return d.skip || d.tooLarge ? undefined : d.key
|
|
3180
|
-
}
|
|
3338
|
+
const diskKey = (rel) => this.diskKey(rel)
|
|
3181
3339
|
// The session and this folder must agree first: a change still on its way waits for the next look.
|
|
3182
3340
|
// Following, that is every file the commits changed: the branch moves only once the session's versions are
|
|
3183
3341
|
// on disk, or a commit made meanwhile (git commit -a) would take a file still on its way for a deletion.
|
|
@@ -3202,7 +3360,9 @@ export class Session extends EventEmitter {
|
|
|
3202
3360
|
if (emptied.has(dir) || [...this.sharedPaths()].some((p) => (dir ? p.startsWith(dir + '/') : !p.includes('/')))) continue
|
|
3203
3361
|
if (await hasFilesUnder(this.root, from, dir)) emptied.add(dir)
|
|
3204
3362
|
}
|
|
3205
|
-
|
|
3363
|
+
// Where the session and the commits changed the same lines, the newer edit stays (upstream.js combine).
|
|
3364
|
+
const times = await commitTimes(this.root, from, to)
|
|
3365
|
+
const plan = planCatchUp({ changes: scope, base, theirs, disk: diskKey, moved, emptied, published: publishedOf(this.doc), oursNewer: (rel) => this.sessionEditedAt(rel) > ((times && times.get(rel)) || 0) + 999 }) // a commit's whole second is its own
|
|
3206
3366
|
if (this.stopped || !this.quietForUpstream()) return
|
|
3207
3367
|
if (plan.conflicts.length) {
|
|
3208
3368
|
const behind = up ? up.behind : await commitsBetween(this.root, from, to)
|
|
@@ -3253,6 +3413,14 @@ export class Session extends EventEmitter {
|
|
|
3253
3413
|
if (following) this.log(`⬇️ ${branch} follows ${isRelay(following) ? 'the relay' : `${following}'s folder`} to ${to.slice(0, 7)} (${count ?? 'new'} commit${count === 1 ? '' : 's'} from ${upName}); the session already has ${isRelay(following) ? 'the files it brought in' : 'their files'}`)
|
|
3254
3414
|
const moves = plan.moves.length ? `; followed ${plan.moves.map((m) => `${m.from} → ${m.to}`).join(', ')}` : ''
|
|
3255
3415
|
if (!following) this.log(`⬇️ brought in ${count ?? 'new'} commit${count === 1 ? '' : 's'} from ${upName} (${written} file${written === 1 ? '' : 's'})${moves}`)
|
|
3416
|
+
// Whoever's lines the commits' newer edit replaced is told once (a notice, nothing to do).
|
|
3417
|
+
for (const m of following ? [] : plan.merged || []) {
|
|
3418
|
+
const lost = (m.lost || []).filter((l) => l.side === 'ours').flatMap((l) => l.lines)
|
|
3419
|
+
if (!lost.length) continue
|
|
3420
|
+
const text = `Quilt brought ${upName}'s commits into ${m.path}: where the session and the commits changed the same lines, the commits' newer edit stays. The replaced lines are in the file's history (Changes):\n${lost.slice(0, 12).map((l) => `- ${l}`).join('\n')}`
|
|
3421
|
+
const who = this.lastEditorOf(m.path)
|
|
3422
|
+
if (who && who !== this.name) { try { this.say(text, { to: who }) } catch {} } else this.log(`🧵 ${text}`)
|
|
3423
|
+
}
|
|
3256
3424
|
if (written && !following) await this.notePull({ from: { sha: from }, to: { sha: to, branch }, files: written })
|
|
3257
3425
|
if (plan.strays.length) {
|
|
3258
3426
|
const list = plan.strays.slice(0, 5).join(', ') + (plan.strays.length > 5 ? ', …' : '')
|
|
@@ -4061,7 +4229,6 @@ export class Session extends EventEmitter {
|
|
|
4061
4229
|
|
|
4062
4230
|
if (!disk) {
|
|
4063
4231
|
if (!this.files.has(rel) && !this.blobs.has(rel)) { this.lastKnown.delete(rel); return false }
|
|
4064
|
-
this.closeHandMerge(rel, null) // deleting a file being merged by hand settles it too
|
|
4065
4232
|
this.transact(() => {
|
|
4066
4233
|
const was = this.files.get(rel)
|
|
4067
4234
|
const before = was ? was.toString() : undefined
|
|
@@ -4088,7 +4255,6 @@ export class Session extends EventEmitter {
|
|
|
4088
4255
|
// Storage refused it, but it's small enough to share inside the document.
|
|
4089
4256
|
}
|
|
4090
4257
|
|
|
4091
|
-
if (!disk.binary) this.closeHandMerge(rel, disk.text)
|
|
4092
4258
|
let detail = ''
|
|
4093
4259
|
this.transact(() => {
|
|
4094
4260
|
const existed = this.files.has(rel) || this.blobs.has(rel)
|
|
@@ -5988,42 +6154,124 @@ export class Session extends EventEmitter {
|
|
|
5988
6154
|
})
|
|
5989
6155
|
if (!res.ok) throw new Error(`upload failed: ${await res.text()}`)
|
|
5990
6156
|
const fileId = await res.text()
|
|
5991
|
-
|
|
6157
|
+
const sent = this.say(text, { to, file: { id: fileId, name: path.basename(abs), size: st.size } })
|
|
6158
|
+
// Ours is in the chat files folder too, like everyone's who can see it.
|
|
6159
|
+
try {
|
|
6160
|
+
const msg = this.chat.toArray().find((m) => m.id === sent.id)
|
|
6161
|
+
const kept = this.chatFilePath(msg, { assign: true })
|
|
6162
|
+
fs.copyFileSync(abs, kept)
|
|
6163
|
+
this.chatFileArrived(msg, kept)
|
|
6164
|
+
return { ...sent, file: this.describeMessage(msg).file }
|
|
6165
|
+
} catch (err) {
|
|
6166
|
+
this.log(`couldn't keep ${path.basename(abs)} in ${CHAT_FILES}/: ${err.message}`)
|
|
6167
|
+
return sent
|
|
6168
|
+
}
|
|
5992
6169
|
}
|
|
5993
6170
|
|
|
5994
|
-
/** Downloads a message's attachment (to
|
|
6171
|
+
/** Downloads a message's attachment (to the chat files folder by default). */
|
|
5995
6172
|
async fetchFile (msgOrId, dest) {
|
|
5996
6173
|
const msg = typeof msgOrId === 'string' ? this.chat.toArray().find((m) => validMessage(m) && (m.id === msgOrId || (m.file && m.file.id === msgOrId))) : msgOrId
|
|
5997
6174
|
if (!msg || !msg.file || !this.canSee(msg)) throw new Error('no such file')
|
|
5998
|
-
const
|
|
6175
|
+
const fresh = !dest && !this.chatFileNames()[msg.id]
|
|
6176
|
+
const target = dest ? path.resolve(dest) : this.chatFilePath(msg, { assign: true })
|
|
5999
6177
|
const finalPath = fs.existsSync(target) && fs.statSync(target).isDirectory() ? path.join(target, safeName(msg.file.name)) : target
|
|
6000
6178
|
if (!dest) {
|
|
6001
|
-
// Belt and braces: the
|
|
6002
|
-
const inside = path.relative(path.join(this.
|
|
6003
|
-
if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) throw new Error('refusing to save a file outside the
|
|
6179
|
+
// Belt and braces: the name is sanitised, so this can't leave the folder; never let it anyway.
|
|
6180
|
+
const inside = path.relative(path.join(this.root, CHAT_FILES), finalPath)
|
|
6181
|
+
if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) throw new Error('refusing to save a file outside the chat files folder')
|
|
6182
|
+
}
|
|
6183
|
+
try {
|
|
6184
|
+
const res = await fetch(`${this.httpBase()}/files/${encodeURIComponent(this.room)}/${encodeURIComponent(msg.file.id)}`, {
|
|
6185
|
+
headers: await this.relayHeaders()
|
|
6186
|
+
})
|
|
6187
|
+
if (!res.ok) throw new Error(`download failed: ${await res.text()}`)
|
|
6188
|
+
fs.mkdirSync(path.dirname(finalPath), { recursive: true })
|
|
6189
|
+
fs.writeFileSync(finalPath, Buffer.from(await res.arrayBuffer()))
|
|
6190
|
+
if (!dest) this.chatFileArrived(msg, finalPath)
|
|
6191
|
+
} catch (err) {
|
|
6192
|
+
// Not saved: its name is free again, and the next start tries again.
|
|
6193
|
+
if (fresh) this.forgetChatFile(msg.id)
|
|
6194
|
+
throw err
|
|
6004
6195
|
}
|
|
6005
|
-
const res = await fetch(`${this.httpBase()}/files/${encodeURIComponent(this.room)}/${encodeURIComponent(msg.file.id)}`, {
|
|
6006
|
-
headers: await this.relayHeaders()
|
|
6007
|
-
})
|
|
6008
|
-
if (!res.ok) throw new Error(`download failed: ${await res.text()}`)
|
|
6009
|
-
fs.mkdirSync(path.dirname(finalPath), { recursive: true })
|
|
6010
|
-
fs.writeFileSync(finalPath, Buffer.from(await res.arrayBuffer()))
|
|
6011
6196
|
return finalPath
|
|
6012
6197
|
}
|
|
6013
6198
|
|
|
6014
|
-
/**
|
|
6015
|
-
|
|
6016
|
-
|
|
6017
|
-
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
|
|
6021
|
-
|
|
6199
|
+
/**
|
|
6200
|
+
* Every file in the chat I can see is in my chat files folder: those sent while I was away
|
|
6201
|
+
* or before I joined are downloaded, one at a time; one an older Quilt kept in .quilt/inbox/ is moved.
|
|
6202
|
+
* One saved before and since deleted from the folder stays deleted (opening it in chat fetches it again).
|
|
6203
|
+
*/
|
|
6204
|
+
async fetchMissedFiles () {
|
|
6205
|
+
if (this.fetchingMissed) return
|
|
6206
|
+
this.fetchingMissed = true
|
|
6207
|
+
try {
|
|
6208
|
+
for (const m of this.chat.toArray()) {
|
|
6209
|
+
if (this.stopped) return
|
|
6210
|
+
try {
|
|
6211
|
+
if (!m || !m.file || !this.canSee(m) || this.chatFileNames()[m.id]) continue
|
|
6212
|
+
const old = path.join(this.stateDir, 'inbox', `${m.id.slice(0, 6)}-${safeName(m.file.name)}`)
|
|
6213
|
+
if (fs.existsSync(old)) { const to = this.chatFilePath(m, { assign: true }); fs.renameSync(old, to); this.chatFileArrived(m, to); continue }
|
|
6214
|
+
const dest = await this.fetchFile(m)
|
|
6215
|
+
if (m.by !== this.name) this.log(`📎 received ${m.file.name} from ${m.by} while you were away → ${path.relative(this.root, dest)}`)
|
|
6216
|
+
} catch {} // gone from the relay, or offline: the next start tries again
|
|
6217
|
+
}
|
|
6218
|
+
} finally { this.fetchingMissed = false }
|
|
6219
|
+
}
|
|
6220
|
+
|
|
6221
|
+
/** Names given to chat files in the chat files folder, by message id (kept in .quilt/). */
|
|
6222
|
+
chatFileNames () {
|
|
6223
|
+
if (!this.chatFileIndex) {
|
|
6224
|
+
try { this.chatFileIndex = JSON.parse(fs.readFileSync(path.join(this.stateDir, 'chat-files.json'), 'utf8')) } catch {}
|
|
6225
|
+
if (!this.chatFileIndex || typeof this.chatFileIndex !== 'object') this.chatFileIndex = {}
|
|
6226
|
+
}
|
|
6227
|
+
return this.chatFileIndex
|
|
6228
|
+
}
|
|
6229
|
+
|
|
6230
|
+
/**
|
|
6231
|
+
* Where a message's file is kept: `Chat files/<its name>`, or `<name> (2)` and on when another
|
|
6232
|
+
* file there has that name. With `assign`, a file not given a name yet gets one (and the folder is made).
|
|
6233
|
+
*/
|
|
6234
|
+
chatFilePath (msg, { assign = false } = {}) {
|
|
6235
|
+
const dir = path.join(this.root, CHAT_FILES)
|
|
6236
|
+
const names = this.chatFileNames()
|
|
6237
|
+
// Names come from safeName below; one edited by hand to leave the folder is taken for none.
|
|
6238
|
+
let name = typeof names[msg.id] === 'string' && names[msg.id] === path.basename(names[msg.id]) && !names[msg.id].startsWith('.') ? names[msg.id] : null
|
|
6239
|
+
if (!name) {
|
|
6240
|
+
if (!assign) return null
|
|
6241
|
+
const taken = new Set(Object.values(names))
|
|
6242
|
+
const want = safeName(msg.file.name)
|
|
6243
|
+
const ext = path.extname(want)
|
|
6244
|
+
const stem = want.slice(0, want.length - ext.length) || want
|
|
6245
|
+
name = want
|
|
6246
|
+
for (let n = 2; taken.has(name) || fs.existsSync(path.join(dir, name)); n++) name = `${stem} (${n})${ext}`
|
|
6247
|
+
names[msg.id] = name
|
|
6248
|
+
writePrivateJson(path.join(this.stateDir, 'chat-files.json'), names)
|
|
6249
|
+
}
|
|
6250
|
+
if (assign) {
|
|
6251
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
6252
|
+
// Git leaves the folder alone (Quilt never syncs it: pathrules.js), with nothing added to the project's .gitignore.
|
|
6253
|
+
const gi = path.join(dir, '.gitignore')
|
|
6254
|
+
if (!fs.existsSync(gi)) fs.writeFileSync(gi, '# Files sent in the Quilt session\'s chat: only for the people who can see them.\n*\n')
|
|
6022
6255
|
}
|
|
6256
|
+
return path.join(dir, name)
|
|
6257
|
+
}
|
|
6258
|
+
|
|
6259
|
+
/** The app's file tree shows the chat files folder: a file saved there is a change to it. */
|
|
6260
|
+
chatFileArrived (msg, file) {
|
|
6261
|
+
this.emit('file-changed', { path: `${CHAT_FILES}/${path.basename(file)}`, by: msg.by, chat: true })
|
|
6262
|
+
}
|
|
6263
|
+
|
|
6264
|
+
forgetChatFile (id) {
|
|
6265
|
+
const names = this.chatFileNames()
|
|
6266
|
+
if (!(id in names)) return
|
|
6267
|
+
delete names[id]
|
|
6268
|
+
try { writePrivateJson(path.join(this.stateDir, 'chat-files.json'), names) } catch {}
|
|
6023
6269
|
}
|
|
6024
6270
|
|
|
6025
|
-
|
|
6026
|
-
|
|
6271
|
+
/** The message's file in the chat files folder, when it is there. */
|
|
6272
|
+
chatFileSaved (msg) {
|
|
6273
|
+
const p = this.chatFilePath(msg)
|
|
6274
|
+
return p && fs.existsSync(p) ? p : null
|
|
6027
6275
|
}
|
|
6028
6276
|
|
|
6029
6277
|
/**
|
|
@@ -6065,8 +6313,8 @@ export class Session extends EventEmitter {
|
|
|
6065
6313
|
describeMessage (msg) {
|
|
6066
6314
|
const out = { ...msg, unread: msg.by !== this.name && !this.readIds().has(msg.id) }
|
|
6067
6315
|
if (msg.file && validMessage(msg)) {
|
|
6068
|
-
const local = this.
|
|
6069
|
-
out.file = { ...msg.file, localPath:
|
|
6316
|
+
const local = this.chatFileSaved(msg)
|
|
6317
|
+
out.file = { ...msg.file, localPath: local ? path.relative(this.root, local) : null }
|
|
6070
6318
|
}
|
|
6071
6319
|
return out
|
|
6072
6320
|
}
|
|
@@ -6647,7 +6895,8 @@ export class Session extends EventEmitter {
|
|
|
6647
6895
|
settleReleasedMerges () {
|
|
6648
6896
|
for (const rec of this.mergeList()) {
|
|
6649
6897
|
if (rec.kind !== 'claimed' || rec.state !== 'open' || rec.by !== this.name || rec.binary || rec.oursDeleted || rec.theirsHash === null) continue
|
|
6650
|
-
|
|
6898
|
+
// A record anyone can write names any path: never one the session doesn't sync (.env, .git, ignored files).
|
|
6899
|
+
if (!this.syncable(rec.path) || this.mergeHeldBy(rec) || this.writeRefusal(rec.path) || this.merging.has(rec.path)) continue
|
|
6651
6900
|
const theirs = this.sharedKey(rec.path)
|
|
6652
6901
|
if (typeof theirs !== 'string' || theirs.startsWith('bin:')) continue
|
|
6653
6902
|
const tried = `${rec.id}:${sha1(theirs)}`
|
|
@@ -6658,10 +6907,13 @@ export class Session extends EventEmitter {
|
|
|
6658
6907
|
const { ours, base } = this.mergeTexts(rec)
|
|
6659
6908
|
if (typeof ours !== 'string') continue
|
|
6660
6909
|
const holder = rec.claimedBy || rec.others[0] || 'someone'
|
|
6661
|
-
if (theirs !== (base ?? '')) { this.log(`⚠️ ${holder} let go of ${rec.path}, and it changed meanwhile: see Merges`); continue }
|
|
6662
6910
|
try {
|
|
6663
|
-
|
|
6664
|
-
|
|
6911
|
+
// Changed meanwhile: combined by Quilt (autoSettle's rule), the newer edit kept where both changed the same lines.
|
|
6912
|
+
const oursNewer = rec.ts >= this.sessionEditedAt(rec.path)
|
|
6913
|
+
const r = theirs === (base ?? '') ? { text: ours, lost: [] } : mergeAuto(base ?? '', ours, theirs, { oursNewer })
|
|
6914
|
+
this.applyMerged(rec.path, r.text, `once ${holder} let go of it`)
|
|
6915
|
+
updateMerge(this.bdoc, this.merges, rec.id, { state: 'done', how: r.lost.length && !oursNewer ? 'theirs' : 'mine', resolvedBy: 'Quilt', reason: r.lost.length ? `Went in once ${holder} let go of it, merged by Quilt: the newer edit kept where both changed the same lines` : `Went in once ${holder} let go of it` }, LOCAL)
|
|
6916
|
+
this.noteLostLines(rec.path, { by: holder, lost: r.lost, oursWon: oursNewer })
|
|
6665
6917
|
} catch (err) {
|
|
6666
6918
|
this.log(`could not combine ${rec.path}: ${err.message}`)
|
|
6667
6919
|
continue
|
|
@@ -6715,204 +6967,18 @@ export class Session extends EventEmitter {
|
|
|
6715
6967
|
return { ours, base, theirs }
|
|
6716
6968
|
}
|
|
6717
6969
|
|
|
6718
|
-
/**
|
|
6719
|
-
* Settles a merge: `mine` writes the returning person's version, `theirs`
|
|
6720
|
-
* keeps the session's, `hand` puts conflict markers in the file for someone
|
|
6721
|
-
* to edit (the record closes when the file next syncs without them),
|
|
6722
|
-
* `agent` and `review` just close it (the file is already as wanted).
|
|
6723
|
-
*/
|
|
6724
|
-
resolveMerge (id, { how } = {}) {
|
|
6725
|
-
const rec = this.mergeList().find((m) => m.id === id)
|
|
6726
|
-
if (!rec) throw new Error('no such merge')
|
|
6727
|
-
if (rec.state === 'done') throw new Error('that merge is already settled')
|
|
6728
|
-
if (!['mine', 'theirs', 'hand', 'agent', 'review'].includes(how)) throw new Error('say how: mine, theirs, hand, agent or review')
|
|
6729
|
-
// A record anyone can write names any path: never touch one the session doesn't sync (.env, .git, ignored files).
|
|
6730
|
-
if (!this.syncable(rec.path) && how !== 'agent' && how !== 'review') throw new Error(`${rec.path} is not synced in this session, so Quilt will not write it`)
|
|
6731
|
-
// The doc holds the markers now, so "theirs" would be marker text.
|
|
6732
|
-
if (rec.state === 'editing' && (how === 'theirs' || how === 'hand')) throw new Error(STILL_MARKED)
|
|
6733
|
-
// Settling is an edit of the session: viewers (and agents outside their folders) only see the record.
|
|
6734
|
-
const refusal = this.writeRefusal(rec.path)
|
|
6735
|
-
if (refusal) throw new Error(refusal)
|
|
6736
|
-
if (this.branchLive() && how === 'hand') throw new Error('This branch syncs with GitHub: conflict markers never go in the shared file. Propose the merged file with quilt_clash, or keep one side.')
|
|
6737
|
-
if (this.branchLive() && this.kind === 'agent' && (how === 'mine' || how === 'agent')) throw new Error('This branch syncs with GitHub: the clash is held, and agents merge it with quilt_clash (propose; the reviewer accepts).')
|
|
6738
|
-
// Someone else holds the file (settling it, or their AI is): only keeping the session's version is left, so nobody settles one merge twice.
|
|
6739
|
-
if (how !== 'theirs') this.mergeHeldCheck(rec)
|
|
6740
|
-
const onlyThere = () => new Error(`${rec.by === this.name ? 'your' : `${rec.by}'s`} version of ${rec.path} is only in the merge folder on ${rec.by === this.name ? 'the computer you merged on' : 'their computer'}`)
|
|
6741
|
-
const { ours, base, theirs } = this.mergeTexts(rec)
|
|
6742
|
-
if (how === 'mine') {
|
|
6743
|
-
let data = null // null: deleted while away, so keeping mine deletes it in the session too
|
|
6744
|
-
if (!rec.oursDeleted) {
|
|
6745
|
-
// Binary versions never go in the record: only the opener's machine has ours.
|
|
6746
|
-
data = ours
|
|
6747
|
-
if (rec.binary) {
|
|
6748
|
-
try { data = fs.readFileSync(path.join(this.mergeDir(rec.id), 'ours')) } catch {}
|
|
6749
|
-
}
|
|
6750
|
-
if (data === null) throw onlyThere()
|
|
6751
|
-
}
|
|
6752
|
-
this.keepMine(rec.path, data)
|
|
6753
|
-
} else if (how === 'theirs') {
|
|
6754
|
-
this.tryWrite(rec.path)
|
|
6755
|
-
} else if (how === 'hand') {
|
|
6756
|
-
if (!rec.binary && !rec.oursDeleted && ours === null) throw onlyThere()
|
|
6757
|
-
if (rec.binary || ours === null || theirs === null) throw new Error('markers only work when both sides have a text version: keep mine or keep theirs instead')
|
|
6758
|
-
this.applyMerged(rec.path, withMarkers(base || '', ours, theirs, { mine: rec.by, theirs: rec.others[0] || 'session' }), 'with conflict markers to edit by hand')
|
|
6759
|
-
return updateMerge(this.bdoc, this.merges, id, { state: 'editing', how: 'hand', resolvedBy: this.name }, LOCAL)
|
|
6760
|
-
}
|
|
6761
|
-
const out = updateMerge(this.bdoc, this.merges, id, { state: 'done', how, resolvedBy: this.name }, LOCAL)
|
|
6762
|
-
this.log(`✅ ${rec.path}: merge settled (${how === 'mine' ? `${rec.by}'s version` : how === 'theirs' ? "the session's version" : how === 'agent' ? 'merged by an AI' : 'reviewed'})`)
|
|
6763
|
-
this.scheduleStatusWrite()
|
|
6764
|
-
return out
|
|
6765
|
-
}
|
|
6766
|
-
|
|
6767
|
-
/**
|
|
6768
|
-
* Takes a merge's file for us before settling it or sending it to an AI, so two people
|
|
6769
|
-
* can't settle one merge at once: the relay hands a claim to one of them and refuses the
|
|
6770
|
-
* other, who then sees it claimed and can ask for it. Already ours: nothing to take.
|
|
6771
|
-
* Returns whether a claim was made here, to let go of once the merge is settled.
|
|
6772
|
-
*/
|
|
6773
|
-
async takeMerge (rec) {
|
|
6774
|
-
if (!this.conn || this.mergeHeldBy(rec)) return false // offline, or someone else's: the checks after say so
|
|
6775
|
-
const claim = this.claimFor(rec.path)
|
|
6776
|
-
if (claim && this.ownClaim(claim)) return false
|
|
6777
|
-
try {
|
|
6778
|
-
await this.claim(rec.path, `${MERGE_CLAIM_NOTE} ${rec.path}`)
|
|
6779
|
-
} catch (err) {
|
|
6780
|
-
const by = /claimed by (.+)$/.exec(err.message)?.[1] || /overlaps (.+?)'s claim/.exec(err.message)?.[1]
|
|
6781
|
-
throw new Error(by ? `${by} is settling ${rec.path} right now. Ask ${by} for it, or keep ${by}'s version once they're done.` : err.message)
|
|
6782
|
-
}
|
|
6783
|
-
this.mergeClaims.add(rec.path)
|
|
6784
|
-
return true
|
|
6785
|
-
}
|
|
6786
|
-
|
|
6787
|
-
/**
|
|
6788
|
-
* Lets go of the claim a merge took (takeMerge) once it is settled; known by its note too,
|
|
6789
|
-
* so one from before a restart goes as well. Someone waiting for it is handed it by its holder instead.
|
|
6790
|
-
*/
|
|
6791
|
-
async releaseMergeClaim (rel) {
|
|
6792
|
-
const took = this.mergeClaims.delete(rel)
|
|
6793
|
-
const c = this.claims.get(rel)
|
|
6794
|
-
if (!c || !this.ownClaim(c) || (c.queue && c.queue.length)) return
|
|
6795
|
-
if (!took && !String(c.note || '').startsWith(MERGE_CLAIM_NOTE)) return
|
|
6796
|
-
await this.release(rel).catch(() => {})
|
|
6797
|
-
}
|
|
6798
|
-
|
|
6799
|
-
/** resolveMerge with the file taken first; the claim stays while markers are in it, and goes once it is settled. */
|
|
6800
|
-
async settleMerge (id, { how } = {}) {
|
|
6801
|
-
const rec = this.mergeList().find((m) => m.id === id)
|
|
6802
|
-
if (!rec) throw new Error('no such merge')
|
|
6803
|
-
const writes = how === 'mine' || how === 'theirs' || how === 'hand'
|
|
6804
|
-
const took = writes && rec.state !== 'done' ? await this.takeMerge(rec) : false
|
|
6805
|
-
let out
|
|
6806
|
-
try {
|
|
6807
|
-
out = this.resolveMerge(id, { how })
|
|
6808
|
-
} catch (err) {
|
|
6809
|
-
if (took) await this.releaseMergeClaim(rec.path)
|
|
6810
|
-
throw err
|
|
6811
|
-
}
|
|
6812
|
-
if (out?.state === 'done') await this.releaseMergeClaim(rec.path)
|
|
6813
|
-
return out
|
|
6814
|
-
}
|
|
6815
|
-
|
|
6816
|
-
/** prepareMergeSend with the file taken first: it stays ours while our AI merges it, until the merge is settled. */
|
|
6817
|
-
async sendMerge (id) {
|
|
6818
|
-
const rec = this.mergeList().find((m) => m.id === id)
|
|
6819
|
-
if (!rec) throw new Error('no such merge')
|
|
6820
|
-
const took = rec.state === 'open' ? await this.takeMerge(rec) : false
|
|
6821
|
-
try {
|
|
6822
|
-
return this.prepareMergeSend(id)
|
|
6823
|
-
} catch (err) {
|
|
6824
|
-
if (took) await this.releaseMergeClaim(rec.path)
|
|
6825
|
-
throw err
|
|
6826
|
-
}
|
|
6827
|
-
}
|
|
6828
|
-
|
|
6829
|
-
/**
|
|
6830
|
-
* Keep mine: the disk first, then the doc, so a failed write never leaves
|
|
6831
|
-
* the session with ours while the record stays open. `data` null deletes.
|
|
6832
|
-
*/
|
|
6833
|
-
keepMine (rel, data) {
|
|
6834
|
-
if (this.merging.has(rel) || this.downloading.has(rel)) throw new Error(`${rel} is still syncing; try again in a moment`)
|
|
6835
|
-
const abs = resolveInside(this.root, rel)
|
|
6836
|
-
if (data === null) {
|
|
6837
|
-
fs.rmSync(abs, { force: true })
|
|
6838
|
-
removeEmptyParents(this.root, path.dirname(abs))
|
|
6839
|
-
} else {
|
|
6840
|
-
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
6841
|
-
this.writeFile(rel, abs, data)
|
|
6842
|
-
}
|
|
6843
|
-
this.writeFailed.delete(rel) // the disk now holds a version we chose, not a stale one
|
|
6844
|
-
if (this.ingest(rel)) return
|
|
6845
|
-
// Nothing pushed: fine if the session already has it, or a large file is on its way up.
|
|
6846
|
-
const want = data === null ? undefined : Buffer.isBuffer(data) ? `bin:${sha1(data)}` : data
|
|
6847
|
-
if (this.sharedKey(rel) === want || (Buffer.isBuffer(data) && this.uploading.get(rel) === sha1(data))) return
|
|
6848
|
-
throw new Error(`could not share ${rel}; the merge stays open`)
|
|
6849
|
-
}
|
|
6850
|
-
|
|
6851
6970
|
/** Who else holds a merge's file right now, or null: settling it (or sending it to an AI) writes the file. */
|
|
6852
6971
|
mergeHeldBy (rec) {
|
|
6853
6972
|
const claim = this.claimFor(rec.path)
|
|
6854
6973
|
return claim && !this.ownClaim(claim) ? claim : null
|
|
6855
6974
|
}
|
|
6856
6975
|
|
|
6857
|
-
mergeHeldCheck (rec) {
|
|
6858
|
-
const claim = this.mergeHeldBy(rec)
|
|
6859
|
-
if (claim) throw new Error(`${rec.path} is claimed by ${claim.by}${claim.note ? ` (${claim.note})` : ''}. Ask ${claim.by} for it to merge the two, or keep ${claim.by}'s version.`)
|
|
6860
|
-
}
|
|
6861
|
-
|
|
6862
6976
|
/** A merge as status shows it: without its texts, with who holds the file and whether we asked for it. */
|
|
6863
6977
|
mergeStatus ({ ours, base, ...m }) {
|
|
6864
6978
|
const claim = m.state === 'done' ? null : this.mergeHeldBy(m)
|
|
6865
6979
|
return claim ? { ...m, heldBy: claim.by, asked: (claim.queue || []).some((r) => r.by === this.name) } : m
|
|
6866
6980
|
}
|
|
6867
6981
|
|
|
6868
|
-
/** A file being edited by hand lost its markers: that merge is settled. */
|
|
6869
|
-
closeHandMerge (rel, text) {
|
|
6870
|
-
const rec = this.mergeList().find((m) => m.path === rel && m.state === 'editing')
|
|
6871
|
-
if (!rec || hasMarkers(text)) return
|
|
6872
|
-
updateMerge(this.bdoc, this.merges, rec.id, { state: 'done', how: 'hand', resolvedBy: this.name }, LOCAL)
|
|
6873
|
-
this.log(`✅ ${rel}: merged by hand`)
|
|
6874
|
-
this.releaseMergeClaim(rel).catch(() => {})
|
|
6875
|
-
}
|
|
6876
|
-
|
|
6877
|
-
/** What a coding tool is asked to do for Send to…: names the three files, the path and the merge id. */
|
|
6878
|
-
mergePromptFor (rec) {
|
|
6879
|
-
const dir = path.relative(this.root, this.mergeDir(rec.id)).split(path.sep).join('/')
|
|
6880
|
-
const other = rec.others[0] || 'someone in the session'
|
|
6881
|
-
return `Merge conflict in \`${rec.path}\` (Quilt merge ${rec.id}).
|
|
6882
|
-
|
|
6883
|
-
${rec.by} changed this file while away from the session; meanwhile ${other} changed it in the session. Quilt could not combine the two on its own. Please merge them:
|
|
6884
|
-
|
|
6885
|
-
- \`${dir}/base\`: the version both started from
|
|
6886
|
-
- \`${dir}/ours\`: ${rec.by}'s version (offline)${rec.oursDeleted ? ' — deleted' : ''}
|
|
6887
|
-
- \`${dir}/theirs\`: the session's version (${other})${rec.theirsHash === null ? ' — deleted' : ''}
|
|
6888
|
-
- \`${rec.path}\`: currently the session's version
|
|
6889
|
-
|
|
6890
|
-
Write the merged result to \`${rec.path}\`, keeping every change from both sides and changing no behaviour. If the two really cannot both be true, say so and ask ${rec.by} and ${other} in the chat (quilt_message) rather than picking one.
|
|
6891
|
-
When the file is right, call the \`quilt_resolve_merge\` tool with id \`${rec.id}\` and how \`agent\` (or tell the person to click Resolved in Quilt).
|
|
6892
|
-
`
|
|
6893
|
-
}
|
|
6894
|
-
|
|
6895
|
-
/** Writes the three versions and PROMPT.md for a tool to work from; returns the prompt. */
|
|
6896
|
-
prepareMergeSend (id) {
|
|
6897
|
-
const rec = this.mergeList().find((m) => m.id === id)
|
|
6898
|
-
if (!rec) throw new Error('no such merge')
|
|
6899
|
-
if (rec.state === 'editing') throw new Error(STILL_MARKED)
|
|
6900
|
-
// The prompt asks a tool to write rec.path: never one the session doesn't sync.
|
|
6901
|
-
if (!this.syncable(rec.path)) throw new Error(`${rec.path} is not synced in this session, so Quilt will not send it`)
|
|
6902
|
-
this.mergeHeldCheck(rec)
|
|
6903
|
-
const dir = this.mergeDir(id)
|
|
6904
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
6905
|
-
const { ours, base, theirs } = this.mergeTexts(rec)
|
|
6906
|
-
// base and ours never change; theirs is rewritten, as the session may have moved on.
|
|
6907
|
-
const put = (name, text) => { if (text !== null && !fs.existsSync(path.join(dir, name))) fs.writeFileSync(path.join(dir, name), text) }
|
|
6908
|
-
put('base', base)
|
|
6909
|
-
put('ours', ours)
|
|
6910
|
-
if (theirs !== null) fs.writeFileSync(path.join(dir, 'theirs'), theirs)
|
|
6911
|
-
const prompt = this.mergePromptFor(rec)
|
|
6912
|
-
fs.writeFileSync(path.join(dir, 'PROMPT.md'), prompt)
|
|
6913
|
-
return { prompt, dir }
|
|
6914
|
-
}
|
|
6915
|
-
|
|
6916
6982
|
// ------------------------------------------------------------ AI feed --
|
|
6917
6983
|
|
|
6918
6984
|
/**
|
|
@@ -7131,6 +7197,7 @@ When the file is right, call the \`quilt_resolve_merge\` tool with id \`${rec.id
|
|
|
7131
7197
|
const blob = this.blobs.get(p)
|
|
7132
7198
|
out.push({ path: p, binary: !!blob && !this.files.has(p), edited: edited.get(p) || null, claim: claimFor(p), github: fromGithub(p) })
|
|
7133
7199
|
}
|
|
7200
|
+
out.push(...this.chatFileEntries())
|
|
7134
7201
|
out.sort((a, b) => a.path.localeCompare(b.path))
|
|
7135
7202
|
return {
|
|
7136
7203
|
files: out,
|
|
@@ -7139,10 +7206,28 @@ When the file is right, call the \`quilt_resolve_merge\` tool with id \`${rec.id
|
|
|
7139
7206
|
}
|
|
7140
7207
|
}
|
|
7141
7208
|
|
|
7142
|
-
/**
|
|
7209
|
+
/** The chat files folder, for the file tree: each file I can see that is saved there, with who sent it. */
|
|
7210
|
+
chatFileEntries () {
|
|
7211
|
+
const out = []
|
|
7212
|
+
for (const m of this.chat.toArray()) {
|
|
7213
|
+
try {
|
|
7214
|
+
const p = m && m.file && this.canSee(m) ? this.chatFileSaved(m) : null
|
|
7215
|
+
if (p) out.push({ path: `${CHAT_FILES}/${path.basename(p)}`, chat: { id: m.id, by: m.by, ts: m.ts, ...(m.to ? { to: m.to } : {}) } })
|
|
7216
|
+
} catch {}
|
|
7217
|
+
}
|
|
7218
|
+
return out
|
|
7219
|
+
}
|
|
7220
|
+
|
|
7221
|
+
/** Contents of one shared file, straight from the shared doc (never from disk); a chat file from the chat files folder. */
|
|
7143
7222
|
readShared (rel) {
|
|
7144
7223
|
rel = String(rel || '').replace(/^\.\//, '')
|
|
7145
7224
|
if (!isSafeRelPath(rel)) return null
|
|
7225
|
+
if (rel.startsWith(`${CHAT_FILES}/`)) {
|
|
7226
|
+
const e = this.chatFileEntries().find((x) => x.path === rel)
|
|
7227
|
+
if (!e) return null
|
|
7228
|
+
const buf = fs.readFileSync(path.join(this.root, rel))
|
|
7229
|
+
return buf.length <= MAX_TEXT_BYTES && !looksBinary(buf) ? { path: rel, text: buf.toString('utf8'), chat: e.chat } : { path: rel, binary: true, size: buf.length, chat: e.chat }
|
|
7230
|
+
}
|
|
7146
7231
|
const t = this.files.get(rel)
|
|
7147
7232
|
if (t) return { path: rel, text: t.toString() }
|
|
7148
7233
|
const b = this.blobs.get(rel)
|
|
@@ -7275,7 +7360,7 @@ When the file is right, call the \`quilt_resolve_merge\` tool with id \`${rec.id
|
|
|
7275
7360
|
}
|
|
7276
7361
|
this.stopped = true
|
|
7277
7362
|
clearInterval(this.retryTimer)
|
|
7278
|
-
if (this.watcher) await this.watcher
|
|
7363
|
+
if (this.watcher) await closeWatcher(this.watcher)
|
|
7279
7364
|
for (const t of this.rechecks.values()) clearTimeout(t)
|
|
7280
7365
|
this.rechecks.clear()
|
|
7281
7366
|
clearInterval(this.reconcileTimer)
|