@leftoff/cli 0.6.8 → 0.7.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 +17 -0
- package/bin/leftoff.js +314 -302
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +140 -0
- package/ui/app.js +165 -12
- package/ui/handoffs.js +184 -0
- package/ui/index.html +1 -0
- package/ui/prs.js +128 -0
package/ui/handoffs.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// To do and Files (0.7.0). Pure HTML builders over what the daemon sends:
|
|
2
|
+
// /v1/needs .todos ({ open, done }) and a session's todos / files / filesSummary.
|
|
3
|
+
// app.js does the fetching and wires the clicks. renderDoc is the viewer's
|
|
4
|
+
// Markdown: everything is escaped first, so raw HTML in a doc shows as text and
|
|
5
|
+
// never runs; links open only for https. Unit tests: test/unit/handoffs-ui.test.mjs.
|
|
6
|
+
import { mdInline } from './changelog-md.js'
|
|
7
|
+
|
|
8
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
9
|
+
const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many || one + 's')
|
|
10
|
+
const safeArtifact = (u) => (/^https:\/\/claude\.ai\/code\/artifact\/[A-Za-z0-9_-]{4,80}$/.test(String(u || '')) ? String(u) : '')
|
|
11
|
+
|
|
12
|
+
// ------------------------------------------------------------------ to-dos
|
|
13
|
+
|
|
14
|
+
export function todoRow(t, { agoFn = null, mayEdit = true } = {}) {
|
|
15
|
+
const codes = (t.codes || []).map((c) => '<button type="button" class="todo-code" data-action="copy-cmd" data-cmd="' + esc(c) + '" title="Copy"><code>' + esc(c.length > 60 ? c.slice(0, 57) + '…' : c) + '</code><span class="todo-cp" aria-hidden="true">Copy</span></button>').join('')
|
|
16
|
+
const meta = [t.manual ? 'you added' : null, agoFn && t.at ? agoFn(t.at) : null].filter(Boolean).join(' · ')
|
|
17
|
+
return '<li class="todo" data-todo="' + esc(t.key) + '"><label class="todo-check"><input type="checkbox" data-todo-done="' + esc(t.key) + '"' + (mayEdit ? '' : ' disabled') + ' aria-label="Done: ' + esc(t.text) + '"><span class="box" aria-hidden="true"></span></label>' +
|
|
18
|
+
'<div class="todo-main"><div class="todo-text">' + mdInline(t.text) + '</div>' + (codes ? '<div class="todo-codes">' + codes + '</div>' : '') + (meta ? '<div class="todo-meta">' + esc(meta) + '</div>' : '') + '</div></li>'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function todoAddForm(sessionId = '') {
|
|
22
|
+
return '<form class="todo-add addform" data-todo-add="' + esc(sessionId || '-') + '" autocomplete="off"><input type="text" name="todo" maxlength="200" placeholder="Add a to-do" aria-label="Add a to-do" enterkeyhint="done"><button type="submit" class="btn sm">Add</button></form>'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Open to-dos grouped by session (your own, with no session, last).
|
|
26
|
+
export function groupTodos(open = []) {
|
|
27
|
+
const groups = new Map()
|
|
28
|
+
for (const t of open) {
|
|
29
|
+
const id = (t.session && t.session.id) || ''
|
|
30
|
+
if (!groups.has(id)) groups.set(id, { session: t.session || null, items: [] })
|
|
31
|
+
groups.get(id).items.push(t)
|
|
32
|
+
}
|
|
33
|
+
return [...groups.values()].sort((a, b) => (!a.session) - (!b.session) || String(b.items[b.items.length - 1].at || '').localeCompare(String(a.items[a.items.length - 1].at || '')))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function doneTodosHtml(done = [], { open = false, agoFn = null } = {}) {
|
|
37
|
+
if (!done.length) return ''
|
|
38
|
+
return '<details class="dismissed todo-done-list"' + (open ? ' open' : '') + '><summary>Done (' + done.length + ')</summary><ul>' +
|
|
39
|
+
done.slice(0, 100).map((d) => '<li><div class="dl-main"><span class="dl-title">' + mdInline(d.text) + '</span>' + (d.session ? '<span class="dl-line">' + esc(d.session.title) + '</span>' : '') +
|
|
40
|
+
(agoFn && d.doneAt ? '<span class="dl-at">done ' + esc(agoFn(d.doneAt)) + '</span>' : '') + '</div>' +
|
|
41
|
+
'<button type="button" class="btn sm" data-action="todo-restore" data-todo="' + esc(d.key) + '">Restore</button></li>').join('') +
|
|
42
|
+
'</ul>' + (done.length > 1 ? '<button type="button" class="linkish" data-action="todo-restore-all">Restore all</button>' : '') + '</details>'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The Needs view's "To do" section.
|
|
46
|
+
export function todosSection(ov, { agoFn = null, mayEdit = true, doneOpen = false, repos = [] } = {}) {
|
|
47
|
+
const open = ((ov && ov.open) || []).filter((t) => !repos.length || (t.session && repos.includes(t.session.repoLabel)) || !t.session)
|
|
48
|
+
const head = '<div class="needs-head todo-head"><h2>To do <span class="count">' + open.length + '</span></h2><span class="sub">Steps sessions handed back to you, and your own</span></div>'
|
|
49
|
+
const body = open.length
|
|
50
|
+
? groupTodos(open).map((g) => '<div class="todo-group">' + (g.session ? '<div class="todo-sess"><a href="#/s/' + encodeURIComponent(g.session.id) + '">' + esc(g.session.title) + '</a>' + (g.session.repoLabel ? '<span class="chip mono">' + esc(g.session.repoLabel) + '</span>' : '') + '</div>' : '<div class="todo-sess"><span>Your to-dos</span></div>') +
|
|
51
|
+
'<ul class="todos">' + g.items.map((t) => todoRow(t, { agoFn, mayEdit })).join('') + '</ul></div>').join('')
|
|
52
|
+
: '<div class="nocp">No to-dos. When a session says you need to do something yourself (run a command, set a secret, add DNS records), it shows up here.</div>'
|
|
53
|
+
return '<section class="todos-sec mt" aria-label="To do">' + head + (mayEdit ? todoAddForm('') : '') + body + doneTodosHtml((ov && ov.done) || [], { open: doneOpen, agoFn }) + '</section>'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A session page's to-dos.
|
|
57
|
+
export function sessionTodosSection(s, { agoFn = null, mayEdit = true } = {}) {
|
|
58
|
+
const list = s.todos || []
|
|
59
|
+
if (!list.length && !mayEdit) return ''
|
|
60
|
+
return '<h2>To do <span class="count">' + list.length + '</span></h2>' +
|
|
61
|
+
(list.length ? '<ul class="todos">' + list.map((t) => todoRow(t, { agoFn, mayEdit })).join('') + '</ul>' : '') + (mayEdit ? todoAddForm(s.id) : '')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ------------------------------------------------------------------ chips
|
|
65
|
+
|
|
66
|
+
export function filesChipLabel(fs, { compact = false } = {}) {
|
|
67
|
+
if (!fs) return ''
|
|
68
|
+
const parts = []
|
|
69
|
+
const docs = (fs.docs || 0) + (fs.plans || 0)
|
|
70
|
+
if (docs) parts.push(compact ? docs + (docs === 1 ? ' doc' : ' docs') : plural(docs, 'doc'))
|
|
71
|
+
if (fs.artifacts) parts.push(plural(fs.artifacts, 'artifact'))
|
|
72
|
+
return parts.join(' · ')
|
|
73
|
+
}
|
|
74
|
+
// The card's small chips: "2 to-dos" and "2 docs · 1 artifact".
|
|
75
|
+
export function cardHandoffChips(s) {
|
|
76
|
+
const n = (s.todos || []).length
|
|
77
|
+
const fl = filesChipLabel(s.filesSummary)
|
|
78
|
+
if (!n && !fl) return ''
|
|
79
|
+
const href = '#/s/' + encodeURIComponent(s.id)
|
|
80
|
+
return '<div class="handoff-chips">' + (n ? '<a class="chip todochip" href="' + href + '" title="Steps handed back to you">☐ ' + esc(plural(n, 'to-do')) + '</a>' : '') +
|
|
81
|
+
(fl ? '<a class="chip fileschip" href="' + href + '" title="Docs and artifacts from this session">' + esc(fl) + '</a>' : '') + '</div>'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ------------------------------------------------------------------ files
|
|
85
|
+
|
|
86
|
+
const TYPE_ICON = { markdown: 'MD', image: 'IMG', pdf: 'PDF', html: 'HTML', text: 'TXT', other: 'FILE' }
|
|
87
|
+
export function sessionFilesSection(s, { agoFn = null } = {}) {
|
|
88
|
+
const f = s.files
|
|
89
|
+
if (!f) return ''
|
|
90
|
+
const arts = (f.artifacts || []).filter((a) => safeArtifact(a.url))
|
|
91
|
+
const plans = f.plans || []
|
|
92
|
+
const docs = f.docs || []
|
|
93
|
+
if (!arts.length && !plans.length && !docs.length && !f.code) return ''
|
|
94
|
+
const when = (at) => (agoFn && at ? '<span class="f-at">' + esc(agoFn(at)) + '</span>' : '')
|
|
95
|
+
let h = '<h2>Files</h2><ul class="files-list">'
|
|
96
|
+
for (const a of arts) h += '<li class="f-art"><a class="f-row" href="' + esc(safeArtifact(a.url)) + '" target="_blank" rel="noopener noreferrer"><span class="f-ic art" aria-hidden="true">◆</span><span class="f-name">' + esc(a.title) + '</span><span class="f-kind">Artifact ↗</span>' + when(a.at) + '</a></li>'
|
|
97
|
+
for (const p of plans) h += '<li><button type="button" class="f-row" data-action="file-open" data-plan="' + esc(p.id) + '" data-session="' + esc(s.id) + '"><span class="f-ic" aria-hidden="true">PLAN</span><span class="f-name">' + esc(p.title) + '</span><span class="f-kind">Plan</span>' + when(p.at) + '</button></li>'
|
|
98
|
+
for (const d of docs) h += '<li><button type="button" class="f-row" data-action="file-open" data-path="' + esc(d.path) + '" data-session="' + esc(s.id) + '"><span class="f-ic" aria-hidden="true">' + (d.plan ? 'PLAN' : TYPE_ICON[d.type] || 'FILE') + '</span><span class="f-name mono">' + esc(d.name) + '</span><span class="f-kind ' + (d.kind === 'new' ? 'new' : '') + '">' + (d.plan ? 'Plan' : d.kind === 'new' ? 'new' : 'edited') + '</span>' + when(d.at) + '</button></li>'
|
|
99
|
+
h += '</ul>'
|
|
100
|
+
if (f.code) h += '<div class="f-code faint">' + esc(plural(f.code, 'code file')) + ' changed</div>'
|
|
101
|
+
return h
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ------------------------------------------------------------------ viewer
|
|
105
|
+
|
|
106
|
+
// Markdown -> safe HTML: headings, paragraphs, lists, task boxes, quotes,
|
|
107
|
+
// fenced code, tables, rules. Inline markup via mdInline (escaped; https links only).
|
|
108
|
+
export function renderDoc(md) {
|
|
109
|
+
const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').replace(/^---\n[\s\S]*?\n---\n/, '').split('\n')
|
|
110
|
+
const out = []
|
|
111
|
+
let i = 0
|
|
112
|
+
const para = []
|
|
113
|
+
const flush = () => { if (para.length) { out.push('<p>' + para.map((x) => mdInline(x)).join('<br>') + '</p>'); para.length = 0 } }
|
|
114
|
+
while (i < lines.length) {
|
|
115
|
+
const ln = lines[i]
|
|
116
|
+
let m
|
|
117
|
+
if ((m = ln.match(/^\s*(```|~~~)\s*([\w+-]*)/))) {
|
|
118
|
+
flush()
|
|
119
|
+
const fence = m[1]; const body = []
|
|
120
|
+
i++
|
|
121
|
+
while (i < lines.length && !lines[i].trim().startsWith(fence)) body.push(lines[i++])
|
|
122
|
+
i++
|
|
123
|
+
out.push('<pre class="doc-code"><code>' + esc(body.join('\n')) + '</code></pre>')
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
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 + '>' + mdInline(m[2].replace(/\s+#+\s*$/, '')) + '</h' + n + '>'); i++; continue }
|
|
128
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(ln)) { flush(); out.push('<hr>'); i++; continue }
|
|
129
|
+
if (/^\s*>/.test(ln)) {
|
|
130
|
+
flush(); const q = []
|
|
131
|
+
while (i < lines.length && /^\s*>/.test(lines[i])) q.push(lines[i++].replace(/^\s*>\s?/, ''))
|
|
132
|
+
out.push('<blockquote>' + renderDoc(q.join('\n')) + '</blockquote>'); continue
|
|
133
|
+
}
|
|
134
|
+
if (/^\s*\|.*\|\s*$/.test(ln) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
|
|
135
|
+
flush()
|
|
136
|
+
const cells = (r) => r.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim())
|
|
137
|
+
const head = cells(ln); i += 2
|
|
138
|
+
const rows = []
|
|
139
|
+
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>' + mdInline(c) + '</th>').join('') + '</tr></thead><tbody>' + rows.map((r) => '<tr>' + r.map((c) => '<td>' + mdInline(c) + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>')
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (/^\s*(?:[-*+]|\d{1,3}[.)])\s+/.test(ln)) {
|
|
144
|
+
flush()
|
|
145
|
+
const ordered = /^\s*\d/.test(ln)
|
|
146
|
+
const items = []
|
|
147
|
+
while (i < lines.length && (/^\s*(?:[-*+]|\d{1,3}[.)])\s+/.test(lines[i]) || (/^\s{2,}\S/.test(lines[i]) && items.length))) {
|
|
148
|
+
const l = lines[i++]
|
|
149
|
+
const mm = l.match(/^(\s*)(?:[-*+]|\d{1,3}[.)])\s+(.*)$/)
|
|
150
|
+
if (mm) items.push({ depth: Math.floor(mm[1].replace(/\t/g, ' ').length / 2), text: mm[2] })
|
|
151
|
+
else items[items.length - 1].text += ' ' + l.trim()
|
|
152
|
+
}
|
|
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>' + mdInline(tm[2]) + '</li>' : '<li>' + mdInline(t) + '</li>' }
|
|
154
|
+
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
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
para.push(ln.trim()); i++
|
|
158
|
+
}
|
|
159
|
+
flush()
|
|
160
|
+
return out.join('\n')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The viewer's body for a file from GET /v1/sessions/:id/file.
|
|
164
|
+
export function docViewerBody(r) {
|
|
165
|
+
if (!r) return ''
|
|
166
|
+
if (r.missing) return '<div class="empty">File no longer exists</div>'
|
|
167
|
+
if (r.tooBig) return '<div class="empty">' + esc(r.message || 'Too big to open here.') + '</div>'
|
|
168
|
+
if (r.type === 'markdown') return '<article class="doc-md">' + renderDoc(r.text || '') + '</article>'
|
|
169
|
+
if (r.type === 'image') {
|
|
170
|
+
const src = r.base64 ? 'data:' + (/^image\/(png|jpeg|gif|webp)$/.test(r.mime) ? r.mime : 'application/octet-stream') + ';base64,' + r.base64
|
|
171
|
+
: r.mime === 'image/svg+xml' && typeof r.text === 'string' ? 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(r.text) : ''
|
|
172
|
+
return src ? '<div class="doc-img"><img alt="' + esc(r.name) + '" src="' + esc(src) + '"></div>' : '<div class="empty">Can’t show this image.</div>'
|
|
173
|
+
}
|
|
174
|
+
if (r.type === 'text' || r.type === 'html') return '<pre class="doc-pre">' + esc(r.text || '') + '</pre>'
|
|
175
|
+
return '<div class="empty">No preview for this kind of file.</div>'
|
|
176
|
+
}
|
|
177
|
+
// A download for anything (an HTML file is offered as a file, never rendered live).
|
|
178
|
+
export function docDownloadHref(r) {
|
|
179
|
+
if (!r || r.missing || r.tooBig) return ''
|
|
180
|
+
const mime = r.type === 'html' ? 'text/plain' : r.mime || 'application/octet-stream'
|
|
181
|
+
if (typeof r.base64 === 'string') return 'data:' + mime + ';base64,' + r.base64
|
|
182
|
+
if (typeof r.text === 'string') return 'data:' + mime + ';charset=utf-8,' + encodeURIComponent(r.text)
|
|
183
|
+
return ''
|
|
184
|
+
}
|
package/ui/index.html
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
<a href="#/" data-route="board"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><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"/></svg><span class="lbl">Board</span></a>
|
|
30
30
|
<a href="#/waiting" data-route="waiting" id="needsTab"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M10.268 21a2 2 0 0 0 3.464 0"/> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg><span class="lbl">Needs you</span><span class="lbl-short">Needs</span><span class="navcount" id="needsCount" hidden></span></a>
|
|
31
31
|
<a href="#/lists" data-route="lists" id="listsTab"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M13 5h8"/> <path d="M13 12h8"/> <path d="M13 19h8"/> <path d="m3 17 2 2 4-4"/> <path d="m3 7 2 2 4-4"/></svg><span class="lbl">Lists</span><span class="navcount" id="listsCount" hidden></span></a>
|
|
32
|
+
<a href="#/prs" data-route="prs" id="prsTab"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="18" cy="18" r="3"/> <circle cx="6" cy="6" r="3"/> <path d="M13 6h3a2 2 0 0 1 2 2v7"/> <line x1="6" x2="6" y1="9" y2="21"/></svg><span class="lbl">PRs</span></a>
|
|
32
33
|
<a href="#/timeline" data-route="timeline"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/></svg><span class="lbl">Timeline</span></a>
|
|
33
34
|
<a href="#/digest" data-route="digest"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 18h-5"/> <path d="M18 14h-8"/> <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/> <rect width="8" height="4" x="10" y="6" rx="1"/></svg><span class="lbl">Digest</span></a>
|
|
34
35
|
</nav>
|
package/ui/prs.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The PRs tab and PR rows (0.6.9). Pure HTML builders, no fetching: app.js
|
|
2
|
+
// loads /v1/prs and /v1/sessions/:id and paints with these. Sessions and PRs
|
|
3
|
+
// are many-to-many; a row shows every session it is linked to.
|
|
4
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
5
|
+
const safeUrl = (u) => (/^https:\/\/github\.com\//.test(String(u || '')) ? String(u) : '')
|
|
6
|
+
|
|
7
|
+
export function shortAgo(iso, now = Date.now()) {
|
|
8
|
+
const t = Date.parse(iso || '')
|
|
9
|
+
if (!t) return ''
|
|
10
|
+
const s = Math.max(0, Math.round((now - t) / 1000))
|
|
11
|
+
if (s < 60) return 'now'
|
|
12
|
+
if (s < 3600) return Math.round(s / 60) + 'm'
|
|
13
|
+
if (s < 86400) return Math.round(s / 3600) + 'h'
|
|
14
|
+
return Math.round(s / 86400) + 'd'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ✓ 3/3, ✕ 1 failing, ◌ 2 running — the class says which.
|
|
18
|
+
export function checksHtml(ck, { compact = false } = {}) {
|
|
19
|
+
if (!ck || !ck.total) return ''
|
|
20
|
+
const cls = ck.failed ? 'fail' : ck.pending ? 'pending' : 'pass'
|
|
21
|
+
const ic = { fail: '✕', pending: '◌', pass: '✓' }[cls]
|
|
22
|
+
const full = ck.summary || (ck.total - ck.failed - ck.pending) + '/' + ck.total + ' checks passed'
|
|
23
|
+
// On a card: ✕ 1/3 (failing of all), ◌ 1/2 (passed so far), ✓ 3/3.
|
|
24
|
+
const short = (ck.failed ? ck.failed : ck.total - ck.failed - ck.pending) + '/' + ck.total
|
|
25
|
+
return '<span class="pr-checks ' + cls + '"' + (compact ? ' title="' + esc(full) + '" aria-label="' + esc(full) + '"' : '') + '><span aria-hidden="true">' + ic + '</span> ' + esc(compact ? short : full) + '</span>'
|
|
26
|
+
}
|
|
27
|
+
const STATE = { open: 'Open', draft: 'Draft', merged: 'Merged', closed: 'Closed' }
|
|
28
|
+
export function stateHtml(state) {
|
|
29
|
+
if (!state) return ''
|
|
30
|
+
return '<span class="pr-state ' + esc(state) + '">' + esc(STATE[state] || state) + '</span>'
|
|
31
|
+
}
|
|
32
|
+
const REVIEW = { approved: 'Approved', changes_requested: 'Changes requested', review_required: 'Review needed' }
|
|
33
|
+
|
|
34
|
+
// A session card's PRs: one compact line each (#, title, state, checks), three at most.
|
|
35
|
+
export function cardPrsHtml(prs) {
|
|
36
|
+
const list = (prs || []).filter((p) => p && p.number)
|
|
37
|
+
if (!list.length) return ''
|
|
38
|
+
const shown = list.slice(0, 3)
|
|
39
|
+
const more = list.length - shown.length
|
|
40
|
+
return '<div class="card-prs">' + shown.map((p) => {
|
|
41
|
+
const url = safeUrl(p.url)
|
|
42
|
+
const inner = '<span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + '<span class="pr-title">' + esc(p.title || p.repo || '') + '</span>' + checksHtml(p.checks, { compact: true })
|
|
43
|
+
return url ? '<a class="cpr" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" title="' + esc((p.repo ? p.repo + ' ' : '') + '#' + p.number + ' on GitHub') + '">' + inner + '</a>' : '<span class="cpr">' + inner + '</span>'
|
|
44
|
+
}).join('') + (more > 0 ? '<a class="cpr more" href="#/prs">+' + more + ' more</a>' : '') + '</div>'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The session page: its PRs with Unlink, and "Link a PR" (a text box: a URL or #number).
|
|
48
|
+
export function sessionPrsSection(s, { mayEdit = true } = {}) {
|
|
49
|
+
const list = (s.prs || []).filter((p) => p && p.number)
|
|
50
|
+
let h = '<h2>Pull requests' + (list.length ? ' <span class="count">' + list.length + '</span>' : '') + '</h2>'
|
|
51
|
+
if (list.length) {
|
|
52
|
+
h += '<div class="prlist">' + list.map((p) => {
|
|
53
|
+
const url = safeUrl(p.url)
|
|
54
|
+
return '<div class="prrow slim"><div class="pr-main">' +
|
|
55
|
+
'<div class="pr-line1"><span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) + '</div>' +
|
|
56
|
+
'<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
|
+
(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
|
+
}).join('') + '</div>'
|
|
60
|
+
} 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
|
+
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
|
+
return h
|
|
63
|
+
}
|
|
64
|
+
const VIA = { created: 'opened here', mentioned: 'mentioned', branch: 'this branch', manual: 'linked by hand' }
|
|
65
|
+
|
|
66
|
+
// gh missing or signed out: the 0.6.5 status line with the fix to copy.
|
|
67
|
+
export function ghLineHtml(g) {
|
|
68
|
+
if (!g || g.status === 'ready') return ''
|
|
69
|
+
const what = g.status === 'signed-out' ? 'gh is installed but not signed in to GitHub, so PRs can’t load.' : 'GitHub’s gh isn’t on this Mac, so PRs can’t load.'
|
|
70
|
+
let h = '<div class="ghcell prs-gh"><div class="ghrow"><span class="chip warn">' + (g.status === 'signed-out' ? 'Signed out' : 'Missing') + '</span><span class="ghwhat">' + what + '</span>' +
|
|
71
|
+
'<button type="button" class="linkish" data-action="prs-refresh">Check again</button></div>'
|
|
72
|
+
if (g.fix && g.fix.cmd) h += '<div class="joinline ghfix"><code class="cmd">' + esc(g.fix.cmd) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(g.fix.cmd) + '">Copy</button></div>'
|
|
73
|
+
if (g.fix && g.fix.url) h += '<div class="hint">Install gh from <a href="' + esc(g.fix.url) + '" target="_blank" rel="noopener">cli.github.com</a>, then run this.</div>'
|
|
74
|
+
return h + '</div>'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sessionLinks(r, opts) {
|
|
78
|
+
const ss = r.sessions || []
|
|
79
|
+
if (!ss.length) return ''
|
|
80
|
+
return '<div class="pr-sessions">' + ss.slice(0, 4).map((s) => {
|
|
81
|
+
const act = opts.sessionAction ? opts.sessionAction(s) : ''
|
|
82
|
+
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>'
|
|
83
|
+
}).join('') + (ss.length > 4 ? '<span class="faint">+' + (ss.length - 4) + '</span>' : '') + '</div>'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// One PR row on the PRs tab.
|
|
87
|
+
export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false } = {}) {
|
|
88
|
+
const p = r.pr || {}
|
|
89
|
+
const url = safeUrl(p.url)
|
|
90
|
+
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>' : '')
|
|
92
|
+
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)
|
|
95
|
+
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">' +
|
|
97
|
+
'<div class="pr-line1">' + line1 + '</div>' +
|
|
98
|
+
'<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>' : '') +
|
|
102
|
+
(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
|
+
}
|
|
104
|
+
|
|
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 } = {}) {
|
|
107
|
+
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
|
+
h += '<div class="filters prs-filters"><div class="seg" role="group" aria-label="Whose PRs">' +
|
|
109
|
+
[['mine', 'Mine'], ['team', 'Team']].map(([k, l]) => '<button type="button" data-action="prs-view" data-view="' + k + '" aria-pressed="' + (view === k) + '">' + l + '</button>').join('') +
|
|
110
|
+
'</div><button type="button" class="linkish" data-action="prs-refresh">Refresh</button></div>'
|
|
111
|
+
if (!d) return h + '<div class="empty">Loading pull requests…</div>'
|
|
112
|
+
if (d.error) return h + '<div class="banner warn"><span>' + esc(d.error) + '</span></div>'
|
|
113
|
+
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
|
+
}
|
|
125
|
+
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
|
+
if (foot.length) h += '<div class="prs-foot faint">' + esc(foot.join(' · ')) + '</div>'
|
|
127
|
+
return h
|
|
128
|
+
}
|