sideby 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +7 -0
- package/dist/src/cli/commands.js +259 -9
- package/dist/src/cli/json-schemas.js +109 -2
- package/dist/src/cli/main.js +30 -6
- package/dist/src/core/config.d.ts +1 -0
- package/dist/src/core/config.js +8 -2
- package/dist/src/core/local-build.d.ts +63 -0
- package/dist/src/core/local-build.js +189 -0
- package/dist/src/core/skill-check.d.ts +14 -0
- package/dist/src/core/skill-check.js +29 -3
- package/dist/src/core/update.d.ts +73 -1
- package/dist/src/core/update.js +155 -23
- package/dist/src/panel/handler.d.ts +12 -1
- package/dist/src/panel/handler.js +84 -5
- package/dist/src/panel/i18n.d.ts +125 -5
- package/dist/src/panel/i18n.js +280 -40
- package/dist/src/panel/page-logic.d.ts +41 -0
- package/dist/src/panel/page-logic.js +46 -0
- package/dist/src/panel/page-script.js +616 -162
- package/dist/src/panel/page-styles.js +74 -23
- package/dist/src/panel/page.js +3 -0
- package/dist/src/runtime.d.ts +2 -0
- 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 +15 -1
- 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/local.json +165 -0
- 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 +1 -1
- 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 +8 -3
- package/skills/sideby/references/handoff.md +8 -0
- package/skills/sideby/references/panel.md +28 -0
- package/skills/sideby/references/quota.md +6 -0
- package/skills/sideby/references/update.md +15 -2
|
@@ -1,12 +1,13 @@
|
|
|
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, inDisplayOrder, loginClusters, loginKey, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelHint, panelLang, passedWindow, quotaPressure, remoteControlSummary, remoteControlTodo, reorder, 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,
|
|
@@ -18,6 +19,7 @@ const LOGIC = [
|
|
|
18
19
|
mergeDaily,
|
|
19
20
|
newAccountDir,
|
|
20
21
|
nextReset,
|
|
22
|
+
panelFixes,
|
|
21
23
|
panelHint,
|
|
22
24
|
panelLang,
|
|
23
25
|
passedWindow,
|
|
@@ -29,7 +31,9 @@ const LOGIC = [
|
|
|
29
31
|
suggestAlias,
|
|
30
32
|
suggestName,
|
|
31
33
|
updatedAge,
|
|
34
|
+
pinnedIn,
|
|
32
35
|
windowState,
|
|
36
|
+
withPinned,
|
|
33
37
|
]
|
|
34
38
|
.map((fn) => fn.toString())
|
|
35
39
|
.join('\n');
|
|
@@ -59,7 +63,7 @@ const MAIN = String.raw `
|
|
|
59
63
|
silent: false, lastTry: 0, pending: false, fastUntil: 0, fastFamily: null,
|
|
60
64
|
}
|
|
61
65
|
// View state that is not saved: the search text, the attention filter, the Health check filter.
|
|
62
|
-
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 }
|
|
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 }
|
|
63
67
|
const $ = (id) => document.getElementById(id)
|
|
64
68
|
|
|
65
69
|
// ---------- preferences ----------
|
|
@@ -268,6 +272,10 @@ const MAIN = String.raw `
|
|
|
268
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',
|
|
269
273
|
x: 'M6 6l12 12M18 6L6 18',
|
|
270
274
|
arrowUp: 'M12 19V5M5 12l7-7 7 7',
|
|
275
|
+
arrowDown: 'M12 5v14M5 12l7 7 7-7',
|
|
276
|
+
swap: 'M7 4 3 8l4 4M3 8h14M17 20l4-4-4-4M21 16H7',
|
|
277
|
+
// Lucide "pin" (ISC license)
|
|
278
|
+
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',
|
|
271
279
|
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',
|
|
272
280
|
grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
|
|
273
281
|
list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
|
|
@@ -496,7 +504,7 @@ const MAIN = String.raw `
|
|
|
496
504
|
try { const u = new URL(url); ok = u.protocol === 'https:' || u.protocol === 'http:' } catch {}
|
|
497
505
|
return ok ? h('a', { class: cls, href: url, target: '_blank', rel: 'noopener noreferrer' }, text) : null
|
|
498
506
|
}
|
|
499
|
-
async function copyText(text) {
|
|
507
|
+
async function copyText(text, shown) {
|
|
500
508
|
let ok = true
|
|
501
509
|
try { await navigator.clipboard.writeText(text) } catch {
|
|
502
510
|
const ta = h('textarea', { css: { position: 'fixed', opacity: '0' } })
|
|
@@ -506,7 +514,7 @@ const MAIN = String.raw `
|
|
|
506
514
|
try { ok = document.execCommand('copy') } catch { ok = false }
|
|
507
515
|
ta.remove()
|
|
508
516
|
}
|
|
509
|
-
if (ok) toast(t('toast.copied', { text: prefs.hideEmail && text.includes('@') ? maskEmail(text) : text }))
|
|
517
|
+
if (ok) toast(t('toast.copied', { text: shown || (prefs.hideEmail && text.includes('@') ? maskEmail(text) : text) }))
|
|
510
518
|
else toast(t('toast.copyFailed'), { kind: 'fail' })
|
|
511
519
|
}
|
|
512
520
|
function codeCopy(text) {
|
|
@@ -565,6 +573,7 @@ const MAIN = String.raw `
|
|
|
565
573
|
function openMenu(anchor, items, label) {
|
|
566
574
|
const el = h('div', { class: 'menu', role: 'menu', 'aria-label': label },
|
|
567
575
|
items.filter(Boolean).map((it) => it === '-' ? h('div', { class: 'menu-sep', role: 'separator' })
|
|
576
|
+
: it.header ? h('div', { class: 'menu-head', role: 'presentation' }, it.header)
|
|
568
577
|
: h('button', { class: 'menu-item', type: 'button', role: 'menuitem', tabindex: '-1', disabled: it.disabled, onclick: () => { closeMenu(true); it.run() } },
|
|
569
578
|
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)))
|
|
570
579
|
el.addEventListener('keydown', menuKeys)
|
|
@@ -612,24 +621,81 @@ const MAIN = String.raw `
|
|
|
612
621
|
window.addEventListener('resize', () => closeMenu())
|
|
613
622
|
window.addEventListener('scroll', () => closeMenu(), { passive: true })
|
|
614
623
|
|
|
615
|
-
|
|
624
|
+
// What one directory does: start it, its short commands, its desktop app, its folder, model, tuning, Health.
|
|
625
|
+
function dirCopies(a) {
|
|
616
626
|
const run = 'sideby run ' + a.ref
|
|
617
|
-
|
|
627
|
+
return [
|
|
618
628
|
{ icon: 'terminal', label: t('menu.copyRun'), hint: run, run: () => copyText(run) },
|
|
619
629
|
...(a.aliases || []).slice(0, 2).map((x) => ({ icon: 'terminal', label: t('menu.copyAlias'), hint: x, run: () => copyText(x) })),
|
|
620
|
-
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,
|
|
621
|
-
browserFor(a) ? { icon: 'terminal', label: t('br.open'), disabled: Boolean(ui.browserRef), run: () => openBrowserDialog(a) } : null,
|
|
622
|
-
a.kind !== 'api' ? { icon: 'key', label: t('menu.copyLogin'), hint: 'sideby login ' + a.ref, run: () => copyText('sideby login ' + a.ref) } : null,
|
|
623
630
|
a.dir ? { icon: 'folder', label: t('menu.copyDir'), hint: a.dir, run: () => copyText(a.dir) } : null,
|
|
624
|
-
|
|
625
|
-
|
|
631
|
+
]
|
|
632
|
+
}
|
|
633
|
+
function deskItem(a) {
|
|
634
|
+
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
|
|
635
|
+
}
|
|
636
|
+
function dirItems(a) { return [...dirCopies(a), deskItem(a)] }
|
|
637
|
+
function dirTools(a) {
|
|
638
|
+
return [
|
|
626
639
|
a.modelInfo && a.modelInfo.settable ? { icon: 'terminal', label: t('menu.model'), run: () => openModel(a) } : null,
|
|
627
640
|
family(a.family).tuning ? { icon: 'check', label: t('menu.tune'), run: () => openTune(a) } : null,
|
|
628
641
|
{ icon: 'check', label: t('menu.check'), disabled: S.checking || S.fixing || Boolean(ui.checkingRef), run: () => checkAccount(a.ref) },
|
|
629
|
-
BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) },
|
|
630
642
|
]
|
|
643
|
+
}
|
|
644
|
+
// What the login does, whichever directory: the browser, signing in, the email, a new account like it.
|
|
645
|
+
function loginItems(a) {
|
|
646
|
+
return [
|
|
647
|
+
browserFor(a) ? { icon: 'globe', label: t('br.open'), disabled: Boolean(ui.browserRef), run: () => openBrowserDialog(a) } : null,
|
|
648
|
+
a.kind !== 'api' ? { icon: 'key', label: t('menu.copyLogin'), hint: 'sideby login ' + a.ref, run: () => copyText('sideby login ' + a.ref) } : null,
|
|
649
|
+
a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
|
|
650
|
+
]
|
|
651
|
+
}
|
|
652
|
+
function likeItem(a) { return BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) } }
|
|
653
|
+
// Grouped by what the user wants to do: open, copy, manage.
|
|
654
|
+
function accountMenu(a, anchor) {
|
|
655
|
+
const [browser, login, email] = loginItems(a)
|
|
656
|
+
const opens = [deskItem(a), browser].filter(Boolean)
|
|
657
|
+
const copies = dirCopies(a)
|
|
658
|
+
const items = [pinItem(a),
|
|
659
|
+
opens.length ? '-' : null, opens.length ? { header: t('menu.groupOpen') } : null, ...opens,
|
|
660
|
+
'-', { header: t('menu.groupCopy') }, ...copies, login, email,
|
|
661
|
+
'-', { header: t('menu.groupManage') }, ...dirTools(a), likeItem(a)]
|
|
631
662
|
openMenu(anchor, items, t('menu.label', { ref: a.ref }))
|
|
632
663
|
}
|
|
664
|
+
// A Same Login row (ADR-0026): pin first, then a section per directory, then what the login shares.
|
|
665
|
+
function loginMenu(ms, anchor) {
|
|
666
|
+
const items = [pinItem(ms[0])]
|
|
667
|
+
for (const m of ms) items.push('-', { header: m.name + ((m.aliases || []).length ? ' · ' + m.aliases[0] : '') }, ...dirItems(m), ...dirTools(m))
|
|
668
|
+
items.push('-', ...loginItems(ms[0]), likeItem(ms[0]))
|
|
669
|
+
openMenu(anchor, items, t('login.label', { names: ms.map((m) => m.name).join(', ') }))
|
|
670
|
+
}
|
|
671
|
+
function loginMoreButton(ms) {
|
|
672
|
+
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'))
|
|
673
|
+
b.addEventListener('click', () => { if (menu.anchor === b) closeMenu(); else loginMenu(ms, b) })
|
|
674
|
+
return b
|
|
675
|
+
}
|
|
676
|
+
// The most used action, on the row itself: copy the command that starts this account (its short command when it
|
|
677
|
+
// has one, which is shorter to type).
|
|
678
|
+
function runButton(a) {
|
|
679
|
+
const cmd = (a.aliases || [])[0] || 'sideby run ' + a.ref
|
|
680
|
+
const text = t('row.copyRun', { cmd })
|
|
681
|
+
return h('button', { class: 'icon-btn row-run', type: 'button', 'aria-label': text, title: text, onclick: (e) => { e.stopPropagation(); copyText(cmd) } }, icon('terminal'))
|
|
682
|
+
}
|
|
683
|
+
// The pin at the end of a row (spec §3.25): one click pins or unpins. A pinned row always shows it, filled; the
|
|
684
|
+
// others show it on hover or focus. A read-only Panel shows the mark of a pinned row and no button.
|
|
685
|
+
function pinButton(a, label) {
|
|
686
|
+
const on = isPinned(a)
|
|
687
|
+
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
|
|
688
|
+
const full = !on && pinnedUnits(a.family) >= PIN_MAX
|
|
689
|
+
const text = full ? t('pin.full', { n: PIN_MAX }) : t(on ? 'pin.unpinName' : 'pin.pinName', { name: label, title: family(a.family).title })
|
|
690
|
+
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,
|
|
691
|
+
onclick: (e) => { e.stopPropagation(); if (full) toast(t('pin.full', { n: PIN_MAX })); else togglePin(a) } }, icon('pin'))
|
|
692
|
+
}
|
|
693
|
+
function pinItem(a) {
|
|
694
|
+
if (BOOT.readOnly) return null
|
|
695
|
+
const on = isPinned(a)
|
|
696
|
+
const full = !on && pinnedUnits(a.family) >= PIN_MAX
|
|
697
|
+
return { icon: 'pin', label: full ? t('pin.full', { n: PIN_MAX }) : t(on ? 'pin.unpin' : 'pin.pin'), disabled: full, run: () => togglePin(a) }
|
|
698
|
+
}
|
|
633
699
|
function moreButton(a) {
|
|
634
700
|
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'))
|
|
635
701
|
b.addEventListener('click', () => { if (menu.anchor === b) closeMenu(); else accountMenu(a, b) })
|
|
@@ -663,7 +729,7 @@ const MAIN = String.raw `
|
|
|
663
729
|
function whyOf(a, now) {
|
|
664
730
|
const m = attentionWhy(a, healthOf(a.ref), now, WARN)
|
|
665
731
|
if (m && m.key === 'why.not-installed' && family(a.family).brokenBin) return t('why.broken')
|
|
666
|
-
return m ? t(m.key, m.vars) : ''
|
|
732
|
+
return m ? t(m.key, m.vars && m.vars.label ? Object.assign({}, m.vars, { label: winTitle(m.vars.label) }) : m.vars) : ''
|
|
667
733
|
}
|
|
668
734
|
function setupFor(fam) { return S.state && S.state.quotaSetups.find((s) => s.family === fam) }
|
|
669
735
|
|
|
@@ -740,7 +806,10 @@ const MAIN = String.raw `
|
|
|
740
806
|
for (const r of rs) counts[r] = (counts[r] || 0) + 1
|
|
741
807
|
}
|
|
742
808
|
const order = ['error', 'health-fail', 'signed-out', 'not-installed', 'quota-high', 'health-warn']
|
|
743
|
-
|
|
809
|
+
// Who needs a look, by name: counting reasons beside a count of accounts made numbers that did not add up.
|
|
810
|
+
const needing = list.filter((a) => reasonsOf(a, now).length)
|
|
811
|
+
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')
|
|
812
|
+
const needTitle = need ? order.filter((r) => counts[r]).map((r) => tn('need.' + r, counts[r])).join(' · ') : null
|
|
744
813
|
const usages = list.filter((a) => a.usage && a.usage.status === 'ok')
|
|
745
814
|
const total = usages.reduce((s, a) => s + (Number(a.usage.totalTokens) || 0), 0)
|
|
746
815
|
const sessions = usages.reduce((s, a) => s + (Number(a.usage.sessions) || 0), 0)
|
|
@@ -749,12 +818,12 @@ const MAIN = String.raw `
|
|
|
749
818
|
patch(box, [
|
|
750
819
|
stat(t('stat.accounts'), String(list.length), dots([tn('stat.families', famCount), apiCount ? tn('stat.api', apiCount) : null])),
|
|
751
820
|
need
|
|
752
|
-
? stat(t('stat.attention'), String(need), needSub,
|
|
821
|
+
? 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() } })
|
|
753
822
|
: stat(t('stat.attention'), '0', needSub, null, { cls: 'stat-ok' }),
|
|
754
823
|
stat(t('stat.tokens', { n: BOOT.usageDays }), tokens(total), tn('stat.sessions', sessions), spark(daily, 'spark-lg')),
|
|
755
824
|
stat(h('span', { class: 'label-tip' }, t('stat.nextReset'), tip('age')),
|
|
756
825
|
nr ? t('time.in', { d: formatDuration(nr.at - now, lang) }) : '—',
|
|
757
|
-
nr ? dots([h('span', { class: 'mono' }, nr.ref), nr.label, clock(new Date(nr.at).toISOString())]) : t('stat.noQuota')),
|
|
826
|
+
nr ? dots([h('span', { class: 'mono' }, nr.ref), winTitle(nr.label), clock(new Date(nr.at).toISOString())]) : t('stat.noQuota')),
|
|
758
827
|
])
|
|
759
828
|
}
|
|
760
829
|
|
|
@@ -780,21 +849,29 @@ const MAIN = String.raw `
|
|
|
780
849
|
})
|
|
781
850
|
tb.family = h('select', { class: 'tb-select', 'aria-label': t('tb.family') })
|
|
782
851
|
tb.family.addEventListener('change', () => { prefs.family = tb.family.value; savePrefs(); renderAccounts() })
|
|
852
|
+
// "Arrange order…" is not a sort: it opens the arrange mode (spec §3.25), and the sort shows "my order".
|
|
783
853
|
tb.sort = h('select', { class: 'tb-select', 'aria-label': t('tb.sort') },
|
|
784
|
-
CHOICES.sort.map((v) => h('option', { value: v }, t('sort.' + v)))
|
|
854
|
+
CHOICES.sort.map((v) => h('option', { value: v }, t('sort.' + v))),
|
|
855
|
+
BOOT.readOnly ? null : h('option', { value: 'arrange' }, t('sort.arrange')))
|
|
785
856
|
tb.sort.value = prefs.sort
|
|
786
|
-
tb.sort.addEventListener('change', () => {
|
|
787
|
-
|
|
857
|
+
tb.sort.addEventListener('change', () => {
|
|
858
|
+
const v = tb.sort.value
|
|
859
|
+
// "My order" before any order of one's own would show nothing new: it opens the arrange mode instead.
|
|
860
|
+
if (v === 'arrange' || (v === 'manual' && !ordered() && !BOOT.readOnly)) { startArrange(); return }
|
|
861
|
+
if (ui.arrange) endArrange()
|
|
862
|
+
prefs.sort = v
|
|
863
|
+
savePrefs()
|
|
864
|
+
renderAccounts()
|
|
865
|
+
})
|
|
788
866
|
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')))
|
|
789
867
|
display.addEventListener('click', () => openPopover(display, 'display', t('tb.display'), displayPanel, 'end'))
|
|
790
868
|
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')))
|
|
791
869
|
setKids($('toolbar'), [
|
|
792
870
|
h('label', { class: 'search' }, icon('search', 'search-ic'), tb.search, h('kbd', { class: 'kbd', 'aria-hidden': 'true' }, '/')),
|
|
793
|
-
h('div', { class: 'tools' }, tb.family, tb.sort,
|
|
871
|
+
h('div', { class: 'tools' }, tb.family, tb.sort, display),
|
|
794
872
|
create,
|
|
795
873
|
])
|
|
796
874
|
renderFamilyFilter()
|
|
797
|
-
renderOrderReset()
|
|
798
875
|
if (hadFocus) tb.search.focus()
|
|
799
876
|
}
|
|
800
877
|
// "Display": the view settings people change rarely, one popover instead of three buttons in the toolbar.
|
|
@@ -806,7 +883,7 @@ const MAIN = String.raw `
|
|
|
806
883
|
h('div', { class: 'dp-row' }, h('span', { class: 'dp-label' }, t('tb.quota')),
|
|
807
884
|
seg(t('tb.quota'), [['used', t('tb.used')], ['left', t('tb.left')]], prefs.quota, (v) => { prefs.quota = v; redo() })),
|
|
808
885
|
h('div', { class: 'dp-row' }, h('span', { class: 'dp-label' }, t('tb.view')),
|
|
809
|
-
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() })),
|
|
886
|
+
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() })),
|
|
810
887
|
emails ? h('label', { class: 'dp-row dp-check' },
|
|
811
888
|
h('input', { type: 'checkbox', checked: prefs.hideEmail, onchange: (e) => { prefs.hideEmail = e.target.checked; redo() } }),
|
|
812
889
|
h('span', null, t('tb.hideEmails'))) : null)
|
|
@@ -838,6 +915,8 @@ const MAIN = String.raw `
|
|
|
838
915
|
const KNOWN_WINDOWS = { '5h': 300, '7d': 10080 }
|
|
839
916
|
// A window's column: the models it covers when it covers only some (Cursor's "Other models"), else its length.
|
|
840
917
|
function winKey(w) { return w.name || w.label }
|
|
918
|
+
// The words for a window in labels (5-hour, weekly) instead of 5h and 7d; other labels as they are.
|
|
919
|
+
function winTitle(k) { return k === '5h' || k === '7d' ? t('win.' + k) : k }
|
|
841
920
|
// One group's columns: the windows its accounts report, shortest first; 5h and 7d when none reports any yet.
|
|
842
921
|
function quotaColumns(list) {
|
|
843
922
|
const mins = {}
|
|
@@ -855,14 +934,15 @@ const MAIN = String.raw `
|
|
|
855
934
|
const left = prefs.quota === 'left'
|
|
856
935
|
const shown = left ? 100 - st.pct : st.pct
|
|
857
936
|
const asOf = stale ? t('quota.asOf', { ago: ago(observedAt) }) : ''
|
|
937
|
+
const reset = passed ? t('quota.resetShort') : t('quota.resetsIn', { d: formatDuration(Date.parse(w.resetsAt) - now, lang) })
|
|
858
938
|
const title = [passed ? t('quota.resetNoData', { date: day(passed.since) }) : t('quota.resetsAt', { at: clock(w.resetsAt) }), asOf].filter(Boolean).join(' · ')
|
|
859
939
|
return h('div', { class: 'meter lvl-' + st.level + (stale || passed ? ' lvl-stale' : ''), title },
|
|
860
940
|
h('div', { class: 'm-top' },
|
|
861
|
-
h('span', { class: 'm-label' }, winKey(w)),
|
|
941
|
+
h('span', { class: 'm-label' }, winTitle(winKey(w))),
|
|
862
942
|
h('span', { class: 'm-pct' }, shown + '%', h('span', { class: 'm-unit' }, ' ' + t(left ? 'quota.unitLeft' : 'quota.unitUsed')),
|
|
863
|
-
|
|
864
|
-
h('span', { class: 'm-reset' + (passed ? ' m-passed' : '') }, passed ?
|
|
865
|
-
h('div', { class: 'bar', role: 'meter', 'aria-label': t(left ? 'quota.meterLeft' : 'quota.meterUsed', { label: 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 + '%' } })))
|
|
943
|
+
null),
|
|
944
|
+
h('span', { class: 'm-reset' + (passed ? ' m-passed' : '') }, Number.isFinite(Date.parse(w.resetsAt)) || passed ? reset : '?')),
|
|
945
|
+
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 + '%' } })))
|
|
866
946
|
}
|
|
867
947
|
// A column whose window this account does not report right now, such as a 5h window that has not started.
|
|
868
948
|
function noMeter(label) {
|
|
@@ -901,8 +981,10 @@ const MAIN = String.raw `
|
|
|
901
981
|
return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title },
|
|
902
982
|
wins.length ? cols.map((c) => {
|
|
903
983
|
const w = wins.find((x) => winKey(x) === c)
|
|
904
|
-
return w ? meter(w, now, stale, q.observedAt) : noMeter(c)
|
|
905
|
-
}) : h('div', { class: 'q-note' }, t('quota.noWindows'))
|
|
984
|
+
return w ? meter(w, now, stale, q.observedAt) : noMeter(winTitle(c))
|
|
985
|
+
}) : h('div', { class: 'q-note' }, t('quota.noWindows')),
|
|
986
|
+
// Old numbers keep their colours and say their age once, under the bars.
|
|
987
|
+
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)
|
|
906
988
|
}
|
|
907
989
|
function usageBlock(a) {
|
|
908
990
|
const u = a.usage
|
|
@@ -948,9 +1030,13 @@ const MAIN = String.raw `
|
|
|
948
1030
|
const lvl = r.includes('health-fail') ? 'fail' : r.includes('quota-high') || r.includes('health-warn') ? 'warn' : ''
|
|
949
1031
|
if (!lvl) return null
|
|
950
1032
|
const why = whyOf(subject, Date.now())
|
|
951
|
-
|
|
1033
|
+
// A word, not only a colour (spec §3.8): what kind of look the account needs; the reason is on hover.
|
|
1034
|
+
const word = r.includes('health-fail') ? t('att.fix') : r.includes('quota-high') ? t('att.quota') : t('att.check')
|
|
1035
|
+
return h('span', { class: 'tag tag-' + lvl + ' att-tag', title: why, 'aria-label': why }, h('span', { class: 'dot dot-' + lvl, 'aria-hidden': 'true' }), word)
|
|
952
1036
|
}
|
|
953
|
-
|
|
1037
|
+
// members: the directories of a Same Login shown as this one row (spec §3.21).
|
|
1038
|
+
function nameLine(a, members) {
|
|
1039
|
+
const next = members ? members.some(isNext) : isNext(a)
|
|
954
1040
|
return h('div', { class: 'ident-name' },
|
|
955
1041
|
h('span', { class: 'name', title: a.name }, a.name),
|
|
956
1042
|
attentionDot(a),
|
|
@@ -958,9 +1044,10 @@ const MAIN = String.raw `
|
|
|
958
1044
|
const args = aliasArgsOf(a, x)
|
|
959
1045
|
return h('span', { class: 'tag tag-alias', title: args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias') }, x)
|
|
960
1046
|
}),
|
|
1047
|
+
members ? h('span', { class: 'tag tag-login', title: t('login.explain') }, t('login.dirs', { n: members.length })) : null,
|
|
961
1048
|
a.kind === 'api' ? h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api')) : null,
|
|
962
|
-
|
|
963
|
-
?
|
|
1049
|
+
next
|
|
1050
|
+
? nextTag(a)
|
|
964
1051
|
: null,
|
|
965
1052
|
a.login === 'logged-out' && a.kind !== 'api'
|
|
966
1053
|
? 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'))
|
|
@@ -1123,7 +1210,7 @@ const MAIN = String.raw `
|
|
|
1123
1210
|
}
|
|
1124
1211
|
// What a row or card shows depends on more than the account (health, view preferences, language); the
|
|
1125
1212
|
// fingerprint covers what its click handlers use.
|
|
1126
|
-
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)]) }
|
|
1213
|
+
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)]) }
|
|
1127
1214
|
// Whether the row sits in a login group (spec §3.21): its first row then shows the quota for all of them.
|
|
1128
1215
|
function inLogin(a) { return ui.loginRefs.has(a.ref) }
|
|
1129
1216
|
// The group's first row, which shows the quota for all of them.
|
|
@@ -1196,11 +1283,13 @@ const MAIN = String.raw `
|
|
|
1196
1283
|
const btn = el.querySelector('.row-toggle')
|
|
1197
1284
|
if (btn) syncAttrs(btn, rowToggle(a))
|
|
1198
1285
|
el.classList.toggle('row-open', open)
|
|
1199
|
-
|
|
1286
|
+
// A Same Login row (ADR-0026) opens onto its directories.
|
|
1287
|
+
const ms = el.classList.contains('row-login') ? loginMembers(S.state.accounts.filter((x) => ui.loginRefs.get(x.ref) === a.ref)) : null
|
|
1288
|
+
el.dataset.sig = ms ? sig(ms.map(rowSig)) : rowSig(a)
|
|
1200
1289
|
let detail = rowDetails(el)[0]
|
|
1201
1290
|
if (open && !detail) {
|
|
1202
|
-
detail = rowDetail(a, Boolean(emailLine(a)))
|
|
1203
|
-
const more = el.querySelector(':scope > .
|
|
1291
|
+
detail = ms ? loginDetail(ms) : rowDetail(a, Boolean(emailLine(a)))
|
|
1292
|
+
const more = el.querySelector(':scope > .row-end')
|
|
1204
1293
|
el.insertBefore(detail, more ? more.nextSibling : null)
|
|
1205
1294
|
detail.style.opacity = '0'
|
|
1206
1295
|
}
|
|
@@ -1239,33 +1328,87 @@ const MAIN = String.raw `
|
|
|
1239
1328
|
for (const p of parts) p.style.opacity = ''
|
|
1240
1329
|
}
|
|
1241
1330
|
// The Account 'sideby next <family>' would pick; the server ranks it with the CLI's rule (spec §3.13).
|
|
1331
|
+
// "Suggested": a click says what it means and offers the command, instead of copying without a word.
|
|
1332
|
+
function nextTag(a) {
|
|
1333
|
+
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'))
|
|
1334
|
+
b.addEventListener('click', (e) => {
|
|
1335
|
+
e.stopPropagation()
|
|
1336
|
+
openPopover(b, 'next', t('acct.next'), () => [
|
|
1337
|
+
h('p', { class: 'next-why' }, t('acct.nextWhy', { title: famTitle(a) })),
|
|
1338
|
+
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { closeMenu(true); copyText('sideby next ' + a.family) } }, icon('copy'), t('acct.nextCopy')),
|
|
1339
|
+
h('code', { class: 'next-cmd' }, 'sideby next ' + a.family),
|
|
1340
|
+
])
|
|
1341
|
+
})
|
|
1342
|
+
return b
|
|
1343
|
+
}
|
|
1242
1344
|
function isNext(a) { return Boolean(S.state && S.state.handoff && S.state.handoff[a.family] === a.ref) }
|
|
1243
1345
|
function row(a, now, cols) {
|
|
1346
|
+
if (ui.arrange) return arrangeRow(a, [a])
|
|
1244
1347
|
const open = isOpen(a)
|
|
1245
1348
|
const email = emailLine(a)
|
|
1246
|
-
const lead = loginLead(a)
|
|
1247
1349
|
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) },
|
|
1248
|
-
h('div', { class: 'ident' },
|
|
1249
|
-
|
|
1350
|
+
h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), email || subLine(a))),
|
|
1351
|
+
quotaBlock(a, now, cols),
|
|
1250
1352
|
lastUsed(a),
|
|
1251
|
-
moreButton(a),
|
|
1353
|
+
h('div', { class: 'row-end' }, runButton(a), pinButton(a, a.name), moreButton(a)),
|
|
1252
1354
|
open ? rowDetail(a, Boolean(email)) : null,
|
|
1253
1355
|
notes(a))
|
|
1254
1356
|
}
|
|
1255
|
-
// Directories signed in as one login (spec §3.21): the vendor counts one quota, so
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
}
|
|
1265
|
-
//
|
|
1266
|
-
function
|
|
1267
|
-
const
|
|
1268
|
-
|
|
1357
|
+
// Directories signed in as one login (spec §3.21, ADR-0026) are one row: the vendor counts one quota, so the row
|
|
1358
|
+
// shows it once, with the email and the newest use; opening it lists each directory with its own short command,
|
|
1359
|
+
// last use, tokens, Health, a button that copies its start command and its own menu.
|
|
1360
|
+
// The directory that names a Same Login row: the one the user put first (Display Order), else the main one, else
|
|
1361
|
+
// the first by name.
|
|
1362
|
+
function loginMembers(members) {
|
|
1363
|
+
const order = accountOrder(members[0].family) || []
|
|
1364
|
+
const at = (m) => { const i = order.indexOf(m.name); return i < 0 ? Infinity : i }
|
|
1365
|
+
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 }))
|
|
1366
|
+
}
|
|
1367
|
+
// Names the row after another directory: puts it just before the current one in the Display Order.
|
|
1368
|
+
function showLoginAs(ms, m) {
|
|
1369
|
+
const before = displayOf()
|
|
1370
|
+
const next = copyDisplay(before)
|
|
1371
|
+
const names = allNames(m.family).filter((n) => n !== m.name)
|
|
1372
|
+
names.splice(Math.max(0, names.indexOf(ms[0].name)), 0, m.name)
|
|
1373
|
+
next.accounts[m.family] = names
|
|
1374
|
+
saveDisplay(before, next)
|
|
1375
|
+
}
|
|
1376
|
+
function newestOf(members) {
|
|
1377
|
+
const at = (m) => { const v = Date.parse((m.usage && m.usage.status === 'ok' && m.usage.lastActivityAt) || ''); return Number.isFinite(v) ? v : -1 }
|
|
1378
|
+
return members.reduce((x, y) => at(y) > at(x) ? y : x)
|
|
1379
|
+
}
|
|
1380
|
+
function loginRow(members, now, cols) {
|
|
1381
|
+
const ms = loginMembers(members)
|
|
1382
|
+
const a = ms[0]
|
|
1383
|
+
if (ui.arrange) return arrangeRow(a, ms)
|
|
1384
|
+
const open = isOpen(a)
|
|
1385
|
+
const email = emailLine(a)
|
|
1386
|
+
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) },
|
|
1387
|
+
h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a, ms), email || subLine(a))),
|
|
1388
|
+
quotaBlock(a, now, cols),
|
|
1389
|
+
lastUsed(newestOf(ms)),
|
|
1390
|
+
h('div', { class: 'row-end' }, runButton(a), pinButton(a, ms.map((m) => m.name).join(', ')), loginMoreButton(ms)),
|
|
1391
|
+
open ? loginDetail(ms) : null,
|
|
1392
|
+
notes(a))
|
|
1393
|
+
}
|
|
1394
|
+
function loginDetail(ms) {
|
|
1395
|
+
return h('div', { class: 'row-detail login-detail' },
|
|
1396
|
+
h('p', { class: 'rd-note' }, t('login.explain')),
|
|
1397
|
+
h('div', { class: 'login-dirs', role: 'list' }, ms.map((m) => {
|
|
1398
|
+
const run = 'sideby run ' + m.ref
|
|
1399
|
+
return h('div', { class: 'login-dir', role: 'listitem', 'data-ref': m.ref },
|
|
1400
|
+
h('div', { class: 'ld-name' },
|
|
1401
|
+
h('span', { class: 'name' }, m.name),
|
|
1402
|
+
m.ref !== ms[0].ref ? attentionDot(m) : null,
|
|
1403
|
+
(m.aliases || []).slice(0, 3).map((x) => h('span', { class: 'tag tag-alias' }, x))),
|
|
1404
|
+
m.ref === ms[0].ref ? h('span', { class: 'tag ld-shown', title: t('login.shownHint') }, t('login.shown'))
|
|
1405
|
+
: 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')),
|
|
1406
|
+
lastUsed(m),
|
|
1407
|
+
usageBlock(m),
|
|
1408
|
+
healthChip(m),
|
|
1409
|
+
h('button', { class: 'btn btn-sm', type: 'button', title: run, onclick: () => copyText(run) }, icon('terminal'), t('login.copyRun')),
|
|
1410
|
+
moreButton(m))
|
|
1411
|
+
})))
|
|
1269
1412
|
}
|
|
1270
1413
|
// What a click on a row opens: how it runs (ref, model and effort, a shared login), its tokens and its Health.
|
|
1271
1414
|
function sameLoginNote(a) {
|
|
@@ -1286,7 +1429,7 @@ const MAIN = String.raw `
|
|
|
1286
1429
|
h('header', { class: 'card-head' },
|
|
1287
1430
|
badge(a.family, 'sm'),
|
|
1288
1431
|
h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a), sameLoginNote(a)),
|
|
1289
|
-
moreButton(a)),
|
|
1432
|
+
h('div', { class: 'row-end' }, pinButton(a, a.name), moreButton(a))),
|
|
1290
1433
|
notes(a),
|
|
1291
1434
|
quotaBlock(a, now, cols),
|
|
1292
1435
|
h('footer', { class: 'card-foot' }, usageBlock(a), h('div', { class: 'card-side' }, lastUsed(a), healthChip(a))))
|
|
@@ -1298,7 +1441,7 @@ const MAIN = String.raw `
|
|
|
1298
1441
|
const titled = list.some((a) => a.quota && a.quota.status === 'ok')
|
|
1299
1442
|
return h('div', { class: 'col-head', 'aria-hidden': 'true', 'data-key': 'cols' },
|
|
1300
1443
|
h('span', { class: 'ch-id' }, t('col.account')),
|
|
1301
|
-
h('span', { class: 'quota' }, titled ? cols.map((c) => h('span', null, t(left ? 'col.left' : 'col.used', { label: c }))) : null),
|
|
1444
|
+
h('span', { class: 'quota' }, titled ? cols.map((c) => h('span', null, t(left ? 'col.left' : 'col.used', { label: winTitle(c) }))) : null),
|
|
1302
1445
|
h('span', { class: 'ch-last' }, t('col.last')),
|
|
1303
1446
|
h('span', { class: 'ch-more' }))
|
|
1304
1447
|
}
|
|
@@ -1399,30 +1542,43 @@ const MAIN = String.raw `
|
|
|
1399
1542
|
}
|
|
1400
1543
|
function appGroup(list) {
|
|
1401
1544
|
const app = list[0].app
|
|
1402
|
-
const
|
|
1545
|
+
const id = appId(list[0].ref)
|
|
1546
|
+
ui.order.units[id] = list.map((p) => [p.name])
|
|
1547
|
+
const collapsed = prefs.collapsed['app:' + app] === true && !ui.arrange
|
|
1403
1548
|
const bodyId = 'grp-app-' + app.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
1404
|
-
const head = h('div', { class: 'group-head', 'data-key': 'head' },
|
|
1405
|
-
|
|
1549
|
+
const head = h('div', { class: 'group-head', 'data-key': ui.arrange ? 'head-arrange' : 'head' },
|
|
1550
|
+
dragHandle('family', id, id, app),
|
|
1551
|
+
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) },
|
|
1406
1552
|
icon('chevron', 'chev'), appBadge(), h('span', { class: 'group-title' }, app),
|
|
1407
1553
|
h('span', { class: 'group-count' }, String(list.length))),
|
|
1408
1554
|
h('div', { class: 'group-meta' },
|
|
1409
1555
|
h('span', { class: 'muted small label-tip' }, t('app.apart'), tipRaw(app, t('app.apartTip', { app })))),
|
|
1410
1556
|
list[0].installed ? null : h('div', { class: 'group-meta' }, h('span', { class: 'tag tag-warn' }, t('acct.notInstalled'))))
|
|
1411
|
-
const body = collapsed ? null : h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, list.map(appRow))
|
|
1412
|
-
return h('section', { class: 'group group-list' + (collapsed ? ' is-collapsed' : ''), 'aria-label': app, 'data-key': 'app:' + app, 'data-patch': '' },
|
|
1413
|
-
head, body, collapsed ? null : appFoot(list))
|
|
1557
|
+
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)))
|
|
1558
|
+
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': '' },
|
|
1559
|
+
head, body, collapsed || ui.arrange ? null : appFoot(list))
|
|
1560
|
+
}
|
|
1561
|
+
// A desktop app window in the arrange mode: grip, mark, name, up and down.
|
|
1562
|
+
function appArrangeRow(p, id) {
|
|
1563
|
+
const units = ui.order.units[id] || []
|
|
1564
|
+
const at = units.findIndex((u) => u[0] === p.name)
|
|
1565
|
+
const name = p.isMain ? t('app.mainName') : p.name
|
|
1566
|
+
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]) },
|
|
1567
|
+
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)))),
|
|
1568
|
+
h('div', { class: 'arrange-tools' },
|
|
1569
|
+
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')),
|
|
1570
|
+
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'))))
|
|
1414
1571
|
}
|
|
1415
1572
|
// Shown with the account groups unless a family filter or the attention filter is on; the search matches names.
|
|
1416
|
-
function appGroups() {
|
|
1417
|
-
|
|
1573
|
+
function appGroups() { return appIds().map(appGroupFor).filter(Boolean) }
|
|
1574
|
+
// A desktop app's group, its windows in the Display Order (spec §3.25); none under a family or attention filter.
|
|
1575
|
+
function appGroupFor(id) {
|
|
1576
|
+
if (prefs.family || ui.attention) return null
|
|
1418
1577
|
const q = ui.query.trim().toLowerCase()
|
|
1419
|
-
const
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
if (list.length) out.push(appGroup(list))
|
|
1424
|
-
}
|
|
1425
|
-
return out
|
|
1578
|
+
const all = inDisplayOrder(appProfiles().filter((p) => appId(p.ref) === id), (p) => p.name, accountOrder(id))
|
|
1579
|
+
const app = all.length ? all[0].app : ''
|
|
1580
|
+
const list = q && !app.toLowerCase().includes(q) ? all.filter((p) => p.ref.toLowerCase().includes(q)) : all
|
|
1581
|
+
return list.length ? appGroup(list) : null
|
|
1426
1582
|
}
|
|
1427
1583
|
async function openApp(ref) {
|
|
1428
1584
|
if (ui.appBusy) return
|
|
@@ -1468,11 +1624,13 @@ const MAIN = String.raw `
|
|
|
1468
1624
|
const setup = setupFor(fam)
|
|
1469
1625
|
const off = list.some((a) => a.quota && a.quota.status === 'unavailable' && a.quota.reason === 'not-enabled')
|
|
1470
1626
|
const bodyId = 'grp-' + fam.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
1471
|
-
|
|
1627
|
+
// The arrange mode shows every group open, as a short list, in either view (spec §3.25).
|
|
1628
|
+
const cards = prefs.view === 'cards' && !ui.arrange
|
|
1629
|
+
const collapsedNow = collapsed && !ui.arrange
|
|
1472
1630
|
if (!cards) ui.order.units[fam] = loginClusters(list).map((x) => Array.isArray(x) ? x.map((a) => a.name) : [x.name])
|
|
1473
|
-
const head = h('div', { class: 'group-head', 'data-key': 'head' },
|
|
1631
|
+
const head = h('div', { class: 'group-head', 'data-key': ui.arrange ? 'head-arrange' : 'head' },
|
|
1474
1632
|
dragHandle('family', fam, fam, info.title),
|
|
1475
|
-
h('button', { class: 'group-toggle', type: 'button', 'aria-expanded': String(!
|
|
1633
|
+
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) },
|
|
1476
1634
|
icon('chevron', 'chev'), badge(fam, 'sm'), h('span', { class: 'group-title' }, info.title),
|
|
1477
1635
|
h('span', { class: 'group-count' }, list.length === total ? String(total) : list.length + ' / ' + total)),
|
|
1478
1636
|
h('div', { class: 'group-meta' },
|
|
@@ -1484,11 +1642,11 @@ const MAIN = String.raw `
|
|
|
1484
1642
|
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')))
|
|
1485
1643
|
: null,
|
|
1486
1644
|
off && setup && setup.plan.status === 'blocked' ? h('span', { class: 'muted small label-tip' }, t('quota.blocked'), tipRaw(t('quota.blocked'), setup.plan.message)) : null))
|
|
1487
|
-
const body =
|
|
1645
|
+
const body = collapsedNow ? null : cards
|
|
1488
1646
|
? h('div', { class: 'grid', id: bodyId, 'data-key': 'cards', 'data-patch': '' }, list.map((a) => card(a, now, cols)))
|
|
1489
|
-
: h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, loginClusters(list).map((x) => Array.isArray(x) ?
|
|
1490
|
-
return h('section', { class: 'group' + (cards ? ' group-cards' : ' group-list') + (
|
|
1491
|
-
head,
|
|
1647
|
+
: 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)))
|
|
1648
|
+
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': '' },
|
|
1649
|
+
head, collapsedNow || cards || ui.arrange ? null : colHead(cols, list), body)
|
|
1492
1650
|
}
|
|
1493
1651
|
function emptyState() {
|
|
1494
1652
|
const fams = S.state.families.slice().sort((x, y) => Number(y.installed) - Number(x.installed))
|
|
@@ -1499,7 +1657,8 @@ const MAIN = String.raw `
|
|
|
1499
1657
|
h('p', { class: 'muted' }, t('empty.body')),
|
|
1500
1658
|
fams.length ? h('ul', { class: 'host-list' }, fams.map((f) => h('li', null, badge(f.id, 'sm'), h('span', null, f.title),
|
|
1501
1659
|
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')),
|
|
1502
|
-
BOOT.readOnly ? null : h('button', { class: 'btn', type: 'button', onclick: () => openCreate(null) }, icon('plus'), t('empty.create'))
|
|
1660
|
+
BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', onclick: () => openCreate(null) }, icon('plus'), t('empty.create')),
|
|
1661
|
+
BOOT.readOnly ? null : h('p', { class: 'muted small' }, t('ai.emptyHint'), ' ', h('button', { class: 'link-btn', type: 'button', onclick: openAi }, t('ai.how'))))
|
|
1503
1662
|
}
|
|
1504
1663
|
function clearFilters() {
|
|
1505
1664
|
ui.query = ''
|
|
@@ -1523,24 +1682,142 @@ const MAIN = String.raw `
|
|
|
1523
1682
|
h('p', null, S.loadError.message),
|
|
1524
1683
|
h('button', { class: 'btn', type: 'button', onclick: () => load({ check: !S.report }) }, icon('refresh'), t('btn.retry')))
|
|
1525
1684
|
}
|
|
1526
|
-
// ---------- Display Order (spec §3.25) ----------
|
|
1685
|
+
// ---------- Display Order and Pinned Accounts (spec §3.25) ----------
|
|
1686
|
+
const PIN_MAX = 3
|
|
1527
1687
|
function displayOf() { return (S.state && S.state.display) || {} }
|
|
1528
1688
|
// Every Family id in the Display Order, the ones only accounts name (a plugin that failed) last.
|
|
1529
1689
|
function familyOrder() {
|
|
1530
1690
|
const ids = S.state.families.map((f) => f.id)
|
|
1531
1691
|
for (const a of S.state.accounts) if (!ids.includes(a.family)) ids.push(a.family)
|
|
1692
|
+
// Desktop apps with windows of their own (Grok Bot) take a place in the same order, after the tools by default.
|
|
1693
|
+
for (const id of appIds()) if (!ids.includes(id)) ids.push(id)
|
|
1532
1694
|
return inDisplayOrder(ids, (x) => x, displayOf().families)
|
|
1533
1695
|
}
|
|
1696
|
+
function appIds() { return [...new Set(appProfiles().map((p) => appId(p.ref)))] }
|
|
1697
|
+
function isAppGroup(id) { return appIds().includes(id) && !S.state.families.some((f) => f.id === id) }
|
|
1534
1698
|
function accountOrder(fam) { return (displayOf().accounts || {})[fam] }
|
|
1699
|
+
function pinnedOf(fam) { return (displayOf().pinned || {})[fam] || [] }
|
|
1700
|
+
function isPinned(a) { return pinnedIn(a, pinnedOf(a.family)) }
|
|
1701
|
+
// Whether a shown unit (an account, or the directories of one login) is pinned.
|
|
1702
|
+
function unitPinned(fam, unit) {
|
|
1703
|
+
const pins = pinnedOf(fam)
|
|
1704
|
+
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) })
|
|
1705
|
+
}
|
|
1706
|
+
// Pinned places taken in a Family: a Same Login pinned together counts once.
|
|
1707
|
+
function pinnedUnits(fam) {
|
|
1708
|
+
const names = pinnedOf(fam)
|
|
1709
|
+
return loginClusters(S.state.accounts.filter((a) => a.family === fam && names.includes(a.name))).length
|
|
1710
|
+
}
|
|
1535
1711
|
// Every account name of a Family, hidden ones too, in the Display Order.
|
|
1536
1712
|
function allNames(fam) {
|
|
1713
|
+
if (isAppGroup(fam)) return inDisplayOrder(appProfiles().filter((p) => appId(p.ref) === fam).map((p) => p.name), (x) => x, accountOrder(fam))
|
|
1537
1714
|
return sortAccounts(S.state.accounts.filter((a) => a.family === fam), 'manual', Date.now(), accountOrder(fam)).map((a) => a.name)
|
|
1538
1715
|
}
|
|
1539
1716
|
function ordered() { const d = displayOf(); return Boolean((d.families && d.families.length) || (d.accounts && Object.keys(d.accounts).length)) }
|
|
1540
|
-
|
|
1541
|
-
|
|
1717
|
+
function copyDisplay(d) {
|
|
1718
|
+
return { families: d.families ? d.families.slice() : [], accounts: Object.assign({}, d.accounts || {}), pinned: Object.assign({}, d.pinned || {}) }
|
|
1719
|
+
}
|
|
1720
|
+
// Shows next at once and writes it to the config. Writes go one at a time; a failed one puts back what the config
|
|
1721
|
+
// last confirmed, never another write's unsaved state.
|
|
1722
|
+
const displaySaves = { chain: Promise.resolve(), pending: 0, confirmed: null }
|
|
1723
|
+
function saveDisplay(before, next, failKey) {
|
|
1724
|
+
if (!displaySaves.pending) displaySaves.confirmed = before
|
|
1725
|
+
displaySaves.pending++
|
|
1726
|
+
S.state.display = next
|
|
1727
|
+
renderAccounts()
|
|
1728
|
+
const run = displaySaves.chain.then(async () => {
|
|
1729
|
+
try {
|
|
1730
|
+
const r = await api('/api/display-order', { display: next })
|
|
1731
|
+
displaySaves.confirmed = r.display
|
|
1732
|
+
if (S.state.display === next) S.state.display = r.display
|
|
1733
|
+
return true
|
|
1734
|
+
} catch (e) {
|
|
1735
|
+
// A newer write already on its way settles the screen; only the latest one puts things back.
|
|
1736
|
+
if (S.state.display === next) { S.state.display = displaySaves.confirmed; renderAccounts() }
|
|
1737
|
+
toast(t(failKey || 'order.failed', { msg: e.message }), { kind: 'fail' })
|
|
1738
|
+
return false
|
|
1739
|
+
} finally { displaySaves.pending-- }
|
|
1740
|
+
})
|
|
1741
|
+
displaySaves.chain = run.catch(() => {})
|
|
1742
|
+
return run
|
|
1743
|
+
}
|
|
1744
|
+
// Pins or unpins an Account, with the other directories of its Same Login. A new pin goes last among the pinned.
|
|
1745
|
+
function togglePin(a) {
|
|
1746
|
+
const before = displayOf()
|
|
1747
|
+
const next = copyDisplay(before)
|
|
1748
|
+
const names = [a.name].concat((a.sameLogin || []).map(refName))
|
|
1749
|
+
// Names of accounts that are gone are dropped on the way (spec §3.25).
|
|
1750
|
+
const list = pinnedOf(a.family).filter((n) => !names.includes(n) && S.state.accounts.some((x) => x.family === a.family && x.name === n))
|
|
1751
|
+
if (!isPinned(a)) {
|
|
1752
|
+
if (pinnedUnits(a.family) >= PIN_MAX) { toast(t('pin.full', { n: PIN_MAX }), { kind: 'fail' }); return }
|
|
1753
|
+
list.push(...names.filter((n) => S.state.accounts.some((x) => x.family === a.family && x.name === n)))
|
|
1754
|
+
}
|
|
1755
|
+
next.pinned[a.family] = list
|
|
1756
|
+
saveDisplay(before, next, 'pin.failed')
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// The arrange mode: the only place to drag (spec §3.25). It starts from the order on screen, saves each move at
|
|
1760
|
+
// once, and "Done" offers to put back the order it started from.
|
|
1761
|
+
function sortAfter(start) { return start.sortBefore === 'manual' ? 'pressure' : start.sortBefore }
|
|
1762
|
+
function startArrange() {
|
|
1763
|
+
if (BOOT.readOnly) return
|
|
1764
|
+
closeMenu()
|
|
1765
|
+
if (!ui.arrange) ui.arrange = { before: copyDisplay(displayOf()), sortBefore: prefs.sort }
|
|
1766
|
+
prefs.sort = 'manual'
|
|
1767
|
+
savePrefs()
|
|
1768
|
+
if (tb.sort) tb.sort.value = 'manual'
|
|
1769
|
+
renderAccounts()
|
|
1770
|
+
const first = document.querySelector('#accounts .drag-handle')
|
|
1771
|
+
if (first) first.focus({ preventScroll: true })
|
|
1772
|
+
}
|
|
1773
|
+
function endArrange() {
|
|
1774
|
+
const start = ui.arrange
|
|
1775
|
+
if (!start) return
|
|
1776
|
+
ui.arrange = null
|
|
1777
|
+
// With no order of one's own left, "my order" is the default order: go back to the usual sort.
|
|
1778
|
+
if (!ordered()) { prefs.sort = sortAfter(start); savePrefs() }
|
|
1779
|
+
if (tb.sort) tb.sort.value = prefs.sort
|
|
1780
|
+
renderAccounts()
|
|
1781
|
+
if (JSON.stringify(copyDisplay(displayOf())) === JSON.stringify(start.before)) return
|
|
1782
|
+
toast(t('order.saved'), { action: { label: t('order.undo'), run: async () => {
|
|
1783
|
+
if (!(await saveDisplay(displayOf(), start.before))) return
|
|
1784
|
+
if (!ordered() && prefs.sort === 'manual') { prefs.sort = sortAfter(start); savePrefs(); if (tb.sort) tb.sort.value = prefs.sort; renderAccounts() }
|
|
1785
|
+
toast(t('order.undone'))
|
|
1786
|
+
} } })
|
|
1787
|
+
}
|
|
1788
|
+
function arrangeBar() {
|
|
1789
|
+
return h('div', { class: 'arrange-bar', role: 'region', 'aria-label': t('sort.arrange'), 'data-key': 'arrange-bar', 'data-sig': sig([ordered(), lang]) },
|
|
1790
|
+
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'))),
|
|
1791
|
+
h('div', { class: 'arrange-actions' },
|
|
1792
|
+
h('button', { class: 'link-btn', type: 'button', title: t('order.resetHint'), disabled: ordered() ? undefined : true, onclick: resetOrder }, t('order.resetButton')),
|
|
1793
|
+
h('button', { class: 'btn btn-primary btn-sm', type: 'button', onclick: endArrange }, t('order.done'))))
|
|
1794
|
+
}
|
|
1795
|
+
// An account row in the arrange mode: grip, mark, name and short commands, then up, down and pin. Nothing else.
|
|
1796
|
+
function arrangeRow(a, ms) {
|
|
1797
|
+
const fam = a.family
|
|
1798
|
+
const units = ui.order.units[fam] || []
|
|
1799
|
+
const at = units.findIndex((u) => u.includes(a.name))
|
|
1800
|
+
const nPinned = units.filter((u) => unitPinned(fam, u)).length
|
|
1801
|
+
const pinned = isPinned(a)
|
|
1802
|
+
const lo = pinned ? 0 : nPinned
|
|
1803
|
+
const hi = pinned ? nPinned - 1 : units.length - 1
|
|
1804
|
+
const label = ms.map((m) => m.name).join(', ')
|
|
1805
|
+
const full = !pinned && pinnedUnits(fam) >= PIN_MAX
|
|
1806
|
+
const pinLabel = full ? t('pin.full', { n: PIN_MAX }) : t(pinned ? 'pin.unpinName' : 'pin.pinName', { name: label, title: family(fam).title })
|
|
1807
|
+
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]) },
|
|
1808
|
+
h('div', { class: 'ident' }, dragHandle('account', fam, a.name, label), badge(fam, 'sm'), h('div', { class: 'ident-text' }, h('div', { class: 'ident-name' },
|
|
1809
|
+
h('span', { class: 'name', title: a.name }, a.name),
|
|
1810
|
+
(a.aliases || []).slice(0, 3).map((x) => h('span', { class: 'tag tag-alias' }, x)),
|
|
1811
|
+
ms.length > 1 ? h('span', { class: 'tag tag-login', title: t('login.explain') }, t('login.dirs', { n: ms.length })) : null))),
|
|
1812
|
+
h('div', { class: 'arrange-tools' },
|
|
1813
|
+
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')),
|
|
1814
|
+
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')),
|
|
1815
|
+
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'))))
|
|
1816
|
+
}
|
|
1817
|
+
// 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
|
|
1818
|
+
// the arrow keys. Only in the arrange mode.
|
|
1542
1819
|
function dragHandle(kind, fam, key, name) {
|
|
1543
|
-
if (BOOT.readOnly ||
|
|
1820
|
+
if (BOOT.readOnly || !ui.arrange) return null
|
|
1544
1821
|
const label = t(kind === 'family' ? 'order.moveFamily' : 'order.moveAccount', { name })
|
|
1545
1822
|
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'),
|
|
1546
1823
|
onclick: (e) => e.stopPropagation(),
|
|
@@ -1557,7 +1834,7 @@ const MAIN = String.raw `
|
|
|
1557
1834
|
},
|
|
1558
1835
|
ondragstart: (e) => {
|
|
1559
1836
|
const units = unitsOf(kind, fam)
|
|
1560
|
-
ui.drag = { kind, fam, from: units.findIndex((u) => u.includes(key)), to: null }
|
|
1837
|
+
ui.drag = { kind, fam, from: units.findIndex((u) => u.includes(key)), to: null, other: null, dropped: false }
|
|
1561
1838
|
e.dataTransfer.effectAllowed = 'move'
|
|
1562
1839
|
try { e.dataTransfer.setData('text/plain', name) } catch {}
|
|
1563
1840
|
const el = unitElement(kind, fam, key)
|
|
@@ -1567,11 +1844,10 @@ const MAIN = String.raw `
|
|
|
1567
1844
|
}, icon('grip'))
|
|
1568
1845
|
}
|
|
1569
1846
|
function unitsOf(kind, fam) { return kind === 'family' ? ui.order.families.map((f) => [f]) : (ui.order.units[fam] || []) }
|
|
1570
|
-
// The element that moves: a Family's group, an account's row
|
|
1847
|
+
// The element that moves: a Family's group, or an account's row (a Same Login is one row).
|
|
1571
1848
|
function unitElement(kind, fam, key) {
|
|
1572
1849
|
if (kind === 'family') return document.querySelector('#accounts > section[data-fam="' + CSS.escape(fam) + '"]')
|
|
1573
|
-
|
|
1574
|
-
return rowEl && (rowEl.closest('.login') || rowEl)
|
|
1850
|
+
return document.querySelector('#accounts .row[data-ref="' + CSS.escape(fam + ':' + key) + '"]')
|
|
1575
1851
|
}
|
|
1576
1852
|
// Where a drop at the pointer lands: the list of places it can go and the index among them, or null outside.
|
|
1577
1853
|
function dropTarget(e) {
|
|
@@ -1582,7 +1858,9 @@ const MAIN = String.raw `
|
|
|
1582
1858
|
return els.length ? { els, at: slotAt(els, e.clientY) } : null
|
|
1583
1859
|
}
|
|
1584
1860
|
const sec = e.target.closest && e.target.closest('#accounts > section[data-fam]')
|
|
1585
|
-
|
|
1861
|
+
// Over another Family: remembered, so letting go there can say why nothing moved.
|
|
1862
|
+
d.other = sec && sec.getAttribute('data-fam') !== d.fam ? sec.getAttribute('data-fam') : null
|
|
1863
|
+
if (!sec || d.other) return null
|
|
1586
1864
|
const box = sec.querySelector('.rows')
|
|
1587
1865
|
if (!box) return null
|
|
1588
1866
|
const els = Array.from(box.children)
|
|
@@ -1597,9 +1875,11 @@ const MAIN = String.raw `
|
|
|
1597
1875
|
}
|
|
1598
1876
|
function clearDropMarks() { for (const el of document.querySelectorAll('.drop-before, .drop-after')) el.classList.remove('drop-before', 'drop-after') }
|
|
1599
1877
|
function endDrag() {
|
|
1878
|
+
const d = ui.drag
|
|
1600
1879
|
clearDropMarks()
|
|
1601
1880
|
for (const el of document.querySelectorAll('.is-dragging')) el.classList.remove('is-dragging')
|
|
1602
1881
|
ui.drag = null
|
|
1882
|
+
if (d && d.other && !d.dropped) toast(t('order.sameFamily', { title: family(d.fam).title }))
|
|
1603
1883
|
if (S.pending) { S.pending = false; renderAll() }
|
|
1604
1884
|
}
|
|
1605
1885
|
function onDragOver(e) {
|
|
@@ -1617,57 +1897,53 @@ const MAIN = String.raw `
|
|
|
1617
1897
|
const d = ui.drag
|
|
1618
1898
|
if (!d || !dropTarget(e)) return
|
|
1619
1899
|
e.preventDefault()
|
|
1900
|
+
d.dropped = true
|
|
1620
1901
|
const to = d.to
|
|
1621
1902
|
endDrag()
|
|
1622
1903
|
if (to !== null && to !== d.from) moveUnit(d.kind, d.fam, d.from, to)
|
|
1623
1904
|
}
|
|
1624
|
-
// Records
|
|
1625
|
-
|
|
1905
|
+
// Records a move: Families anywhere; pinned accounts only among the pinned (their pin order), the others only
|
|
1906
|
+
// below them (the Display Order). A move past the edge of its part stops at the edge.
|
|
1907
|
+
function moveUnit(kind, fam, from, to, opts) {
|
|
1626
1908
|
opts = opts || {}
|
|
1627
1909
|
const units = unitsOf(kind, fam)
|
|
1628
1910
|
if (from < 0 || to < 0 || from >= units.length || to >= units.length) return
|
|
1629
|
-
if (from === to) { if (opts.label) say(t('order.position', { name: opts.label, n: from + 1, total: units.length })); return }
|
|
1630
1911
|
const before = displayOf()
|
|
1631
|
-
const next =
|
|
1912
|
+
const next = copyDisplay(before)
|
|
1913
|
+
let place = to
|
|
1914
|
+
let first = 0
|
|
1915
|
+
let total = units.length
|
|
1632
1916
|
if (kind === 'family') next.families = reorder(familyOrder(), units, from, to)
|
|
1633
|
-
else
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1917
|
+
else {
|
|
1918
|
+
// Names of accounts that are gone are dropped on the way (spec §3.25).
|
|
1919
|
+
const pins = pinnedOf(fam).filter((n) => S.state.accounts.some((x) => x.family === fam && x.name === n))
|
|
1920
|
+
const nPinned = units.filter((u) => unitPinned(fam, u)).length
|
|
1921
|
+
const pinned = from < nPinned
|
|
1922
|
+
place = pinned ? Math.min(to, nPinned - 1) : Math.max(to, nPinned)
|
|
1923
|
+
first = pinned ? 0 : nPinned
|
|
1924
|
+
total = pinned ? nPinned : units.length - nPinned
|
|
1925
|
+
if (place !== from) {
|
|
1926
|
+
if (pinned) next.pinned[fam] = reorder(pins, units.slice(0, nPinned), from, place)
|
|
1927
|
+
else next.accounts[fam] = reorder(allNames(fam), units, from, place)
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
const where = () => { if (opts.label) say(t('order.position', { name: opts.label, n: place - first + 1, total })) }
|
|
1931
|
+
if (place === from) { where(); return }
|
|
1932
|
+
saveDisplay(before, next)
|
|
1638
1933
|
if (opts.focus) {
|
|
1639
|
-
const
|
|
1934
|
+
const grip = '#accounts .drag-handle[data-move="' + kind + '"][data-fam="' + CSS.escape(fam) + '"][data-name="' + CSS.escape(opts.focus) + '"]'
|
|
1935
|
+
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
|
|
1936
|
+
// At the edge the arrow it used is off now: the grip keeps the focus.
|
|
1937
|
+
if (!btn || btn.disabled) btn = document.querySelector(grip)
|
|
1640
1938
|
if (btn) btn.focus({ preventScroll: false })
|
|
1641
|
-
|
|
1642
|
-
}
|
|
1643
|
-
try {
|
|
1644
|
-
const r = await api('/api/display-order', { display: next })
|
|
1645
|
-
S.state.display = r.display
|
|
1646
|
-
} catch (e) {
|
|
1647
|
-
S.state.display = before
|
|
1648
|
-
renderAccounts()
|
|
1649
|
-
toast(t('order.failed', { msg: e.message }), { kind: 'fail' })
|
|
1939
|
+
where()
|
|
1650
1940
|
}
|
|
1651
1941
|
}
|
|
1942
|
+
// Forgets the order of Families and accounts; pins stay.
|
|
1652
1943
|
async function resetOrder() {
|
|
1653
1944
|
const before = displayOf()
|
|
1654
|
-
|
|
1655
|
-
if (
|
|
1656
|
-
renderAccounts()
|
|
1657
|
-
try {
|
|
1658
|
-
const r = await api('/api/display-order', { display: null })
|
|
1659
|
-
S.state.display = r.display
|
|
1660
|
-
toast(t('order.reset'))
|
|
1661
|
-
} catch (e) {
|
|
1662
|
-
S.state.display = before
|
|
1663
|
-
renderAccounts()
|
|
1664
|
-
toast(t('order.failed', { msg: e.message }), { kind: 'fail' })
|
|
1665
|
-
}
|
|
1666
|
-
}
|
|
1667
|
-
// The toolbar's "Default order" button, shown once the user has put things in an order of their own.
|
|
1668
|
-
function renderOrderReset() {
|
|
1669
|
-
if (!tb.reset) return
|
|
1670
|
-
tb.reset.hidden = BOOT.readOnly || !ordered()
|
|
1945
|
+
const pinned = before.pinned && Object.keys(before.pinned).length ? before.pinned : null
|
|
1946
|
+
if (await saveDisplay(before, pinned ? { pinned } : {})) toast(t('order.reset'))
|
|
1671
1947
|
}
|
|
1672
1948
|
// Words for screen readers only, such as the new place of something moved with the keyboard.
|
|
1673
1949
|
let live = null
|
|
@@ -1697,25 +1973,29 @@ const MAIN = String.raw `
|
|
|
1697
1973
|
const apps = appGroups()
|
|
1698
1974
|
if (!shown.length && !apps.length) { patch(box, noMatch()); return }
|
|
1699
1975
|
ui.loginRefs = new Map()
|
|
1700
|
-
if (prefs.view !== 'cards')
|
|
1701
|
-
for (const x of loginClusters(shown)) if (Array.isArray(x)) for (const a of x) ui.loginRefs.set(a.ref, x[0].ref)
|
|
1976
|
+
if (prefs.view !== 'cards' || ui.arrange)
|
|
1977
|
+
for (const x of loginClusters(shown)) if (Array.isArray(x)) for (const a of x) ui.loginRefs.set(a.ref, loginMembers(x)[0].ref)
|
|
1702
1978
|
// Families in the Display Order (spec §3.25) whatever the sort; each group gets the quota columns it uses.
|
|
1703
1979
|
// Every group is as wide as the one with the most windows, so the columns after them line up.
|
|
1704
1980
|
ui.order = { families: [], units: {} }
|
|
1705
1981
|
const groups = []
|
|
1706
1982
|
let width = 1
|
|
1707
1983
|
for (const fam of familyOrder()) {
|
|
1984
|
+
if (isAppGroup(fam)) {
|
|
1985
|
+
const g = appGroupFor(fam)
|
|
1986
|
+
if (g) { ui.order.families.push(fam); groups.push(g) }
|
|
1987
|
+
continue
|
|
1988
|
+
}
|
|
1708
1989
|
const list = shown.filter((a) => a.family === fam)
|
|
1709
1990
|
if (!list.length) continue
|
|
1710
1991
|
ui.order.families.push(fam)
|
|
1711
|
-
const sorted = sortAccounts(list, prefs.sort, now, accountOrder(fam))
|
|
1992
|
+
const sorted = withPinned(sortAccounts(list, prefs.sort, now, accountOrder(fam)), pinnedOf(fam))
|
|
1712
1993
|
const cols = quotaColumns(sorted)
|
|
1713
1994
|
width = Math.max(width, cols.length)
|
|
1714
1995
|
groups.push(group(fam, sorted, all.filter((a) => a.family === fam).length, now, cols))
|
|
1715
1996
|
}
|
|
1716
1997
|
box.style.setProperty('--qn', String(width))
|
|
1717
|
-
|
|
1718
|
-
patch(box, groups.concat(apps))
|
|
1998
|
+
patch(box, (ui.arrange ? [arrangeBar()] : []).concat(groups))
|
|
1719
1999
|
if (ui.fade && !REDUCED) {
|
|
1720
2000
|
box.classList.add('view-in')
|
|
1721
2001
|
setTimeout(() => box.classList.remove('view-in'), 400)
|
|
@@ -1787,7 +2067,17 @@ const MAIN = String.raw `
|
|
|
1787
2067
|
renderCheckup()
|
|
1788
2068
|
$('checkup').scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'start' })
|
|
1789
2069
|
}
|
|
2070
|
+
// The Panel updates an outdated sideby skill itself when the Update plan allows it (spec §3.16, §3.23).
|
|
2071
|
+
function skillFixable() {
|
|
2072
|
+
const u = updState()
|
|
2073
|
+
return !BOOT.readOnly && !!(u && u.method.kind !== 'local' && u.skillUpdate && u.skillUpdate.outdated && u.skillUpdate.canRun)
|
|
2074
|
+
}
|
|
2075
|
+
// A Finding in plain words (its code's sentence); the check's own text goes under "Details".
|
|
1790
2076
|
function findingRow(f) {
|
|
2077
|
+
const own = panelFixes(f, skillFixable())
|
|
2078
|
+
const key = 'finding.' + f.code
|
|
2079
|
+
const plain = Object.hasOwn(MESSAGES.en, key) ? t(key, { item: f.item }) : ''
|
|
2080
|
+
const fixHere = plain && f.fixable && !BOOT.readOnly && !own
|
|
1791
2081
|
return h('li', { class: 'finding lvl-' + f.level },
|
|
1792
2082
|
h('span', { class: 'dot', title: f.level }),
|
|
1793
2083
|
h('div', { class: 'finding-body' },
|
|
@@ -1795,12 +2085,20 @@ const MAIN = String.raw `
|
|
|
1795
2085
|
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),
|
|
1796
2086
|
h('span', { class: 'muted small mono' }, f.item),
|
|
1797
2087
|
f.fixable ? h('span', { class: 'chip chip-sm chip-accent' }, t('health.autoFix')) : null,
|
|
1798
|
-
f.source && f.source !== 'core' ? h('span', { class: 'muted small' }, t('health.from', { source: f.source })) : null
|
|
1799
|
-
|
|
1800
|
-
|
|
2088
|
+
f.source && f.source !== 'core' ? h('span', { class: 'muted small' }, t('health.from', { source: f.source })) : null,
|
|
2089
|
+
own ? h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: openUpdate }, icon('arrowUp'), t('upd.skillLabel')) : null),
|
|
2090
|
+
h('p', null, plain || f.message),
|
|
2091
|
+
own ? null
|
|
2092
|
+
: fixHere ? h('p', { class: 'hint' }, t('finding.fixHint'))
|
|
2093
|
+
: f.hint && !plain ? h('p', { class: 'hint' }, panelHint(f.hint, f.fixable && !BOOT.readOnly, t('fix.hintAction'))) : null,
|
|
2094
|
+
// The check's own words (and any command to run) stay one click away.
|
|
2095
|
+
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))
|
|
1801
2096
|
}
|
|
1802
2097
|
function renderCheckup() {
|
|
1803
2098
|
const box = $('checkup')
|
|
2099
|
+
// With no account there is nothing to check: the empty state is the only thing to do.
|
|
2100
|
+
box.hidden = Boolean(S.state && !S.state.accounts.length)
|
|
2101
|
+
if (box.hidden) return
|
|
1804
2102
|
const all = findingsNow()
|
|
1805
2103
|
const ref = ui.healthRef
|
|
1806
2104
|
const findings = all && ref ? all.filter((f) => f.account === ref) : all
|
|
@@ -1808,7 +2106,11 @@ const MAIN = String.raw `
|
|
|
1808
2106
|
const busy = S.checking || S.fixing
|
|
1809
2107
|
const fixable = all ? all.filter((f) => f.fixable).length : 0
|
|
1810
2108
|
// Say why Fix all is off, unless a running check or fix already explains it.
|
|
1811
|
-
const
|
|
2109
|
+
const skillFix = skillFixable()
|
|
2110
|
+
const forAi = (list) => list.filter((f) => !panelFixes(f, skillFix))
|
|
2111
|
+
const fixWhy = busy || fixable ? null : !all ? t('fix.whyNoCheck') : !all.length ? t('fix.whyNothing')
|
|
2112
|
+
: forAi(all).length ? t('fix.whyManual') : t('fix.whyButton')
|
|
2113
|
+
const prompt = findings && !busy ? fixPrompt(forAi(findings), { lead: t('fix.promptLead'), hint: t('fix.promptHint'), fixFirst: t('fix.promptFixFirst'), check: t('fix.promptCheck') }) : ''
|
|
1812
2114
|
const status = S.checking ? t('health.running') : S.fixing ? t('health.fixing') : at ? t('health.last', { ago: ago(at) }) : t('health.never')
|
|
1813
2115
|
const out = [h('div', { class: 'section-head' },
|
|
1814
2116
|
h('div', null,
|
|
@@ -1817,6 +2119,7 @@ const MAIN = String.raw `
|
|
|
1817
2119
|
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')) }))),
|
|
1818
2120
|
h('div', { class: 'actions' },
|
|
1819
2121
|
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')),
|
|
2122
|
+
prompt ? h('button', { class: 'btn', type: 'button', title: t('fix.copyAiTitle'), onclick: () => copyText(prompt, t('fix.promptShown')) }, icon('copy'), t('fix.copyAi')) : null,
|
|
1820
2123
|
BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', disabled: busy || !fixable, 'aria-busy': S.fixing ? 'true' : null, title: fixWhy, onclick: runFix },
|
|
1821
2124
|
S.fixing ? spinner() : null, S.fixing ? t('health.fixing') : t('fix.all') + (fixable ? ' (' + fixable + ')' : ''))))]
|
|
1822
2125
|
if (ref) out.push(h('div', { class: 'filter-bar' },
|
|
@@ -2011,10 +2314,12 @@ const MAIN = String.raw `
|
|
|
2011
2314
|
if (g.outOfRange) rows.push(h('p', { class: 'note note-warn' }, t('tune.outOfRange', { size: tokens(g.current) })))
|
|
2012
2315
|
if (!BOOT.readOnly) {
|
|
2013
2316
|
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) }))))
|
|
2014
|
-
|
|
2317
|
+
// With a suggestion there is one main button; choosing a size by hand waits behind a click.
|
|
2318
|
+
const custom = h('div', { class: 'tune-setting' },
|
|
2015
2319
|
h('b', { class: 'small' }, t('tune.setting')),
|
|
2016
2320
|
h('div', { class: 'row-actions' }, sel,
|
|
2017
|
-
h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(busy) || value === g.current, onclick: () => useAsk(g, value) }, t('tune.useThis'))))
|
|
2321
|
+
h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(busy) || value === g.current, onclick: () => useAsk(g, value) }, t('tune.useThis'))))
|
|
2322
|
+
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)
|
|
2018
2323
|
rows.push(useBox(g, false))
|
|
2019
2324
|
if (g.undo && !(mine && (useUi.step === 'done' || useUi.step === 'undone')))
|
|
2020
2325
|
rows.push(h('p', { class: 'small tune-undo' }, t('tune.applied', { prev: tuneSize(g.undo.previous) }), ' ',
|
|
@@ -2109,7 +2414,9 @@ const MAIN = String.raw `
|
|
|
2109
2414
|
setKids(nav, h('div', { class: 'seg', role: 'presentation' }, tabs.map(([id, label]) =>
|
|
2110
2415
|
h('button', { class: 'seg-btn', type: 'button', role: 'tab', 'aria-selected': curPage === id ? 'true' : 'false', onclick: () => setPage(id) }, label))))
|
|
2111
2416
|
const away = curPage !== 'accounts' && tabs.some(([id]) => id === curPage)
|
|
2112
|
-
|
|
2417
|
+
// With no account yet the empty state is the whole page: no summary, no health check.
|
|
2418
|
+
const none = Boolean(S.state && !S.state.accounts.length)
|
|
2419
|
+
for (const id of ['summary', 'accounts-page', 'checkup']) $(id).hidden = away || (none && id !== 'accounts-page')
|
|
2113
2420
|
const onTune = away && curPage === 'tuning'
|
|
2114
2421
|
const onHandoff = away && curPage === 'handoff'
|
|
2115
2422
|
$('tuning-page').hidden = !onTune
|
|
@@ -2254,9 +2561,12 @@ const MAIN = String.raw `
|
|
|
2254
2561
|
h('p', { class: 'muted small' }, t('nf.intro')),
|
|
2255
2562
|
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'))),
|
|
2256
2563
|
h('div', { class: 'field' }, h('label', { class: 'field', for: 'nf-name' }, h('span', null, t('nf.name')), e.name), e.suggest),
|
|
2257
|
-
|
|
2258
|
-
h('
|
|
2259
|
-
|
|
2564
|
+
// The first screen asks only for the tool and a name; the rest waits behind "More options".
|
|
2565
|
+
h('details', { class: 'more-opts', open: nf.api || Boolean(nf.alias) || Boolean(nf.error) || undefined },
|
|
2566
|
+
h('summary', null, t('nf.more')),
|
|
2567
|
+
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),
|
|
2568
|
+
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')),
|
|
2569
|
+
e.preview),
|
|
2260
2570
|
e.error,
|
|
2261
2571
|
e.errNote = nf.error ? errorNote(nf.error, create, nf.error.code === 'create-refused' && !nf.api ? t('nf.refusedApi') : null) : null)
|
|
2262
2572
|
setKids(el, dialogFrame(t('nf.title'), null, body, [
|
|
@@ -3019,6 +3329,8 @@ const MAIN = String.raw `
|
|
|
3019
3329
|
const out = []
|
|
3020
3330
|
if (!LANG_FIXED) out.push('lang')
|
|
3021
3331
|
if (BOOT.colorScheme === 'auto') out.push('theme')
|
|
3332
|
+
if (S.state && S.state.accounts.length && !BOOT.readOnly) out.push('order')
|
|
3333
|
+
out.push('ai')
|
|
3022
3334
|
if (S.state && rcSummary()) out.push('rc')
|
|
3023
3335
|
if (S.state && ahState()) out.push('ah')
|
|
3024
3336
|
if (S.state && (S.state.quotaChecks || []).length) out.push('checks')
|
|
@@ -3044,7 +3356,19 @@ const MAIN = String.raw `
|
|
|
3044
3356
|
h('span', { class: 'menu-label' }, label), h('span', { class: 'chip chip-sm ' + cls }, state), icon('chevron'))
|
|
3045
3357
|
if (secs.includes('lang')) out.push(seg(t('set.lang'), [['en', 'English'], ['zh', '中文']], lang, setLang))
|
|
3046
3358
|
if (secs.includes('theme')) out.push(seg(t('set.theme'), CHOICES.theme.map((v) => [v, t('set.theme.' + v)]), prefs.theme, setTheme))
|
|
3047
|
-
if (out.length && (secs.includes('rc') || secs.includes('ah'))) out.push(h('div', { class: 'menu-sep', role: 'separator' }))
|
|
3359
|
+
if (out.length && (secs.includes('order') || secs.includes('rc') || secs.includes('ah'))) out.push(h('div', { class: 'menu-sep', role: 'separator' }))
|
|
3360
|
+
// Everything here can also be asked of an AI assistant through the sideby skill (spec §3.8).
|
|
3361
|
+
if (secs.includes('ai')) out.push(link(t('ai.title'), t(skillInstalled() ? 'ai.installed' : 'ai.notInstalled'), skillInstalled() ? 'chip-ok' : '', openAi))
|
|
3362
|
+
// The arrange mode (spec §3.25) has a place here too, where people look for "how do I change this".
|
|
3363
|
+
if (secs.includes('order')) {
|
|
3364
|
+
const pins = Object.values(displayOf().pinned || {}).some((x) => x.length)
|
|
3365
|
+
out.push(link(t('set.order'), t(ordered() || pins ? 'set.orderMine' : 'set.orderDefault'), '', () => {
|
|
3366
|
+
setPage('accounts')
|
|
3367
|
+
startArrange()
|
|
3368
|
+
const bar = document.querySelector('#accounts .arrange-bar')
|
|
3369
|
+
if (bar) bar.scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'center' })
|
|
3370
|
+
}))
|
|
3371
|
+
}
|
|
3048
3372
|
if (secs.includes('rc')) {
|
|
3049
3373
|
const sum = rcSummary()
|
|
3050
3374
|
out.push(link(t('rc.button'), t('rc.state.' + sum), sum === 'on' ? 'chip-warn' : sum === 'off' ? 'chip-ok' : '', openRc))
|
|
@@ -3089,6 +3413,25 @@ const MAIN = String.raw `
|
|
|
3089
3413
|
applyTheme()
|
|
3090
3414
|
refreshPopover('settings')
|
|
3091
3415
|
}
|
|
3416
|
+
// The sideby Agent Skill: what to say to an AI assistant instead of clicking, and how to install it when needed.
|
|
3417
|
+
function skillInstalled() {
|
|
3418
|
+
const u = S.state && S.state.update
|
|
3419
|
+
return Boolean(u && u.skillUpdate && u.skillUpdate.installed && u.skillUpdate.installed.length)
|
|
3420
|
+
}
|
|
3421
|
+
const AI_ASKS = ['ai.ask.quota', 'ai.ask.new', 'ai.ask.pin', 'ai.ask.fix', 'ai.ask.handoff', 'ai.ask.rc']
|
|
3422
|
+
function openAi() {
|
|
3423
|
+
showDialog('ai')
|
|
3424
|
+
const asks = AI_ASKS.map((k) => {
|
|
3425
|
+
const text = t(k)
|
|
3426
|
+
return h('li', null, h('button', { class: 'ai-ask', type: 'button', title: t('ai.copy'), onclick: () => copyText(text) }, icon('copy'), h('span', null, text)))
|
|
3427
|
+
})
|
|
3428
|
+
setKids(dlg.el, dialogFrame(t('ai.title'), null, h('div', { class: 'form' },
|
|
3429
|
+
h('p', null, t('ai.intro')),
|
|
3430
|
+
skillInstalled() ? h('p', { class: 'note' }, t('ai.ready')) : h('div', { class: 'field' }, h('p', { class: 'small' }, t('ai.install')), codeCopy(BOOT.skillInstall)),
|
|
3431
|
+
h('p', { class: 'small muted' }, t('ai.examples')),
|
|
3432
|
+
h('ul', { class: 'ai-asks' }, asks)),
|
|
3433
|
+
[h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close'))]))
|
|
3434
|
+
}
|
|
3092
3435
|
function openRc() {
|
|
3093
3436
|
if (!S.state || !rcSummary()) return
|
|
3094
3437
|
rc.target = null
|
|
@@ -3226,6 +3569,17 @@ const MAIN = String.raw `
|
|
|
3226
3569
|
// build and reloads (a background Panel restarts itself, a Portal loads the new code by itself).
|
|
3227
3570
|
let up = null
|
|
3228
3571
|
function updState() { return BOOT.updates && S.state ? S.state.update || null : null }
|
|
3572
|
+
// The skill agents read is part of the version (spec §3.23): behind a current CLI it is the one thing to update.
|
|
3573
|
+
const isLocal = (u) => u.method.kind === 'local'
|
|
3574
|
+
const skillOld = (u) => !!(u && !u.available && !isLocal(u) && u.skillUpdate && u.skillUpdate.outdated)
|
|
3575
|
+
const skillDue = (u) => skillOld(u) && u.skillUpdate.canRun
|
|
3576
|
+
function skillFor(u) {
|
|
3577
|
+
const v = u.skillUpdate.installed.find((x) => x !== u.current)
|
|
3578
|
+
return v ? v : t('upd.skillNoVer')
|
|
3579
|
+
}
|
|
3580
|
+
// Why sideby cannot do something itself, in the viewer's language; the English reason when the code is unknown.
|
|
3581
|
+
const why = (o) => (o && o.reasonCode ? t('why.' + o.reasonCode, { at: o.reasonAt || '' }) : (o && o.reason) || '')
|
|
3582
|
+
const offText = (u) => (u.checksOffCode ? t('off.' + u.checksOffCode, { why: u.checksOff }) : t('upd.checksOff', { why: u.checksOff }))
|
|
3229
3583
|
function renderUpdateButton() {
|
|
3230
3584
|
const b = $('update')
|
|
3231
3585
|
const u = updState()
|
|
@@ -3234,27 +3588,29 @@ const MAIN = String.raw `
|
|
|
3234
3588
|
const v = $('ver-top')
|
|
3235
3589
|
v.hidden = !BOOT.version
|
|
3236
3590
|
if (!v.hidden) {
|
|
3237
|
-
const newer = !!(u && u.available)
|
|
3591
|
+
const newer = !!(u && (u.available || skillDue(u)))
|
|
3238
3592
|
v.textContent = verLabel()
|
|
3239
3593
|
v.classList.toggle('has-update', newer)
|
|
3240
3594
|
v.disabled = !BOOT.updates
|
|
3241
|
-
const tip = !BOOT.updates ? '' :
|
|
3595
|
+
const tip = !BOOT.updates ? '' : u && u.available ? t('upd.labelLong', { v: u.latest, cur: u.current })
|
|
3596
|
+
: skillDue(u) ? t('upd.skillLabelLong', { s: skillFor(u), cur: u.current }) : t('upd.verTitle', { v: BOOT.version })
|
|
3242
3597
|
v.title = tip
|
|
3243
3598
|
if (tip) v.setAttribute('aria-label', tip)
|
|
3244
3599
|
else v.removeAttribute('aria-label')
|
|
3245
3600
|
}
|
|
3246
|
-
b.hidden = !(u && u.available)
|
|
3601
|
+
b.hidden = !(u && (u.available || skillDue(u)))
|
|
3247
3602
|
if (!b.hidden) {
|
|
3248
|
-
|
|
3249
|
-
b
|
|
3250
|
-
b.
|
|
3603
|
+
const long = u.available ? t('upd.labelLong', { v: u.latest, cur: u.current }) : t('upd.skillLabelLong', { s: skillFor(u), cur: u.current })
|
|
3604
|
+
setKids(b, [icon('arrowUp'), h('span', null, u.available ? t('upd.label', { v: u.latest }) : t('upd.skillLabel'))])
|
|
3605
|
+
b.setAttribute('aria-label', long)
|
|
3606
|
+
b.title = long
|
|
3251
3607
|
}
|
|
3252
3608
|
if (dlg.kind === 'update' && up && !up.busy) renderUpdate()
|
|
3253
3609
|
if (!vp.checking) renderVerPop()
|
|
3254
3610
|
}
|
|
3255
3611
|
function openUpdate() {
|
|
3256
3612
|
if (!BOOT.updates) return
|
|
3257
|
-
up = { busy: false, error: null, done: null, changed: false, autoPlan: null, autoBusy: false, waitUntil: 0, checking: false, checked: false }
|
|
3613
|
+
up = { busy: false, error: null, done: null, changed: false, skillDone: false, skillChanged: false, autoPlan: null, autoBusy: false, waitUntil: 0, checking: false, checked: false }
|
|
3258
3614
|
showDialog('update')
|
|
3259
3615
|
renderUpdate()
|
|
3260
3616
|
}
|
|
@@ -3281,6 +3637,27 @@ const MAIN = String.raw `
|
|
|
3281
3637
|
dlg.busy = false
|
|
3282
3638
|
renderUpdate()
|
|
3283
3639
|
}
|
|
3640
|
+
// The CLI is current and the skill is behind: update the skill alone, as sideby update --yes does.
|
|
3641
|
+
async function updSkillRun() {
|
|
3642
|
+
up.busy = true
|
|
3643
|
+
dlg.busy = true
|
|
3644
|
+
up.error = null
|
|
3645
|
+
up.skillChanged = false
|
|
3646
|
+
renderUpdate()
|
|
3647
|
+
try {
|
|
3648
|
+
const r = await api('/api/update', { confirm: true, skill: true })
|
|
3649
|
+
if (r.skillUpdated) {
|
|
3650
|
+
up.skillDone = true
|
|
3651
|
+
toast(t('upd.skillDoneOnly'))
|
|
3652
|
+
// The health check still lists the old skill: check again so its Findings go.
|
|
3653
|
+
if ((findingsNow() || []).some((f) => f.code === 'skill.outdated')) runCheck()
|
|
3654
|
+
} else up.skillChanged = true
|
|
3655
|
+
await load({ silent: true })
|
|
3656
|
+
} catch (e) { up.error = e }
|
|
3657
|
+
up.busy = false
|
|
3658
|
+
dlg.busy = false
|
|
3659
|
+
renderUpdate()
|
|
3660
|
+
}
|
|
3284
3661
|
// The version pill opens a small popover, not the dialog: the version, whether it is the newest, "Check for
|
|
3285
3662
|
// updates" in place, and the way into the dialog when there is something to do (update, Auto Update).
|
|
3286
3663
|
const vp = { checking: false, checked: false, error: null }
|
|
@@ -3304,9 +3681,9 @@ const MAIN = String.raw `
|
|
|
3304
3681
|
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) : '' })))
|
|
3305
3682
|
let status
|
|
3306
3683
|
if (!u) status = t('upd.loading')
|
|
3307
|
-
else if (u.available) status = null
|
|
3684
|
+
else if (u.available || skillOld(u)) status = null
|
|
3308
3685
|
else if (vp.checked && u.latest) status = t('upd.latestNow')
|
|
3309
|
-
else if (u.checksOff) status =
|
|
3686
|
+
else if (u.checksOff) status = offText(u)
|
|
3310
3687
|
else if (u.latest) status = t('upd.latest') + (u.checkedAt ? ' · ' + t('upd.checked', { ago: ago(u.checkedAt) }) : '')
|
|
3311
3688
|
else status = t('upd.notYet')
|
|
3312
3689
|
if (status) kids.push(h('p', { class: 'muted small' }, status))
|
|
@@ -3314,16 +3691,63 @@ const MAIN = String.raw `
|
|
|
3314
3691
|
kids.push(h('p', { class: 'small' }, t('upd.available', { v: u.latest }), ' ',
|
|
3315
3692
|
h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
|
|
3316
3693
|
kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: toDialog }, t('upd.open'))))
|
|
3694
|
+
} else if (skillOld(u)) {
|
|
3695
|
+
kids.push(h('p', { class: 'small' }, t('upd.skillBehind', { s: skillFor(u) })))
|
|
3696
|
+
kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm' + (skillDue(u) ? ' btn-primary' : ''), type: 'button', onclick: toDialog }, t('upd.open'))))
|
|
3317
3697
|
} else if (!BOOT.readOnly) {
|
|
3318
3698
|
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 },
|
|
3319
3699
|
vp.checking ? spinner() : icon('refresh'), vp.checking ? t('upd.checking') : t('upd.check'))))
|
|
3320
3700
|
}
|
|
3321
3701
|
if (vp.error) kids.push(errorNote(vp.error, null))
|
|
3322
|
-
|
|
3702
|
+
const sw = localSwitchKids(u)
|
|
3703
|
+
if (sw) kids.push(sw)
|
|
3704
|
+
if (u && !BOOT.readOnly && !isLocal(u))
|
|
3323
3705
|
kids.push(h('p', { class: 'muted small popover-foot' }, t(u.auto ? 'upd.autoIsOn' : 'upd.autoIsOff'), ' · ',
|
|
3324
3706
|
h('button', { class: 'link-btn', type: 'button', onclick: toDialog }, t('upd.settings'))))
|
|
3325
3707
|
return kids
|
|
3326
3708
|
}
|
|
3709
|
+
// The switch between a Local Build and the npm package (ADR-0024), only where the state offers it: the other side,
|
|
3710
|
+
// one button. After the switch the page waits for the server to run the new build and reloads into it.
|
|
3711
|
+
const vs = { busy: false, to: null, waitUntil: 0, stuck: false, error: null }
|
|
3712
|
+
function localSwitchKids(u) {
|
|
3713
|
+
const ls = u && u.localSwitch
|
|
3714
|
+
if (!ls || BOOT.readOnly) return null
|
|
3715
|
+
// While it switches the state already shows the other side; keep the words of the side clicked.
|
|
3716
|
+
const toNpm = vs.busy ? vs.to === 'npm' : ls.running === 'local'
|
|
3717
|
+
if (!toNpm && !ls.local) return null
|
|
3718
|
+
const l = ls.local
|
|
3719
|
+
const build = l ? l.version + '-dev · ' + (l.commit || '?') + (l.dirty ? '+' : '') : ''
|
|
3720
|
+
const kids = [h('p', { class: 'muted small' }, toNpm ? t('loc.toNpmHint') : t('loc.toLocalHint', { b: build, at: l.builtAt ? clock(l.builtAt) : '' }))]
|
|
3721
|
+
if (vs.stuck) {
|
|
3722
|
+
kids.push(h('p', { class: 'small' }, t('loc.stuck')))
|
|
3723
|
+
kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: () => location.reload() }, icon('refresh'), t('loc.reload'))))
|
|
3724
|
+
} else {
|
|
3725
|
+
kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm', type: 'button', disabled: vs.busy, 'aria-busy': vs.busy ? 'true' : null, onclick: () => localSwitchTo(toNpm ? 'npm' : 'local') },
|
|
3726
|
+
vs.busy ? spinner() : icon('swap'), vs.busy ? t('loc.switching') : t(toNpm ? 'loc.toNpm' : 'loc.toLocal'))))
|
|
3727
|
+
}
|
|
3728
|
+
if (vs.error) kids.push(errorNote(vs.error, null))
|
|
3729
|
+
return h('div', { class: 'popover-foot' }, kids)
|
|
3730
|
+
}
|
|
3731
|
+
async function localSwitchTo(to) {
|
|
3732
|
+
vs.busy = true
|
|
3733
|
+
vs.to = to
|
|
3734
|
+
vs.error = null
|
|
3735
|
+
vs.stuck = false
|
|
3736
|
+
renderVerPop()
|
|
3737
|
+
try {
|
|
3738
|
+
await api('/api/local', { to, confirm: true })
|
|
3739
|
+
} catch (e) {
|
|
3740
|
+
// A background Panel restarting from the new code may drop this answer: wait for the build all the same.
|
|
3741
|
+
if (!e.network) { vs.busy = false; vs.error = e; renderVerPop(); return }
|
|
3742
|
+
}
|
|
3743
|
+
vs.waitUntil = Date.now() + 20000
|
|
3744
|
+
localWait()
|
|
3745
|
+
}
|
|
3746
|
+
function localWait() {
|
|
3747
|
+
if (S.newBuild) { location.reload(); return }
|
|
3748
|
+
if (Date.now() > vs.waitUntil) { vs.busy = false; vs.stuck = true; renderVerPop(); return }
|
|
3749
|
+
setTimeout(() => { void load({ silent: true }).then(localWait, localWait) }, 1500)
|
|
3750
|
+
}
|
|
3327
3751
|
async function verPopCheck() {
|
|
3328
3752
|
vp.checking = true
|
|
3329
3753
|
vp.error = null
|
|
@@ -3381,13 +3805,17 @@ const MAIN = String.raw `
|
|
|
3381
3805
|
up.autoBusy = false
|
|
3382
3806
|
renderUpdate()
|
|
3383
3807
|
}
|
|
3384
|
-
// The installed sideby skill follows the CLI (spec §3.23):
|
|
3808
|
+
// The installed sideby skill follows the CLI (spec §3.23): one quiet line when the Update brings it along, the
|
|
3809
|
+
// reason and the command only when sideby may not.
|
|
3385
3810
|
function updSkill(u) {
|
|
3386
3811
|
const s = u.skillUpdate
|
|
3387
|
-
if (!s) return [
|
|
3388
|
-
if (!s.outdated) return []
|
|
3812
|
+
if (!s || !s.outdated) return []
|
|
3389
3813
|
if (s.canRun) return [h('p', { class: 'muted small' }, t('upd.skillToo'))]
|
|
3390
|
-
return [h('p', { class: 'small' }, t('upd.skillManaged', { why: s
|
|
3814
|
+
return [h('p', { class: 'small' }, t('upd.skillManaged', { why: why(s) })), codeCopy(s.command)]
|
|
3815
|
+
}
|
|
3816
|
+
// What a person runs by hand, behind a click: the default path is the button.
|
|
3817
|
+
function updManual(cmds) {
|
|
3818
|
+
return h('details', { class: 'tune-details' }, h('summary', null, t('upd.manual')), cmds)
|
|
3391
3819
|
}
|
|
3392
3820
|
function renderUpdate() {
|
|
3393
3821
|
if (!up || dlg.kind !== 'update') return
|
|
@@ -3404,30 +3832,50 @@ const MAIN = String.raw `
|
|
|
3404
3832
|
body.push(h('p', { class: 'muted small' }, Date.now() > up.waitUntil ? t('upd.restartHint') : [spinner(), ' ', t('upd.reloading')]))
|
|
3405
3833
|
if (up.done.skillUpdated) body.push(h('p', { class: 'small' }, t('upd.skillDone')))
|
|
3406
3834
|
else body.push(...updSkill(u))
|
|
3835
|
+
} else if (up.skillDone && !skillOld(u)) {
|
|
3836
|
+
body.push(h('p', { class: 'note' }, t('upd.skillDoneOnly')))
|
|
3837
|
+
} else if (u.available && isLocal(u)) {
|
|
3838
|
+
// A Local Build (ADR-0024): one way back to npm, which brings the skill along.
|
|
3839
|
+
body.push(h('p', null, t('upd.localAvail', { v: u.latest }), ' ',
|
|
3840
|
+
h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
|
|
3841
|
+
body.push(codeCopy(u.command))
|
|
3407
3842
|
} else if (u.available) {
|
|
3408
3843
|
if (up.changed) body.push(h('p', { class: 'note note-warn' }, t('upd.changed', { v: u.latest })))
|
|
3409
3844
|
body.push(h('p', null, h('b', null, t('upd.available', { v: u.latest })), ' ',
|
|
3410
3845
|
h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
|
|
3411
3846
|
if (u.canRun) {
|
|
3412
|
-
body.push(
|
|
3847
|
+
body.push(...updSkill(u))
|
|
3413
3848
|
body.push(h('p', { class: 'muted small' }, t('upd.after')))
|
|
3849
|
+
body.push(updManual([h('p', { class: 'small' }, t('upd.runs')), codeCopy(u.command)]))
|
|
3414
3850
|
} else {
|
|
3415
|
-
body.push(h('p', { class: 'note note-warn' }, t('upd.cannot', { why: u.method
|
|
3851
|
+
body.push(h('p', { class: 'note note-warn' }, t('upd.cannot', { why: why(u.method) || u.method.kind })))
|
|
3416
3852
|
body.push(codeCopy(u.command))
|
|
3853
|
+
// An install by hand leaves the skill behind; sideby update (or Auto Update) brings it along afterwards.
|
|
3854
|
+
const s = u.skillUpdate
|
|
3855
|
+
if (s && s.outdated && s.canRun) body.push(h('p', { class: 'muted small' }, t('upd.skillLater')))
|
|
3856
|
+
else body.push(...updSkill(u))
|
|
3417
3857
|
}
|
|
3418
|
-
|
|
3858
|
+
} else if (skillOld(u)) {
|
|
3859
|
+
if (up.skillChanged) body.push(h('p', { class: 'note note-warn' }, t('upd.skillChanged')))
|
|
3860
|
+
const s = u.skillUpdate
|
|
3861
|
+
body.push(h('p', null, h('b', null, t('upd.skillBehind', { s: skillFor(u) }))))
|
|
3862
|
+
if (s.canRun) {
|
|
3863
|
+
body.push(h('p', { class: 'muted small' }, t('upd.skillWhat', { cur: u.current })))
|
|
3864
|
+
body.push(updManual([codeCopy(s.command)]))
|
|
3865
|
+
} else body.push(h('p', { class: 'small' }, t('upd.skillManaged', { why: why(s) })), codeCopy(s.command))
|
|
3419
3866
|
} else if (up.checked && u.latest) {
|
|
3420
3867
|
body.push(h('p', { class: 'note' }, t('upd.latestNow')))
|
|
3421
3868
|
} else if (u.checksOff) {
|
|
3422
|
-
body.push(h('p', { class: 'muted small' },
|
|
3869
|
+
body.push(h('p', { class: 'muted small' }, offText(u)))
|
|
3423
3870
|
} else if (u.latest) {
|
|
3424
3871
|
body.push(h('p', { class: 'muted small' }, t('upd.latest') + (u.checkedAt ? ' · ' + t('upd.checked', { ago: ago(u.checkedAt) }) : '')))
|
|
3425
3872
|
} else body.push(h('p', { class: 'muted small' }, t('upd.notYet')))
|
|
3426
3873
|
// A read-only Panel sends no request and writes nothing, so it cannot check (spec §3.23).
|
|
3427
|
-
if (!BOOT.readOnly && !up.done && !u.available)
|
|
3874
|
+
if (!BOOT.readOnly && !up.done && !u.available && !skillDue(u))
|
|
3428
3875
|
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 },
|
|
3429
3876
|
up.checking ? spinner() : icon('refresh'), up.checking ? t('upd.checking') : t('upd.check'))))
|
|
3430
|
-
if (!BOOT.readOnly && !up.done) {
|
|
3877
|
+
if (!BOOT.readOnly && !up.done && isLocal(u)) body.push(h('p', { class: 'muted small' }, t('upd.autoLocal')))
|
|
3878
|
+
else if (!BOOT.readOnly && !up.done) {
|
|
3431
3879
|
const box = h('input', { type: 'checkbox', disabled: up.autoBusy || !u.method.canUpdate })
|
|
3432
3880
|
box.checked = up.autoPlan ? up.autoPlan.auto : u.auto
|
|
3433
3881
|
box.addEventListener('change', () => updAuto(box.checked))
|
|
@@ -3439,8 +3887,9 @@ const MAIN = String.raw `
|
|
|
3439
3887
|
if (BOOT.readOnly) body.push(h('p', { class: 'note' }, t('banner.readOnly')))
|
|
3440
3888
|
if (up.error) body.push(errorNote(up.error, null))
|
|
3441
3889
|
const foot = [h('button', { class: 'btn', type: 'button', disabled: up.busy, onclick: () => dlg.el.close() }, t('btn.close'))]
|
|
3442
|
-
|
|
3443
|
-
|
|
3890
|
+
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)
|
|
3891
|
+
if (u.available && u.canRun && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.now', { v: u.latest }), updRun))
|
|
3892
|
+
else if (skillDue(u) && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.skillNow'), updSkillRun))
|
|
3444
3893
|
setKids(dlg.el, dialogFrame(t('upd.title'), null, body, foot))
|
|
3445
3894
|
}
|
|
3446
3895
|
|
|
@@ -3453,6 +3902,11 @@ const MAIN = String.raw `
|
|
|
3453
3902
|
$('refresh').addEventListener('click', refreshAll)
|
|
3454
3903
|
$('accounts').addEventListener('dragover', onDragOver)
|
|
3455
3904
|
$('accounts').addEventListener('drop', onDrop)
|
|
3905
|
+
// Escape leaves the arrange mode, as "Done" does, unless a menu or dialog takes it first.
|
|
3906
|
+
document.addEventListener('keydown', (e) => {
|
|
3907
|
+
if (e.key !== 'Escape' || e.defaultPrevented || !ui.arrange || menu.el || (dlg.el && dlg.el.open) || e.target.closest('input, select, textarea')) return
|
|
3908
|
+
endArrange()
|
|
3909
|
+
})
|
|
3456
3910
|
$('settings').addEventListener('click', () => openPopover($('settings'), 'settings', t('set.button'), settingsPop, 'end'))
|
|
3457
3911
|
applyTheme()
|
|
3458
3912
|
renderStatic()
|