@leftoff/cli 0.8.0 → 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/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
@@ -21,11 +21,27 @@ export function repoKeyOf(s) {
21
21
  // Short labels: the folder name, and for folder names that collide, as many
22
22
  // parent folders as it takes to tell them apart ("colorGenie/expo" vs
23
23
  // "food_fight/expo"). keys -> Map(key -> label).
24
- export function labelRepos(keys) {
24
+ // 0.8.3: `names` (Map key -> name) is what a repo is called when that's not
25
+ // its folder (its origin remote's name); two repos with one name fall back to
26
+ // their folders. "/" and a bare home folder are "No repo", never "/" or an
27
+ // account name.
28
+ export const NO_REPO_LABEL = 'No repo'
29
+ export function isNoRepoDir(p, home = null) {
30
+ const s = String(p || '').replace(/\/+$/, '')
31
+ if (!s || s === '~' || p === '/' || /^[A-Za-z]:\\?$/.test(s)) return true
32
+ if (home && s === String(home).replace(/\/+$/, '')) return true
33
+ return /^\/(Users|home)\/[^/]+$/.test(s) || s === '/root' || s === '/var/root'
34
+ }
35
+ export function labelRepos(keys, { names = null } = {}) {
25
36
  const uniq = [...new Set([...keys].filter(Boolean))]
26
37
  const depth = new Map(uniq.map((k) => [k, 1]))
38
+ const nameOf = (k) => (names && names.get(k)) || null
39
+ const named = new Map()
40
+ for (const k of uniq) { const n = nameOf(k); if (n) named.set(n, (named.get(n) || 0) + 1) }
27
41
  const label = (k) => {
28
- if (k === 'no-repo') return 'no repo'
42
+ if (k === 'no-repo' || isNoRepoDir(k)) return NO_REPO_LABEL
43
+ const n = nameOf(k)
44
+ if (n && named.get(n) === 1 && depth.get(k) === 1 && !uniq.some((o) => o !== k && !nameOf(o) && baseOf(o) === n)) return n
29
45
  const s = segments(k)
30
46
  return s.length ? s.slice(-Math.min(depth.get(k), s.length)).join('/') : k
31
47
  }
@@ -168,6 +184,12 @@ export function inStatus(s, status) {
168
184
 
169
185
  // Which chips show in the row and which go behind "More ▾": the top `max` by
170
186
  // recent activity, plus any selected one that would otherwise be hidden.
187
+ // 0.8.3: chosen chips first, in the order chosen, then the rest as they were.
188
+ export function orderSelectedFirst(chips, selected = []) {
189
+ if (!selected || !selected.length) return chips
190
+ const sel = chips.filter((c) => chipSelected(selected, c))
191
+ return [...sel, ...chips.filter((c) => !sel.includes(c))]
192
+ }
171
193
  export function splitChips(chips, selected = [], max = 8) {
172
194
  if (chips.length <= max) return { shown: chips, more: [] }
173
195
  const isSel = (c) => chipSelected(selected, c)
package/ui/viewer.js ADDED
@@ -0,0 +1,51 @@
1
+ // 0.8.3: who's looking at the board. The daemon says whether the request came
2
+ // from another computer (a tailnet or LAN name, the relay); the browser tells
3
+ // a phone or tablet from a desktop by its user agent. Plain functions, no DOM.
4
+
5
+ // Phones and tablets (an iPad asking for the desktop site says "Macintosh"
6
+ // but has a touch screen: maxTouchPoints > 1).
7
+ export function isPhoneUa(ua, { maxTouchPoints = 0 } = {}) {
8
+ const u = String(ua || '')
9
+ if (/iPhone|iPod|Android.*Mobile|Mobile Safari|Windows Phone|BlackBerry|Opera Mini|IEMobile/i.test(u)) return true
10
+ if (/iPad|Android(?!.*Mobile)|Tablet|Silk/i.test(u)) return true
11
+ if (/Macintosh/.test(u) && maxTouchPoints > 1) return true
12
+ return false
13
+ }
14
+
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 } = {}) {
20
+ if (!viewer || !viewer.remote || !viewer.setup || dismissed) return false
21
+ return !isPhoneUa(ua, { maxTouchPoints })
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
+ }