@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/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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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.via ? ' · ' + esc(VIA[p.via] || p.via) : '') + '</div></div>' +
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
- const VIA = { created: 'opened here', mentioned: 'mentioned', branch: 'this branch', manual: 'linked by hand' }
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
- export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false } = {}) {
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.author ? '@' + p.author : '', p.createdAt ? 'opened ' + shortAgo(p.createdAt, now) + ' ago' : '', p.branch || ''].filter(Boolean)
94
- : [p.repo, p.branch, p.state === 'merged' && p.mergedAt ? 'merged ' + shortAgo(p.mergedAt, now) + ' ago' : p.updatedAt ? 'updated ' + shortAgo(p.updatedAt, now) + ' ago' : ''].filter(Boolean)
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">' + (url ? '<a class="btn sm" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">GitHub ↗</a>' : '') +
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
- // The whole tab: Mine / Team switch, the gh line, groups.
106
- export function prsPageHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null } = {}) {
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
- const groups = (d.groups || []).filter((g) => g.rows.length || (view === 'team' && g.failed))
115
- const showMachine = !!d.multiMachine
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 dismissed.
16
- export function wantsSetupBanner({ viewer, ua, maxTouchPoints = 0, dismissed = false } = {}) {
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
+ }