sideby 0.13.1 → 0.15.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 +2 -1
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +4 -2
- package/dist/src/cli/commands.js +194 -11
- package/dist/src/cli/format.js +7 -3
- package/dist/src/cli/json-schemas.js +104 -3
- package/dist/src/cli/main.js +27 -7
- package/dist/src/core/config.d.ts +28 -0
- package/dist/src/core/config.js +54 -1
- package/dist/src/core/handoff-enable.js +10 -5
- package/dist/src/core/launch.d.ts +5 -0
- package/dist/src/core/launch.js +14 -3
- package/dist/src/core/quota-fetch.d.ts +22 -0
- package/dist/src/core/quota-fetch.js +98 -0
- package/dist/src/core/quota-levels.d.ts +3 -2
- package/dist/src/core/quota-levels.js +8 -4
- package/dist/src/core/quota-store.d.ts +19 -0
- package/dist/src/core/quota-store.js +62 -0
- package/dist/src/core/skill-check.d.ts +16 -0
- package/dist/src/core/skill-check.js +47 -3
- package/dist/src/core/update.d.ts +67 -1
- package/dist/src/core/update.js +147 -37
- package/dist/src/families/grok/handoff-hook.d.ts +6 -0
- package/dist/src/families/grok/handoff-hook.js +45 -2
- package/dist/src/families/grok/handoff.d.ts +6 -1
- package/dist/src/families/grok/handoff.js +30 -13
- package/dist/src/families/grok/index.js +5 -0
- package/dist/src/families/grok/quota-log.d.ts +30 -0
- package/dist/src/families/grok/quota-log.js +140 -0
- package/dist/src/families/grok/quota.d.ts +5 -0
- package/dist/src/families/grok/quota.js +112 -0
- package/dist/src/panel/handler.d.ts +13 -3
- package/dist/src/panel/handler.js +81 -8
- package/dist/src/panel/i18n.d.ts +138 -8
- package/dist/src/panel/i18n.js +301 -41
- package/dist/src/panel/page-logic.d.ts +59 -2
- package/dist/src/panel/page-logic.js +99 -6
- package/dist/src/panel/page-script.js +769 -175
- package/dist/src/panel/page-styles.js +83 -15
- package/dist/src/panel/page.js +3 -0
- package/dist/src/runtime.d.ts +32 -2
- package/dist/src/runtime.js +64 -21
- package/dist/src/types.d.ts +49 -1
- package/package.json +3 -2
- package/schemas/alias.json +1 -1
- package/schemas/app-install.json +1 -1
- package/schemas/app-uninstall.json +1 -1
- package/schemas/browser-open.json +1 -1
- package/schemas/browser-switch.json +1 -1
- package/schemas/browser.json +1 -1
- package/schemas/config.json +60 -0
- package/schemas/desktop-remove.json +1 -1
- package/schemas/desktop.json +1 -1
- package/schemas/doctor.json +1 -1
- package/schemas/error.json +1 -1
- package/schemas/families.json +1 -1
- package/schemas/handoff-disable.json +1 -1
- package/schemas/handoff-enable.json +1 -1
- package/schemas/handoff-ready.json +1 -1
- package/schemas/handoff-setup.json +1 -1
- package/schemas/handoff-status.json +282 -1
- package/schemas/handoff-teardown.json +1 -1
- package/schemas/list.json +6 -1
- package/schemas/model.json +1 -1
- package/schemas/new.json +1 -1
- package/schemas/next.json +1 -1
- package/schemas/order.json +148 -0
- package/schemas/pin.json +126 -0
- package/schemas/plugins.json +1 -1
- package/schemas/quota-check.json +88 -0
- package/schemas/quota-setup.json +1 -1
- package/schemas/quota-teardown.json +1 -1
- package/schemas/quota.json +15 -2
- package/schemas/remote-control.json +1 -1
- package/schemas/tune-change.json +1 -1
- package/schemas/tune.json +1 -1
- package/schemas/unpin.json +126 -0
- package/schemas/update-auto.json +84 -0
- package/schemas/update.json +79 -1
- package/skills/sideby/SKILL.md +7 -3
- package/skills/sideby/references/diagnose.md +2 -1
- package/skills/sideby/references/handoff.md +9 -1
- package/skills/sideby/references/panel.md +28 -0
- package/skills/sideby/references/quota.md +11 -1
- package/skills/sideby/references/update.md +6 -2
|
@@ -1,15 +1,17 @@
|
|
|
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, loginClusters, loginKey, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelHint, panelLang, passedWindow, quotaPressure, remoteControlSummary, remoteControlTodo, sortAccounts, suggestAlias, suggestName, updatedAge, windowState, } from './page-logic.js';
|
|
3
|
+
import { aliasArgsOf, aliasIssue, attentionReasons, attentionWhy, cacheHitRate, fixPrompt, formatDuration, handoffChangeOf, handoffHintAccount, inDisplayOrder, loginClusters, loginKey, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelFixes, panelHint, panelLang, passedWindow, pinnedIn, quotaPressure, remoteControlSummary, remoteControlTodo, reorder, sortAccounts, suggestAlias, suggestName, updatedAge, windowState, withPinned, } from './page-logic.js';
|
|
4
4
|
const LOGIC = [
|
|
5
5
|
aliasArgsOf,
|
|
6
6
|
aliasIssue,
|
|
7
7
|
attentionReasons,
|
|
8
8
|
attentionWhy,
|
|
9
9
|
cacheHitRate,
|
|
10
|
+
fixPrompt,
|
|
10
11
|
formatDuration,
|
|
11
12
|
handoffChangeOf,
|
|
12
13
|
handoffHintAccount,
|
|
14
|
+
inDisplayOrder,
|
|
13
15
|
loginClusters,
|
|
14
16
|
loginKey,
|
|
15
17
|
maskEmail,
|
|
@@ -17,17 +19,21 @@ const LOGIC = [
|
|
|
17
19
|
mergeDaily,
|
|
18
20
|
newAccountDir,
|
|
19
21
|
nextReset,
|
|
22
|
+
panelFixes,
|
|
20
23
|
panelHint,
|
|
21
24
|
panelLang,
|
|
22
25
|
passedWindow,
|
|
23
26
|
quotaPressure,
|
|
24
27
|
remoteControlSummary,
|
|
25
28
|
remoteControlTodo,
|
|
29
|
+
reorder,
|
|
26
30
|
sortAccounts,
|
|
27
31
|
suggestAlias,
|
|
28
32
|
suggestName,
|
|
29
33
|
updatedAge,
|
|
34
|
+
pinnedIn,
|
|
30
35
|
windowState,
|
|
36
|
+
withPinned,
|
|
31
37
|
]
|
|
32
38
|
.map((fn) => fn.toString())
|
|
33
39
|
.join('\n');
|
|
@@ -57,14 +63,14 @@ const MAIN = String.raw `
|
|
|
57
63
|
silent: false, lastTry: 0, pending: false, fastUntil: 0, fastFamily: null,
|
|
58
64
|
}
|
|
59
65
|
// View state that is not saved: the search text, the attention filter, the Health check filter.
|
|
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
|
|
66
|
+
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 Map(), order: { families: [], units: {} }, drag: null, arrange: null }
|
|
61
67
|
const $ = (id) => document.getElementById(id)
|
|
62
68
|
|
|
63
69
|
// ---------- preferences ----------
|
|
64
70
|
// Kept in localStorage, which can be missing or throw (private windows, blocked storage): every access is guarded
|
|
65
71
|
// and the defaults apply.
|
|
66
72
|
const PREFS_KEY = 'sideby.panel.v2'
|
|
67
|
-
const CHOICES = { view: ['list', 'cards'], sort: ['pressure', 'name', 'recent'], quota: ['used', 'left'], theme: ['system', 'light', 'dark'], lang: ['en', 'zh'] }
|
|
73
|
+
const CHOICES = { view: ['list', 'cards'], sort: ['pressure', 'name', 'recent', 'manual'], quota: ['used', 'left'], theme: ['system', 'light', 'dark'], lang: ['en', 'zh'] }
|
|
68
74
|
const prefs = loadPrefs()
|
|
69
75
|
function loadPrefs() {
|
|
70
76
|
let raw = {}
|
|
@@ -235,6 +241,8 @@ const MAIN = String.raw `
|
|
|
235
241
|
function uiBusy() {
|
|
236
242
|
// A row or group in the middle of folding; a render now would cut the motion short.
|
|
237
243
|
for (const el of folding) if (el.isConnected) return true
|
|
244
|
+
// A row or group being dragged into a new place (spec §3.25).
|
|
245
|
+
if (ui.drag) return true
|
|
238
246
|
if (menu.el || (dlg.el && dlg.el.open) || document.querySelector('.tip.is-open')) return true
|
|
239
247
|
// A plan on the Auto Handoff page, and the change a person opened to read, stay as they are.
|
|
240
248
|
if (!$('handoff-page').hidden && (hp.preview || hp.askSame || document.querySelector('#handoff-page details[open]'))) return true
|
|
@@ -249,6 +257,7 @@ const MAIN = String.raw `
|
|
|
249
257
|
search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-4.2-4.2',
|
|
250
258
|
chevron: 'M9 6l6 6-6 6',
|
|
251
259
|
more: 'M5 12h.01M12 12h.01M19 12h.01',
|
|
260
|
+
grip: 'M9 6h.01M9 12h.01M9 18h.01M15 6h.01M15 12h.01M15 18h.01',
|
|
252
261
|
plus: 'M12 5v14M5 12h14',
|
|
253
262
|
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',
|
|
254
263
|
moon: 'M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z',
|
|
@@ -263,6 +272,9 @@ const MAIN = String.raw `
|
|
|
263
272
|
userPlus: 'M15 19a6 6 0 0 0-12 0M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM19 8v6M16 11h6',
|
|
264
273
|
x: 'M6 6l12 12M18 6L6 18',
|
|
265
274
|
arrowUp: 'M12 19V5M5 12l7-7 7 7',
|
|
275
|
+
arrowDown: 'M12 5v14M5 12l7 7 7-7',
|
|
276
|
+
// Lucide "pin" (ISC license)
|
|
277
|
+
pin: 'M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z',
|
|
266
278
|
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',
|
|
267
279
|
grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
|
|
268
280
|
list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
|
|
@@ -491,7 +503,7 @@ const MAIN = String.raw `
|
|
|
491
503
|
try { const u = new URL(url); ok = u.protocol === 'https:' || u.protocol === 'http:' } catch {}
|
|
492
504
|
return ok ? h('a', { class: cls, href: url, target: '_blank', rel: 'noopener noreferrer' }, text) : null
|
|
493
505
|
}
|
|
494
|
-
async function copyText(text) {
|
|
506
|
+
async function copyText(text, shown) {
|
|
495
507
|
let ok = true
|
|
496
508
|
try { await navigator.clipboard.writeText(text) } catch {
|
|
497
509
|
const ta = h('textarea', { css: { position: 'fixed', opacity: '0' } })
|
|
@@ -501,7 +513,7 @@ const MAIN = String.raw `
|
|
|
501
513
|
try { ok = document.execCommand('copy') } catch { ok = false }
|
|
502
514
|
ta.remove()
|
|
503
515
|
}
|
|
504
|
-
if (ok) toast(t('toast.copied', { text: prefs.hideEmail && text.includes('@') ? maskEmail(text) : text }))
|
|
516
|
+
if (ok) toast(t('toast.copied', { text: shown || (prefs.hideEmail && text.includes('@') ? maskEmail(text) : text) }))
|
|
505
517
|
else toast(t('toast.copyFailed'), { kind: 'fail' })
|
|
506
518
|
}
|
|
507
519
|
function codeCopy(text) {
|
|
@@ -560,6 +572,7 @@ const MAIN = String.raw `
|
|
|
560
572
|
function openMenu(anchor, items, label) {
|
|
561
573
|
const el = h('div', { class: 'menu', role: 'menu', 'aria-label': label },
|
|
562
574
|
items.filter(Boolean).map((it) => it === '-' ? h('div', { class: 'menu-sep', role: 'separator' })
|
|
575
|
+
: it.header ? h('div', { class: 'menu-head', role: 'presentation' }, it.header)
|
|
563
576
|
: h('button', { class: 'menu-item', type: 'button', role: 'menuitem', tabindex: '-1', disabled: it.disabled, onclick: () => { closeMenu(true); it.run() } },
|
|
564
577
|
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
578
|
el.addEventListener('keydown', menuKeys)
|
|
@@ -607,24 +620,81 @@ const MAIN = String.raw `
|
|
|
607
620
|
window.addEventListener('resize', () => closeMenu())
|
|
608
621
|
window.addEventListener('scroll', () => closeMenu(), { passive: true })
|
|
609
622
|
|
|
610
|
-
|
|
623
|
+
// What one directory does: start it, its short commands, its desktop app, its folder, model, tuning, Health.
|
|
624
|
+
function dirCopies(a) {
|
|
611
625
|
const run = 'sideby run ' + a.ref
|
|
612
|
-
|
|
626
|
+
return [
|
|
613
627
|
{ icon: 'terminal', label: t('menu.copyRun'), hint: run, run: () => copyText(run) },
|
|
614
628
|
...(a.aliases || []).slice(0, 2).map((x) => ({ icon: 'terminal', label: t('menu.copyAlias'), hint: x, run: () => copyText(x) })),
|
|
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,
|
|
617
|
-
a.kind !== 'api' ? { icon: 'key', label: t('menu.copyLogin'), hint: 'sideby login ' + a.ref, run: () => copyText('sideby login ' + a.ref) } : null,
|
|
618
629
|
a.dir ? { icon: 'folder', label: t('menu.copyDir'), hint: a.dir, run: () => copyText(a.dir) } : null,
|
|
619
|
-
|
|
620
|
-
|
|
630
|
+
]
|
|
631
|
+
}
|
|
632
|
+
function deskItem(a) {
|
|
633
|
+
return a.desktopApp && a.desktopApp.installed && a.kind !== 'api' && !BOOT.readOnly ? { icon: 'monitor', label: t(a.desktopApp.running ? 'desk.show' : 'desk.open', { app: a.desktopApp.app }), disabled: Boolean(ui.desktopRef), run: () => openDesktop(a) } : null
|
|
634
|
+
}
|
|
635
|
+
function dirItems(a) { return [...dirCopies(a), deskItem(a)] }
|
|
636
|
+
function dirTools(a) {
|
|
637
|
+
return [
|
|
621
638
|
a.modelInfo && a.modelInfo.settable ? { icon: 'terminal', label: t('menu.model'), run: () => openModel(a) } : null,
|
|
622
639
|
family(a.family).tuning ? { icon: 'check', label: t('menu.tune'), run: () => openTune(a) } : null,
|
|
623
640
|
{ icon: 'check', label: t('menu.check'), disabled: S.checking || S.fixing || Boolean(ui.checkingRef), run: () => checkAccount(a.ref) },
|
|
624
|
-
BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) },
|
|
625
641
|
]
|
|
642
|
+
}
|
|
643
|
+
// What the login does, whichever directory: the browser, signing in, the email, a new account like it.
|
|
644
|
+
function loginItems(a) {
|
|
645
|
+
return [
|
|
646
|
+
browserFor(a) ? { icon: 'globe', label: t('br.open'), disabled: Boolean(ui.browserRef), run: () => openBrowserDialog(a) } : null,
|
|
647
|
+
a.kind !== 'api' ? { icon: 'key', label: t('menu.copyLogin'), hint: 'sideby login ' + a.ref, run: () => copyText('sideby login ' + a.ref) } : null,
|
|
648
|
+
a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
|
|
649
|
+
]
|
|
650
|
+
}
|
|
651
|
+
function likeItem(a) { return BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) } }
|
|
652
|
+
// Grouped by what the user wants to do: open, copy, manage.
|
|
653
|
+
function accountMenu(a, anchor) {
|
|
654
|
+
const [browser, login, email] = loginItems(a)
|
|
655
|
+
const opens = [deskItem(a), browser].filter(Boolean)
|
|
656
|
+
const copies = dirCopies(a)
|
|
657
|
+
const items = [pinItem(a),
|
|
658
|
+
opens.length ? '-' : null, opens.length ? { header: t('menu.groupOpen') } : null, ...opens,
|
|
659
|
+
'-', { header: t('menu.groupCopy') }, ...copies, login, email,
|
|
660
|
+
'-', { header: t('menu.groupManage') }, ...dirTools(a), likeItem(a)]
|
|
626
661
|
openMenu(anchor, items, t('menu.label', { ref: a.ref }))
|
|
627
662
|
}
|
|
663
|
+
// A Same Login row (ADR-0026): pin first, then a section per directory, then what the login shares.
|
|
664
|
+
function loginMenu(ms, anchor) {
|
|
665
|
+
const items = [pinItem(ms[0])]
|
|
666
|
+
for (const m of ms) items.push('-', { header: m.name + ((m.aliases || []).length ? ' · ' + m.aliases[0] : '') }, ...dirItems(m), ...dirTools(m))
|
|
667
|
+
items.push('-', ...loginItems(ms[0]), likeItem(ms[0]))
|
|
668
|
+
openMenu(anchor, items, t('login.label', { names: ms.map((m) => m.name).join(', ') }))
|
|
669
|
+
}
|
|
670
|
+
function loginMoreButton(ms) {
|
|
671
|
+
const b = h('button', { class: 'icon-btn more', type: 'button', 'aria-haspopup': 'menu', 'aria-expanded': 'false', 'aria-label': t('login.label', { names: ms.map((m) => m.name).join(', ') }), title: t('menu.title') }, icon('more'))
|
|
672
|
+
b.addEventListener('click', () => { if (menu.anchor === b) closeMenu(); else loginMenu(ms, b) })
|
|
673
|
+
return b
|
|
674
|
+
}
|
|
675
|
+
// The most used action, on the row itself: copy the command that starts this account (its short command when it
|
|
676
|
+
// has one, which is shorter to type).
|
|
677
|
+
function runButton(a) {
|
|
678
|
+
const cmd = (a.aliases || [])[0] || 'sideby run ' + a.ref
|
|
679
|
+
const text = t('row.copyRun', { cmd })
|
|
680
|
+
return h('button', { class: 'icon-btn row-run', type: 'button', 'aria-label': text, title: text, onclick: (e) => { e.stopPropagation(); copyText(cmd) } }, icon('terminal'))
|
|
681
|
+
}
|
|
682
|
+
// The pin at the end of a row (spec §3.25): one click pins or unpins. A pinned row always shows it, filled; the
|
|
683
|
+
// others show it on hover or focus. A read-only Panel shows the mark of a pinned row and no button.
|
|
684
|
+
function pinButton(a, label) {
|
|
685
|
+
const on = isPinned(a)
|
|
686
|
+
if (BOOT.readOnly) return on ? h('span', { class: 'icon-btn pin-btn row-pin is-on', role: 'img', title: t('pin.mark'), 'aria-label': t('pin.mark') }, icon('pin')) : null
|
|
687
|
+
const full = !on && pinnedUnits(a.family) >= PIN_MAX
|
|
688
|
+
const text = full ? t('pin.full', { n: PIN_MAX }) : t(on ? 'pin.unpinName' : 'pin.pinName', { name: label, title: family(a.family).title })
|
|
689
|
+
return h('button', { class: 'icon-btn pin-btn row-pin' + (on ? ' is-on' : ''), type: 'button', 'aria-pressed': String(on), 'aria-label': text, title: text, 'aria-disabled': full ? 'true' : null,
|
|
690
|
+
onclick: (e) => { e.stopPropagation(); if (full) toast(t('pin.full', { n: PIN_MAX })); else togglePin(a) } }, icon('pin'))
|
|
691
|
+
}
|
|
692
|
+
function pinItem(a) {
|
|
693
|
+
if (BOOT.readOnly) return null
|
|
694
|
+
const on = isPinned(a)
|
|
695
|
+
const full = !on && pinnedUnits(a.family) >= PIN_MAX
|
|
696
|
+
return { icon: 'pin', label: full ? t('pin.full', { n: PIN_MAX }) : t(on ? 'pin.unpin' : 'pin.pin'), disabled: full, run: () => togglePin(a) }
|
|
697
|
+
}
|
|
628
698
|
function moreButton(a) {
|
|
629
699
|
const b = h('button', { class: 'icon-btn more', type: 'button', 'aria-haspopup': 'menu', 'aria-expanded': 'false', 'aria-label': t('menu.label', { ref: a.ref }), title: t('menu.title') }, icon('more'))
|
|
630
700
|
b.addEventListener('click', () => { if (menu.anchor === b) closeMenu(); else accountMenu(a, b) })
|
|
@@ -658,7 +728,7 @@ const MAIN = String.raw `
|
|
|
658
728
|
function whyOf(a, now) {
|
|
659
729
|
const m = attentionWhy(a, healthOf(a.ref), now, WARN)
|
|
660
730
|
if (m && m.key === 'why.not-installed' && family(a.family).brokenBin) return t('why.broken')
|
|
661
|
-
return m ? t(m.key, m.vars) : ''
|
|
731
|
+
return m ? t(m.key, m.vars && m.vars.label ? Object.assign({}, m.vars, { label: winTitle(m.vars.label) }) : m.vars) : ''
|
|
662
732
|
}
|
|
663
733
|
function setupFor(fam) { return S.state && S.state.quotaSetups.find((s) => s.family === fam) }
|
|
664
734
|
|
|
@@ -735,7 +805,10 @@ const MAIN = String.raw `
|
|
|
735
805
|
for (const r of rs) counts[r] = (counts[r] || 0) + 1
|
|
736
806
|
}
|
|
737
807
|
const order = ['error', 'health-fail', 'signed-out', 'not-installed', 'quota-high', 'health-warn']
|
|
738
|
-
|
|
808
|
+
// Who needs a look, by name: counting reasons beside a count of accounts made numbers that did not add up.
|
|
809
|
+
const needing = list.filter((a) => reasonsOf(a, now).length)
|
|
810
|
+
const needSub = need ? t(needing.length > 2 ? 'stat.needWhoMore' : 'stat.needWho', { names: needing.slice(0, 2).map((a) => a.ref).join(' · '), n: needing.length - 2 }) : t('stat.allClear')
|
|
811
|
+
const needTitle = need ? order.filter((r) => counts[r]).map((r) => tn('need.' + r, counts[r])).join(' · ') : null
|
|
739
812
|
const usages = list.filter((a) => a.usage && a.usage.status === 'ok')
|
|
740
813
|
const total = usages.reduce((s, a) => s + (Number(a.usage.totalTokens) || 0), 0)
|
|
741
814
|
const sessions = usages.reduce((s, a) => s + (Number(a.usage.sessions) || 0), 0)
|
|
@@ -744,12 +817,12 @@ const MAIN = String.raw `
|
|
|
744
817
|
patch(box, [
|
|
745
818
|
stat(t('stat.accounts'), String(list.length), dots([tn('stat.families', famCount), apiCount ? tn('stat.api', apiCount) : null])),
|
|
746
819
|
need
|
|
747
|
-
? stat(t('stat.attention'), String(need), needSub,
|
|
820
|
+
? stat(t('stat.attention'), String(need), needSub, h('span', { class: 'stat-act' }, t(ui.attention ? 'stat.attentionOff' : 'stat.attentionOn')), { cls: 'stat-warn', pressed: ui.attention, title: [needTitle, t(ui.attention ? 'stat.attentionOff' : 'stat.attentionOn')].join('\n'), onclick: () => { ui.attention = !ui.attention; renderSummary(); renderAccounts() } })
|
|
748
821
|
: stat(t('stat.attention'), '0', needSub, null, { cls: 'stat-ok' }),
|
|
749
822
|
stat(t('stat.tokens', { n: BOOT.usageDays }), tokens(total), tn('stat.sessions', sessions), spark(daily, 'spark-lg')),
|
|
750
823
|
stat(h('span', { class: 'label-tip' }, t('stat.nextReset'), tip('age')),
|
|
751
824
|
nr ? t('time.in', { d: formatDuration(nr.at - now, lang) }) : '—',
|
|
752
|
-
nr ? dots([h('span', { class: 'mono' }, nr.ref), nr.label, clock(new Date(nr.at).toISOString())]) : t('stat.noQuota')),
|
|
825
|
+
nr ? dots([h('span', { class: 'mono' }, nr.ref), winTitle(nr.label), clock(new Date(nr.at).toISOString())]) : t('stat.noQuota')),
|
|
753
826
|
])
|
|
754
827
|
}
|
|
755
828
|
|
|
@@ -775,10 +848,20 @@ const MAIN = String.raw `
|
|
|
775
848
|
})
|
|
776
849
|
tb.family = h('select', { class: 'tb-select', 'aria-label': t('tb.family') })
|
|
777
850
|
tb.family.addEventListener('change', () => { prefs.family = tb.family.value; savePrefs(); renderAccounts() })
|
|
851
|
+
// "Arrange order…" is not a sort: it opens the arrange mode (spec §3.25), and the sort shows "my order".
|
|
778
852
|
tb.sort = h('select', { class: 'tb-select', 'aria-label': t('tb.sort') },
|
|
779
|
-
CHOICES.sort.map((v) => h('option', { value: v }, t('sort.' + v)))
|
|
853
|
+
CHOICES.sort.map((v) => h('option', { value: v }, t('sort.' + v))),
|
|
854
|
+
BOOT.readOnly ? null : h('option', { value: 'arrange' }, t('sort.arrange')))
|
|
780
855
|
tb.sort.value = prefs.sort
|
|
781
|
-
tb.sort.addEventListener('change', () => {
|
|
856
|
+
tb.sort.addEventListener('change', () => {
|
|
857
|
+
const v = tb.sort.value
|
|
858
|
+
// "My order" before any order of one's own would show nothing new: it opens the arrange mode instead.
|
|
859
|
+
if (v === 'arrange' || (v === 'manual' && !ordered() && !BOOT.readOnly)) { startArrange(); return }
|
|
860
|
+
if (ui.arrange) endArrange()
|
|
861
|
+
prefs.sort = v
|
|
862
|
+
savePrefs()
|
|
863
|
+
renderAccounts()
|
|
864
|
+
})
|
|
782
865
|
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
866
|
display.addEventListener('click', () => openPopover(display, 'display', t('tb.display'), displayPanel, 'end'))
|
|
784
867
|
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')))
|
|
@@ -799,7 +882,7 @@ const MAIN = String.raw `
|
|
|
799
882
|
h('div', { class: 'dp-row' }, h('span', { class: 'dp-label' }, t('tb.quota')),
|
|
800
883
|
seg(t('tb.quota'), [['used', t('tb.used')], ['left', t('tb.left')]], prefs.quota, (v) => { prefs.quota = v; redo() })),
|
|
801
884
|
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() })),
|
|
885
|
+
seg(t('tb.view'), [['list', [icon('list'), h('span', null, t('tb.list'))], t('tb.list')], ['cards', [icon('grid'), h('span', null, t('tb.cards'))], t('tb.cards')]], prefs.view, (v) => { prefs.view = v; ui.fade = true; redo() })),
|
|
803
886
|
emails ? h('label', { class: 'dp-row dp-check' },
|
|
804
887
|
h('input', { type: 'checkbox', checked: prefs.hideEmail, onchange: (e) => { prefs.hideEmail = e.target.checked; redo() } }),
|
|
805
888
|
h('span', null, t('tb.hideEmails'))) : null)
|
|
@@ -829,12 +912,18 @@ const MAIN = String.raw `
|
|
|
829
912
|
// One column per Quota window label. 5h and 7d always have one, so rows of every group line up under the same
|
|
830
913
|
// header; a label only some account reports (such as 60m) adds a column, at most three in all.
|
|
831
914
|
const KNOWN_WINDOWS = { '5h': 300, '7d': 10080 }
|
|
915
|
+
// A window's column: the models it covers when it covers only some (Cursor's "Other models"), else its length.
|
|
916
|
+
function winKey(w) { return w.name || w.label }
|
|
917
|
+
// The words for a window in labels (5-hour, weekly) instead of 5h and 7d; other labels as they are.
|
|
918
|
+
function winTitle(k) { return k === '5h' || k === '7d' ? t('win.' + k) : k }
|
|
919
|
+
// One group's columns: the windows its accounts report, shortest first; 5h and 7d when none reports any yet.
|
|
832
920
|
function quotaColumns(list) {
|
|
833
|
-
const mins =
|
|
921
|
+
const mins = {}
|
|
834
922
|
for (const a of list)
|
|
835
923
|
if (a.quota && a.quota.status === 'ok')
|
|
836
|
-
for (const w of a.quota.windows || []) if (!Object.hasOwn(mins, w
|
|
837
|
-
|
|
924
|
+
for (const w of a.quota.windows || []) if (!Object.hasOwn(mins, winKey(w))) mins[winKey(w)] = (Number(w.windowMinutes) || 0) + (w.name ? -0.5 : 0)
|
|
925
|
+
if (!Object.keys(mins).length) Object.assign(mins, KNOWN_WINDOWS)
|
|
926
|
+
return Object.keys(mins).sort((x, y) => mins[x] - mins[y]).slice(0, 4)
|
|
838
927
|
}
|
|
839
928
|
// A window's cell: percent and countdown on one line, the bar under them. The label shows where no column
|
|
840
929
|
// header does (cards, narrow screens).
|
|
@@ -843,13 +932,16 @@ const MAIN = String.raw `
|
|
|
843
932
|
const passed = passedWindow(w, observedAt, now)
|
|
844
933
|
const left = prefs.quota === 'left'
|
|
845
934
|
const shown = left ? 100 - st.pct : st.pct
|
|
846
|
-
const
|
|
935
|
+
const asOf = stale ? t('quota.asOf', { ago: ago(observedAt) }) : ''
|
|
936
|
+
const reset = passed ? t('quota.resetShort') : t('quota.resetsIn', { d: formatDuration(Date.parse(w.resetsAt) - now, lang) })
|
|
937
|
+
const title = [passed ? t('quota.resetNoData', { date: day(passed.since) }) : t('quota.resetsAt', { at: clock(w.resetsAt) }), asOf].filter(Boolean).join(' · ')
|
|
847
938
|
return h('div', { class: 'meter lvl-' + st.level + (stale || passed ? ' lvl-stale' : ''), title },
|
|
848
939
|
h('div', { class: 'm-top' },
|
|
849
|
-
h('span', { class: 'm-label' }, w
|
|
850
|
-
h('span', { class: 'm-pct' }, shown + '%', h('span', { class: 'm-unit' }, ' ' + t(left ? 'quota.unitLeft' : 'quota.unitUsed'))
|
|
851
|
-
|
|
852
|
-
|
|
940
|
+
h('span', { class: 'm-label' }, winTitle(winKey(w))),
|
|
941
|
+
h('span', { class: 'm-pct' }, shown + '%', h('span', { class: 'm-unit' }, ' ' + t(left ? 'quota.unitLeft' : 'quota.unitUsed')),
|
|
942
|
+
null),
|
|
943
|
+
h('span', { class: 'm-reset' + (passed ? ' m-passed' : '') }, Number.isFinite(Date.parse(w.resetsAt)) || passed ? reset : '?')),
|
|
944
|
+
h('div', { class: 'bar', role: 'meter', 'aria-label': t(left ? 'quota.meterLeft' : 'quota.meterUsed', { label: winTitle(winKey(w)) }), 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(shown), 'aria-valuetext': t(left ? 'quota.pctLeft' : 'quota.pctUsed', { n: shown }) }, h('span', { css: { width: shown + '%' } })))
|
|
853
945
|
}
|
|
854
946
|
// A column whose window this account does not report right now, such as a 5h window that has not started.
|
|
855
947
|
function noMeter(label) {
|
|
@@ -859,6 +951,9 @@ const MAIN = String.raw `
|
|
|
859
951
|
function quotaNote(a, q) {
|
|
860
952
|
const setup = setupFor(a.family)
|
|
861
953
|
// One setup turns quota on for every account of the Family, so the switch lives in the group header only.
|
|
954
|
+
// A check that asks the Host (Grok) and is turned off, or failed (spec §3.26): say why and what to do.
|
|
955
|
+
if (q.reason === 'fetch-failed') return h('div', { class: 'q-note q-off' }, h('span', { class: 'muted' }, t('quota.fetchFailed')), tipRaw(t('quota.fetchFailed'), q.detail || ''))
|
|
956
|
+
if (q.reason === 'not-enabled' && checkOf(a.family)) return h('div', { class: 'q-note q-off' }, h('span', { class: 'muted' }, t('quota.checkOff')), tipRaw(t('quota.checkOff'), q.detail || t('quota.checkOffHint')))
|
|
862
957
|
if (q.reason === 'not-enabled') {
|
|
863
958
|
const host = famTitle(a)
|
|
864
959
|
const body = setup && setup.plan.status === 'ready' && !BOOT.readOnly ? t('tip.quotaOff.body', { host })
|
|
@@ -870,22 +965,25 @@ const MAIN = String.raw `
|
|
|
870
965
|
const text = { 'no-session': 'quota.noSession', 'no-source': 'quota.noSource', unrecognized: 'quota.unrecognized' }[q.reason]
|
|
871
966
|
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 }))
|
|
872
967
|
}
|
|
873
|
-
|
|
874
|
-
function quotaBlock(a, now, cols, readFrom) {
|
|
968
|
+
function quotaBlock(a, now, cols) {
|
|
875
969
|
const q = a.quota
|
|
876
970
|
if (!q) return h('div', { class: 'quota' }, h('div', { class: 'q-note' }, '—'))
|
|
877
971
|
if (q.status !== 'ok') return h('div', { class: 'quota' }, quotaNote(a, q))
|
|
878
972
|
const stale = isStale(q.observedAt, now)
|
|
879
973
|
const wins = q.windows || []
|
|
880
974
|
// A reading borrowed from another Account of the same login says where it came from (spec §3.21).
|
|
881
|
-
const from =
|
|
882
|
-
|
|
883
|
-
|
|
975
|
+
const from = q.from ? t('quota.sharedFrom', { name: refName(q.from), ago: ago(q.observedAt) }) : null
|
|
976
|
+
// Where the numbers came from when the Host was asked (spec §3.26), and why the latest ask failed.
|
|
977
|
+
const via = q.fetched ? t('quota.fetchedFrom', { host: famTitle(a), ago: ago(q.observedAt) }) : null
|
|
978
|
+
const failed = q.fetchError ? t('quota.lastCheckFailed', { why: q.fetchError }) : null
|
|
979
|
+
const title = [from || via || (stale ? null : t('quota.dataFrom', { ago: ago(q.observedAt) })), failed].filter(Boolean).join('\n') || null
|
|
980
|
+
return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title },
|
|
884
981
|
wins.length ? cols.map((c) => {
|
|
885
|
-
const w = wins.find((x) => x
|
|
886
|
-
return w ? meter(w, now, stale, q.observedAt) : noMeter(c)
|
|
982
|
+
const w = wins.find((x) => winKey(x) === c)
|
|
983
|
+
return w ? meter(w, now, stale, q.observedAt) : noMeter(winTitle(c))
|
|
887
984
|
}) : h('div', { class: 'q-note' }, t('quota.noWindows')),
|
|
888
|
-
|
|
985
|
+
// Old numbers keep their colours and say their age once, under the bars.
|
|
986
|
+
stale && wins.length ? h('div', { class: 'q-asof', title: t('quota.asOf', { ago: ago(q.observedAt) }) }, icon('clock'), t('quota.staleShort', { ago: agoShort(now - Date.parse(q.observedAt)) })) : null)
|
|
889
987
|
}
|
|
890
988
|
function usageBlock(a) {
|
|
891
989
|
const u = a.usage
|
|
@@ -921,16 +1019,23 @@ const MAIN = String.raw `
|
|
|
921
1019
|
}
|
|
922
1020
|
// A dot after the name when the account needs a look that no tag shows: a fuller quota window or a Health
|
|
923
1021
|
// finding. Its reason is on hover; the details say more.
|
|
924
|
-
// Inside a login group the quota warning is the
|
|
1022
|
+
// Inside a login group the quota warning is the first row's, so another row's dot is only about the row itself.
|
|
925
1023
|
function attentionDot(a) {
|
|
926
|
-
const
|
|
1024
|
+
const lead = loginLead(a)
|
|
1025
|
+
const subject = !lead ? a
|
|
1026
|
+
: lead !== a.ref ? Object.assign({}, a, { quota: undefined })
|
|
1027
|
+
: Object.assign({}, a, { quota: a.quota && Object.assign({}, a.quota, { from: undefined }) })
|
|
927
1028
|
const r = reasonsOf(subject, Date.now())
|
|
928
1029
|
const lvl = r.includes('health-fail') ? 'fail' : r.includes('quota-high') || r.includes('health-warn') ? 'warn' : ''
|
|
929
1030
|
if (!lvl) return null
|
|
930
1031
|
const why = whyOf(subject, Date.now())
|
|
931
|
-
|
|
1032
|
+
// A word, not only a colour (spec §3.8): what kind of look the account needs; the reason is on hover.
|
|
1033
|
+
const word = r.includes('health-fail') ? t('att.fix') : r.includes('quota-high') ? t('att.quota') : t('att.check')
|
|
1034
|
+
return h('span', { class: 'tag tag-' + lvl + ' att-tag', title: why, 'aria-label': why }, h('span', { class: 'dot dot-' + lvl, 'aria-hidden': 'true' }), word)
|
|
932
1035
|
}
|
|
933
|
-
|
|
1036
|
+
// members: the directories of a Same Login shown as this one row (spec §3.21).
|
|
1037
|
+
function nameLine(a, members) {
|
|
1038
|
+
const next = members ? members.some(isNext) : isNext(a)
|
|
934
1039
|
return h('div', { class: 'ident-name' },
|
|
935
1040
|
h('span', { class: 'name', title: a.name }, a.name),
|
|
936
1041
|
attentionDot(a),
|
|
@@ -938,15 +1043,18 @@ const MAIN = String.raw `
|
|
|
938
1043
|
const args = aliasArgsOf(a, x)
|
|
939
1044
|
return h('span', { class: 'tag tag-alias', title: args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias') }, x)
|
|
940
1045
|
}),
|
|
1046
|
+
members ? h('span', { class: 'tag tag-login', title: t('login.explain') }, t('login.dirs', { n: members.length })) : null,
|
|
941
1047
|
a.kind === 'api' ? h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api')) : null,
|
|
942
|
-
|
|
943
|
-
?
|
|
1048
|
+
next
|
|
1049
|
+
? nextTag(a)
|
|
944
1050
|
: null,
|
|
945
1051
|
a.login === 'logged-out' && a.kind !== 'api'
|
|
946
1052
|
? 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'))
|
|
947
1053
|
: null,
|
|
948
1054
|
a.hostInstalled === false ? h('span', { class: 'tag tag-warn' }, t(family(a.family).brokenBin ? 'acct.broken' : 'acct.notInstalled')) : null)
|
|
949
1055
|
}
|
|
1056
|
+
// An organization name as the vendor wrote it, without a trailing comma or full stop it sometimes carries.
|
|
1057
|
+
function orgText(org) { return org ? String(org).replace(/[\s,,;;、。]+$/u, '') : '' }
|
|
950
1058
|
// The name part of a ref (claude:001 gives 001), as rows show other Accounts of the same Family.
|
|
951
1059
|
function refName(ref) { return String(ref).slice(String(ref).indexOf(':') + 1) }
|
|
952
1060
|
// Who the account is signed in as, under its name; masked while "Hide emails" is on.
|
|
@@ -954,7 +1062,7 @@ const MAIN = String.raw `
|
|
|
954
1062
|
const id = a.identity
|
|
955
1063
|
if (!id || (!id.email && !browserButton(a))) return null
|
|
956
1064
|
const email = id.email ? emailText(id.email) : ''
|
|
957
|
-
return h('div', { class: 'ident-email', title: [email, id.org].filter(Boolean).join(' · ') },
|
|
1065
|
+
return h('div', { class: 'ident-email', title: [email, orgText(id.org)].filter(Boolean).join(' · ') },
|
|
958
1066
|
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)]))
|
|
959
1067
|
}
|
|
960
1068
|
function subLine(a) {
|
|
@@ -1101,10 +1209,11 @@ const MAIN = String.raw `
|
|
|
1101
1209
|
}
|
|
1102
1210
|
// What a row or card shows depends on more than the account (health, view preferences, language); the
|
|
1103
1211
|
// fingerprint covers what its click handlers use.
|
|
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),
|
|
1105
|
-
// Whether the row sits in a login group (spec §3.21):
|
|
1212
|
+
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), loginLead(a), isPinned(a)]) }
|
|
1213
|
+
// Whether the row sits in a login group (spec §3.21): its first row then shows the quota for all of them.
|
|
1106
1214
|
function inLogin(a) { return ui.loginRefs.has(a.ref) }
|
|
1107
|
-
|
|
1215
|
+
// The group's first row, which shows the quota for all of them.
|
|
1216
|
+
function loginLead(a) { return ui.loginRefs.get(a.ref) || null }
|
|
1108
1217
|
// A row shows who the account is, its quota and its last use; a click opens the details under it (model, tokens,
|
|
1109
1218
|
// Health) and closes them again. The choice is kept in this browser's preferences.
|
|
1110
1219
|
function isOpen(a) { return prefs.open[a.ref] === true }
|
|
@@ -1173,11 +1282,13 @@ const MAIN = String.raw `
|
|
|
1173
1282
|
const btn = el.querySelector('.row-toggle')
|
|
1174
1283
|
if (btn) syncAttrs(btn, rowToggle(a))
|
|
1175
1284
|
el.classList.toggle('row-open', open)
|
|
1176
|
-
|
|
1285
|
+
// A Same Login row (ADR-0026) opens onto its directories.
|
|
1286
|
+
const ms = el.classList.contains('row-login') ? loginMembers(S.state.accounts.filter((x) => ui.loginRefs.get(x.ref) === a.ref)) : null
|
|
1287
|
+
el.dataset.sig = ms ? sig(ms.map(rowSig)) : rowSig(a)
|
|
1177
1288
|
let detail = rowDetails(el)[0]
|
|
1178
1289
|
if (open && !detail) {
|
|
1179
|
-
detail = rowDetail(a, Boolean(
|
|
1180
|
-
const more = el.querySelector(':scope > .
|
|
1290
|
+
detail = ms ? loginDetail(ms) : rowDetail(a, Boolean(emailLine(a)))
|
|
1291
|
+
const more = el.querySelector(':scope > .row-end')
|
|
1181
1292
|
el.insertBefore(detail, more ? more.nextSibling : null)
|
|
1182
1293
|
detail.style.opacity = '0'
|
|
1183
1294
|
}
|
|
@@ -1215,72 +1326,88 @@ const MAIN = String.raw `
|
|
|
1215
1326
|
ease(now, from, [], parts, 1, () => {})
|
|
1216
1327
|
for (const p of parts) p.style.opacity = ''
|
|
1217
1328
|
}
|
|
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 = ''
|
|
1242
|
-
}
|
|
1243
1329
|
// The Account 'sideby next <family>' would pick; the server ranks it with the CLI's rule (spec §3.13).
|
|
1330
|
+
// "Suggested": a click says what it means and offers the command, instead of copying without a word.
|
|
1331
|
+
function nextTag(a) {
|
|
1332
|
+
const b = h('button', { class: 'tag tag-ok tag-btn', type: 'button', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', title: t('acct.nextWhy', { title: famTitle(a) }) }, t('acct.next'))
|
|
1333
|
+
b.addEventListener('click', (e) => {
|
|
1334
|
+
e.stopPropagation()
|
|
1335
|
+
openPopover(b, 'next', t('acct.next'), () => [
|
|
1336
|
+
h('p', { class: 'next-why' }, t('acct.nextWhy', { title: famTitle(a) })),
|
|
1337
|
+
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { closeMenu(true); copyText('sideby next ' + a.family) } }, icon('copy'), t('acct.nextCopy')),
|
|
1338
|
+
h('code', { class: 'next-cmd' }, 'sideby next ' + a.family),
|
|
1339
|
+
])
|
|
1340
|
+
})
|
|
1341
|
+
return b
|
|
1342
|
+
}
|
|
1244
1343
|
function isNext(a) { return Boolean(S.state && S.state.handoff && S.state.handoff[a.family] === a.ref) }
|
|
1245
1344
|
function row(a, now, cols) {
|
|
1345
|
+
if (ui.arrange) return arrangeRow(a, [a])
|
|
1246
1346
|
const open = isOpen(a)
|
|
1247
|
-
const email =
|
|
1347
|
+
const email = emailLine(a)
|
|
1248
1348
|
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
|
-
|
|
1251
|
-
inLogin(a) ? null : quotaBlock(a, now, cols),
|
|
1349
|
+
h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), email || subLine(a))),
|
|
1350
|
+
quotaBlock(a, now, cols),
|
|
1252
1351
|
lastUsed(a),
|
|
1253
|
-
moreButton(a),
|
|
1352
|
+
h('div', { class: 'row-end' }, runButton(a), pinButton(a, a.name), moreButton(a)),
|
|
1254
1353
|
open ? rowDetail(a, Boolean(email)) : null,
|
|
1255
1354
|
notes(a))
|
|
1256
1355
|
}
|
|
1257
|
-
// Directories signed in as one login (spec §3.21): the vendor counts one quota, so the
|
|
1258
|
-
//
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
const
|
|
1264
|
-
const
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
const
|
|
1270
|
-
const
|
|
1271
|
-
const
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1356
|
+
// Directories signed in as one login (spec §3.21, ADR-0026) are one row: the vendor counts one quota, so the row
|
|
1357
|
+
// shows it once, with the email and the newest use; opening it lists each directory with its own short command,
|
|
1358
|
+
// last use, tokens, Health, a button that copies its start command and its own menu.
|
|
1359
|
+
// The directory that names a Same Login row: the one the user put first (Display Order), else the main one, else
|
|
1360
|
+
// the first by name.
|
|
1361
|
+
function loginMembers(members) {
|
|
1362
|
+
const order = accountOrder(members[0].family) || []
|
|
1363
|
+
const at = (m) => { const i = order.indexOf(m.name); return i < 0 ? Infinity : i }
|
|
1364
|
+
return members.slice().sort((x, y) => at(x) - at(y) || Number(Boolean(y.isMain)) - Number(Boolean(x.isMain)) || x.name.localeCompare(y.name, undefined, { numeric: true }))
|
|
1365
|
+
}
|
|
1366
|
+
// Names the row after another directory: puts it just before the current one in the Display Order.
|
|
1367
|
+
function showLoginAs(ms, m) {
|
|
1368
|
+
const before = displayOf()
|
|
1369
|
+
const next = copyDisplay(before)
|
|
1370
|
+
const names = allNames(m.family).filter((n) => n !== m.name)
|
|
1371
|
+
names.splice(Math.max(0, names.indexOf(ms[0].name)), 0, m.name)
|
|
1372
|
+
next.accounts[m.family] = names
|
|
1373
|
+
saveDisplay(before, next)
|
|
1374
|
+
}
|
|
1375
|
+
function newestOf(members) {
|
|
1376
|
+
const at = (m) => { const v = Date.parse((m.usage && m.usage.status === 'ok' && m.usage.lastActivityAt) || ''); return Number.isFinite(v) ? v : -1 }
|
|
1377
|
+
return members.reduce((x, y) => at(y) > at(x) ? y : x)
|
|
1378
|
+
}
|
|
1379
|
+
function loginRow(members, now, cols) {
|
|
1380
|
+
const ms = loginMembers(members)
|
|
1381
|
+
const a = ms[0]
|
|
1382
|
+
if (ui.arrange) return arrangeRow(a, ms)
|
|
1383
|
+
const open = isOpen(a)
|
|
1384
|
+
const email = emailLine(a)
|
|
1385
|
+
return h('div', { class: 'row row-login fold-hit' + (open ? ' row-open' : '') + (a.error ? ' row-error' : ''), role: 'listitem', 'aria-label': t('login.label', { names: ms.map((m) => m.name).join(', ') }), 'data-ref': a.ref, 'data-key': loginKey(ms.map((m) => m.ref)), 'data-sig': sig(ms.map(rowSig)), onclick: foldClick(a) },
|
|
1386
|
+
h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a, ms), email || subLine(a))),
|
|
1387
|
+
quotaBlock(a, now, cols),
|
|
1388
|
+
lastUsed(newestOf(ms)),
|
|
1389
|
+
h('div', { class: 'row-end' }, runButton(a), pinButton(a, ms.map((m) => m.name).join(', ')), loginMoreButton(ms)),
|
|
1390
|
+
open ? loginDetail(ms) : null,
|
|
1391
|
+
notes(a))
|
|
1392
|
+
}
|
|
1393
|
+
function loginDetail(ms) {
|
|
1394
|
+
return h('div', { class: 'row-detail login-detail' },
|
|
1395
|
+
h('p', { class: 'rd-note' }, t('login.explain')),
|
|
1396
|
+
h('div', { class: 'login-dirs', role: 'list' }, ms.map((m) => {
|
|
1397
|
+
const run = 'sideby run ' + m.ref
|
|
1398
|
+
return h('div', { class: 'login-dir', role: 'listitem', 'data-ref': m.ref },
|
|
1399
|
+
h('div', { class: 'ld-name' },
|
|
1400
|
+
h('span', { class: 'name' }, m.name),
|
|
1401
|
+
m.ref !== ms[0].ref ? attentionDot(m) : null,
|
|
1402
|
+
(m.aliases || []).slice(0, 3).map((x) => h('span', { class: 'tag tag-alias' }, x))),
|
|
1403
|
+
m.ref === ms[0].ref ? h('span', { class: 'tag ld-shown', title: t('login.shownHint') }, t('login.shown'))
|
|
1404
|
+
: BOOT.readOnly ? h('span') : h('button', { class: 'link-btn small', type: 'button', title: t('login.showAsHint', { name: m.name }), onclick: () => showLoginAs(ms, m) }, t('login.showAs')),
|
|
1405
|
+
lastUsed(m),
|
|
1406
|
+
usageBlock(m),
|
|
1407
|
+
healthChip(m),
|
|
1408
|
+
h('button', { class: 'btn btn-sm', type: 'button', title: run, onclick: () => copyText(run) }, icon('terminal'), t('login.copyRun')),
|
|
1409
|
+
moreButton(m))
|
|
1410
|
+
})))
|
|
1284
1411
|
}
|
|
1285
1412
|
// What a click on a row opens: how it runs (ref, model and effort, a shared login), its tokens and its Health.
|
|
1286
1413
|
function sameLoginNote(a) {
|
|
@@ -1301,7 +1428,7 @@ const MAIN = String.raw `
|
|
|
1301
1428
|
h('header', { class: 'card-head' },
|
|
1302
1429
|
badge(a.family, 'sm'),
|
|
1303
1430
|
h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a), sameLoginNote(a)),
|
|
1304
|
-
moreButton(a)),
|
|
1431
|
+
h('div', { class: 'row-end' }, pinButton(a, a.name), moreButton(a))),
|
|
1305
1432
|
notes(a),
|
|
1306
1433
|
quotaBlock(a, now, cols),
|
|
1307
1434
|
h('footer', { class: 'card-foot' }, usageBlock(a), h('div', { class: 'card-side' }, lastUsed(a), healthChip(a))))
|
|
@@ -1313,7 +1440,7 @@ const MAIN = String.raw `
|
|
|
1313
1440
|
const titled = list.some((a) => a.quota && a.quota.status === 'ok')
|
|
1314
1441
|
return h('div', { class: 'col-head', 'aria-hidden': 'true', 'data-key': 'cols' },
|
|
1315
1442
|
h('span', { class: 'ch-id' }, t('col.account')),
|
|
1316
|
-
h('span', { class: 'quota' }, titled ? cols.map((c) => h('span', null, t(left ? 'col.left' : 'col.used', { label: c }))) : null),
|
|
1443
|
+
h('span', { class: 'quota' }, titled ? cols.map((c) => h('span', null, t(left ? 'col.left' : 'col.used', { label: winTitle(c) }))) : null),
|
|
1317
1444
|
h('span', { class: 'ch-last' }, t('col.last')),
|
|
1318
1445
|
h('span', { class: 'ch-more' }))
|
|
1319
1446
|
}
|
|
@@ -1414,30 +1541,43 @@ const MAIN = String.raw `
|
|
|
1414
1541
|
}
|
|
1415
1542
|
function appGroup(list) {
|
|
1416
1543
|
const app = list[0].app
|
|
1417
|
-
const
|
|
1544
|
+
const id = appId(list[0].ref)
|
|
1545
|
+
ui.order.units[id] = list.map((p) => [p.name])
|
|
1546
|
+
const collapsed = prefs.collapsed['app:' + app] === true && !ui.arrange
|
|
1418
1547
|
const bodyId = 'grp-app-' + app.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
1419
|
-
const head = h('div', { class: 'group-head', 'data-key': 'head' },
|
|
1420
|
-
|
|
1548
|
+
const head = h('div', { class: 'group-head', 'data-key': ui.arrange ? 'head-arrange' : 'head' },
|
|
1549
|
+
dragHandle('family', id, id, app),
|
|
1550
|
+
h('button', { class: 'group-toggle', type: 'button', disabled: ui.arrange ? true : undefined, '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) },
|
|
1421
1551
|
icon('chevron', 'chev'), appBadge(), h('span', { class: 'group-title' }, app),
|
|
1422
1552
|
h('span', { class: 'group-count' }, String(list.length))),
|
|
1423
1553
|
h('div', { class: 'group-meta' },
|
|
1424
1554
|
h('span', { class: 'muted small label-tip' }, t('app.apart'), tipRaw(app, t('app.apartTip', { app })))),
|
|
1425
1555
|
list[0].installed ? null : h('div', { class: 'group-meta' }, h('span', { class: 'tag tag-warn' }, t('acct.notInstalled'))))
|
|
1426
|
-
const body = collapsed ? null : h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, list.map(appRow))
|
|
1427
|
-
return h('section', { class: 'group group-list' + (collapsed ? ' is-collapsed' : ''), 'aria-label': app, 'data-key': 'app:' + app, 'data-patch': '' },
|
|
1428
|
-
head, body, collapsed ? null : appFoot(list))
|
|
1556
|
+
const body = collapsed ? null : h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': ui.arrange ? 'rows-arrange' : 'rows', 'data-patch': '' }, list.map((p) => ui.arrange ? appArrangeRow(p, id) : appRow(p)))
|
|
1557
|
+
return h('section', { class: 'group group-list' + (collapsed ? ' is-collapsed' : '') + (ui.arrange ? ' is-arranging' : ''), 'aria-label': app, 'data-key': 'app:' + app, 'data-fam': id, 'data-patch': '' },
|
|
1558
|
+
head, body, collapsed || ui.arrange ? null : appFoot(list))
|
|
1559
|
+
}
|
|
1560
|
+
// A desktop app window in the arrange mode: grip, mark, name, up and down.
|
|
1561
|
+
function appArrangeRow(p, id) {
|
|
1562
|
+
const units = ui.order.units[id] || []
|
|
1563
|
+
const at = units.findIndex((u) => u[0] === p.name)
|
|
1564
|
+
const name = p.isMain ? t('app.mainName') : p.name
|
|
1565
|
+
return h('div', { class: 'row row-arrange', role: 'listitem', 'data-ref': p.ref, 'data-key': 'arr:' + p.ref, 'data-sig': sig([p.ref, at, units.length]) },
|
|
1566
|
+
h('div', { class: 'ident' }, dragHandle('account', id, p.name, name), appBadge(), h('div', { class: 'ident-text' }, h('div', { class: 'ident-name' }, h('span', { class: 'name' }, name), h('span', { class: 'muted small mono' }, p.ref)))),
|
|
1567
|
+
h('div', { class: 'arrange-tools' },
|
|
1568
|
+
h('button', { class: 'icon-btn', type: 'button', 'aria-label': t('order.up', { name }), title: t('order.up', { name }), disabled: at <= 0 ? true : undefined, onclick: () => moveUnit('account', id, at, at - 1, { focus: p.name, label: name, button: 'up' }) }, icon('arrowUp')),
|
|
1569
|
+
h('button', { class: 'icon-btn', type: 'button', 'aria-label': t('order.down', { name }), title: t('order.down', { name }), disabled: at >= units.length - 1 ? true : undefined, onclick: () => moveUnit('account', id, at, at + 1, { focus: p.name, label: name, button: 'down' }) }, icon('arrowDown'))))
|
|
1429
1570
|
}
|
|
1430
1571
|
// Shown with the account groups unless a family filter or the attention filter is on; the search matches names.
|
|
1431
|
-
function appGroups() {
|
|
1432
|
-
|
|
1572
|
+
function appGroups() { return appIds().map(appGroupFor).filter(Boolean) }
|
|
1573
|
+
// A desktop app's group, its windows in the Display Order (spec §3.25); none under a family or attention filter.
|
|
1574
|
+
function appGroupFor(id) {
|
|
1575
|
+
if (prefs.family || ui.attention) return null
|
|
1433
1576
|
const q = ui.query.trim().toLowerCase()
|
|
1434
|
-
const
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
if (list.length) out.push(appGroup(list))
|
|
1439
|
-
}
|
|
1440
|
-
return out
|
|
1577
|
+
const all = inDisplayOrder(appProfiles().filter((p) => appId(p.ref) === id), (p) => p.name, accountOrder(id))
|
|
1578
|
+
const app = all.length ? all[0].app : ''
|
|
1579
|
+
const list = q && !app.toLowerCase().includes(q) ? all.filter((p) => p.ref.toLowerCase().includes(q)) : all
|
|
1580
|
+
return list.length ? appGroup(list) : null
|
|
1441
1581
|
}
|
|
1442
1582
|
async function openApp(ref) {
|
|
1443
1583
|
if (ui.appBusy) return
|
|
@@ -1483,9 +1623,13 @@ const MAIN = String.raw `
|
|
|
1483
1623
|
const setup = setupFor(fam)
|
|
1484
1624
|
const off = list.some((a) => a.quota && a.quota.status === 'unavailable' && a.quota.reason === 'not-enabled')
|
|
1485
1625
|
const bodyId = 'grp-' + fam.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
1486
|
-
|
|
1487
|
-
const
|
|
1488
|
-
|
|
1626
|
+
// The arrange mode shows every group open, as a short list, in either view (spec §3.25).
|
|
1627
|
+
const cards = prefs.view === 'cards' && !ui.arrange
|
|
1628
|
+
const collapsedNow = collapsed && !ui.arrange
|
|
1629
|
+
if (!cards) ui.order.units[fam] = loginClusters(list).map((x) => Array.isArray(x) ? x.map((a) => a.name) : [x.name])
|
|
1630
|
+
const head = h('div', { class: 'group-head', 'data-key': ui.arrange ? 'head-arrange' : 'head' },
|
|
1631
|
+
dragHandle('family', fam, fam, info.title),
|
|
1632
|
+
h('button', { class: 'group-toggle', type: 'button', disabled: ui.arrange ? true : undefined, 'aria-expanded': String(!collapsedNow), '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) },
|
|
1489
1633
|
icon('chevron', 'chev'), badge(fam, 'sm'), h('span', { class: 'group-title' }, info.title),
|
|
1490
1634
|
h('span', { class: 'group-count' }, list.length === total ? String(total) : list.length + ' / ' + total)),
|
|
1491
1635
|
h('div', { class: 'group-meta' },
|
|
@@ -1497,11 +1641,11 @@ const MAIN = String.raw `
|
|
|
1497
1641
|
h('button', { class: 'btn btn-sm', type: 'button', title: setup.summary, 'aria-label': t('quota.turnOnFamilyLabel', { host: info.title }), onclick: () => openSetup(fam) }, t('quota.turnOnFamily')))
|
|
1498
1642
|
: null,
|
|
1499
1643
|
off && setup && setup.plan.status === 'blocked' ? h('span', { class: 'muted small label-tip' }, t('quota.blocked'), tipRaw(t('quota.blocked'), setup.plan.message)) : null))
|
|
1500
|
-
const body =
|
|
1644
|
+
const body = collapsedNow ? null : cards
|
|
1501
1645
|
? h('div', { class: 'grid', id: bodyId, 'data-key': 'cards', 'data-patch': '' }, list.map((a) => card(a, now, cols)))
|
|
1502
|
-
: h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, loginClusters(list).map((x) => Array.isArray(x) ?
|
|
1503
|
-
return h('section', { class: 'group' + (cards ? ' group-cards' : ' group-list') + (
|
|
1504
|
-
head,
|
|
1646
|
+
: h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': ui.arrange ? 'rows-arrange' : 'rows', 'data-patch': '' }, loginClusters(list).map((x) => Array.isArray(x) ? loginRow(x, now, cols) : row(x, now, cols)))
|
|
1647
|
+
return h('section', { class: 'group' + (cards ? ' group-cards' : ' group-list') + (collapsedNow ? ' is-collapsed' : '') + (ui.arrange ? ' is-arranging' : ''), 'aria-label': info.title, 'data-key': 'g:' + fam, 'data-fam': fam, 'data-patch': '' },
|
|
1648
|
+
head, collapsedNow || cards || ui.arrange ? null : colHead(cols, list), body)
|
|
1505
1649
|
}
|
|
1506
1650
|
function emptyState() {
|
|
1507
1651
|
const fams = S.state.families.slice().sort((x, y) => Number(y.installed) - Number(x.installed))
|
|
@@ -1512,7 +1656,8 @@ const MAIN = String.raw `
|
|
|
1512
1656
|
h('p', { class: 'muted' }, t('empty.body')),
|
|
1513
1657
|
fams.length ? h('ul', { class: 'host-list' }, fams.map((f) => h('li', null, badge(f.id, 'sm'), h('span', null, f.title),
|
|
1514
1658
|
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')),
|
|
1515
|
-
BOOT.readOnly ? null : h('button', { class: 'btn', type: 'button', onclick: () => openCreate(null) }, icon('plus'), t('empty.create'))
|
|
1659
|
+
BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', onclick: () => openCreate(null) }, icon('plus'), t('empty.create')),
|
|
1660
|
+
BOOT.readOnly ? null : h('p', { class: 'muted small' }, t('ai.emptyHint'), ' ', h('button', { class: 'link-btn', type: 'button', onclick: openAi }, t('ai.how'))))
|
|
1516
1661
|
}
|
|
1517
1662
|
function clearFilters() {
|
|
1518
1663
|
ui.query = ''
|
|
@@ -1536,6 +1681,276 @@ const MAIN = String.raw `
|
|
|
1536
1681
|
h('p', null, S.loadError.message),
|
|
1537
1682
|
h('button', { class: 'btn', type: 'button', onclick: () => load({ check: !S.report }) }, icon('refresh'), t('btn.retry')))
|
|
1538
1683
|
}
|
|
1684
|
+
// ---------- Display Order and Pinned Accounts (spec §3.25) ----------
|
|
1685
|
+
const PIN_MAX = 3
|
|
1686
|
+
function displayOf() { return (S.state && S.state.display) || {} }
|
|
1687
|
+
// Every Family id in the Display Order, the ones only accounts name (a plugin that failed) last.
|
|
1688
|
+
function familyOrder() {
|
|
1689
|
+
const ids = S.state.families.map((f) => f.id)
|
|
1690
|
+
for (const a of S.state.accounts) if (!ids.includes(a.family)) ids.push(a.family)
|
|
1691
|
+
// Desktop apps with windows of their own (Grok Bot) take a place in the same order, after the tools by default.
|
|
1692
|
+
for (const id of appIds()) if (!ids.includes(id)) ids.push(id)
|
|
1693
|
+
return inDisplayOrder(ids, (x) => x, displayOf().families)
|
|
1694
|
+
}
|
|
1695
|
+
function appIds() { return [...new Set(appProfiles().map((p) => appId(p.ref)))] }
|
|
1696
|
+
function isAppGroup(id) { return appIds().includes(id) && !S.state.families.some((f) => f.id === id) }
|
|
1697
|
+
function accountOrder(fam) { return (displayOf().accounts || {})[fam] }
|
|
1698
|
+
function pinnedOf(fam) { return (displayOf().pinned || {})[fam] || [] }
|
|
1699
|
+
function isPinned(a) { return pinnedIn(a, pinnedOf(a.family)) }
|
|
1700
|
+
// Whether a shown unit (an account, or the directories of one login) is pinned.
|
|
1701
|
+
function unitPinned(fam, unit) {
|
|
1702
|
+
const pins = pinnedOf(fam)
|
|
1703
|
+
return unit.some((n) => { const a = S.state.accounts.find((x) => x.family === fam && x.name === n); return a ? pinnedIn(a, pins) : pins.includes(n) })
|
|
1704
|
+
}
|
|
1705
|
+
// Pinned places taken in a Family: a Same Login pinned together counts once.
|
|
1706
|
+
function pinnedUnits(fam) {
|
|
1707
|
+
const names = pinnedOf(fam)
|
|
1708
|
+
return loginClusters(S.state.accounts.filter((a) => a.family === fam && names.includes(a.name))).length
|
|
1709
|
+
}
|
|
1710
|
+
// Every account name of a Family, hidden ones too, in the Display Order.
|
|
1711
|
+
function allNames(fam) {
|
|
1712
|
+
if (isAppGroup(fam)) return inDisplayOrder(appProfiles().filter((p) => appId(p.ref) === fam).map((p) => p.name), (x) => x, accountOrder(fam))
|
|
1713
|
+
return sortAccounts(S.state.accounts.filter((a) => a.family === fam), 'manual', Date.now(), accountOrder(fam)).map((a) => a.name)
|
|
1714
|
+
}
|
|
1715
|
+
function ordered() { const d = displayOf(); return Boolean((d.families && d.families.length) || (d.accounts && Object.keys(d.accounts).length)) }
|
|
1716
|
+
function copyDisplay(d) {
|
|
1717
|
+
return { families: d.families ? d.families.slice() : [], accounts: Object.assign({}, d.accounts || {}), pinned: Object.assign({}, d.pinned || {}) }
|
|
1718
|
+
}
|
|
1719
|
+
// Shows next at once and writes it to the config. Writes go one at a time; a failed one puts back what the config
|
|
1720
|
+
// last confirmed, never another write's unsaved state.
|
|
1721
|
+
const displaySaves = { chain: Promise.resolve(), pending: 0, confirmed: null }
|
|
1722
|
+
function saveDisplay(before, next, failKey) {
|
|
1723
|
+
if (!displaySaves.pending) displaySaves.confirmed = before
|
|
1724
|
+
displaySaves.pending++
|
|
1725
|
+
S.state.display = next
|
|
1726
|
+
renderAccounts()
|
|
1727
|
+
const run = displaySaves.chain.then(async () => {
|
|
1728
|
+
try {
|
|
1729
|
+
const r = await api('/api/display-order', { display: next })
|
|
1730
|
+
displaySaves.confirmed = r.display
|
|
1731
|
+
if (S.state.display === next) S.state.display = r.display
|
|
1732
|
+
return true
|
|
1733
|
+
} catch (e) {
|
|
1734
|
+
// A newer write already on its way settles the screen; only the latest one puts things back.
|
|
1735
|
+
if (S.state.display === next) { S.state.display = displaySaves.confirmed; renderAccounts() }
|
|
1736
|
+
toast(t(failKey || 'order.failed', { msg: e.message }), { kind: 'fail' })
|
|
1737
|
+
return false
|
|
1738
|
+
} finally { displaySaves.pending-- }
|
|
1739
|
+
})
|
|
1740
|
+
displaySaves.chain = run.catch(() => {})
|
|
1741
|
+
return run
|
|
1742
|
+
}
|
|
1743
|
+
// Pins or unpins an Account, with the other directories of its Same Login. A new pin goes last among the pinned.
|
|
1744
|
+
function togglePin(a) {
|
|
1745
|
+
const before = displayOf()
|
|
1746
|
+
const next = copyDisplay(before)
|
|
1747
|
+
const names = [a.name].concat((a.sameLogin || []).map(refName))
|
|
1748
|
+
// Names of accounts that are gone are dropped on the way (spec §3.25).
|
|
1749
|
+
const list = pinnedOf(a.family).filter((n) => !names.includes(n) && S.state.accounts.some((x) => x.family === a.family && x.name === n))
|
|
1750
|
+
if (!isPinned(a)) {
|
|
1751
|
+
if (pinnedUnits(a.family) >= PIN_MAX) { toast(t('pin.full', { n: PIN_MAX }), { kind: 'fail' }); return }
|
|
1752
|
+
list.push(...names.filter((n) => S.state.accounts.some((x) => x.family === a.family && x.name === n)))
|
|
1753
|
+
}
|
|
1754
|
+
next.pinned[a.family] = list
|
|
1755
|
+
saveDisplay(before, next, 'pin.failed')
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// The arrange mode: the only place to drag (spec §3.25). It starts from the order on screen, saves each move at
|
|
1759
|
+
// once, and "Done" offers to put back the order it started from.
|
|
1760
|
+
function sortAfter(start) { return start.sortBefore === 'manual' ? 'pressure' : start.sortBefore }
|
|
1761
|
+
function startArrange() {
|
|
1762
|
+
if (BOOT.readOnly) return
|
|
1763
|
+
closeMenu()
|
|
1764
|
+
if (!ui.arrange) ui.arrange = { before: copyDisplay(displayOf()), sortBefore: prefs.sort }
|
|
1765
|
+
prefs.sort = 'manual'
|
|
1766
|
+
savePrefs()
|
|
1767
|
+
if (tb.sort) tb.sort.value = 'manual'
|
|
1768
|
+
renderAccounts()
|
|
1769
|
+
const first = document.querySelector('#accounts .drag-handle')
|
|
1770
|
+
if (first) first.focus({ preventScroll: true })
|
|
1771
|
+
}
|
|
1772
|
+
function endArrange() {
|
|
1773
|
+
const start = ui.arrange
|
|
1774
|
+
if (!start) return
|
|
1775
|
+
ui.arrange = null
|
|
1776
|
+
// With no order of one's own left, "my order" is the default order: go back to the usual sort.
|
|
1777
|
+
if (!ordered()) { prefs.sort = sortAfter(start); savePrefs() }
|
|
1778
|
+
if (tb.sort) tb.sort.value = prefs.sort
|
|
1779
|
+
renderAccounts()
|
|
1780
|
+
if (JSON.stringify(copyDisplay(displayOf())) === JSON.stringify(start.before)) return
|
|
1781
|
+
toast(t('order.saved'), { action: { label: t('order.undo'), run: async () => {
|
|
1782
|
+
if (!(await saveDisplay(displayOf(), start.before))) return
|
|
1783
|
+
if (!ordered() && prefs.sort === 'manual') { prefs.sort = sortAfter(start); savePrefs(); if (tb.sort) tb.sort.value = prefs.sort; renderAccounts() }
|
|
1784
|
+
toast(t('order.undone'))
|
|
1785
|
+
} } })
|
|
1786
|
+
}
|
|
1787
|
+
function arrangeBar() {
|
|
1788
|
+
return h('div', { class: 'arrange-bar', role: 'region', 'aria-label': t('sort.arrange'), 'data-key': 'arrange-bar', 'data-sig': sig([ordered(), lang]) },
|
|
1789
|
+
h('span', { class: 'arrange-text' }, icon('grip'), h('span', null, t('order.bar')), h('button', { class: 'link-btn small', type: 'button', onclick: openAi }, t('ai.orderHint'))),
|
|
1790
|
+
h('div', { class: 'arrange-actions' },
|
|
1791
|
+
h('button', { class: 'link-btn', type: 'button', title: t('order.resetHint'), disabled: ordered() ? undefined : true, onclick: resetOrder }, t('order.resetButton')),
|
|
1792
|
+
h('button', { class: 'btn btn-primary btn-sm', type: 'button', onclick: endArrange }, t('order.done'))))
|
|
1793
|
+
}
|
|
1794
|
+
// An account row in the arrange mode: grip, mark, name and short commands, then up, down and pin. Nothing else.
|
|
1795
|
+
function arrangeRow(a, ms) {
|
|
1796
|
+
const fam = a.family
|
|
1797
|
+
const units = ui.order.units[fam] || []
|
|
1798
|
+
const at = units.findIndex((u) => u.includes(a.name))
|
|
1799
|
+
const nPinned = units.filter((u) => unitPinned(fam, u)).length
|
|
1800
|
+
const pinned = isPinned(a)
|
|
1801
|
+
const lo = pinned ? 0 : nPinned
|
|
1802
|
+
const hi = pinned ? nPinned - 1 : units.length - 1
|
|
1803
|
+
const label = ms.map((m) => m.name).join(', ')
|
|
1804
|
+
const full = !pinned && pinnedUnits(fam) >= PIN_MAX
|
|
1805
|
+
const pinLabel = full ? t('pin.full', { n: PIN_MAX }) : t(pinned ? 'pin.unpinName' : 'pin.pinName', { name: label, title: family(fam).title })
|
|
1806
|
+
return h('div', { class: 'row row-arrange' + (pinned ? ' is-pinned' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': 'arr:' + loginKey(ms.map((m) => m.ref)), 'data-sig': sig([ms.map(rowSig), at, lo, hi, full]) },
|
|
1807
|
+
h('div', { class: 'ident' }, dragHandle('account', fam, a.name, label), badge(fam, 'sm'), h('div', { class: 'ident-text' }, h('div', { class: 'ident-name' },
|
|
1808
|
+
h('span', { class: 'name', title: a.name }, a.name),
|
|
1809
|
+
(a.aliases || []).slice(0, 3).map((x) => h('span', { class: 'tag tag-alias' }, x)),
|
|
1810
|
+
ms.length > 1 ? h('span', { class: 'tag tag-login', title: t('login.explain') }, t('login.dirs', { n: ms.length })) : null))),
|
|
1811
|
+
h('div', { class: 'arrange-tools' },
|
|
1812
|
+
h('button', { class: 'icon-btn', type: 'button', 'aria-label': t('order.up', { name: label }), title: t('order.up', { name: label }), disabled: at <= lo ? true : undefined, onclick: () => moveUnit('account', fam, at, at - 1, { focus: a.name, label, button: 'up' }) }, icon('arrowUp')),
|
|
1813
|
+
h('button', { class: 'icon-btn', type: 'button', 'aria-label': t('order.down', { name: label }), title: t('order.down', { name: label }), disabled: at >= hi ? true : undefined, onclick: () => moveUnit('account', fam, at, at + 1, { focus: a.name, label, button: 'down' }) }, icon('arrowDown')),
|
|
1814
|
+
h('button', { class: 'icon-btn pin-btn', type: 'button', 'aria-pressed': String(pinned), 'aria-label': pinLabel, title: pinLabel, 'aria-disabled': full ? 'true' : null, onclick: () => { if (full) toast(t('pin.full', { n: PIN_MAX })); else togglePin(a) } }, icon('pin'))))
|
|
1815
|
+
}
|
|
1816
|
+
// A grip to drag a Family group or an account (a Same Login moves as one) into a new place, or to move it with
|
|
1817
|
+
// the arrow keys. Only in the arrange mode.
|
|
1818
|
+
function dragHandle(kind, fam, key, name) {
|
|
1819
|
+
if (BOOT.readOnly || !ui.arrange) return null
|
|
1820
|
+
const label = t(kind === 'family' ? 'order.moveFamily' : 'order.moveAccount', { name })
|
|
1821
|
+
return h('button', { class: 'icon-btn drag-handle', type: 'button', draggable: 'true', 'data-move': kind, 'data-fam': fam, 'data-name': key, 'aria-label': label, title: t('order.handle'),
|
|
1822
|
+
onclick: (e) => e.stopPropagation(),
|
|
1823
|
+
onkeydown: (e) => {
|
|
1824
|
+
const keys = { ArrowUp: -1, ArrowDown: 1, Home: -Infinity, End: Infinity }
|
|
1825
|
+
if (!Object.hasOwn(keys, e.key)) return
|
|
1826
|
+
e.preventDefault()
|
|
1827
|
+
e.stopPropagation()
|
|
1828
|
+
const units = unitsOf(kind, fam)
|
|
1829
|
+
const from = units.findIndex((u) => u.includes(key))
|
|
1830
|
+
const step = keys[e.key]
|
|
1831
|
+
const to = Math.max(0, Math.min(units.length - 1, Number.isFinite(step) ? from + step : step < 0 ? 0 : units.length - 1))
|
|
1832
|
+
moveUnit(kind, fam, from, to, { focus: key, label: name })
|
|
1833
|
+
},
|
|
1834
|
+
ondragstart: (e) => {
|
|
1835
|
+
const units = unitsOf(kind, fam)
|
|
1836
|
+
ui.drag = { kind, fam, from: units.findIndex((u) => u.includes(key)), to: null, other: null, dropped: false }
|
|
1837
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
1838
|
+
try { e.dataTransfer.setData('text/plain', name) } catch {}
|
|
1839
|
+
const el = unitElement(kind, fam, key)
|
|
1840
|
+
if (el) { e.dataTransfer.setDragImage(el, 16, 16); requestAnimationFrame(() => el.classList.add('is-dragging')) }
|
|
1841
|
+
},
|
|
1842
|
+
ondragend: () => endDrag(),
|
|
1843
|
+
}, icon('grip'))
|
|
1844
|
+
}
|
|
1845
|
+
function unitsOf(kind, fam) { return kind === 'family' ? ui.order.families.map((f) => [f]) : (ui.order.units[fam] || []) }
|
|
1846
|
+
// The element that moves: a Family's group, or an account's row (a Same Login is one row).
|
|
1847
|
+
function unitElement(kind, fam, key) {
|
|
1848
|
+
if (kind === 'family') return document.querySelector('#accounts > section[data-fam="' + CSS.escape(fam) + '"]')
|
|
1849
|
+
return document.querySelector('#accounts .row[data-ref="' + CSS.escape(fam + ':' + key) + '"]')
|
|
1850
|
+
}
|
|
1851
|
+
// Where a drop at the pointer lands: the list of places it can go and the index among them, or null outside.
|
|
1852
|
+
function dropTarget(e) {
|
|
1853
|
+
const d = ui.drag
|
|
1854
|
+
if (!d) return null
|
|
1855
|
+
if (d.kind === 'family') {
|
|
1856
|
+
const els = Array.from(document.querySelectorAll('#accounts > section[data-fam]'))
|
|
1857
|
+
return els.length ? { els, at: slotAt(els, e.clientY) } : null
|
|
1858
|
+
}
|
|
1859
|
+
const sec = e.target.closest && e.target.closest('#accounts > section[data-fam]')
|
|
1860
|
+
// Over another Family: remembered, so letting go there can say why nothing moved.
|
|
1861
|
+
d.other = sec && sec.getAttribute('data-fam') !== d.fam ? sec.getAttribute('data-fam') : null
|
|
1862
|
+
if (!sec || d.other) return null
|
|
1863
|
+
const box = sec.querySelector('.rows')
|
|
1864
|
+
if (!box) return null
|
|
1865
|
+
const els = Array.from(box.children)
|
|
1866
|
+
return { els, at: slotAt(els, e.clientY) }
|
|
1867
|
+
}
|
|
1868
|
+
function slotAt(els, y) {
|
|
1869
|
+
for (let i = 0; i < els.length; i++) {
|
|
1870
|
+
const r = els[i].getBoundingClientRect()
|
|
1871
|
+
if (y < r.top + r.height / 2) return i
|
|
1872
|
+
}
|
|
1873
|
+
return els.length
|
|
1874
|
+
}
|
|
1875
|
+
function clearDropMarks() { for (const el of document.querySelectorAll('.drop-before, .drop-after')) el.classList.remove('drop-before', 'drop-after') }
|
|
1876
|
+
function endDrag() {
|
|
1877
|
+
const d = ui.drag
|
|
1878
|
+
clearDropMarks()
|
|
1879
|
+
for (const el of document.querySelectorAll('.is-dragging')) el.classList.remove('is-dragging')
|
|
1880
|
+
ui.drag = null
|
|
1881
|
+
if (d && d.other && !d.dropped) toast(t('order.sameFamily', { title: family(d.fam).title }))
|
|
1882
|
+
if (S.pending) { S.pending = false; renderAll() }
|
|
1883
|
+
}
|
|
1884
|
+
function onDragOver(e) {
|
|
1885
|
+
const tgt = dropTarget(e)
|
|
1886
|
+
if (!tgt) return
|
|
1887
|
+
e.preventDefault()
|
|
1888
|
+
e.dataTransfer.dropEffect = 'move'
|
|
1889
|
+
clearDropMarks()
|
|
1890
|
+
if (tgt.at < tgt.els.length) tgt.els[tgt.at].classList.add('drop-before')
|
|
1891
|
+
else tgt.els[tgt.els.length - 1].classList.add('drop-after')
|
|
1892
|
+
// Dropped after itself means one place earlier once it is taken out.
|
|
1893
|
+
ui.drag.to = tgt.at > ui.drag.from ? tgt.at - 1 : tgt.at
|
|
1894
|
+
}
|
|
1895
|
+
function onDrop(e) {
|
|
1896
|
+
const d = ui.drag
|
|
1897
|
+
if (!d || !dropTarget(e)) return
|
|
1898
|
+
e.preventDefault()
|
|
1899
|
+
d.dropped = true
|
|
1900
|
+
const to = d.to
|
|
1901
|
+
endDrag()
|
|
1902
|
+
if (to !== null && to !== d.from) moveUnit(d.kind, d.fam, d.from, to)
|
|
1903
|
+
}
|
|
1904
|
+
// Records a move: Families anywhere; pinned accounts only among the pinned (their pin order), the others only
|
|
1905
|
+
// below them (the Display Order). A move past the edge of its part stops at the edge.
|
|
1906
|
+
function moveUnit(kind, fam, from, to, opts) {
|
|
1907
|
+
opts = opts || {}
|
|
1908
|
+
const units = unitsOf(kind, fam)
|
|
1909
|
+
if (from < 0 || to < 0 || from >= units.length || to >= units.length) return
|
|
1910
|
+
const before = displayOf()
|
|
1911
|
+
const next = copyDisplay(before)
|
|
1912
|
+
let place = to
|
|
1913
|
+
let first = 0
|
|
1914
|
+
let total = units.length
|
|
1915
|
+
if (kind === 'family') next.families = reorder(familyOrder(), units, from, to)
|
|
1916
|
+
else {
|
|
1917
|
+
// Names of accounts that are gone are dropped on the way (spec §3.25).
|
|
1918
|
+
const pins = pinnedOf(fam).filter((n) => S.state.accounts.some((x) => x.family === fam && x.name === n))
|
|
1919
|
+
const nPinned = units.filter((u) => unitPinned(fam, u)).length
|
|
1920
|
+
const pinned = from < nPinned
|
|
1921
|
+
place = pinned ? Math.min(to, nPinned - 1) : Math.max(to, nPinned)
|
|
1922
|
+
first = pinned ? 0 : nPinned
|
|
1923
|
+
total = pinned ? nPinned : units.length - nPinned
|
|
1924
|
+
if (place !== from) {
|
|
1925
|
+
if (pinned) next.pinned[fam] = reorder(pins, units.slice(0, nPinned), from, place)
|
|
1926
|
+
else next.accounts[fam] = reorder(allNames(fam), units, from, place)
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
const where = () => { if (opts.label) say(t('order.position', { name: opts.label, n: place - first + 1, total })) }
|
|
1930
|
+
if (place === from) { where(); return }
|
|
1931
|
+
saveDisplay(before, next)
|
|
1932
|
+
if (opts.focus) {
|
|
1933
|
+
const grip = '#accounts .drag-handle[data-move="' + kind + '"][data-fam="' + CSS.escape(fam) + '"][data-name="' + CSS.escape(opts.focus) + '"]'
|
|
1934
|
+
let btn = opts.button ? document.querySelector('#accounts .row[data-ref="' + CSS.escape(fam + ':' + opts.focus) + '"] .arrange-tools button:nth-child(' + (opts.button === 'up' ? 1 : 2) + ')') : null
|
|
1935
|
+
// At the edge the arrow it used is off now: the grip keeps the focus.
|
|
1936
|
+
if (!btn || btn.disabled) btn = document.querySelector(grip)
|
|
1937
|
+
if (btn) btn.focus({ preventScroll: false })
|
|
1938
|
+
where()
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
// Forgets the order of Families and accounts; pins stay.
|
|
1942
|
+
async function resetOrder() {
|
|
1943
|
+
const before = displayOf()
|
|
1944
|
+
const pinned = before.pinned && Object.keys(before.pinned).length ? before.pinned : null
|
|
1945
|
+
if (await saveDisplay(before, pinned ? { pinned } : {})) toast(t('order.reset'))
|
|
1946
|
+
}
|
|
1947
|
+
// Words for screen readers only, such as the new place of something moved with the keyboard.
|
|
1948
|
+
let live = null
|
|
1949
|
+
function say(text) {
|
|
1950
|
+
if (!live) { live = h('div', { class: 'sr-only', 'aria-live': 'polite' }); document.body.append(live) }
|
|
1951
|
+
live.textContent = ''
|
|
1952
|
+
setTimeout(() => { live.textContent = text }, 30)
|
|
1953
|
+
}
|
|
1539
1954
|
function renderAccounts() {
|
|
1540
1955
|
closeMenu()
|
|
1541
1956
|
const box = $('accounts')
|
|
@@ -1556,19 +1971,30 @@ const MAIN = String.raw `
|
|
|
1556
1971
|
: tn('count.accounts', all.length))
|
|
1557
1972
|
const apps = appGroups()
|
|
1558
1973
|
if (!shown.length && !apps.length) { patch(box, noMatch()); return }
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
for (const a of all) if (!ids.includes(a.family)) ids.push(a.family)
|
|
1974
|
+
ui.loginRefs = new Map()
|
|
1975
|
+
if (prefs.view !== 'cards' || ui.arrange)
|
|
1976
|
+
for (const x of loginClusters(shown)) if (Array.isArray(x)) for (const a of x) ui.loginRefs.set(a.ref, loginMembers(x)[0].ref)
|
|
1977
|
+
// Families in the Display Order (spec §3.25) whatever the sort; each group gets the quota columns it uses.
|
|
1978
|
+
// Every group is as wide as the one with the most windows, so the columns after them line up.
|
|
1979
|
+
ui.order = { families: [], units: {} }
|
|
1566
1980
|
const groups = []
|
|
1567
|
-
|
|
1981
|
+
let width = 1
|
|
1982
|
+
for (const fam of familyOrder()) {
|
|
1983
|
+
if (isAppGroup(fam)) {
|
|
1984
|
+
const g = appGroupFor(fam)
|
|
1985
|
+
if (g) { ui.order.families.push(fam); groups.push(g) }
|
|
1986
|
+
continue
|
|
1987
|
+
}
|
|
1568
1988
|
const list = shown.filter((a) => a.family === fam)
|
|
1569
|
-
if (list.length)
|
|
1989
|
+
if (!list.length) continue
|
|
1990
|
+
ui.order.families.push(fam)
|
|
1991
|
+
const sorted = withPinned(sortAccounts(list, prefs.sort, now, accountOrder(fam)), pinnedOf(fam))
|
|
1992
|
+
const cols = quotaColumns(sorted)
|
|
1993
|
+
width = Math.max(width, cols.length)
|
|
1994
|
+
groups.push(group(fam, sorted, all.filter((a) => a.family === fam).length, now, cols))
|
|
1570
1995
|
}
|
|
1571
|
-
|
|
1996
|
+
box.style.setProperty('--qn', String(width))
|
|
1997
|
+
patch(box, (ui.arrange ? [arrangeBar()] : []).concat(groups))
|
|
1572
1998
|
if (ui.fade && !REDUCED) {
|
|
1573
1999
|
box.classList.add('view-in')
|
|
1574
2000
|
setTimeout(() => box.classList.remove('view-in'), 400)
|
|
@@ -1640,7 +2066,17 @@ const MAIN = String.raw `
|
|
|
1640
2066
|
renderCheckup()
|
|
1641
2067
|
$('checkup').scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'start' })
|
|
1642
2068
|
}
|
|
2069
|
+
// The Panel updates an outdated sideby skill itself when the Update plan allows it (spec §3.16, §3.23).
|
|
2070
|
+
function skillFixable() {
|
|
2071
|
+
const u = updState()
|
|
2072
|
+
return !BOOT.readOnly && !!(u && u.method.kind !== 'local' && u.skillUpdate && u.skillUpdate.outdated && u.skillUpdate.canRun)
|
|
2073
|
+
}
|
|
2074
|
+
// A Finding in plain words (its code's sentence); the check's own text goes under "Details".
|
|
1643
2075
|
function findingRow(f) {
|
|
2076
|
+
const own = panelFixes(f, skillFixable())
|
|
2077
|
+
const key = 'finding.' + f.code
|
|
2078
|
+
const plain = Object.hasOwn(MESSAGES.en, key) ? t(key, { item: f.item }) : ''
|
|
2079
|
+
const fixHere = plain && f.fixable && !BOOT.readOnly && !own
|
|
1644
2080
|
return h('li', { class: 'finding lvl-' + f.level },
|
|
1645
2081
|
h('span', { class: 'dot', title: f.level }),
|
|
1646
2082
|
h('div', { class: 'finding-body' },
|
|
@@ -1648,12 +2084,20 @@ const MAIN = String.raw `
|
|
|
1648
2084
|
ui.healthRef ? null : h('button', { class: 'chip chip-btn mono', type: 'button', title: t('health.only', { ref: f.account }), 'aria-label': t('health.only', { ref: f.account }), onclick: () => filterHealth(f.account) }, badge(f.account.split(':')[0], 'xs'), f.account),
|
|
1649
2085
|
h('span', { class: 'muted small mono' }, f.item),
|
|
1650
2086
|
f.fixable ? h('span', { class: 'chip chip-sm chip-accent' }, t('health.autoFix')) : null,
|
|
1651
|
-
f.source && f.source !== 'core' ? h('span', { class: 'muted small' }, t('health.from', { source: f.source })) : null
|
|
1652
|
-
|
|
1653
|
-
|
|
2087
|
+
f.source && f.source !== 'core' ? h('span', { class: 'muted small' }, t('health.from', { source: f.source })) : null,
|
|
2088
|
+
own ? h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: openUpdate }, icon('arrowUp'), t('upd.skillLabel')) : null),
|
|
2089
|
+
h('p', null, plain || f.message),
|
|
2090
|
+
own ? null
|
|
2091
|
+
: fixHere ? h('p', { class: 'hint' }, t('finding.fixHint'))
|
|
2092
|
+
: f.hint && !plain ? h('p', { class: 'hint' }, panelHint(f.hint, f.fixable && !BOOT.readOnly, t('fix.hintAction'))) : null,
|
|
2093
|
+
// The check's own words (and any command to run) stay one click away.
|
|
2094
|
+
plain ? h('details', { class: 'finding-raw' }, h('summary', null, t('finding.raw')), h('p', { class: 'mono small' }, f.message), f.hint ? h('p', { class: 'mono small' }, f.hint) : null) : null))
|
|
1654
2095
|
}
|
|
1655
2096
|
function renderCheckup() {
|
|
1656
2097
|
const box = $('checkup')
|
|
2098
|
+
// With no account there is nothing to check: the empty state is the only thing to do.
|
|
2099
|
+
box.hidden = Boolean(S.state && !S.state.accounts.length)
|
|
2100
|
+
if (box.hidden) return
|
|
1657
2101
|
const all = findingsNow()
|
|
1658
2102
|
const ref = ui.healthRef
|
|
1659
2103
|
const findings = all && ref ? all.filter((f) => f.account === ref) : all
|
|
@@ -1661,7 +2105,11 @@ const MAIN = String.raw `
|
|
|
1661
2105
|
const busy = S.checking || S.fixing
|
|
1662
2106
|
const fixable = all ? all.filter((f) => f.fixable).length : 0
|
|
1663
2107
|
// Say why Fix all is off, unless a running check or fix already explains it.
|
|
1664
|
-
const
|
|
2108
|
+
const skillFix = skillFixable()
|
|
2109
|
+
const forAi = (list) => list.filter((f) => !panelFixes(f, skillFix))
|
|
2110
|
+
const fixWhy = busy || fixable ? null : !all ? t('fix.whyNoCheck') : !all.length ? t('fix.whyNothing')
|
|
2111
|
+
: forAi(all).length ? t('fix.whyManual') : t('fix.whyButton')
|
|
2112
|
+
const prompt = findings && !busy ? fixPrompt(forAi(findings), { lead: t('fix.promptLead'), hint: t('fix.promptHint'), fixFirst: t('fix.promptFixFirst'), check: t('fix.promptCheck') }) : ''
|
|
1665
2113
|
const status = S.checking ? t('health.running') : S.fixing ? t('health.fixing') : at ? t('health.last', { ago: ago(at) }) : t('health.never')
|
|
1666
2114
|
const out = [h('div', { class: 'section-head' },
|
|
1667
2115
|
h('div', null,
|
|
@@ -1670,6 +2118,7 @@ const MAIN = String.raw `
|
|
|
1670
2118
|
tx('health.lead', { shared: h('span', { class: 'label-tip' }, t('health.sharedTerm'), tip('shared')), drift: h('span', { class: 'label-tip' }, t('health.driftTerm'), tip('drift')) }))),
|
|
1671
2119
|
h('div', { class: 'actions' },
|
|
1672
2120
|
h('button', { class: 'btn', type: 'button', disabled: busy || !S.state, 'aria-busy': S.checking ? 'true' : null, title: !S.state && !busy ? t('health.waiting') : null, onclick: runCheck }, S.checking ? spinner() : null, S.checking ? t('health.running') : t('health.run')),
|
|
2121
|
+
prompt ? h('button', { class: 'btn', type: 'button', title: t('fix.copyAiTitle'), onclick: () => copyText(prompt, t('fix.promptShown')) }, icon('copy'), t('fix.copyAi')) : null,
|
|
1673
2122
|
BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', disabled: busy || !fixable, 'aria-busy': S.fixing ? 'true' : null, title: fixWhy, onclick: runFix },
|
|
1674
2123
|
S.fixing ? spinner() : null, S.fixing ? t('health.fixing') : t('fix.all') + (fixable ? ' (' + fixable + ')' : ''))))]
|
|
1675
2124
|
if (ref) out.push(h('div', { class: 'filter-bar' },
|
|
@@ -1864,10 +2313,12 @@ const MAIN = String.raw `
|
|
|
1864
2313
|
if (g.outOfRange) rows.push(h('p', { class: 'note note-warn' }, t('tune.outOfRange', { size: tokens(g.current) })))
|
|
1865
2314
|
if (!BOOT.readOnly) {
|
|
1866
2315
|
if (pick && !asking) rows.push(h('div', { class: 'row-actions' }, h('button', { class: 'btn btn-primary', type: 'button', onclick: () => useAsk(g, pick.threshold) }, t('tune.use', { size: tokens(pick.threshold) }))))
|
|
1867
|
-
|
|
2316
|
+
// With a suggestion there is one main button; choosing a size by hand waits behind a click.
|
|
2317
|
+
const custom = h('div', { class: 'tune-setting' },
|
|
1868
2318
|
h('b', { class: 'small' }, t('tune.setting')),
|
|
1869
2319
|
h('div', { class: 'row-actions' }, sel,
|
|
1870
|
-
h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(busy) || value === g.current, onclick: () => useAsk(g, value) }, t('tune.useThis'))))
|
|
2320
|
+
h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(busy) || value === g.current, onclick: () => useAsk(g, value) }, t('tune.useThis'))))
|
|
2321
|
+
rows.push(pick ? h('details', { class: 'tune-custom', open: value !== (pick ? pick.threshold : null) && value !== g.current ? true : undefined }, h('summary', null, t('tune.custom')), custom) : custom)
|
|
1871
2322
|
rows.push(useBox(g, false))
|
|
1872
2323
|
if (g.undo && !(mine && (useUi.step === 'done' || useUi.step === 'undone')))
|
|
1873
2324
|
rows.push(h('p', { class: 'small tune-undo' }, t('tune.applied', { prev: tuneSize(g.undo.previous) }), ' ',
|
|
@@ -1962,7 +2413,9 @@ const MAIN = String.raw `
|
|
|
1962
2413
|
setKids(nav, h('div', { class: 'seg', role: 'presentation' }, tabs.map(([id, label]) =>
|
|
1963
2414
|
h('button', { class: 'seg-btn', type: 'button', role: 'tab', 'aria-selected': curPage === id ? 'true' : 'false', onclick: () => setPage(id) }, label))))
|
|
1964
2415
|
const away = curPage !== 'accounts' && tabs.some(([id]) => id === curPage)
|
|
1965
|
-
|
|
2416
|
+
// With no account yet the empty state is the whole page: no summary, no health check.
|
|
2417
|
+
const none = Boolean(S.state && !S.state.accounts.length)
|
|
2418
|
+
for (const id of ['summary', 'accounts-page', 'checkup']) $(id).hidden = away || (none && id !== 'accounts-page')
|
|
1966
2419
|
const onTune = away && curPage === 'tuning'
|
|
1967
2420
|
const onHandoff = away && curPage === 'handoff'
|
|
1968
2421
|
$('tuning-page').hidden = !onTune
|
|
@@ -2107,9 +2560,12 @@ const MAIN = String.raw `
|
|
|
2107
2560
|
h('p', { class: 'muted small' }, t('nf.intro')),
|
|
2108
2561
|
h('fieldset', { class: 'field fam-field' }, h('legend', null, t('nf.tool')), nf.fams.length ? h('div', { class: 'fam-grid' }, cards) : h('p', { class: 'muted small' }, t('empty.noFamilies'))),
|
|
2109
2562
|
h('div', { class: 'field' }, h('label', { class: 'field', for: 'nf-name' }, h('span', null, t('nf.name')), e.name), e.suggest),
|
|
2110
|
-
|
|
2111
|
-
h('
|
|
2112
|
-
|
|
2563
|
+
// The first screen asks only for the tool and a name; the rest waits behind "More options".
|
|
2564
|
+
h('details', { class: 'more-opts', open: nf.api || Boolean(nf.alias) || Boolean(nf.error) || undefined },
|
|
2565
|
+
h('summary', null, t('nf.more')),
|
|
2566
|
+
h('div', { class: 'field' }, h('label', { class: 'field', for: 'nf-alias' }, h('span', null, t('nf.alias'), ' ', h('span', { class: 'muted small' }, t('nf.optional'))), e.alias), e.aliasHint, e.aliasErr),
|
|
2567
|
+
h('div', { class: 'check-row' }, h('label', { class: 'check' }, e.api, h('span', null, h('b', null, t('nf.api')), h('span', { class: 'muted small' }, t('nf.apiHint')))), tip('api')),
|
|
2568
|
+
e.preview),
|
|
2113
2569
|
e.error,
|
|
2114
2570
|
e.errNote = nf.error ? errorNote(nf.error, create, nf.error.code === 'create-refused' && !nf.api ? t('nf.refusedApi') : null) : null)
|
|
2115
2571
|
setKids(el, dialogFrame(t('nf.title'), null, body, [
|
|
@@ -2842,6 +3298,29 @@ const MAIN = String.raw `
|
|
|
2842
3298
|
const api = S.state.accounts.filter((a) => a.kind === 'api').map((a) => a.ref)
|
|
2843
3299
|
return remoteControlSummary(S.state.remoteControl || [], api)
|
|
2844
3300
|
}
|
|
3301
|
+
// The refresh button reads everything again; Families that ask their Host for Quota (Grok, spec §3.26) are asked
|
|
3302
|
+
// again first, so the numbers are new and not the kept ones.
|
|
3303
|
+
function asking() { return !BOOT.readOnly && S.state && (S.state.quotaChecks || []).some((c) => c.on && !c.blocked) }
|
|
3304
|
+
async function refreshAll() {
|
|
3305
|
+
if (asking()) {
|
|
3306
|
+
const btn = $('refresh')
|
|
3307
|
+
btn.disabled = true
|
|
3308
|
+
btn.classList.add('is-spinning')
|
|
3309
|
+
try { await api('/api/quota/refresh', {}) } catch (e) { toast(t('quota.refreshFailed', { msg: e.message }), { kind: 'fail' }) }
|
|
3310
|
+
btn.classList.remove('is-spinning')
|
|
3311
|
+
btn.disabled = false
|
|
3312
|
+
}
|
|
3313
|
+
load({ check: true, announce: true })
|
|
3314
|
+
}
|
|
3315
|
+
function checkOf(fam) { return S.state && (S.state.quotaChecks || []).find((c) => c.family === fam) }
|
|
3316
|
+
async function setCheck(fam, on) {
|
|
3317
|
+
try {
|
|
3318
|
+
await api('/api/quota/check', { family: fam, on })
|
|
3319
|
+
toast(t(on ? 'quota.checkOnToast' : 'quota.checkOffToast', { host: famTitleOf(fam) }))
|
|
3320
|
+
} catch (e) { toast(t('quota.checkFailed', { msg: e.message }), { kind: 'fail' }) }
|
|
3321
|
+
load({ silent: true })
|
|
3322
|
+
}
|
|
3323
|
+
function famTitleOf(fam) { const f = S.state && S.state.families.find((x) => x.id === fam); return f ? f.title : fam }
|
|
2845
3324
|
// ---------- settings ----------
|
|
2846
3325
|
// One header button holds what is set once and rarely changed: language, theme, Remote Control and Auto Handoff.
|
|
2847
3326
|
// A dot on it says Remote Control starts by itself, so that state stays visible after its banner is gone.
|
|
@@ -2849,8 +3328,11 @@ const MAIN = String.raw `
|
|
|
2849
3328
|
const out = []
|
|
2850
3329
|
if (!LANG_FIXED) out.push('lang')
|
|
2851
3330
|
if (BOOT.colorScheme === 'auto') out.push('theme')
|
|
3331
|
+
if (S.state && S.state.accounts.length && !BOOT.readOnly) out.push('order')
|
|
3332
|
+
out.push('ai')
|
|
2852
3333
|
if (S.state && rcSummary()) out.push('rc')
|
|
2853
3334
|
if (S.state && ahState()) out.push('ah')
|
|
3335
|
+
if (S.state && (S.state.quotaChecks || []).length) out.push('checks')
|
|
2854
3336
|
return out
|
|
2855
3337
|
}
|
|
2856
3338
|
function renderSettingsButton() {
|
|
@@ -2873,7 +3355,19 @@ const MAIN = String.raw `
|
|
|
2873
3355
|
h('span', { class: 'menu-label' }, label), h('span', { class: 'chip chip-sm ' + cls }, state), icon('chevron'))
|
|
2874
3356
|
if (secs.includes('lang')) out.push(seg(t('set.lang'), [['en', 'English'], ['zh', '中文']], lang, setLang))
|
|
2875
3357
|
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' }))
|
|
3358
|
+
if (out.length && (secs.includes('order') || secs.includes('rc') || secs.includes('ah'))) out.push(h('div', { class: 'menu-sep', role: 'separator' }))
|
|
3359
|
+
// Everything here can also be asked of an AI assistant through the sideby skill (spec §3.8).
|
|
3360
|
+
if (secs.includes('ai')) out.push(link(t('ai.title'), t(skillInstalled() ? 'ai.installed' : 'ai.notInstalled'), skillInstalled() ? 'chip-ok' : '', openAi))
|
|
3361
|
+
// The arrange mode (spec §3.25) has a place here too, where people look for "how do I change this".
|
|
3362
|
+
if (secs.includes('order')) {
|
|
3363
|
+
const pins = Object.values(displayOf().pinned || {}).some((x) => x.length)
|
|
3364
|
+
out.push(link(t('set.order'), t(ordered() || pins ? 'set.orderMine' : 'set.orderDefault'), '', () => {
|
|
3365
|
+
setPage('accounts')
|
|
3366
|
+
startArrange()
|
|
3367
|
+
const bar = document.querySelector('#accounts .arrange-bar')
|
|
3368
|
+
if (bar) bar.scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'center' })
|
|
3369
|
+
}))
|
|
3370
|
+
}
|
|
2877
3371
|
if (secs.includes('rc')) {
|
|
2878
3372
|
const sum = rcSummary()
|
|
2879
3373
|
out.push(link(t('rc.button'), t('rc.state.' + sum), sum === 'on' ? 'chip-warn' : sum === 'off' ? 'chip-ok' : '', openRc))
|
|
@@ -2882,6 +3376,18 @@ const MAIN = String.raw `
|
|
|
2882
3376
|
const on = ahState().settings.auto
|
|
2883
3377
|
out.push(link(t('ah.button'), on ? t('ah.on') : t('ah.off'), on ? 'chip-ok' : '', () => setPage('handoff')))
|
|
2884
3378
|
}
|
|
3379
|
+
// Quota checks that ask the Host (spec §3.26): one switch per Family, with what it does in plain words.
|
|
3380
|
+
if (secs.includes('checks')) {
|
|
3381
|
+
if (out.length) out.push(h('div', { class: 'menu-sep', role: 'separator' }))
|
|
3382
|
+
for (const c of S.state.quotaChecks) {
|
|
3383
|
+
const label = t('set.quotaCheck', { host: c.title })
|
|
3384
|
+
out.push(h('div', { class: 'set-row set-check' },
|
|
3385
|
+
h('div', { class: 'set-text' }, h('span', { class: 'set-label' }, label), h('span', { class: 'set-hint' }, c.blocked ? c.blocked : t('set.quotaCheckHint', { host: c.title, n: c.everyMinutes }))),
|
|
3386
|
+
BOOT.readOnly ? h('span', { class: 'chip chip-sm' + (c.on ? ' chip-ok' : '') }, c.on ? t('ah.on') : t('ah.off'))
|
|
3387
|
+
: h('div', { class: 'seg', role: 'group', 'aria-label': label }, [[true, t('ah.on')], [false, t('ah.off')]].map(([v, text]) =>
|
|
3388
|
+
h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(v === c.on), onclick: () => { if (v !== c.on) { closeMenu(); setCheck(c.family, v) } } }, text)))))
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
2885
3391
|
return out
|
|
2886
3392
|
}
|
|
2887
3393
|
function setLang(v) {
|
|
@@ -2906,6 +3412,25 @@ const MAIN = String.raw `
|
|
|
2906
3412
|
applyTheme()
|
|
2907
3413
|
refreshPopover('settings')
|
|
2908
3414
|
}
|
|
3415
|
+
// The sideby Agent Skill: what to say to an AI assistant instead of clicking, and how to install it when needed.
|
|
3416
|
+
function skillInstalled() {
|
|
3417
|
+
const u = S.state && S.state.update
|
|
3418
|
+
return Boolean(u && u.skillUpdate && u.skillUpdate.installed && u.skillUpdate.installed.length)
|
|
3419
|
+
}
|
|
3420
|
+
const AI_ASKS = ['ai.ask.quota', 'ai.ask.new', 'ai.ask.pin', 'ai.ask.fix', 'ai.ask.handoff', 'ai.ask.rc']
|
|
3421
|
+
function openAi() {
|
|
3422
|
+
showDialog('ai')
|
|
3423
|
+
const asks = AI_ASKS.map((k) => {
|
|
3424
|
+
const text = t(k)
|
|
3425
|
+
return h('li', null, h('button', { class: 'ai-ask', type: 'button', title: t('ai.copy'), onclick: () => copyText(text) }, icon('copy'), h('span', null, text)))
|
|
3426
|
+
})
|
|
3427
|
+
setKids(dlg.el, dialogFrame(t('ai.title'), null, h('div', { class: 'form' },
|
|
3428
|
+
h('p', null, t('ai.intro')),
|
|
3429
|
+
skillInstalled() ? h('p', { class: 'note' }, t('ai.ready')) : h('div', { class: 'field' }, h('p', { class: 'small' }, t('ai.install')), codeCopy(BOOT.skillInstall)),
|
|
3430
|
+
h('p', { class: 'small muted' }, t('ai.examples')),
|
|
3431
|
+
h('ul', { class: 'ai-asks' }, asks)),
|
|
3432
|
+
[h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close'))]))
|
|
3433
|
+
}
|
|
2909
3434
|
function openRc() {
|
|
2910
3435
|
if (!S.state || !rcSummary()) return
|
|
2911
3436
|
rc.target = null
|
|
@@ -3043,6 +3568,17 @@ const MAIN = String.raw `
|
|
|
3043
3568
|
// build and reloads (a background Panel restarts itself, a Portal loads the new code by itself).
|
|
3044
3569
|
let up = null
|
|
3045
3570
|
function updState() { return BOOT.updates && S.state ? S.state.update || null : null }
|
|
3571
|
+
// The skill agents read is part of the version (spec §3.23): behind a current CLI it is the one thing to update.
|
|
3572
|
+
const isLocal = (u) => u.method.kind === 'local'
|
|
3573
|
+
const skillOld = (u) => !!(u && !u.available && !isLocal(u) && u.skillUpdate && u.skillUpdate.outdated)
|
|
3574
|
+
const skillDue = (u) => skillOld(u) && u.skillUpdate.canRun
|
|
3575
|
+
function skillFor(u) {
|
|
3576
|
+
const v = u.skillUpdate.installed.find((x) => x !== u.current)
|
|
3577
|
+
return v ? v : t('upd.skillNoVer')
|
|
3578
|
+
}
|
|
3579
|
+
// Why sideby cannot do something itself, in the viewer's language; the English reason when the code is unknown.
|
|
3580
|
+
const why = (o) => (o && o.reasonCode ? t('why.' + o.reasonCode, { at: o.reasonAt || '' }) : (o && o.reason) || '')
|
|
3581
|
+
const offText = (u) => (u.checksOffCode ? t('off.' + u.checksOffCode, { why: u.checksOff }) : t('upd.checksOff', { why: u.checksOff }))
|
|
3046
3582
|
function renderUpdateButton() {
|
|
3047
3583
|
const b = $('update')
|
|
3048
3584
|
const u = updState()
|
|
@@ -3051,27 +3587,29 @@ const MAIN = String.raw `
|
|
|
3051
3587
|
const v = $('ver-top')
|
|
3052
3588
|
v.hidden = !BOOT.version
|
|
3053
3589
|
if (!v.hidden) {
|
|
3054
|
-
const newer = !!(u && u.available)
|
|
3590
|
+
const newer = !!(u && (u.available || skillDue(u)))
|
|
3055
3591
|
v.textContent = verLabel()
|
|
3056
3592
|
v.classList.toggle('has-update', newer)
|
|
3057
3593
|
v.disabled = !BOOT.updates
|
|
3058
|
-
const tip = !BOOT.updates ? '' :
|
|
3594
|
+
const tip = !BOOT.updates ? '' : u && u.available ? t('upd.labelLong', { v: u.latest, cur: u.current })
|
|
3595
|
+
: skillDue(u) ? t('upd.skillLabelLong', { s: skillFor(u), cur: u.current }) : t('upd.verTitle', { v: BOOT.version })
|
|
3059
3596
|
v.title = tip
|
|
3060
3597
|
if (tip) v.setAttribute('aria-label', tip)
|
|
3061
3598
|
else v.removeAttribute('aria-label')
|
|
3062
3599
|
}
|
|
3063
|
-
b.hidden = !(u && u.available)
|
|
3600
|
+
b.hidden = !(u && (u.available || skillDue(u)))
|
|
3064
3601
|
if (!b.hidden) {
|
|
3065
|
-
|
|
3066
|
-
b
|
|
3067
|
-
b.
|
|
3602
|
+
const long = u.available ? t('upd.labelLong', { v: u.latest, cur: u.current }) : t('upd.skillLabelLong', { s: skillFor(u), cur: u.current })
|
|
3603
|
+
setKids(b, [icon('arrowUp'), h('span', null, u.available ? t('upd.label', { v: u.latest }) : t('upd.skillLabel'))])
|
|
3604
|
+
b.setAttribute('aria-label', long)
|
|
3605
|
+
b.title = long
|
|
3068
3606
|
}
|
|
3069
3607
|
if (dlg.kind === 'update' && up && !up.busy) renderUpdate()
|
|
3070
3608
|
if (!vp.checking) renderVerPop()
|
|
3071
3609
|
}
|
|
3072
3610
|
function openUpdate() {
|
|
3073
3611
|
if (!BOOT.updates) return
|
|
3074
|
-
up = { busy: false, error: null, done: null, changed: false, autoPlan: null, autoBusy: false, waitUntil: 0, checking: false, checked: false }
|
|
3612
|
+
up = { busy: false, error: null, done: null, changed: false, skillDone: false, skillChanged: false, autoPlan: null, autoBusy: false, waitUntil: 0, checking: false, checked: false }
|
|
3075
3613
|
showDialog('update')
|
|
3076
3614
|
renderUpdate()
|
|
3077
3615
|
}
|
|
@@ -3098,6 +3636,27 @@ const MAIN = String.raw `
|
|
|
3098
3636
|
dlg.busy = false
|
|
3099
3637
|
renderUpdate()
|
|
3100
3638
|
}
|
|
3639
|
+
// The CLI is current and the skill is behind: update the skill alone, as sideby update --yes does.
|
|
3640
|
+
async function updSkillRun() {
|
|
3641
|
+
up.busy = true
|
|
3642
|
+
dlg.busy = true
|
|
3643
|
+
up.error = null
|
|
3644
|
+
up.skillChanged = false
|
|
3645
|
+
renderUpdate()
|
|
3646
|
+
try {
|
|
3647
|
+
const r = await api('/api/update', { confirm: true, skill: true })
|
|
3648
|
+
if (r.skillUpdated) {
|
|
3649
|
+
up.skillDone = true
|
|
3650
|
+
toast(t('upd.skillDoneOnly'))
|
|
3651
|
+
// The health check still lists the old skill: check again so its Findings go.
|
|
3652
|
+
if ((findingsNow() || []).some((f) => f.code === 'skill.outdated')) runCheck()
|
|
3653
|
+
} else up.skillChanged = true
|
|
3654
|
+
await load({ silent: true })
|
|
3655
|
+
} catch (e) { up.error = e }
|
|
3656
|
+
up.busy = false
|
|
3657
|
+
dlg.busy = false
|
|
3658
|
+
renderUpdate()
|
|
3659
|
+
}
|
|
3101
3660
|
// The version pill opens a small popover, not the dialog: the version, whether it is the newest, "Check for
|
|
3102
3661
|
// updates" in place, and the way into the dialog when there is something to do (update, Auto Update).
|
|
3103
3662
|
const vp = { checking: false, checked: false, error: null }
|
|
@@ -3121,9 +3680,9 @@ const MAIN = String.raw `
|
|
|
3121
3680
|
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
3681
|
let status
|
|
3123
3682
|
if (!u) status = t('upd.loading')
|
|
3124
|
-
else if (u.available) status = null
|
|
3683
|
+
else if (u.available || skillOld(u)) status = null
|
|
3125
3684
|
else if (vp.checked && u.latest) status = t('upd.latestNow')
|
|
3126
|
-
else if (u.checksOff) status =
|
|
3685
|
+
else if (u.checksOff) status = offText(u)
|
|
3127
3686
|
else if (u.latest) status = t('upd.latest') + (u.checkedAt ? ' · ' + t('upd.checked', { ago: ago(u.checkedAt) }) : '')
|
|
3128
3687
|
else status = t('upd.notYet')
|
|
3129
3688
|
if (status) kids.push(h('p', { class: 'muted small' }, status))
|
|
@@ -3131,12 +3690,15 @@ const MAIN = String.raw `
|
|
|
3131
3690
|
kids.push(h('p', { class: 'small' }, t('upd.available', { v: u.latest }), ' ',
|
|
3132
3691
|
h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
|
|
3133
3692
|
kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: toDialog }, t('upd.open'))))
|
|
3693
|
+
} else if (skillOld(u)) {
|
|
3694
|
+
kids.push(h('p', { class: 'small' }, t('upd.skillBehind', { s: skillFor(u) })))
|
|
3695
|
+
kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm' + (skillDue(u) ? ' btn-primary' : ''), type: 'button', onclick: toDialog }, t('upd.open'))))
|
|
3134
3696
|
} else if (!BOOT.readOnly) {
|
|
3135
3697
|
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
3698
|
vp.checking ? spinner() : icon('refresh'), vp.checking ? t('upd.checking') : t('upd.check'))))
|
|
3137
3699
|
}
|
|
3138
3700
|
if (vp.error) kids.push(errorNote(vp.error, null))
|
|
3139
|
-
if (u && !BOOT.readOnly)
|
|
3701
|
+
if (u && !BOOT.readOnly && !isLocal(u))
|
|
3140
3702
|
kids.push(h('p', { class: 'muted small popover-foot' }, t(u.auto ? 'upd.autoIsOn' : 'upd.autoIsOff'), ' · ',
|
|
3141
3703
|
h('button', { class: 'link-btn', type: 'button', onclick: toDialog }, t('upd.settings'))))
|
|
3142
3704
|
return kids
|
|
@@ -3198,13 +3760,17 @@ const MAIN = String.raw `
|
|
|
3198
3760
|
up.autoBusy = false
|
|
3199
3761
|
renderUpdate()
|
|
3200
3762
|
}
|
|
3201
|
-
// The installed sideby skill follows the CLI (spec §3.23):
|
|
3763
|
+
// The installed sideby skill follows the CLI (spec §3.23): one quiet line when the Update brings it along, the
|
|
3764
|
+
// reason and the command only when sideby may not.
|
|
3202
3765
|
function updSkill(u) {
|
|
3203
3766
|
const s = u.skillUpdate
|
|
3204
|
-
if (!s) return [
|
|
3205
|
-
if (!s.outdated) return []
|
|
3767
|
+
if (!s || !s.outdated) return []
|
|
3206
3768
|
if (s.canRun) return [h('p', { class: 'muted small' }, t('upd.skillToo'))]
|
|
3207
|
-
return [h('p', { class: 'small' }, t('upd.skillManaged', { why: s
|
|
3769
|
+
return [h('p', { class: 'small' }, t('upd.skillManaged', { why: why(s) })), codeCopy(s.command)]
|
|
3770
|
+
}
|
|
3771
|
+
// What a person runs by hand, behind a click: the default path is the button.
|
|
3772
|
+
function updManual(cmds) {
|
|
3773
|
+
return h('details', { class: 'tune-details' }, h('summary', null, t('upd.manual')), cmds)
|
|
3208
3774
|
}
|
|
3209
3775
|
function renderUpdate() {
|
|
3210
3776
|
if (!up || dlg.kind !== 'update') return
|
|
@@ -3221,30 +3787,50 @@ const MAIN = String.raw `
|
|
|
3221
3787
|
body.push(h('p', { class: 'muted small' }, Date.now() > up.waitUntil ? t('upd.restartHint') : [spinner(), ' ', t('upd.reloading')]))
|
|
3222
3788
|
if (up.done.skillUpdated) body.push(h('p', { class: 'small' }, t('upd.skillDone')))
|
|
3223
3789
|
else body.push(...updSkill(u))
|
|
3790
|
+
} else if (up.skillDone && !skillOld(u)) {
|
|
3791
|
+
body.push(h('p', { class: 'note' }, t('upd.skillDoneOnly')))
|
|
3792
|
+
} else if (u.available && isLocal(u)) {
|
|
3793
|
+
// A Local Build (ADR-0024): one way back to npm, which brings the skill along.
|
|
3794
|
+
body.push(h('p', null, t('upd.localAvail', { v: u.latest }), ' ',
|
|
3795
|
+
h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
|
|
3796
|
+
body.push(codeCopy(u.command))
|
|
3224
3797
|
} else if (u.available) {
|
|
3225
3798
|
if (up.changed) body.push(h('p', { class: 'note note-warn' }, t('upd.changed', { v: u.latest })))
|
|
3226
3799
|
body.push(h('p', null, h('b', null, t('upd.available', { v: u.latest })), ' ',
|
|
3227
3800
|
h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
|
|
3228
3801
|
if (u.canRun) {
|
|
3229
|
-
body.push(
|
|
3802
|
+
body.push(...updSkill(u))
|
|
3230
3803
|
body.push(h('p', { class: 'muted small' }, t('upd.after')))
|
|
3804
|
+
body.push(updManual([h('p', { class: 'small' }, t('upd.runs')), codeCopy(u.command)]))
|
|
3231
3805
|
} else {
|
|
3232
|
-
body.push(h('p', { class: 'note note-warn' }, t('upd.cannot', { why: u.method
|
|
3806
|
+
body.push(h('p', { class: 'note note-warn' }, t('upd.cannot', { why: why(u.method) || u.method.kind })))
|
|
3233
3807
|
body.push(codeCopy(u.command))
|
|
3808
|
+
// An install by hand leaves the skill behind; sideby update (or Auto Update) brings it along afterwards.
|
|
3809
|
+
const s = u.skillUpdate
|
|
3810
|
+
if (s && s.outdated && s.canRun) body.push(h('p', { class: 'muted small' }, t('upd.skillLater')))
|
|
3811
|
+
else body.push(...updSkill(u))
|
|
3234
3812
|
}
|
|
3235
|
-
|
|
3813
|
+
} else if (skillOld(u)) {
|
|
3814
|
+
if (up.skillChanged) body.push(h('p', { class: 'note note-warn' }, t('upd.skillChanged')))
|
|
3815
|
+
const s = u.skillUpdate
|
|
3816
|
+
body.push(h('p', null, h('b', null, t('upd.skillBehind', { s: skillFor(u) }))))
|
|
3817
|
+
if (s.canRun) {
|
|
3818
|
+
body.push(h('p', { class: 'muted small' }, t('upd.skillWhat', { cur: u.current })))
|
|
3819
|
+
body.push(updManual([codeCopy(s.command)]))
|
|
3820
|
+
} else body.push(h('p', { class: 'small' }, t('upd.skillManaged', { why: why(s) })), codeCopy(s.command))
|
|
3236
3821
|
} else if (up.checked && u.latest) {
|
|
3237
3822
|
body.push(h('p', { class: 'note' }, t('upd.latestNow')))
|
|
3238
3823
|
} else if (u.checksOff) {
|
|
3239
|
-
body.push(h('p', { class: 'muted small' },
|
|
3824
|
+
body.push(h('p', { class: 'muted small' }, offText(u)))
|
|
3240
3825
|
} else if (u.latest) {
|
|
3241
3826
|
body.push(h('p', { class: 'muted small' }, t('upd.latest') + (u.checkedAt ? ' · ' + t('upd.checked', { ago: ago(u.checkedAt) }) : '')))
|
|
3242
3827
|
} else body.push(h('p', { class: 'muted small' }, t('upd.notYet')))
|
|
3243
3828
|
// A read-only Panel sends no request and writes nothing, so it cannot check (spec §3.23).
|
|
3244
|
-
if (!BOOT.readOnly && !up.done && !u.available)
|
|
3829
|
+
if (!BOOT.readOnly && !up.done && !u.available && !skillDue(u))
|
|
3245
3830
|
body.push(h('p', null, h('button', { class: 'btn btn-sm', type: 'button', disabled: up.checking || up.busy, 'aria-busy': up.checking ? 'true' : null, onclick: updCheck },
|
|
3246
3831
|
up.checking ? spinner() : icon('refresh'), up.checking ? t('upd.checking') : t('upd.check'))))
|
|
3247
|
-
if (!BOOT.readOnly && !up.done) {
|
|
3832
|
+
if (!BOOT.readOnly && !up.done && isLocal(u)) body.push(h('p', { class: 'muted small' }, t('upd.autoLocal')))
|
|
3833
|
+
else if (!BOOT.readOnly && !up.done) {
|
|
3248
3834
|
const box = h('input', { type: 'checkbox', disabled: up.autoBusy || !u.method.canUpdate })
|
|
3249
3835
|
box.checked = up.autoPlan ? up.autoPlan.auto : u.auto
|
|
3250
3836
|
box.addEventListener('change', () => updAuto(box.checked))
|
|
@@ -3256,8 +3842,9 @@ const MAIN = String.raw `
|
|
|
3256
3842
|
if (BOOT.readOnly) body.push(h('p', { class: 'note' }, t('banner.readOnly')))
|
|
3257
3843
|
if (up.error) body.push(errorNote(up.error, null))
|
|
3258
3844
|
const foot = [h('button', { class: 'btn', type: 'button', disabled: up.busy, onclick: () => dlg.el.close() }, t('btn.close'))]
|
|
3259
|
-
|
|
3260
|
-
|
|
3845
|
+
const primary = (label, run) => h('button', { class: 'btn btn-primary', type: 'button', disabled: up.busy, 'aria-busy': up.busy ? 'true' : null, onclick: run }, up.busy ? spinner() : null, up.busy ? t('upd.updating') : label)
|
|
3846
|
+
if (u.available && u.canRun && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.now', { v: u.latest }), updRun))
|
|
3847
|
+
else if (skillDue(u) && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.skillNow'), updSkillRun))
|
|
3261
3848
|
setKids(dlg.el, dialogFrame(t('upd.title'), null, body, foot))
|
|
3262
3849
|
}
|
|
3263
3850
|
|
|
@@ -3267,7 +3854,14 @@ const MAIN = String.raw `
|
|
|
3267
3854
|
$('ver').addEventListener('click', () => openVerPop($('ver')))
|
|
3268
3855
|
$('update').addEventListener('click', openUpdate)
|
|
3269
3856
|
$('ver-top').addEventListener('click', () => openVerPop($('ver-top')))
|
|
3270
|
-
$('refresh').addEventListener('click',
|
|
3857
|
+
$('refresh').addEventListener('click', refreshAll)
|
|
3858
|
+
$('accounts').addEventListener('dragover', onDragOver)
|
|
3859
|
+
$('accounts').addEventListener('drop', onDrop)
|
|
3860
|
+
// Escape leaves the arrange mode, as "Done" does, unless a menu or dialog takes it first.
|
|
3861
|
+
document.addEventListener('keydown', (e) => {
|
|
3862
|
+
if (e.key !== 'Escape' || e.defaultPrevented || !ui.arrange || menu.el || (dlg.el && dlg.el.open) || e.target.closest('input, select, textarea')) return
|
|
3863
|
+
endArrange()
|
|
3864
|
+
})
|
|
3271
3865
|
$('settings').addEventListener('click', () => openPopover($('settings'), 'settings', t('set.button'), settingsPop, 'end'))
|
|
3272
3866
|
applyTheme()
|
|
3273
3867
|
renderStatic()
|