ui: Add QuantizedSlices plugin Port SwiPerf trace comparison tool as a Perfetto plugin. Supports trace import, quantized slice visualization, clustering, cross-trace comparison, and CSV export. Change-Id: Iacd11a9f85bdae21f06362807799d0c16443d2bf
diff --git a/ui/src/core/default_plugins.ts b/ui/src/core/default_plugins.ts index 9cef762..d31eba4 100644 --- a/ui/src/core/default_plugins.ts +++ b/ui/src/core/default_plugins.ts
@@ -33,6 +33,7 @@ 'com.android.AndroidStartup', 'com.android.Bluetooth', 'com.android.ContainedTraces', + 'com.google.QuantizedSlices', 'com.android.CpuPerUid', 'com.android.CujFrameDebugTrack', 'com.android.DayExplorer',
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/cluster_tabs.ts b/ui/src/plugins/com.google.QuantizedSlices/components/cluster_tabs.ts new file mode 100644 index 0000000..22ef9b1 --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/components/cluster_tabs.ts
@@ -0,0 +1,72 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {Tabs} from '../../../widgets/tabs'; +import type {TabsTab} from '../../../widgets/tabs'; +import { + S, + activeCluster, + addCluster, + removeCluster, + renameCluster, + switchCluster, +} from '../state'; +import type {Cluster} from '../state'; + +function renderTabTitle(cl: Cluster): string { + const count = cl.traces.length; + return count > 1 ? `${cl.name} (${count})` : cl.name; +} + +export interface ClusterTabsAttrs { + // Content to render for the active cluster tab. + readonly contentForCluster?: (cl: Cluster) => m.Children; +} + +export class ClusterTabs implements m.ClassComponent<ClusterTabsAttrs> { + view({attrs}: m.CVnode<ClusterTabsAttrs>): m.Children { + if (S.clusters.length === 0) return null; + + const cl = activeCluster(); + const contentFn = attrs.contentForCluster; + + const tabs: TabsTab[] = S.clusters.map((c) => ({ + key: c.id, + title: renderTabTitle(c), + content: contentFn && c.id === cl?.id ? contentFn(c) : null, + closeButton: true, + })); + + return m(Tabs, { + className: 'qs-cluster-tabs', + tabs, + activeTabKey: S.activeClusterId ?? undefined, + onTabChange: (key: string) => { + switchCluster(key); + }, + onTabClose: (key: string) => { + removeCluster(key); + }, + onTabRename: (key: string, newTitle: string) => { + renameCluster(key, newTitle); + }, + }); + } +} + +/** Helper: create a new empty cluster (for an "add tab" button). */ +export function addEmptyCluster(name?: string): void { + addCluster(name ?? 'New cluster', []); +}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/cross_compare_modal.ts b/ui/src/plugins/com.google.QuantizedSlices/components/cross_compare_modal.ts new file mode 100644 index 0000000..adc618f --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/components/cross_compare_modal.ts
@@ -0,0 +1,660 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {Button, ButtonVariant} from '../../../widgets/button'; +import {Intent} from '../../../widgets/common'; +import type {Cluster, TraceState} from '../state'; +import { + getCrossCompareState, + closeCrossCompare, + recordCrossComparison, + applyCrossCompareResults, + resetCrossCompare, + ensureCache, + updateSlider, + undoCrossComparison, + discardCrossCompareTrace, + skipCrossComparison, +} from '../state'; +import {getProgress, getResults} from '../models/cross_compare'; +import type {CrossCompareState} from '../models/cross_compare'; +import {MiniTimeline} from './mini_timeline'; +import {SummaryTables} from './summary_tables'; +import {fmtDur} from '../utils/format'; +import {buildTraceLink} from '../utils/export'; +import type {SortState} from '../models/types'; + +// -- Module-level state -- + +let keyHandler: ((e: KeyboardEvent) => void) | null = null; +let ccSliderPct = 100; +let lastPairKey: string | null = null; + +// Anchor mode: when set, this trace stays on screen and others rotate against it +let anchorKey: string | null = null; +let anchorSide: 'left' | 'right' | null = null; + +// Review screen state +let reviewPairIdx = 0; + +// Sort state for summary tables inside panels +const panelSortState: Record<string, SortState> = {}; + +// -- Slider helpers -- + +function updateBothSliders(cl: Cluster, pct: number): void { + ccSliderPct = pct; + const state = getCrossCompareState(); + if (!state?.currentPair) return; + const frac = pct / 100; + for (const key of state.currentPair) { + const ts = findTrace(cl, key); + if (!ts) continue; + ensureCache(ts); + const target = Math.max(2, Math.round(2 + (ts.origN - 2) * frac)); + updateSlider(ts, target); + } +} + +// -- Lookup maps (rebuilt once per render cycle) -- + +let traceMap: Map<string, TraceState> | null = null; +let indexMap: Map<string, number> | null = null; +let mapClusterId: string | null = null; + +function ensureMaps(cl: Cluster): void { + if (mapClusterId === cl.id && traceMap) return; + traceMap = new Map(); + indexMap = new Map(); + cl.traces.forEach((ts, i) => { + traceMap!.set(ts._key, ts); + indexMap!.set(ts._key, i); + }); + mapClusterId = cl.id; +} + +function findTrace(cl: Cluster, key: string): TraceState | undefined { + ensureMaps(cl); + return traceMap!.get(key); +} + +function traceIndex(cl: Cluster, key: string): number { + ensureMaps(cl); + return indexMap!.get(key) ?? -1; +} + +// -- Anchor helpers -- + +function toggleAnchor(key: string, side: 'left' | 'right'): void { + if (anchorKey === key && anchorSide === side) { + anchorKey = null; + anchorSide = null; + } else { + anchorKey = key; + anchorSide = side; + } + const state = getCrossCompareState(); + if (state) state.selectedSide = null; + m.redraw(); +} + +function clearAnchor(): void { + anchorKey = null; + anchorSide = null; +} + +/** After advancing, ensure anchor stays on the correct side of currentPair. */ +function ensureAnchorSide(): void { + const state = getCrossCompareState(); + if (!anchorKey || !state?.currentPair) return; + // Clear anchor if anchor trace was discarded + if (state.discardedKeys.has(anchorKey)) { + clearAnchor(); + return; + } + const [a, b] = state.currentPair; + // Only swap if anchor is actually in this pair + if (anchorSide === 'left' && b === anchorKey && a !== anchorKey) { + state.currentPair = [b, a]; + } else if (anchorSide === 'right' && a === anchorKey && b !== anchorKey) { + state.currentPair = [b, a]; + } +} + +/** Whether the anchor trace is in the current pair. */ +function anchorActive(state: CrossCompareState | null): boolean { + return ( + !!anchorKey && !!state?.currentPair && state.currentPair.includes(anchorKey) + ); +} + +function discardSideForAnchor(): 'left' | 'right' | null { + if (anchorSide === 'left') return 'right'; + if (anchorSide === 'right') return 'left'; + return null; +} + +// -- Panel rendering -- + +function renderPanel( + cl: Cluster, + key: string, + side: 'left' | 'right', +): m.Children { + const ts = findTrace(cl, key); + if (!ts) return m('.qs-cc-panel', 'Trace not found'); + ensureCache(ts); + const isAnchor = anchorKey === key; + const state = getCrossCompareState(); + const aa = anchorActive(state); + const isSelected = !aa && state?.selectedSide === side; + + const panelClass = + '.qs-cc-panel' + + (isAnchor ? '.qs-cc-anchored' : isSelected ? '.qs-cc-selected' : ''); + + return m( + panelClass, + { + onclick: (e: Event) => { + e.stopPropagation(); + toggleAnchor(key, side); + }, + }, + [ + m('.qs-cc-panel-header', [ + m('span.qs-cc-panel-idx', `#${traceIndex(cl, key) + 1}`), + m('span.qs-cc-panel-pkg', ts.trace.package_name), + ts.trace.startup_dur + ? m('span.qs-cc-panel-dur', fmtDur(ts.trace.startup_dur)) + : null, + (() => { + const href = buildTraceLink( + ts.trace.trace_uuid, + ts.trace.package_name, + ); + return href + ? m( + 'a.qs-cc-trace-link', + { + href, + target: '_blank', + rel: 'noopener', + onclick: (e: Event) => e.stopPropagation(), + title: 'Open in trace viewer', + }, + '\u2197', + ) + : null; + })(), + isAnchor ? m('span.qs-cc-anchor-badge', 'anchor') : null, + ]), + m(MiniTimeline, {ts}), + m( + '.qs-cc-panel-detail', + m(SummaryTables, { + ts, + sortState: panelSortState, + }), + ), + ], + ); +} + +function renderReviewTraceRow(cl: Cluster, key: string): m.Children { + const ts = findTrace(cl, key); + if (!ts) return null; + ensureCache(ts); + const href = buildTraceLink(ts.trace.trace_uuid, ts.trace.package_name); + + return m('.qs-cc-review-row', [ + m('.qs-cc-review-row-header', [ + m('span.qs-cc-panel-idx', `#${traceIndex(cl, key) + 1}`), + m('span.qs-cc-panel-pkg', ts.trace.package_name), + ts.trace.startup_dur + ? m('span.qs-cc-panel-dur', fmtDur(ts.trace.startup_dur)) + : null, + href + ? m( + 'a.qs-cc-trace-link', + { + href, + target: '_blank', + rel: 'noopener', + title: 'Open in trace viewer', + }, + '\u2197', + ) + : null, + ]), + m(MiniTimeline, {ts}), + ]); +} + +// -- Review screen -- + +function buildPairings(n: number): Array<[number, number]> { + if (n < 2) return [[0, -1]]; + const pairs: Array<[number, number]> = []; + for (let p = 0; p < n; p++) { + for (let neg = 0; neg < n; neg++) { + if (neg !== p) pairs.push([p, neg]); + } + } + return pairs; +} + +function cycleReview(delta: number, groupCount: number): void { + const pairings = buildPairings(groupCount); + if (pairings.length <= 1) return; + reviewPairIdx = + (((reviewPairIdx + delta) % pairings.length) + pairings.length) % + pairings.length; + m.redraw(); +} + +function renderReview(cl: Cluster): m.Children { + const state = getCrossCompareState(); + if (!state) return null; + const {groups, discarded} = getResults(state); + + // Pure anchor: anchor's group vs all others combined. Two groups, no cycling. + const pureAnchor = + anchorKey !== null && groups.some((g) => g.includes(anchorKey!)); + let positiveGroup: string[]; + let negativeGroup: string[]; + let pairings: Array<[number, number]>; + let posIdx: number; + let negIdx: number; + + if (pureAnchor) { + const ai = groups.findIndex((g) => g.includes(anchorKey!)); + positiveGroup = groups[ai]; + negativeGroup = groups.flatMap((g, i) => (i === ai ? [] : g)); + posIdx = ai; + negIdx = -1; + pairings = [[posIdx, negIdx]]; + } else { + pairings = buildPairings(groups.length); + if (reviewPairIdx >= pairings.length) reviewPairIdx = 0; + [posIdx, negIdx] = pairings[reviewPairIdx]; + positiveGroup = groups[posIdx] ?? []; + negativeGroup = negIdx >= 0 ? groups[negIdx] ?? [] : []; + } + + return m('.qs-cc-review', [ + m('.qs-cc-review-split', [ + m('.qs-cc-review-panel', [ + m('.qs-cc-review-panel-header.qs-cc-negative', [ + m('span', 'Negative'), + m('span.qs-cc-review-count', `${negativeGroup.length}`), + ]), + m( + '.qs-cc-review-panel-body', + negativeGroup.map((key) => renderReviewTraceRow(cl, key)), + ), + ]), + m('.qs-cc-review-panel', [ + m('.qs-cc-review-panel-header.qs-cc-positive', [ + m('span', 'Positive'), + m('span.qs-cc-review-count', `${positiveGroup.length}`), + ]), + m( + '.qs-cc-review-panel-body', + positiveGroup.map((key) => renderReviewTraceRow(cl, key)), + ), + ]), + ]), + m('.qs-cc-review-nav', [ + pairings.length > 1 + ? m( + 'span.qs-cc-hint', + `Pairing ${reviewPairIdx + 1} / ${pairings.length}`, + ) + : null, + discarded.length > 0 + ? m('span.qs-cc-hint', `${discarded.length} discarded`) + : null, + ]), + pairings.length > 1 + ? m('.qs-cc-hint', '\u2190 \u2192 to cycle pairings') + : null, + m('.qs-cc-footer', [ + m(Button, { + label: 'Apply', + intent: Intent.Success, + variant: ButtonVariant.Filled, + onclick: () => applyCrossCompareResults(cl, posIdx, negIdx), + }), + m(Button, { + label: 'Undo', + variant: ButtonVariant.Outlined, + disabled: state.history.length === 0, + onclick: () => { + undoCrossComparison(anchorKey ?? undefined); + ensureAnchorSide(); + }, + }), + m(Button, { + label: 'Reset', + variant: ButtonVariant.Outlined, + onclick: () => { + resetCrossCompare(cl); + clearAnchor(); + }, + }), + m(Button, { + label: 'Close', + variant: ButtonVariant.Outlined, + onclick: closeCrossCompare, + }), + ]), + ]); +} + +// -- Main component -- + +export const CrossCompareModal: m.Component<{cl: Cluster}> = { + oncreate(vnode: m.VnodeDOM<{cl: Cluster}>) { + const getCl = () => vnode.attrs.cl; + keyHandler = (e: KeyboardEvent) => { + const target = e.target; + if (target instanceof HTMLElement) { + const tag = target.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + } + const state = getCrossCompareState(); + if (!state) return; + + if (e.key === 'Escape') { + closeCrossCompare(); + return; + } + if (e.key === 'z' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + undoCrossComparison(anchorKey ?? undefined); + ensureAnchorSide(); + return; + } + + if (state.isComplete) { + const {groups} = getResults(state); + if (e.key === 'ArrowLeft') { + cycleReview(-1, groups.length); + return; + } + if (e.key === 'ArrowRight') { + cycleReview(1, groups.length); + return; + } + return; + } + + // Comparison keys -- pass anchorKey so advancePair tries anchor-first + const ak = anchorKey ?? undefined; + const aa = anchorActive(state); + + if (e.key === 'p' || e.key === 'P') { + recordCrossComparison('positive', ak); + ensureAnchorSide(); + return; + } + if (e.key === 'n' || e.key === 'N') { + recordCrossComparison('negative', ak); + ensureAnchorSide(); + return; + } + if (e.key === 's' || e.key === 'S') { + skipCrossComparison(ak); + ensureAnchorSide(); + return; + } + if (e.key === 'd' || e.key === 'D') { + const side = aa ? discardSideForAnchor() : state.selectedSide; + if (side) { + discardCrossCompareTrace(getCl(), side, ak); + ensureAnchorSide(); + } + return; + } + + // Arrow keys: select side when anchor not active in current pair + if (!aa) { + if (e.key === 'ArrowLeft') { + state.selectedSide = 'left'; + m.redraw(); + return; + } + if (e.key === 'ArrowRight') { + state.selectedSide = 'right'; + m.redraw(); + return; + } + } + }; + document.addEventListener('keydown', keyHandler); + }, + + onremove() { + if (keyHandler) { + document.removeEventListener('keydown', keyHandler); + keyHandler = null; + } + }, + + onupdate(vnode: m.VnodeDOM<{cl: Cluster}>) { + const {cl} = vnode.attrs; + const state = getCrossCompareState(); + if (!state) return; + + // Apply slider to new pairs when they change. + const pairKey = state.currentPair + ? state.currentPair[0] + '|' + state.currentPair[1] + : null; + if (pairKey && pairKey !== lastPairKey) { + lastPairKey = pairKey; + if (ccSliderPct < 100) updateBothSliders(cl, ccSliderPct); + } + + // Clear anchor if discarded. + if (anchorKey && state.discardedKeys.has(anchorKey)) clearAnchor(); + }, + + view(vnode: m.Vnode<{cl: Cluster}>) { + const {cl} = vnode.attrs; + const state = getCrossCompareState(); + if (!state) return null; + + const progress = getProgress(state); + + const active = anchorActive(state); + const canDiscard = active || !!state.selectedSide; + + return m( + '.qs-cc-overlay', + { + onclick: () => { + if (anchorKey) { + clearAnchor(); + m.redraw(); + } else if (state.selectedSide) { + state.selectedSide = null; + m.redraw(); + } else { + closeCrossCompare(); + } + }, + }, + [ + m( + '.qs-cc-modal', + { + onclick: (e: Event) => { + e.stopPropagation(); + }, + }, + [ + // Header + m('.qs-cc-header', [ + m('span.qs-cc-title', 'Compare'), + m(Button, { + label: '\u00d7', + variant: ButtonVariant.Minimal, + compact: true, + onclick: closeCrossCompare, + title: 'Close (Esc)', + className: 'qs-cc-close', + }), + ]), + + // Progress bar + m('.qs-cc-progress', [ + m( + '.qs-cc-progress-text', + `${progress.completed} / ${progress.total} pairs resolved (${progress.pct}%)`, + ), + m('.qs-cc-progress-bar', [ + m('.qs-cc-progress-fill', { + style: {width: progress.pct + '%'}, + }), + ]), + ]), + + // Body + state.isComplete + ? renderReview(cl) + : state.currentPair + ? m('.qs-cc-body', [ + m('.qs-cc-pair', [ + renderPanel(cl, state.currentPair[0], 'left'), + m('.qs-cc-pair-divider', 'vs'), + renderPanel(cl, state.currentPair[1], 'right'), + ]), + m('.qs-cc-slider', [ + m('span.qs-cc-slider-label', 'Detail'), + m('span.qs-cc-slider-num', ccSliderPct + '%'), + m('input[type=range]', { + min: 1, + max: 100, + value: ccSliderPct, + step: 1, + oninput: (e: Event) => { + const el = e.target as HTMLInputElement; + updateBothSliders(cl, +el.value); + }, + onchange: (e: Event) => { + (e.target as HTMLElement).blur(); + }, + }), + ]), + m('.qs-cc-actions', [ + m(Button, { + label: 'Positive (P)', + intent: Intent.Success, + variant: ButtonVariant.Filled, + onclick: () => { + recordCrossComparison( + 'positive', + anchorKey ?? undefined, + ); + ensureAnchorSide(); + }, + }), + m(Button, { + label: 'Negative (N)', + intent: Intent.Danger, + variant: ButtonVariant.Filled, + onclick: () => { + recordCrossComparison( + 'negative', + anchorKey ?? undefined, + ); + ensureAnchorSide(); + }, + }), + m(Button, { + label: 'Skip (S)', + variant: ButtonVariant.Outlined, + onclick: () => { + skipCrossComparison(anchorKey ?? undefined); + ensureAnchorSide(); + }, + }), + m(Button, { + label: 'Discard (D)', + intent: Intent.Warning, + variant: ButtonVariant.Outlined, + disabled: !canDiscard, + title: active + ? 'Discard the non-anchor trace' + : state.selectedSide + ? 'Discard selected trace' + : 'Click a panel to anchor, or \u2190\u2192 to select', + onclick: () => { + const side = active + ? discardSideForAnchor() + : state.selectedSide; + if (side) { + discardCrossCompareTrace( + cl, + side, + anchorKey ?? undefined, + ); + ensureAnchorSide(); + } + }, + }), + m(Button, { + label: 'Undo (\u2318Z)', + variant: ButtonVariant.Outlined, + disabled: state.history.length === 0, + title: 'Undo (Ctrl+Z)', + onclick: () => { + undoCrossComparison(anchorKey ?? undefined); + ensureAnchorSide(); + }, + }), + ]), + m( + '.qs-cc-hint', + active + ? 'Anchored \u00b7 P/N/S/D apply to other trace \u00b7 click anchor to deselect' + : anchorKey + ? 'Anchor set (not in this pair) \u00b7 \u2190\u2192 to select \u00b7 click panel to re-anchor' + : 'Click a panel to anchor \u00b7 \u2190\u2192 to select for discard \u00b7 Esc close', + ), + m('.qs-cc-footer', [ + m(Button, { + label: 'Apply Current Results', + variant: ButtonVariant.Outlined, + onclick: () => applyCrossCompareResults(cl), + }), + m(Button, { + label: 'Reset', + variant: ButtonVariant.Outlined, + onclick: () => { + resetCrossCompare(cl); + clearAnchor(); + }, + }), + ]), + ]) + : null, + ], + ), + ], + ); + }, +};
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/import_panel.ts b/ui/src/plugins/com.google.QuantizedSlices/components/import_panel.ts new file mode 100644 index 0000000..d30cbf9 --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/components/import_panel.ts
@@ -0,0 +1,313 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {Button, ButtonVariant} from '../../../widgets/button'; +import {Spinner} from '../../../widgets/spinner'; +import {parseText} from '../parse'; +import { + S, + activeCluster, + addCluster, + loadSingleJson, + loadMultipleTraces, + exportSession, + importSessionDataAsync, +} from '../state'; +import type {TraceState} from '../state'; +import type {TraceEntry} from '../models/types'; + +let debounceTimer: ReturnType<typeof setTimeout> | null = null; + +function applyParsedTraces(traces: TraceEntry[], clusterName: string): void { + if (traces.length === 0) { + S.importMsg = {text: 'No valid traces found', ok: false}; + return; + } + if ( + traces.length === 1 && + traces[0].package_name === 'unknown' && + traces[0].startup_dur === 0 + ) { + loadSingleJson(traces[0].slices); + S.importMsg = {text: `Loaded ${traces[0].slices.length} slices`, ok: true}; + } else { + loadMultipleTraces(clusterName, traces); + S.importMsg = {text: `Loaded ${traces.length} traces`, ok: true}; + } +} + +function handleTextInput(text: string, clusterName: string): void { + text = text.trim(); + if (!text) return; + try { + applyParsedTraces(parseText(text), clusterName); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + S.importMsg = {text: message, ok: false}; + } + m.redraw(); +} + +function readFileAsText(file: File): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error(`Failed to read ${file.name}`)); + reader.readAsText(file); + }); +} + +async function loadFromFile(e: Event): Promise<void> { + const input = e.target as HTMLInputElement; + if (input.files === null || input.files.length === 0) return; + const fileList = Array.from(input.files).filter((f) => + /\.(json|txt|tsv|csv)$/i.test(f.name), + ); + input.value = ''; + if (fileList.length === 0) { + S.importMsg = {text: 'No supported files', ok: false}; + m.redraw(); + return; + } + + S.loadProgress = { + message: `Reading ${fileList.length} file${fileList.length > 1 ? 's' : ''}...`, + }; + m.redraw(); + + try { + let totalTraces = 0; + let totalFiles = 0; + for (let i = 0; i < fileList.length; i++) { + S.loadProgress = { + message: `Reading file ${i + 1}/${fileList.length}...`, + pct: (i / fileList.length) * 100, + }; + m.redraw(); + const content = await readFileAsText(fileList[i]); + const name = fileList[i].name.replace(/\.\w+$/, ''); + const traces = parseText(content); + if (traces.length > 0) { + addCluster(name, traces); + totalTraces += traces.length; + totalFiles++; + } + } + + S.importMsg = + totalTraces > 0 + ? { + text: `Loaded ${totalTraces} traces from ${totalFiles} file${totalFiles > 1 ? 's' : ''}`, + ok: true, + } + : {text: 'No valid traces found', ok: false}; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + S.importMsg = {text: message, ok: false}; + } + + S.loadProgress = null; + m.redraw(); +} + +function saveSession(): void { + const json = exportSession(); + const blob = new Blob([json], {type: 'application/json'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const date = new Date().toISOString().slice(0, 10); + a.download = `qs-session-${date}.json`; + a.click(); + // Delay revoke to allow the browser to start the download. + setTimeout(() => URL.revokeObjectURL(url), 60_000); + S.importMsg = {text: 'Session saved', ok: true}; + m.redraw(); +} + +async function loadSession(e: Event): Promise<void> { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + input.value = ''; + + S.loadProgress = {message: 'Reading session file...'}; + m.redraw(); + + try { + const json = await readFileAsText(file); + + S.loadProgress = {message: 'Parsing session...'}; + m.redraw(); + + const data = JSON.parse(json); + if (data.version !== 1) throw new Error('Unknown session version'); + + await importSessionDataAsync(data, (msg, pct) => { + S.loadProgress = {message: msg, pct}; + m.redraw(); + }); + S.importMsg = { + text: `Session restored (${S.clusters.length} clusters)`, + ok: true, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + S.importMsg = {text: `Session load failed: ${message}`, ok: false}; + } + + S.loadProgress = null; + m.redraw(); +} + +// Unique IDs for hidden file inputs (avoids global getElementById collisions). +const FILE_INPUT_ID = 'qs-file-input'; +const SESSION_INPUT_ID = 'qs-session-input'; + +export class ImportPanel implements m.ClassComponent { + view(): m.Children { + const loading = S.loadProgress !== null; + + return m('.qs-import', [ + m('.qs-import-hint', [ + 'Paste or import data. Supports: ', + m('code', '[{ts, dur, state}]'), + ' slices, ', + m('code', '{trace_uuid, slices}'), + ' traces, or TSV/CSV with a ', + m('code', 'slices/json/data/base64'), + ' column.', + ]), + + m('textarea.qs-json-area', { + placeholder: + 'Paste JSON / TSV / CSV \u2014 creates a new cluster tab\u2026', + spellcheck: false, + disabled: loading, + rows: 4, + onpaste: (e: ClipboardEvent) => { + const text = e.clipboardData?.getData('text/plain'); + if (!text?.trim()) return; + e.preventDefault(); + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + handleTextInput(text, 'Paste'); + }, 50); + }, + oninput: (e: Event) => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + const el = e.target as HTMLTextAreaElement; + if (el.value.trim()) { + const text = el.value; + el.value = ''; + handleTextInput(text, 'Paste'); + } + }, 600); + }, + }), + + // Loading indicator + loading + ? m('.qs-load-progress', [ + m(Spinner), + m('span.qs-progress-text', S.loadProgress!.message), + ]) + : null, + + m('.qs-import-actions', [ + m(Button, { + label: 'Import files\u2026', + variant: ButtonVariant.Outlined, + disabled: loading, + onclick: () => { + const el = document.getElementById(FILE_INPUT_ID); + if (el) (el as HTMLInputElement).click(); + }, + }), + m('input', { + id: FILE_INPUT_ID, + type: 'file', + accept: '.json,.txt,.tsv,.csv', + multiple: true, + style: {display: 'none'}, + onchange: loadFromFile, + }), + + S.clusters.length > 0 + ? m(Button, { + label: 'Save session', + variant: ButtonVariant.Outlined, + disabled: loading, + onclick: saveSession, + }) + : null, + + m(Button, { + label: 'Load session', + variant: ButtonVariant.Outlined, + disabled: loading, + onclick: () => { + const el = document.getElementById(SESSION_INPUT_ID); + if (el) (el as HTMLInputElement).click(); + }, + }), + m('input', { + id: SESSION_INPUT_ID, + type: 'file', + accept: '.json', + style: {display: 'none'}, + onchange: loadSession, + }), + + activeCluster() + ? m(Button, { + label: 'Copy compressed', + variant: ButtonVariant.Outlined, + onclick: () => { + const cl = activeCluster(); + if (!cl) return; + const ts = cl.traces[0] as TraceState | undefined; + if (ts === undefined) return; + const clean = ts.currentSeq.map((s) => ({ + ts: s.ts, + dur: s.dur, + name: s.name, + state: s.state, + depth: s.depth, + + io_wait: s.io_wait, + + blocked_function: s.blocked_function, + _merged: s._merged, + })); + navigator.clipboard.writeText(JSON.stringify(clean, null, 2)); + S.importMsg = {text: 'Copied to clipboard', ok: true}; + m.redraw(); + }, + }) + : null, + + // Status message + !loading && S.importMsg + ? m( + `span.${S.importMsg.ok ? 'qs-msg-ok' : 'qs-msg-err'}`, + S.importMsg.text, + ) + : null, + ]), + ]); + } +}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/mini_timeline.ts b/ui/src/plugins/com.google.QuantizedSlices/components/mini_timeline.ts new file mode 100644 index 0000000..a6da3e3 --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/components/mini_timeline.ts
@@ -0,0 +1,198 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {MergedSlice} from '../models/types'; +import {renderMiniCanvas, showTooltip, hideTooltip} from './timeline_canvas'; +import type {HitRect} from './timeline_canvas'; + +/** + * Minimal interface for the trace-level state that MiniTimeline needs. + * Avoids a direct import of the plugin's global state module, preventing + * circular dependencies. + */ +export interface MiniTimelineTrace { + currentSeq: MergedSlice[]; + totalDur: number; +} + +interface MiniTimelineAttrs { + ts: MiniTimelineTrace; + /** Optional callback invoked before rendering to ensure caches are warm. */ + ensureCache?: (ts: MiniTimelineTrace) => void; +} + +// Store hit rects + trace state per canvas — survives across lifecycle hooks. +const canvasHits = new WeakMap< + HTMLCanvasElement, + {hits: HitRect[]; totalDur: number; ts: MiniTimelineTrace} +>(); +const canvasHover = new WeakMap<HTMLCanvasElement, number | undefined>(); +type CanvasListeners = { + move: (e: MouseEvent) => void; + leave: (e: MouseEvent) => void; +}; +const canvasListeners = new WeakMap<HTMLCanvasElement, CanvasListeners>(); + +// Viewport-gated rendering via IntersectionObserver. +const isVisible = new WeakMap<Element, boolean>(); +const needsRender = new WeakSet<Element>(); + +let observer: IntersectionObserver | null = null; +let observerRefCount = 0; +const hasIO = typeof IntersectionObserver !== 'undefined'; + +function getObserver(): IntersectionObserver | null { + if (!hasIO) return null; + if (!observer) { + observer = new IntersectionObserver( + (entries) => { + let anyBecameVisible = false; + for (const entry of entries) { + const wasVisible = isVisible.get(entry.target) ?? false; + isVisible.set(entry.target, entry.isIntersecting); + if ( + entry.isIntersecting && + !wasVisible && + needsRender.has(entry.target) + ) { + needsRender.delete(entry.target); + anyBecameVisible = true; + } + } + if (anyBecameVisible) m.redraw(); + }, + {rootMargin: '200px 0px'}, + ); + } + observerRefCount++; + return observer; +} + +function releaseObserver(): void { + if (!hasIO) return; + observerRefCount--; + if (observerRefCount <= 0 && observer) { + observer.disconnect(); + observer = null; + observerRefCount = 0; + } +} + +function doRender( + dom: Element, + ts: MiniTimelineTrace, + ensureCache?: (ts: MiniTimelineTrace) => void, +): void { + if (hasIO && !isVisible.get(dom)) { + needsRender.add(dom); + return; + } + needsRender.delete(dom); + const canvas = dom.querySelector('canvas'); + if (!canvas) return; + if (ensureCache) ensureCache(ts); + const hits = renderMiniCanvas(canvas, { + seq: ts.currentSeq, + totalDur: ts.totalDur, + highlightIdx: canvasHover.get(canvas), + }); + canvasHits.set(canvas, {hits, totalDur: ts.totalDur, ts}); +} + +export const MiniTimeline: m.Component<MiniTimelineAttrs> = { + oncreate(vnode: m.VnodeDOM<MiniTimelineAttrs>) { + const obs = getObserver(); + if (obs) { + needsRender.add(vnode.dom); + obs.observe(vnode.dom); + } else { + doRender(vnode.dom, vnode.attrs.ts, vnode.attrs.ensureCache); + } + + const canvas = vnode.dom.querySelector('canvas'); + if (!canvas) return; + + const onMove = (e: MouseEvent): void => { + const cvs = e.target as HTMLCanvasElement; + const data = canvasHits.get(cvs); + if (!data) return; + showTooltip(e, data.hits, data.totalDur); + + // Find hovered segment index and re-render with highlight. + const rect = cvs.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + let hitIdx: number | undefined; + for (let i = data.hits.length - 1; i >= 0; i--) { + const r = data.hits[i]; + if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) { + hitIdx = i; + break; + } + } + if (hitIdx !== canvasHover.get(cvs)) { + canvasHover.set(cvs, hitIdx); + const hits = renderMiniCanvas(cvs, { + seq: data.ts.currentSeq, + totalDur: data.totalDur, + highlightIdx: hitIdx, + }); + canvasHits.set(cvs, {hits, totalDur: data.totalDur, ts: data.ts}); + } + }; + + const onLeave = (_e: MouseEvent): void => { + hideTooltip(); + const cvs = _e.target as HTMLCanvasElement; + const data = canvasHits.get(cvs); + if (data && canvasHover.get(cvs) != null) { + canvasHover.delete(cvs); + const hits = renderMiniCanvas(cvs, { + seq: data.ts.currentSeq, + totalDur: data.totalDur, + }); + canvasHits.set(cvs, {hits, totalDur: data.totalDur, ts: data.ts}); + } + }; + + canvas.addEventListener('mousemove', onMove); + canvas.addEventListener('mouseleave', onLeave); + canvasListeners.set(canvas, {move: onMove, leave: onLeave}); + }, + + onupdate(vnode: m.VnodeDOM<MiniTimelineAttrs>) { + doRender(vnode.dom, vnode.attrs.ts, vnode.attrs.ensureCache); + }, + + onremove(vnode: m.VnodeDOM<MiniTimelineAttrs>) { + const canvas = vnode.dom.querySelector('canvas'); + if (canvas) { + const listeners = canvasListeners.get(canvas); + if (listeners) { + canvas.removeEventListener('mousemove', listeners.move); + canvas.removeEventListener('mouseleave', listeners.leave); + canvasListeners.delete(canvas); + } + } + if (observer) observer.unobserve(vnode.dom); + isVisible.delete(vnode.dom); + needsRender.delete(vnode.dom); + releaseObserver(); + }, + + view() { + return m('.qs-mini-canvas', m('canvas')); + }, +};
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/summary_tables.ts b/ui/src/plugins/com.google.QuantizedSlices/components/summary_tables.ts new file mode 100644 index 0000000..d90c740 --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/components/summary_tables.ts
@@ -0,0 +1,274 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import { + MergedSlice, + SortState, + SummaryRow, + LONG_PKG_PREFIX, +} from '../models/types'; +import {stateColor, stateLabel, nameColor} from '../utils/colors'; +import {fmtDur, fmtPct} from '../utils/format'; + +function buildSummaryData(data: MergedSlice[]): { + stateMap: Record<string, {dur: number; count: number; color: string}>; + nameMap: Record<string, {dur: number; count: number}>; + bfMap: Record<string, {dur: number; count: number}>; +} { + const stateMap: Record<string, {dur: number; count: number; color: string}> = + {}; + const nameMap: Record<string, {dur: number; count: number}> = {}; + const bfMap: Record<string, {dur: number; count: number}> = {}; + + for (const d of data) { + const sk = stateLabel(d); + if (stateMap[sk] === undefined) { + stateMap[sk] = {dur: 0, count: 0, color: stateColor(d)}; + } + stateMap[sk].dur += d.dur; + stateMap[sk].count += 1; + + if (d.name !== null) { + if (nameMap[d.name] === undefined) { + nameMap[d.name] = {dur: 0, count: 0}; + } + nameMap[d.name].dur += d.dur; + nameMap[d.name].count += 1; + } + + if (d.blocked_function !== null) { + if (bfMap[d.blocked_function] === undefined) { + bfMap[d.blocked_function] = {dur: 0, count: 0}; + } + bfMap[d.blocked_function].dur += d.dur; + bfMap[d.blocked_function].count += 1; + } + } + return {stateMap, nameMap, bfMap}; +} + +type SortCol = 'label' | 'dur' | 'pct' | 'count'; + +function sortRows(rows: SummaryRow[], sort: SortState): SummaryRow[] { + return [...rows].sort((a, b) => { + const col = sort.col as SortCol; + let va: string | number = a[col]; + let vb: string | number = b[col]; + if (col === 'label') { + va = (va as string).toLowerCase(); + vb = (vb as string).toLowerCase(); + } + if (va < vb) return -1 * sort.dir; + if (va > vb) return 1 * sort.dir; + return 0; + }); +} + +interface TableCardAttrs { + id: string; + title: string; + rows: SummaryRow[]; + maxDur: number; + totalDur: number; + sortState: Record<string, SortState>; +} + +const TableCard: m.Component<TableCardAttrs> = { + view(vnode: m.Vnode<TableCardAttrs>) { + const {id, title, rows, maxDur, totalDur, sortState} = vnode.attrs; + if (sortState[id] === undefined) sortState[id] = {col: 'dur', dir: -1}; + const sort = sortState[id]; + const sorted = sortRows(rows, sort); + + const cols: Array<{col: string; label: string}> = [ + {col: 'label', label: 'Name'}, + {col: 'dur', label: 'Duration'}, + {col: 'pct', label: '%'}, + {col: 'count', label: 'Count'}, + ]; + + function onHeaderClick(col: string): void { + if (sort.col === col) { + sort.dir = (sort.dir === -1 ? 1 : -1) as 1 | -1; + } else { + sort.col = col; + sort.dir = col === 'label' ? 1 : -1; + } + } + + return m('.qs-card.qs-table-card', [ + m('.qs-table-card-head', m('span', title)), + m( + '.qs-table-scroll', + m('table.qs-summary', [ + m( + 'thead', + m( + 'tr', + cols.map((c) => + m( + 'th', + { + class: sort.col === c.col ? 'sorted' : '', + onclick: () => onHeaderClick(c.col), + }, + [ + c.label, + ' ', + m( + 'span.qs-sort-arrow', + sort.col === c.col + ? sort.dir === -1 + ? '\u2193' + : '\u2191' + : '\u2195', + ), + ], + ), + ), + ), + ), + m( + 'tbody', + sorted.map((row) => + m('tr', [ + m( + 'td', + m('.qs-cell-label', [ + row.color + ? m('span.qs-swatch', { + style: {background: row.color}, + }) + : null, + m('div', [ + m( + 'span.qs-name-text', + {title: row.label}, + row.short || row.label, + ), + m( + '.qs-bar-wrap', + m('.qs-bar-fill', { + style: { + width: ((row.dur / maxDur) * 100).toFixed(1) + '%', + background: row.color || 'var(--accent)', + }, + }), + ), + ]), + ]), + ), + m('td', fmtDur(row.dur)), + m('td', fmtPct(row.dur, totalDur)), + m('td', String(row.count)), + ]), + ), + ), + ]), + ), + ]); + }, +}; + +/** + * Minimal interface for the trace-level state that SummaryTables needs. + */ +export interface SummaryTrace { + currentSeq: MergedSlice[]; + totalDur: number; +} + +interface SummaryAttrs { + ts: SummaryTrace; + /** Mutable sort state record, shared with the parent to persist across renders. */ + sortState: Record<string, SortState>; +} + +export const SummaryTables: m.Component<SummaryAttrs> = { + view(vnode: m.Vnode<SummaryAttrs>) { + const {ts, sortState} = vnode.attrs; + const data = ts.currentSeq; + const totalDur = ts.totalDur; + const {stateMap, nameMap, bfMap} = buildSummaryData(data); + + const tables: m.Children[] = []; + + const maxState = Math.max(...Object.values(stateMap).map((v) => v.dur), 1); + const stateRows: SummaryRow[] = Object.entries(stateMap).map(([k, v]) => ({ + label: k, + short: k, + dur: v.dur, + count: v.count, + color: v.color, + pct: totalDur > 0 ? (v.dur / totalDur) * 100 : 0, + })); + tables.push( + m(TableCard, { + id: 'state', + title: 'States', + rows: stateRows, + maxDur: maxState, + totalDur, + sortState, + }), + ); + + if (Object.keys(nameMap).length) { + const maxName = Math.max(...Object.values(nameMap).map((v) => v.dur), 1); + const nameRows: SummaryRow[] = Object.entries(nameMap).map(([k, v]) => ({ + label: k, + short: k.replace(LONG_PKG_PREFIX, ''), + dur: v.dur, + count: v.count, + color: nameColor(k), + pct: totalDur > 0 ? (v.dur / totalDur) * 100 : 0, + })); + tables.push( + m(TableCard, { + id: 'name', + title: 'Names', + rows: nameRows, + maxDur: maxName, + totalDur, + sortState, + }), + ); + } + + if (Object.keys(bfMap).length) { + const maxBf = Math.max(...Object.values(bfMap).map((v) => v.dur), 1); + const bfRows: SummaryRow[] = Object.entries(bfMap).map(([k, v]) => ({ + label: k, + short: k, + dur: v.dur, + count: v.count, + color: '#c62828', + pct: totalDur > 0 ? (v.dur / totalDur) * 100 : 0, + })); + tables.push( + m(TableCard, { + id: 'bf', + title: 'Blocked functions', + rows: bfRows, + maxDur: maxBf, + totalDur, + sortState, + }), + ); + } + + return m('.qs-summary-grid', tables); + }, +};
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/timeline_canvas.ts b/ui/src/plugins/com.google.QuantizedSlices/components/timeline_canvas.ts new file mode 100644 index 0000000..209fd9a --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/components/timeline_canvas.ts
@@ -0,0 +1,156 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {MergedSlice, LONG_PKG_PREFIX} from '../models/types'; +import {stateColor, stateLabel, nameColor, isDark} from '../utils/colors'; +import {fmtDur, fmtPct} from '../utils/format'; + +export interface HitRect { + x: number; + y: number; + w: number; + h: number; + d: MergedSlice; +} + +export interface RenderParams { + seq: MergedSlice[]; + totalDur: number; + highlightIdx?: number; +} + +export function renderMiniCanvas( + canvas: HTMLCanvasElement, + params: RenderParams, +): HitRect[] { + const {seq, totalDur, highlightIdx} = params; + const dpr = window.devicePixelRatio || 1; + const parent = canvas.parentElement; + if (!parent) return []; + const cssW = parent.clientWidth - 16; + const cssH = 30; + canvas.style.width = cssW + 'px'; + canvas.style.height = cssH + 'px'; + canvas.width = Math.round(cssW * dpr); + canvas.height = Math.round(cssH * dpr); + const ctx = canvas.getContext('2d'); + if (!ctx) return []; + ctx.scale(dpr, dpr); + + const dark = isDark(); + ctx.fillStyle = dark ? '#17171a' : '#ffffff'; + ctx.fillRect(0, 0, cssW, cssH); + + const dimmed = highlightIdx != null; + const hits: HitRect[] = []; + const scale = cssW / totalDur; + for (let i = 0; i < seq.length; i++) { + const d = seq[i]; + const x = d.tsRel * scale; + const w = Math.max(d.dur * scale, 0.5); + ctx.globalAlpha = dimmed && i !== highlightIdx ? 0.25 : 1; + ctx.fillStyle = stateColor(d); + ctx.fillRect(x, 0, w, 12); + ctx.fillStyle = d.name ? nameColor(d.name) : dark ? '#1c1c26' : '#ede9e2'; + ctx.fillRect(x, 14, w, 16); + hits.push({x, y: 0, w, h: cssH, d}); + } + ctx.globalAlpha = 1; + return hits; +} + +// DOM-based tooltip builder — avoids innerHTML and XSS risks. + +function createSpan( + className: string, + text: string, + color?: string, +): HTMLSpanElement { + const span = document.createElement('span'); + span.className = className; + span.textContent = text; + if (color) span.style.color = color; + return span; +} + +export function showTooltip( + e: MouseEvent, + hits: HitRect[], + totalDur: number, +): void { + const tip = document.querySelector<HTMLElement>('.qs-tooltip'); + if (!tip) return; + const canvas = e.target as HTMLCanvasElement; + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + let hit: HitRect | undefined; + for (let i = hits.length - 1; i >= 0; i--) { + const r = hits[i]; + if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) { + hit = r; + break; + } + } + + if (!hit) { + tip.style.display = 'none'; + return; + } + + const d = hit.d; + const rows: Array<[string, string, string | undefined]> = [ + ['state', stateLabel(d), stateColor(d)], + ['io_wait', d.io_wait !== null ? String(d.io_wait) : '\u2014', undefined], + ['blocked', d.blocked_function ?? '\u2014', undefined], + ['dur', fmtDur(d.dur) + ' (' + fmtPct(d.dur, totalDur) + ')', undefined], + ['start', '+' + fmtDur(d.tsRel), undefined], + ['depth', d.depth !== null ? String(d.depth) : '\u2014', undefined], + ['\u00d7merged', String(d._merged), undefined], + ]; + + // Build tooltip DOM without innerHTML. + tip.replaceChildren(); + + const nameDiv = document.createElement('div'); + nameDiv.className = 'qs-tooltip-name'; + nameDiv.textContent = (d.name ?? 'null').replace(LONG_PKG_PREFIX, ''); + tip.appendChild(nameDiv); + + const grid = document.createElement('div'); + grid.className = 'qs-tooltip-grid'; + for (const [k, v, col] of rows) { + grid.appendChild(createSpan('qs-tooltip-key', k)); + grid.appendChild(createSpan('qs-tooltip-val', v, col)); + } + tip.appendChild(grid); + + tip.style.display = 'block'; + const TW = tip.offsetWidth || 300; + const TH = tip.offsetHeight || 160; + const VW = window.innerWidth; + const VH = window.innerHeight; + let tx = e.clientX + 16; + let ty = e.clientY - 8; + if (tx + TW > VW - 8) tx = e.clientX - TW - 12; + if (ty + TH > VH - 8) ty = VH - TH - 8; + tip.style.left = tx + 'px'; + tip.style.top = ty + 'px'; +} + +export function hideTooltip(): void { + const tip = document.querySelector<HTMLElement>('.qs-tooltip'); + if (tip) tip.style.display = 'none'; +}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/trace_card.ts b/ui/src/plugins/com.google.QuantizedSlices/components/trace_card.ts new file mode 100644 index 0000000..a2a6733 --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/components/trace_card.ts
@@ -0,0 +1,187 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {Button, ButtonVariant} from '../../../widgets/button'; +import {Intent} from '../../../widgets/common'; +import {Card} from '../../../widgets/card'; +import {MiniTimeline} from './mini_timeline'; +import {SummaryTables} from './summary_tables'; +import {setVerdict, ensureCache, updateSlider} from '../state'; +import type {TraceState, Cluster} from '../state'; +import {fmtDur} from '../utils/format'; +import {buildTraceLink} from '../utils/export'; + +const expanded = new Set<string>(); + +function toggleExpand(uuid: string): void { + if (expanded.has(uuid)) expanded.delete(uuid); + else expanded.add(uuid); +} + +function renderSlider(ts: TraceState): m.Children { + ensureCache(ts); + return m('.qs-trace-slider', [ + m('span.qs-slider-label', 'Slices'), + m('span.qs-slider-num', String(ts.currentSeq.length)), + m('input[type=range].qs-slider-input', { + min: 2, + max: ts.origN, + value: ts.sliderValue, + step: 1, + onclick: (e: Event) => e.stopPropagation(), + oninput: (e: Event) => { + e.stopPropagation(); + updateSlider(ts, +(e.target as HTMLInputElement).value); + }, + }), + m('span.qs-slider-of', `/ ${ts.origN}`), + ]); +} + +export interface TraceCardAttrs { + cl: Cluster; + ts: TraceState; + idx: number; +} + +export class TraceCard implements m.ClassComponent<TraceCardAttrs> { + view({attrs}: m.CVnode<TraceCardAttrs>): m.Children { + const {cl, ts, idx} = attrs; + const key = ts._key; + const isExpanded = expanded.has(key); + const verdict = cl.verdicts.get(key); + + const verdictClass = + verdict === 'like' + ? 'qs-verdict-positive' + : verdict === 'dislike' + ? 'qs-verdict-negative' + : verdict === 'discard' + ? 'qs-verdict-discard' + : ''; + + const href = buildTraceLink(ts.trace.trace_uuid, ts.trace.package_name); + + return m( + Card, + {className: `qs-trace-card ${verdictClass}`}, + m( + '.qs-card-header', + { + onclick: () => toggleExpand(key), + }, + [ + m( + 'span.qs-collapse-arrow', + {className: isExpanded ? 'qs-open' : ''}, + '\u25b6', + ), + m('span.qs-trace-idx', `#${idx + 1}`), + m('span.qs-trace-pkg', ts.trace.package_name), + ts.trace.startup_dur + ? m('span.qs-trace-startup-dur', fmtDur(ts.trace.startup_dur)) + : null, + href + ? m( + 'a.qs-trace-link', + { + href, + target: '_blank', + rel: 'noopener', + onclick: (e: Event) => e.stopPropagation(), + title: 'Open in trace viewer', + }, + '\u2197', + ) + : null, + m('span.qs-trace-actions', [ + m(Button, { + label: '+', + variant: ButtonVariant.Minimal, + intent: verdict === 'like' ? Intent.Success : Intent.None, + active: verdict === 'like', + compact: true, + className: 'qs-verdict-btn', + tooltip: 'Positive', + onclick: (e: PointerEvent) => { + e.stopPropagation(); + setVerdict(cl, key, 'like'); + }, + }), + m(Button, { + label: '\u2212', + variant: ButtonVariant.Minimal, + intent: verdict === 'dislike' ? Intent.Danger : Intent.None, + active: verdict === 'dislike', + compact: true, + className: 'qs-verdict-btn', + tooltip: 'Negative', + onclick: (e: PointerEvent) => { + e.stopPropagation(); + setVerdict(cl, key, 'dislike'); + }, + }), + m(Button, { + label: '\u00d7', + variant: ButtonVariant.Minimal, + active: verdict === 'discard', + compact: true, + className: 'qs-verdict-btn', + tooltip: 'Discard', + onclick: (e: PointerEvent) => { + e.stopPropagation(); + setVerdict(cl, key, 'discard'); + }, + }), + ]), + ], + ), + m('.qs-card-body', [m(MiniTimeline, {ts}), renderSlider(ts)]), + isExpanded + ? m('.qs-card-detail', [ + m('.qs-detail-section', [ + m('.qs-detail-label', 'Breakdown'), + m(SummaryTables, {ts, sortState: cl.tableSortState}), + ]), + m('.qs-detail-meta', [ + m('.qs-tt-grid', [ + m('span.qs-tt-k', 'UUID'), + m('span.qs-tt-v', ts.trace.trace_uuid), + m('span.qs-tt-k', 'Package'), + m('span.qs-tt-v', ts.trace.package_name), + m('span.qs-tt-k', 'Startup'), + m( + 'span.qs-tt-v', + ts.trace.startup_dur + ? fmtDur(ts.trace.startup_dur) + : '\u2014', + ), + m('span.qs-tt-k', 'Slices'), + m('span.qs-tt-v', String(ts.origN)), + m('span.qs-tt-k', 'Total dur'), + m('span.qs-tt-v', fmtDur(ts.totalDur)), + ...(ts.trace.extra + ? Object.entries(ts.trace.extra).flatMap(([k, v]) => [ + m('span.qs-tt-k', k), + m('span.qs-tt-v', String(v)), + ]) + : []), + ]), + ]), + ]) + : null, + ); + } +}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/components/trace_list.ts b/ui/src/plugins/com.google.QuantizedSlices/components/trace_list.ts new file mode 100644 index 0000000..158b4dd --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/components/trace_list.ts
@@ -0,0 +1,514 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {SegmentedButtons} from '../../../widgets/segmented_buttons'; +import {SplitPanel} from '../../../widgets/split_panel'; +import {Popup, PopupPosition} from '../../../widgets/popup'; +import {Menu, MenuItem, MenuDivider} from '../../../widgets/menu'; +import {Button, ButtonVariant} from '../../../widgets/button'; +import { + S, + activeCluster, + filteredTraces, + filterTraces, + updateGlobalSlider, + getFilterableFields, + getFieldValues, + togglePropFilter, + clearPropFilter, + copyFilteredToNewTab, + getCrossCompareState, + startCrossCompare, +} from '../state'; +import type {TraceState, Cluster} from '../state'; +import type {OverviewFilter} from '../models/types'; +import {BRUSH_BASE_URL} from '../models/types'; +import {traceExportRow, rowsToTsv, rowsToJson} from '../utils/export'; +import type {ExportRow} from '../utils/export'; +import {TraceCard} from './trace_card'; +import {CrossCompareModal} from './cross_compare_modal'; + +// -- Module-level state -- + +let openFilterDropdown: string | null = null; +const PAGE_SIZE = 100; +const renderLimit = new Map<string, number>(); + +const ALL_FILTERS: ReadonlyArray<{id: OverviewFilter; label: string}> = [ + {id: 'all', label: 'All'}, + {id: 'positive', label: 'Positive'}, + {id: 'negative', label: 'Negative'}, + {id: 'pending', label: 'Pending'}, + {id: 'discarded', label: 'Discarded'}, +]; + +// -- Export helpers -- + +function downloadFile(content: string, filename: string, mime: string): void { + const blob = new Blob([content], {type: mime}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 60_000); +} + +function buildRows(clusters: Cluster[]): ExportRow[] { + const rows: ExportRow[] = []; + for (const cl of clusters) { + for (const ts of cl.traces) { + rows.push(traceExportRow(ts.trace, ts._key, cl.name, cl.verdicts)); + } + } + return rows; +} + +function doExport(scope: 'tab' | 'all', format: 'json' | 'tsv'): void { + const cl = activeCluster(); + if (!cl) return; + const clusters = scope === 'tab' ? [cl] : S.clusters; + const rows = buildRows(clusters); + const date = new Date().toISOString().slice(0, 10); + const stem = scope === 'tab' ? `qs-${cl.name}` : 'qs-all'; + const safeStem = stem.replace(/[^a-zA-Z0-9_-]/g, '_'); + if (format === 'json') { + downloadFile( + rowsToJson(rows), + `${safeStem}-${date}.json`, + 'application/json', + ); + } else { + downloadFile( + rowsToTsv(rows), + `${safeStem}-${date}.tsv`, + 'text/tab-separated-values', + ); + } +} + +function doCopy(scope: 'tab' | 'all'): void { + const cl = activeCluster(); + if (!cl) return; + const clusters = scope === 'tab' ? [cl] : S.clusters; + const rows = buildRows(clusters); + navigator.clipboard.writeText(rowsToTsv(rows)); +} + +// -- Filter helpers -- + +function filterCount(cl: Cluster, filter: OverviewFilter): number { + switch (filter) { + case 'positive': + return cl.counts.positive; + case 'negative': + return cl.counts.negative; + case 'pending': + return cl.counts.pending; + case 'discarded': + return cl.counts.discarded; + default: + return cl.traces.length; + } +} + +function filterIndex(filter: OverviewFilter): number { + return ALL_FILTERS.findIndex((f) => f.id === filter); +} + +// -- Sub-renders -- + +function renderFilterBar( + cl: Cluster, + activeFilter: OverviewFilter, + onSelect: (f: OverviewFilter) => void, +): m.Children { + return m(SegmentedButtons, { + className: 'qs-filter-bar', + options: ALL_FILTERS.map((f) => ({ + label: `${f.label} (${filterCount(cl, f.id)})`, + })), + selectedOption: filterIndex(activeFilter), + onOptionSelected: (idx: number) => onSelect(ALL_FILTERS[idx].id), + }); +} + +function renderGlobalSlider(cl: Cluster): m.Children { + return m('.qs-trace-slider.qs-global-slider', [ + m('span.qs-slider-label', 'All'), + m('span.qs-slider-num', String(cl.globalSlider) + '%'), + m('input[type=range].qs-slider-input', { + min: 1, + max: 100, + value: cl.globalSlider, + step: 1, + oninput: (e: Event) => { + updateGlobalSlider(cl, +(e.target as HTMLInputElement).value); + }, + }), + ]); +} + +function renderSortBtn(cl: Cluster): m.Children { + const active = cl.sortField === 'startup_dur'; + return m(Button, { + label: active + ? `Startup ${cl.sortDir === 1 ? '\u2191' : '\u2193'}` + : 'Sort', + variant: ButtonVariant.Outlined, + active, + compact: true, + tooltip: active + ? 'Click to reverse, double-click index to reset' + : 'Sort by startup duration', + onclick: () => { + if (cl.sortField === 'startup_dur') { + cl.sortDir = cl.sortDir === 1 ? -1 : 1; + } else { + cl.sortField = 'startup_dur'; + cl.sortDir = 1; + } + }, + }); +} + +function renderFilterDropdown(cl: Cluster): m.Children { + const fields = getFilterableFields(cl); + if (fields.length === 0) return null; + const hasActive = cl.propFilters.size > 0; + + return m('.qs-filter-dropdown-wrap', [ + m(Button, { + label: hasActive ? `Filter (${cl.propFilters.size})` : 'Filter', + variant: ButtonVariant.Outlined, + active: hasActive, + compact: true, + onclick: (e: PointerEvent) => { + e.stopPropagation(); + openFilterDropdown = openFilterDropdown ? null : cl.id; + }, + }), + openFilterDropdown === cl.id + ? m( + '.qs-filter-dropdown', + {onclick: (e: Event) => e.stopPropagation()}, + fields.map((field) => { + const values = getFieldValues(cl, field); + const active = cl.propFilters.get(field); + return m('.qs-filter-field', [ + m('.qs-filter-field-header', [ + m('span.qs-filter-field-name', field.replace(/_/g, ' ')), + active + ? m(Button, { + label: 'clear', + variant: ButtonVariant.Minimal, + compact: true, + onclick: () => clearPropFilter(cl, field), + }) + : null, + ]), + m( + '.qs-filter-field-values', + values.map((val) => + m('label.qs-filter-value-label', [ + m('input[type=checkbox]', { + checked: !active || active.has(val), + onchange: () => togglePropFilter(cl, field, val), + }), + m('span', val || '(empty)'), + ]), + ), + ), + ]); + }), + ) + : null, + ]); +} + +function renderExportDropdown(): m.Children { + const hasMultipleTabs = S.clusters.length > 1; + + const menuContent: m.Children = [ + m(MenuItem, { + label: 'Copy (this tab)', + icon: 'content_copy', + onclick: () => doCopy('tab'), + }), + m(MenuItem, { + label: 'JSON (this tab)', + icon: 'download', + onclick: () => doExport('tab', 'json'), + }), + m(MenuItem, { + label: 'TSV (this tab)', + icon: 'download', + onclick: () => doExport('tab', 'tsv'), + }), + ]; + + if (hasMultipleTabs) { + menuContent.push( + m(MenuDivider), + m(MenuItem, { + label: 'Copy (all tabs)', + icon: 'content_copy', + onclick: () => doCopy('all'), + }), + m(MenuItem, { + label: 'JSON (all tabs)', + icon: 'download', + onclick: () => doExport('all', 'json'), + }), + m(MenuItem, { + label: 'TSV (all tabs)', + icon: 'download', + onclick: () => doExport('all', 'tsv'), + }), + ); + } + + return m( + Popup, + { + trigger: m(Button, { + label: 'Export', + icon: 'upload', + variant: ButtonVariant.Filled, + compact: true, + }), + position: PopupPosition.BottomEnd, + }, + m(Menu, menuContent), + ); +} + +function renderCardList(cl: Cluster, traces: TraceState[]): m.Children { + // Build index map once instead of O(n) indexOf per card + const idxMap = new Map<TraceState, number>(); + cl.traces.forEach((ts, i) => idxMap.set(ts, i)); + + const limit = renderLimit.get(cl.id) ?? PAGE_SIZE; + const visible = traces.slice(0, limit); + const remaining = traces.length - visible.length; + + const cards = visible.map((ts) => + m(TraceCard, {key: ts._key, cl, ts, idx: idxMap.get(ts) ?? 0}), + ); + const showMore = + remaining > 0 + ? m( + '.qs-show-more-wrap', + m(Button, { + label: `Show ${Math.min(remaining, PAGE_SIZE)} more (${remaining} remaining)`, + variant: ButtonVariant.Outlined, + className: 'qs-show-more', + onclick: () => { + renderLimit.set(cl.id, limit + PAGE_SIZE); + }, + }), + ) + : null; + return m('.qs-trace-list', [m('.qs-trace-cards', cards), showMore]); +} + +function renderOpenInBrush( + cl: Cluster, + filtered: TraceState[] | null, +): m.Children { + return m(Button, { + label: 'Open in Brush', + variant: ButtonVariant.Outlined, + compact: true, + disabled: cl.traces.length === 0, + tooltip: 'Open visible traces in Brush', + onclick: () => { + const visible = cl.splitView + ? [ + ...filterTraces(cl, cl.splitFilters[0]), + ...filterTraces(cl, cl.splitFilters[1]), + ] + : filtered || []; + const uuids = visible.map((ts) => ts.trace.trace_uuid).filter(Boolean); + if (uuids.length === 0) return; + const filters = [ + {column: 'trace_uuid', operator: 'in', value: JSON.stringify(uuids)}, + ]; + const encoded = encodeURIComponent(JSON.stringify(filters)); + const url = + `${BRUSH_BASE_URL}?filters=${encoded}` + + '&metric_id=android_startup&charts=gallery' + + '&gallerySvgColumn=svg&galleryMetricColumn=dur_ms' + + '&galleryMetricNameColumn=process_name'; + window.open(url, '_blank'); + }, + }); +} + +// -- Toolbar -- + +function renderToolbar(cl: Cluster, filtered: TraceState[] | null): m.Children { + return m('.qs-list-toolbar', [ + cl.splitView + ? m('.qs-list-filters-label', 'Split View') + : renderFilterBar(cl, cl.overviewFilter, (f) => { + cl.overviewFilter = f; + }), + renderGlobalSlider(cl), + m('.qs-list-actions', [ + renderSortBtn(cl), + renderFilterDropdown(cl), + m(Button, { + label: cl.splitView ? 'Single' : 'Split', + variant: ButtonVariant.Outlined, + active: cl.splitView, + compact: true, + tooltip: 'Toggle split view', + onclick: () => { + cl.splitView = !cl.splitView; + }, + }), + m(Button, { + label: 'Copy to tab', + variant: ButtonVariant.Outlined, + compact: true, + disabled: cl.splitView || !filtered || filtered.length === 0, + tooltip: cl.splitView + ? 'Switch to single view first' + : 'Copy visible traces to a new tab', + onclick: () => { + if (filtered) copyFilteredToNewTab(cl, filtered); + }, + }), + m(Button, { + label: 'Compare', + variant: ButtonVariant.Outlined, + compact: true, + disabled: cl.traces.length < 2, + tooltip: 'Compare traces in pairs to find groups', + onclick: () => startCrossCompare(cl), + }), + renderOpenInBrush(cl, filtered), + renderExportDropdown(), + ]), + ]); +} + +// -- Split view -- + +function renderSplitView(cl: Cluster): m.Children { + const leftTraces = filterTraces(cl, cl.splitFilters[0]); + const rightTraces = filterTraces(cl, cl.splitFilters[1]); + + const leftPanel = m('.qs-split-panel-inner', [ + m('.qs-split-panel-header', [ + renderFilterBar(cl, cl.splitFilters[0], (f) => { + cl.splitFilters[0] = f; + }), + m('span.qs-split-count', `${leftTraces.length}`), + ]), + m('.qs-split-panel-body', renderCardList(cl, leftTraces)), + ]); + + const rightPanel = m('.qs-split-panel-inner', [ + m('.qs-split-panel-header', [ + renderFilterBar(cl, cl.splitFilters[1], (f) => { + cl.splitFilters[1] = f; + }), + m('span.qs-split-count', `${rightTraces.length}`), + ]), + m('.qs-split-panel-body', renderCardList(cl, rightTraces)), + ]); + + return m(SplitPanel, { + className: 'qs-split-container', + direction: 'horizontal', + initialSplit: {percent: cl.splitRatio * 100}, + firstPanel: leftPanel, + secondPanel: rightPanel, + onResize: (pct: number) => { + cl.splitRatio = pct / 100; + }, + }); +} + +// -- Document click handler for closing dropdowns -- + +let docClickHandler: (() => void) | null = null; + +// -- Main component -- + +export class TraceList implements m.ClassComponent { + oncreate(): void { + docClickHandler = () => { + if (openFilterDropdown) { + openFilterDropdown = null; + m.redraw(); + } + }; + document.addEventListener('click', docClickHandler); + } + + onremove(): void { + if (docClickHandler) { + document.removeEventListener('click', docClickHandler); + docClickHandler = null; + } + } + + view(): m.Children { + const cl = activeCluster(); + if (!cl || cl.traces.length === 0) { + return m('.qs-section', [ + m('.qs-section-head', 'Traces'), + m( + 'p', + {style: {color: 'var(--dim)', fontSize: '11px'}}, + 'No traces loaded.', + ), + ]); + } + + const filtered = cl.splitView ? null : filteredTraces(); + const toolbar = renderToolbar(cl, filtered); + const ccModal = getCrossCompareState() ? m(CrossCompareModal, {cl}) : null; + + if (filtered) { + // Normal single-panel view + const countLabel = + filtered.length !== cl.traces.length + ? `${filtered.length}/${cl.traces.length}` + : `${filtered.length}`; + return [ + m('.qs-section', [ + m('.qs-section-head', `Traces (${countLabel})`), + toolbar, + renderCardList(cl, filtered), + ]), + ccModal, + ]; + } + + // Split view + return [ + m('.qs-section', [ + m('.qs-section-head', 'Traces (split view)'), + toolbar, + renderSplitView(cl), + ]), + ccModal, + ]; + } +}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/index.ts b/ui/src/plugins/com.google.QuantizedSlices/index.ts new file mode 100644 index 0000000..e1e6bf0 --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/index.ts
@@ -0,0 +1,36 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {App} from '../../public/app'; +import {PerfettoPlugin} from '../../public/plugin'; +import {QuantizedSlicesPage} from './page'; + +export default class implements PerfettoPlugin { + static readonly id = 'com.google.QuantizedSlices'; + + static onActivate(app: App): void { + app.pages.registerPage({ + route: '/quantized_slices', + render: (subpage) => m(QuantizedSlicesPage, {subpage}), + }); + app.sidebar.addMenuItem({ + section: 'trace_files', + text: 'Quantized Slices', + href: '#!/quantized_slices', + icon: 'analytics', + sortOrder: 5, + }); + } +}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/models/compression.ts b/ui/src/plugins/com.google.QuantizedSlices/models/compression.ts new file mode 100644 index 0000000..7110869 --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/models/compression.ts
@@ -0,0 +1,205 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {MergedSlice, Slice} from './types'; + +function tokenDistance(a: MergedSlice, b: MergedSlice): number { + let d = 0; + if (a.state !== b.state) { + d += 4; + } else if (a.state === 'Uninterruptible Sleep' && a.io_wait !== b.io_wait) { + d += 2; + } + if (a.name !== b.name) { + d += a.name === null || b.name === null ? 1 : 2; + } + if (a.blocked_function !== b.blocked_function) { + d += a.blocked_function === null || b.blocked_function === null ? 0.5 : 1; + } + return d; +} + +function mergeCost(a: MergedSlice, b: MergedSlice): number { + const dist = tokenDistance(a, b); + if (dist === 0) { + return 0.01 * Math.log1p((a.dur + b.dur) / 1e6); + } + const loser = a.dur <= b.dur ? a : b; + const loserWeight = + Math.log1p(loser.dur / 1e6) * + (1 + + (loser.name !== null ? 1 : 0) + + (loser.blocked_function !== null ? 1 : 0)); + return dist * loserWeight; +} + +function mergeTwo(a: MergedSlice, b: MergedSlice): MergedSlice { + const winner = a.dur >= b.dur ? a : b; + return { + ts: a.ts, + tsRel: a.tsRel, + dur: a.dur + b.dur, + state: winner.state, + io_wait: winner.io_wait, + name: winner.name, + depth: winner.depth, + blocked_function: winner.blocked_function, + _merged: (a._merged || 1) + (b._merged || 1), + }; +} + +// Determine which lengths to cache — power-of-2 steps plus the original. +function isCheckpoint(len: number, origN: number): boolean { + if (len === origN || len === 2) return true; + // Cache at powers of 2 and every 50 steps for fine-grained slider control. + return (len & (len - 1)) === 0 || len % 50 === 0; +} + +// Linked-list node for in-place merging. Avoids O(n) array copies per step. +interface LLNode { + data: MergedSlice; + prev: number; // index into nodes[], -1 if head + next: number; // index into nodes[], -1 if tail + alive: boolean; + cost: number; // merge cost with the next alive node +} + +function snapshot(nodes: LLNode[], head: number): MergedSlice[] { + const result: MergedSlice[] = []; + let cur = head; + while (cur !== -1) { + result.push({...nodes[cur].data}); + cur = nodes[cur].next; + } + return result; +} + +export interface MergeCache { + cache: Map<number, MergedSlice[]>; + sortedKeys: number[]; // sorted ascending for binary search +} + +export function buildMergeCache(rawData: Slice[]): MergeCache { + const cache = new Map<number, MergedSlice[]>(); + if (rawData.length === 0) return {cache, sortedKeys: []}; + + const n = rawData.length; + const nodes: LLNode[] = rawData.map((d, i) => ({ + data: { + ...d, + tsRel: d.ts - rawData[0].ts, + _merged: 1, + }, + prev: i - 1, + next: i + 1 < n ? i + 1 : -1, + alive: true, + cost: 0, + })); + + // Compute initial merge costs. + for (let i = 0; i < n; i++) { + if (nodes[i].next !== -1) { + nodes[i].cost = mergeCost(nodes[i].data, nodes[nodes[i].next].data); + } else { + nodes[i].cost = Infinity; + } + } + + const head = 0; + let aliveCount = n; + + // Cache original length. + cache.set(n, snapshot(nodes, head)); + + while (aliveCount > 2) { + // Find min-cost pair by scanning alive nodes. + let bestIdx = -1; + let bestCost = Infinity; + let cur = head; + while (cur !== -1) { + if (nodes[cur].next !== -1 && nodes[cur].cost < bestCost) { + bestCost = nodes[cur].cost; + bestIdx = cur; + } + cur = nodes[cur].next; + } + if (bestIdx === -1) break; + + // Merge bestIdx with its next neighbor. + const nextIdx = nodes[bestIdx].next; + nodes[bestIdx].data = mergeTwo(nodes[bestIdx].data, nodes[nextIdx].data); + + // Remove nextIdx from linked list. + nodes[nextIdx].alive = false; + nodes[bestIdx].next = nodes[nextIdx].next; + if (nodes[nextIdx].next !== -1) { + nodes[nodes[nextIdx].next].prev = bestIdx; + } + aliveCount--; + + // Recompute costs for bestIdx and its predecessor. + if (nodes[bestIdx].next !== -1) { + nodes[bestIdx].cost = mergeCost( + nodes[bestIdx].data, + nodes[nodes[bestIdx].next].data, + ); + } else { + nodes[bestIdx].cost = Infinity; + } + if (nodes[bestIdx].prev !== -1) { + const prevIdx = nodes[bestIdx].prev; + nodes[prevIdx].cost = mergeCost(nodes[prevIdx].data, nodes[bestIdx].data); + } + + if (isCheckpoint(aliveCount, n)) { + cache.set(aliveCount, snapshot(nodes, head)); + } + } + + // Always cache the minimum (2 elements). + if (!cache.has(2) && aliveCount === 2) { + cache.set(aliveCount, snapshot(nodes, head)); + } + + const sortedKeys = [...cache.keys()].sort((a, b) => a - b); + return {cache, sortedKeys}; +} + +// Binary search for smallest key >= target in sorted array. +function ceilSearch(arr: number[], target: number): number { + let lo = 0; + let hi = arr.length - 1; + let result = arr[hi]; // fallback to largest + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (arr[mid] >= target) { + result = arr[mid]; + hi = mid - 1; + } else { + lo = mid + 1; + } + } + return result; +} + +export function getCompressed( + mergeCache: MergeCache, + _origN: number, + target: number, +): MergedSlice[] { + const t = Math.max(2, target); + if (mergeCache.sortedKeys.length === 0) return []; + const best = ceilSearch(mergeCache.sortedKeys, t); + return mergeCache.cache.get(best) ?? mergeCache.cache.get(2) ?? []; +}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/models/cross_compare.ts b/ui/src/plugins/com.google.QuantizedSlices/models/cross_compare.ts new file mode 100644 index 0000000..9bf5e0c --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/models/cross_compare.ts
@@ -0,0 +1,321 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Cross Compare — Union-Find based pairwise comparison algorithm. +// +// Traces are nodes in a graph. Positive comparisons merge nodes into +// clusters (union-find). Negative comparisons mark inter-component +// separation. Transitivity: if A+B and B+C then A+C is implied. +// The pair selector prioritises the two largest unresolved components. + +export function edgeKey(a: string, b: string): string { + return a < b ? `${a}|${b}` : `${b}|${a}`; +} + +// ── Union-Find ── + +export class UnionFind { + private parent = new Map<string, string>(); + private rankMap = new Map<string, number>(); + + constructor(keys: string[]) { + for (const k of keys) { + this.parent.set(k, k); + this.rankMap.set(k, 0); + } + } + + find(x: string): string { + let root = x; + while (this.parent.get(root) !== root) root = this.parent.get(root)!; + let cur = x; + while (cur !== root) { + const next = this.parent.get(cur)!; + this.parent.set(cur, root); + cur = next; + } + return root; + } + + union(a: string, b: string): void { + const ra = this.find(a); + const rb = this.find(b); + if (ra === rb) return; + const rankA = this.rankMap.get(ra)!; + const rankB = this.rankMap.get(rb)!; + if (rankA < rankB) { + this.parent.set(ra, rb); + } else if (rankA > rankB) { + this.parent.set(rb, ra); + } else { + this.parent.set(rb, ra); + this.rankMap.set(ra, rankA + 1); + } + } + + connected(a: string, b: string): boolean { + return this.find(a) === this.find(b); + } + + components(): Map<string, string[]> { + const map = new Map<string, string[]>(); + for (const k of this.parent.keys()) { + const root = this.find(k); + let arr = map.get(root); + if (!arr) { + arr = []; + map.set(root, arr); + } + arr.push(k); + } + return map; + } +} + +// ── Cross Compare State ── + +export type ComparisonResult = 'positive' | 'negative'; + +export type HistoryEntry = + | {type: 'compare'; keyA: string; keyB: string; result: ComparisonResult} + | {type: 'discard'; key: string}; + +export interface CrossCompareState { + uf: UnionFind; + negativeEdges: Set<string>; + comparisons: Map<string, ComparisonResult>; + skippedPairs: Set<string>; + discardedKeys: Set<string>; + history: HistoryEntry[]; + traceKeys: string[]; + currentPair: [string, string] | null; + selectedSide: 'left' | 'right' | null; + isComplete: boolean; +} + +export function createCrossCompareState( + traceKeys: string[], +): CrossCompareState { + const state: CrossCompareState = { + uf: new UnionFind(traceKeys), + negativeEdges: new Set(), + comparisons: new Map(), + skippedPairs: new Set(), + discardedKeys: new Set(), + history: [], + traceKeys, + currentPair: null, + selectedSide: null, + isComplete: false, + }; + state.currentPair = nextPair(state); + if (!state.currentPair) state.isComplete = true; + return state; +} + +// ── Core Operations ── + +export function recordComparison( + state: CrossCompareState, + keyA: string, + keyB: string, + result: ComparisonResult, +): void { + const ek = edgeKey(keyA, keyB); + state.comparisons.set(ek, result); + state.skippedPairs.delete(ek); + state.history.push({type: 'compare', keyA, keyB, result}); + + if (result === 'positive') { + const rootA = state.uf.find(keyA); + const rootB = state.uf.find(keyB); + const toRekey: [string, string][] = []; + for (const neg of state.negativeEdges) { + const [x, y] = neg.split('|'); + if (x === rootA || x === rootB || y === rootA || y === rootB) { + toRekey.push([x, y]); + } + } + state.uf.union(keyA, keyB); + for (const [x, y] of toRekey) { + state.negativeEdges.delete(edgeKey(x, y)); + const rx = state.uf.find(x); + const ry = state.uf.find(y); + if (rx !== ry) state.negativeEdges.add(edgeKey(rx, ry)); + } + } else if (result === 'negative') { + const rootA = state.uf.find(keyA); + const rootB = state.uf.find(keyB); + if (rootA !== rootB) { + state.negativeEdges.add(edgeKey(rootA, rootB)); + } + } +} + +export function skipCurrentPair(state: CrossCompareState): void { + if (!state.currentPair) return; + state.skippedPairs.add(edgeKey(state.currentPair[0], state.currentPair[1])); +} + +export function discardTrace(state: CrossCompareState, key: string): void { + state.discardedKeys.add(key); + state.history.push({type: 'discard', key}); +} + +export function undoComparison(state: CrossCompareState): void { + if (state.history.length === 0) return; + const prev = state.history.slice(0, -1); + state.uf = new UnionFind(state.traceKeys); + state.negativeEdges.clear(); + state.comparisons.clear(); + state.skippedPairs.clear(); + state.discardedKeys.clear(); + state.history = []; + for (const entry of prev) { + if (entry.type === 'compare') { + recordComparison(state, entry.keyA, entry.keyB, entry.result); + } else { + discardTrace(state, entry.key); + } + } + state.currentPair = nextPair(state); + state.isComplete = !state.currentPair; + state.selectedSide = null; +} + +export function nextPair(state: CrossCompareState): [string, string] | null { + const comps = activeComponents(state); + const sorted = [...comps.entries()].sort((a, b) => b[1].length - a[1].length); + + let fallback: [string, string] | null = null; + for (let i = 0; i < sorted.length; i++) { + for (let j = i + 1; j < sorted.length; j++) { + const rootI = sorted[i][0]; + const rootJ = sorted[j][0]; + if (state.negativeEdges.has(edgeKey(rootI, rootJ))) continue; + const pair = findUncomparedPair( + state, + sorted[i][1], + sorted[j][1], + state.skippedPairs, + ); + if (pair) return pair; + if (!fallback) { + const skipped = findUncomparedPair(state, sorted[i][1], sorted[j][1]); + if (skipped) fallback = skipped; + } + } + } + return fallback; +} + +export function nextPairForAnchor( + state: CrossCompareState, + anchorKey: string, +): [string, string] | null { + if (state.discardedKeys.has(anchorKey)) return null; + const comps = activeComponents(state); + const anchorRoot = state.uf.find(anchorKey); + + const sorted = [...comps.entries()] + .filter(([root]) => root !== anchorRoot) + .sort((a, b) => b[1].length - a[1].length); + + let fallback: [string, string] | null = null; + for (const [root, members] of sorted) { + if (state.negativeEdges.has(edgeKey(anchorRoot, root))) continue; + for (const other of members) { + const ek = edgeKey(anchorKey, other); + if (!state.comparisons.has(ek) && !state.skippedPairs.has(ek)) { + return [anchorKey, other]; + } + if (!fallback && !state.comparisons.has(ek)) { + fallback = [anchorKey, other]; + } + } + } + return fallback; +} + +function activeComponents(state: CrossCompareState): Map<string, string[]> { + if (state.discardedKeys.size === 0) return state.uf.components(); + const comps = state.uf.components(); + const filtered = new Map<string, string[]>(); + for (const [root, members] of comps) { + const active = members.filter((k) => !state.discardedKeys.has(k)); + if (active.length > 0) filtered.set(root, active); + } + return filtered; +} + +function findUncomparedPair( + state: CrossCompareState, + membersA: string[], + membersB: string[], + exclude?: Set<string>, +): [string, string] | null { + for (const a of membersA) { + for (const b of membersB) { + const ek = edgeKey(a, b); + if (!state.comparisons.has(ek) && (!exclude || !exclude.has(ek))) { + return [a, b]; + } + } + } + return null; +} + +// ── Progress ── + +export function getProgress(state: CrossCompareState): { + completed: number; + total: number; + pct: number; +} { + const comps = activeComponents(state); + const roots = [...comps.keys()]; + let total = 0; + let resolved = 0; + for (let i = 0; i < roots.length; i++) { + for (let j = i + 1; j < roots.length; j++) { + total++; + if (state.negativeEdges.has(edgeKey(roots[i], roots[j]))) { + resolved++; + } + } + } + const n = state.traceKeys.length - state.discardedKeys.size; + const maxPairs = (n * (n - 1)) / 2; + const mergedPairs = maxPairs - total; + const totalWork = maxPairs; + const completedWork = mergedPairs + resolved; + return { + completed: completedWork, + total: totalWork, + pct: totalWork > 0 ? Math.round((completedWork / totalWork) * 100) : 100, + }; +} + +// ── Results ── + +export interface CrossCompareResults { + groups: string[][]; + discarded: string[]; +} + +export function getResults(state: CrossCompareState): CrossCompareResults { + const comps = activeComponents(state); + const groups = [...comps.values()].sort((a, b) => b.length - a.length); + return {groups, discarded: [...state.discardedKeys]}; +}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/models/types.ts b/ui/src/plugins/com.google.QuantizedSlices/models/types.ts new file mode 100644 index 0000000..87b2975 --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/models/types.ts
@@ -0,0 +1,143 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export interface Slice { + ts: number; + dur: number; + name: string | null; + state: string | null; + depth: number | null; + io_wait: number | null; + blocked_function: string | null; +} + +export interface MergedSlice extends Slice { + tsRel: number; + _merged: number; +} + +export interface TraceEntry { + trace_uuid: string; + package_name: string; + startup_dur: number; + slices: Slice[]; + extra?: Record<string, unknown>; +} + +export type Verdict = 'like' | 'dislike' | 'discard'; + +export type OverviewFilter = + | 'all' + | 'positive' + | 'negative' + | 'pending' + | 'discarded'; + +export interface SortState { + col: string; + dir: 1 | -1; +} + +export interface SummaryRow { + label: string; + short: string; + dur: number; + count: number; + color: string; + pct: number; +} + +export interface ColumnConfig { + trace_uuid: {aliases: string[]; fallback: {factory: () => string}}; + package_name: {aliases: string[]; fallback: {factory: () => string}}; + startup_dur: {aliases: string[]; fallback: {factory: () => number}}; + slices: {aliases: string[]; fallback: {factory: () => Slice[]}}; +} + +export const DEFAULT_COLUMN_CONFIG: ColumnConfig = { + trace_uuid: { + aliases: ['trace_uuid', 'uuid', 'id', 'trace_id', 'trace_address'], + fallback: {factory: () => crypto.randomUUID()}, + }, + package_name: { + aliases: [ + 'package_name', + 'process_name', + 'process', + 'package', + 'pkg', + 'app', + ], + fallback: {factory: () => 'unknown'}, + }, + startup_dur: { + aliases: [ + 'startup_dur', + 'startup_dur_ms', + 'startup_duration', + 'dur', + 'duration', + 'total_dur', + 'startup_ms', + ], + fallback: {factory: () => 0}, + }, + slices: { + aliases: [ + 'slices', + 'quantized_sequence', + 'quantized_sequence_json', + 'json', + 'data', + 'trace_data', + 'base64', + 'thread_slices', + ], + fallback: {factory: () => []}, + }, +}; + +export interface SliceFieldConfig { + ts: {aliases: string[]; fallback: number}; + dur: {aliases: string[]; fallback: number}; + name: {aliases: string[]; fallback: string | null}; + state: {aliases: string[]; fallback: string | null}; + depth: {aliases: string[]; fallback: number | null}; + io_wait: {aliases: string[]; fallback: number | null}; + blocked_function: {aliases: string[]; fallback: string | null}; +} + +export const DEFAULT_SLICE_FIELD_CONFIG: SliceFieldConfig = { + ts: {aliases: ['ts', 'timestamp', 'start', 'start_ts', 'begin'], fallback: 0}, + dur: {aliases: ['dur', 'duration', 'length'], fallback: 0}, + name: {aliases: ['name', 'slice_name', 'label', 'event'], fallback: null}, + state: {aliases: ['state', 'thread_state', 'sched_state'], fallback: null}, + depth: {aliases: ['depth', 'level', 'stack_depth'], fallback: null}, + io_wait: {aliases: ['io_wait', 'iowait', 'io'], fallback: null}, + blocked_function: { + aliases: ['blocked_function', 'blocked_fn', 'blocked', 'wchan'], + fallback: null, + }, +}; + +// Shared constants used by multiple components. +export const LONG_PKG_PREFIX = + 'com.redfin.android.core.activity.launch.deeplink.'; + +// Base URL for the trace viewer. Change this to point to a different viewer. +export const TRACE_VIEWER_BASE_URL = + 'https://apconsole.corp.google.com/link/perfetto/field_traces'; + +// Base URL for the Brush tool. +export const BRUSH_BASE_URL = 'https://brush.corp.google.com/';
diff --git a/ui/src/plugins/com.google.QuantizedSlices/page.ts b/ui/src/plugins/com.google.QuantizedSlices/page.ts new file mode 100644 index 0000000..115086b --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/page.ts
@@ -0,0 +1,60 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {S, activeCluster} from './state'; +import {ImportPanel} from './components/import_panel'; +import {ClusterTabs} from './components/cluster_tabs'; +import {TraceList} from './components/trace_list'; + +export interface QuantizedSlicesPageAttrs { + readonly subpage?: string; +} + +export class QuantizedSlicesPage + implements m.ClassComponent<QuantizedSlicesPageAttrs> +{ + view(_vnode: m.Vnode<QuantizedSlicesPageAttrs>): m.Children { + const cl = activeCluster(); + const hasClusters = S.clusters.length > 0; + + return m('.qs-page', [ + // Tooltip element for canvas hover + m('.qs-tooltip'), + + // Header + m('.qs-header', [ + m('h2.qs-title', 'Quantized Slices'), + hasClusters + ? m( + 'span.qs-header-stats', + `${S.clusters.reduce((n, c) => n + c.traces.length, 0)} traces in ${S.clusters.length} tab${S.clusters.length !== 1 ? 's' : ''}`, + ) + : null, + ]), + + // Import panel + m(ImportPanel), + + // Cluster tabs + trace list + hasClusters + ? [ + m(ClusterTabs, { + contentForCluster: () => (cl ? m(TraceList) : null), + }), + ] + : null, + ]); + } +}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/parse.ts b/ui/src/plugins/com.google.QuantizedSlices/parse.ts new file mode 100644 index 0000000..f900c2f --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/parse.ts
@@ -0,0 +1,539 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Pure parsing functions, no side effects. +// Shared between main thread and Web Worker. +// +// Every function here takes data in and returns data out. +// No mithril, no state, no DOM access. + +import type {Slice, TraceEntry} from './models/types'; +import { + DEFAULT_COLUMN_CONFIG, + DEFAULT_SLICE_FIELD_CONFIG, +} from './models/types'; + +// -- Field resolution -- + +// Resolves a field value from an object by trying a list of aliases. +// The fallback can be a static value or a factory function (for values like +// crypto.randomUUID() that must be unique per call). Factory functions are +// wrapped in {factory: () => T} to avoid ambiguity when T itself is a function. +type FallbackValue<T> = T | {factory: () => T}; + +export function resolveField<T>( + obj: Record<string, unknown>, + aliases: string[], + fallback: FallbackValue<T>, +): T { + for (const alias of aliases) { + if (obj[alias] !== undefined) return obj[alias] as T; + } + if ( + typeof fallback === 'object' && + fallback !== null && + 'factory' in fallback + ) { + return (fallback as {factory: () => T}).factory(); + } + return fallback as T; +} + +// -- Slice normalization -- + +export function normalizeSlice(raw: Record<string, unknown>): Slice { + const cfg = DEFAULT_SLICE_FIELD_CONFIG; + return { + ts: resolveField(raw, cfg.ts.aliases, cfg.ts.fallback), + dur: resolveField(raw, cfg.dur.aliases, cfg.dur.fallback), + name: resolveField(raw, cfg.name.aliases, cfg.name.fallback), + state: resolveField(raw, cfg.state.aliases, cfg.state.fallback), + depth: resolveField(raw, cfg.depth.aliases, cfg.depth.fallback), + io_wait: resolveField(raw, cfg.io_wait.aliases, cfg.io_wait.fallback), + blocked_function: resolveField( + raw, + cfg.blocked_function.aliases, + cfg.blocked_function.fallback, + ), + }; +} + +// -- Startup duration (ms -> ns conversion) -- + +const MS_ALIASES = new Set(['startup_dur_ms', 'startup_ms']); + +function resolveStartupDur(obj: Record<string, unknown>): number { + const cfg = DEFAULT_COLUMN_CONFIG; + for (const alias of cfg.startup_dur.aliases) { + if (obj[alias] !== undefined) { + const val = parseFloat(String(obj[alias])) || 0; + return MS_ALIASES.has(alias) ? val * 1e6 : val; + } + } + return 0; +} + +// -- Package name (handles JSON-encoded column) -- + +export function resolvePackageName(raw: Record<string, unknown>): string { + const cfg = DEFAULT_COLUMN_CONFIG; + const val = resolveField( + raw, + cfg.package_name.aliases, + cfg.package_name.fallback, + ); + if (typeof val === 'string' && val.startsWith('{')) { + try { + const parsed = JSON.parse(val) as Record<string, unknown>; + if (typeof parsed.package_name === 'string') return parsed.package_name; + } catch { + /* not JSON, use as-is */ + } + } + return val; +} + +// -- Array-of-arrays -> objects -- + +export function arrayOfArraysToObjects( + arr: unknown[][], +): Record<string, unknown>[] { + const headers = arr[0] as string[]; + return arr.slice(1).map((row) => { + const obj: Record<string, unknown> = {}; + headers.forEach((h, i) => { + if ((row as unknown[])[i] !== undefined) obj[h] = (row as unknown[])[i]; + }); + return obj; + }); +} + +// -- UUID extraction -- +// trace_address values are paths like "/path/to/uuid.pftrace.gz" +// Extract the UUID portion (basename without extension) if the value looks +// like a path. If it's already a bare UUID, return as-is. + +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + +function extractUuid(val: string): string { + if (!val || !val.includes('/')) return val; + const match = val.match(UUID_RE); + if (match) return match[0]; + // Fallback: use basename without extension + const base = val.split('/').pop() || val; + return base.replace(/\.\w+(\.\w+)*$/, ''); +} + +// -- Shared slice-field parsing -- +// Handles string (JSON/base64), object-with-slices, or raw array inputs. + +export function parseSlicesField(rawSlices: unknown): Slice[] | null { + if (typeof rawSlices === 'string') { + let decoded = rawSlices; + if (!decoded.startsWith('[') && !decoded.startsWith('{')) { + try { + decoded = atob(decoded); + } catch { + return null; + } + } + try { + let parsed: unknown; + try { + parsed = JSON.parse(decoded); + } catch { + parsed = JSON.parse(repairJson(decoded)); + } + const parsedObj = parsed as Record<string, unknown>; + const arr = Array.isArray(parsed) + ? (parsed as unknown[]) + : ((parsedObj.slices ?? parsedObj.data) as unknown[] | undefined); + if (!Array.isArray(arr) || arr.length === 0) return null; + return arr.map((s: unknown) => + normalizeSlice(s as Record<string, unknown>), + ); + } catch { + return null; + } + } + if (Array.isArray(rawSlices)) { + const slices = rawSlices.map((s: unknown) => + normalizeSlice(s as Record<string, unknown>), + ); + return slices.length > 0 ? slices : null; + } + return null; +} + +// -- Trace normalization -- + +export function normalizeTrace( + raw: Record<string, unknown>, +): TraceEntry | null { + const cfg = DEFAULT_COLUMN_CONFIG; + const rawSlices = resolveField<unknown>( + raw, + cfg.slices.aliases, + cfg.slices.fallback, + ); + + const slices = parseSlicesField(rawSlices); + if (!slices) return null; + + // Collect extra fields (anything not a known column alias) + const knownKeys = new Set([ + ...cfg.trace_uuid.aliases, + ...cfg.package_name.aliases, + ...cfg.startup_dur.aliases, + ...cfg.slices.aliases, + ]); + const extra: Record<string, unknown> = {}; + for (const [k, v] of Object.entries(raw)) { + if (!knownKeys.has(k)) extra[k] = v; + } + + return { + trace_uuid: extractUuid( + resolveField(raw, cfg.trace_uuid.aliases, cfg.trace_uuid.fallback), + ), + package_name: resolvePackageName(raw), + startup_dur: resolveStartupDur(raw), + slices, + extra: Object.keys(extra).length > 0 ? extra : undefined, + }; +} + +// -- JSON repair for truncated input -- + +export function repairJson(text: string): string { + let result = text.trimEnd(); + let inStr = false; + let escape = false; + const stack: string[] = []; + for (let i = 0; i < result.length; i++) { + const ch = result[i]; + if (escape) { + escape = false; + continue; + } + if (ch === '\\' && inStr) { + escape = true; + continue; + } + if (ch === '"' && !escape) { + inStr = !inStr; + continue; + } + if (inStr) continue; + if (ch === '[') stack.push(']'); + else if (ch === '{') stack.push('}'); + else if (ch === ']' || ch === '}') { + if (stack.length > 0 && stack[stack.length - 1] === ch) stack.pop(); + } + } + if (inStr) result += '"'; + while (stack.length > 0) result += stack.pop(); + return result; +} + +// -- Delimited (TSV/CSV) row parsing -- RFC 4180 compliant -- + +export function parseDelimitedRows( + text: string, + delimiter: string, +): string[][] { + const rows: string[][] = []; + let fields: string[] = []; + let current = ''; + let inQ = false; + let i = 0; + + while (i < text.length) { + const ch = text[i]; + if (inQ) { + if (ch === '"') { + if (i + 1 < text.length && text[i + 1] === '"') { + // Escaped quote "" + current += '"'; + i += 2; + continue; + } + // End of quoted field + inQ = false; + i++; + continue; + } + current += ch; + i++; + } else { + if (ch === '"' && current === '') { + inQ = true; + i++; + } else if (ch === delimiter) { + fields.push(current); + current = ''; + i++; + } else if (ch === '\n' || ch === '\r') { + fields.push(current); + current = ''; + if (ch === '\r' && i + 1 < text.length && text[i + 1] === '\n') i++; + if (fields.some((f) => f.trim() !== '')) rows.push(fields); + fields = []; + i++; + } else { + current += ch; + i++; + } + } + } + + // Final row + fields.push(current); + if (fields.some((f) => f.trim() !== '')) rows.push(fields); + return rows; +} + +// -- Progress callback type -- + +export interface ParseProgress { + message: string; + current?: number; + total?: number; +} + +// -- High-level parse: JSON text -> TraceEntry[] -- + +export function parseJsonToTraces( + text: string, + onProgress?: (p: ParseProgress) => void, +): TraceEntry[] { + onProgress?.({message: 'Parsing JSON...'}); + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = JSON.parse(repairJson(text)); + } + + if (Array.isArray(parsed) && parsed.length) { + let items: unknown[] = parsed; + + // Array-of-arrays: [[headers], [row1], [row2], ...] + if ( + Array.isArray(parsed[0]) && + parsed.length >= 2 && + (parsed[0] as unknown[]).every((h: unknown) => typeof h === 'string') + ) { + onProgress?.({message: 'Converting array-of-arrays...'}); + items = arrayOfArraysToObjects(parsed as unknown[][]); + } + + const first = items[0]; + if (typeof first !== 'object' || first === null || Array.isArray(first)) { + throw new Error('Expected array of objects or [headers, ...rows]'); + } + + const firstObj = first as Record<string, unknown>; + + // Detect: is this an array of slices or an array of traces? + const looksLikeSlice = + DEFAULT_SLICE_FIELD_CONFIG.ts.aliases.some( + (a) => firstObj[a] !== undefined, + ) && + DEFAULT_SLICE_FIELD_CONFIG.dur.aliases.some( + (a) => firstObj[a] !== undefined, + ); + const looksLikeTrace = DEFAULT_COLUMN_CONFIG.slices.aliases.some( + (a) => firstObj[a] !== undefined, + ); + + if (looksLikeSlice && !looksLikeTrace) { + onProgress?.({message: `Normalizing ${items.length} slices...`}); + const slices = items.map((s) => + normalizeSlice(s as Record<string, unknown>), + ); + return [ + { + trace_uuid: crypto.randomUUID(), + package_name: 'unknown', + startup_dur: 0, + slices, + }, + ]; + } + + if (looksLikeTrace) { + const total = items.length; + const traces: TraceEntry[] = []; + for (let i = 0; i < total; i++) { + if (i % 10 === 0) { + onProgress?.({ + message: `Processing trace ${i + 1}/${total}...`, + current: i, + total, + }); + } + const t = normalizeTrace(items[i] as Record<string, unknown>); + if (t) traces.push(t); + } + if (traces.length === 0) throw new Error('No valid traces in array'); + return traces; + } + + throw new Error( + 'Array items need ts+dur (slices) or a slices/json/data column (traces)', + ); + } + + if (typeof parsed === 'object' && parsed !== null) { + const trace = normalizeTrace(parsed as Record<string, unknown>); + if (trace) return [trace]; + throw new Error('Object must have a slices/json/data field'); + } + + throw new Error('Expected array or object'); +} + +// -- High-level parse: delimited text -> TraceEntry[] -- + +export function parseDelimitedToTraces( + text: string, + delimiter: string, + onProgress?: (p: ParseProgress) => void, +): TraceEntry[] { + onProgress?.({message: 'Parsing rows...'}); + const rows = parseDelimitedRows(text, delimiter); + if (rows.length < 2) throw new Error('Need header + data rows'); + + const headers = rows[0]; + const cfg = DEFAULT_COLUMN_CONFIG; + const norm = (s: string): string => + s.toLowerCase().trim().replace(/\s+/g, '_'); + const findCol = (aliases: string[]): number => { + for (const a of aliases) { + const idx = headers.findIndex((h) => norm(h) === a.toLowerCase()); + if (idx >= 0) return idx; + } + return -1; + }; + + const slicesIdx = findCol(cfg.slices.aliases); + const uuidIdx = findCol(cfg.trace_uuid.aliases); + const pkgIdx = findCol(cfg.package_name.aliases); + const durIdx = findCol(cfg.startup_dur.aliases); + const durIsMs = durIdx >= 0 && MS_ALIASES.has(norm(headers[durIdx])); + + if (slicesIdx < 0) { + throw new Error(`Need a column matching: ${cfg.slices.aliases.join(', ')}`); + } + + const traces: TraceEntry[] = []; + let parseErrors = 0; + const total = rows.length - 1; + + for (let ri = 1; ri < rows.length; ri++) { + if ((ri - 1) % 10 === 0) { + onProgress?.({ + message: `Processing row ${ri}/${total}...`, + current: ri - 1, + total, + }); + } + + const cols = rows[ri]; + if (!cols[slicesIdx]?.trim()) continue; + + try { + const slices = parseSlicesField(cols[slicesIdx].trim()); + if (!slices) { + parseErrors++; + continue; + } + + const extra: Record<string, unknown> = {}; + headers.forEach((h, idx) => { + if ( + idx !== slicesIdx && + idx !== uuidIdx && + idx !== pkgIdx && + idx !== durIdx + ) { + if (cols[idx]?.trim()) extra[norm(h)] = cols[idx].trim(); + } + }); + + let pkgName = + pkgIdx >= 0 && cols[pkgIdx] + ? cols[pkgIdx].trim() + : cfg.package_name.fallback.factory(); + if (pkgName.startsWith('{')) { + try { + const p = JSON.parse(pkgName) as Record<string, unknown>; + if (typeof p.package_name === 'string') pkgName = p.package_name; + } catch { + /* not JSON */ + } + } + + traces.push({ + trace_uuid: extractUuid( + uuidIdx >= 0 && cols[uuidIdx] + ? cols[uuidIdx].trim() + : cfg.trace_uuid.fallback.factory(), + ), + package_name: pkgName, + startup_dur: + durIdx >= 0 && cols[durIdx] + ? (parseFloat(cols[durIdx]) || 0) * (durIsMs ? 1e6 : 1) + : 0, + slices, + extra: Object.keys(extra).length > 0 ? extra : undefined, + }); + } catch { + parseErrors++; + } + } + + if (!traces.length) { + throw new Error(`No valid traces (${parseErrors} parse errors)`); + } + return traces; +} + +// -- Unified entry point: auto-detect format -- + +export function parseText( + text: string, + onProgress?: (p: ParseProgress) => void, +): TraceEntry[] { + text = text.trim(); + if (!text) return []; + + if (text.startsWith('[') || text.startsWith('{')) { + return parseJsonToTraces(text, onProgress); + } + + const firstNewline = text.indexOf('\n'); + const firstLine = firstNewline >= 0 ? text.slice(0, firstNewline) : text; + if (firstLine.includes('\t')) { + return parseDelimitedToTraces(text, '\t', onProgress); + } + if (firstLine.includes(',')) { + return parseDelimitedToTraces(text, ',', onProgress); + } + + // Fallback: try as JSON + return parseJsonToTraces(text, onProgress); +}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/state.ts b/ui/src/plugins/com.google.QuantizedSlices/state.ts new file mode 100644 index 0000000..e800c53 --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/state.ts
@@ -0,0 +1,650 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import type { + TraceEntry, + Slice, + MergedSlice, + Verdict, + OverviewFilter, + SortState, +} from './models/types'; +import {buildMergeCache, getCompressed} from './models/compression'; +import type {MergeCache} from './models/compression'; +import type {CrossCompareState} from './models/cross_compare'; +import { + createCrossCompareState, + recordComparison as ccRecord, + nextPair, + nextPairForAnchor, + getResults as ccResults, + undoComparison as ccUndo, + discardTrace as ccDiscard, + skipCurrentPair as ccSkip, +} from './models/cross_compare'; + +export interface TraceState { + // Immutable data + trace: TraceEntry; + _key: string; + totalDur: number; + origN: number; + + // Mutable compression cache (lazily initialized) + _mergeCache: MergeCache | null; + sliderValue: number; + currentSeq: MergedSlice[]; +} + +export function traceKey(t: TraceEntry): string { + const startupId = t.extra?.startup_id ?? ''; + return `${t.trace_uuid}|${t.package_name}|${startupId}|${t.startup_dur}`; +} + +interface VerdictCounts { + positive: number; + negative: number; + pending: number; + discarded: number; +} + +export interface Cluster { + // -- Data -- + id: string; + name: string; + traces: TraceState[]; + verdicts: Map<string, Verdict>; + counts: VerdictCounts; + + // -- View state -- + overviewFilter: OverviewFilter; + splitView: boolean; + splitFilters: [OverviewFilter, OverviewFilter]; + splitRatio: number; + + // -- Sort / filter state -- + tableSortState: Record<string, SortState>; + sortField: 'index' | 'startup_dur'; + sortDir: 1 | -1; + propFilters: Map<string, Set<string>>; + + // -- Compression state -- + globalSlider: number; // 1-100 percentage +} + +function makeCluster(name: string, traces: TraceState[]): Cluster { + return { + id: crypto.randomUUID(), + name, + traces, + verdicts: new Map(), + overviewFilter: 'all', + counts: {positive: 0, negative: 0, pending: traces.length, discarded: 0}, + tableSortState: {}, + splitView: false, + splitFilters: ['pending', 'positive'], + splitRatio: 0.5, + sortField: 'index', + sortDir: 1, + propFilters: new Map(), + globalSlider: 100, + }; +} + +interface AppState { + clusters: Cluster[]; + activeClusterId: string | null; + importMsg: {text: string; ok: boolean} | null; + loadProgress: {message: string; pct?: number} | null; +} + +export const S: AppState = { + clusters: [], + activeClusterId: null, + importMsg: null, + loadProgress: null, +}; + +export function activeCluster(): Cluster | null { + return S.clusters.find((c) => c.id === S.activeClusterId) ?? null; +} + +export function recomputeCounts(cl: Cluster): void { + let positive = 0; + let negative = 0; + let discarded = 0; + for (const v of cl.verdicts.values()) { + if (v === 'like') positive++; + else if (v === 'dislike') negative++; + else if (v === 'discard') discarded++; + } + cl.counts = { + positive, + negative, + discarded, + pending: cl.traces.length - positive - negative - discarded, + }; +} + +export function initTraceLazy(trace: TraceEntry): TraceState { + // Sort slices by timestamp to ensure totalDur computation is correct. + const slices = trace.slices; + slices.sort((a, b) => a.ts - b.ts); + + const totalDur = + slices.length > 0 + ? slices.reduce((mx, d) => Math.max(mx, d.ts - slices[0].ts + d.dur), 0) + : 0; + return { + trace, + _key: traceKey(trace), + _mergeCache: null, + totalDur, + origN: slices.length, + sliderValue: slices.length, + currentSeq: [], + }; +} + +export function ensureCache(ts: TraceState): void { + if (ts._mergeCache !== null) return; + ts._mergeCache = buildMergeCache(ts.trace.slices); + ts.currentSeq = getCompressed(ts._mergeCache, ts.origN, ts.sliderValue); +} + +export function updateSlider(ts: TraceState, value: number): void { + ensureCache(ts); + ts.sliderValue = value; + ts.currentSeq = getCompressed(ts._mergeCache!, ts.origN, value); +} + +export function updateGlobalSlider(cl: Cluster, pct: number): void { + cl.globalSlider = pct; + const frac = pct / 100; + for (const ts of cl.traces) { + const target = Math.max(2, Math.round(2 + (ts.origN - 2) * frac)); + updateSlider(ts, target); + } + m.redraw(); +} + +export function addCluster(name: string, entries: TraceEntry[]): void { + const allStates = entries.map(initTraceLazy); + // Deduplicate by composite key + const seen = new Set<string>(); + const states = allStates.filter((ts) => { + if (seen.has(ts._key)) return false; + seen.add(ts._key); + return true; + }); + const cl = makeCluster(name, states); + if (states.length > 0) ensureCache(states[0]); + S.clusters.push(cl); + S.activeClusterId = cl.id; + m.redraw(); +} + +export function loadSingleJson( + data: Slice[], + uuid?: string, + pkg?: string, + dur?: number, +): void { + addCluster('Import', [ + { + trace_uuid: uuid || crypto.randomUUID(), + package_name: pkg || 'unknown', + startup_dur: dur ?? 0, + slices: data, + }, + ]); +} + +export function loadMultipleTraces(name: string, traces: TraceEntry[]): void { + addCluster(name, traces); +} + +/** Deep-copy filtered traces into a new independent tab, carrying over verdicts. */ +export function copyFilteredToNewTab( + sourceCl: Cluster, + filteredStates: TraceState[], +): void { + if (filteredStates.length === 0) return; + const entries: TraceEntry[] = filteredStates.map((ts) => ({ + trace_uuid: ts.trace.trace_uuid, + package_name: ts.trace.package_name, + startup_dur: ts.trace.startup_dur, + slices: ts.trace.slices, + extra: ts.trace.extra ? {...ts.trace.extra} : undefined, + })); + const newStates = entries.map(initTraceLazy); + const cl = makeCluster(sourceCl.name + ' (copy)', newStates); + // Carry over verdicts from source + for (const ts of newStates) { + const v = sourceCl.verdicts.get(ts._key); + if (v) cl.verdicts.set(ts._key, v); + } + recomputeCounts(cl); + if (newStates.length > 0) ensureCache(newStates[0]); + S.clusters.push(cl); + S.activeClusterId = cl.id; + m.redraw(); +} + +export function removeCluster(id: string): void { + S.clusters = S.clusters.filter((c) => c.id !== id); + if (S.activeClusterId === id) { + S.activeClusterId = S.clusters.length > 0 ? S.clusters[0].id : null; + } + // Clean up cross-compare state if it belonged to the removed cluster. + if (_ccState) { + const remaining = S.clusters.find((c) => + c.traces.some((ts) => _ccState!.traceKeys.includes(ts._key)), + ); + if (!remaining) _ccState = null; + } + m.redraw(); +} + +export function switchCluster(id: string): void { + S.activeClusterId = id; + m.redraw(); +} + +export function renameCluster(id: string, name: string): void { + const cl = S.clusters.find((c) => c.id === id); + if (cl && name.trim()) cl.name = name.trim(); + m.redraw(); +} + +export function setVerdict(cl: Cluster, uuid: string, verdict: Verdict): void { + if (cl.verdicts.get(uuid) === verdict) { + cl.verdicts.delete(uuid); + } else { + cl.verdicts.set(uuid, verdict); + } + recomputeCounts(cl); + m.redraw(); +} + +function applyPropFilters(cl: Cluster, traces: TraceState[]): TraceState[] { + if (cl.propFilters.size === 0) return traces; + return traces.filter((ts) => { + for (const [field, allowed] of cl.propFilters) { + const val = traceFieldValue(ts, field); + if (!allowed.has(val)) return false; + } + return true; + }); +} + +function applySorting(cl: Cluster, traces: TraceState[]): TraceState[] { + if (cl.sortField === 'index') return traces; + const sorted = [...traces]; + sorted.sort( + (a, b) => (a.trace.startup_dur - b.trace.startup_dur) * cl.sortDir, + ); + return sorted; +} + +export function filterTraces( + cl: Cluster, + filter: OverviewFilter, +): TraceState[] { + let result: TraceState[]; + switch (filter) { + case 'positive': + result = cl.traces.filter((ts) => cl.verdicts.get(ts._key) === 'like'); + break; + case 'negative': + result = cl.traces.filter((ts) => cl.verdicts.get(ts._key) === 'dislike'); + break; + case 'pending': + result = cl.traces.filter((ts) => { + const v = cl.verdicts.get(ts._key); + return !v; + }); + break; + case 'discarded': + result = cl.traces.filter((ts) => cl.verdicts.get(ts._key) === 'discard'); + break; + default: + result = cl.traces; + } + result = applyPropFilters(cl, result); + return applySorting(cl, result); +} + +export function filteredTraces(): TraceState[] { + const cl = activeCluster(); + if (!cl) return []; + return filterTraces(cl, cl.overviewFilter); +} + +// Resolve a filterable field value -- checks top-level trace fields first, then extra +function traceFieldValue(ts: TraceState, field: string): string { + const trace = ts.trace as unknown as Record<string, unknown>; + if (field in trace) return String(trace[field] ?? ''); + return String(ts.trace.extra?.[field] ?? ''); +} + +// Collect unique values for a given field across all traces +export function getFieldValues(cl: Cluster, field: string): string[] { + const vals = new Set<string>(); + for (const ts of cl.traces) { + vals.add(traceFieldValue(ts, field)); + } + return [...vals].sort(); +} + +// Only these fields appear in the filter dropdown +const FILTERABLE_FIELDS = [ + 'startup_type', + 'package_name', + 'device_name', + 'unique_session_name', +]; + +// Get list of filterable extra fields that have multiple distinct values +export function getFilterableFields(cl: Cluster): string[] { + return FILTERABLE_FIELDS.filter((field) => { + const vals = new Set<string>(); + for (const ts of cl.traces) { + vals.add(traceFieldValue(ts, field)); + if (vals.size >= 2) return true; + } + return false; + }); +} + +export function togglePropFilter( + cl: Cluster, + field: string, + value: string, +): void { + let allowed = cl.propFilters.get(field); + if (!allowed) { + // First click: select only this value (deselect all others) + allowed = new Set([value]); + cl.propFilters.set(field, allowed); + } else if (allowed.has(value)) { + allowed.delete(value); + if (allowed.size === 0) cl.propFilters.delete(field); + } else { + allowed.add(value); + // If all values selected, remove filter entirely + const all = getFieldValues(cl, field); + if (allowed.size === all.length) cl.propFilters.delete(field); + } + m.redraw(); +} + +export function clearPropFilter(cl: Cluster, field: string): void { + cl.propFilters.delete(field); + m.redraw(); +} + +// -- Cross Compare -- + +let _ccState: CrossCompareState | null = null; + +export function getCrossCompareState(): CrossCompareState | null { + return _ccState; +} + +export function startCrossCompare(cl: Cluster): void { + const keys = cl.traces.map((ts) => ts._key); + // Resume if trace set matches, otherwise start fresh + if ( + _ccState && + _ccState.traceKeys.length === keys.length && + _ccState.traceKeys.every((k, i) => k === keys[i]) + ) { + m.redraw(); + return; + } + _ccState = createCrossCompareState(keys); + m.redraw(); +} + +export function closeCrossCompare(): void { + _ccState = null; + m.redraw(); +} + +function isPureAnchor(anchorKey: string): boolean { + if (!_ccState || _ccState.history.length === 0) return false; + return _ccState.history.every( + (e) => e.type === 'discard' || e.keyA === anchorKey || e.keyB === anchorKey, + ); +} + +function advancePair(anchorKey?: string): void { + if (!_ccState) return; + if (anchorKey && !_ccState.discardedKeys.has(anchorKey)) { + _ccState.currentPair = nextPairForAnchor(_ccState, anchorKey); + if (!_ccState.currentPair) { + // Pure anchor session: all comparisons involved the anchor -> done + if (!isPureAnchor(anchorKey)) { + _ccState.currentPair = nextPair(_ccState); + } + } + } else { + _ccState.currentPair = nextPair(_ccState); + } + if (!_ccState.currentPair) _ccState.isComplete = true; + _ccState.selectedSide = null; +} + +export function recordCrossComparison( + result: 'positive' | 'negative', + anchorKey?: string, +): void { + if (!_ccState || !_ccState.currentPair) return; + const [a, b] = _ccState.currentPair; + ccRecord(_ccState, a, b, result); + advancePair(anchorKey); + m.redraw(); +} + +export function skipCrossComparison(anchorKey?: string): void { + if (!_ccState || !_ccState.currentPair) return; + ccSkip(_ccState); + advancePair(anchorKey); + m.redraw(); +} + +export function undoCrossComparison(anchorKey?: string): void { + if (!_ccState || _ccState.history.length === 0) return; + ccUndo(_ccState); + // Re-advance with anchor if set + if (anchorKey && !_ccState.discardedKeys.has(anchorKey)) { + const pair = nextPairForAnchor(_ccState, anchorKey); + if (pair) { + _ccState.currentPair = pair; + _ccState.isComplete = false; + } + } + m.redraw(); +} + +export function discardCrossCompareTrace( + cl: Cluster, + side: 'left' | 'right', + anchorKey?: string, +): void { + if (!_ccState || !_ccState.currentPair) return; + const key = + side === 'left' ? _ccState.currentPair[0] : _ccState.currentPair[1]; + cl.verdicts.set(key, 'discard'); + recomputeCounts(cl); + ccDiscard(_ccState, key); + advancePair(anchorKey); + m.redraw(); +} + +export function applyCrossCompareResults( + cl: Cluster, + positiveIdx = 0, + negativeIdx = 1, +): void { + if (!_ccState) return; + const {groups} = ccResults(_ccState); + if (positiveIdx >= 0 && positiveIdx < groups.length) { + for (const key of groups[positiveIdx]) cl.verdicts.set(key, 'like'); + } + if (negativeIdx === -1) { + // Pure anchor: all groups except positive -> negative + for (let i = 0; i < groups.length; i++) { + if (i === positiveIdx) continue; + for (const key of groups[i]) cl.verdicts.set(key, 'dislike'); + } + } else if (negativeIdx >= 0 && negativeIdx < groups.length) { + for (const key of groups[negativeIdx]) { + cl.verdicts.set(key, 'dislike'); + } + } + recomputeCounts(cl); + _ccState = null; + // Switch to split view: negative left, positive right + cl.splitView = true; + cl.splitFilters = ['negative', 'positive']; + m.redraw(); +} + +export function resetCrossCompare(cl: Cluster): void { + const keys = cl.traces.map((ts) => ts._key); + _ccState = createCrossCompareState(keys); + m.redraw(); +} + +// -- Session save / restore -- + +interface SessionData { + version: 1; + activeClusterId: string | null; + clusters: { + id: string; + name: string; + traces: TraceEntry[]; + verdicts: [string, Verdict][]; + overviewFilter: OverviewFilter; + splitView: boolean; + splitFilters: [OverviewFilter, OverviewFilter]; + splitRatio: number; + sortField?: 'index' | 'startup_dur'; + sortDir?: 1 | -1; + propFilters?: [string, string[]][]; + globalSlider?: number; + }[]; +} + +export function exportSession(): string { + const data: SessionData = { + version: 1, + activeClusterId: S.activeClusterId, + clusters: S.clusters.map((cl) => ({ + id: cl.id, + name: cl.name, + traces: cl.traces.map((ts) => ts.trace), + verdicts: [...cl.verdicts.entries()], + overviewFilter: cl.overviewFilter, + splitView: cl.splitView, + splitFilters: cl.splitFilters, + splitRatio: cl.splitRatio, + sortField: cl.sortField, + sortDir: cl.sortDir, + propFilters: [...cl.propFilters.entries()].map(([k, v]) => [k, [...v]]), + globalSlider: cl.globalSlider, + })), + }; + return JSON.stringify(data); +} + +/** Hydrate pre-parsed session data into app state synchronously. */ +export function importSessionData(data: SessionData): void { + S.clusters = data.clusters.map((sc) => hydrateCluster(sc)); + S.activeClusterId = data.activeClusterId; + m.redraw(); +} + +function hydrateCluster(sc: SessionData['clusters'][0]): Cluster { + const traces = sc.traces.map(initTraceLazy); + const cl: Cluster = { + id: sc.id, + name: sc.name, + traces, + verdicts: new Map(sc.verdicts), + overviewFilter: sc.overviewFilter, + counts: {positive: 0, negative: 0, pending: 0, discarded: 0}, + tableSortState: {}, + splitView: sc.splitView, + splitFilters: sc.splitFilters, + splitRatio: sc.splitRatio, + sortField: sc.sortField || 'index', + sortDir: sc.sortDir || 1, + propFilters: new Map( + (sc.propFilters || []).map(([k, v]) => [k, new Set(v)]), + ), + globalSlider: sc.globalSlider ?? 100, + }; + recomputeCounts(cl); + if (traces.length > 0) ensureCache(traces[0]); + return cl; +} + +/** + * Hydrate session data asynchronously, yielding to the event loop + * between clusters so the progress bar can update. + */ +export async function importSessionDataAsync( + data: SessionData, + onProgress?: (message: string, pct: number) => void, +): Promise<void> { + const clusters: Cluster[] = []; + const total = data.clusters.length; + let processedTraces = 0; + const totalTraces = data.clusters.reduce( + (sum, sc) => sum + sc.traces.length, + 0, + ); + + for (let i = 0; i < total; i++) { + const sc = data.clusters[i]; + onProgress?.( + `Hydrating cluster ${i + 1}/${total}: ${sc.name} (${sc.traces.length} traces)`, + totalTraces > 0 + ? (processedTraces / totalTraces) * 100 + : (i / total) * 100, + ); + // Yield so the UI can repaint the progress bar + await new Promise<void>((r) => setTimeout(r, 0)); + + clusters.push(hydrateCluster(sc)); + processedTraces += sc.traces.length; + } + + S.clusters = clusters; + S.activeClusterId = data.activeClusterId; + onProgress?.('Done', 100); + m.redraw(); +} + +/** Parse + hydrate a session JSON string. Sync -- use parseSessionAsync for large files. */ +export function importSession(json: string): void { + const data: SessionData = JSON.parse(json); + if (data.version !== 1) throw new Error('Unknown session version'); + importSessionData(data); +}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/styles.scss b/ui/src/plugins/com.google.QuantizedSlices/styles.scss new file mode 100644 index 0000000..ce6cd1f --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/styles.scss
@@ -0,0 +1,1486 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@use "../../assets/theme"; + +// ============================================================================ +// QuantizedSlices plugin styles +// All classes prefixed with .qs- to avoid collisions with Perfetto CSS. +// Colors use Perfetto CSS variables — no light/dark media queries needed. +// ============================================================================ + +// --------------------------------------------------------------------------- +// Page layout +// --------------------------------------------------------------------------- +.qs-page { + height: 100%; + padding: 20px 24px 56px; + font-size: 13px; + line-height: 1.5; + color: var(--pf-color-text); + overflow-y: auto; + overflow-x: hidden; + box-sizing: border-box; +} + +.qs-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.qs-title { + font-size: 15px; + font-weight: 600; + color: var(--pf-color-text); + letter-spacing: -0.2px; +} + +.qs-header-stats { + font-size: 11px; + color: var(--pf-color-text-secondary); + font-family: var(--pf-font-family-monospace, monospace); + margin-top: 2px; +} + +// --------------------------------------------------------------------------- +// Tooltip +// --------------------------------------------------------------------------- +.qs-tooltip { + position: fixed; + background: var(--pf-color-background); + border: 1px solid var(--pf-color-border); + border-radius: theme.$border-radius-large; + padding: 11px 14px; + font-size: 11px; + line-height: 1.4; + pointer-events: none; + display: none; + z-index: 10001; + min-width: 280px; + max-width: 520px; + width: max-content; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + + .qs-tooltip-name { + font-weight: 600; + color: var(--pf-color-text); + font-size: 12px; + margin-bottom: 8px; + line-height: 1.4; + word-break: break-all; + white-space: normal; + } + + .qs-tooltip-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 10px; + align-items: baseline; + } + + .qs-tooltip-key { + color: var(--pf-color-text-secondary); + font-size: 10px; + font-family: var(--pf-font-family-monospace, monospace); + white-space: nowrap; + } + + .qs-tooltip-val { + color: var(--pf-color-text); + font-family: var(--pf-font-family-monospace, monospace); + font-size: 10px; + word-break: break-all; + white-space: normal; + line-height: 1.5; + } +} + +// --------------------------------------------------------------------------- +// Cards (shared) +// --------------------------------------------------------------------------- +.qs-card { + background: var(--pf-color-background); + border: 1px solid var(--pf-color-border); + border-radius: theme.$border-radius-large; +} + +// --------------------------------------------------------------------------- +// Buttons (shared) +// --------------------------------------------------------------------------- +.qs-btn { + font-size: 12px; + font-weight: 500; + padding: 6px 14px; + border-radius: theme.$border-radius-large; + cursor: pointer; + border: 1px solid var(--pf-color-border); + background: var(--pf-color-background); + color: var(--pf-color-text-secondary); + transition: all 0.15s; + white-space: nowrap; + + &:hover { + background: var(--pf-color-border); + color: var(--pf-color-text); + } + + &:disabled { + opacity: 0.4; + cursor: default; + } + + &.primary { + background: color-mix(in srgb, var(--pf-color-accent) 10%, transparent); + color: var(--pf-color-accent); + border-color: color-mix(in srgb, var(--pf-color-accent) 30%, transparent); + + &:hover { + background: color-mix(in srgb, var(--pf-color-accent) 20%, transparent); + } + } + + &.active-split { + background: color-mix(in srgb, var(--pf-color-accent) 10%, transparent); + color: var(--pf-color-accent); + border-color: color-mix(in srgb, var(--pf-color-accent) 30%, transparent); + } +} + +// --------------------------------------------------------------------------- +// Import panel +// --------------------------------------------------------------------------- +.qs-import { + padding: 14px; + + .qs-import-hint { + font-size: 11px; + color: var(--pf-color-text-secondary); + margin-bottom: 10px; + + code { + font-family: var(--pf-font-family-monospace, monospace); + font-size: 10px; + color: var(--pf-color-text-secondary); + background: var(--pf-color-background); + border: 1px solid var(--pf-color-border); + padding: 1px 5px; + border-radius: theme.$border-radius; + } + } +} + +.qs-json-area { + width: 100%; + min-height: 90px; + max-height: 180px; + background: var(--pf-color-background); + border: 1px solid var(--pf-color-border); + border-radius: theme.$border-radius-large; + color: var(--pf-color-text-secondary); + font-family: var(--pf-font-family-monospace, monospace); + font-size: 10px; + line-height: 1.6; + padding: 10px 12px; + resize: vertical; + outline: none; + transition: border-color 0.2s; + + &:focus { + border-color: var(--pf-color-accent); + } +} + +.qs-import-actions { + display: flex; + gap: 8px; + margin-top: 9px; + align-items: center; + flex-wrap: wrap; +} + +.qs-msg-ok { + font-size: 10px; + color: var(--pf-color-success); + font-family: var(--pf-font-family-monospace, monospace); +} + +.qs-msg-err { + font-size: 10px; + color: var(--pf-color-danger); + font-family: var(--pf-font-family-monospace, monospace); +} + +// --------------------------------------------------------------------------- +// Progress bar +// --------------------------------------------------------------------------- +.qs-progress { + margin-top: 10px; + + .qs-progress-text { + font-size: 10px; + color: var(--pf-color-text-secondary); + font-family: var(--pf-font-family-monospace, monospace); + margin-bottom: 4px; + } + + .qs-progress-bar { + height: 3px; + border-radius: theme.$border-radius; + background: var(--pf-color-border); + overflow: hidden; + } + + .qs-progress-fill { + height: 100%; + background: var(--pf-color-accent); + border-radius: theme.$border-radius; + transition: width 0.2s ease-out; + } +} + +@keyframes qs-pulse { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 1; + } +} + +// --------------------------------------------------------------------------- +// Trace list toolbar +// --------------------------------------------------------------------------- +.qs-list-toolbar { + display: flex; + align-items: center; + padding: 8px 16px; + margin-bottom: 14px; + gap: 10px; +} + +.qs-list-filters { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.qs-filter-btn { + font-size: 11px; + font-weight: 500; + padding: 4px 10px; + border-radius: theme.$border-radius-large; + cursor: pointer; + border: 1px solid var(--pf-color-border); + background: var(--pf-color-background); + color: var(--pf-color-text-secondary); + transition: all 0.15s; + display: flex; + align-items: center; + gap: 5px; + white-space: nowrap; + + &:hover { + background: var(--pf-color-border); + color: var(--pf-color-text); + } + + &.active { + background: color-mix(in srgb, var(--pf-color-accent) 10%, transparent); + color: var(--pf-color-accent); + border-color: color-mix(in srgb, var(--pf-color-accent) 30%, transparent); + } +} + +.qs-filter-count { + font-family: var(--pf-font-family-monospace, monospace); + font-size: 9px; + background: var(--pf-color-background); + border-radius: theme.$border-radius-large; + padding: 0 5px; + min-width: 18px; + text-align: center; +} + +.qs-fc-positive { + color: var(--pf-color-success); +} + +.qs-fc-negative { + color: var(--pf-color-danger); +} + +.qs-fc-pending { + color: var(--pf-color-text-secondary); +} + +.qs-fc-discarded { + color: var(--pf-color-text-secondary); + opacity: 0.6; +} + +.qs-fc-all { + color: var(--pf-color-text-secondary); +} + +.qs-list-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +// --------------------------------------------------------------------------- +// Filter dropdown +// --------------------------------------------------------------------------- +.qs-filter-dropdown-wrap { + position: relative; +} + +.qs-filter-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + z-index: 100; + background: var(--pf-color-background); + border: 1px solid var(--pf-color-border); + border-radius: theme.$border-radius-large; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + padding: 8px 0; + min-width: 200px; + max-height: 360px; + overflow-y: auto; + + .qs-filter-field { + padding: 4px 12px; + + & + .qs-filter-field { + border-top: 1px solid var(--pf-color-border); + padding-top: 8px; + margin-top: 4px; + } + } + + .qs-filter-field-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; + } + + .qs-filter-field-name { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--pf-color-text-secondary); + } + + .qs-filter-clear { + font-size: 9px; + color: var(--pf-color-accent); + background: none; + border: none; + cursor: pointer; + padding: 0; + + &:hover { + text-decoration: underline; + } + } + + .qs-filter-field-values { + display: flex; + flex-direction: column; + gap: 2px; + } + + .qs-filter-value-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--pf-color-text-secondary); + cursor: pointer; + padding: 2px 0; + + &:hover { + color: var(--pf-color-text); + } + + input[type="checkbox"] { + accent-color: var(--pf-color-accent); + } + } +} + +// --------------------------------------------------------------------------- +// Export dropdown +// --------------------------------------------------------------------------- +.qs-export-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + z-index: 100; + background: var(--pf-color-background); + border: 1px solid var(--pf-color-border); + border-radius: theme.$border-radius-large; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + padding: 6px 0; + min-width: 120px; + + .qs-export-section-label { + font-size: 9px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--pf-color-text-secondary); + padding: 4px 12px 2px; + } + + .qs-export-item { + display: block; + width: 100%; + text-align: left; + padding: 5px 12px; + font-size: 11px; + color: var(--pf-color-text-secondary); + background: none; + border: none; + cursor: pointer; + transition: all 0.1s; + + &:hover { + background: color-mix(in srgb, var(--pf-color-accent) 10%, transparent); + color: var(--pf-color-accent); + } + } +} + +// --------------------------------------------------------------------------- +// Trace cards +// --------------------------------------------------------------------------- +.qs-trace-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.qs-show-more { + width: 100%; + padding: 8px; + font-size: 11px; + text-align: center; +} + +.qs-trace-card { + overflow: hidden; + transition: border-color 0.2s; + + &.verdict-positive { + border-color: var(--pf-color-success); + } + + &.verdict-negative { + border-color: var(--pf-color-danger); + } + + &.verdict-discard { + border-color: var(--pf-color-text-secondary); + opacity: 0.5; + } +} + +.qs-card-header { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 8px 14px; + user-select: none; + + &:hover { + background: color-mix(in srgb, var(--pf-color-text) 3%, transparent); + } + + .qs-collapse-arrow { + font-size: 10px; + color: var(--pf-color-text-secondary); + transition: transform 0.15s; + + &.open { + transform: rotate(90deg); + } + } + + .qs-trace-idx { + font-family: var(--pf-font-family-monospace, monospace); + font-size: 10px; + color: var(--pf-color-text-secondary); + min-width: 28px; + } + + .qs-trace-pkg { + font-size: 11px; + font-weight: 500; + color: var(--pf-color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .qs-trace-startup-dur { + font-family: var(--pf-font-family-monospace, monospace); + font-size: 12px; + font-weight: 700; + color: var(--pf-color-accent); + white-space: nowrap; + } + + .qs-trace-actions { + margin-left: auto; + display: flex; + gap: 4px; + flex-shrink: 0; + } +} + +.qs-trace-link { + font-size: 12px; + color: var(--pf-color-accent); + text-decoration: none; + opacity: 0.6; + transition: opacity 0.15s; + + &:hover { + opacity: 1; + } +} + +.qs-verdict-btn-sm { + font-family: var(--pf-font-family-monospace, monospace); + font-size: 14px; + font-weight: 700; + width: 32px; + height: 32px; + border-radius: theme.$border-radius-large; + cursor: pointer; + border: 1px solid var(--pf-color-border); + background: var(--pf-color-background); + color: var(--pf-color-text-secondary); + transition: all 0.12s; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } +} + +.qs-active-positive { + background: var(--pf-color-success); + color: #fff; + border-color: var(--pf-color-success); +} + +.qs-active-negative { + background: var(--pf-color-danger); + color: #fff; + border-color: var(--pf-color-danger); +} + +.qs-active-discard { + background: var(--pf-color-text-secondary); + color: #fff; + border-color: var(--pf-color-text-secondary); +} + +// --------------------------------------------------------------------------- +// Trace card body & detail +// --------------------------------------------------------------------------- +.qs-trace-card-body { + padding: 6px 14px 10px; +} + +.qs-trace-slider { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + + .qs-slider-label { + font-size: 10px; + color: var(--pf-color-text-secondary); + white-space: nowrap; + } + + .qs-slider-num { + font-family: var(--pf-font-family-monospace, monospace); + font-size: 12px; + font-weight: 500; + color: var(--pf-color-text); + min-width: 24px; + } + + .qs-slider-of { + font-family: var(--pf-font-family-monospace, monospace); + font-size: 10px; + color: var(--pf-color-text-secondary); + } +} + +.qs-global-slider { + padding: 0; + flex: 1; + min-width: 0; + + input[type="range"] { + flex: 1; + min-width: 60px; + } +} + +.qs-card-body { + padding: 6px 14px 10px; +} + +.qs-card-detail { + padding: 10px 14px; + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + max-height: 600px; +} + +.qs-detail-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--pf-color-text-secondary); + margin-bottom: 6px; +} + +.qs-detail-meta { + padding: 10px 0 0; +} + +// Key-value grid for trace metadata (UUID, package, startup, extras) +.qs-tt-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 5px 14px; + align-items: baseline; +} + +.qs-tt-k { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--pf-color-text-secondary); + font-family: var(--pf-font-family-monospace, monospace); + white-space: nowrap; +} + +.qs-tt-v { + font-size: 11px; + color: var(--pf-color-text); + font-family: var(--pf-font-family-monospace, monospace); + word-break: break-all; + line-height: 1.5; +} + +// --------------------------------------------------------------------------- +// Range input styling +// --------------------------------------------------------------------------- +.qs-page input[type="range"] { + -webkit-appearance: none; + appearance: none; + width: 200px; + height: 2px; + border-radius: 1px; + background: var(--pf-color-border); + outline: none; + cursor: pointer; + transition: background 0.2s; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + width: 11px; + height: 11px; + border-radius: 50%; + background: var(--pf-color-accent); + cursor: pointer; + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--pf-color-accent) 15%, transparent); + } +} + +// --------------------------------------------------------------------------- +// Mini timeline canvas +// --------------------------------------------------------------------------- +.qs-mini-canvas { + padding: 4px 0; + + canvas { + display: block; + width: 100%; + height: 30px; + cursor: crosshair; + } +} + +// --------------------------------------------------------------------------- +// Summary tables +// --------------------------------------------------------------------------- +.qs-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 14px; +} + +.qs-table-card { + overflow: hidden; + + .qs-table-card-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 9px 14px; + border-bottom: 1px solid var(--pf-color-border); + background: var(--pf-color-background); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--pf-color-text-secondary); + } + + .qs-table-scroll { + max-height: 260px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--pf-color-border); + border-radius: theme.$border-radius; + } + } +} + +table.qs-summary { + width: 100%; + border-collapse: collapse; + + thead th { + position: sticky; + top: 0; + background: var(--pf-color-background); + padding: 6px 14px; + font-size: 10px; + font-weight: 600; + color: var(--pf-color-text-secondary); + text-align: left; + letter-spacing: 0.06em; + text-transform: uppercase; + border-bottom: 1px solid var(--pf-color-border); + cursor: pointer; + user-select: none; + white-space: nowrap; + + &:hover { + color: var(--pf-color-text); + } + + .sort-arrow { + margin-left: 4px; + opacity: 0.4; + } + + &.sorted .sort-arrow { + opacity: 1; + color: var(--pf-color-accent); + } + + &:not(:first-child) { + text-align: right; + } + } + + tbody { + tr { + transition: background 0.1s; + + &:hover { + background: color-mix(in srgb, var(--pf-color-text) 3%, transparent); + } + } + + td { + padding: 5px 14px; + font-size: 11px; + color: var(--pf-color-text-secondary); + vertical-align: middle; + + &:first-child { + color: var(--pf-color-text); + max-width: 220px; + font-size: 11px; + } + + &:not(:first-child) { + font-family: var(--pf-font-family-monospace, monospace); + font-size: 10px; + color: var(--pf-color-text-secondary); + text-align: right; + white-space: nowrap; + } + } + } +} + +.qs-cell-label { + display: flex; + align-items: center; + gap: 6px; +} + +.qs-swatch { + width: 7px; + height: 7px; + border-radius: 1px; + flex-shrink: 0; + display: inline-block; +} + +.qs-name-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 180px; + display: block; +} + +.qs-bar-wrap { + margin-top: 3px; + height: 2px; + background: var(--pf-color-border); + border-radius: 1px; + overflow: hidden; +} + +.qs-bar-fill { + height: 2px; + border-radius: 1px; +} + +// --------------------------------------------------------------------------- +// Cluster tabs +// --------------------------------------------------------------------------- +.qs-cluster-tabs { + display: flex; + gap: 2px; + margin-bottom: 16px; + flex-wrap: wrap; + border-bottom: 1px solid var(--pf-color-border); + padding-bottom: 0; +} + +.qs-cluster-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + font-size: 11px; + font-weight: 500; + color: var(--pf-color-text-secondary); + cursor: pointer; + border: 1px solid var(--pf-color-border); + border-bottom: none; + border-radius: theme.$border-radius-large theme.$border-radius-large 0 0; + background: var(--pf-color-background); + transition: all 0.15s; + position: relative; + top: 1px; + + &:hover { + color: var(--pf-color-text); + } + + &.active { + color: var(--pf-color-accent); + border-bottom: 1px solid var(--pf-color-background); + font-weight: 600; + } + + .qs-cluster-count { + font-family: var(--pf-font-family-monospace, monospace); + font-size: 9px; + color: var(--pf-color-text-secondary); + } + + .qs-cluster-close { + font-size: 14px; + line-height: 1; + color: var(--pf-color-text-secondary); + background: none; + border: none; + cursor: pointer; + padding: 0 2px; + border-radius: theme.$border-radius; + transition: all 0.1s; + + &:hover { + color: var(--pf-color-danger); + background: color-mix(in srgb, var(--pf-color-danger) 10%, transparent); + } + } +} + +// --------------------------------------------------------------------------- +// Split view +// --------------------------------------------------------------------------- +.qs-split-container { + height: calc(100vh - 280px); + min-height: 300px; +} + +.qs-split-panel-inner { + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; + overflow: hidden; +} + +.qs-split-panel { + display: flex; + flex-direction: column; + min-width: 200px; + overflow: hidden; +} + +.qs-split-panel-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + background: var(--pf-color-background); + flex-wrap: wrap; + + .qs-list-filters { + gap: 2px; + } + + .qs-filter-btn { + padding: 2px 8px; + font-size: 10px; + } + + .qs-filter-count { + font-size: 8px; + padding: 0 3px; + min-width: 14px; + } +} + +.qs-split-count { + font-family: var(--pf-font-family-monospace, monospace); + font-size: 9px; + color: var(--pf-color-text-secondary); + margin-left: auto; + white-space: nowrap; +} + +.qs-split-panel-body { + flex: 1; + overflow-y: auto; + padding: 8px; + background: var(--pf-color-background); + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--pf-color-border); + border-radius: theme.$border-radius; + } +} + +.qs-split-divider { + width: 6px; + cursor: col-resize; + background: var(--pf-color-border); + flex-shrink: 0; + position: relative; + margin: 0 1px; + transition: background 0.15s; + + &:hover { + background: var(--pf-color-accent); + } + + &::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 2px; + height: 32px; + background: var(--pf-color-text-secondary); + border-radius: 1px; + } +} + +// --------------------------------------------------------------------------- +// Cross Compare modal +// --------------------------------------------------------------------------- +.qs-cc-overlay { + position: fixed; + inset: 0; + z-index: 10000; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; +} + +.qs-cc-modal { + background: var(--pf-color-background); + border: 1px solid var(--pf-color-border); + border-radius: theme.$border-radius-large; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + max-width: 1200px; + width: 95vw; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.qs-cc-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + border-bottom: 1px solid var(--pf-color-border); + + .qs-cc-title { + font-size: 13px; + font-weight: 600; + color: var(--pf-color-text); + } +} + +.qs-cc-close { + background: none; + border: none; + font-size: 18px; + cursor: pointer; + color: var(--pf-color-text-secondary); + padding: 0 4px; + line-height: 1; + + &:hover { + color: var(--pf-color-text); + } +} + +.qs-cc-progress { + padding: 10px 20px; + + .qs-cc-progress-text { + font-size: 10px; + color: var(--pf-color-text-secondary); + font-family: var(--pf-font-family-monospace, monospace); + margin-bottom: 4px; + } +} + +.qs-cc-progress-bar { + height: 4px; + background: var(--pf-color-border); + border-radius: theme.$border-radius; + overflow: hidden; +} + +.qs-cc-progress-fill { + height: 100%; + background: var(--pf-color-accent); + border-radius: theme.$border-radius; + transition: width 0.2s; +} + +.qs-cc-body { + flex: 1; + overflow-y: auto; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.qs-cc-pair { + display: flex; + gap: 12px; + align-items: stretch; +} + +.qs-cc-pair-divider { + display: flex; + align-items: center; + font-size: 11px; + font-weight: 600; + color: var(--pf-color-text-secondary); + padding: 0 4px; +} + +.qs-cc-panel { + flex: 1; + border: 2px solid var(--pf-color-border); + border-radius: theme.$border-radius-large; + padding: 10px; + display: flex; + flex-direction: column; + gap: 6px; + transition: border-color 0.15s; + overflow: hidden; + cursor: pointer; + + &.selected { + border-color: var(--pf-color-accent); + } + + &.anchored { + border-color: var(--pf-color-accent); + background: color-mix(in srgb, var(--pf-color-accent) 5%, transparent); + } + + .qs-cc-panel-header { + display: flex; + align-items: center; + gap: 8px; + } + + .qs-cc-panel-idx { + font-family: var(--pf-font-family-monospace, monospace); + font-size: 10px; + color: var(--pf-color-text-secondary); + } + + .qs-cc-panel-pkg { + font-size: 11px; + font-weight: 500; + color: var(--pf-color-text); + } + + .qs-cc-panel-dur { + font-family: var(--pf-font-family-monospace, monospace); + font-size: 12px; + font-weight: 700; + color: var(--pf-color-accent); + margin-left: auto; + } + + .qs-cc-panel-detail { + max-height: 200px; + overflow-y: auto; + } +} + +.qs-cc-anchor-badge { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--pf-color-accent); + background: color-mix(in srgb, var(--pf-color-accent) 10%, transparent); + padding: 1px 5px; + border-radius: theme.$border-radius-large; + margin-left: auto; +} + +.qs-cc-actions { + display: flex; + gap: 10px; + justify-content: center; + padding: 4px 0; + + .qs-cc-action-btn { + padding: 7px 16px; + border-radius: theme.$border-radius-large; + font-size: 11px; + font-weight: 600; + cursor: pointer; + border: 1px solid var(--pf-color-border); + background: var(--pf-color-background); + color: var(--pf-color-text-secondary); + display: flex; + align-items: center; + gap: 6px; + transition: all 0.1s; + + &:hover { + background: var(--pf-color-border); + color: var(--pf-color-text); + } + + &.positive { + background: color-mix(in srgb, var(--pf-color-success) 15%, transparent); + color: var(--pf-color-success); + border-color: var(--pf-color-success); + + &:hover { + background: color-mix( + in srgb, + var(--pf-color-success) 25%, + transparent + ); + } + } + + &.negative { + background: color-mix(in srgb, var(--pf-color-danger) 15%, transparent); + color: var(--pf-color-danger); + border-color: var(--pf-color-danger); + + &:hover { + background: color-mix(in srgb, var(--pf-color-danger) 25%, transparent); + } + } + + &.discard { + background: var(--pf-color-background); + color: var(--pf-color-text-secondary); + border-color: var(--pf-color-border); + + &:hover { + background: var(--pf-color-border); + color: var(--pf-color-text-secondary); + } + } + + kbd { + font-family: var(--pf-font-family-monospace, monospace); + font-size: 9px; + padding: 1px 4px; + border: 1px solid currentColor; + border-radius: theme.$border-radius; + opacity: 0.7; + color: inherit; + } + } +} + +.qs-cc-slider { + display: flex; + align-items: center; + gap: 8px; + justify-content: center; + padding: 2px 0; + + input[type="range"] { + width: 200px; + } +} + +.qs-cc-hint { + font-size: 10px; + color: var(--pf-color-text-secondary); + text-align: center; +} + +.qs-cc-footer { + display: flex; + gap: 8px; + justify-content: center; + padding: 8px 20px; + border-top: 1px solid var(--pf-color-border); +} + +// --------------------------------------------------------------------------- +// Cross Compare review screen +// --------------------------------------------------------------------------- +.qs-cc-review { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + overflow: hidden; +} + +.qs-cc-review-split { + display: flex; + gap: 12px; + flex: 1; + overflow: hidden; + padding: 12px 20px 0; +} + +.qs-cc-review-panel { + flex: 1; + display: flex; + flex-direction: column; + border: 1px solid var(--pf-color-border); + border-radius: theme.$border-radius-large; + overflow: hidden; + min-width: 0; +} + +.qs-cc-review-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + border-bottom: 1px solid var(--pf-color-border); + + &.positive { + background: color-mix(in srgb, var(--pf-color-success) 10%, transparent); + color: var(--pf-color-success); + } + + &.negative { + background: color-mix(in srgb, var(--pf-color-danger) 10%, transparent); + color: var(--pf-color-danger); + } +} + +.qs-cc-review-count { + font-family: var(--pf-font-family-monospace, monospace); + font-size: 10px; + padding: 1px 6px; + border-radius: theme.$border-radius-large; + background: var(--pf-color-background); +} + +.qs-cc-review-panel-body { + flex: 1; + overflow-y: auto; + padding: 6px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.qs-cc-review-row { + padding: 6px 8px; + border-radius: theme.$border-radius-large; + border: 1px solid var(--pf-color-border); + background: var(--pf-color-background); + + .qs-cc-review-row-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 2px; + } +} + +.qs-cc-review-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 2px 0; +} + +// --------------------------------------------------------------------------- +// Legend +// --------------------------------------------------------------------------- +.qs-legend { + display: flex; + flex-wrap: wrap; + gap: 14px; + align-items: center; + + .qs-legend-item { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--pf-color-text-secondary); + } + + .qs-legend-swatch { + width: 8px; + height: 8px; + border-radius: 1px; + flex-shrink: 0; + } +} + +// --------------------------------------------------------------------------- +// Section headings +// --------------------------------------------------------------------------- +.qs-section { + margin-bottom: 20px; +} + +.qs-section-head { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--pf-color-text-secondary); + margin-top: 16px; + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 10px; + + &::after { + content: ""; + flex: 1; + height: 1px; + background: var(--pf-color-border); + } +} + +// --------------------------------------------------------------------------- +// Canvas (full timeline) +// --------------------------------------------------------------------------- +.qs-canvas-wrap { + padding: 14px 14px 10px; + overflow: hidden; + + canvas { + display: block; + width: 100%; + cursor: crosshair; + } +}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/utils/colors.ts b/ui/src/plugins/com.google.QuantizedSlices/utils/colors.ts new file mode 100644 index 0000000..b8286fe --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/utils/colors.ts
@@ -0,0 +1,76 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {HSLColor, HSLuvColor} from '../../../base/color'; +import {hash} from '../../../base/hash'; + +const nameColorCache = new Map<string, string>(); + +export function nameColor(name: string): string { + const seed = name.replace(/( )?\d+/g, ''); + if (nameColorCache.has(seed)) return nameColorCache.get(seed)!; + const hue = hash(seed, 360); + const lightness = hash(seed + 'x', 40) + 40; + const color = new HSLuvColor([hue, 80, lightness]); + nameColorCache.set(seed, color.cssString); + return color.cssString; +} + +export function isDark(): boolean { + return !!document.querySelector('.pf-theme-provider--dark'); +} + +// Thread state colors — using Perfetto's HSLColor. +const STATE_RUNNING = new HSLColor([120, 44, 34]).cssString; +const STATE_RUNNABLE = new HSLColor([75, 55, 47]).cssString; +const STATE_IO_WAIT = new HSLColor([36, 100, 50]).cssString; +const STATE_NONIO = new HSLColor([3, 30, 49]).cssString; +const STATE_CREATED = new HSLColor([0, 0, 70]).cssString; +const STATE_UNKNOWN = new HSLColor([44, 63, 91]).cssString; +const STATE_DEAD = new HSLColor([0, 0, 62]).cssString; +const STATE_INDIGO = new HSLColor([231, 48, 48]).cssString; + +export function stateColor(d: { + state: string | null; + io_wait: number | null; +}): string { + const s = d.state; + if (!s) return isDark() ? '#44444e' : STATE_UNKNOWN; + if (s === 'Created') return STATE_CREATED; + if (s === 'Running') return STATE_RUNNING; + if (s.startsWith('Runnable')) return STATE_RUNNABLE; + if (s.includes('Uninterruptible Sleep')) { + if (s.includes('non-IO') || d.io_wait === 0) return STATE_NONIO; + return STATE_IO_WAIT; + } + if (s.includes('Dead')) return STATE_DEAD; + if (s.includes('Sleeping') || s.includes('Idle')) { + return isDark() ? '#2a2a3a' : '#ffffff'; + } + if (s.includes('Unknown')) return STATE_UNKNOWN; + return STATE_INDIGO; +} + +export function stateLabel(d: { + state: string | null; + io_wait: number | null; +}): string { + if (!d.state) return 'Unknown'; + if (d.state === 'Uninterruptible Sleep') { + if (d.io_wait === 1) return 'Unint. Sleep (IO)'; + if (d.io_wait === 0) return 'Unint. Sleep (non-IO)'; + return 'Unint. Sleep'; + } + return d.state; +}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/utils/export.ts b/ui/src/plugins/com.google.QuantizedSlices/utils/export.ts new file mode 100644 index 0000000..c922459 --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/utils/export.ts
@@ -0,0 +1,114 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {Verdict, TRACE_VIEWER_BASE_URL} from '../models/types'; + +export interface ExportRow { + trace_uuid: string; + package_name: string; + startup_dur: number; + tab_name: string; + verdict: string; + link: string; + [key: string]: unknown; +} + +export interface ExportableTrace { + trace_uuid: string; + package_name: string; + startup_dur: number; + extra?: Record<string, unknown>; +} + +const EXCLUDED_EXTRA = new Set([ + 'slices', + 'quantized_sequence', + 'quantized_sequence_json', + 'quantized_sequence_base64', +]); + +export function buildTraceLink(uuid: string, packageName?: string): string { + if (!uuid) return ''; + let url = `${TRACE_VIEWER_BASE_URL}?uuid=${uuid}`; + if (packageName) { + url += `&query=${encodeURIComponent(`com.android.AndroidStartup.packageName=${packageName}`)}`; + } + return url; +} + +function verdictLabel(v: Verdict | undefined): string { + if (v === 'like') return 'positive'; + if (v === 'dislike') return 'negative'; + if (v === 'discard') return 'discarded'; + return 'pending'; +} + +export function traceExportRow( + trace: ExportableTrace, + traceKey: string, + tabName: string, + verdicts: Map<string, Verdict>, +): ExportRow { + const row: ExportRow = { + trace_uuid: trace.trace_uuid, + package_name: trace.package_name, + startup_dur: trace.startup_dur, + tab_name: tabName, + verdict: verdictLabel(verdicts.get(traceKey)), + link: buildTraceLink(trace.trace_uuid, trace.package_name), + }; + if (trace.extra) { + for (const [k, v] of Object.entries(trace.extra)) { + if (!EXCLUDED_EXTRA.has(k)) row[k] = v; + } + } + return row; +} + +const FIXED_COLS = [ + 'trace_uuid', + 'package_name', + 'startup_dur', + 'tab_name', + 'verdict', + 'link', +]; + +function tsvEscape(v: unknown): string { + if (v == null) return ''; + if (typeof v === 'object') { + return JSON.stringify(v).replace(/[\t\n\r]/g, ' '); + } + return String(v).replace(/[\t\n\r]/g, ' '); +} + +export function rowsToTsv(rows: ExportRow[]): string { + if (rows.length === 0) return ''; + const extraCols = new Set<string>(); + for (const row of rows) { + for (const k of Object.keys(row)) { + if (!FIXED_COLS.includes(k)) extraCols.add(k); + } + } + const cols = [...FIXED_COLS, ...[...extraCols].sort()]; + const header = cols.join('\t'); + const lines = rows.map((row) => + cols.map((c) => tsvEscape(row[c])).join('\t'), + ); + return header + '\n' + lines.join('\n'); +} + +export function rowsToJson(rows: ExportRow[]): string { + return JSON.stringify(rows, null, 2); +}
diff --git a/ui/src/plugins/com.google.QuantizedSlices/utils/format.ts b/ui/src/plugins/com.google.QuantizedSlices/utils/format.ts new file mode 100644 index 0000000..68c97eb --- /dev/null +++ b/ui/src/plugins/com.google.QuantizedSlices/utils/format.ts
@@ -0,0 +1,24 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export function fmtDur(ns: number): string { + if (ns >= 1e9) return (ns / 1e9).toFixed(3) + ' s'; + if (ns >= 1e6) return (ns / 1e6).toFixed(1) + ' ms'; + return (ns / 1e3).toFixed(0) + ' \u00b5s'; +} + +export function fmtPct(ns: number, total: number): string { + if (total === 0) return '0.0%'; + return ((ns / total) * 100).toFixed(1) + '%'; +}