@vimoxshah/tokenflow 1.1.1 → 1.2.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/CHANGELOG.md +228 -0
- package/Dockerfile.team +20 -0
- package/README.md +30 -11
- package/bin/tokenflow.js +147 -12
- package/design/tokens.yaml +330 -0
- package/docs/architecture.md +5 -4
- package/docs/cli.md +204 -0
- package/docs/configuration.md +117 -2
- package/docs/design-system.md +187 -0
- package/docs/exports-and-budgets.md +85 -0
- package/docs/guard-codex.md +132 -0
- package/docs/ledger.md +144 -0
- package/docs/live-mode.md +40 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/receipts-aurora-dark.png +0 -0
- package/docs/providers-otel.md +179 -0
- package/docs/providers.md +54 -1
- package/docs/receipt-schema.md +74 -0
- package/docs/roadmap.md +182 -0
- package/docs/team-server.md +170 -0
- package/docs/ui-views.md +322 -0
- package/package.json +7 -2
- package/schemas/receipt.v0.json +160 -0
- package/scripts/build-dmg.sh +11 -2
- package/scripts/build-menubar-app.sh +58 -7
- package/scripts/design-build.js +475 -0
- package/src/analytics/anatomy.js +467 -0
- package/src/analytics/branch-compare.js +159 -0
- package/src/analytics/cache-health.js +141 -0
- package/src/analytics/live-view.js +266 -0
- package/src/analytics/receipt-schema.js +214 -0
- package/src/analytics/receipt.js +709 -0
- package/src/analytics/rhythm.js +184 -0
- package/src/analytics/whatif.js +263 -0
- package/src/commands/budget-scopes.js +133 -0
- package/src/commands/doctor-checks.js +400 -0
- package/src/commands/guard.js +531 -0
- package/src/commands/hooks.js +238 -0
- package/src/commands/pricing-diff.js +316 -0
- package/src/commands/receipt.js +226 -0
- package/src/commands/team-serve.js +407 -0
- package/src/commands/week.js +86 -0
- package/src/core/annotations.js +97 -0
- package/src/core/budget.js +33 -0
- package/src/core/bundle.js +45 -2
- package/src/core/ingest.js +33 -0
- package/src/core/live-status.js +227 -2
- package/src/core/policy.js +103 -0
- package/src/core/receipt-note.js +123 -0
- package/src/core/repo.js +64 -0
- package/src/core/sync.js +163 -26
- package/src/core/team.js +0 -0
- package/src/export/html-snapshot.js +28 -1
- package/src/export/menubar.js +21 -0
- package/src/export/receipt-card.js +210 -0
- package/src/export/week-card.js +185 -0
- package/src/providers/mock/index.js +383 -52
- package/src/providers/openai/index.js +31 -1
- package/src/providers/otel/index.js +656 -0
- package/src/server/routes/annotations.js +42 -0
- package/src/server/routes/cache-health.js +95 -0
- package/src/server/routes/index.js +54 -0
- package/src/server/routes/session.js +157 -0
- package/src/server/server.js +47 -1
- package/src/ui/app.js +541 -308
- package/src/ui/charts.js +95 -0
- package/src/ui/first-run.js +144 -0
- package/src/ui/index.html +4 -1
- package/src/ui/palette.js +335 -0
- package/src/ui/styles/anatomy.css +117 -0
- package/src/ui/styles/annotations.css +40 -0
- package/src/ui/styles/branches.css +99 -0
- package/src/ui/styles/cache.css +6 -0
- package/src/ui/styles/first-run.css +31 -0
- package/src/ui/styles/live.css +100 -0
- package/src/ui/styles/palette.css +85 -0
- package/src/ui/styles/rhythm.css +8 -0
- package/src/ui/styles/whatif.css +55 -0
- package/src/ui/styles.css +303 -196
- package/src/ui/views/anatomy.js +567 -0
- package/src/ui/views/annotations.js +121 -0
- package/src/ui/views/branches.js +304 -0
- package/src/ui/views/cache.js +232 -0
- package/src/ui/views/index.js +85 -0
- package/src/ui/views/live.js +683 -0
- package/src/ui/views/rhythm.js +206 -0
- package/src/ui/views/whatif.js +196 -0
package/src/ui/charts.js
CHANGED
|
@@ -19,6 +19,82 @@ export const SERIES_VARS = [
|
|
|
19
19
|
export const OTHER_COLOR = 'var(--text-muted)';
|
|
20
20
|
export const SEQ = ['var(--seq-1)', 'var(--seq-2)', 'var(--seq-3)', 'var(--seq-4)', 'var(--seq-5)', 'var(--seq-6)', 'var(--seq-7)'];
|
|
21
21
|
|
|
22
|
+
// ------------------------------------------------------------- annotations --
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Day annotations every daily time-series chart overlays. Views must not
|
|
26
|
+
* import app.js, and app.js never learns about a view's data, so this is a
|
|
27
|
+
* module-level list rather than a per-call prop: the Annotations view calls
|
|
28
|
+
* `setAnnotations` after it loads (or reloads) the file, and every chart
|
|
29
|
+
* rendered afterwards — on any tab — picks up the current list. A saved
|
|
30
|
+
* snapshot seeds it at load time from the embedded bundle, since there is no
|
|
31
|
+
* "after it loads" moment there; a live dashboard starts empty until the
|
|
32
|
+
* Annotations tab has been visited at least once in the session.
|
|
33
|
+
*/
|
|
34
|
+
let currentAnnotations = (typeof window !== 'undefined' && Array.isArray(window.__TOKENFLOW_BUNDLE__?.annotations))
|
|
35
|
+
? window.__TOKENFLOW_BUNDLE__.annotations
|
|
36
|
+
: [];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Set the annotations drawn on every daily time-series chart from here on.
|
|
40
|
+
* @param {{id:string,date:string,text:string}[]} list
|
|
41
|
+
*/
|
|
42
|
+
export function setAnnotations(list) {
|
|
43
|
+
currentAnnotations = Array.isArray(list) ? list : [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
47
|
+
|
|
48
|
+
function daysBetweenIso(a, b) {
|
|
49
|
+
return Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86400000);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* True when `data` is a calendar-day series: its first two buckets are
|
|
54
|
+
* exactly one day apart. A week or month bucket also has a date-shaped key
|
|
55
|
+
* (the start of the period), so key format alone cannot tell daily from
|
|
56
|
+
* weekly/monthly — only the spacing between buckets can.
|
|
57
|
+
*/
|
|
58
|
+
function isDailyAxis(data) {
|
|
59
|
+
if (!data || !data.length || !ISO_DATE_RE.test(data[0].key)) return false;
|
|
60
|
+
if (data.length === 1) return true;
|
|
61
|
+
return ISO_DATE_RE.test(data[1].key) && daysBetweenIso(data[0].key, data[1].key) === 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Map annotations onto x-axis bucket indices for a daily time-series chart.
|
|
66
|
+
* An annotation whose date falls outside [first bucket, last bucket] is
|
|
67
|
+
* dropped; a chart whose axis is not daily (week/month buckets) yields no
|
|
68
|
+
* markers at all, since there is no single bucket a day maps onto.
|
|
69
|
+
* @param {{key:string}[]} data bucket rows, ascending by key
|
|
70
|
+
* @param {{id:string,date:string,text:string}[]} annotations
|
|
71
|
+
* @returns {{index:number, annotation:object}[]}
|
|
72
|
+
*/
|
|
73
|
+
export function annotationMarkers(data, annotations) {
|
|
74
|
+
if (!isDailyAxis(data) || !annotations || !annotations.length) return [];
|
|
75
|
+
const first = data[0].key;
|
|
76
|
+
const last = data[data.length - 1].key;
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const a of annotations) {
|
|
79
|
+
if (!a || !a.date || a.date < first || a.date > last) continue;
|
|
80
|
+
let i = daysBetweenIso(first, a.date);
|
|
81
|
+
if (i < 0 || i >= data.length || data[i].key !== a.date) {
|
|
82
|
+
// The series has a gap for this exact day (e.g. a non-contiguous
|
|
83
|
+
// fill): fall back to the first bucket at or after the date so the
|
|
84
|
+
// marker still lands inside the visible range instead of vanishing.
|
|
85
|
+
i = data.findIndex((d) => d.key >= a.date);
|
|
86
|
+
if (i === -1) i = data.length - 1;
|
|
87
|
+
}
|
|
88
|
+
out.push({ index: i, annotation: a });
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function truncateLabel(s, max) {
|
|
94
|
+
const str = String(s ?? '');
|
|
95
|
+
return str.length > max ? `${str.slice(0, Math.max(0, max - 1))}…` : str;
|
|
96
|
+
}
|
|
97
|
+
|
|
22
98
|
/**
|
|
23
99
|
* Stable colour assignment: a key always gets the same slot for the lifetime
|
|
24
100
|
* of the page, so a filter that removes series 2 leaves series 3's colour
|
|
@@ -171,6 +247,8 @@ export function niceTicks(min, max, count = 5) {
|
|
|
171
247
|
* @param {string} [o.ariaLabel] accessible name for the <svg>
|
|
172
248
|
* @param {boolean} [o.fillArea] shade the area under the line
|
|
173
249
|
* @param {boolean} [o.endLabel] label the final value at the line's end
|
|
250
|
+
* @param {{id:string,date:string,text:string}[]} [o.annotations] overrides the
|
|
251
|
+
* module-level list set by `setAnnotations`; only a test needs this.
|
|
174
252
|
*/
|
|
175
253
|
export function timeSeries(o) {
|
|
176
254
|
const H = o.height || 300;
|
|
@@ -292,6 +370,23 @@ export function timeSeries(o) {
|
|
|
292
370
|
}
|
|
293
371
|
}
|
|
294
372
|
|
|
373
|
+
// marked days: a dashed hairline in --text-muted, never a series colour, so
|
|
374
|
+
// an annotation never reads as "another data series".
|
|
375
|
+
const marks = annotationMarkers(data, o.annotations || currentAnnotations);
|
|
376
|
+
for (const { index, annotation } of marks) {
|
|
377
|
+
const ax = x(index);
|
|
378
|
+
root.appendChild(svg('line', {
|
|
379
|
+
class: 'annot-mark', x1: ax, x2: ax, y1: M.t, y2: M.t + ih,
|
|
380
|
+
stroke: 'var(--text-muted)', 'stroke-width': 1, 'stroke-dasharray': '4 3',
|
|
381
|
+
}));
|
|
382
|
+
const label = svg('text', {
|
|
383
|
+
class: 'annot-label', x: Math.min(ax + 4, W - M.r), y: M.t + 10, 'text-anchor': 'start',
|
|
384
|
+
style: 'fill: var(--text-muted); font-size: 10px',
|
|
385
|
+
}, [txt(truncateLabel(annotation.text, 24))]);
|
|
386
|
+
label.appendChild(svg('title', {}, [txt(annotation.text)]));
|
|
387
|
+
root.appendChild(label);
|
|
388
|
+
}
|
|
389
|
+
|
|
295
390
|
// ---- hover layer: crosshair snapping to the nearest X -------------------
|
|
296
391
|
const cross = svg('line', { class: 'crosshair', y1: M.t, y2: M.t + ih, opacity: 0 });
|
|
297
392
|
const dots = svg('g', { opacity: 0 });
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The first-run screen: a one-time modal explaining what TokenFlow read off
|
|
3
|
+
* this machine, the moment a fresh install (or a fresh version) opens the
|
|
4
|
+
* live dashboard.
|
|
5
|
+
*
|
|
6
|
+
* Never shown in a snapshot — app.js only calls `maybeShowFirstRun` when
|
|
7
|
+
* `!SNAPSHOT`, since a saved file has nothing new to report and no
|
|
8
|
+
* `/api/providers` to ask. `/api/providers` itself is optional here too: a
|
|
9
|
+
* failed or slow fetch must never hold up the dialog, so the source rows
|
|
10
|
+
* (built from `bundle.meta.sources`, already in hand) render immediately and
|
|
11
|
+
* the "detected but no data yet" section is appended only if the fetch
|
|
12
|
+
* succeeds.
|
|
13
|
+
*
|
|
14
|
+
* Like palette.js, this module imports nothing from app.js or charts.js —
|
|
15
|
+
* only the leaf formatter module core/units.js, which has no DOM dependency
|
|
16
|
+
* of its own.
|
|
17
|
+
*/
|
|
18
|
+
import { int, shortDate } from '../core/units.js';
|
|
19
|
+
|
|
20
|
+
const SEEN_KEY = 'tokenflow-first-run-seen-version';
|
|
21
|
+
const OPT_OUT_KEY = 'tokenflow-first-run-opt-out';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether the screen should show for `appVersion`.
|
|
25
|
+
*
|
|
26
|
+
* "Open the dashboard" marks only THIS version seen — an upgrade to a new
|
|
27
|
+
* appVersion shows it again, since a new version may read new sources.
|
|
28
|
+
* "Do not show again" is a separate, permanent flag that survives upgrades.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} appVersion
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
function shouldShow(appVersion) {
|
|
34
|
+
try {
|
|
35
|
+
if (localStorage.getItem(OPT_OUT_KEY) === '1') return false;
|
|
36
|
+
return localStorage.getItem(SEEN_KEY) !== appVersion;
|
|
37
|
+
} catch {
|
|
38
|
+
// Private mode / file:// — the flag cannot be read. Showing the screen
|
|
39
|
+
// once more costs less than silently hiding it forever by assuming "yes,
|
|
40
|
+
// seen" on a store that cannot say either way.
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function markSeen(appVersion) {
|
|
46
|
+
try { localStorage.setItem(SEEN_KEY, appVersion); } catch { /* private mode / file:// — it may show again next time, which is safe */ }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function markOptOut() {
|
|
50
|
+
try { localStorage.setItem(OPT_OUT_KEY, '1'); } catch { /* private mode / file:// — it may show again next time, which is safe */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One plain sentence for a source already in `bundle.meta.sources`. */
|
|
54
|
+
function sourceSentence(s) {
|
|
55
|
+
const records = s.records || 0;
|
|
56
|
+
const files = s.files || 0;
|
|
57
|
+
const cov = s.coverage;
|
|
58
|
+
const range = cov && cov.from ? `covering ${shortDate(cov.from)} to ${shortDate(cov.to)}` : 'with no dated coverage yet';
|
|
59
|
+
// The demo generator (and any other source with no backing files on disk)
|
|
60
|
+
// reports records with files:0 — "from 0 files" would read as broken, so
|
|
61
|
+
// the clause is dropped rather than stating a record count with no file.
|
|
62
|
+
const fromFiles = files > 0 ? ` from ${int(files)} file${files === 1 ? '' : 's'}` : '';
|
|
63
|
+
return `${s.id}: ${int(records)} record${records === 1 ? '' : 's'}${fromFiles}, ${range}.`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One plain sentence for an adapter /api/providers detected that contributed nothing to `sources`. */
|
|
67
|
+
function providerSentence(p) {
|
|
68
|
+
const label = p.name || p.id;
|
|
69
|
+
if (p.available === false) return `${label}: not found on this machine.${p.detail ? ` ${p.detail}` : ''}`;
|
|
70
|
+
if (p.enabled === false) return `${label}: found, but turned off in the current config.`;
|
|
71
|
+
return `${label}: found, but nothing has been read from it yet.`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @typedef {object} FirstRunContext
|
|
76
|
+
* @property {(tag:string, attrs?:object, kids?:any)=>HTMLElement} el
|
|
77
|
+
* @property {string} appVersion
|
|
78
|
+
* @property {{id:string,records:number,files:number,coverage:{from:string|null,to:string|null}|null}[]} sources bundle.meta.sources
|
|
79
|
+
* @property {()=>Promise<{providers:object[]}|null>} fetchProviders resolves to null (never rejects) on any failure.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Show the first-run modal if it has not been seen for `ctx.appVersion` and
|
|
84
|
+
* the user has not opted out permanently. Call only when `!SNAPSHOT` — this
|
|
85
|
+
* module does not check that itself, so it stays usable from a test with a
|
|
86
|
+
* plain object ctx.
|
|
87
|
+
*
|
|
88
|
+
* @param {FirstRunContext} ctx
|
|
89
|
+
*/
|
|
90
|
+
export function maybeShowFirstRun(ctx) {
|
|
91
|
+
if (!shouldShow(ctx.appVersion)) return;
|
|
92
|
+
const { el } = ctx;
|
|
93
|
+
|
|
94
|
+
const dialog = /** @type {HTMLDialogElement} */ (document.createElement('dialog'));
|
|
95
|
+
dialog.className = 'first-run-dialog';
|
|
96
|
+
dialog.setAttribute('aria-label', 'What TokenFlow found on this machine');
|
|
97
|
+
|
|
98
|
+
dialog.appendChild(el('div', { class: 'd-head' }, [el('h2', { text: 'What TokenFlow found on this machine' })]));
|
|
99
|
+
|
|
100
|
+
const body = el('div', { class: 'd-body first-run-body' });
|
|
101
|
+
const list = el('div', { class: 'first-run-list' });
|
|
102
|
+
const sources = ctx.sources || [];
|
|
103
|
+
if (sources.length) {
|
|
104
|
+
for (const s of sources) list.appendChild(el('p', { class: 'first-run-row', text: sourceSentence(s) }));
|
|
105
|
+
} else {
|
|
106
|
+
list.appendChild(el('p', { class: 'first-run-row muted', text: 'No source has read a record yet.' }));
|
|
107
|
+
}
|
|
108
|
+
body.appendChild(list);
|
|
109
|
+
const missingHost = el('div', { class: 'first-run-missing' });
|
|
110
|
+
body.appendChild(missingHost);
|
|
111
|
+
body.appendChild(el('p', { class: 'first-run-closing', text: 'Everything here was read from logs already on this machine. Nothing was sent anywhere.' }));
|
|
112
|
+
dialog.appendChild(body);
|
|
113
|
+
|
|
114
|
+
const foot = el('div', { class: 'd-foot' });
|
|
115
|
+
const dismissBtn = el('button', { class: 'btn ghost', text: 'Do not show again' });
|
|
116
|
+
const openBtn = el('button', { class: 'btn primary', text: 'Open the dashboard' });
|
|
117
|
+
foot.appendChild(dismissBtn);
|
|
118
|
+
foot.appendChild(openBtn);
|
|
119
|
+
dialog.appendChild(foot);
|
|
120
|
+
|
|
121
|
+
const done = (opt) => {
|
|
122
|
+
if (opt) markOptOut(); else markSeen(ctx.appVersion);
|
|
123
|
+
dialog.close();
|
|
124
|
+
};
|
|
125
|
+
openBtn.addEventListener('click', () => done(false));
|
|
126
|
+
dismissBtn.addEventListener('click', () => done(true));
|
|
127
|
+
// Escape and any other native dismissal count as "seen", not a permanent
|
|
128
|
+
// opt-out — same as clicking "Open the dashboard".
|
|
129
|
+
dialog.addEventListener('cancel', () => markSeen(ctx.appVersion));
|
|
130
|
+
dialog.addEventListener('close', () => dialog.remove());
|
|
131
|
+
|
|
132
|
+
document.body.appendChild(dialog);
|
|
133
|
+
dialog.showModal();
|
|
134
|
+
openBtn.focus();
|
|
135
|
+
|
|
136
|
+
const sourceIds = new Set(sources.map((s) => s.id));
|
|
137
|
+
ctx.fetchProviders().then((res) => {
|
|
138
|
+
if (!dialog.isConnected || !res || !Array.isArray(res.providers)) return;
|
|
139
|
+
const missing = res.providers.filter((p) => !sourceIds.has(p.id));
|
|
140
|
+
if (!missing.length) return;
|
|
141
|
+
missingHost.appendChild(el('div', { class: 'sec-title', text: 'Detected, but nothing read yet' }));
|
|
142
|
+
for (const p of missing) missingHost.appendChild(el('p', { class: 'first-run-row', text: providerSentence(p) }));
|
|
143
|
+
}).catch(() => { /* never blocks: the dialog already shows what bundle.meta.sources knows */ });
|
|
144
|
+
}
|
package/src/ui/index.html
CHANGED
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
<div id="banners"></div>
|
|
25
25
|
<div id="filters" class="filters"></div>
|
|
26
26
|
<div id="crumbs" class="crumbs"></div>
|
|
27
|
-
<
|
|
27
|
+
<div class="tabbar">
|
|
28
|
+
<nav class="tabs" id="tabs" role="tablist"></nav>
|
|
29
|
+
<button class="chip palette-chip" id="palette-chip" type="button" aria-label="Open command palette (Cmd K or Ctrl K)" title="Command palette (⌘K)">⌘K</button>
|
|
30
|
+
</div>
|
|
28
31
|
<main id="view" style="padding-top:14px"></main>
|
|
29
32
|
<footer class="muted" style="padding:32px 0 0;font-size:11.5px;line-height:1.7" id="footer"></footer>
|
|
30
33
|
</div>
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command palette: Cmd+K / Ctrl+K, or the "⌘K" chip at the end of the tab bar.
|
|
3
|
+
*
|
|
4
|
+
* Split in two on purpose. `rankCommands` is pure — no DOM, no `window`, no
|
|
5
|
+
* `localStorage` — so test/palette.test.js can cover the matching rules with
|
|
6
|
+
* plain node:test. `mountPalette` is the browser half: it builds a native
|
|
7
|
+
* `<dialog>` (free modality, top-layer stacking, and — per the HTML living
|
|
8
|
+
* standard — focus returns to whatever had it when the dialog opened, the
|
|
9
|
+
* moment `close()` runs) and wires up typing, the arrow keys, Enter and Esc.
|
|
10
|
+
*
|
|
11
|
+
* Everything the palette needs to act — the tab list, the quick ranges, the
|
|
12
|
+
* skins, exporting, refreshing — arrives through the `ctx` app.js builds in
|
|
13
|
+
* `mountPalette(ctx)`. palette.js never imports app.js or charts.js, so it
|
|
14
|
+
* stays importable from a plain node:test run with no DOM at all.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const RECENT_KEY = 'tokenflow-palette-recent';
|
|
18
|
+
const RECENT_MAX = 8;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Rank `commands` against `query`, dropping anything that does not match.
|
|
22
|
+
*
|
|
23
|
+
* Four match tiers, best first:
|
|
24
|
+
* 0. the label equals the query exactly
|
|
25
|
+
* 1. the label starts with the query
|
|
26
|
+
* 2. a word inside the label starts with the query ("word-prefix")
|
|
27
|
+
* 3. the query is a subsequence of the label (each character of the query
|
|
28
|
+
* appears in the label, in order, not necessarily adjacent)
|
|
29
|
+
* A command's optional `keywords` string is searched the same way, but a
|
|
30
|
+
* keyword hit never outranks any label hit. Ties (same tier, same position)
|
|
31
|
+
* keep the order `commands` arrived in, so the caller controls what counts as
|
|
32
|
+
* "first" among equals.
|
|
33
|
+
*
|
|
34
|
+
* An empty (or whitespace-only) query skips matching entirely: it returns the
|
|
35
|
+
* commands whose id appears in `recent`, most-recently-used first, then every
|
|
36
|
+
* other command in its original order. An id in `recent` that names no
|
|
37
|
+
* current command is ignored — the caller is not responsible for pruning a
|
|
38
|
+
* stale recent list (a "Refresh data" run before a snapshot load, say).
|
|
39
|
+
*
|
|
40
|
+
* @param {{id:string,label:string,keywords?:string}[]} commands
|
|
41
|
+
* @param {string} [query]
|
|
42
|
+
* @param {string[]} [recent] command ids, most recent first
|
|
43
|
+
* @returns {object[]} the subset of `commands` that match, in rank order
|
|
44
|
+
*/
|
|
45
|
+
export function rankCommands(commands, query, recent = []) {
|
|
46
|
+
const list = Array.isArray(commands) ? commands : [];
|
|
47
|
+
const q = String(query ?? '').trim().toLowerCase();
|
|
48
|
+
|
|
49
|
+
if (!q) {
|
|
50
|
+
const recencyOf = new Map();
|
|
51
|
+
(Array.isArray(recent) ? recent : []).forEach((id, i) => { if (!recencyOf.has(id)) recencyOf.set(id, i); });
|
|
52
|
+
const known = list.filter((c) => recencyOf.has(c.id));
|
|
53
|
+
known.sort((a, b) => recencyOf.get(a.id) - recencyOf.get(b.id));
|
|
54
|
+
const rest = list.filter((c) => !recencyOf.has(c.id));
|
|
55
|
+
return [...known, ...rest];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const scored = [];
|
|
59
|
+
list.forEach((c, index) => {
|
|
60
|
+
const score = matchScore(c, q);
|
|
61
|
+
if (score !== null) scored.push({ c, score, index });
|
|
62
|
+
});
|
|
63
|
+
scored.sort((a, b) => a.score - b.score || a.index - b.index);
|
|
64
|
+
return scored.map((s) => s.c);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Lowest (best) tier score for one command against a lowercased query, or null for no match. */
|
|
68
|
+
function matchScore(command, q) {
|
|
69
|
+
const texts = [String(command.label || '').toLowerCase(), String(command.keywords || '').toLowerCase()];
|
|
70
|
+
let best = null;
|
|
71
|
+
texts.forEach((text, hi) => {
|
|
72
|
+
if (!text) return;
|
|
73
|
+
const tierBase = hi * 4000; // any label match outranks every keyword-only match
|
|
74
|
+
let tier = null;
|
|
75
|
+
if (text === q) tier = 0;
|
|
76
|
+
else if (text.startsWith(q)) tier = 1000;
|
|
77
|
+
else {
|
|
78
|
+
const wp = wordPrefixIndex(text, q);
|
|
79
|
+
if (wp !== -1) tier = 2000 + wp;
|
|
80
|
+
else {
|
|
81
|
+
const sub = subsequenceIndex(text, q);
|
|
82
|
+
if (sub !== -1) tier = 3000 + sub;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (tier !== null) {
|
|
86
|
+
const total = tierBase + tier;
|
|
87
|
+
if (best === null || total < best) best = total;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
return best;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Index (in `text`) of the first word that starts with `q`, or -1. */
|
|
94
|
+
function wordPrefixIndex(text, q) {
|
|
95
|
+
const words = text.split(/[^a-z0-9]+/i);
|
|
96
|
+
let at = 0;
|
|
97
|
+
for (const w of words) {
|
|
98
|
+
if (w && w.startsWith(q)) return at;
|
|
99
|
+
at += w.length + 1;
|
|
100
|
+
}
|
|
101
|
+
return -1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Index of the first character of `text`'s earliest in-order match of every character in `q`, or -1. */
|
|
105
|
+
function subsequenceIndex(text, q) {
|
|
106
|
+
let from = 0;
|
|
107
|
+
let first = -1;
|
|
108
|
+
for (let i = 0; i < q.length; i++) {
|
|
109
|
+
const at = text.indexOf(q[i], from);
|
|
110
|
+
if (at === -1) return -1;
|
|
111
|
+
if (first === -1) first = at;
|
|
112
|
+
from = at + 1;
|
|
113
|
+
}
|
|
114
|
+
return first;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The persisted "recently run" command ids, most recent first. Never throws. */
|
|
118
|
+
function loadRecent() {
|
|
119
|
+
try {
|
|
120
|
+
const raw = localStorage.getItem(RECENT_KEY);
|
|
121
|
+
const arr = raw ? JSON.parse(raw) : [];
|
|
122
|
+
return Array.isArray(arr) ? arr.filter((x) => typeof x === 'string') : [];
|
|
123
|
+
} catch {
|
|
124
|
+
// Private mode, or a saved snapshot opened from file:// — recent commands
|
|
125
|
+
// simply do not persist between visits.
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function saveRecent(ids) {
|
|
131
|
+
try {
|
|
132
|
+
localStorage.setItem(RECENT_KEY, JSON.stringify(ids.slice(0, RECENT_MAX)));
|
|
133
|
+
} catch { /* private mode / file:// — see loadRecent() */ }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whether `target` is a field a real keystroke could be editing (input, textarea, contenteditable). */
|
|
137
|
+
function isEditable(target) {
|
|
138
|
+
if (!target || typeof target.tagName !== 'string') return false;
|
|
139
|
+
const tag = target.tagName;
|
|
140
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || !!target.isContentEditable;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @typedef {object} PaletteContext
|
|
145
|
+
* @property {(tag:string, attrs?:object, kids?:any)=>HTMLElement} el
|
|
146
|
+
* @property {()=>{id:string,label:string}[]} getTabs the merged tab list, read at open time.
|
|
147
|
+
* @property {(id:string)=>void} goToTab
|
|
148
|
+
* @property {{id:string,label:string}[]} ranges the quick ranges (no "custom").
|
|
149
|
+
* @property {(id:string)=>void} applyRange
|
|
150
|
+
* @property {{id:string,name:string}[]} skins
|
|
151
|
+
* @property {(id:string)=>void} setSkin
|
|
152
|
+
* @property {{id:string,label:string}[]} modes
|
|
153
|
+
* @property {(id:string)=>void} setMode
|
|
154
|
+
* @property {()=>boolean} canRefresh false in a snapshot: there is nothing to refresh.
|
|
155
|
+
* @property {()=>void} refresh
|
|
156
|
+
* @property {()=>void} exportCsv
|
|
157
|
+
* @property {()=>void} exportHtmlInfo
|
|
158
|
+
* @property {()=>void} clearFilters
|
|
159
|
+
* @property {()=>void} copyDeepLink
|
|
160
|
+
* @property {()=>(HTMLElement|null)} [activeTabButton] a focus fallback if the original target is gone.
|
|
161
|
+
*/
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Mount the command palette once. Returns `{ open, close, isOpen }` so app.js
|
|
165
|
+
* can drive it from the "⌘K" chip as well as the keyboard shortcut.
|
|
166
|
+
*
|
|
167
|
+
* @param {PaletteContext} ctx
|
|
168
|
+
*/
|
|
169
|
+
export function mountPalette(ctx) {
|
|
170
|
+
const { el } = ctx;
|
|
171
|
+
let recent = loadRecent();
|
|
172
|
+
let allCommands = [];
|
|
173
|
+
let items = [];
|
|
174
|
+
let activeIndex = 0;
|
|
175
|
+
let lastFocused = null;
|
|
176
|
+
|
|
177
|
+
const dialog = /** @type {HTMLDialogElement} */ (document.createElement('dialog'));
|
|
178
|
+
dialog.className = 'palette-dialog';
|
|
179
|
+
dialog.setAttribute('aria-label', 'Command palette');
|
|
180
|
+
dialog.setAttribute('role', 'dialog');
|
|
181
|
+
|
|
182
|
+
const input = /** @type {HTMLInputElement} */ (el('input', {
|
|
183
|
+
type: 'text', class: 'palette-input', placeholder: 'Type a command…',
|
|
184
|
+
role: 'combobox', 'aria-expanded': 'true', 'aria-controls': 'palette-list', 'aria-autocomplete': 'list',
|
|
185
|
+
}));
|
|
186
|
+
const list = el('div', { class: 'palette-list', id: 'palette-list', role: 'listbox' });
|
|
187
|
+
const empty = el('div', { class: 'palette-empty', text: 'No matching commands' });
|
|
188
|
+
|
|
189
|
+
dialog.appendChild(input);
|
|
190
|
+
dialog.appendChild(list);
|
|
191
|
+
dialog.appendChild(empty);
|
|
192
|
+
document.body.appendChild(dialog);
|
|
193
|
+
|
|
194
|
+
function buildCommands() {
|
|
195
|
+
const cmds = [];
|
|
196
|
+
for (const t of ctx.getTabs()) {
|
|
197
|
+
cmds.push({ id: `tab:${t.id}`, label: t.label, group: 'Go to tab', keywords: 'tab view', run: () => ctx.goToTab(t.id) });
|
|
198
|
+
}
|
|
199
|
+
for (const r of ctx.ranges) {
|
|
200
|
+
cmds.push({ id: `range:${r.id}`, label: r.label, group: 'Quick range', keywords: 'range date filter', run: () => ctx.applyRange(r.id) });
|
|
201
|
+
}
|
|
202
|
+
for (const s of ctx.skins) {
|
|
203
|
+
cmds.push({ id: `skin:${s.id}`, label: `Skin: ${s.name}`, group: 'Appearance', keywords: 'theme skin appearance colour color', run: () => ctx.setSkin(s.id) });
|
|
204
|
+
}
|
|
205
|
+
for (const m of ctx.modes) {
|
|
206
|
+
cmds.push({ id: `mode:${m.id}`, label: `Mode: ${m.label}`, group: 'Appearance', keywords: 'theme mode appearance', run: () => ctx.setMode(m.id) });
|
|
207
|
+
}
|
|
208
|
+
cmds.push({ id: 'export-csv', label: 'Export CSV', group: 'Export', keywords: 'csv download export', run: () => ctx.exportCsv() });
|
|
209
|
+
cmds.push({ id: 'export-html', label: 'Export HTML snapshot', group: 'Export', keywords: 'html snapshot offline export', run: () => ctx.exportHtmlInfo() });
|
|
210
|
+
if (ctx.canRefresh()) {
|
|
211
|
+
cmds.push({ id: 'refresh', label: 'Refresh data', group: 'Actions', keywords: 'refresh reload rescan', run: () => ctx.refresh() });
|
|
212
|
+
}
|
|
213
|
+
cmds.push({ id: 'clear-filters', label: 'Clear filters', group: 'Actions', keywords: 'reset clear filters', run: () => ctx.clearFilters() });
|
|
214
|
+
cmds.push({ id: 'copy-link', label: 'Copy deep link', group: 'Actions', keywords: 'link share url copy', run: () => ctx.copyDeepLink() });
|
|
215
|
+
return cmds;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** DOM rows in the same order as `items`, so setActive() never has to rebuild the list to move the highlight. */
|
|
219
|
+
let rowEls = [];
|
|
220
|
+
|
|
221
|
+
/** Rebuild the list from the current query. Only this touches `list.textContent`, so the arrow keys and a hover never reset scroll position. */
|
|
222
|
+
function rebuild() {
|
|
223
|
+
items = rankCommands(allCommands, input.value, recent);
|
|
224
|
+
list.textContent = '';
|
|
225
|
+
rowEls = [];
|
|
226
|
+
empty.style.display = items.length ? 'none' : '';
|
|
227
|
+
activeIndex = items.length ? Math.min(activeIndex, items.length - 1) : 0;
|
|
228
|
+
items.forEach((c, i) => {
|
|
229
|
+
const row = el('div', {
|
|
230
|
+
class: 'palette-row', role: 'option', id: `palette-opt-${i}`, 'aria-selected': 'false',
|
|
231
|
+
}, [
|
|
232
|
+
el('span', { class: 'palette-row-label', text: c.label }),
|
|
233
|
+
c.group ? el('span', { class: 'palette-row-group', text: c.group }) : null,
|
|
234
|
+
]);
|
|
235
|
+
row.addEventListener('mousemove', () => setActive(i));
|
|
236
|
+
row.addEventListener('mousedown', (ev) => { ev.preventDefault(); runIndex(i); });
|
|
237
|
+
list.appendChild(row);
|
|
238
|
+
rowEls.push(row);
|
|
239
|
+
});
|
|
240
|
+
setActive(activeIndex);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Move the highlight to row `i`, clamped to the list's bounds. Only toggles
|
|
245
|
+
* classes/attributes on the rows rebuild() already built, and scrolls the
|
|
246
|
+
* new row into view — no transition either way, so the highlight jumps
|
|
247
|
+
* under the arrow keys instead of trailing them.
|
|
248
|
+
*/
|
|
249
|
+
function setActive(i) {
|
|
250
|
+
if (!items.length) { input.removeAttribute('aria-activedescendant'); return; }
|
|
251
|
+
activeIndex = Math.max(0, Math.min(i, items.length - 1));
|
|
252
|
+
rowEls.forEach((row, ri) => {
|
|
253
|
+
const on = ri === activeIndex;
|
|
254
|
+
row.classList.toggle('active', on);
|
|
255
|
+
row.setAttribute('aria-selected', String(on));
|
|
256
|
+
});
|
|
257
|
+
input.setAttribute('aria-activedescendant', `palette-opt-${activeIndex}`);
|
|
258
|
+
rowEls[activeIndex].scrollIntoView({ block: 'nearest' });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function remember(id) {
|
|
262
|
+
recent = [id, ...recent.filter((r) => r !== id)].slice(0, RECENT_MAX);
|
|
263
|
+
saveRecent(recent);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Refocus `target` if it is still in the document, else the active tab button, so focus never lands on `<body>`. */
|
|
267
|
+
function restoreFocus(target) {
|
|
268
|
+
const t = target && document.body.contains(target) ? target : (ctx.activeTabButton && ctx.activeTabButton());
|
|
269
|
+
if (t && typeof t.focus === 'function') t.focus();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function runIndex(i) {
|
|
273
|
+
const c = items[i];
|
|
274
|
+
if (!c) return;
|
|
275
|
+
remember(c.id);
|
|
276
|
+
const before = lastFocused;
|
|
277
|
+
closePalette();
|
|
278
|
+
// A command's own action (switching tabs, applying a range) re-renders
|
|
279
|
+
// the page and can detach whatever `before` pointed at, so the fallback
|
|
280
|
+
// in restoreFocus runs AFTER the action, not before it.
|
|
281
|
+
try { c.run(); } finally { restoreFocus(before); }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function openPalette() {
|
|
285
|
+
if (dialog.open) { input.focus(); return; }
|
|
286
|
+
lastFocused = document.activeElement;
|
|
287
|
+
allCommands = buildCommands();
|
|
288
|
+
input.value = '';
|
|
289
|
+
activeIndex = 0;
|
|
290
|
+
rebuild();
|
|
291
|
+
dialog.showModal();
|
|
292
|
+
input.focus();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function closePalette() {
|
|
296
|
+
if (!dialog.open) return;
|
|
297
|
+
dialog.close();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
input.addEventListener('input', () => { activeIndex = 0; rebuild(); });
|
|
301
|
+
input.addEventListener('keydown', (ev) => {
|
|
302
|
+
if (ev.key === 'ArrowDown') { ev.preventDefault(); setActive(activeIndex + 1); }
|
|
303
|
+
else if (ev.key === 'ArrowUp') { ev.preventDefault(); setActive(activeIndex - 1); }
|
|
304
|
+
else if (ev.key === 'Enter') { ev.preventDefault(); runIndex(activeIndex); }
|
|
305
|
+
else if (ev.key === 'Escape') { ev.preventDefault(); const before = lastFocused; closePalette(); restoreFocus(before); }
|
|
306
|
+
});
|
|
307
|
+
// Clicking the backdrop lands a click on the dialog element itself (the
|
|
308
|
+
// backdrop is not part of the interactive tree), never on `input` or `list`.
|
|
309
|
+
dialog.addEventListener('mousedown', (ev) => { if (ev.target === dialog) { const before = lastFocused; closePalette(); restoreFocus(before); } });
|
|
310
|
+
|
|
311
|
+
window.addEventListener('keydown', (ev) => {
|
|
312
|
+
const k = ev.key ? ev.key.toLowerCase() : '';
|
|
313
|
+
if (k !== 'k' || (!ev.metaKey && !ev.ctrlKey) || ev.altKey || ev.repeat) return;
|
|
314
|
+
if (dialog.open) {
|
|
315
|
+
ev.preventDefault();
|
|
316
|
+
const before = lastFocused; closePalette(); restoreFocus(before);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
// Ctrl+K is a real editing shortcut (macOS "kill to end of line") in any
|
|
320
|
+
// other text field on the page — the explorer search, a filter input.
|
|
321
|
+
// Meta+K carries no such meaning anywhere, so it always opens the
|
|
322
|
+
// palette; a bare Ctrl+K only opens it when focus is not already in a
|
|
323
|
+
// field that wants it. The palette's own input can never be `ev.target`
|
|
324
|
+
// here: it is inert while the dialog is closed.
|
|
325
|
+
if (ev.ctrlKey && !ev.metaKey && isEditable(ev.target)) return;
|
|
326
|
+
// A native <dialog> occupies the browser's top layer; opening a second
|
|
327
|
+
// one on top of an already-open one (the shared export/pricing modal)
|
|
328
|
+
// would render underneath it and look broken, so leave it alone.
|
|
329
|
+
if (document.querySelector('dialog[open]')) return;
|
|
330
|
+
ev.preventDefault();
|
|
331
|
+
openPalette();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
return { open: openPalette, close: closePalette, isOpen: () => dialog.open };
|
|
335
|
+
}
|