@leftoff/cli 0.8.3 → 0.8.5
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 +15 -0
- package/assets/sample-transcript.jsonl +11 -11
- package/bin/leftoff.js +304 -303
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +38 -0
- package/ui/app.js +92 -11
- package/ui/prs.js +188 -24
- package/ui/viewer.js +34 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Joel"
|
package/mod/hooks/register.js
CHANGED
|
@@ -27,7 +27,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
27
27
|
const MODEL_TIMEOUT_MS = 20000
|
|
28
28
|
|
|
29
29
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
30
|
-
const MOD_VERSION = '0.8.
|
|
30
|
+
const MOD_VERSION = '0.8.5' // keep in step with .claude-plugin/plugin.json
|
|
31
31
|
|
|
32
32
|
const st = {
|
|
33
33
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -1427,6 +1427,44 @@ a.cpr:hover { border-color: var(--line-strong); }
|
|
|
1427
1427
|
.prlink input { flex: 1 1 auto; min-width: 0; font-size: 16px; }
|
|
1428
1428
|
.prs-gh { margin-top: 6px; }
|
|
1429
1429
|
.prs-foot { font-size: 12px; margin-top: 6px; }
|
|
1430
|
+
/* 0.8.5: PR filters (search, repo chips, state/check chips), the why, Quiet, details on tap */
|
|
1431
|
+
.prs-search { margin: 0 0 4px; }
|
|
1432
|
+
.prs-q { width: 100%; max-width: 480px; font-size: 16px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--card); color: var(--text); }
|
|
1433
|
+
.prs-chips { margin-top: 8px; }
|
|
1434
|
+
.prs-chips .rchip.ck .rl::before { content: '◇ '; color: var(--faint); }
|
|
1435
|
+
.pr-why { color: var(--muted); }
|
|
1436
|
+
.prrow { cursor: pointer; }
|
|
1437
|
+
.prrow.open { border-color: var(--line-strong); }
|
|
1438
|
+
.prrow a, .prrow button, .prrow .pr-detail { cursor: auto; }
|
|
1439
|
+
.prrow a, .prrow button { cursor: pointer; }
|
|
1440
|
+
.prgroup.quiet > summary { list-style: none; cursor: pointer; }
|
|
1441
|
+
.prgroup.quiet > summary::-webkit-details-marker { display: none; }
|
|
1442
|
+
.prgroup.quiet > summary h2 { color: var(--muted); }
|
|
1443
|
+
.prgroup.quiet > summary h2::before { content: '▸ '; }
|
|
1444
|
+
.prgroup.quiet[open] > summary h2::before { content: '▾ '; }
|
|
1445
|
+
.prgroup.quiet .qhint { font-weight: 400; font-size: 12px; }
|
|
1446
|
+
.pr-detail { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 10px; font-size: 13px; cursor: auto; }
|
|
1447
|
+
.pr-detail.loading, .pr-detail.none { color: var(--muted); }
|
|
1448
|
+
.pd-h { font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--faint); margin-bottom: 4px; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
|
|
1449
|
+
.pd-stats { text-transform: none; letter-spacing: 0; font-family: var(--mono); font-weight: 500; color: var(--muted); }
|
|
1450
|
+
.pd-add { color: var(--green); }
|
|
1451
|
+
.pd-del { color: var(--red); }
|
|
1452
|
+
.pd-checks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
1453
|
+
.pd-checks li { display: flex; gap: 7px; align-items: baseline; min-width: 0; }
|
|
1454
|
+
.pd-checks li a { color: var(--text); overflow-wrap: anywhere; }
|
|
1455
|
+
.pd-checks li.failed .pd-ic { color: var(--red); }
|
|
1456
|
+
.pd-checks li.pending .pd-ic { color: var(--yellow); }
|
|
1457
|
+
.pd-st { margin-left: auto; font-size: 12px; color: var(--muted); flex: none; }
|
|
1458
|
+
.pd-pass { color: var(--green); font-size: 12.5px; margin-top: 2px; }
|
|
1459
|
+
.pd-review { display: flex; flex-wrap: wrap; gap: 4px 10px; }
|
|
1460
|
+
.pd-text, .pd-clip { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text2); }
|
|
1461
|
+
.pd-body > summary { list-style: none; cursor: pointer; }
|
|
1462
|
+
.pd-body > summary::-webkit-details-marker { display: none; }
|
|
1463
|
+
.pd-body[open] > summary { display: none; }
|
|
1464
|
+
.pd-more { margin-left: 6px; }
|
|
1465
|
+
.pd-comments { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
1466
|
+
.pd-who { font-size: 12px; font-weight: 600; }
|
|
1467
|
+
.pd-cbody { color: var(--text2); overflow-wrap: anywhere; }
|
|
1430
1468
|
@media (max-width: 639.98px) {
|
|
1431
1469
|
.tabs { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
|
1432
1470
|
.prrow { flex-direction: column; gap: 8px; }
|
package/ui/app.js
CHANGED
|
@@ -8,8 +8,8 @@ import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, to
|
|
|
8
8
|
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
9
9
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
10
10
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
11
|
-
import { cardPrsHtml, sessionPrsSection, prsPageHtml } from './prs.js'
|
|
12
|
-
import { wantsSetupBanner } from './viewer.js'
|
|
11
|
+
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
12
|
+
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers } from './viewer.js'
|
|
13
13
|
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref } from './handoffs.js'
|
|
14
14
|
const $app = document.getElementById('app')
|
|
15
15
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
@@ -214,7 +214,13 @@ function syncBanners(h) {
|
|
|
214
214
|
out += '<div class="banner warn lowdisk" role="status"><div class="grow"><b>' + esc(d.text) + '</b><div class="fixline">fix: ' + esc(d.fix.text) + '</div></div>' + (d.fix.cmd ? '<button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(d.fix.cmd) + '" title="' + esc(d.fix.cmd) + '">Copy</button>' : '') + '</div>'
|
|
215
215
|
}
|
|
216
216
|
const v = h.viewer || {}
|
|
217
|
-
|
|
217
|
+
const who = { viewer: v, ua: navigator.userAgent, maxTouchPoints: navigator.maxTouchPoints || 0, dismissed: setupDismissed() }
|
|
218
|
+
// Ask this computer's own Leftoff once; the banner waits for the answer.
|
|
219
|
+
if (setupCandidate(who) && state.localHere === undefined) {
|
|
220
|
+
state.localHere = null
|
|
221
|
+
localLeftoffAnswers().then((here) => { state.localHere = here; if (!here) render() })
|
|
222
|
+
}
|
|
223
|
+
if (wantsSetupBanner({ ...who, here: state.localHere })) {
|
|
218
224
|
const su = v.setup
|
|
219
225
|
out += '<div class="banner info setup-mac" role="status"><div class="grow">This board is running on <b>' + esc(su.machine || 'another Mac') + '</b>. To track sessions on this computer too, run:<div class="joinline"><code class="cmd">' + esc(su.command) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(su.command) + '">Copy</button></div></div>' +
|
|
220
226
|
'<button type="button" class="btn x" data-action="dismiss-setup" aria-label="Dismiss" title="Dismiss">✕</button></div>'
|
|
@@ -933,30 +939,75 @@ function fact(k, v, cls) { return '<div class="fact' + (cls ? ' ' + cls : '') +
|
|
|
933
939
|
// PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
|
|
934
940
|
// PR in the repos your sessions are in). The daemon fetches with gh (or asks
|
|
935
941
|
// the Mac that has it) and caches ~5 min; while it loads we look again shortly.
|
|
936
|
-
const prsUi = { fresh: false, polls: 0, timer: null, data: {} }
|
|
942
|
+
const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, view: 'mine', filter: {} }
|
|
943
|
+
// 0.8.5: the filters live in the hash (linkable; back/forward work); a chip
|
|
944
|
+
// tap repaints from what we have and reads again only when that is old.
|
|
945
|
+
function prsOpts(view, filter) { return { view, mayEdit: true, sessionAction: prSessionAction, filter, open: prsUi.open, details: prsUi.details, quietOpen: prsUi.quietOpen } }
|
|
946
|
+
function paintPrs(d) {
|
|
947
|
+
const q = document.getElementById('prs-q')
|
|
948
|
+
const focused = q && document.activeElement === q, caret = focused ? q.selectionStart : null
|
|
949
|
+
$app.innerHTML = banners() + prsPageHtml(d, prsOpts(prsUi.view, prsUi.filter))
|
|
950
|
+
if (focused) { const n = document.getElementById('prs-q'); if (n) { n.focus(); try { n.setSelectionRange(caret, caret) } catch {} } }
|
|
951
|
+
}
|
|
952
|
+
// Just the chips and the list (typing in the search box keeps its focus).
|
|
953
|
+
function paintPrsBody() {
|
|
954
|
+
const el = document.getElementById('prs-body'), d = prsUi.data[prsUi.view]
|
|
955
|
+
if (el && d) el.innerHTML = prsBodyHtml(d, prsOpts(prsUi.view, prsUi.filter))
|
|
956
|
+
}
|
|
937
957
|
async function viewPrs() {
|
|
938
958
|
const params = (state.route && state.route.params) || new URLSearchParams()
|
|
939
959
|
let view = params.get('view')
|
|
940
960
|
if (!view) { try { view = localStorage.getItem('leftoff.prsView') } catch {} }
|
|
941
961
|
view = view === 'team' ? 'team' : 'mine'
|
|
942
|
-
|
|
943
|
-
|
|
962
|
+
prsUi.view = view
|
|
963
|
+
prsUi.filter = readPrFilter(params)
|
|
964
|
+
const cached = prsUi.data[view]
|
|
965
|
+
paintPrs(cached || null)
|
|
944
966
|
const fresh = prsUi.fresh; prsUi.fresh = false
|
|
967
|
+
if (cached && !fresh && !cached.loading && !cached.error && Date.now() - (prsUi.at[view] || 0) < 15000) return
|
|
945
968
|
let d
|
|
946
969
|
try { d = await api('GET', '/v1/prs?view=' + view + (fresh ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
|
|
947
970
|
catch (e) { if (e.code === 'login_required') throw e; d = { error: 'Couldn’t load PRs: ' + e.message } }
|
|
948
971
|
if (!(state.route && state.route.name === 'prs')) return
|
|
949
|
-
if (d && d.groups) {
|
|
972
|
+
if (d && (d.rows || d.groups)) {
|
|
950
973
|
// Go / Resume on a row look the session up like a card does.
|
|
951
|
-
for (const
|
|
974
|
+
for (const r of prRows(d)) for (const x of r.sessions || []) if (!state.sessions.some((y) => y.id === x.id)) state.sessions.push(x)
|
|
952
975
|
d.multiMachine = new Set(state.sessions.map((x) => x.machine).filter(Boolean)).size > 1
|
|
953
976
|
}
|
|
954
977
|
prsUi.data[view] = d
|
|
955
|
-
|
|
978
|
+
prsUi.at[view] = Date.now()
|
|
979
|
+
if (prsUi.view === view) paintPrs(d)
|
|
956
980
|
clearTimeout(prsUi.timer)
|
|
957
|
-
if (d && d.loading && prsUi.polls < 6) { prsUi.polls++; prsUi.timer = setTimeout(() => { if (state.route && state.route.name === 'prs') render() }, 2500) }
|
|
981
|
+
if (d && d.loading && prsUi.polls < 6) { prsUi.polls++; prsUi.timer = setTimeout(() => { if (state.route && state.route.name === 'prs') { prsUi.at[view] = 0; render() } }, 2500) }
|
|
958
982
|
else if (!(d && d.loading)) prsUi.polls = 0
|
|
959
983
|
}
|
|
984
|
+
// Tap a PR: it opens in place; its details load once (cached ~5 min by the
|
|
985
|
+
// daemon that has gh, a little here too).
|
|
986
|
+
async function togglePr(url, { retry = false } = {}) {
|
|
987
|
+
if (!url) return
|
|
988
|
+
const was = prsUi.open.has(url)
|
|
989
|
+
if (was && !retry) prsUi.open.delete(url); else prsUi.open.add(url)
|
|
990
|
+
const repaint = () => {
|
|
991
|
+
const d = prsUi.data[prsUi.view]
|
|
992
|
+
const r = prRows(d).find((x) => x.pr.url === url)
|
|
993
|
+
const el = [...document.querySelectorAll('article.prrow[data-pr]')].find((a) => a.dataset.pr === url)
|
|
994
|
+
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] })
|
|
995
|
+
}
|
|
996
|
+
const have = prsUi.details[url]
|
|
997
|
+
if (!prsUi.open.has(url) || (have && !retry && (have.loading || (have.ok && Date.now() - have.at < 60000)))) return repaint()
|
|
998
|
+
prsUi.details[url] = { loading: true }
|
|
999
|
+
repaint()
|
|
1000
|
+
let res
|
|
1001
|
+
try { res = await api('GET', '/v1/prs/detail?url=' + encodeURIComponent(url) + (retry ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
|
|
1002
|
+
catch (e) { if (e.code === 'login_required') throw e; res = { ok: false, code: 'failed' } }
|
|
1003
|
+
prsUi.details[url] = { ...res, at: Date.now() }
|
|
1004
|
+
if (state.route && state.route.name === 'prs') repaint()
|
|
1005
|
+
}
|
|
1006
|
+
function setPrsFilter(f) {
|
|
1007
|
+
const h = prsHash(prsUi.view, f)
|
|
1008
|
+
if (location.hash !== h) location.hash = h
|
|
1009
|
+
}
|
|
1010
|
+
|
|
960
1011
|
function prSessionAction(s) {
|
|
961
1012
|
if (s.live === 'closed' || s.status === 'ended') {
|
|
962
1013
|
const b = resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty })
|
|
@@ -2840,6 +2891,25 @@ document.addEventListener('click', (ev) => {
|
|
|
2840
2891
|
location.hash = c.dataset.href
|
|
2841
2892
|
})
|
|
2842
2893
|
|
|
2894
|
+
// 0.8.5: a tap anywhere on a PR row (not a link or button) opens it in place.
|
|
2895
|
+
document.addEventListener('click', (ev) => {
|
|
2896
|
+
const row = ev.target.closest('article.prrow[data-pr]')
|
|
2897
|
+
if (!row || ev.defaultPrevented || ev.target.closest('a, button, input, textarea, label, summary, details.pd-body, [data-action]')) return
|
|
2898
|
+
if (String(window.getSelection && window.getSelection()).trim()) return
|
|
2899
|
+
togglePr(row.dataset.pr)
|
|
2900
|
+
})
|
|
2901
|
+
document.addEventListener('toggle', (ev) => { if (ev.target && ev.target.matches && ev.target.matches('details.prgroup.quiet')) prsUi.quietOpen = ev.target.open }, true)
|
|
2902
|
+
let prsQTimer = null
|
|
2903
|
+
document.addEventListener('input', (ev) => {
|
|
2904
|
+
if (ev.target.id !== 'prs-q') return
|
|
2905
|
+
prsUi.filter = { ...prsUi.filter, q: ev.target.value }
|
|
2906
|
+
paintPrsBody()
|
|
2907
|
+
// The link follows without a history entry per keystroke.
|
|
2908
|
+
clearTimeout(prsQTimer)
|
|
2909
|
+
prsQTimer = setTimeout(() => { const h = prsHash(prsUi.view, prsUi.filter); if (location.hash !== h) history.replaceState(null, '', h) }, 250)
|
|
2910
|
+
})
|
|
2911
|
+
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 })) } })
|
|
2912
|
+
|
|
2843
2913
|
document.addEventListener('click', async (ev) => {
|
|
2844
2914
|
const btn = ev.target.closest('[data-action]')
|
|
2845
2915
|
if (!btn) return
|
|
@@ -2853,7 +2923,17 @@ document.addEventListener('click', async (ev) => {
|
|
|
2853
2923
|
try {
|
|
2854
2924
|
if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
|
|
2855
2925
|
// PRs (0.6.9)
|
|
2856
|
-
if (action === 'prs-view') { const v = btn.dataset.view === 'team' ? 'team' : 'mine'; try { localStorage.setItem('leftoff.prsView', v) } catch {} location.hash = v
|
|
2926
|
+
if (action === 'prs-view') { const v = btn.dataset.view === 'team' ? 'team' : 'mine'; try { localStorage.setItem('leftoff.prsView', v) } catch {} location.hash = prsHash(v, prsUi.filter); return }
|
|
2927
|
+
// 0.8.5: filters (in the hash), expand a PR in place, the Quiet group
|
|
2928
|
+
if (action === 'prs-filter') {
|
|
2929
|
+
const f = { ...prsUi.filter }, k = btn.dataset.k, v = btn.dataset.v || ''
|
|
2930
|
+
if (k === 'repo') f.repos = v ? toggleRepo(f.repos || [], { key: btn.dataset.key || v, label: v }, ev.shiftKey || ev.metaKey || ev.ctrlKey) : []
|
|
2931
|
+
else if (k === 'state' || k === 'checks') f[k] = f[k] === v ? null : v
|
|
2932
|
+
return setPrsFilter(f)
|
|
2933
|
+
}
|
|
2934
|
+
if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
|
|
2935
|
+
if (action === 'pr-expand') return togglePr(btn.dataset.url)
|
|
2936
|
+
if (action === 'pr-expand-retry') return togglePr(btn.dataset.url, { retry: true })
|
|
2857
2937
|
if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
|
|
2858
2938
|
if (action === 'pr-unlink') {
|
|
2859
2939
|
btn.disabled = true
|
|
@@ -3456,6 +3536,7 @@ let stream = null
|
|
|
3456
3536
|
function connect() {
|
|
3457
3537
|
if (stream) { try { stream.close() } catch {} }
|
|
3458
3538
|
const es = stream = new EventSource('/v1/stream')
|
|
3539
|
+
es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
|
|
3459
3540
|
for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated']) es.addEventListener(t, soon)
|
|
3460
3541
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
3461
3542
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
package/ui/prs.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// loads /v1/prs and /v1/sessions/:id and paints with these. Sessions and PRs
|
|
3
3
|
// are many-to-many; a row shows every session it is linked to.
|
|
4
4
|
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
5
|
+
import { repoChips, labelRepos, repoMatches, chipSelected, orderSelectedFirst } from './repos.js'
|
|
5
6
|
const safeUrl = (u) => (/^https:\/\/github\.com\//.test(String(u || '')) ? String(u) : '')
|
|
6
7
|
|
|
7
8
|
export function shortAgo(iso, now = Date.now()) {
|
|
@@ -54,14 +55,17 @@ export function sessionPrsSection(s, { mayEdit = true } = {}) {
|
|
|
54
55
|
return '<div class="prrow slim"><div class="pr-main">' +
|
|
55
56
|
'<div class="pr-line1"><span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) + '</div>' +
|
|
56
57
|
'<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || 'Pull request #' + p.number) + ' ↗</a>' : esc(p.title || '')) + '</div>' +
|
|
57
|
-
'<div class="pr-meta">' + esc(p.repo || '') + (p
|
|
58
|
+
'<div class="pr-meta">' + esc(p.repo || '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + '</div></div>' +
|
|
58
59
|
(mayEdit ? '<button type="button" class="btn sm" data-action="pr-unlink" data-id="' + esc(s.id) + '" data-url="' + esc(p.url) + '">Unlink</button>' : '') + '</div>'
|
|
59
60
|
}).join('') + '</div>'
|
|
60
61
|
} else h += '<div class="nocp">No pull requests linked yet. They link on their own from gh pr create, PR links in the chat, or the branch.</div>'
|
|
61
62
|
if (mayEdit) h += '<form class="prlink" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false"><button type="submit" class="btn">Link</button></form>'
|
|
62
63
|
return h
|
|
63
64
|
}
|
|
64
|
-
|
|
65
|
+
// 0.8.5: why a PR is linked, in words (the daemon sends created / you / branch / manual).
|
|
66
|
+
export const WHY = { created: 'created here', you: 'you mentioned it', branch: 'same branch', mentioned: 'same branch', manual: 'linked by you' }
|
|
67
|
+
const WHY_ORDER = ['manual', 'created', 'you', 'branch', 'mentioned']
|
|
68
|
+
export function whyText(r) { const v = r && (r.why || (Array.isArray(r.via) ? WHY_ORDER.find((k) => r.via.includes(k)) : r.via)); return v ? WHY[v] || '' : '' }
|
|
65
69
|
|
|
66
70
|
// gh missing or signed out: the 0.6.5 status line with the fix to copy.
|
|
67
71
|
export function ghLineHtml(g) {
|
|
@@ -83,27 +87,196 @@ function sessionLinks(r, opts) {
|
|
|
83
87
|
}).join('') + (ss.length > 4 ? '<span class="faint">+' + (ss.length - 4) + '</span>' : '') + '</div>'
|
|
84
88
|
}
|
|
85
89
|
|
|
86
|
-
// One PR row on the PRs tab.
|
|
87
|
-
|
|
90
|
+
// One PR row on the PRs tab. 0.8.5: tap the row to expand it in place
|
|
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 } = {}) {
|
|
88
93
|
const p = r.pr || {}
|
|
89
94
|
const url = safeUrl(p.url)
|
|
90
95
|
const line1 = '<span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) +
|
|
91
|
-
(r.attention && r.attention !== 'Checks failing' ? '<span class="pr-att">' + esc(r.attention) + '</span>' : p.review && REVIEW[p.review] ? '<span class="pr-review ' + esc(p.review) + '">' + REVIEW[p.review] + '</span>' : '')
|
|
96
|
+
(r.attention && r.attention !== 'Checks failing' ? '<span class="pr-att">' + esc(r.attention) + '</span>' : p.review && REVIEW[p.review] && (p.state === 'open' || p.state === 'draft') ? '<span class="pr-review ' + esc(p.review) + '">' + REVIEW[p.review] + '</span>' : '')
|
|
97
|
+
const when = p.state === 'merged' && p.mergedAt ? 'merged ' + shortAgo(p.mergedAt, now) + ' ago' : p.updatedAt ? 'updated ' + shortAgo(p.updatedAt, now) + ' ago' : ''
|
|
92
98
|
const meta = team
|
|
93
|
-
? [p.
|
|
94
|
-
: [p.repo, p.branch,
|
|
99
|
+
? [p.repo, p.author ? '@' + p.author : '', p.branch || '', when].filter(Boolean)
|
|
100
|
+
: [p.repo, p.branch, when].filter(Boolean)
|
|
101
|
+
const why = whyText(r)
|
|
95
102
|
const unlinkTo = !team && mayEdit && (r.sessions || []).length === 1 ? r.sessions[0] : null
|
|
96
|
-
return '<article class="prrow" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
|
|
103
|
+
return '<article class="prrow' + (open ? ' open' : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
|
|
97
104
|
'<div class="pr-line1">' + line1 + '</div>' +
|
|
98
105
|
'<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || '') + '</a>' : esc(p.title || '')) + '</div>' +
|
|
99
|
-
'<div class="pr-meta">' + meta.map(esc).join(' · ') + '</div>' +
|
|
100
|
-
sessionLinks(r, { sessionAction, showMachine }) + '</div>' +
|
|
101
|
-
'<div class="pr-actions">' +
|
|
106
|
+
'<div class="pr-meta">' + meta.map(esc).join(' · ') + (why ? ' · <span class="pr-why">' + esc(why) + '</span>' : '') + '</div>' +
|
|
107
|
+
sessionLinks(r, { sessionAction, showMachine }) + (open ? prDetailHtml(p, detail, { now }) : '') + '</div>' +
|
|
108
|
+
'<div class="pr-actions">' + '<button type="button" class="btn sm pr-toggle" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + open + '">' + (open ? 'Less ▴' : 'Details ▾') + '</button>' +
|
|
109
|
+
(url ? '<a class="btn sm" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">GitHub ↗</a>' : '') +
|
|
102
110
|
(unlinkTo ? '<button type="button" class="btn sm" data-action="pr-unlink" data-id="' + esc(unlinkTo.id) + '" data-url="' + esc(p.url) + '" title="Unlink from this session">Unlink</button>' : '') + '</div></article>'
|
|
103
111
|
}
|
|
104
112
|
|
|
105
|
-
//
|
|
106
|
-
|
|
113
|
+
// ---------------------------------------------------------------- 0.8.5: details on tap
|
|
114
|
+
|
|
115
|
+
const REVIEW_STATE = { approved: 'approved', changes_requested: 'requested changes', commented: 'commented' }
|
|
116
|
+
const anyUrl = (u) => (/^https:\/\/[^\s"'<>]+$/.test(String(u || '')) ? String(u) : '')
|
|
117
|
+
// Markdown-lite: paragraphs and list lines as plain text, links and code marks dropped to their words.
|
|
118
|
+
export function plainBody(md) {
|
|
119
|
+
return String(md || '').replace(/<!--[\s\S]*?-->/g, '').replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
120
|
+
.replace(/<\/?[a-z][^>]*>/gi, '').replace(/^#{1,6}\s+/gm, '').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1')
|
|
121
|
+
.replace(/^[ \t]*[-*][ \t]+\[x\][ \t]+/gim, '☑ ').replace(/^[ \t]*[-*][ \t]+\[ \][ \t]+/gm, '☐ ').replace(/^[ \t]*[-*][ \t]+/gm, '• ').replace(/\n{3,}/g, '\n\n').trim()
|
|
122
|
+
}
|
|
123
|
+
export function prDetailHtml(p, st, { now = Date.now() } = {}) {
|
|
124
|
+
if (!st || st.loading) return '<div class="pr-detail loading" aria-busy="true">Loading details…</div>'
|
|
125
|
+
if (!st.ok) return '<div class="pr-detail none">' + (st.code === 'no_gh' ? 'Details need gh on a Mac (none of yours has it signed in).' : 'Couldn’t load the details right now. <button type="button" class="linkish" data-action="pr-expand-retry" data-url="' + esc(p.url) + '">Try again</button>') + '</div>'
|
|
126
|
+
const d = st.detail || {}
|
|
127
|
+
let h = '<div class="pr-detail">'
|
|
128
|
+
const bad = (d.checkList || []).filter((c) => c.state !== 'passed')
|
|
129
|
+
const passed = (d.checkList || []).length - bad.length
|
|
130
|
+
if ((d.checkList || []).length) {
|
|
131
|
+
h += '<div class="pd-sec"><div class="pd-h">Checks</div>' + (bad.length ? '<ul class="pd-checks">' + bad.map((c) => {
|
|
132
|
+
const u = anyUrl(c.url)
|
|
133
|
+
const name = esc(c.name) + (c.workflow ? '<span class="faint"> · ' + esc(c.workflow) + '</span>' : '')
|
|
134
|
+
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
|
+
}).join('') + '</ul>' : '') + (passed ? '<div class="pd-pass">✓ ' + passed + (bad.length ? ' passed' : passed === 1 ? ' check passed' : ' checks passed') + '</div>' : '') + '</div>'
|
|
136
|
+
}
|
|
137
|
+
const rv = REVIEW[d.review] || (d.state === 'open' ? 'No review yet' : '')
|
|
138
|
+
const who = (d.reviews || []).map((r) => esc('@' + r.author) + ' ' + esc(REVIEW_STATE[r.state] || r.state))
|
|
139
|
+
if (rv || who.length || (d.reviewRequested || []).length) {
|
|
140
|
+
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>' : '') +
|
|
141
|
+
(who.length ? '<span>' + who.join(', ') + '</span>' : '') +
|
|
142
|
+
((d.reviewRequested || []).length ? '<span class="faint">waiting on ' + d.reviewRequested.map((x) => esc('@' + x)).join(', ') + '</span>' : '') + '</div></div>'
|
|
143
|
+
}
|
|
144
|
+
const stats = [d.changedFiles != null ? d.changedFiles + ' file' + (d.changedFiles === 1 ? '' : 's') : '', d.additions != null ? '<span class="pd-add">+' + d.additions + '</span>' : '', d.deletions != null ? '<span class="pd-del">−' + d.deletions + '</span>' : ''].filter(Boolean)
|
|
145
|
+
const body = plainBody(d.body)
|
|
146
|
+
h += '<div class="pd-sec"><div class="pd-h">Description' + (stats.length ? ' <span class="pd-stats">' + stats.join(' ') + '</span>' : '') + '</div>' +
|
|
147
|
+
(body ? (body.length > 280 || body.split('\n').length > 5 ? '<details class="pd-body"><summary><span class="pd-clip">' + esc(body.split('\n').slice(0, 4).join('\n').slice(0, 280)) + '…</span><span class="linkish pd-more">more</span></summary><div class="pd-text">' + esc(body) + '</div></details>' : '<div class="pd-text">' + esc(body) + '</div>') : '<div class="faint">No description.</div>') + '</div>'
|
|
148
|
+
if ((d.comments || []).length) {
|
|
149
|
+
h += '<div class="pd-sec"><div class="pd-h">Latest comments' + (d.commentCount > d.comments.length ? ' <span class="faint">' + d.comments.length + ' of ' + d.commentCount + '</span>' : '') + '</div><ul class="pd-comments">' + d.comments.map((c) => {
|
|
150
|
+
const first = String(c.body || '').split('\n').filter((x) => x.trim()).slice(0, 2).join(' ')
|
|
151
|
+
return '<li><div class="pd-who">' + esc(c.author ? '@' + c.author : 'someone') + (c.at ? '<span class="faint"> · ' + esc(shortAgo(c.at, now)) + (shortAgo(c.at, now) === 'now' ? '' : ' ago') + '</span>' : '') + '</div><div class="pd-cbody">' + esc(plainBody(first).slice(0, 220)) + '</div></li>'
|
|
152
|
+
}).join('') + '</ul></div>'
|
|
153
|
+
}
|
|
154
|
+
return h + '</div>'
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------- 0.8.5: filters
|
|
158
|
+
|
|
159
|
+
// The page's filter, from the hash (#/prs?view=team&repo=web&state=open&checks=failing&q=search).
|
|
160
|
+
export const PR_STATES = [['attention', 'Needs attention'], ['open', 'Open'], ['draft', 'Draft'], ['merged', 'Merged'], ['closed', 'Closed']]
|
|
161
|
+
export const PR_CHECKS = [['failing', 'Checks failing'], ['passing', 'Checks passing']]
|
|
162
|
+
export const QUIET_DAYS = 14
|
|
163
|
+
const splitList = (v) => String(v || '').split(',').map((x) => x.trim()).filter(Boolean)
|
|
164
|
+
export function readPrFilter(params) {
|
|
165
|
+
const p = typeof params === 'string' ? new URLSearchParams(params.replace(/^.*\?/, '')) : (params || new URLSearchParams())
|
|
166
|
+
const state = p.get('state'), checks = p.get('checks')
|
|
167
|
+
return {
|
|
168
|
+
repos: [...new Set(p.getAll('repo').flatMap(splitList))],
|
|
169
|
+
state: PR_STATES.some(([k]) => k === state) ? state : null,
|
|
170
|
+
checks: PR_CHECKS.some(([k]) => k === checks) ? checks : null,
|
|
171
|
+
q: String(p.get('q') || '').slice(0, 200),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
export function prsHash(view, f = {}) {
|
|
175
|
+
const parts = []
|
|
176
|
+
if (view === 'team') parts.push('view=team')
|
|
177
|
+
if (f.repos && f.repos.length) parts.push('repo=' + f.repos.map((r) => encodeURIComponent(r).replace(/%2F/gi, '/')).join(','))
|
|
178
|
+
if (f.state) parts.push('state=' + f.state)
|
|
179
|
+
if (f.checks) parts.push('checks=' + f.checks)
|
|
180
|
+
if (f.q && f.q.trim()) parts.push('q=' + encodeURIComponent(f.q.trim()))
|
|
181
|
+
return '#/prs' + (parts.length ? '?' + parts.join('&') : '')
|
|
182
|
+
}
|
|
183
|
+
export const prFilterActive = (f) => !!(f && ((f.repos && f.repos.length) || f.state || f.checks || (f.q && f.q.trim())))
|
|
184
|
+
|
|
185
|
+
// Every row, flat: the daemon's `rows`, or an older daemon's groups.
|
|
186
|
+
export function prRows(d) {
|
|
187
|
+
if (!d) return []
|
|
188
|
+
if (Array.isArray(d.rows)) return d.rows
|
|
189
|
+
const seen = new Set(), out = []
|
|
190
|
+
for (const g of d.groups || []) for (const r of g.rows || []) if (r && r.pr && !seen.has(r.pr.url)) { seen.add(r.pr.url); out.push(r) }
|
|
191
|
+
return out.sort((a, b) => String(b.pr.updatedAt || '').localeCompare(String(a.pr.updatedAt || '')))
|
|
192
|
+
}
|
|
193
|
+
const lastAt = (pr) => Math.max(Date.parse(pr.updatedAt || '') || 0, Date.parse(pr.mergedAt || '') || 0)
|
|
194
|
+
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 r.attention && (r.pr.state === 'open' || r.pr.state === 'draft') ? 'attention' : r.pr.state }
|
|
196
|
+
export function inPrState(r, state) { return !state || (state === 'attention' ? !!r.attention && (r.pr.state === 'open' || r.pr.state === 'draft') : r.pr.state === 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
|
+
}
|
|
202
|
+
export function inPrSearch(r, q) {
|
|
203
|
+
const words = String(q || '').toLowerCase().split(/\s+/).filter(Boolean)
|
|
204
|
+
if (!words.length) return true
|
|
205
|
+
const p = r.pr
|
|
206
|
+
const hay = [p.title, p.branch, p.repo, p.author ? '@' + p.author : '', '#' + p.number, String(p.number)].filter(Boolean).join(' ').toLowerCase()
|
|
207
|
+
return words.every((w) => hay.includes(w) || (w.startsWith('#') && String(p.number) === w.slice(1)))
|
|
208
|
+
}
|
|
209
|
+
// The repo chip a PR belongs to: its GitHub "owner/name", labelled by name.
|
|
210
|
+
export function prRepoChips(rows, { countIn = null } = {}) {
|
|
211
|
+
return repoChips(rows.map((r) => ({ repoKey: r.pr.repo || 'no-repo', lastActivityAt: r.pr.updatedAt })), { countIn: countIn && countIn.map((r) => ({ repoKey: r.pr.repo || 'no-repo' })) })
|
|
212
|
+
}
|
|
213
|
+
export function inPrRepos(r, repos, labels = null) {
|
|
214
|
+
if (!repos || !repos.length) return true
|
|
215
|
+
const key = r.pr.repo || 'no-repo'
|
|
216
|
+
return repos.some((t) => repoMatches({ repoKey: key, repoLabel: labels ? labels.get(key) : null }, t))
|
|
217
|
+
}
|
|
218
|
+
export function filterPrRows(rows, f = {}, { skip = null } = {}) {
|
|
219
|
+
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)) && (skip === 'checks' || inPrChecks(r, f.checks)) && inPrSearch(r, f.q))
|
|
221
|
+
}
|
|
222
|
+
// Groups, newest first in each; anything with no activity for 14+ days in Quiet.
|
|
223
|
+
export function groupPrRows(rows, { now = Date.now() } = {}) {
|
|
224
|
+
const g = Object.fromEntries(PR_STATES.map(([k]) => [k, []]))
|
|
225
|
+
const quiet = []
|
|
226
|
+
for (const r of rows) { if (isQuiet(r, now)) quiet.push(r); else (g[prStateOf(r)] || (g[prStateOf(r)] = [])).push(r) }
|
|
227
|
+
const by = (a, b) => lastAt(b.pr) - lastAt(a.pr)
|
|
228
|
+
const out = PR_STATES.map(([key, label]) => ({ key, label, rows: g[key].sort(by) })).filter((x) => x.rows.length)
|
|
229
|
+
return { groups: out, quiet: quiet.sort(by) }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function chipRow(label, chips, cls) {
|
|
233
|
+
return '<div class="repos prs-chips ' + cls + '" role="group" aria-label="' + esc(label) + '"><div class="track">' + chips.join('') + '</div></div>'
|
|
234
|
+
}
|
|
235
|
+
function fchip({ on, count, label, attrs, cls = '' }) {
|
|
236
|
+
return '<button type="button" class="rchip' + (cls ? ' ' + cls : '') + (on ? ' on' : '') + (count ? '' : ' zero') + '" ' + attrs + ' aria-pressed="' + on + '"' + (on && cls !== 'all' ? ' aria-label="' + esc('Remove filter ' + label) + '"' : '') + '><span class="rl">' + esc(label) + '</span><span class="rc">' + count + '</span>' + (on && cls !== 'all' ? '<span class="rx" aria-hidden="true">✕</span>' : '') + '</button>'
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The chip rows and the grouped list (repainted as you type in the search box).
|
|
240
|
+
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, checks: null, q: '', ...filter }
|
|
242
|
+
const all = prRows(d)
|
|
243
|
+
let h = ''
|
|
244
|
+
if (all.length) {
|
|
245
|
+
// Repos: All, then by latest activity; a chosen one always shows, first and removable.
|
|
246
|
+
const repoCount = filterPrRows(all, f, { skip: 'repos' })
|
|
247
|
+
let chips = prRepoChips(all, { countIn: repoCount })
|
|
248
|
+
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
|
+
chips = orderSelectedFirst(chips, f.repos)
|
|
250
|
+
if (chips.length > 1 || f.repos.length) {
|
|
251
|
+
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
|
+
.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
|
+
}
|
|
254
|
+
const stCount = filterPrRows(all, f, { skip: 'state' }), ckCount = filterPrRows(all, f, { skip: 'checks' })
|
|
255
|
+
const sts = PR_STATES.map(([k, l]) => ({ k: 'state', v: k, l, n: stCount.filter((r) => inPrState(r, k)).length, on: f.state === k }))
|
|
256
|
+
const cks = PR_CHECKS.map(([k, l]) => ({ k: 'checks', v: k, l, n: ckCount.filter((r) => inPrChecks(r, k)).length, on: f.checks === k }))
|
|
257
|
+
const both = [...sts, ...cks]
|
|
258
|
+
const ordered = [...both.filter((x) => x.on), ...both.filter((x) => !x.on && (x.n || x.k === 'state'))]
|
|
259
|
+
h += chipRow('Filter by state and checks', ordered.map((x) => fchip({ on: x.on, count: x.n, label: x.l, cls: x.k === 'checks' ? 'ck' : '', attrs: 'data-action="prs-filter" data-k="' + x.k + '" data-v="' + x.v + '"' })), 'prs-states')
|
|
260
|
+
}
|
|
261
|
+
const rows = filterPrRows(all, f)
|
|
262
|
+
const { groups, quiet } = groupPrRows(rows, { now })
|
|
263
|
+
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 })
|
|
265
|
+
if (!all.length) {
|
|
266
|
+
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
|
+
} else if (!rows.length) {
|
|
268
|
+
h += '<div class="empty">No PRs match. <button type="button" class="linkish" data-action="prs-clear">Clear filters</button></div>'
|
|
269
|
+
} else {
|
|
270
|
+
for (const g of groups) h += '<section class="prgroup' + (g.key === 'attention' ? ' 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
|
+
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
|
+
}
|
|
273
|
+
if (view === 'team' && (d.failed || []).length) h += '<div class="hint">Couldn’t load ' + esc(d.failed.join(', ')) + ' right now.</div>'
|
|
274
|
+
return h
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The whole tab: Mine / Team switch, the search box, the filters, the list.
|
|
278
|
+
export function prsPageHtml(d, opts = {}) {
|
|
279
|
+
const { view = 'mine', now = Date.now(), filter = {} } = opts
|
|
107
280
|
let h = '<div class="pagehead"><div><h1>Pull requests</h1><div class="sub">' + (view === 'team' ? 'Every open PR in the repos you work in.' : 'PRs linked to your sessions.') + '</div></div></div>'
|
|
108
281
|
h += '<div class="filters prs-filters"><div class="seg" role="group" aria-label="Whose PRs">' +
|
|
109
282
|
[['mine', 'Mine'], ['team', 'Team']].map(([k, l]) => '<button type="button" data-action="prs-view" data-view="' + k + '" aria-pressed="' + (view === k) + '">' + l + '</button>').join('') +
|
|
@@ -111,17 +284,8 @@ export function prsPageHtml(d, { view = 'mine', now = Date.now(), mayEdit = true
|
|
|
111
284
|
if (!d) return h + '<div class="empty">Loading pull requests…</div>'
|
|
112
285
|
if (d.error) return h + '<div class="banner warn"><span>' + esc(d.error) + '</span></div>'
|
|
113
286
|
if (d.gh && d.gh.status !== 'ready') return h + ghLineHtml(d.gh)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (!groups.length) {
|
|
117
|
-
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, mentions a PR, or works on a branch with an open PR.') + '</div>'
|
|
118
|
-
} else {
|
|
119
|
-
for (const g of groups) {
|
|
120
|
-
h += '<section class="prgroup' + (g.key === 'attention' ? ' attention' : '') + '"><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span></h2>'
|
|
121
|
-
if (g.failed && !g.rows.length) h += '<div class="hint">Couldn’t load this repo’s PRs right now.</div>'
|
|
122
|
-
h += g.rows.map((r) => prRowHtml(r, { team: view === 'team', now, mayEdit, sessionAction, showMachine })).join('') + '</section>'
|
|
123
|
-
}
|
|
124
|
-
}
|
|
287
|
+
if (prRows(d).length || prFilterActive(filter)) h += '<div class="prs-search"><input type="search" id="prs-q" class="prs-q" placeholder="Search title, branch, #number, repo" aria-label="Search pull requests" autocapitalize="off" autocomplete="off" spellcheck="false" value="' + esc(filter.q || '') + '"></div>'
|
|
288
|
+
h += '<div id="prs-body">' + prsBodyHtml(d, opts) + '</div>'
|
|
125
289
|
const foot = [d.servedBy && d.machine && d.servedBy !== d.machine ? 'From ' + d.servedBy + '’s gh' : '', d.loading ? 'Updating…' : d.updatedAt ? 'Updated ' + shortAgo(d.updatedAt, now) + (shortAgo(d.updatedAt, now) === 'now' ? '' : ' ago') : ''].filter(Boolean)
|
|
126
290
|
if (foot.length) h += '<div class="prs-foot faint">' + esc(foot.join(' · ')) + '</div>'
|
|
127
291
|
return h
|
package/ui/viewer.js
CHANGED
|
@@ -12,8 +12,40 @@ export function isPhoneUa(ua, { maxTouchPoints = 0 } = {}) {
|
|
|
12
12
|
return false
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
// "Set up this Mac too": only a desktop browser on another computer, until
|
|
16
|
-
|
|
15
|
+
// "Set up this Mac too": only a desktop browser on another computer, until
|
|
16
|
+
// dismissed, and (0.8.4) only once this computer's own Leftoff didn't answer:
|
|
17
|
+
// `here` is false after localLeftoffAnswers() said no, true when it answered,
|
|
18
|
+
// null/undefined while not asked yet (no banner, so it never flashes).
|
|
19
|
+
export function setupCandidate({ viewer, ua, maxTouchPoints = 0, dismissed = false } = {}) {
|
|
17
20
|
if (!viewer || !viewer.remote || !viewer.setup || dismissed) return false
|
|
18
21
|
return !isPhoneUa(ua, { maxTouchPoints })
|
|
19
22
|
}
|
|
23
|
+
export function wantsSetupBanner({ here, ...rest } = {}) {
|
|
24
|
+
return here === false && setupCandidate(rest)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 0.8.4: does this computer already run Leftoff? Ask its daemon on the default
|
|
28
|
+
// port (GET /v1/here: an empty 204, presence only). Chrome's Local Network
|
|
29
|
+
// Access shows a permission prompt before a page may reach this computer's
|
|
30
|
+
// own ports; a banner isn't worth a prompt, so when the permission isn't
|
|
31
|
+
// already granted, don't ask (the daemon's tailnet check still applies).
|
|
32
|
+
export const LOCAL_PORT = 7429
|
|
33
|
+
export async function localLeftoffAnswers({ fetchImpl = (...a) => fetch(...a), permissions = (typeof navigator !== 'undefined' && navigator.permissions) || null, timeoutMs = 1500, port = LOCAL_PORT } = {}) {
|
|
34
|
+
if (await wouldPrompt(permissions)) return false
|
|
35
|
+
try {
|
|
36
|
+
const r = await fetchImpl('http://127.0.0.1:' + port + '/v1/here', { mode: 'cors', credentials: 'omit', cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) })
|
|
37
|
+
return r.status === 204 && r.headers.get('x-leftoff') === 'here'
|
|
38
|
+
} catch { return false }
|
|
39
|
+
}
|
|
40
|
+
// The permission names Chrome has used for it; a browser that knows neither has no such prompt.
|
|
41
|
+
async function wouldPrompt(permissions) {
|
|
42
|
+
if (!permissions || typeof permissions.query !== 'function') return false
|
|
43
|
+
let known = false
|
|
44
|
+
for (const name of ['loopback-network', 'local-network-access']) {
|
|
45
|
+
let s
|
|
46
|
+
try { s = await permissions.query({ name }) } catch { continue }
|
|
47
|
+
known = true
|
|
48
|
+
if (s && s.state === 'granted') return false
|
|
49
|
+
}
|
|
50
|
+
return known
|
|
51
|
+
}
|