Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 160 additions & 55 deletions ui/src/plugins/com.android.HeapDumpExplorer/heap_dump_page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,46 @@ import OverviewView from './views/overview_view';
import DominatorsView from './views/dominators_view';
import ObjectView from './views/object_view';
import AllObjectsView from './views/all_objects_view';
import InstancesView from './views/instances_view';
import BitmapGalleryView from './views/bitmap_gallery_view';
import ClassesView from './views/classes_view';
import StringsView from './views/strings_view';
import ArraysView from './views/arrays_view';
import FlamegraphObjectsView from './views/flamegraph_objects_view';
import FlamegraphObjectsView, {
flamegraphQuery,
} from './views/flamegraph_objects_view';
import {SQL_PREAMBLE} from './components';
import {NUM} from '../../trace_processor/query_result';

// Each "Open in Heapdump Explorer" creates a closable flamegraph tab.
let nextFgId = 0;
const flamegraphTabs: Array<{id: number} & HeapdumpSelection> = [];
const flamegraphTabs: Array<
{id: number; count: number | null} & HeapdumpSelection
> = [];
let activeFgId = -1;

export function setFlamegraphSelection(sel: HeapdumpSelection): void {
export function setFlamegraphSelection(
sel: HeapdumpSelection,
engine: Engine,
): void {
const existing = flamegraphTabs.find(
(t) => t.pathHashes === sel.pathHashes && t.isDominator === sel.isDominator,
);
if (existing) {
activeFgId = existing.id;
navigate('flamegraph-objects');
return;
}
const id = nextFgId++;
flamegraphTabs.push({id, ...sel});
const tab = {id, count: null as number | null, ...sel};
flamegraphTabs.push(tab);
activeFgId = id;
const q = flamegraphQuery(sel.pathHashes, sel.isDominator);
engine
.query(`${SQL_PREAMBLE}; SELECT COUNT(*) AS c FROM (${q})`)
.then((r) => {
tab.count = Number(r.firstRow({c: NUM}).c);
m.redraw();
});
}

export function resetFlamegraphSelection(): void {
Expand All @@ -65,14 +89,74 @@ export function resetCachedOverview(): void {
overviewLoading = false;
}

// Maps drill-down views to their parent tab.
function activeTabKey(view: string): string {
switch (view) {
case 'object':
case 'instances':
return 'classes';
default:
return view;
// Closable object tabs — clicking an object anywhere opens a new tab.
interface InstanceTab {
id: number;
objId: number;
label: string;
}

let nextInstanceTabId = 0;
const instanceTabs: InstanceTab[] = [];
let activeInstanceTabId = -1;

function instanceTabKey(id: number): string {
return `inst-${id}`;
}

export function resetInstanceTabs(): void {
instanceTabs.length = 0;
nextInstanceTabId = 0;
activeInstanceTabId = -1;
}

function openInstanceTab(objId: number, label?: string): void {
const existing = instanceTabs.find((t) => t.objId === objId);
if (existing) {
activeInstanceTabId = existing.id;
return;
}
const displayLabel = label ?? 'Instance';
const tab: InstanceTab = {
id: nextInstanceTabId++,
objId,
label:
displayLabel.length > 30
? displayLabel.slice(0, 30) + '\u2026'
: displayLabel,
};
instanceTabs.push(tab);
activeInstanceTabId = tab.id;
}

// Navigate wrapper: intercepts 'object' to open closable instance tabs.
function navigateWithTabs(
view: NavState['view'],
params?: Record<string, unknown>,
): void {
if (view === 'object') {
openInstanceTab(params?.id as number, params?.label as string | undefined);
navigate(view, params);
return;
}
activeInstanceTabId = -1;
navigate(view, params);
}

// When nav state points to 'object' (e.g. after browser back), ensure
// the matching instance tab exists and is active. When nav moves away
// from 'object', clear the active instance tab so fixed tabs are shown.
function syncInstanceTabFromNav(): void {
if (nav.view !== 'object') {
activeInstanceTabId = -1;
return;
}
const objId = nav.params.id;
const existing = instanceTabs.find((t) => t.objId === objId);
if (existing) {
activeInstanceTabId = existing.id;
} else {
openInstanceTab(objId, nav.params.label);
}
}

Expand All @@ -92,54 +176,53 @@ function getActiveTabKey(): string {
tab ? tab.id : flamegraphTabs[flamegraphTabs.length - 1].id,
);
}
return activeTabKey(nav.view);
if (activeInstanceTabId >= 0) {
return instanceTabKey(activeInstanceTabId);
}
return nav.view;
}

function handleTabChange(key: string): void {
const id = parseFgTabKey(key);
if (id !== undefined) {
activeFgId = id;
const fgId = parseFgTabKey(key);
if (fgId !== undefined) {
activeFgId = fgId;
navigate('flamegraph-objects');
} else if (key.startsWith('inst-')) {
activeInstanceTabId = parseInt(key.slice(5), 10);
const tab = instanceTabs.find((t) => t.id === activeInstanceTabId);
if (tab) {
navigate('object', {id: tab.objId});
}
} else {
activeFgId = -1;
activeInstanceTabId = -1;
navigate(key as NavState['view']);
}
}

function handleTabClose(key: string): void {
const id = parseFgTabKey(key);
if (id === undefined) return;
const idx = flamegraphTabs.findIndex((t) => t.id === id);
const fgId = parseFgTabKey(key);
if (fgId !== undefined) {
const idx = flamegraphTabs.findIndex((t) => t.id === fgId);
if (idx === -1) return;
flamegraphTabs.splice(idx, 1);
if (activeFgId === fgId) {
activeFgId = -1;
navigate('overview');
}
return;
}
if (!key.startsWith('inst-')) return;
const id = parseInt(key.slice(5), 10);
const idx = instanceTabs.findIndex((t) => t.id === id);
if (idx === -1) return;
flamegraphTabs.splice(idx, 1);
if (activeFgId === id) {
activeFgId = -1;
instanceTabs.splice(idx, 1);
if (activeInstanceTabId === id) {
activeInstanceTabId = -1;
navigate('overview');
}
}

// Renders the content for the "classes" tab, which also hosts drill-down
// views (instances, object detail) based on the current nav state.
function classesTabContent(
state: NavState,
engine: Engine,
overview: OverviewData,
): m.Children {
switch (state.view) {
case 'object':
return m(ObjectView, {
engine,
heaps: overview.heaps,
navigate,
params: state.params,
});
case 'instances':
return m(InstancesView, {engine, navigate, params: state.params});
default:
return m(ClassesView, {engine, navigate});
}
}

function buildTabs(
state: NavState,
engine: Engine,
Expand All @@ -150,29 +233,33 @@ function buildTabs(
{
key: 'overview',
title: 'Overview',
content: m(OverviewView, {overview, navigate}),
content: m(OverviewView, {overview, navigate: navigateWithTabs}),
},
{
key: 'classes',
title: 'Classes',
content: classesTabContent(state, engine, overview),
content: m(ClassesView, {engine, navigate: navigateWithTabs}),
},
{
key: 'objects',
title: 'Objects',
content: m(AllObjectsView, {engine, navigate}),
content: m(AllObjectsView, {
engine,
navigate: navigateWithTabs,
initialClass: state.view === 'objects' ? state.params.cls : undefined,
}),
},
{
key: 'dominators',
title: 'Dominators',
content: m(DominatorsView, {engine, navigate}),
content: m(DominatorsView, {engine, navigate: navigateWithTabs}),
},
{
key: 'bitmaps',
title: 'Bitmaps',
content: m(BitmapGalleryView, {
engine,
navigate,
navigate: navigateWithTabs,
hasFieldValues: overview.hasFieldValues,
filterKey:
state.view === 'bitmaps' ? state.params.filterKey : undefined,
Expand All @@ -183,7 +270,7 @@ function buildTabs(
title: 'Strings',
content: m(StringsView, {
engine,
navigate,
navigate: navigateWithTabs,
initialQuery: state.view === 'strings' ? state.params.q : undefined,
hasFieldValues: overview.hasFieldValues,
}),
Expand All @@ -192,9 +279,8 @@ function buildTabs(
key: 'arrays',
title: 'Arrays',
content: m(ArraysView, {
key: state.view === 'arrays' ? state.params.arrayHash ?? '' : '',
engine,
navigate,
navigate: navigateWithTabs,
initialArrayHash:
state.view === 'arrays' ? state.params.arrayHash : undefined,
hasFieldValues: overview.hasFieldValues,
Expand All @@ -206,11 +292,14 @@ function buildTabs(
for (const fg of flamegraphTabs) {
tabs.push({
key: fgTabKey(fg.id),
title: 'Flamegraph',
title:
fg.count !== null
? `Flamegraph (${fg.count.toLocaleString()})`
: 'Flamegraph',
closeButton: true,
content: m(FlamegraphObjectsView, {
engine,
navigate,
navigate: navigateWithTabs,
pathHashes: fg.pathHashes,
isDominator: fg.isDominator,
onBackToTimeline: () => {
Expand All @@ -220,6 +309,21 @@ function buildTabs(
});
}

// Append closable object instance tabs.
for (const obj of instanceTabs) {
tabs.push({
key: instanceTabKey(obj.id),
title: obj.label,
closeButton: true,
content: m(ObjectView, {
engine,
heaps: overview.heaps,
navigate: navigateWithTabs,
params: {id: obj.objId},
}),
});
}

return tabs;
}

Expand Down Expand Up @@ -260,6 +364,7 @@ export class HeapDumpPage implements m.ClassComponent<HeapDumpPageAttrs> {

view(vnode: m.Vnode<HeapDumpPageAttrs>) {
syncFromSubpage(vnode.attrs.subpage);
syncInstanceTabFromNav();

if (!HeapDumpPage.engine || !HeapDumpPage.hasHeapData) {
return m(
Expand Down
4 changes: 3 additions & 1 deletion ui/src/plugins/com.android.HeapDumpExplorer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
HeapDumpPage,
setFlamegraphSelection,
resetFlamegraphSelection,
resetInstanceTabs,
resetCachedOverview,
} from './heap_dump_page';
import {resetBitmapDumpDataCache} from './queries';
Expand Down Expand Up @@ -49,12 +50,13 @@ export default class implements PerfettoPlugin {
HeapDumpPage.hasHeapData = true;
resetBitmapDumpDataCache();
resetFlamegraphSelection();
resetInstanceTabs();
resetCachedOverview();

ctx.plugins
.getPlugin(HeapProfilePlugin)
.registerOnNodeSelectedListener(({pathHashes, isDominator}) =>
setFlamegraphSelection({pathHashes, isDominator}),
setFlamegraphSelection({pathHashes, isDominator}, ctx.engine),
);

ctx.sidebar.addMenuItem({
Expand Down
Loading
Loading