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
3 changes: 2 additions & 1 deletion log-viewer/src/components/LogInspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { customElement, property, state } from 'lit/decorators.js';
import { type DetailSelection, type DetailSource, eventBus } from '../core/events/EventBus.js';
import { debounce } from '../core/utility/Util.js';
import { getSettings, updateSetting } from '../features/settings/Settings.js';
import { emptyTextFor } from './detailEmptyText.js';
import { buildDetailSections } from './detailSections.js';
import { globalStyles } from '../styles/global.styles.js';
import type { DockPosition } from './DetailDock.js';
Expand Down Expand Up @@ -135,7 +136,7 @@ export class LogInspector extends LitElement {
.sections=${this.sections}
.collapsed=${this.collapsedSections}
.paneSizes=${this.paneSizes}
emptyText="Select a row to inspect it."
emptyText=${emptyTextFor(this._activeSource)}
@dock-position-change=${this._onDockPositionChange}
@dock-resize=${this._onDockResize}
@dock-hide=${this._hidePanel}
Expand Down
35 changes: 35 additions & 0 deletions log-viewer/src/components/__tests__/LogInspector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ jest.mock('../../features/settings/Settings.js', () => ({
}));

// The real builders mount Tabulator tables; only the section ids matter here.
// `detailEmptyText.js` is deliberately left unmocked so the assertions below
// exercise the real copy LogInspector renders.
//
// `deferSections` lets a test hold a build's resolution open so it can
// interleave a second, faster selection ahead of it (see the epoch test below);
// every other test leaves it false and gets an immediately-resolved build.
Expand Down Expand Up @@ -103,6 +106,14 @@ function visible(el: LogInspector): boolean {
return !!el.shadowRoot?.querySelector('dock-layout')?.hasAttribute('visible');
}

function emptyText(el: LogInspector): string | null {
const dock = el.shadowRoot
?.querySelector('dock-layout')
?.shadowRoot?.querySelector('detail-dock')
?.shadowRoot?.querySelector('.empty');
return dock?.textContent?.trim() ?? null;
}

function select(source: 'timeline' | 'database', eventIndex: number): void {
eventBus.emit('detail:select', { source, selection: { kind: 'event', eventIndex } });
}
Expand Down Expand Up @@ -221,6 +232,30 @@ describe('LogInspector', () => {
expect(paneView(el).collapsed).toEqual({ vitals: true });
});

it('shows a source-specific empty state, and updates it as the active tab changes', async () => {
settings.inspector = {
position: 'right',
size: 400,
collapsed: {},
paneSizes: {},
visible: true,
};
const el = await mount('timeline-tab');
expect(emptyText(el)).toBe('Select a frame on the timeline to inspect it.');

el.activeTab = 'tree-tab';
await flush(el);
expect(emptyText(el)).toBe('Select a frame in the call tree to inspect it.');

el.activeTab = 'analysis-tab';
await flush(el);
expect(emptyText(el)).toBe('Select a row in the analysis grid to inspect it.');

el.activeTab = 'database-tab';
await flush(el);
expect(emptyText(el)).toBe('Select a SOQL, DML or SOSL row to inspect it.');
});

it('drops a superseded rebuild: a stale build resolving late does not overwrite a newer one', async () => {
// Mount undeferred so its own (empty-selection) rebuild resolves, then defer
// only the two builds this test drives.
Expand Down
19 changes: 19 additions & 0 deletions log-viewer/src/components/__tests__/detailEmptyText.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*/
import { describe, expect, it } from '@jest/globals';

import { emptyTextFor } from '../detailEmptyText.js';

describe('emptyTextFor', () => {
it('names what to click, per source', () => {
expect(emptyTextFor('timeline')).toBe('Select a frame on the timeline to inspect it.');
expect(emptyTextFor('calltree')).toBe('Select a frame in the call tree to inspect it.');
expect(emptyTextFor('analysis')).toBe('Select a row in the analysis grid to inspect it.');
expect(emptyTextFor('database')).toBe('Select a SOQL, DML or SOSL row to inspect it.');
});

it('falls back to a generic message when no tab is active', () => {
expect(emptyTextFor(undefined)).toBe('Select a row to inspect it.');
});
});
6 changes: 4 additions & 2 deletions log-viewer/src/components/__tests__/detailSections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ describe('buildDetailSections', () => {
expect(sections.map((s) => s.id)).toEqual(['vitals', 'callstack', 'calltree']);
});

it('builds no sections when nothing is selected', async () => {
expect(await buildDetailSections('calltree', null)).toEqual([]);
it('builds no sections when nothing is selected, for every source', async () => {
for (const source of ['timeline', 'calltree', 'analysis', 'database'] as const) {
expect(await buildDetailSections(source, null)).toEqual([]);
}
});
});
25 changes: 25 additions & 0 deletions log-viewer/src/components/detailEmptyText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*/
import type { DetailSource } from '../core/events/EventBus.js';

/**
* Per-source copy for the inspector's empty state, rendered by `DetailDock`
* whenever `buildDetailSections` returns no sections. Kept in a leaf module so
* consumers (and their tests) can reach the real copy without pulling in the
* section builders' web components.
*/
const EMPTY_TEXT: Record<DetailSource, string> = {
timeline: 'Select a frame on the timeline to inspect it.',
calltree: 'Select a frame in the call tree to inspect it.',
analysis: 'Select a row in the analysis grid to inspect it.',
database: 'Select a SOQL, DML or SOSL row to inspect it.',
};

/**
* Empty-state copy for the given source. `source` is `undefined` until a tab id
* resolves, so that case falls back to generic wording.
*/
export function emptyTextFor(source: DetailSource | undefined): string {
return source ? EMPTY_TEXT[source] : 'Select a row to inspect it.';
}
7 changes: 6 additions & 1 deletion log-viewer/src/components/detailSections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,17 @@ import './EventVitals.js';
* the same shared trio — Details, Call stack, Call tree — scoped to the
* selection; the Database view keeps its richer set (Vitals + SOQL issues) via
* {@link buildDatabaseSections}.
*
* Precedence rule, binding on future scoping inputs such as a timeline time
* range: an explicit row/frame `selection` always wins. A range or other
* ambient scope only applies when `selection` is `null`, so it belongs inside
* the `!selection` branch — never above it.
*/
export async function buildDetailSections(
source: DetailSource,
selection: DetailSelection | null,
): Promise<PaneSection[]> {
// Nothing selected: no sections yet, so the inspector shows its empty text.
// Nothing selected: no sections, so the inspector shows `emptyTextFor(source)`.
if (!selection) {
return [];
}
Expand Down