From 9f6e9c6b3ddaf87e389de4bf2cd345ef1c17a4e7 Mon Sep 17 00:00:00 2001 From: erKam Date: Mon, 12 Oct 2026 21:18:31 +0000 Subject: [PATCH 1/3] feat(ui): add a command palette, settings, item links and an Errors inspector Adds a Ctrl/Cmd+K command palette (hub commands or a local palette), a popup.hotkey shortcut, a Settings page with theme, tab order and passive mode, #tab=&id=&page= item links with an open-in-panel tool, and an Errors inspector with NG code links, owning components, one shared console tap and the list-errors, explain-error and list-console tools. Refs #342 --- app/src/__tests__/command-palette.test.ts | 163 +++++ .../__tests__/component-tree-commands.test.ts | 109 +++ app/src/__tests__/errors-view.test.ts | 263 +++++++ app/src/__tests__/settings.test.ts | 223 ++++++ app/src/__tests__/shell-deep-link.test.ts | 158 +++++ app/src/app.ts | 369 +++++++++- app/src/devtools-config.ts | 1 + app/src/hub-dock-sync.ts | 16 + app/src/pages/component-tree.ts | 42 ++ app/src/pages/errors-inspector.ts | 660 ++++++++++++++++++ app/src/pages/errors-types.ts | 127 ++++ app/src/pages/settings.ts | 520 ++++++++++++++ app/src/pages/tab-icon.ts | 11 + app/src/palette-filter.ts | 57 ++ app/src/shell-prefs.ts | 187 +++++ app/src/theme.service.ts | 44 +- app/src/types/component-command.types.ts | 7 + app/src/types/tab.types.ts | 4 +- app/src/ui/command-palette.ts | 324 +++++++++ apps/docs/src/content/agents/resources.md | 1 + apps/docs/src/content/agents/tools.md | 26 +- .../getting-started/command-palette.md | 63 ++ .../content/getting-started/configuration.md | 32 +- .../content/getting-started/popup-and-hub.md | 23 +- .../src/content/getting-started/settings.md | 65 ++ apps/docs/src/content/inspectors/errors.md | 132 ++++ apps/docs/src/ngmd.config.ts | 3 + .../src/__tests__/console-tap.test.ts | 116 +++ .../src/__tests__/docs-tool-count.test.ts | 1 + .../src/__tests__/errors-collector.test.ts | 382 ++++++++++ .../src/__tests__/errors-real.test.ts | 152 ++++ .../src/__tests__/errors-tools.test.ts | 223 ++++++ .../src/__tests__/panel-shell.test.ts | 255 +++++++ packages/devtools/src/__tests__/popup.test.ts | 103 +++ packages/devtools/src/analog-runtime.ts | 15 +- packages/devtools/src/config.ts | 290 +++++++- packages/devtools/src/console-tap.ts | 127 ++++ packages/devtools/src/devframe.ts | 80 +++ packages/devtools/src/errors-collector.ts | 535 ++++++++++++++ packages/devtools/src/forms-collector.ts | 12 +- packages/devtools/src/http.ts | 27 +- packages/devtools/src/hub-commands.ts | 73 ++ packages/devtools/src/ng-error-codes.ts | 255 +++++++ packages/devtools/src/overlay.ts | 9 + packages/devtools/src/popup.ts | 122 +++- packages/devtools/src/rpc/errors-register.ts | 175 +++++ packages/devtools/src/rpc/errors-tools.ts | 428 ++++++++++++ packages/devtools/src/rpc/open-in-panel.ts | 98 +++ packages/devtools/vitest.compat.config.ts | 3 + scripts/panel-axe.mjs | 84 ++- 50 files changed, 7091 insertions(+), 104 deletions(-) create mode 100644 app/src/__tests__/command-palette.test.ts create mode 100644 app/src/__tests__/component-tree-commands.test.ts create mode 100644 app/src/__tests__/errors-view.test.ts create mode 100644 app/src/__tests__/settings.test.ts create mode 100644 app/src/__tests__/shell-deep-link.test.ts create mode 100644 app/src/pages/errors-inspector.ts create mode 100644 app/src/pages/errors-types.ts create mode 100644 app/src/pages/settings.ts create mode 100644 app/src/palette-filter.ts create mode 100644 app/src/shell-prefs.ts create mode 100644 app/src/types/component-command.types.ts create mode 100644 app/src/ui/command-palette.ts create mode 100644 apps/docs/src/content/getting-started/command-palette.md create mode 100644 apps/docs/src/content/getting-started/settings.md create mode 100644 apps/docs/src/content/inspectors/errors.md create mode 100644 packages/devtools/src/__tests__/console-tap.test.ts create mode 100644 packages/devtools/src/__tests__/errors-collector.test.ts create mode 100644 packages/devtools/src/__tests__/errors-real.test.ts create mode 100644 packages/devtools/src/__tests__/errors-tools.test.ts create mode 100644 packages/devtools/src/__tests__/panel-shell.test.ts create mode 100644 packages/devtools/src/console-tap.ts create mode 100644 packages/devtools/src/errors-collector.ts create mode 100644 packages/devtools/src/hub-commands.ts create mode 100644 packages/devtools/src/ng-error-codes.ts create mode 100644 packages/devtools/src/rpc/errors-register.ts create mode 100644 packages/devtools/src/rpc/errors-tools.ts create mode 100644 packages/devtools/src/rpc/open-in-panel.ts diff --git a/app/src/__tests__/command-palette.test.ts b/app/src/__tests__/command-palette.test.ts new file mode 100644 index 00000000..da238eba --- /dev/null +++ b/app/src/__tests__/command-palette.test.ts @@ -0,0 +1,163 @@ +import { Component, signal } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CommandPalette } from '../ui/command-palette'; +import { filterCommands, fuzzyScore, type PaletteCommand } from '../palette-filter'; + +const COMMANDS: PaletteCommand[] = [ + { id: 'tab:dashboard', title: 'Go to Dashboard', group: 'Go to' }, + { id: 'tab:signals', title: 'Go to Signals', group: 'Go to' }, + { id: 'tab:network', title: 'Go to SSR & HTTP', group: 'Go to' }, + { id: 'pick-component', title: 'Pick component on page', group: 'Actions', keywords: 'select' }, + { id: 'theme:dark', title: 'Theme: dark', group: 'Theme' }, +]; + +describe('palette filter', () => { + it('matches letters in order and ranks word starts and runs higher', () => { + expect(fuzzyScore('Go to Signals', 'gtsig')).toBeGreaterThan(0); + expect(fuzzyScore('Go to Signals', 'xyz')).toBe(-1); + expect(fuzzyScore('Go to Signals', 'sig')).toBeGreaterThan(fuzzyScore('Design', 'sig')); + }); + + it('keeps the order for an empty query and puts the best match first', () => { + expect(filterCommands(COMMANDS, ' ')).toEqual(COMMANDS); + expect(filterCommands(COMMANDS, 'sig').map((c) => c.id)[0]).toBe('tab:signals'); + expect(filterCommands(COMMANDS, 'http').map((c) => c.id)).toEqual(['tab:network']); + expect(filterCommands(COMMANDS, 'dark').map((c) => c.id)).toEqual(['theme:dark']); + }); + + it('also matches the group and keywords, below a title match', () => { + expect(filterCommands(COMMANDS, 'select').map((c) => c.id)).toEqual(['pick-component']); + expect(filterCommands(COMMANDS, 'theme').map((c) => c.id)[0]).toBe('theme:dark'); + }); +}); + +@Component({ + imports: [CommandPalette], + template: ` + + + `, +}) +class Host { + readonly commands = COMMANDS; + readonly open = signal(false); + readonly picked: string[] = []; +} + +async function settle(fixture: ComponentFixture) { + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } +} + +function key(target: Element, name: string) { + target.dispatchEvent( + new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true }), + ); +} + +async function openPalette() { + const fixture = TestBed.createComponent(Host); + document.body.appendChild(fixture.nativeElement); + await settle(fixture); + const opener = fixture.nativeElement.querySelector('#opener') as HTMLButtonElement; + opener.focus(); + opener.click(); + await settle(fixture); + const root = fixture.nativeElement as HTMLElement; + const input = root.querySelector('input[role="combobox"]') as HTMLInputElement; + return { fixture, root, input, opener }; +} + +afterEach(() => { + document.body.innerHTML = ''; + TestBed.resetTestingModule(); +}); + +describe('CommandPalette', () => { + it('is a modal dialog with a combobox that controls a listbox', async () => { + const { root, input } = await openPalette(); + const dialog = root.querySelector('[role="dialog"]')!; + expect(dialog.getAttribute('aria-modal')).toBe('true'); + const title = root.querySelector(`#${dialog.getAttribute('aria-labelledby')}`); + expect(title?.textContent?.trim()).toBe('Command palette'); + expect(document.activeElement).toBe(input); + expect(input.getAttribute('aria-expanded')).toBe('true'); + const list = root.querySelector(`#${input.getAttribute('aria-controls')}`)!; + expect(list.getAttribute('role')).toBe('listbox'); + const options = list.querySelectorAll('[role="option"]'); + expect(options).toHaveLength(COMMANDS.length); + expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id); + expect(options[0].getAttribute('aria-selected')).toBe('true'); + expect(options[1].getAttribute('aria-selected')).toBe('false'); + }); + + it('filters as you type and says when nothing matches', async () => { + const { fixture, root, input } = await openPalette(); + input.value = 'sig'; + input.dispatchEvent(new Event('input')); + await settle(fixture); + const options = [...root.querySelectorAll('[role="option"]')]; + expect(options[0].textContent).toContain('Go to Signals'); + expect(root.querySelector('[role="status"]')?.textContent).toMatch(/command/); + + input.value = 'zzzz'; + input.dispatchEvent(new Event('input')); + await settle(fixture); + expect(root.querySelectorAll('[role="option"]')).toHaveLength(0); + expect(root.querySelector('.empty')?.textContent).toContain('No commands match “zzzz”'); + expect(input.getAttribute('aria-activedescendant')).toBeNull(); + }); + + it('moves with the arrow keys, wraps, and runs the active command on Enter', async () => { + const { fixture, root, input, opener } = await openPalette(); + key(input, 'ArrowDown'); + key(input, 'ArrowDown'); + await settle(fixture); + const options = root.querySelectorAll('[role="option"]'); + expect(input.getAttribute('aria-activedescendant')).toBe(options[2].id); + key(input, 'ArrowUp'); + key(input, 'ArrowUp'); + key(input, 'ArrowUp'); + await settle(fixture); + expect(input.getAttribute('aria-activedescendant')).toBe(options[COMMANDS.length - 1].id); + + key(input, 'Enter'); + await settle(fixture); + expect(root.querySelector('[role="dialog"]')).toBeNull(); + expect(fixture.componentInstance.picked).toEqual(['theme:dark']); + // Focus went back to what opened it before the command ran. + expect(document.activeElement).toBe(opener); + }); + + it('closes on Escape without running anything, and keeps Tab inside', async () => { + const { fixture, root, input, opener } = await openPalette(); + const tab = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); + input.dispatchEvent(tab); + expect(tab.defaultPrevented).toBe(true); + const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + input.dispatchEvent(escape); + expect(escape.defaultPrevented).toBe(true); + await settle(fixture); + expect(root.querySelector('[role="dialog"]')).toBeNull(); + expect(fixture.componentInstance.picked).toEqual([]); + expect(fixture.componentInstance.open()).toBe(false); + expect(document.activeElement).toBe(opener); + }); + + it('runs a command on click and starts empty the next time', async () => { + const { fixture, root, input } = await openPalette(); + input.value = 'pick'; + input.dispatchEvent(new Event('input')); + await settle(fixture); + (root.querySelector('[role="option"]') as HTMLElement).click(); + await settle(fixture); + expect(fixture.componentInstance.picked).toEqual(['pick-component']); + fixture.componentInstance.open.set(true); + await settle(fixture); + expect((root.querySelector('input[role="combobox"]') as HTMLInputElement).value).toBe(''); + expect(root.querySelectorAll('[role="option"]')).toHaveLength(COMMANDS.length); + }); +}); diff --git a/app/src/__tests__/component-tree-commands.test.ts b/app/src/__tests__/component-tree-commands.test.ts new file mode 100644 index 00000000..e46a1e51 --- /dev/null +++ b/app/src/__tests__/component-tree-commands.test.ts @@ -0,0 +1,109 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { DevframeRpcClient } from 'devframe/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ComponentTree } from '../pages/component-tree'; + +const roots = [{ id: 'app', name: 'App', tag: 'app-root', children: [] }]; + +function client(options: { live?: boolean; recording?: boolean } = {}) { + const calls: { name: string; args: unknown[] }[] = []; + const page = { + pageId: 'a', + title: 'a', + url: '/', + roots: options.live === false ? [] : roots, + count: 1, + detail: null, + reportedAt: 1, + }; + const state: Record = { + 'component-tree': { pages: { a: page } }, + 'change-detection': { + pages: { + a: { + pageId: 'a', + supported: true, + recording: options.recording === true, + startedAt: null, + cycles: [], + dropped: 0, + components: [], + hosts: {}, + }, + }, + }, + }; + const rpc = { + call: (name: string, ...args: unknown[]) => { + calls.push({ name, args }); + // A pick waits for a click in the app; the test only checks it started. + return name === 'request-component-pick' ? new Promise(() => {}) : Promise.resolve([]); + }, + callEvent: () => Promise.resolve(), + sharedState: (name: string) => + Promise.resolve({ value: () => state[name] ?? null, on: () => () => {} }), + }; + const fake = { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient; + return { fake, calls }; +} + +async function settle(fixture: ComponentFixture) { + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } +} + +async function setup(options: { live?: boolean; recording?: boolean } = {}) { + const { fake, calls } = client(options); + const fixture = TestBed.createComponent(ComponentTree); + fixture.componentRef.setInput('rpc', fake); + const handled = vi.fn(); + fixture.componentInstance.commandHandled.subscribe(handled); + document.body.appendChild(fixture.nativeElement); + await settle(fixture); + return { fixture, calls, handled }; +} + +afterEach(() => { + document.body.innerHTML = ''; + TestBed.resetTestingModule(); +}); + +describe('Components tab palette commands', () => { + it('starts picking a component on the page', async () => { + const { fixture, calls, handled } = await setup(); + fixture.componentRef.setInput('command', { kind: 'pick' }); + await settle(fixture); + expect(calls.some((call) => call.name === 'request-component-pick')).toBe(true); + expect(fixture.componentInstance.picking()).toBe(true); + expect(handled).toHaveBeenCalledTimes(1); + }); + + it('starts, stops and clears the change detection recording', async () => { + const records = async (kind: 'cd-start' | 'cd-stop' | 'cd-clear', recording: boolean) => { + const { fixture, calls } = await setup({ recording }); + fixture.componentRef.setInput('command', { kind }); + await settle(fixture); + const sent = calls + .filter((call) => call.name === 'request-change-detection-record') + .map((call) => call.args[0]); + fixture.destroy(); + TestBed.resetTestingModule(); + return sent; + }; + expect(await records('cd-start', false)).toEqual([{ pageId: 'a', on: true }]); + expect(await records('cd-start', true)).toEqual([]); + expect(await records('cd-stop', true)).toEqual([{ pageId: 'a', on: false }]); + expect(await records('cd-clear', false)).toEqual([{ pageId: 'a', clear: true }]); + }); + + it('drops a command when no live page is connected', async () => { + const { fixture, calls, handled } = await setup({ live: false }); + fixture.componentRef.setInput('command', { kind: 'pick' }); + await settle(fixture); + expect(calls.some((call) => call.name === 'request-component-pick')).toBe(false); + expect(handled).toHaveBeenCalledTimes(1); + expect(fixture.nativeElement.textContent).toContain('The page reported no component instances'); + }); +}); diff --git a/app/src/__tests__/errors-view.test.ts b/app/src/__tests__/errors-view.test.ts new file mode 100644 index 00000000..565e17fe --- /dev/null +++ b/app/src/__tests__/errors-view.test.ts @@ -0,0 +1,263 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { DevframeRpcClient } from 'devframe/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ConsoleEntry, ErrorGroup } from '../pages/errors-types'; + +const connection = vi.hoisted(() => ({ resolve: (_client: unknown) => {} })); + +vi.mock('devframe/client', () => ({ + connectDevframe: () => + new Promise((resolve) => { + connection.resolve = resolve; + }), +})); + +const { App } = await import('../app'); +const { ErrorsInspector } = await import('../pages/errors-inspector'); +const { errorsPages, defaultErrorsPage } = await import('../pages/errors-types'); + +const group = (extra: Partial = {}): ErrorGroup => ({ + id: 'e1', + message: 'Error: No seats left on trip 42', + name: 'Error', + count: 3, + firstAt: 1_000, + lastAt: 2_000, + sources: ['handler'], + stack: 'Error: No seats left\n at _TripCard.book (main.js:10:5)', + owner: { id: 'c1-7', name: 'TripCard' }, + ...extra, +}); + +const entry = (id: number, extra: Partial = {}): ConsoleEntry => ({ + id, + level: 'log', + text: `line ${id}`, + at: 1_000 + id, + count: 1, + ...extra, +}); + +/** The shape the server sends: groups and entries keyed by id. */ +function wirePage( + pageId: string, + errors: ErrorGroup[], + console: ConsoleEntry[], + extra: Record = {}, +) { + return { + pageId, + url: `/${pageId}`, + title: `Trips ${pageId}`, + reportedAt: 5_000, + handler: 'wrapped', + errors: Object.fromEntries(errors.map((e) => [e.id, e])), + console: Object.fromEntries(console.map((e) => [String(e.id), e])), + dropped: { errors: 0, console: 0 }, + ...extra, + }; +} + +function fakeClient(value: unknown, configs: Record = {}) { + const rpc = { + call: () => Promise.resolve([]), + callEvent: () => Promise.resolve(), + sharedState: (name: string) => + Promise.resolve({ + value: () => (name === 'errors' ? value : null), + on: () => () => {}, + }), + }; + return { + connectionMeta: { configs }, + scope: () => ({ rpc }), + events: { on: () => () => {} }, + } as unknown as DevframeRpcClient; +} + +async function settle(fixture: ComponentFixture) { + for (let i = 0; i < 4; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } +} + +const host = (fixture: ComponentFixture) => fixture.nativeElement as HTMLElement; +const text = (fixture: ComponentFixture) => + (host(fixture).textContent ?? '').replace(/\s+/g, ' '); + +async function mount(value: unknown) { + const fixture = TestBed.createComponent(ErrorsInspector); + fixture.componentRef.setInput('rpc', fakeClient(value)); + document.body.append(host(fixture)); + await settle(fixture); + return fixture; +} + +function button(fixture: ComponentFixture, name: string): HTMLButtonElement { + const found = [...host(fixture).querySelectorAll('button')].find((b) => + (b.textContent ?? '').replace(/\s+/g, ' ').trim().startsWith(name), + ); + if (!found) throw new Error(`No button ${name}`); + return found; +} + +afterEach(() => { + document.body.innerHTML = ''; + location.hash = ''; + sessionStorage.clear(); +}); + +describe('the Errors tab', () => { + it('says how to get data when no page reports', async () => { + const fixture = await mount({ pages: {} }); + expect(text(fixture)).toContain('No page reports errors yet.'); + }); + + it('lists errors with count, code link, stack collapsed and a Show component link', async () => { + const fixture = await mount({ + pages: { + p1: wirePage( + 'p1', + [ + group(), + group({ + id: 'e2', + message: 'Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError', + code: 'NG0100', + docUrl: 'https://angular.dev/errors/NG0100', + lastAt: 3_000, + count: 1, + owner: undefined, + }), + group({ id: 'e3', message: 'NG0303: unknown', code: 'NG0303', lastAt: 500 }), + ], + [], + ), + }, + }); + const items = [...host(fixture).querySelectorAll('ol.errors > li')]; + expect(items.map((li) => li.querySelector('.message')?.textContent)).toEqual([ + 'Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError', + 'Error: No seats left on trip 42', + 'NG0303: unknown', + ]); + const link = items[0]!.querySelector('a.chip.code')!; + expect(link.href).toBe('https://angular.dev/errors/NG0100'); + expect(link.target).toBe('_blank'); + // An undocumented code is shown, without a link. + expect(items[2]!.querySelector('a.chip.code')).toBeNull(); + expect(items[2]!.querySelector('.chip.code')?.textContent?.trim()).toBe('NG0303'); + expect(items[1]!.querySelector('.times')?.getAttribute('aria-label')).toBe('3 times'); + const details = items[1]!.querySelector('details')!; + expect(details.open).toBe(false); + expect(details.querySelector('summary')?.textContent).toBe('Stack'); + + const shown: unknown[] = []; + fixture.componentInstance.showComponent.subscribe((focus) => shown.push(focus)); + button(fixture, 'Show component').click(); + expect(shown).toEqual([{ id: 'c1-7', pageId: 'p1' }]); + expect(text(fixture)).toContain('3 of 3 errors'); + }); + + it('filters by message and offers Clear filter when nothing matches', async () => { + const fixture = await mount({ pages: { p1: wirePage('p1', [group()], []) } }); + const search = host(fixture).querySelector('input[type="search"]')!; + search.value = 'zzz'; + search.dispatchEvent(new Event('input')); + await settle(fixture); + expect(text(fixture)).toContain('No error matches “zzz”.'); + button(fixture, 'Clear filter').click(); + await settle(fixture); + expect(host(fixture).querySelectorAll('ol.errors > li')).toHaveLength(1); + expect(document.activeElement).toBe(search); + }); + + it('shows the empty state and the unwrapped handler notice', async () => { + const fixture = await mount({ pages: { p1: wirePage('p1', [], [], { handler: 'none' }) } }); + expect(text(fixture)).toContain('No errors since the page loaded.'); + expect(text(fixture)).toContain("Angular's ErrorHandler isn't wrapped"); + }); + + it('shows the console log newest first, with a level filter and repeat counts', async () => { + const fixture = await mount({ + pages: { + p1: wirePage( + 'p1', + [], + [ + entry(1, { text: 'Loaded trips', count: 4 }), + entry(2, { + level: 'warn', + text: 'NG0913: image too big', + code: 'NG0913', + docUrl: 'https://angular.dev/errors/NG0913', + }), + entry(3, { level: 'error', text: 'Failed to save' }), + ], + ), + }, + }); + button(fixture, 'Console').click(); + await settle(fixture); + const rows = () => + [...host(fixture).querySelectorAll('ol.console > li .text')].map((t) => t.textContent); + expect(rows()).toEqual(['Failed to save', 'NG0913: image too big', 'Loaded trips']); + expect(host(fixture).querySelector('ol.console a.chip.code')?.getAttribute('href')).toBe( + 'https://angular.dev/errors/NG0913', + ); + expect(text(fixture)).toContain('4×'); + fixture.componentInstance.level.set('warn'); + await settle(fixture); + expect(rows()).toEqual(['NG0913: image too big']); + expect(text(fixture)).toContain('1 of 3 calls'); + expect(button(fixture, 'Console').getAttribute('aria-pressed')).toBe('true'); + }); + + it('merges pages under All pages and picks the panel page when it reports', () => { + const value = { + pages: { + a: wirePage('a', [group({ lastAt: 10 })], []), + b: { ...wirePage('b', [group({ id: 'e9', lastAt: 20 })], []), reportedAt: 9_000 }, + }, + }; + const pages = errorsPages(value); + expect(pages.map((p) => p.pageId)).toEqual(['b', 'a']); + expect(defaultErrorsPage(pages, 'a')).toBe('a'); + expect(defaultErrorsPage(pages, null)).toBe('all'); + expect(defaultErrorsPage(pages.slice(0, 1), null)).toBe('b'); + }); +}); + +describe('the Errors tab in the panel', () => { + it('appears in the tab bar, opens the Components tab from Show component, and goes with its inspector', async () => { + const open = async (configs: Record) => { + location.hash = '#tab=errors'; + const fixture = TestBed.createComponent(App); + document.body.append(host(fixture)); + await settle(fixture); + connection.resolve(fakeClient({ pages: { p1: wirePage('p1', [group()], []) } }, configs)); + await settle(fixture); + return fixture; + }; + const fixture = await open({}); + const names = [...host(fixture).querySelectorAll('nav button')].map((b) => + b.textContent?.trim(), + ); + expect(names).toContain('Errors'); + expect(host(fixture).querySelector('nav button.active')?.textContent?.trim()).toBe('Errors'); + button(fixture, 'Show component').click(); + await settle(fixture); + expect(host(fixture).querySelector('nav button.active')?.textContent?.trim()).toBe( + 'Components', + ); + fixture.destroy(); + document.body.innerHTML = ''; + + const off = await open({ pangular: { inspectors: { errors: false } } }); + const offNames = [...host(off).querySelectorAll('nav button')].map((b) => + b.textContent?.trim(), + ); + expect(offNames).not.toContain('Errors'); + }); +}); diff --git a/app/src/__tests__/settings.test.ts b/app/src/__tests__/settings.test.ts new file mode 100644 index 00000000..a7d0e916 --- /dev/null +++ b/app/src/__tests__/settings.test.ts @@ -0,0 +1,223 @@ +// @vitest-environment jsdom +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Settings } from '../pages/settings'; +import { + DEFAULT_TAB_LAYOUT, + arrangeTabs, + moveTab, + setTabHidden, + setTabPinned, + storePassive, + storeTabLayout, + storeThemePreference, + storedPassive, + storedTabLayout, + storedThemePreference, + type TabLayout, +} from '../shell-prefs'; +import { ThemeService } from '../theme.service'; + +const TABS = [ + { id: 'dashboard', label: 'Dashboard' }, + { id: 'components', label: 'Components' }, + { id: 'signals', label: 'Signals' }, + { id: 'forms', label: 'Forms' }, +]; +const IDS = TABS.map((tab) => tab.id); + +beforeEach(() => localStorage.clear()); +afterEach(() => { + document.body.innerHTML = ''; + delete document.documentElement.dataset['theme']; + TestBed.resetTestingModule(); +}); + +describe('settings storage', () => { + it('keeps the tab layout, theme and passive mode across reloads', () => { + expect(storedTabLayout()).toEqual(DEFAULT_TAB_LAYOUT); + const layout: TabLayout = { order: ['forms', 'dashboard'], hidden: ['signals'], pinned: [] }; + storeTabLayout(layout); + expect(storedTabLayout()).toEqual(layout); + storeTabLayout(DEFAULT_TAB_LAYOUT); + expect(localStorage.getItem('pangular-pref:tabs')).toBeNull(); + + expect(storedThemePreference()).toBe('system'); + storeThemePreference('light'); + expect(storedThemePreference()).toBe('light'); + storeThemePreference('system'); + expect(localStorage.getItem('pangular-pref:theme')).toBeNull(); + + expect(storedPassive()).toBe(false); + storePassive(true); + expect(storedPassive()).toBe(true); + }); + + it('ignores a stored layout it cannot use', () => { + for (const raw of ['{nope', '[]', 'null', '{"order":"x","hidden":[1,"forms"]}']) { + localStorage.setItem('pangular-pref:tabs', raw); + const layout = storedTabLayout(); + expect(Array.isArray(layout.order)).toBe(true); + expect(layout.hidden.every((id) => typeof id === 'string')).toBe(true); + } + }); +}); + +describe('tab layout', () => { + it('orders pinned tabs first and leaves hidden ones out, except the open one', () => { + const layout: TabLayout = { order: [], hidden: ['signals'], pinned: ['forms'] }; + expect(arrangeTabs(TABS, layout).map((tab) => tab.id)).toEqual([ + 'forms', + 'dashboard', + 'components', + ]); + expect(arrangeTabs(TABS, layout, 'signals').map((tab) => tab.id)).toContain('signals'); + }); + + it('moves a tab within its section and not past the ends', () => { + let layout = moveTab(IDS, DEFAULT_TAB_LAYOUT, 'signals', -1); + expect(arrangeTabs(TABS, layout).map((tab) => tab.id)).toEqual([ + 'dashboard', + 'signals', + 'components', + 'forms', + ]); + expect(moveTab(IDS, layout, 'dashboard', -1)).toBe(layout); + layout = setTabPinned(IDS, layout, 'forms', true); + // The first unpinned tab can't move above a pinned one. + expect(moveTab(IDS, layout, 'dashboard', -1)).toBe(layout); + }); + + it('unpins a tab it hides, and shows a tab it pins', () => { + let layout = setTabPinned(IDS, DEFAULT_TAB_LAYOUT, 'forms', true); + layout = setTabHidden(layout, 'forms', true); + expect(layout.pinned).toEqual([]); + expect(layout.hidden).toEqual(['forms']); + layout = setTabPinned(IDS, layout, 'forms', true); + expect(layout.hidden).toEqual([]); + }); +}); + +async function settle(fixture: ComponentFixture) { + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } +} + +async function render() { + const fixture = TestBed.createComponent(Settings); + fixture.componentRef.setInput('tabs', TABS); + fixture.componentRef.setInput('hotkey', 'Alt+Shift+P'); + document.body.appendChild(fixture.nativeElement); + await settle(fixture); + const root = fixture.nativeElement as HTMLElement; + const rows = () => + [...root.querySelectorAll('.tab-row')].map((row) => row.dataset['tab']); + const button = (label: string) => + root.querySelector(`[aria-label="${label}"]`)!; + return { fixture, root, rows, button }; +} + +describe('Settings page', () => { + it('starts with h2 sections and lists the tabs in order', async () => { + const { root, rows } = await render(); + expect([...root.querySelectorAll('h2')].map((h) => h.textContent?.trim())).toEqual([ + 'Theme', + 'Tabs', + 'Popup on the page', + 'Keyboard shortcuts', + ]); + expect(root.querySelector('h1, h3')).toBeNull(); + expect(rows()).toEqual(IDS); + }); + + it('reorders with the buttons, keeps focus on the moved row and says where it went', async () => { + const { fixture, root, rows, button } = await render(); + button('Move Signals up').focus(); + button('Move Signals up').click(); + await settle(fixture); + expect(rows()).toEqual(['dashboard', 'signals', 'components', 'forms']); + expect(fixture.componentInstance.layout().order).toEqual([ + 'dashboard', + 'signals', + 'components', + 'forms', + ]); + expect(document.activeElement).toBe(button('Move Signals up')); + expect(root.querySelector('[role="status"]')?.textContent).toContain( + 'Signals moved to position 2 of 4.', + ); + // At the top, focus moves to the other arrow instead of a disabled one. + button('Move Signals up').click(); + await settle(fixture); + expect(rows()[0]).toBe('signals'); + expect(button('Move Signals up').disabled).toBe(true); + expect(document.activeElement).toBe(button('Move Signals down')); + }); + + it('reorders with Alt and the arrow keys on a row', async () => { + const { fixture, rows, button } = await render(); + const pin = button('Pin Dashboard'); + pin.focus(); + pin.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true }), + ); + await settle(fixture); + expect(rows()).toEqual(['components', 'dashboard', 'signals', 'forms']); + }); + + it('hides, pins and resets tabs', async () => { + const { fixture, root, rows, button } = await render(); + const shown = root.querySelector( + '[aria-label="Forms shown in the tab bar"]', + )!; + shown.click(); + await settle(fixture); + expect(fixture.componentInstance.layout().hidden).toEqual(['forms']); + + button('Pin Signals').click(); + await settle(fixture); + expect(rows()[0]).toBe('signals'); + expect(button('Pin Signals').getAttribute('aria-pressed')).toBe('true'); + + const reset = [...root.querySelectorAll('button')].find( + (b) => b.textContent?.trim() === 'Reset tabs', + )!; + reset.click(); + await settle(fixture); + expect(fixture.componentInstance.layout()).toEqual(DEFAULT_TAB_LAYOUT); + expect(rows()).toEqual(IDS); + }); + + it('turns passive mode off when the popup has no shortcut', async () => { + const { fixture, root } = await render(); + const passive = () => + root.querySelector('input[aria-describedby="settings-passive-hint"]')!; + expect(passive().disabled).toBe(false); + fixture.componentRef.setInput('hotkey', false); + await settle(fixture); + expect(passive().disabled).toBe(true); + expect(root.textContent).toContain('popup.hotkey'); + }); +}); + +describe('theme preference', () => { + it('pins light or dark over the system, saves it, and follows the system again', () => { + const service = TestBed.inject(ThemeService); + expect(service.preference()).toBe('system'); + service.setPreference('light'); + expect(service.current()).toBe('light'); + expect(document.documentElement.dataset['theme']).toBe('light'); + expect(storedThemePreference()).toBe('light'); + service.setPreference('system'); + expect(document.documentElement.dataset['theme']).toBeUndefined(); + expect(storedThemePreference()).toBe('system'); + }); + + it('starts from the saved preference', () => { + storeThemePreference('light'); + const service = TestBed.inject(ThemeService); + expect(service.current()).toBe('light'); + }); +}); diff --git a/app/src/__tests__/shell-deep-link.test.ts b/app/src/__tests__/shell-deep-link.test.ts new file mode 100644 index 00000000..117cd6b3 --- /dev/null +++ b/app/src/__tests__/shell-deep-link.test.ts @@ -0,0 +1,158 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { DevframeRpcClient } from 'devframe/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { parsePanelLink } from '@pangular-inspector/devtools/config'; + +const server = vi.hoisted(() => ({ + /** Events the panel registered, by name, to call as the server would broadcast. */ + handlers: new Map void>(), +})); + +function fakeClient(): DevframeRpcClient { + const rpc = { + call: () => new Promise(() => {}), + callEvent: () => Promise.resolve(), + sharedState: () => Promise.resolve({ value: () => undefined, on: () => () => {} }), + register: (definition: { name: string; handler: (message: unknown) => void }) => + server.handlers.set(definition.name, definition.handler), + }; + return { + connectionMeta: { configs: {} }, + scope: () => ({ rpc }), + events: { on: () => () => {} }, + close: () => {}, + } as unknown as DevframeRpcClient; +} + +vi.mock('devframe/client', () => ({ + connectDevframe: () => Promise.resolve(fakeClient()), +})); + +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(); + } +} + +async function boot(hash = '') { + history.replaceState(null, '', `/${hash}`); + const fixture = TestBed.createComponent(App); + document.body.appendChild(fixture.nativeElement); + await settle(fixture); + return { fixture, app: fixture.componentInstance, root: fixture.nativeElement as HTMLElement }; +} + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + server.handlers.clear(); +}); + +afterEach(() => { + document.body.innerHTML = ''; + history.replaceState(null, '', '/'); + TestBed.resetTestingModule(); +}); + +describe('deep links', () => { + it('opens the tab and selects the component a link names', async () => { + const { fixture, app } = await boot('#tab=components&id=12&page=p1'); + expect(app.tab()).toBe('components'); + expect(app.componentFocus()).toEqual({ id: '12', pageId: 'p1' }); + fixture.destroy(); + }); + + it('selects the injector of a component, and a form', async () => { + const booted = await boot('#tab=injectors&id=c4'); + expect(booted.app.injectorFocus()).toEqual({ componentId: 'c4', pageId: undefined }); + booted.fixture.destroy(); + TestBed.resetTestingModule(); + // The Forms tab takes the focus at once and clears it, so the hand-over is watched. + history.replaceState(null, '', '/#tab=forms&id=f1'); + const fixture = TestBed.createComponent(App); + const handed = vi.spyOn(fixture.componentInstance.formFocus, 'set'); + await settle(fixture); + expect(fixture.componentInstance.tab()).toBe('forms'); + expect(handed).toHaveBeenCalledWith({ id: 'f1' }); + fixture.destroy(); + }); + + it('opens a tab without an item, and ignores an unknown tab', async () => { + let booted = await boot('#tab=signals&id=3'); + expect(booted.app.tab()).toBe('signals'); + booted.fixture.destroy(); + TestBed.resetTestingModule(); + booted = await boot('#tab=nope&id=3'); + expect(booted.app.tab()).toBe('dashboard'); + booted.fixture.destroy(); + }); + + it('follows a link the server sends to open panels', async () => { + const { fixture, app } = await boot(); + server.handlers.get('open-in-panel')!({ tab: 'components', id: '7', page: 'p2' }); + await settle(fixture); + expect(app.tab()).toBe('components'); + expect(app.componentFocus()).toEqual({ id: '7', pageId: 'p2' }); + expect(parsePanelLink(location.hash)).toEqual({ tab: 'components', id: '7', page: 'p2' }); + fixture.destroy(); + }); + + it('runs commands from the hub palette', async () => { + const { fixture, app } = await boot(); + server.handlers.get('panel-command')!({ command: 'tab:routes' }); + await settle(fixture); + expect(app.tab()).toBe('routes'); + server.handlers.get('panel-command')!({ command: 'pick-component' }); + await settle(fixture); + expect(app.tab()).toBe('components'); + fixture.destroy(); + }); +}); + +describe('shell palette and settings', () => { + it('opens the palette with Ctrl+K and runs a tab command', async () => { + const { fixture, app, root } = await boot(); + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true }), + ); + await settle(fixture); + const input = root.querySelector('app-command-palette input')!; + expect(document.activeElement).toBe(input); + input.value = 'go to forms'; + input.dispatchEvent(new Event('input')); + await settle(fixture); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await settle(fixture); + expect(app.tab()).toBe('forms'); + fixture.destroy(); + }); + + it('opens Settings from the header and goes back to the tab before', async () => { + const { fixture, app, root } = await boot('#tab=signals'); + const settings = root.querySelector('button[aria-label="Settings"]')!; + settings.click(); + await settle(fixture); + expect(app.tab()).toBe('settings'); + expect(settings.getAttribute('aria-pressed')).toBe('true'); + expect(root.querySelector('app-settings h2')?.textContent).toBe('Theme'); + settings.click(); + await settle(fixture); + expect(app.tab()).toBe('signals'); + fixture.destroy(); + }); + + it('orders and hides tab bar buttons as saved in Settings', async () => { + localStorage.setItem( + 'pangular-pref:tabs', + JSON.stringify({ order: [], hidden: ['pipes'], pinned: ['forms'] }), + ); + const { fixture, root } = await boot(); + const labels = [...root.querySelectorAll('nav button')].map((b) => b.textContent?.trim()); + expect(labels[0]).toBe('Forms'); + expect(labels).not.toContain('Pipes'); + fixture.destroy(); + }); +}); diff --git a/app/src/app.ts b/app/src/app.ts index 71ee816f..033d2b80 100644 --- a/app/src/app.ts +++ b/app/src/app.ts @@ -30,11 +30,12 @@ import { AnalogInspector } from './pages/analog-inspector'; import { NetworkInspector } from './pages/network-inspector'; import { ComingSoon, type ComingSoonInfo } from './pages/coming-soon'; import { CapacitorInspector } from './pages/capacitor-inspector'; +import { ErrorsInspector } from './pages/errors-inspector'; import { capacitorPages } from './pages/capacitor-types'; import { TabIcon } from './pages/tab-icon'; import { styleHubRail } from './hub-rail-style'; -import { ThemeService } from './theme.service'; -import { followHubDocks, inHub, selectHubDock } from './hub-dock-sync'; +import { ThemeService, themeTargets } from './theme.service'; +import { followHubDocks, inHub, openHubPalette, selectHubDock } from './hub-dock-sync'; import { fullPanelHref, isPlainClick } from './full-panel'; import { INSPECTORS_DOCS_URL, panelConfig, tabEnabled, tabInspector } from './devtools-config'; import { hostPageId, scopeToPage } from './page-id'; @@ -47,9 +48,32 @@ import { viewAccent } from './view-accents'; import { HUB_ALL_TOOLS, HUB_VIEWS, + PANEL_LINK_ITEMS, + PANEL_TABS, + hotkeyLabel, hubDockId, + matchesHotkey, + panelCommands, + panelLinkHash, + parsePanelLink, type HubView, + type PanelLink, } from '@pangular-inspector/devtools/config'; +import { Settings } from './pages/settings'; +import { CommandPalette } from './ui/command-palette'; +import type { PaletteCommand } from './palette-filter'; +import type { ComponentCommand } from './types/component-command.types'; +import { + arrangeTabs, + inPopup, + isMacPlatform, + storePassive, + storeTabLayout, + storedPassive, + storedTabLayout, + type TabLayout, + type ThemePreference, +} from './shell-prefs'; type View = HubView; @@ -169,6 +193,7 @@ function readView(): View | null { '[style.--accent]': 'viewAccent()?.accent ?? null', '[style.--accent-text]': 'viewAccent()?.text ?? null', '(window:message)': 'inspectFromPanel($event)', + '(document:keydown)': 'onGlobalKey($event)', }, imports: [ Dashboard, @@ -182,8 +207,11 @@ function readView(): View | null { AnalogInspector, NetworkInspector, CapacitorInspector, + ErrorsInspector, ComingSoon, TabIcon, + Settings, + CommandPalette, ], template: `
@@ -392,6 +420,33 @@ function readView(): View | null { Open full panel } + + + +

{{ backgroundNote() }}

- @if (connectionFailed()) { + @if (tab() === 'settings') { + + } @else if (connectionFailed()) {
+ `, styles: ` @use 'mixins' as m; @@ -718,7 +799,50 @@ function readView(): View | null { @include m.focus-ring(2px); } } - .full-panel + .status { + .shell-actions { + display: inline-flex; + flex-shrink: 0; + gap: 4px; + margin-left: auto; + } + .full-panel + .shell-actions { + margin-left: 0; + } + .shell-btn { + display: grid; + place-items: center; + width: 30px; + height: 30px; + padding: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--text-2); + cursor: pointer; + transition: + background-color 0.16s var(--ease), + color 0.16s var(--ease); + svg { + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + } + &:hover { + background: var(--surface-2); + color: var(--text-strong); + } + &.active { + border-color: var(--accent-line); + background: var(--accent-soft); + color: var(--text-strong); + } + &:focus-visible { + @include m.focus-ring(2px); + } + } + .shell-actions + .status { margin-left: 0; } .dot { @@ -883,27 +1007,17 @@ function readView(): View | null { }) export class App implements OnInit, OnDestroy { readonly analog = signal(false); - private readonly allTabs: Tabs[] = [ - { id: 'dashboard', label: 'Dashboard' }, - { id: 'analog', label: 'Analog' }, - { id: 'components', label: 'Components' }, - { id: 'routes', label: 'Routes' }, - { id: 'signals', label: 'Signals' }, - { id: 'injectors', label: 'Injectors' }, - { id: 'store', label: 'Store' }, - { id: 'forms', label: 'Forms' }, - { id: 'pipes', label: 'Pipes' }, - { id: 'network', label: 'SSR & HTTP' }, - { id: 'capacitor', label: 'Capacitor' }, - ]; + private readonly allTabs: Tabs[] = PANEL_TABS.map(({ id, label }) => ({ id, label })); readonly view = signal(readView()); readonly viewAccent = computed(() => viewAccent(this.view(), this.themeService.current())); readonly title = computed(() => { const view = this.view(); return view ? VIEW_TITLE[view] : 'Pangular Inspector'; }); - protected readonly mainLabel = computed( - () => this.availableTabs().find((t) => t.id === this.tab())?.label ?? this.title(), + protected readonly mainLabel = computed(() => + this.tab() === 'settings' + ? 'Settings' + : (this.availableTabs().find((t) => t.id === this.tab())?.label ?? this.title()), ); readonly analogKnown = signal(false); /** Whether a Capacitor app reports, so the Capacitor tab has something to show. */ @@ -954,12 +1068,16 @@ export class App implements OnInit, OnDestroy { !(view === 'angular' && TAB_VIEW[t.id]), ); }); + /** How the viewer arranged the tab bar in Settings. */ + readonly tabLayout = signal(storedTabLayout()); readonly tabs = computed(() => { - const tabs = this.availableTabs(); - if (this.rpc()) return tabs; const tab = this.tab(); + const tabs = arrangeTabs(this.availableTabs(), this.tabLayout(), tab); + if (this.rpc()) return tabs; return tabs.filter((t) => t.id === 'dashboard' || t.id === tab); }); + /** The tabs Settings arranges: every tab this panel can show, in the default order. */ + protected readonly settingsTabs = computed(() => this.availableTabs()); /** The one tab button in the Tab order; the arrow keys reach the others. */ protected readonly rovingTab = computed(() => { @@ -1095,13 +1213,14 @@ export class App implements OnInit, OnDestroy { if (dock === ALL_TOOLS) this.showFullPanel(); else this.showView(dock); }); - const restored = initialTab( - location.hash, - storedTab(this.tabScope()), - this.availableTabs().map((t) => t.id), - ); - if (restored) this.tab.set(restored); - else if ( + const restored = initialTab(location.hash, storedTab(this.tabScope()), [ + ...this.availableTabs().map((t) => t.id), + 'settings' as const, + ]); + if (restored) { + this.tab.set(restored); + this.restoreLinkItem(); + } else if ( !this.view() && initialTab(location.hash, storedTab(this.tabScope()), ['capacitor']) === 'capacitor' ) { @@ -1142,6 +1261,7 @@ export class App implements OnInit, OnDestroy { this.refocusOnConnect = false; this.rpc.set(client); this.connected.set(true); + this.listenForServer(client); void this.watchVisibility(client); void this.watchAngularNative(client); void this.watchCapacitor(client); @@ -1252,6 +1372,199 @@ export class App implements OnInit, OnDestroy { void selectHubDock(ALL_TOOLS); } + // Command palette, settings and deep links. + readonly paletteOpen = signal(false); + protected readonly paletteShortcut = isMacPlatform() ? '⌘K' : 'Ctrl+K'; + readonly themePreference = computed(() => this.themeService.preference()); + readonly passive = signal(storedPassive()); + /** The popup shortcut from the config, as the platform writes it. */ + readonly popupHotkey = computed(() => { + const hotkey = this.config().popup.hotkey; + return hotkey === false ? false : hotkeyLabel(hotkey, isMacPlatform()); + }); + private readonly popup = inPopup(); + /** The tab to go back to when Settings closes. */ + private tabBeforeSettings: Tab | null = null; + /** A command for the Components tab, handed over once it shows. */ + readonly componentCommand = linkedSignal({ + source: this.tab, + computation: () => null, + }); + + readonly paletteCommands = computed(() => { + const available = new Set(this.availableTabs().map((t) => t.id)); + const components = available.has('components') && !!this.rpc(); + return panelCommands(this.config()).filter((command) => { + if (command.id.startsWith('tab:')) return available.has(command.id.slice(4)); + if (command.popupOnly) return this.popup; + if (command.id === 'pick-component' || command.id.startsWith('cd-')) return components; + return true; + }); + }); + + /** Cmd+K on macOS, Ctrl+K elsewhere, opens the palette from anywhere in the panel. */ + onGlobalKey(event: KeyboardEvent) { + if (event.defaultPrevented) return; + const mac = isMacPlatform(); + const hotkey = this.config().popup.hotkey; + if (this.popup && hotkey && !event.repeat && matchesHotkey(event, hotkey, mac)) { + // The popup can't hear keys inside a hub dock, so the panel asks it to close. + event.preventDefault(); + this.postToPopup({ type: 'pangular:popup-close' }); + return; + } + const mod = mac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; + if (!mod || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'k') return; + if (event.repeat) return; + event.preventDefault(); + if (this.paletteOpen()) return; + this.openPalette(); + } + + /** The hub's palette lists the same commands; outside the hub the panel has its own. */ + openPalette() { + if (inHub() && openHubPalette()) return; + this.paletteOpen.set(true); + } + + /** Runs a palette command, from this panel's palette or the hub's. */ + runCommand(id: string) { + if (id.startsWith('tab:')) { + const tab = id.slice(4) as Tab; + if (this.availableTabs().some((t) => t.id === tab)) this.switchTab(tab); + return; + } + if (id.startsWith('theme:')) { + const theme = id.slice(6); + if (theme === 'system' || theme === 'light' || theme === 'dark') this.setTheme(theme); + return; + } + if (id.startsWith('dock:')) { + this.postToPopup({ type: 'pangular:popup-dock', mode: id.slice(5) }); + return; + } + const kinds: Record = { + 'pick-component': 'pick', + 'cd-record-start': 'cd-start', + 'cd-record-stop': 'cd-stop', + 'cd-clear': 'cd-clear', + }; + if (id === 'settings') { + if (this.tab() !== 'settings') this.toggleSettings(); + } else if (kinds[id] && this.availableTabs().some((t) => t.id === 'components')) { + // Switch first: the command belongs to the tab, so the switch would clear it. + if (this.tab() !== 'components') this.switchTab('components'); + this.componentCommand.set({ kind: kinds[id] }); + } + } + + toggleSettings() { + if (this.tab() === 'settings') { + const back = this.tabBeforeSettings; + const available = this.availableTabs().map((t) => t.id); + this.switchTab(back && available.includes(back) ? back : (available[0] ?? 'dashboard')); + return; + } + this.tabBeforeSettings = this.tab(); + this.switchTab('settings'); + } + + setTabLayout(layout: TabLayout) { + this.tabLayout.set(layout); + storeTabLayout(layout); + } + + setTheme(theme: ThemePreference) { + this.themeService.setPreference(theme); + } + + setPassive(passive: boolean) { + this.passive.set(passive); + storePassive(passive); + this.postToPopup({ type: 'pangular:popup-passive', passive }); + } + + /** Sends a message to the popup on the app's page, which checks it came from its panel. */ + private postToPopup(message: Record) { + for (const { win, origin } of themeTargets()) { + try { + win.postMessage(message, origin); + } catch { + // a window that went away + } + } + } + + /** + * Opens a deep link: its tab, and the item it names on the tabs that can + * select one (`PANEL_LINK_ITEMS`). Returns whether the tab exists here. + */ + openLink(link: PanelLink): boolean { + const tab = link.tab as Tab; + if (!this.availableTabs().some((t) => t.id === tab)) return false; + if (this.tab() !== tab) this.switchTab(tab); + if (link.id && Object.hasOwn(PANEL_LINK_ITEMS, tab)) { + const pageId = link.page; + if (tab === 'components') this.componentFocus.set({ id: link.id, pageId }); + else if (tab === 'injectors') this.injectorFocus.set({ componentId: link.id, pageId }); + else if (tab === 'forms') this.formFocus.set({ id: link.id }); + } + history.replaceState(history.state, '', panelLinkHash(link)); + return true; + } + + /** Listens for the commands and links the server sends to every open panel. */ + private listenForServer(client: DevframeRpcClient) { + const rpc = client.scope('pangular').rpc as unknown as { + register: (definition: { + name: string; + type: 'event'; + jsonSerializable: boolean; + handler: (message: unknown) => void; + }) => void; + }; + // Only a panel someone can see acts, so a tab in the background stays put. + const visible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden'; + try { + rpc.register({ + name: 'panel-command', + type: 'event', + jsonSerializable: true, + handler: (message) => { + const command = (message as { command?: unknown } | null)?.command; + if (typeof command === 'string' && visible()) this.runCommand(command); + }, + }); + rpc.register({ + name: 'open-in-panel', + type: 'event', + jsonSerializable: true, + handler: (message) => { + const link = message as Partial | null; + if (!visible() || typeof link?.tab !== 'string') return; + this.openLink({ + tab: link.tab, + ...(typeof link.id === 'string' ? { id: link.id } : {}), + ...(typeof link.page === 'string' ? { page: link.page } : {}), + }); + }, + }); + } catch { + // a client that cannot take server events; the palette still works locally + } + } + + /** The item a deep link on load names, applied once its tab is open. */ + private restoreLinkItem() { + const link = parsePanelLink(location.hash); + if (!link?.id || link.tab !== this.tab()) return; + const tab = link.tab as Tab; + if (tab === 'components') this.componentFocus.set({ id: link.id, pageId: link.page }); + else if (tab === 'injectors') { + this.injectorFocus.set({ componentId: link.id, pageId: link.page }); + } else if (tab === 'forms') this.formFocus.set({ id: link.id }); + } + formFocus = signal<{ id: string } | null>(null); // Belongs to the Components tab that received it, so leaving the tab drops it. readonly componentFocus = linkedSignal({ diff --git a/app/src/devtools-config.ts b/app/src/devtools-config.ts index f6f11572..4a373875 100644 --- a/app/src/devtools-config.ts +++ b/app/src/devtools-config.ts @@ -30,6 +30,7 @@ const TAB_INSPECTOR: Partial> = { network: 'http', analog: 'analog', capacitor: 'capacitor', + errors: 'errors', }; /** The `inspectors` key that turns a tab on or off, if any. */ diff --git a/app/src/hub-dock-sync.ts b/app/src/hub-dock-sync.ts index 1461ca93..280fb3f0 100644 --- a/app/src/hub-dock-sync.ts +++ b/app/src/hub-dock-sync.ts @@ -7,6 +7,7 @@ interface DockState { } interface HubClientContext { + commands?: { openPalette?: (scopeId?: string | null) => void }; docks: { selectedId: string | null; getStateById: (id: string) => DockState | undefined; @@ -65,3 +66,18 @@ export async function selectHubDock(view: string): Promise { return false; } } + +/** + * Opens the hub's command palette, which lists the panel's commands through + * `ctx.commands`. `false` outside the hub or when the hub has no palette. + */ +export function openHubPalette(): boolean { + const open = hubContext()?.commands?.openPalette; + if (typeof open !== 'function') return false; + try { + open(); + return true; + } catch { + return false; + } +} diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts index b1233d7c..4abab9f9 100644 --- a/app/src/pages/component-tree.ts +++ b/app/src/pages/component-tree.ts @@ -36,6 +36,7 @@ import { import { HydrationSection, type HydrationReport } from './hydration-section'; import { CdRecording, type CdPage } from './cd-recording'; import type { ComponentFocus } from '../types/component-focus.types'; +import type { ComponentCommand } from '../types/component-command.types'; import type { InjectorFocus } from '../types/injector-focus.types'; import { SourceLink } from '../ui/source-link'; import { actionAllowed } from '../devtools-config'; @@ -1641,6 +1642,9 @@ export class ComponentTree { readonly focus = input(null); readonly showForm = output(); readonly focusHandled = output(); + /** A palette command to run once the tree shows a live page. */ + readonly command = input(null); + readonly commandHandled = output(); /** Whether the Injectors tab is on, so "Show in Injectors" can open it. */ readonly canShowInjector = input(false); readonly showInjector = output(); @@ -1675,6 +1679,9 @@ export class ComponentTree { private readonly injector = inject(Injector); private readonly host = inject>(ElementRef); private readonly filterInput = viewChild>('filterInput'); + private readonly cdRecording = viewChild(CdRecording); + /** Whether the server sent the component tree state yet, so an empty one means no page. */ + private readonly pagesKnown = signal(false); private readonly cleanups: (() => void)[] = []; private selection = { pageId: null as string | null, detailId: null as string | null }; @@ -1963,6 +1970,38 @@ export class ComponentTree { this.focusHandled.emit(); }); }); + effect(() => { + const command = this.command(); + if (!command) return; + if (!this.live()) { + // The tab already explains that no live page is connected. + if (this.pagesKnown()) untracked(() => this.commandHandled.emit()); + return; + } + if (command.kind === 'pick') { + untracked(() => { + if (this.native()) this.say('Picking needs a page in a browser.'); + else if (!this.picking()) void this.pick(); + this.commandHandled.emit(); + }); + return; + } + const recording = this.cdRecording(); + if (!recording) { + if (this.native() || this.staticReport()) { + untracked(() => { + this.say('This page cannot record change detection.'); + this.commandHandled.emit(); + }); + } + return; + } + untracked(() => { + if (command.kind === 'cd-clear') void recording.clear(); + else if ((command.kind === 'cd-start') !== recording.recording()) void recording.toggle(); + this.commandHandled.emit(); + }); + }); this.destroyRef.onDestroy(() => { this.highlight(null); for (const cleanup of this.cleanups.splice(0)) cleanup(); @@ -1978,11 +2017,14 @@ export class ComponentTree { const applyTree = (value: unknown) => { const pages = (value as { pages?: Record } | null)?.pages; this.applyPages(pages && typeof pages === 'object' ? pages : {}); + // A state the client hasn't loaded from the server yet says nothing. + if (value !== undefined) this.pagesKnown.set(true); }; applyTree(tree.value()); this.cleanups.push(tree.on('updated', applyTree)); } catch { this.applyPages({}); + this.pagesKnown.set(true); } try { const cd = await my.rpc.sharedState('change-detection'); diff --git a/app/src/pages/errors-inspector.ts b/app/src/pages/errors-inspector.ts new file mode 100644 index 00000000..81245867 --- /dev/null +++ b/app/src/pages/errors-inspector.ts @@ -0,0 +1,660 @@ +import { + Component, + DestroyRef, + computed, + effect, + inject, + input, + linkedSignal, + output, + signal, +} from '@angular/core'; +import type { DevframeRpcClient } from 'devframe/client'; +import { time } from '../format'; +import { hostPageId } from '../page-id'; +import { Select, type SelectOption } from '../ui/select'; +import type { ComponentFocus } from '../types/component-focus.types'; +import { + ALL_PAGES, + CONSOLE_LEVELS, + SOURCE_LABELS, + codeLink, + consoleOf, + defaultErrorsPage, + errorsOf, + errorsPages, + type ConsoleLevel, + type ErrorsPage, +} from './errors-types'; + +type View = 'errors' | 'console'; + +const LEVEL_TONE: Record = { + error: 'danger', + warn: 'warn', + info: 'accent', + log: 'plain', + debug: 'plain', +}; + +@Component({ + selector: 'app-errors-inspector', + imports: [Select], + template: ` + @if (!loaded()) { +

Loading errors…

+ } @else if (failed()) { + + } @else if (!pages().length) { +
+

No page reports errors yet.

+

+ Open the app with the overlay loaded. Errors and console calls show up here as they + happen. +

+
+ } @else { +

+ Errors from Angular's ErrorHandler, uncaught errors and unhandled promise rejections on the + live page, grouped by message, and the page's console calls. Messages and values are masked + like other live data. +

+ +
+
+ + +
+ + @if (view() === 'console') { + + } + @if (pageOptions().length > 2) { + + } + {{ countText() }} +
+ + @if (view() === 'errors') { +
+

Errors

+ @if (unwrapped()) { +

+ Angular's ErrorHandler isn't wrapped on this page yet, so errors Angular catches show + up only when the default handler logs them. +

+ } + @if (droppedErrors()) { +

+ {{ droppedErrors() }} older error + {{ droppedErrors() === 1 ? 'group was' : 'groups were' }} + dropped. Each page keeps its last 100. +

+ } + @if (!errors().length) { +

+ No errors since the page loaded. Errors show up here as the app hits them. +

+ } @else if (!shownErrors().length) { +
+

No error matches “{{ filter() }}”.

+ +
+ } @else { +
    + @for (error of shownErrors(); track error.pageId + error.id) { +
  1. +
    + + {{ error.count }}× + + {{ error.message }} +
    +
    + @if (error.code; as ngCode) { + @if (codeLink(ngCode, error.docUrl).href; as href) { + {{ ngCode + }} reference (opens in a new tab) + } @else { + {{ ngCode }} + } + } + @for (source of error.sources; track source) { + {{ sourceLabels[source] }} + } + Last at {{ time(error.lastAt) }} + @if (showPage()) { + {{ pageLabel(error.pageId) }} + } + @if (error.owner; as owner) { + @if (canShowComponent()) { + + } @else { + In {{ owner.name }} + } + } +
    + @if (error.stack) { +
    + Stack +
    {{ error.stack }}
    +
    + } +
  2. + } +
+ } +
+ } @else { +
+

Console

+ @if (droppedConsole()) { +

+ {{ droppedConsole() }} older {{ droppedConsole() === 1 ? 'call was' : 'calls were' }} + dropped. Each page keeps its last 200. +

+ } + @if (!entries().length) { +

+ No console calls since the overlay connected. They show up here as the app logs. +

+ } @else if (!shownEntries().length) { +
+

No console call matches the filters.

+ +
+ } @else { +
    + @for (entry of shownEntries(); track entry.pageId + entry.id) { +
  1. + {{ + entry.level + }} + {{ entry.text }} + @if (entry.count > 1) { + {{ entry.count }}× + } + @if (entry.code; as ngCode) { + @if (codeLink(ngCode, entry.docUrl).href; as href) { + {{ ngCode }} reference (opens in a new tab) + } @else { + {{ ngCode }} + } + } + +
  2. + } +
+ } +
+ } + } + `, + styles: ` + @use 'mixins' as m; + :host { + display: block; + font-size: 13px; + } + .intro { + margin: 0 0 12px; + color: var(--text-2); + line-height: 1.5; + } + code, + pre { + font-family: var(--font-mono); + font-size: 12px; + } + .muted { + color: var(--text-2); + } + .toolbar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + margin: 0 0 12px; + padding: 8px; + background: color-mix(in srgb, var(--surface) 85%, transparent); + backdrop-filter: blur(10px); + border: 1px solid var(--border); + border-radius: var(--radius); + } + .views { + display: inline-flex; + gap: 4px; + } + .view { + display: inline-flex; + gap: 6px; + align-items: center; + height: var(--control-h); + padding: 0 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font: inherit; + cursor: pointer; + transition: background-color 150ms var(--ease); + } + .view:hover { + background: var(--surface-2); + } + .view[aria-pressed='true'] { + border-color: var(--accent-line); + background: var(--accent-soft); + color: var(--text-strong); + } + .view:focus-visible { + @include m.focus-ring; + } + .badge { + color: var(--text-2); + font-variant-numeric: tabular-nums; + } + .search { + position: relative; + flex: 1 1 200px; + min-width: 0; + } + .search-icon { + position: absolute; + top: 50%; + left: 12px; + width: 14px; + height: 14px; + transform: translateY(-50%); + fill: none; + stroke: var(--text-3); + stroke-width: 2.2; + stroke-linecap: round; + pointer-events: none; + } + input[type='search'] { + width: 100%; + height: var(--control-h); + padding: 0 12px 0 34px; + background: var(--bg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 13px; + } + input[type='search']:focus { + @include m.field-focus; + } + .level-select, + .page-select { + flex: 0 1 180px; + min-width: 0; + } + .count { + margin-left: auto; + color: var(--text-2); + font-size: 12px; + } + .notice { + margin: 0 0 12px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-2); + line-height: 1.5; + } + .errors, + .console { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + } + .error { + @include m.panel; + display: grid; + gap: 6px; + min-width: 0; + padding: 8px 12px; + } + .head { + display: flex; + gap: 8px; + align-items: baseline; + min-width: 0; + } + .message { + min-width: 0; + color: var(--text-strong); + overflow-wrap: anywhere; + white-space: pre-wrap; + } + .times { + flex-shrink: 0; + color: var(--danger-text); + font-weight: 600; + font-variant-numeric: tabular-nums; + } + .meta { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + align-items: center; + font-size: 12px; + } + .chip { + display: inline-flex; + align-items: center; + padding: 1px 8px; + border: 1px solid transparent; + border-radius: 99px; + font-size: 11px; + font-weight: 600; + @include m.tint(accent); + } + .chip.source, + .chip.plain, + .chip[data-tone='plain'] { + border-color: var(--border); + background: var(--surface-2); + color: var(--text-2); + } + .chip[data-tone='danger'] { + @include m.tint(danger); + } + .chip[data-tone='warn'] { + @include m.tint(warn); + } + a.chip { + text-decoration: underline; + text-underline-offset: 2px; + } + a.chip:focus-visible { + @include m.focus-ring; + } + .link { + display: inline-flex; + gap: 4px; + align-items: baseline; + padding: 0; + border: 0; + border-radius: 2px; + background: none; + color: var(--accent-text); + font: inherit; + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + } + .link:focus-visible { + @include m.focus-ring; + } + details summary { + width: fit-content; + border-radius: 2px; + color: var(--text-2); + font-size: 12px; + cursor: pointer; + } + details summary:focus-visible { + @include m.focus-ring; + } + pre { + max-height: 240px; + margin: 6px 0 0; + padding: 8px; + overflow: auto; + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + white-space: pre; + } + pre:focus-visible { + @include m.focus-ring(-2px); + } + .entry { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + align-items: baseline; + min-width: 0; + padding: 6px 10px; + border-bottom: 1px solid var(--border); + } + .entry .level { + min-width: 44px; + justify-content: center; + } + .entry .times { + color: var(--text-2); + } + .text { + flex: 1 1 240px; + min-width: 0; + color: var(--text); + overflow-wrap: anywhere; + white-space: pre-wrap; + } + .when { + margin-left: auto; + color: var(--text-2); + font-size: 12px; + font-variant-numeric: tabular-nums; + } + .small { + height: var(--control-h); + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + } + .small:focus-visible { + @include m.focus-ring; + } + .empty, + .state { + margin: 0 0 12px; + color: var(--text-2); + } + .empty p, + .state p { + margin: 0 0 8px; + } + `, +}) +export class ErrorsInspector { + readonly rpc = input(null); + /** Whether the Components tab is on, so "Show component" has somewhere to go. */ + readonly canShowComponent = input(true); + readonly showComponent = output(); + + private readonly destroyRef = inject(DestroyRef); + private readonly hostPageId = hostPageId(); + private unsubscribe?: () => void; + + protected readonly time = time; + protected readonly codeLink = codeLink; + protected readonly sourceLabels = SOURCE_LABELS; + protected readonly levelTone = LEVEL_TONE; + protected readonly allPages = ALL_PAGES; + protected readonly levelOptions: SelectOption[] = [ + { value: 'all', label: 'All levels' }, + ...CONSOLE_LEVELS.map((level) => ({ value: level, label: level })), + ]; + + readonly pages = signal([]); + readonly loaded = signal(false); + readonly failed = signal(false); + readonly view = signal('errors'); + readonly filter = signal(''); + readonly level = signal('all'); + /** Keeps the picked page while it reports, and falls back once it is gone. */ + readonly pageId = linkedSignal({ + source: () => this.pages(), + computation: (pages, previous) => + previous?.value && + (previous.value === ALL_PAGES || pages.some((page) => page.pageId === previous.value)) + ? previous.value + : defaultErrorsPage(pages, this.hostPageId), + }); + + readonly pageOptions = computed(() => [ + { value: ALL_PAGES, label: 'All pages' }, + ...this.pages().map((page) => ({ + value: page.pageId, + label: page.title || page.url || page.pageId, + hint: page.url, + })), + ]); + readonly showPage = computed(() => this.pageId() === ALL_PAGES && this.pages().length > 1); + private readonly picked = computed(() => + this.pages().filter((page) => this.pageId() === ALL_PAGES || page.pageId === this.pageId()), + ); + readonly unwrapped = computed(() => this.picked().some((page) => page.handler !== 'wrapped')); + readonly droppedErrors = computed(() => + this.picked().reduce((sum, page) => sum + page.dropped.errors, 0), + ); + readonly droppedConsole = computed(() => + this.picked().reduce((sum, page) => sum + page.dropped.console, 0), + ); + readonly errors = computed(() => errorsOf(this.pages(), this.pageId())); + readonly entries = computed(() => consoleOf(this.pages(), this.pageId())); + readonly shownErrors = computed(() => { + const text = this.filter().trim().toLowerCase(); + if (!text) return this.errors(); + return this.errors().filter( + (error) => + error.message.toLowerCase().includes(text) || + (error.code ?? '').toLowerCase().includes(text) || + (error.owner?.name ?? '').toLowerCase().includes(text), + ); + }); + readonly shownEntries = computed(() => { + const text = this.filter().trim().toLowerCase(); + const level = this.level(); + return this.entries().filter( + (entry) => + (level === 'all' || entry.level === level) && + (!text || entry.text.toLowerCase().includes(text)), + ); + }); + readonly countText = computed(() => + this.view() === 'errors' + ? `${this.shownErrors().length} of ${this.errors().length} errors` + : `${this.shownEntries().length} of ${this.entries().length} calls`, + ); + + constructor() { + effect(() => { + const client = this.rpc(); + if (client) void this.load(client); + }); + this.destroyRef.onDestroy(() => this.unsubscribe?.()); + } + + retry() { + const client = this.rpc(); + if (client) void this.load(client); + } + + clearFilters() { + this.filter.set(''); + this.level.set('all'); + } + + timesLabel(count: number): string { + return count === 1 ? 'Once' : `${count} times`; + } + + pageLabel(pageId: string): string { + const page = this.pages().find((p) => p.pageId === pageId); + return page?.title || page?.url || pageId; + } + + private async load(client: DevframeRpcClient) { + this.failed.set(false); + try { + const state = await client.scope('pangular').rpc.sharedState('errors'); + if (this.destroyRef.destroyed) return; + const apply = (value: unknown) => this.pages.set(errorsPages(value)); + apply(state.value()); + this.unsubscribe?.(); + this.unsubscribe = state.on('updated', apply); + } catch { + this.failed.set(true); + } finally { + this.loaded.set(true); + } + } +} diff --git a/app/src/pages/errors-types.ts b/app/src/pages/errors-types.ts new file mode 100644 index 00000000..52268ca6 --- /dev/null +++ b/app/src/pages/errors-types.ts @@ -0,0 +1,127 @@ +/** The Errors inspector data as the `errors` shared state holds it. */ + +export type ConsoleLevel = 'log' | 'info' | 'warn' | 'error' | 'debug'; + +export const CONSOLE_LEVELS: readonly ConsoleLevel[] = ['log', 'info', 'warn', 'error', 'debug']; + +export interface ErrorOwner { + /** The host element id the Components tab selects. */ + id: string; + name: string; +} + +export interface ErrorGroup { + id: string; + message: string; + name?: string; + code?: string; + docUrl?: string; + count: number; + firstAt: number; + lastAt: number; + sources: ('handler' | 'window' | 'promise')[]; + stack?: string; + owner?: ErrorOwner; +} + +export interface ConsoleEntry { + id: number; + level: ConsoleLevel; + text: string; + at: number; + count: number; + code?: string; + docUrl?: string; +} + +export interface ErrorsPage { + pageId: string; + url: string; + title: string; + reportedAt: number; + handler: 'wrapped' | 'none'; + errors: ErrorGroup[]; + console: ConsoleEntry[]; + dropped: { errors: number; console: number }; +} + +/** An error group with the page it comes from, for the list across pages. */ +export interface PageError extends ErrorGroup { + pageId: string; +} + +export interface PageConsoleEntry extends ConsoleEntry { + pageId: string; +} + +export const ALL_PAGES = 'all'; + +export const SOURCE_LABELS: Record = { + handler: 'ErrorHandler', + window: 'Uncaught', + promise: 'Unhandled rejection', +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +/** + * The pages in the shared state, newest report first. The server keys groups + * and console entries by id, so one push patches one entry; the panel reads + * them as lists. + */ +export function errorsPages(value: unknown): ErrorsPage[] { + const pages = isRecord(value) ? value['pages'] : undefined; + if (!isRecord(pages)) return []; + return Object.values(pages) + .filter( + (page): page is Record => + isRecord(page) && typeof page['pageId'] === 'string', + ) + .map((page) => ({ + pageId: page['pageId'] as string, + url: typeof page['url'] === 'string' ? page['url'] : '', + title: typeof page['title'] === 'string' ? page['title'] : '', + reportedAt: typeof page['reportedAt'] === 'number' ? page['reportedAt'] : 0, + handler: page['handler'] === 'wrapped' ? ('wrapped' as const) : ('none' as const), + errors: isRecord(page['errors']) ? (Object.values(page['errors']) as ErrorGroup[]) : [], + console: isRecord(page['console']) + ? (Object.values(page['console']) as ConsoleEntry[]).sort((a, b) => a.id - b.id) + : [], + dropped: isRecord(page['dropped']) + ? { + errors: Number(page['dropped']['errors']) || 0, + console: Number(page['dropped']['console']) || 0, + } + : { errors: 0, console: 0 }, + })) + .sort((a, b) => b.reportedAt - a.reportedAt); +} + +/** The page to show first: the panel's own page, else the newest. */ +export function defaultErrorsPage(pages: ErrorsPage[], hostPageId: string | null): string { + if (hostPageId && pages.some((page) => page.pageId === hostPageId)) return hostPageId; + return pages.length > 1 ? ALL_PAGES : (pages[0]?.pageId ?? ALL_PAGES); +} + +/** The error groups of one page or of every page, most recent first. */ +export function errorsOf(pages: ErrorsPage[], pageId: string): PageError[] { + return pages + .filter((page) => pageId === ALL_PAGES || page.pageId === pageId) + .flatMap((page) => page.errors.map((group) => ({ ...group, pageId: page.pageId }))) + .sort((a, b) => b.lastAt - a.lastAt); +} + +/** The console entries of one page or of every page, newest first. */ +export function consoleOf(pages: ErrorsPage[], pageId: string): PageConsoleEntry[] { + return pages + .filter((page) => pageId === ALL_PAGES || page.pageId === pageId) + .flatMap((page) => page.console.map((entry) => ({ ...entry, pageId: page.pageId }))) + .sort((a, b) => b.at - a.at || b.id - a.id); +} + +/** `NG0100` and its reference page, or just the code when the reference has none. */ +export function codeLink(code: string, docUrl?: string): { code: string; href?: string } { + return docUrl?.startsWith('https://angular.dev/errors/') ? { code, href: docUrl } : { code }; +} diff --git a/app/src/pages/settings.ts b/app/src/pages/settings.ts new file mode 100644 index 00000000..42f9388d --- /dev/null +++ b/app/src/pages/settings.ts @@ -0,0 +1,520 @@ +import { + Component, + ElementRef, + Injector, + afterNextRender, + computed, + inject, + input, + model, + signal, +} from '@angular/core'; +import { + moveTab, + orderedTabIds, + setTabHidden, + setTabPinned, + DEFAULT_TAB_LAYOUT, + type TabLayout, + type ThemePreference, +} from '../shell-prefs'; +import { TabIcon } from './tab-icon'; + +export interface SettingsTab { + id: string; + label: string; +} + +const THEMES: { value: ThemePreference; label: string; hint: string }[] = [ + { value: 'system', label: 'System', hint: 'Follows the hub, or your system without it' }, + { value: 'light', label: 'Light', hint: 'Always light' }, + { value: 'dark', label: 'Dark', hint: 'Always dark' }, +]; + +/** Per-viewer settings: theme, tab bar layout and the popup's passive mode. */ +@Component({ + selector: 'app-settings', + imports: [TabIcon], + template: ` +

+ Settings for this panel in this browser. They are saved in the browser's local storage, so + every panel on this address uses them. +

+ +
+

Theme

+
+ @for (option of themes; track option.value) { + + } +
+
+ +
+
+

Tabs

+ +
+

+ Pinned tabs come first in the tab bar. Hidden tabs stay reachable from the command palette + and from links. Use the arrow buttons, or Alt with ↑ and + ↓ on a row, to change the order. +

+
    + @for (tab of orderedTabs(); track tab.id; let first = $first, last = $last) { +
  1. + + + {{ tab.label }} + @if (isPinned(tab.id)) { + Pinned + } + + + + + + + +
  2. + } +
+
+ +
+

Popup on the page

+ +

+ @if (hotkey(); as shortcut) { + Press {{ shortcut }} on your app's page to open or close the panel. The button + shows while the panel is open. The popup on the page saves this setting. + } @else { + The popup shortcut is turned off in the devtools config (popup.hotkey), so + the button always shows. + } +

+
+ +
+

Keyboard shortcuts

+ + + + + + + + + + + + + + + + + + +
+ Keyboard shortcuts +
ShortcutAction
+ {{ paletteShortcut() }} + Open the command palette in the panel
+ @if (hotkey(); as shortcut) { + {{ shortcut }} + } @else { + Off + } + Open or close the popup on your app's page
+
+

{{ announcement() }}

+ `, + styles: ` + @use 'mixins' as m; + + :host { + display: block; + max-width: 760px; + } + .intro { + max-width: 720px; + margin: 0 0 16px; + color: var(--text-2); + line-height: 1.5; + } + .card { + margin: 0 0 16px; + padding: 16px; + @include m.panel(1); + } + .card-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + } + h2 { + margin: 0 0 10px; + color: var(--text-strong); + font-size: 15px; + font-weight: 650; + } + .card-head h2 { + margin: 0; + } + .hint { + margin: 8px 0 12px; + color: var(--text-2); + font-size: 12px; + line-height: 1.5; + } + .themes { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 8px; + } + .theme { + position: relative; + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 8px; + align-items: center; + padding: 10px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--bg); + cursor: pointer; + .hint { + grid-column: 2; + margin: 0; + } + &.checked { + border-color: var(--accent-line); + background: var(--accent-soft); + } + &:has(input:focus-visible) { + @include m.focus-ring(2px); + } + } + .theme input { + margin: 0; + accent-color: var(--accent); + } + .theme-label { + color: var(--text-strong); + font-weight: 600; + } + .tab-list { + margin: 0; + padding: 0; + list-style: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + } + .tab-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + min-height: 44px; + padding: 4px 8px 4px 12px; + color: var(--text); + & + & { + border-top: 1px solid var(--border); + } + app-tab-icon { + color: var(--text-3); + } + } + .tab-row.hidden-tab .tab-label { + color: var(--text-2); + } + .tab-label { + display: inline-flex; + flex: 1 1 120px; + align-items: center; + gap: 8px; + min-width: 0; + } + .tag { + padding: 1px 8px; + border: 1px solid; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + @include m.tint(accent); + } + .action { + display: inline-flex; + align-items: center; + height: var(--control-h); + padding: 0 12px; + 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; + &.small { + height: 28px; + padding: 0 10px; + font-size: 12px; + } + &[aria-pressed='true'] { + border-color: var(--accent-line); + background: var(--accent-soft); + color: var(--text-strong); + } + &:hover:not(:disabled) { + background: var(--surface-3); + } + &:disabled { + color: var(--text-3); + cursor: default; + } + &:focus-visible { + @include m.focus-ring; + } + } + .check { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-2); + font-size: 12px; + cursor: pointer; + input { + margin: 0; + accent-color: var(--accent); + } + input:focus-visible { + @include m.focus-ring; + } + &.wide { + color: var(--text); + font-size: 13px; + } + } + .moves { + display: inline-flex; + gap: 4px; + margin-left: auto; + } + .icon { + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + cursor: pointer; + svg { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 2; + } + &:hover:not(:disabled) { + background: var(--surface-3); + } + &:disabled { + color: var(--text-3); + opacity: 0.6; + cursor: default; + } + &:focus-visible { + @include m.focus-ring; + } + } + kbd { + padding: 1px 6px; + border: 1px solid var(--border-strong); + border-radius: 4px; + background: var(--surface-2); + color: var(--text); + font-family: var(--font-mono); + font-size: 11px; + } + code { + font-family: var(--font-mono); + } + .keys { + width: 100%; + border-collapse: collapse; + font-size: 13px; + th { + padding: 6px 8px; + text-align: left; + @include m.label; + } + td { + padding: 8px; + border-top: 1px solid var(--border); + color: var(--text); + } + } + `, +}) +export class Settings { + /** Every tab the panel can show here, in the default order. */ + readonly tabs = input([]); + readonly layout = model(DEFAULT_TAB_LAYOUT); + readonly theme = model('system'); + readonly passive = model(false); + /** The popup shortcut as the platform writes it, or `false` when it is off. */ + readonly hotkey = input(false); + readonly paletteShortcut = input('Ctrl+K'); + + protected readonly themes = THEMES; + protected readonly announcement = signal(''); + private readonly host = inject>(ElementRef); + private readonly injector = inject(Injector); + + private readonly ids = computed(() => this.tabs().map((tab) => tab.id)); + readonly orderedTabs = computed(() => { + const byId = new Map(this.tabs().map((tab) => [tab.id, tab])); + return orderedTabIds(this.ids(), this.layout()).map((id) => byId.get(id)!); + }); + protected readonly isDefaultLayout = computed(() => { + const { order, hidden, pinned } = this.layout(); + const shown = orderedTabIds(this.ids(), this.layout()); + return ( + !hidden.length && + !pinned.length && + (!order.length || shown.every((id, index) => id === this.ids()[index])) + ); + }); + + isPinned(id: string) { + return this.layout().pinned.includes(id); + } + + isHidden(id: string) { + return this.layout().hidden.includes(id) && !this.isPinned(id); + } + + canMove(id: string, delta: -1 | 1) { + return moveTab(this.ids(), this.layout(), id, delta) !== this.layout(); + } + + move(id: string, delta: -1 | 1) { + const next = moveTab(this.ids(), this.layout(), id, delta); + if (next === this.layout()) return; + this.layout.set(next); + const order = orderedTabIds(this.ids(), next); + const label = this.label(id); + this.say(`${label} moved to position ${order.indexOf(id) + 1} of ${order.length}.`); + // Keep focus on the moved row, on the same button, or the other one at an end. + afterNextRender( + () => { + const row = [...this.host.nativeElement.querySelectorAll('.tab-row')].find( + (candidate) => candidate.dataset['tab'] === id, + ); + const wanted = row?.querySelector( + `[data-move="${delta < 0 ? 'up' : 'down'}"]`, + ); + const other = row?.querySelector( + `[data-move="${delta < 0 ? 'down' : 'up'}"]`, + ); + (wanted && !wanted.disabled ? wanted : other)?.focus(); + }, + { injector: this.injector }, + ); + } + + onRowKey(event: KeyboardEvent, id: string) { + if (!event.altKey || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return; + event.preventDefault(); + this.move(id, event.key === 'ArrowUp' ? -1 : 1); + } + + togglePinned(id: string) { + const pinned = !this.isPinned(id); + this.layout.set(setTabPinned(this.ids(), this.layout(), id, pinned)); + this.say(pinned ? `${this.label(id)} pinned.` : `${this.label(id)} unpinned.`); + } + + setShown(id: string, shown: boolean) { + this.layout.set(setTabHidden(this.layout(), id, !shown)); + this.say(shown ? `${this.label(id)} shown.` : `${this.label(id)} hidden.`); + } + + resetTabs() { + this.layout.set(DEFAULT_TAB_LAYOUT); + this.say('Tabs reset to the default order.'); + } + + private label(id: string) { + return this.tabs().find((tab) => tab.id === id)?.label ?? id; + } + + private say(message: string) { + this.announcement.update((current) => (current === message ? `${message} ` : message)); + } +} diff --git a/app/src/pages/tab-icon.ts b/app/src/pages/tab-icon.ts index 53fdba4b..50079c0d 100644 --- a/app/src/pages/tab-icon.ts +++ b/app/src/pages/tab-icon.ts @@ -63,6 +63,17 @@ import { Component, input } from '@angular/core'; } + @case ('settings') { + + + } + @case ('errors') { + + + + } @case ('analog') { } diff --git a/app/src/palette-filter.ts b/app/src/palette-filter.ts new file mode 100644 index 00000000..67e252f0 --- /dev/null +++ b/app/src/palette-filter.ts @@ -0,0 +1,57 @@ +/** A command as the palette lists it. */ +export interface PaletteCommand { + id: string; + title: string; + group: string; + keywords?: string; + /** A shortcut that also runs it, shown beside the title. */ + shortcut?: string; +} + +/** + * How well `query` matches `text` as a subsequence, or `-1` when it doesn't. + * Consecutive letters and letters at the start of a word score higher, so + * `gtsig` finds "Go to Signals" and `sig` puts it before "Design". + */ +export function fuzzyScore(text: string, query: string): number { + const haystack = text.toLowerCase(); + const needle = query.toLowerCase().replace(/\s+/g, ''); + if (!needle) return 0; + let score = 0; + let from = 0; + let previous = -2; + for (const char of needle) { + const index = haystack.indexOf(char, from); + if (index < 0) return -1; + const wordStart = index === 0 || /[\s:&/-]/.test(haystack[index - 1]); + score += 1; + if (index === previous + 1) score += 3; + if (wordStart) score += 4; + previous = index; + from = index + 1; + } + // Shorter texts with the same letters read as the closer match. + return score - haystack.length / 100; +} + +/** + * The commands that match `query`, best first. A title match beats one in + * the group or keywords. An empty query keeps the given order. + */ +export function filterCommands( + commands: readonly T[], + query: string, +): T[] { + const trimmed = query.trim(); + if (!trimmed) return [...commands]; + return commands + .map((command, index) => { + const title = fuzzyScore(command.title, trimmed); + const extra = fuzzyScore(`${command.group} ${command.keywords ?? ''}`, trimmed); + const score = Math.max(title >= 0 ? title + 10 : -1, extra); + return { command, index, score }; + }) + .filter((entry) => entry.score >= 0) + .sort((a, b) => b.score - a.score || a.index - b.index) + .map((entry) => entry.command); +} diff --git a/app/src/shell-prefs.ts b/app/src/shell-prefs.ts new file mode 100644 index 00000000..c0c274a5 --- /dev/null +++ b/app/src/shell-prefs.ts @@ -0,0 +1,187 @@ +const PREF_KEY = 'pangular-pref'; + +/** The theme the viewer picked in Settings; `system` follows the hub or the system. */ +export type ThemePreference = 'system' | 'light' | 'dark'; + +/** + * How the viewer arranged the tab bar in Settings. `order` lists tab ids in + * the order they chose; tabs it doesn't name keep their default place after + * the ones it does. Pinned tabs come first, and a pinned tab is never hidden. + */ +export interface TabLayout { + order: string[]; + hidden: string[]; + pinned: string[]; +} + +export const DEFAULT_TAB_LAYOUT: TabLayout = { order: [], hidden: [], pinned: [] }; + +function read(name: string): string | null { + try { + return localStorage.getItem(`${PREF_KEY}:${name}`); + } catch { + return null; + } +} + +function write(name: string, value: string | null) { + try { + if (value === null) localStorage.removeItem(`${PREF_KEY}:${name}`); + else localStorage.setItem(`${PREF_KEY}:${name}`, value); + } catch { + // Blocked storage only forgets the preference on the next reload. + } +} + +export function storedThemePreference(): ThemePreference { + const value = read('theme'); + return value === 'light' || value === 'dark' ? value : 'system'; +} + +export function storeThemePreference(value: ThemePreference) { + write('theme', value === 'system' ? null : value); +} + +function ids(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const valid = value.filter( + (id): id is string => typeof id === 'string' && id.length > 0 && id.length <= 40, + ); + return [...new Set(valid)].slice(0, 50); +} + +export function storedTabLayout(): TabLayout { + const raw = read('tabs'); + if (!raw) return DEFAULT_TAB_LAYOUT; + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return DEFAULT_TAB_LAYOUT; + // Anything in storage may be stale or hand edited, so each list is checked. + const saved = parsed as Record; + return { + order: ids(saved['order']), + hidden: ids(saved['hidden']), + pinned: ids(saved['pinned']), + }; + } catch { + return DEFAULT_TAB_LAYOUT; + } +} + +export function storeTabLayout(layout: TabLayout) { + const empty = !layout.order.length && !layout.hidden.length && !layout.pinned.length; + write('tabs', empty ? null : JSON.stringify(layout)); +} + +/** Passive mode, as the panel last set it. The popup on the page keeps its own copy. */ +export function storedPassive(): boolean { + return read('passive') === 'true'; +} + +export function storePassive(value: boolean) { + write('passive', value ? 'true' : null); +} + +/** `ids` in the viewer's order: pinned first, then the rest, each in `layout.order`. */ +export function orderedTabIds(all: readonly string[], layout: TabLayout): string[] { + const rank = (id: string) => { + const index = layout.order.indexOf(id); + return index < 0 ? layout.order.length + all.indexOf(id) : index; + }; + const sorted = [...all].sort((a, b) => rank(a) - rank(b)); + const pinned = sorted.filter((id) => layout.pinned.includes(id)); + return [...pinned, ...sorted.filter((id) => !layout.pinned.includes(id))]; +} + +/** + * The tabs of the bar in the viewer's order, without the hidden ones. `keep` + * stays even when hidden, so the open tab is always in the bar. + */ +export function arrangeTabs( + tabs: readonly T[], + layout: TabLayout, + keep?: string, +): T[] { + const byId = new Map(tabs.map((tab) => [tab.id, tab])); + return orderedTabIds( + tabs.map((tab) => tab.id), + layout, + ) + .filter((id) => id === keep || layout.pinned.includes(id) || !layout.hidden.includes(id)) + .map((id) => byId.get(id)!); +} + +/** + * Moves a tab one place up (`-1`) or down (`1`) among `all`, as Settings + * shows them. A tab stays inside its section: pinned tabs move among the + * pinned ones, the others among the others. + */ +export function moveTab( + all: readonly string[], + layout: TabLayout, + id: string, + delta: -1 | 1, +): TabLayout { + const order = orderedTabIds(all, layout); + const index = order.indexOf(id); + const target = index + delta; + if (index < 0 || target < 0 || target >= order.length) return layout; + const pinned = layout.pinned.includes(id); + if (layout.pinned.includes(order[target]) !== pinned) return layout; + const next = [...order]; + [next[index], next[target]] = [next[target], next[index]]; + return { ...layout, order: next }; +} + +export function setTabHidden(layout: TabLayout, id: string, hidden: boolean): TabLayout { + const rest = layout.hidden.filter((other) => other !== id); + return { + ...layout, + hidden: hidden ? [...rest, id] : rest, + pinned: hidden ? layout.pinned.filter((other) => other !== id) : layout.pinned, + }; +} + +export function setTabPinned( + all: readonly string[], + layout: TabLayout, + id: string, + pinned: boolean, +): TabLayout { + const rest = layout.pinned.filter((other) => other !== id); + // Keep today's visible order, so pinning moves only the pinned tab. + const order = orderedTabIds(all, layout); + return { + order, + pinned: pinned ? [...rest, id] : rest, + hidden: pinned ? layout.hidden.filter((other) => other !== id) : layout.hidden, + }; +} + +/** Whether the viewer is on macOS or iOS, where `Mod` shortcuts use Cmd. */ +export function isMacPlatform(): boolean { + try { + const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; + return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform ?? nav.platform ?? ''); + } catch { + return false; + } +} + +/** + * Whether the panel runs in the popup on the app's page, which has dock + * modes and passive mode. Only frames on the same origin can tell. + */ +export function inPopup(win: Window = window): boolean { + try { + for (let current = win; current !== current.parent; current = current.parent) { + const root = current.frameElement?.getRootNode(); + if (root && 'host' in root && (root.host as Element | null)?.id === 'pangular-popup-root') { + return true; + } + } + } catch { + // a frame on another origin; the popup can't be named + } + return false; +} diff --git a/app/src/theme.service.ts b/app/src/theme.service.ts index a2aca7af..87d3e0a7 100644 --- a/app/src/theme.service.ts +++ b/app/src/theme.service.ts @@ -1,10 +1,17 @@ import { DestroyRef, Service, effect, inject, signal } from '@angular/core'; +import { storeThemePreference, storedThemePreference, type ThemePreference } from './shell-prefs'; export type Theme = 'dark' | 'light'; @Service() export class ThemeService { readonly current = signal('dark'); + /** What the viewer picked in Settings. `system` follows the hub, the extension or the system. */ + readonly preference = signal(storedThemePreference()); + /** The theme the hub, the extension, a `data-theme` attribute or the system asks for. */ + private source: Theme = 'dark'; + /** Whether the source set `data-theme`, which the system preference never does. */ + private sourceAttr = false; constructor() { const destroyRef = inject(DestroyRef); @@ -13,19 +20,21 @@ export class ThemeService { let pinned = attr === 'light' || attr === 'dark'; if (attr === 'light' || attr === 'dark') { - this.current.set(attr); + this.fromSource(attr, true); } else if (colorRoot) { pinned = true; - this.apply(hubTheme(colorRoot)); + this.fromSource(hubTheme(colorRoot), true); } else { const query = prefersLight(); if (query) { - this.current.set(query.matches ? 'light' : 'dark'); + this.fromSource(query.matches ? 'light' : 'dark', false); const onChange = (e: MediaQueryListEvent) => { - if (!pinned) this.current.set(e.matches ? 'light' : 'dark'); + if (!pinned) this.fromSource(e.matches ? 'light' : 'dark', false); }; query.addEventListener('change', onChange); destroyRef.onDestroy(() => query.removeEventListener('change', onChange)); + } else { + this.resolve(); } } @@ -50,13 +59,13 @@ export class ThemeService { const msg = e.data as { type?: unknown; theme?: unknown } | null; if (msg?.type !== 'pangular:theme-change') return; pinned = true; - this.apply(msg.theme === 'dark' ? 'dark' : 'light'); + this.fromSource(msg.theme === 'dark' ? 'dark' : 'light', true); }; window.addEventListener('message', onMessage); let observer: MutationObserver | undefined; if (colorRoot) { - observer = new MutationObserver(() => this.apply(hubTheme(colorRoot))); + observer = new MutationObserver(() => this.fromSource(hubTheme(colorRoot), true)); observer.observe(colorRoot, { attributes: true, attributeFilter: ['class'] }); } @@ -67,9 +76,28 @@ export class ThemeService { }); } - private apply(theme: Theme) { + /** Picks light, dark or the source's theme, and keeps the choice for the next visit. */ + setPreference(preference: ThemePreference) { + this.preference.set(preference); + storeThemePreference(preference); + this.resolve(); + } + + private fromSource(theme: Theme, attr: boolean) { + this.source = theme; + this.sourceAttr = attr; + this.resolve(); + } + + private resolve() { + const preference = this.preference(); + const theme = preference === 'system' ? this.source : preference; this.current.set(theme); - document.documentElement.dataset['theme'] = theme; + const root = document.documentElement; + // The system preference reaches the styles through the media query, so + // `data-theme` is only set when something picked a theme. + if (preference !== 'system' || this.sourceAttr) root.dataset['theme'] = theme; + else delete root.dataset['theme']; } } diff --git a/app/src/types/component-command.types.ts b/app/src/types/component-command.types.ts new file mode 100644 index 00000000..1c201a06 --- /dev/null +++ b/app/src/types/component-command.types.ts @@ -0,0 +1,7 @@ +/** + * An action for the Components tab to run, from the command palette or the + * hub's. A new object each time, so running the same command again counts. + */ +export interface ComponentCommand { + kind: 'pick' | 'cd-start' | 'cd-stop' | 'cd-clear'; +} diff --git a/app/src/types/tab.types.ts b/app/src/types/tab.types.ts index bf2c8b01..340cad06 100644 --- a/app/src/types/tab.types.ts +++ b/app/src/types/tab.types.ts @@ -9,7 +9,9 @@ export type Tab = | 'forms' | 'network' | 'analog' - | 'capacitor'; + | 'capacitor' + | 'settings' + | 'errors'; export type Tabs = { id: Tab; diff --git a/app/src/ui/command-palette.ts b/app/src/ui/command-palette.ts new file mode 100644 index 00000000..3bc421a3 --- /dev/null +++ b/app/src/ui/command-palette.ts @@ -0,0 +1,324 @@ +import { + Component, + ElementRef, + Injector, + afterNextRender, + computed, + effect, + inject, + input, + linkedSignal, + model, + output, + signal, + untracked, + viewChild, +} from '@angular/core'; +import { filterCommands, type PaletteCommand } from '../palette-filter'; + +let nextId = 0; + +/** + * The panel's own command palette: a modal dialog with a combobox that + * filters a listbox of commands. Arrow keys move, Enter runs, Escape closes, + * and focus goes back to where it was when the palette opened. + */ +@Component({ + selector: 'app-command-palette', + template: ` + @if (open()) { + + + } + `, + styles: ` + @use 'mixins' as m; + + .backdrop { + position: fixed; + inset: 0; + z-index: 100; + background: color-mix(in srgb, var(--bg) 55%, transparent); + backdrop-filter: blur(2px); + } + .palette { + position: fixed; + top: min(12vh, 96px); + right: 0; + left: 0; + z-index: 101; + display: flex; + flex-direction: column; + width: min(560px, calc(100vw - 32px)); + max-height: min(70vh, 520px); + margin-inline: auto; + @include m.panel(1); + box-shadow: 0 24px 64px -24px rgba(0, 0, 0, 0.6); + overflow: hidden; + @include m.enter(0.18s); + } + .field { + position: relative; + flex-shrink: 0; + padding: 10px; + border-bottom: 1px solid var(--border); + } + .search-icon { + position: absolute; + top: 50%; + left: 22px; + width: 16px; + height: 16px; + transform: translateY(-50%); + fill: none; + stroke: var(--text-3); + stroke-width: 2; + pointer-events: none; + } + input { + box-sizing: border-box; + width: 100%; + height: var(--control-h); + padding: 0 12px 0 36px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text-strong); + font: inherit; + font-size: 14px; + &::placeholder { + color: var(--text-3); + } + &:focus { + @include m.field-focus; + } + } + .list { + flex: 1; + min-height: 0; + margin: 0; + padding: 6px; + overflow-y: auto; + list-style: none; + &[hidden] { + display: none; + } + } + li { + display: flex; + align-items: center; + gap: 10px; + min-height: var(--control-h); + padding: 0 10px; + border-radius: var(--radius-sm); + color: var(--text); + cursor: pointer; + } + li.active { + background: var(--accent-soft); + color: var(--text-strong); + box-shadow: inset 2px 0 0 var(--accent); + } + .title { + flex: 1; + @include m.truncate; + } + .group { + flex-shrink: 0; + color: var(--text-2); + font-size: 12px; + } + kbd { + padding: 1px 6px; + border: 1px solid var(--border-strong); + border-radius: 4px; + background: var(--surface-2); + color: var(--text); + font-family: var(--font-mono); + font-size: 11px; + } + .empty { + margin: 0; + padding: 16px; + color: var(--text-2); + } + .help { + flex-shrink: 0; + margin: 0; + padding: 8px 12px; + border-top: 1px solid var(--border); + color: var(--text-2); + font-size: 12px; + } + @media (max-width: 480px) { + .group, + .help { + display: none; + } + } + `, +}) +export class CommandPalette { + readonly commands = input([]); + readonly open = model(false); + /** The id of the command picked, sent after the palette closed and gave focus back. */ + readonly picked = output(); + + protected readonly uid = `cp${nextId++}`; + readonly query = signal(''); + readonly results = computed(() => filterCommands(this.commands(), this.query())); + readonly active = linkedSignal(() => (this.results().length ? 0 : -1)); + protected readonly activeId = computed(() => + this.active() >= 0 ? `${this.uid}-opt-${this.active()}` : null, + ); + protected readonly status = computed(() => { + const count = this.results().length; + if (!count) return 'No commands match.'; + return `${count} ${count === 1 ? 'command' : 'commands'}.`; + }); + + private readonly input = viewChild>('input'); + private readonly injector = inject(Injector); + private returnFocus: HTMLElement | null = null; + + constructor() { + effect(() => { + if (!this.open()) return; + untracked(() => { + const active = document.activeElement; + this.returnFocus = + active instanceof HTMLElement && active !== document.body ? active : null; + this.query.set(''); + afterNextRender(() => this.input()?.nativeElement.focus(), { injector: this.injector }); + }); + }); + } + + close(then?: () => void) { + if (!this.open()) return; + this.open.set(false); + const back = this.returnFocus; + this.returnFocus = null; + afterNextRender( + () => { + if (back?.isConnected) back.focus(); + then?.(); + }, + { injector: this.injector }, + ); + } + + run(command: PaletteCommand) { + this.close(() => this.picked.emit(command.id)); + } + + onKey(event: KeyboardEvent) { + const count = this.results().length; + switch (event.key) { + case 'ArrowDown': + case 'ArrowUp': { + event.preventDefault(); + if (!count) return; + const step = event.key === 'ArrowDown' ? 1 : -1; + this.active.set((this.active() + step + count) % count); + this.reveal(); + return; + } + case 'PageDown': + case 'PageUp': + event.preventDefault(); + if (!count) return; + this.active.set(event.key === 'PageDown' ? count - 1 : 0); + this.reveal(); + return; + case 'Enter': { + event.preventDefault(); + const command = this.results()[this.active()]; + if (command) this.run(command); + return; + } + case 'Escape': + event.preventDefault(); + // The popup closes on Escape too; this one belongs to the palette. + event.stopPropagation(); + this.close(); + return; + case 'Tab': + // The input is the only stop in the dialog, so focus stays in it. + event.preventDefault(); + return; + } + } + + private reveal() { + afterNextRender( + () => { + const id = this.activeId(); + if (id) document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }); + }, + { injector: this.injector }, + ); + } +} diff --git a/apps/docs/src/content/agents/resources.md b/apps/docs/src/content/agents/resources.md index b9ef96ee..a68346db 100644 --- a/apps/docs/src/content/agents/resources.md +++ b/apps/docs/src/content/agents/resources.md @@ -94,6 +94,7 @@ This table covers the data that has no resource of its own. | `pangular:http-payloads` | The TransferState payload of each page, by page id | | `pangular:change-detection` | Change detection recordings, by page id | | `pangular:pipe-usage` | Live pipe instances and recorded calls | +| `pangular:errors` | Error groups and console calls, by page id | | `pangular:analog` | Analog page data and the server call log | | `pangular:routes` | Declared but not filled. Use `get-routes` or `list-routes` instead. | diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 01fd217a..fd52d913 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -4,7 +4,7 @@ description: Every agent tool the devtools expose, grouped by inspector, with wh --- - Sixty-seven tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel. + Seventy-one tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel. # Tools @@ -45,6 +45,18 @@ Lists the tabs, [Angular Native](../getting-started/angular-native.md) apps, [Na The URL comes from the HTTP or router report. A page that sends neither (no router, and no [HTTP setup](../getting-started/express.md)) still shows the URL its component tree reports, which also stands in when the other reports are more than 15 seconds older. A tab in the background stops reporting until it is shown again, so its last report is marked `(background)`: an old time there means the data is the last known state, not that the page is gone. +### open-in-panel + +Gives the user a link that opens the panel on one tab with one item selected, and switches the panels that are open to it. Reads: page. Use it to point the user at what you found. + +| Argument | Required | What it is | +| -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `tab` | Yes | The tab: `dashboard`, `components`, `routes`, `signals`, `injectors`, `store`, `forms`, `pipes`, `network` (SSR & HTTP), `analog` or `capacitor`. Only tabs that are on count. | +| `id` | No | The item to select: a component id on `components`, the id of the component whose element injector to show on `injectors`, a form id on `forms`. Other tabs ignore it. | +| `page` | No | The page the item belongs to, from `list-pages`. | + +The answer is a full URL, such as `http://localhost:4200/__devframes/pangular/#tab=components&id=12&page=k3x9`, when the hub serves the panel and a page has reported its address. Otherwise it is the hash to add to the panel's address. Each panel that is open and not in a background tab opens the link at once. The tool is `read`: it changes what the panel shows, not the app. See [Deep links](../getting-started/popup-and-hub.md#deep-links). + ### Action tools Every tool has one of three safety tiers. The server sends the tier to your client as the tool's read-only and destructive hints. @@ -535,6 +547,18 @@ These tools read what a Capacitor app reported: its platform, its plugins and th `plugin` keeps calls to one plugin, ignoring case. `failed` keeps calls that failed. `slowMs` keeps calls that took at least that many milliseconds, counting a pending call's time so far. `limit` defaults to 30 (at most 200). Without `page`, both tools answer for the most recent page with a native bridge, so a desktop tab of the same app doesn't hide the device, and they name the other pages that report. A long answer is cut at about 20,000 characters with a note on how to narrow it, and a plugin lists its first 20 methods. Native calls are logged on iOS and Android. Calls a web implementation answers are logged only when the app loads `@pangular-inspector/devtools/capacitor-hook` before `@capacitor/core`, and the answer says so when it doesn't. Arguments, results and errors are masked with the server's redaction too. +## Errors + +These tools read what a page captured: errors from Angular's `ErrorHandler`, uncaught errors, unhandled promise rejections and console calls. See [Errors](../inspectors/errors.md). Reads: page. + +| Tool | What it answers | Arguments | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | +| `list-errors` | Error groups, most recent first: id, count, when last seen, the Angular error code linked to its reference page, the message, the owning component with its instance id, and the source (`handler`, `window` or `promise`). | `code`, `limit` | +| `explain-error` | One error by `id`: message, count, sources, the owning component, the stack of the first occurrence, and for an Angular code its reference page and a short known cause. With `code` instead, the reference entry and the errors that carry it. | `id`, `code` | +| `list-console` | Console calls, newest first: when, level, the text of the arguments and the Angular code it mentions. Calls repeated in a row show once with a count. | `level`, `search`, `limit` | + +`code` accepts `NG0100`, `ng100` or `NG01203`. `explain-error` says so when Angular's error reference has no page for a code, and gives no known cause then. Pass the owner's instance id to `inspect-component` for its inputs and state. `list-errors` takes `limit` up to 100 (default 30), and `list-console` up to 200 (default 50). Messages, stacks and console text are masked like other live values. + ## Analog These tools cover *Analog apps. Most read your source. Some also read what the Vite plugin recorded. diff --git a/apps/docs/src/content/getting-started/command-palette.md b/apps/docs/src/content/getting-started/command-palette.md new file mode 100644 index 00000000..191b402d --- /dev/null +++ b/apps/docs/src/content/getting-started/command-palette.md @@ -0,0 +1,63 @@ +--- +title: Command palette +description: Open any tab and run the main panel actions from the keyboard with the command palette. +--- + + + Press Cmd+K or Ctrl+K in the panel to jump to a tab or run an action without the mouse. + + +# Command palette + +The command palette lists a command for every tab and for the main actions of the panel. Type a few letters to filter it, then press Enter. + +## Open it + +| Where you are | What Cmd+K or Ctrl+K opens | +| ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| The panel in the [hub](./popup-and-hub.md#the-hub), in the popup or at `/__devframes/` | The hub's palette, which lists the Pangular commands next to the commands of other tools | +| The panel without the hub: the standalone CLI, `initDevframe()` or the [Chrome extension](./chrome-extension.md) | The panel's own palette | + +Use Cmd on macOS and Ctrl elsewhere. The palette button in the panel header opens it too. + +## Commands + +| Command | What it does | +| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| **Go to** a tab | Opens the tab. Hidden tabs are listed too. Tabs that are turned off or have nothing to show are not. | +| **Open settings** | Opens [Settings](./settings.md). | +| **Pick component on page** | Opens **Components** and starts **Pick component on page**. | +| **Start recording change detection** | Opens **Components** and starts the change detection recording. | +| **Stop recording change detection** | Opens **Components** and stops the recording. | +| **Clear change detection recording** | Opens **Components** and clears the recorded cycles. | +| **Theme: follow the system**, **Theme: light**, **Theme: dark** | Sets the panel theme, like the [Settings](./settings.md#theme) page. | +| **Popup: float**, **Popup: dock to the bottom**, **Popup: dock to the right** | Changes the [dock mode](./popup-and-hub.md#dock-modes). Only in the popup's own palette. | + +The **Components** commands need the Components inspector on and a page connected. Without a live page the tab shows why, and the command does nothing. + +## Filter and run + +The filter matches the letters you type in order, anywhere in the title, so `gtsig` finds **Go to Signals**. Letters at the start of a word and letters next to each other rank higher. It also matches the group (**Go to**, **Actions**, **Theme**, **Popup**) and a few extra words, such as `profiler` for the recording commands. + +| Key | Action | +| ------------------------------------------- | --------------------------------------------- | +| ↑ and ↓ | Move through the list. It wraps at both ends. | +| Page Up and Page Down | Jump to the first or the last command. | +| Enter | Run the highlighted command. | +| Escape | Close the palette without running anything. | + +The palette is a modal dialog. The filter box is a combobox that controls the list, and screen readers hear the highlighted command and how many commands match. When the palette closes, focus goes back to where it was, then the command runs. + +## In the hub + +In the hub, the Pangular commands are in the hub's command palette, in the **Pangular Inspector** category. **Pangular: open a tab** groups the tab commands. A **Go to** or **Components** command first opens the **All tools** dock, so the panel is on screen. + +The server registers the commands through Devframe's command registry when the hub starts, and each one asks the open panels to run it. A panel in a background browser tab ignores it. If you have the hub open in two windows, both run the command. The dock mode commands stay out of the hub's palette, because the hub has its own. + +## Where to next + + + + + + diff --git a/apps/docs/src/content/getting-started/configuration.md b/apps/docs/src/content/getting-started/configuration.md index e7e1c7d0..cec10c52 100644 --- a/apps/docs/src/content/getting-started/configuration.md +++ b/apps/docs/src/content/getting-started/configuration.md @@ -99,7 +99,8 @@ type PangularInspector = | 'pipes' | 'http' | 'analog' - | 'capacitor'; + | 'capacitor' + | 'errors'; type PangularAction = 'forms' | 'router' | 'ngrx' | 'http' | 'analog' | 'components'; @@ -117,6 +118,7 @@ interface PangularConfig { cdCycles?: number; capacitorCalls?: number; }; + popup?: {hotkey?: string | false}; } ``` @@ -179,6 +181,33 @@ When a timeline reaches its limit, the oldest entries are dropped. The timeline On Angular 20 and later, the page reports about 250 ms after Angular runs change detection, plus a heartbeat every 4 seconds. `refreshMs` doesn't change that. The page polls every `refreshMs` instead when it can't follow change detection, such as until the app bootstraps. The Analog inspector also reads the page every `refreshMs`. At any value, the page sends its data at least every 8 seconds, even when nothing changed, so the server never drops a page that is still open. +### Popup + +| Option | Default | What it does | +| -------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `popup.hotkey` | `Alt+Shift+P` | The shortcut that opens and closes the [popup](./popup-and-hub.md#keyboard-and-mouse) on your app's page. `false` turns it off. | + +Write the shortcut as modifiers and one key joined with `+`, such as `Mod+Shift+K` or `Ctrl+Alt+D`: + +| Part | Values | +| --------- | --------------------------------------------------------------------------------------------------------------- | +| Modifiers | `Mod` (Cmd on macOS, Ctrl elsewhere), `Ctrl`, `Meta` or `Cmd`, `Alt` or `Option`, `Shift` | +| Key | One letter, one digit, or `F1` to `F12` | + +It needs at least one modifier other than `Shift`, so typing in your app never sets it off. Letters and digits match by key position, so an Option combination that types another character on macOS still works. A value that is not a shortcut prints a warning and keeps `Alt+Shift+P`. + +```ts {6} +// src/server.ts +import {initPangularHub} from '@pangular-inspector/devtools/hub'; + +const devtools = initPangularHub({ + ws: false, + popup: {hotkey: 'Mod+Shift+K'}, +}); +``` + +The overlay reads the option from the server when it connects, so the default works until then. With `false`, [passive mode](./settings.md#passive-mode) has no effect and the floating button always shows. + ## Check the active configuration The server checks the options when it starts. An unknown key, a value of the wrong type or a limit outside its bounds prints one `[pangular]` warning in the terminal that lists each problem and the value used instead. An unknown key names the closest known key: @@ -202,6 +231,7 @@ The [Dashboard](../inspectors/dashboard.md#configuration-block) has a **Configur | **Extra secret names** | `redaction.secretNames`. | | **Unmasked names** | `redaction.unmask`. | | **Limits** | Limits that differ from the defaults. | +| **Popup shortcut** | `popup.hotkey`, or **Off** when it is `false`. | ## Where to next diff --git a/apps/docs/src/content/getting-started/popup-and-hub.md b/apps/docs/src/content/getting-started/popup-and-hub.md index de6a3f10..328c66de 100644 --- a/apps/docs/src/content/getting-started/popup-and-hub.md +++ b/apps/docs/src/content/getting-started/popup-and-hub.md @@ -90,6 +90,8 @@ The button and the panel sit in the browser top layer, like a ``, a popo | Action | How | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Open the panel | Click the button, or focus it and press Enter or Space; from the keyboard, focus moves into the panel | +| Open or close the panel | Press Alt+Shift+P anywhere on the page, or the shortcut in [`popup.hotkey`](./configuration.md#popup). Closing gives focus back to where it was | +| Run a command | Cmd+K or Ctrl+K in the panel opens the [command palette](./command-palette.md) | | Move between tabs | Arrow keys, Home and End in the tab bar, then Enter to open a tab | | Move through a long list | ↑, ↓, Home and End in the SSR & HTTP timeline, the Store changes and events, and the Signals list. Each list is one Tab stop | | Close the panel | Escape, outside a search box with text | @@ -103,11 +105,15 @@ In a search box that has text, Escape clears the box and leaves the p ### Saved layout -The panel saves its position, size and dock mode in `localStorage` under `pangular-popup`, so it keeps its layout across reloads. Clear that key to reset it. +The panel saves its position, size, dock mode and [passive mode](./settings.md#passive-mode) in `localStorage` under `pangular-popup`, so it keeps its layout across reloads. Clear that key to reset it. + +### Passive mode + +In passive mode the floating button stays hidden until you press the popup shortcut. Turn it on in [Settings](./settings.md#passive-mode). ### Theme -The panel follows the color scheme of the hub, which follows your system unless you pick light or dark in the hub. Without the hub, the panel follows your system. The popup frame around the panel switches with it and saves the last theme with the layout. It takes the theme only from the panel's own frame, so other frames on your page cannot change it. +The panel follows the color scheme of the hub, which follows your system unless you pick light or dark in the hub. Without the hub, the panel follows your system. To keep the panel light or dark whatever the hub does, pick it in [Settings](./settings.md#theme). The popup frame around the panel switches with it and saves the last theme with the layout. It takes the theme only from the panel's own frame, so other frames on your page cannot change it. ## The hub @@ -188,6 +194,19 @@ The URL hash selects a tab. Open `/__devframes/pangular/#tab=signals` to land on | SSR & HTTP | `#tab=network` | | Analog | `#tab=analog` | | Capacitor | `#tab=capacitor` | +| Settings | `#tab=settings` | + +### Link to an item + +A link can also select an item on its tab, on one page: `#tab=&id=&page=`. Open `/__devframes/pangular/#tab=components&id=12&page=k3x9` to land on Components with that component instance selected. + +| Tab | `id` selects | +| ---------- | ---------------------------------------------------------- | +| Components | A component instance, by its id in the component tree | +| Injectors | The element injector of a component, by the component's id | +| Forms | A form, by its id | + +On other tabs, `id` is ignored and the link opens the tab. `page` is the page id from `list-pages`. Without it, the tab looks on the page it shows. If the item is not on the page, the tab says so. The [`open-in-panel`](../agents/tools.md#open-in-panel) agent tool builds these links and switches the panels that are open to them. ### Last tab diff --git a/apps/docs/src/content/getting-started/settings.md b/apps/docs/src/content/getting-started/settings.md new file mode 100644 index 00000000..260797dd --- /dev/null +++ b/apps/docs/src/content/getting-started/settings.md @@ -0,0 +1,65 @@ +--- +title: Settings +description: Pick the panel theme, hide, pin and reorder tabs, and hide the popup button until you press its shortcut. +--- + + + Your own preferences for the panel: theme, tab bar and the popup on your app's page. + + +# Settings + +Open **Settings** with the gear button in the panel header, or with **Open settings** in the [command palette](./command-palette.md). Press the gear button again to go back to the tab you had open. The page is also at `#tab=settings`. + +## Where settings are saved + +Settings belong to you, not to the project. The panel saves them in the browser's `localStorage`, so every panel on the same address uses them, and they survive reloads. The Chrome extension and the panel on your dev server have different addresses, so each keeps its own. + +| Key | Holds | +| ----------------------- | -------------------------------------------- | +| `pangular-pref:theme` | `light` or `dark`. Missing means **System**. | +| `pangular-pref:tabs` | The order, hidden tabs and pinned tabs. | +| `pangular-pref:passive` | `true` when passive mode is on. | + +If the browser blocks storage, the settings apply until the panel reloads. Remove the keys to go back to the defaults. + +## Theme + +| Choice | The panel | +| ---------- | -------------------------------------------------------------------------------------------------------------- | +| **System** | Follows the hub, the Chrome DevTools theme in the extension, or your system color scheme. This is the default. | +| **Light** | Stays light. | +| **Dark** | Stays dark. | + +The popup frame on your app's page follows the panel's theme. The command palette has the same three choices. + +## Tabs + +The **Tabs** list shows every tab the panel can show, in tab bar order. + +| Control | What it does | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| **Pin** | Moves the tab to the start of the tab bar, with the other pinned tabs. A pinned tab is always shown. | +| **Shown** | Clear it to hide the tab from the tab bar. A hidden tab stays reachable from the command palette and from links. Hiding unpins it. | +| Up and down arrows | Move the tab one place. Pinned tabs move among the pinned ones, the others among the others. | +| **Reset tabs** | Back to the default order, with every tab shown and none pinned. | + +Each row works with the keyboard: Tab reaches its buttons, and Alt+↑ or Alt+↓ on any control in the row moves the tab. After a move, focus stays on the row and a screen reader hears the new position, such as **Signals moved to position 2 of 11.** Dragging is not needed. + +The tab you have open stays in the tab bar while it is open, even when it is hidden. Turning an inspector off in the [configuration](./configuration.md) removes its tab whatever the settings say. + +## Passive mode + +Passive mode hides the floating button on your app's page until you press the popup shortcut, `Alt+Shift+P` unless [`popup.hotkey`](./configuration.md#popup) sets another one. The button shows while the panel is open and hides again when it closes. + +The popup saves passive mode with its layout, under `pangular-popup` in your app's `localStorage`, so it stays on after a reload. Turn it on or off from the panel in the popup: the panel sends the change to the popup around it, and the button hides as soon as you close the panel. A panel opened elsewhere, such as the Chrome extension, saves the setting but has no popup to change. + +With `popup.hotkey: false` there is no shortcut to bring the button back, so passive mode is off and the checkbox is disabled. + +## Where to next + + + + + + diff --git a/apps/docs/src/content/inspectors/errors.md b/apps/docs/src/content/inspectors/errors.md new file mode 100644 index 00000000..6a6e44e2 --- /dev/null +++ b/apps/docs/src/content/inspectors/errors.md @@ -0,0 +1,132 @@ +--- +title: Errors +description: Errors from Angular's ErrorHandler, uncaught errors, unhandled promise rejections and the console log of the running page. +--- + + + The errors your page hit, grouped by message, with their Angular error code, the component they came from and the page's console log. + + +# Errors + +The Errors tab collects what goes wrong on the running page: errors that reach Angular's `ErrorHandler`, uncaught errors and unhandled promise rejections. It groups them by message, links Angular error codes to their reference page, and points at the component that threw when it can find it. A second view shows the page's console calls. + +## What it shows + +### Errors + +Each row is one message with: + +| Part | What it shows | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Count | How many times the page hit this message, as **3×**. | +| Message | The error name and message, masked and cut to 500 characters. | +| Code chip | The Angular error code, such as **NG0100**. It links to the code's page in [Angular's error reference](https://angular.dev/errors) when it has one. | +| Source | **ErrorHandler**, **Uncaught** (a `window` error) or **Unhandled rejection**. | +| Time | When the page last hit it. | +| **Show component** | Opens the [Components](./components.md) tab with the owning component selected. | +| **Stack** | The stack of the first occurrence, collapsed until you open it. | + +The list is newest first. Search by message, code or component name, and press `Escape` to clear the search. + +### Console + +Click **Console** to see the page's `console.log`, `info`, `warn`, `error` and `debug` calls, newest first. Pick a level in **Console level**, or search the text. A call repeated in a row shows once with a count. A call that mentions an Angular code gets a code chip too. + +### Pages + +When several tabs report, **Page** picks one, or **All pages** to see them together. The panel of a tab starts on that tab. + +## Where the data comes from + + + + The overlay wraps the handleError method of the app's ErrorHandler, your own or Angular's. Your handler still runs, with the same arguments, after the overlay records the error. + + + Listeners for error and unhandledrejection on window. They only listen, so the browser and Angular handle the event as before. + + + One shared tap on console, the same one the Forms, Analog and SSR & HTTP inspectors read their messages from. Every call still prints. + + + +Angular forwards `window` errors to its `ErrorHandler` when the app uses `provideBrowserGlobalErrorListeners()`. The overlay counts that error once. + +### The owning component + +The tab looks for the component in this order: + +1. The element whose event listener threw, through `ng.getOwningComponent`. This covers template listeners such as `(click)`. +2. A component Angular names in the message, as in `used in the 'TripCard' component template`. +3. A component class in the stack, such as `_TripCard.book` or a compiled template function such as `TripList_Template`. + +A name from the message or the stack counts only when a component with that class name is on the page. The tab uses the same component ids as the Components tab, so **Show component** selects that instance. + +### Angular error codes + +A `RuntimeError` carries its code, and other errors carry it in the message. The tab reads `NG0` codes (runtime, such as `NG0100` and `NG01203`) and compiler codes (such as `NG8001`). + +## Agent tools + +| Tool | What it does | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| `pangular:list-errors` | The error groups, with count, code link, owning component and source. | +| `pangular:explain-error` | One error by `id`, or an Angular `code`: the message, the owning component, the stack, the reference page and cause. | +| `pangular:list-console` | The console calls, by `level` or `search`. | + +The known causes come from Angular's error reference, in a sentence or two. For a code without a reference page, `explain-error` says so. See [Tools](../agents/tools.md#errors). + +## Limits and gotchas + + + Messages, stacks and console text are masked like other live values: secret field names, JWTs, bearer tokens and secret key=value pairs. See Access and redaction. + + +### Caps + +| What | Kept per page | +| --------------------- | ------------------------------------- | +| Error groups | 100. The least recent one goes first. | +| Console calls | 200. The oldest one goes first. | +| Message, console text | 500 characters. | +| Stack | 40 lines and 4,000 characters. | + +The tab says how many groups or calls a page dropped. + +### Capture starts with the overlay + +The overlay starts after the page claims its page id, so errors and console calls from before it connects are not in the list. The `ErrorHandler` is wrapped as soon as the app has bootstrapped. Until then, the tab counts the `ERROR` lines Angular's default handler logs, and says the handler isn't wrapped yet. + +### It needs a development build + +Finding the `ErrorHandler` and the owning component uses Angular's debug APIs, which only development builds have. + +### Turn it off + +Set `inspectors: {errors: false}` to drop the tab, the collector and the agent tools. See [Inspectors and agent tools](../getting-started/configuration.md#inspectors-and-agent-tools). + +## FAQ + + + + It did not come from a template listener, and no component on the page matches a class name in its message or stack. Errors from services, timers and promises often have none. + + + Angular's error reference has no page for that code. + + + No. Your handler gets every error, with the same arguments, and its return value is passed back. Stopping the overlay puts the method back. + + + +## Where to next + + + + The component an error points at. + + + Every tool a coding agent can call. + + diff --git a/apps/docs/src/ngmd.config.ts b/apps/docs/src/ngmd.config.ts index 0dae9dd7..5779cdac 100644 --- a/apps/docs/src/ngmd.config.ts +++ b/apps/docs/src/ngmd.config.ts @@ -183,6 +183,8 @@ const config: NgmdConfig = { {label: 'Standalone CLI', href: '/getting-started/cli'}, {label: 'Configuration', href: '/getting-started/configuration', status: 'new'}, {label: 'Popup and hub', href: '/getting-started/popup-and-hub', status: 'new'}, + {label: 'Command palette', href: '/getting-started/command-palette', status: 'new'}, + {label: 'Settings', href: '/getting-started/settings', status: 'new'}, {label: 'Browser overlay', href: '/getting-started/overlay', status: 'updated'}, {label: 'Chrome extension', href: '/getting-started/chrome-extension'}, {label: 'Angular Native', href: '/getting-started/angular-native', status: 'new'}, @@ -203,6 +205,7 @@ const config: NgmdConfig = { {label: 'SSR & HTTP', href: '/inspectors/ssr-http', status: 'new'}, {label: 'Analog', href: '/inspectors/analog', status: 'new'}, {label: 'Capacitor', href: '/inspectors/capacitor', status: 'new'}, + {label: 'Errors', href: '/inspectors/errors', status: 'new'}, ], }, { diff --git a/packages/devtools/src/__tests__/console-tap.test.ts b/packages/devtools/src/__tests__/console-tap.test.ts new file mode 100644 index 00000000..fe12f3dc --- /dev/null +++ b/packages/devtools/src/__tests__/console-tap.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { consoleTapInfo, tapConsole, type ConsoleLevel } from '../console-tap.ts'; +import { attachAnalog } from '../analog-runtime.ts'; +import { attachForms } from '../forms-collector.ts'; + +const originals = { log: console.log, warn: console.warn, error: console.error }; + +afterEach(() => { + console.log = originals.log; + console.warn = originals.warn; + console.error = originals.error; + vi.useRealTimers(); +}); + +describe('tapConsole', () => { + it('patches each level once and fans calls out to every listener', () => { + const printed: unknown[][] = []; + const base = (...args: unknown[]) => void printed.push(args); + console.warn = base; + const first: unknown[][] = []; + const second: [ConsoleLevel, unknown[]][] = []; + const stopFirst = tapConsole(['warn'], (_level, args) => void first.push([...args])); + const patched = console.warn; + const stopSecond = tapConsole( + ['warn', 'error'], + (level, args) => void second.push([level, [...args]]), + ); + // The second listener reuses the patch on warn. + expect(console.warn).toBe(patched); + expect(consoleTapInfo().patched).toEqual(['warn', 'error']); + + console.warn('NG0500: hydration', 1); + expect(printed).toEqual([['NG0500: hydration', 1]]); + expect(first).toEqual([['NG0500: hydration', 1]]); + expect(second).toEqual([['warn', ['NG0500: hydration', 1]]]); + + stopFirst(); + expect(console.warn).toBe(patched); + stopSecond(); + expect(console.warn).toBe(base); + expect(console.error).toBe(originals.error); + expect(consoleTapInfo()).toEqual({ listeners: 0, patched: [] }); + }); + + it("keeps the console's this, arguments and return value", () => { + let seenThis: unknown; + console.log = function (this: unknown, ...args: unknown[]) { + seenThis = this; + return args.length; + } as typeof console.log; + const stop = tapConsole(['log'], () => {}); + const result = (console.log as unknown as (...args: unknown[]) => unknown)('a', 'b'); + expect(result).toBe(2); + expect(seenThis).toBe(console); + stop(); + }); + + it('never lets a listener break logging or hear itself', () => { + const printed: unknown[] = []; + console.error = (...args: unknown[]) => void printed.push(args[0]); + let heard = 0; + const stopThrowing = tapConsole(['error'], () => { + throw new Error('listener failed'); + }); + const stopLogging = tapConsole(['error'], () => { + heard++; + console.error('from the listener'); + }); + expect(() => console.error('app error')).not.toThrow(); + expect(heard).toBe(1); + expect(printed).toEqual(['from the listener', 'app error']); + stopThrowing(); + stopLogging(); + }); + + it('wraps a method replaced after it patched, without hearing a call twice', () => { + const heard: unknown[] = []; + const stop = tapConsole(['warn'], (_level, args) => void heard.push(args[0])); + const ours = console.warn; + // Another tool wraps the patched method. + console.warn = (...args: unknown[]) => ours(...args); + const again = tapConsole(['warn'], () => {}); + console.warn('once'); + expect(heard).toEqual(['once']); + again(); + stop(); + }); + + it('leaves a method someone wrapped on top of it in place when it stops', () => { + const stop = tapConsole(['log'], () => {}); + const theirs = (...args: unknown[]) => args; + console.log = theirs; + stop(); + expect(console.log).toBe(theirs); + }); + + it('serves the forms and Analog collectors from one patch and restores the console after both', () => { + vi.useFakeTimers(); + const base = console.error; + const my = { rpc: { call: async () => undefined, register: () => {} } }; + const forms = attachForms(my as never, 'p1', () => undefined, { + show: () => {}, + clear: () => {}, + }); + const afterForms = console.error; + const stopAnalog = attachAnalog(my as never, 'p1', () => undefined, 1000); + expect(console.error).toBe(afterForms); + expect(consoleTapInfo().listeners).toBe(2); + forms.stop(); + expect(console.error).toBe(afterForms); + stopAnalog(); + expect(console.error).toBe(base); + expect(consoleTapInfo().listeners).toBe(0); + }); +}); diff --git a/packages/devtools/src/__tests__/docs-tool-count.test.ts b/packages/devtools/src/__tests__/docs-tool-count.test.ts index 4adaf598..12b6a452 100644 --- a/packages/devtools/src/__tests__/docs-tool-count.test.ts +++ b/packages/devtools/src/__tests__/docs-tool-count.test.ts @@ -28,6 +28,7 @@ const WORDS = [ 'Sixty-eight', 'Sixty-nine', 'Seventy', + 'Seventy-one', ]; it('states on the tools page how many tools the server registers', async () => { diff --git a/packages/devtools/src/__tests__/errors-collector.test.ts b/packages/devtools/src/__tests__/errors-collector.test.ts new file mode 100644 index 00000000..4245760d --- /dev/null +++ b/packages/devtools/src/__tests__/errors-collector.test.ts @@ -0,0 +1,382 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { elementById, elementId } from '../element-id.ts'; +import { + MAX_CONSOLE_ENTRIES, + MAX_ERROR_GROUPS, + MAX_MESSAGE, + MAX_STACK, + attachErrors, + consoleText, + ownerCandidates, + type ErrorsDebugNg, +} from '../errors-collector.ts'; +import { ngCodeOf, normalizeNgCode, ngCodeUrl } from '../ng-error-codes.ts'; +import { consoleTapInfo } from '../console-tap.ts'; +import type { ErrorsReport } from '../rpc/errors-tools.ts'; + +const JWT = ['eyJhbGciOiJIUzI1NiJ9', 'eyJzdWIiOiIxMjM0NSJ9', 'c2lnbmF0dXJlc2ln'].join('.'); + +/** Angular's ErrorHandler token, as `ɵgetInjectorProviders` lists it. */ +class ErrorHandler { + handleError(error: unknown) { + console.error('ERROR', error); + } +} + +class TripList {} +class TripCard {} + +interface Harness { + reports: ErrorsReport[]; + calls: { name: string; args: unknown[] }[]; + collector: ReturnType; + handler: ErrorHandler; + answer: { known: boolean }; +} + +const stops: (() => void)[] = []; +const originalError = console.error; +const originalLog = console.log; + +afterEach(() => { + for (const stop of stops.splice(0)) stop(); + console.error = originalError; + console.log = originalLog; + document.body.innerHTML = ''; + delete (globalThis as { ng?: unknown }).ng; + vi.useRealTimers(); +}); + +function fakeNg(handler: ErrorHandler, extra: Partial = {}): ErrorsDebugNg { + const injector = { get: (token: unknown) => (token === ErrorHandler ? handler : null) }; + return { + getInjector: () => injector, + ɵgetInjectorResolutionPath: () => [injector], + ɵgetInjectorProviders: () => [{ token: ErrorHandler }], + ...extra, + } as ErrorsDebugNg; +} + +function harness(ng?: ErrorsDebugNg | null, now?: () => number): Harness { + document.body.innerHTML ||= ''; + const handler = new ErrorHandler(); + const reports: ErrorsReport[] = []; + const calls: { name: string; args: unknown[] }[] = []; + const answer = { known: true }; + const my = { + rpc: { + call: async (name: string, ...args: unknown[]) => { + calls.push({ name, args }); + if (name === 'push-errors') reports.push(structuredClone(args[0]) as ErrorsReport); + return { ...answer }; + }, + }, + }; + const theNg = ng === null ? undefined : (ng ?? fakeNg(handler)); + const collector = attachErrors(my, 'page-1', () => theNg, { now }); + stops.push(collector.stop); + return { reports, calls, collector, handler, answer }; +} + +const quiet = () => (console.error = () => {}); + +describe('NG codes', () => { + it('reads the code of a RuntimeError and of a message, and links documented codes only', () => { + expect(ngCodeOf({ code: -100, message: 'whatever' })).toBe('NG0100'); + expect(ngCodeOf({ code: 1203 })).toBe('NG01203'); + expect(ngCodeOf(new Error('x'), 'NG0303: Can\'t bind to "foo"')).toBe('NG0303'); + expect(ngCodeOf(null, 'NG8001: not a known element')).toBe('NG8001'); + expect(ngCodeOf(null, 'XNG0100 inside a token')).toBeUndefined(); + expect(ngCodeOf(null, 'NG0100123 too many digits')).toBeUndefined(); + expect(ngCodeOf(null, 'no code')).toBeUndefined(); + expect(ngCodeUrl('NG0100')).toBe('https://angular.dev/errors/NG0100'); + expect(ngCodeUrl('NG0303')).toBeUndefined(); + expect(normalizeNgCode('ng100')).toBe('NG0100'); + expect(normalizeNgCode('NG01203')).toBe('NG01203'); + expect(normalizeNgCode('hello')).toBeUndefined(); + }); +}); + +describe('ownerCandidates', () => { + it('reads component names from Angular messages and stack frames', () => { + expect( + ownerCandidates("NG0303: Can't bind to 'x' (used in the 'TripCard' component template).", ''), + ).toEqual(['TripCard']); + expect(ownerCandidates('NG0100: Expression location: _TripList component', '')).toEqual([ + 'TripList', + ]); + const stack = [ + 'Error: boom', + ' at _TripCard.book (http://localhost/main.js:10:5)', + ' at TripList_Conditional_2_Template_button_click_0_listener (http://localhost/main.js:20:3)', + ' at Object.next (http://localhost/rxjs.js:1:1)', + 'handle@http://localhost/main.js:3:1', + ].join('\n'); + const names = ownerCandidates('Error: boom', stack); + expect(names.slice(0, 2)).toEqual([ + 'TripCard', + 'TripList_Conditional_2_Template_button_click_0', + ]); + expect(names).toContain('TripList'); + expect(names).not.toContain('Object'); + }); +}); + +describe('consoleText', () => { + it('drops %c styles and masks tokens', () => { + expect(consoleText(['%cHello%c world', 'color: red', 'color: blue', 42])).toBe( + 'Hello world 42', + ); + expect(consoleText(['token', `Bearer ${JWT}`])).not.toContain('eyJ'); + expect(consoleText([{ password: 'hunter2', user: 'ada' }])).not.toContain('hunter2'); + expect(consoleText([new TypeError('bad')])).toBe('TypeError: bad'); + }); +}); + +describe('attachErrors', () => { + it('wraps the ErrorHandler, keeps the original running and puts it back on stop', async () => { + const logged: unknown[][] = []; + console.error = (...args: unknown[]) => void logged.push(args); + const h = harness(); + expect(Object.hasOwn(h.handler, 'handleError')).toBe(true); + const error = new Error('Payment failed'); + h.handler.handleError(error); + expect(logged).toEqual([['ERROR', error]]); + await h.collector.push(); + const report = h.reports.at(-1)!; + expect(report).toMatchObject({ pageId: 'page-1', full: true, handler: 'wrapped' }); + expect(report.errors).toHaveLength(1); + expect(report.errors[0]).toMatchObject({ + message: 'Error: Payment failed', + name: 'Error', + count: 1, + sources: ['handler'], + }); + expect(report.errors[0]!.stack).toContain('Payment failed'); + h.collector.stop(); + expect(Object.hasOwn(h.handler, 'handleError')).toBe(false); + expect(h.handler.handleError).toBe(ErrorHandler.prototype.handleError); + }); + + it('counts the default handler logging ERROR when no handler can be wrapped', async () => { + quiet(); + const h = harness(null); + console.error('ERROR', new Error('Bootstrap failed')); + await h.collector.push(); + expect(h.reports.at(-1)).toMatchObject({ handler: 'none' }); + expect(h.reports.at(-1)!.errors[0]).toMatchObject({ + message: 'Error: Bootstrap failed', + sources: ['handler'], + }); + }); + + it('captures window errors and unhandled rejections', async () => { + quiet(); + const h = harness(); + window.dispatchEvent( + new ErrorEvent('error', { error: new RangeError('Index out of range'), message: 'x' }), + ); + window.dispatchEvent(new ErrorEvent('error', { message: 'Script error.' })); + const rejection = new Event('unhandledrejection') as Event & { reason?: unknown }; + rejection.reason = 'quota exceeded'; + window.dispatchEvent(rejection); + await h.collector.push(); + const errors = h.reports.at(-1)!.errors; + expect(errors.map((e) => [e.message, e.sources])).toEqual([ + ['RangeError: Index out of range', ['window']], + ['Script error.', ['window']], + ['quota exceeded', ['promise']], + ]); + }); + + it('counts one occurrence once when Angular forwards a window error to its handler', async () => { + quiet(); + const h = harness(); + const error = new Error('Seen twice'); + window.dispatchEvent(new ErrorEvent('error', { error })); + h.handler.handleError(error); + await h.collector.push(); + expect(h.reports.at(-1)!.errors).toEqual([ + expect.objectContaining({ message: 'Error: Seen twice', count: 1, sources: ['window'] }), + ]); + }); + + it('groups repeated errors by message with a count and the last time', async () => { + quiet(); + let clock = 1000; + const h = harness(undefined, () => clock); + for (let i = 0; i < 3; i++) { + clock += 5000; + h.handler.handleError(new Error('Trip not found')); + } + await h.collector.push(); + expect(h.reports.at(-1)!.errors).toEqual([ + expect.objectContaining({ count: 3, firstAt: 6000, lastAt: 16000 }), + ]); + }); + + it('parses NG codes and links the reference page', async () => { + quiet(); + const h = harness(); + const runtime = Object.assign( + new Error('NG0100: ExpressionChangedAfterItHasBeenCheckedError'), + { + code: -100, + }, + ); + h.handler.handleError(runtime); + h.handler.handleError( + new Error("NG0303: Can't bind to 'tripId' since it isn't a known property"), + ); + await h.collector.push(); + const [first, second] = h.reports.at(-1)!.errors; + expect(first).toMatchObject({ code: 'NG0100', docUrl: 'https://angular.dev/errors/NG0100' }); + expect(second).toMatchObject({ code: 'NG0303' }); + expect(second!.docUrl).toBeUndefined(); + }); + + it('masks tokens in messages and stacks and caps their length', async () => { + quiet(); + const h = harness(); + const error = new Error(`Auth failed for Bearer ${JWT} ${'x'.repeat(2000)}`); + error.stack = `Error: token=${JWT}\n${' at frame (main.js:1:1)\n'.repeat(500)}`; + h.handler.handleError(error); + await h.collector.push(); + const [group] = h.reports.at(-1)!.errors; + expect(group!.message).not.toContain('eyJ'); + expect(group!.message.length).toBeLessThanOrEqual(MAX_MESSAGE + 1); + expect(group!.stack).not.toContain('eyJ'); + expect(group!.stack!.length).toBeLessThanOrEqual(MAX_STACK + 1); + }); + + it(`keeps at most ${MAX_ERROR_GROUPS} groups and counts the ones it dropped`, async () => { + quiet(); + let clock = 0; + const h = harness(undefined, () => ++clock); + for (let i = 0; i < MAX_ERROR_GROUPS + 5; i++) h.handler.handleError(new Error(`Error ${i}`)); + await h.collector.push(); + const report = h.reports.at(-1)!; + expect(report.errors).toHaveLength(MAX_ERROR_GROUPS); + expect(report.errors[0]!.message).toBe('Error: Error 5'); + expect(report.dropped.errors).toBe(5); + }); + + it('links the component whose template listener threw, by its host element id', async () => { + quiet(); + document.body.innerHTML = + ''; + const card = document.querySelector('app-trip-card')!; + const button = document.querySelector('button')!; + const instance = new TripCard(); + const handler = new ErrorHandler(); + const ng = fakeNg(handler, { + getOwningComponent: (el) => (el === button ? instance : null), + getHostElement: (component) => (component === instance ? card : null), + }); + const h = harness(ng); + button.addEventListener('click', () => handler.handleError(new Error('No seats'))); + button.click(); + await h.collector.push(); + const [group] = h.reports.at(-1)!.errors; + expect(group!.owner).toEqual({ id: elementId(card), name: 'TripCard' }); + expect(elementById(group!.owner!.id)).toBe(card); + expect(card.getAttributeNames()).toEqual([]); + }); + + it('finds the owner from the stack when the error did not come from a listener', async () => { + quiet(); + document.body.innerHTML = + ''; + const list = document.querySelector('app-trip-list')!; + const handler = new ErrorHandler(); + const instance = new TripList(); + const ng = fakeNg(handler, { getComponent: (el) => (el === list ? instance : null) }); + const h = harness(ng); + const error = new Error('Cannot read properties of undefined'); + error.stack = + 'TypeError: x\n at TripList_Template (main.js:1:1)\n at refreshView (core.js:1:1)'; + handler.handleError(error); + await h.collector.push(); + expect(h.reports.at(-1)!.errors[0]!.owner).toEqual({ id: elementId(list), name: 'TripList' }); + }); + + it('logs console calls by level, counts repeats in a row and skips its own messages', async () => { + console.log = () => {}; + quiet(); + const h = harness(); + console.log('Loaded trips', { count: 3 }); + console.log('Loaded trips', { count: 3 }); + console.error('NG0500: During hydration Angular expected
'); + console.log('[pangular] Connected'); + await h.collector.push(); + expect(h.reports.at(-1)!.console).toEqual([ + expect.objectContaining({ id: 1, level: 'log', text: 'Loaded trips {"count":3}', count: 2 }), + expect.objectContaining({ id: 2, level: 'error', code: 'NG0500' }), + ]); + }); + + it(`keeps the last ${MAX_CONSOLE_ENTRIES} console calls and sends only new ones after the first push`, async () => { + console.log = () => {}; + const h = harness(); + for (let i = 0; i < MAX_CONSOLE_ENTRIES + 10; i++) console.log(`line ${i}`); + await h.collector.push(); + const first = h.reports.at(-1)!; + expect(first.console).toHaveLength(MAX_CONSOLE_ENTRIES); + expect(first.console[0]!.text).toBe('line 10'); + expect(first.dropped.console).toBe(10); + console.log('one more'); + await h.collector.push(); + const next = h.reports.at(-1)!; + expect(next.full).toBe(false); + expect(next.console.map((entry) => entry.text)).toEqual(['one more']); + expect(next.firstConsoleId).toBe(12); + }); + + it('skips unchanged pushes, sends a keepalive when due, and resends all when the server forgot the page', async () => { + quiet(); + let clock = 0; + const h = harness(undefined, () => clock); + h.handler.handleError(new Error('once')); + await h.collector.push(); + await h.collector.push(); + expect(h.reports).toHaveLength(1); + clock += 60_000; + await h.collector.push(); + expect(h.reports).toHaveLength(2); + expect(h.reports[1]).toMatchObject({ full: false, errors: [], console: [] }); + h.answer.known = false; + clock += 60_000; + await h.collector.push(); + h.answer.known = true; + await h.collector.push(); + expect(h.reports.at(-1)).toMatchObject({ + full: true, + errors: [expect.objectContaining({ message: 'Error: once' })], + }); + expect(h.reports.every((report) => report.pageId === 'page-1')).toBe(true); + }); + + it('forgets the page on leave, stays quiet until it resumes, and restores everything on stop', async () => { + quiet(); + const before = console.error; + const h = harness(); + h.collector.leave(); + expect(h.calls.at(-1)).toEqual({ name: 'forget-errors-page', args: ['page-1'] }); + h.handler.handleError(new Error('while away')); + await h.collector.push(); + expect(h.reports).toHaveLength(0); + h.collector.resume(); + await h.collector.push(); + expect(h.reports.at(-1)).toMatchObject({ full: true }); + h.collector.stop(); + expect(console.error).toBe(before); + expect(consoleTapInfo().listeners).toBe(0); + const swallow = (event: Event) => event.preventDefault(); + window.addEventListener('error', swallow); + window.dispatchEvent(new ErrorEvent('error', { error: new Error('after stop') })); + window.removeEventListener('error', swallow); + await h.collector.push(); + expect(h.reports.at(-1)!.errors.map((e) => e.message)).not.toContain('Error: after stop'); + }); +}); diff --git a/packages/devtools/src/__tests__/errors-real.test.ts b/packages/devtools/src/__tests__/errors-real.test.ts new file mode 100644 index 00000000..db00e1f1 --- /dev/null +++ b/packages/devtools/src/__tests__/errors-real.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment jsdom +import '@angular/compiler'; +import { + Component, + ErrorHandler, + Injectable, + computed, + provideZonelessChangeDetection, + signal, + type ApplicationRef, +} from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { elementById } from '../element-id.ts'; +import { attachErrors, type ErrorsDebugNg } from '../errors-collector.ts'; +import type { ErrorsReport } from '../rpc/errors-tools.ts'; + +class TripCard { + readonly seats = signal(3); + book(): void { + throw new Error('No seats left on trip 42'); + } + /** Writes a signal inside a computed, which Angular refuses with NG0600. */ + recount(): void { + computed(() => this.seats.set(2))(); + } +} +Component({ + selector: 'app-trip-card', + template: ` + `, +})(TripCard); + +class TripsApp {} +Component({ + selector: 'app-root', + imports: [TripCard], + template: `

Trips

`, +})(TripsApp); + +const handled: unknown[] = []; +class RecordingHandler extends ErrorHandler { + override handleError(error: unknown): void { + handled.push(error); + } +} +Injectable()(RecordingHandler); + +const settle = async (app: ApplicationRef) => { + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + app.tick(); + } +}; + +function harness() { + const reports: ErrorsReport[] = []; + const my = { + rpc: { + call: async (name: string, report: unknown) => { + if (name === 'push-errors') reports.push(report as ErrorsReport); + return { known: true }; + }, + }, + }; + const ng = () => (globalThis as { ng?: ErrorsDebugNg }).ng; + return { reports, collector: attachErrors(my, 'real-page', ng) }; +} + +describe('the errors collector with a real app', () => { + let app: ApplicationRef | null = null; + let stop = () => {}; + afterEach(() => { + stop(); + app?.destroy(); + app = null; + handled.length = 0; + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it("wraps the default ErrorHandler, keeps it logging, and links the template's component", async () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + document.body.innerHTML = ''; + app = await bootstrapApplication(TripsApp, { providers: [provideZonelessChangeDetection()] }); + await settle(app); + const { reports, collector } = harness(); + stop = collector.stop; + await collector.push(); + expect(reports.at(-1)?.handler).toBe('wrapped'); + + document.querySelector('button')!.click(); + await collector.push(); + + // Angular's own handler still ran and logged the error. + expect(logged).toHaveBeenCalledWith('ERROR', expect.any(Error)); + const [group] = reports.at(-1)!.errors; + expect(group).toMatchObject({ + message: 'Error: No seats left on trip 42', + count: 1, + sources: ['handler'], + owner: { name: 'TripCard' }, + }); + expect(elementById(group!.owner!.id)).toBe(document.querySelector('app-trip-card')); + // The console log has the handler's line too. + expect(reports.at(-1)!.console.map((entry) => entry.level)).toContain('error'); + }); + + it('reads the code of a real RuntimeError and links its reference page', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + document.body.innerHTML = ''; + app = await bootstrapApplication(TripsApp, { providers: [provideZonelessChangeDetection()] }); + await settle(app); + const { reports, collector } = harness(); + stop = collector.stop; + document.querySelector('button.recount')!.click(); + await collector.push(); + expect(reports.at(-1)!.errors[0]).toMatchObject({ + code: 'NG0600', + docUrl: 'https://angular.dev/errors/NG0600', + owner: { name: 'TripCard' }, + }); + }); + + it('keeps a custom ErrorHandler running and restores it on stop', async () => { + document.body.innerHTML = ''; + app = await bootstrapApplication(TripsApp, { + providers: [ + provideZonelessChangeDetection(), + { provide: ErrorHandler, useClass: RecordingHandler }, + ], + }); + await settle(app); + const { reports, collector } = harness(); + await collector.push(); + expect(reports.at(-1)?.handler).toBe('wrapped'); + + stop = collector.stop; + document.querySelector('button')!.click(); + document.querySelector('button')!.click(); + await collector.push(); + expect(handled).toHaveLength(2); + expect(reports.at(-1)!.errors[0]).toMatchObject({ count: 2, owner: { name: 'TripCard' } }); + + const handler = app.injector.get(ErrorHandler) as RecordingHandler; + expect(Object.hasOwn(handler, 'handleError')).toBe(true); + collector.stop(); + expect(Object.hasOwn(handler, 'handleError')).toBe(false); + document.querySelector('button')!.click(); + expect(handled).toHaveLength(3); + }); +}); diff --git a/packages/devtools/src/__tests__/errors-tools.test.ts b/packages/devtools/src/__tests__/errors-tools.test.ts new file mode 100644 index 00000000..acd427b1 --- /dev/null +++ b/packages/devtools/src/__tests__/errors-tools.test.ts @@ -0,0 +1,223 @@ +import { createHostContext } from 'devframe/node'; +import { describe, expect, it } from 'vitest'; +import pangular, { createPangular } from '../devframe.ts'; +import { + explainErrorText, + mergeErrorsReport, + staleErrorPages, + toErrorsReport, + type ErrorGroup, + type ErrorsReport, + type ErrorsState, +} from '../rpc/errors-tools.ts'; + +const JWT = ['eyJhbGciOiJIUzI1NiJ9', 'eyJzdWIiOiIxMjM0NSJ9', 'c2lnbmF0dXJlc2ln'].join('.'); + +async function boot(definition = pangular) { + const host = { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost', + getStorageDir: () => '', + }; + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: host as never }); + await definition.setup(ctx as never); + const call = (name: string, payload: unknown) => + ctx.rpc.invokeLocal(`pangular:${name}` as never, ...([payload] as never)); + const invoke = async (id: string, args: Record = {}) => + ((await ctx.agent.invoke(`pangular:${id}`, args)) as { markdown: string }).markdown; + const state = async () => + ( + await ( + ctx.rpc as unknown as { + sharedState: { get: (key: string) => Promise<{ value: () => ErrorsState }> }; + } + ).sharedState.get('pangular:errors') + ).value(); + return { ctx, call, invoke, state }; +} + +const group = (extra: Partial = {}): ErrorGroup => ({ + id: 'e1', + message: 'Error: No seats left', + name: 'Error', + count: 2, + firstAt: Date.now() - 10_000, + lastAt: Date.now() - 1_000, + sources: ['handler'], + stack: 'Error: No seats left\n at _TripCard.book (main.js:10:5)', + owner: { id: 'cabc-3', name: 'TripCard' }, + ...extra, +}); + +const report = (extra: Partial = {}): ErrorsReport => ({ + pageId: 'tab1', + full: true, + url: '/trips', + title: 'Trips', + handler: 'wrapped', + errors: [group()], + console: [ + { id: 1, level: 'log', text: 'Loaded trips', at: Date.now() - 5_000, count: 3 }, + { + id: 2, + level: 'warn', + text: 'NG0913: image is too large', + at: Date.now(), + count: 1, + code: 'NG0913', + }, + ], + dropped: { errors: 0, console: 0 }, + ...extra, +}); + +describe('errors reports on the server', () => { + it('keeps one entry per page and merges partial reports into it', async () => { + const { call, state } = await boot(); + expect(await call('push-errors', report())).toEqual({ known: true }); + expect(await call('push-errors', report({ pageId: 'tab2' }))).toEqual({ known: true }); + await call( + 'push-errors', + report({ + full: false, + errors: [group({ count: 5 })], + console: [{ id: 3, level: 'error', text: 'boom', at: Date.now(), count: 1 }], + firstConsoleId: 2, + }), + ); + const value = await state(); + expect(Object.keys(value.pages).sort()).toEqual(['tab1', 'tab2']); + expect(value.pages['tab1']!.errors['e1']!.count).toBe(5); + expect(Object.keys(value.pages['tab1']!.console)).toEqual(['2', '3']); + expect(value.pages['tab2']!.errors['e1']!.count).toBe(2); + }); + + it('asks for the whole report when it does not know the page', async () => { + const { call } = await boot(); + expect(await call('push-errors', report({ full: false }))).toEqual({ known: false }); + }); + + it('forgets a page on forget-errors-page and when it stops reporting', async () => { + const { call, state } = await boot(); + await call('push-errors', report()); + await call('forget-errors-page', 'tab1'); + expect((await state()).pages).toEqual({}); + const merged: ErrorsState = { pages: {} }; + mergeErrorsReport(merged, toErrorsReport(report())!, 1000); + expect(staleErrorPages(merged, 1000 + 14_000, () => 15_000)).toEqual([]); + expect(staleErrorPages(merged, 1000 + 16_000, () => 15_000)).toEqual(['tab1']); + }); + + it('masks and caps what a page sends, and rebuilds the reference link itself', () => { + const checked = toErrorsReport({ + ...report(), + errors: [ + group({ + message: `Bearer ${JWT} ${'y'.repeat(2000)}`, + code: 'NG0100', + docUrl: 'https://evil.example/NG0100', + }), + { id: 'e2', message: 'not a code', code: ' Pangular Inspector - + From 2db5098fe787310734905728debc192c777d4f8c Mon Sep 17 00:00:00 2001 From: erKam Date: Mon, 12 Oct 2026 21:28:03 +0000 Subject: [PATCH 3/3] docs(ui): list the Errors tab for open-in-panel and the tab hashes The open-in-panel tool description, its docs row and the tab hash table now include the errors tab. Refs #342 --- apps/docs/src/content/agents/tools.md | 10 +++++----- apps/docs/src/content/getting-started/popup-and-hub.md | 1 + packages/devtools/src/rpc/open-in-panel.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index fd52d913..fc37c233 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -49,11 +49,11 @@ The URL comes from the HTTP or router report. A page that sends neither (no rout Gives the user a link that opens the panel on one tab with one item selected, and switches the panels that are open to it. Reads: page. Use it to point the user at what you found. -| Argument | Required | What it is | -| -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `tab` | Yes | The tab: `dashboard`, `components`, `routes`, `signals`, `injectors`, `store`, `forms`, `pipes`, `network` (SSR & HTTP), `analog` or `capacitor`. Only tabs that are on count. | -| `id` | No | The item to select: a component id on `components`, the id of the component whose element injector to show on `injectors`, a form id on `forms`. Other tabs ignore it. | -| `page` | No | The page the item belongs to, from `list-pages`. | +| Argument | Required | What it is | +| -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tab` | Yes | The tab: `dashboard`, `components`, `routes`, `signals`, `injectors`, `store`, `forms`, `pipes`, `network` (SSR & HTTP), `errors`, `analog` or `capacitor`. Only tabs that are on count. | +| `id` | No | The item to select: a component id on `components`, the id of the component whose element injector to show on `injectors`, a form id on `forms`. Other tabs ignore it. | +| `page` | No | The page the item belongs to, from `list-pages`. | The answer is a full URL, such as `http://localhost:4200/__devframes/pangular/#tab=components&id=12&page=k3x9`, when the hub serves the panel and a page has reported its address. Otherwise it is the hash to add to the panel's address. Each panel that is open and not in a background tab opens the link at once. The tool is `read`: it changes what the panel shows, not the app. See [Deep links](../getting-started/popup-and-hub.md#deep-links). diff --git a/apps/docs/src/content/getting-started/popup-and-hub.md b/apps/docs/src/content/getting-started/popup-and-hub.md index 328c66de..dcce8708 100644 --- a/apps/docs/src/content/getting-started/popup-and-hub.md +++ b/apps/docs/src/content/getting-started/popup-and-hub.md @@ -192,6 +192,7 @@ The URL hash selects a tab. Open `/__devframes/pangular/#tab=signals` to land on | Forms | `#tab=forms` | | Pipes | `#tab=pipes` | | SSR & HTTP | `#tab=network` | +| Errors | `#tab=errors` | | Analog | `#tab=analog` | | Capacitor | `#tab=capacitor` | | Settings | `#tab=settings` | diff --git a/packages/devtools/src/rpc/open-in-panel.ts b/packages/devtools/src/rpc/open-in-panel.ts index 4b3b0acb..d236b854 100644 --- a/packages/devtools/src/rpc/open-in-panel.ts +++ b/packages/devtools/src/rpc/open-in-panel.ts @@ -11,7 +11,7 @@ import { export const OPEN_IN_PANEL_EVENT = 'open-in-panel'; export const OPEN_IN_PANEL_DESCRIPTION = - "Give the user a link that opens the devtools panel on one tab, with one item selected, and switch the panels that are open to it. Use it to point the user at what you found. `tab` is the panel tab: dashboard, components, routes, signals, injectors, store, forms, pipes, network (SSR & HTTP), analog or capacitor. `id` selects an item on components (a component instance id, from list-components or component-tree), injectors (the id of the component whose element injector to show) and forms (a form id, from forms); other tabs open without a selection. Pass `page` (see list-pages) when more than one tab reports. Answers with the link in markdown. The link is a full URL when the panel runs in the hub and a page has reported its address; otherwise it is the hash to add to the panel's address."; + "Give the user a link that opens the devtools panel on one tab, with one item selected, and switch the panels that are open to it. Use it to point the user at what you found. `tab` is the panel tab: dashboard, components, routes, signals, injectors, store, forms, pipes, network (SSR & HTTP), errors, analog or capacitor. `id` selects an item on components (a component instance id, from list-components or component-tree), injectors (the id of the component whose element injector to show) and forms (a form id, from forms); other tabs open without a selection. Pass `page` (see list-pages) when more than one tab reports. Answers with the link in markdown. The link is a full URL when the panel runs in the hub and a page has reported its address; otherwise it is the hash to add to the panel's address."; export interface OpenInPanelArgs { tab?: unknown;