@leftoff/cli 0.8.5 → 0.8.7

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
@@ -276,13 +276,117 @@ function onKey(host, e) {
276
276
  }
277
277
  }
278
278
 
279
+
280
+ // 0.8.7: a card's "⋯" menu. Not a select: a list of actions (role menu, items
281
+ // role menuitem). The items are real <button data-action …> elements, so the
282
+ // board's one click handler runs them exactly as it ran the old row of buttons;
283
+ // choosing one closes the menu. Same look as the selects' menu: under (or
284
+ // above) the ⋯ on a desktop, a bottom sheet under 600px. Arrows, Home/End,
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 }
288
+ export function actionMenuOpen() { return !!act }
289
+ export function closeActionMenu({ focus = false } = {}) {
290
+ if (!act) return
291
+ const { anchor, menu, backdrop } = act
292
+ act = null
293
+ menu.remove(); if (backdrop) backdrop.remove()
294
+ anchor.setAttribute('aria-expanded', 'false')
295
+ if (focus && anchor.isConnected) anchor.focus()
296
+ }
297
+ function placeAction() {
298
+ if (!act) return
299
+ const { menu, anchor } = act
300
+ const vw = document.documentElement.clientWidth, vh = (window.visualViewport && window.visualViewport.height) || window.innerHeight
301
+ if (menu.classList.contains('sheet')) { menu.style.maxHeight = Math.round(vh * 0.72) + 'px'; return }
302
+ const r = anchor.getBoundingClientRect()
303
+ 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
306
+ // right edges line up (the ⋯ sits at the end of the row), never past the viewport
307
+ 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'
309
+ menu.classList.toggle('up', up)
310
+ }
311
+ function actFocus(k) {
312
+ if (!act) return
313
+ const n = act.items.length
314
+ if (!n) return
315
+ act.items[((k % n) + n) % n].focus()
316
+ }
317
+ export function openActionMenu(anchor, items, { title = '' } = {}) {
318
+ if (act && act.anchor === anchor) { closeActionMenu({ focus: true }); return null }
319
+ closeActionMenu(); closeMenu()
320
+ const sheet = narrow()
321
+ const id = 'lo-act' + (++uid)
322
+ const menu = document.createElement('div')
323
+ menu.className = 'lo-menu lo-actmenu' + (sheet ? ' sheet' : '')
324
+ menu.id = id
325
+ menu.setAttribute('role', 'menu')
326
+ if (title) menu.setAttribute('aria-label', title)
327
+ 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>' : '') +
328
+ items.map((it) => '<button type="button" role="menuitem" tabindex="-1" class="lo-act' + (it.cls ? ' ' + esc(it.cls) : '') + '"' + attrs(it.attrs || {}) + '>' + (it.icon || '') + '<span>' + esc(it.label) + '</span></button>').join('')
329
+ let backdrop = null
330
+ if (sheet) { backdrop = document.createElement('div'); backdrop.className = 'lo-backdrop'; document.body.appendChild(backdrop) }
331
+ document.body.appendChild(menu)
332
+ act = { anchor, menu, backdrop, items: [...menu.querySelectorAll('[role="menuitem"]')] }
333
+ anchor.setAttribute('aria-haspopup', 'menu')
334
+ anchor.setAttribute('aria-controls', id)
335
+ anchor.setAttribute('aria-expanded', 'true')
336
+ menu.addEventListener('click', (e) => {
337
+ if (e.target.closest('[data-act-close]')) { closeActionMenu({ focus: true }); return }
338
+ // the item's own click goes on to the board's handler (the event's path is
339
+ // already fixed); the menu just gets out of the way
340
+ if (e.target.closest('[role="menuitem"]')) closeActionMenu()
341
+ })
342
+ menu.addEventListener('mousemove', (e) => { const it = e.target.closest('[role="menuitem"]'); if (it && document.activeElement !== it) it.focus({ preventScroll: true }) })
343
+ menu.addEventListener('keydown', (e) => {
344
+ const k = act ? act.items.indexOf(document.activeElement) : -1
345
+ switch (e.key) {
346
+ case 'ArrowDown': e.preventDefault(); actFocus(k + 1); return
347
+ case 'ArrowUp': e.preventDefault(); actFocus(k < 0 ? -1 : k - 1); return
348
+ case 'Home': e.preventDefault(); actFocus(0); return
349
+ case 'End': e.preventDefault(); actFocus(-1); return
350
+ case 'Escape': e.preventDefault(); e.stopPropagation(); closeActionMenu({ focus: true }); return
351
+ case 'Tab': e.preventDefault(); closeActionMenu({ focus: true }); return
352
+ default:
353
+ if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && act) {
354
+ const ch = e.key.toLowerCase()
355
+ const j = act.items.findIndex((x, i) => i > k && x.textContent.trim().toLowerCase().startsWith(ch))
356
+ const j2 = j >= 0 ? j : act.items.findIndex((x) => x.textContent.trim().toLowerCase().startsWith(ch))
357
+ if (j2 >= 0) { e.preventDefault(); actFocus(j2) }
358
+ }
359
+ }
360
+ })
361
+ placeAction()
362
+ actFocus(0)
363
+ return menu
364
+ }
365
+ // the ⋯ button itself: ArrowDown/Up opens the menu too (Enter and Space click it)
366
+ export function actionMenuKey(anchor, e, open) {
367
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); open(); if (act && e.key === 'ArrowUp') actFocus(-1) }
368
+ }
369
+
279
370
  if (typeof window !== 'undefined' && window.customElements && !customElements.get('lo-select')) {
280
371
  customElements.define('lo-select', LoSelect)
281
372
  customElements.define('lo-combo', LoCombo)
282
373
  // a click outside (or a tap on the page behind a sheet) closes; so does the page moving under a desktop menu
283
374
  document.addEventListener('pointerdown', (e) => { if (open && !open.menu.contains(e.target) && !open.host.contains(e.target)) closeMenu() }, true)
284
- window.addEventListener('resize', () => { if (open) closeMenu() })
285
- document.addEventListener('scroll', (e) => { if (open && !open.menu.contains(e.target)) place() }, true)
375
+ window.addEventListener('resize', () => { if (open) closeMenu(); if (act) closeActionMenu() })
376
+ document.addEventListener('scroll', (e) => { if (open && !open.menu.contains(e.target)) place(); if (act && !act.menu.contains(e.target)) placeAction() }, true)
377
+ // 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
+ document.addEventListener('pointerdown', (e) => {
379
+ if (!act || act.menu.contains(e.target) || act.anchor.contains(e.target)) return
380
+ closeActionMenu()
381
+ // the click that ends this same tap is eaten; a later tap elsewhere is not
382
+ const x = e.clientX, y = e.clientY, at = Date.now()
383
+ const swallow = (ev) => {
384
+ document.removeEventListener('click', swallow, true)
385
+ if (Date.now() - at < 600 && Math.abs(ev.clientX - x) < 12 && Math.abs(ev.clientY - y) < 12) { ev.preventDefault(); ev.stopPropagation() }
386
+ }
387
+ document.addEventListener('click', swallow, true)
388
+ setTimeout(() => document.removeEventListener('click', swallow, true), 600)
389
+ }, true)
286
390
  // <label for="f-tz"> focuses (and on a click, opens) the control, as it would a select
287
391
  document.addEventListener('click', (e) => {
288
392
  const l = e.target.closest && e.target.closest('label[for]')
package/ui/repos.js CHANGED
@@ -198,3 +198,22 @@ export function splitChips(chips, selected = [], max = 8) {
198
198
  for (const c of chips.slice(max)) (isSel(c) ? shown : more).push(c)
199
199
  return { shown, more }
200
200
  }
201
+
202
+ // 0.8.6: find a session on the board. Every word must match one of: title,
203
+ // repo, branch, worktree, folder, last reply, first prompt, checkpoint text
204
+ // (extra), or the session id by prefix (paste it from /status, or cp:xxxxxx).
205
+ export function searchMatches(s, q, extra = []) {
206
+ const words = String(q || '').toLowerCase().trim().split(/\s+/).filter(Boolean)
207
+ if (!words.length) return true
208
+ 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,
210
+ cp && cp.doing, cp && cp.next, cp && cp.stoppedAt, ...extra].filter(Boolean).map((h) => String(h).toLowerCase())
211
+ const id = String(s.id || '').toLowerCase()
212
+ const short = String(s.shortId || '').toLowerCase()
213
+ return words.every((w) => {
214
+ const bare = w.replace(/^cp:/, '')
215
+ if (bare.length >= 4 && (id.startsWith(bare) || (short && short === bare))) return true
216
+ if (w.startsWith('cp:')) return false
217
+ return hay.some((h) => h.includes(w))
218
+ })
219
+ }
package/ui/viewer.js CHANGED
@@ -49,3 +49,18 @@ async function wouldPrompt(permissions) {
49
49
  }
50
50
  return known
51
51
  }
52
+
53
+ // 0.8.6: which machine is "this one" for the person looking: the board's own
54
+ // Mac on its localhost; over the tailnet the Mac whois named (when the folder
55
+ // knows it); over the relay or unknown, none (never the serving Mac by default).
56
+ export function viewerMachine(health) {
57
+ const h = health || {}
58
+ const v = h.viewer || null
59
+ if (!v) return null
60
+ if (v.remote === false) return v.machine || h.machine || null
61
+ return v.machine || null
62
+ }
63
+ export function thisOneSuffix(machine, health) {
64
+ const me = viewerMachine(health)
65
+ return me && machine === me ? ' (this one)' : ''
66
+ }