@leftoff/cli 0.8.9 → 0.9.1
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 +26 -1
- package/assets/sample-transcript.jsonl +12 -12
- package/bin/leftoff.js +377 -372
- 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 +36 -0
- package/ui/app.js +244 -43
- package/ui/icons.js +2 -0
- package/ui/picker.js +38 -1
- package/ui/prs.js +61 -14
- package/ui/repos.js +23 -3
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,12 +80,15 @@ 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
|
|
@@ -122,6 +126,20 @@ export function plainBody(md) {
|
|
|
122
126
|
.replace(/<\/?[a-z][^>]*>/gi, '').replace(/^#{1,6}\s+/gm, '').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1')
|
|
123
127
|
.replace(/^[ \t]*[-*][ \t]+\[x\][ \t]+/gim, '☑ ').replace(/^[ \t]*[-*][ \t]+\[ \][ \t]+/gm, '☐ ').replace(/^[ \t]*[-*][ \t]+/gm, '• ').replace(/\n{3,}/g, '\n\n').trim()
|
|
124
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
|
+
}
|
|
125
143
|
export function prDetailHtml(p, st, { now = Date.now() } = {}) {
|
|
126
144
|
if (!st || st.loading) return '<div class="pr-detail loading" aria-busy="true">Loading details…</div>'
|
|
127
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>'
|
|
@@ -146,7 +164,7 @@ export function prDetailHtml(p, st, { now = Date.now() } = {}) {
|
|
|
146
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)
|
|
147
165
|
const body = plainBody(d.body)
|
|
148
166
|
h += '<div class="pd-sec"><div class="pd-h">Description' + (stats.length ? ' <span class="pd-stats">' + stats.join(' ') + '</span>' : '') + '</div>' +
|
|
149
|
-
(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>'
|
|
150
168
|
if ((d.comments || []).length) {
|
|
151
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) => {
|
|
152
170
|
const first = String(c.body || '').split('\n').filter((x) => x.trim()).slice(0, 2).join(' ')
|
|
@@ -214,17 +232,19 @@ export function readPrFilter(params) {
|
|
|
214
232
|
repos: [...new Set(p.getAll('repo').flatMap(splitList))],
|
|
215
233
|
state,
|
|
216
234
|
q: String(p.get('q') || '').slice(0, 200),
|
|
235
|
+
project: p.get('project') || null, // 0.9.0
|
|
217
236
|
}
|
|
218
237
|
}
|
|
219
238
|
export function prsHash(view, f = {}) {
|
|
220
239
|
const parts = []
|
|
221
240
|
if (view === 'team') parts.push('view=team')
|
|
241
|
+
if (f.project) parts.push('project=' + encodeURIComponent(f.project))
|
|
222
242
|
if (f.repos && f.repos.length) parts.push('repo=' + f.repos.map((r) => encodeURIComponent(r).replace(/%2F/gi, '/')).join(','))
|
|
223
243
|
if (f.state) parts.push('state=' + f.state)
|
|
224
244
|
if (f.q && f.q.trim()) parts.push('q=' + encodeURIComponent(f.q.trim()))
|
|
225
245
|
return '#/prs' + (parts.length ? '?' + parts.join('&') : '')
|
|
226
246
|
}
|
|
227
|
-
export const prFilterActive = (f) => !!(f && ((f.repos && f.repos.length) || f.state || (f.q && f.q.trim())))
|
|
247
|
+
export const prFilterActive = (f) => !!(f && ((f.repos && f.repos.length) || f.state || f.project || (f.q && f.q.trim())))
|
|
228
248
|
|
|
229
249
|
// Every row, flat: the daemon's `rows`, or an older daemon's groups.
|
|
230
250
|
export function prRows(d) {
|
|
@@ -254,9 +274,19 @@ export function inPrRepos(r, repos, labels = null) {
|
|
|
254
274
|
const key = r.pr.repo || 'no-repo'
|
|
255
275
|
return repos.some((t) => repoMatches({ repoKey: key, repoLabel: labels ? labels.get(key) : null }, t))
|
|
256
276
|
}
|
|
257
|
-
|
|
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 = [] } = {}) {
|
|
258
288
|
const labels = labelRepos(rows.map((r) => r.pr.repo || 'no-repo'))
|
|
259
|
-
return rows.filter((r) => (skip === 'repos' || inPrRepos(r, f.repos, labels)) && (skip === 'state' || inPrState(r, f.state, me)) && inPrSearch(r, f.q))
|
|
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))
|
|
260
290
|
}
|
|
261
291
|
// Groups (0.8.9: the buckets), newest first in each; anything with no
|
|
262
292
|
// activity for 14+ days in Quiet.
|
|
@@ -277,15 +307,32 @@ function fchip({ on, count, label, attrs, cls = '' }) {
|
|
|
277
307
|
}
|
|
278
308
|
|
|
279
309
|
// The chip rows and the grouped list (repainted as you type in the search box).
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
282
320
|
const all = prRows(d)
|
|
283
321
|
const me = d.me || null
|
|
284
322
|
let h = ''
|
|
285
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
|
+
}
|
|
286
332
|
// Repos: All, then by latest activity; a chosen one always shows, first and removable.
|
|
287
|
-
const
|
|
288
|
-
|
|
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 })
|
|
289
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 })
|
|
290
337
|
chips = orderSelectedFirst(chips, f.repos)
|
|
291
338
|
if (chips.length > 1 || f.repos.length) {
|
|
@@ -295,14 +342,14 @@ export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true
|
|
|
295
342
|
// 0.8.9: one row of buckets. The four open ones always show (dimmed at 0,
|
|
296
343
|
// after the rest), Merged and Closed only when there are some; a chosen
|
|
297
344
|
// one comes first.
|
|
298
|
-
const stCount = filterPrRows(all, f, { skip: 'state', me })
|
|
345
|
+
const stCount = filterPrRows(all, f, { skip: 'state', me, projects })
|
|
299
346
|
const bs = PR_BUCKETS.map(([k, l]) => ({ v: k, l, n: stCount.filter((r) => bucketOf(r, me) === k).length, on: f.state === k }))
|
|
300
347
|
const shown = bs.filter((x) => !x.on && (x.n || ['review', 'fix', 'ready', 'waiting'].includes(x.v)) && !(x.v === 'review' && !x.n && !me))
|
|
301
348
|
// the ones with PRs lead, so on a phone the empty ones are what scrolls off
|
|
302
349
|
const ordered = [...bs.filter((x) => x.on), ...shown.filter((x) => x.n), ...shown.filter((x) => !x.n)]
|
|
303
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')
|
|
304
351
|
}
|
|
305
|
-
const rows = filterPrRows(all, f, { me })
|
|
352
|
+
const rows = filterPrRows(all, f, { me, projects })
|
|
306
353
|
const { groups, quiet } = groupPrRows(rows, { now, me })
|
|
307
354
|
const showMachine = !!d.multiMachine
|
|
308
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 })
|
|
@@ -311,7 +358,7 @@ export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true
|
|
|
311
358
|
} else if (!rows.length) {
|
|
312
359
|
h += '<div class="empty">No PRs match. <button type="button" class="linkish" data-action="prs-clear">Clear filters</button></div>'
|
|
313
360
|
} else {
|
|
314
|
-
for (const g of groups) h += '<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>'
|
|
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>'
|
|
315
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>'
|
|
316
363
|
}
|
|
317
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()
|