diff --git a/app/src/__tests__/app-shell.test.ts b/app/src/__tests__/app-shell.test.ts new file mode 100644 index 00000000..e9160562 --- /dev/null +++ b/app/src/__tests__/app-shell.test.ts @@ -0,0 +1,156 @@ +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[], + /** Sends a `connection:status` event to the last connected client's listeners. */ + emitStatus: (_status: string) => {}, +})); + +function fakeClient(): DevframeRpcClient { + const rpc = { + call: () => new Promise(() => {}), + callEvent: () => Promise.resolve(), + sharedState: () => Promise.resolve({ value: () => null, on: () => () => {} }), + }; + const listeners: ((status: string) => void)[] = []; + connection.emitStatus = (status) => listeners.forEach((listener) => listener(status)); + return { + connectionMeta: { configs: {} }, + scope: () => ({ rpc }), + events: { + on: (name: string, listener: (status: string) => void) => { + if (name === 'connection:status') listeners.push(listener); + return () => {}; + }, + }, + close: () => {}, + } 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('connects again when an open connection drops', async () => { + connection.answers = [true, true]; + const fixture = TestBed.createComponent(App); + await settle(fixture); + expect(status(fixture)).toBe('Live'); + + // The dev server restarted: the old client is closed for good. + connection.emitStatus('disconnected'); + await settle(fixture); + expect(status(fixture)).toBe('Disconnected'); + expect(host(fixture).querySelector('.connection-error button')).not.toBeNull(); + + 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__/native-controls.test.ts b/app/src/__tests__/native-controls.test.ts index e75cbc31..41776b83 100644 --- a/app/src/__tests__/native-controls.test.ts +++ b/app/src/__tests__/native-controls.test.ts @@ -1,7 +1,7 @@ import { TestBed, type ComponentFixture } from '@angular/core/testing'; import type { DevframeRpcClient } from 'devframe/client'; import { afterEach, describe, expect, it } from 'vitest'; -import { isAngularNativePage } from '../native-page'; +import { isAngularNativePage, isNativePage } from '../native-page'; import { ComponentTree } from '../pages/component-tree'; function fakeClient(pages: Record): DevframeRpcClient { @@ -84,6 +84,14 @@ describe('browser-only controls', () => { expect(isAngularNativePage(null)).toBe(false); }); + it('treats Angular Native and NativeScript pages as native, and only those', () => { + expect(isNativePage({ platform: 'angular-native' })).toBe(true); + expect(isNativePage({ platform: 'nativescript' })).toBe(true); + expect(isAngularNativePage({ platform: 'nativescript' })).toBe(false); + expect(isNativePage({ platform: 'browser' })).toBe(false); + expect(isNativePage(null)).toBe(false); + }); + it('shows the component picker and change detection recording for a browser page', async () => { const fixture = await render(); expect(buttons(fixture)).toContain('Pick component on page'); @@ -91,15 +99,18 @@ describe('browser-only controls', () => { expect((fixture.nativeElement as HTMLElement).querySelector('#cd-heading')).not.toBeNull(); }); - it('hides them for an Angular Native page and keeps the tree', async () => { - const fixture = await render('angular-native'); - const host = fixture.nativeElement as HTMLElement; - expect(buttons(fixture)).not.toContain('Pick component on page'); - expect(buttons(fixture)).not.toContain('Record'); - expect(host.querySelector('#cd-heading')).toBeNull(); - expect(host.textContent).toContain('App'); - expect(host.querySelector('input[type="search"]')).not.toBeNull(); - }); + it.each(['angular-native', 'nativescript'])( + 'hides them for a %s page and keeps the tree', + async (platform) => { + const fixture = await render(platform); + const host = fixture.nativeElement as HTMLElement; + expect(buttons(fixture)).not.toContain('Pick component on page'); + expect(buttons(fixture)).not.toContain('Record'); + expect(host.querySelector('#cd-heading')).toBeNull(); + expect(host.textContent).toContain('App'); + expect(host.querySelector('input[type="search"]')).not.toBeNull(); + }, + ); it('cancels a pick on its own page when the dropdown shows another page', async () => { const { client, calls } = liveClient({ p1: page('p1'), p2: page('p2') }); diff --git a/app/src/__tests__/roving-list.test.ts b/app/src/__tests__/roving-list.test.ts new file mode 100644 index 00000000..ced8127a --- /dev/null +++ b/app/src/__tests__/roving-list.test.ts @@ -0,0 +1,125 @@ +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']); + }); + it('moves focus to the new stop when the focused row is removed', async () => { + const fixture = mount(); + await settle(fixture); + rows(fixture)[1].focus(); + + fixture.componentInstance.items.set(['c', 'd']); + await settle(fixture); + expect(document.activeElement).toBe(rows(fixture)[0]); + press('ArrowDown'); + expect(document.activeElement?.textContent?.trim()).toBe('d'); + }); + + it('leaves focus alone when it had already left the list', async () => { + const fixture = mount(); + const outside = document.createElement('button'); + document.body.appendChild(outside); + await settle(fixture); + + rows(fixture)[1].focus(); + outside.focus(); + fixture.componentInstance.items.set(['c', 'd']); + await settle(fixture); + expect(document.activeElement).toBe(outside); + + rows(fixture)[0].focus(); + rows(fixture)[0].blur(); + await settle(fixture); + fixture.componentInstance.items.set(['d']); + await settle(fixture); + expect(document.activeElement).toBe(document.body); + }); +}); 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..ca653466 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,19 +360,23 @@ 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) { - - } + } @if (fullPanelLink(); as link) { @@ -399,9 +403,13 @@ function readView(): View | null {

{{ backgroundNote() }}

@if (connectionFailed()) { - + } @else if (comingSoon(); as info) { } @else if (view() === 'angular-native' && !nativePageId()) { @@ -409,6 +417,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 (opens in a new tab)

} @else { @for (scope of [scopeKey()]; track scope) { @@ -583,6 +597,9 @@ function readView(): View | null { nav:empty { display: none; } + .tab-bar { + display: contents; + } nav button { position: relative; display: inline-flex; @@ -785,6 +802,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 +820,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 +923,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 +951,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 +1093,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); @@ -1040,17 +1145,47 @@ export class App implements OnInit, OnDestroy { ); client.events.on('connection:status', (status) => { this.connected.set(status === 'connected'); + // The client doesn't reconnect by itself, so a dropped one is replaced. + if ((status !== 'disconnected' && status !== 'error') || this.rpc() !== client) return; + this.rpc.set(null); + client.close?.(); + this.retryLater(); }); }, () => { - this.connectionFailed.set(true); + this.connecting = false; + if (this.destroyed) return; this.analogKnown.set(true); this.nativeKnown.set(true); + this.retryLater(); }, ); } + /** Shows the connection error and calls `connect()` again after the next backoff delay. */ + private retryLater() { + if (this.destroyed) return; + this.connectionFailed.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)]; + clearTimeout(this.retryTimer); + 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 +1380,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/native-page.ts b/app/src/native-page.ts index 4de4bb96..249f1bf8 100644 --- a/app/src/native-page.ts +++ b/app/src/native-page.ts @@ -8,6 +8,11 @@ export function isAngularNativePage(page: { platform?: string } | null | undefin return page?.platform === 'angular-native'; } +/** An app on a device or simulator, which has no DOM for browser-only controls. */ +export function isNativePage(page: { platform?: string } | null | undefined): boolean { + return isAngularNativePage(page) || page?.platform === 'nativescript'; +} + export function angularNativePage( pages: Record | null | undefined, previous: string | null, diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts index 0c000e7b..b47d8d24 100644 --- a/app/src/pages/component-tree.ts +++ b/app/src/pages/component-tree.ts @@ -19,7 +19,7 @@ import type { DevframeRpcClient } from 'devframe/client'; import { ExtensionBridge, type SourceLocation } from '../extension-bridge'; import { hostPageId } from '../page-id'; import { pickPage } from '../live-pages'; -import { isAngularNativePage } from '../native-page'; +import { isNativePage } from '../native-page'; import { isStaticReport } from '../rpc'; import { Select, type SelectOption } from '../ui/select'; import { @@ -1433,7 +1433,7 @@ export class ComponentTree { ); readonly live = computed(() => (this.page()?.roots.length ?? 0) > 0); - readonly native = computed(() => isAngularNativePage(this.page())); + readonly native = computed(() => isNativePage(this.page())); readonly devtoolsActions = computed( () => this.bridge.available && !!this.page() && !this.native() && !this.staticReport(), ); 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 1910eb1f..a62d25b1 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'; @@ -287,7 +288,7 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ @Component({ selector: 'app-network-inspector', - imports: [JsonPipe, LimitNote, Select], + imports: [JsonPipe, LimitNote, RovingList, Select], template: ` @if (loading()) {
    @@ -297,10 +298,8 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ } @else if (failed()) { } @@ -600,7 +599,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.

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