@leftoff/cli 0.8.7 → 0.8.9
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/CHANGELOG.md +16 -0
- package/bin/leftoff.js +371 -367
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +154 -2
- package/mod/hooks/register.js +36 -6
- package/package.json +1 -1
- package/ui/app.css +102 -16
- package/ui/app.js +107 -16
- package/ui/changelog-md.js +6 -2
- package/ui/handoffs.js +62 -5
- package/ui/icons.js +2 -0
- package/ui/prs.js +81 -37
package/ui/app.js
CHANGED
|
@@ -11,7 +11,7 @@ import { mdInline, renderSection } from './changelog-md.js'
|
|
|
11
11
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
12
12
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix } from './viewer.js'
|
|
13
13
|
import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
|
|
14
|
-
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref } from './handoffs.js'
|
|
14
|
+
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName } from './handoffs.js'
|
|
15
15
|
const $app = document.getElementById('app')
|
|
16
16
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
17
17
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
@@ -996,7 +996,11 @@ async function viewSession(id) {
|
|
|
996
996
|
const branch = s.repo && s.repo.branch
|
|
997
997
|
const spv = await api('GET', '/v1/spaces').catch(() => null)
|
|
998
998
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
999
|
-
|
|
999
|
+
// 0.8.9: rename in place: tap the title or the pencil (this Mac's sessions; like /cp-name, your name wins over automatic ones)
|
|
1000
|
+
const canRename = s.isLocal !== false && !s.agent && !/^agent:/.test(String(s.id || ''))
|
|
1001
|
+
html += '<div class="pagehead"><div>' + (canRename
|
|
1002
|
+
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" title="Rename">' + esc(s.title || 'Untitled session') + '</span></h1><button type="button" class="icon rename-pen" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" title="Rename">' + icon('pencil') + '</button></div>'
|
|
1003
|
+
: '<h1>' + esc(s.title || 'Untitled session') + '</h1>') + '<div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + wtChips(s) + ' ' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
|
|
1000
1004
|
'<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (s.isLocal ? '<button type="button" class="btn primary" data-action="checkpoint" data-id="' + esc(s.id) + '">Checkpoint now</button>' : '') + '</div></div>'
|
|
1001
1005
|
html += '<div class="facts">' +
|
|
1002
1006
|
fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
@@ -1102,7 +1106,7 @@ async function togglePr(url, { retry = false } = {}) {
|
|
|
1102
1106
|
const d = prsUi.data[prsUi.view]
|
|
1103
1107
|
const r = prRows(d).find((x) => x.pr.url === url)
|
|
1104
1108
|
const el = [...document.querySelectorAll('article.prrow[data-pr]')].find((a) => a.dataset.pr === url)
|
|
1105
|
-
if (el && r) el.outerHTML = prRowHtml(r, { ...prsOpts(prsUi.view, prsUi.filter), team: prsUi.view === 'team', now: Date.now(), showMachine: !!d.multiMachine, open: prsUi.open.has(url), detail: prsUi.details[url] })
|
|
1109
|
+
if (el && r) el.outerHTML = prRowHtml(r, { ...prsOpts(prsUi.view, prsUi.filter), team: prsUi.view === 'team', now: Date.now(), showMachine: !!d.multiMachine, me: d.me || null, open: prsUi.open.has(url), detail: prsUi.details[url] })
|
|
1106
1110
|
}
|
|
1107
1111
|
const have = prsUi.details[url]
|
|
1108
1112
|
if (!prsUi.open.has(url) || (have && !retry && (have.loading || (have.ok && Date.now() - have.at < 60000)))) return repaint()
|
|
@@ -1199,7 +1203,7 @@ function renderMd(md, { spaces = [] } = {}) {
|
|
|
1199
1203
|
}
|
|
1200
1204
|
if (/^\s{2,}\S/.test(line) && inList) {
|
|
1201
1205
|
// "Stopped at: … Next: …" is two lines, each with its label.
|
|
1202
|
-
const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
|
|
1206
|
+
const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you|Merged|Commits): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
|
|
1203
1207
|
if (sm) out.push('<span class="sub"><span class="k">' + sm[1] + '</span> ' + inline(sm[2]) + '</span>' + (sm[3] ? '<span class="sub"><span class="k">' + sm[3] + '</span> ' + inline(sm[4]) + '</span>' : ''))
|
|
1204
1208
|
else out.push('<span class="sub">' + inline(t) + '</span>')
|
|
1205
1209
|
continue
|
|
@@ -3022,6 +3026,8 @@ document.addEventListener('input', (ev) => {
|
|
|
3022
3026
|
document.addEventListener('keydown', (ev) => { if (ev.target.id === 'prs-q' && ev.key === 'Escape' && ev.target.value) { ev.preventDefault(); ev.target.value = ''; ev.target.dispatchEvent(new Event('input', { bubbles: true })) } })
|
|
3023
3027
|
|
|
3024
3028
|
window.addEventListener('hashchange', () => closeActionMenu())
|
|
3029
|
+
// 0.8.9: a session page's title is a button too (Enter or Space renames it)
|
|
3030
|
+
document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t && t.matches && t.matches('.s-title-text[role="button"]')) { e.preventDefault(); t.click() } })
|
|
3025
3031
|
// 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
|
|
3026
3032
|
document.addEventListener('keydown', (ev) => {
|
|
3027
3033
|
const b = ev.target && ev.target.closest && ev.target.closest('[data-action="card-more"]')
|
|
@@ -3045,7 +3051,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
3045
3051
|
if (action === 'prs-filter') {
|
|
3046
3052
|
const f = { ...prsUi.filter }, k = btn.dataset.k, v = btn.dataset.v || ''
|
|
3047
3053
|
if (k === 'repo') f.repos = v ? toggleRepo(f.repos || [], { key: btn.dataset.key || v, label: v }, ev.shiftKey || ev.metaKey || ev.ctrlKey) : []
|
|
3048
|
-
else if (k === 'state'
|
|
3054
|
+
else if (k === 'state') f.state = f.state === v ? null : v
|
|
3049
3055
|
return setPrsFilter(f)
|
|
3050
3056
|
}
|
|
3051
3057
|
if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
|
|
@@ -3327,6 +3333,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
3327
3333
|
}
|
|
3328
3334
|
if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
|
|
3329
3335
|
if (action === 'doc-close') return closeDoc()
|
|
3336
|
+
if (action === 'doc-dl') { ev.preventDefault(); return openDocMenu(btn) }
|
|
3337
|
+
if (action === 'doc-copy' || action === 'doc-download' || action === 'doc-pdf') return docAction(action.slice(4))
|
|
3330
3338
|
if (action === 'need-restore' || action === 'need-restore-all') {
|
|
3331
3339
|
btn.disabled = true
|
|
3332
3340
|
state.dismissedOpen = true
|
|
@@ -3339,7 +3347,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
3339
3347
|
if (action === 'resume-open') { const ok = await resumeOpen(btn, btn.dataset.id || id); if (ok && btn.closest('.parked-card')) await render(); return ok }
|
|
3340
3348
|
if (action === 'unpark') {
|
|
3341
3349
|
btn.disabled = true
|
|
3342
|
-
try { const r = await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id || id) + '/park', { done: true }); toast(r.session && r.session.status === 'ended' ? 'Done with it · moved to Closed' : 'No longer parked') } catch (e) { toast(e.message, 5000); btn.disabled = false; return }
|
|
3350
|
+
try { const r = await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id || id) + '/park', { done: true }); toast((r.session && r.session.status === 'ended' ? 'Done with it · moved to Closed' : 'No longer parked') + (r.worktreeUnlocked ? ' · its worktree is unlocked (still there)' : '')) } catch (e) { toast(e.message, 5000); btn.disabled = false; return }
|
|
3343
3351
|
return render()
|
|
3344
3352
|
}
|
|
3345
3353
|
if (action === 'closed-more') { closedUi.limit += CLOSED_PAGE; btn.disabled = true; try { await closedFetch(state.boardFilter || { repos: [] }, state.boardSpace); closedRepaint() } catch (e) { toast(e.message, 5000) } return }
|
|
@@ -3370,8 +3378,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
3370
3378
|
} else if (action === 'rename') {
|
|
3371
3379
|
const box = btn.closest('.title')
|
|
3372
3380
|
if (!box || box.querySelector('input')) return
|
|
3373
|
-
const current = s ? s.title || '' : ''
|
|
3374
|
-
box.innerHTML = '<input value="' + esc(current) + '" aria-label="Session title">'
|
|
3381
|
+
const current = s ? s.title || '' : btn.dataset.title || ''
|
|
3382
|
+
box.innerHTML = '<input value="' + esc(current) + '" aria-label="Session title" maxlength="120" enterkeyhint="done">'
|
|
3375
3383
|
const input = box.querySelector('input')
|
|
3376
3384
|
input.focus(); input.select()
|
|
3377
3385
|
let finished = false
|
|
@@ -3379,12 +3387,14 @@ document.addEventListener('click', async (ev) => {
|
|
|
3379
3387
|
if (finished) return
|
|
3380
3388
|
finished = true
|
|
3381
3389
|
if (save && input.value.trim() && input.value.trim() !== current) {
|
|
3382
|
-
|
|
3383
|
-
|
|
3390
|
+
try {
|
|
3391
|
+
await api('PATCH', '/v1/sessions/' + encodeURIComponent(id), { title: input.value.trim() })
|
|
3392
|
+
toast('Renamed. With the mod, the session and its tab title follow within a minute')
|
|
3393
|
+
} catch (e) { toast(e.message, 5000) }
|
|
3384
3394
|
}
|
|
3385
3395
|
render()
|
|
3386
3396
|
}
|
|
3387
|
-
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') done(true)
|
|
3397
|
+
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); done(true) } if (e.key === 'Escape') { e.stopPropagation(); done(false) } })
|
|
3388
3398
|
input.addEventListener('blur', () => done(true))
|
|
3389
3399
|
}
|
|
3390
3400
|
} catch (e) { toast(e.message); btn.disabled = false }
|
|
@@ -3507,6 +3517,7 @@ async function todoDoneChange(cb) {
|
|
|
3507
3517
|
// The in-app viewer (0.7.0): a doc or plan a session wrote, read from the Mac
|
|
3508
3518
|
// that owns it (through another Mac or the relay from a phone). Markdown is
|
|
3509
3519
|
// rendered from escaped text; HTML files show as source; anything else downloads.
|
|
3520
|
+
const docUi = { r: null, name: '', printing: null }
|
|
3510
3521
|
function docViewerEl() {
|
|
3511
3522
|
let el = document.getElementById('docViewer')
|
|
3512
3523
|
if (el) return el
|
|
@@ -3517,12 +3528,15 @@ function docViewerEl() {
|
|
|
3517
3528
|
el.setAttribute('role', 'dialog')
|
|
3518
3529
|
el.setAttribute('aria-modal', 'true')
|
|
3519
3530
|
el.setAttribute('aria-labelledby', 'docViewerTitle')
|
|
3520
|
-
|
|
3531
|
+
// 0.8.9: Download is a small menu (Copy / Download as Markdown / Download as PDF…), our own, not a native one
|
|
3532
|
+
el.innerHTML = '<div class="dv-sheet"><div class="dv-head"><div class="dv-title" id="docViewerTitle"></div>' +
|
|
3533
|
+
'<button type="button" class="btn sm dv-dl" data-action="doc-dl" aria-haspopup="menu" aria-expanded="false" hidden>' + icon('download') + '<span class="dl-word">Download</span><span class="chev" aria-hidden="true">▾</span></button>' +
|
|
3534
|
+
'<button type="button" class="dv-x" data-action="doc-close" aria-label="Close">×</button></div><div class="dv-sub faint"></div><div class="dv-ptitle" aria-hidden="true"></div><div class="dv-body"></div></div>'
|
|
3521
3535
|
el.addEventListener('click', (ev) => { if (ev.target === el) closeDoc() })
|
|
3522
3536
|
document.body.appendChild(el)
|
|
3523
3537
|
return el
|
|
3524
3538
|
}
|
|
3525
|
-
function closeDoc() { const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = '' } document.documentElement.classList.remove('dv-open') }
|
|
3539
|
+
function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = '' } docUi.r = null; document.documentElement.classList.remove('dv-open') }
|
|
3526
3540
|
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
|
|
3527
3541
|
async function openDoc(sessionId, { path, plan } = {}) {
|
|
3528
3542
|
if (!sessionId || (!path && !plan)) return
|
|
@@ -3531,7 +3545,8 @@ async function openDoc(sessionId, { path, plan } = {}) {
|
|
|
3531
3545
|
el.querySelector('.dv-title').textContent = name
|
|
3532
3546
|
el.querySelector('.dv-sub').textContent = ''
|
|
3533
3547
|
el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
|
|
3534
|
-
const dl = el.querySelector('.dv-dl'); dl.hidden = true
|
|
3548
|
+
const dl = el.querySelector('.dv-dl'); dl.hidden = true
|
|
3549
|
+
docUi.r = null; docUi.name = name
|
|
3535
3550
|
el.hidden = false
|
|
3536
3551
|
document.documentElement.classList.add('dv-open')
|
|
3537
3552
|
const qs = new URLSearchParams(plan ? { plan } : { path })
|
|
@@ -3541,12 +3556,88 @@ async function openDoc(sessionId, { path, plan } = {}) {
|
|
|
3541
3556
|
el.querySelector('.dv-title').textContent = r.name || name
|
|
3542
3557
|
el.querySelector('.dv-sub').textContent = [r.plan ? 'Plan' : r.kind === 'new' ? 'New' : r.kind === 'edited' ? 'Edited' : '', r.size ? (r.size < 1024 ? r.size + ' B' : Math.round(r.size / 1024) + ' KB') : '', r.peer ? 'from ' + r.peer : ''].filter(Boolean).join(' · ')
|
|
3543
3558
|
el.querySelector('.dv-body').innerHTML = docViewerBody(r)
|
|
3544
|
-
|
|
3545
|
-
|
|
3559
|
+
el.querySelector('.dv-ptitle').textContent = docFileName(r, name)
|
|
3560
|
+
docUi.r = r; docUi.name = name
|
|
3561
|
+
const items = docMenuItems(r)
|
|
3562
|
+
if (items.length) {
|
|
3563
|
+
dl.hidden = false
|
|
3564
|
+
const menu = items.length > 1
|
|
3565
|
+
dl.setAttribute('aria-haspopup', menu ? 'menu' : 'false')
|
|
3566
|
+
dl.querySelector('.chev').hidden = !menu
|
|
3567
|
+
dl.title = menu ? 'Copy or download' : 'Download ' + docFileName(r, name)
|
|
3568
|
+
dl.setAttribute('aria-label', menu ? 'Copy or download' : 'Download')
|
|
3569
|
+
}
|
|
3546
3570
|
} catch (e) {
|
|
3547
3571
|
el.querySelector('.dv-body').innerHTML = '<div class="empty">' + esc(e.message) + '</div>'
|
|
3548
3572
|
}
|
|
3549
3573
|
}
|
|
3574
|
+
function openDocMenu(btn) {
|
|
3575
|
+
const r = docUi.r
|
|
3576
|
+
const items = docMenuItems(r)
|
|
3577
|
+
if (!items.length) return
|
|
3578
|
+
if (items.length === 1) return docAction(items[0].key)
|
|
3579
|
+
const ic = { copy: 'copy', download: 'download', pdf: 'file-text' }
|
|
3580
|
+
openActionMenu(btn, items.map((it) => ({ label: it.label, icon: icon(ic[it.key] || 'download'), attrs: { 'data-action': 'doc-' + it.key, title: it.title } })), { title: docFileName(r, docUi.name) })
|
|
3581
|
+
}
|
|
3582
|
+
async function copyRaw(text) {
|
|
3583
|
+
if (await clipWrite(text)) return true
|
|
3584
|
+
const ta = document.createElement('textarea')
|
|
3585
|
+
ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;font-size:16px'
|
|
3586
|
+
document.body.appendChild(ta); ta.select()
|
|
3587
|
+
let ok = false
|
|
3588
|
+
try { ok = document.execCommand('copy') } catch {}
|
|
3589
|
+
ta.remove()
|
|
3590
|
+
return ok
|
|
3591
|
+
}
|
|
3592
|
+
async function docAction(key) {
|
|
3593
|
+
const r = docUi.r
|
|
3594
|
+
if (!r) return
|
|
3595
|
+
const btn = document.querySelector('#docViewer .dv-dl')
|
|
3596
|
+
if (key === 'copy') {
|
|
3597
|
+
const ok = await copyRaw(String(r.text || ''))
|
|
3598
|
+
if (!ok) { toast('Couldn’t copy here. Download it instead.', 4000); return }
|
|
3599
|
+
toast('Copied ' + docFileName(r, docUi.name))
|
|
3600
|
+
if (btn) {
|
|
3601
|
+
const word = btn.querySelector('.dl-word')
|
|
3602
|
+
btn.classList.add('copied'); if (word) word.textContent = 'Copied'
|
|
3603
|
+
clearTimeout(docUi.copiedTimer)
|
|
3604
|
+
docUi.copiedTimer = setTimeout(() => { btn.classList.remove('copied'); if (word) word.textContent = 'Download' }, 1500)
|
|
3605
|
+
}
|
|
3606
|
+
return
|
|
3607
|
+
}
|
|
3608
|
+
if (key === 'download') {
|
|
3609
|
+
const a = document.createElement('a')
|
|
3610
|
+
a.href = docDownloadHref(r); a.download = docFileName(r, docUi.name); a.rel = 'noopener'
|
|
3611
|
+
document.body.appendChild(a); a.click(); a.remove()
|
|
3612
|
+
return
|
|
3613
|
+
}
|
|
3614
|
+
if (key === 'pdf') return printDoc()
|
|
3615
|
+
}
|
|
3616
|
+
// Download as PDF: the browser's print dialog over just this file (print
|
|
3617
|
+
// styles in app.css), in the light theme, titled with the file's name, so
|
|
3618
|
+
// "Save as PDF" makes a clean PDF. No PDF library.
|
|
3619
|
+
function printDoc() {
|
|
3620
|
+
const r = docUi.r
|
|
3621
|
+
if (!r || r.type !== 'markdown') return
|
|
3622
|
+
endDocPrint()
|
|
3623
|
+
const root = document.documentElement
|
|
3624
|
+
docUi.printing = { title: document.title, theme: root.getAttribute('data-theme') }
|
|
3625
|
+
root.setAttribute('data-theme', 'light')
|
|
3626
|
+
root.classList.add('dv-printing')
|
|
3627
|
+
document.title = docFileName(r, docUi.name).replace(/\.(md|markdown)$/i, '')
|
|
3628
|
+
window.addEventListener('afterprint', endDocPrint, { once: true })
|
|
3629
|
+
toast('Choose “Save as PDF” in the print dialog', 5000)
|
|
3630
|
+
setTimeout(() => { try { window.print() } catch { endDocPrint() } }, 60)
|
|
3631
|
+
}
|
|
3632
|
+
function endDocPrint() {
|
|
3633
|
+
const p = docUi.printing
|
|
3634
|
+
if (!p) return
|
|
3635
|
+
docUi.printing = null
|
|
3636
|
+
const root = document.documentElement
|
|
3637
|
+
root.classList.remove('dv-printing')
|
|
3638
|
+
if (p.theme) root.setAttribute('data-theme', p.theme); else root.removeAttribute('data-theme')
|
|
3639
|
+
document.title = p.title
|
|
3640
|
+
}
|
|
3550
3641
|
|
|
3551
3642
|
// Dismiss a need (0.6.5): gone from every card and count at once, kept on
|
|
3552
3643
|
// the daemon (so your other devices and the relay see it too), Undo for 5 s.
|
package/ui/changelog-md.js
CHANGED
|
@@ -15,11 +15,13 @@ const anchor = (href, inner) => '<a href="' + esc(href) + '" target="_blank" rel
|
|
|
15
15
|
const docLink = (u) => /^#\/docs\/[a-z-]+$/.test(String(u || '').trim())
|
|
16
16
|
|
|
17
17
|
// One line of inline markdown -> HTML. Everything that isn't markup is escaped.
|
|
18
|
-
export function mdInline(s, { links = true } = {}) {
|
|
18
|
+
export function mdInline(s, { links = true, em = false } = {}) {
|
|
19
19
|
const held = []
|
|
20
20
|
const hold = (html) => '\u0000' + (held.push(html) - 1) + '\u0000'
|
|
21
21
|
const back = (t) => t.replace(/\u0000(\d+)\u0000/g, (_, i) => held[Number(i)])
|
|
22
|
-
|
|
22
|
+
// 0.8.9 (em: the file viewer): *italic*, _italic_ (not inside snake_case) and ~~struck~~ too
|
|
23
|
+
const emph = (t) => !em ? t : t.replace(/~~([^~\s][^~]*?)~~/g, '<del>$1</del>').replace(/(^|[^\w*])\*([^*\s][^*]*?)\*(?![\w*])/g, '$1<em>$2</em>').replace(/(^|[^\w])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>')
|
|
24
|
+
const marks = (t) => emph(esc(t).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>'))
|
|
23
25
|
let t = String(s == null ? '' : s)
|
|
24
26
|
// `code` first, so nothing inside it is read as markup.
|
|
25
27
|
t = t.replace(/`([^`]+)`/g, (_, c) => hold('<code>' + esc(c) + '</code>'))
|
|
@@ -28,6 +30,8 @@ export function mdInline(s, { links = true } = {}) {
|
|
|
28
30
|
const inner = back(marks(text))
|
|
29
31
|
return hold(links && httpsUrl(url) ? anchor(url, inner) : links && docLink(url) ? '<a href="' + esc(url.trim()) + '">' + inner + '</a>' : inner)
|
|
30
32
|
})
|
|
33
|
+
// with em on, bare links are held first so an _ or * in a URL stays a URL
|
|
34
|
+
if (links && em) t = t.replace(/(^|[\s(])(https?:\/\/[^\s<)\u0000]+[^\s<).,\u0000])/g, (_, pre, url) => pre + hold(anchor(url, esc(url))))
|
|
31
35
|
t = marks(t)
|
|
32
36
|
if (links) t = t.replace(/(^|[\s(])(https?:\/\/[^\s<)\u0000]+[^\s<).,\u0000])/g, (_, pre, url) => pre + anchor(url.replace(/&/g, '&'), url))
|
|
33
37
|
return back(t)
|
package/ui/handoffs.js
CHANGED
|
@@ -103,14 +103,52 @@ export function sessionFilesSection(s, { agoFn = null } = {}) {
|
|
|
103
103
|
|
|
104
104
|
// ------------------------------------------------------------------ viewer
|
|
105
105
|
|
|
106
|
+
// 0.8.9: a light tint for code blocks, not a real highlighter: comments,
|
|
107
|
+
// strings, numbers and common keywords get a class (.tk-c/.tk-s/.tk-n/.tk-k),
|
|
108
|
+
// everything escaped. Plain text, diffs and unknown languages: diff lines
|
|
109
|
+
// tinted, nothing else.
|
|
110
|
+
const KEYWORDS = new Set(('if else for while do switch case break continue return function const let var class new this import export from default async await try catch finally throw typeof instanceof in of yield ' +
|
|
111
|
+
'def lambda elif pass with as is not and or None True False self raise except global nonlocal ' +
|
|
112
|
+
'fn pub mut impl struct enum trait match use mod crate where loop ' +
|
|
113
|
+
'func package type interface go defer chan select range map ' +
|
|
114
|
+
'true false null undefined nil void public private protected static final extends implements ' +
|
|
115
|
+
'echo then fi esac done local export readonly ' +
|
|
116
|
+
'SELECT FROM WHERE JOIN LEFT RIGHT INNER OUTER ON GROUP BY ORDER LIMIT INSERT INTO VALUES UPDATE SET DELETE CREATE TABLE INDEX AND OR NOT AS WITH').split(' '))
|
|
117
|
+
const PROSE_LANGS = new Set(['', 'text', 'txt', 'plain', 'plaintext', 'markdown', 'md', 'output', 'console', 'log'])
|
|
118
|
+
export function tintCode(code, lang = '') {
|
|
119
|
+
const src = String(code == null ? '' : code)
|
|
120
|
+
lang = String(lang || '').toLowerCase()
|
|
121
|
+
if (lang === 'diff' || lang === 'patch') return src.split('\n').map((l) => /^\+(?!\+\+)/.test(l) ? '<span class="tk-add">' + esc(l) + '</span>' : /^-(?!--)/.test(l) ? '<span class="tk-del">' + esc(l) + '</span>' : /^@@/.test(l) ? '<span class="tk-k">' + esc(l) + '</span>' : esc(l)).join('\n')
|
|
122
|
+
if (PROSE_LANGS.has(lang)) return esc(src)
|
|
123
|
+
const hash = /^(sh|bash|zsh|shell|py|python|rb|ruby|yaml|yml|toml|ini|conf|dockerfile|make|makefile|r|perl|pl|elixir|ex)$/.test(lang)
|
|
124
|
+
const dash = /^(sql|lua|haskell|hs)$/.test(lang)
|
|
125
|
+
const re = new RegExp([
|
|
126
|
+
hash ? '(#[^\\n]*)' : dash ? '(--[^\\n]*)' : '(\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)',
|
|
127
|
+
'("(?:[^"\\\\\\n]|\\\\.)*"|\'(?:[^\'\\\\\\n]|\\\\.)*\'|`(?:[^`\\\\]|\\\\.)*`)',
|
|
128
|
+
'\\b(\\d+(?:\\.\\d+)?)\\b',
|
|
129
|
+
'\\b([A-Za-z_][A-Za-z0-9_]*)\\b',
|
|
130
|
+
].join('|'), 'g')
|
|
131
|
+
let out = '', last = 0, m
|
|
132
|
+
while ((m = re.exec(src))) {
|
|
133
|
+
out += esc(src.slice(last, m.index))
|
|
134
|
+
if (m[1] != null) out += '<span class="tk-c">' + esc(m[1]) + '</span>'
|
|
135
|
+
else if (m[2] != null) out += '<span class="tk-s">' + esc(m[2]) + '</span>'
|
|
136
|
+
else if (m[3] != null) out += '<span class="tk-n">' + esc(m[3]) + '</span>'
|
|
137
|
+
else out += KEYWORDS.has(m[4]) ? '<span class="tk-k">' + esc(m[4]) + '</span>' : esc(m[4])
|
|
138
|
+
last = re.lastIndex
|
|
139
|
+
}
|
|
140
|
+
return out + esc(src.slice(last))
|
|
141
|
+
}
|
|
142
|
+
|
|
106
143
|
// Markdown -> safe HTML: headings, paragraphs, lists, task boxes, quotes,
|
|
107
144
|
// fenced code, tables, rules. Inline markup via mdInline (escaped; https links only).
|
|
145
|
+
const mdi = (t) => mdInline(t, { em: true })
|
|
108
146
|
export function renderDoc(md) {
|
|
109
147
|
const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').replace(/^---\n[\s\S]*?\n---\n/, '').split('\n')
|
|
110
148
|
const out = []
|
|
111
149
|
let i = 0
|
|
112
150
|
const para = []
|
|
113
|
-
const flush = () => { if (para.length) { out.push('<p>' + para.map((x) =>
|
|
151
|
+
const flush = () => { if (para.length) { out.push('<p>' + para.map((x) => mdi(x)).join('<br>') + '</p>'); para.length = 0 } }
|
|
114
152
|
while (i < lines.length) {
|
|
115
153
|
const ln = lines[i]
|
|
116
154
|
let m
|
|
@@ -120,11 +158,12 @@ export function renderDoc(md) {
|
|
|
120
158
|
i++
|
|
121
159
|
while (i < lines.length && !lines[i].trim().startsWith(fence)) body.push(lines[i++])
|
|
122
160
|
i++
|
|
123
|
-
|
|
161
|
+
const lang = (m[2] || '').toLowerCase()
|
|
162
|
+
out.push('<pre class="doc-code"' + (lang ? ' data-lang="' + esc(lang) + '"' : '') + '><code>' + tintCode(body.join('\n'), lang) + '</code></pre>')
|
|
124
163
|
continue
|
|
125
164
|
}
|
|
126
165
|
if (!ln.trim()) { flush(); i++; continue }
|
|
127
|
-
if ((m = ln.match(/^(#{1,6})\s+(.*)$/))) { flush(); const n = Math.min(6, m[1].length + 1); out.push('<h' + n + '>' +
|
|
166
|
+
if ((m = ln.match(/^(#{1,6})\s+(.*)$/))) { flush(); const n = Math.min(6, m[1].length + 1); out.push('<h' + n + '>' + mdi(m[2].replace(/\s+#+\s*$/, '')) + '</h' + n + '>'); i++; continue }
|
|
128
167
|
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(ln)) { flush(); out.push('<hr>'); i++; continue }
|
|
129
168
|
if (/^\s*>/.test(ln)) {
|
|
130
169
|
flush(); const q = []
|
|
@@ -137,7 +176,7 @@ export function renderDoc(md) {
|
|
|
137
176
|
const head = cells(ln); i += 2
|
|
138
177
|
const rows = []
|
|
139
178
|
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(cells(lines[i++]))
|
|
140
|
-
out.push('<div class="doc-table"><table><thead><tr>' + head.map((c) => '<th>' +
|
|
179
|
+
out.push('<div class="doc-table"><table><thead><tr>' + head.map((c) => '<th>' + mdi(c) + '</th>').join('') + '</tr></thead><tbody>' + rows.map((r) => '<tr>' + r.map((c) => '<td>' + mdi(c) + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>')
|
|
141
180
|
continue
|
|
142
181
|
}
|
|
143
182
|
if (/^\s*(?:[-*+]|\d{1,3}[.)])\s+/.test(ln)) {
|
|
@@ -150,7 +189,7 @@ export function renderDoc(md) {
|
|
|
150
189
|
if (mm) items.push({ depth: Math.floor(mm[1].replace(/\t/g, ' ').length / 2), text: mm[2] })
|
|
151
190
|
else items[items.length - 1].text += ' ' + l.trim()
|
|
152
191
|
}
|
|
153
|
-
const li = (t) => { const tm = t.match(/^\[([ xX])\]\s+(.*)$/); return tm ? '<li class="task"><span class="tbox' + (tm[1] !== ' ' ? ' on' : '') + '" aria-hidden="true"></span>' +
|
|
192
|
+
const li = (t) => { const tm = t.match(/^\[([ xX])\]\s+(.*)$/); return tm ? '<li class="task"><span class="tbox' + (tm[1] !== ' ' ? ' on' : '') + '" aria-hidden="true"></span>' + mdi(tm[2]) + '</li>' : '<li>' + mdi(t) + '</li>' }
|
|
154
193
|
out.push('<' + (ordered ? 'ol' : 'ul') + '>' + items.map((it) => (it.depth ? li(it.text).replace('<li', '<li class="sub"') : li(it.text))).join('') + '</' + (ordered ? 'ol' : 'ul') + '>')
|
|
155
194
|
continue
|
|
156
195
|
}
|
|
@@ -174,6 +213,24 @@ export function docViewerBody(r) {
|
|
|
174
213
|
if (r.type === 'text' || r.type === 'html') return '<pre class="doc-pre">' + esc(r.text || '') + '</pre>'
|
|
175
214
|
return '<div class="empty">No preview for this kind of file.</div>'
|
|
176
215
|
}
|
|
216
|
+
// 0.8.9: what the viewer's Download menu offers for a file: Copy (the raw text),
|
|
217
|
+
// the file itself, and for markdown a PDF (the browser's print dialog, "Save as
|
|
218
|
+
// PDF"). Images and other binaries keep a plain Download (no menu).
|
|
219
|
+
export const PDF_HINT = 'Opens the print dialog: choose “Save as PDF”'
|
|
220
|
+
export function docMenuItems(r) {
|
|
221
|
+
if (!r || r.missing || r.tooBig || !docDownloadHref(r)) return []
|
|
222
|
+
const text = typeof r.text === 'string' && !r.base64 && r.type !== 'image'
|
|
223
|
+
const items = []
|
|
224
|
+
if (text) items.push({ key: 'copy', label: r.type === 'markdown' ? 'Copy' : 'Copy text', title: 'Copy the file’s text' })
|
|
225
|
+
items.push({ key: 'download', label: r.type === 'markdown' ? 'Download as Markdown' : 'Download', title: 'Save ' + docFileName(r) })
|
|
226
|
+
if (r.type === 'markdown') items.push({ key: 'pdf', label: 'Download as PDF…', title: PDF_HINT })
|
|
227
|
+
return items
|
|
228
|
+
}
|
|
229
|
+
// The name a download is saved as (a plan has no extension of its own).
|
|
230
|
+
export function docFileName(r, fallback = 'file') {
|
|
231
|
+
const n = String((r && r.name) || fallback).split('/').pop() || fallback
|
|
232
|
+
return r && r.plan && !/\.md$/i.test(n) ? n + '.md' : n
|
|
233
|
+
}
|
|
177
234
|
// A download for anything (an HTML file is offered as a file, never rendered live).
|
|
178
235
|
export function docDownloadHref(r) {
|
|
179
236
|
if (!r || r.missing || r.tooBig) return ''
|
package/ui/icons.js
CHANGED
|
@@ -32,6 +32,8 @@ export const ICONS = {
|
|
|
32
32
|
"chevron-left": "<path d=\"m15 18-6-6 6-6\"/>",
|
|
33
33
|
"mail": "<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"/> <path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/>",
|
|
34
34
|
"search": "<circle cx=\"11\" cy=\"11\" r=\"8\"/> <path d=\"m21 21-4.3-4.3\"/>",
|
|
35
|
+
"file-text": "<path d=\"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z\"/> <path d=\"M14 2v4a2 2 0 0 0 2 2h4\"/> <path d=\"M10 9H8\"/> <path d=\"M16 13H8\"/> <path d=\"M16 17H8\"/>",
|
|
36
|
+
"pencil": "<path d=\"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z\"/> <path d=\"m15 5 4 4\"/>",
|
|
35
37
|
"copy": "<rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/> <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/>",
|
|
36
38
|
"ellipsis": "<circle cx=\"12\" cy=\"12\" r=\"1\"/> <circle cx=\"19\" cy=\"12\" r=\"1\"/> <circle cx=\"5\" cy=\"12\" r=\"1\"/>",
|
|
37
39
|
"pin": "<path d=\"M12 17v5\"/> <path d=\"M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z\"/>",
|
package/ui/prs.js
CHANGED
|
@@ -89,11 +89,13 @@ function sessionLinks(r, opts) {
|
|
|
89
89
|
|
|
90
90
|
// One PR row on the PRs tab. 0.8.5: tap the row to expand it in place
|
|
91
91
|
// (`open`, with `detail` as /v1/prs/detail answered: { loading } | { ok, detail } | { ok: false, code }).
|
|
92
|
-
export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null } = {}) {
|
|
92
|
+
export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null, me = null } = {}) {
|
|
93
93
|
const p = r.pr || {}
|
|
94
94
|
const url = safeUrl(p.url)
|
|
95
|
+
// 0.8.9: what the checks and review chips don't already say (a conflict, your review)
|
|
96
|
+
const bwhy = r.bucketWhy !== undefined ? r.bucketWhy : prBucket(p, me).why
|
|
95
97
|
const line1 = '<span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) +
|
|
96
|
-
(
|
|
98
|
+
(bwhy === 'Merge conflict' || bwhy === 'Your review' ? '<span class="pr-att">' + esc(bwhy) + '</span>' : p.review && REVIEW[p.review] && (p.state === 'open' || p.state === 'draft') ? '<span class="pr-review ' + esc(p.review) + '">' + REVIEW[p.review] + '</span>' : '')
|
|
97
99
|
const when = p.state === 'merged' && p.mergedAt ? 'merged ' + shortAgo(p.mergedAt, now) + ' ago' : p.updatedAt ? 'updated ' + shortAgo(p.updatedAt, now) + ' ago' : ''
|
|
98
100
|
const meta = team
|
|
99
101
|
? [p.repo, p.author ? '@' + p.author : '', p.branch || '', when].filter(Boolean)
|
|
@@ -134,7 +136,7 @@ export function prDetailHtml(p, st, { now = Date.now() } = {}) {
|
|
|
134
136
|
return '<li class="' + esc(c.state) + '"><span class="pd-ic" aria-hidden="true">' + (c.state === 'failed' ? '✕' : '◌') + '</span>' + (u ? '<a href="' + esc(u) + '" target="_blank" rel="noopener noreferrer">' + name + ' ↗</a>' : name) + '<span class="pd-st">' + (c.state === 'failed' ? 'failed' : 'running') + '</span></li>'
|
|
135
137
|
}).join('') + '</ul>' : '') + (passed ? '<div class="pd-pass">✓ ' + passed + (bad.length ? ' passed' : passed === 1 ? ' check passed' : ' checks passed') + '</div>' : '') + '</div>'
|
|
136
138
|
}
|
|
137
|
-
const rv = REVIEW[d.review] || (d.state === 'open' ? 'No review yet' : '')
|
|
139
|
+
const rv = REVIEW[d.review] || (d.review === 'none' && d.state === 'open' ? 'No review required' : d.state === 'open' ? 'No review yet' : '')
|
|
138
140
|
const who = (d.reviews || []).map((r) => esc('@' + r.author) + ' ' + esc(REVIEW_STATE[r.state] || r.state))
|
|
139
141
|
if (rv || who.length || (d.reviewRequested || []).length) {
|
|
140
142
|
h += '<div class="pd-sec"><div class="pd-h">Review</div><div class="pd-review">' + (rv ? '<span class="pr-review ' + esc(d.review || '') + '">' + esc(rv) + '</span>' : '') +
|
|
@@ -154,20 +156,63 @@ export function prDetailHtml(p, st, { now = Date.now() } = {}) {
|
|
|
154
156
|
return h + '</div>'
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
// ---------------------------------------------------------------- 0.8.9: buckets
|
|
160
|
+
|
|
161
|
+
// What an open PR is waiting on, in one of four buckets (idea: "merge ready /
|
|
162
|
+
// needs fix / waiting on others"), from the fields gh already gives us:
|
|
163
|
+
// review Your review: you're a requested reviewer and it isn't yours
|
|
164
|
+
// fix Needs fix: a check failing, changes requested, or a merge conflict
|
|
165
|
+
// ready Merge ready: not a draft, approved or no review required, every
|
|
166
|
+
// check passed (or none), and GitHub says it merges cleanly
|
|
167
|
+
// waiting Waiting on others: everything else (review pending, checks
|
|
168
|
+
// running, a draft, or anything we can't tell yet)
|
|
169
|
+
// Anything unknown (no mergeable from an older daemon, GitHub still working
|
|
170
|
+
// it out, no check list) lands in waiting, never in ready. Merged and closed
|
|
171
|
+
// PRs keep their state. -> { bucket, why }
|
|
172
|
+
export const PR_BUCKETS = [['review', 'Your review'], ['fix', 'Needs fix'], ['ready', 'Merge ready'], ['waiting', 'Waiting on others'], ['merged', 'Merged'], ['closed', 'Closed']]
|
|
173
|
+
const sameUser = (a, b) => !!a && !!b && String(a).toLowerCase() === String(b).toLowerCase()
|
|
174
|
+
export function prBucket(pr, me = null) {
|
|
175
|
+
if (!pr || typeof pr !== 'object') return { bucket: 'waiting', why: null }
|
|
176
|
+
if (pr.state === 'merged') return { bucket: 'merged', why: null }
|
|
177
|
+
if (pr.state === 'closed') return { bucket: 'closed', why: null }
|
|
178
|
+
const ck = pr.checks && typeof pr.checks === 'object' ? pr.checks : null
|
|
179
|
+
if (me && !sameUser(pr.author, me) && (pr.reviewRequested || []).some((x) => sameUser(x, me))) return { bucket: 'review', why: 'Your review' }
|
|
180
|
+
if (ck && ck.failed > 0) return { bucket: 'fix', why: 'Checks failing' }
|
|
181
|
+
if (pr.review === 'changes_requested') return { bucket: 'fix', why: 'Changes requested' }
|
|
182
|
+
if (pr.mergeable === 'conflicting') return { bucket: 'fix', why: 'Merge conflict' }
|
|
183
|
+
if (pr.state === 'draft') return { bucket: 'waiting', why: 'Draft' }
|
|
184
|
+
if (pr.state !== 'open') return { bucket: 'waiting', why: null }
|
|
185
|
+
if (!ck || ck.unknown || !Number.isFinite(ck.total)) return { bucket: 'waiting', why: null }
|
|
186
|
+
if (ck.pending > 0) return { bucket: 'waiting', why: 'Checks running' }
|
|
187
|
+
if (pr.review === 'review_required') return { bucket: 'waiting', why: 'Review pending' }
|
|
188
|
+
if (pr.review !== 'approved' && pr.review !== 'none') return { bucket: 'waiting', why: null }
|
|
189
|
+
if (pr.mergeable !== 'mergeable') return { bucket: 'waiting', why: null }
|
|
190
|
+
return { bucket: 'ready', why: null }
|
|
191
|
+
}
|
|
192
|
+
// A row's bucket: what the daemon worked out, else (an older daemon) worked out here.
|
|
193
|
+
export function bucketOf(r, me = null) {
|
|
194
|
+
if (r && PR_BUCKETS.some(([k]) => k === r.bucket)) return r.bucket
|
|
195
|
+
return prBucket(r && r.pr, me).bucket
|
|
196
|
+
}
|
|
197
|
+
|
|
157
198
|
// ---------------------------------------------------------------- 0.8.5: filters
|
|
158
199
|
|
|
159
|
-
// The page's filter, from the hash (#/prs?view=team&repo=web&state=
|
|
160
|
-
|
|
161
|
-
|
|
200
|
+
// The page's filter, from the hash (#/prs?view=team&repo=web&state=ready&q=search).
|
|
201
|
+
// 0.8.9: `state` is a bucket (PR_BUCKETS). Links from 0.8.5 still open:
|
|
202
|
+
// state=attention or checks=failing -> Needs fix, state=draft -> Waiting on
|
|
203
|
+
// others, state=open and checks=passing -> no filter.
|
|
204
|
+
export const PR_STATES = PR_BUCKETS
|
|
162
205
|
export const QUIET_DAYS = 14
|
|
206
|
+
const LEGACY_STATE = { attention: 'fix', draft: 'waiting' }
|
|
163
207
|
const splitList = (v) => String(v || '').split(',').map((x) => x.trim()).filter(Boolean)
|
|
164
208
|
export function readPrFilter(params) {
|
|
165
209
|
const p = typeof params === 'string' ? new URLSearchParams(params.replace(/^.*\?/, '')) : (params || new URLSearchParams())
|
|
166
|
-
|
|
210
|
+
let state = p.get('state')
|
|
211
|
+
state = PR_BUCKETS.some(([k]) => k === state) ? state : LEGACY_STATE[state] || null
|
|
212
|
+
if (!state && p.get('checks') === 'failing') state = 'fix'
|
|
167
213
|
return {
|
|
168
214
|
repos: [...new Set(p.getAll('repo').flatMap(splitList))],
|
|
169
|
-
state
|
|
170
|
-
checks: PR_CHECKS.some(([k]) => k === checks) ? checks : null,
|
|
215
|
+
state,
|
|
171
216
|
q: String(p.get('q') || '').slice(0, 200),
|
|
172
217
|
}
|
|
173
218
|
}
|
|
@@ -176,11 +221,10 @@ export function prsHash(view, f = {}) {
|
|
|
176
221
|
if (view === 'team') parts.push('view=team')
|
|
177
222
|
if (f.repos && f.repos.length) parts.push('repo=' + f.repos.map((r) => encodeURIComponent(r).replace(/%2F/gi, '/')).join(','))
|
|
178
223
|
if (f.state) parts.push('state=' + f.state)
|
|
179
|
-
if (f.checks) parts.push('checks=' + f.checks)
|
|
180
224
|
if (f.q && f.q.trim()) parts.push('q=' + encodeURIComponent(f.q.trim()))
|
|
181
225
|
return '#/prs' + (parts.length ? '?' + parts.join('&') : '')
|
|
182
226
|
}
|
|
183
|
-
export const prFilterActive = (f) => !!(f && ((f.repos && f.repos.length) || f.state ||
|
|
227
|
+
export const prFilterActive = (f) => !!(f && ((f.repos && f.repos.length) || f.state || (f.q && f.q.trim())))
|
|
184
228
|
|
|
185
229
|
// Every row, flat: the daemon's `rows`, or an older daemon's groups.
|
|
186
230
|
export function prRows(d) {
|
|
@@ -192,13 +236,8 @@ export function prRows(d) {
|
|
|
192
236
|
}
|
|
193
237
|
const lastAt = (pr) => Math.max(Date.parse(pr.updatedAt || '') || 0, Date.parse(pr.mergedAt || '') || 0)
|
|
194
238
|
export const isQuiet = (r, now = Date.now()) => { const t = lastAt(r.pr || {}); return !!t && now - t >= QUIET_DAYS * 864e5 }
|
|
195
|
-
export function prStateOf(r) { return
|
|
196
|
-
export function inPrState(r, state) { return !state || (
|
|
197
|
-
export function inPrChecks(r, checks) {
|
|
198
|
-
const c = r.pr.checks || {}
|
|
199
|
-
if (!checks) return true
|
|
200
|
-
return checks === 'failing' ? c.failed > 0 : c.total > 0 && !c.failed && !c.pending
|
|
201
|
-
}
|
|
239
|
+
export function prStateOf(r, me = null) { return bucketOf(r, me) }
|
|
240
|
+
export function inPrState(r, state, me = null) { return !state || bucketOf(r, me) === state }
|
|
202
241
|
export function inPrSearch(r, q) {
|
|
203
242
|
const words = String(q || '').toLowerCase().split(/\s+/).filter(Boolean)
|
|
204
243
|
if (!words.length) return true
|
|
@@ -215,17 +254,18 @@ export function inPrRepos(r, repos, labels = null) {
|
|
|
215
254
|
const key = r.pr.repo || 'no-repo'
|
|
216
255
|
return repos.some((t) => repoMatches({ repoKey: key, repoLabel: labels ? labels.get(key) : null }, t))
|
|
217
256
|
}
|
|
218
|
-
export function filterPrRows(rows, f = {}, { skip = null } = {}) {
|
|
257
|
+
export function filterPrRows(rows, f = {}, { skip = null, me = null } = {}) {
|
|
219
258
|
const labels = labelRepos(rows.map((r) => r.pr.repo || 'no-repo'))
|
|
220
|
-
return rows.filter((r) => (skip === 'repos' || inPrRepos(r, f.repos, labels)) && (skip === 'state' || inPrState(r, f.state
|
|
259
|
+
return rows.filter((r) => (skip === 'repos' || inPrRepos(r, f.repos, labels)) && (skip === 'state' || inPrState(r, f.state, me)) && inPrSearch(r, f.q))
|
|
221
260
|
}
|
|
222
|
-
// Groups, newest first in each; anything with no
|
|
223
|
-
|
|
224
|
-
|
|
261
|
+
// Groups (0.8.9: the buckets), newest first in each; anything with no
|
|
262
|
+
// activity for 14+ days in Quiet.
|
|
263
|
+
export function groupPrRows(rows, { now = Date.now(), me = null } = {}) {
|
|
264
|
+
const g = Object.fromEntries(PR_BUCKETS.map(([k]) => [k, []]))
|
|
225
265
|
const quiet = []
|
|
226
|
-
for (const r of rows) { if (isQuiet(r, now)) quiet.push(r); else
|
|
266
|
+
for (const r of rows) { if (isQuiet(r, now)) quiet.push(r); else g[bucketOf(r, me)].push(r) }
|
|
227
267
|
const by = (a, b) => lastAt(b.pr) - lastAt(a.pr)
|
|
228
|
-
const out =
|
|
268
|
+
const out = PR_BUCKETS.map(([key, label]) => ({ key, label, rows: g[key].sort(by) })).filter((x) => x.rows.length)
|
|
229
269
|
return { groups: out, quiet: quiet.sort(by) }
|
|
230
270
|
}
|
|
231
271
|
|
|
@@ -238,12 +278,13 @@ function fchip({ on, count, label, attrs, cls = '' }) {
|
|
|
238
278
|
|
|
239
279
|
// The chip rows and the grouped list (repainted as you type in the search box).
|
|
240
280
|
export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null, filter = {}, open = null, details = null, quietOpen = false } = {}) {
|
|
241
|
-
const f = { repos: [], state: null,
|
|
281
|
+
const f = { repos: [], state: null, q: '', ...filter }
|
|
242
282
|
const all = prRows(d)
|
|
283
|
+
const me = d.me || null
|
|
243
284
|
let h = ''
|
|
244
285
|
if (all.length) {
|
|
245
286
|
// Repos: All, then by latest activity; a chosen one always shows, first and removable.
|
|
246
|
-
const repoCount = filterPrRows(all, f, { skip: 'repos' })
|
|
287
|
+
const repoCount = filterPrRows(all, f, { skip: 'repos', me })
|
|
247
288
|
let chips = prRepoChips(all, { countIn: repoCount })
|
|
248
289
|
for (const tok of f.repos) if (!chips.some((c) => chipSelected([tok], c))) chips.push({ key: tok, label: tok, count: 0, total: 0, last: 0 })
|
|
249
290
|
chips = orderSelectedFirst(chips, f.repos)
|
|
@@ -251,23 +292,26 @@ export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true
|
|
|
251
292
|
h += chipRow('Filter by repo', [fchip({ on: !f.repos.length, count: repoCount.length, label: 'All', cls: 'all', attrs: 'data-action="prs-filter" data-k="repo" data-v=""' })]
|
|
252
293
|
.concat(chips.map((c) => fchip({ on: chipSelected(f.repos, c), count: c.count, label: c.label, attrs: 'data-action="prs-filter" data-k="repo" data-v="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc(c.key + ' · shift- or ⌘-click to pick several') + '"' }))), 'prs-repos')
|
|
253
294
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
|
|
295
|
+
// 0.8.9: one row of buckets. The four open ones always show (dimmed at 0,
|
|
296
|
+
// after the rest), Merged and Closed only when there are some; a chosen
|
|
297
|
+
// one comes first.
|
|
298
|
+
const stCount = filterPrRows(all, f, { skip: 'state', me })
|
|
299
|
+
const bs = PR_BUCKETS.map(([k, l]) => ({ v: k, l, n: stCount.filter((r) => bucketOf(r, me) === k).length, on: f.state === k }))
|
|
300
|
+
const shown = bs.filter((x) => !x.on && (x.n || ['review', 'fix', 'ready', 'waiting'].includes(x.v)) && !(x.v === 'review' && !x.n && !me))
|
|
301
|
+
// the ones with PRs lead, so on a phone the empty ones are what scrolls off
|
|
302
|
+
const ordered = [...bs.filter((x) => x.on), ...shown.filter((x) => x.n), ...shown.filter((x) => !x.n)]
|
|
303
|
+
h += chipRow('Filter by what each PR is waiting on', ordered.map((x) => fchip({ on: x.on, count: x.n, label: x.l, cls: 'b-' + x.v, attrs: 'data-action="prs-filter" data-k="state" data-v="' + x.v + '"' })), 'prs-states prs-buckets')
|
|
260
304
|
}
|
|
261
|
-
const rows = filterPrRows(all, f)
|
|
262
|
-
const { groups, quiet } = groupPrRows(rows, { now })
|
|
305
|
+
const rows = filterPrRows(all, f, { me })
|
|
306
|
+
const { groups, quiet } = groupPrRows(rows, { now, me })
|
|
263
307
|
const showMachine = !!d.multiMachine
|
|
264
|
-
const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null })
|
|
308
|
+
const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, me, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null })
|
|
265
309
|
if (!all.length) {
|
|
266
310
|
h += '<div class="empty">' + (d.loading ? 'Loading pull requests…' : view === 'team' ? (d.repos && d.repos.length ? 'No open PRs in ' + esc(d.repos.join(', ')) + '.' : 'None of your sessions are in a GitHub repo yet.') : 'No PRs linked to your sessions yet. They link on their own when a session runs gh pr create, works on a branch with an open PR, or you paste a PR’s link in it.') + '</div>'
|
|
267
311
|
} else if (!rows.length) {
|
|
268
312
|
h += '<div class="empty">No PRs match. <button type="button" class="linkish" data-action="prs-clear">Clear filters</button></div>'
|
|
269
313
|
} else {
|
|
270
|
-
for (const g of groups) h += '<section class="prgroup' + (g.key === '
|
|
314
|
+
for (const g of groups) h += '<section class="prgroup b-' + g.key + (g.key === 'fix' || g.key === 'review' ? ' attention' : '') + '" data-group="' + g.key + '"><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span></h2>' + g.rows.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</section>'
|
|
271
315
|
if (quiet.length) h += '<details class="prgroup quiet" data-group="quiet"' + (quietOpen || !groups.length ? ' open' : '') + '><summary><h2>Quiet <span class="count">' + quiet.length + '</span> <span class="faint qhint">no activity for ' + QUIET_DAYS + '+ days</span></h2></summary>' + quiet.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</details>'
|
|
272
316
|
}
|
|
273
317
|
if (view === 'team' && (d.failed || []).length) h += '<div class="hint">Couldn’t load ' + esc(d.failed.join(', ')) + ' right now.</div>'
|