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.
Files changed (129) hide show
  1. package/RELEASES.md +292 -17
  2. package/bin/quilt.js +72 -5
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -1
  5. package/src/agent-task-workflow.js +2 -2
  6. package/src/agents/common.js +13 -0
  7. package/src/ai-vm.js +9 -1
  8. package/src/api/activity.js +5 -0
  9. package/src/api/memory-store.js +21 -10
  10. package/src/api/notify.js +134 -0
  11. package/src/api/routes/browsers.js +29 -0
  12. package/src/api/routes/github.js +0 -13
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/seam-connect.js +103 -0
  15. package/src/api/routes/session-migrate.js +39 -0
  16. package/src/api/routes/sessions.js +73 -1
  17. package/src/api/server.js +46 -12
  18. package/src/api/supabase-store.js +53 -4
  19. package/src/autowork.js +182 -16
  20. package/src/connection.js +39 -9
  21. package/src/control.js +139 -13
  22. package/src/force-date.js +27 -0
  23. package/src/fsutil.js +13 -3
  24. package/src/gitstate.js +38 -2
  25. package/src/history.js +25 -1
  26. package/src/mcp.js +93 -7
  27. package/src/merge3.js +11 -3
  28. package/src/now-summary.js +113 -0
  29. package/src/pathrules.js +12 -0
  30. package/src/protocol.js +10 -2
  31. package/src/quilt-default.js +84 -0
  32. package/src/quilt-history.js +106 -0
  33. package/src/quilt-remote.js +78 -0
  34. package/src/relay-commit.js +5 -0
  35. package/src/relay-lease.js +6 -2
  36. package/src/relay-mcp.js +172 -8
  37. package/src/relay-outbound.js +1 -1
  38. package/src/relay-prune.js +4 -1
  39. package/src/release-words.js +2 -2
  40. package/src/runner.js +20 -7
  41. package/src/seam-access.js +97 -0
  42. package/src/seam-claims.js +53 -0
  43. package/src/seam-url.js +15 -0
  44. package/src/server.js +1767 -33
  45. package/src/session-follow.js +515 -0
  46. package/src/session-layout3.js +466 -0
  47. package/src/session-migrate.js +123 -0
  48. package/src/session-quilt.js +145 -0
  49. package/src/session-seam.js +284 -0
  50. package/src/session.js +899 -46
  51. package/src/source-live/bring-in.js +283 -0
  52. package/src/source-live/live-block.js +507 -35
  53. package/src/source-live/live-square.js +17 -6
  54. package/src/status.js +5 -0
  55. package/src/ui/agent-guide.js +1 -1
  56. package/src/ui/app.css +85 -20
  57. package/src/ui/app.js +4 -1
  58. package/src/ui/chat.js +9 -3
  59. package/src/ui/cloud-ai-model.js +13 -0
  60. package/src/ui/cloud-ai.js +10 -2
  61. package/src/ui/common.js +6 -2
  62. package/src/ui/fileview.js +20 -7
  63. package/src/ui/force-words.js +19 -0
  64. package/src/ui/git-rule.js +9 -0
  65. package/src/ui/home.js +0 -147
  66. package/src/ui/loom-model.js +15 -1
  67. package/src/ui/quiltview.js +213 -0
  68. package/src/ui/seam-model.js +251 -0
  69. package/src/ui/session.js +413 -31
  70. package/src/ui/voice-call.js +83 -14
  71. package/src/ui/voice-model.js +39 -0
  72. package/src/ui/voice.js +167 -17
  73. package/src/ui-server.js +80 -28
  74. package/src/update-check.js +25 -1
  75. package/src/voice.js +13 -6
  76. package/src/git.js +0 -147
  77. package/src/quilts/AGENTS.md +0 -1
  78. package/src/quilts/CLAUDE.md +0 -1
  79. package/src/quilts/archiver.js +0 -175
  80. package/src/quilts/autorevert.js +0 -49
  81. package/src/quilts/bisect-run.js +0 -31
  82. package/src/quilts/blame.js +0 -103
  83. package/src/quilts/block.js +0 -347
  84. package/src/quilts/bucket.js +0 -25
  85. package/src/quilts/cbor.js +0 -92
  86. package/src/quilts/claims.js +0 -144
  87. package/src/quilts/cli.js +0 -449
  88. package/src/quilts/client.js +0 -128
  89. package/src/quilts/conflict-format.js +0 -190
  90. package/src/quilts/describe.js +0 -266
  91. package/src/quilts/dogfood.js +0 -82
  92. package/src/quilts/errors.js +0 -8
  93. package/src/quilts/events.js +0 -160
  94. package/src/quilts/export-git.js +0 -130
  95. package/src/quilts/export.js +0 -81
  96. package/src/quilts/fastmerge.js +0 -41
  97. package/src/quilts/hash.js +0 -44
  98. package/src/quilts/history.js +0 -266
  99. package/src/quilts/ids.js +0 -12
  100. package/src/quilts/import-git.js +0 -291
  101. package/src/quilts/import.js +0 -134
  102. package/src/quilts/index.js +0 -72
  103. package/src/quilts/limits.js +0 -67
  104. package/src/quilts/lines.js +0 -49
  105. package/src/quilts/live.js +0 -50
  106. package/src/quilts/merge-smart.js +0 -114
  107. package/src/quilts/merge.js +0 -486
  108. package/src/quilts/objects.js +0 -144
  109. package/src/quilts/pack.js +0 -118
  110. package/src/quilts/records.js +0 -211
  111. package/src/quilts/resolutions.js +0 -69
  112. package/src/quilts/reviews.js +0 -195
  113. package/src/quilts/routes.js +0 -126
  114. package/src/quilts/service.js +0 -923
  115. package/src/quilts/setup.js +0 -34
  116. package/src/quilts/signing.js +0 -149
  117. package/src/quilts/squares.js +0 -239
  118. package/src/quilts/store-memory.js +0 -683
  119. package/src/quilts/store-rpc.js +0 -79
  120. package/src/quilts/store-supabase.js +0 -13
  121. package/src/quilts/tags.js +0 -64
  122. package/src/quilts/textdiff.js +0 -96
  123. package/src/quilts/tokens.js +0 -66
  124. package/src/quilts/tools.js +0 -375
  125. package/src/quilts/tree.js +0 -351
  126. package/src/quilts/webhook.js +0 -116
  127. package/src/quilts/window.js +0 -119
  128. package/src/quilts/xdiff.js +0 -435
  129. package/src/ui/branches.js +0 -111
package/src/ui/home.js CHANGED
@@ -316,10 +316,6 @@ export function newSessionDialog (workspace = '') {
316
316
  const { form, close } = dialog(`
317
317
  <h3>New session${workspace ? ` in ${esc(state.workspaces?.find((w) => w.id === workspace)?.name || 'this workspace')}` : ''}</h3>
318
318
  <p class="lead">Pick what you want to work on together. You'll get invites to send once it starts.</p>
319
- <div class="segmented src-switch" role="tablist" aria-label="Start from">
320
- <button type="button" role="tab" data-src="folder" class="on" aria-selected="true">${I.folder}<span>Folder</span></button>
321
- <button type="button" role="tab" data-src="github" aria-selected="false">${I.branch}<span>GitHub</span></button>
322
- </div>
323
319
  <div id="src-folder">
324
320
  <div class="field">
325
321
  <label for="n-dir">Project folder</label>
@@ -327,33 +323,11 @@ export function newSessionDialog (workspace = '') {
327
323
  <button type="button" class="btn icon" id="n-browse" title="Browse" aria-label="Browse">${I.folder}</button></div>
328
324
  </div>
329
325
  </div>
330
- <div id="src-github" hidden></div>
331
326
  <p class="error" id="n-error"></p>
332
327
  <div class="actions"><button type="button" class="btn ghost" data-cancel>Cancel</button><button class="btn primary" type="submit">Start session</button></div>`)
333
- let src = 'folder'
334
- const gh = githubPicker(form.querySelector('#src-github'))
335
- form.querySelectorAll('[data-src]').forEach((b) => {
336
- b.onclick = () => {
337
- src = b.dataset.src
338
- form.querySelectorAll('[data-src]').forEach((x) => { x.classList.toggle('on', x === b); x.setAttribute('aria-selected', String(x === b)) })
339
- form.querySelector('#src-folder').hidden = src !== 'folder'
340
- form.querySelector('#src-github').hidden = src !== 'github'
341
- form.querySelector('#n-dir').required = src === 'folder'
342
- form.querySelector('button[type=submit]').textContent = src === 'github' ? 'Clone and start' : 'Start session'
343
- form.querySelector('#n-error').textContent = ''
344
- if (src === 'github') gh.open()
345
- else form.querySelector('#n-dir').focus()
346
- }
347
- })
348
328
  form.querySelector('#n-browse').onclick = () => pickFolder(form.querySelector('#n-dir'))
349
329
  form.onsubmit = async (e) => {
350
330
  e.preventDefault()
351
- if (src === 'github') {
352
- let body
353
- try { body = gh.value() } catch (err) { form.querySelector('#n-error').textContent = err.message; return }
354
- if (await submit(form, '#n-error', { mode: 'github', ...body, workspace }, 'Cloning…')) close()
355
- return
356
- }
357
331
  const dir = form.querySelector('#n-dir').value
358
332
  state.lastCreateDir = dir
359
333
  if (await submit(form, '#n-error', { mode: 'create', dir, workspace })) close()
@@ -547,127 +521,6 @@ function workspaceInviteDialog (id) {
547
521
  return close
548
522
  }
549
523
 
550
- /** The GitHub side of the New session dialog: repo, branch, and where to clone it. */
551
- function githubPicker (root) {
552
- let repos = null
553
- let repo = null // { name, defaultBranch }
554
- let branches = null // { branches, defaultBranch }
555
- let loaded = false
556
-
557
- async function open () {
558
- if (loaded) return root.querySelector('#gh-search')?.focus()
559
- loaded = true
560
- root.innerHTML = `<p class="hint gh-wait">${loadingNote('Checking GitHub…')}</p>`
561
- let st
562
- try { st = await api('GET', '/api/github/status') } catch (err) { st = { authenticated: false, message: err.message } }
563
- if (!st.authenticated) {
564
- loaded = false
565
- root.innerHTML = `<div class="note gh-setup">${I.branch}<span>Quilt can't list your GitHub repositories on this computer. Start from a folder instead: a clone of the repository works the same, and once anyone connects GitHub for the session, git is on for everyone in it.</span></div>`
566
- return
567
- }
568
- root.innerHTML = `
569
- <div class="field">
570
- <label for="gh-search">Repository${st.user ? ` <span class="hint">· signed in as ${esc(st.user)}</span>` : ''}</label>
571
- <input class="input" id="gh-search" placeholder="Search your repositories" spellcheck="false" autocomplete="off">
572
- <div class="gh-list" id="gh-list" role="listbox" aria-label="Repositories"><p class="hint">${loadingNote('Loading repositories…')}</p></div>
573
- </div>
574
- <div id="gh-branch" hidden>
575
- <div class="field">
576
- <span class="label">Branch</span>
577
- <div class="choice-cards">
578
- <label class="choice-card"><input type="radio" name="ghb" value="existing" checked><span><b>Use an existing branch</b></span></label>
579
- <label class="choice-card"><input type="radio" name="ghb" value="new"><span><b>Create a new branch</b></span></label>
580
- </div>
581
- <div id="gh-existing"><select class="input" id="gh-branch-select" aria-label="Branch"></select></div>
582
- <div id="gh-new" class="row" hidden>
583
- <input class="input grow" id="gh-new-name" placeholder="my-feature" spellcheck="false" aria-label="New branch name">
584
- <span class="hint">from</span>
585
- <select class="input gh-base" id="gh-base" aria-label="Start from branch"></select>
586
- </div>
587
- </div>
588
- <div class="field">
589
- <label for="gh-dir">Clone into</label>
590
- <div class="row"><input class="input grow" id="gh-dir" spellcheck="false">
591
- <button type="button" class="btn icon" id="gh-browse" title="Browse" aria-label="Browse">${I.folder}</button></div>
592
- <span class="hint">Leave empty to use a new folder in ${esc(state.profile.joinDir)}.</span>
593
- </div>
594
- </div>`
595
- const search = root.querySelector('#gh-search')
596
- search.addEventListener('input', paintRepos)
597
- search.addEventListener('keydown', (e) => {
598
- if (e.key !== 'Enter') return
599
- e.preventDefault() // pick the first match rather than submitting
600
- root.querySelector('#gh-list [data-repo]')?.click()
601
- })
602
- root.querySelector('#gh-browse').onclick = () => pickFolder(root.querySelector('#gh-dir'))
603
- root.querySelectorAll('[name=ghb]').forEach((r) => {
604
- r.onchange = () => {
605
- root.querySelector('#gh-existing').hidden = r.value !== 'existing'
606
- root.querySelector('#gh-new').hidden = r.value !== 'new'
607
- if (r.value === 'new') root.querySelector('#gh-new-name').focus()
608
- }
609
- })
610
- search.focus()
611
- try {
612
- repos = (await api('GET', '/api/github/repos')).repos
613
- } catch (err) {
614
- root.querySelector('#gh-list').innerHTML = `<p class="hint warn">${esc(err.message)}</p>`
615
- return
616
- }
617
- paintRepos()
618
- }
619
-
620
- function paintRepos () {
621
- const list = root.querySelector('#gh-list')
622
- if (!list || !repos) return
623
- const q = root.querySelector('#gh-search').value.trim().toLowerCase()
624
- const shown = repos.filter((r) => !q || r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)).slice(0, 60)
625
- list.innerHTML = shown.length
626
- ? shown.map((r) => `<button type="button" class="gh-repo${repo && repo.name === r.name ? ' on' : ''}" role="option" aria-selected="${repo && repo.name === r.name}" data-repo="${esc(r.name)}">
627
- <span class="grow"><b>${esc(r.name)}</b>${r.description ? `<span class="hint">${esc(r.description)}</span>` : ''}</span>
628
- ${r.updatedAt ? `<span class="hint">${esc(ago(Date.parse(r.updatedAt)))}</span>` : ''}</button>`).join('')
629
- : `<p class="hint">${repos.length ? 'No repositories match.' : 'You have no repositories on GitHub yet.'}</p>`
630
- list.querySelectorAll('[data-repo]').forEach((b) => { b.onclick = () => pickRepo(repos.find((r) => r.name === b.dataset.repo)) })
631
- }
632
-
633
- async function pickRepo (r) {
634
- repo = r
635
- branches = null
636
- paintRepos()
637
- root.querySelector('#gh-branch').hidden = false
638
- root.querySelector('#gh-dir').placeholder = `${state.profile.joinDir}/${r.name.split('/').pop()}`
639
- const sel = root.querySelector('#gh-branch-select')
640
- const baseSel = root.querySelector('#gh-base')
641
- sel.innerHTML = baseSel.innerHTML = '<option>Loading…</option>'
642
- sel.disabled = baseSel.disabled = true
643
- try {
644
- const b = await api('GET', `/api/github/branches?repo=${encodeURIComponent(r.name)}`)
645
- if (repo !== r) return // picked another meanwhile
646
- branches = b
647
- const opts = b.branches.map((n) => `<option value="${esc(n)}" ${n === b.defaultBranch ? 'selected' : ''}>${esc(n)}${n === b.defaultBranch ? ' (default)' : ''}</option>`).join('')
648
- sel.innerHTML = baseSel.innerHTML = opts || '<option value="">No branches</option>'
649
- sel.disabled = baseSel.disabled = false
650
- } catch (err) {
651
- sel.innerHTML = baseSel.innerHTML = '<option value="">Couldn’t load branches</option>'
652
- toast(err.message)
653
- }
654
- }
655
-
656
- function value () {
657
- if (!repo) throw new Error('Pick a repository.')
658
- if (!branches) throw new Error('Wait for the branches to load.')
659
- const dir = root.querySelector('#gh-dir').value.trim() || undefined
660
- if (root.querySelector('[name=ghb]:checked').value === 'new') {
661
- const newBranch = root.querySelector('#gh-new-name').value.trim()
662
- if (!newBranch) throw new Error('Name your new branch.')
663
- return { repo: repo.name, newBranch, base: root.querySelector('#gh-base').value || undefined, dir }
664
- }
665
- return { repo: repo.name, branch: root.querySelector('#gh-branch-select').value || undefined, dir }
666
- }
667
-
668
- return { open, value }
669
- }
670
-
671
524
  export function joinSessionDialog (invite = '') {
672
525
  const p = state.profile
673
526
  const { form, close } = dialog(`
@@ -302,6 +302,20 @@ export function foldAiLanes ({ people = [], owners = new Map(), feeds = new Map(
302
302
  }
303
303
  }
304
304
 
305
+ /**
306
+ * Whether someone's AI is working right now, for the loom's needle and the tab pulse.
307
+ * A tool with hooks reports its AI's status; an agent without them (a CLI, a script) says so
308
+ * with quilt_set_work. A reader that is `unavailable` (no such tool on that machine) counts as
309
+ * no tool at all, so it can't silence quilt_set_work. Paused sharing hides it.
310
+ */
311
+ export function aiWorking (p) {
312
+ const agent = (p && p.agent) || null
313
+ if (agent && agent.sharing === false) return false
314
+ if (agent && agent.status === 'working') return true
315
+ const hooked = !!(agent && agent.tool && agent.status !== 'unavailable')
316
+ return !hooked && !!(p && p.work && p.work.state === 'working')
317
+ }
318
+
305
319
  /**
306
320
  * Everything the Loom shows. `people`: [{ name, color, isMe, kind, tool, online,
307
321
  * agent, optional, focus, focusAt }] in lane order; `feeds`: Map name -> feed entries; `messages`: the
@@ -334,7 +348,7 @@ export function buildLoom ({ people = [], feeds = new Map(), messages = [], task
334
348
  sharing,
335
349
  // A tool with hooks reports its AI's status; an agent without them (a CLI, a script) says so
336
350
  // with quilt_set_work, or Quilt says it for it while it edits (Session.reportWorking).
337
- working: sharing && ((!!agent && agent.status === 'working') || (!(agent && agent.tool) && !!p.work && p.work.state === 'working')),
351
+ working: aiWorking(p),
338
352
  // Since when they've been on their focus (stitching); hidden like their status while paused.
339
353
  since: sharing && p.focusAt > 0 ? p.focusAt : 0,
340
354
  focus: sharing && p.focusAt > 0 ? String(p.focus || '') : '',
@@ -0,0 +1,213 @@
1
+ // A file in a session linked to a quilt (Seam): its History (the live changes not committed yet,
2
+ // then the patches that touched it, from the quilt's log, the chronology quilt_history gives
3
+ // agents), its Blame (who wrote each line, in which patch), and code search over main. The data
4
+ // comes through the relay (src/server.js quiltBlame, quiltSearch, quiltShow). A session that isn't
5
+ // linked never shows any of it.
6
+ import { esc, avatar, colorFor, I } from './common.js'
7
+ import { diffLines } from './diffview.js'
8
+
9
+ export const SEARCH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>'
10
+
11
+ /** How long ago, short: now, 5m, 3h, 2d, 3w, 4mo, 2y. */
12
+ export function shortAgo (ts, now = Date.now()) {
13
+ const s = Math.max(0, (now - ts) / 1000)
14
+ if (s < 60) return 'now'
15
+ if (s < 3600) return `${Math.round(s / 60)}m`
16
+ if (s < 86400) return `${Math.round(s / 3600)}h`
17
+ if (s < 14 * 86400) return `${Math.round(s / 86400)}d`
18
+ if (s < 60 * 86400) return `${Math.round(s / (7 * 86400))}w`
19
+ if (s < 365 * 86400) return `${Math.round(s / (30 * 86400))}mo`
20
+ return `${Math.round(s / (365 * 86400))}y`
21
+ }
22
+
23
+ /** The live changes, one row per person: what they changed in the file, summed, newest first. */
24
+ export function liveByPerson (entries) {
25
+ const by = new Map()
26
+ for (const e of entries || []) {
27
+ const p = by.get(e.by) || by.set(e.by, { by: e.by, ts: 0, added: 0, removed: 0, entries: [] }).get(e.by)
28
+ p.ts = Math.max(p.ts, e.ts || 0)
29
+ p.added += e.added || 0
30
+ p.removed += e.removed || 0
31
+ p.entries.push(e)
32
+ }
33
+ return [...by.values()].sort((a, b) => b.ts - a.ts)
34
+ }
35
+
36
+ /**
37
+ * What a file view still has to read when it is drawn, so a tab put back after a reload (on any
38
+ * segment) fetches its data instead of waiting for a click: 'history' (the file's changes, or in
39
+ * a linked session its History) until it is read, and 'blame' on the Blame segment of a linked
40
+ * file. `history`/`blame` are undefined until asked for; `busy` names reads already under way.
41
+ */
42
+ export function fileReadsNeeded ({ tab, linked = false, history, blame, busy = new Set() }) {
43
+ const out = []
44
+ if (history === undefined && !busy.has('history')) out.push('history')
45
+ if (tab === 'blame' && linked && blame === undefined && !busy.has('blame')) out.push('blame')
46
+ return out
47
+ }
48
+
49
+ /** How many rows History lists: a row per person changing the file now, and one per patch. */
50
+ export const historyCount = (live, patches) => liveByPerson(live).length + (patches || []).length
51
+
52
+ /** Whether a patch came in from git (the session shadows the quilt, or it is from before the cut-over) or was merged in Seam. */
53
+ const fromGit = (p, quilt) => !!(quilt && (quilt.shadow || (quilt.at && p.ts < Date.parse(quilt.at))))
54
+
55
+ /**
56
+ * The History tab of a linked file.
57
+ * @param {object} o
58
+ * @param {string} o.path the file
59
+ * @param {object[]} o.live the session's own changes not in the log yet, newest first
60
+ * @param {object[]} o.patches the log's patches that touched the file, newest first (quilt-history.js entries)
61
+ * @param {object} o.quilt the link ({ slug, shadow, at })
62
+ * @param {string|null} o.open the row opened: a commit id, or `live:<name>`
63
+ * @param {object|null} o.shown the opened patch: { files, diff }, { error }, or null while it loads
64
+ * @param {string} [o.note] why the log is missing, when it is
65
+ */
66
+ export function patchesMarkup ({ path, live = [], patches = [], quilt = null, open = null, shown = null, note = '', who = (n) => n, colorOf = () => null }) {
67
+ const people = liveByPerson(live)
68
+ const head = note ? `<p class="hint qt-shadow">${esc(note)}</p>` : ''
69
+ if (!people.length && !patches.length) return `${head}<div class="fv-note">No patch in ${esc((quilt && quilt.slug) || 'the quilt')} touched this file, and nobody is changing it now.</div>`
70
+ const rows = people.map((p) => {
71
+ const on = open === `live:${p.by}`
72
+ const diffs = p.entries.filter((e) => e.diff).map((e) => diffLines(e.diff)).join('')
73
+ return `<article class="chg-entry qt-patch qt-live${on ? ' on' : ''}">
74
+ <button type="button" class="chg-row qt-row" data-qt-patch="live:${esc(p.by)}" aria-expanded="${on}">${avatar(p.by, colorFor(p.by, colorOf(p.by)))}<b>${esc(who(p.by))}</b><span class="qt-msg">${who(p.by) === 'you' ? 'are' : 'is'} changing <code>${esc(path)}</code>, not committed yet</span><span class="chg-kind">live</span><span class="spacer"></span><span class="hint">${esc(shortAgo(p.ts))}</span><span class="chg-delta"><span class="add">+${p.added}</span> <span class="del">−${p.removed}</span></span></button>
75
+ ${on ? (diffs ? `<pre class="chg-diff">${diffs}</pre>` : '<p class="hint">No lines changed.</p>') : ''}
76
+ </article>`
77
+ })
78
+ for (const p of patches) {
79
+ const on = open === p.commit
80
+ const mine = on && shown && shown.files ? shown.files.find((f) => f.path === path) : null
81
+ rows.push(`<article class="chg-entry qt-patch${on ? ' on' : ''}">
82
+ <button type="button" class="chg-row qt-row" data-qt-patch="${esc(p.commit)}" aria-expanded="${on}">${avatar(p.by, colorFor(p.by, colorOf(p.by)))}<b>${esc(who(p.by))}</b><span class="qt-no">${p.patch ? `#${esc(p.patch.no)}` : esc(p.short || '')}</span><span class="qt-msg">${esc(p.message)}</span>${fromGit(p, quilt) ? '<span class="chg-kind pulled">from git</span>' : '<span class="chg-kind">merged in Seam</span>'}<span class="spacer"></span><span class="hint">${esc(shortAgo(p.ts))}</span>${mine ? delta(mine) : ''}</button>
83
+ ${on ? patchBody(shown) : ''}
84
+ </article>`)
85
+ }
86
+ return head + rows.join('')
87
+ }
88
+
89
+ const delta = (f) => `<span class="chg-delta"><span class="add">+${f.added}</span> <span class="del">−${f.removed}</span></span>`
90
+
91
+ function patchBody (shown) {
92
+ if (!shown) return '<p class="hint">Reading the patch…</p>'
93
+ if (shown.error) return `<p class="hint">${esc(shown.error)}</p>`
94
+ const files = (shown.files || []).map((f) => `<button type="button" class="chg-file" data-qt-file="${esc(f.path)}"><code>${esc(f.path)}</code></button>${f.binary ? '<span class="hint">binary</span>' : delta(f)}`).join('')
95
+ return `${files ? `<div class="qt-files">${files}</div>` : ''}${shown.diff ? `<pre class="chg-diff">${diffLines(shown.diff)}</pre>` : '<p class="hint">No lines of this file changed in it.</p>'}`
96
+ }
97
+
98
+ /**
99
+ * For each line of `live` (the file now), the index of the same line in `base` (the file on
100
+ * main, as blamed), or -1 for a line typed since and not committed: common start and end, then
101
+ * the longest common subsequence of what is between.
102
+ */
103
+ export function alignLines (base, live) {
104
+ const out = new Array(live.length).fill(-1)
105
+ let s = 0
106
+ while (s < base.length && s < live.length && base[s] === live[s]) { out[s] = s; s++ }
107
+ let ea = base.length - 1
108
+ let eb = live.length - 1
109
+ while (ea >= s && eb >= s && base[ea] === live[eb]) { out[eb] = ea; ea--; eb-- }
110
+ const a = base.slice(s, ea + 1)
111
+ const b = live.slice(s, eb + 1)
112
+ if (!a.length || !b.length) return out
113
+ if (a.length * b.length > 4_000_000) return out // too far apart to line up: what differs is live
114
+ const n = a.length
115
+ const m = b.length
116
+ const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1))
117
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
118
+ let i = 0
119
+ let j = 0
120
+ while (i < n && j < m) {
121
+ if (a[i] === b[j]) { out[s + j] = s + i; i++; j++ } else if (dp[i + 1][j] >= dp[i][j + 1]) i++
122
+ else j++
123
+ }
124
+ return out
125
+ }
126
+
127
+ const MAX_BLAME_LINES = 20000
128
+
129
+ /**
130
+ * The Blame tab: runs of lines, each headed by who wrote them, in which patch and how long ago.
131
+ * Lines typed now and not committed are tinted and say "live".
132
+ * @param {object} o
133
+ * @param {string} o.text the file now
134
+ * @param {object|null} o.blame the relay's answer: { blame }, { missing }, { working }, { error }, or null while it loads
135
+ * @param {string} [o.liveBy] who is changing the file now (its live lines are theirs)
136
+ */
137
+ export function blameMarkup ({ text, blame, liveBy = '', who = (n) => n, colorOf = () => null, now = Date.now() }) {
138
+ if (!blame) return '<div class="fv-note">Reading who wrote each line…</div>'
139
+ if (blame.error) return `<div class="fv-note">${esc(blame.error)}</div>`
140
+ if (blame.working) return '<div class="fv-note">Seam is working out who wrote each line. This takes a moment the first time.</div>'
141
+ if (text == null) return '<div class="fv-note">Reading the file…</div>'
142
+ const lines = String(text).split('\n')
143
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
144
+ const b = blame.blame || { who: [], lines: [] }
145
+ const map = blame.missing ? lines.map(() => -1) : alignLines(b.lines.map((l) => l[0]), lines)
146
+ let prev = null
147
+ const rows = lines.slice(0, MAX_BLAME_LINES).map((l, i) => {
148
+ const at = map[i]
149
+ const w = at >= 0 ? b.who[b.lines[at][1]] || null : null
150
+ const src = at < 0 ? 'live' : w ? w.commit : 'none'
151
+ const run = src !== prev
152
+ prev = src
153
+ let gutter = ''
154
+ if (run && src === 'live') {
155
+ gutter = `${liveBy ? avatar(liveBy, colorFor(liveBy, colorOf(liveBy))) : ''}<span class="qt-bw">${esc(liveBy ? who(liveBy) : 'Not committed')}</span><span class="qt-bp">live</span><span class="qt-bt">now</span>`
156
+ } else if (run && w) {
157
+ gutter = `${avatar(w.by, colorFor(w.by, colorOf(w.by)))}<span class="qt-bw">${esc(who(w.by))}</span><button type="button" class="qt-bp" data-qt-blame="${esc(w.commit)}" title="Open this patch in History">${w.patch != null ? `#${esc(w.patch)}` : esc(w.short)}</button><span class="qt-bt">${esc(shortAgo(w.at, now))}</span>`
158
+ }
159
+ return `<tr class="${[run ? 'qt-run' : '', src === 'live' ? 'qt-live' : ''].filter(Boolean).join(' ')}"><td class="qt-bg">${gutter}</td><td class="ln">${i + 1}</td><td class="lt">${esc(l) || ' '}</td></tr>`
160
+ })
161
+ return `<table class="fv-code qt-blame"><tbody>${rows.join('')}</tbody></table>${lines.length > MAX_BLAME_LINES ? `<div class="fv-note">Showing the first ${MAX_BLAME_LINES.toLocaleString()} lines.</div>` : ''}`
162
+ }
163
+
164
+ /** The plain words of a Seam search, to mark in the hits (not path:, lang: or -excluded terms). */
165
+ export function searchWords (q) {
166
+ return (String(q).match(/"[^"]+"|\S+/g) || [])
167
+ .filter((t) => !t.startsWith('-') && !/^[a-z]+:/i.test(t) && t !== 'OR' && !(t.startsWith('/') && t.endsWith('/') && t.length > 1))
168
+ .map((t) => t.replace(/^"|"$/g, ''))
169
+ .filter(Boolean)
170
+ }
171
+
172
+ function marked (text, words) {
173
+ if (!words.length) return esc(text) || ' '
174
+ const re = new RegExp(`(${words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi')
175
+ return String(text).split(re).map((part, i) => i % 2 ? `<mark>${esc(part)}</mark>` : esc(part)).join('') || ' '
176
+ }
177
+
178
+ /**
179
+ * A search tab: the field to search again, how much was found, and the hits grouped by file.
180
+ * Each hit opens its file at its line.
181
+ * @param {object} o
182
+ * @param {string} o.q what was searched
183
+ * @param {object|null} o.result the relay's answer: { search }, { working }, { error }, or null while it loads
184
+ */
185
+ export function searchMarkup ({ q, result }) {
186
+ const form = `<form class="qt-sform" data-qt-search-form><span class="qt-sico">${SEARCH_ICON}</span><input class="input" name="q" value="${esc(q)}" aria-label="Search the code on main" autocomplete="off" spellcheck="false"></form>`
187
+ let body
188
+ if (!result) body = '<p class="hint qt-scount">Searching main…</p>'
189
+ else if (result.error) body = `<p class="hint qt-scount">${esc(result.error)}</p>`
190
+ else if (result.working) body = '<p class="hint qt-scount">Seam is still searching. It answers in a moment.</p>'
191
+ else if (result.quilt === null) body = '<p class="hint qt-scount">This session is no longer linked to a quilt.</p>'
192
+ else {
193
+ const s = result.search
194
+ const files = new Map()
195
+ for (const h of s.hits) (files.get(h.path) || files.set(h.path, []).get(h.path)).push(h)
196
+ const n = s.hits.filter((h) => h.line).length
197
+ const words = searchWords(q)
198
+ const count = s.hits.length
199
+ ? `${n} line${n === 1 ? '' : 's'} in ${files.size} file${files.size === 1 ? '' : 's'} on main${s.more ? ', the first ones' : ''}`
200
+ : 'Nothing on main matches.'
201
+ body = `<p class="hint qt-scount">${count}</p>` + [...files].map(([p, hits]) => {
202
+ const lines = hits.filter((h) => h.line)
203
+ return `<section class="qt-hit"><div class="qt-hitpath"><button type="button" class="chg-file" data-qt-hit="${esc(p)}"><code>${esc(p)}</code></button><span class="hint">${lines.length ? `${lines.length} line${lines.length === 1 ? '' : 's'}` : 'its name'}</span></div>${lines.length ? `
204
+ <table class="fv-code"><tbody>${lines.map((h) => `<tr data-qt-hit="${esc(p)}" data-line="${h.line}"><td class="ln">${h.line}</td><td class="lt">${h.symbol ? `<mark>${esc(h.symbol)}</mark>` : marked(h.text || '', words)}</td></tr>`).join('')}</tbody></table>` : ''}</section>`
205
+ }).join('')
206
+ }
207
+ return `<div class="qt-search">${form}${body}</div>`
208
+ }
209
+
210
+ /** The search tab's label in the tab row. */
211
+ export const searchTabMarkup = (q, on) => `<div class="ws-tab qt-tab${on ? ' on' : ''}" role="tab" aria-selected="${on}" tabindex="0" data-kind="search" data-tab="${esc(q)}" title="${esc(`Search: ${q}`)}">
212
+ <span class="ico">${SEARCH_ICON}</span><span class="nm">Search: ${esc(q)}</span>
213
+ <button class="x" data-kind="search" data-close="${esc(q)}" aria-label="Close search ${esc(q)}">${I.x}</button></div>`