@leftoff/cli 0.8.8 → 0.9.0
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 +31 -1
- package/assets/sample-transcript.jsonl +12 -12
- package/bin/leftoff.js +377 -371
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +2 -1
- package/mod/hooks/register.js +14 -4
- package/package.json +1 -1
- package/ui/app.css +138 -16
- package/ui/app.js +348 -56
- package/ui/changelog-md.js +6 -2
- package/ui/handoffs.js +62 -5
- package/ui/icons.js +4 -0
- package/ui/picker.js +38 -1
- package/ui/prs.js +134 -43
- package/ui/repos.js +23 -3
package/ui/changelog-md.js
CHANGED
|
@@ -15,11 +15,13 @@ const anchor = (href, inner) => '<a href="' + esc(href) + '" target="_blank" rel
|
|
|
15
15
|
const docLink = (u) => /^#\/docs\/[a-z-]+$/.test(String(u || '').trim())
|
|
16
16
|
|
|
17
17
|
// One line of inline markdown -> HTML. Everything that isn't markup is escaped.
|
|
18
|
-
export function mdInline(s, { links = true } = {}) {
|
|
18
|
+
export function mdInline(s, { links = true, em = false } = {}) {
|
|
19
19
|
const held = []
|
|
20
20
|
const hold = (html) => '\u0000' + (held.push(html) - 1) + '\u0000'
|
|
21
21
|
const back = (t) => t.replace(/\u0000(\d+)\u0000/g, (_, i) => held[Number(i)])
|
|
22
|
-
|
|
22
|
+
// 0.8.9 (em: the file viewer): *italic*, _italic_ (not inside snake_case) and ~~struck~~ too
|
|
23
|
+
const emph = (t) => !em ? t : t.replace(/~~([^~\s][^~]*?)~~/g, '<del>$1</del>').replace(/(^|[^\w*])\*([^*\s][^*]*?)\*(?![\w*])/g, '$1<em>$2</em>').replace(/(^|[^\w])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>')
|
|
24
|
+
const marks = (t) => emph(esc(t).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>'))
|
|
23
25
|
let t = String(s == null ? '' : s)
|
|
24
26
|
// `code` first, so nothing inside it is read as markup.
|
|
25
27
|
t = t.replace(/`([^`]+)`/g, (_, c) => hold('<code>' + esc(c) + '</code>'))
|
|
@@ -28,6 +30,8 @@ export function mdInline(s, { links = true } = {}) {
|
|
|
28
30
|
const inner = back(marks(text))
|
|
29
31
|
return hold(links && httpsUrl(url) ? anchor(url, inner) : links && docLink(url) ? '<a href="' + esc(url.trim()) + '">' + inner + '</a>' : inner)
|
|
30
32
|
})
|
|
33
|
+
// with em on, bare links are held first so an _ or * in a URL stays a URL
|
|
34
|
+
if (links && em) t = t.replace(/(^|[\s(])(https?:\/\/[^\s<)\u0000]+[^\s<).,\u0000])/g, (_, pre, url) => pre + hold(anchor(url, esc(url))))
|
|
31
35
|
t = marks(t)
|
|
32
36
|
if (links) t = t.replace(/(^|[\s(])(https?:\/\/[^\s<)\u0000]+[^\s<).,\u0000])/g, (_, pre, url) => pre + anchor(url.replace(/&/g, '&'), url))
|
|
33
37
|
return back(t)
|
package/ui/handoffs.js
CHANGED
|
@@ -103,14 +103,52 @@ export function sessionFilesSection(s, { agoFn = null } = {}) {
|
|
|
103
103
|
|
|
104
104
|
// ------------------------------------------------------------------ viewer
|
|
105
105
|
|
|
106
|
+
// 0.8.9: a light tint for code blocks, not a real highlighter: comments,
|
|
107
|
+
// strings, numbers and common keywords get a class (.tk-c/.tk-s/.tk-n/.tk-k),
|
|
108
|
+
// everything escaped. Plain text, diffs and unknown languages: diff lines
|
|
109
|
+
// tinted, nothing else.
|
|
110
|
+
const KEYWORDS = new Set(('if else for while do switch case break continue return function const let var class new this import export from default async await try catch finally throw typeof instanceof in of yield ' +
|
|
111
|
+
'def lambda elif pass with as is not and or None True False self raise except global nonlocal ' +
|
|
112
|
+
'fn pub mut impl struct enum trait match use mod crate where loop ' +
|
|
113
|
+
'func package type interface go defer chan select range map ' +
|
|
114
|
+
'true false null undefined nil void public private protected static final extends implements ' +
|
|
115
|
+
'echo then fi esac done local export readonly ' +
|
|
116
|
+
'SELECT FROM WHERE JOIN LEFT RIGHT INNER OUTER ON GROUP BY ORDER LIMIT INSERT INTO VALUES UPDATE SET DELETE CREATE TABLE INDEX AND OR NOT AS WITH').split(' '))
|
|
117
|
+
const PROSE_LANGS = new Set(['', 'text', 'txt', 'plain', 'plaintext', 'markdown', 'md', 'output', 'console', 'log'])
|
|
118
|
+
export function tintCode(code, lang = '') {
|
|
119
|
+
const src = String(code == null ? '' : code)
|
|
120
|
+
lang = String(lang || '').toLowerCase()
|
|
121
|
+
if (lang === 'diff' || lang === 'patch') return src.split('\n').map((l) => /^\+(?!\+\+)/.test(l) ? '<span class="tk-add">' + esc(l) + '</span>' : /^-(?!--)/.test(l) ? '<span class="tk-del">' + esc(l) + '</span>' : /^@@/.test(l) ? '<span class="tk-k">' + esc(l) + '</span>' : esc(l)).join('\n')
|
|
122
|
+
if (PROSE_LANGS.has(lang)) return esc(src)
|
|
123
|
+
const hash = /^(sh|bash|zsh|shell|py|python|rb|ruby|yaml|yml|toml|ini|conf|dockerfile|make|makefile|r|perl|pl|elixir|ex)$/.test(lang)
|
|
124
|
+
const dash = /^(sql|lua|haskell|hs)$/.test(lang)
|
|
125
|
+
const re = new RegExp([
|
|
126
|
+
hash ? '(#[^\\n]*)' : dash ? '(--[^\\n]*)' : '(\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)',
|
|
127
|
+
'("(?:[^"\\\\\\n]|\\\\.)*"|\'(?:[^\'\\\\\\n]|\\\\.)*\'|`(?:[^`\\\\]|\\\\.)*`)',
|
|
128
|
+
'\\b(\\d+(?:\\.\\d+)?)\\b',
|
|
129
|
+
'\\b([A-Za-z_][A-Za-z0-9_]*)\\b',
|
|
130
|
+
].join('|'), 'g')
|
|
131
|
+
let out = '', last = 0, m
|
|
132
|
+
while ((m = re.exec(src))) {
|
|
133
|
+
out += esc(src.slice(last, m.index))
|
|
134
|
+
if (m[1] != null) out += '<span class="tk-c">' + esc(m[1]) + '</span>'
|
|
135
|
+
else if (m[2] != null) out += '<span class="tk-s">' + esc(m[2]) + '</span>'
|
|
136
|
+
else if (m[3] != null) out += '<span class="tk-n">' + esc(m[3]) + '</span>'
|
|
137
|
+
else out += KEYWORDS.has(m[4]) ? '<span class="tk-k">' + esc(m[4]) + '</span>' : esc(m[4])
|
|
138
|
+
last = re.lastIndex
|
|
139
|
+
}
|
|
140
|
+
return out + esc(src.slice(last))
|
|
141
|
+
}
|
|
142
|
+
|
|
106
143
|
// Markdown -> safe HTML: headings, paragraphs, lists, task boxes, quotes,
|
|
107
144
|
// fenced code, tables, rules. Inline markup via mdInline (escaped; https links only).
|
|
145
|
+
const mdi = (t) => mdInline(t, { em: true })
|
|
108
146
|
export function renderDoc(md) {
|
|
109
147
|
const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').replace(/^---\n[\s\S]*?\n---\n/, '').split('\n')
|
|
110
148
|
const out = []
|
|
111
149
|
let i = 0
|
|
112
150
|
const para = []
|
|
113
|
-
const flush = () => { if (para.length) { out.push('<p>' + para.map((x) =>
|
|
151
|
+
const flush = () => { if (para.length) { out.push('<p>' + para.map((x) => mdi(x)).join('<br>') + '</p>'); para.length = 0 } }
|
|
114
152
|
while (i < lines.length) {
|
|
115
153
|
const ln = lines[i]
|
|
116
154
|
let m
|
|
@@ -120,11 +158,12 @@ export function renderDoc(md) {
|
|
|
120
158
|
i++
|
|
121
159
|
while (i < lines.length && !lines[i].trim().startsWith(fence)) body.push(lines[i++])
|
|
122
160
|
i++
|
|
123
|
-
|
|
161
|
+
const lang = (m[2] || '').toLowerCase()
|
|
162
|
+
out.push('<pre class="doc-code"' + (lang ? ' data-lang="' + esc(lang) + '"' : '') + '><code>' + tintCode(body.join('\n'), lang) + '</code></pre>')
|
|
124
163
|
continue
|
|
125
164
|
}
|
|
126
165
|
if (!ln.trim()) { flush(); i++; continue }
|
|
127
|
-
if ((m = ln.match(/^(#{1,6})\s+(.*)$/))) { flush(); const n = Math.min(6, m[1].length + 1); out.push('<h' + n + '>' +
|
|
166
|
+
if ((m = ln.match(/^(#{1,6})\s+(.*)$/))) { flush(); const n = Math.min(6, m[1].length + 1); out.push('<h' + n + '>' + mdi(m[2].replace(/\s+#+\s*$/, '')) + '</h' + n + '>'); i++; continue }
|
|
128
167
|
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(ln)) { flush(); out.push('<hr>'); i++; continue }
|
|
129
168
|
if (/^\s*>/.test(ln)) {
|
|
130
169
|
flush(); const q = []
|
|
@@ -137,7 +176,7 @@ export function renderDoc(md) {
|
|
|
137
176
|
const head = cells(ln); i += 2
|
|
138
177
|
const rows = []
|
|
139
178
|
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(cells(lines[i++]))
|
|
140
|
-
out.push('<div class="doc-table"><table><thead><tr>' + head.map((c) => '<th>' +
|
|
179
|
+
out.push('<div class="doc-table"><table><thead><tr>' + head.map((c) => '<th>' + mdi(c) + '</th>').join('') + '</tr></thead><tbody>' + rows.map((r) => '<tr>' + r.map((c) => '<td>' + mdi(c) + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>')
|
|
141
180
|
continue
|
|
142
181
|
}
|
|
143
182
|
if (/^\s*(?:[-*+]|\d{1,3}[.)])\s+/.test(ln)) {
|
|
@@ -150,7 +189,7 @@ export function renderDoc(md) {
|
|
|
150
189
|
if (mm) items.push({ depth: Math.floor(mm[1].replace(/\t/g, ' ').length / 2), text: mm[2] })
|
|
151
190
|
else items[items.length - 1].text += ' ' + l.trim()
|
|
152
191
|
}
|
|
153
|
-
const li = (t) => { const tm = t.match(/^\[([ xX])\]\s+(.*)$/); return tm ? '<li class="task"><span class="tbox' + (tm[1] !== ' ' ? ' on' : '') + '" aria-hidden="true"></span>' +
|
|
192
|
+
const li = (t) => { const tm = t.match(/^\[([ xX])\]\s+(.*)$/); return tm ? '<li class="task"><span class="tbox' + (tm[1] !== ' ' ? ' on' : '') + '" aria-hidden="true"></span>' + mdi(tm[2]) + '</li>' : '<li>' + mdi(t) + '</li>' }
|
|
154
193
|
out.push('<' + (ordered ? 'ol' : 'ul') + '>' + items.map((it) => (it.depth ? li(it.text).replace('<li', '<li class="sub"') : li(it.text))).join('') + '</' + (ordered ? 'ol' : 'ul') + '>')
|
|
155
194
|
continue
|
|
156
195
|
}
|
|
@@ -174,6 +213,24 @@ export function docViewerBody(r) {
|
|
|
174
213
|
if (r.type === 'text' || r.type === 'html') return '<pre class="doc-pre">' + esc(r.text || '') + '</pre>'
|
|
175
214
|
return '<div class="empty">No preview for this kind of file.</div>'
|
|
176
215
|
}
|
|
216
|
+
// 0.8.9: what the viewer's Download menu offers for a file: Copy (the raw text),
|
|
217
|
+
// the file itself, and for markdown a PDF (the browser's print dialog, "Save as
|
|
218
|
+
// PDF"). Images and other binaries keep a plain Download (no menu).
|
|
219
|
+
export const PDF_HINT = 'Opens the print dialog: choose “Save as PDF”'
|
|
220
|
+
export function docMenuItems(r) {
|
|
221
|
+
if (!r || r.missing || r.tooBig || !docDownloadHref(r)) return []
|
|
222
|
+
const text = typeof r.text === 'string' && !r.base64 && r.type !== 'image'
|
|
223
|
+
const items = []
|
|
224
|
+
if (text) items.push({ key: 'copy', label: r.type === 'markdown' ? 'Copy' : 'Copy text', title: 'Copy the file’s text' })
|
|
225
|
+
items.push({ key: 'download', label: r.type === 'markdown' ? 'Download as Markdown' : 'Download', title: 'Save ' + docFileName(r) })
|
|
226
|
+
if (r.type === 'markdown') items.push({ key: 'pdf', label: 'Download as PDF…', title: PDF_HINT })
|
|
227
|
+
return items
|
|
228
|
+
}
|
|
229
|
+
// The name a download is saved as (a plan has no extension of its own).
|
|
230
|
+
export function docFileName(r, fallback = 'file') {
|
|
231
|
+
const n = String((r && r.name) || fallback).split('/').pop() || fallback
|
|
232
|
+
return r && r.plan && !/\.md$/i.test(n) ? n + '.md' : n
|
|
233
|
+
}
|
|
177
234
|
// A download for anything (an HTML file is offered as a file, never rendered live).
|
|
178
235
|
export function docDownloadHref(r) {
|
|
179
236
|
if (!r || r.missing || r.tooBig) return ''
|
package/ui/icons.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// so they follow light and dark; always aria-hidden next to a text label.
|
|
4
4
|
// index.html carries the nav's copies inline (no script needed to draw them).
|
|
5
5
|
export const ICONS = {
|
|
6
|
+
"folder": "<path d=\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\"/>",
|
|
7
|
+
"trash": "<path d=\"M3 6h18\"/> <path d=\"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6\"/> <path d=\"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2\"/> <line x1=\"10\" x2=\"10\" y1=\"11\" y2=\"17\"/> <line x1=\"14\" x2=\"14\" y1=\"11\" y2=\"17\"/>",
|
|
6
8
|
"clipboard": "<rect width=\"8\" height=\"4\" x=\"8\" y=\"2\" rx=\"1\" ry=\"1\"/> <path d=\"M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2\"/>",
|
|
7
9
|
"layout-dashboard": "<rect width=\"7\" height=\"9\" x=\"3\" y=\"3\" rx=\"1\"/> <rect width=\"7\" height=\"5\" x=\"14\" y=\"3\" rx=\"1\"/> <rect width=\"7\" height=\"9\" x=\"14\" y=\"12\" rx=\"1\"/> <rect width=\"7\" height=\"5\" x=\"3\" y=\"16\" rx=\"1\"/>",
|
|
8
10
|
"coffee": "<path d=\"M10 2v2\"/> <path d=\"M14 2v2\"/> <path d=\"M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1\"/> <path d=\"M6 2v2\"/>",
|
|
@@ -32,6 +34,8 @@ export const ICONS = {
|
|
|
32
34
|
"chevron-left": "<path d=\"m15 18-6-6 6-6\"/>",
|
|
33
35
|
"mail": "<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"/> <path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/>",
|
|
34
36
|
"search": "<circle cx=\"11\" cy=\"11\" r=\"8\"/> <path d=\"m21 21-4.3-4.3\"/>",
|
|
37
|
+
"file-text": "<path d=\"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z\"/> <path d=\"M14 2v4a2 2 0 0 0 2 2h4\"/> <path d=\"M10 9H8\"/> <path d=\"M16 13H8\"/> <path d=\"M16 17H8\"/>",
|
|
38
|
+
"pencil": "<path d=\"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z\"/> <path d=\"m15 5 4 4\"/>",
|
|
35
39
|
"copy": "<rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/> <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/>",
|
|
36
40
|
"ellipsis": "<circle cx=\"12\" cy=\"12\" r=\"1\"/> <circle cx=\"19\" cy=\"12\" r=\"1\"/> <circle cx=\"5\" cy=\"12\" r=\"1\"/>",
|
|
37
41
|
"pin": "<path d=\"M12 17v5\"/> <path d=\"M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z\"/>",
|
package/ui/picker.js
CHANGED
|
@@ -367,12 +367,49 @@ export function actionMenuKey(anchor, e, open) {
|
|
|
367
367
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); open(); if (act && e.key === 'ArrowUp') actFocus(-1) }
|
|
368
368
|
}
|
|
369
369
|
|
|
370
|
+
// 0.9.0: a one-field form in the same popover / bottom sheet ("New project…",
|
|
371
|
+
// "Rename project…"): a text box (16px on touch, like every input) and one
|
|
372
|
+
// button. Enter submits, Escape or a tap outside cancels. onSubmit(value) may
|
|
373
|
+
// return a promise; an error it throws shows under the box and keeps it open.
|
|
374
|
+
export function openNameSheet(anchor, { title = '', label = 'Name', value = '', placeholder = '', submit = 'Save', onSubmit } = {}) {
|
|
375
|
+
closeActionMenu(); closeMenu()
|
|
376
|
+
const sheet = narrow()
|
|
377
|
+
const id = 'lo-act' + (++uid)
|
|
378
|
+
const menu = document.createElement('div')
|
|
379
|
+
menu.className = 'lo-menu lo-actmenu lo-namesheet' + (sheet ? ' sheet' : '')
|
|
380
|
+
menu.id = id
|
|
381
|
+
menu.setAttribute('role', 'dialog')
|
|
382
|
+
menu.setAttribute('aria-label', title || label)
|
|
383
|
+
menu.innerHTML = (title ? '<div class="lo-sheet-head"><span>' + esc(title) + '</span>' + (sheet ? '<button type="button" class="lo-sheet-done" data-act-close>Cancel</button>' : '') + '</div>' : '') +
|
|
384
|
+
'<form class="lo-nameform"><input type="text" class="lo-name" aria-label="' + esc(label) + '" placeholder="' + esc(placeholder) + '" value="' + esc(value) + '" maxlength="40" autocomplete="off" spellcheck="false">' +
|
|
385
|
+
'<button type="submit" class="btn primary lo-name-ok">' + esc(submit) + '</button></form><div class="lo-name-err" role="alert" hidden></div>'
|
|
386
|
+
let backdrop = null
|
|
387
|
+
if (sheet) { backdrop = document.createElement('div'); backdrop.className = 'lo-backdrop'; document.body.appendChild(backdrop) }
|
|
388
|
+
document.body.appendChild(menu)
|
|
389
|
+
const input = menu.querySelector('input'), errBox = menu.querySelector('.lo-name-err'), ok = menu.querySelector('.lo-name-ok')
|
|
390
|
+
act = { anchor, menu, backdrop, items: [input, ok], form: true }
|
|
391
|
+
anchor.setAttribute('aria-expanded', 'true')
|
|
392
|
+
menu.addEventListener('click', (e) => { if (e.target.closest('[data-act-close]')) closeActionMenu({ focus: true }) })
|
|
393
|
+
menu.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeActionMenu({ focus: true }) } })
|
|
394
|
+
menu.querySelector('form').addEventListener('submit', async (e) => {
|
|
395
|
+
e.preventDefault()
|
|
396
|
+
const v = input.value.trim()
|
|
397
|
+
if (!v) { input.focus(); return }
|
|
398
|
+
ok.disabled = true
|
|
399
|
+
try { await (onSubmit && onSubmit(v)); closeActionMenu() } catch (err) { errBox.textContent = (err && err.message) || String(err); errBox.hidden = false; ok.disabled = false; input.focus() }
|
|
400
|
+
})
|
|
401
|
+
placeAction()
|
|
402
|
+
input.focus()
|
|
403
|
+
try { input.setSelectionRange(input.value.length, input.value.length) } catch {}
|
|
404
|
+
return menu
|
|
405
|
+
}
|
|
406
|
+
|
|
370
407
|
if (typeof window !== 'undefined' && window.customElements && !customElements.get('lo-select')) {
|
|
371
408
|
customElements.define('lo-select', LoSelect)
|
|
372
409
|
customElements.define('lo-combo', LoCombo)
|
|
373
410
|
// a click outside (or a tap on the page behind a sheet) closes; so does the page moving under a desktop menu
|
|
374
411
|
document.addEventListener('pointerdown', (e) => { if (open && !open.menu.contains(e.target) && !open.host.contains(e.target)) closeMenu() }, true)
|
|
375
|
-
window.addEventListener('resize', () => { if (open) closeMenu(); if (act) closeActionMenu() })
|
|
412
|
+
window.addEventListener('resize', () => { if (open) closeMenu(); if (act && !act.form) closeActionMenu() }) // a form stays open while a phone's keyboard comes up
|
|
376
413
|
document.addEventListener('scroll', (e) => { if (open && !open.menu.contains(e.target)) place(); if (act && !act.menu.contains(e.target)) placeAction() }, true)
|
|
377
414
|
// a tap outside a card's ⋯ menu closes it (and goes nowhere: a tap on the backdrop, or anywhere on a desktop page, only closes)
|
|
378
415
|
document.addEventListener('pointerdown', (e) => {
|
package/ui/prs.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
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
5
|
import { repoChips, labelRepos, repoMatches, chipSelected, orderSelectedFirst } from './repos.js'
|
|
6
|
+
import { renderDoc } from './handoffs.js'
|
|
6
7
|
const safeUrl = (u) => (/^https:\/\/github\.com\//.test(String(u || '')) ? String(u) : '')
|
|
7
8
|
|
|
8
9
|
export function shortAgo(iso, now = Date.now()) {
|
|
@@ -79,21 +80,26 @@ export function ghLineHtml(g) {
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
function sessionLinks(r, opts) {
|
|
82
|
-
|
|
83
|
+
// 0.9.0: never scripted runs (claude -p / codex exec, archived as such); two, then "+N more" that opens
|
|
84
|
+
const ss = (r.sessions || []).filter((s) => s && !s.nonInteractive && s.archivedReason !== 'non_interactive')
|
|
83
85
|
if (!ss.length) return ''
|
|
84
|
-
|
|
86
|
+
const one = (s) => {
|
|
85
87
|
const act = opts.sessionAction ? opts.sessionAction(s) : ''
|
|
86
88
|
return '<span class="pr-sess"><a href="#/s/' + encodeURIComponent(s.id) + '" class="sesslink">' + esc(s.title || 'Untitled session') + '</a>' + (s.machine && opts.showMachine ? '<span class="faint"> · ' + esc(s.machine) + '</span>' : '') + act + '</span>'
|
|
87
|
-
}
|
|
89
|
+
}
|
|
90
|
+
return '<div class="pr-sessions">' + ss.slice(0, 2).map(one).join('') +
|
|
91
|
+
(ss.length > 2 ? '<details class="pr-more-sess"><summary class="linkish">+' + (ss.length - 2) + ' more</summary>' + ss.slice(2).map(one).join('') + '</details>' : '') + '</div>'
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
// One PR row on the PRs tab. 0.8.5: tap the row to expand it in place
|
|
91
95
|
// (`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 } = {}) {
|
|
96
|
+
export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null, me = null } = {}) {
|
|
93
97
|
const p = r.pr || {}
|
|
94
98
|
const url = safeUrl(p.url)
|
|
99
|
+
// 0.8.9: what the checks and review chips don't already say (a conflict, your review)
|
|
100
|
+
const bwhy = r.bucketWhy !== undefined ? r.bucketWhy : prBucket(p, me).why
|
|
95
101
|
const line1 = '<span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) +
|
|
96
|
-
(
|
|
102
|
+
(bwhy === 'Merge conflict' || bwhy === 'Your review' ? '<span class="pr-att">' + esc(bwhy) + '</span>' : p.review && REVIEW[p.review] && (p.state === 'open' || p.state === 'draft') ? '<span class="pr-review ' + esc(p.review) + '">' + REVIEW[p.review] + '</span>' : '')
|
|
97
103
|
const when = p.state === 'merged' && p.mergedAt ? 'merged ' + shortAgo(p.mergedAt, now) + ' ago' : p.updatedAt ? 'updated ' + shortAgo(p.updatedAt, now) + ' ago' : ''
|
|
98
104
|
const meta = team
|
|
99
105
|
? [p.repo, p.author ? '@' + p.author : '', p.branch || '', when].filter(Boolean)
|
|
@@ -120,6 +126,20 @@ export function plainBody(md) {
|
|
|
120
126
|
.replace(/<\/?[a-z][^>]*>/gi, '').replace(/^#{1,6}\s+/gm, '').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1')
|
|
121
127
|
.replace(/^[ \t]*[-*][ \t]+\[x\][ \t]+/gim, '☑ ').replace(/^[ \t]*[-*][ \t]+\[ \][ \t]+/gm, '☐ ').replace(/^[ \t]*[-*][ \t]+/gm, '• ').replace(/\n{3,}/g, '\n\n').trim()
|
|
122
128
|
}
|
|
129
|
+
// 0.9.0: a PR's description as markdown, with the file viewer's renderer and
|
|
130
|
+
// styles (headings, lists, task boxes, tables, code, quotes). Safe: GitHub's
|
|
131
|
+
// raw HTML is dropped to its text (renderDoc escapes everything else), an
|
|
132
|
+
// image becomes a link to it (never hotlinked), links open in a new tab.
|
|
133
|
+
export function prBodyMd(md) {
|
|
134
|
+
return String(md || '').replace(/\r\n?/g, '\n').replace(/<!--[\s\S]*?-->/g, '').replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, '')
|
|
135
|
+
.replace(/<img\b[^>]*?\bsrc=["']([^"']+)["'][^>]*>/gi, (m, u) => '[🖼 image](' + u + ')')
|
|
136
|
+
.replace(/!\[([^\]]*)\]\(([^)\s]+)[^)]*\)/g, (m, alt, u) => '[🖼 ' + (alt.trim() || 'image') + '](' + u + ')')
|
|
137
|
+
.replace(/<br\s*\/?>/gi, '\n').replace(/<\/?(p|div|details|summary|h[1-6]|li|ul|ol|table|tr)\b[^>]*>/gi, '\n')
|
|
138
|
+
.replace(/<\/?[a-z][^>]*>/gi, '').replace(/\n{3,}/g, '\n\n').trim()
|
|
139
|
+
}
|
|
140
|
+
export function prBodyHtml(md) {
|
|
141
|
+
return renderDoc(prBodyMd(md)).replace(/<a href="/g, '<a target="_blank" rel="noopener noreferrer" href="').replace(/(<a target="_blank" rel="noopener noreferrer" href="[^"]*") target="_blank" rel="noopener"/g, '$1')
|
|
142
|
+
}
|
|
123
143
|
export function prDetailHtml(p, st, { now = Date.now() } = {}) {
|
|
124
144
|
if (!st || st.loading) return '<div class="pr-detail loading" aria-busy="true">Loading details…</div>'
|
|
125
145
|
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>'
|
|
@@ -134,7 +154,7 @@ export function prDetailHtml(p, st, { now = Date.now() } = {}) {
|
|
|
134
154
|
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
155
|
}).join('') + '</ul>' : '') + (passed ? '<div class="pd-pass">✓ ' + passed + (bad.length ? ' passed' : passed === 1 ? ' check passed' : ' checks passed') + '</div>' : '') + '</div>'
|
|
136
156
|
}
|
|
137
|
-
const rv = REVIEW[d.review] || (d.state === 'open' ? 'No review yet' : '')
|
|
157
|
+
const rv = REVIEW[d.review] || (d.review === 'none' && d.state === 'open' ? 'No review required' : d.state === 'open' ? 'No review yet' : '')
|
|
138
158
|
const who = (d.reviews || []).map((r) => esc('@' + r.author) + ' ' + esc(REVIEW_STATE[r.state] || r.state))
|
|
139
159
|
if (rv || who.length || (d.reviewRequested || []).length) {
|
|
140
160
|
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>' : '') +
|
|
@@ -144,7 +164,7 @@ export function prDetailHtml(p, st, { now = Date.now() } = {}) {
|
|
|
144
164
|
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
165
|
const body = plainBody(d.body)
|
|
146
166
|
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-
|
|
167
|
+
(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="doc-md pd-md">' + prBodyHtml(d.body) + '</div></details>' : '<div class="doc-md pd-md">' + prBodyHtml(d.body) + '</div>') : '<div class="faint">No description.</div>') + '</div>'
|
|
148
168
|
if ((d.comments || []).length) {
|
|
149
169
|
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
170
|
const first = String(c.body || '').split('\n').filter((x) => x.trim()).slice(0, 2).join(' ')
|
|
@@ -154,33 +174,77 @@ export function prDetailHtml(p, st, { now = Date.now() } = {}) {
|
|
|
154
174
|
return h + '</div>'
|
|
155
175
|
}
|
|
156
176
|
|
|
177
|
+
// ---------------------------------------------------------------- 0.8.9: buckets
|
|
178
|
+
|
|
179
|
+
// What an open PR is waiting on, in one of four buckets (idea: "merge ready /
|
|
180
|
+
// needs fix / waiting on others"), from the fields gh already gives us:
|
|
181
|
+
// review Your review: you're a requested reviewer and it isn't yours
|
|
182
|
+
// fix Needs fix: a check failing, changes requested, or a merge conflict
|
|
183
|
+
// ready Merge ready: not a draft, approved or no review required, every
|
|
184
|
+
// check passed (or none), and GitHub says it merges cleanly
|
|
185
|
+
// waiting Waiting on others: everything else (review pending, checks
|
|
186
|
+
// running, a draft, or anything we can't tell yet)
|
|
187
|
+
// Anything unknown (no mergeable from an older daemon, GitHub still working
|
|
188
|
+
// it out, no check list) lands in waiting, never in ready. Merged and closed
|
|
189
|
+
// PRs keep their state. -> { bucket, why }
|
|
190
|
+
export const PR_BUCKETS = [['review', 'Your review'], ['fix', 'Needs fix'], ['ready', 'Merge ready'], ['waiting', 'Waiting on others'], ['merged', 'Merged'], ['closed', 'Closed']]
|
|
191
|
+
const sameUser = (a, b) => !!a && !!b && String(a).toLowerCase() === String(b).toLowerCase()
|
|
192
|
+
export function prBucket(pr, me = null) {
|
|
193
|
+
if (!pr || typeof pr !== 'object') return { bucket: 'waiting', why: null }
|
|
194
|
+
if (pr.state === 'merged') return { bucket: 'merged', why: null }
|
|
195
|
+
if (pr.state === 'closed') return { bucket: 'closed', why: null }
|
|
196
|
+
const ck = pr.checks && typeof pr.checks === 'object' ? pr.checks : null
|
|
197
|
+
if (me && !sameUser(pr.author, me) && (pr.reviewRequested || []).some((x) => sameUser(x, me))) return { bucket: 'review', why: 'Your review' }
|
|
198
|
+
if (ck && ck.failed > 0) return { bucket: 'fix', why: 'Checks failing' }
|
|
199
|
+
if (pr.review === 'changes_requested') return { bucket: 'fix', why: 'Changes requested' }
|
|
200
|
+
if (pr.mergeable === 'conflicting') return { bucket: 'fix', why: 'Merge conflict' }
|
|
201
|
+
if (pr.state === 'draft') return { bucket: 'waiting', why: 'Draft' }
|
|
202
|
+
if (pr.state !== 'open') return { bucket: 'waiting', why: null }
|
|
203
|
+
if (!ck || ck.unknown || !Number.isFinite(ck.total)) return { bucket: 'waiting', why: null }
|
|
204
|
+
if (ck.pending > 0) return { bucket: 'waiting', why: 'Checks running' }
|
|
205
|
+
if (pr.review === 'review_required') return { bucket: 'waiting', why: 'Review pending' }
|
|
206
|
+
if (pr.review !== 'approved' && pr.review !== 'none') return { bucket: 'waiting', why: null }
|
|
207
|
+
if (pr.mergeable !== 'mergeable') return { bucket: 'waiting', why: null }
|
|
208
|
+
return { bucket: 'ready', why: null }
|
|
209
|
+
}
|
|
210
|
+
// A row's bucket: what the daemon worked out, else (an older daemon) worked out here.
|
|
211
|
+
export function bucketOf(r, me = null) {
|
|
212
|
+
if (r && PR_BUCKETS.some(([k]) => k === r.bucket)) return r.bucket
|
|
213
|
+
return prBucket(r && r.pr, me).bucket
|
|
214
|
+
}
|
|
215
|
+
|
|
157
216
|
// ---------------------------------------------------------------- 0.8.5: filters
|
|
158
217
|
|
|
159
|
-
// The page's filter, from the hash (#/prs?view=team&repo=web&state=
|
|
160
|
-
|
|
161
|
-
|
|
218
|
+
// The page's filter, from the hash (#/prs?view=team&repo=web&state=ready&q=search).
|
|
219
|
+
// 0.8.9: `state` is a bucket (PR_BUCKETS). Links from 0.8.5 still open:
|
|
220
|
+
// state=attention or checks=failing -> Needs fix, state=draft -> Waiting on
|
|
221
|
+
// others, state=open and checks=passing -> no filter.
|
|
222
|
+
export const PR_STATES = PR_BUCKETS
|
|
162
223
|
export const QUIET_DAYS = 14
|
|
224
|
+
const LEGACY_STATE = { attention: 'fix', draft: 'waiting' }
|
|
163
225
|
const splitList = (v) => String(v || '').split(',').map((x) => x.trim()).filter(Boolean)
|
|
164
226
|
export function readPrFilter(params) {
|
|
165
227
|
const p = typeof params === 'string' ? new URLSearchParams(params.replace(/^.*\?/, '')) : (params || new URLSearchParams())
|
|
166
|
-
|
|
228
|
+
let state = p.get('state')
|
|
229
|
+
state = PR_BUCKETS.some(([k]) => k === state) ? state : LEGACY_STATE[state] || null
|
|
230
|
+
if (!state && p.get('checks') === 'failing') state = 'fix'
|
|
167
231
|
return {
|
|
168
232
|
repos: [...new Set(p.getAll('repo').flatMap(splitList))],
|
|
169
|
-
state
|
|
170
|
-
checks: PR_CHECKS.some(([k]) => k === checks) ? checks : null,
|
|
233
|
+
state,
|
|
171
234
|
q: String(p.get('q') || '').slice(0, 200),
|
|
235
|
+
project: p.get('project') || null, // 0.9.0
|
|
172
236
|
}
|
|
173
237
|
}
|
|
174
238
|
export function prsHash(view, f = {}) {
|
|
175
239
|
const parts = []
|
|
176
240
|
if (view === 'team') parts.push('view=team')
|
|
241
|
+
if (f.project) parts.push('project=' + encodeURIComponent(f.project))
|
|
177
242
|
if (f.repos && f.repos.length) parts.push('repo=' + f.repos.map((r) => encodeURIComponent(r).replace(/%2F/gi, '/')).join(','))
|
|
178
243
|
if (f.state) parts.push('state=' + f.state)
|
|
179
|
-
if (f.checks) parts.push('checks=' + f.checks)
|
|
180
244
|
if (f.q && f.q.trim()) parts.push('q=' + encodeURIComponent(f.q.trim()))
|
|
181
245
|
return '#/prs' + (parts.length ? '?' + parts.join('&') : '')
|
|
182
246
|
}
|
|
183
|
-
export const prFilterActive = (f) => !!(f && ((f.repos && f.repos.length) || f.state || f.
|
|
247
|
+
export const prFilterActive = (f) => !!(f && ((f.repos && f.repos.length) || f.state || f.project || (f.q && f.q.trim())))
|
|
184
248
|
|
|
185
249
|
// Every row, flat: the daemon's `rows`, or an older daemon's groups.
|
|
186
250
|
export function prRows(d) {
|
|
@@ -192,13 +256,8 @@ export function prRows(d) {
|
|
|
192
256
|
}
|
|
193
257
|
const lastAt = (pr) => Math.max(Date.parse(pr.updatedAt || '') || 0, Date.parse(pr.mergedAt || '') || 0)
|
|
194
258
|
export const isQuiet = (r, now = Date.now()) => { const t = lastAt(r.pr || {}); return !!t && now - t >= QUIET_DAYS * 864e5 }
|
|
195
|
-
export function prStateOf(r) { return
|
|
196
|
-
export function inPrState(r, state) { return !state || (
|
|
197
|
-
export function inPrChecks(r, checks) {
|
|
198
|
-
const c = r.pr.checks || {}
|
|
199
|
-
if (!checks) return true
|
|
200
|
-
return checks === 'failing' ? c.failed > 0 : c.total > 0 && !c.failed && !c.pending
|
|
201
|
-
}
|
|
259
|
+
export function prStateOf(r, me = null) { return bucketOf(r, me) }
|
|
260
|
+
export function inPrState(r, state, me = null) { return !state || bucketOf(r, me) === state }
|
|
202
261
|
export function inPrSearch(r, q) {
|
|
203
262
|
const words = String(q || '').toLowerCase().split(/\s+/).filter(Boolean)
|
|
204
263
|
if (!words.length) return true
|
|
@@ -215,17 +274,28 @@ export function inPrRepos(r, repos, labels = null) {
|
|
|
215
274
|
const key = r.pr.repo || 'no-repo'
|
|
216
275
|
return repos.some((t) => repoMatches({ repoKey: key, repoLabel: labels ? labels.get(key) : null }, t))
|
|
217
276
|
}
|
|
218
|
-
|
|
277
|
+
// 0.9.0: the project a PR is in: the one holding its GitHub repo, else the
|
|
278
|
+
// project of a session it's linked to. projects: GET /v1/projects' list.
|
|
279
|
+
export function prProjectOf(r, projects = []) {
|
|
280
|
+
const repo = String((r && r.pr && r.pr.repo) || '').toLowerCase()
|
|
281
|
+
const p = repo && (projects || []).find((x) => (x.repos || []).some((y) => y.github && y.github.toLowerCase() === repo))
|
|
282
|
+
if (p) return p.id
|
|
283
|
+
const s = ((r && r.sessions) || []).find((x) => x && x.repoProject)
|
|
284
|
+
return s ? s.repoProject.id : null
|
|
285
|
+
}
|
|
286
|
+
export const inPrProject = (r, id, projects = []) => !id || prProjectOf(r, projects) === id
|
|
287
|
+
export function filterPrRows(rows, f = {}, { skip = null, me = null, projects = [] } = {}) {
|
|
219
288
|
const labels = labelRepos(rows.map((r) => r.pr.repo || 'no-repo'))
|
|
220
|
-
return rows.filter((r) => (skip === '
|
|
289
|
+
return rows.filter((r) => (skip === 'project' || inPrProject(r, f.project, projects)) && (skip === 'repos' || inPrRepos(r, f.repos, labels)) && (skip === 'state' || inPrState(r, f.state, me)) && inPrSearch(r, f.q))
|
|
221
290
|
}
|
|
222
|
-
// Groups, newest first in each; anything with no
|
|
223
|
-
|
|
224
|
-
|
|
291
|
+
// Groups (0.8.9: the buckets), newest first in each; anything with no
|
|
292
|
+
// activity for 14+ days in Quiet.
|
|
293
|
+
export function groupPrRows(rows, { now = Date.now(), me = null } = {}) {
|
|
294
|
+
const g = Object.fromEntries(PR_BUCKETS.map(([k]) => [k, []]))
|
|
225
295
|
const quiet = []
|
|
226
|
-
for (const r of rows) { if (isQuiet(r, now)) quiet.push(r); else
|
|
296
|
+
for (const r of rows) { if (isQuiet(r, now)) quiet.push(r); else g[bucketOf(r, me)].push(r) }
|
|
227
297
|
const by = (a, b) => lastAt(b.pr) - lastAt(a.pr)
|
|
228
|
-
const out =
|
|
298
|
+
const out = PR_BUCKETS.map(([key, label]) => ({ key, label, rows: g[key].sort(by) })).filter((x) => x.rows.length)
|
|
229
299
|
return { groups: out, quiet: quiet.sort(by) }
|
|
230
300
|
}
|
|
231
301
|
|
|
@@ -237,37 +307,58 @@ function fchip({ on, count, label, attrs, cls = '' }) {
|
|
|
237
307
|
}
|
|
238
308
|
|
|
239
309
|
// The chip rows and the grouped list (repainted as you type in the search box).
|
|
240
|
-
|
|
241
|
-
|
|
310
|
+
// 0.9.0: Merged and Closed fold to one row each ("Merged · 17 ›"), opened on a
|
|
311
|
+
// tap and remembered like Quiet; open when it's the bucket you picked or all there is.
|
|
312
|
+
export const DONE_GROUPS = ['merged', 'closed']
|
|
313
|
+
export const isOpenPr = (r) => !['merged', 'closed'].includes(String((r && r.pr && r.pr.state) || '').toLowerCase())
|
|
314
|
+
function doneGroupHtml(g, { open, rowOpts }) {
|
|
315
|
+
return '<details class="prgroup done b-' + g.key + '" data-group="' + g.key + '"' + (open ? ' open' : '') + '><summary><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span><span class="chev" aria-hidden="true">›</span></h2></summary>' + g.rows.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</details>'
|
|
316
|
+
}
|
|
317
|
+
export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null, filter = {}, open = null, details = null, quietOpen = false, doneOpen = null, projects = [] } = {}) {
|
|
318
|
+
const f = { repos: [], state: null, q: '', project: null, ...filter }
|
|
319
|
+
if (f.project && !(projects || []).some((p) => p.id === f.project)) f.project = null
|
|
242
320
|
const all = prRows(d)
|
|
321
|
+
const me = d.me || null
|
|
243
322
|
let h = ''
|
|
244
323
|
if (all.length) {
|
|
324
|
+
// 0.9.0: projects first (when you have any), then the repos in the picked one
|
|
325
|
+
if ((projects || []).length) {
|
|
326
|
+
const pCount = filterPrRows(all, f, { skip: 'project', me, projects }).filter(isOpenPr) // 0.9.0: counts are open PRs
|
|
327
|
+
const n = new Map()
|
|
328
|
+
for (const r of pCount) { const id = prProjectOf(r, projects); if (id) n.set(id, (n.get(id) || 0) + 1) }
|
|
329
|
+
h += chipRow('Filter by project', [fchip({ on: !f.project, count: pCount.length, label: 'All projects', cls: 'all', attrs: 'data-action="prs-filter" data-k="project" data-v=""' })]
|
|
330
|
+
.concat(projects.map((p) => fchip({ on: f.project === p.id, count: n.get(p.id) || 0, label: (p.emoji ? p.emoji + ' ' : '') + p.name, cls: 'pchip', attrs: 'data-action="prs-filter" data-k="project" data-v="' + esc(p.id) + '"' }))), 'prs-projects')
|
|
331
|
+
}
|
|
245
332
|
// Repos: All, then by latest activity; a chosen one always shows, first and removable.
|
|
246
|
-
const
|
|
247
|
-
|
|
333
|
+
const inProj = f.project ? all.filter((r) => inPrProject(r, f.project, projects)) : all
|
|
334
|
+
const repoCount = filterPrRows(all, f, { skip: 'repos', me, projects }).filter(isOpenPr) // 0.9.0: counts are open PRs
|
|
335
|
+
let chips = prRepoChips(inProj, { countIn: repoCount })
|
|
248
336
|
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
337
|
chips = orderSelectedFirst(chips, f.repos)
|
|
250
338
|
if (chips.length > 1 || f.repos.length) {
|
|
251
339
|
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
340
|
.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
341
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
|
|
342
|
+
// 0.8.9: one row of buckets. The four open ones always show (dimmed at 0,
|
|
343
|
+
// after the rest), Merged and Closed only when there are some; a chosen
|
|
344
|
+
// one comes first.
|
|
345
|
+
const stCount = filterPrRows(all, f, { skip: 'state', me, projects })
|
|
346
|
+
const bs = PR_BUCKETS.map(([k, l]) => ({ v: k, l, n: stCount.filter((r) => bucketOf(r, me) === k).length, on: f.state === k }))
|
|
347
|
+
const shown = bs.filter((x) => !x.on && (x.n || ['review', 'fix', 'ready', 'waiting'].includes(x.v)) && !(x.v === 'review' && !x.n && !me))
|
|
348
|
+
// the ones with PRs lead, so on a phone the empty ones are what scrolls off
|
|
349
|
+
const ordered = [...bs.filter((x) => x.on), ...shown.filter((x) => x.n), ...shown.filter((x) => !x.n)]
|
|
350
|
+
h += chipRow('Filter by what each PR is waiting on', ordered.map((x) => fchip({ on: x.on, count: x.n, label: x.l, cls: 'b-' + x.v, attrs: 'data-action="prs-filter" data-k="state" data-v="' + x.v + '"' })), 'prs-states prs-buckets')
|
|
260
351
|
}
|
|
261
|
-
const rows = filterPrRows(all, f)
|
|
262
|
-
const { groups, quiet } = groupPrRows(rows, { now })
|
|
352
|
+
const rows = filterPrRows(all, f, { me, projects })
|
|
353
|
+
const { groups, quiet } = groupPrRows(rows, { now, me })
|
|
263
354
|
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 })
|
|
355
|
+
const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, me, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null })
|
|
265
356
|
if (!all.length) {
|
|
266
357
|
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
358
|
} else if (!rows.length) {
|
|
268
359
|
h += '<div class="empty">No PRs match. <button type="button" class="linkish" data-action="prs-clear">Clear filters</button></div>'
|
|
269
360
|
} else {
|
|
270
|
-
for (const g of groups) h += '<section class="prgroup' + (g.key === '
|
|
361
|
+
for (const g of groups) h += DONE_GROUPS.includes(g.key) ? doneGroupHtml(g, { open: !!(doneOpen && doneOpen[g.key]) || f.state === g.key || !groups.some((x) => !DONE_GROUPS.includes(x.key)), rowOpts }) : '<section class="prgroup b-' + g.key + (g.key === 'fix' || g.key === 'review' ? ' attention' : '') + '" data-group="' + g.key + '"><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span></h2>' + g.rows.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</section>'
|
|
271
362
|
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
363
|
}
|
|
273
364
|
if (view === 'team' && (d.failed || []).length) h += '<div class="hint">Couldn’t load ' + esc(d.failed.join(', ')) + ' right now.</div>'
|
package/ui/repos.js
CHANGED
|
@@ -133,18 +133,38 @@ export function readFilter(params, storage) {
|
|
|
133
133
|
try { const v = storage && storage.getItem(STORE_KEY); repos = v ? JSON.parse(v) : [] ; if (repos.length) from = 'storage' } catch { repos = [] }
|
|
134
134
|
if (!Array.isArray(repos)) repos = []
|
|
135
135
|
}
|
|
136
|
+
// 0.9.0: the project (its id) the same way: the hash first, else this browser's last pick
|
|
137
|
+
let project = null
|
|
138
|
+
if (p.has('project')) { project = p.get('project') || null; if (from === 'none') from = 'hash' }
|
|
139
|
+
else if (!p.has('repo')) { try { project = (storage && storage.getItem(PROJECT_KEY)) || null } catch {} ; if (project && from === 'none') from = 'storage' }
|
|
136
140
|
const status = p.get('status')
|
|
137
|
-
return { repos: [...new Set(repos.map(String))], status: status && STATUS_FILTERS[status] ? status : null, from }
|
|
141
|
+
return { repos: [...new Set(repos.map(String))], status: status && STATUS_FILTERS[status] ? status : null, project, from }
|
|
138
142
|
}
|
|
139
143
|
|
|
144
|
+
export const PROJECT_KEY = 'cp.project'
|
|
145
|
+
export function saveProject(storage, id) {
|
|
146
|
+
try { if (!storage) return; if (id) storage.setItem(PROJECT_KEY, id); else storage.removeItem(PROJECT_KEY) } catch {}
|
|
147
|
+
}
|
|
148
|
+
// 0.9.0: a session in a project (its repo's, by id); no project picked = every session.
|
|
149
|
+
export function inProject(s, id) { return !id || !!(s && s.repoProject && s.repoProject.id === id) }
|
|
150
|
+
// The project chips: every project (they are yours, so a zero still shows),
|
|
151
|
+
// with the count of `sessions` in each. -> [{ id, name, emoji, count }]
|
|
152
|
+
export function projectChips(projects, sessions) {
|
|
153
|
+
const n = new Map()
|
|
154
|
+
for (const s of sessions || []) if (s && s.repoProject) n.set(s.repoProject.id, (n.get(s.repoProject.id) || 0) + 1)
|
|
155
|
+
return (projects || []).map((p) => ({ id: p.id, name: p.name, emoji: p.emoji || null, count: n.get(p.id) || 0 }))
|
|
156
|
+
}
|
|
157
|
+
export const projectLabel = (p) => (p ? (p.emoji ? p.emoji + ' ' : '') + p.name : '')
|
|
158
|
+
|
|
140
159
|
export function saveRepos(storage, repos) {
|
|
141
160
|
try { if (!storage) return; if (repos && repos.length) storage.setItem(STORE_KEY, JSON.stringify(repos)); else storage.removeItem(STORE_KEY) } catch {}
|
|
142
161
|
}
|
|
143
162
|
|
|
144
163
|
// The board's hash for a filter: "#/", "#/?repo=clutch_hunt",
|
|
145
164
|
// "#/?repo=a,b&status=active". Commas and slashes stay readable.
|
|
146
|
-
export function filterHash({ repos = [], status = null } = {}) {
|
|
165
|
+
export function filterHash({ repos = [], status = null, project = null } = {}) {
|
|
147
166
|
const parts = []
|
|
167
|
+
if (project) parts.push('project=' + encodeURIComponent(project))
|
|
148
168
|
if (repos.length) parts.push('repo=' + repos.map((r) => encodeURIComponent(r).replace(/%2F/gi, '/')).join(','))
|
|
149
169
|
if (status) parts.push('status=' + encodeURIComponent(status))
|
|
150
170
|
return '#/' + (parts.length ? '?' + parts.join('&') : '')
|
|
@@ -206,7 +226,7 @@ export function searchMatches(s, q, extra = []) {
|
|
|
206
226
|
const words = String(q || '').toLowerCase().trim().split(/\s+/).filter(Boolean)
|
|
207
227
|
if (!words.length) return true
|
|
208
228
|
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,
|
|
229
|
+
const hay = [s.title, s.repoLabel, s.repoName, s.repoProject && s.repoProject.name, 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
230
|
cp && cp.doing, cp && cp.next, cp && cp.stoppedAt, ...extra].filter(Boolean).map((h) => String(h).toLowerCase())
|
|
211
231
|
const id = String(s.id || '').toLowerCase()
|
|
212
232
|
const short = String(s.shortId || '').toLowerCase()
|