@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/CHANGELOG.md +19 -0
- package/bin/leftoff.js +276 -266
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +43 -2
- package/mod/hooks/register.js +17 -4
- package/package.json +1 -1
- package/ui/app.css +48 -0
- package/ui/app.js +154 -21
- package/ui/icons.js +8 -1
- package/ui/machines.js +43 -0
- package/ui/picker.js +106 -2
- package/ui/repos.js +19 -0
- package/ui/viewer.js +15 -0
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
|
+
}
|