@leftoff/cli 0.9.3 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/bin/leftoff.js +310 -289
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +72 -6
- package/mod/hooks/register.js +11 -3
- package/package.json +1 -1
- package/ui/app.css +84 -10
- package/ui/app.js +181 -59
- package/ui/cp-text.js +43 -0
- package/ui/handoffs.js +1 -1
- package/ui/picker.js +84 -18
- package/ui/prs.js +1 -1
- package/ui/settings-dirty.js +27 -0
- package/ui/writing.js +55 -0
package/ui/picker.js
CHANGED
|
@@ -283,8 +283,9 @@ function onKey(host, e) {
|
|
|
283
283
|
// choosing one closes the menu. Same look as the selects' menu: under (or
|
|
284
284
|
// above) the ⋯ on a desktop, a bottom sheet under 600px. Arrows, Home/End,
|
|
285
285
|
// Enter/Space, Escape (focus back on the ⋯), Tab, and a tap outside closes.
|
|
286
|
-
// items: [{ label, icon (svg markup, optional), attrs: { 'data-action': …, 'data-id': … }, cls }]
|
|
287
|
-
|
|
286
|
+
// items: [{ label, icon (svg markup, optional), attrs: { 'data-action': …, 'data-id': … }, cls, sub, on }]
|
|
287
|
+
// plus { note } (a plain line) and { …item, more: { title, items } } (a › to a second page)
|
|
288
|
+
let act = null // { anchor, menu, backdrop, items, page, up }
|
|
288
289
|
export function actionMenuOpen() { return !!act }
|
|
289
290
|
export function closeActionMenu({ focus = false } = {}) {
|
|
290
291
|
if (!act) return
|
|
@@ -294,18 +295,22 @@ export function closeActionMenu({ focus = false } = {}) {
|
|
|
294
295
|
anchor.setAttribute('aria-expanded', 'false')
|
|
295
296
|
if (focus && anchor.isConnected) anchor.focus()
|
|
296
297
|
}
|
|
297
|
-
|
|
298
|
+
// h: the height to place for (a page change animates to it); keep: stay on the
|
|
299
|
+
// side of the ⋯ the menu opened on, so a second page doesn't make it jump.
|
|
300
|
+
function placeAction({ h: hTo, keep = false } = {}) {
|
|
298
301
|
if (!act) return
|
|
299
302
|
const { menu, anchor } = act
|
|
300
303
|
const vw = document.documentElement.clientWidth, vh = (window.visualViewport && window.visualViewport.height) || window.innerHeight
|
|
301
304
|
if (menu.classList.contains('sheet')) { menu.style.maxHeight = Math.round(vh * 0.72) + 'px'; return }
|
|
302
305
|
const r = anchor.getBoundingClientRect()
|
|
303
306
|
const pad = 8
|
|
304
|
-
const w = menu.offsetWidth, h = menu.offsetHeight
|
|
305
|
-
const up = vh - r.bottom - pad < h + 6 && r.top - pad > vh - r.bottom - pad
|
|
307
|
+
const w = menu.offsetWidth, h = hTo || menu.offsetHeight
|
|
308
|
+
const up = keep && act.up != null ? act.up : vh - r.bottom - pad < h + 6 && r.top - pad > vh - r.bottom - pad
|
|
309
|
+
act.up = up
|
|
306
310
|
// right edges line up (the ⋯ sits at the end of the row), never past the viewport
|
|
307
311
|
menu.style.left = Math.max(pad, Math.min(r.right - w, vw - pad - w)) + 'px'
|
|
308
|
-
|
|
312
|
+
// never past the bottom either (a taller second page can't fall off the screen)
|
|
313
|
+
menu.style.top = (up ? Math.max(pad, r.top - 6 - h) : Math.max(pad, Math.min(r.bottom + 6, vh - pad - h))) + 'px'
|
|
309
314
|
menu.classList.toggle('up', up)
|
|
310
315
|
}
|
|
311
316
|
function actFocus(k) {
|
|
@@ -314,6 +319,44 @@ function actFocus(k) {
|
|
|
314
319
|
if (!n) return
|
|
315
320
|
act.items[((k % n) + n) % n].focus()
|
|
316
321
|
}
|
|
322
|
+
const SVG = (d, cls = 'ic') => '<svg class="' + cls + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + d + '</svg>'
|
|
323
|
+
const ACT_NEXT = SVG('<path d="m9 18 6-6-6-6"/>', 'ic lo-act-chev')
|
|
324
|
+
const ACT_BACK = SVG('<path d="m15 18-6-6 6-6"/>', 'ic lo-act-chev')
|
|
325
|
+
const ACT_CHECK = SVG('<path d="M20 6 9 17l-5-5"/>', 'ic lo-act-check')
|
|
326
|
+
const reducedMotion = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
327
|
+
// 0.9.4: show page p of the menu (0 is the main one). The new page slides in
|
|
328
|
+
// from the side it lives on and the menu's height eases to the new one (its
|
|
329
|
+
// width is fixed), unless you asked for less motion.
|
|
330
|
+
function actPage(p, { focus } = {}) {
|
|
331
|
+
if (!act) return
|
|
332
|
+
const { menu } = act
|
|
333
|
+
const to = menu.querySelector('.lo-act-page[data-page="' + p + '"]'), from = act.page
|
|
334
|
+
if (!to || !from || to === from) return
|
|
335
|
+
const back = p < Number(from.dataset.page)
|
|
336
|
+
const h0 = menu.offsetHeight
|
|
337
|
+
from.hidden = true; to.hidden = false
|
|
338
|
+
from.classList.remove('lo-in-fwd', 'lo-in-back')
|
|
339
|
+
act.page = to
|
|
340
|
+
act.items = [...to.querySelectorAll('[role="menuitem"]')]
|
|
341
|
+
menu.classList.toggle('on-sub', p > 0)
|
|
342
|
+
const h1 = menu.offsetHeight
|
|
343
|
+
if (!reducedMotion()) {
|
|
344
|
+
to.classList.remove('lo-in-fwd', 'lo-in-back'); void to.offsetWidth
|
|
345
|
+
to.classList.add(back ? 'lo-in-back' : 'lo-in-fwd')
|
|
346
|
+
if (h1 !== h0) {
|
|
347
|
+
menu.classList.remove('lo-act-sizing')
|
|
348
|
+
menu.style.height = h0 + 'px'; void menu.offsetHeight
|
|
349
|
+
menu.classList.add('lo-act-sizing')
|
|
350
|
+
menu.style.height = h1 + 'px'
|
|
351
|
+
let t = 0
|
|
352
|
+
const done = (e) => { if (e && e.target !== menu) return; clearTimeout(t); menu.removeEventListener('transitionend', done); if (act && act.menu === menu) { menu.style.height = ''; menu.classList.remove('lo-act-sizing') } }
|
|
353
|
+
menu.addEventListener('transitionend', done); t = setTimeout(done, 320)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
placeAction({ h: h1, keep: true })
|
|
357
|
+
const el = focus || (p > 0 ? (to.querySelector('.lo-act.on') || act.items[1] || act.items[0]) : act.items[0])
|
|
358
|
+
if (el) el.focus({ preventScroll: true })
|
|
359
|
+
}
|
|
317
360
|
export function openActionMenu(anchor, items, { title = '' } = {}) {
|
|
318
361
|
if (act && act.anchor === anchor) { closeActionMenu({ focus: true }); return null }
|
|
319
362
|
closeActionMenu(); closeMenu()
|
|
@@ -324,39 +367,62 @@ export function openActionMenu(anchor, items, { title = '' } = {}) {
|
|
|
324
367
|
menu.id = id
|
|
325
368
|
menu.setAttribute('role', 'menu')
|
|
326
369
|
if (title) menu.setAttribute('aria-label', title)
|
|
327
|
-
// 0.9.2: an item may carry a sub line ({ sub })
|
|
328
|
-
// what the menu is about, read out as its description)
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
370
|
+
// 0.9.2: an item may carry a sub line ({ sub }) or be a plain line ({ note }:
|
|
371
|
+
// what the menu is about, read out as its description).
|
|
372
|
+
// 0.9.4: an item may open a second page ({ more: { title, items } }), the iOS
|
|
373
|
+
// way: the row itself does its action, and a › at its right edge (its own
|
|
374
|
+
// target) slides the menu over to that page, which starts with a ‹ title row
|
|
375
|
+
// (back). Items there may be { on }: a ✓, e.g. your default length.
|
|
376
|
+
const one = (it, extra = '') => '<button type="button" role="menuitem" tabindex="-1" class="lo-act' + extra + (it.sub ? ' has-sub' : '') + (it.on ? ' on' : '') + (it.cls ? ' ' + esc(it.cls) : '') + '"' + attrs(it.attrs || {}) + '>' + (it.icon || '') +
|
|
377
|
+
(it.sub ? '<span class="lo-act-txt"><span>' + esc(it.label) + '</span><span class="lo-act-sub">' + esc(it.sub) + '</span></span>' : '<span>' + esc(it.label) + '</span>') +
|
|
378
|
+
(it.on && it.onLabel ? '<span class="lo-vh">' + esc(it.onLabel) + '</span>' : '') + '</button>'
|
|
379
|
+
const notes = [], subs = []
|
|
380
|
+
const split = (it) => { subs.push(it.more); const p = subs.length
|
|
381
|
+
return '<div class="lo-act-split" role="none">' + one(it, ' lo-act-main') +
|
|
382
|
+
'<button type="button" role="menuitem" tabindex="-1" class="lo-act-next" data-act-page="' + p + '" aria-haspopup="menu" aria-label="' + esc(it.more.title) + '" title="' + esc(it.more.title) + '">' + ACT_NEXT + '</button></div>' }
|
|
383
|
+
const main = items.map((it, i) => it.note ? (notes.push(id + '-n' + i), '<div class="lo-act-note" id="' + id + '-n' + i + '" role="none">' + (it.icon || '') + '<span>' + esc(it.note) + '</span></div>')
|
|
384
|
+
: it.more ? split(it) : one(it)).join('')
|
|
385
|
+
const page = (m, p) => '<div class="lo-act-page" data-page="' + p + '" role="group" aria-label="' + esc(m.title) + '" hidden>' +
|
|
386
|
+
'<button type="button" role="menuitem" tabindex="-1" class="lo-act lo-act-back" data-act-back aria-label="' + esc('Back · ' + m.title) + '">' + ACT_BACK + '<span>' + esc(m.title) + '</span></button>' +
|
|
387
|
+
(m.items || []).map((x) => one({ ...x, icon: x.on ? ACT_CHECK : '<span class="ic" aria-hidden="true"></span>' })).join('') + '</div>'
|
|
333
388
|
menu.innerHTML = (sheet && title ? '<div class="lo-sheet-head"><span>' + esc(title) + '</span><button type="button" class="lo-sheet-done" data-act-close>Done</button></div>' : '') +
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
: one(it)).join('')
|
|
389
|
+
(subs.length ? '<div class="lo-act-page" data-page="0" role="none">' + main + '</div>' + subs.map((m, j) => page(m, j + 1)).join('') : main)
|
|
390
|
+
if (subs.length) menu.classList.add('has-pages')
|
|
337
391
|
if (notes.length) menu.setAttribute('aria-describedby', notes.join(' '))
|
|
338
392
|
let backdrop = null
|
|
339
393
|
if (sheet) { backdrop = document.createElement('div'); backdrop.className = 'lo-backdrop'; document.body.appendChild(backdrop) }
|
|
340
394
|
document.body.appendChild(menu)
|
|
341
|
-
|
|
395
|
+
const page0 = menu.querySelector('.lo-act-page[data-page="0"]')
|
|
396
|
+
act = { anchor, menu, backdrop, page: page0, up: null, items: [...(page0 || menu).querySelectorAll('[role="menuitem"]')] }
|
|
342
397
|
anchor.setAttribute('aria-haspopup', 'menu')
|
|
343
398
|
anchor.setAttribute('aria-controls', id)
|
|
344
399
|
anchor.setAttribute('aria-expanded', 'true')
|
|
400
|
+
const backFocus = () => act && act.menu.querySelector('[data-act-page="' + act.page.dataset.page + '"]')
|
|
345
401
|
menu.addEventListener('click', (e) => {
|
|
346
402
|
if (e.target.closest('[data-act-close]')) { closeActionMenu({ focus: true }); return }
|
|
403
|
+
const next = e.target.closest('[data-act-page]')
|
|
404
|
+
if (next) { e.preventDefault(); actPage(Number(next.dataset.actPage)); return }
|
|
405
|
+
if (e.target.closest('[data-act-back]')) { e.preventDefault(); actPage(0, { focus: backFocus() }); return }
|
|
347
406
|
// the item's own click goes on to the board's handler (the event's path is
|
|
348
407
|
// already fixed); the menu just gets out of the way
|
|
349
408
|
if (e.target.closest('[role="menuitem"]')) closeActionMenu()
|
|
350
409
|
})
|
|
351
|
-
menu.addEventListener('mousemove', (e) => { const it = e.target.closest('[role="menuitem"]'); if (it && document.activeElement !== it) it.focus({ preventScroll: true }) })
|
|
410
|
+
menu.addEventListener('mousemove', (e) => { const it = e.target.closest('[role="menuitem"]'); if (it && document.activeElement !== it && act && act.page && act.page.contains(it)) it.focus({ preventScroll: true }) })
|
|
352
411
|
menu.addEventListener('keydown', (e) => {
|
|
353
412
|
const k = act ? act.items.indexOf(document.activeElement) : -1
|
|
413
|
+
const onSub = !!(act && act.page && Number(act.page.dataset.page) > 0)
|
|
354
414
|
switch (e.key) {
|
|
355
415
|
case 'ArrowDown': e.preventDefault(); actFocus(k + 1); return
|
|
356
416
|
case 'ArrowUp': e.preventDefault(); actFocus(k < 0 ? -1 : k - 1); return
|
|
357
417
|
case 'Home': e.preventDefault(); actFocus(0); return
|
|
358
418
|
case 'End': e.preventDefault(); actFocus(-1); return
|
|
359
|
-
case '
|
|
419
|
+
case 'ArrowRight': {
|
|
420
|
+
const row = document.activeElement && document.activeElement.closest('.lo-act-split')
|
|
421
|
+
if (row) { e.preventDefault(); actPage(Number(row.querySelector('[data-act-page]').dataset.actPage)) }
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
case 'ArrowLeft': if (onSub) { e.preventDefault(); actPage(0, { focus: backFocus() }) } return
|
|
425
|
+
case 'Escape': e.preventDefault(); e.stopPropagation(); if (onSub) actPage(0, { focus: backFocus() }); else closeActionMenu({ focus: true }); return
|
|
360
426
|
case 'Tab': e.preventDefault(); closeActionMenu({ focus: true }); return
|
|
361
427
|
default:
|
|
362
428
|
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && act) {
|
package/ui/prs.js
CHANGED
|
@@ -60,7 +60,7 @@ export function sessionPrsSection(s, { mayEdit = true } = {}) {
|
|
|
60
60
|
(mayEdit ? '<button type="button" class="btn sm" data-action="pr-unlink" data-id="' + esc(s.id) + '" data-url="' + esc(p.url) + '">Unlink</button>' : '') + '</div>'
|
|
61
61
|
}).join('') + '</div>'
|
|
62
62
|
} 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>'
|
|
63
|
-
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>'
|
|
63
|
+
if (mayEdit) h += '<form class="prlink addform sess-add" 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" enterkeyhint="done"><button type="submit" class="btn sm">Link</button></form>'
|
|
64
64
|
return h
|
|
65
65
|
}
|
|
66
66
|
// 0.8.5: why a PR is linked, in words (the daemon sends created / you / branch / manual).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Settings → Save (0.9.5): Save is on only while the form differs from what
|
|
2
|
+
// was last saved. Pure, so the daemon-free unit tests can check the compare.
|
|
3
|
+
|
|
4
|
+
// Deep equality for the plain values the Settings form reads: numbers, strings,
|
|
5
|
+
// booleans, null, arrays and plain objects. A key that is missing equals one
|
|
6
|
+
// that is undefined; NaN equals NaN (an emptied number field reads the same twice).
|
|
7
|
+
export function sameValue(a, b) {
|
|
8
|
+
if (Object.is(a, b)) return true
|
|
9
|
+
if (a === undefined || b === undefined || a === null || b === null) return false
|
|
10
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
11
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
|
12
|
+
return a.every((x, i) => sameValue(x, b[i]))
|
|
13
|
+
}
|
|
14
|
+
if (typeof a !== 'object' || typeof b !== 'object') return false
|
|
15
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)])
|
|
16
|
+
for (const k of keys) if (!sameValue(a[k], b[k])) return false
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The top-level fields that differ, in the order of `now` (then `saved`).
|
|
21
|
+
export function changedKeys(saved, now) {
|
|
22
|
+
if (!saved || !now) return []
|
|
23
|
+
const keys = [...new Set([...Object.keys(now), ...Object.keys(saved)])]
|
|
24
|
+
return keys.filter((k) => !sameValue(saved[k], now[k]))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const settingsDirty = (saved, now) => changedKeys(saved, now).length > 0
|
package/ui/writing.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// "Checkpoint now" while it's being written (0.9.5): a state that stays on the
|
|
2
|
+
// card and the session page until the checkpoint lands or fails, instead of a
|
|
3
|
+
// toast you can miss. Pure, so the unit tests can drive it with a fake clock.
|
|
4
|
+
//
|
|
5
|
+
// One entry per session id:
|
|
6
|
+
// writing the request is out (this Mac's summarizer or the Mac it lives on)
|
|
7
|
+
// syncing the other Mac took longer than we wait; it shows up when it syncs
|
|
8
|
+
// late ...and still nothing after WRITING_SYNC_MS: a quiet note instead
|
|
9
|
+
// failed it didn't work; the error stays until dismissed or the next try
|
|
10
|
+
|
|
11
|
+
export const WRITING_SYNC_MS = 3 * 60 * 1000
|
|
12
|
+
export const FRESH_MS = 2500
|
|
13
|
+
|
|
14
|
+
export function beginWriting(map, id, { length = null, lastCpId = null, machine = null, now = Date.now() } = {}) {
|
|
15
|
+
map[id] = { phase: 'writing', length, lastCpId, machine, since: now }
|
|
16
|
+
return map[id]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const isWriting = (map, id) => !!(map[id] && (map[id].phase === 'writing' || map[id].phase === 'syncing'))
|
|
20
|
+
|
|
21
|
+
// The request came back: { ok: true }, { slow: true } (the other Mac is still at
|
|
22
|
+
// it) or { error: 'message' }.
|
|
23
|
+
export function settleWriting(map, id, outcome, now = Date.now()) {
|
|
24
|
+
const w = map[id]
|
|
25
|
+
if (!w) return null
|
|
26
|
+
if (outcome && outcome.ok) { delete map[id]; return null }
|
|
27
|
+
if (outcome && outcome.slow) { w.phase = 'syncing'; w.syncSince = now; return w }
|
|
28
|
+
w.phase = 'failed'; w.error = String((outcome && outcome.error) || 'Couldn’t write the checkpoint'); w.at = now
|
|
29
|
+
return w
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Read against the session as it is now. A synced checkpoint newer than the one
|
|
33
|
+
// we started from ends syncing/late (returns { landed: id }); syncing too long
|
|
34
|
+
// turns into late. Writing and failed don't change here.
|
|
35
|
+
export function reconcileWriting(map, s, now = Date.now()) {
|
|
36
|
+
const w = s && map[s.id]
|
|
37
|
+
if (!w) return null
|
|
38
|
+
const newer = s.lastCheckpointId && s.lastCheckpointId !== w.lastCpId
|
|
39
|
+
if ((w.phase === 'syncing' || w.phase === 'late' || w.phase === 'failed') && newer) { delete map[s.id]; return { landed: s.lastCheckpointId } }
|
|
40
|
+
if (w.phase === 'syncing' && now - (w.syncSince || w.since) >= WRITING_SYNC_MS) w.phase = 'late'
|
|
41
|
+
return w
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function dismissWriting(map, id) { if (map[id] && (map[id].phase === 'failed' || map[id].phase === 'late')) delete map[id] }
|
|
45
|
+
|
|
46
|
+
// The words shown. lengthLabel maps 'short' → 'Short'.
|
|
47
|
+
export function writingText(w, lengthLabel = (k) => k) {
|
|
48
|
+
if (!w) return ''
|
|
49
|
+
const len = w.length ? ' · ' + lengthLabel(w.length) : ''
|
|
50
|
+
if (w.phase === 'writing') return 'Writing checkpoint' + len + (w.machine ? ' on ' + w.machine : '') + '…'
|
|
51
|
+
if (w.phase === 'syncing') return (w.machine || 'The other Mac') + ' is still writing it · it shows up here when it syncs…'
|
|
52
|
+
if (w.phase === 'late') return 'Not here yet · it shows up when ' + (w.machine || 'the other Mac') + ' syncs'
|
|
53
|
+
if (w.phase === 'failed') return 'Couldn’t write the checkpoint: ' + w.error
|
|
54
|
+
return ''
|
|
55
|
+
}
|