From 50fd311f347087882d088939b02f09d38767d7f1 Mon Sep 17 00:00:00 2001 From: erKam Date: Sun, 11 Oct 2026 22:17:11 +0000 Subject: [PATCH 01/17] fix(ui): keep the panel reachable and keyboard friendly The panel gave up on the devframe connection after one failure, the Store page showed an empty state while it was still loading, and long lists cost one Tab stop per row. Keep retrying the connection and add Retry buttons, show a loading state on the Store page, and make long lists a single Tab stop moved with the arrow keys. Lock down the popup theme messages, move focus into the panel when a keyboard opens it, and read the hub views, dock ids, probe paths and page ids from one place. Update the popup and hub, overlay, Express, configuration and dashboard docs to match. Refs #353 --- app/src/__tests__/app-shell.test.ts | 128 +++++++++++++++ app/src/__tests__/roving-list.test.ts | 94 +++++++++++ app/src/__tests__/store-inspector.test.ts | 34 ++++ app/src/__tests__/theme.service.test.ts | 50 +++++- app/src/app.ts | 150 ++++++++++++++++-- app/src/devtools-config.ts | 8 + app/src/hub-dock-sync.ts | 12 +- app/src/pages/dashboard.ts | 52 ++++-- app/src/pages/network-inspector.ts | 19 ++- app/src/pages/signal-inspector.ts | 5 +- app/src/pages/store-inspector.ts | 19 ++- app/src/theme.service.ts | 57 ++++++- app/src/ui/roving-list.ts | 82 ++++++++++ .../content/getting-started/configuration.md | 2 +- .../src/content/getting-started/express.md | 2 +- .../src/content/getting-started/overlay.md | 2 +- .../content/getting-started/popup-and-hub.md | 27 ++-- apps/docs/src/content/inspectors/dashboard.md | 2 +- .../src/__tests__/overlay-manual.test.ts | 1 + .../devtools/src/__tests__/page-id.test.ts | 18 +++ packages/devtools/src/__tests__/popup.test.ts | 86 +++++++++- .../src/__tests__/store-inspector.test.ts | 1 + packages/devtools/src/config.ts | 23 +++ packages/devtools/src/hub-docks.ts | 18 +-- .../devtools/src/overlay-angular-native.ts | 3 +- packages/devtools/src/overlay-nativescript.ts | 3 +- packages/devtools/src/overlay.ts | 10 +- packages/devtools/src/page-id.ts | 16 ++ packages/devtools/src/panel-frame.ts | 7 + packages/devtools/src/popup.ts | 99 +++++++++++- 30 files changed, 937 insertions(+), 93 deletions(-) create mode 100644 app/src/__tests__/app-shell.test.ts create mode 100644 app/src/__tests__/roving-list.test.ts create mode 100644 app/src/ui/roving-list.ts create mode 100644 packages/devtools/src/__tests__/page-id.test.ts create mode 100644 packages/devtools/src/page-id.ts diff --git a/app/src/__tests__/app-shell.test.ts b/app/src/__tests__/app-shell.test.ts new file mode 100644 index 00000000..47e54abe --- /dev/null +++ b/app/src/__tests__/app-shell.test.ts @@ -0,0 +1,128 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { DevframeRpcClient } from 'devframe/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const connection = vi.hoisted(() => ({ + attempts: 0, + /** What each attempt does: `true` connects, `false` fails. */ + answers: [] as boolean[], +})); + +function fakeClient(): DevframeRpcClient { + const rpc = { + call: () => new Promise(() => {}), + callEvent: () => Promise.resolve(), + sharedState: () => Promise.resolve({ value: () => null, on: () => () => {} }), + }; + return { + connectionMeta: { configs: {} }, + scope: () => ({ rpc }), + events: { on: () => () => {} }, + } as unknown as DevframeRpcClient; +} + +vi.mock('devframe/client', () => ({ + connectDevframe: () => { + const ok = connection.answers[connection.attempts++] ?? false; + return ok ? Promise.resolve(fakeClient()) : Promise.reject(new Error('no server')); + }, +})); + +const { App } = await import('../app'); + +async function settle(fixture: ComponentFixture) { + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } +} + +const host = (fixture: ComponentFixture) => fixture.nativeElement as HTMLElement; +const status = (fixture: ComponentFixture) => + host(fixture).querySelector('.status')?.textContent?.trim(); + +afterEach(() => { + document.body.innerHTML = ''; + connection.attempts = 0; + connection.answers = []; + TestBed.resetTestingModule(); +}); + +describe('App connection retry', () => { + it('offers Retry now when the server does not answer, and connects from it', async () => { + connection.answers = [false, true]; + const fixture = TestBed.createComponent(App); + document.body.appendChild(host(fixture)); + await settle(fixture); + + const alert = host(fixture).querySelector('.connection-error')!; + expect(alert.getAttribute('role')).toBe('alert'); + expect(status(fixture)).toBe('Disconnected'); + const retry = alert.querySelector('button')!; + expect(retry.textContent?.trim()).toBe('Retry now'); + + retry.focus(); + retry.click(); + await settle(fixture); + expect(connection.attempts).toBe(2); + expect(host(fixture).querySelector('.connection-error')).toBeNull(); + expect(status(fixture)).toBe('Live'); + // The button went with the error, so focus moved on rather than falling to the page. + expect(document.activeElement).toBe(host(fixture).querySelector('nav button.active')); + fixture.destroy(); + }); + + it('tries again on its own after a failed first connection', async () => { + connection.answers = [false, true]; + const fixture = TestBed.createComponent(App); + await settle(fixture); + expect(connection.attempts).toBe(1); + + await vi.waitFor(() => expect(connection.attempts).toBe(2), { timeout: 3000 }); + await settle(fixture); + expect(status(fixture)).toBe('Live'); + fixture.destroy(); + }); + + it('stops retrying once the panel is gone', async () => { + connection.answers = [false, true]; + const fixture = TestBed.createComponent(App); + await settle(fixture); + fixture.destroy(); + + await new Promise((resolve) => setTimeout(resolve, 1_200)); + expect(connection.attempts).toBe(1); + }); +}); + +describe('App tab bar', () => { + it('keeps one tab in the Tab order and moves along the bar with arrow keys', async () => { + connection.answers = [true]; + const fixture = TestBed.createComponent(App); + document.body.appendChild(host(fixture)); + await settle(fixture); + + const buttons = [...host(fixture).querySelectorAll('nav button')]; + expect(buttons.length).toBeGreaterThan(2); + expect(buttons.filter((b) => b.tabIndex === 0)).toEqual([buttons[0]]); + + const press = (key: string) => + document.activeElement!.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }), + ); + buttons[0].focus(); + press('ArrowRight'); + expect(document.activeElement).toBe(buttons[1]); + press('ArrowLeft'); + press('ArrowLeft'); + expect(document.activeElement).toBe(buttons.at(-1)); + press('Home'); + expect(document.activeElement).toBe(buttons[0]); + + buttons[2].click(); + await settle(fixture); + const now = [...host(fixture).querySelectorAll('nav button')]; + expect(now.filter((b) => b.tabIndex === 0)).toEqual([now[2]]); + fixture.destroy(); + }); +}); diff --git a/app/src/__tests__/roving-list.test.ts b/app/src/__tests__/roving-list.test.ts new file mode 100644 index 00000000..f99b93c1 --- /dev/null +++ b/app/src/__tests__/roving-list.test.ts @@ -0,0 +1,94 @@ +import { Component, signal } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { afterEach, describe, expect, it } from 'vitest'; +import { RovingList } from '../ui/roving-list'; + +@Component({ + imports: [RovingList], + template: ` +
    + @for (item of items(); track item) { +
  • + +
  • + } +
+ `, +}) +class Host { + readonly items = signal(['a', 'b', 'c', 'd']); + readonly selected = signal(null); +} + +async function settle(fixture: ComponentFixture) { + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } +} + +function mount() { + const fixture = TestBed.createComponent(Host); + document.body.appendChild(fixture.nativeElement); + return fixture; +} + +const rows = (fixture: ComponentFixture) => [ + ...(fixture.nativeElement as HTMLElement).querySelectorAll('button.row'), +]; +const tabStops = (fixture: ComponentFixture) => + rows(fixture) + .filter((row) => row.tabIndex === 0) + .map((row) => row.textContent?.trim()); +const press = (key: string) => + document.activeElement!.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }), + ); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('RovingList', () => { + it('keeps one row in the Tab order, the selected one when there is one', async () => { + const fixture = mount(); + fixture.componentInstance.selected.set('c'); + await settle(fixture); + expect(tabStops(fixture)).toEqual(['c']); + }); + + it('moves focus with the arrow keys, Home and End, and stops at the ends', async () => { + const fixture = mount(); + await settle(fixture); + expect(tabStops(fixture)).toEqual(['a']); + + rows(fixture)[0].focus(); + press('ArrowUp'); + expect(document.activeElement).toBe(rows(fixture)[0]); + press('ArrowDown'); + expect(document.activeElement).toBe(rows(fixture)[1]); + expect(tabStops(fixture)).toEqual(['b']); + press('End'); + expect(document.activeElement).toBe(rows(fixture)[3]); + press('ArrowDown'); + expect(document.activeElement).toBe(rows(fixture)[3]); + press('Home'); + expect(document.activeElement).toBe(rows(fixture)[0]); + }); + + it('takes rows that arrive later out of the Tab order, and finds a new stop when its row goes', async () => { + const fixture = mount(); + await settle(fixture); + rows(fixture)[1].focus(); + + fixture.componentInstance.items.set(['a', 'b', 'c', 'd', 'e']); + await settle(fixture); + expect(tabStops(fixture)).toEqual(['b']); + + fixture.componentInstance.items.set(['c', 'd']); + await settle(fixture); + expect(tabStops(fixture)).toEqual(['c']); + }); +}); diff --git a/app/src/__tests__/store-inspector.test.ts b/app/src/__tests__/store-inspector.test.ts index 26ce6ef9..6aaa5d25 100644 --- a/app/src/__tests__/store-inspector.test.ts +++ b/app/src/__tests__/store-inspector.test.ts @@ -403,3 +403,37 @@ describe('StoreInspector source declarations', () => { expect(box.value).toBe(''); }); }); + +describe('StoreInspector before the shared state arrives', () => { + it('says it is loading, then shows the empty state once the server answers', async () => { + let value: unknown = undefined; + let push: (next: unknown) => void = () => {}; + const rpc = { + call: () => Promise.resolve([]), + callEvent: () => Promise.resolve(), + sharedState: () => + Promise.resolve({ + value: () => value, + on: (_: string, listener: (next: unknown) => void) => { + push = listener; + return () => {}; + }, + }), + }; + const client = { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient; + const fixture = TestBed.createComponent(StoreInspector); + fixture.componentRef.setInput('rpc', client); + await settle(fixture); + const text = () => (fixture.nativeElement as HTMLElement).textContent ?? ''; + + const status = (fixture.nativeElement as HTMLElement).querySelector('.empty[role="status"]'); + expect(status?.textContent).toContain('Loading…'); + expect(text()).not.toContain('No page is reporting NgRx state yet.'); + + value = { pages: [] }; + push(value); + await settle(fixture); + expect(text()).not.toContain('Loading…'); + expect(text()).toContain('No page is reporting NgRx state yet.'); + }); +}); diff --git a/app/src/__tests__/theme.service.test.ts b/app/src/__tests__/theme.service.test.ts index 7e055d30..017927c4 100644 --- a/app/src/__tests__/theme.service.test.ts +++ b/app/src/__tests__/theme.service.test.ts @@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ThemeService } from '../theme.service'; +import { ThemeService, themeTargets } from '../theme.service'; try { TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); @@ -135,3 +135,51 @@ describe('ThemeService', () => { expect(svc.current()).toBe('dark'); }); }); + +describe('themeTargets', () => { + /** A window stand-in: `origin` readable as on a same origin page, or throwing. */ + function fakeWindow(origin: string | null, parent?: Window, referrer = '') { + const win = { + document: { referrer }, + location: { + get origin(): string { + if (origin === null) throw new DOMException('Blocked', 'SecurityError'); + return origin; + }, + }, + } as unknown as Window & { parent: Window }; + win.parent = parent ?? win; + return win; + } + + it('names each ancestor by the origins the browser lists, never "*"', () => { + const top = fakeWindow(null); + const parent = fakeWindow(null, top); + const panel = fakeWindow('http://localhost:4000', parent); + const origins = ['http://localhost:4000', 'https://host.test']; + Object.assign(panel.location, { + ancestorOrigins: { item: (i: number) => origins[i] ?? null, length: 2 }, + }); + + expect(themeTargets(panel)).toEqual([ + { win: parent, origin: 'http://localhost:4000' }, + { win: top, origin: 'https://host.test' }, + ]); + }); + + it('falls back to a same origin read, then the referrer for the parent', () => { + const top = fakeWindow(null); + const parent = fakeWindow(null, top); + const panel = fakeWindow('http://localhost:4000', parent, 'https://embedder.test/page'); + + expect(themeTargets(panel)).toEqual([{ win: parent, origin: 'https://embedder.test' }]); + + const sameOrigin = fakeWindow('http://localhost:4000', top); + const inner = fakeWindow('http://localhost:4000', sameOrigin); + expect(themeTargets(inner)).toEqual([{ win: sameOrigin, origin: 'http://localhost:4000' }]); + }); + + it('sends nothing from a top level page', () => { + expect(themeTargets(fakeWindow('http://localhost:4000'))).toEqual([]); + }); +}); diff --git a/app/src/app.ts b/app/src/app.ts index 05c1343c..b8a4edd5 100644 --- a/app/src/app.ts +++ b/app/src/app.ts @@ -35,27 +35,24 @@ import { styleHubRail } from './hub-rail-style'; import { ThemeService } from './theme.service'; import { followHubDocks, inHub, selectHubDock } from './hub-dock-sync'; import { fullPanelHref, isPlainClick } from './full-panel'; -import { panelConfig, tabEnabled } from './devtools-config'; +import { INSPECTORS_DOCS_URL, panelConfig, tabEnabled, tabInspector } from './devtools-config'; import { hostPageId, scopeToPage } from './page-id'; import { angularNativePage, type PlatformPage } from './native-page'; import { initialTab, storeTab, storedTab } from './tab-memory'; import { detectBaseURL } from './base-url'; import { clearHighlightsOnHide } from './rpc'; import { viewAccent } from './view-accents'; +import { + HUB_ALL_TOOLS, + HUB_VIEWS, + hubDockId, + type HubView, +} from '@pangular-inspector/devtools/config'; -const HUB_VIEWS = [ - 'angular', - 'ngrx', - 'analog', - 'angular-native', - 'nativescript', - 'capacitor', -] as const; - -type View = (typeof HUB_VIEWS)[number]; +type View = HubView; /** The hub dock that opens this panel without a view, with every tab. */ -const ALL_TOOLS = 'all'; +const ALL_TOOLS = HUB_ALL_TOOLS; const HUB_DOCKS = [ALL_TOOLS, ...HUB_VIEWS] as const; const VIEW_TITLE: Record = { @@ -156,6 +153,9 @@ const NO_ANGULAR_NATIVE: ComingSoonInfo = { }, }; +/** How long the panel waits before each retry while it can't reach the server. */ +const RETRY_DELAYS_MS = [1_000, 2_000, 5_000, 10_000]; + function readView(): View | null { const view = new URLSearchParams(location.search).get('view'); return HUB_VIEWS.find((known) => known === view) ?? null; @@ -360,6 +360,7 @@ function readView(): View | null { [class.fade-start]="navFade().start" [class.fade-end]="navFade().end" (scroll)="measureNav()" + (keydown)="onNavKey($event)" > @if (availableTabs().length > 1) { @for (t of tabs(); track t.id) { @@ -367,6 +368,7 @@ function readView(): View | null { type="button" [class.active]="tab() === t.id" [attr.aria-current]="tab() === t.id ? 'page' : null" + [attr.tabindex]="rovingTab() === t.id ? 0 : -1" (click)="switchTab(t.id)" > @@ -399,9 +401,13 @@ function readView(): View | null {

{{ backgroundNote() }}

@if (connectionFailed()) { - + } @else if (comingSoon(); as info) { } @else if (view() === 'angular-native' && !nativePageId()) { @@ -409,6 +415,12 @@ function readView(): View | null { } @else if (!tabEnabled(tab(), config())) {

This inspector is turned off in the devtools config (inspectors). + @if (turnOnSnippet(); as snippet) { + To turn it on, set {{ snippet }} in the devtools options. + } + About inspectors

} @else { @for (scope of [scopeKey()]; track scope) { @@ -785,6 +797,16 @@ function readView(): View | null { background: var(--surface); color: var(--text); } + .turned-off a { + color: var(--accent); + border-radius: 2px; + &:focus-visible { + @include m.focus-ring; + } + } + .turned-off code { + font-family: var(--font-mono); + } .connection-error { max-width: 60ch; margin: 0; @@ -793,6 +815,29 @@ function readView(): View | null { border-radius: var(--radius); background: var(--surface); color: var(--text); + p { + margin: 0 0 10px; + } + button { + display: inline-flex; + align-items: center; + height: var(--control-h); + padding: 0 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-weight: 500; + cursor: pointer; + &:hover { + background: var(--surface-3); + border-color: var(--accent-line); + } + &:focus-visible { + @include m.focus-ring; + } + } } @media (max-width: 720px) { header { @@ -873,6 +918,12 @@ export class App implements OnInit, OnDestroy { }); readonly config = computed(() => panelConfig(this.rpc())); protected readonly tabEnabled = tabEnabled; + protected readonly inspectorsDocsUrl = INSPECTORS_DOCS_URL; + /** The option that turns the current tab back on, such as `inspectors: {ngrx: true}`. */ + protected readonly turnOnSnippet = computed(() => { + const inspector = tabInspector(this.tab()); + return inspector ? `inspectors: {${inspector}: true}` : null; + }); protected readonly availableTabs = computed(() => { if (this.comingSoon()) return []; const view = this.view(); @@ -895,6 +946,33 @@ export class App implements OnInit, OnDestroy { return tabs.filter((t) => t.id === 'dashboard' || t.id === tab); }); + /** The one tab button in the Tab order; the arrow keys reach the others. */ + protected readonly rovingTab = computed(() => { + const tabs = this.tabs(); + const tab = this.tab(); + return tabs.some((t) => t.id === tab) ? tab : tabs[0]?.id; + }); + + /** Arrow keys, Home and End move focus along the tab bar; Enter or Space opens a tab. */ + onNavKey(event: KeyboardEvent) { + const nav = this.nav()?.nativeElement; + if (!nav || event.altKey || event.ctrlKey || event.metaKey) return; + const buttons = [...nav.querySelectorAll('button')]; + const index = buttons.indexOf(document.activeElement as HTMLButtonElement); + if (index < 0) return; + const last = buttons.length - 1; + const moves: Record = { + ArrowRight: index === last ? 0 : index + 1, + ArrowLeft: index === 0 ? last : index - 1, + Home: 0, + End: last, + }; + const next = Object.hasOwn(moves, event.key) ? moves[event.key] : -1; + if (next < 0) return; + event.preventDefault(); + buttons[next].focus(); + } + /** The link from a hub view to the full panel, on the tab that shows the same content. */ protected readonly fullPanelLink = computed(() => { const view = this.view(); @@ -1010,9 +1088,31 @@ export class App implements OnInit, OnDestroy { } this.stopHighlights = clearHighlightsOnHide(() => this.rpc()); + this.connect(); + } + + private connecting = false; + private retries = 0; + private retryTimer: ReturnType | undefined; + + /** Connects to the devframe server, and keeps trying with backoff while it can't. */ + private connect() { + if (this.connecting || this.rpc()) return; + this.connecting = true; + clearTimeout(this.retryTimer); const baseURL = detectBaseURL(); connectDevframe(baseURL ? { baseURL } : {}).then( (client) => { + this.connecting = false; + if (this.destroyed) { + client.close?.(); + return; + } + this.retries = 0; + this.connectionFailed.set(false); + // Retry now disappears with the error, so its focus moves to the tabs. + if (this.refocusOnConnect) this.focusCurrentTab(); + this.refocusOnConnect = false; this.rpc.set(client); this.connected.set(true); void this.watchVisibility(client); @@ -1043,14 +1143,32 @@ export class App implements OnInit, OnDestroy { }); }, () => { + this.connecting = false; + if (this.destroyed) return; this.connectionFailed.set(true); this.analogKnown.set(true); this.nativeKnown.set(true); + // A dev server that starts slowly or restarts answers a moment later. + const delay = RETRY_DELAYS_MS[Math.min(this.retries++, RETRY_DELAYS_MS.length - 1)]; + this.retryTimer = setTimeout(() => this.connect(), delay); }, ); } + /** Tries the connection again at once, instead of waiting for the next retry. */ + retryConnection() { + this.retries = 0; + this.refocusOnConnect = true; + this.connect(); + } + + private refocusOnConnect = false; + + private destroyed = false; + ngOnDestroy() { + this.destroyed = true; + clearTimeout(this.retryTimer); this.stopNative(); this.stopCapacitor(); this.stopFollowing(); @@ -1245,7 +1363,7 @@ export class App implements OnInit, OnDestroy { return; } try { - await client.call('hub:docks:activate', { dockId: `pangular:${view}` }); + await client.call('hub:docks:activate', { dockId: hubDockId(view) }); this.keepFocus(); this.showView(view); } catch { diff --git a/app/src/devtools-config.ts b/app/src/devtools-config.ts index 4fd11122..f6f11572 100644 --- a/app/src/devtools-config.ts +++ b/app/src/devtools-config.ts @@ -32,6 +32,14 @@ const TAB_INSPECTOR: Partial> = { capacitor: 'capacitor', }; +/** The `inspectors` key that turns a tab on or off, if any. */ +export function tabInspector(tab: Tab): PangularInspector | undefined { + return TAB_INSPECTOR[tab]; +} + +export const INSPECTORS_DOCS_URL = + 'https://pangular-inspector.dev/getting-started/configuration/#inspectors-and-agent-tools'; + export function tabEnabled(tab: Tab, config: ResolvedPangularConfig): boolean { const inspector = TAB_INSPECTOR[tab]; return !inspector || config.inspectors[inspector]; diff --git a/app/src/hub-dock-sync.ts b/app/src/hub-dock-sync.ts index d94a2528..1461ca93 100644 --- a/app/src/hub-dock-sync.ts +++ b/app/src/hub-dock-sync.ts @@ -1,5 +1,6 @@ +import { hubDockId } from '@pangular-inspector/devtools/config'; + const CLIENT_CONTEXT_KEY = '__DEVFRAME_HUB_CLIENT_CONTEXT__'; -const DOCK_PREFIX = 'pangular:'; interface DockState { events: { on: (event: 'entry:activated', listener: () => void) => () => void }; @@ -41,12 +42,11 @@ export function followHubDocks( } const offs = views.map( (view) => - ctx.docks - .getStateById(DOCK_PREFIX + view) - ?.events.on('entry:activated', () => onView(view)) ?? (() => {}), + ctx.docks.getStateById(hubDockId(view))?.events.on('entry:activated', () => onView(view)) ?? + (() => {}), ); const selected = ctx.docks.selectedId; - const current = views.find((view) => DOCK_PREFIX + view === selected); + const current = views.find((view) => hubDockId(view) === selected); if (current) onView(current); return () => offs.forEach((off) => off()); } @@ -60,7 +60,7 @@ export async function selectHubDock(view: string): Promise { const ctx = hubContext(); if (!ctx) return false; try { - return await ctx.docks.switchEntry(DOCK_PREFIX + view); + return await ctx.docks.switchEntry(hubDockId(view)); } catch { return false; } diff --git a/app/src/pages/dashboard.ts b/app/src/pages/dashboard.ts index ec953af9..8d0f9dc0 100644 --- a/app/src/pages/dashboard.ts +++ b/app/src/pages/dashboard.ts @@ -236,9 +236,12 @@ export function httpCard(state: HttpSnapshot | null, hostPageId: string | null): @if (staticReport()) { Run pangular build again to rebuild the report. } @else { - Check that the dev server is running, then reload the panel. + Check that the dev server is running. }

+ @if (!staticReport()) { + + } }
    @@ -354,6 +357,27 @@ export function httpCard(state: HttpSnapshot | null, hostPageId: string | null): font-size: 13px; line-height: 1.5; } + .retry { + display: inline-flex; + margin-top: 8px; + align-items: center; + height: var(--control-h); + padding: 0 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-weight: 500; + cursor: pointer; + &:hover { + background: var(--surface-3); + border-color: var(--accent-line); + } + &:focus-visible { + @include m.focus-ring; + } + } .chips { display: flex; flex-wrap: wrap; @@ -658,24 +682,34 @@ export class Dashboard { return out; }); + /** Reads the project details, again after a failure when Try again is pressed. */ + loadMeta(client = this.rpc()) { + if (!client) return; + this.metaState.set('loading'); + client + .scope('pangular') + .rpc.call('build-meta') + .then((m) => { + if (this.rpc() !== client) return; + this.meta.set(m as BuildMeta); + this.metaState.set('ready'); + }) + .catch(() => { + if (this.rpc() === client) this.metaState.set('error'); + }); + } + constructor() { effect(() => { const client = this.rpc(); if (!client) return; const my = client.scope('pangular'); - this.metaState.set('loading'); this.states.set({}); this.rows.set({}); this.formsState.set(null); this.httpState.set(null); - my.rpc - .call('build-meta') - .then((m) => { - this.meta.set(m as BuildMeta); - this.metaState.set('ready'); - }) - .catch(() => this.metaState.set('error')); + this.loadMeta(client); const config = panelConfig(client); const on = (tab: StatTab) => tabEnabled(tab, config); if (on('components')) this.load(my.rpc.call('get-components'), 'components'); diff --git a/app/src/pages/network-inspector.ts b/app/src/pages/network-inspector.ts index ecd05422..77f19b66 100644 --- a/app/src/pages/network-inspector.ts +++ b/app/src/pages/network-inspector.ts @@ -25,6 +25,7 @@ import { } from '@pangular-inspector/devtools/config'; import { prettyJson } from '../format'; import { LimitNote } from '../ui/limit-note'; +import { RovingList } from '../ui/roving-list'; import { Select, type SelectOption } from '../ui/select'; type HttpSide = 'client' | 'server'; @@ -286,7 +287,7 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ @Component({ selector: 'app-network-inspector', - imports: [JsonPipe, LimitNote, Select], + imports: [JsonPipe, LimitNote, RovingList, Select], template: ` @if (loading()) {
    @@ -296,10 +297,8 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ } @else if (failed()) { } @@ -591,7 +590,7 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ Notes - + @for (entry of visibleCalls(); track entry.side + entry.id) { = { @Component({ selector: 'app-signal-inspector', - imports: [DatePipe, JsonPipe, NgTemplateOutlet, Select], + imports: [DatePipe, JsonPipe, NgTemplateOutlet, RovingList, Select], template: `
    = { @if (graph() && (visibleNodes().length || !filteredResources().length)) {

    Signals

    -
      +
        @for (node of filteredNodes(); track node.id) {
    + } @else if (!stateLoaded()) { +
    +

    Loading…

    +
    } @else if (!page()) {

    No page is reporting NgRx state yet.

    @@ -359,7 +364,11 @@ const CLASSIC_KINDS = new Set([ limit="changeLog" />
    -
      +
        @for (entry of storeLog(); track entry.seq) {