@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/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
- let act = null // { anchor, menu, backdrop, items }
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
- function placeAction() {
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
- menu.style.top = (up ? Math.max(pad, r.top - 6 - h) : r.bottom + 6) + 'px'
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 }), be a plain line ({ note }:
328
- // what the menu is about, read out as its description) or a row of small
329
- // buttons ({ row: [items], label }), e.g. the other checkpoint lengths.
330
- const one = (it, extra = '') => '<button type="button" role="menuitem" tabindex="-1" class="lo-act' + extra + (it.sub ? ' has-sub' : '') + (it.cls ? ' ' + esc(it.cls) : '') + '"' + attrs(it.attrs || {}) + '>' + (it.icon || '') +
331
- (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>') + '</button>'
332
- const notes = []
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
- 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>')
335
- : it.row ? '<div class="lo-act-row" role="group"' + (it.label ? ' aria-label="' + esc(it.label) + '"' : '') + '>' + (it.label ? '<span class="lo-act-rowlabel" aria-hidden="true">' + esc(it.label) + '</span>' : '') + it.row.map((x) => one(x, ' alt')).join('') + '</div>'
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
- act = { anchor, menu, backdrop, items: [...menu.querySelectorAll('[role="menuitem"]')] }
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 'Escape': e.preventDefault(); e.stopPropagation(); closeActionMenu({ focus: true }); return
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
+ }