@leftoff/cli 0.8.3 → 0.8.6
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 +29 -0
- package/assets/sample-transcript.jsonl +11 -11
- package/bin/leftoff.js +317 -306
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +43 -2
- package/mod/hooks/register.js +17 -4
- package/package.json +1 -1
- package/ui/app.css +63 -0
- package/ui/app.js +183 -16
- package/ui/machines.js +43 -0
- package/ui/prs.js +188 -24
- package/ui/repos.js +19 -0
- package/ui/viewer.js +49 -2
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/repos.js
CHANGED
|
@@ -198,3 +198,22 @@ export function splitChips(chips, selected = [], max = 8) {
|
|
|
198
198
|
for (const c of chips.slice(max)) (isSel(c) ? shown : more).push(c)
|
|
199
199
|
return { shown, more }
|
|
200
200
|
}
|
|
201
|
+
|
|
202
|
+
// 0.8.6: find a session on the board. Every word must match one of: title,
|
|
203
|
+
// repo, branch, worktree, folder, last reply, first prompt, checkpoint text
|
|
204
|
+
// (extra), or the session id by prefix (paste it from /status, or cp:xxxxxx).
|
|
205
|
+
export function searchMatches(s, q, extra = []) {
|
|
206
|
+
const words = String(q || '').toLowerCase().trim().split(/\s+/).filter(Boolean)
|
|
207
|
+
if (!words.length) return true
|
|
208
|
+
const cp = s.lastCheckpoint || null
|
|
209
|
+
const hay = [s.title, s.repoLabel, s.repoName, s.repo && s.repo.branch, s.repo && s.repo.github, s.worktree && s.worktree.name, s.worktree && s.worktree.branch, s.cwd, s.lastPreview, s.firstPrompt, s.machine,
|
|
210
|
+
cp && cp.doing, cp && cp.next, cp && cp.stoppedAt, ...extra].filter(Boolean).map((h) => String(h).toLowerCase())
|
|
211
|
+
const id = String(s.id || '').toLowerCase()
|
|
212
|
+
const short = String(s.shortId || '').toLowerCase()
|
|
213
|
+
return words.every((w) => {
|
|
214
|
+
const bare = w.replace(/^cp:/, '')
|
|
215
|
+
if (bare.length >= 4 && (id.startsWith(bare) || (short && short === bare))) return true
|
|
216
|
+
if (w.startsWith('cp:')) return false
|
|
217
|
+
return hay.some((h) => h.includes(w))
|
|
218
|
+
})
|
|
219
|
+
}
|
package/ui/viewer.js
CHANGED
|
@@ -12,8 +12,55 @@ 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
|
+
}
|
|
52
|
+
|
|
53
|
+
// 0.8.6: which machine is "this one" for the person looking: the board's own
|
|
54
|
+
// Mac on its localhost; over the tailnet the Mac whois named (when the folder
|
|
55
|
+
// knows it); over the relay or unknown, none (never the serving Mac by default).
|
|
56
|
+
export function viewerMachine(health) {
|
|
57
|
+
const h = health || {}
|
|
58
|
+
const v = h.viewer || null
|
|
59
|
+
if (!v) return null
|
|
60
|
+
if (v.remote === false) return v.machine || h.machine || null
|
|
61
|
+
return v.machine || null
|
|
62
|
+
}
|
|
63
|
+
export function thisOneSuffix(machine, health) {
|
|
64
|
+
const me = viewerMachine(health)
|
|
65
|
+
return me && machine === me ? ' (this one)' : ''
|
|
66
|
+
}
|