heyquilt 0.3.121 → 0.3.138
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASES.md +292 -17
- package/bin/quilt.js +72 -5
- package/desktop/main.js +16 -2
- package/package.json +3 -1
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +21 -10
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/github.js +0 -13
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +39 -0
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +46 -12
- package/src/api/supabase-store.js +53 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +39 -9
- package/src/control.js +139 -13
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +25 -1
- package/src/mcp.js +93 -7
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-commit.js +5 -0
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +172 -8
- package/src/relay-outbound.js +1 -1
- package/src/relay-prune.js +4 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1767 -33
- package/src/session-follow.js +515 -0
- package/src/session-layout3.js +466 -0
- package/src/session-migrate.js +123 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +284 -0
- package/src/session.js +899 -46
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +507 -35
- package/src/source-live/live-square.js +17 -6
- package/src/status.js +5 -0
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/chat.js +9 -3
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -2
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -0
- package/src/ui/home.js +0 -147
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +413 -31
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui-server.js +80 -28
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/git.js +0 -147
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -449
- package/src/quilts/client.js +0 -128
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -266
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -160
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-git.js +0 -291
- package/src/quilts/import.js +0 -134
- package/src/quilts/index.js +0 -72
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -486
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -195
- package/src/quilts/routes.js +0 -126
- package/src/quilts/service.js +0 -923
- package/src/quilts/setup.js +0 -34
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -683
- package/src/quilts/store-rpc.js +0 -79
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -375
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -119
- package/src/quilts/xdiff.js +0 -435
- package/src/ui/branches.js +0 -111
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(`
|
package/src/ui/loom-model.js
CHANGED
|
@@ -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:
|
|
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>`
|