sideby 0.12.1 → 0.13.0
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/README.md +1 -1
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +4 -2
- package/dist/src/cli/commands.js +117 -17
- package/dist/src/cli/json-schemas.js +52 -1
- package/dist/src/cli/main.js +34 -3
- package/dist/src/core/brand-mark.d.ts +5 -7
- package/dist/src/core/brand-mark.js +18 -5
- package/dist/src/core/browser-profiles.d.ts +71 -0
- package/dist/src/core/browser-profiles.js +361 -0
- package/dist/src/core/config.d.ts +15 -0
- package/dist/src/core/config.js +13 -1
- package/dist/src/core/create.js +2 -1
- package/dist/src/core/desktop-app.d.ts +3 -1
- package/dist/src/core/desktop-app.js +29 -8
- package/dist/src/core/launch.d.ts +7 -0
- package/dist/src/core/launch.js +22 -2
- package/dist/src/core/leveldb-keys.d.ts +14 -0
- package/dist/src/core/leveldb-keys.js +291 -0
- package/dist/src/core/remote-control.d.ts +17 -6
- package/dist/src/core/remote-control.js +40 -17
- package/dist/src/core/update.d.ts +10 -3
- package/dist/src/core/update.js +38 -3
- package/dist/src/core/version.d.ts +11 -0
- package/dist/src/core/version.js +26 -0
- package/dist/src/desktop/app.d.ts +2 -0
- package/dist/src/desktop/app.js +39 -3
- package/dist/src/families/browser.d.ts +20 -0
- package/dist/src/families/browser.js +23 -0
- package/dist/src/families/claude/browser.d.ts +3 -0
- package/dist/src/families/claude/browser.js +14 -0
- package/dist/src/families/claude/desktop.js +1 -0
- package/dist/src/families/codex/browser.d.ts +7 -0
- package/dist/src/families/codex/browser.js +26 -0
- package/dist/src/families/desktop.d.ts +5 -1
- package/dist/src/families/pi/index.js +2 -0
- package/dist/src/panel/handler.d.ts +6 -0
- package/dist/src/panel/handler.js +49 -6
- package/dist/src/panel/i18n.d.ts +71 -4
- package/dist/src/panel/i18n.js +152 -18
- package/dist/src/panel/page-logic.d.ts +23 -0
- package/dist/src/panel/page-logic.js +44 -0
- package/dist/src/panel/page-script.js +576 -181
- package/dist/src/panel/page-styles.js +61 -18
- package/dist/src/panel/page.d.ts +5 -0
- package/dist/src/panel/page.js +10 -9
- package/dist/src/runtime.d.ts +6 -1
- package/dist/src/runtime.js +22 -11
- package/dist/src/types.d.ts +2 -0
- package/package.json +5 -3
- package/schemas/browser-open.json +47 -0
- package/schemas/browser-switch.json +42 -0
- package/schemas/browser.json +99 -0
- package/schemas/config.json +11 -0
- package/schemas/families.json +6 -0
- package/schemas/list.json +6 -0
- package/schemas/remote-control.json +4 -0
- package/schemas/update.json +4 -0
- package/skills/sideby/SKILL.md +5 -3
- package/skills/sideby/references/setup.md +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The Panel page's inline script. Plain browser JavaScript in a string (no template literals inside, so String.raw
|
|
2
2
|
// keeps it verbatim); the pure helpers from page-logic.ts are prepended so the tested code is the code that runs.
|
|
3
|
-
import { aliasArgsOf, aliasIssue, attentionReasons, attentionWhy, cacheHitRate, formatDuration, handoffChangeOf, handoffHintAccount, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelHint, panelLang, passedWindow, quotaPressure, sortAccounts, suggestAlias, suggestName, updatedAge, windowState, } from './page-logic.js';
|
|
3
|
+
import { aliasArgsOf, aliasIssue, attentionReasons, attentionWhy, cacheHitRate, formatDuration, handoffChangeOf, handoffHintAccount, loginClusters, loginKey, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelHint, panelLang, passedWindow, quotaPressure, remoteControlSummary, remoteControlTodo, sortAccounts, suggestAlias, suggestName, updatedAge, windowState, } from './page-logic.js';
|
|
4
4
|
const LOGIC = [
|
|
5
5
|
aliasArgsOf,
|
|
6
6
|
aliasIssue,
|
|
@@ -10,6 +10,8 @@ const LOGIC = [
|
|
|
10
10
|
formatDuration,
|
|
11
11
|
handoffChangeOf,
|
|
12
12
|
handoffHintAccount,
|
|
13
|
+
loginClusters,
|
|
14
|
+
loginKey,
|
|
13
15
|
maskEmail,
|
|
14
16
|
matchesQuery,
|
|
15
17
|
mergeDaily,
|
|
@@ -19,6 +21,8 @@ const LOGIC = [
|
|
|
19
21
|
panelLang,
|
|
20
22
|
passedWindow,
|
|
21
23
|
quotaPressure,
|
|
24
|
+
remoteControlSummary,
|
|
25
|
+
remoteControlTodo,
|
|
22
26
|
sortAccounts,
|
|
23
27
|
suggestAlias,
|
|
24
28
|
suggestName,
|
|
@@ -53,7 +57,7 @@ const MAIN = String.raw `
|
|
|
53
57
|
silent: false, lastTry: 0, pending: false, fastUntil: 0, fastFamily: null,
|
|
54
58
|
}
|
|
55
59
|
// View state that is not saved: the search text, the attention filter, the Health check filter.
|
|
56
|
-
const ui = { query: '', attention: false, allFindings: false, healthRef: null, checkingRef: null, desktopRef: null, appBusy: null, appRemove: null, appNew: null, highlight: null, fade: true }
|
|
60
|
+
const ui = { query: '', attention: false, allFindings: false, healthRef: null, checkingRef: null, desktopRef: null, browserRef: null, appBusy: null, appRemove: null, appNew: null, highlight: null, fade: true, loginRefs: new Set() }
|
|
57
61
|
const $ = (id) => document.getElementById(id)
|
|
58
62
|
|
|
59
63
|
// ---------- preferences ----------
|
|
@@ -65,7 +69,7 @@ const MAIN = String.raw `
|
|
|
65
69
|
function loadPrefs() {
|
|
66
70
|
let raw = {}
|
|
67
71
|
try { raw = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}') || {} } catch {}
|
|
68
|
-
const p = { view: 'list', sort: 'pressure', quota: 'used', theme: 'system', lang: '', family: '', collapsed: {},
|
|
72
|
+
const p = { view: 'list', sort: 'pressure', quota: 'used', theme: 'system', lang: '', family: '', collapsed: {}, open: {}, hideEmail: false }
|
|
69
73
|
for (const k of Object.keys(CHOICES)) if (CHOICES[k].includes(raw[k])) p[k] = raw[k]
|
|
70
74
|
if (raw.hideEmail === true) p.hideEmail = true
|
|
71
75
|
if (typeof raw.ahHintAt === 'number') p.ahHintAt = raw.ahHintAt
|
|
@@ -73,8 +77,8 @@ const MAIN = String.raw `
|
|
|
73
77
|
if (typeof raw.family === 'string') p.family = raw.family
|
|
74
78
|
if (raw.collapsed && typeof raw.collapsed === 'object')
|
|
75
79
|
for (const k of Object.keys(raw.collapsed)) if (raw.collapsed[k] === true) p.collapsed[k] = true
|
|
76
|
-
if (raw.
|
|
77
|
-
for (const k of Object.keys(raw.
|
|
80
|
+
if (raw.open && typeof raw.open === 'object')
|
|
81
|
+
for (const k of Object.keys(raw.open)) if (raw.open[k] === true) p.open[k] = true
|
|
78
82
|
return p
|
|
79
83
|
}
|
|
80
84
|
function savePrefs() { try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) } catch {} }
|
|
@@ -229,6 +233,8 @@ const MAIN = String.raw `
|
|
|
229
233
|
// Whether the viewer is in the middle of something a background render would disturb: an open menu, dialog or
|
|
230
234
|
// tooltip, a focused field, or a text selection in the account list.
|
|
231
235
|
function uiBusy() {
|
|
236
|
+
// A row or group in the middle of folding; a render now would cut the motion short.
|
|
237
|
+
for (const el of folding) if (el.isConnected) return true
|
|
232
238
|
if (menu.el || (dlg.el && dlg.el.open) || document.querySelector('.tip.is-open')) return true
|
|
233
239
|
// A plan on the Auto Handoff page, and the change a person opened to read, stay as they are.
|
|
234
240
|
if (!$('handoff-page').hidden && (hp.preview || hp.askSame || document.querySelector('#handoff-page details[open]'))) return true
|
|
@@ -247,6 +253,8 @@ const MAIN = String.raw `
|
|
|
247
253
|
sun: 'M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4',
|
|
248
254
|
moon: 'M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z',
|
|
249
255
|
monitor: 'M3 5h18v11H3zM8 20h8M12 16v4',
|
|
256
|
+
// Lucide "settings" (ISC license)
|
|
257
|
+
gear: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
|
|
250
258
|
copy: 'M9 9h11v11H9zM5 15H4V4h11v1',
|
|
251
259
|
terminal: 'M4 17l6-5-6-5M12 19h8',
|
|
252
260
|
key: 'M15 9a3 3 0 1 0-.01 0M12.9 11.1L4 20M7 17l2 2M9.5 14.5l2 2',
|
|
@@ -258,8 +266,10 @@ const MAIN = String.raw `
|
|
|
258
266
|
bot: 'M6 9h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2zM12 5v4M9.5 13.5v1M14.5 13.5v1',
|
|
259
267
|
grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
|
|
260
268
|
list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
|
|
269
|
+
sliders: 'M4 7h10M18 7h2M4 17h4M12 17h8M16 5v4M10 15v4',
|
|
261
270
|
clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
|
|
262
271
|
mail: 'M3 6h18v12H3zM3 7l9 6 9-6',
|
|
272
|
+
globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3c2.5 2.5 3.8 5.5 3.8 9s-1.3 6.5-3.8 9c-2.5-2.5-3.8-5.5-3.8-9S9.5 5.5 12 3z',
|
|
263
273
|
eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
|
|
264
274
|
eyeOff: 'M3 3l18 18M10.6 5.1A10.8 10.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.6 6.6A17.4 17.4 0 0 0 2 12s3.5 7 10 7a10.4 10.4 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2',
|
|
265
275
|
}
|
|
@@ -526,29 +536,51 @@ const MAIN = String.raw `
|
|
|
526
536
|
document.addEventListener('focusin', (e) => { if (e.target.classList && e.target.classList.contains('tip-btn')) showTip(e.target) })
|
|
527
537
|
document.addEventListener('focusout', (e) => { if (e.target.classList && e.target.classList.contains('tip-btn')) hideTip(e.target) })
|
|
528
538
|
|
|
529
|
-
// ----------
|
|
530
|
-
|
|
531
|
-
|
|
539
|
+
// ---------- floating panels: menus and popovers ----------
|
|
540
|
+
// One shared base for everything that floats next to a button: it is placed under (or above) its anchor, kept
|
|
541
|
+
// inside the window, closed by a click outside, Escape, a resize or a scroll, and only one is open at a time.
|
|
542
|
+
// openMenu() fills it with menu items; openPopover() with any content that render() returns (re-run by refreshPopover()).
|
|
543
|
+
const menu = { el: null, anchor: null, kind: null, render: null }
|
|
544
|
+
function openFloat(anchor, el, kind, align) {
|
|
532
545
|
closeMenu()
|
|
533
|
-
const el = h('div', { class: 'menu', role: 'menu', 'aria-label': label },
|
|
534
|
-
items.filter(Boolean).map((it) => it === '-' ? h('div', { class: 'menu-sep', role: 'separator' })
|
|
535
|
-
: h('button', { class: 'menu-item', type: 'button', role: 'menuitem', tabindex: '-1', disabled: it.disabled, onclick: () => { closeMenu(true); it.run() } },
|
|
536
|
-
icon(it.icon), h('span', { class: 'menu-label' }, it.label), it.hint ? h('code', { class: 'menu-hint', title: it.hint }, it.hint.length > 24 ? '…' + it.hint.slice(-23) : it.hint) : null)))
|
|
537
|
-
el.addEventListener('keydown', menuKeys)
|
|
538
546
|
document.body.append(el)
|
|
539
547
|
const r = anchor.getBoundingClientRect()
|
|
540
548
|
const w = el.offsetWidth
|
|
541
549
|
const hgt = el.offsetHeight
|
|
542
|
-
const
|
|
550
|
+
const x = align === 'start' ? r.left : r.right - w
|
|
551
|
+
const left = Math.max(8, Math.min(window.innerWidth - w - 8, x))
|
|
543
552
|
const top = r.bottom + 4 + hgt > window.innerHeight - 8 && r.top - hgt - 4 > 8 ? r.top - hgt - 4 : r.bottom + 4
|
|
544
553
|
el.style.setProperty('left', left + 'px')
|
|
545
554
|
el.style.setProperty('top', top + 'px')
|
|
546
555
|
anchor.setAttribute('aria-expanded', 'true')
|
|
547
556
|
menu.el = el
|
|
548
557
|
menu.anchor = anchor
|
|
558
|
+
menu.kind = kind
|
|
559
|
+
}
|
|
560
|
+
function openMenu(anchor, items, label) {
|
|
561
|
+
const el = h('div', { class: 'menu', role: 'menu', 'aria-label': label },
|
|
562
|
+
items.filter(Boolean).map((it) => it === '-' ? h('div', { class: 'menu-sep', role: 'separator' })
|
|
563
|
+
: h('button', { class: 'menu-item', type: 'button', role: 'menuitem', tabindex: '-1', disabled: it.disabled, onclick: () => { closeMenu(true); it.run() } },
|
|
564
|
+
icon(it.icon), h('span', { class: 'menu-label' }, it.label), it.hint ? h('code', { class: 'menu-hint', title: it.hint }, it.hint.length > 24 ? '…' + it.hint.slice(-23) : it.hint) : null)))
|
|
565
|
+
el.addEventListener('keydown', menuKeys)
|
|
566
|
+
openFloat(anchor, el, 'menu', 'end')
|
|
549
567
|
const first = el.querySelector('.menu-item:not(:disabled)')
|
|
550
568
|
if (first) first.focus()
|
|
551
569
|
}
|
|
570
|
+
// A popover: a small panel of text and buttons, lighter than a dialog. A second click on its anchor closes it.
|
|
571
|
+
function openPopover(anchor, kind, label, render, align = 'start') {
|
|
572
|
+
if (menu.anchor === anchor) { closeMenu(true); return }
|
|
573
|
+
const el = h('div', { class: 'menu popover', role: 'dialog', 'aria-label': label })
|
|
574
|
+
el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); closeMenu(true) } })
|
|
575
|
+
setKids(el, render())
|
|
576
|
+
openFloat(anchor, el, kind, align)
|
|
577
|
+
menu.render = render
|
|
578
|
+
const first = el.querySelector('button:not(:disabled), a[href]')
|
|
579
|
+
if (first) first.focus()
|
|
580
|
+
}
|
|
581
|
+
function refreshPopover(kind) {
|
|
582
|
+
if (menu.el && menu.kind === kind && menu.render) setKids(menu.el, menu.render())
|
|
583
|
+
}
|
|
552
584
|
function closeMenu(restore) {
|
|
553
585
|
if (!menu.el) return
|
|
554
586
|
menu.el.remove()
|
|
@@ -556,6 +588,8 @@ const MAIN = String.raw `
|
|
|
556
588
|
if (restore && menu.anchor.isConnected) menu.anchor.focus()
|
|
557
589
|
menu.el = null
|
|
558
590
|
menu.anchor = null
|
|
591
|
+
menu.kind = null
|
|
592
|
+
menu.render = null
|
|
559
593
|
}
|
|
560
594
|
function menuKeys(e) {
|
|
561
595
|
const items = Array.from(menu.el.querySelectorAll('.menu-item:not(:disabled)'))
|
|
@@ -579,6 +613,7 @@ const MAIN = String.raw `
|
|
|
579
613
|
{ icon: 'terminal', label: t('menu.copyRun'), hint: run, run: () => copyText(run) },
|
|
580
614
|
...(a.aliases || []).slice(0, 2).map((x) => ({ icon: 'terminal', label: t('menu.copyAlias'), hint: x, run: () => copyText(x) })),
|
|
581
615
|
a.desktopApp && a.desktopApp.installed && a.kind !== 'api' && !BOOT.readOnly ? { icon: 'terminal', label: t(a.desktopApp.running ? 'desk.show' : 'desk.open', { app: a.desktopApp.app }), disabled: Boolean(ui.desktopRef), run: () => openDesktop(a) } : null,
|
|
616
|
+
browserFor(a) ? { icon: 'terminal', label: t('br.open'), disabled: Boolean(ui.browserRef), run: () => openBrowserDialog(a) } : null,
|
|
582
617
|
a.kind !== 'api' ? { icon: 'key', label: t('menu.copyLogin'), hint: 'sideby login ' + a.ref, run: () => copyText('sideby login ' + a.ref) } : null,
|
|
583
618
|
a.dir ? { icon: 'folder', label: t('menu.copyDir'), hint: a.dir, run: () => copyText(a.dir) } : null,
|
|
584
619
|
a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
|
|
@@ -620,20 +655,29 @@ const MAIN = String.raw `
|
|
|
620
655
|
}
|
|
621
656
|
function reasonsOf(a, now) { return attentionReasons(a, healthOf(a.ref), now, WARN) }
|
|
622
657
|
// One line on why an Account needs a look, such as "7d at 62% (warns from 60%)".
|
|
623
|
-
function whyOf(a, now) {
|
|
658
|
+
function whyOf(a, now) {
|
|
659
|
+
const m = attentionWhy(a, healthOf(a.ref), now, WARN)
|
|
660
|
+
if (m && m.key === 'why.not-installed' && family(a.family).brokenBin) return t('why.broken')
|
|
661
|
+
return m ? t(m.key, m.vars) : ''
|
|
662
|
+
}
|
|
624
663
|
function setupFor(fam) { return S.state && S.state.quotaSetups.find((s) => s.family === fam) }
|
|
625
664
|
|
|
626
665
|
// ---------- header and summary ----------
|
|
627
666
|
function renderUpdated() {
|
|
628
667
|
const busy = S.loading && !S.silent
|
|
629
|
-
const
|
|
668
|
+
const every = Math.round((S.fastUntil > Date.now() ? FAST_MS : AUTO_MS) / 1000)
|
|
669
|
+
const text = S.loading && !S.state ? t('top.loading') : busy ? t('top.refreshing') : S.loadedAt ? t('top.updated', { ago: agoShort(Date.now() - S.loadedAt), s: every }) : ''
|
|
630
670
|
const el = $('updated')
|
|
631
671
|
if (el.textContent !== text) el.textContent = text
|
|
632
|
-
|
|
672
|
+
// A dot says whether the numbers are live: green while they refresh on their own, amber when they stopped.
|
|
673
|
+
const stale = conn.lost || Boolean(S.loadError)
|
|
674
|
+
el.classList.toggle('is-live', Boolean(S.loadedAt) && !stale)
|
|
675
|
+
el.classList.toggle('is-stale', Boolean(S.loadedAt) && stale)
|
|
676
|
+
el.title = !S.loadedAt ? '' : (stale ? t('top.stale') + ' ' : '') + t('top.updatedTitle', { at: clock(new Date(S.loadedAt).toISOString()), s: Math.round((S.fastUntil > Date.now() ? FAST_MS : AUTO_MS) / 1000) })
|
|
633
677
|
}
|
|
634
678
|
function renderTop() {
|
|
635
679
|
renderUpdated()
|
|
636
|
-
|
|
680
|
+
renderSettingsButton()
|
|
637
681
|
renderUpdateButton()
|
|
638
682
|
const u = updState()
|
|
639
683
|
$('foot-text').textContent = t(u && !u.checksOff ? 'foot.textChecks' : 'foot.text')
|
|
@@ -648,16 +692,7 @@ const MAIN = String.raw `
|
|
|
648
692
|
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'
|
|
649
693
|
for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.getAttribute('data-i18n'))
|
|
650
694
|
$('refresh').setAttribute('aria-label', t('top.refresh'))
|
|
651
|
-
|
|
652
|
-
lb.hidden = LANG_FIXED
|
|
653
|
-
lb.textContent = lang === 'zh' ? 'EN' : '中文'
|
|
654
|
-
lb.setAttribute('aria-label', t('top.language'))
|
|
655
|
-
lb.title = t('top.language')
|
|
656
|
-
const tb = $('theme')
|
|
657
|
-
tb.hidden = BOOT.colorScheme !== 'auto'
|
|
658
|
-
setKids(tb, icon(prefs.theme === 'light' ? 'sun' : prefs.theme === 'dark' ? 'moon' : 'monitor'))
|
|
659
|
-
tb.title = t('theme.' + prefs.theme)
|
|
660
|
-
tb.setAttribute('aria-label', t('theme.' + prefs.theme))
|
|
695
|
+
renderSettingsButton()
|
|
661
696
|
}
|
|
662
697
|
function applyTheme() {
|
|
663
698
|
if (BOOT.colorScheme !== 'auto') return
|
|
@@ -744,34 +779,30 @@ const MAIN = String.raw `
|
|
|
744
779
|
CHOICES.sort.map((v) => h('option', { value: v }, t('sort.' + v))))
|
|
745
780
|
tb.sort.value = prefs.sort
|
|
746
781
|
tb.sort.addEventListener('change', () => { prefs.sort = tb.sort.value; savePrefs(); renderAccounts() })
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
tb.hideEmail = h('button', { class: 'icon-btn tb-icon', type: 'button', 'aria-pressed': String(prefs.hideEmail), onclick: () => {
|
|
750
|
-
prefs.hideEmail = !prefs.hideEmail
|
|
751
|
-
savePrefs()
|
|
752
|
-
renderEmailToggle()
|
|
753
|
-
renderAccounts()
|
|
754
|
-
} })
|
|
782
|
+
const display = h('button', { class: 'btn tb-display', type: 'button', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', title: t('tb.display') }, icon('sliders'), h('span', null, t('tb.display')))
|
|
783
|
+
display.addEventListener('click', () => openPopover(display, 'display', t('tb.display'), displayPanel, 'end'))
|
|
755
784
|
const create = BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', 'aria-label': t('tb.new'), onclick: () => openCreate(null) }, icon('plus'), h('span', null, t('tb.new')))
|
|
756
785
|
setKids($('toolbar'), [
|
|
757
786
|
h('label', { class: 'search' }, icon('search', 'search-ic'), tb.search, h('kbd', { class: 'kbd', 'aria-hidden': 'true' }, '/')),
|
|
758
|
-
h('div', { class: 'tools' }, tb.family, tb.sort,
|
|
787
|
+
h('div', { class: 'tools' }, tb.family, tb.sort, display),
|
|
759
788
|
create,
|
|
760
789
|
])
|
|
761
790
|
renderFamilyFilter()
|
|
762
|
-
renderEmailToggle()
|
|
763
791
|
if (hadFocus) tb.search.focus()
|
|
764
792
|
}
|
|
765
|
-
// "
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
793
|
+
// "Display": the view settings people change rarely, one popover instead of three buttons in the toolbar.
|
|
794
|
+
// "Hide emails" shows once any account has one.
|
|
795
|
+
function displayPanel() {
|
|
796
|
+
const redo = () => { savePrefs(); renderAccounts(); refreshPopover('display') }
|
|
797
|
+
const emails = S.state && S.state.accounts.some((a) => a.identity && a.identity.email)
|
|
798
|
+
return h('div', { class: 'display-panel' },
|
|
799
|
+
h('div', { class: 'dp-row' }, h('span', { class: 'dp-label' }, t('tb.quota')),
|
|
800
|
+
seg(t('tb.quota'), [['used', t('tb.used')], ['left', t('tb.left')]], prefs.quota, (v) => { prefs.quota = v; redo() })),
|
|
801
|
+
h('div', { class: 'dp-row' }, h('span', { class: 'dp-label' }, t('tb.view')),
|
|
802
|
+
seg(t('tb.view'), [['list', icon('list'), t('tb.list')], ['cards', icon('grid'), t('tb.cards')]], prefs.view, (v) => { prefs.view = v; ui.fade = true; redo() })),
|
|
803
|
+
emails ? h('label', { class: 'dp-row dp-check' },
|
|
804
|
+
h('input', { type: 'checkbox', checked: prefs.hideEmail, onchange: (e) => { prefs.hideEmail = e.target.checked; redo() } }),
|
|
805
|
+
h('span', null, t('tb.hideEmails'))) : null)
|
|
775
806
|
}
|
|
776
807
|
function renderFamilyFilter() {
|
|
777
808
|
if (!tb.family) return
|
|
@@ -839,14 +870,16 @@ const MAIN = String.raw `
|
|
|
839
870
|
const text = { 'no-session': 'quota.noSession', 'no-source': 'quota.noSource', unrecognized: 'quota.unrecognized' }[q.reason]
|
|
840
871
|
return h('div', { class: 'q-note', title: q.reason === 'no-session' ? t('quota.noSessionHint') : q.detail || null }, text ? t(text) : t('quota.unavailable', { reason: q.reason }))
|
|
841
872
|
}
|
|
842
|
-
|
|
873
|
+
// readFrom: the Account a login group's head shows the quota of, named on hover instead of "same login as".
|
|
874
|
+
function quotaBlock(a, now, cols, readFrom) {
|
|
843
875
|
const q = a.quota
|
|
844
876
|
if (!q) return h('div', { class: 'quota' }, h('div', { class: 'q-note' }, '—'))
|
|
845
877
|
if (q.status !== 'ok') return h('div', { class: 'quota' }, quotaNote(a, q))
|
|
846
878
|
const stale = isStale(q.observedAt, now)
|
|
847
879
|
const wins = q.windows || []
|
|
848
880
|
// A reading borrowed from another Account of the same login says where it came from (spec §3.21).
|
|
849
|
-
const from =
|
|
881
|
+
const from = readFrom ? t('quota.readFrom', { name: refName(readFrom), ago: ago(q.observedAt) })
|
|
882
|
+
: q.from ? t('quota.sharedFrom', { name: refName(q.from), ago: ago(q.observedAt) }) : null
|
|
850
883
|
return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title: from || (stale ? null : t('quota.dataFrom', { ago: ago(q.observedAt) })) },
|
|
851
884
|
wins.length ? cols.map((c) => {
|
|
852
885
|
const w = wins.find((x) => x.label === c)
|
|
@@ -886,34 +919,43 @@ const MAIN = String.raw `
|
|
|
886
919
|
return h('button', { class: 'chip chip-btn health' + (lvl ? ' chip-' + lvl : ''), type: 'button', title: t('health.filterHint'), 'aria-label': t('health.chipLabel', { ref: a.ref, status: text }), onclick: () => filterHealth(a.ref) },
|
|
887
920
|
h('span', { class: 'dot' + (lvl ? ' dot-' + lvl : ''), 'aria-hidden': 'true' }), text)
|
|
888
921
|
}
|
|
922
|
+
// A dot after the name when the account needs a look that no tag shows: a fuller quota window or a Health
|
|
923
|
+
// finding. Its reason is on hover; the details say more.
|
|
924
|
+
// Inside a login group the quota warning is the head's, so a row's dot is only about the row itself.
|
|
925
|
+
function attentionDot(a) {
|
|
926
|
+
const subject = inLogin(a) ? Object.assign({}, a, { quota: undefined }) : a
|
|
927
|
+
const r = reasonsOf(subject, Date.now())
|
|
928
|
+
const lvl = r.includes('health-fail') ? 'fail' : r.includes('quota-high') || r.includes('health-warn') ? 'warn' : ''
|
|
929
|
+
if (!lvl) return null
|
|
930
|
+
const why = whyOf(subject, Date.now())
|
|
931
|
+
return h('span', { class: 'dot dot-' + lvl + ' att-dot', title: why, role: 'img', 'aria-label': why })
|
|
932
|
+
}
|
|
889
933
|
function nameLine(a) {
|
|
890
934
|
return h('div', { class: 'ident-name' },
|
|
891
935
|
h('span', { class: 'name', title: a.name }, a.name),
|
|
936
|
+
attentionDot(a),
|
|
892
937
|
(a.aliases || []).slice(0, 3).map((x) => {
|
|
893
938
|
const args = aliasArgsOf(a, x)
|
|
894
939
|
return h('span', { class: 'tag tag-alias', title: args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias') }, x)
|
|
895
940
|
}),
|
|
896
941
|
a.kind === 'api' ? h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api')) : null,
|
|
897
|
-
a.sameLogin && a.sameLogin.length
|
|
898
|
-
? h('span', { class: 'tag', title: t('acct.sameLoginHint', { names: a.sameLogin.map(refName).join(', ') }) }, t('acct.sameLogin', { names: a.sameLogin.map(refName).join(', ') }))
|
|
899
|
-
: null,
|
|
900
942
|
isNext(a)
|
|
901
943
|
? h('button', { class: 'tag tag-ok tag-btn', type: 'button', title: t('acct.nextHint', { family: a.family }), 'aria-label': t('acct.nextLabel', { ref: a.ref, family: a.family }), onclick: () => copyText('sideby next ' + a.family) }, t('acct.next'))
|
|
902
944
|
: null,
|
|
903
945
|
a.login === 'logged-out' && a.kind !== 'api'
|
|
904
946
|
? h('button', { class: 'tag tag-fail tag-btn', type: 'button', title: t('acct.signInHint'), 'aria-label': t('acct.signedOutLabel', { ref: a.ref }), onclick: () => copyText('sideby login ' + a.ref) }, h('span', { class: 'dot dot-fail', 'aria-hidden': 'true' }), t('acct.signedOut'))
|
|
905
947
|
: null,
|
|
906
|
-
a.hostInstalled === false ? h('span', { class: 'tag tag-warn' }, t('acct.notInstalled')) : null)
|
|
948
|
+
a.hostInstalled === false ? h('span', { class: 'tag tag-warn' }, t(family(a.family).brokenBin ? 'acct.broken' : 'acct.notInstalled')) : null)
|
|
907
949
|
}
|
|
908
950
|
// The name part of a ref (claude:001 gives 001), as rows show other Accounts of the same Family.
|
|
909
951
|
function refName(ref) { return String(ref).slice(String(ref).indexOf(':') + 1) }
|
|
910
952
|
// Who the account is signed in as, under its name; masked while "Hide emails" is on.
|
|
911
953
|
function emailLine(a) {
|
|
912
954
|
const id = a.identity
|
|
913
|
-
if (!id || (!id.email && !
|
|
955
|
+
if (!id || (!id.email && !browserButton(a))) return null
|
|
914
956
|
const email = id.email ? emailText(id.email) : ''
|
|
915
957
|
return h('div', { class: 'ident-email', title: [email, id.org].filter(Boolean).join(' · ') },
|
|
916
|
-
dots([email ? h('button', { class: 'email email-btn', type: 'button', title: t('menu.copyEmail'), 'aria-label': t('menu.copyEmail') + ' ' + email, onclick: (e) => { e.stopPropagation(); copyText(id.email) } }, email) : null,
|
|
958
|
+
dots([email ? h('button', { class: 'email email-btn', type: 'button', title: t('menu.copyEmail'), 'aria-label': t('menu.copyEmail') + ' ' + email, onclick: (e) => { e.stopPropagation(); copyText(id.email) } }, email) : null, browserButton(a)]))
|
|
917
959
|
}
|
|
918
960
|
function subLine(a) {
|
|
919
961
|
const plan = a.quota && a.quota.status === 'ok' && a.quota.plan
|
|
@@ -922,19 +964,8 @@ const MAIN = String.raw `
|
|
|
922
964
|
a.kind === 'api' && a.model ? h('span', { class: 'mono', title: t('acct.model') }, a.model) : null,
|
|
923
965
|
a.kind !== 'api' ? modelChip(a) : null,
|
|
924
966
|
a.kind !== 'api' && plan ? h('span', { class: 'plan' }, plan) : null,
|
|
925
|
-
desktopButton(a),
|
|
926
967
|
]))
|
|
927
968
|
}
|
|
928
|
-
// The family's desktop app as this account (spec §3.20): only where it is installed, never for API accounts or a
|
|
929
|
-
// read-only panel. A running window shows as "open"; the click brings it to the front instead of a second one.
|
|
930
|
-
function desktopButton(a) {
|
|
931
|
-
const d = a.desktopApp
|
|
932
|
-
if (!d || !d.installed || a.kind === 'api' || BOOT.readOnly) return null
|
|
933
|
-
const busy = ui.desktopRef === a.ref
|
|
934
|
-
const label = busy ? t('desk.opening', { app: d.app }) : t(d.running ? 'desk.show' : 'desk.open', { app: d.app })
|
|
935
|
-
return h('button', { class: 'tag tag-btn' + (d.running ? ' tag-ok' : ''), type: 'button', disabled: busy, title: t(d.running ? 'desk.showHint' : 'desk.openHint', { app: d.app, ref: a.ref }), 'aria-label': label + ' (' + a.ref + ')', onclick: (e) => { e.stopPropagation(); openDesktop(a) } },
|
|
936
|
-
d.running ? h('span', { class: 'dot dot-ok', 'aria-hidden': 'true' }) : null, label)
|
|
937
|
-
}
|
|
938
969
|
async function openDesktop(a) {
|
|
939
970
|
if (ui.desktopRef) return
|
|
940
971
|
ui.desktopRef = a.ref
|
|
@@ -950,91 +981,326 @@ const MAIN = String.raw `
|
|
|
950
981
|
renderAccounts()
|
|
951
982
|
load({ silent: true })
|
|
952
983
|
}
|
|
984
|
+
// Browser Sign-ins (spec §3.24): which Chrome browser user signed in to the account's website, read on this
|
|
985
|
+
// computer only. Off: the button explains what turning it on reads. On: it names the browser user, and opens it.
|
|
986
|
+
const BR_URL = { claude: 'claude.ai', codex: 'chatgpt.com', grok: 'grok.com' }
|
|
987
|
+
function browserInfo() { return S.state && S.state.browser }
|
|
988
|
+
function browserFor(a) {
|
|
989
|
+
const b = browserInfo()
|
|
990
|
+
if (!b || a.kind === 'api' || BOOT.readOnly) return null
|
|
991
|
+
if (!b.families.matched.includes(a.family) && !b.families.openOnly.includes(a.family)) return null
|
|
992
|
+
return { b, found: b.enabled ? b.profiles.filter((p) => p.signIns.includes(a.ref)) : [] }
|
|
993
|
+
}
|
|
994
|
+
// A browser user's name may be an email; it hides with the others.
|
|
995
|
+
function profileName(name) { return String(name).includes('@') ? emailText(name) : name }
|
|
996
|
+
// A browser user shows as the Google account it uses in Chrome, else its name. Two that would show the same get
|
|
997
|
+
// their folder after it.
|
|
998
|
+
function profileLabel(p) {
|
|
999
|
+
const b = browserInfo()
|
|
1000
|
+
const full = (b && b.profiles.find((o) => o.dir === p.dir)) || p
|
|
1001
|
+
const key = (x) => x.email || x.name
|
|
1002
|
+
const twin = b && b.profiles.some((o) => o.dir !== full.dir && key(o) === key(full))
|
|
1003
|
+
return (full.email ? emailText(full.email) : profileName(full.name)) + (twin ? ' (' + full.dir + ')' : '')
|
|
1004
|
+
}
|
|
1005
|
+
function browserButton(a) {
|
|
1006
|
+
const info = browserFor(a)
|
|
1007
|
+
if (!info) return null
|
|
1008
|
+
const busy = ui.browserRef === a.ref
|
|
1009
|
+
const first = info.found[0]
|
|
1010
|
+
if (!first) return null
|
|
1011
|
+
const label = t(info.found.length > 1 ? 'br.signedInMore' : 'br.signedIn', { name: profileLabel(first), n: info.found.length })
|
|
1012
|
+
return h('button', { class: 'email-btn br-btn', type: 'button', disabled: busy, title: t('br.hint', { url: BR_URL[a.family], ref: a.ref }), 'aria-label': 'Chrome ' + label + ' (' + a.ref + ')', onclick: (e) => { e.stopPropagation(); if (info.found.length === 1) openBrowser(a, first); else openBrowserDialog(a) } },
|
|
1013
|
+
busy ? spinner() : icon('globe', 'br-ico'), label)
|
|
1014
|
+
}
|
|
1015
|
+
async function openBrowser(a, p) {
|
|
1016
|
+
if (ui.browserRef) return
|
|
1017
|
+
ui.browserRef = a.ref
|
|
1018
|
+
renderAccounts()
|
|
1019
|
+
if (dlg.kind === 'browser') renderBrowser()
|
|
1020
|
+
try {
|
|
1021
|
+
const r = await api('/api/browser/open', { account: a.ref, profile: p.dir })
|
|
1022
|
+
toast(t('br.opened', { url: r.url.replace(/^https:\/\//, ''), name: profileLabel(r.profile) }))
|
|
1023
|
+
if (dlg.kind === 'browser') dlg.el.close()
|
|
1024
|
+
} catch (e) { toast(t('br.failed', { ref: a.ref, msg: e.message }), { kind: 'fail' }) }
|
|
1025
|
+
ui.browserRef = null
|
|
1026
|
+
renderAccounts()
|
|
1027
|
+
if (dlg.kind === 'browser') renderBrowser()
|
|
1028
|
+
}
|
|
1029
|
+
let br = null
|
|
1030
|
+
function openBrowserDialog(a) {
|
|
1031
|
+
br = { ref: a.ref, busy: false, error: null }
|
|
1032
|
+
showDialog('browser')
|
|
1033
|
+
// Rows skip background renders while a dialog is open; turning the finding on changes what they show.
|
|
1034
|
+
dlg.onClose = () => renderAccounts()
|
|
1035
|
+
renderBrowser()
|
|
1036
|
+
}
|
|
1037
|
+
async function brSwitch(on) {
|
|
1038
|
+
br.busy = true
|
|
1039
|
+
dlg.busy = true
|
|
1040
|
+
br.error = null
|
|
1041
|
+
renderBrowser()
|
|
1042
|
+
try {
|
|
1043
|
+
await api('/api/browser/settings', { enabled: on, confirm: true })
|
|
1044
|
+
toast(t(on ? 'br.enabled' : 'br.disabled'))
|
|
1045
|
+
await load({ silent: true })
|
|
1046
|
+
} catch (e) { br.error = e }
|
|
1047
|
+
br.busy = false
|
|
1048
|
+
dlg.busy = false
|
|
1049
|
+
if (!on && !br.error) { dlg.el.close(); return }
|
|
1050
|
+
renderBrowser()
|
|
1051
|
+
}
|
|
1052
|
+
function brProfileRow(a, p) {
|
|
1053
|
+
const busy = ui.browserRef === a.ref
|
|
1054
|
+
return h('li', { class: 'br-row' },
|
|
1055
|
+
h('span', null, h('b', null, profileName(p.name)), p.email ? h('span', { class: 'muted small' }, ' · ' + emailText(p.email)) : null, ' ', h('span', { class: 'muted small mono' }, p.dir), p.changedAt ? h('span', { class: 'muted small' }, ' · ' + t('br.changed', { when: ago(p.changedAt) })) : null),
|
|
1056
|
+
h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(ui.browserRef), 'aria-busy': busy ? 'true' : null, onclick: () => openBrowser(a, p) }, t('br.openIt')))
|
|
1057
|
+
}
|
|
1058
|
+
function renderBrowser() {
|
|
1059
|
+
if (!br || dlg.kind !== 'browser') return
|
|
1060
|
+
const a = S.state && S.state.accounts.find((x) => x.ref === br.ref)
|
|
1061
|
+
const info = a && browserFor(a)
|
|
1062
|
+
if (!info) { dlg.el.close(); return }
|
|
1063
|
+
const url = BR_URL[a.family]
|
|
1064
|
+
const body = []
|
|
1065
|
+
const foot = [h('button', { class: 'btn', type: 'button', disabled: br.busy, onclick: () => dlg.el.close() }, t('btn.close'))]
|
|
1066
|
+
if (!info.b.enabled) {
|
|
1067
|
+
body.push(h('p', null, t('br.offIntro', { ref: a.ref, url })))
|
|
1068
|
+
body.push(h('p', { class: 'small' }, t('br.reads')), h('ul', { class: 'small' }, ['br.read1', 'br.read2', 'br.read3'].map((k) => h('li', null, t(k)))))
|
|
1069
|
+
body.push(h('p', { class: 'muted small' }, t('br.never')))
|
|
1070
|
+
foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: br.busy, 'aria-busy': br.busy ? 'true' : null, onclick: () => brSwitch(true) }, br.busy ? spinner() : null, t('br.enable')))
|
|
1071
|
+
} else if (!info.b.found) {
|
|
1072
|
+
body.push(h('p', { class: 'note' }, t('br.noChrome')))
|
|
1073
|
+
} else {
|
|
1074
|
+
const others = info.b.profiles.filter((p) => !info.found.includes(p))
|
|
1075
|
+
if (info.found.length) body.push(h('p', { class: 'small' }, t('br.found', { ref: a.ref })), h('ul', { class: 'br-list' }, info.found.map((p) => brProfileRow(a, p))))
|
|
1076
|
+
else body.push(h('p', { class: 'muted small' }, t(info.b.families.matched.includes(a.family) ? 'br.none' : 'br.noMatch', { ref: a.ref, url })))
|
|
1077
|
+
if (others.length) body.push(h('p', { class: 'small' }, t(info.found.length ? 'br.others' : 'br.all')), h('ul', { class: 'br-list' }, others.map((p) => brProfileRow(a, p))))
|
|
1078
|
+
body.push(h('p', { class: 'muted small' }, t('br.local')))
|
|
1079
|
+
foot.unshift(h('button', { class: 'btn', type: 'button', disabled: br.busy, onclick: () => brSwitch(false) }, t('br.disable')))
|
|
1080
|
+
}
|
|
1081
|
+
if (br.error) body.push(errorNote(br.error, null))
|
|
1082
|
+
setKids(dlg.el, dialogFrame(t('br.title') + ' · ' + a.ref, a.family, body, foot))
|
|
1083
|
+
}
|
|
1084
|
+
// How to get a Host that cannot run: the sentence, then on its own line the install command to copy (with the
|
|
1085
|
+
// install page next to it) when the Family has one; without a command the sentence ends with the page's link.
|
|
1086
|
+
function hostNote(fam) {
|
|
1087
|
+
const cmdLine = () => h('div', { class: 'note-line note-cmd' }, codeCopy(fam.installCommand), link(t('acct.installDocs'), fam.installUrl))
|
|
1088
|
+
if (fam.brokenBin) {
|
|
1089
|
+
const text = tx('acct.brokenHost', { host: fam.title, path: h('code', null, fam.brokenBin) })
|
|
1090
|
+
return fam.installCommand ? [h('div', { class: 'note-line' }, text), cmdLine()] : [h('div', { class: 'note-line' }, text, ' ', link(t('acct.installIt'), fam.installUrl) || '')]
|
|
1091
|
+
}
|
|
1092
|
+
if (fam.installCommand) return [h('div', { class: 'note-line' }, tx('acct.installHostCmd', { host: fam.title })), cmdLine()]
|
|
1093
|
+
return tx('acct.installHost', { host: fam.title, link: link(t('acct.installIt'), fam.installUrl) || '' })
|
|
1094
|
+
}
|
|
953
1095
|
function notes(a) {
|
|
954
1096
|
const fam = family(a.family)
|
|
955
1097
|
const out = []
|
|
956
1098
|
if (a.error) out.push(h('div', { class: 'note note-fail' }, h('b', null, t('acct.readError')), ' ', h('span', null, a.error)))
|
|
957
|
-
if (a.hostInstalled === false) out.push(h('div', { class: 'note note-warn' },
|
|
958
|
-
// The other reasons already show in the row (read error, missing tool, sign-in, Health); a full window does not.
|
|
959
|
-
const why = attentionWhy(a, healthOf(a.ref), Date.now(), WARN)
|
|
960
|
-
if (why && why.key === 'why.quota-high') out.push(h('div', { class: 'note note-warn' }, t('acct.nearLimit', why.vars)))
|
|
1099
|
+
if (a.hostInstalled === false) out.push(h('div', { class: 'note note-warn' }, hostNote(fam)))
|
|
961
1100
|
return out.length ? h('div', { class: 'row-notes' }, out) : null
|
|
962
1101
|
}
|
|
963
1102
|
// What a row or card shows depends on more than the account (health, view preferences, language); the
|
|
964
1103
|
// fingerprint covers what its click handlers use.
|
|
965
|
-
function rowSig(a) { return sig([a, healthOf(a.ref), isNext(a), ui.desktopRef === a.ref,
|
|
966
|
-
//
|
|
967
|
-
|
|
968
|
-
function
|
|
1104
|
+
function rowSig(a) { return sig([a, healthOf(a.ref), isNext(a), ui.desktopRef === a.ref, ui.browserRef === a.ref, browserFor(a), prefs.hideEmail, isOpen(a), inLogin(a)]) }
|
|
1105
|
+
// Whether the row sits in a login group (spec §3.21): the group's head then shows the email and the quota.
|
|
1106
|
+
function inLogin(a) { return ui.loginRefs.has(a.ref) }
|
|
1107
|
+
function rowEmail(a) { return inLogin(a) ? null : emailLine(a) }
|
|
1108
|
+
// A row shows who the account is, its quota and its last use; a click opens the details under it (model, tokens,
|
|
1109
|
+
// Health) and closes them again. The choice is kept in this browser's preferences.
|
|
1110
|
+
function isOpen(a) { return prefs.open[a.ref] === true }
|
|
969
1111
|
function rowToggle(a) {
|
|
970
|
-
const open =
|
|
1112
|
+
const open = isOpen(a)
|
|
971
1113
|
const label = t(open ? 'row.collapse' : 'row.expand', { name: a.name })
|
|
972
1114
|
return h('button', { class: 'icon-btn row-toggle', type: 'button', 'aria-expanded': String(open), 'aria-label': label, title: label, onclick: (e) => { e.stopPropagation(); toggleRow(a) } }, icon('chevron', 'chev'))
|
|
973
1115
|
}
|
|
974
1116
|
// The whole account cell (and a folded row anywhere) folds and opens it, not only the arrow; the buttons and links
|
|
975
1117
|
// inside keep their own action, and a click that ends a text selection does nothing.
|
|
976
|
-
|
|
1118
|
+
// A click folds the row unless it ended a drag that selects text; a selection left from before does not block it.
|
|
1119
|
+
let downAt = null
|
|
1120
|
+
document.addEventListener('pointerdown', (e) => { downAt = { x: e.clientX, y: e.clientY } }, true)
|
|
1121
|
+
// A double click would select a word under the pointer as well as fold the row twice; keep the word unselected.
|
|
1122
|
+
document.addEventListener('mousedown', (e) => {
|
|
1123
|
+
if (e.detail > 1 && e.target.closest && e.target.closest('.fold-hit') && !e.target.closest('input, select, textarea')) e.preventDefault()
|
|
1124
|
+
})
|
|
1125
|
+
function foldClick(a) { return foldHit(() => toggleRow(a)) }
|
|
1126
|
+
function foldHit(run) {
|
|
977
1127
|
return (e) => {
|
|
978
1128
|
if (e.target.closest('button, a, input, select, textarea, .tip')) return
|
|
979
|
-
|
|
980
|
-
|
|
1129
|
+
const dragged = downAt && Math.hypot(e.clientX - downAt.x, e.clientY - downAt.y) > 4
|
|
1130
|
+
if (dragged && window.getSelection && String(window.getSelection())) return
|
|
1131
|
+
run()
|
|
981
1132
|
}
|
|
982
1133
|
}
|
|
983
|
-
// Folding animates: the
|
|
984
|
-
//
|
|
1134
|
+
// Folding animates: the height eases while the details fade and the arrow turns. A click during the motion turns
|
|
1135
|
+
// it around from where it is. Reduced motion just switches.
|
|
985
1136
|
const EASE = 'cubic-bezier(.2, 0, 0, 1)'
|
|
986
|
-
|
|
1137
|
+
const FOLD_MS = 220
|
|
1138
|
+
const folding = new Set()
|
|
1139
|
+
function rowDetails(el) { return Array.from(el.querySelectorAll(':scope > .row-detail')) }
|
|
1140
|
+
function moving() { return !(REDUCED || document.hidden || !Element.prototype.animate) }
|
|
1141
|
+
// Eases el's height from 'from' to its current height (measured with the 'hide' parts out of the layout),
|
|
1142
|
+
// fading 'parts' toward 'opacity'; 'done' runs at the end unless a newer fold took over.
|
|
1143
|
+
function ease(el, from, hide, parts, opacity, done) {
|
|
1144
|
+
const run = el._fold
|
|
1145
|
+
const fades = parts.map((p) => +getComputedStyle(p).opacity)
|
|
1146
|
+
if (run) run.cancel()
|
|
1147
|
+
for (const p of hide) p.style.display = 'none'
|
|
1148
|
+
const to = el.getBoundingClientRect().height
|
|
1149
|
+
for (const p of hide) p.style.display = ''
|
|
1150
|
+
el.style.overflow = 'hidden'
|
|
1151
|
+
const anims = [el.animate([{ height: from + 'px' }, { height: to + 'px' }], { duration: FOLD_MS, easing: EASE })]
|
|
1152
|
+
parts.forEach((p, i) => anims.push(p.animate([{ opacity: fades[i] }, { opacity }], { duration: FOLD_MS, easing: 'ease-out', fill: 'forwards' })))
|
|
1153
|
+
const me = { cancel: () => { for (const x of anims) x.cancel() } }
|
|
1154
|
+
el._fold = me
|
|
1155
|
+
folding.add(el)
|
|
1156
|
+
anims[0].finished.then(() => {
|
|
1157
|
+
if (el._fold !== me) return
|
|
1158
|
+
el._fold = null
|
|
1159
|
+
folding.delete(el)
|
|
1160
|
+
el.style.overflow = ''
|
|
1161
|
+
me.cancel()
|
|
1162
|
+
done()
|
|
1163
|
+
}, () => {})
|
|
1164
|
+
}
|
|
1165
|
+
// A row folds in place: only its arrow, class and details change, so the rest of the list is left alone.
|
|
987
1166
|
function toggleRow(a) {
|
|
988
|
-
const
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1167
|
+
const open = !isOpen(a)
|
|
1168
|
+
if (open) prefs.open[a.ref] = true; else delete prefs.open[a.ref]
|
|
1169
|
+
savePrefs()
|
|
1170
|
+
const el = document.querySelector('#accounts [data-ref="' + CSS.escape(a.ref) + '"]')
|
|
1171
|
+
if (!el) { renderAccounts(); return }
|
|
1172
|
+
const from = el.getBoundingClientRect().height
|
|
1173
|
+
const btn = el.querySelector('.row-toggle')
|
|
1174
|
+
if (btn) syncAttrs(btn, rowToggle(a))
|
|
1175
|
+
el.classList.toggle('row-open', open)
|
|
1176
|
+
el.dataset.sig = rowSig(a)
|
|
1177
|
+
let detail = rowDetails(el)[0]
|
|
1178
|
+
if (open && !detail) {
|
|
1179
|
+
detail = rowDetail(a, Boolean(rowEmail(a)))
|
|
1180
|
+
const more = el.querySelector(':scope > .more')
|
|
1181
|
+
el.insertBefore(detail, more ? more.nextSibling : null)
|
|
1182
|
+
detail.style.opacity = '0'
|
|
994
1183
|
}
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1184
|
+
if (!detail) return
|
|
1185
|
+
if (!moving()) { if (!open) detail.remove(); return }
|
|
1186
|
+
ease(el, from, open ? [] : [detail], [detail], open ? 1 : 0, () => { if (!open) detail.remove() })
|
|
1187
|
+
detail.style.opacity = ''
|
|
1188
|
+
}
|
|
1189
|
+
function groupParts(el) { return Array.from(el.querySelectorAll(':scope > :not(.group-head)')) }
|
|
1190
|
+
// A group closes on the page as it is and renders when the motion ends; it opens by rendering its body first.
|
|
1191
|
+
function toggleGroup(key, domKey) {
|
|
1192
|
+
const find = () => document.querySelector('#accounts > [data-key="' + CSS.escape(domKey) + '"]')
|
|
1193
|
+
const el = find()
|
|
1194
|
+
const btn = el && el.querySelector('.group-head .group-toggle')
|
|
1195
|
+
const open = btn ? btn.getAttribute('aria-expanded') !== 'true' : prefs.collapsed[key] === true
|
|
1196
|
+
if (open) delete prefs.collapsed[key]; else prefs.collapsed[key] = true
|
|
1197
|
+
savePrefs()
|
|
1198
|
+
if (!el || !moving()) { renderAccounts(); return }
|
|
1199
|
+
const from = el.getBoundingClientRect().height
|
|
1200
|
+
if (!open) {
|
|
1201
|
+
btn.setAttribute('aria-expanded', 'false')
|
|
1202
|
+
const parts = groupParts(el)
|
|
1203
|
+
ease(el, from, parts, parts, 0, renderAccounts)
|
|
1204
|
+
return
|
|
1205
|
+
}
|
|
1206
|
+
// Turned around while closing: the body is still there.
|
|
1207
|
+
if (el._fold) { btn.setAttribute('aria-expanded', 'true'); ease(el, from, [], groupParts(el), 1, renderAccounts); return }
|
|
1208
|
+
renderAccounts()
|
|
1209
|
+
const now = find()
|
|
1210
|
+
if (!now) return
|
|
1211
|
+
const parts = groupParts(now)
|
|
1212
|
+
for (const p of parts) p.style.opacity = '0'
|
|
1213
|
+
const chev = now.querySelector('.group-head .chev')
|
|
1214
|
+
if (chev) chev.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(90deg)' }], { duration: FOLD_MS, easing: EASE })
|
|
1215
|
+
ease(now, from, [], parts, 1, () => {})
|
|
1216
|
+
for (const p of parts) p.style.opacity = ''
|
|
1217
|
+
}
|
|
1218
|
+
// A Same Login group folds its directory rows like a family group, and remembers it the same way.
|
|
1219
|
+
function toggleLogin(key) {
|
|
1220
|
+
const find = () => document.querySelector('#accounts .login[data-key="' + CSS.escape(key) + '"]')
|
|
1221
|
+
const el = find()
|
|
1222
|
+
const open = prefs.collapsed[key] === true
|
|
1223
|
+
if (open) delete prefs.collapsed[key]; else prefs.collapsed[key] = true
|
|
1224
|
+
savePrefs()
|
|
1225
|
+
if (!el || !moving()) { renderAccounts(); return }
|
|
1226
|
+
const from = el.getBoundingClientRect().height
|
|
1227
|
+
const rowsOf = (x) => Array.from(x.querySelectorAll(':scope > .login-rows'))
|
|
1228
|
+
if (!open) {
|
|
1229
|
+
const btn = el.querySelector('.login-head .row-toggle')
|
|
1230
|
+
if (btn) btn.setAttribute('aria-expanded', 'false')
|
|
1231
|
+
const parts = rowsOf(el)
|
|
1232
|
+
ease(el, from, parts, parts, 0, renderAccounts)
|
|
1233
|
+
return
|
|
1234
|
+
}
|
|
1235
|
+
renderAccounts()
|
|
1236
|
+
const now = find()
|
|
1237
|
+
if (!now) return
|
|
1238
|
+
const parts = rowsOf(now)
|
|
1239
|
+
for (const p of parts) p.style.opacity = '0'
|
|
1240
|
+
ease(now, from, [], parts, 1, () => {})
|
|
1241
|
+
for (const p of parts) p.style.opacity = ''
|
|
1012
1242
|
}
|
|
1013
1243
|
// The Account 'sideby next <family>' would pick; the server ranks it with the CLI's rule (spec §3.13).
|
|
1014
1244
|
function isNext(a) { return Boolean(S.state && S.state.handoff && S.state.handoff[a.family] === a.ref) }
|
|
1015
1245
|
function row(a, now, cols) {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
quotaBlock(a, now, cols),
|
|
1023
|
-
usageBlock(a),
|
|
1246
|
+
const open = isOpen(a)
|
|
1247
|
+
const email = rowEmail(a)
|
|
1248
|
+
return h('div', { class: 'row fold-hit' + (open ? ' row-open' : '') + (a.error ? ' row-error' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), onclick: foldClick(a) },
|
|
1249
|
+
h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), email || subLine(a),
|
|
1250
|
+
inLogin(a) ? h('span', { class: 'sr-only' }, t('login.sharedAbove')) : null)),
|
|
1251
|
+
inLogin(a) ? null : quotaBlock(a, now, cols),
|
|
1024
1252
|
lastUsed(a),
|
|
1025
|
-
h('div', { class: 'cell-health' }, healthChip(a)),
|
|
1026
1253
|
moreButton(a),
|
|
1254
|
+
open ? rowDetail(a, Boolean(email)) : null,
|
|
1027
1255
|
notes(a))
|
|
1028
1256
|
}
|
|
1257
|
+
// Directories signed in as one login (spec §3.21): the vendor counts one quota, so the head shows the email and
|
|
1258
|
+
// that quota once, and each directory keeps a row of its own for what is its own (name, model, tokens, Health).
|
|
1259
|
+
function loginGroup(members, now, cols) {
|
|
1260
|
+
const first = members[0]
|
|
1261
|
+
const source = members.find((a) => a.quota && a.quota.status === 'ok' && !a.quota.from) || first
|
|
1262
|
+
const id = first.identity || {}
|
|
1263
|
+
const names = members.map((a) => a.name).join(', ')
|
|
1264
|
+
const hint = t('login.hint', { names })
|
|
1265
|
+
const m = source.quota && source.quota.status === 'ok' ? attentionWhy({ family: source.family, name: source.name, ref: source.ref, quota: source.quota }, null, now, WARN) : null
|
|
1266
|
+
const why = m ? t(m.key, m.vars) : ''
|
|
1267
|
+
const email = id.email ? emailText(id.email) : ''
|
|
1268
|
+
const key = loginKey(members.map((a) => a.ref))
|
|
1269
|
+
const collapsed = prefs.collapsed[key] === true
|
|
1270
|
+
const label = t(collapsed ? 'login.expand' : 'login.collapse', { names })
|
|
1271
|
+
const toggle = h('button', { class: 'icon-btn row-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-label': label, title: label, onclick: (e) => { e.stopPropagation(); toggleLogin(key) } }, icon('chevron', 'chev'))
|
|
1272
|
+
return h('div', { class: 'login' + (collapsed ? ' is-collapsed' : ''), role: 'listitem', 'aria-label': t('login.label', { names }), 'data-key': key },
|
|
1273
|
+
h('div', { class: 'row login-head fold-hit', onclick: foldHit(() => toggleLogin(key)) },
|
|
1274
|
+
h('div', { class: 'ident' }, toggle, badge(first.family, 'sm'),
|
|
1275
|
+
h('div', { class: 'ident-text' },
|
|
1276
|
+
h('div', { class: 'ident-name' },
|
|
1277
|
+
email ? h('button', { class: 'name email-btn', type: 'button', title: t('menu.copyEmail'), 'aria-label': t('menu.copyEmail') + ' ' + email, onclick: () => copyText(id.email) }, email) : h('span', { class: 'name' }, names),
|
|
1278
|
+
why ? h('span', { class: 'dot dot-warn att-dot', title: why, role: 'img', 'aria-label': why }) : null),
|
|
1279
|
+
h('div', { class: 'ident-sub', title: hint }, dots([id.org ? h('span', null, id.org) : null, h('span', null, t('login.count', { n: members.length })), collapsed ? h('span', null, names) : null])))),
|
|
1280
|
+
quotaBlock(source, now, cols, source.quota && source.quota.status === 'ok' ? source.ref : null),
|
|
1281
|
+
h('div', { class: 'last' }),
|
|
1282
|
+
h('div', { class: 'more' })),
|
|
1283
|
+
collapsed ? null : h('div', { class: 'login-rows', role: 'list', 'data-key': 'rows', 'data-patch': '' }, members.map((a) => row(a, now, cols))))
|
|
1284
|
+
}
|
|
1285
|
+
// What a click on a row opens: how it runs (ref, model and effort, a shared login), its tokens and its Health.
|
|
1286
|
+
function sameLoginNote(a) {
|
|
1287
|
+
if (!a.sameLogin || !a.sameLogin.length || inLogin(a)) return null
|
|
1288
|
+
const names = a.sameLogin.map(refName).join(', ')
|
|
1289
|
+
return h('div', { class: 'rd-note', title: t('acct.sameLoginHint', { names }) }, t('acct.sameLogin', { names }))
|
|
1290
|
+
}
|
|
1291
|
+
function rowDetail(a, withSub) {
|
|
1292
|
+
const same = sameLoginNote(a)
|
|
1293
|
+
return h('div', { class: 'row-detail' },
|
|
1294
|
+
withSub || same ? h('div', { class: 'rd-item rd-run' }, h('span', { class: 'rd-label' }, t('row.runs')), withSub ? subLine(a) : null, same) : null,
|
|
1295
|
+
h('div', { class: 'rd-item rd-usage' }, h('span', { class: 'rd-label' }, t('col.tokens', { n: BOOT.usageDays })), usageBlock(a)),
|
|
1296
|
+
h('div', { class: 'rd-item rd-health' }, h('span', { class: 'rd-label' }, t('col.health')), healthChip(a)))
|
|
1297
|
+
}
|
|
1298
|
+
// A card shows everything at once; it has room for it.
|
|
1029
1299
|
function card(a, now, cols) {
|
|
1030
|
-
if (folded(a))
|
|
1031
|
-
return h('article', { class: 'card card-folded fold-hit' + (a.error ? ' card-error' : ''), 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), 'aria-label': a.ref, onclick: foldClick(a) },
|
|
1032
|
-
h('header', { class: 'card-head' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a)), moreButton(a)))
|
|
1033
1300
|
return h('article', { class: 'card' + (a.error ? ' card-error' : ''), 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), 'aria-label': a.ref },
|
|
1034
|
-
h('header', { class: 'card-head
|
|
1035
|
-
rowToggle(a),
|
|
1301
|
+
h('header', { class: 'card-head' },
|
|
1036
1302
|
badge(a.family, 'sm'),
|
|
1037
|
-
h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a)),
|
|
1303
|
+
h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a), sameLoginNote(a)),
|
|
1038
1304
|
moreButton(a)),
|
|
1039
1305
|
notes(a),
|
|
1040
1306
|
quotaBlock(a, now, cols),
|
|
@@ -1048,9 +1314,7 @@ const MAIN = String.raw `
|
|
|
1048
1314
|
return h('div', { class: 'col-head', 'aria-hidden': 'true', 'data-key': 'cols' },
|
|
1049
1315
|
h('span', { class: 'ch-id' }, t('col.account')),
|
|
1050
1316
|
h('span', { class: 'quota' }, titled ? cols.map((c) => h('span', null, t(left ? 'col.left' : 'col.used', { label: c }))) : null),
|
|
1051
|
-
h('span', { class: 'ch-usage' }, t('col.tokens', { n: BOOT.usageDays })),
|
|
1052
1317
|
h('span', { class: 'ch-last' }, t('col.last')),
|
|
1053
|
-
h('span', { class: 'ch-health' }, t('col.health')),
|
|
1054
1318
|
h('span', { class: 'ch-more' }))
|
|
1055
1319
|
}
|
|
1056
1320
|
// Quota is on but no account of the Family has numbers yet: say what brings the first ones.
|
|
@@ -1153,11 +1417,7 @@ const MAIN = String.raw `
|
|
|
1153
1417
|
const collapsed = prefs.collapsed['app:' + app] === true
|
|
1154
1418
|
const bodyId = 'grp-app-' + app.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
1155
1419
|
const head = h('div', { class: 'group-head', 'data-key': 'head' },
|
|
1156
|
-
h('button', { class: 'group-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-controls': bodyId, title: t(collapsed ? 'group.expand' : 'group.collapse'), 'aria-label': t('group.toggle', { title: app, count: String(list.length), action: t(collapsed ? 'group.expand' : 'group.collapse') }), onclick: () =>
|
|
1157
|
-
if (collapsed) delete prefs.collapsed['app:' + app]; else prefs.collapsed['app:' + app] = true
|
|
1158
|
-
savePrefs()
|
|
1159
|
-
renderAccounts()
|
|
1160
|
-
} },
|
|
1420
|
+
h('button', { class: 'group-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-controls': bodyId, title: t(collapsed ? 'group.expand' : 'group.collapse'), 'aria-label': t('group.toggle', { title: app, count: String(list.length), action: t(collapsed ? 'group.expand' : 'group.collapse') }), onclick: () => toggleGroup('app:' + app, 'app:' + app) },
|
|
1161
1421
|
icon('chevron', 'chev'), appBadge(), h('span', { class: 'group-title' }, app),
|
|
1162
1422
|
h('span', { class: 'group-count' }, String(list.length))),
|
|
1163
1423
|
h('div', { class: 'group-meta' },
|
|
@@ -1225,11 +1485,7 @@ const MAIN = String.raw `
|
|
|
1225
1485
|
const bodyId = 'grp-' + fam.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
1226
1486
|
const cards = prefs.view === 'cards'
|
|
1227
1487
|
const head = h('div', { class: 'group-head', 'data-key': 'head' },
|
|
1228
|
-
h('button', { class: 'group-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-controls': bodyId, title: t(collapsed ? 'group.expand' : 'group.collapse'), 'aria-label': t('group.toggle', { title: info.title, count: list.length === total ? String(total) : list.length + ' / ' + total, action: t(collapsed ? 'group.expand' : 'group.collapse') }), onclick: () =>
|
|
1229
|
-
if (collapsed) delete prefs.collapsed[fam]; else prefs.collapsed[fam] = true
|
|
1230
|
-
savePrefs()
|
|
1231
|
-
renderAccounts()
|
|
1232
|
-
} },
|
|
1488
|
+
h('button', { class: 'group-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-controls': bodyId, title: t(collapsed ? 'group.expand' : 'group.collapse'), 'aria-label': t('group.toggle', { title: info.title, count: list.length === total ? String(total) : list.length + ' / ' + total, action: t(collapsed ? 'group.expand' : 'group.collapse') }), onclick: () => toggleGroup(fam, 'g:' + fam) },
|
|
1233
1489
|
icon('chevron', 'chev'), badge(fam, 'sm'), h('span', { class: 'group-title' }, info.title),
|
|
1234
1490
|
h('span', { class: 'group-count' }, list.length === total ? String(total) : list.length + ' / ' + total)),
|
|
1235
1491
|
h('div', { class: 'group-meta' },
|
|
@@ -1243,7 +1499,7 @@ const MAIN = String.raw `
|
|
|
1243
1499
|
off && setup && setup.plan.status === 'blocked' ? h('span', { class: 'muted small label-tip' }, t('quota.blocked'), tipRaw(t('quota.blocked'), setup.plan.message)) : null))
|
|
1244
1500
|
const body = collapsed ? null : cards
|
|
1245
1501
|
? h('div', { class: 'grid', id: bodyId, 'data-key': 'cards', 'data-patch': '' }, list.map((a) => card(a, now, cols)))
|
|
1246
|
-
: h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, list.map((
|
|
1502
|
+
: h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, loginClusters(list).map((x) => Array.isArray(x) ? loginGroup(x, now, cols) : row(x, now, cols)))
|
|
1247
1503
|
return h('section', { class: 'group' + (cards ? ' group-cards' : ' group-list') + (collapsed ? ' is-collapsed' : ''), 'aria-label': info.title, 'data-key': 'g:' + fam, 'data-patch': '' },
|
|
1248
1504
|
head, collapsed || cards ? null : colHead(cols, list), body)
|
|
1249
1505
|
}
|
|
@@ -1255,7 +1511,7 @@ const MAIN = String.raw `
|
|
|
1255
1511
|
h('h3', null, t('empty.title')),
|
|
1256
1512
|
h('p', { class: 'muted' }, t('empty.body')),
|
|
1257
1513
|
fams.length ? h('ul', { class: 'host-list' }, fams.map((f) => h('li', null, badge(f.id, 'sm'), h('span', null, f.title),
|
|
1258
|
-
f.installed ? h('span', { class: 'muted small' }, tx('empty.runOnce', { bin: h('code', null, f.bin) })) : link(t('empty.install'), f.installUrl, 'btn btn-sm')))) : h('p', { class: 'muted small' }, t('empty.noFamilies')),
|
|
1514
|
+
f.installed ? h('span', { class: 'muted small' }, tx('empty.runOnce', { bin: h('code', null, f.bin) })) : f.installCommand ? codeCopy(f.installCommand) : link(t('empty.install'), f.installUrl, 'btn btn-sm')))) : h('p', { class: 'muted small' }, t('empty.noFamilies')),
|
|
1259
1515
|
BOOT.readOnly ? null : h('button', { class: 'btn', type: 'button', onclick: () => openCreate(null) }, icon('plus'), t('empty.create')))
|
|
1260
1516
|
}
|
|
1261
1517
|
function clearFilters() {
|
|
@@ -1302,6 +1558,9 @@ const MAIN = String.raw `
|
|
|
1302
1558
|
if (!shown.length && !apps.length) { patch(box, noMatch()); return }
|
|
1303
1559
|
const cols = quotaColumns(shown)
|
|
1304
1560
|
box.style.setProperty('--qn', String(cols.length))
|
|
1561
|
+
ui.loginRefs = new Set()
|
|
1562
|
+
if (prefs.view !== 'cards')
|
|
1563
|
+
for (const x of loginClusters(shown)) if (Array.isArray(x)) for (const a of x) ui.loginRefs.add(a.ref)
|
|
1305
1564
|
const ids = S.state.families.map((f) => f.id)
|
|
1306
1565
|
for (const a of all) if (!ids.includes(a.family)) ids.push(a.family)
|
|
1307
1566
|
const groups = []
|
|
@@ -1840,7 +2099,7 @@ const MAIN = String.raw `
|
|
|
1840
2099
|
renderPreview()
|
|
1841
2100
|
})
|
|
1842
2101
|
const n = S.state.accounts.filter((a) => a.family === f.id).length
|
|
1843
|
-
const sub = st === 'ready' ? tn('nf.accounts', n) : st === 'no-main' ? tx('nf.runOnce', { bin: h('code', null, f.bin) }) : t('nf.notInstalled')
|
|
2102
|
+
const sub = st === 'ready' ? tn('nf.accounts', n) : st === 'no-main' ? tx('nf.runOnce', { bin: h('code', null, f.bin) }) : t(f.brokenBin ? 'nf.broken' : 'nf.notInstalled')
|
|
1844
2103
|
return h('label', { class: 'fam-card' + (st === 'ready' ? '' : ' is-off') }, input, badge(f.id, 'sm'),
|
|
1845
2104
|
h('span', { class: 'fam-text' }, h('span', { class: 'fam-title' }, f.title), h('span', { class: 'fam-sub' }, sub)))
|
|
1846
2105
|
})
|
|
@@ -2048,15 +2307,6 @@ const MAIN = String.raw `
|
|
|
2048
2307
|
let ah = null
|
|
2049
2308
|
const HINT_QUIET_MS = 24 * 3600000
|
|
2050
2309
|
function ahState() { return S.state && S.state.autoHandoff }
|
|
2051
|
-
function renderHandoffButton() {
|
|
2052
|
-
const b = $('handoff')
|
|
2053
|
-
const st = ahState()
|
|
2054
|
-
b.hidden = !st
|
|
2055
|
-
if (!st) return
|
|
2056
|
-
const on = st.settings.auto
|
|
2057
|
-
setKids(b, [h('span', { class: 'hide-sm' }, t('ah.button')), h('span', { class: 'chip chip-sm' + (on ? ' chip-ok' : '') }, on ? t('ah.on') : t('ah.off'))])
|
|
2058
|
-
b.setAttribute('aria-label', t('ah.button') + ': ' + (on ? t('ah.on') : t('ah.off')))
|
|
2059
|
-
}
|
|
2060
2310
|
function handoffHint() {
|
|
2061
2311
|
const st = ahState()
|
|
2062
2312
|
if (!st || BOOT.readOnly) return null
|
|
@@ -2532,7 +2782,8 @@ const MAIN = String.raw `
|
|
|
2532
2782
|
// ---------- Remote Control (spec §3.22) ----------
|
|
2533
2783
|
// Shown while some Claude settings file turns Remote Control on. "Turn all off" first lists each file, the change
|
|
2534
2784
|
// and the accounts it reaches, in the banner itself; the toast after it carries the undo.
|
|
2535
|
-
|
|
2785
|
+
// target: the side the dialog's switch was moved to, waiting for its confirmation; null while nothing is chosen.
|
|
2786
|
+
const rc = { confirm: false, busy: false, target: null }
|
|
2536
2787
|
const shortRef = (ref) => ref.replace(/^claude:/, '')
|
|
2537
2788
|
function remoteControlBanner() {
|
|
2538
2789
|
const groups = S.state.remoteControl || []
|
|
@@ -2549,29 +2800,34 @@ const MAIN = String.raw `
|
|
|
2549
2800
|
h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: () => { rc.confirm = true; renderBanners() } }, t('rc.turnOff')),
|
|
2550
2801
|
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { prefs.rcHide = true; savePrefs(); renderBanners(); toast(t('rc.hidden')) } }, t('rc.keep'))))
|
|
2551
2802
|
const lines = todo.map((g) => h('li', null, h('code', null, g.file), ' ',
|
|
2552
|
-
t('rc.change', { from: g.state === 'on' ? 'true' : t('rc.notSet') }), ' ',
|
|
2803
|
+
t('rc.change', { from: g.state === 'on' ? 'true' : t('rc.notSet'), to: 'false' }), ' ',
|
|
2553
2804
|
h('span', { class: 'muted' }, t('rc.usedBy', { refs: g.accounts.map(shortRef).join(', ') }))))
|
|
2554
2805
|
return h('div', { class: 'banner banner-warn', role: 'status' },
|
|
2555
2806
|
h('div', null, h('b', null, t('rc.confirmTitle')), h('ul', null, lines), h('div', { class: 'small' }, t('rc.confirmNote'))),
|
|
2556
2807
|
h('div', { class: 'banner-actions' },
|
|
2557
|
-
h('button', { class: 'btn btn-sm btn-primary', type: 'button', disabled: rc.busy, onclick:
|
|
2808
|
+
h('button', { class: 'btn btn-sm btn-primary', type: 'button', disabled: rc.busy, onclick: () => setRemoteControl('off') }, rc.busy ? spinner() : null, t('rc.confirm')),
|
|
2558
2809
|
h('button', { class: 'btn btn-sm', type: 'button', disabled: rc.busy, onclick: () => { rc.confirm = false; renderBanners() } }, t('btn.cancel'))))
|
|
2559
2810
|
}
|
|
2560
|
-
async function
|
|
2811
|
+
async function setRemoteControl(to) {
|
|
2561
2812
|
rc.busy = true
|
|
2562
2813
|
renderBanners()
|
|
2814
|
+
if (dlg.kind === 'rc') renderRc()
|
|
2815
|
+
const fail = to === 'on' ? 'rc.failedOn' : 'rc.failed'
|
|
2563
2816
|
try {
|
|
2564
|
-
const r = await api('/api/remote-control/
|
|
2817
|
+
const r = await api('/api/remote-control/' + to, {})
|
|
2565
2818
|
if (r.ok) {
|
|
2566
|
-
//
|
|
2567
|
-
|
|
2568
|
-
|
|
2819
|
+
// Show the new state now; the reload that follows confirms it.
|
|
2820
|
+
const changed = new Set(remoteControlTodo(S.state.remoteControl || [], to).map((g) => g.file))
|
|
2821
|
+
S.state.remoteControl = (S.state.remoteControl || []).map((g) => changed.has(g.file) ? { ...g, state: to, undo: true } : g)
|
|
2822
|
+
toast(t(to === 'on' ? 'rc.doneOn' : 'rc.done'), { action: { label: t('toast.undo'), run: remoteControlUndo }, duration: 12000 })
|
|
2569
2823
|
}
|
|
2570
|
-
else toast(t(
|
|
2571
|
-
} catch (e) { toast(t(
|
|
2824
|
+
else toast(t(fail, { msg: r.message }), { kind: 'fail' })
|
|
2825
|
+
} catch (e) { toast(t(fail, { msg: e.message }), { kind: 'fail' }) }
|
|
2572
2826
|
rc.busy = false
|
|
2573
2827
|
rc.confirm = false
|
|
2828
|
+
rc.target = null
|
|
2574
2829
|
renderBanners()
|
|
2830
|
+
if (dlg.kind === 'rc') renderRc()
|
|
2575
2831
|
await load({ silent: true })
|
|
2576
2832
|
}
|
|
2577
2833
|
async function remoteControlUndo() {
|
|
@@ -2581,6 +2837,109 @@ const MAIN = String.raw `
|
|
|
2581
2837
|
} catch (e) { toast(t('toast.undoFailed', { msg: e.message }), { kind: 'fail' }) }
|
|
2582
2838
|
await load({ silent: true })
|
|
2583
2839
|
}
|
|
2840
|
+
// The header button keeps the setting in reach after the banner is gone (turned off, or "Keep it on").
|
|
2841
|
+
function rcSummary() {
|
|
2842
|
+
const api = S.state.accounts.filter((a) => a.kind === 'api').map((a) => a.ref)
|
|
2843
|
+
return remoteControlSummary(S.state.remoteControl || [], api)
|
|
2844
|
+
}
|
|
2845
|
+
// ---------- settings ----------
|
|
2846
|
+
// One header button holds what is set once and rarely changed: language, theme, Remote Control and Auto Handoff.
|
|
2847
|
+
// A dot on it says Remote Control starts by itself, so that state stays visible after its banner is gone.
|
|
2848
|
+
function settingsSections() {
|
|
2849
|
+
const out = []
|
|
2850
|
+
if (!LANG_FIXED) out.push('lang')
|
|
2851
|
+
if (BOOT.colorScheme === 'auto') out.push('theme')
|
|
2852
|
+
if (S.state && rcSummary()) out.push('rc')
|
|
2853
|
+
if (S.state && ahState()) out.push('ah')
|
|
2854
|
+
return out
|
|
2855
|
+
}
|
|
2856
|
+
function renderSettingsButton() {
|
|
2857
|
+
const b = $('settings')
|
|
2858
|
+
const secs = settingsSections()
|
|
2859
|
+
b.hidden = !secs.length
|
|
2860
|
+
const warn = secs.includes('rc') && rcSummary() === 'on'
|
|
2861
|
+
setKids(b, [icon('gear'), h('span', { class: 'hide-sm' }, t('set.button')), warn ? h('span', { class: 'dot set-dot', 'aria-hidden': 'true' }) : null])
|
|
2862
|
+
b.setAttribute('aria-label', t('set.button') + (warn ? ': ' + t('set.rcOn') : ''))
|
|
2863
|
+
refreshPopover('settings')
|
|
2864
|
+
if (dlg.kind === 'rc') renderRc()
|
|
2865
|
+
}
|
|
2866
|
+
function settingsPop() {
|
|
2867
|
+
const secs = settingsSections()
|
|
2868
|
+
const out = []
|
|
2869
|
+
const seg = (label, opts, cur, pick) => h('div', { class: 'set-row' }, h('span', { class: 'set-label' }, label),
|
|
2870
|
+
h('div', { class: 'seg', role: 'group', 'aria-label': label }, opts.map(([v, text]) =>
|
|
2871
|
+
h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(v === cur), onclick: () => pick(v) }, text))))
|
|
2872
|
+
const link = (label, state, cls, run) => h('button', { class: 'menu-item set-link', type: 'button', onclick: () => { closeMenu(); run() } },
|
|
2873
|
+
h('span', { class: 'menu-label' }, label), h('span', { class: 'chip chip-sm ' + cls }, state), icon('chevron'))
|
|
2874
|
+
if (secs.includes('lang')) out.push(seg(t('set.lang'), [['en', 'English'], ['zh', '中文']], lang, setLang))
|
|
2875
|
+
if (secs.includes('theme')) out.push(seg(t('set.theme'), CHOICES.theme.map((v) => [v, t('set.theme.' + v)]), prefs.theme, setTheme))
|
|
2876
|
+
if (out.length && (secs.includes('rc') || secs.includes('ah'))) out.push(h('div', { class: 'menu-sep', role: 'separator' }))
|
|
2877
|
+
if (secs.includes('rc')) {
|
|
2878
|
+
const sum = rcSummary()
|
|
2879
|
+
out.push(link(t('rc.button'), t('rc.state.' + sum), sum === 'on' ? 'chip-warn' : sum === 'off' ? 'chip-ok' : '', openRc))
|
|
2880
|
+
}
|
|
2881
|
+
if (secs.includes('ah')) {
|
|
2882
|
+
const on = ahState().settings.auto
|
|
2883
|
+
out.push(link(t('ah.button'), on ? t('ah.on') : t('ah.off'), on ? 'chip-ok' : '', () => setPage('handoff')))
|
|
2884
|
+
}
|
|
2885
|
+
return out
|
|
2886
|
+
}
|
|
2887
|
+
function setLang(v) {
|
|
2888
|
+
if (LANG_FIXED || v === lang) return
|
|
2889
|
+
lang = v
|
|
2890
|
+
prefs.lang = lang
|
|
2891
|
+
savePrefs()
|
|
2892
|
+
renderStatic()
|
|
2893
|
+
buildToolbar()
|
|
2894
|
+
renderAll()
|
|
2895
|
+
if (dlg.kind === 'create' && nf && !nf.creating) renderCreate()
|
|
2896
|
+
else if (dlg.kind === 'setup' && qs) renderSetup()
|
|
2897
|
+
else if (dlg.kind === 'tune' && tuneState) renderTune()
|
|
2898
|
+
else if (dlg.kind === 'model' && md) { md.form = null; renderModel() }
|
|
2899
|
+
else if (dlg.kind === 'browser') renderBrowser()
|
|
2900
|
+
else if (dlg.kind === 'update' && up) renderUpdate()
|
|
2901
|
+
else if (dlg.kind === 'rc') renderRc()
|
|
2902
|
+
}
|
|
2903
|
+
function setTheme(v) {
|
|
2904
|
+
prefs.theme = v
|
|
2905
|
+
savePrefs()
|
|
2906
|
+
applyTheme()
|
|
2907
|
+
refreshPopover('settings')
|
|
2908
|
+
}
|
|
2909
|
+
function openRc() {
|
|
2910
|
+
if (!S.state || !rcSummary()) return
|
|
2911
|
+
rc.target = null
|
|
2912
|
+
showDialog('rc')
|
|
2913
|
+
renderRc()
|
|
2914
|
+
}
|
|
2915
|
+
// One switch for every Claude settings file: moving it lists what changes, and only the confirmation writes.
|
|
2916
|
+
function renderRc() {
|
|
2917
|
+
const groups = S.state.remoteControl || []
|
|
2918
|
+
const sum = rcSummary()
|
|
2919
|
+
const to = rc.target
|
|
2920
|
+
const todo = to ? remoteControlTodo(groups, to) : []
|
|
2921
|
+
const changing = new Set(todo.map((g) => g.file))
|
|
2922
|
+
const undo = groups.some((g) => g.undo)
|
|
2923
|
+
const pick = (v) => { rc.target = v === sum ? null : v; renderRc() }
|
|
2924
|
+
const sw = h('div', { class: 'set-row rc-switch' }, h('span', null, t('rc.switch')),
|
|
2925
|
+
h('div', { class: 'seg', role: 'group', 'aria-label': t('rc.switch') }, ['on', 'off'].map((v) =>
|
|
2926
|
+
h('button', { class: 'seg-btn', type: 'button', disabled: BOOT.readOnly || rc.busy, 'aria-pressed': String((to || sum) === v), onclick: () => pick(v) }, t('rc.state.' + v)))))
|
|
2927
|
+
const fromOf = (g) => g.state === 'on' ? 'true' : g.state === 'off' ? 'false' : t('rc.notSet')
|
|
2928
|
+
const rows = groups.map((g) => h('li', { class: 'rc-row' },
|
|
2929
|
+
h('div', null, h('code', null, g.file), ' ', h('span', { class: 'chip chip-sm ' + (g.state === 'on' ? 'chip-warn' : g.state === 'off' ? 'chip-ok' : g.state === 'blocked' ? 'chip-fail' : '') }, t('rc.state.' + g.state))),
|
|
2930
|
+
h('div', { class: 'muted small' }, t('rc.usedBy', { refs: g.accounts.map(shortRef).join(', ') })),
|
|
2931
|
+
g.reason ? h('div', { class: 'muted small' }, g.reason) : null,
|
|
2932
|
+
changing.has(g.file) ? h('div', { class: 'small rc-change' }, t('rc.change', { from: fromOf(g), to: to === 'on' ? 'true' : 'false' })) : null))
|
|
2933
|
+
const body = [h('p', { class: 'small' }, t('rc.explain')), sw, h('ul', { class: 'rc-list' }, rows)]
|
|
2934
|
+
if (to === 'on') body.push(h('div', { class: 'note note-warn small' }, t('rc.onWarn')))
|
|
2935
|
+
else if (to === 'off') body.push(h('p', { class: 'small muted' }, t('rc.confirmNote')))
|
|
2936
|
+
else if (sum === 'off') body.push(h('p', { class: 'small muted' }, t('rc.allOff')))
|
|
2937
|
+
if (BOOT.readOnly) body.push(h('p', { class: 'small' }, t('rc.readOnlyBoth')))
|
|
2938
|
+
const foot = [h('button', { class: 'btn', type: 'button', disabled: rc.busy, onclick: () => to ? pick(sum) : dlg.el.close() }, t(to ? 'btn.cancel' : 'btn.close'))]
|
|
2939
|
+
if (!BOOT.readOnly && !to && undo) foot.push(h('button', { class: 'btn', type: 'button', disabled: rc.busy, onclick: async () => { rc.busy = true; renderRc(); await remoteControlUndo(); rc.busy = false; if (dlg.kind === 'rc') renderRc() } }, t('rc.undo')))
|
|
2940
|
+
if (!BOOT.readOnly && to) foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: rc.busy || !todo.length, 'aria-busy': rc.busy ? 'true' : null, onclick: () => setRemoteControl(to) }, rc.busy ? spinner() : null, t(to === 'on' ? 'rc.confirmOn' : 'rc.confirm')))
|
|
2941
|
+
setKids(dlg.el, dialogFrame(t('rc.dialogTitle'), null, body, foot))
|
|
2942
|
+
}
|
|
2584
2943
|
|
|
2585
2944
|
// ---------- banners ----------
|
|
2586
2945
|
function renderBanners() {
|
|
@@ -2607,7 +2966,7 @@ const MAIN = String.raw `
|
|
|
2607
2966
|
}
|
|
2608
2967
|
|
|
2609
2968
|
// ---------- loading ----------
|
|
2610
|
-
function renderAll() { renderTop(); renderBanners(); renderSummary(); renderFamilyFilter();
|
|
2969
|
+
function renderAll() { renderTop(); renderBanners(); renderSummary(); renderFamilyFilter(); renderAccounts(); renderCheckup(); renderPageChrome() }
|
|
2611
2970
|
// A load that starts while another runs is queued, never dropped, so a change made meanwhile (a new
|
|
2612
2971
|
// account, a fix) always ends up on screen; and only the newest response is ever rendered. A silent load is a
|
|
2613
2972
|
// background refresh: no spinner, and its render waits while the viewer is busy (see uiBusy).
|
|
@@ -2693,7 +3052,7 @@ const MAIN = String.raw `
|
|
|
2693
3052
|
v.hidden = !BOOT.version
|
|
2694
3053
|
if (!v.hidden) {
|
|
2695
3054
|
const newer = !!(u && u.available)
|
|
2696
|
-
v.textContent =
|
|
3055
|
+
v.textContent = verLabel()
|
|
2697
3056
|
v.classList.toggle('has-update', newer)
|
|
2698
3057
|
v.disabled = !BOOT.updates
|
|
2699
3058
|
const tip = !BOOT.updates ? '' : newer ? t('upd.labelLong', { v: u.latest, cur: u.current }) : t('upd.verTitle', { v: BOOT.version })
|
|
@@ -2708,6 +3067,7 @@ const MAIN = String.raw `
|
|
|
2708
3067
|
b.title = t('upd.labelLong', { v: u.latest, cur: u.current })
|
|
2709
3068
|
}
|
|
2710
3069
|
if (dlg.kind === 'update' && up && !up.busy) renderUpdate()
|
|
3070
|
+
if (!vp.checking) renderVerPop()
|
|
2711
3071
|
}
|
|
2712
3072
|
function openUpdate() {
|
|
2713
3073
|
if (!BOOT.updates) return
|
|
@@ -2738,6 +3098,63 @@ const MAIN = String.raw `
|
|
|
2738
3098
|
dlg.busy = false
|
|
2739
3099
|
renderUpdate()
|
|
2740
3100
|
}
|
|
3101
|
+
// The version pill opens a small popover, not the dialog: the version, whether it is the newest, "Check for
|
|
3102
|
+
// updates" in place, and the way into the dialog when there is something to do (update, Auto Update).
|
|
3103
|
+
const vp = { checking: false, checked: false, error: null }
|
|
3104
|
+
function openVerPop(anchor) {
|
|
3105
|
+
if (!BOOT.updates) return
|
|
3106
|
+
vp.checked = false
|
|
3107
|
+
vp.error = null
|
|
3108
|
+
openPopover(anchor, 'version', t('upd.title'), verPopKids)
|
|
3109
|
+
}
|
|
3110
|
+
function renderVerPop() { refreshPopover('version') }
|
|
3111
|
+
// A Local Build (ADR-0024) names its commit, with + when the working tree had changes, so the viewer can tell
|
|
3112
|
+
// which code they are looking at.
|
|
3113
|
+
function verLabel() {
|
|
3114
|
+
const l = BOOT.local
|
|
3115
|
+
return 'v' + BOOT.version + (l ? '-dev' + (l.commit ? ' · ' + l.commit + (l.dirty ? '+' : '') : '') : '')
|
|
3116
|
+
}
|
|
3117
|
+
function verPopKids() {
|
|
3118
|
+
const u = updState()
|
|
3119
|
+
const toDialog = () => { closeMenu(); openUpdate() }
|
|
3120
|
+
const kids = [h('b', null, 'sideby ' + verLabel())]
|
|
3121
|
+
if (BOOT.local) kids.push(h('p', { class: 'muted small' }, t(BOOT.local.dirty ? 'upd.localDirty' : 'upd.local', { at: BOOT.local.builtAt ? clock(BOOT.local.builtAt) : '' })))
|
|
3122
|
+
let status
|
|
3123
|
+
if (!u) status = t('upd.loading')
|
|
3124
|
+
else if (u.available) status = null
|
|
3125
|
+
else if (vp.checked && u.latest) status = t('upd.latestNow')
|
|
3126
|
+
else if (u.checksOff) status = t('upd.checksOff', { why: u.checksOff })
|
|
3127
|
+
else if (u.latest) status = t('upd.latest') + (u.checkedAt ? ' · ' + t('upd.checked', { ago: ago(u.checkedAt) }) : '')
|
|
3128
|
+
else status = t('upd.notYet')
|
|
3129
|
+
if (status) kids.push(h('p', { class: 'muted small' }, status))
|
|
3130
|
+
if (u && u.available) {
|
|
3131
|
+
kids.push(h('p', { class: 'small' }, t('upd.available', { v: u.latest }), ' ',
|
|
3132
|
+
h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
|
|
3133
|
+
kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: toDialog }, t('upd.open'))))
|
|
3134
|
+
} else if (!BOOT.readOnly) {
|
|
3135
|
+
kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm', type: 'button', disabled: vp.checking, 'aria-busy': vp.checking ? 'true' : null, onclick: verPopCheck },
|
|
3136
|
+
vp.checking ? spinner() : icon('refresh'), vp.checking ? t('upd.checking') : t('upd.check'))))
|
|
3137
|
+
}
|
|
3138
|
+
if (vp.error) kids.push(errorNote(vp.error, null))
|
|
3139
|
+
if (u && !BOOT.readOnly)
|
|
3140
|
+
kids.push(h('p', { class: 'muted small popover-foot' }, t(u.auto ? 'upd.autoIsOn' : 'upd.autoIsOff'), ' · ',
|
|
3141
|
+
h('button', { class: 'link-btn', type: 'button', onclick: toDialog }, t('upd.settings'))))
|
|
3142
|
+
return kids
|
|
3143
|
+
}
|
|
3144
|
+
async function verPopCheck() {
|
|
3145
|
+
vp.checking = true
|
|
3146
|
+
vp.error = null
|
|
3147
|
+
renderVerPop()
|
|
3148
|
+
try {
|
|
3149
|
+
await api('/api/update', {})
|
|
3150
|
+
await load({ silent: true })
|
|
3151
|
+
vp.checked = true
|
|
3152
|
+
} catch (e) { vp.error = e }
|
|
3153
|
+
vp.checking = false
|
|
3154
|
+
renderVerPop()
|
|
3155
|
+
const b = menu.el && menu.el.querySelector('.popover-actions button')
|
|
3156
|
+
if (b) b.focus()
|
|
3157
|
+
}
|
|
2741
3158
|
// "Check for updates": asks the registry now (the daily check only runs once a day), then shows the answer.
|
|
2742
3159
|
async function updCheck() {
|
|
2743
3160
|
up.checking = true
|
|
@@ -2838,33 +3255,11 @@ const MAIN = String.raw `
|
|
|
2838
3255
|
// ---------- start ----------
|
|
2839
3256
|
$('ver').textContent = BOOT.version ? 'sideby v' + BOOT.version : 'sideby'
|
|
2840
3257
|
$('ver').disabled = !BOOT.updates
|
|
2841
|
-
$('ver').addEventListener('click',
|
|
3258
|
+
$('ver').addEventListener('click', () => openVerPop($('ver')))
|
|
2842
3259
|
$('update').addEventListener('click', openUpdate)
|
|
2843
|
-
$('ver-top').addEventListener('click',
|
|
3260
|
+
$('ver-top').addEventListener('click', () => openVerPop($('ver-top')))
|
|
2844
3261
|
$('refresh').addEventListener('click', () => load({ check: true, announce: true }))
|
|
2845
|
-
$('
|
|
2846
|
-
$('lang').addEventListener('click', () => {
|
|
2847
|
-
if (LANG_FIXED) return
|
|
2848
|
-
lang = lang === 'zh' ? 'en' : 'zh'
|
|
2849
|
-
prefs.lang = lang
|
|
2850
|
-
savePrefs()
|
|
2851
|
-
renderStatic()
|
|
2852
|
-
buildToolbar()
|
|
2853
|
-
renderAll()
|
|
2854
|
-
if (dlg.kind === 'create' && nf && !nf.creating) renderCreate()
|
|
2855
|
-
else if (dlg.kind === 'setup' && qs) renderSetup()
|
|
2856
|
-
else if (dlg.kind === 'tune' && tuneState) renderTune()
|
|
2857
|
-
else if (dlg.kind === 'model' && md) { md.form = null; renderModel() }
|
|
2858
|
-
else if (dlg.kind === 'update' && up) renderUpdate()
|
|
2859
|
-
})
|
|
2860
|
-
$('theme').addEventListener('click', () => {
|
|
2861
|
-
const order = CHOICES.theme
|
|
2862
|
-
prefs.theme = order[(order.indexOf(prefs.theme) + 1) % order.length]
|
|
2863
|
-
savePrefs()
|
|
2864
|
-
applyTheme()
|
|
2865
|
-
renderStatic()
|
|
2866
|
-
toast(t('theme.' + prefs.theme), { key: 'theme', kind: 'plain' })
|
|
2867
|
-
})
|
|
3262
|
+
$('settings').addEventListener('click', () => openPopover($('settings'), 'settings', t('set.button'), settingsPop, 'end'))
|
|
2868
3263
|
applyTheme()
|
|
2869
3264
|
renderStatic()
|
|
2870
3265
|
buildToolbar()
|