diff --git a/.claude/rules/angular.md b/.claude/rules/angular.md index 58df53e6..a27b7c7a 100644 --- a/.claude/rules/angular.md +++ b/.claude/rules/angular.md @@ -33,7 +33,7 @@ These apply to every Angular and TypeScript file in the repository: the panel (` - Use `model()` for two-way bound properties with `[(prop)]` syntax instead of pairing `input()` with `output()` - Use `computed()` for derived state - Use `linkedSignal()` for state derived from multiple reactive sources that must stay synchronized -- Prefer inline templates for small components +- Give every component its own files: `templateUrl: './x.html'` and `styleUrl: './x.scss'` next to `x.ts`. Do NOT use inline `template:` or `styles:` in new or split components - Prefer Signal Forms (`@angular/forms/signals`) for new forms. They are stable in Angular v22+ and provide signal-based state, type-safe field access, and schema-based validation - When not using Signal Forms, prefer Reactive forms instead of Template-driven ones - Do NOT use `ngClass`, use `class` bindings instead diff --git a/app/src/__tests__/page-conventions.test.ts b/app/src/__tests__/page-conventions.test.ts index 2561ed97..0f604817 100644 --- a/app/src/__tests__/page-conventions.test.ts +++ b/app/src/__tests__/page-conventions.test.ts @@ -14,11 +14,15 @@ async function pageSources(): Promise> { readdirSync(path: string, options: { recursive: true }): string[]; }; // Recursive, so a page split into a folder (pages/network/) is still checked. - const files = fs - .readdirSync(PAGES, { recursive: true }) - .map((file) => file.replaceAll('\\', '/')) - .filter((file) => file.endsWith('.ts')); - return new Map(files.map((file) => [file, fs.readFileSync(`${PAGES}${file}`, 'utf8')])); + const all = fs.readdirSync(PAGES, { recursive: true }).map((file) => file.replaceAll('\\', '/')); + const files = all.filter((file) => file.endsWith('.ts')); + // A component's template and style files count as part of its source. + const read = (file: string) => + [file, file.replace(/\.ts$/, '.html'), file.replace(/\.ts$/, '.scss')] + .filter((path) => all.includes(path)) + .map((path) => fs.readFileSync(`${PAGES}${path}`, 'utf8')) + .join('\n'); + return new Map(files.map((file) => [file, read(file)])); } describe('every page uses the shared search field', () => { @@ -36,7 +40,7 @@ describe('every page uses the shared search field', () => { ), ); // The values filter sits under the component filter in the Components detail panel. - expect(labels).toEqual(['component-tree.ts: Filter values']); + expect(labels).toEqual(['components/component-detail.ts: Filter values']); }); it('uses Filter, not Find, in every placeholder', async () => { @@ -59,10 +63,10 @@ describe('pages with a splitter', () => { const page = (name: string) => sources.get(name) ?? ''; expect(page('di-inspector.ts')).toMatch(/]*view="injectors"/s); expect(page('di-inspector.ts')).toContain("storedPaneSize('injectors', 420)"); - expect(page('store-inspector.ts')).toMatch( + expect(page('store/store-log.ts')).toMatch( /]*view="store-log"[^>]*pane="before"/s, ); - expect(page('store-inspector.ts')).toContain("storedPaneSize('store-log', 300)"); + expect(page('store/store-log.ts')).toContain("storedPaneSize('store-log', 300)"); }); }); @@ -76,7 +80,7 @@ describe('pages use the shared state component', () => { 'plugins-inspector.ts', 'audit-inspector.ts', 'query-inspector.ts', - 'store-inspector.ts', + 'store/store-live.ts', 'network-inspector.ts', 'live-route.ts', 'route-lint.ts', @@ -128,12 +132,13 @@ describe('pages with a keyboard list use the shared roving list', () => { const sources = await pageSources(); const lists = { 'capacitor-inspector.ts': '[data-cap-call]', - 'pipes-inspector.ts': '[data-pipe-row]', + 'pipes/pipe-list.ts': '[data-pipe-row]', 'network/call-timeline.ts': '', 'network/ssr-requests.ts': '', 'query-inspector.ts': '', 'signal-inspector.ts': '', - 'store-inspector.ts': '', + 'store/store-log.ts': '', + 'store/store-events.ts': '', 'timeline.ts': '', }; for (const [file, selector] of Object.entries(lists)) { diff --git a/app/src/__tests__/tint-contrast.test.ts b/app/src/__tests__/tint-contrast.test.ts index 64dab59c..eeeb2889 100644 --- a/app/src/__tests__/tint-contrast.test.ts +++ b/app/src/__tests__/tint-contrast.test.ts @@ -221,9 +221,25 @@ describe('signal kind badges', () => { // both themes get a value these tests check. const TOKEN_ONLY_PAGES = [ 'analog-inspector.ts', + 'analog/analog-content.ts', + 'analog/analog-lint.ts', + 'analog/analog-render.ts', + 'analog/analog-routes.ts', + 'analog/analog-server.ts', + // The Analog page's styles, which moved out of the page into shared mixins. + '../styles/_analog.scss', 'route-timeline.ts', 'signal-graph-view.ts', 'signal-inspector.ts', + 'signals/resource-list.ts', + 'signals/signal-detail-state.ts', + 'signals/signal-detail.ts', + 'signals/signal-graph-section.ts', + 'signals/signal-list.ts', + 'signals/signal-model.ts', + 'signals/signal-state.ts', + 'signals/source-signals.ts', + 'signals/watched-signals.ts', ]; describe('pages that use theme tokens only', () => { @@ -233,8 +249,14 @@ describe('pages that use theme tokens only', () => { const fsName = 'node:fs'; const fs = (await import(/* @vite-ignore */ fsName)) as { readFileSync(path: string, encoding: 'utf8'): string; + existsSync(path: string): boolean; }; - const source = fs.readFileSync(`${PAGES}${file}`, 'utf8'); + // A component's template and style files count as part of its source. + const source = [file, file.replace(/\.ts$/, '.html'), file.replace(/\.ts$/, '.scss')] + .filter((path, index, all) => all.indexOf(path) === index) + .filter((path) => fs.existsSync(`${PAGES}${path}`)) + .map((path) => fs.readFileSync(`${PAGES}${path}`, 'utf8')) + .join('\n'); expect(source.match(/#[0-9a-f]{3,8}\b/gi) ?? []).toEqual([]); }); } diff --git a/app/src/pages/analog-inspector.html b/app/src/pages/analog-inspector.html new file mode 100644 index 00000000..a43d5c44 --- /dev/null +++ b/app/src/pages/analog-inspector.html @@ -0,0 +1,107 @@ +@if (project() === null && loadFailed()) { + +} @else if (project() === null) { +
+ + Reading the project… +
+} @else if (!project()!.analog) { +
+

This app is not an Analog app.

+

+ Add pangular() from @pangular-inspector/devtools/vite next to + analog() in vite.config.ts and run the Analog dev server. +

+
+} @else { +
+
+ Analog + {{ project()!.version || 'unknown' }} +
+
+ Pages + {{ pageCount() }} +
+
+ API routes + {{ project()!.api.length }} +
+
+ Server calls + {{ tabCalls().length }} +
+
+ Issues + {{ lintCount().errors + lintCount().warnings }} +
+ @if (page(); as p) { +
+ Open in the browser + {{ p.url }} +
+ } +
+ +
+ @for (v of views(); track v.id) { + + } +
+ +
+ @switch (view()) { + @case ('routes') { +
+ } + @case ('server') { +
+ } + @case ('render') { +
+ } + @case ('content') { +
+ } + @case ('data') { + + } + @case ('runtime') { + + } + @case ('lint') { +
+ } + } +
+} diff --git a/app/src/pages/analog-inspector.scss b/app/src/pages/analog-inspector.scss new file mode 100644 index 00000000..f9882fdf --- /dev/null +++ b/app/src/pages/analog-inspector.scss @@ -0,0 +1,176 @@ +@use 'mixins' as m; +@use 'analog' as a; + +:host { + --good: var(--ok); + --bad: var(--danger); +} +:host { + --mono: var(--font-mono); + display: grid; + gap: 16px; + min-width: 0; + color: var(--text); + font-size: 13px; +} +.loading { + display: flex; + gap: 12px; + align-items: center; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text-2); +} +.spinner { + width: 16px; + height: 16px; + flex: none; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + border-color: var(--accent-line); + } +} +@include a.base; +.summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 12px; + animation: enter 0.35s var(--ease) both; +} +.stat { + display: grid; + gap: 6px; + align-content: start; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); + transition: + border-color 180ms var(--ease), + background-color 180ms var(--ease); +} +.stat:hover { + border-color: var(--border-strong); +} +.stat.wide { + grid-column: span 2; + min-width: 0; +} +@media (max-width: 340px) { + .stat.wide { + grid-column: 1 / -1; + } +} +.stat .label { + color: var(--text-3); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.stat .value { + color: var(--text-strong); + font-size: 20px; + font-weight: 600; + letter-spacing: -0.01em; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} +.stat .value.mono { + overflow: hidden; + color: var(--text); + font-size: 13px; + font-weight: 500; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; +} +.stat[data-tone='warn'] .value { + color: var(--warn); +} +.stat[data-tone='good'] .value { + color: var(--ok); +} +.stat[data-tone='bad'] .value { + color: var(--danger-text); +} + +.tabs { + display: inline-flex; + flex-wrap: wrap; + gap: 2px; + justify-self: start; + max-width: 100%; + padding: 3px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); +} +[role='tab'] { + display: inline-flex; + gap: 8px; + align-items: center; + min-height: 28px; + padding: 4px 12px; + border: none; + border-radius: 7px; + background: transparent; + color: var(--text-2); + font: inherit; + font-weight: 500; + cursor: pointer; + transition: + background-color 180ms var(--ease), + color 180ms var(--ease), + box-shadow 180ms var(--ease); +} +[role='tab']:hover { + color: var(--text); +} +[role='tab'][aria-selected='true'] { + background: var(--surface-3); + color: var(--text-strong); + box-shadow: inset 0 0 0 1px var(--border-strong); +} +@include a.count; + +.panel { + display: grid; + gap: 16px; + min-width: 0; + animation: enter 0.35s var(--ease) both; +} +@include a.form; +@include a.cards; + +.empty { + display: grid; + gap: 8px; + justify-items: center; + padding: 40px 24px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + text-align: center; + animation: enter 0.35s var(--ease) both; +} +.empty p { + margin: 0; + max-width: 520px; + line-height: 1.6; +} +.empty-title { + margin: 0; + color: var(--text-strong); + font-size: 15px; + font-weight: 600; +} diff --git a/app/src/pages/analog-inspector.ts b/app/src/pages/analog-inspector.ts index 89441a6a..a3229ab7 100644 --- a/app/src/pages/analog-inspector.ts +++ b/app/src/pages/analog-inspector.ts @@ -1,2728 +1,89 @@ -import { - Component, - DestroyRef, - ElementRef, - computed, - effect, - inject, - input, - linkedSignal, - signal, - untracked, -} from '@angular/core'; +import { Component, effect, inject, input, untracked } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; -import { plural, time } from '../format'; -import { hostPageId } from '../page-id'; -import { rpcCall, rpcTry as call } from '../rpc'; -import { SourceLink } from '../ui/source-link'; -import { SearchField } from '../ui/search-field'; -import { AnalogPlayground, type PlaygroundTarget } from './analog-playground'; -import { AnalogBuild } from './analog-build'; import { AnalogData } from './analog-data'; -import { AnalogContentPreview } from './analog-content-preview'; import { AnalogRuntime } from './analog-runtime'; -import { AnalogTransform } from './analog-transform'; - -interface AnalogRoute { - id: string; - fullPath: string; - file?: string; - kind: 'page' | 'layout' | 'markdown' | 'group' | 'implicit'; - params: string[]; - catchAll?: 'required' | 'optional'; - serverFile?: string; - serverExports?: string[]; - routeMeta?: string[]; - title?: string; - children: AnalogRoute[]; -} - -interface ApiRoute { - path: string; - method: string; - file: string; -} - -interface ContentFile { - file: string; - slug: string; - attributes: Record; - error?: string; - schemaErrors?: string[]; -} - -interface ServerFn { - name: string; - file: string; - id: string; - method: string; -} - -interface AnalogProject { - analog: boolean; - version?: string; - routes: AnalogRoute[]; - api: ApiRoute[]; - middleware: string[]; - content: ContentFile[]; - serverFns?: ServerFn[]; - contentSchema?: { file: string; error?: string; unsupported?: string[] }; - contentUsage?: { file: string; api: string; line: number }[]; -} - -interface RenderCheck { - path: string; - configured?: RenderRow['mode']; - observed?: RenderRow['mode']; - status?: number; - ms?: number; - matches?: boolean; - note?: string; - error?: string; -} - -type ActionOutcome = 'success' | 'redirect' | 'invalid' | 'error'; - -interface AnalogCall { - id: number; - at: number; - kind: 'page' | 'load' | 'action' | 'fn' | 'api'; - method: string; - url: string; - route?: string; - status: number; - ms: number; - bytes?: number; - from: string; - render?: 'ssr' | 'client'; - outcome?: ActionOutcome; - location?: string; - seeded?: boolean; - preview?: string; - /** The tab the call came from, when the dev server could tell. */ - pageId?: string; -} - -interface AnalogPage { - pageId: string; - url: string; - chain: { path: string; file?: string; serverFile?: string; outlet?: string }[]; - load?: { preview: string; bytes: number; keys: string[] }; - serverContext?: string; - hydrated: number; - hydrationErrors: string[]; -} - -interface DuplicateLoad { - route: string; - ssrAt: number; - browserAt: number; -} - -interface ServerFnRefetch { - id: string; - ssrAt: number; - browserAt: number; -} - -interface AnalogState { - pages?: AnalogPage[]; - calls?: AnalogCall[]; - duplicates?: DuplicateLoad[]; - refetches?: ServerFnRefetch[]; -} - -interface Finding { - rule: string; - severity: 'error' | 'warning' | 'info'; - file?: string; - path?: string; - relatedFile?: string; - message: string; - fix: string; -} - -interface UrlMatch { - matched: boolean; - chain: AnalogRoute[]; - params: Record; - rejected: { file?: string; path: string; reason: string }[]; -} - -interface RenderRow { - path: string; - file?: string; - mode: 'ssr' | 'ssg' | 'client' | 'cached' | 'redirect'; - reason: string; - last?: { render?: 'ssr' | 'client'; status: number; ms: number; at: number }; -} - -interface PrerenderPlan { - dynamicConfig: boolean; - configPartial?: string[]; - listed: string[] | null; - fromRules?: string[]; - staticMissing: string[]; - dynamic: string[]; - built: string[]; - notBuilt: string[]; -} - -type View = 'routes' | 'server' | 'render' | 'content' | 'data' | 'runtime' | 'lint'; - -interface LintCard { - rule: string; - severity: Finding['severity']; - title: string; - summary: string; - fix: string; - items: { file?: string; path?: string; message?: string }[]; -} - -const LINT_TEXT: Record = { - 'scan-error': { - title: 'A folder could not be read', - summary: 'Its files are missing from routes, API routes and lint results.', - }, - 'duplicate-url': { - title: 'Two files serve the same URL', - summary: 'Only one of them is reachable; the other never renders.', - }, - 'sibling-params': { - title: 'Two dynamic pages in one folder', - summary: 'Both are [param] pages at the same level, so the first one always wins.', - }, - 'missing-default-export': { - title: 'Page has no default export', - summary: 'Analog needs the component as the default export, so the page renders nothing.', - }, - 'redirect-with-component': { - title: 'Redirect page also exports a component', - summary: 'The redirect runs first, so the component never shows.', - }, - 'redirect-path-match': { - title: 'Redirect matches too much', - summary: 'An empty-path redirect without pathMatch "full" catches every URL below it.', - }, - 'layout-without-outlet': { - title: 'Layout has no router-outlet', - summary: 'The layout has child pages, but without they never render.', - }, - 'server-without-load': { - title: '.server.ts without load or action', - summary: 'The server file exports no load, action or server function, so Analog calls nothing.', - }, - 'orphan-server-file': { - title: '.server.ts without a page', - summary: 'No page file sits next to it, so its load never runs.', - }, - 'api-method-suffix': { - title: 'Unknown method suffix on an API file', - summary: 'The suffix is not an HTTP method, so it becomes part of the URL.', - }, - 'duplicate-api-route': { - title: 'Two handlers for one API route', - summary: 'Two files answer the same method and path.', - }, - 'api-outside-prefix': { - title: 'Server route outside the API prefix', - summary: 'During vite dev only routes under the prefix reach Nitro.', - }, - 'prerender-unknown-route': { - title: 'Prerender entry matches no page', - summary: 'prerender.routes lists a path that no page file serves.', - }, - 'prerender-missing-root': { - title: 'Home page is not prerendered', - summary: 'static is on, but prerender.routes leaves out /.', - }, - 'content-frontmatter': { - title: 'Broken frontmatter', - summary: 'The markdown frontmatter cannot be read.', - }, - 'content-schema': { - title: 'Frontmatter does not match the content schema', - summary: 'The keys or values differ from the JSON Schema that analog.contentSchema names.', - }, - 'content-schema-unreadable': { - title: 'The content schema could not be used', - summary: 'analog.contentSchema names a file that is missing, outside the project or not JSON.', - }, - 'duplicate-slug': { - title: 'Two posts share a slug', - summary: 'injectContent picks one of them at random.', - }, - 'content-shadows-page': { - title: 'Markdown file takes over a page', - summary: - 'Files under src/content are routes too, so these URLs render the markdown file instead of the [param] page.', - }, - 'load-fetched-twice': { - title: 'load() runs twice', - summary: - 'These pages fetched their data while rendering on the server and again in the browser.', - }, - 'fn-fetched-twice': { - title: 'Server function read runs twice', - summary: - 'These reads ran while rendering on the server and again in the browser right after hydration.', - }, - 'restart-needed': { - title: 'New pages need a restart', - summary: 'These page files exist, but the running router does not know them yet.', - }, - 'hydration-error': { - title: 'Hydration error', - summary: 'The browser DOM did not match the server HTML.', - }, - 'api-not-found': { - title: 'API call failed with 404 or 405', - summary: 'A request hit a path or method that no server route handles.', - }, -}; -type Kind = 'all' | AnalogCall['kind']; - -/** Rows the call list renders at most, newest first. */ -const MAX_SHOWN_CALLS = 150; -/** Calls the dev server keeps (`MAX_CALLS` in packages/devtools/src/analog-server-log.ts). */ -const MAX_SERVER_CALLS = 200; - -const STATIC_MESSAGES = new Set([ - 'missing-default-export', - 'redirect-with-component', - 'redirect-path-match', - 'layout-without-outlet', - 'server-without-load', - 'orphan-server-file', - 'load-fetched-twice', - 'restart-needed', - 'prerender-missing-root', -]); - -const MODE_LABEL = { - ssr: 'SSR', - ssg: 'Prerendered', - client: 'Client only', - cached: 'Cached', - redirect: 'Redirect', -} as const; -const KIND_LABEL: Record = { - all: 'All', - page: 'Pages', - load: 'load()', - action: 'Actions', - fn: 'Server fn', - api: 'API', -}; -const OUTCOME_LABEL: Record = { - success: 'succeeded', - redirect: 'redirected', - invalid: 'validation errors', - error: 'failed', -}; -const OUTCOME_TONE: Record = { - success: 'good', - redirect: 'info', - invalid: 'warn', - error: 'bad', -}; - -function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth: number }[] = []) { - for (const route of routes) { - out.push({ route, depth }); - walk(route.children, depth + 1, out); - } - return out; -} - +import { AnalogCallsState } from './analog/analog-calls-state'; +import { AnalogContent } from './analog/analog-content'; +import { AnalogLint } from './analog/analog-lint'; +import type { AnalogRoute, ApiRoute } from './analog/analog-model'; +import { AnalogPageState } from './analog/analog-page-state'; +import { AnalogRender } from './analog/analog-render'; +import { AnalogRoutes } from './analog/analog-routes'; +import { AnalogServer } from './analog/analog-server'; + +/** + * The Analog page: the project summary, the view tabs, and one view per tab. The Routes, Server, + * Render, Content and Lint views live in `./analog/` and share the page's {@link AnalogPageState} + * and {@link AnalogCallsState}; the page keeps the members its tests and the shell read on itself. + */ @Component({ selector: 'app-analog-inspector', imports: [ - SearchField, - SourceLink, - AnalogPlayground, - AnalogBuild, + AnalogContent, AnalogData, - AnalogContentPreview, + AnalogLint, + AnalogRender, + AnalogRoutes, AnalogRuntime, - AnalogTransform, + AnalogServer, ], - template: ` - @if (project() === null && loadFailed()) { - - } @else if (project() === null) { -
- - Reading the project… -
- } @else if (!project()!.analog) { -
-

This app is not an Analog app.

-

- Add pangular() from @pangular-inspector/devtools/vite next to - analog() in vite.config.ts and run the Analog dev server. -

-
- } @else { -
-
- Analog - {{ project()!.version || 'unknown' }} -
-
- Pages - {{ pageCount() }} -
-
- API routes - {{ project()!.api.length }} -
-
- Server calls - {{ tabCalls().length }} -
-
- Issues - {{ lintCount().errors + lintCount().warnings }} -
- @if (page(); as p) { -
- Open in the browser - {{ p.url }} -
- } -
- -
- @for (v of views(); track v.id) { - - } -
- -
- @switch (view()) { - @case ('routes') { -
-
- - - -
- -
-
- @if (explainError(); as error) { -
{{ error }}
- } @else if (explained(); as e) { -
- @if (e.match.matched) { - {{ e.url }} renders -
    - @for (r of e.match.chain; track r.id) { -
  1. - @if (r.file) { - {{ - short(r.file) - }} - } @else { - {{ r.fullPath }} - } - {{ r.kind }} -
  2. - } -
- @if (paramList(e.match.params).length) { -
- @for (p of paramList(e.match.params); track p[0]) { - {{ p[0] }} = {{ p[1] }} - } -
- } - } @else { - {{ e.url }} matches no file route. Angular throws - NG04002 "Cannot match any routes". - @if (e.match.rejected.length) { -
    - @for (r of e.match.rejected.slice(0, 5); track $index) { -
  • - @if (r.file) { - {{ - short(r.file) - }} - } @else { - {{ r.path }} - } - {{ r.reason }} -
  • - } -
- } - } -
- } -
-
- - - - - - - - - - - - @for (row of routeRows(); track row.route.id) { - - - - - - - - } @empty { - - - - } - -
RouteFileDataRoute metaActions
-
- @if (row.depth) { - - } - {{ row.route.fullPath }} - {{ - kindText(row.route) - }} - @if (isOpen(row.route)) { - {{ openText(row.route) }} - } -
-
- @if (row.route.file) { - {{ dir(row.route.file) }}{{ base(row.route.file) }} - } @else { - folder only - } - -
- @for (e of serverExports(row.route); track e) { - {{ e }}() - } -
-
- @if (row.route.title) { - "{{ row.route.title }}" - } - @for (key of row.route.routeMeta ?? []; track key) { - @if (key !== 'title') { - {{ key }} - } - } - - @if (row.route.file && row.route.kind !== 'markdown') { - - } -
- @if (filter().trim()) { - No route matches the filter. Try part of a path or a file name. - } @else { - No file routes yet. Add a .page.ts file under src/app/pages. - } -
-
- @if (transformFile(); as file) { - - } - } - - @case ('server') { - @if (duplicates().length) { -
- load() ran twice for - @for (d of duplicates(); track d; let last = $last) { - {{ d }}{{ last ? '' : ', ' }} - } - : once while server rendering, again in the browser. TransferState did not serve the - server result. -
- } - @if (refetchedFns().length) { -
- Server function read ran twice: - @for (name of refetchedFns(); track name; let last = $last) { - {{ name }}{{ last ? '' : ', ' }} - } - ran while server rendering, then again in the browser. The browser did not use the - TransferState seed. -
- } -

Server calls

-
-
- Show calls of kind - @for (k of kinds; track k) { - - } -
-
- - {{ callsMessage() }} -
-
-
- - - @if (scopePageId()) { - - } - {{ matchingCalls().length }} of {{ tabCalls().length }} -
- @if (shownCount() < matchingCalls().length || serverFull()) { -

- @if (shownCount() < matchingCalls().length) { - Showing the newest {{ shownCount() }} of {{ matchingCalls().length }}. - } - @if (serverFull()) { - The dev server keeps only the newest {{ maxServerCalls }} calls and drops older - ones. - } -

- } - @if (calls().length) { -
- - - - - - - - - - - - - @for (c of calls(); track c.id) { - - - - - - - - - } - -
AtKindRequestStatusDurationFrom
{{ time(c.at) }} - {{ - kindLabel(c.kind) - }} - -
- {{ c.method }} - @if (fnOf(c); as fn) { - {{ fn.name }} - @if (fn.file) { - {{ - short(fn.file) - }} - } - } @else { - {{ c.url }} - } - @if (c.seeded) { - ran during server rendering - } - @if (c.outcome) { - {{ outcomeLabel(c.outcome) }} - @if (c.location) { - to {{ c.location }} - } - - } - @if (c.render) { - {{ c.render === 'ssr' ? 'server rendered' : 'client only' }} - } -
- @if (c.preview) { -
- Response -
{{ pretty(c.preview) }}
-
- } -
- {{ - c.status - }} - - @if (c.seeded) { - in process - } @else { - {{ c.ms }} ms - } - {{ c.from }}
-
- } @else if (tabCalls().length && callFiltered()) { -
-

No calls match.

-

- @if (callQuery().trim()) { - Nothing matches “{{ callQuery().trim() }}” with these filters. - } @else { - None of the {{ tabCalls().length }} calls fit these filters. - } -

-
- -
-
- } @else { -
-

- @if (tabCalls().length) { - No calls of this kind yet. - } @else if (allCalls().length) { - No calls from this tab yet. - } @else { - No calls yet. - } -

-

- Navigate in the app to see page renders, load() fetches, form actions, server - functions and API calls. - @if (!tabCalls().length && allCalls().length) { - Tick “All tabs (including untagged)” to see the - {{ allCalls().length }} calls from other tabs or tied to no tab. - } -

-
- } - -

API routes

-
- - - - - - - - - - - @for (api of project()!.api; track api.file + api.method) { - - - - - - - } @empty { - - - - } - -
MethodPathFileActions
- {{ api.method }} - {{ api.path }} - {{ dir(api.file) }}{{ base(api.file) }} - - -
- No API routes. Add a handler under src/server/routes to test it here. -
-
- - @if (serverFns().length) { -

Server functions

-
- - - - - - - - - - - @for (fn of serverFns(); track fn.id) { - - - - - - - } - -
MethodNameFileCalls
- {{ fn.method }} - {{ fn.name }} - {{ dir(fn.file) }}{{ base(fn.file) }} - {{ fnCalls(fn.id) }}
-
- } - - - } - - @case ('render') { - @if (plan()?.configPartial?.length) { -

- The analog() options in vite.config could not be read in full, so these modes and - the prerender plan may be incomplete. - @for (reason of plan()!.configPartial; track reason) { - {{ reason }} - } -

- } - @if (modeCounts().length) { -
- @for (m of modeCounts(); track m.mode) { - {{ m.label }} · {{ m.count }} - } -
- } -
- - - - - - - - - - - - @for (row of renderRows(); track row.path) { - - - - - - - - } @empty { - - - - } - -
RouteConfiguredLast requestDev server checkFile
{{ row.path }} -
- {{ - modeLabel(row.mode) - }} - {{ row.reason }} -
-
- @if (row.last; as last) { -
- {{ - last.status - }} - {{ last.render === 'client' ? 'client only' : 'server rendered' }} · - {{ last.ms }} ms - @if (mismatch(row)) { - differs from config - } -
- } @else { - not requested yet - } -
- @if (checks()[row.path]; as check) { - @if (check === 'running') { - Checking… - } @else if (check.error) { - {{ check.error }} - } @else { -
- {{ - modeLabel(check.observed!) - }} - {{ - check.matches ? 'fits the config' : 'differs from config' - }} - @if (check.note) { - {{ check.note }} - } -
- } - } - -
- @if (row.file) { - {{ dir(row.file) }}{{ base(row.file) }} - } -
- No page routes to render yet. Add a .page.ts file under src/app/pages. -
-
- @if (plan(); as p) { -
-

Prerender plan

- @if (p.dynamicConfig) { -

- prerender.routes is a function, so the list is known only at build time. -

- } @else { -
-
Listed
-
- @for (r of p.listed ?? ['/']; track r) { - {{ r }} - } - @if (!p.listed) { - default, nothing configured - } -
- @if (p.fromRules?.length) { -
From routeRules
-
- @for (r of p.fromRules; track r) { - {{ r }} - } -
- } - @if (p.staticMissing.length) { -
Static, not listed
-
- @for (r of p.staticMissing; track r) { - {{ r }} - } -
- } - @if (p.dynamic.length) { -
Need explicit entries
-
- @for (r of p.dynamic; track r) { - {{ r }} - } -
- } -
Build output
-
- @if (p.built.length) { - {{ plural(p.built.length, 'page') }} in dist/analog/public - @if (p.notBuilt.length) { - · missing - @for (r of p.notBuilt; track r) { - {{ r }} - } - } - } @else { - no build yet - } -
-
- } -
- } - - } - - @case ('content') { - @if (project()!.content.length) { -
- - - - - - - - - - - - - @for (f of project()!.content; track f.file) { - - - - - - - - - } - -
TitleURLSlugDateFileActions
- {{ f.attributes['title'] || '(no title)' }} - @if (f.error) { -
- {{ f.error }} -
- } - @if (shadowed(f.file); as page) { -
- takes over {{ base(page) }} -
- } - @if (f.schemaErrors?.length) { -
- {{ - plural(f.schemaErrors!.length, 'schema problem') - }} -
- } -
{{ contentUrl(f.file) ?? '' }}{{ f.slug }}{{ f.attributes['date'] || '' }} - {{ dir(f.file) }}{{ base(f.file) }} - - -
-
- } @else { -
-

No markdown files under src/content.

-

- Add a .md file there to see its title, slug, date and the URL it serves. -

-
- } - @if (project()!.content.length) { -
-
Schema
-
- @if (project()!.contentSchema; as schema) { - @if (schema.error) { - {{ schema.error }} - } @else { - {{ schema.file }} - @if (schema.unsupported?.length) { - - (skips {{ schema.unsupported!.join(', ') }}) - } - } - } @else { - none; set analog.contentSchema to check frontmatter - } -
-
Read by
-
- @for (u of project()!.contentUsage ?? []; track u.file + u.line) { - {{ u.api }} · {{ base(u.file) }}:{{ u.line }} - } @empty { - no injectContentFiles, injectContent or contentFilesSource call under - src - } -
-
- } - @if (previewFile(); as file) { - - } - } - - @case ('data') { - - } - - @case ('runtime') { - - } - - @case ('lint') { - @if (findings().length) { -

- {{ plural(findings().length, 'issue') }} in - {{ plural(lintCards().length, 'group') }}. Each card says what is wrong, where, and - how to fix it. -

-
    - @for (card of lintCards(); track card.rule) { -
  • -
    - {{ - card.severity - }} - {{ card.title }} - @if (card.items.length > 1) { - {{ card.items.length }} - } -
    -

    {{ card.summary }}

    -
      - @for (item of card.items; track $index) { -
    • - @if (item.file) { - {{ dir(item.file) }}{{ base(item.file) }} - } - @if (item.path && item.path !== item.file) { - {{ item.path }} - } - @if (item.message) { -

      {{ item.message }}

      - } -
    • - } -
    -
    How to fix {{ card.fix }}
    - {{ card.rule }} -
  • - } -
- } @else { -
- No Analog problems found. Routes, server files, prerender config - and content all check out. -
- } - } - } -
- } - `, - styles: ` - @use 'mixins' as m; - - :host { - --good: var(--ok); - --bad: var(--danger); - } - :host { - --mono: var(--font-mono); - display: grid; - gap: 16px; - min-width: 0; - color: var(--text); - font-size: 13px; - } - .loading { - display: flex; - gap: 12px; - align-items: center; - padding: 16px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface); - color: var(--text-2); - } - .spinner { - width: 16px; - height: 16px; - flex: none; - border: 2px solid var(--border-strong); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - } - @media (prefers-reduced-motion: reduce) { - .spinner { - animation: none; - border-color: var(--accent-line); - } - } - .tnum { - font-variant-numeric: tabular-nums; - } - .meta { - display: flex; - flex-wrap: wrap; - gap: 6px 8px; - align-items: center; - min-width: 0; - } - .empty-row { - padding: 24px 16px; - color: var(--text-2); - text-align: center; - } - tbody tr:hover td.empty-row { - background: transparent; - } - .empty-box { - display: grid; - gap: 4px; - padding: 24px 16px; - border: 1px dashed var(--border-strong); - border-radius: var(--radius); - text-align: center; - } - .empty-box p { - margin: 0; - line-height: 1.55; - } - .empty-lead { - color: var(--text-strong); - font-weight: 600; - } - .mono { - font-family: var(--mono); - font-size: 12.5px; - } - .muted { - color: var(--text-2); - } - .small { - font-size: 12px; - } - .nowrap { - white-space: nowrap; - } - code { - padding: 1px 6px; - border: 1px solid var(--border); - border-radius: 6px; - background: var(--surface-2); - color: var(--text-strong); - font-family: var(--mono); - font-size: 12.5px; - } - - .summary { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); - gap: 12px; - animation: enter 0.35s var(--ease) both; - } - .stat { - display: grid; - gap: 6px; - align-content: start; - padding: 12px 16px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface); - box-shadow: var(--shadow); - transition: - border-color 180ms var(--ease), - background-color 180ms var(--ease); - } - .stat:hover { - border-color: var(--border-strong); - } - .stat.wide { - grid-column: span 2; - min-width: 0; - } - @media (max-width: 340px) { - .stat.wide { - grid-column: 1 / -1; - } - } - .stat .label { - color: var(--text-3); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - } - .stat .value { - color: var(--text-strong); - font-size: 20px; - font-weight: 600; - letter-spacing: -0.01em; - font-variant-numeric: tabular-nums; - overflow-wrap: anywhere; - } - .stat .value.mono { - overflow: hidden; - color: var(--text); - font-size: 13px; - font-weight: 500; - line-height: 24px; - text-overflow: ellipsis; - white-space: nowrap; - } - .stat[data-tone='warn'] .value { - color: var(--warn); - } - .stat[data-tone='good'] .value { - color: var(--ok); - } - .stat[data-tone='bad'] .value { - color: var(--danger-text); - } - - .tabs { - display: inline-flex; - flex-wrap: wrap; - gap: 2px; - justify-self: start; - max-width: 100%; - padding: 3px; - border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg); - } - [role='tab'] { - display: inline-flex; - gap: 8px; - align-items: center; - min-height: 28px; - padding: 4px 12px; - border: none; - border-radius: 7px; - background: transparent; - color: var(--text-2); - font: inherit; - font-weight: 500; - cursor: pointer; - transition: - background-color 180ms var(--ease), - color 180ms var(--ease), - box-shadow 180ms var(--ease); - } - [role='tab']:hover { - color: var(--text); - } - [role='tab'][aria-selected='true'] { - background: var(--surface-3); - color: var(--text-strong); - box-shadow: inset 0 0 0 1px var(--border-strong); - } - .count { - min-width: 20px; - padding: 0 6px; - border-radius: 99px; - background: var(--surface-3); - color: var(--text-2); - font-size: 11px; - font-weight: 600; - line-height: 18px; - text-align: center; - font-variant-numeric: tabular-nums; - } - [role='tab'][aria-selected='true'] .count { - background: var(--border-strong); - color: var(--text); - } - .count[data-tone='warn'], - [role='tab'][aria-selected='true'] .count[data-tone='warn'] { - background: color-mix(in srgb, var(--warn) 12%, transparent); - color: var(--warn-text); - } - .count[data-tone='bad'], - [role='tab'][aria-selected='true'] .count[data-tone='bad'] { - background: color-mix(in srgb, var(--danger) 12%, transparent); - color: var(--danger-text); - } - - .panel { - display: grid; - gap: 16px; - min-width: 0; - animation: enter 0.35s var(--ease) both; - } - .toolbar { - position: sticky; - top: 0; - z-index: 2; - display: flex; - flex-wrap: wrap; - gap: 12px; - align-items: center; - justify-content: space-between; - padding: 12px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: color-mix(in srgb, var(--surface) 85%, transparent); - backdrop-filter: blur(10px); - } - .explain { - flex: 1 1 320px; - min-width: 0; - } - .filter { - flex: 0 1 240px; - min-width: 0; - } - @media (max-width: 560px) { - .filter { - flex: 1 1 100%; - } - } - .inline, - .row { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - } - .inline > label, - .playground > label:not(.check) { - color: var(--text-3); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - } - - .field { - box-sizing: border-box; - height: 34px; - padding: 0 12px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background-color: var(--bg); - color: var(--text); - font: inherit; - font-size: 13px; - transition: - border-color 180ms var(--ease), - box-shadow 180ms var(--ease); - } - .field.mono { - font-family: var(--mono); - font-size: 13px; - } - .field::placeholder { - color: var(--text-3); - } - .field:focus, - .field:focus-visible { - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-soft); - outline: none; - } - .method-select { - width: 112px; - font-family: var(--mono); - } - textarea.field { - height: auto; - padding: 8px 12px; - } - .grow { - flex: 1 1 180px; - min-width: 0; - } - - .btn { - display: inline-flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - height: 34px; - padding: 0 14px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--surface-2); - color: var(--text); - font: inherit; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: - background-color 160ms var(--ease), - border-color 160ms var(--ease), - color 160ms var(--ease); - } - .btn:hover { - background: var(--surface-3); - border-color: var(--control-border); - } - .btn:active { - transform: translateY(1px); - } - .btn.primary { - border-color: var(--accent); - background: var(--accent); - color: var(--accent-ink); - font-weight: 600; - } - .btn.primary:hover { - border-color: var(--accent-hover); - background: var(--accent-hover); - } - .btn.ghost { - height: 28px; - padding: 0 12px; - font-size: 12px; - } - .btn.ghost:hover { - border-color: var(--accent-line); - color: var(--text-strong); - } - td.actions { - width: 1%; - text-align: right; - vertical-align: middle; - } - .btn:disabled { - opacity: 0.5; - cursor: not-allowed; - } - [role='tab']:focus-visible, - .btn:focus-visible, - .table-wrap:focus-visible, - summary:focus-visible, - .segmented label:focus-within { - outline: 2px solid var(--accent); - outline-offset: 2px; - } - - .callout { - padding: 12px 16px; - border: 1px solid color-mix(in srgb, var(--hue-info) 30%, transparent); - border-radius: var(--radius); - background: color-mix(in srgb, var(--hue-info) 8%, var(--surface)); - line-height: 1.6; - animation: enter 0.35s var(--ease) both; - } - .callout[data-tone='good'] { - border-color: color-mix(in srgb, var(--ok) 30%, transparent); - background: color-mix(in srgb, var(--ok) 12%, transparent); - } - .callout[data-tone='warn'] { - border-color: color-mix(in srgb, var(--warn) 30%, transparent); - background: color-mix(in srgb, var(--warn) 12%, transparent); - } - .callout[data-tone='bad'] { - border-color: color-mix(in srgb, var(--danger) 30%, transparent); - background: color-mix(in srgb, var(--danger) 12%, transparent); - } - .callout strong { - color: var(--text-strong); - } - .callout strong.mono { - overflow-wrap: anywhere; - } - .chain { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin: 8px 0 0; - padding: 0; - list-style: none; - } - .chain li { - display: inline-flex; - gap: 6px; - align-items: center; - min-width: 0; - overflow-wrap: anywhere; - } - .chain li:not(:last-child)::after { - content: '›'; - margin-left: 2px; - color: var(--text-3); - } - .callout .chips { - margin-top: 8px; - } - .plain { - margin: 8px 0 0; - padding-left: 18px; - } - .plain li { - display: flex; - flex-wrap: wrap; - gap: 8px; - } - - .table-wrap { - overflow-x: auto; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface); - box-shadow: var(--shadow); - animation: enter 0.35s var(--ease) both; - } - table { - width: 100%; - min-width: 560px; - border-collapse: collapse; - } - th, - td { - padding: 10px 12px; - border-bottom: 1px solid var(--border); - text-align: left; - vertical-align: top; - } - tbody tr:last-child td { - border-bottom: none; - } - th { - position: sticky; - top: 0; - background: var(--surface); - color: var(--text-3); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - white-space: nowrap; - } - .num { - text-align: right; - font-variant-numeric: tabular-nums; - } - td.nowrap { - font-variant-numeric: tabular-nums; - } - tbody td { - transition: background-color 160ms var(--ease); - } - tbody tr:hover td { - background: var(--surface-2); - } - tr.open td { - background: var(--accent-soft); - color: var(--text-strong); - } - tr.open:hover td { - background: color-mix(in srgb, var(--accent) 16%, transparent); - } - tr.open td:first-child { - box-shadow: inset 2px 0 0 var(--accent); - } - tr.dim .path { - color: var(--text-2); - } - .route { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - } - .guide { - color: var(--text-3); - font-family: var(--mono); - } - .path { - color: var(--text-strong); - overflow-wrap: anywhere; - } - .file { - font-family: var(--mono); - font-size: 12.5px; - color: var(--text); - overflow-wrap: anywhere; - } - .dir { - color: var(--text-3); - } - - .pill, - .chip, - .status, - .method { - display: inline-block; - padding: 1px 8px; - border-radius: 99px; - font-size: 11px; - font-weight: 500; - line-height: 18px; - white-space: nowrap; - } - .pill { - border: 1px solid var(--border-strong); - background: var(--surface-2); - color: var(--text-2); - } - .chip { - margin: 0 4px 4px 0; - padding: 3px 10px; - border: 1px solid var(--border); - background: var(--surface-2); - color: var(--text); - font-size: 12px; - line-height: 16px; - } - .chip.mono { - font-size: 12px; - } - .chips { - display: flex; - flex-wrap: wrap; - gap: 8px; - } - .chips .pill { - padding: 3px 10px; - font-size: 12px; - } - .pill.live { - border-color: var(--accent-line); - background: color-mix(in srgb, var(--accent) 12%, var(--surface)); - color: var(--accent-text); - } - .pill[data-kind='layout'], - .pill[data-mode='redirect'] { - border-color: var(--hue-slate); - color: var(--hue-slate-text); - } - .pill[data-kind='markdown'] { - border-color: var(--hue-sky); - color: var(--hue-sky-text); - } - .pill[data-kind='load'], - .pill[data-call='load'], - .pill[data-call='fn'] { - border-color: var(--hue-teal); - color: var(--hue-teal-text); - } - .pill[data-mode='ssr'], - .pill[data-call='page'] { - border-color: var(--hue-blue); - color: var(--hue-blue-text); - } - .pill[data-mode='ssg'] { - border-color: var(--hue-green); - color: var(--hue-green-text); - } - .pill[data-mode='client'] { - border-color: var(--hue-yellow); - color: var(--hue-yellow-text); - } - .pill[data-mode='cached'] { - border-color: var(--hue-purple); - color: var(--hue-purple-text); - } - .pill[data-call='api'] { - border-color: var(--hue-orange); - color: var(--hue-orange-text); - } - .pill[data-call='action'] { - border-color: var(--hue-pink); - color: var(--hue-pink-text); - } - [data-tone='good'].pill { - @include m.tint(ok); - } - [data-tone='warn'].pill, - [data-tone='warn'].chip { - @include m.tint(warn); - } - [data-tone='bad'].pill, - [data-tone='bad'].chip { - @include m.tint(danger); - } - [data-tone='info'].pill { - @include m.tint(hue-info); - } - - .status { - border: 1px solid var(--border-strong); - font-family: var(--mono); - font-weight: 600; - font-variant-numeric: tabular-nums; - } - .status[data-status='good'] { - @include m.tint(ok); - } - .status[data-status='warn'] { - @include m.tint(warn); - } - .status[data-status='bad'] { - @include m.tint(danger); - } - .method { - min-width: 52px; - border: 1px solid var(--border-strong); - border-radius: 6px; - background: var(--surface-2); - color: var(--text); - font-family: var(--mono); - font-weight: 600; - text-align: center; - } - .method[data-method='GET'] { - @include m.tint(hue-sky); - } - .method[data-method='POST'] { - @include m.tint(ok); - } - .method[data-method='PUT'], - .method[data-method='PATCH'] { - @include m.tint(warn); - } - .method[data-method='DELETE'] { - @include m.tint(danger); - } - .request { - min-width: 260px; - } - .request .url { - color: var(--text-strong); - overflow-wrap: anywhere; - } - details { - margin-top: 8px; - } - summary { - width: fit-content; - border-radius: 6px; - color: var(--text-2); - font-size: 12px; - cursor: pointer; - transition: color 160ms var(--ease); - } - summary:hover { - color: var(--text); - } - .code { - margin: 8px 0 0; - padding: 10px 12px; - max-height: 220px; - overflow: auto; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--bg); - color: var(--text); - font-family: var(--mono); - font-size: 12.5px; - line-height: 1.55; - white-space: pre-wrap; - overflow-wrap: anywhere; - } - - .calls-bar { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 8px; - } - .calls-actions { - display: flex; - flex-wrap: wrap; - gap: 8px 12px; - align-items: center; - } - .calls-message { - color: var(--text-2); - font-size: 12.5px; - } - .call-filters { - display: flex; - flex-wrap: wrap; - gap: 8px 12px; - align-items: center; - } - .search { - position: relative; - flex: 1 1 220px; - min-width: 0; - } - .search .field { - width: 100%; - padding-left: 34px; - } - .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; - } - .search:focus-within .search-icon { - stroke: var(--accent); - } - .call-filters .check { - flex: none; - font-size: 13px; - font-weight: 500; - } - .total { - flex: none; - color: var(--text-2); - font-size: 12px; - white-space: nowrap; - font-variant-numeric: tabular-nums; - } - .note { - margin: 0; - padding: 8px 12px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--surface); - color: var(--text-2); - line-height: 1.5; - } - .empty-actions { - display: flex; - justify-content: center; - margin-top: 8px; - } - .segmented { - display: inline-flex; - flex-wrap: wrap; - gap: 2px; - justify-self: start; - margin: 0; - padding: 3px; - border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg); - } - .segmented label { - display: inline-flex; - gap: 6px; - align-items: center; - padding: 5px 12px; - border-radius: 7px; - background: transparent; - color: var(--text-2); - font-weight: 500; - cursor: pointer; - transition: - background-color 180ms var(--ease), - color 180ms var(--ease), - box-shadow 180ms var(--ease); - } - .segmented label:hover { - color: var(--text); - } - .segmented label .muted { - color: var(--text-3); - font-size: 12px; - font-variant-numeric: tabular-nums; - } - .segmented label.on { - background: var(--surface-3); - color: var(--text-strong); - box-shadow: inset 0 0 0 1px var(--border-strong); - } - .segmented label.on .muted { - color: var(--text-2); - } - - h2 { - display: flex; - gap: 8px; - align-items: center; - margin: 8px 0 0; - color: var(--text-3); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - } - .card { - display: grid; - gap: 12px; - padding: 16px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface); - box-shadow: var(--shadow); - animation: enter 0.35s var(--ease) both; - } - .card h2 { - margin: 0; - } - .check { - display: flex; - gap: 8px; - align-items: center; - color: var(--text-2); - cursor: pointer; - } - .check input { - width: 15px; - height: 15px; - margin: 0; - accent-color: var(--accent); - } - .check input:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } - .response { - display: grid; - gap: 8px; - padding: 12px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--surface-2); - } - .response .code { - margin: 0; - } - .facts { - display: grid; - grid-template-columns: max-content 1fr; - gap: 10px 16px; - margin: 0; - } - .facts dt { - padding-top: 3px; - color: var(--text-3); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - } - .facts dd { - margin: 0; - } - - .findings { - display: grid; - gap: 12px; - margin: 0; - padding: 0; - list-style: none; - animation: enter 0.35s var(--ease) both; - } - .findings > li { - position: relative; - padding: 16px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface); - box-shadow: - inset 3px 0 0 var(--hue-info), - var(--shadow); - transition: border-color 180ms var(--ease); - } - .findings > li:hover { - border-color: var(--border-strong); - } - .findings > li[data-tone='bad'] { - box-shadow: - inset 3px 0 0 var(--danger), - var(--shadow); - } - .findings > li[data-tone='warn'] { - box-shadow: - inset 3px 0 0 var(--warn), - var(--shadow); - } - .findings p { - margin: 8px 0 0; - line-height: 1.55; - } - .findings > li > p { - color: var(--text-2); - } - .finding-head { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - } - .finding-title { - color: var(--text-strong); - font-size: 14px; - font-weight: 600; - } - .where { - display: grid; - gap: 6px; - margin: 12px 0 0; - padding: 10px 12px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--surface-2); - list-style: none; - } - .where li { - display: flex; - flex-wrap: wrap; - gap: 4px 12px; - } - .where .detail { - flex-basis: 100%; - margin: 0; - color: var(--text-2); - overflow-wrap: anywhere; - } - .fix { - margin-top: 12px; - color: var(--text); - line-height: 1.55; - } - .fix strong { - display: block; - margin-bottom: 4px; - color: var(--ok); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - } - .rule { - display: block; - margin-top: 12px; - color: var(--text-3); - font-size: 12px; - } - - .empty { - display: grid; - gap: 8px; - justify-items: center; - padding: 40px 24px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface); - text-align: center; - animation: enter 0.35s var(--ease) both; - } - .empty p { - margin: 0; - max-width: 520px; - line-height: 1.6; - } - .empty-title { - margin: 0; - color: var(--text-strong); - font-size: 15px; - font-weight: 600; - } - `, + providers: [AnalogPageState, AnalogCallsState], + templateUrl: './analog-inspector.html', + styleUrl: './analog-inspector.scss', }) export class AnalogInspector { - protected readonly plural = plural; rpc = input(null); - readonly kinds: Kind[] = ['all', 'page', 'load', 'action', 'fn', 'api']; - readonly view = signal('routes'); - readonly project = signal(null); - readonly loadFailed = signal(false); - readonly state = signal({}); - readonly findings = signal([]); - readonly renderRows = signal([]); - readonly plan = signal(null); - readonly filter = signal(''); - readonly testUrl = signal(''); - readonly explained = signal<{ url: string; match: UrlMatch } | null>(null); - readonly explainError = signal(''); - readonly explaining = signal(false); - readonly kind = signal('all'); - /** The dev server origin for copied requests: the open page's, or the panel's own. */ - readonly devOrigin = computed(() => { - const url = this.page()?.url ?? ''; - if (/^https?:\/\//.test(url)) { - // A malformed page URL must not break the whole Server view. - try { - return new URL(url).origin; - } catch { - return ''; - } - } - return /^https?:$/.test(location.protocol) ? location.origin : ''; - }); - /** The page file whose Vite transform stack is open below the routes. */ - readonly transformFile = signal(null); - /** The content file open in the preview. */ - readonly previewFile = signal(null); - /** Dev server render checks by route. */ - readonly checks = signal>({}); - /** The route the last Try button handed to the playground. */ - // Cleared when the view changes, so coming back to Server doesn't load the last Try again. - readonly tryTarget = linkedSignal({ - source: this.view, - computation: () => null, - }); - private tryNonce = 0; - - private unsubscribe: (() => void) | null = null; - private refreshTimer: ReturnType | undefined; - private refreshRun = 0; - private readonly destroyRef = inject(DestroyRef); - private readonly host = inject>(ElementRef); - - private readonly hostPageId = hostPageId(); - - readonly page = computed(() => { - const pages = this.state().pages ?? []; - return (this.hostPageId ? pages.find((p) => p.pageId === this.hostPageId) : pages[0]) ?? null; - }); - /** Open page files, mapped to their named outlet ('' for the primary outlet). */ - readonly openFiles = computed(() => { - const open = new Map(); - for (const c of this.page()?.chain ?? []) { - // The primary outlet wins when one file renders in several outlets. - if (c.file && (!open.has(c.file) || !c.outlet)) open.set(c.file, c.outlet ?? ''); - } - return open; - }); - /** Every call the dev server keeps, from every tab. */ - readonly allCalls = computed(() => this.state().calls ?? []); - /** The tab the Server view shows by default: the panel's page. */ - readonly scopePageId = computed(() => this.page()?.pageId); - /** Shows the calls of every tab, and those the dev server tied to no tab. */ - readonly allTabs = signal(false); - /** The calls of the panel's tab, or every call with "All tabs" on or no page reporting. */ - readonly tabCalls = computed(() => { - const pageId = this.scopePageId(); - const calls = this.allCalls(); - return this.allTabs() || !pageId ? calls : calls.filter((c) => c.pageId === pageId); - }); - readonly maxServerCalls = MAX_SERVER_CALLS; - readonly callQuery = signal(''); - readonly failedOnly = signal(false); - readonly callsMessage = signal(''); - /** True when the search or the failed filter is on, so an empty list is a no-match. */ - readonly callFiltered = computed(() => !!this.callQuery().trim() || this.failedOnly()); - /** Every call that passes the kind, search and failed filters, oldest first. */ - readonly matchingCalls = computed(() => { - const kind = this.kind(); - const query = this.callQuery().trim().toLowerCase(); - const failedOnly = this.failedOnly(); - return this.tabCalls().filter( - (c) => - (kind === 'all' || c.kind === kind) && - (!failedOnly || this.isFailed(c)) && - (!query || this.callText(c).includes(query)), - ); - }); - readonly calls = computed(() => this.matchingCalls().slice(-MAX_SHOWN_CALLS).reverse()); - readonly shownCount = computed(() => this.calls().length); - readonly serverFull = computed(() => this.allCalls().length >= MAX_SERVER_CALLS); - readonly allRoutes = computed(() => walk(this.project()?.routes ?? [])); - readonly pageCount = computed( - () => this.allRoutes().filter((r) => r.route.file && r.route.kind !== 'layout').length, - ); - readonly routeRows = computed(() => { - const needle = this.filter().trim().toLowerCase(); - if (!needle) return this.allRoutes(); - return this.allRoutes().filter( - (r) => - r.route.fullPath.toLowerCase().includes(needle) || - !!r.route.file?.toLowerCase().includes(needle), - ); - }); - readonly serverFns = computed(() => this.project()?.serverFns ?? []); - private readonly fnById = computed(() => new Map(this.serverFns().map((fn) => [fn.id, fn]))); - /** The routes and server functions the shown calls touch, so a warning about another tab stays hidden. */ - private readonly shownRoutes = computed( - () => new Set(this.tabCalls().map((c) => `${c.kind} ${c.route ?? ''}`)), - ); - readonly refetchedFns = computed(() => - Array.from( - new Set( - (this.state().refetches ?? []) - .filter((r) => this.shownRoutes().has(`fn ${r.id}`)) - .map((r) => this.fnById().get(r.id)?.name ?? r.id), - ), - ), - ); - readonly duplicates = computed(() => - Array.from( - new Set( - (this.state().duplicates ?? []) - .filter((d) => this.shownRoutes().has(`load ${d.route}`)) - .map((d) => d.route), - ), - ), - ); - readonly modeCounts = computed(() => - (['ssr', 'ssg', 'cached', 'client', 'redirect'] as const) - .map((mode) => ({ - mode, - label: MODE_LABEL[mode], - count: this.renderRows().filter((r) => r.mode === mode).length, - })) - .filter((m) => m.count), - ); - readonly lintCards = computed(() => { - const order = { error: 0, warning: 1, info: 2 }; - const cards = new Map(); - for (const finding of this.findings()) { - let card = cards.get(finding.rule); - if (!card) { - const known = LINT_TEXT[finding.rule]; - card = { - rule: finding.rule, - severity: finding.severity, - title: known?.title ?? finding.rule, - summary: known?.summary ?? finding.message, - fix: finding.fix, - items: [], - }; - cards.set(finding.rule, card); - } - const message = - STATIC_MESSAGES.has(finding.rule) || finding.message === card.summary - ? undefined - : finding.message; - const same = (item: LintCard['items'][number]) => - item.file === finding.file && item.path === finding.path && item.message === message; - if (!card.items.some(same)) - card.items.push({ file: finding.file, path: finding.path, message }); - } - return Array.from(cards.values()).sort((a, b) => order[a.severity] - order[b.severity]); - }); - /** Errors and warnings; info findings are advice, so they are not counted. */ - readonly lintCount = computed(() => { - const findings = this.findings(); - return { - errors: findings.filter((f) => f.severity === 'error').length, - warnings: findings.filter((f) => f.severity === 'warning').length, - }; - }); - readonly lintTone = computed(() => { - const { errors, warnings } = this.lintCount(); - return errors ? 'bad' : warnings ? 'warn' : ''; - }); - readonly views = computed(() => { - const { errors, warnings } = this.lintCount(); - return [ - { id: 'routes' as View, label: 'Routes', count: this.pageCount() as number | null, tone: '' }, - { id: 'server' as View, label: 'Server', count: this.tabCalls().length, tone: '' }, - { id: 'render' as View, label: 'Render', count: this.renderRows().length, tone: '' }, - { - id: 'content' as View, - label: 'Content', - count: this.project()?.content.length ?? 0, - tone: '', - }, - { id: 'data' as View, label: 'Data', count: null, tone: '' }, - { id: 'runtime' as View, label: 'Runtime', count: null, tone: '' }, - { id: 'lint' as View, label: 'Lint', count: errors + warnings, tone: this.lintTone() }, - ]; - }); + private readonly pageState = inject(AnalogPageState); + private readonly callsState = inject(AnalogCallsState); + + readonly view = this.pageState.view; + readonly project = this.pageState.project; + readonly loadFailed = this.pageState.loadFailed; + readonly state = this.pageState.state; + readonly findings = this.pageState.findings; + readonly filter = this.pageState.filter; + readonly page = this.pageState.page; + readonly serverFns = this.pageState.serverFns; + readonly tabCalls = this.pageState.tabCalls; + readonly pageCount = this.pageState.pageCount; + readonly lintCount = this.pageState.lintCount; + readonly lintTone = this.pageState.lintTone; + readonly views = this.pageState.views; + + readonly kind = this.callsState.kind; + readonly callQuery = this.callsState.callQuery; + readonly failedOnly = this.callsState.failedOnly; + readonly tryTarget = this.callsState.tryTarget; + readonly duplicates = this.callsState.duplicates; constructor() { + this.pageState.connect(this.rpc); effect(() => { const client = this.rpc(); - if (client) untracked(() => void this.load(client)); + if (client) untracked(() => void this.pageState.load(client)); }); effect(() => { const view = this.view(); this.state(); - untracked(() => this.scheduleRefresh(view)); - }); - this.destroyRef.onDestroy(() => { - this.unsubscribe?.(); - clearTimeout(this.refreshTimer); + untracked(() => this.pageState.scheduleRefresh(view)); }); } - private async load(client: DevframeRpcClient) { - const project = await call(client, 'analog-project'); - if (this.destroyRef.destroyed) return; - this.project.set(project); - this.loadFailed.set(project === null); - try { - const shared = await client.scope('pangular').rpc.sharedState('analog'); - if (this.destroyRef.destroyed) return; - const apply = (value: unknown) => this.state.set((value as AnalogState) ?? {}); - apply(shared.value()); - this.unsubscribe?.(); - this.unsubscribe = shared.on('updated', apply); - } catch { - this.state.set({}); - } - if (this.destroyRef.destroyed) return; - await this.refresh(this.view()); - } - retry() { - const client = this.rpc(); - if (!client) return; - this.loadFailed.set(false); - void this.load(client); - } - - private scheduleRefresh(view: View) { - clearTimeout(this.refreshTimer); - this.refreshTimer = setTimeout(() => void this.refresh(view), 300); - } - - private async refresh(view: View) { - const client = this.rpc(); - if (!client) return; - const run = ++this.refreshRun; - const [findings, render] = await Promise.all([ - call(client, 'analog-lint', this.hostPageId ?? undefined), - call<{ rows: RenderRow[]; plan: PrerenderPlan }>(client, 'analog-render'), - ]); - if (run !== this.refreshRun) return; - this.findings.set(findings ?? []); - this.renderRows.set(render?.rows ?? []); - this.plan.set(render?.plan ?? null); - if (view === 'routes' || view === 'content' || this.project() === null) { - const project = await call(client, 'analog-project'); - if (project && run === this.refreshRun) { - this.project.set(project); - this.loadFailed.set(false); - } - } + this.pageState.retry(); } isOpen(route: AnalogRoute): boolean { - return !!route.file && this.openFiles().has(route.file); + return this.pageState.isOpen(route); } openText(route: AnalogRoute): string { - const outlet = route.file ? this.openFiles().get(route.file) : undefined; - return outlet ? `open in ${outlet}` : 'open'; - } - - kindText(route: AnalogRoute): string { - if (route.catchAll) return route.catchAll === 'optional' ? 'optional catch-all' : 'catch-all'; - if (route.kind === 'implicit') return 'folder'; - return route.kind; - } - - serverExports(route: AnalogRoute): string[] { - return (route.serverExports ?? []).filter((e) => e === 'load' || e === 'action'); - } - - short(file: string | undefined): string | undefined { - return file?.replace(/^\/src\/app\//, '').replace(/^\//, ''); - } - - dir(file: string): string { - const short = this.short(file) ?? file; - return short.includes('/') ? short.slice(0, short.lastIndexOf('/') + 1) : ''; - } - - base(file: string): string { - return file.slice(file.lastIndexOf('/') + 1); - } - - paramList(params: Record): [string, string][] { - return Object.entries(params); - } - - kindLabel(kind: Kind): string { - return KIND_LABEL[kind]; - } - - kindCount(kind: Kind): number { - return kind === 'all' - ? this.tabCalls().length - : this.tabCalls().filter((c) => c.kind === kind).length; - } - - modeLabel(mode: RenderRow['mode']): string { - return MODE_LABEL[mode]; - } - - mismatch(row: RenderRow): boolean { - if (!row.last?.render || row.mode === 'redirect') return false; - return row.mode === 'client' ? row.last.render !== 'client' : row.last.render === 'client'; - } - - fnOf(call: AnalogCall): ServerFn | undefined { - return call.kind === 'fn' && call.route ? this.fnById().get(call.route) : undefined; - } - - fnCalls(id: string): number { - return this.tabCalls().filter((c) => c.kind === 'fn' && c.route === id).length; - } - - outcomeLabel(outcome: ActionOutcome): string { - return OUTCOME_LABEL[outcome]; - } - - outcomeTone(outcome: ActionOutcome): string { - return OUTCOME_TONE[outcome]; - } - - statusClass(status: number): 'good' | 'warn' | 'bad' { - if (status >= 500 || status === 0) return 'bad'; - if (status >= 400) return 'warn'; - return 'good'; - } - - tone(severity: Finding['severity']): 'bad' | 'warn' | 'info' { - return severity === 'error' ? 'bad' : severity === 'warning' ? 'warn' : 'info'; - } - - shadowed(file: string): string | undefined { - const finding = this.findings().find( - (f) => f.rule === 'content-shadows-page' && f.file === file, - ); - return finding?.relatedFile; - } - - contentUrl(file: string): string | undefined { - return this.allRoutes().find((r) => r.route.file === file)?.route.fullPath; - } - - pretty(text: string): string { - try { - return JSON.stringify(JSON.parse(text), null, 2); - } catch { - return text; - } - } - - readonly time = time; - - showTransform(file: string) { - this.transformFile.set(this.transformFile() === file ? null : file); - } - - closeTransform(file: string) { - this.transformFile.set(null); - this.refocus(`Show the Vite transform stack of ${file}`); - } - - showPreview(file: string) { - this.previewFile.set(this.previewFile() === file ? null : file); - } - - closePreview(file: string) { - this.previewFile.set(null); - this.refocus(`Preview ${file}`); - } - - /** Hands focus back to the row button that opened a detail card. */ - private refocus(label: string) { - queueMicrotask(() => - this.host.nativeElement - .querySelector(`button[aria-label="${CSS.escape(label)}"]`) - ?.focus(), - ); - } - - async checkRender(path: string) { - this.checks.update((all) => ({ ...all, [path]: 'running' })); - const check = await call(this.rpc(), 'analog-render-check', path); - this.checks.update((all) => ({ - ...all, - [path]: check ?? { path, error: 'No answer from the devtools server.' }, - })); + return this.pageState.openText(route); } tryApi(api: ApiRoute) { - this.tryTarget.set({ route: api, nonce: ++this.tryNonce }); - } - - /** A failed call answered with 400 or more, never answered, or ended in an error outcome. */ - isFailed(c: AnalogCall): boolean { - return !c.status || c.status >= 400 || c.outcome === 'error'; - } - - private callText(c: AnalogCall): string { - const fn = this.fnOf(c); - return [c.url, c.route, fn?.name, fn?.file].filter(Boolean).join(' ').toLowerCase(); - } - - clearCallFilters() { - this.callQuery.set(''); - this.failedOnly.set(false); - this.kind.set('all'); - queueMicrotask(() => - this.host.nativeElement.querySelector('#analog-call-search')?.focus(), - ); - } - - async clearCalls() { - const client = this.rpc(); - try { - if (!client) throw new Error('No devtools server'); - await rpcCall(client, 'analog-clear-calls'); - this.callsMessage.set('Calls cleared.'); - // The button is disabled once the list is empty, so hand focus to the kind filter. - queueMicrotask(() => - this.host.nativeElement - .querySelector('input[name="analog-kind"]:checked') - ?.focus(), - ); - } catch { - this.callsMessage.set('Could not clear the calls.'); - } - } - - async explain() { - const url = this.testUrl().trim(); - if (!url) { - this.explained.set(null); - this.explainError.set('Type a URL to explain, for example /products/42.'); - return; - } - this.explaining.set(true); - this.explainError.set(''); - try { - const match = (await rpcCall(this.rpc(), 'analog-explain-url', url)) as UrlMatch | null; - if (!match) throw new Error('The devtools server did not answer'); - this.explained.set({ url, match }); - } catch (error) { - const reason = String((error as Error)?.message || 'The devtools server did not answer'); - this.explained.set(null); - this.explainError.set(`Could not explain ${url}. ${reason.replace(/\.?$/, '.')}`); - } finally { - this.explaining.set(false); - } + this.callsState.tryApi(api); } onKey(event: KeyboardEvent) { diff --git a/app/src/pages/analog/analog-calls-state.ts b/app/src/pages/analog/analog-calls-state.ts new file mode 100644 index 00000000..fbcb0891 --- /dev/null +++ b/app/src/pages/analog/analog-calls-state.ts @@ -0,0 +1,144 @@ +import { Injectable, computed, inject, linkedSignal, signal } from '@angular/core'; +import { rpcCall } from '../../rpc'; +import type { PlaygroundTarget } from '../analog-playground'; +import { + KIND_LABEL, + MAX_SERVER_CALLS, + MAX_SHOWN_CALLS, + OUTCOME_LABEL, + OUTCOME_TONE, + type ActionOutcome, + type AnalogCall, + type ApiRoute, + type Kind, + type ServerFn, + type View, +} from './analog-model'; +import { AnalogPageState } from './analog-page-state'; + +/** + * The Server view's call list: the kind, search and failed filters, the warnings about reads + * that ran twice, and the route the last Try button handed to the playground. Kept on the page, + * so the filters stay set when another view is open. + */ +@Injectable() +export class AnalogCallsState { + private readonly page = inject(AnalogPageState); + + readonly kinds: Kind[] = ['all', 'page', 'load', 'action', 'fn', 'api']; + readonly kind = signal('all'); + /** The route the last Try button handed to the playground. */ + // Cleared when the view changes, so coming back to Server doesn't load the last Try again. + readonly tryTarget = linkedSignal({ + source: this.page.view, + computation: () => null, + }); + private tryNonce = 0; + + readonly maxServerCalls = MAX_SERVER_CALLS; + readonly callQuery = signal(''); + readonly failedOnly = signal(false); + readonly callsMessage = signal(''); + /** True when the search or the failed filter is on, so an empty list is a no-match. */ + readonly callFiltered = computed(() => !!this.callQuery().trim() || this.failedOnly()); + /** Every call that passes the kind, search and failed filters, oldest first. */ + readonly matchingCalls = computed(() => { + const kind = this.kind(); + const query = this.callQuery().trim().toLowerCase(); + const failedOnly = this.failedOnly(); + return this.page + .tabCalls() + .filter( + (c) => + (kind === 'all' || c.kind === kind) && + (!failedOnly || this.isFailed(c)) && + (!query || this.callText(c).includes(query)), + ); + }); + readonly calls = computed(() => this.matchingCalls().slice(-MAX_SHOWN_CALLS).reverse()); + readonly shownCount = computed(() => this.calls().length); + readonly serverFull = computed(() => this.page.allCalls().length >= MAX_SERVER_CALLS); + /** The routes and server functions the shown calls touch, so a warning about another tab stays hidden. */ + private readonly shownRoutes = computed( + () => new Set(this.page.tabCalls().map((c) => `${c.kind} ${c.route ?? ''}`)), + ); + readonly refetchedFns = computed(() => + Array.from( + new Set( + (this.page.state().refetches ?? []) + .filter((r) => this.shownRoutes().has(`fn ${r.id}`)) + .map((r) => this.page.fnById().get(r.id)?.name ?? r.id), + ), + ), + ); + readonly duplicates = computed(() => + Array.from( + new Set( + (this.page.state().duplicates ?? []) + .filter((d) => this.shownRoutes().has(`load ${d.route}`)) + .map((d) => d.route), + ), + ), + ); + + kindLabel(kind: Kind): string { + return KIND_LABEL[kind]; + } + + kindCount(kind: Kind): number { + return kind === 'all' + ? this.page.tabCalls().length + : this.page.tabCalls().filter((c) => c.kind === kind).length; + } + + fnOf(call: AnalogCall): ServerFn | undefined { + return call.kind === 'fn' && call.route ? this.page.fnById().get(call.route) : undefined; + } + + fnCalls(id: string): number { + return this.page.tabCalls().filter((c) => c.kind === 'fn' && c.route === id).length; + } + + outcomeLabel(outcome: ActionOutcome): string { + return OUTCOME_LABEL[outcome]; + } + + outcomeTone(outcome: ActionOutcome): string { + return OUTCOME_TONE[outcome]; + } + + tryApi(api: ApiRoute) { + this.tryTarget.set({ route: api, nonce: ++this.tryNonce }); + } + + /** A failed call answered with 400 or more, never answered, or ended in an error outcome. */ + isFailed(c: AnalogCall): boolean { + return !c.status || c.status >= 400 || c.outcome === 'error'; + } + + private callText(c: AnalogCall): string { + const fn = this.fnOf(c); + return [c.url, c.route, fn?.name, fn?.file].filter(Boolean).join(' ').toLowerCase(); + } + + /** Clears the search, the failed filter and the kind. */ + resetFilters() { + this.callQuery.set(''); + this.failedOnly.set(false); + this.kind.set('all'); + } + + /** Asks the dev server to drop its calls; true when it did. */ + async clearCalls(): Promise { + const client = this.page.rpc(); + try { + if (!client) throw new Error('No devtools server'); + await rpcCall(client, 'analog-clear-calls'); + this.callsMessage.set('Calls cleared.'); + return true; + } catch { + this.callsMessage.set('Could not clear the calls.'); + return false; + } + } +} diff --git a/app/src/pages/analog/analog-content.html b/app/src/pages/analog/analog-content.html new file mode 100644 index 00000000..51ca81a7 --- /dev/null +++ b/app/src/pages/analog/analog-content.html @@ -0,0 +1,108 @@ +@if (project()!.content.length) { +
+ + + + + + + + + + + + + @for (f of project()!.content; track f.file) { + + + + + + + + + } + +
TitleURLSlugDateFileActions
+ {{ f.attributes['title'] || '(no title)' }} + @if (f.error) { +
+ {{ f.error }} +
+ } + @if (shadowed(f.file); as page) { +
+ takes over {{ base(page) }} +
+ } + @if (f.schemaErrors?.length) { +
+ {{ + plural(f.schemaErrors!.length, 'schema problem') + }} +
+ } +
{{ contentUrl(f.file) ?? '' }}{{ f.slug }}{{ f.attributes['date'] || '' }} + {{ dir(f.file) }}{{ base(f.file) }} + + +
+
+} @else { +
+

No markdown files under src/content.

+

Add a .md file there to see its title, slug, date and the URL it serves.

+
+} +@if (project()!.content.length) { +
+
Schema
+
+ @if (project()!.contentSchema; as schema) { + @if (schema.error) { + {{ schema.error }} + } @else { + {{ schema.file }} + @if (schema.unsupported?.length) { + (skips {{ schema.unsupported!.join(', ') }}) + } + } + } @else { + none; set analog.contentSchema to check frontmatter + } +
+
Read by
+
+ @for (u of project()!.contentUsage ?? []; track u.file + u.line) { + {{ u.api }} · {{ base(u.file) }}:{{ u.line }} + } @empty { + no injectContentFiles, injectContent or contentFilesSource call under src + } +
+
+} +@if (previewFile(); as file) { + +} diff --git a/app/src/pages/analog/analog-content.scss b/app/src/pages/analog/analog-content.scss new file mode 100644 index 00000000..c6d3af3f --- /dev/null +++ b/app/src/pages/analog/analog-content.scss @@ -0,0 +1,11 @@ +@use 'analog' as a; + +// A wrapper only: the view's parts are items of the page's panel grid, as they were. +:host { + display: contents; +} + +@include a.base; +@include a.form; +@include a.table; +@include a.cards; diff --git a/app/src/pages/analog/analog-content.ts b/app/src/pages/analog/analog-content.ts new file mode 100644 index 00000000..98b99fba --- /dev/null +++ b/app/src/pages/analog/analog-content.ts @@ -0,0 +1,54 @@ +import { Component, ElementRef, inject } from '@angular/core'; +import { plural } from '../../format'; +import { SourceLink } from '../../ui/source-link'; +import { AnalogContentPreview } from '../analog-content-preview'; +import { base, dir } from './analog-model'; +import { AnalogPageState } from './analog-page-state'; + +/** The Content view of the Analog page: the markdown files, the content schema and the preview. */ +@Component({ + selector: 'div[appAnalogContent]', + imports: [SourceLink, AnalogContentPreview], + templateUrl: './analog-content.html', + styleUrl: './analog-content.scss', +}) +export class AnalogContent { + private readonly page = inject(AnalogPageState); + private readonly host = inject>(ElementRef); + + protected readonly plural = plural; + protected readonly rpc = this.page.rpc; + protected readonly project = this.page.project; + protected readonly previewFile = this.page.previewFile; + protected readonly dir = dir; + protected readonly base = base; + + protected shadowed(file: string): string | undefined { + const finding = this.page + .findings() + .find((f) => f.rule === 'content-shadows-page' && f.file === file); + return finding?.relatedFile; + } + + protected contentUrl(file: string): string | undefined { + return this.page.allRoutes().find((r) => r.route.file === file)?.route.fullPath; + } + + protected showPreview(file: string) { + this.previewFile.set(this.previewFile() === file ? null : file); + } + + protected closePreview(file: string) { + this.previewFile.set(null); + this.refocus(`Preview ${file}`); + } + + /** Hands focus back to the row button that opened a detail card. */ + private refocus(label: string) { + queueMicrotask(() => + this.host.nativeElement + .querySelector(`button[aria-label="${CSS.escape(label)}"]`) + ?.focus(), + ); + } +} diff --git a/app/src/pages/analog/analog-lint.html b/app/src/pages/analog/analog-lint.html new file mode 100644 index 00000000..9ac112e8 --- /dev/null +++ b/app/src/pages/analog/analog-lint.html @@ -0,0 +1,45 @@ +@if (findings().length) { +

+ {{ plural(findings().length, 'issue') }} in {{ plural(lintCards().length, 'group') }}. Each card + says what is wrong, where, and how to fix it. +

+
    + @for (card of lintCards(); track card.rule) { +
  • +
    + {{ card.severity }} + {{ card.title }} + @if (card.items.length > 1) { + {{ card.items.length }} + } +
    +

    {{ card.summary }}

    +
      + @for (item of card.items; track $index) { +
    • + @if (item.file) { + {{ dir(item.file) }}{{ base(item.file) }} + } + @if (item.path && item.path !== item.file) { + {{ item.path }} + } + @if (item.message) { +

      {{ item.message }}

      + } +
    • + } +
    +
    How to fix {{ card.fix }}
    + {{ card.rule }} +
  • + } +
+} @else { +
+ No Analog problems found. Routes, server files, prerender config and content + all check out. +
+} diff --git a/app/src/pages/analog/analog-lint.scss b/app/src/pages/analog/analog-lint.scss new file mode 100644 index 00000000..49de25c3 --- /dev/null +++ b/app/src/pages/analog/analog-lint.scss @@ -0,0 +1,12 @@ +@use 'analog' as a; + +// A wrapper only: the view's parts are items of the page's panel grid, as they were. +:host { + display: contents; +} + +@include a.base; +@include a.count; +@include a.callout; +@include a.table; +@include a.findings; diff --git a/app/src/pages/analog/analog-lint.ts b/app/src/pages/analog/analog-lint.ts new file mode 100644 index 00000000..b71cc3c6 --- /dev/null +++ b/app/src/pages/analog/analog-lint.ts @@ -0,0 +1,26 @@ +import { Component, inject } from '@angular/core'; +import { plural } from '../../format'; +import { SourceLink } from '../../ui/source-link'; +import { base, dir, type Finding } from './analog-model'; +import { AnalogPageState } from './analog-page-state'; + +/** The Lint view of the Analog page: one card per rule, with where it fired and how to fix it. */ +@Component({ + selector: 'div[appAnalogLint]', + imports: [SourceLink], + templateUrl: './analog-lint.html', + styleUrl: './analog-lint.scss', +}) +export class AnalogLint { + private readonly page = inject(AnalogPageState); + + protected readonly plural = plural; + protected readonly findings = this.page.findings; + protected readonly lintCards = this.page.lintCards; + protected readonly dir = dir; + protected readonly base = base; + + protected tone(severity: Finding['severity']): 'bad' | 'warn' | 'info' { + return severity === 'error' ? 'bad' : severity === 'warning' ? 'warn' : 'info'; + } +} diff --git a/app/src/pages/analog/analog-model.ts b/app/src/pages/analog/analog-model.ts new file mode 100644 index 00000000..4b4dd670 --- /dev/null +++ b/app/src/pages/analog/analog-model.ts @@ -0,0 +1,342 @@ +// The data the Analog page reads from the devtools server (`analog-project`, `analog-lint`, +// `analog-render` and the `analog` shared state), and the labels its views show for it. +export interface AnalogRoute { + id: string; + fullPath: string; + file?: string; + kind: 'page' | 'layout' | 'markdown' | 'group' | 'implicit'; + params: string[]; + catchAll?: 'required' | 'optional'; + serverFile?: string; + serverExports?: string[]; + routeMeta?: string[]; + title?: string; + children: AnalogRoute[]; +} + +export interface ApiRoute { + path: string; + method: string; + file: string; +} + +export interface ContentFile { + file: string; + slug: string; + attributes: Record; + error?: string; + schemaErrors?: string[]; +} + +export interface ServerFn { + name: string; + file: string; + id: string; + method: string; +} + +export interface AnalogProject { + analog: boolean; + version?: string; + routes: AnalogRoute[]; + api: ApiRoute[]; + middleware: string[]; + content: ContentFile[]; + serverFns?: ServerFn[]; + contentSchema?: { file: string; error?: string; unsupported?: string[] }; + contentUsage?: { file: string; api: string; line: number }[]; +} + +export interface RenderCheck { + path: string; + configured?: RenderRow['mode']; + observed?: RenderRow['mode']; + status?: number; + ms?: number; + matches?: boolean; + note?: string; + error?: string; +} + +export type ActionOutcome = 'success' | 'redirect' | 'invalid' | 'error'; + +export interface AnalogCall { + id: number; + at: number; + kind: 'page' | 'load' | 'action' | 'fn' | 'api'; + method: string; + url: string; + route?: string; + status: number; + ms: number; + bytes?: number; + from: string; + render?: 'ssr' | 'client'; + outcome?: ActionOutcome; + location?: string; + seeded?: boolean; + preview?: string; + /** The tab the call came from, when the dev server could tell. */ + pageId?: string; +} + +export interface AnalogPage { + pageId: string; + url: string; + chain: { path: string; file?: string; serverFile?: string; outlet?: string }[]; + load?: { preview: string; bytes: number; keys: string[] }; + serverContext?: string; + hydrated: number; + hydrationErrors: string[]; +} + +export interface DuplicateLoad { + route: string; + ssrAt: number; + browserAt: number; +} + +export interface ServerFnRefetch { + id: string; + ssrAt: number; + browserAt: number; +} + +export interface AnalogState { + pages?: AnalogPage[]; + calls?: AnalogCall[]; + duplicates?: DuplicateLoad[]; + refetches?: ServerFnRefetch[]; +} + +export interface Finding { + rule: string; + severity: 'error' | 'warning' | 'info'; + file?: string; + path?: string; + relatedFile?: string; + message: string; + fix: string; +} + +export interface UrlMatch { + matched: boolean; + chain: AnalogRoute[]; + params: Record; + rejected: { file?: string; path: string; reason: string }[]; +} + +export interface RenderRow { + path: string; + file?: string; + mode: 'ssr' | 'ssg' | 'client' | 'cached' | 'redirect'; + reason: string; + last?: { render?: 'ssr' | 'client'; status: number; ms: number; at: number }; +} + +export interface PrerenderPlan { + dynamicConfig: boolean; + configPartial?: string[]; + listed: string[] | null; + fromRules?: string[]; + staticMissing: string[]; + dynamic: string[]; + built: string[]; + notBuilt: string[]; +} + +export type View = 'routes' | 'server' | 'render' | 'content' | 'data' | 'runtime' | 'lint'; + +export interface LintCard { + rule: string; + severity: Finding['severity']; + title: string; + summary: string; + fix: string; + items: { file?: string; path?: string; message?: string }[]; +} + +export const LINT_TEXT: Record = { + 'scan-error': { + title: 'A folder could not be read', + summary: 'Its files are missing from routes, API routes and lint results.', + }, + 'duplicate-url': { + title: 'Two files serve the same URL', + summary: 'Only one of them is reachable; the other never renders.', + }, + 'sibling-params': { + title: 'Two dynamic pages in one folder', + summary: 'Both are [param] pages at the same level, so the first one always wins.', + }, + 'missing-default-export': { + title: 'Page has no default export', + summary: 'Analog needs the component as the default export, so the page renders nothing.', + }, + 'redirect-with-component': { + title: 'Redirect page also exports a component', + summary: 'The redirect runs first, so the component never shows.', + }, + 'redirect-path-match': { + title: 'Redirect matches too much', + summary: 'An empty-path redirect without pathMatch "full" catches every URL below it.', + }, + 'layout-without-outlet': { + title: 'Layout has no router-outlet', + summary: 'The layout has child pages, but without they never render.', + }, + 'server-without-load': { + title: '.server.ts without load or action', + summary: 'The server file exports no load, action or server function, so Analog calls nothing.', + }, + 'orphan-server-file': { + title: '.server.ts without a page', + summary: 'No page file sits next to it, so its load never runs.', + }, + 'api-method-suffix': { + title: 'Unknown method suffix on an API file', + summary: 'The suffix is not an HTTP method, so it becomes part of the URL.', + }, + 'duplicate-api-route': { + title: 'Two handlers for one API route', + summary: 'Two files answer the same method and path.', + }, + 'api-outside-prefix': { + title: 'Server route outside the API prefix', + summary: 'During vite dev only routes under the prefix reach Nitro.', + }, + 'prerender-unknown-route': { + title: 'Prerender entry matches no page', + summary: 'prerender.routes lists a path that no page file serves.', + }, + 'prerender-missing-root': { + title: 'Home page is not prerendered', + summary: 'static is on, but prerender.routes leaves out /.', + }, + 'content-frontmatter': { + title: 'Broken frontmatter', + summary: 'The markdown frontmatter cannot be read.', + }, + 'content-schema': { + title: 'Frontmatter does not match the content schema', + summary: 'The keys or values differ from the JSON Schema that analog.contentSchema names.', + }, + 'content-schema-unreadable': { + title: 'The content schema could not be used', + summary: 'analog.contentSchema names a file that is missing, outside the project or not JSON.', + }, + 'duplicate-slug': { + title: 'Two posts share a slug', + summary: 'injectContent picks one of them at random.', + }, + 'content-shadows-page': { + title: 'Markdown file takes over a page', + summary: + 'Files under src/content are routes too, so these URLs render the markdown file instead of the [param] page.', + }, + 'load-fetched-twice': { + title: 'load() runs twice', + summary: + 'These pages fetched their data while rendering on the server and again in the browser.', + }, + 'fn-fetched-twice': { + title: 'Server function read runs twice', + summary: + 'These reads ran while rendering on the server and again in the browser right after hydration.', + }, + 'restart-needed': { + title: 'New pages need a restart', + summary: 'These page files exist, but the running router does not know them yet.', + }, + 'hydration-error': { + title: 'Hydration error', + summary: 'The browser DOM did not match the server HTML.', + }, + 'api-not-found': { + title: 'API call failed with 404 or 405', + summary: 'A request hit a path or method that no server route handles.', + }, +}; +export type Kind = 'all' | AnalogCall['kind']; + +/** Rows the call list renders at most, newest first. */ +export const MAX_SHOWN_CALLS = 150; +/** Calls the dev server keeps (`MAX_CALLS` in packages/devtools/src/analog-server-log.ts). */ +export const MAX_SERVER_CALLS = 200; + +export const STATIC_MESSAGES = new Set([ + 'missing-default-export', + 'redirect-with-component', + 'redirect-path-match', + 'layout-without-outlet', + 'server-without-load', + 'orphan-server-file', + 'load-fetched-twice', + 'restart-needed', + 'prerender-missing-root', +]); + +export const MODE_LABEL = { + ssr: 'SSR', + ssg: 'Prerendered', + client: 'Client only', + cached: 'Cached', + redirect: 'Redirect', +} as const; +export const KIND_LABEL: Record = { + all: 'All', + page: 'Pages', + load: 'load()', + action: 'Actions', + fn: 'Server fn', + api: 'API', +}; +export const OUTCOME_LABEL: Record = { + success: 'succeeded', + redirect: 'redirected', + invalid: 'validation errors', + error: 'failed', +}; +export const OUTCOME_TONE: Record = { + success: 'good', + redirect: 'info', + invalid: 'warn', + error: 'bad', +}; + +export function walk( + routes: AnalogRoute[], + depth = 0, + out: { route: AnalogRoute; depth: number }[] = [], +) { + for (const route of routes) { + out.push({ route, depth }); + walk(route.children, depth + 1, out); + } + return out; +} + +// The helpers below were methods of the page; the views share them. + +export function short(file: string | undefined): string | undefined { + return file?.replace(/^\/src\/app\//, '').replace(/^\//, ''); +} + +export function dir(file: string): string { + const shortened = short(file) ?? file; + return shortened.includes('/') ? shortened.slice(0, shortened.lastIndexOf('/') + 1) : ''; +} + +export function base(file: string): string { + return file.slice(file.lastIndexOf('/') + 1); +} + +export function statusClass(status: number): 'good' | 'warn' | 'bad' { + if (status >= 500 || status === 0) return 'bad'; + if (status >= 400) return 'warn'; + return 'good'; +} + +export function modeLabel(mode: RenderRow['mode']): string { + return MODE_LABEL[mode]; +} diff --git a/app/src/pages/analog/analog-page-state.ts b/app/src/pages/analog/analog-page-state.ts new file mode 100644 index 00000000..fe58518e --- /dev/null +++ b/app/src/pages/analog/analog-page-state.ts @@ -0,0 +1,231 @@ +import { DestroyRef, Injectable, computed, inject, signal, type Signal } from '@angular/core'; +import type { DevframeRpcClient } from 'devframe/client'; +import { hostPageId } from '../../page-id'; +import { rpcTry as call } from '../../rpc'; +import { + LINT_TEXT, + STATIC_MESSAGES, + walk, + type AnalogProject, + type AnalogRoute, + type AnalogState, + type Finding, + type LintCard, + type PrerenderPlan, + type RenderCheck, + type RenderRow, + type UrlMatch, + type View, +} from './analog-model'; + +/** + * What the Analog page reads from the devtools server, and the view state that outlives a switch + * between its views (the URL being explained, the route filter, the open transform and preview, + * the render checks). The page provides it; its views in `./analog/` share it. + */ +@Injectable() +export class AnalogPageState { + private rpcSource: Signal = signal(null); + readonly rpc = computed(() => this.rpcSource()); + + readonly view = signal('routes'); + readonly project = signal(null); + readonly loadFailed = signal(false); + readonly state = signal({}); + readonly findings = signal([]); + readonly renderRows = signal([]); + readonly plan = signal(null); + readonly filter = signal(''); + readonly testUrl = signal(''); + readonly explained = signal<{ url: string; match: UrlMatch } | null>(null); + readonly explainError = signal(''); + readonly explaining = signal(false); + /** The dev server origin for copied requests: the open page's, or the panel's own. */ + readonly devOrigin = computed(() => { + const url = this.page()?.url ?? ''; + if (/^https?:\/\//.test(url)) { + // A malformed page URL must not break the whole Server view. + try { + return new URL(url).origin; + } catch { + return ''; + } + } + return /^https?:$/.test(location.protocol) ? location.origin : ''; + }); + /** The page file whose Vite transform stack is open below the routes. */ + readonly transformFile = signal(null); + /** The content file open in the preview. */ + readonly previewFile = signal(null); + /** Dev server render checks by route. */ + readonly checks = signal>({}); + + private unsubscribe: (() => void) | null = null; + private refreshTimer: ReturnType | undefined; + private refreshRun = 0; + private readonly destroyRef = inject(DestroyRef); + + readonly hostPageId = hostPageId(); + + readonly page = computed(() => { + const pages = this.state().pages ?? []; + return (this.hostPageId ? pages.find((p) => p.pageId === this.hostPageId) : pages[0]) ?? null; + }); + /** Open page files, mapped to their named outlet ('' for the primary outlet). */ + readonly openFiles = computed(() => { + const open = new Map(); + for (const c of this.page()?.chain ?? []) { + // The primary outlet wins when one file renders in several outlets. + if (c.file && (!open.has(c.file) || !c.outlet)) open.set(c.file, c.outlet ?? ''); + } + return open; + }); + /** Every call the dev server keeps, from every tab. */ + readonly allCalls = computed(() => this.state().calls ?? []); + /** The tab the Server view shows by default: the panel's page. */ + readonly scopePageId = computed(() => this.page()?.pageId); + /** Shows the calls of every tab, and those the dev server tied to no tab. */ + readonly allTabs = signal(false); + /** The calls of the panel's tab, or every call with "All tabs" on or no page reporting. */ + readonly tabCalls = computed(() => { + const pageId = this.scopePageId(); + const calls = this.allCalls(); + return this.allTabs() || !pageId ? calls : calls.filter((c) => c.pageId === pageId); + }); + readonly allRoutes = computed(() => walk(this.project()?.routes ?? [])); + readonly pageCount = computed( + () => this.allRoutes().filter((r) => r.route.file && r.route.kind !== 'layout').length, + ); + readonly serverFns = computed(() => this.project()?.serverFns ?? []); + readonly fnById = computed(() => new Map(this.serverFns().map((fn) => [fn.id, fn]))); + readonly lintCards = computed(() => { + const order = { error: 0, warning: 1, info: 2 }; + const cards = new Map(); + for (const finding of this.findings()) { + let card = cards.get(finding.rule); + if (!card) { + const known = LINT_TEXT[finding.rule]; + card = { + rule: finding.rule, + severity: finding.severity, + title: known?.title ?? finding.rule, + summary: known?.summary ?? finding.message, + fix: finding.fix, + items: [], + }; + cards.set(finding.rule, card); + } + const message = + STATIC_MESSAGES.has(finding.rule) || finding.message === card.summary + ? undefined + : finding.message; + const same = (item: LintCard['items'][number]) => + item.file === finding.file && item.path === finding.path && item.message === message; + if (!card.items.some(same)) + card.items.push({ file: finding.file, path: finding.path, message }); + } + return Array.from(cards.values()).sort((a, b) => order[a.severity] - order[b.severity]); + }); + /** Errors and warnings; info findings are advice, so they are not counted. */ + readonly lintCount = computed(() => { + const findings = this.findings(); + return { + errors: findings.filter((f) => f.severity === 'error').length, + warnings: findings.filter((f) => f.severity === 'warning').length, + }; + }); + readonly lintTone = computed(() => { + const { errors, warnings } = this.lintCount(); + return errors ? 'bad' : warnings ? 'warn' : ''; + }); + readonly views = computed(() => { + const { errors, warnings } = this.lintCount(); + return [ + { id: 'routes' as View, label: 'Routes', count: this.pageCount() as number | null, tone: '' }, + { id: 'server' as View, label: 'Server', count: this.tabCalls().length, tone: '' }, + { id: 'render' as View, label: 'Render', count: this.renderRows().length, tone: '' }, + { + id: 'content' as View, + label: 'Content', + count: this.project()?.content.length ?? 0, + tone: '', + }, + { id: 'data' as View, label: 'Data', count: null, tone: '' }, + { id: 'runtime' as View, label: 'Runtime', count: null, tone: '' }, + { id: 'lint' as View, label: 'Lint', count: errors + warnings, tone: this.lintTone() }, + ]; + }); + + constructor() { + this.destroyRef.onDestroy(() => { + this.unsubscribe?.(); + clearTimeout(this.refreshTimer); + }); + } + + /** Reads the RPC client from the page's `rpc` input. Called once, by the page. */ + connect(rpc: Signal) { + this.rpcSource = rpc; + } + + async load(client: DevframeRpcClient) { + const project = await call(client, 'analog-project'); + if (this.destroyRef.destroyed) return; + this.project.set(project); + this.loadFailed.set(project === null); + try { + const shared = await client.scope('pangular').rpc.sharedState('analog'); + if (this.destroyRef.destroyed) return; + const apply = (value: unknown) => this.state.set((value as AnalogState) ?? {}); + apply(shared.value()); + this.unsubscribe?.(); + this.unsubscribe = shared.on('updated', apply); + } catch { + this.state.set({}); + } + if (this.destroyRef.destroyed) return; + await this.refresh(this.view()); + } + + retry() { + const client = this.rpc(); + if (!client) return; + this.loadFailed.set(false); + void this.load(client); + } + + scheduleRefresh(view: View) { + clearTimeout(this.refreshTimer); + this.refreshTimer = setTimeout(() => void this.refresh(view), 300); + } + + private async refresh(view: View) { + const client = this.rpc(); + if (!client) return; + const run = ++this.refreshRun; + const [findings, render] = await Promise.all([ + call(client, 'analog-lint', this.hostPageId ?? undefined), + call<{ rows: RenderRow[]; plan: PrerenderPlan }>(client, 'analog-render'), + ]); + if (run !== this.refreshRun) return; + this.findings.set(findings ?? []); + this.renderRows.set(render?.rows ?? []); + this.plan.set(render?.plan ?? null); + if (view === 'routes' || view === 'content' || this.project() === null) { + const project = await call(client, 'analog-project'); + if (project && run === this.refreshRun) { + this.project.set(project); + this.loadFailed.set(false); + } + } + } + + isOpen(route: AnalogRoute): boolean { + return !!route.file && this.openFiles().has(route.file); + } + + openText(route: AnalogRoute): string { + const outlet = route.file ? this.openFiles().get(route.file) : undefined; + return outlet ? `open in ${outlet}` : 'open'; + } +} diff --git a/app/src/pages/analog/analog-render.html b/app/src/pages/analog/analog-render.html new file mode 100644 index 00000000..78b66d06 --- /dev/null +++ b/app/src/pages/analog/analog-render.html @@ -0,0 +1,163 @@ +@if (plan()?.configPartial?.length) { +

+ The analog() options in vite.config could not be read in full, so these modes and the prerender + plan may be incomplete. + @for (reason of plan()!.configPartial; track reason) { + {{ reason }} + } +

+} +@if (modeCounts().length) { +
+ @for (m of modeCounts(); track m.mode) { + {{ m.label }} · {{ m.count }} + } +
+} +
+ + + + + + + + + + + + @for (row of renderRows(); track row.path) { + + + + + + + + } @empty { + + + + } + +
RouteConfiguredLast requestDev server checkFile
{{ row.path }} +
+ {{ modeLabel(row.mode) }} + {{ row.reason }} +
+
+ @if (row.last; as last) { +
+ {{ + last.status + }} + {{ last.render === 'client' ? 'client only' : 'server rendered' }} · + {{ last.ms }} ms + @if (mismatch(row)) { + differs from config + } +
+ } @else { + not requested yet + } +
+ @if (checks()[row.path]; as check) { + @if (check === 'running') { + Checking… + } @else if (check.error) { + {{ check.error }} + } @else { +
+ {{ + modeLabel(check.observed!) + }} + {{ + check.matches ? 'fits the config' : 'differs from config' + }} + @if (check.note) { + {{ check.note }} + } +
+ } + } + +
+ @if (row.file) { + {{ dir(row.file) }}{{ base(row.file) }} + } +
+ No page routes to render yet. Add a .page.ts file under src/app/pages. +
+
+@if (plan(); as p) { +
+

Prerender plan

+ @if (p.dynamicConfig) { +

prerender.routes is a function, so the list is known only at build time.

+ } @else { +
+
Listed
+
+ @for (r of p.listed ?? ['/']; track r) { + {{ r }} + } + @if (!p.listed) { + default, nothing configured + } +
+ @if (p.fromRules?.length) { +
From routeRules
+
+ @for (r of p.fromRules; track r) { + {{ r }} + } +
+ } + @if (p.staticMissing.length) { +
Static, not listed
+
+ @for (r of p.staticMissing; track r) { + {{ r }} + } +
+ } + @if (p.dynamic.length) { +
Need explicit entries
+
+ @for (r of p.dynamic; track r) { + {{ r }} + } +
+ } +
Build output
+
+ @if (p.built.length) { + {{ plural(p.built.length, 'page') }} in dist/analog/public + @if (p.notBuilt.length) { + · missing + @for (r of p.notBuilt; track r) { + {{ r }} + } + } + } @else { + no build yet + } +
+
+ } +
+} + diff --git a/app/src/pages/analog/analog-render.scss b/app/src/pages/analog/analog-render.scss new file mode 100644 index 00000000..e94058be --- /dev/null +++ b/app/src/pages/analog/analog-render.scss @@ -0,0 +1,12 @@ +@use 'analog' as a; + +// A wrapper only: the view's parts are items of the page's panel grid, as they were. +:host { + display: contents; +} + +@include a.base; +@include a.form; +@include a.table; +@include a.calls; +@include a.cards; diff --git a/app/src/pages/analog/analog-render.ts b/app/src/pages/analog/analog-render.ts new file mode 100644 index 00000000..c8527c78 --- /dev/null +++ b/app/src/pages/analog/analog-render.ts @@ -0,0 +1,60 @@ +import { Component, computed, inject } from '@angular/core'; +import { plural } from '../../format'; +import { rpcTry as call } from '../../rpc'; +import { SourceLink } from '../../ui/source-link'; +import { AnalogBuild } from '../analog-build'; +import { + MODE_LABEL, + base, + dir, + modeLabel, + statusClass, + type RenderCheck, + type RenderRow, +} from './analog-model'; +import { AnalogPageState } from './analog-page-state'; + +/** The Render view of the Analog page: the render mode of each route, the prerender plan and the build. */ +@Component({ + selector: 'div[appAnalogRender]', + imports: [SourceLink, AnalogBuild], + templateUrl: './analog-render.html', + styleUrl: './analog-render.scss', +}) +export class AnalogRender { + private readonly page = inject(AnalogPageState); + + protected readonly plural = plural; + protected readonly rpc = this.page.rpc; + protected readonly plan = this.page.plan; + protected readonly renderRows = this.page.renderRows; + protected readonly checks = this.page.checks; + protected readonly dir = dir; + protected readonly base = base; + protected readonly modeLabel = modeLabel; + protected readonly statusClass = statusClass; + + protected readonly modeCounts = computed(() => + (['ssr', 'ssg', 'cached', 'client', 'redirect'] as const) + .map((mode) => ({ + mode, + label: MODE_LABEL[mode], + count: this.renderRows().filter((r) => r.mode === mode).length, + })) + .filter((m) => m.count), + ); + + protected mismatch(row: RenderRow): boolean { + if (!row.last?.render || row.mode === 'redirect') return false; + return row.mode === 'client' ? row.last.render !== 'client' : row.last.render === 'client'; + } + + protected async checkRender(path: string) { + this.checks.update((all) => ({ ...all, [path]: 'running' })); + const check = await call(this.rpc(), 'analog-render-check', path); + this.checks.update((all) => ({ + ...all, + [path]: check ?? { path, error: 'No answer from the devtools server.' }, + })); + } +} diff --git a/app/src/pages/analog/analog-routes.html b/app/src/pages/analog/analog-routes.html new file mode 100644 index 00000000..8dfa90e9 --- /dev/null +++ b/app/src/pages/analog/analog-routes.html @@ -0,0 +1,164 @@ +
+
+ + + +
+ +
+
+ @if (explainError(); as error) { +
{{ error }}
+ } @else if (explained(); as e) { +
+ @if (e.match.matched) { + {{ e.url }} renders +
    + @for (r of e.match.chain; track r.id) { +
  1. + @if (r.file) { + {{ short(r.file) }} + } @else { + {{ r.fullPath }} + } + {{ r.kind }} +
  2. + } +
+ @if (paramList(e.match.params).length) { +
+ @for (p of paramList(e.match.params); track p[0]) { + {{ p[0] }} = {{ p[1] }} + } +
+ } + } @else { + {{ e.url }} matches no file route. Angular throws NG04002 + "Cannot match any routes". + @if (e.match.rejected.length) { +
    + @for (r of e.match.rejected.slice(0, 5); track $index) { +
  • + @if (r.file) { + {{ + short(r.file) + }} + } @else { + {{ r.path }} + } + {{ r.reason }} +
  • + } +
+ } + } +
+ } +
+
+ + + + + + + + + + + + @for (row of routeRows(); track row.route.id) { + + + + + + + + } @empty { + + + + } + +
RouteFileDataRoute metaActions
+
+ @if (row.depth) { + + } + {{ row.route.fullPath }} + {{ kindText(row.route) }} + @if (isOpen(row.route)) { + {{ openText(row.route) }} + } +
+
+ @if (row.route.file) { + {{ dir(row.route.file) }}{{ base(row.route.file) }} + } @else { + folder only + } + +
+ @for (e of serverExports(row.route); track e) { + {{ e }}() + } +
+
+ @if (row.route.title) { + "{{ row.route.title }}" + } + @for (key of row.route.routeMeta ?? []; track key) { + @if (key !== 'title') { + {{ key }} + } + } + + @if (row.route.file && row.route.kind !== 'markdown') { + + } +
+ @if (filter().trim()) { + No route matches the filter. Try part of a path or a file name. + } @else { + No file routes yet. Add a .page.ts file under src/app/pages. + } +
+
+@if (transformFile(); as file) { + +} diff --git a/app/src/pages/analog/analog-routes.scss b/app/src/pages/analog/analog-routes.scss new file mode 100644 index 00000000..05e54026 --- /dev/null +++ b/app/src/pages/analog/analog-routes.scss @@ -0,0 +1,11 @@ +@use 'analog' as a; + +// A wrapper only: the view's parts are items of the page's panel grid, as they were. +:host { + display: contents; +} + +@include a.base; +@include a.form; +@include a.callout; +@include a.table; diff --git a/app/src/pages/analog/analog-routes.ts b/app/src/pages/analog/analog-routes.ts new file mode 100644 index 00000000..8e5a0d34 --- /dev/null +++ b/app/src/pages/analog/analog-routes.ts @@ -0,0 +1,105 @@ +import { Component, ElementRef, computed, inject } from '@angular/core'; +import { rpcCall } from '../../rpc'; +import { SearchField } from '../../ui/search-field'; +import { SourceLink } from '../../ui/source-link'; +import { AnalogTransform } from '../analog-transform'; +import { base, dir, short, type AnalogRoute, type UrlMatch } from './analog-model'; +import { AnalogPageState } from './analog-page-state'; + +/** The Routes view of the Analog page: the URL tester and the file route table. */ +@Component({ + selector: 'div[appAnalogRoutes]', + imports: [SearchField, SourceLink, AnalogTransform], + templateUrl: './analog-routes.html', + styleUrl: './analog-routes.scss', +}) +export class AnalogRoutes { + private readonly page = inject(AnalogPageState); + private readonly host = inject>(ElementRef); + + protected readonly rpc = this.page.rpc; + protected readonly serverFns = this.page.serverFns; + protected readonly filter = this.page.filter; + protected readonly testUrl = this.page.testUrl; + protected readonly explained = this.page.explained; + protected readonly explainError = this.page.explainError; + protected readonly explaining = this.page.explaining; + protected readonly transformFile = this.page.transformFile; + protected readonly short = short; + protected readonly dir = dir; + protected readonly base = base; + + protected readonly routeRows = computed(() => { + const needle = this.filter().trim().toLowerCase(); + if (!needle) return this.page.allRoutes(); + return this.page + .allRoutes() + .filter( + (r) => + r.route.fullPath.toLowerCase().includes(needle) || + !!r.route.file?.toLowerCase().includes(needle), + ); + }); + + protected isOpen(route: AnalogRoute): boolean { + return this.page.isOpen(route); + } + + protected openText(route: AnalogRoute): string { + return this.page.openText(route); + } + + protected kindText(route: AnalogRoute): string { + if (route.catchAll) return route.catchAll === 'optional' ? 'optional catch-all' : 'catch-all'; + if (route.kind === 'implicit') return 'folder'; + return route.kind; + } + + protected serverExports(route: AnalogRoute): string[] { + return (route.serverExports ?? []).filter((e) => e === 'load' || e === 'action'); + } + + protected paramList(params: Record): [string, string][] { + return Object.entries(params); + } + + protected showTransform(file: string) { + this.transformFile.set(this.transformFile() === file ? null : file); + } + + protected closeTransform(file: string) { + this.transformFile.set(null); + this.refocus(`Show the Vite transform stack of ${file}`); + } + + /** Hands focus back to the row button that opened a detail card. */ + private refocus(label: string) { + queueMicrotask(() => + this.host.nativeElement + .querySelector(`button[aria-label="${CSS.escape(label)}"]`) + ?.focus(), + ); + } + + protected async explain() { + const url = this.testUrl().trim(); + if (!url) { + this.explained.set(null); + this.explainError.set('Type a URL to explain, for example /products/42.'); + return; + } + this.explaining.set(true); + this.explainError.set(''); + try { + const match = (await rpcCall(this.rpc(), 'analog-explain-url', url)) as UrlMatch | null; + if (!match) throw new Error('The devtools server did not answer'); + this.explained.set({ url, match }); + } catch (error) { + const reason = String((error as Error)?.message || 'The devtools server did not answer'); + this.explained.set(null); + this.explainError.set(`Could not explain ${url}. ${reason.replace(/\.?$/, '.')}`); + } finally { + this.explaining.set(false); + } + } +} diff --git a/app/src/pages/analog/analog-server.html b/app/src/pages/analog/analog-server.html new file mode 100644 index 00000000..840f8909 --- /dev/null +++ b/app/src/pages/analog/analog-server.html @@ -0,0 +1,282 @@ +@if (duplicates().length) { +
+ load() ran twice for + @for (d of duplicates(); track d; let last = $last) { + {{ d }}{{ last ? '' : ', ' }} + } + : once while server rendering, again in the browser. TransferState did not serve the server + result. +
+} +@if (refetchedFns().length) { +
+ Server function read ran twice: + @for (name of refetchedFns(); track name; let last = $last) { + {{ name }}{{ last ? '' : ', ' }} + } + ran while server rendering, then again in the browser. The browser did not use the TransferState + seed. +
+} +

Server calls

+
+
+ Show calls of kind + @for (k of kinds; track k) { + + } +
+
+ + {{ callsMessage() }} +
+
+
+ + + @if (scopePageId()) { + + } + {{ matchingCalls().length }} of {{ tabCalls().length }} +
+@if (shownCount() < matchingCalls().length || serverFull()) { +

+ @if (shownCount() < matchingCalls().length) { + Showing the newest {{ shownCount() }} of {{ matchingCalls().length }}. + } + @if (serverFull()) { + The dev server keeps only the newest {{ maxServerCalls }} calls and drops older ones. + } +

+} +@if (calls().length) { +
+ + + + + + + + + + + + + @for (c of calls(); track c.id) { + + + + + + + + + } + +
AtKindRequestStatusDurationFrom
{{ time(c.at) }} + {{ kindLabel(c.kind) }} + +
+ {{ c.method }} + @if (fnOf(c); as fn) { + {{ fn.name }} + @if (fn.file) { + {{ + short(fn.file) + }} + } + } @else { + {{ c.url }} + } + @if (c.seeded) { + ran during server rendering + } + @if (c.outcome) { + {{ outcomeLabel(c.outcome) }} + @if (c.location) { + to {{ c.location }} + } + + } + @if (c.render) { + {{ + c.render === 'ssr' ? 'server rendered' : 'client only' + }} + } +
+ @if (c.preview) { +
+ Response +
{{ pretty(c.preview) }}
+
+ } +
+ {{ c.status }} + + @if (c.seeded) { + in process + } @else { + {{ c.ms }} ms + } + {{ c.from }}
+
+} @else if (tabCalls().length && callFiltered()) { +
+

No calls match.

+

+ @if (callQuery().trim()) { + Nothing matches “{{ callQuery().trim() }}” with these filters. + } @else { + None of the {{ tabCalls().length }} calls fit these filters. + } +

+
+ +
+
+} @else { +
+

+ @if (tabCalls().length) { + No calls of this kind yet. + } @else if (allCalls().length) { + No calls from this tab yet. + } @else { + No calls yet. + } +

+

+ Navigate in the app to see page renders, load() fetches, form actions, server functions and + API calls. + @if (!tabCalls().length && allCalls().length) { + Tick “All tabs (including untagged)” to see the + {{ allCalls().length }} calls from other tabs or tied to no tab. + } +

+
+} + +

API routes

+
+ + + + + + + + + + + @for (api of project()!.api; track api.file + api.method) { + + + + + + + } @empty { + + + + } + +
MethodPathFileActions
+ {{ api.method }} + {{ api.path }} + {{ dir(api.file) }}{{ base(api.file) }} + + +
+ No API routes. Add a handler under src/server/routes to test it here. +
+
+ +@if (serverFns().length) { +

Server functions

+
+ + + + + + + + + + + @for (fn of serverFns(); track fn.id) { + + + + + + + } + +
MethodNameFileCalls
+ {{ fn.method }} + {{ fn.name }} + {{ dir(fn.file) }}{{ base(fn.file) }} + {{ fnCalls(fn.id) }}
+
+} + + diff --git a/app/src/pages/analog/analog-server.scss b/app/src/pages/analog/analog-server.scss new file mode 100644 index 00000000..4e2522fd --- /dev/null +++ b/app/src/pages/analog/analog-server.scss @@ -0,0 +1,13 @@ +@use 'analog' as a; + +// A wrapper only: the view's parts are items of the page's panel grid, as they were. +:host { + display: contents; +} + +@include a.base; +@include a.form; +@include a.callout; +@include a.table; +@include a.calls; +@include a.cards; diff --git a/app/src/pages/analog/analog-server.ts b/app/src/pages/analog/analog-server.ts new file mode 100644 index 00000000..d931dc0c --- /dev/null +++ b/app/src/pages/analog/analog-server.ts @@ -0,0 +1,114 @@ +import { Component, ElementRef, inject } from '@angular/core'; +import { time } from '../../format'; +import { SearchField } from '../../ui/search-field'; +import { SourceLink } from '../../ui/source-link'; +import { AnalogPlayground } from '../analog-playground'; +import { AnalogCallsState } from './analog-calls-state'; +import { + base, + dir, + short, + statusClass, + type ActionOutcome, + type AnalogCall, + type ApiRoute, + type Kind, +} from './analog-model'; +import { AnalogPageState } from './analog-page-state'; + +/** The Server view of the Analog page: the server calls, API routes, server functions and the playground. */ +@Component({ + selector: 'div[appAnalogServer]', + imports: [SearchField, SourceLink, AnalogPlayground], + templateUrl: './analog-server.html', + styleUrl: './analog-server.scss', +}) +export class AnalogServer { + private readonly page = inject(AnalogPageState); + private readonly callsState = inject(AnalogCallsState); + private readonly host = inject>(ElementRef); + + protected readonly rpc = this.page.rpc; + protected readonly project = this.page.project; + protected readonly serverFns = this.page.serverFns; + protected readonly devOrigin = this.page.devOrigin; + protected readonly allCalls = this.page.allCalls; + protected readonly tabCalls = this.page.tabCalls; + protected readonly scopePageId = this.page.scopePageId; + protected readonly allTabs = this.page.allTabs; + + protected readonly kinds = this.callsState.kinds; + protected readonly kind = this.callsState.kind; + protected readonly tryTarget = this.callsState.tryTarget; + protected readonly maxServerCalls = this.callsState.maxServerCalls; + protected readonly callQuery = this.callsState.callQuery; + protected readonly failedOnly = this.callsState.failedOnly; + protected readonly callsMessage = this.callsState.callsMessage; + protected readonly callFiltered = this.callsState.callFiltered; + protected readonly matchingCalls = this.callsState.matchingCalls; + protected readonly calls = this.callsState.calls; + protected readonly shownCount = this.callsState.shownCount; + protected readonly serverFull = this.callsState.serverFull; + protected readonly refetchedFns = this.callsState.refetchedFns; + protected readonly duplicates = this.callsState.duplicates; + + protected readonly time = time; + protected readonly short = short; + protected readonly dir = dir; + protected readonly base = base; + protected readonly statusClass = statusClass; + + protected kindLabel(kind: Kind): string { + return this.callsState.kindLabel(kind); + } + + protected kindCount(kind: Kind): number { + return this.callsState.kindCount(kind); + } + + protected fnOf(call: AnalogCall) { + return this.callsState.fnOf(call); + } + + protected fnCalls(id: string): number { + return this.callsState.fnCalls(id); + } + + protected outcomeLabel(outcome: ActionOutcome): string { + return this.callsState.outcomeLabel(outcome); + } + + protected outcomeTone(outcome: ActionOutcome): string { + return this.callsState.outcomeTone(outcome); + } + + protected tryApi(api: ApiRoute) { + this.callsState.tryApi(api); + } + + protected pretty(text: string): string { + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } + } + + protected clearCallFilters() { + this.callsState.resetFilters(); + queueMicrotask(() => + this.host.nativeElement.querySelector('#analog-call-search')?.focus(), + ); + } + + protected async clearCalls() { + if (await this.callsState.clearCalls()) { + // The button is disabled once the list is empty, so hand focus to the kind filter. + queueMicrotask(() => + this.host.nativeElement + .querySelector('input[name="analog-kind"]:checked') + ?.focus(), + ); + } + } +} diff --git a/app/src/pages/component-tree.html b/app/src/pages/component-tree.html new file mode 100644 index 00000000..07364bdd --- /dev/null +++ b/app/src/pages/component-tree.html @@ -0,0 +1,266 @@ +@if (live()) { +

+ Every component instance on the page, in the order Angular rendered them. Hover a row to + highlight its host in the page, and select it to read its live inputs, properties, outputs and + injected services. +

+ +
+ + + Type plain text, a tag such as <button>, or a directive such as [routerLink]. Combine + them to narrow the tree. + + @if (pageOptions().length > 1) { + Page + + } + {{ countLabel() }} +
+ @if (page()!.directiveHostCount) { + + } + + + +
+
+

{{ announcement() }}

+ @if (pickMessage()) { +

{{ pickMessage() }}

+ } + @if (page()!.truncated) { +

{{ truncationText() }}

+ } + +
+
+ + @if (selectedHost(); as host) { +
+ } @else if (selectedNode(); as sel) { +
+ } @else { +
+ @if (destroyed(); as name) { +

The selected component was destroyed

+

+ {{ name }} is no longer on the page. Select another component + to see its live values. +

+ } @else { +

+ Select a component to see its live inputs, properties, outputs, change detection and + injected services. +

+ } +
+ } +
+ + + @if (deferUnavailable()) { + + } + @if (!native()) { + + } + + @if (!staticReport()) { + + + } +} @else { + @if (page()) { +

+ The page reported no component instances. The live tree needs a development build with + Angular's debug API. Showing what the source declares instead. +

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

Loading the live component tree…

+ } @else { +

+ No page is connected, so this lists what the source declares. Open the app in a browser with + the devtools connected to see each rendered instance. +

+ } + +
+ + @if (source().length) { + + @if (filter().trim()) { + {{ filteredSource().length }} of {{ source().length }} + } @else { + {{ sourceCounts().components }} + {{ sourceCounts().components === 1 ? 'component' : 'components' }} · + {{ sourceCounts().directives }} + {{ sourceCounts().directives === 1 ? 'directive' : 'directives' }} + } + + } + +
+ + @if (loading() && !source().length) { +
+ +

Scanning components…

+
+ } @else if (error() && !source().length) { + + } @else if (!source().length) { +
+

No components found

+

No @Component or @Directive was found in the source.

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

No components match “{{ filter().trim() }}”

+ +
+ } @else { +
    + } +} diff --git a/app/src/pages/component-tree.scss b/app/src/pages/component-tree.scss new file mode 100644 index 00000000..f273d3f9 --- /dev/null +++ b/app/src/pages/component-tree.scss @@ -0,0 +1,111 @@ +@use 'mixins' as m; +@use 'components' as ct; + +:host { + display: block; + color: var(--text); + font-size: 13px; +} +@include ct.base; +.intro { + max-width: 720px; + margin: 0 0 12px; + color: var(--text-2); + line-height: 1.5; +} +.notice { + margin: 0 0 12px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-2); + line-height: 1.5; +} +.toolbar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin: 0 0 12px; + padding: 8px; + background: color-mix(in srgb, var(--surface) 85%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--border); + border-radius: var(--radius); +} +@include ct.search; +@include ct.buttons; +.refresh.spinning svg { + animation: spin 0.8s linear infinite; +} +.page-select { + flex: 0 1 220px; + min-width: 0; +} +.tree-action:disabled, +.tree-action:disabled:hover { + background: var(--surface-2); + border-color: var(--border-strong); + opacity: 0.5; +} +.pick.on { + background: var(--accent-soft); + border-color: var(--accent-line); + color: var(--text-strong); +} +.defer { + margin-top: 16px; +} +.pick-message { + margin: 0 0 12px; + color: var(--text-2); + line-height: 1.5; +} +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + align-items: start; +} +@media (min-width: 880px) { + .layout { + grid-template-columns: minmax(0, 1fr) minmax(340px, 44%); + } + .detail { + position: sticky; + top: 64px; + max-height: calc(100vh - 150px); + overflow: auto; + } +} +@include ct.narrow; +.tree-col { + position: relative; + min-width: 0; +} +.detail { + min-width: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + @include m.enter; +} +.detail.placeholder { + padding: 24px 16px; + border-style: dashed; + text-align: center; +} +@include ct.states; +.source-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts index 94826039..5c41c1e9 100644 --- a/app/src/pages/component-tree.ts +++ b/app/src/pages/component-tree.ts @@ -1,1879 +1,69 @@ import { Component, DestroyRef, - ElementRef, - Injector, - afterNextRender, computed, effect, inject, input, - linkedSignal, output, - signal, untracked, viewChild, } from '@angular/core'; -import { NgTemplateOutlet } from '@angular/common'; import type { DevframeRpcClient } from 'devframe/client'; -import { ExtensionBridge, type SourceLocation } from '../extension-bridge'; -import { hostPageId } from '../page-id'; -import { ComponentPipesSection } from './component-pipes-section'; -import { - angularVersionAtLeast, - lacksCapability, -} from '@pangular-inspector/devtools/internal/config'; -import { angularPageFor, injectAngularPages } from '../angular-pages'; import { AngularUnavailable } from '../ui/angular-unavailable'; -import { pickPage } from '../live-pages'; -import { isNativePage } from '../native-page'; -import { isStaticReport } from '../rpc'; -import { Select, type SelectOption } from '../ui/select'; -import { - countText, - directiveHostLabel, - filterAnnouncement, - truncationNotice, - filterTree, - hydrationLabel, - nearestRow, - reconcileSelection, - withoutDirectiveHosts, - type HydrationStatus, -} from './component-tree-state'; -import { HydrationSection, type HydrationReport } from './hydration-section'; -import { CdRecording, type CdPage } from './cd-recording'; +import { Select } from '../ui/select'; +import { SearchField } from '../ui/search-field'; +import { CdRecording } from './cd-recording'; +import { DeferBlocksSection, type DeferBlock } from './defer-blocks-section'; +import { ForBlocksSection, type ForBlock } from './for-blocks-section'; +import { HydrationSection } from './hydration-section'; +import { LeaksSection } from './leaks-section'; 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 type { SignalFocus } from '../types/signal-focus.types'; -import { componentJson, hostPathText } from './component-copy'; -import { ValueFlash, flashKey } from './component-value-flash'; -import { SourceLink } from '../ui/source-link'; -import { actionAllowed } from '../devtools-config'; -import { ValueTree } from './component-value-tree'; -import { ValueStore } from './component-value-store'; -import { nestedMatches } from './component-value-state'; -import { StoreGlobal } from '../ui/store-global'; -import { DeferBlocksSection, type DeferBlock } from './defer-blocks-section'; -import { ForBlocksSection, type ForBlock } from './for-blocks-section'; -import { LeaksSection, type LeaksPage } from './leaks-section'; -import { VirtualWindow } from '../ui/virtual-window'; -import { keepBelowSticky, stickyClearance } from '../ui/sticky-clearance'; -import { SearchField } from '../ui/search-field'; -import { RedactionCount } from '../ui/redaction-count'; - -interface SourceComponent { - selector: string; - className: string; - kind: 'component' | 'directive'; - file: string; - line: number; - inputs: string[]; - outputs: string[]; - isStandalone: boolean; - changeDetection?: 'OnPush' | 'Eager' | 'unknown'; -} -interface LiveNode { - id: string; - name: string; - tag: string; - /** `directive` for an element that hosts directives but no component. */ - kind?: 'directive'; - directives?: string[]; - attributes?: string[]; - hydration?: HydrationStatus; - children: LiveNode[]; -} - -interface Prop { - name: string; - prop: string; - value?: unknown; - listened?: boolean; - kind?: 'signal' | 'resource'; - edit?: 'property' | 'signal'; - locked?: string; - editInside?: 'property' | 'signal'; -} - -interface Dependency { - from: string; - token: string; - flags: string[]; - providedBy: string | null; - providedByName?: string; -} - -interface Detail { - id: string; - name: string; - tag: string; - path: string; - /** A CSS selector that finds the host with `querySelector`, from a browser page. */ - selector?: string; - source?: SourceLocation; - changeDetection?: string; - encapsulation?: string; - inputs: Prop[]; - outputs: Prop[]; - properties?: Prop[]; - listeners: string[]; - directives: { name: string; inputs: Prop[]; outputs: Prop[] }[]; - dependencies: Dependency[]; - /** Nested value levels opened in the panel, read again on each push. */ - expanded?: Record; -} - -interface Page { - pageId: string; - url?: string; - title?: string; - platform?: string; - deferBlocks?: DeferBlock[]; - /** The page has more defer blocks than the ones in `deferBlocks`. */ - deferTruncated?: boolean; - forBlocks?: ForBlock[]; - forBlocksTruncated?: boolean; - roots: LiveNode[]; - count: number; - /** Elements in `roots` that host directives but no component. */ - directiveHostCount?: number; - hydration?: HydrationReport; - truncated?: boolean; - truncatedBy?: { components?: number; depth?: number; directiveHosts?: number }; - detail: Detail | null; - reportedAt: number; - /** The server clock minus the page clock, in ms, for ages of page times. */ - clockOffset?: number; -} - -interface OutletInfo { - outlet: string; - route?: string; - element?: string; - devtoolsId?: string; - activated: boolean; - children?: OutletInfo[]; -} - -interface RoutedHit { - route: string; - outlet: string; -} - -interface PickResult { - ok?: boolean; - id?: string; - name?: string; - pageId?: string; - error?: string; -} - -interface Row { - node: LiveNode; - depth: number; - hasChildren: boolean; - expanded: boolean; - /** Position among its siblings, from 1, and how many siblings there are, for screen readers. */ - posinset: number; - setsize: number; -} - -/** Height of a tree row, fixed while the tree renders only the rows in view. */ -const TREE_ROW_PX = 32; - -function bare(name: string): string { - return name.replace(/^_(?=[A-Z])/, ''); -} - -/** True when two paths name the same file, one maybe longer or with `./` or backslashes. */ -function sameSourceFile(a: string, b: string): boolean { - const norm = (path: string) => path.replace(/\\/g, '/').replace(/^\.\//, ''); - const [x, y] = [norm(a), norm(b)]; - return x === y || x.endsWith('/' + y) || y.endsWith('/' + x); -} - -/** The ids above `id`, nearest first; stops at a repeated id so a bad map cannot loop. */ -export function ancestors(parents: ReadonlyMap, id: string): string[] { - const out: string[] = []; - const seen = new Set([id]); - for (let parent = parents.get(id); parent && !seen.has(parent); parent = parents.get(parent)) { - seen.add(parent); - out.push(parent); - } - return out; -} - -/** The keys that move a running pick on the page, as the page names them. */ -const PICK_KEY_NAMES: Record = { - ArrowDown: 'next', - ArrowUp: 'previous', - ArrowLeft: 'parent', - ArrowRight: 'child', - Enter: 'pick', -}; - -const DIRECTIVE_HOSTS_KEY = 'pangular:components:directive-hosts'; - -/** Whether this viewer last chose to show directive hosts; shown unless they hid them. */ -function storedDirectiveHosts(): boolean { - try { - return localStorage.getItem(DIRECTIVE_HOSTS_KEY) !== 'hidden'; - } catch { - return true; - } -} - +import type { LiveNode } from './components/component-model'; +import { ComponentDetail } from './components/component-detail'; +import { DetailState } from './components/detail-state'; +import { HostDetail } from './components/host-detail'; +import { PickState } from './components/pick-state'; +import { SourceList } from './components/source-list'; +import { SourceScan } from './components/source-scan'; +import { TreeRows } from './components/tree-rows'; +import { TreeState } from './components/tree-state'; + +export { ancestors } from './components/component-model'; + +/** + * The Components page: the live component tree with the selected instance's detail and the + * page sections under it, or what the source declares when no page reports a tree. The sections + * live in `./components/` and share the page's {@link TreeState}, {@link PickState}, + * {@link SourceScan} and {@link DetailState}; the page keeps the members the app and its tests + * read on itself. + */ @Component({ selector: 'app-component-tree', imports: [ SearchField, - RedactionCount, - NgTemplateOutlet, Select, CdRecording, - SourceLink, - ValueTree, - StoreGlobal, DeferBlocksSection, ForBlocksSection, HydrationSection, LeaksSection, AngularUnavailable, - ComponentPipesSection, + TreeRows, + HostDetail, + ComponentDetail, + SourceList, ], + providers: [TreeState, PickState, SourceScan, DetailState], host: { '(keydown.escape)': 'cancelPick()', '(keydown)': 'pickKey($event)' }, - template: ` - @if (live()) { -

    - Every component instance on the page, in the order Angular rendered them. Hover a row to - highlight its host in the page, and select it to read its live inputs, properties, outputs - and injected services. -

    - -
    - - - Type plain text, a tag such as <button>, or a directive such as [routerLink]. - Combine them to narrow the tree. - - @if (pageOptions().length > 1) { - Page - - } - {{ countLabel() }} -
    - @if (page()!.directiveHostCount) { - - } - - - -
    -
    -

    {{ announcement() }}

    - @if (pickMessage()) { -

    {{ pickMessage() }}

    - } - @if (page()!.truncated) { -

    {{ truncationText() }}

    - } - -
    -
    -

    Component tree

    - @if (!rows().length) { -
    -

    No components match “{{ filter().trim() }}”

    - @if (page()!.truncated) { -

    The filter only searches the components the tree lists.

    - } - @if (!showDirectiveHosts() && page()!.directiveHostCount) { -

    Directive hosts are hidden, so they are not searched.

    - } - -
    - } - @if (rowWindow.enabled()) { -

    - {{ rows().length }} rows. Rows render as you scroll, so the browser's find only sees - the ones near view; the filter above searches them all. -

    - } -
    - @for (shown of shownRows(); track shown.row.node.id) { - @let row = shown.row; -
    - @if (row.hasChildren) { - - } @else { - - } - @if (row.node.kind === 'directive') { - <{{ row.node.tag }}> - {{ - hostLabel(row.node) - }} - directive host - } @else { - {{ row.node.name }} - <{{ row.node.tag }}> - } - @if (row.node.hydration; as status) { - Hydration: {{ hydrationText(status) }} - } - @for (hit of routedById().get(row.node.id) ?? []; track hit.outlet + hit.route) { - {{ hit.route || '/' }} - } - @if (cdHosts()[row.node.id]; as checks) { - - {{ checks - }} - {{ checks === 1 ? 'check' : 'checks' }} while recording - - } - @if (row.node.kind !== 'directive' && row.node.directives?.length) { - - +{{ row.node.directives!.length }} - - {{ row.node.directives!.length === 1 ? 'directive' : 'directives' }} - - - } -
    - } -
    - @if (nameTip(); as tip) { - - } -
    - - @if (selectedHost(); as host) { -
    -
    - directive host -

    <{{ host.tag }}>

    - {{ hostLabel(host) }} -
    -
    -

    - Directives {{ host.directives?.length ?? 0 }} -

    -
      - @for (name of host.directives ?? []; track name + $index) { -
    • - {{ name }} - -
    • - } -
    -
    -
    -

    - This element hosts directives but no component. Their inputs are bound in the - template of the component that renders it. -

    - @if (ownerOf(host.id); as owner) { -
    - -
    - } -
    -
    - } @else if (selectedNode(); as sel) { -
    -
    - component -

    {{ sel.name }}

    - <{{ sel.tag }}> - @if (detail()?.source; as where) { -

    - -

    - } @else { - @let guesses = sourceCandidates(sel); - @if (guesses.length === 1) { -

    - Likely, matched by class name: -

    - } @else if (guesses.length > 1) { -
    -

    - Not certain - {{ guesses.length }} source files define {{ bareName(sel.name) }}: -

    -
      - @for (guess of guesses; track sourceKey(guess)) { -
    • - } -
    -
    - } - } - @if (devtoolsActions()) { -
    - - -
    -

    {{ actionMessage() }}

    - } - @if (sourceFor(sel); as src) { - @if (formsIn(src.file).length) { -
    - @for (form of formsIn(src.file); track form.formId) { - - } -
    - } - } - @if ((canShowInjector() || canShowSignals()) && !staticReport()) { -
    - @if (canShowInjector()) { - - } - @if (canShowSignals()) { - - } -
    - } - @if (detail(); as d) { -
    - - -
    -

    {{ copyMessage() }}

    - } - @if (!staticReport() && !native()) { - - } -
    - - @if (detail(); as d) { -
    -
    Change detection
    -
    {{ d.changeDetection ?? 'Unknown' }}
    -
    Encapsulation
    -
    {{ d.encapsulation ?? 'Unknown' }}
    -
    Host path
    -
    {{ d.path }}
    - @for (hit of routedById().get(sel.id) ?? []; track hit.outlet + hit.route) { -
    Routed
    -
    {{ hit.route || '/' }} in outlet {{ hit.outlet }}
    - } -
    - -
    - - @if (propQuery()) { - {{ shownValues()?.count ?? 0 }} of {{ valueTotal() }} - } -

    {{ propFilterStatus() }}

    -
    - - @if (shownValues(); as v) { - - @if (propQuery() && !v.count) { -
    -

    - No inputs, outputs or properties match “{{ propFilter().trim() }}” -

    -

    - The filter searches names, and the nested keys of values you opened. -

    - -
    - } - - @if (!propQuery() || v.inputs.length) { -
    -

    - Inputs {{ v.inputs.length }} -

    - @if (v.inputs.length) { -
      - @for (p of v.inputs; track p.name) { -
    • - - {{ p.name }} - @if (p.prop !== p.name) { - ({{ p.prop }}) - } - - -
    • - } -
    - } @else { -

    No inputs.

    - } -
    - } - - @if (!propQuery() || v.outputs.length) { -
    -

    - Outputs {{ v.outputs.length }} -

    - @if (v.outputs.length) { -
      - @for (p of v.outputs; track p.name) { -
    • - {{ p.name }} - {{ - p.listened ? 'listened' : 'no listener' - }} -
    • - } -
    - } @else { -

    No outputs.

    - } -
    - } - - @if (!propQuery() || v.properties.length) { -
    -

    - Properties {{ v.properties.length }} -

    - @if (v.properties.length) { -
      - @for (p of v.properties; track p.name) { -
    • - - {{ p.name }} - @if (p.kind) { - {{ p.kind }} - } - @if (p.kind === 'signal' && canShowSignals() && !staticReport()) { - - } - - -
    • - } -
    - } @else { -

    No other properties.

    - } -
    - } - - @if (!propQuery() && d.listeners.length) { -
    -

    - DOM listeners {{ d.listeners.length }} -

    -
      - @for (name of d.listeners; track name) { -
    • {{ name }}
    • - } -
    -
    - } - - @if (!propQuery() && ownedDeferBlocks().length) { -
    -

    - Defer blocks {{ ownedDeferBlocks().length }} -

    -
      - @for (block of ownedDeferBlocks(); track block.id) { -
    • - {{ block.state }} - {{ - block.triggers.join(', ') || 'no triggers' - }} -
    • - } -
    -
    - } - - @for (dir of v.directives; track dir.name + dir.index) { -
    -

    - {{ dir.name }} - directive on host -

    -

    - -

    - @if (dir.inputs.length) { -
      - @for (p of dir.inputs; track p.name) { -
    • - {{ p.name }} - -
    • - } -
    - } - @if (dir.outputs.length) { -
      - @for (p of dir.outputs; track p.name) { -
    • - {{ p.name }} - {{ - p.listened ? 'listened' : 'no listener' - }} -
    • - } -
    - } - @if (!dir.inputs.length && !dir.outputs.length) { -

    No inputs or outputs.

    - } -
    - } - } - -
    -

    - Injected {{ d.dependencies.length }} -

    - @if (d.dependencies.length) { -
      - @for (dep of d.dependencies; track dep.token + $index) { -
    • - {{ dep.token }} - @for (flag of dep.flags; track flag) { - {{ flag }} - } - @if (dep.providedBy) { - from - {{ - dep.providedByName ?? 'an injector' - }} - } @else { - not provided - } -
    • - } -
    - } @else { -

    Nothing is injected through the constructor or inject().

    - } -
    - - @if (canShowPipes()) { - - } - } @else { -
    - -

    Reading live values from the page…

    -
    - } -
    - } @else { -
    - @if (destroyed(); as name) { -

    The selected component was destroyed

    -

    - {{ name }} is no longer on the page. Select another - component to see its live values. -

    - } @else { -

    - Select a component to see its live inputs, properties, outputs, change detection and - injected services. -

    - } -
    - } -
    - - - @if (deferUnavailable()) { - - } - @if (!native()) { - - } - - @if (!staticReport()) { - - - } - } @else { - @if (page()) { -

    - The page reported no component instances. The live tree needs a development build with - Angular's debug API. Showing what the source declares instead. -

    - } @else if (!pagesKnown()) { -

    Loading the live component tree…

    - } @else { -

    - No page is connected, so this lists what the source declares. Open the app in a browser - with the devtools connected to see each rendered instance. -

    - } - -
    - - @if (source().length) { - - @if (filter().trim()) { - {{ filteredSource().length }} of {{ source().length }} - } @else { - {{ sourceCounts().components }} - {{ sourceCounts().components === 1 ? 'component' : 'components' }} · - {{ sourceCounts().directives }} - {{ sourceCounts().directives === 1 ? 'directive' : 'directives' }} - } - - } - -
    - - @if (loading() && !source().length) { -
    - -

    Scanning components…

    -
    - } @else if (error() && !source().length) { - - } @else if (!source().length) { -
    -

    No components found

    -

    No @Component or @Directive was found in the source.

    -
    - } @else if (!filteredSource().length) { -
    -

    No components match “{{ filter().trim() }}”

    - -
    - } @else { -
      - @for (comp of filteredSource(); track sourceKey(comp); let i = $index) { -
    • - - @if (openKey() === sourceKey(comp)) { -
      -
      -
      Class
      -
      {{ bareName(comp.className) }}
      -
      File
      -
      - -
      -
      Standalone
      -
      {{ comp.isStandalone ? 'Yes' : 'No' }}
      - @if (comp.kind === 'component') { -
      Change detection
      -
      {{ comp.changeDetection ?? 'Unknown' }}
      - } -
      Inputs
      -
      {{ comp.inputs.join(', ') || 'None' }}
      -
      Outputs
      -
      {{ comp.outputs.join(', ') || 'None' }}
      -
      - @for (form of formsIn(comp.file); track form.formId) { - - } -
      - } -
    • - } -
    - } - } - - - - @let found = directiveSources(name); - @if (found.length === 1) { - - Likely, matched by class name: - - } @else if (found.length > 1) { - {{ found.length }} source files define {{ bareName(name) }} - } - - `, - styles: ` - @use 'mixins' as m; - - :host { - display: block; - color: var(--text); - font-size: 13px; - } - .mono { - font-family: var(--font-mono); - } - .muted { - color: var(--text-2); - } - .intro { - max-width: 720px; - margin: 0 0 12px; - color: var(--text-2); - line-height: 1.5; - } - .notice { - margin: 0 0 12px; - padding: 10px 12px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--surface); - color: var(--text-2); - line-height: 1.5; - } - .toolbar { - position: sticky; - top: 0; - z-index: 2; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; - margin: 0 0 12px; - padding: 8px; - background: color-mix(in srgb, var(--surface) 85%, transparent); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - border: 1px solid var(--border); - border-radius: var(--radius); - } - .search { - position: relative; - flex: 1 1 220px; - 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; - } - .search:focus-within .search-icon { - stroke: var(--accent); - } - input[type='search'] { - width: 100%; - height: 34px; - padding: 0 12px 0 34px; - background: var(--bg); - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - color: var(--text); - font: inherit; - font-size: 13px; - } - input[type='search']::placeholder { - color: var(--text-3); - } - input[type='search']:focus-visible { - @include m.field-focus; - } - .count { - flex: none; - color: var(--text-2); - font-size: 12px; - font-variant-numeric: tabular-nums; - white-space: nowrap; - } - button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - height: 34px; - padding: 0 14px; - background: var(--surface-2); - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - color: var(--text); - cursor: pointer; - font: inherit; - font-size: 13px; - font-weight: 500; - transition: - background-color 150ms var(--ease), - border-color 150ms var(--ease); - } - button:hover { - background: var(--surface-3); - border-color: var(--accent-line); - } - button:focus-visible { - @include m.focus-ring; - } - button svg { - flex: none; - width: 14px; - height: 14px; - fill: none; - stroke: currentColor; - stroke-width: 2.2; - stroke-linecap: round; - stroke-linejoin: round; - } - .refresh.spinning svg { - animation: spin 0.8s linear infinite; - } - .page-select { - flex: 0 1 220px; - min-width: 0; - } - .tree-action:disabled, - .tree-action:disabled:hover { - background: var(--surface-2); - border-color: var(--border-strong); - opacity: 0.5; - } - .pick.on { - background: var(--accent-soft); - border-color: var(--accent-line); - color: var(--text-strong); - } - .defer { - margin-top: 16px; - } - .pick-message { - margin: 0 0 12px; - color: var(--text-2); - line-height: 1.5; - } - .layout { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 12px; - align-items: start; - } - @media (min-width: 880px) { - .layout { - grid-template-columns: minmax(0, 1fr) minmax(340px, 44%); - } - .detail { - position: sticky; - top: 64px; - max-height: calc(100vh - 150px); - overflow: auto; - } - } - .tree { - min-width: 0; - padding: 6px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - @include m.enter; - } - .window-note { - margin: 0 0 6px; - font-size: 12px; - color: var(--text-2); - } - .tree.windowed .row { - height: 32px; - overflow: hidden; - } - .row { - position: relative; - scroll-margin-top: var(--ct-toolbar-clearance, 72px); - scroll-margin-bottom: 8px; - display: flex; - align-items: center; - gap: 6px; - min-height: 32px; - padding: 0 8px 0 calc(4px + var(--depth, 0) * 18px); - border-radius: var(--radius-sm); - cursor: pointer; - transition: background-color 120ms var(--ease); - } - .row::before { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 14px; - width: calc(var(--depth, 0) * 18px); - background: repeating-linear-gradient(to right, var(--border) 0 1px, transparent 1px 18px); - pointer-events: none; - } - .row:hover { - background: var(--surface-2); - } - .row:focus-visible { - @include m.focus-ring(-2px); - } - .row.selected { - background: var(--accent-soft); - box-shadow: inset 2px 0 0 var(--accent); - } - .twisty, - .twisty-space { - flex: none; - width: 20px; - height: 20px; - } - .twisty { - display: grid; - place-items: center; - border-radius: 4px; - color: var(--text-3); - } - .twisty:hover { - color: var(--text); - background: var(--surface-3); - } - .twisty svg { - width: 14px; - height: 14px; - fill: none; - stroke: currentColor; - stroke-width: 2.2; - stroke-linecap: round; - stroke-linejoin: round; - transition: transform 150ms var(--ease); - } - .twisty.open svg { - transform: rotate(90deg); - } - .name { - min-width: 0; - color: var(--text-strong); - font-size: 12px; - font-weight: 600; - @include m.truncate; - } - .tag { - flex: 0 1 auto; - min-width: 0; - color: var(--text-2); - font-size: 12px; - @include m.truncate; - } - .routed { - flex: none; - max-width: 40%; - padding: 0 8px; - border: 1px solid var(--accent-line); - border-radius: 99px; - background: var(--accent-soft); - color: var(--accent); - font-family: var(--font-mono); - font-size: 11px; - line-height: 18px; - @include m.truncate; - } - .checks { - flex: none; - margin-left: auto; - padding: 0 7px; - border-radius: 99px; - background: var(--accent-soft); - color: var(--text); - font-size: 11px; - line-height: 18px; - font-variant-numeric: tabular-nums; - } - .checks + .dirs { - margin-left: 0; - } - .row.dir-host .host-tag { - color: var(--text); - } - .host-dirs { - min-width: 0; - color: var(--text-2); - font-size: 12px; - font-style: italic; - @include m.truncate; - } - .row.dir-host .twisty-space, - .row.dir-host .twisty { - border-left: 1px dashed var(--border-strong); - } - .hyd { - flex: none; - padding: 0 7px; - border-radius: 99px; - background: var(--surface-3); - color: var(--text-2); - font-size: 11px; - line-height: 18px; - } - .hyd-skipped { - @include m.tint(warn); - } - .hyd-mismatched { - @include m.tint(danger); - } - .tree-action[aria-pressed='true'] { - background: var(--accent-soft); - border-color: var(--accent-line); - color: var(--text-strong); - } - /* A narrow panel keeps the search and count on one row and the tree - actions on a second one, as icons named by their hidden labels. */ - .tree-actions { - display: contents; - } - @media (max-width: 600px) { - .toolbar { - gap: 8px; - } - .search { - flex-basis: 100%; - } - .toolbar:has(.page-select) .search { - flex-basis: 160px; - } - .page-select { - flex: 1 1 120px; - } - .count { - flex: 1 1 80px; - min-width: 0; - white-space: normal; - line-height: 1.3; - } - .tree-actions { - display: flex; - flex: none; - gap: 8px; - margin-left: auto; - } - .tree-action, - .pick { - flex: none; - width: var(--control-h); - padding: 0; - } - .tree-action .label, - .pick .label { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - } - } - .tree-col { - position: relative; - min-width: 0; - } - .name-tip { - position: absolute; - z-index: 1; - left: 8px; - max-width: calc(100% - 16px); - padding: 4px 8px; - background: var(--surface-3); - border: 1px solid var(--border-strong); - border-radius: var(--radius-sm); - box-shadow: var(--shadow); - color: var(--text-strong); - font-size: 12px; - line-height: 1.4; - overflow-wrap: anywhere; - pointer-events: none; - @include m.enter(150ms); - } - .owner-action { - margin-top: 10px; - } - .dirs { - flex: none; - margin-left: auto; - padding: 0 7px; - border-radius: 99px; - background: var(--surface-3); - color: var(--text-2); - font-size: 11px; - line-height: 18px; - } - .detail { - min-width: 0; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - @include m.enter; - } - .detail.placeholder { - padding: 24px 16px; - border-style: dashed; - text-align: center; - } - .detail-head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px 10px; - padding: 14px 16px; - border-bottom: 1px solid var(--border); - } - .detail-head h2 { - min-width: 0; - margin: 0; - color: var(--text-strong); - font-size: 15px; - font-weight: 600; - overflow-wrap: anywhere; - } - .where { - width: 100%; - margin: 0; - color: var(--text-2); - font-size: 12px; - overflow-wrap: anywhere; - } - .directive-where { - margin: -4px 0 8px; - font-size: 12px; - } - .directive-where:empty { - display: none; - } - .directive-guess { - display: inline-flex; - align-items: center; - font-family: var(--font-sans); - } - .likely { - display: inline-block; - margin-right: 6px; - padding: 0 6px; - border: 1px solid; - border-radius: 99px; - @include m.tint(warn); - font-family: var(--font-sans); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.06em; - line-height: 16px; - text-transform: uppercase; - vertical-align: 1px; - } - .guess-text { - margin: 0; - } - .guess-list { - margin: 4px 0 0; - padding-left: 18px; - } - .badge { - padding: 2px 8px; - border-radius: 99px; - @include m.tint(accent); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - } - .actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - width: 100%; - } - .show-form { - background: var(--accent); - border-color: var(--accent); - color: var(--accent-ink); - font-weight: 600; - } - .show-form:hover { - background: var(--accent-hover); - border-color: var(--accent-hover); - } - .facts { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - gap: 8px 16px; - margin: 0; - padding: 14px 16px; - border-bottom: 1px solid var(--border); - } - .facts dt { - @include m.label; - align-self: center; - } - .facts dd { - min-width: 0; - margin: 0; - color: var(--text); - overflow-wrap: anywhere; - } - .block { - padding: 14px 16px; - border-bottom: 1px solid var(--border); - } - .block:last-child { - border-bottom: 0; - } - h3 { - @include m.label; - display: flex; - align-items: center; - gap: 6px; - margin: 0 0 8px; - } - .pill { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 16px; - padding: 0 5px; - border-radius: 99px; - background: var(--surface-3); - color: var(--text-2); - font-size: 10px; - font-weight: 600; - letter-spacing: 0; - } - .props, - .deps, - .chips { - display: grid; - gap: 6px; - margin: 0; - padding: 0; - list-style: none; - } - .chips { - display: flex; - flex-wrap: wrap; - } - .prop, - .dep { - min-width: 0; - padding: 8px 10px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--bg); - } - .prop.changed { - outline: 2px solid var(--accent); - outline-offset: -1px; - animation: value-flash 1s var(--ease) forwards; - } - @keyframes value-flash { - from { - outline-color: var(--accent); - } - to { - outline-color: transparent; - } - } - @media (prefers-reduced-motion: reduce) { - .prop.changed { - animation: none; - } - } - .signal-link { - height: auto; - min-height: 0; - margin-left: 6px; - padding: 0 6px; - font-family: inherit; - font-size: 11px; - font-weight: 500; - line-height: 18px; - } - .signal-link:focus-visible { - @include m.focus-ring(-2px); - } - .prop-name { - display: block; - margin-bottom: 4px; - color: var(--text-strong); - font-size: 12px; - font-weight: 600; - } - pre { - max-height: 200px; - margin: 0; - overflow: auto; - color: var(--text); - font-family: var(--font-mono); - font-size: 12px; - line-height: 1.5; - white-space: pre-wrap; - overflow-wrap: anywhere; - } - .chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 2px 10px; - border: 1px solid var(--border-strong); - border-radius: 99px; - color: var(--text); - font-size: 12px; - } - .chip.on { - @include m.tint(ok); - } - .chip-note { - color: var(--text-2); - font-size: 11px; - } - .chip.on .chip-note { - color: inherit; - } - .dep { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px 8px; - } - .token { - min-width: 0; - color: var(--text-strong); - font-size: 12px; - font-weight: 600; - overflow-wrap: anywhere; - } - .flag { - padding: 0 6px; - border: 1px solid var(--border-strong); - border-radius: 5px; - color: var(--text-2); - font-family: var(--font-mono); - font-size: 10px; - font-weight: 400; - letter-spacing: 0; - line-height: 16px; - text-transform: none; - } - .from { - margin-left: auto; - color: var(--text-2); - font-size: 12px; - } - .from .mono { - color: var(--text); - } - .from.missing { - color: var(--warn); - } - .empty-line { - margin: 0; - color: var(--text-2); - } - .state { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - padding: 40px 16px; - text-align: center; - background: var(--surface); - border: 1px dashed var(--border-strong); - border-radius: var(--radius); - } - .state.compact { - padding: 24px 12px; - border: 0; - background: none; - } - .state-title { - margin: 0; - color: var(--text-strong); - font-size: 14px; - font-weight: 600; - overflow-wrap: anywhere; - } - .state-hint { - max-width: 440px; - margin: 0 auto; - color: var(--text-2); - line-height: 1.5; - } - .spinner { - width: 20px; - height: 20px; - border: 2px solid var(--border-strong); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - } - @media (prefers-reduced-motion: reduce) { - .spinner, - .refresh.spinning svg { - animation: none; - } - } - .source-list { - display: flex; - flex-direction: column; - gap: 8px; - margin: 0; - padding: 0; - list-style: none; - } - .source-item { - min-width: 0; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - overflow: hidden; - } - .source-item.expanded { - border-color: var(--accent-line); - box-shadow: inset 2px 0 0 var(--accent); - } - .source-toggle { - justify-content: flex-start; - gap: 12px; - width: 100%; - height: auto; - min-height: 52px; - padding: 10px 16px; - background: none; - border: 0; - border-radius: 0; - text-align: left; - } - .source-toggle:hover { - background: var(--surface-2); - } - .source-toggle:focus-visible { - @include m.focus-ring(-2px); - } - .kind-flag { - flex: none; - padding: 1px 8px; - border-radius: 99px; - @include m.tint(accent); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - } - .kind-flag.directive { - @include m.tint(ok); - } - .row-main { - display: flex; - flex: 1; - flex-direction: column; - gap: 2px; - min-width: 0; - } - .selector { - color: var(--text-strong); - font-weight: 600; - overflow-wrap: anywhere; - } - .file { - color: var(--text-2); - font-size: 12px; - @include m.truncate; - } - .chevron { - flex: none; - width: 16px; - height: 16px; - fill: none; - stroke: var(--text-3); - stroke-width: 2.2; - transition: transform 200ms var(--ease); - } - .expanded .chevron { - transform: rotate(90deg); - stroke: var(--accent); - } - .inline-detail { - padding: 0 0 12px; - border-top: 1px solid var(--border); - } - .inline-detail .facts { - border-bottom: 0; - } - .inline-detail .show-form { - margin: 0 16px; - } - `, + templateUrl: './component-tree.html', + styleUrl: './component-tree.scss', }) export class ComponentTree { readonly rpc = input(null); - readonly staticReport = computed(() => isStaticReport(this.rpc())); readonly focus = input(null); readonly showForm = output(); readonly focusHandled = output(); @@ -1891,394 +81,92 @@ export class ComponentTree { readonly canShowSignals = input(false); readonly showSignals = output(); - readonly filter = signal(''); - readonly loading = signal(false); - readonly error = signal(false); - readonly source = signal([]); - readonly formOwners = signal<{ formId: string; label: string; file: string | null }[]>([]); - readonly pages = signal>({}); - readonly chosenPageId = signal(null); - readonly selectedId = signal(null); - readonly destroyed = signal(null); - readonly announcement = signal(''); - readonly picking = signal(false); - readonly pickMessage = signal(''); - private pickPageId: string | null = null; - private pickSeq = 0; - readonly focusId = signal(null); - /** The full text of the focused row when its name or tag is cut off, shown below the row. */ - readonly nameTip = signal<{ text: string; top: number } | null>(null); - readonly pickLabel = computed(() => - this.picking() - ? 'Cancel pick' - : this.native() - ? 'Pick component on device' - : 'Pick component on page', - ); - private readonly bridge = inject(ExtensionBridge); - readonly actionMessage = linkedSignal({ - source: this.selectedId, - computation: () => '', - }); - /** What the last copy did, for the selected component only. */ - readonly copyMessage = linkedSignal({ - source: this.selectedId, - computation: () => '', - }); - /** Values of the selected component that changed in the last push, outlined for a second. */ - private readonly flash = new ValueFlash(); - readonly collapsed = signal>(new Set()); - /** Elements that host directives but no component show as rows; this viewer can hide them. */ - readonly showDirectiveHosts = signal(storedDirectiveHosts()); - readonly openKey = signal(null); - private readonly routerPages = signal<{ pageId?: string; outlets?: OutletInfo[] }[]>([]); - private readonly pageId = hostPageId(); - private readonly angularPages = injectAngularPages(() => this.rpc()); - /** The shown page's Angular version and debug API parts, for the unavailable notes. */ - readonly angularPage = computed(() => - angularPageFor(this.angularPages(), this.page()?.pageId ?? this.pageId), - ); - /** `@defer` and `@for` exist from Angular 17.0, so older pages get no note about them. */ - private readonly hasControlFlow = computed(() => { - const version = this.angularPage()?.version; - return !version || angularVersionAtLeast(version, '17.0'); - }); - readonly deferUnavailable = computed( - () => this.hasControlFlow() && lacksCapability(this.angularPage(), 'deferBlocks'), - ); - readonly forUnavailable = computed( - () => this.hasControlFlow() && lacksCapability(this.angularPage(), 'forBlocks'), - ); + private readonly state = inject(TreeState); + private readonly picker = inject(PickState); + private readonly scan = inject(SourceScan); + private readonly details = inject(DetailState); private readonly destroyRef = inject(DestroyRef); - 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. */ - protected readonly pagesKnown = signal(false); - private readonly cleanups: (() => void)[] = []; - private selection = { pageId: null as string | null, detailId: null as string | null }; - - private readonly shownPageId = linkedSignal< - { pages: Record; chosen: string | null }, - string | null - >({ - source: () => ({ pages: this.pages(), chosen: this.chosenPageId() }), - computation: ({ pages, chosen }, previous) => - pickPage(pages, { chosen, host: this.pageId, previous: previous?.value ?? null }), - }); - - readonly page = computed(() => { - const id = this.shownPageId(); - return id ? (this.pages()[id] ?? null) : null; - }); - - readonly pageOptions = computed(() => - Object.values(this.pages()).map((page) => ({ - value: page.pageId, - label: page.title || page.url || `Page ${page.pageId}`, - hint: page.url, - })), - ); - - readonly live = computed(() => (this.page()?.roots.length ?? 0) > 0); - readonly native = computed(() => isNativePage(this.page())); - /** Inputs, properties and writable signals are editable on a live browser page. */ - readonly canEdit = computed( - () => - this.live() && - !this.native() && - !this.staticReport() && - actionAllowed(this.rpc(), 'components'), - ); - readonly devtoolsActions = computed( - () => this.bridge.available && !!this.page() && !this.native() && !this.staticReport(), - ); - - private readonly cdPages = signal>({}); - readonly cdPage = computed(() => { - const pageId = this.page()?.pageId; - return pageId ? (this.cdPages()[pageId] ?? null) : null; - }); - readonly cdHosts = computed(() => this.cdPage()?.hosts ?? {}); - private readonly leaksPages = signal>({}); - /** The shown page's leak report, from the `leaks` shared state. */ - readonly leaksPage = computed(() => { - const pageId = this.page()?.pageId; - return pageId ? (this.leaksPages()[pageId] ?? null) : null; - }); - - /** The page's tree, without directive hosts when this viewer hid them. */ - private readonly visibleRoots = computed(() => { - const roots = this.page()?.roots ?? []; - return this.showDirectiveHosts() || !this.page()?.directiveHostCount - ? roots - : withoutDirectiveHosts(roots); - }); - - private readonly index = computed(() => { - const map = new Map(); - const parents = new Map(); - const walk = (nodes: LiveNode[], parent: string | null) => { - for (const node of nodes) { - // The first row with an id wins, so `parents` can never loop. - if (map.has(node.id)) continue; - map.set(node.id, node); - if (parent) parents.set(node.id, parent); - walk(node.children, node.id); - } - }; - walk(this.visibleRoots(), null); - return { map, parents }; - }); - - private readonly query = computed(() => this.filter().trim().toLowerCase()); - - private readonly filtered = computed(() => filterTree(this.visibleRoots(), this.query())); - - /** Directive host rows in the tree as shown. */ - private readonly shownDirectiveHosts = computed(() => - this.showDirectiveHosts() ? (this.page()?.directiveHostCount ?? 0) : 0, - ); - - readonly deferBlocks = computed(() => this.page()?.deferBlocks ?? []); - /** The defer blocks in the selected component's template. */ - readonly ownedDeferBlocks = computed(() => { - const id = this.selectedId(); - return id ? this.deferBlocks().filter((block) => block.owner?.id === id) : []; - }); - - readonly countLabel = computed(() => - countText( - this.page()?.count ?? 0, - this.filtered().matches, - !!this.query(), - !!this.page()?.truncated, - this.shownDirectiveHosts(), - ), - ); - - readonly truncationText = computed(() => truncationNotice(this.page()?.truncatedBy)); - - readonly rows = computed(() => { - const rows: Row[] = []; - const collapsed = this.collapsed(); - const searching = !!this.query(); - const walk = (nodes: LiveNode[], depth: number) => { - nodes.forEach((node, i) => { - const expanded = searching || !collapsed.has(node.id); - rows.push({ - node, - depth, - hasChildren: node.children.length > 0, - expanded, - posinset: i + 1, - setsize: nodes.length, - }); - if (expanded) walk(node.children, depth + 1); - }); - }; - walk(this.filtered().nodes, 0); - return rows; - }); - - /** A long tree renders only the rows near the visible part of the page. */ - readonly rowWindow = new VirtualWindow({ - count: computed(() => this.rows().length), - estimate: TREE_ROW_PX, - fixed: true, - }); - readonly shownRows = computed(() => { - const { start, end } = this.rowWindow.range(); - return this.rows() - .slice(start, end) - .map((row, i) => ({ row, index: start + i })); - }); - /** The row Tab lands on: the roving one, or the first rendered row when it is out of view. */ - readonly tabStop = computed(() => { - const id = this.rovingId(); - const shown = this.shownRows(); - return shown.some(({ row }) => row.node.id === id) ? id : (shown[0]?.row.node.id ?? null); - }); - private readonly treeEl = viewChild>('treeEl'); - - readonly selectedNode = computed(() => { - const id = this.selectedId(); - return id ? (this.index().map.get(id) ?? null) : null; - }); - - /** The selected row when it is a directive host, which has no component detail. */ - readonly selectedHost = computed(() => { - const node = this.selectedNode(); - return node?.kind === 'directive' ? node : null; - }); - - readonly detail = computed(() => { - const detail = this.page()?.detail; - return detail && detail.id === this.selectedId() ? detail : null; - }); - - /** Opened value levels of the selected component. */ - readonly values = new ValueStore(); - /** The detail's filter over input, output and property names and opened nested keys. */ - readonly propFilter = signal(''); - readonly propQuery = computed(() => this.propFilter().trim().toLowerCase()); - private readonly propFilterInput = viewChild('propFilterInput'); - - /** Inputs, outputs, properties and directive inputs the filter keeps, and how many. */ - readonly shownValues = computed(() => { - const d = this.detail(); - if (!d) return null; - const query = this.propQuery(); - const named = (p: Prop) => - !query || p.name.toLowerCase().includes(query) || p.prop.toLowerCase().includes(query); - const kept = (p: Prop, directive?: number) => - named(p) || - nestedMatches([p.prop], p.value, (path) => this.values.level(path, directive), query, 1) - .length > 0; - const inputs = d.inputs.filter((p) => kept(p)); - const outputs = d.outputs.filter(named); - const properties = (d.properties ?? []).filter((p) => kept(p)); - const directives = d.directives - .map((dir, index) => ({ - ...dir, - index, - inputs: dir.inputs.filter((p) => kept(p, index)), - outputs: dir.outputs.filter(named), - })) - .filter((dir) => !query || dir.inputs.length || dir.outputs.length); - const count = - inputs.length + - outputs.length + - properties.length + - directives.reduce((n, dir) => n + dir.inputs.length + dir.outputs.length, 0); - return { inputs, outputs, properties, directives, count }; - }); - - readonly valueTotal = computed(() => { - const d = this.detail(); - if (!d) return 0; - return ( - d.inputs.length + - d.outputs.length + - (d.properties?.length ?? 0) + - d.directives.reduce((n, dir) => n + dir.inputs.length + dir.outputs.length, 0) - ); - }); - - readonly propFilterStatus = computed(() => { - if (!this.propQuery()) return ''; - const count = this.shownValues()?.count ?? 0; - return count - ? `${count} of ${this.valueTotal()} values match.` - : 'No inputs, outputs or properties match.'; - }); - - /** Clears the detail filter and keeps focus in its field. */ - clearPropFilter(event?: Event) { - if (event && this.propFilter()) { - event.stopPropagation(); - event.preventDefault(); - } - this.propFilter.set(''); - this.propFilterInput()?.focus(); - } - - readonly rovingId = computed(() => { - const rows = this.rows(); - const ids = new Set(rows.map((r) => r.node.id)); - const focus = this.focusId(); - if (focus && ids.has(focus)) return focus; - const selected = this.selectedId(); - if (selected && ids.has(selected)) return selected; - return rows[0]?.node.id ?? null; - }); - - private readonly outlets = computed(() => { - const shown = this.page()?.pageId; - return this.routerPages().find((p) => p.pageId === shown)?.outlets ?? []; - }); - - readonly routedById = computed(() => { - const map = new Map(); - const visit = (outlets: OutletInfo[]) => { - for (const outlet of outlets) { - if (outlet.activated && outlet.devtoolsId) { - const hits = map.get(outlet.devtoolsId) ?? []; - hits.push({ route: outlet.route ?? '', outlet: outlet.outlet }); - map.set(outlet.devtoolsId, hits); - } - if (outlet.children) visit(outlet.children); - } - }; - visit(this.outlets()); - return map; - }); - - private readonly sourceByClass = computed(() => { - const map = new Map(); - for (const comp of this.source()) { - const key = bare(comp.className); - map.set(key, [...(map.get(key) ?? []), comp]); - } - return map; - }); - - readonly filteredSource = computed(() => { - const q = this.query(); - const all = this.source(); - if (!q) return all; - return all.filter( - (c) => - c.selector.toLowerCase().includes(q) || - c.className.toLowerCase().includes(q) || - c.file.toLowerCase().includes(q), - ); - }); - - readonly sourceCounts = computed(() => { - const components = this.source().filter((c) => c.kind === 'component').length; - return { components, directives: this.source().length - components }; - }); + readonly staticReport = computed(() => this.state.staticReport()); + readonly filter = this.state.filter; + readonly pages = this.state.pages; + readonly chosenPageId = this.state.chosenPageId; + readonly selectedId = this.state.selectedId; + readonly destroyed = this.state.destroyed; + readonly announcement = this.state.announcement; + readonly pickMessage = this.state.pickMessage; + readonly focusId = this.state.focusId; + readonly collapsed = this.state.collapsed; + readonly showDirectiveHosts = this.state.showDirectiveHosts; + protected readonly pagesKnown = this.state.pagesKnown; + readonly angularPage = this.state.angularPage; + readonly deferUnavailable = this.state.deferUnavailable; + readonly forUnavailable = this.state.forUnavailable; + readonly page = this.state.page; + readonly pageOptions = this.state.pageOptions; + readonly live = this.state.live; + readonly native = this.state.native; + readonly canEdit = this.state.canEdit; + readonly cdPage = this.state.cdPage; + readonly cdHosts = this.state.cdHosts; + readonly leaksPage = this.state.leaksPage; + readonly deferBlocks = this.state.deferBlocks; + readonly ownedDeferBlocks = this.state.ownedDeferBlocks; + readonly countLabel = this.state.countLabel; + readonly truncationText = this.state.truncationText; + readonly rows = this.state.rows; + readonly rowWindow = this.state.rowWindow; + readonly shownRows = this.state.shownRows; + readonly tabStop = this.state.tabStop; + readonly selectedNode = this.state.selectedNode; + readonly selectedHost = this.state.selectedHost; + readonly detail = this.state.detail; + readonly rovingId = this.state.rovingId; + readonly routedById = this.state.routedById; + + readonly picking = this.picker.picking; + readonly pickLabel = this.picker.pickLabel; + + readonly loading = this.scan.loading; + readonly error = this.scan.error; + readonly source = this.scan.source; + readonly formOwners = this.scan.formOwners; + readonly openKey = this.scan.openKey; + readonly filteredSource = this.scan.filteredSource; + readonly sourceCounts = this.scan.sourceCounts; + + readonly values = this.details.values; + readonly propFilter = this.details.propFilter; + readonly propQuery = this.details.propQuery; + readonly shownValues = this.details.shownValues; + readonly valueTotal = this.details.valueTotal; + readonly propFilterStatus = this.details.propFilterStatus; + readonly actionMessage = this.details.actionMessage; + readonly copyMessage = this.details.copyMessage; + readonly devtoolsActions = this.details.devtoolsActions; constructor() { - effect(() => { - const tree = this.treeEl()?.nativeElement; - if (tree) this.rowWindow.attach(tree); - }); - effect(() => { - const detail = this.detail(); - const target = { - rpc: this.rpc(), - pageId: this.page()?.pageId ?? null, - componentId: detail?.id ?? null, - }; - untracked(() => this.values.follow(target, detail?.expanded)); + const state = this.state; + state.connect({ + rpc: this.rpc, + canShowInjector: this.canShowInjector, + canShowSignals: this.canShowSignals, + canShowPipes: this.canShowPipes, }); + effect(() => this.details.followValues()); effect(() => { const shown = this.page()?.pageId ?? null; - untracked(() => { - if (this.picking() && shown !== this.pickPageId) this.dropPick(); - }); + untracked(() => this.picker.followPage(shown)); }); - effect(() => { - const detail = this.detail(); - const owner = detail ? `${this.page()?.pageId ?? ''}|${detail.id}` : null; - const values = new Map(); - for (const p of detail?.inputs ?? []) values.set(flashKey('input', p.name), p.value); - for (const p of detail?.properties ?? []) values.set(flashKey('property', p.name), p.value); - detail?.directives.forEach((dir, index) => { - for (const p of dir.inputs) values.set(flashKey('directive', p.name, index), p.value); - }); - untracked(() => this.flash.update(owner, values)); - }); - this.destroyRef.onDestroy(() => this.flash.clear()); + effect(() => this.details.trackChanges()); + this.destroyRef.onDestroy(() => this.details.clearChanges()); effect(() => { const client = this.rpc(); if (!client) return; void this.refresh(); - void this.watch(client); + void state.watch(client); }); effect(() => { const focus = this.focus(); @@ -2292,7 +180,7 @@ export class ComponentTree { if (!this.live()) return; untracked(() => { const onPage = !focus.pageId || shown === focus.pageId; - if (onPage && this.index().map.has(focus.id)) this.reveal(focus.id); + if (onPage && state.index().map.has(focus.id)) this.reveal(focus.id); else this.announcement.set('That component is no longer on the page.'); this.focusHandled.emit(); }); @@ -2316,7 +204,7 @@ export class ComponentTree { if (!recording) { if (this.native() || this.staticReport()) { untracked(() => { - this.say('This page cannot record change detection.'); + state.say('This page cannot record change detection.'); this.commandHandled.emit(); }); } @@ -2328,283 +216,19 @@ export class ComponentTree { this.commandHandled.emit(); }); }); - this.destroyRef.onDestroy(() => { - this.highlight(null); - for (const cleanup of this.cleanups.splice(0)) cleanup(); - }); - } - - private async watch(client: DevframeRpcClient) { - for (const cleanup of this.cleanups.splice(0)) cleanup(); - const my = client.scope('pangular'); - try { - const tree = await my.rpc.sharedState('component-tree'); - if (this.destroyRef.destroyed) return; - 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'); - if (this.destroyRef.destroyed) return; - const applyCd = (value: unknown) => { - const pages = (value as { pages?: Record } | null)?.pages; - this.cdPages.set(pages && typeof pages === 'object' ? pages : {}); - }; - applyCd(cd.value()); - this.cleanups.push(cd.on('updated', applyCd)); - } catch { - this.cdPages.set({}); - } - try { - const leaks = await my.rpc.sharedState('leaks'); - if (this.destroyRef.destroyed) return; - const applyLeaks = (value: unknown) => { - const pages = (value as { pages?: Record } | null)?.pages; - this.leaksPages.set(pages && typeof pages === 'object' ? pages : {}); - }; - applyLeaks(leaks.value()); - this.cleanups.push(leaks.on('updated', applyLeaks)); - } catch { - this.leaksPages.set({}); - } - try { - const router = await my.rpc.sharedState('router'); - if (this.destroyRef.destroyed) return; - const applyRouter = (value: unknown) => { - const pages = (value as { pages?: { pageId?: string; outlets?: OutletInfo[] }[] })?.pages; - this.routerPages.set(Array.isArray(pages) ? pages : []); - }; - applyRouter(router.value()); - this.cleanups.push(router.on('updated', applyRouter)); - } catch { - this.routerPages.set([]); - } - } - - async refresh() { - const client = this.rpc(); - if (!client) return; - this.loading.set(true); - const my = client.scope('pangular'); - try { - this.source.set((await my.rpc.call('get-components')) as SourceComponent[]); - this.error.set(false); - } catch { - this.error.set(true); - } finally { - this.loading.set(false); - } - const owners = (await my.rpc.call('forms-owners').catch(() => [])) as { - formId: string; - label: string; - file: string | null; - }[]; - this.formOwners.set(owners ?? []); + this.destroyRef.onDestroy(() => state.destroy()); } - formsIn(file: string) { - return this.formOwners().filter((form) => form.file === file); - } - - /** - * The exact location the page gives, else the source scan's guess when only one class may be - * it. Several candidates give null, so Open source falls back to the class itself. - */ - whereOf(node: LiveNode): SourceLocation | null { - const exact = this.detail()?.source; - if (exact) return exact; - const candidates = this.sourceCandidates(node); - return candidates.length === 1 ? candidates[0] : null; - } - - async revealInElements(node: LiveNode) { - const pageId = this.page()?.pageId; - if (!pageId) return; - this.actionMessage.set(''); - const result = await this.bridge.reveal(pageId, node.id); - if (this.selectedId() !== node.id) return; - this.actionMessage.set( - result.ok - ? '' - : 'Could not reveal it. The component must be rendered in the tab these DevTools inspect.', - ); - } - - /** Whether a value of the detail changed in the last push, so it shows an outline. */ - flashed(section: 'input' | 'property' | 'directive', name: string, index?: number): boolean { - return this.flash.changed().has(flashKey(section, name, index)); - } - - /** Opens the Signals tab on this component's graph, with the signal in `key` selected. */ - openSignals(node: LiveNode, key?: string) { - const pageId = this.page()?.pageId; - this.showSignals.emit({ - componentId: node.id, - ...(pageId ? { pageId } : {}), - ...(key ? { key } : {}), - }); - } - - /** Copies only what the panel holds, which the page serialized and masked already. */ - async copyJson(detail: Detail) { - await this.copy(detail.id, componentJson(detail), `Copied ${detail.name} as JSON.`); - } - - async copyHostPath(detail: Detail) { - await this.copy( - detail.id, - hostPathText(detail), - detail.selector ? 'Copied the host selector.' : 'Copied the host path.', - ); - } - - private async copy(id: string, text: string, done: string) { - let message = done; - try { - await navigator.clipboard.writeText(text); - } catch { - message = 'The clipboard is not available here.'; - } - if (this.selectedId() === id) this.copyMessage.set(message); - } - - /** Opens the Injectors tab on this component's element injector. */ - openInjector(node: LiveNode) { - const pageId = this.page()?.pageId; - this.showInjector.emit(pageId ? { componentId: node.id, pageId } : { componentId: node.id }); - } - - async openSource(node: LiveNode) { - const pageId = this.page()?.pageId; - if (!pageId) return; - const where = this.whereOf(node); - this.actionMessage.set(''); - const result = await this.bridge.openSource(pageId, node.id, where); - if (this.selectedId() !== node.id) return; - if (result.ok) return; - this.actionMessage.set( - where - ? `Not found in Sources. Open ${where.file}:${where.line} in your editor.` - : 'Could not open the source. The component must be rendered in the tab these DevTools inspect.', - ); - } - - /** Why a location from the source scan is marked as a guess. */ - protected readonly guessTitle = - 'The page gave no exact location. This comes from the source scan, matched by class name.'; - - /** - * The source scan components that may define this instance, for when the page gives no exact - * location: the classes with its name, narrowed to those whose selector is its host tag when - * any is. More than one means the scan cannot tell which file it is. - */ - sourceCandidates(node: LiveNode): SourceComponent[] { - const matches = (this.sourceByClass().get(bare(node.name)) ?? []).filter( - (c) => c.kind === 'component', - ); - const bySelector = matches.filter((c) => c.selector === node.tag); - return bySelector.length ? bySelector : matches; - } - - /** - * The source scan entry of this instance: the one in the file the page names, or the only - * candidate. Null when none matches or several may be it. - */ - sourceFor(node: LiveNode): SourceComponent | null { - const candidates = this.sourceCandidates(node); - const exact = this.detail()?.source; - if (exact) return candidates.find((c) => sameSourceFile(c.file, exact.file)) ?? null; - return candidates.length === 1 ? candidates[0] : null; - } - - /** - * The source scan directives a directive on the page may be, by class name. Angular gives - * directives no debug location, so a match is only likely. - */ - directiveSources(name: string): SourceComponent[] { - return (this.sourceByClass().get(bare(name)) ?? []).filter((c) => c.kind === 'directive'); - } - - sourceKey(comp: SourceComponent) { - return `${comp.file}#${comp.className}`; - } - - sourceLabel(comp: SourceComponent) { - if (!comp.selector) return bare(comp.className); - return comp.kind === 'component' && /^[a-z][\w-]*$/i.test(comp.selector) - ? `<${comp.selector}>` - : comp.selector; - } - - bareName(name: string) { - return bare(name); - } - - private applyPages(pages: Record) { - const active = document.activeElement; - const focused = - active instanceof HTMLElement && this.host.nativeElement.contains(active) - ? (active.closest('.row')?.dataset['id'] ?? null) - : null; - const before = focused ? this.rows().map((row) => row.node.id) : []; - const known = this.index().map; - this.pages.set(pages); - this.syncSelection(known); - if (!focused) return; - const after = new Set(this.rows().map((row) => row.node.id)); - if (after.has(focused)) return; - const next = nearestRow(before, after, focused); - if (!next) return; - this.focusId.set(next); - afterNextRender(() => this.rowElement(next)?.focus(), { injector: this.injector }); - } - - private syncSelection(known = this.index().map) { - const page = this.page(); - const next = reconcileSelection( - { ...this.selection, selectedId: this.selectedId() }, - page - ? { pageId: page.pageId, roots: page.roots, detail: page.detail, truncated: page.truncated } - : null, - ); - const name = next.destroyed ? known.get(next.destroyed)?.name : undefined; - this.selection = { pageId: next.pageId, detailId: next.detailId }; - if (next.selectedId !== this.selectedId()) this.selectedId.set(next.selectedId); - if (next.selectedId) this.destroyed.set(null); - if (!next.destroyed) return; - this.destroyed.set(name ?? 'The component'); - this.announcement.set('The selected component was destroyed.'); - this.sendSelection(null); + refresh() { + return this.scan.refresh(); } selectPage(pageId: string | null) { - if (!pageId) return; - this.chosenPageId.set(pageId); - this.destroyed.set(null); - this.syncSelection(); + this.state.selectPage(pageId); } setFilter(value: string) { - this.filter.set(value); - const { matches } = this.filtered(); - this.announcement.set( - filterAnnouncement( - this.page()?.count ?? 0, - matches, - value, - !!this.page()?.truncated, - this.shownDirectiveHosts(), - ), - ); + this.state.setFilter(value); } /** Clears the filter from the no-match state and keeps focus on the search box. */ @@ -2614,288 +238,68 @@ export class ComponentTree { this.filterInput()?.focus(); } - async pick() { - const client = this.rpc(); - if (!client || this.picking()) return; - const seq = ++this.pickSeq; - this.pickPageId = this.page()?.pageId ?? null; - this.picking.set(true); - this.say( - this.native() - ? 'Tap a component on the device, or use the arrow keys to move between components and Enter to pick one. Press Escape to cancel.' - : 'Click a component in the app, or use the arrow keys to move between components and Enter to pick one. Press Escape to cancel.', - ); - const result = (await client - .scope('pangular') - .rpc.call('request-component-pick', { pageId: this.pickPageId ?? undefined }) - .catch(() => ({ ok: false, error: 'Could not reach the devtools server.' }))) as PickResult; - if (seq !== this.pickSeq) return; - this.picking.set(false); - if (!result?.ok || !result.id) { - this.say(result?.error ?? 'No component was picked.'); - return; - } - if (result.pageId && result.pageId !== this.page()?.pageId) this.selectPage(result.pageId); - this.say(`Picked ${result.name ?? 'a component'}.`); - this.destroyed.set(null); - if (this.index().map.has(result.id)) this.reveal(result.id); - else this.selectedId.set(result.id); + pick() { + return this.picker.pick(); } - /** - * Moves a running pick with the keyboard: down and up go through the components in page - * order, left to the one around, right to the first one inside, and Enter picks. - */ pickKey(event: KeyboardEvent) { - const key = PICK_KEY_NAMES[event.key]; - if (!this.picking() || !key || event.altKey || event.ctrlKey || event.metaKey) return; - // Enter would press the focused button, which cancels the pick. - event.preventDefault(); - const client = this.rpc(); - if (!client) return; - const seq = this.pickSeq; - void client - .scope('pangular') - .rpc.call('request-component-pick-move', { pageId: this.pickPageId ?? undefined, key }) - .then((answer) => { - const result = answer as { moved?: boolean; name?: string; tag?: string; error?: string }; - if (seq !== this.pickSeq || !this.picking() || key === 'pick') return; - this.say( - result?.moved - ? `${result.name ?? 'Component'} <${result.tag ?? ''}>. Press Enter to pick it.` - : (result?.error ?? 'The page did not move.'), - ); - }) - .catch(() => {}); + this.picker.pickKey(event); } cancelPick() { - const client = this.rpc(); - if (!client || !this.picking()) return; - void client - .scope('pangular') - .rpc.call('cancel-component-pick', { pageId: this.pickPageId ?? undefined }) - .catch(() => {}); - } - - private dropPick() { - this.cancelPick(); - this.pickSeq++; - this.picking.set(false); - this.say('Picking stopped because the page changed.'); - } - - private say(message: string) { - this.pickMessage.set(message); - this.announce(message); - } - - private sendSelection(id: string | null) { - const client = this.rpc(); - if (!client) return; - void client - .scope('pangular') - .rpc.call('select-component', { pageId: this.page()?.pageId, id, console: true }) - .catch(() => {}); - } - - private rowElement(id: string) { - return this.host.nativeElement.querySelector(`.row[data-id="${CSS.escape(id)}"]`); + this.picker.cancelPick(); } select(id: string) { - const next = this.selectedId() === id ? null : id; - this.selectedId.set(next); - this.destroyed.set(null); - this.focusId.set(id); - // A directive host has no component detail for the page to send. - if (next && this.index().map.get(next)?.kind === 'directive') return; - this.sendSelection(next); - } - - hostLabel(node: LiveNode) { - return directiveHostLabel(node); - } - - hydrationText(status: HydrationStatus) { - return hydrationLabel(status); - } - - /** The nearest component above a row, such as the one whose template renders a directive host. */ - ownerOf(id: string): LiveNode | null { - const { map, parents } = this.index(); - for (const parent of ancestors(parents, id)) { - const node = map.get(parent); - if (node && node.kind !== 'directive') return node; - } - return null; + this.state.select(id); } toggleDirectiveHosts() { - const next = !this.showDirectiveHosts(); - this.showDirectiveHosts.set(next); - try { - localStorage.setItem(DIRECTIVE_HOSTS_KEY, next ? 'shown' : 'hidden'); - } catch { - // the choice lasts for this view only - } - const selected = this.selectedId(); - if (!next && selected && !this.index().map.has(selected)) this.selectedId.set(null); - this.announce(next ? 'Directive hosts are shown.' : 'Directive hosts are hidden.'); + this.state.toggleDirectiveHosts(); } setHydrationOverlay(on: boolean) { - const client = this.rpc(); - const pageId = this.page()?.pageId; - if (!client || !pageId) return; - void client - .scope('pangular') - .rpc.call('request-hydration-overlay', { pageId, on }) - .catch(() => {}); - this.announce(on ? 'Outlining hydration on the page.' : 'Hydration outline removed.'); + this.state.setHydrationOverlay(on); } /** Shows a node that the hydration summary names, when it is in the tree. */ revealNode(id: string) { - if (this.index().map.has(id)) this.reveal(id); + if (this.state.index().map.has(id)) this.reveal(id); } /** Shows a component a Leaks row names, and moves keyboard focus to its tree row. */ showLeakComponent(id: string) { - if (this.index().map.has(id)) this.reveal(id, true); + if (this.state.index().map.has(id)) this.reveal(id, true); } /** Opens the tree to a node and selects it; `focus` also moves keyboard focus to its row. */ reveal(id: string, focus = false) { - const { parents } = this.index(); - this.collapsed.update((set) => { - const next = new Set(set); - for (const parent of ancestors(parents, id)) next.delete(parent); - return next; - }); - if (!this.rows().some((row) => row.node.id === id)) this.setFilter(''); - if (this.selectedId() !== id) this.select(id); - if (focus) this.focusId.set(id); - // In a long tree the row may be outside the rendered window. - const index = this.rows().findIndex((row) => row.node.id === id); - if (index >= 0) this.rowWindow.reveal(index); - afterNextRender( - () => { - const row = document.querySelector(`.row[data-id="${CSS.escape(id)}"]`); - this.clearToolbar(); - row?.scrollIntoView({ block: 'nearest' }); - if (row) this.keepBelowToolbar(row); - if (focus) row?.focus({ preventScroll: true }); - }, - { injector: this.injector }, - ); - } - - /** - * Sets the rows' `scroll-margin-top` so a row scrolled into view sits below - * the sticky toolbar, whose height changes as it wraps and which sticks - * inside the scroller's padding. - */ - private clearToolbar() { - const toolbar = this.host.nativeElement.querySelector('.toolbar'); - if (!toolbar) return; - this.host.nativeElement.style.setProperty( - '--ct-toolbar-clearance', - `${stickyClearance(toolbar)}px`, - ); - } - - /** Moves a row the sticky toolbar covers back below it. */ - private keepBelowToolbar(row: HTMLElement) { - const toolbar = this.host.nativeElement.querySelector('.toolbar'); - if (toolbar) keepBelowSticky(row, toolbar); + this.state.reveal(id, focus); } toggle(id: string, event?: Event) { - event?.stopPropagation(); - if (this.query()) return; - this.collapsed.update((set) => { - const next = new Set(set); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); + this.state.toggle(id, event); } expandAll() { - if (this.query()) return; - this.collapsed.set(new Set()); - this.announce('Expanded every component.'); + this.state.expandAll(); } collapseAll() { - if (this.query()) return; - const { map, parents } = this.index(); - const previous = this.rovingId(); - const next = new Set(); - for (const node of map.values()) if (node.children.length) next.add(node.id); - this.collapsed.set(next); - if (previous) { - const visible = new Set(this.rows().map((row) => row.node.id)); - let id: string | undefined = previous; - while (id && !visible.has(id)) id = parents.get(id); - if (id) this.focusId.set(id); - } - this.announce('Collapsed every component. Only the top level is shown.'); - } - - /** Sets the live message, changing it slightly when it repeats so screen readers say it again. */ - private announce(message: string) { - this.announcement.update((current) => (current === message ? `${message}\u00a0` : message)); - } - - private expandSiblings(id: string) { - if (this.query()) return; - const parent = this.index().parents.get(id); - const siblings = parent - ? (this.index().map.get(parent)?.children ?? []) - : (this.page()?.roots ?? []); - this.collapsed.update((set) => { - const next = new Set(set); - for (const sibling of siblings) next.delete(sibling.id); - return next; - }); - } - - /** Shows the full text of a focused row whose name, tag or directives are cut off. */ - showNameTip(event: FocusEvent) { - const row = event.currentTarget as HTMLElement; - const parts = Array.from(row.querySelectorAll('.name, .tag, .host-dirs')); - const cut = parts.some((part) => part.scrollWidth > part.clientWidth + 1); - if (!cut) { - this.nameTip.set(null); - return; - } - const text = parts.map((part) => part.title || part.textContent?.trim()).join(' '); - this.nameTip.set({ text, top: row.offsetTop + row.offsetHeight + 2 }); + this.state.collapseAll(); } highlight(id: string | null) { - this.requestHighlight(id ? { id } : null); + this.state.highlight(id); } /** One box around every root element of the block, or its owner when none is rendered. */ highlightBlock(block: DeferBlock) { - if (block.rootIds.length > 1) this.requestHighlight({ ids: block.rootIds }); - else this.highlight(block.rootIds[0] ?? block.owner?.id ?? null); - } - - private requestHighlight(target: { id: string } | { ids: string[] } | null) { - const client = this.rpc(); - if (!client) return; - void client - .scope('pangular') - .rpc.call('request-page-highlight', target && { pageId: this.page()?.pageId, ...target }) - .catch(() => {}); + this.state.highlightBlock(block); } showOwner(block: DeferBlock) { - if (block.owner && this.index().map.has(block.owner.id)) this.reveal(block.owner.id); + if (block.owner && this.state.index().map.has(block.owner.id)) this.reveal(block.owner.id); } /** @@ -2905,77 +309,24 @@ export class ComponentTree { showCdComponent(name: string) { const hosts = this.cdHosts(); let best: LiveNode | null = null; - for (const node of this.index().map.values()) { + for (const node of this.state.index().map.values()) { if (node.name !== name) continue; if (!best || (hosts[node.id] ?? 0) > (hosts[best.id] ?? 0)) best = node; } if (!best) { - this.say(`No ${name} is in the tree now.`); + this.state.say(`No ${name} is in the tree now.`); return; } - this.say(`Showing ${name} in the tree.`); + this.state.say(`Showing ${name} in the tree.`); this.reveal(best.id); } showForOwner(block: ForBlock) { - if (block.owner && this.index().map.has(block.owner.id)) this.reveal(block.owner.id); + if (block.owner && this.state.index().map.has(block.owner.id)) this.reveal(block.owner.id); } - onTreeKey(event: KeyboardEvent) { - const rows = this.rows(); - if (!rows.length) return; - const current = rows.findIndex((r) => r.node.id === this.rovingId()); - const at = Math.max(current, 0); - const row = rows[at]; - let next = -1; - switch (event.key) { - case 'ArrowDown': - next = Math.min(at + 1, rows.length - 1); - break; - case 'ArrowUp': - next = Math.max(at - 1, 0); - break; - case 'Home': - next = 0; - break; - case 'End': - next = rows.length - 1; - break; - case 'ArrowRight': - if (row.hasChildren && !row.expanded) this.toggle(row.node.id); - else if (row.hasChildren) next = at + 1; - break; - case 'ArrowLeft': - if (row.hasChildren && row.expanded && !this.query()) this.toggle(row.node.id); - else { - const parent = this.index().parents.get(row.node.id); - next = rows.findIndex((r) => r.node.id === parent); - } - break; - case 'Enter': - case ' ': - this.select(row.node.id); - break; - case '*': - this.expandSiblings(row.node.id); - break; - default: - return; - } - event.preventDefault(); - if (next >= 0) { - const id = rows[next].node.id; - this.focusId.set(id); - const focus = () => { - const el = document.querySelector(`.row[data-id="${CSS.escape(id)}"]`); - this.clearToolbar(); - el?.focus({ preventScroll: true }); - el?.scrollIntoView({ block: 'nearest' }); - if (el) this.keepBelowToolbar(el); - }; - // A row outside the rendered window is brought in first. - if (this.rowWindow.rendered(next)) queueMicrotask(focus); - else this.rowWindow.reveal(next, focus); - } + /** The nearest component above a row, such as the one whose template renders a directive host. */ + ownerOf(id: string): LiveNode | null { + return this.state.ownerOf(id); } } diff --git a/app/src/pages/components/component-detail.html b/app/src/pages/components/component-detail.html new file mode 100644 index 00000000..5b07d52a --- /dev/null +++ b/app/src/pages/components/component-detail.html @@ -0,0 +1,348 @@ +@let sel = node(); +
    + component +

    {{ sel.name }}

    + <{{ sel.tag }}> + @if (detail()?.source; as where) { +

    + +

    + } @else { + @let guesses = sourceCandidates(sel); + @if (guesses.length === 1) { +

    + Likely, matched by class name: +

    + } @else if (guesses.length > 1) { +
    +

    + Not certain + {{ guesses.length }} source files define {{ bareName(sel.name) }}: +

    +
      + @for (guess of guesses; track sourceKey(guess)) { +
    • + } +
    +
    + } + } + @if (devtoolsActions()) { +
    + + +
    +

    {{ actionMessage() }}

    + } + @if (sourceFor(sel); as src) { + @if (formsIn(src.file).length) { +
    + @for (form of formsIn(src.file); track form.formId) { + + } +
    + } + } + @if ((canShowInjector() || canShowSignals()) && !staticReport()) { +
    + @if (canShowInjector()) { + + } + @if (canShowSignals()) { + + } +
    + } + @if (detail(); as d) { +
    + + +
    +

    {{ copyMessage() }}

    + } + @if (!staticReport() && !native()) { + + } +
    + +@if (detail(); as d) { +
    +
    Change detection
    +
    {{ d.changeDetection ?? 'Unknown' }}
    +
    Encapsulation
    +
    {{ d.encapsulation ?? 'Unknown' }}
    +
    Host path
    +
    {{ d.path }}
    + @for (hit of routedById().get(sel.id) ?? []; track hit.outlet + hit.route) { +
    Routed
    +
    {{ hit.route || '/' }} in outlet {{ hit.outlet }}
    + } +
    + +
    + + @if (propQuery()) { + {{ shownValues()?.count ?? 0 }} of {{ valueTotal() }} + } +

    {{ propFilterStatus() }}

    +
    + + @if (shownValues(); as v) { + + @if (propQuery() && !v.count) { +
    +

    + No inputs, outputs or properties match “{{ propFilter().trim() }}” +

    +

    + The filter searches names, and the nested keys of values you opened. +

    + +
    + } + + @if (!propQuery() || v.inputs.length) { +
    +

    + Inputs {{ v.inputs.length }} +

    + @if (v.inputs.length) { +
      + @for (p of v.inputs; track p.name) { +
    • + + {{ p.name }} + @if (p.prop !== p.name) { + ({{ p.prop }}) + } + + +
    • + } +
    + } @else { +

    No inputs.

    + } +
    + } + + @if (!propQuery() || v.outputs.length) { +
    +

    + Outputs {{ v.outputs.length }} +

    + @if (v.outputs.length) { +
      + @for (p of v.outputs; track p.name) { +
    • + {{ p.name }} + {{ p.listened ? 'listened' : 'no listener' }} +
    • + } +
    + } @else { +

    No outputs.

    + } +
    + } + + @if (!propQuery() || v.properties.length) { +
    +

    + Properties {{ v.properties.length }} +

    + @if (v.properties.length) { +
      + @for (p of v.properties; track p.name) { +
    • + + {{ p.name }} + @if (p.kind) { + {{ p.kind }} + } + @if (p.kind === 'signal' && canShowSignals() && !staticReport()) { + + } + + +
    • + } +
    + } @else { +

    No other properties.

    + } +
    + } + + @if (!propQuery() && d.listeners.length) { +
    +

    + DOM listeners {{ d.listeners.length }} +

    +
      + @for (name of d.listeners; track name) { +
    • {{ name }}
    • + } +
    +
    + } + + @if (!propQuery() && ownedDeferBlocks().length) { +
    +

    + Defer blocks {{ ownedDeferBlocks().length }} +

    +
      + @for (block of ownedDeferBlocks(); track block.id) { +
    • + {{ block.state }} + {{ block.triggers.join(', ') || 'no triggers' }} +
    • + } +
    +
    + } + + @for (dir of v.directives; track dir.name + dir.index) { +
    +

    + {{ dir.name }} + directive on host +

    +

    + +

    + @if (dir.inputs.length) { +
      + @for (p of dir.inputs; track p.name) { +
    • + {{ p.name }} + +
    • + } +
    + } + @if (dir.outputs.length) { +
      + @for (p of dir.outputs; track p.name) { +
    • + {{ p.name }} + {{ p.listened ? 'listened' : 'no listener' }} +
    • + } +
    + } + @if (!dir.inputs.length && !dir.outputs.length) { +

    No inputs or outputs.

    + } +
    + } + } + +
    +

    + Injected {{ d.dependencies.length }} +

    + @if (d.dependencies.length) { +
      + @for (dep of d.dependencies; track dep.token + $index) { +
    • + {{ dep.token }} + @for (flag of dep.flags; track flag) { + {{ flag }} + } + @if (dep.providedBy) { + from {{ dep.providedByName ?? 'an injector' }} + } @else { + not provided + } +
    • + } +
    + } @else { +

    Nothing is injected through the constructor or inject().

    + } +
    + + @if (canShowPipes()) { + + } +} @else { +
    + +

    Reading live values from the page…

    +
    +} + + + + @let found = directiveSources(name); + @if (found.length === 1) { + + Likely, matched by class name: + + } @else if (found.length > 1) { + {{ found.length }} source files define {{ bareName(name) }} + } + diff --git a/app/src/pages/components/component-detail.scss b/app/src/pages/components/component-detail.scss new file mode 100644 index 00000000..b2d6e61e --- /dev/null +++ b/app/src/pages/components/component-detail.scss @@ -0,0 +1,10 @@ +@use 'components' as ct; + +@include ct.base; +@include ct.search; +@include ct.buttons; +@include ct.narrow; +@include ct.detail; +@include ct.facts; +@include ct.values; +@include ct.states; diff --git a/app/src/pages/components/component-detail.ts b/app/src/pages/components/component-detail.ts new file mode 100644 index 00000000..276509be --- /dev/null +++ b/app/src/pages/components/component-detail.ts @@ -0,0 +1,141 @@ +import { Component, inject, input, output, viewChild } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { InjectorFocus } from '../../types/injector-focus.types'; +import type { SignalFocus } from '../../types/signal-focus.types'; +import { RedactionCount } from '../../ui/redaction-count'; +import { SearchField } from '../../ui/search-field'; +import { SourceLink } from '../../ui/source-link'; +import { StoreGlobal } from '../../ui/store-global'; +import { ComponentPipesSection } from '../component-pipes-section'; +import { ValueTree } from '../component-value-tree'; +import { bare, type Detail, type LiveNode, type SourceComponent } from './component-model'; +import { DetailState } from './detail-state'; +import { SourceScan } from './source-scan'; +import { TreeState } from './tree-state'; + +/** + * The detail of a selected component: where its source is, the Chrome DevTools, copy and tab + * links, its live inputs, outputs and properties with a filter, its directives, what it injects + * and the pipes of its template. + */ +@Component({ + selector: 'section[appComponentDetail]', + imports: [ + NgTemplateOutlet, + RedactionCount, + SearchField, + SourceLink, + StoreGlobal, + ValueTree, + ComponentPipesSection, + ], + templateUrl: './component-detail.html', + styleUrl: './component-detail.scss', +}) +export class ComponentDetail { + private readonly state = inject(TreeState); + private readonly scan = inject(SourceScan); + private readonly details = inject(DetailState); + + /** The selected component instance. */ + readonly node = input.required(); + readonly showForm = output(); + readonly showInjector = output(); + readonly showSignals = output(); + readonly showPipe = output<{ name: string; pageId?: string }>(); + + protected readonly rpc = this.state.rpc; + protected readonly page = this.state.page; + protected readonly detail = this.state.detail; + protected readonly native = this.state.native; + protected readonly staticReport = this.state.staticReport; + protected readonly canEdit = this.state.canEdit; + protected readonly canShowInjector = this.state.canShowInjector; + protected readonly canShowSignals = this.state.canShowSignals; + protected readonly canShowPipes = this.state.canShowPipes; + protected readonly routedById = this.state.routedById; + protected readonly ownedDeferBlocks = this.state.ownedDeferBlocks; + + protected readonly guessTitle = this.scan.guessTitle; + + protected readonly devtoolsActions = this.details.devtoolsActions; + protected readonly actionMessage = this.details.actionMessage; + protected readonly copyMessage = this.details.copyMessage; + protected readonly values = this.details.values; + protected readonly propFilter = this.details.propFilter; + protected readonly propQuery = this.details.propQuery; + protected readonly shownValues = this.details.shownValues; + protected readonly valueTotal = this.details.valueTotal; + protected readonly propFilterStatus = this.details.propFilterStatus; + private readonly propFilterInput = viewChild('propFilterInput'); + + /** Clears the detail filter and keeps focus in its field. */ + protected clearPropFilter(event?: Event) { + if (event && this.propFilter()) { + event.stopPropagation(); + event.preventDefault(); + } + this.propFilter.set(''); + this.propFilterInput()?.focus(); + } + + protected flashed(section: 'input' | 'property' | 'directive', name: string, index?: number) { + return this.details.flashed(section, name, index); + } + + protected revealInElements(node: LiveNode) { + return this.details.revealInElements(node); + } + + protected openSource(node: LiveNode) { + return this.details.openSource(node); + } + + protected copyJson(detail: Detail) { + return this.details.copyJson(detail); + } + + protected copyHostPath(detail: Detail) { + return this.details.copyHostPath(detail); + } + + /** Opens the Signals tab on this component's graph, with the signal in `key` selected. */ + protected openSignals(node: LiveNode, key?: string) { + const pageId = this.page()?.pageId; + this.showSignals.emit({ + componentId: node.id, + ...(pageId ? { pageId } : {}), + ...(key ? { key } : {}), + }); + } + + /** Opens the Injectors tab on this component's element injector. */ + protected openInjector(node: LiveNode) { + const pageId = this.page()?.pageId; + this.showInjector.emit(pageId ? { componentId: node.id, pageId } : { componentId: node.id }); + } + + protected formsIn(file: string) { + return this.scan.formsIn(file); + } + + protected sourceCandidates(node: LiveNode) { + return this.scan.sourceCandidates(node); + } + + protected sourceFor(node: LiveNode) { + return this.scan.sourceFor(node); + } + + protected directiveSources(name: string) { + return this.scan.directiveSources(name); + } + + protected sourceKey(comp: SourceComponent) { + return this.scan.sourceKey(comp); + } + + protected bareName(name: string) { + return bare(name); + } +} diff --git a/app/src/pages/components/component-model.ts b/app/src/pages/components/component-model.ts new file mode 100644 index 00000000..df26768d --- /dev/null +++ b/app/src/pages/components/component-model.ts @@ -0,0 +1,176 @@ +import type { SourceLocation } from '../../extension-bridge'; +import type { HydrationStatus } from '../component-tree-state'; +import type { HydrationReport } from '../hydration-section'; +import type { DeferBlock } from '../defer-blocks-section'; +import type { ForBlock } from '../for-blocks-section'; + +/** A component or directive the source scan found. */ +export interface SourceComponent { + selector: string; + className: string; + kind: 'component' | 'directive'; + file: string; + line: number; + inputs: string[]; + outputs: string[]; + isStandalone: boolean; + changeDetection?: 'OnPush' | 'Eager' | 'unknown'; +} + +export interface LiveNode { + id: string; + name: string; + tag: string; + /** `directive` for an element that hosts directives but no component. */ + kind?: 'directive'; + directives?: string[]; + attributes?: string[]; + hydration?: HydrationStatus; + children: LiveNode[]; +} + +export interface Prop { + name: string; + prop: string; + value?: unknown; + listened?: boolean; + kind?: 'signal' | 'resource'; + edit?: 'property' | 'signal'; + locked?: string; + editInside?: 'property' | 'signal'; +} + +export interface Dependency { + from: string; + token: string; + flags: string[]; + providedBy: string | null; + providedByName?: string; +} + +export interface Detail { + id: string; + name: string; + tag: string; + path: string; + /** A CSS selector that finds the host with `querySelector`, from a browser page. */ + selector?: string; + source?: SourceLocation; + changeDetection?: string; + encapsulation?: string; + inputs: Prop[]; + outputs: Prop[]; + properties?: Prop[]; + listeners: string[]; + directives: { name: string; inputs: Prop[]; outputs: Prop[] }[]; + dependencies: Dependency[]; + /** Nested value levels opened in the panel, read again on each push. */ + expanded?: Record; +} + +export interface Page { + pageId: string; + url?: string; + title?: string; + platform?: string; + deferBlocks?: DeferBlock[]; + /** The page has more defer blocks than the ones in `deferBlocks`. */ + deferTruncated?: boolean; + forBlocks?: ForBlock[]; + forBlocksTruncated?: boolean; + roots: LiveNode[]; + count: number; + /** Elements in `roots` that host directives but no component. */ + directiveHostCount?: number; + hydration?: HydrationReport; + truncated?: boolean; + truncatedBy?: { components?: number; depth?: number; directiveHosts?: number }; + detail: Detail | null; + reportedAt: number; + /** The server clock minus the page clock, in ms, for ages of page times. */ + clockOffset?: number; +} + +export interface OutletInfo { + outlet: string; + route?: string; + element?: string; + devtoolsId?: string; + activated: boolean; + children?: OutletInfo[]; +} + +export interface RoutedHit { + route: string; + outlet: string; +} + +export interface PickResult { + ok?: boolean; + id?: string; + name?: string; + pageId?: string; + error?: string; +} + +export interface Row { + node: LiveNode; + depth: number; + hasChildren: boolean; + expanded: boolean; + /** Position among its siblings, from 1, and how many siblings there are, for screen readers. */ + posinset: number; + setsize: number; +} + +/** A form the Forms inspector found, and the file of the component that owns it. */ +export interface FormOwner { + formId: string; + label: string; + file: string | null; +} + +/** Height of a tree row, fixed while the tree renders only the rows in view. */ +export const TREE_ROW_PX = 32; + +export function bare(name: string): string { + return name.replace(/^_(?=[A-Z])/, ''); +} + +/** True when two paths name the same file, one maybe longer or with `./` or backslashes. */ +export function sameSourceFile(a: string, b: string): boolean { + const norm = (path: string) => path.replace(/\\/g, '/').replace(/^\.\//, ''); + const [x, y] = [norm(a), norm(b)]; + return x === y || x.endsWith('/' + y) || y.endsWith('/' + x); +} + +/** The ids above `id`, nearest first; stops at a repeated id so a bad map cannot loop. */ +export function ancestors(parents: ReadonlyMap, id: string): string[] { + const out: string[] = []; + const seen = new Set([id]); + for (let parent = parents.get(id); parent && !seen.has(parent); parent = parents.get(parent)) { + seen.add(parent); + out.push(parent); + } + return out; +} + +/** The keys that move a running pick on the page, as the page names them. */ +export const PICK_KEY_NAMES: Record = { + ArrowDown: 'next', + ArrowUp: 'previous', + ArrowLeft: 'parent', + ArrowRight: 'child', + Enter: 'pick', +}; + +export const DIRECTIVE_HOSTS_KEY = 'pangular:components:directive-hosts'; + +/** Whether this viewer last chose to show directive hosts; shown unless they hid them. */ +export function storedDirectiveHosts(): boolean { + try { + return localStorage.getItem(DIRECTIVE_HOSTS_KEY) !== 'hidden'; + } catch { + return true; + } +} diff --git a/app/src/pages/components/detail-state.ts b/app/src/pages/components/detail-state.ts new file mode 100644 index 00000000..b95a0bd3 --- /dev/null +++ b/app/src/pages/components/detail-state.ts @@ -0,0 +1,192 @@ +import { Injectable, computed, inject, linkedSignal, signal, untracked } from '@angular/core'; +import { ExtensionBridge, type SourceLocation } from '../../extension-bridge'; +import { componentJson, hostPathText } from '../component-copy'; +import { ValueFlash, flashKey } from '../component-value-flash'; +import { ValueStore } from '../component-value-store'; +import { nestedMatches } from '../component-value-state'; +import type { Detail, LiveNode, Prop } from './component-model'; +import { SourceScan } from './source-scan'; +import { TreeState } from './tree-state'; + +/** + * The selected component's detail: its opened value levels, the values filter, the values that + * just changed, and the Chrome DevTools and copy actions with their messages. Provided by the + * Components page, so the filter and the opened levels outlive one detail panel. + */ +@Injectable() +export class DetailState { + private readonly tree = inject(TreeState); + private readonly scan = inject(SourceScan); + private readonly bridge = inject(ExtensionBridge); + + readonly actionMessage = linkedSignal({ + source: this.tree.selectedId, + computation: () => '', + }); + /** What the last copy did, for the selected component only. */ + readonly copyMessage = linkedSignal({ + source: this.tree.selectedId, + computation: () => '', + }); + /** Values of the selected component that changed in the last push, outlined for a second. */ + private readonly flash = new ValueFlash(); + + readonly devtoolsActions = computed( + () => + this.bridge.available && + !!this.tree.page() && + !this.tree.native() && + !this.tree.staticReport(), + ); + + /** Opened value levels of the selected component. */ + readonly values = new ValueStore(); + /** The detail's filter over input, output and property names and opened nested keys. */ + readonly propFilter = signal(''); + readonly propQuery = computed(() => this.propFilter().trim().toLowerCase()); + + /** Inputs, outputs, properties and directive inputs the filter keeps, and how many. */ + readonly shownValues = computed(() => { + const d = this.tree.detail(); + if (!d) return null; + const query = this.propQuery(); + const named = (p: Prop) => + !query || p.name.toLowerCase().includes(query) || p.prop.toLowerCase().includes(query); + const kept = (p: Prop, directive?: number) => + named(p) || + nestedMatches([p.prop], p.value, (path) => this.values.level(path, directive), query, 1) + .length > 0; + const inputs = d.inputs.filter((p) => kept(p)); + const outputs = d.outputs.filter(named); + const properties = (d.properties ?? []).filter((p) => kept(p)); + const directives = d.directives + .map((dir, index) => ({ + ...dir, + index, + inputs: dir.inputs.filter((p) => kept(p, index)), + outputs: dir.outputs.filter(named), + })) + .filter((dir) => !query || dir.inputs.length || dir.outputs.length); + const count = + inputs.length + + outputs.length + + properties.length + + directives.reduce((n, dir) => n + dir.inputs.length + dir.outputs.length, 0); + return { inputs, outputs, properties, directives, count }; + }); + + readonly valueTotal = computed(() => { + const d = this.tree.detail(); + if (!d) return 0; + return ( + d.inputs.length + + d.outputs.length + + (d.properties?.length ?? 0) + + d.directives.reduce((n, dir) => n + dir.inputs.length + dir.outputs.length, 0) + ); + }); + + readonly propFilterStatus = computed(() => { + if (!this.propQuery()) return ''; + const count = this.shownValues()?.count ?? 0; + return count + ? `${count} of ${this.valueTotal()} values match.` + : 'No inputs, outputs or properties match.'; + }); + + /** Lets the value store follow the selected component; run from an effect. */ + followValues() { + const detail = this.tree.detail(); + const target = { + rpc: this.tree.rpc(), + pageId: this.tree.page()?.pageId ?? null, + componentId: detail?.id ?? null, + }; + untracked(() => this.values.follow(target, detail?.expanded)); + } + + /** Compares the selected component's values with the last push; run from an effect. */ + trackChanges() { + const detail = this.tree.detail(); + const owner = detail ? `${this.tree.page()?.pageId ?? ''}|${detail.id}` : null; + const values = new Map(); + for (const p of detail?.inputs ?? []) values.set(flashKey('input', p.name), p.value); + for (const p of detail?.properties ?? []) values.set(flashKey('property', p.name), p.value); + detail?.directives.forEach((dir, index) => { + for (const p of dir.inputs) values.set(flashKey('directive', p.name, index), p.value); + }); + untracked(() => this.flash.update(owner, values)); + } + + /** Stops the change outlines, when the page is destroyed. */ + clearChanges() { + this.flash.clear(); + } + + /** Whether a value of the detail changed in the last push, so it shows an outline. */ + flashed(section: 'input' | 'property' | 'directive', name: string, index?: number): boolean { + return this.flash.changed().has(flashKey(section, name, index)); + } + + /** + * The exact location the page gives, else the source scan's guess when only one class may be + * it. Several candidates give null, so Open source falls back to the class itself. + */ + whereOf(node: LiveNode): SourceLocation | null { + const exact = this.tree.detail()?.source; + if (exact) return exact; + const candidates = this.scan.sourceCandidates(node); + return candidates.length === 1 ? candidates[0] : null; + } + + async revealInElements(node: LiveNode) { + const pageId = this.tree.page()?.pageId; + if (!pageId) return; + this.actionMessage.set(''); + const result = await this.bridge.reveal(pageId, node.id); + if (this.tree.selectedId() !== node.id) return; + this.actionMessage.set( + result.ok + ? '' + : 'Could not reveal it. The component must be rendered in the tab these DevTools inspect.', + ); + } + + async openSource(node: LiveNode) { + const pageId = this.tree.page()?.pageId; + if (!pageId) return; + const where = this.whereOf(node); + this.actionMessage.set(''); + const result = await this.bridge.openSource(pageId, node.id, where); + if (this.tree.selectedId() !== node.id) return; + if (result.ok) return; + this.actionMessage.set( + where + ? `Not found in Sources. Open ${where.file}:${where.line} in your editor.` + : 'Could not open the source. The component must be rendered in the tab these DevTools inspect.', + ); + } + + /** Copies only what the panel holds, which the page serialized and masked already. */ + async copyJson(detail: Detail) { + await this.copy(detail.id, componentJson(detail), `Copied ${detail.name} as JSON.`); + } + + async copyHostPath(detail: Detail) { + await this.copy( + detail.id, + hostPathText(detail), + detail.selector ? 'Copied the host selector.' : 'Copied the host path.', + ); + } + + private async copy(id: string, text: string, done: string) { + let message = done; + try { + await navigator.clipboard.writeText(text); + } catch { + message = 'The clipboard is not available here.'; + } + if (this.tree.selectedId() === id) this.copyMessage.set(message); + } +} diff --git a/app/src/pages/components/host-detail.html b/app/src/pages/components/host-detail.html new file mode 100644 index 00000000..becfb488 --- /dev/null +++ b/app/src/pages/components/host-detail.html @@ -0,0 +1,48 @@ +@let host = node(); +
    + directive host +

    <{{ host.tag }}>

    + {{ hostLabel(host) }} +
    +
    +

    + Directives {{ host.directives?.length ?? 0 }} +

    +
      + @for (name of host.directives ?? []; track name + $index) { +
    • + {{ name }} + +
    • + } +
    +
    +
    +

    + This element hosts directives but no component. Their inputs are bound in the template of the + component that renders it. +

    + @if (ownerOf(host.id); as owner) { +
    + +
    + } +
    + + + + @let found = directiveSources(name); + @if (found.length === 1) { + + Likely, matched by class name: + + } @else if (found.length > 1) { + {{ found.length }} source files define {{ bareName(name) }} + } + diff --git a/app/src/pages/components/host-detail.scss b/app/src/pages/components/host-detail.scss new file mode 100644 index 00000000..8701719f --- /dev/null +++ b/app/src/pages/components/host-detail.scss @@ -0,0 +1,9 @@ +@use 'components' as ct; + +@include ct.base; +@include ct.buttons; +.owner-action { + margin-top: 10px; +} +@include ct.detail; +@include ct.values; diff --git a/app/src/pages/components/host-detail.ts b/app/src/pages/components/host-detail.ts new file mode 100644 index 00000000..ecf9e18e --- /dev/null +++ b/app/src/pages/components/host-detail.ts @@ -0,0 +1,43 @@ +import { Component, inject, input } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { SourceLink } from '../../ui/source-link'; +import { directiveHostLabel } from '../component-tree-state'; +import { bare, type LiveNode } from './component-model'; +import { SourceScan } from './source-scan'; +import { TreeState } from './tree-state'; + +/** The detail of a selected directive host: its directives and the component that renders it. */ +@Component({ + selector: 'section[appHostDetail]', + imports: [NgTemplateOutlet, SourceLink], + templateUrl: './host-detail.html', + styleUrl: './host-detail.scss', +}) +export class HostDetail { + private readonly state = inject(TreeState); + private readonly scan = inject(SourceScan); + /** The selected row, an element that hosts directives but no component. */ + readonly node = input.required(); + + protected readonly guessTitle = this.scan.guessTitle; + + protected hostLabel(node: LiveNode) { + return directiveHostLabel(node); + } + + protected ownerOf(id: string) { + return this.state.ownerOf(id); + } + + protected reveal(id: string, focus = false) { + this.state.reveal(id, focus); + } + + protected directiveSources(name: string) { + return this.scan.directiveSources(name); + } + + protected bareName(name: string) { + return bare(name); + } +} diff --git a/app/src/pages/components/pick-state.ts b/app/src/pages/components/pick-state.ts new file mode 100644 index 00000000..40a7123d --- /dev/null +++ b/app/src/pages/components/pick-state.ts @@ -0,0 +1,101 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { PICK_KEY_NAMES, type PickResult } from './component-model'; +import { TreeState } from './tree-state'; + +/** + * Picking a component on the page: the pick button's state, the keys that move a running pick, + * and what happens when the page answers. Provided by the Components page. + */ +@Injectable() +export class PickState { + private readonly tree = inject(TreeState); + + readonly picking = signal(false); + private pickPageId: string | null = null; + private pickSeq = 0; + readonly pickLabel = computed(() => + this.picking() + ? 'Cancel pick' + : this.tree.native() + ? 'Pick component on device' + : 'Pick component on page', + ); + + /** Stops a running pick when the shown page is no longer the one it runs on. */ + followPage(shown: string | null) { + if (this.picking() && shown !== this.pickPageId) this.dropPick(); + } + + async pick() { + const client = this.tree.rpc(); + if (!client || this.picking()) return; + const seq = ++this.pickSeq; + this.pickPageId = this.tree.page()?.pageId ?? null; + this.picking.set(true); + this.tree.say( + this.tree.native() + ? 'Tap a component on the device, or use the arrow keys to move between components and Enter to pick one. Press Escape to cancel.' + : 'Click a component in the app, or use the arrow keys to move between components and Enter to pick one. Press Escape to cancel.', + ); + const result = (await client + .scope('pangular') + .rpc.call('request-component-pick', { pageId: this.pickPageId ?? undefined }) + .catch(() => ({ ok: false, error: 'Could not reach the devtools server.' }))) as PickResult; + if (seq !== this.pickSeq) return; + this.picking.set(false); + if (!result?.ok || !result.id) { + this.tree.say(result?.error ?? 'No component was picked.'); + return; + } + if (result.pageId && result.pageId !== this.tree.page()?.pageId) { + this.tree.selectPage(result.pageId); + } + this.tree.say(`Picked ${result.name ?? 'a component'}.`); + this.tree.destroyed.set(null); + if (this.tree.index().map.has(result.id)) this.tree.reveal(result.id); + else this.tree.selectedId.set(result.id); + } + + /** + * Moves a running pick with the keyboard: down and up go through the components in page + * order, left to the one around, right to the first one inside, and Enter picks. + */ + pickKey(event: KeyboardEvent) { + const key = PICK_KEY_NAMES[event.key]; + if (!this.picking() || !key || event.altKey || event.ctrlKey || event.metaKey) return; + // Enter would press the focused button, which cancels the pick. + event.preventDefault(); + const client = this.tree.rpc(); + if (!client) return; + const seq = this.pickSeq; + void client + .scope('pangular') + .rpc.call('request-component-pick-move', { pageId: this.pickPageId ?? undefined, key }) + .then((answer) => { + const result = answer as { moved?: boolean; name?: string; tag?: string; error?: string }; + if (seq !== this.pickSeq || !this.picking() || key === 'pick') return; + this.tree.say( + result?.moved + ? `${result.name ?? 'Component'} <${result.tag ?? ''}>. Press Enter to pick it.` + : (result?.error ?? 'The page did not move.'), + ); + }) + .catch(() => {}); + } + + cancelPick() { + const client = this.tree.rpc(); + if (!client || !this.picking()) return; + void client + .scope('pangular') + .rpc.call('cancel-component-pick', { pageId: this.pickPageId ?? undefined }) + .catch(() => {}); + } + + private dropPick() { + this.cancelPick(); + this.pickSeq++; + this.picking.set(false); + this.tree.say('Picking stopped because the page changed.'); + } +} diff --git a/app/src/pages/components/source-list.html b/app/src/pages/components/source-list.html new file mode 100644 index 00000000..0c413fd4 --- /dev/null +++ b/app/src/pages/components/source-list.html @@ -0,0 +1,52 @@ +@for (comp of filteredSource(); track sourceKey(comp); let i = $index) { +
  • + + @if (openKey() === sourceKey(comp)) { +
    +
    +
    Class
    +
    {{ bareName(comp.className) }}
    +
    File
    +
    + +
    +
    Standalone
    +
    {{ comp.isStandalone ? 'Yes' : 'No' }}
    + @if (comp.kind === 'component') { +
    Change detection
    +
    {{ comp.changeDetection ?? 'Unknown' }}
    + } +
    Inputs
    +
    {{ comp.inputs.join(', ') || 'None' }}
    +
    Outputs
    +
    {{ comp.outputs.join(', ') || 'None' }}
    +
    + @for (form of formsIn(comp.file); track form.formId) { + + } +
    + } +
  • +} diff --git a/app/src/pages/components/source-list.scss b/app/src/pages/components/source-list.scss new file mode 100644 index 00000000..f0992203 --- /dev/null +++ b/app/src/pages/components/source-list.scss @@ -0,0 +1,88 @@ +@use 'mixins' as m; +@use 'components' as ct; + +@include ct.base; +@include ct.buttons; +@include ct.facts; +.source-item { + min-width: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.source-item.expanded { + border-color: var(--accent-line); + box-shadow: inset 2px 0 0 var(--accent); +} +.source-toggle { + justify-content: flex-start; + gap: 12px; + width: 100%; + height: auto; + min-height: 52px; + padding: 10px 16px; + background: none; + border: 0; + border-radius: 0; + text-align: left; +} +.source-toggle:hover { + background: var(--surface-2); +} +.source-toggle:focus-visible { + @include m.focus-ring(-2px); +} +.kind-flag { + flex: none; + padding: 1px 8px; + border-radius: 99px; + @include m.tint(accent); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.kind-flag.directive { + @include m.tint(ok); +} +.row-main { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.selector { + color: var(--text-strong); + font-weight: 600; + overflow-wrap: anywhere; +} +.file { + color: var(--text-2); + font-size: 12px; + @include m.truncate; +} +.chevron { + flex: none; + width: 16px; + height: 16px; + fill: none; + stroke: var(--text-3); + stroke-width: 2.2; + transition: transform 200ms var(--ease); +} +.expanded .chevron { + transform: rotate(90deg); + stroke: var(--accent); +} +.inline-detail { + padding: 0 0 12px; + border-top: 1px solid var(--border); +} +.inline-detail .facts { + border-bottom: 0; +} +.inline-detail .show-form { + margin: 0 16px; +} diff --git a/app/src/pages/components/source-list.ts b/app/src/pages/components/source-list.ts new file mode 100644 index 00000000..b83102b7 --- /dev/null +++ b/app/src/pages/components/source-list.ts @@ -0,0 +1,35 @@ +import { Component, inject, output } from '@angular/core'; +import { SourceLink } from '../../ui/source-link'; +import { bare, type SourceComponent } from './component-model'; +import { SourceScan } from './source-scan'; + +/** What the source declares, one row per component or directive, opened in place. */ +@Component({ + selector: 'ul[appSourceList]', + imports: [SourceLink], + templateUrl: './source-list.html', + styleUrl: './source-list.scss', +}) +export class SourceList { + private readonly scan = inject(SourceScan); + readonly showForm = output(); + + protected readonly filteredSource = this.scan.filteredSource; + protected readonly openKey = this.scan.openKey; + + protected formsIn(file: string) { + return this.scan.formsIn(file); + } + + protected sourceKey(comp: SourceComponent) { + return this.scan.sourceKey(comp); + } + + protected sourceLabel(comp: SourceComponent) { + return this.scan.sourceLabel(comp); + } + + protected bareName(name: string) { + return bare(name); + } +} diff --git a/app/src/pages/components/source-scan.ts b/app/src/pages/components/source-scan.ts new file mode 100644 index 00000000..9f4c0f3e --- /dev/null +++ b/app/src/pages/components/source-scan.ts @@ -0,0 +1,119 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { + bare, + sameSourceFile, + type FormOwner, + type LiveNode, + type SourceComponent, +} from './component-model'; +import { TreeState } from './tree-state'; + +/** + * What the source declares: the components and directives of the source scan, the forms they + * own, and the guesses that match a live instance or directive to a source file by class name. + * Provided by the Components page. + */ +@Injectable() +export class SourceScan { + private readonly tree = inject(TreeState); + + readonly loading = signal(false); + readonly error = signal(false); + readonly source = signal([]); + readonly formOwners = signal([]); + readonly openKey = signal(null); + + /** Why a location from the source scan is marked as a guess. */ + readonly guessTitle = + 'The page gave no exact location. This comes from the source scan, matched by class name.'; + + private readonly sourceByClass = computed(() => { + const map = new Map(); + for (const comp of this.source()) { + const key = bare(comp.className); + map.set(key, [...(map.get(key) ?? []), comp]); + } + return map; + }); + + readonly filteredSource = computed(() => { + const q = this.tree.query(); + const all = this.source(); + if (!q) return all; + return all.filter( + (c) => + c.selector.toLowerCase().includes(q) || + c.className.toLowerCase().includes(q) || + c.file.toLowerCase().includes(q), + ); + }); + + readonly sourceCounts = computed(() => { + const components = this.source().filter((c) => c.kind === 'component').length; + return { components, directives: this.source().length - components }; + }); + + async refresh() { + const client = this.tree.rpc(); + if (!client) return; + this.loading.set(true); + const my = client.scope('pangular'); + try { + this.source.set((await my.rpc.call('get-components')) as SourceComponent[]); + this.error.set(false); + } catch { + this.error.set(true); + } finally { + this.loading.set(false); + } + const owners = (await my.rpc.call('forms-owners').catch(() => [])) as FormOwner[]; + this.formOwners.set(owners ?? []); + } + + formsIn(file: string) { + return this.formOwners().filter((form) => form.file === file); + } + + /** + * The source scan components that may define this instance, for when the page gives no exact + * location: the classes with its name, narrowed to those whose selector is its host tag when + * any is. More than one means the scan cannot tell which file it is. + */ + sourceCandidates(node: LiveNode): SourceComponent[] { + const matches = (this.sourceByClass().get(bare(node.name)) ?? []).filter( + (c) => c.kind === 'component', + ); + const bySelector = matches.filter((c) => c.selector === node.tag); + return bySelector.length ? bySelector : matches; + } + + /** + * The source scan entry of this instance: the one in the file the page names, or the only + * candidate. Null when none matches or several may be it. + */ + sourceFor(node: LiveNode): SourceComponent | null { + const candidates = this.sourceCandidates(node); + const exact = this.tree.detail()?.source; + if (exact) return candidates.find((c) => sameSourceFile(c.file, exact.file)) ?? null; + return candidates.length === 1 ? candidates[0] : null; + } + + /** + * The source scan directives a directive on the page may be, by class name. Angular gives + * directives no debug location, so a match is only likely. + */ + directiveSources(name: string): SourceComponent[] { + return (this.sourceByClass().get(bare(name)) ?? []).filter((c) => c.kind === 'directive'); + } + + sourceKey(comp: SourceComponent) { + return `${comp.file}#${comp.className}`; + } + + sourceLabel(comp: SourceComponent) { + if (!comp.selector) return bare(comp.className); + return comp.kind === 'component' && /^[a-z][\w-]*$/i.test(comp.selector) + ? `<${comp.selector}>` + : comp.selector; + } +} diff --git a/app/src/pages/components/tree-rows.html b/app/src/pages/components/tree-rows.html new file mode 100644 index 00000000..1cc79e0c --- /dev/null +++ b/app/src/pages/components/tree-rows.html @@ -0,0 +1,107 @@ +

    Component tree

    +@if (!rows().length) { +
    +

    No components match “{{ filter().trim() }}”

    + @if (page()!.truncated) { +

    The filter only searches the components the tree lists.

    + } + @if (!showDirectiveHosts() && page()!.directiveHostCount) { +

    Directive hosts are hidden, so they are not searched.

    + } + +
    +} +@if (rowWindow.enabled()) { +

    + {{ rows().length }} rows. Rows render as you scroll, so the browser's find only sees the ones + near view; the filter above searches them all. +

    +} +
    + @for (shown of shownRows(); track shown.row.node.id) { + @let row = shown.row; +
    + @if (row.hasChildren) { + + } @else { + + } + @if (row.node.kind === 'directive') { + <{{ row.node.tag }}> + {{ + hostLabel(row.node) + }} + directive host + } @else { + {{ row.node.name }} + <{{ row.node.tag }}> + } + @if (row.node.hydration; as status) { + Hydration: {{ hydrationText(status) }} + } + @for (hit of routedById().get(row.node.id) ?? []; track hit.outlet + hit.route) { + {{ hit.route || '/' }} + } + @if (cdHosts()[row.node.id]; as checks) { + + {{ checks + }} {{ checks === 1 ? 'check' : 'checks' }} while recording + + } + @if (row.node.kind !== 'directive' && row.node.directives?.length) { + + +{{ row.node.directives!.length }} + + {{ row.node.directives!.length === 1 ? 'directive' : 'directives' }} + + + } +
    + } +
    +@if (nameTip(); as tip) { + +} diff --git a/app/src/pages/components/tree-rows.scss b/app/src/pages/components/tree-rows.scss new file mode 100644 index 00000000..2a044430 --- /dev/null +++ b/app/src/pages/components/tree-rows.scss @@ -0,0 +1,182 @@ +@use 'mixins' as m; +@use 'components' as ct; + +@include ct.base; +@include ct.buttons; +.tree { + min-width: 0; + padding: 6px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + @include m.enter; +} +.window-note { + margin: 0 0 6px; + font-size: 12px; + color: var(--text-2); +} +.tree.windowed .row { + height: 32px; + overflow: hidden; +} +.row { + position: relative; + scroll-margin-top: var(--ct-toolbar-clearance, 72px); + scroll-margin-bottom: 8px; + display: flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 0 8px 0 calc(4px + var(--depth, 0) * 18px); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background-color 120ms var(--ease); +} +.row::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 14px; + width: calc(var(--depth, 0) * 18px); + background: repeating-linear-gradient(to right, var(--border) 0 1px, transparent 1px 18px); + pointer-events: none; +} +.row:hover { + background: var(--surface-2); +} +.row:focus-visible { + @include m.focus-ring(-2px); +} +.row.selected { + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent); +} +.twisty, +.twisty-space { + flex: none; + width: 20px; + height: 20px; +} +.twisty { + display: grid; + place-items: center; + border-radius: 4px; + color: var(--text-3); +} +.twisty:hover { + color: var(--text); + background: var(--surface-3); +} +.twisty svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; + transition: transform 150ms var(--ease); +} +.twisty.open svg { + transform: rotate(90deg); +} +.name { + min-width: 0; + color: var(--text-strong); + font-size: 12px; + font-weight: 600; + @include m.truncate; +} +.tag { + flex: 0 1 auto; + min-width: 0; + color: var(--text-2); + font-size: 12px; + @include m.truncate; +} +.routed { + flex: none; + max-width: 40%; + padding: 0 8px; + border: 1px solid var(--accent-line); + border-radius: 99px; + background: var(--accent-soft); + color: var(--accent); + font-family: var(--font-mono); + font-size: 11px; + line-height: 18px; + @include m.truncate; +} +.checks { + flex: none; + margin-left: auto; + padding: 0 7px; + border-radius: 99px; + background: var(--accent-soft); + color: var(--text); + font-size: 11px; + line-height: 18px; + font-variant-numeric: tabular-nums; +} +.checks + .dirs { + margin-left: 0; +} +.row.dir-host .host-tag { + color: var(--text); +} +.host-dirs { + min-width: 0; + color: var(--text-2); + font-size: 12px; + font-style: italic; + @include m.truncate; +} +.row.dir-host .twisty-space, +.row.dir-host .twisty { + border-left: 1px dashed var(--border-strong); +} +.hyd { + flex: none; + padding: 0 7px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 11px; + line-height: 18px; +} +.hyd-skipped { + @include m.tint(warn); +} +.hyd-mismatched { + @include m.tint(danger); +} +.name-tip { + position: absolute; + z-index: 1; + left: 8px; + max-width: calc(100% - 16px); + padding: 4px 8px; + background: var(--surface-3); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + box-shadow: var(--shadow); + color: var(--text-strong); + font-size: 12px; + line-height: 1.4; + overflow-wrap: anywhere; + pointer-events: none; + @include m.enter(150ms); +} +.dirs { + flex: none; + margin-left: auto; + padding: 0 7px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 11px; + line-height: 18px; +} +@include ct.states; diff --git a/app/src/pages/components/tree-rows.ts b/app/src/pages/components/tree-rows.ts new file mode 100644 index 00000000..23a46d7f --- /dev/null +++ b/app/src/pages/components/tree-rows.ts @@ -0,0 +1,133 @@ +import { Component, ElementRef, effect, inject, output, signal, viewChild } from '@angular/core'; +import { directiveHostLabel, hydrationLabel, type HydrationStatus } from '../component-tree-state'; +import type { LiveNode } from './component-model'; +import { TreeState } from './tree-state'; + +/** + * The live component tree of the Components page: one ARIA tree item per instance, windowed + * when the tree is long, with arrow key moves and the full name of a cut off row under it. + */ +@Component({ + selector: 'div[appTreeRows]', + templateUrl: './tree-rows.html', + styleUrl: './tree-rows.scss', +}) +export class TreeRows { + private readonly state = inject(TreeState); + /** The no-match state's Clear filters, which the page handles so focus goes to its search. */ + readonly clearFilter = output(); + + protected readonly filter = this.state.filter; + protected readonly page = this.state.page; + protected readonly rows = this.state.rows; + protected readonly rowWindow = this.state.rowWindow; + protected readonly shownRows = this.state.shownRows; + protected readonly selectedId = this.state.selectedId; + protected readonly tabStop = this.state.tabStop; + protected readonly focusId = this.state.focusId; + protected readonly showDirectiveHosts = this.state.showDirectiveHosts; + protected readonly routedById = this.state.routedById; + protected readonly cdHosts = this.state.cdHosts; + /** The full text of the focused row when its name or tag is cut off, shown below the row. */ + protected readonly nameTip = signal<{ text: string; top: number } | null>(null); + private readonly treeEl = viewChild>('treeEl'); + + constructor() { + effect(() => { + const tree = this.treeEl()?.nativeElement; + if (tree) this.rowWindow.attach(tree); + }); + } + + protected select(id: string) { + this.state.select(id); + } + + protected toggle(id: string, event?: Event) { + this.state.toggle(id, event); + } + + protected highlight(id: string | null) { + this.state.highlight(id); + } + + protected hostLabel(node: LiveNode) { + return directiveHostLabel(node); + } + + protected hydrationText(status: HydrationStatus) { + return hydrationLabel(status); + } + + /** Shows the full text of a focused row whose name, tag or directives are cut off. */ + protected showNameTip(event: FocusEvent) { + const row = event.currentTarget as HTMLElement; + const parts = Array.from(row.querySelectorAll('.name, .tag, .host-dirs')); + const cut = parts.some((part) => part.scrollWidth > part.clientWidth + 1); + if (!cut) { + this.nameTip.set(null); + return; + } + const text = parts.map((part) => part.title || part.textContent?.trim()).join(' '); + this.nameTip.set({ text, top: row.offsetTop + row.offsetHeight + 2 }); + } + + protected onTreeKey(event: KeyboardEvent) { + const state = this.state; + const rows = state.rows(); + if (!rows.length) return; + const current = rows.findIndex((r) => r.node.id === state.rovingId()); + const at = Math.max(current, 0); + const row = rows[at]; + let next = -1; + switch (event.key) { + case 'ArrowDown': + next = Math.min(at + 1, rows.length - 1); + break; + case 'ArrowUp': + next = Math.max(at - 1, 0); + break; + case 'Home': + next = 0; + break; + case 'End': + next = rows.length - 1; + break; + case 'ArrowRight': + if (row.hasChildren && !row.expanded) state.toggle(row.node.id); + else if (row.hasChildren) next = at + 1; + break; + case 'ArrowLeft': + if (row.hasChildren && row.expanded && !state.query()) state.toggle(row.node.id); + else { + const parent = state.index().parents.get(row.node.id); + next = rows.findIndex((r) => r.node.id === parent); + } + break; + case 'Enter': + case ' ': + state.select(row.node.id); + break; + case '*': + state.expandSiblings(row.node.id); + break; + default: + return; + } + event.preventDefault(); + if (next >= 0) { + const id = rows[next].node.id; + state.focusId.set(id); + const focus = () => { + const el = document.querySelector(`.row[data-id="${CSS.escape(id)}"]`); + state.clearToolbar(); + el?.focus({ preventScroll: true }); + el?.scrollIntoView({ block: 'nearest' }); + if (el) state.keepBelowToolbar(el); + }; + // A row outside the rendered window is brought in first. + if (this.rowWindow.rendered(next)) queueMicrotask(focus); + else this.rowWindow.reveal(next, focus); + } + } +} diff --git a/app/src/pages/components/tree-state.ts b/app/src/pages/components/tree-state.ts new file mode 100644 index 00000000..edc46ce4 --- /dev/null +++ b/app/src/pages/components/tree-state.ts @@ -0,0 +1,610 @@ +import { + ElementRef, + Injectable, + Injector, + afterNextRender, + computed, + inject, + linkedSignal, + signal, + type Signal, +} from '@angular/core'; +import type { DevframeRpcClient } from 'devframe/client'; +import { + angularVersionAtLeast, + lacksCapability, +} from '@pangular-inspector/devtools/internal/config'; +import { hostPageId } from '../../page-id'; +import { angularPageFor, injectAngularPages } from '../../angular-pages'; +import { pickPage } from '../../live-pages'; +import { isNativePage } from '../../native-page'; +import { isStaticReport } from '../../rpc'; +import { actionAllowed } from '../../devtools-config'; +import type { SelectOption } from '../../ui/select'; +import { VirtualWindow } from '../../ui/virtual-window'; +import { keepBelowSticky, stickyClearance } from '../../ui/sticky-clearance'; +import { + countText, + filterAnnouncement, + filterTree, + nearestRow, + reconcileSelection, + truncationNotice, + withoutDirectiveHosts, +} from '../component-tree-state'; +import type { CdPage } from '../cd-recording'; +import type { DeferBlock } from '../defer-blocks-section'; +import type { LeaksPage } from '../leaks-section'; +import { + DIRECTIVE_HOSTS_KEY, + TREE_ROW_PX, + ancestors, + storedDirectiveHosts, + type LiveNode, + type OutletInfo, + type Page, + type RoutedHit, + type Row, +} from './component-model'; + +/** The page inputs the state reads, connected by the page. */ +export interface TreeInputs { + rpc: Signal; + /** Whether the Injectors tab is on, so "Show in Injectors" can open it. */ + canShowInjector: Signal; + /** Whether the Signals tab is on, so "Show in Signals" can open it. */ + canShowSignals: Signal; + /** Whether the Pipes inspector is on, so the detail lists the template's pipes. */ + canShowPipes: Signal; +} + +/** + * The state the Components page and its sections share: the `component-tree`, `change-detection`, + * `leaks` and `router` shared states, the shown page, the tree rows with their filter and + * collapsed nodes, the selection and the live message. Provided by the page, so every section of + * one page reads the same signals. + */ +@Injectable() +export class TreeState { + private inputs: TreeInputs = { + rpc: signal(null), + canShowInjector: signal(false), + canShowSignals: signal(false), + canShowPipes: signal(false), + }; + readonly rpc = computed(() => this.inputs.rpc()); + readonly canShowInjector = computed(() => this.inputs.canShowInjector()); + readonly canShowSignals = computed(() => this.inputs.canShowSignals()); + readonly canShowPipes = computed(() => this.inputs.canShowPipes()); + readonly staticReport = computed(() => isStaticReport(this.rpc())); + + readonly filter = signal(''); + readonly pages = signal>({}); + readonly chosenPageId = signal(null); + readonly selectedId = signal(null); + readonly destroyed = signal(null); + readonly announcement = signal(''); + readonly pickMessage = signal(''); + readonly focusId = signal(null); + readonly collapsed = signal>(new Set()); + /** Elements that host directives but no component show as rows; this viewer can hide them. */ + readonly showDirectiveHosts = signal(storedDirectiveHosts()); + private readonly routerPages = signal<{ pageId?: string; outlets?: OutletInfo[] }[]>([]); + private readonly pageId = hostPageId(); + private readonly angularPages = injectAngularPages(() => this.rpc()); + /** The shown page's Angular version and debug API parts, for the unavailable notes. */ + readonly angularPage = computed(() => + angularPageFor(this.angularPages(), this.page()?.pageId ?? this.pageId), + ); + /** `@defer` and `@for` exist from Angular 17.0, so older pages get no note about them. */ + private readonly hasControlFlow = computed(() => { + const version = this.angularPage()?.version; + return !version || angularVersionAtLeast(version, '17.0'); + }); + readonly deferUnavailable = computed( + () => this.hasControlFlow() && lacksCapability(this.angularPage(), 'deferBlocks'), + ); + readonly forUnavailable = computed( + () => this.hasControlFlow() && lacksCapability(this.angularPage(), 'forBlocks'), + ); + private readonly injector = inject(Injector); + private readonly host = inject>(ElementRef); + /** Whether the server sent the component tree state yet, so an empty one means no page. */ + readonly pagesKnown = signal(false); + private readonly cleanups: (() => void)[] = []; + private selection = { pageId: null as string | null, detailId: null as string | null }; + /** Set once the page is destroyed, so a late shared state answer is dropped. */ + private gone = false; + + private readonly shownPageId = linkedSignal< + { pages: Record; chosen: string | null }, + string | null + >({ + source: () => ({ pages: this.pages(), chosen: this.chosenPageId() }), + computation: ({ pages, chosen }, previous) => + pickPage(pages, { chosen, host: this.pageId, previous: previous?.value ?? null }), + }); + + readonly page = computed(() => { + const id = this.shownPageId(); + return id ? (this.pages()[id] ?? null) : null; + }); + + readonly pageOptions = computed(() => + Object.values(this.pages()).map((page) => ({ + value: page.pageId, + label: page.title || page.url || `Page ${page.pageId}`, + hint: page.url, + })), + ); + + readonly live = computed(() => (this.page()?.roots.length ?? 0) > 0); + readonly native = computed(() => isNativePage(this.page())); + /** Inputs, properties and writable signals are editable on a live browser page. */ + readonly canEdit = computed( + () => + this.live() && + !this.native() && + !this.staticReport() && + actionAllowed(this.rpc(), 'components'), + ); + + private readonly cdPages = signal>({}); + readonly cdPage = computed(() => { + const pageId = this.page()?.pageId; + return pageId ? (this.cdPages()[pageId] ?? null) : null; + }); + readonly cdHosts = computed(() => this.cdPage()?.hosts ?? {}); + + private readonly leaksPages = signal>({}); + /** The shown page's leak report, from the `leaks` shared state. */ + readonly leaksPage = computed(() => { + const pageId = this.page()?.pageId; + return pageId ? (this.leaksPages()[pageId] ?? null) : null; + }); + + /** The page's tree, without directive hosts when this viewer hid them. */ + private readonly visibleRoots = computed(() => { + const roots = this.page()?.roots ?? []; + return this.showDirectiveHosts() || !this.page()?.directiveHostCount + ? roots + : withoutDirectiveHosts(roots); + }); + + readonly index = computed(() => { + const map = new Map(); + const parents = new Map(); + const walk = (nodes: LiveNode[], parent: string | null) => { + for (const node of nodes) { + // The first row with an id wins, so `parents` can never loop. + if (map.has(node.id)) continue; + map.set(node.id, node); + if (parent) parents.set(node.id, parent); + walk(node.children, node.id); + } + }; + walk(this.visibleRoots(), null); + return { map, parents }; + }); + + readonly query = computed(() => this.filter().trim().toLowerCase()); + + private readonly filtered = computed(() => filterTree(this.visibleRoots(), this.query())); + + /** Directive host rows in the tree as shown. */ + private readonly shownDirectiveHosts = computed(() => + this.showDirectiveHosts() ? (this.page()?.directiveHostCount ?? 0) : 0, + ); + + readonly deferBlocks = computed(() => this.page()?.deferBlocks ?? []); + /** The defer blocks in the selected component's template. */ + readonly ownedDeferBlocks = computed(() => { + const id = this.selectedId(); + return id ? this.deferBlocks().filter((block) => block.owner?.id === id) : []; + }); + + readonly countLabel = computed(() => + countText( + this.page()?.count ?? 0, + this.filtered().matches, + !!this.query(), + !!this.page()?.truncated, + this.shownDirectiveHosts(), + ), + ); + + readonly truncationText = computed(() => truncationNotice(this.page()?.truncatedBy)); + + readonly rows = computed(() => { + const rows: Row[] = []; + const collapsed = this.collapsed(); + const searching = !!this.query(); + const walk = (nodes: LiveNode[], depth: number) => { + nodes.forEach((node, i) => { + const expanded = searching || !collapsed.has(node.id); + rows.push({ + node, + depth, + hasChildren: node.children.length > 0, + expanded, + posinset: i + 1, + setsize: nodes.length, + }); + if (expanded) walk(node.children, depth + 1); + }); + }; + walk(this.filtered().nodes, 0); + return rows; + }); + + /** A long tree renders only the rows near the visible part of the page. */ + readonly rowWindow = new VirtualWindow({ + count: computed(() => this.rows().length), + estimate: TREE_ROW_PX, + fixed: true, + }); + readonly shownRows = computed(() => { + const { start, end } = this.rowWindow.range(); + return this.rows() + .slice(start, end) + .map((row, i) => ({ row, index: start + i })); + }); + /** The row Tab lands on: the roving one, or the first rendered row when it is out of view. */ + readonly tabStop = computed(() => { + const id = this.rovingId(); + const shown = this.shownRows(); + return shown.some(({ row }) => row.node.id === id) ? id : (shown[0]?.row.node.id ?? null); + }); + + readonly selectedNode = computed(() => { + const id = this.selectedId(); + return id ? (this.index().map.get(id) ?? null) : null; + }); + + /** The selected row when it is a directive host, which has no component detail. */ + readonly selectedHost = computed(() => { + const node = this.selectedNode(); + return node?.kind === 'directive' ? node : null; + }); + + readonly detail = computed(() => { + const detail = this.page()?.detail; + return detail && detail.id === this.selectedId() ? detail : null; + }); + + readonly rovingId = computed(() => { + const rows = this.rows(); + const ids = new Set(rows.map((r) => r.node.id)); + const focus = this.focusId(); + if (focus && ids.has(focus)) return focus; + const selected = this.selectedId(); + if (selected && ids.has(selected)) return selected; + return rows[0]?.node.id ?? null; + }); + + private readonly outlets = computed(() => { + const shown = this.page()?.pageId; + return this.routerPages().find((p) => p.pageId === shown)?.outlets ?? []; + }); + + readonly routedById = computed(() => { + const map = new Map(); + const visit = (outlets: OutletInfo[]) => { + for (const outlet of outlets) { + if (outlet.activated && outlet.devtoolsId) { + const hits = map.get(outlet.devtoolsId) ?? []; + hits.push({ route: outlet.route ?? '', outlet: outlet.outlet }); + map.set(outlet.devtoolsId, hits); + } + if (outlet.children) visit(outlet.children); + } + }; + visit(this.outlets()); + return map; + }); + + /** Reads the page's inputs. Call once, from the page's constructor. */ + connect(inputs: TreeInputs) { + this.inputs = inputs; + } + + /** Stops following the shared states, when the page is destroyed. */ + destroy() { + this.gone = true; + this.highlight(null); + for (const cleanup of this.cleanups.splice(0)) cleanup(); + } + + async watch(client: DevframeRpcClient) { + for (const cleanup of this.cleanups.splice(0)) cleanup(); + const my = client.scope('pangular'); + try { + const tree = await my.rpc.sharedState('component-tree'); + if (this.gone) return; + 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'); + if (this.gone) return; + const applyCd = (value: unknown) => { + const pages = (value as { pages?: Record } | null)?.pages; + this.cdPages.set(pages && typeof pages === 'object' ? pages : {}); + }; + applyCd(cd.value()); + this.cleanups.push(cd.on('updated', applyCd)); + } catch { + this.cdPages.set({}); + } + try { + const leaks = await my.rpc.sharedState('leaks'); + if (this.gone) return; + const applyLeaks = (value: unknown) => { + const pages = (value as { pages?: Record } | null)?.pages; + this.leaksPages.set(pages && typeof pages === 'object' ? pages : {}); + }; + applyLeaks(leaks.value()); + this.cleanups.push(leaks.on('updated', applyLeaks)); + } catch { + this.leaksPages.set({}); + } + try { + const router = await my.rpc.sharedState('router'); + if (this.gone) return; + const applyRouter = (value: unknown) => { + const pages = (value as { pages?: { pageId?: string; outlets?: OutletInfo[] }[] })?.pages; + this.routerPages.set(Array.isArray(pages) ? pages : []); + }; + applyRouter(router.value()); + this.cleanups.push(router.on('updated', applyRouter)); + } catch { + this.routerPages.set([]); + } + } + + private applyPages(pages: Record) { + const active = document.activeElement; + const focused = + active instanceof HTMLElement && this.host.nativeElement.contains(active) + ? (active.closest('.row')?.dataset['id'] ?? null) + : null; + const before = focused ? this.rows().map((row) => row.node.id) : []; + const known = this.index().map; + this.pages.set(pages); + this.syncSelection(known); + if (!focused) return; + const after = new Set(this.rows().map((row) => row.node.id)); + if (after.has(focused)) return; + const next = nearestRow(before, after, focused); + if (!next) return; + this.focusId.set(next); + afterNextRender(() => this.rowElement(next)?.focus(), { injector: this.injector }); + } + + private syncSelection(known = this.index().map) { + const page = this.page(); + const next = reconcileSelection( + { ...this.selection, selectedId: this.selectedId() }, + page + ? { pageId: page.pageId, roots: page.roots, detail: page.detail, truncated: page.truncated } + : null, + ); + const name = next.destroyed ? known.get(next.destroyed)?.name : undefined; + this.selection = { pageId: next.pageId, detailId: next.detailId }; + if (next.selectedId !== this.selectedId()) this.selectedId.set(next.selectedId); + if (next.selectedId) this.destroyed.set(null); + if (!next.destroyed) return; + this.destroyed.set(name ?? 'The component'); + this.announcement.set('The selected component was destroyed.'); + this.sendSelection(null); + } + + selectPage(pageId: string | null) { + if (!pageId) return; + this.chosenPageId.set(pageId); + this.destroyed.set(null); + this.syncSelection(); + } + + setFilter(value: string) { + this.filter.set(value); + const { matches } = this.filtered(); + this.announcement.set( + filterAnnouncement( + this.page()?.count ?? 0, + matches, + value, + !!this.page()?.truncated, + this.shownDirectiveHosts(), + ), + ); + } + + /** Shows a message under the toolbar and says it. */ + say(message: string) { + this.pickMessage.set(message); + this.announce(message); + } + + private sendSelection(id: string | null) { + const client = this.rpc(); + if (!client) return; + void client + .scope('pangular') + .rpc.call('select-component', { pageId: this.page()?.pageId, id, console: true }) + .catch(() => {}); + } + + private rowElement(id: string) { + return this.host.nativeElement.querySelector(`.row[data-id="${CSS.escape(id)}"]`); + } + + select(id: string) { + const next = this.selectedId() === id ? null : id; + this.selectedId.set(next); + this.destroyed.set(null); + this.focusId.set(id); + // A directive host has no component detail for the page to send. + if (next && this.index().map.get(next)?.kind === 'directive') return; + this.sendSelection(next); + } + + /** The nearest component above a row, such as the one whose template renders a directive host. */ + ownerOf(id: string): LiveNode | null { + const { map, parents } = this.index(); + for (const parent of ancestors(parents, id)) { + const node = map.get(parent); + if (node && node.kind !== 'directive') return node; + } + return null; + } + + toggleDirectiveHosts() { + const next = !this.showDirectiveHosts(); + this.showDirectiveHosts.set(next); + try { + localStorage.setItem(DIRECTIVE_HOSTS_KEY, next ? 'shown' : 'hidden'); + } catch { + // the choice lasts for this view only + } + const selected = this.selectedId(); + if (!next && selected && !this.index().map.has(selected)) this.selectedId.set(null); + this.announce(next ? 'Directive hosts are shown.' : 'Directive hosts are hidden.'); + } + + setHydrationOverlay(on: boolean) { + const client = this.rpc(); + const pageId = this.page()?.pageId; + if (!client || !pageId) return; + void client + .scope('pangular') + .rpc.call('request-hydration-overlay', { pageId, on }) + .catch(() => {}); + this.announce(on ? 'Outlining hydration on the page.' : 'Hydration outline removed.'); + } + + /** Opens the tree to a node and selects it; `focus` also moves keyboard focus to its row. */ + reveal(id: string, focus = false) { + const { parents } = this.index(); + this.collapsed.update((set) => { + const next = new Set(set); + for (const parent of ancestors(parents, id)) next.delete(parent); + return next; + }); + if (!this.rows().some((row) => row.node.id === id)) this.setFilter(''); + if (this.selectedId() !== id) this.select(id); + if (focus) this.focusId.set(id); + // In a long tree the row may be outside the rendered window. + const index = this.rows().findIndex((row) => row.node.id === id); + if (index >= 0) this.rowWindow.reveal(index); + afterNextRender( + () => { + const row = document.querySelector(`.row[data-id="${CSS.escape(id)}"]`); + this.clearToolbar(); + row?.scrollIntoView({ block: 'nearest' }); + if (row) this.keepBelowToolbar(row); + if (focus) row?.focus({ preventScroll: true }); + }, + { injector: this.injector }, + ); + } + + /** + * Sets the rows' `scroll-margin-top` so a row scrolled into view sits below + * the sticky toolbar, whose height changes as it wraps and which sticks + * inside the scroller's padding. + */ + clearToolbar() { + const toolbar = this.host.nativeElement.querySelector('.toolbar'); + if (!toolbar) return; + this.host.nativeElement.style.setProperty( + '--ct-toolbar-clearance', + `${stickyClearance(toolbar)}px`, + ); + } + + /** Moves a row the sticky toolbar covers back below it. */ + keepBelowToolbar(row: HTMLElement) { + const toolbar = this.host.nativeElement.querySelector('.toolbar'); + if (toolbar) keepBelowSticky(row, toolbar); + } + + toggle(id: string, event?: Event) { + event?.stopPropagation(); + if (this.query()) return; + this.collapsed.update((set) => { + const next = new Set(set); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + expandAll() { + if (this.query()) return; + this.collapsed.set(new Set()); + this.announce('Expanded every component.'); + } + + collapseAll() { + if (this.query()) return; + const { map, parents } = this.index(); + const previous = this.rovingId(); + const next = new Set(); + for (const node of map.values()) if (node.children.length) next.add(node.id); + this.collapsed.set(next); + if (previous) { + const visible = new Set(this.rows().map((row) => row.node.id)); + let id: string | undefined = previous; + while (id && !visible.has(id)) id = parents.get(id); + if (id) this.focusId.set(id); + } + this.announce('Collapsed every component. Only the top level is shown.'); + } + + /** Sets the live message, changing it slightly when it repeats so screen readers say it again. */ + announce(message: string) { + this.announcement.update((current) => (current === message ? `${message} ` : message)); + } + + expandSiblings(id: string) { + if (this.query()) return; + const parent = this.index().parents.get(id); + const siblings = parent + ? (this.index().map.get(parent)?.children ?? []) + : (this.page()?.roots ?? []); + this.collapsed.update((set) => { + const next = new Set(set); + for (const sibling of siblings) next.delete(sibling.id); + return next; + }); + } + + highlight(id: string | null) { + this.requestHighlight(id ? { id } : null); + } + + /** One box around every root element of the block, or its owner when none is rendered. */ + highlightBlock(block: DeferBlock) { + if (block.rootIds.length > 1) this.requestHighlight({ ids: block.rootIds }); + else this.highlight(block.rootIds[0] ?? block.owner?.id ?? null); + } + + private requestHighlight(target: { id: string } | { ids: string[] } | null) { + const client = this.rpc(); + if (!client) return; + void client + .scope('pangular') + .rpc.call('request-page-highlight', target && { pageId: this.page()?.pageId, ...target }) + .catch(() => {}); + } +} diff --git a/app/src/pages/di-inspector.html b/app/src/pages/di-inspector.html new file mode 100644 index 00000000..789d0e86 --- /dev/null +++ b/app/src/pages/di-inspector.html @@ -0,0 +1,260 @@ +

    {{ announcement() }}

    + + + + + @if (pageOptions().length > 1) { +
    + Page + +
    + } +
    +@if (roots().length > 0) { +

    + Every component and directive gets an injector. When it asks for a token, Angular walks up this + tree, then through the environment injectors, until something provides it. +

    + + This tree shows the element injectors the page reports, with providers and environment injectors + only where its Angular exposes them. + + +
    +
    + + +
    + + + @if (view() === 'element') { + + } + + + @if (hideFramework()) { + {{ frameworkHiddenText() }} + } + @if (zone(); as zone) { + Change detection: {{ zone }} + } + +
    + + @if (truncated()) { +

    + + + The page reported only part of its injector tree. + It keeps at most {{ limits().elements }} element injectors and {{ limits().injectors }} in + all, and leaves out injectors nested more than {{ limits().depth }} levels deep, so a lookup + path or a provider can point to one that is not listed. + +

    + } + + @if (providedBy().length) { +
    + Provided by + @for (hit of providedBy(); track hit.id) { + + } + @if (providedByMore(); as more) { + +{{ more }} more + } +
    + } @else if (query().trim() && !rows().length) { +
    + Nothing here matches “{{ query().trim() }}”. + +
    + } + + @if (orphaned().length) { +
    + } + +
    + @if (!rows().length) { +
    +

    No injectors to show

    +

    + @if (hideFramework() && frameworkHiddenCount().injectors > 0) { + Turn off “Hide framework” to see Angular's own injectors. + } @else if (onlyProviding()) { + None of them provide anything themselves. + } @else if (componentsOnly() && view() === 'element') { + Turn off “Components only” to see directives. + } @else { + Try a different search. + } +

    +
    + } +
    + + + @if (gone(); as name) { +
    + } @else if (selected(); as sel) { +
    + } +
    + +} @else if (!loaded()) { +
    + +

    Loading dependency injection data…

    +
    +} @else if (loadFailed() && sourceProviders().length === 0) { + +} @else if (sourceProviders().length === 0) { + @if (pageOptions().length > 1) { +
    + +
    + } +
    +

    No DI data found

    +

    + Open the app in a browser with the devtools connected to see its live injectors. No providers, + injectables or inject() calls were found in the source either. +

    +
    +} @else { +
    + + + + {{ sourceMatchCount() }} of {{ sourceProviders().length }} + +
    + + @if (loadFailed()) { + + } +

    + + + DI from the source scan. + Connect the overlay to a development build to see the live injector tree. + +

    + + @if (groupedProviders().length === 0) { +
    +

    No providers match “{{ query() }}”

    +

    Try part of a token name or a file path.

    + +
    + } @else { +
    + } +} diff --git a/app/src/pages/di-inspector.scss b/app/src/pages/di-inspector.scss new file mode 100644 index 00000000..9f77aadf --- /dev/null +++ b/app/src/pages/di-inspector.scss @@ -0,0 +1,334 @@ +@use 'mixins' as m; +@use 'injectors' as inj; + +:host { + --directive: #7cb4ff; + display: block; + color: var(--text); + font-size: 13px; + + @include m.light { + --directive: #1e40af; + } +} +@include inj.mono; +.intro { + max-width: 720px; + margin: 0 0 12px; + color: var(--text-2); + line-height: 1.5; +} +.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); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.page-picker { + display: flex; + flex: 0 1 240px; + align-items: center; + gap: 8px; + min-width: 0; +} +.page-picker app-select { + flex: 1 1 auto; + min-width: 0; +} +.label-key { + flex: none; + color: var(--text-2); + font-size: 12px; +} +.segmented { + display: inline-flex; + flex: none; + height: var(--control-h); + padding: 3px; + gap: 2px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--bg); +} +.segmented button { + display: inline-flex; + align-items: center; + gap: 6px; + height: 100%; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: none; + color: var(--text-2); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: + background-color 150ms var(--ease), + color 150ms var(--ease); +} +.segmented button:hover { + color: var(--text); +} +.segmented button.active { + background: var(--surface-3); + color: var(--text-strong); +} +.segmented button:focus-visible { + @include m.focus-ring(-2px); +} +@include inj.pill; +.segmented .active .pill { + background: var(--accent-soft); + color: var(--accent); +} +.search { + position: relative; + flex: 1 1 220px; + 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; +} +.search:focus-within .search-icon { + stroke: var(--accent); +} +input[type='search'] { + width: 100%; + height: 34px; + padding: 0 12px 0 34px; + background: var(--bg); + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 13px; + transition: + border-color 150ms var(--ease), + box-shadow 150ms var(--ease); +} +input[type='search']::placeholder { + color: var(--text-3); +} +input[type='search']:focus-visible { + @include m.field-focus; +} +.checkbox { + display: flex; + align-items: center; + gap: 8px; + height: 34px; + color: var(--text-2); + white-space: nowrap; + cursor: pointer; +} +.checkbox:hover { + color: var(--text); +} +.checkbox input { + width: 15px; + height: 15px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; +} +.checkbox input:focus-visible { + @include m.focus-ring; +} +.total { + flex: none; + color: var(--text-2); + font-size: 12px; + white-space: nowrap; +} +.where { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin: 0 0 12px; + padding: 8px 10px; + border: 1px solid var(--accent-line); + border-radius: var(--radius-sm); + background: var(--accent-soft); + @include m.enter(0.2s); +} +.where.muted { + border-color: var(--border); + background: var(--surface); + color: var(--text-2); +} +.where-label { + @include m.label; + margin-right: 4px; + color: var(--accent); +} +@include inj.chips; +.chip-tokens { + color: var(--text-2); + @include m.truncate; +} +.more { + color: var(--text-2); + font-size: 12px; +} +@include inj.link; +@include inj.dot; +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + align-items: start; +} +.layout > app-splitter { + display: none; +} +@media (min-width: 880px) { + .layout { + grid-template-columns: minmax(0, 1fr) auto min(var(--detail-size, 420px), 70%); + } + .layout > * { + grid-column: 1; + grid-row: 1; + } + .layout > app-splitter { + display: block; + grid-column: 2; + } + .layout > .detail { + grid-column: 3; + } + .detail { + position: sticky; + top: 64px; + max-height: calc(100vh - 150px); + overflow: auto; + } +} +.tree { + min-width: 0; + padding: 6px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + @include m.enter; +} +.detail { + min-width: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + @include m.enter; +} +@include inj.state; +.spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } +} +@include inj.buttons; +@include inj.state-buttons; +.load-error.compact { + margin: 0 0 16px; + border: 1px solid var(--border); + background: var(--surface); +} +.state-title + .state-hint + button { + margin-top: 8px; +} +@include inj.state-button-focus; +.notice { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0 0 16px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-2); + font-size: 13px; + line-height: 1.5; +} +.notice strong { + color: var(--text); + font-weight: 600; +} +.notice svg { + flex: none; + width: 16px; + height: 16px; + margin-top: 2px; + fill: none; + stroke: var(--accent); + stroke-width: 2; + stroke-linecap: round; +} +.refresh { + display: inline-flex; + align-items: center; + gap: 6px; + height: var(--control-h); + margin-left: auto; + padding: 0 12px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font-size: 12px; + cursor: pointer; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease); +} +.refresh:hover { + background: var(--surface-3); + border-color: var(--accent-line); +} +.refresh[aria-disabled='true'] { + cursor: default; + opacity: 0.6; +} +.refresh:focus-visible { + @include m.focus-ring; +} +@include inj.chevron; +.orphans { + margin: 0 0 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); +} +.source-providers { + display: flex; + flex-direction: column; + gap: 24px; + @include m.enter; +} diff --git a/app/src/pages/di-inspector.ts b/app/src/pages/di-inspector.ts index f1f53236..91db2ad3 100644 --- a/app/src/pages/di-inspector.ts +++ b/app/src/pages/di-inspector.ts @@ -1,14 +1,9 @@ import { Component, - DestroyRef, - ElementRef, - Injector, - afterNextRender, computed, effect, inject, input, - linkedSignal, output, signal, untracked, @@ -16,93 +11,35 @@ import { } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import type { DevframeRpcClient } from 'devframe/client'; -import { pickPage, type ReportedPage } from '../live-pages'; -import { hostPageId } from '../page-id'; import { angularPageFor, injectAngularPages } from '../angular-pages'; import { AngularUnavailable } from '../ui/angular-unavailable'; -import { Select, type SelectOption } from '../ui/select'; -import { zoneLabel } from './dashboard'; -import { SourceLink } from '../ui/source-link'; -import { StoreGlobal } from '../ui/store-global'; -import { ProviderValue } from './di-provider-value'; +import { SearchField } from '../ui/search-field'; +import { Select } from '../ui/select'; +import { Splitter, storedPaneSize } from '../ui/splitter'; import type { ComponentFocus } from '../types/component-focus.types'; import type { InjectorFocus } from '../types/injector-focus.types'; -import { storeFlag, storedFlag } from '../view-prefs'; import { StartupSection } from './startup-section'; -import { SearchField } from '../ui/search-field'; -import { Splitter, storedPaneSize } from '../ui/splitter'; -import type { - InjectorTreeLimits, - InjectorTreeNode, - ProviderInfo as PanelProviderInfo, -} from '@pangular-inspector/devtools/internal/types'; - -type ProviderInfo = PanelProviderInfo; -type InjectorNode = InjectorTreeNode; - -interface SourceProvider { - token: string; - source: string; - file: string; - line: number; - providedIn?: string; - type: string; -} - -interface Row { - node: InjectorNode; - depth: number; - hasChildren: boolean; - expanded: boolean; -} - -type TreeView = 'element' | 'environment'; - -interface InjectorPage { - roots?: unknown; - environment?: unknown; - truncated?: unknown; - limits?: unknown; - zone?: unknown; - reportedAt?: unknown; -} - -interface InjectorTreeState extends InjectorPage { - pages?: Record; -} - -const NULL_ID = 'inj-null'; -const HIDE_FRAMEWORK_PREF = 'injectors.hideFramework'; -const MAX_PROVIDED_BY = 12; -/** The `type` the source scan gives a provide*() call it can't resolve to tokens. */ -const HELPER_TYPE = 'helper'; -/** Source entries that provide a token, which the orphaned providers hint checks. */ -const PROVIDING_TYPES = new Set(['provider', 'injectable']); -/** How long typing must pause before the search result is announced. */ -const SEARCH_ANNOUNCE_MS = 600; - -// Text tones: these color the kind letter and badge on a 14 to 16% tint of -// themselves, so they use the stronger `-text` tokens. -const KIND_TONE: Record = { - component: 'var(--accent-text)', - directive: 'var(--directive)', - environment: 'var(--ok-text)', - null: 'var(--text-3)', -}; - -function kindOf(node: InjectorNode): 'component' | 'directive' | 'environment' { - if (node.injector.type !== 'element') return 'environment'; - return node.injector.component ? 'component' : 'directive'; -} - -function labelOf(node: InjectorNode): string { - return node.injector.type === 'element' ? `<${node.injector.name}>` : node.injector.name; -} - -function isTree(value: unknown): value is InjectorNode[] { - return Array.isArray(value); -} - +import { InjectorDetail } from './injectors/injector-detail'; +import { InjectorGone } from './injectors/injector-gone'; +import { + SEARCH_ANNOUNCE_MS, + labelOf, + toneOf, + type InjectorNode, + type TreeView, +} from './injectors/injector-model'; +import { InjectorOrphans } from './injectors/injector-orphans'; +import { InjectorTree } from './injectors/injector-tree'; +import { InjectorsState } from './injectors/injectors-state'; +import { SourceProviders } from './injectors/source-providers'; +import { SourceScan } from './injectors/source-scan'; + +/** + * The Injectors page: the toolbar, the notices and the tree with its detail, or the source scan + * while no page reports a live tree. The sections live in `./injectors/` and share the page's + * {@link InjectorsState} and {@link SourceScan}; the page keeps the members its tests and the + * shell read on itself. + */ @Component({ selector: 'app-di-inspector', imports: [ @@ -110,1741 +47,17 @@ function isTree(value: unknown): value is InjectorNode[] { Splitter, AngularUnavailable, NgTemplateOutlet, - ProviderValue, + InjectorDetail, + InjectorGone, + InjectorOrphans, + InjectorTree, Select, - SourceLink, + SourceProviders, StartupSection, - StoreGlobal, ], - template: ` -

    {{ announcement() }}

    - - - - - @if (pageOptions().length > 1) { -
    - Page - -
    - } -
    - @if (roots().length > 0) { -

    - Every component and directive gets an injector. When it asks for a token, Angular walks up - this tree, then through the environment injectors, until something provides it. -

    - - This tree shows the element injectors the page reports, with providers and environment - injectors only where its Angular exposes them. - - -
    -
    - - -
    - - - @if (view() === 'element') { - - } - - - @if (hideFramework()) { - {{ frameworkHiddenText() }} - } - @if (zone(); as zone) { - Change detection: {{ zone }} - } - -
    - - @if (truncated()) { -

    - - - The page reported only part of its injector tree. - It keeps at most {{ limits().elements }} element injectors and - {{ limits().injectors }} in all, and leaves out injectors nested more than - {{ limits().depth }} levels deep, so a lookup path or a provider can point to one that - is not listed. - -

    - } - - @if (providedBy().length) { -
    - Provided by - @for (hit of providedBy(); track hit.id) { - - } - @if (providedByMore(); as more) { - +{{ more }} more - } -
    - } @else if (query().trim() && !rows().length) { -
    - Nothing here matches “{{ query().trim() }}”. - -
    - } - - @if (orphaned().length) { -
    - -
    -

    - Provided in your source, but nothing on this page has injected them so far. This is - not an error: a guard, an interceptor or code that has not run yet may still use them. -

    -
      - @for (p of orphaned(); track p.token + p.file + p.line) { -
    • - {{ p.token }} - @if (p.providedIn) { - providedIn: {{ p.providedIn }} - } - -
    • - } -
    -
    -
    - } - -
    - @if (!rows().length) { -
    -

    No injectors to show

    -

    - @if (hideFramework() && frameworkHiddenCount().injectors > 0) { - Turn off “Hide framework” to see Angular's own injectors. - } @else if (onlyProviding()) { - None of them provide anything themselves. - } @else if (componentsOnly() && view() === 'element') { - Turn off “Components only” to see directives. - } @else { - Try a different search. - } -

    -
    - } -
    - @for (row of rows(); track row.node.injector.id; let i = $index) { -
    - @if (row.hasChildren) { - - } @else { - - } - - {{ kind(row.node) }} - {{ label(row.node) }} - @if (row.node.injector.inactive) { - inactive - } - @if (row.node.injector.component) { - {{ row.node.injector.component }} - } @else if (row.node.injector.directives?.length) { - {{ row.node.injector.directives!.join(', ') }} - } - - @if ((row.node.dependencies?.length ?? 0) > 0) { - - - injects - {{ row.node.dependencies!.length }} - - } - @if (providerCount(row.node); as provided) { - - - provides - {{ provided }} - - } - -
    - } -
    - - - @if (gone(); as name) { -
    -
    -

    {{ name }}

    -
    -
    -

    - This injector is no longer on the page. Its component or directive was destroyed. -

    - @if (rows()[0]; as first) { - - } -
    -
    - } @else if (selected(); as sel) { -
    -
    - {{ kind(sel) }} -

    {{ label(sel) }}

    - @if (sel.injector.directives?.length) { -
      - @for (name of sel.injector.directives; track name) { -
    • {{ name }}
    • - } -
    - } - @if (canShowComponent() && sel.injector.componentId; as componentId) { - - } -
    - - @if (sel.injector.inactive) { -

    - Inactive: the router loaded this route's injector, but no element on the page uses - it now. It keeps the services it created until the app is reloaded. -

    - } -
    -

    Lookup path

    -

    Angular asks these injectors in order until one has the token.

    -
      - @for (step of path(); track step.id; let first = $first) { -
    1. - @if (step.id === nullId) { - - - Null injector - throws NullInjectorError - - } @else { - - } -
    2. - } -
    -
    - - @if (sel.injector.type === 'element' || sel.dependencies) { -
    -

    - {{ - sel.injector.type === 'element' ? 'Injected here' : 'Injected by its services' - }} - {{ sel.dependencies?.length ?? 0 }} -

    - @if (sel.injector.type !== 'element') { -

    Only services this injector has already created are listed.

    - } - @if (sel.dependencies?.length) { -
      - @for ( - dep of sel.dependencies; - track dep.from + (dep.tokenId ?? dep.token) + $index - ) { -
    • -
      - {{ dep.token }} - @for (flag of dep.flags; track flag) { - {{ flag }} - } -
      -
      - @if ( - sel.injector.type !== 'element' || - (sel.injector.directives && sel.injector.directives.length > 1) - ) { - for {{ dep.from }} - } - @if (dep.providedBy; as by) { - - } @else if (dep.flags.includes('optional')) { - optional, not provided - } @else { - not provided anywhere - } - -
      -
      - @if (resolvingKey() === depKey(dep, $index)) { -
        - @for (step of resolution(dep); track step.id + $index) { -
      1. - - @if (step.id === nullId) { - Null injector - } @else { - - } - {{ step.text }} -
      2. - } -
      - } -
      -
    • - } -
    - } @else if (sel.injector.type === 'element') { -

    Nothing is injected through the constructor or inject().

    - } @else { -

    No service created here has injected anything yet.

    - } -
    - } - -
    -

    - Provides {{ providerCount(sel) }} -

    - @if (hiddenProviders(sel); as hidden) { -

    - {{ hidden }} framework {{ hidden === 1 ? 'provider' : 'providers' }} hidden. - -

    - } - @if (providerCount(sel)) { -
      - @for ( - p of sel.providers; - track (p.tokenId ?? p.token) + $index; - let index = $index - ) { - @if (!isHidden(p)) { -
    • -
      - {{ p.token }} - {{ - p.type === 'unknown' ? 'provider' : 'use' + capital(p.type) - }} - @if (p.isViewProvider) { - viewProviders - } - @if (p.multi) { - multi - } - @if (p.framework) { - Angular - } -
      - @if (p.importPath?.length) { -
      via {{ p.importPath!.join(' › ') }}
      - } - @if (sourceOf(p.token); as places) { -
      - Declared in - @for (place of places.shown; track place.file + place.line) { - - } - @if (places.more) { - +{{ places.more }} more - } -
      - } - - -
    • - } - } -
    - } @else if (!hiddenProviders(sel)) { -

    - @if (sel.injector.type === 'element') { - No providers or viewProviders on this element. - } @else { - This injector has no providers of its own. - } -

    - } -
    -
    - } -
    - - } @else if (!loaded()) { -
    - -

    Loading dependency injection data…

    -
    - } @else if (loadFailed() && sourceProviders().length === 0) { - - } @else if (sourceProviders().length === 0) { - @if (pageOptions().length > 1) { -
    - -
    - } -
    -

    No DI data found

    -

    - Open the app in a browser with the devtools connected to see its live injectors. No - providers, injectables or inject() calls were found in the source either. -

    -
    - } @else { -
    - - - - {{ sourceMatchCount() }} of {{ sourceProviders().length }} - -
    - - @if (loadFailed()) { - - } -

    - - - DI from the source scan. - Connect the overlay to a development build to see the live injector tree. - -

    - - @if (groupedProviders().length === 0) { -
    -

    No providers match “{{ query() }}”

    -

    Try part of a token name or a file path.

    - -
    - } @else { -
    - @for (group of groupedProviders(); track group.type) { -
    - @if (group.type === helperType) { -

    - -

    -

    - provide*() calls the scan can't resolve to the tokens they register, such as your - own provider functions. -

    - } @else { -

    - {{ group.label }} {{ group.items.length }} -

    - } -
      - @for (p of group.items; track p.token + p.file + p.line) { -
    • -
      - {{ p.token }} - @if (p.providedIn) { - providedIn: {{ p.providedIn }} - } - @if (p.type === helperType) { - helper (unresolved) - } -
      -
      - - @if ( - p.source !== 'class' && - p.source !== 'providers array' && - p.type !== helperType - ) { - as {{ p.source }} - } -
      -
    • - } -
    -
    - } -
    - } - } - `, - styles: ` - @use 'mixins' as m; - - :host { - --directive: #7cb4ff; - display: block; - color: var(--text); - font-size: 13px; - - @include m.light { - --directive: #1e40af; - } - } - .mono { - font-family: var(--font-mono); - } - .intro { - max-width: 720px; - margin: 0 0 12px; - color: var(--text-2); - line-height: 1.5; - } - .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); - -webkit-backdrop-filter: blur(10px); - border: 1px solid var(--border); - border-radius: var(--radius); - } - .page-picker { - display: flex; - flex: 0 1 240px; - align-items: center; - gap: 8px; - min-width: 0; - } - .page-picker app-select { - flex: 1 1 auto; - min-width: 0; - } - .label-key { - flex: none; - color: var(--text-2); - font-size: 12px; - } - .segmented { - display: inline-flex; - flex: none; - height: var(--control-h); - padding: 3px; - gap: 2px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--bg); - } - .segmented button { - display: inline-flex; - align-items: center; - gap: 6px; - height: 100%; - padding: 0 10px; - border: 0; - border-radius: 6px; - background: none; - color: var(--text-2); - font-size: 12px; - font-weight: 600; - cursor: pointer; - transition: - background-color 150ms var(--ease), - color 150ms var(--ease); - } - .segmented button:hover { - color: var(--text); - } - .segmented button.active { - background: var(--surface-3); - color: var(--text-strong); - } - .segmented button:focus-visible { - @include m.focus-ring(-2px); - } - .pill { - display: inline-flex; - align-items: center; - min-width: 18px; - height: 16px; - padding: 0 5px; - justify-content: center; - border-radius: 99px; - background: var(--surface-3); - color: var(--text-2); - font-size: 10px; - font-weight: 600; - letter-spacing: 0; - font-variant-numeric: tabular-nums; - } - .segmented .active .pill { - background: var(--accent-soft); - color: var(--accent); - } - .search { - position: relative; - flex: 1 1 220px; - 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; - } - .search:focus-within .search-icon { - stroke: var(--accent); - } - input[type='search'] { - width: 100%; - height: 34px; - padding: 0 12px 0 34px; - background: var(--bg); - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - color: var(--text); - font-size: 13px; - transition: - border-color 150ms var(--ease), - box-shadow 150ms var(--ease); - } - input[type='search']::placeholder { - color: var(--text-3); - } - input[type='search']:focus-visible { - @include m.field-focus; - } - .checkbox { - display: flex; - align-items: center; - gap: 8px; - height: 34px; - color: var(--text-2); - white-space: nowrap; - cursor: pointer; - } - .checkbox:hover { - color: var(--text); - } - .checkbox input { - width: 15px; - height: 15px; - margin: 0; - accent-color: var(--accent); - cursor: pointer; - } - .checkbox input:focus-visible { - @include m.focus-ring; - } - .total { - flex: none; - color: var(--text-2); - font-size: 12px; - white-space: nowrap; - } - .where { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - margin: 0 0 12px; - padding: 8px 10px; - border: 1px solid var(--accent-line); - border-radius: var(--radius-sm); - background: var(--accent-soft); - @include m.enter(0.2s); - } - .where.muted { - border-color: var(--border); - background: var(--surface); - color: var(--text-2); - } - .where-label { - @include m.label; - margin-right: 4px; - color: var(--accent); - } - .chip, - .from, - .step { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; - max-width: 100%; - height: 26px; - padding: 0 10px; - border: 1px solid var(--control-border); - border-radius: 99px; - background: var(--surface); - color: var(--text); - font-size: 12px; - cursor: pointer; - transition: - border-color 150ms var(--ease), - background-color 150ms var(--ease); - } - .chip:hover, - .from:hover, - .step:hover { - border-color: var(--accent-line); - background: var(--surface-2); - } - .chip:focus-visible, - .from:focus-visible, - .step:focus-visible, - .link:focus-visible { - @include m.focus-ring; - } - .chip-tokens { - color: var(--text-2); - @include m.truncate; - } - .more { - color: var(--text-2); - font-size: 12px; - } - .link { - padding: 0; - border: 0; - background: none; - color: var(--accent); - font-size: 13px; - cursor: pointer; - text-decoration: underline; - text-underline-offset: 2px; - } - .dot { - flex: none; - width: 7px; - height: 7px; - border-radius: 50%; - } - .layout { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 12px; - align-items: start; - } - .layout > app-splitter { - display: none; - } - @media (min-width: 880px) { - .layout { - grid-template-columns: minmax(0, 1fr) auto min(var(--detail-size, 420px), 70%); - } - .layout > * { - grid-column: 1; - grid-row: 1; - } - .layout > app-splitter { - display: block; - grid-column: 2; - } - .layout > .detail { - grid-column: 3; - } - .detail { - position: sticky; - top: 64px; - max-height: calc(100vh - 150px); - overflow: auto; - } - } - .tree { - min-width: 0; - padding: 6px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - @include m.enter; - } - .row { - position: relative; - display: flex; - align-items: center; - gap: 6px; - min-height: 32px; - padding: 0 8px 0 calc(4px + var(--depth, 0) * 18px); - border-radius: var(--radius-sm); - cursor: pointer; - transition: background-color 120ms var(--ease); - } - .row::before { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 14px; - width: calc(var(--depth, 0) * 18px); - background: repeating-linear-gradient(to right, var(--border) 0 1px, transparent 1px 18px); - pointer-events: none; - } - .row:hover { - background: var(--surface-2); - } - .row:focus-visible { - @include m.focus-ring(-2px); - } - .row.selected { - background: var(--accent-soft); - box-shadow: inset 2px 0 0 var(--accent); - } - .twisty, - .twisty-space { - flex: none; - width: 20px; - height: 20px; - } - .twisty { - display: grid; - place-items: center; - padding: 0; - border: 0; - border-radius: 4px; - background: none; - color: var(--text-3); - cursor: pointer; - } - .twisty:hover { - color: var(--text); - background: var(--surface-3); - } - .twisty svg { - width: 14px; - height: 14px; - fill: none; - stroke: currentColor; - stroke-width: 2.2; - stroke-linecap: round; - stroke-linejoin: round; - transition: transform 150ms var(--ease); - } - .twisty.open svg { - transform: rotate(90deg); - } - .kind { - flex: none; - display: grid; - place-items: center; - width: 18px; - height: 18px; - border-radius: 5px; - background: color-mix(in srgb, var(--tone) 16%, transparent); - color: var(--tone); - font-size: 10px; - font-weight: 700; - } - .name { - min-width: 0; - color: var(--text); - font-size: 12px; - @include m.truncate; - } - .row.selected .name { - color: var(--text-strong); - font-weight: 600; - } - .sub { - flex: 0 1 auto; - min-width: 0; - color: var(--text-3); - font-size: 12px; - @include m.truncate; - } - .meta { - display: inline-flex; - flex: none; - gap: 4px; - margin-left: auto; - padding-left: 8px; - } - .count { - display: inline-flex; - align-items: center; - gap: 3px; - height: 20px; - padding: 0 7px; - border-radius: 99px; - background: var(--surface-3); - color: var(--text-2); - font-size: 11px; - } - .count.provides { - @include m.tint(ok); - } - .count svg { - width: 11px; - height: 11px; - fill: none; - stroke: currentColor; - stroke-width: 2.2; - stroke-linecap: round; - stroke-linejoin: round; - } - .detail { - min-width: 0; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - @include m.enter; - } - .detail-head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 10px; - padding: 14px 16px; - border-bottom: 1px solid var(--border); - } - .detail-head h2 { - flex: 1 1 auto; - min-width: 0; - margin: 0; - color: var(--text-strong); - font-size: 15px; - font-weight: 600; - overflow-wrap: anywhere; - } - .badge { - padding: 2px 8px; - border-radius: 99px; - background: color-mix(in srgb, var(--tone) 14%, transparent); - color: var(--tone); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - } - .classes { - display: flex; - flex-wrap: wrap; - gap: 4px; - width: 100%; - margin: 0; - padding: 0; - list-style: none; - } - .classes li { - padding: 1px 8px; - border: 1px solid var(--border-strong); - border-radius: 6px; - color: var(--text-2); - font-family: var(--font-mono); - font-size: 11px; - } - .classes li.is-component { - border-color: var(--accent-line); - color: var(--accent); - } - .block { - padding: 14px 16px; - border-bottom: 1px solid var(--border); - } - .block:last-child { - border-bottom: 0; - } - h3 { - @include m.label; - display: flex; - align-items: center; - gap: 6px; - margin: 0 0 8px; - } - .hint { - margin: -4px 0 10px; - color: var(--text-3); - font-size: 12px; - } - .path { - position: relative; - display: grid; - gap: 6px; - margin: 0; - padding: 0 0 0 18px; - list-style: none; - } - .path::before { - content: ''; - position: absolute; - top: 13px; - bottom: 13px; - left: 5px; - width: 1px; - background: var(--border-strong); - } - .path li { - position: relative; - min-width: 0; - } - .path li::before { - content: ''; - position: absolute; - top: 12px; - left: -16px; - width: 9px; - height: 1px; - background: var(--border-strong); - } - .path li.current .step { - border-color: var(--accent); - background: var(--accent-soft); - } - .step.null { - cursor: default; - color: var(--text-2); - } - .step.null:hover { - border-color: var(--border-strong); - background: var(--surface); - } - .hint-inline { - color: var(--text-3); - } - .step-count { - color: var(--text-3); - font-size: 11px; - } - .deps, - .providers { - display: grid; - gap: 6px; - margin: 0; - padding: 0; - list-style: none; - } - .dep, - .provider { - min-width: 0; - padding: 8px 10px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--bg); - } - .dep.hit, - .provider.hit { - border-color: var(--accent-line); - box-shadow: 0 0 0 3px var(--accent-soft); - } - .provider-global { - margin-top: 6px; - } - .show-component { - display: inline-flex; - align-items: center; - height: var(--control-h); - padding: 0 14px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--surface-2); - color: var(--text); - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: - background-color 150ms var(--ease), - border-color 150ms var(--ease); - } - .show-component:hover { - background: var(--surface-3); - border-color: var(--accent-line); - } - .show-component:focus-visible { - @include m.focus-ring; - } - .framework-note { - margin: 0 0 10px; - color: var(--text-2); - } - .dep-main { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - } - .token { - min-width: 0; - color: var(--text-strong); - font-size: 12px; - font-weight: 600; - overflow-wrap: anywhere; - } - .flag { - padding: 0 6px; - border: 1px solid var(--border-strong); - border-radius: 5px; - color: var(--text-2); - font-family: var(--font-mono); - font-size: 10px; - line-height: 16px; - } - .kind-flag { - @include m.tint(accent); - } - .dep-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px 10px; - margin-top: 6px; - color: var(--text-3); - font-size: 12px; - overflow-wrap: anywhere; - } - .from { - height: 22px; - padding: 0 8px; - color: var(--text-2); - } - .from .mono { - color: var(--text); - } - .from.absent { - cursor: default; - background: none; - } - .from.missing { - cursor: default; - border-color: color-mix(in srgb, var(--warn) 40%, transparent); - background: none; - color: var(--warn); - } - .empty-line { - margin: 0; - color: var(--text-2); - } - .state { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - padding: 40px 16px; - text-align: center; - background: var(--surface); - border: 1px dashed var(--border-strong); - border-radius: var(--radius); - @include m.enter; - } - .state.compact { - padding: 24px 12px; - border: 0; - } - .state-title { - margin: 0; - color: var(--text-strong); - font-size: 14px; - font-weight: 600; - } - .state-hint { - max-width: 440px; - margin: 0; - color: var(--text-2); - line-height: 1.5; - } - .spinner { - width: 20px; - height: 20px; - border: 2px solid var(--border-strong); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - } - @media (prefers-reduced-motion: reduce) { - .spinner { - animation: none; - } - } - button { - font: inherit; - } - .state button, - .provider-group button { - height: 34px; - padding: 0 14px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--surface-2); - color: var(--text); - cursor: pointer; - } - .load-error.compact { - margin: 0 0 16px; - border: 1px solid var(--border); - background: var(--surface); - } - .state-title + .state-hint + button { - margin-top: 8px; - } - .state button:focus-visible { - @include m.focus-ring; - } - .gone .state { - align-items: flex-start; - text-align: left; - } - .notice { - display: flex; - align-items: flex-start; - gap: 8px; - margin: 0 0 16px; - padding: 12px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--surface); - color: var(--text-2); - font-size: 13px; - line-height: 1.5; - } - .notice strong { - color: var(--text); - font-weight: 600; - } - .notice svg { - flex: none; - width: 16px; - height: 16px; - margin-top: 2px; - fill: none; - stroke: var(--accent); - stroke-width: 2; - stroke-linecap: round; - } - .resolve-toggle { - font-size: 12px; - } - .resolution[hidden] { - display: none; - } - .resolve-graph { - position: relative; - display: flex; - flex-direction: column; - gap: 6px; - margin: 8px 0 0; - padding: 0 0 0 4px; - list-style: none; - } - .resolve-graph li { - position: relative; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px 8px; - min-width: 0; - padding-left: 18px; - font-size: 12px; - } - .resolve-graph li::before { - content: ''; - position: absolute; - left: 4px; - top: -6px; - bottom: 50%; - width: 2px; - background: var(--border-strong); - } - .resolve-graph li:first-child::before { - display: none; - } - .resolve-dot { - position: absolute; - left: 0; - width: 10px; - height: 10px; - border: 2px solid var(--border-strong); - border-radius: 50%; - background: var(--surface); - } - .resolve-provides .resolve-dot { - border-color: var(--ok); - background: var(--ok); - } - .resolve-throws .resolve-dot { - border-color: var(--danger); - background: var(--danger); - } - .resolve-state { - color: var(--text-2); - } - .resolve-provides .resolve-state { - color: var(--ok-text); - font-weight: 600; - } - .resolve-throws .resolve-state { - color: var(--danger-text); - } - .resolve-not-asked .resolve-state, - .resolve-skipped .resolve-state { - color: var(--text-3); - } - .declared { - color: var(--text-3); - } - .decl-file { - min-width: 0; - font-family: var(--font-mono); - font-size: 11px; - overflow-wrap: anywhere; - } - .refresh { - display: inline-flex; - align-items: center; - gap: 6px; - height: var(--control-h); - margin-left: auto; - padding: 0 12px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--surface-2); - color: var(--text); - font-size: 12px; - cursor: pointer; - transition: - background-color 150ms var(--ease), - border-color 150ms var(--ease); - } - .refresh:hover { - background: var(--surface-3); - border-color: var(--accent-line); - } - .refresh[aria-disabled='true'] { - cursor: default; - opacity: 0.6; - } - .refresh:focus-visible { - @include m.focus-ring; - } - .refresh svg, - .chevron { - flex: none; - width: 14px; - height: 14px; - fill: none; - stroke: currentColor; - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; - } - .chevron { - transition: transform 150ms var(--ease); - } - .chevron.open { - transform: rotate(90deg); - } - @media (prefers-reduced-motion: reduce) { - .chevron { - transition: none; - } - } - .inactive-flag { - flex: none; - } - .inactive-note { - margin: 0 0 12px; - } - .orphans { - margin: 0 0 12px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--surface); - } - .orphans-toggle, - .provider-group .group-toggle { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - min-height: var(--control-h); - padding: 0 12px; - border: 0; - border-radius: var(--radius-sm); - background: none; - color: var(--text-2); - font-size: 12px; - text-align: left; - cursor: pointer; - } - .provider-group .group-toggle { - width: auto; - height: auto; - min-height: 0; - padding: 2px 4px; - margin-left: -4px; - color: inherit; - font: inherit; - letter-spacing: inherit; - text-transform: inherit; - } - .orphans-toggle:hover, - .provider-group .group-toggle:hover { - color: var(--text); - } - .orphans-toggle:focus-visible, - .provider-group .group-toggle:focus-visible { - @include m.focus-ring(-2px); - } - .hint-tag { - padding: 0 6px; - border-radius: 5px; - @include m.tint(accent); - font-size: 10px; - line-height: 16px; - } - .orphans-body { - padding: 0 12px 12px; - } - .orphan-list { - display: flex; - flex-direction: column; - gap: 4px; - margin: 0; - padding: 0; - list-style: none; - } - .orphan-list li { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px 8px; - min-width: 0; - font-size: 12px; - } - .orphans-body .hint { - margin: 0 0 8px; - } - .orphan-list .orphan-file { - min-width: 0; - color: var(--text-3); - font-family: var(--font-mono); - font-size: 11px; - overflow-wrap: anywhere; - } - .helper-hint { - margin: 0 0 8px; - } - .provider-list[hidden], - .orphans-body[hidden], - .helper-hint[hidden] { - display: none; - } - .source-providers { - display: flex; - flex-direction: column; - gap: 24px; - @include m.enter; - } - .provider-group h2 { - @include m.label; - display: flex; - align-items: center; - gap: 8px; - margin: 0 0 8px; - } - .section-count { - padding: 0 6px; - border-radius: 99px; - background: var(--surface-3); - color: var(--text-2); - font-size: 10px; - letter-spacing: 0; - line-height: 16px; - font-variant-numeric: tabular-nums; - } - .provider-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 6px; - } - .provider-card { - min-width: 0; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 8px 12px; - transition: - background-color 150ms var(--ease), - border-color 150ms var(--ease); - } - .provider-card:hover { - background: var(--surface-2); - border-color: var(--border-strong); - } - .provider-header { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 4px 8px; - } - .provider-header .token { - font-size: 13px; - font-weight: 600; - } - .provided-in { - font-size: 11px; - padding: 1px 8px; - border-radius: 99px; - @include m.tint(ok); - border-width: 1px; - border-style: solid; - } - .provider-meta { - display: flex; - flex-wrap: wrap; - gap: 0 8px; - margin-top: 4px; - font-family: var(--font-mono); - font-size: 11px; - color: var(--text-3); - } - .provider-meta .path { - min-width: 0; - overflow-wrap: anywhere; - } - .provider-meta .as { - color: var(--text-2); - } - `, + providers: [InjectorsState, SourceScan], + templateUrl: './di-inspector.html', + styleUrl: './di-inspector.scss', }) export class DiInspector { rpc = input(null); @@ -1854,437 +67,70 @@ export class DiInspector { readonly canShowComponent = input(false); readonly focusHandled = output(); readonly showComponent = output(); - private readonly injector = inject(Injector); - readonly nullId = NULL_ID; - private readonly hostPage = hostPageId(); + private readonly state = inject(InjectorsState); + private readonly scan = inject(SourceScan); + private readonly angularPages = injectAngularPages(() => this.rpc()); /** The shown page's Angular version and debug API parts, for the unavailable notes. */ readonly angularPage = computed(() => - angularPageFor(this.angularPages(), this.shownPageId() || this.hostPage), + angularPageFor(this.angularPages(), this.shownPageId() || this.state.hostPage), ); - private readonly treeState = signal(null); - private readonly pageTitles = signal>({}); - readonly chosenPageId = signal(null); - - // Older servers send only the newest tree, without the per-page map. - private readonly treePages = computed>(() => { - const state = this.treeState(); - if (!state) return {}; - const pages = state.pages && typeof state.pages === 'object' ? state.pages : {}; - if (Object.keys(pages).length || this.hostPage) return pages; - return { '': state }; - }); - - // A page that reports later never takes over from the one shown. - readonly shownPageId = linkedSignal< - { pages: Record; chosen: string | null }, - string | null - >({ - source: () => ({ pages: this.treePages(), chosen: this.chosenPageId() }), - computation: ({ pages, chosen }, previous) => { - const reported: Record = {}; - for (const [pageId, page] of Object.entries(pages)) { - const at = typeof page?.reportedAt === 'number' ? page.reportedAt : 0; - reported[pageId] = { pageId, reportedAt: at }; - } - return pickPage(reported, { - chosen, - host: this.hostPage, - previous: previous?.value ?? null, - }); - }, - }); - - private readonly shownPage = computed(() => { - const pageId = this.shownPageId(); - return pageId === null ? null : (this.treePages()[pageId] ?? null); - }); - readonly roots = computed(() => { - const roots = this.shownPage()?.roots; - return isTree(roots) ? roots : []; - }); - readonly environment = computed(() => { - const environment = this.shownPage()?.environment; - return isTree(environment) ? environment : []; - }); - readonly truncated = computed(() => this.shownPage()?.truncated === true); - /** The caps the server names with `truncated`, or the ones it has always used. */ - readonly limits = computed(() => { - const limits = this.shownPage()?.limits as Partial | undefined; - const whole = (n: unknown, fallback: number) => - typeof n === 'number' && Number.isInteger(n) && n > 0 ? n : fallback; - return { - elements: whole(limits?.elements, 2000), - injectors: whole(limits?.injectors, 2500), - depth: whole(limits?.depth, 1000), - }; - }); - readonly zone = computed(() => { - const zone = this.shownPage()?.zone; - return typeof zone === 'string' ? zoneLabel(zone) : null; - }); - private readonly multiPage = computed( - () => !this.hostPage && Object.keys(this.treePages()).length > 1, - ); - readonly pageOptions = computed(() => { - if (!this.multiPage()) return []; - const titles = this.pageTitles(); - return Object.keys(this.treePages()).map((pageId) => { - const page = titles[pageId]; - return { - value: pageId, - label: page?.title || page?.url || `Page ${pageId}`, - hint: page?.url, - }; - }); - }); - - /** Injector ids belong to one page, so each page keeps its own selection and expansion. */ - private readonly pageKey = computed(() => this.shownPageId() ?? ''); - private perPage(initial: () => T) { - const saved = new Map(); - return linkedSignal({ - source: this.pageKey, - computation: (key, previous) => { - if (previous) saved.set(previous.source, previous.value); - return saved.has(key) ? (saved.get(key) as T) : initial(); - }, - }); - } - - readonly sourceProviders = signal([]); - readonly loaded = signal(false); - /** The live injector tree or the source scan could not be read from the server. */ - private readonly treeFailed = signal(false); - private readonly sourceFailed = signal(false); - readonly loadFailed = computed(() => this.treeFailed() || this.sourceFailed()); - readonly retrying = signal(false); - private readonly host = inject>(ElementRef); - readonly view = signal('element'); - readonly query = signal(''); + readonly chosenPageId = this.state.chosenPageId; + readonly shownPageId = this.state.shownPageId; + readonly roots = this.state.roots; + readonly environment = this.state.environment; + readonly truncated = this.state.truncated; + readonly limits = this.state.limits; + readonly zone = this.state.zone; + readonly pageOptions = this.state.pageOptions; + readonly sourceProviders = this.state.sourceProviders; + readonly loaded = this.state.loaded; + readonly loadFailed = this.state.loadFailed; + readonly retrying = this.state.retrying; + readonly view = this.state.view; + readonly query = this.state.query; /** The detail width on wide panels, set by the splitter and saved per view. */ readonly detailSize = signal(storedPaneSize('injectors', 420)); private readonly queryInput = viewChild('queryInput'); - readonly onlyProviding = signal(false); - readonly componentsOnly = signal(true); - /** Hides Angular's own injectors and providers (marked `framework` by the server). */ - readonly hideFramework = signal(storedFlag(HIDE_FRAMEWORK_PREF) ?? false); - readonly collapsed = this.perPage>(() => new Set()); - readonly selectedId = this.perPage(() => null); - readonly focusId = this.perPage(() => null); - readonly announcement = signal(''); - private readonly selectedLabel = this.perPage(() => null); - private wasGone = false; - - private readonly index = computed(() => { - const map = new Map(); - const parents = new Map(); - const walk = (nodes: InjectorNode[], parent: string | null) => { - for (const node of nodes) { - map.set(node.injector.id, node); - if (parent) parents.set(node.injector.id, parent); - walk(node.children, node.injector.id); - } - }; - walk(this.roots(), null); - walk(this.environment(), null); - const byComponent = new Map(); - for (const node of map.values()) { - const componentId = node.injector.componentId; - if (componentId && node.injector.type === 'element') - byComponent.set(componentId, node.injector.id); - } - return { map, parents, byComponent }; - }); - - /** The element and environment trees without framework injectors, while they are hidden. */ - private readonly shownRoots = computed(() => this.withoutFramework(this.roots())); - private readonly shownEnvironment = computed(() => this.withoutFramework(this.environment())); - - readonly elementCount = computed(() => this.count(this.shownRoots())); - readonly environmentCount = computed(() => this.count(this.shownEnvironment())); - - /** What "Hide framework" hides in the current view: injectors, and providers on the rest. */ - readonly frameworkHiddenCount = computed(() => { - const all = this.view() === 'element' ? this.roots() : this.environment(); - let injectors = 0; - let providers = 0; - const walk = (nodes: InjectorNode[]) => { - for (const node of nodes) { - if (node.injector.framework) injectors++; - else providers += node.providers.filter((p) => p.framework).length; - walk(node.children); - } - }; - walk(all); - return { injectors, providers }; - }); - readonly frameworkHiddenText = computed(() => { - const { injectors, providers } = this.frameworkHiddenCount(); - const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`; - if (!injectors && !providers) return 'Nothing hidden'; - if (!providers) return `${plural(injectors, 'injector')} hidden`; - if (!injectors) return `${plural(providers, 'provider')} hidden`; - return `${plural(injectors, 'injector')} and ${plural(providers, 'provider')} hidden`; - }); - - private readonly visible = computed(() => { - let nodes = this.view() === 'element' ? this.shownRoots() : this.shownEnvironment(); - if (this.view() === 'element' && this.componentsOnly()) { - nodes = this.prune(nodes, (n) => !!n.injector.component); - } - if (this.onlyProviding()) nodes = this.prune(nodes, (n) => this.providerCount(n) > 0); - const q = this.normalizedQuery(); - if (q) nodes = this.prune(nodes, (n) => this.nodeMatches(n, q)); - return nodes; - }); - - readonly rows = computed(() => { - const rows: Row[] = []; - const collapsed = this.collapsed(); - const searching = !!this.normalizedQuery(); - const walk = (nodes: InjectorNode[], depth: number) => { - for (const node of nodes) { - const expanded = searching || !collapsed.has(node.injector.id); - rows.push({ node, depth, hasChildren: node.children.length > 0, expanded }); - if (expanded) walk(node.children, depth + 1); - } - }; - walk(this.visible(), 0); - return rows; - }); - - /** Falls back to the first row only while nothing is chosen, never when the choice is gone. */ - readonly selected = computed(() => { - const id = this.selectedId(); - if (!id) return this.rows()[0]?.node ?? null; - return this.index().map.get(id) ?? null; - }); - - /** The label of the chosen injector once it has left the page. */ - readonly gone = computed(() => { - const id = this.selectedId(); - return id && !this.index().map.has(id) ? (this.selectedLabel() ?? 'The injector') : null; - }); - - readonly rovingId = computed(() => { - const rows = this.rows(); - const want = this.focusId() ?? this.selected()?.injector.id; - if (want && rows.some((r) => r.node.injector.id === want)) return want; - return rows[0]?.node.injector.id ?? null; - }); - - readonly path = computed(() => { - const sel = this.selected(); - if (!sel) return []; - const { map, parents } = this.index(); - let ids = sel.injector.path ?? []; - if (!ids.length) { - ids = [sel.injector.id]; - let parent = parents.get(sel.injector.id); - while (parent) { - ids.push(parent); - parent = parents.get(parent); - } - ids.push(NULL_ID); - } - return ids.map((id) => { - const node = map.get(id); - return { - id, - label: node ? labelOf(node) : id === NULL_ID ? 'Null injector' : 'Not listed', - kind: node ? kindOf(node) : 'null', - providers: node ? this.providerCount(node) : 0, - }; - }); - }); - - /** Every injector that provides a token matching the search. */ - private readonly providerHits = computed(() => { - const q = this.normalizedQuery(); - if (!q) return []; - const hits: { id: string; label: string; kind: string; tokens: string }[] = []; - for (const node of this.index().map.values()) { - if (this.isHiddenInjector(node)) continue; - const tokens = node.providers.filter( - (p) => !this.isHidden(p) && p.token.toLowerCase().includes(q), - ); - if (!tokens.length) continue; - hits.push({ - id: node.injector.id, - label: labelOf(node), - kind: kindOf(node), - tokens: tokens.map((t) => t.token).join(', '), - }); - } - return hits; - }); - readonly providedBy = computed(() => this.providerHits().slice(0, MAX_PROVIDED_BY)); - readonly providedByMore = computed(() => - Math.max(0, this.providerHits().length - MAX_PROVIDED_BY), - ); - - readonly groupedProviders = computed(() => { - const q = this.normalizedQuery(); - const filtered = q - ? this.sourceProviders().filter( - (p) => p.token.toLowerCase().includes(q) || p.file.toLowerCase().includes(q), - ) - : this.sourceProviders(); - const groups: { type: string; label: string; items: SourceProvider[] }[] = [ - { type: 'root-provider', label: 'Root Providers (provide*)', items: [] }, - { type: 'injectable', label: 'Injectable Services', items: [] }, - { type: 'injection', label: 'inject() Calls', items: [] }, - { type: 'provider', label: 'Component Providers', items: [] }, - { type: HELPER_TYPE, label: 'Helpers (unresolved)', items: [] }, - ]; - for (const p of filtered) groups.find((g) => g.type === p.type)?.items.push(p); - return groups.filter((g) => g.items.length > 0); - }); - - readonly sourceMatchCount = computed(() => - this.groupedProviders().reduce((sum, g) => sum + g.items.length, 0), - ); - - readonly helperType = HELPER_TYPE; - /** The unresolved provide*() helpers start collapsed; a search shows them. */ - readonly helpersOpen = signal(false); - readonly helpersShown = computed(() => this.helpersOpen() || !!this.normalizedQuery()); - - /** - * Providers the source declares (`providers` entries and injectables) whose - * token nothing on the shown page injected: no element injector and no - * created service lists it among its dependencies. A `providedIn` service - * that an environment injector already created counts as injected. Matched - * by name, so this is only a hint. - */ - readonly orphaned = computed(() => { - const roots = this.roots(); - if (!roots.length) return []; - const injected = new Set(); - const created = new Set(); - for (const node of this.index().map.values()) { - for (const dep of node.dependencies ?? []) injected.add(dep.token); - if (node.injector.type !== 'element') { - for (const p of node.providers) created.add(p.token); - } - } - const out: SourceProvider[] = []; - const seen = new Set(); - for (const p of this.sourceProviders()) { - if (!PROVIDING_TYPES.has(p.type)) continue; - // `Feature.Token` in source is the token `Token` on the page. - const name = p.token.slice(p.token.lastIndexOf('.') + 1); - if (injected.has(name) || (p.providedIn && created.has(name))) continue; - const key = `${p.token}|${p.file}|${p.line}`; - if (seen.has(key)) continue; - seen.add(key); - out.push(p); - } - return out; - }); - readonly orphansOpen = signal(false); - - /** - * Where the source scan declares each token: `providers` entries, injectable - * classes and known provide*() functions, by token name (the last part of - * `Feature.Token`). Joins the live Provides list to the source. - */ - private readonly sourceByToken = computed(() => { - const map = new Map(); - for (const p of this.sourceProviders()) { - if (!PROVIDING_TYPES.has(p.type) && p.type !== 'root-provider') continue; - const name = p.token.slice(p.token.lastIndexOf('.') + 1); - let list = map.get(name); - if (!list) map.set(name, (list = [])); - if (!list.some((q) => q.file === p.file && q.line === p.line)) list.push(p); - } - return map; - }); - - /** Up to three source places that declare `token`, and how many more. */ - sourceOf(token: string): { shown: SourceProvider[]; more: number } | null { - const list = this.sourceByToken().get(token); - if (!list?.length) return null; - return { shown: list.slice(0, 3), more: Math.max(0, list.length - 3) }; - } - - /** The dependency whose resolution is open in the details. */ - readonly resolvingKey = signal(null); - - depKey(dep: { from: string; token: string; tokenId?: string }, index: number): string { - return `${index}|${dep.from}|${dep.tokenId ?? dep.token}`; - } - - toggleResolve(key: string) { - this.resolvingKey.update((open) => (open === key ? null : key)); - } - - /** - * The lookup path of the selected injector, read for one dependency: the - * steps Angular asked before the injector that provides the token, that - * injector, and the steps it never reached. `self` stops after the first - * step, `skipSelf` skips it, and a token nobody provides goes on to the null - * injector, which throws unless the dependency is optional. - */ - resolution(dep: { flags: string[]; providedBy: string | null }) { - const steps = this.path(); - const at = dep.providedBy ? steps.findIndex((step) => step.id === dep.providedBy) : -1; - const self = dep.flags.includes('self'); - const skipSelf = dep.flags.includes('skipSelf'); - const optional = dep.flags.includes('optional'); - return steps.map((step, index) => { - let state: 'asked' | 'provides' | 'skipped' | 'not-asked' | 'throws' | 'null'; - if (index === 0 && skipSelf) state = 'skipped'; - else if (at >= 0) state = index < at ? 'asked' : index === at ? 'provides' : 'not-asked'; - else if (self && index > (skipSelf ? 1 : 0)) state = 'not-asked'; - else if (step.id === NULL_ID) state = optional ? 'null' : 'throws'; - else state = 'asked'; - const text = { - asked: 'asked, not here', - provides: 'provides it', - skipped: 'skipped (skipSelf)', - 'not-asked': 'not asked', - throws: 'throws NullInjectorError', - null: 'gives null (optional)', - }[state]; - return { ...step, state, text }; - }); - } - readonly orphanedText = computed(() => { - const count = new Set(this.orphaned().map((p) => p.token)).size; - return count === 1 - ? '1 provider in source is never injected on this page' - : `${count} providers in source are never injected on this page`; - }); - - /** What a screen reader hears about the search results. */ - private readonly searchSummary = computed(() => { - const query = this.query().trim(); - if (!query) return ''; - const hits = this.providerHits().length; - if (hits === 1) return `1 injector provides “${query}”.`; - if (hits > 1) return `${hits} injectors provide “${query}”.`; - if (!this.rows().length) return `Nothing here matches “${query}”.`; - return `No injector provides “${query}”.`; - }); - - private readonly normalizedQuery = computed(() => this.query().trim().toLowerCase()); - - private readonly destroyRef = inject(DestroyRef); - private stopTree: (() => void) | null = null; - private treeLoad = 0; + readonly onlyProviding = this.state.onlyProviding; + readonly componentsOnly = this.state.componentsOnly; + readonly hideFramework = this.state.hideFramework; + readonly collapsed = this.state.collapsed; + readonly selectedId = this.state.selectedId; + readonly focusId = this.state.focusId; + readonly announcement = this.state.announcement; + readonly elementCount = this.state.elementCount; + readonly environmentCount = this.state.environmentCount; + readonly frameworkHiddenCount = this.state.frameworkHiddenCount; + readonly frameworkHiddenText = this.state.frameworkHiddenText; + readonly rows = this.state.rows; + readonly selected = this.state.selected; + readonly gone = this.state.gone; + readonly rovingId = this.state.rovingId; + readonly path = this.state.path; + readonly providedBy = this.state.providedBy; + readonly providedByMore = this.state.providedByMore; + readonly resolvingKey = this.state.resolvingKey; + + readonly groupedProviders = this.scan.groupedProviders; + readonly sourceMatchCount = this.scan.sourceMatchCount; + readonly helpersOpen = this.scan.helpersOpen; + readonly helpersShown = this.scan.helpersShown; + readonly orphaned = this.scan.orphaned; + readonly orphansOpen = this.scan.orphansOpen; + readonly orphanedText = this.scan.orphanedText; private lastSearchMessage = ''; /** The page and element injector the panel last asked the page to read again. */ private refreshedKey: string | null = null; constructor() { + this.state.connect(this.rpc); // One summary once typing pauses, instead of a live region that reads every keystroke. effect((onCleanup) => { - const message = this.searchSummary(); + const message = this.state.searchSummary(); if (!message) { this.lastSearchMessage = ''; return; @@ -2299,8 +145,8 @@ export class DiInspector { effect(() => { const client = this.rpc(); if (!client) return; - void this.loadInjectorTree(client); - void this.loadSourceProviders(client); + void this.state.loadInjectorTree(client); + void this.state.loadSourceProviders(client); }); // Element injectors are read once and cached on the page, so the selected // one is read again, once each time the selection moves to it. @@ -2311,12 +157,12 @@ export class DiInspector { const key = id && pageId ? `${pageId}|${id}` : null; if (!key || key === this.refreshedKey) return; this.refreshedKey = key; - untracked(() => this.requestRefresh(pageId!, id!)); + untracked(() => this.state.requestRefresh(pageId!, id!)); }); // Page titles come from the component tree, and are only needed for the Page picker. effect((onCleanup) => { const client = this.rpc(); - if (!client || !this.multiPage()) return; + if (!client || !this.state.multiPage()) return; let live = true; let stop: (() => void) | null = null; onCleanup(() => { @@ -2330,7 +176,7 @@ export class DiInspector { if (!live) return; const apply = (value: unknown) => { const pages = (value as { pages?: unknown } | null)?.pages; - this.pageTitles.set( + this.state.pageTitles.set( pages && typeof pages === 'object' ? (pages as Record) : {}, @@ -2345,17 +191,18 @@ export class DiInspector { effect(() => { const focus = this.focus(); if (!focus) return; - const pages = this.treePages(); + const pages = this.state.treePages(); const shown = this.shownPageId(); if (focus.pageId && shown !== focus.pageId && pages[focus.pageId]) { untracked(() => this.selectPage(focus.pageId!)); return; } - if (!this.treeState() && !this.treeFailed()) return; + if (!this.state.treeState() && !this.state.treeFailed()) return; untracked(() => { const onPage = !focus.pageId || shown === focus.pageId; - const id = onPage ? this.index().byComponent.get(focus.componentId) : undefined; - const node = id ? this.index().map.get(id) : undefined; + const index = this.state.index(); + const id = onPage ? index.byComponent.get(focus.componentId) : undefined; + const node = id ? index.map.get(id) : undefined; if (id && node) { this.reveal(id); this.announcement.set(`Showing the injector of ${labelOf(node)}.`); @@ -2365,16 +212,11 @@ export class DiInspector { this.focusHandled.emit(); }); }); - this.destroyRef.onDestroy(() => { - this.stopTree?.(); - this.highlight(null); - }); } /** Turns "Hide framework" on or off and remembers the choice for the next visit. */ setHideFramework(hide: boolean) { - this.hideFramework.set(hide); - storeFlag(HIDE_FRAMEWORK_PREF, hide); + this.state.setHideFramework(hide); } /** Opens the Components tab on the component that owns this element injector. */ @@ -2383,87 +225,13 @@ export class DiInspector { this.showComponent.emit(pageId ? { id: componentId, pageId } : { id: componentId }); } - /** Whether a provider is left out of the lists while framework providers are hidden. */ - /** - * `(2 of 3)` for a token that several shown providers share (multi - * providers), so their buttons and value regions get unique names; else ''. - */ - tokenPosition(providers: readonly ProviderInfo[], index: number): string { - const token = providers[index]?.token; - let at = 0; - let count = 0; - providers.forEach((p, i) => { - if (p.token !== token || this.isHidden(p)) return; - count++; - if (i <= index) at++; - }); - return count > 1 ? `(${at} of ${count})` : ''; - } - - positionSuffix(providers: readonly ProviderInfo[], index: number): string { - const position = this.tokenPosition(providers, index); - return position ? ` ${position}` : ''; - } - - isHidden(provider: ProviderInfo): boolean { - return this.hideFramework() && provider.framework === true; - } - - private isHiddenInjector(node: InjectorNode): boolean { - return this.hideFramework() && node.injector.framework === true; - } - - /** How many providers the node shows. */ - providerCount(node: InjectorNode): number { - if (!this.hideFramework()) return node.injector.providerCount; - const hidden = node.providers.filter((p) => p.framework).length; - return Math.max(0, node.injector.providerCount - hidden); - } - - hiddenProviders(node: InjectorNode): number { - return this.hideFramework() ? node.providers.filter((p) => p.framework).length : 0; - } - - /** Loads the injector tree and the source scan again. The alert stays until they answer, so - * focus stays on Retry while it is shown and moves to the toolbar when the alert goes, which - * also happens when live injectors arrive but the source scan still fails. */ - async retry() { - const client = this.rpc(); - if (!client || this.retrying()) return; - const trigger = document.activeElement; - this.retrying.set(true); - try { - await Promise.all([this.loadInjectorTree(client), this.loadSourceProviders(client)]); - } finally { - this.retrying.set(false); - } - if (this.destroyRef.destroyed) return; - afterNextRender( - () => { - if (trigger?.isConnected && document.activeElement !== document.body) return; - this.host.nativeElement - .querySelector('.toolbar input, .toolbar button') - ?.focus(); - }, - { injector: this.injector }, - ); + retry() { + return this.state.retry(); } /** Asks the shown page to read every injector again, element entries included. */ refresh() { - const pageId = this.shownPageId(); - if (!pageId) return; - this.requestRefresh(pageId); - this.announcement.set('Reading the injectors again.'); - } - - private requestRefresh(pageId: string, injectorId?: string) { - const client = this.rpc(); - if (!client) return; - void client - .scope('pangular') - .rpc.call('request-injector-refresh', injectorId ? { pageId, injectorId } : { pageId }) - .catch(() => {}); + this.state.refresh(); } /** Clears the search from a no-match message and keeps focus on the search box. */ @@ -2472,270 +240,35 @@ export class DiInspector { this.queryInput()?.focus(); } - label(node: InjectorNode) { - return labelOf(node); - } - - kind(node: InjectorNode) { - return kindOf(node); - } - tone(kind: string) { - return KIND_TONE[kind] ?? KIND_TONE['null']; - } - - kindById(id: string) { - const node = this.index().map.get(id); - return node ? kindOf(node) : 'null'; - } - - labelById(id: string) { - const node = this.index().map.get(id); - return node ? labelOf(node) : id === NULL_ID ? 'Null injector' : 'not listed'; - } - - capital(text: string) { - return text.charAt(0).toUpperCase() + text.slice(1); + return toneOf(kind); } - matches(token: string) { - const q = this.normalizedQuery(); - return !!q && token.toLowerCase().includes(q); + providerCount(node: InjectorNode): number { + return this.state.providerCount(node); } setView(view: TreeView) { - if (this.view() === view) return; - this.view.set(view); - this.choose(null); - this.focusId.set(null); + this.state.setView(view); } select(id: string) { - this.choose(id); - this.focusId.set(id); - } - - private choose(id: string | null) { - const node = id ? this.index().map.get(id) : undefined; - this.selectedId.set(id); - this.selectedLabel.set(node ? labelOf(node) : null); - this.wasGone = false; - } - - /** Announces once when the chosen injector leaves the page, like the component tree does. */ - private syncGone() { - const gone = this.gone() !== null; - if (gone && !this.wasGone) { - // A trailing no-break space makes a repeat differ, so screen readers say it again. - const message = 'The selected injector is no longer on the page.'; - this.announcement.update((current) => (current === message ? `${message}\u00a0` : message)); - } - this.wasGone = gone; + this.state.select(id); } selectPage(pageId: string | null) { - if (!pageId || pageId === this.shownPageId()) return; - this.chosenPageId.set(pageId); - this.wasGone = this.gone() !== null; - const label = this.pageOptions().find((o) => o.value === pageId)?.label ?? pageId; - this.announcement.set(`Showing the injectors of ${label}.`); - } - - private notePageGone(pages: Record | undefined) { - const chosen = this.chosenPageId(); - if (!chosen || pages?.[chosen]) return; - const label = this.pageOptions().find((o) => o.value === chosen)?.label ?? 'The chosen page'; - this.chosenPageId.set(null); - this.announcement.set(`${label} closed, so this shows another page.`); - // The page that takes over brings its own selection; only a gone injector there is news. - this.wasGone = true; + this.state.selectPage(pageId); } toggle(id: string, event?: Event) { - event?.stopPropagation(); - if (this.normalizedQuery()) return; - this.collapsed.update((set) => { - const next = new Set(set); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); + this.state.toggle(id, event); } reveal(id: string) { - const node = this.index().map.get(id); - if (!node) return; - this.setView(node.injector.type === 'element' ? 'element' : 'environment'); - const { parents } = this.index(); - this.collapsed.update((set) => { - const next = new Set(set); - let parent = parents.get(id); - while (parent) { - next.delete(parent); - parent = parents.get(parent); - } - return next; - }); - if (this.onlyProviding() && this.providerCount(node) === 0) this.onlyProviding.set(false); - if (node.injector.type === 'element' && !node.injector.component) - this.componentsOnly.set(false); - // Shows the target for now; the saved choice stays as it was. - if (this.isHiddenInjector(node)) this.hideFramework.set(false); - const q = this.normalizedQuery(); - if (q && !this.nodeMatches(node, q)) this.query.set(''); - this.select(id); - afterNextRender(() => this.focusRow(id, false), { injector: this.injector }); + this.state.reveal(id); } highlight(node: InjectorNode | null) { - const client = this.rpc(); - if (!client) return; - const selector = node?.injector.type === 'element' ? (node.injector.selector ?? null) : null; - const target = selector ? { pageId: this.shownPageId() || null, selector } : null; - void client - .scope('pangular') - .rpc.call('request-page-highlight', target) - .catch(() => {}); - } - - onTreeKey(event: KeyboardEvent) { - const rows = this.rows(); - if (!rows.length) return; - const roving = this.rovingId(); - const current = rows.findIndex((r) => r.node.injector.id === roving); - const at = Math.max(current, 0); - const row = rows[at]; - let next = -1; - switch (event.key) { - case 'ArrowDown': - next = Math.min(at + 1, rows.length - 1); - break; - case 'ArrowUp': - next = Math.max(at - 1, 0); - break; - case 'Home': - next = 0; - break; - case 'End': - next = rows.length - 1; - break; - case 'ArrowRight': - if (row.hasChildren && !row.expanded) this.toggle(row.node.injector.id); - else if (row.hasChildren) next = at + 1; - break; - case 'ArrowLeft': - if (row.hasChildren && row.expanded && !this.normalizedQuery()) - this.toggle(row.node.injector.id); - else { - // The nearest shown ancestor, since hidden framework injectors have no row. - const { parents } = this.index(); - let parent = parents.get(row.node.injector.id); - next = -1; - while (parent && next < 0) { - const id = parent; - next = rows.findIndex((r) => r.node.injector.id === id); - parent = parents.get(parent); - } - } - break; - case 'Enter': - case ' ': - this.select(row.node.injector.id); - break; - default: - return; - } - event.preventDefault(); - if (next >= 0) { - const id = rows[next].node.injector.id; - this.focusId.set(id); - this.choose(id); - queueMicrotask(() => this.focusRow(id, true)); - } - } - - private focusRow(id: string, focus: boolean) { - const el = document.querySelector(`.row[data-id="${CSS.escape(id)}"]`); - if (!el) return; - if (focus) el.focus(); - el.scrollIntoView({ block: 'nearest' }); - } - - private clearTree() { - this.stopTree?.(); - this.stopTree = null; - } - - private async loadInjectorTree(client: DevframeRpcClient) { - const load = ++this.treeLoad; - this.clearTree(); - const my = client.scope('pangular'); - let state: Awaited>; - try { - state = await my.rpc.sharedState('injector-tree'); - } catch { - if (load === this.treeLoad) this.treeFailed.set(true); - return; - } - if (this.destroyRef.destroyed || this.rpc() !== client || load !== this.treeLoad) return; - this.treeFailed.set(false); - const apply = (value: unknown) => { - const next = value && typeof value === 'object' ? (value as InjectorTreeState) : null; - this.notePageGone(next?.pages); - this.treeState.set(next); - this.syncGone(); - }; - apply(state.value()); - this.clearTree(); - this.stopTree = state.on('updated', apply); - } - - private async loadSourceProviders(client: DevframeRpcClient) { - const my = client.scope('pangular'); - try { - this.sourceProviders.set((await my.rpc.call('get-providers')) as SourceProvider[]); - this.sourceFailed.set(false); - } catch { - this.sourceProviders.set([]); - this.sourceFailed.set(true); - } finally { - this.loaded.set(true); - } - } - - private count(nodes: InjectorNode[]): number { - return nodes.reduce((sum, n) => sum + 1 + this.count(n.children), 0); - } - - private nodeMatches(node: InjectorNode, q: string): boolean { - const i = node.injector; - return ( - i.name.toLowerCase().includes(q) || - !!i.component?.toLowerCase().includes(q) || - !!i.directives?.some((d) => d.toLowerCase().includes(q)) || - node.providers.some((p) => p.token.toLowerCase().includes(q)) || - !!node.dependencies?.some((d) => d.token.toLowerCase().includes(q)) - ); - } - - /** Leaves framework injectors out while they are hidden, moving their children up a level. */ - private withoutFramework(nodes: InjectorNode[]): InjectorNode[] { - if (!this.hideFramework()) return nodes; - const lift = (list: InjectorNode[]): InjectorNode[] => - list.flatMap((node) => - node.injector.framework - ? lift(node.children) - : [{ ...node, children: lift(node.children) }], - ); - return lift(nodes); - } - - private prune(nodes: InjectorNode[], keep: (node: InjectorNode) => boolean): InjectorNode[] { - const out: InjectorNode[] = []; - for (const node of nodes) { - const children = this.prune(node.children, keep); - if (keep(node) || children.length) out.push({ ...node, children }); - } - return out; + this.state.highlight(node); } } diff --git a/app/src/pages/injectors/injector-deps.html b/app/src/pages/injectors/injector-deps.html new file mode 100644 index 00000000..32778b84 --- /dev/null +++ b/app/src/pages/injectors/injector-deps.html @@ -0,0 +1,77 @@ +@let sel = node(); +

    + {{ sel.injector.type === 'element' ? 'Injected here' : 'Injected by its services' }} + {{ sel.dependencies?.length ?? 0 }} +

    +@if (sel.injector.type !== 'element') { +

    Only services this injector has already created are listed.

    +} +@if (sel.dependencies?.length) { +
      + @for (dep of sel.dependencies; track dep.from + (dep.tokenId ?? dep.token) + $index) { +
    • +
      + {{ dep.token }} + @for (flag of dep.flags; track flag) { + {{ flag }} + } +
      +
      + @if ( + sel.injector.type !== 'element' || + (sel.injector.directives && sel.injector.directives.length > 1) + ) { + for {{ dep.from }} + } + @if (dep.providedBy; as by) { + + } @else if (dep.flags.includes('optional')) { + optional, not provided + } @else { + not provided anywhere + } + +
      +
      + @if (resolvingKey() === depKey(dep, $index)) { +
        + @for (step of resolution(dep); track step.id + $index) { +
      1. + + @if (step.id === nullId) { + Null injector + } @else { + + } + {{ step.text }} +
      2. + } +
      + } +
      +
    • + } +
    +} @else if (sel.injector.type === 'element') { +

    Nothing is injected through the constructor or inject().

    +} @else { +

    No service created here has injected anything yet.

    +} diff --git a/app/src/pages/injectors/injector-deps.scss b/app/src/pages/injectors/injector-deps.scss new file mode 100644 index 00000000..13ab6490 --- /dev/null +++ b/app/src/pages/injectors/injector-deps.scss @@ -0,0 +1,103 @@ +@use 'mixins' as m; +@use 'injectors' as inj; + +@include inj.mono; +@include inj.pill; +@include inj.chips; +@include inj.link; +@include inj.dot; +@include inj.heading; +@include inj.hint; +@include inj.dep-list; +@include inj.dep-main; +@include inj.token; +@include inj.flag; +@include inj.dep-meta; +.from { + height: 22px; + padding: 0 8px; + color: var(--text-2); +} +.from .mono { + color: var(--text); +} +.from.absent { + cursor: default; + background: none; +} +.from.missing { + cursor: default; + border-color: color-mix(in srgb, var(--warn) 40%, transparent); + background: none; + color: var(--warn); +} +@include inj.empty-line; +@include inj.buttons; +.resolve-toggle { + font-size: 12px; +} +.resolution[hidden] { + display: none; +} +.resolve-graph { + position: relative; + display: flex; + flex-direction: column; + gap: 6px; + margin: 8px 0 0; + padding: 0 0 0 4px; + list-style: none; +} +.resolve-graph li { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + min-width: 0; + padding-left: 18px; + font-size: 12px; +} +.resolve-graph li::before { + content: ''; + position: absolute; + left: 4px; + top: -6px; + bottom: 50%; + width: 2px; + background: var(--border-strong); +} +.resolve-graph li:first-child::before { + display: none; +} +.resolve-dot { + position: absolute; + left: 0; + width: 10px; + height: 10px; + border: 2px solid var(--border-strong); + border-radius: 50%; + background: var(--surface); +} +.resolve-provides .resolve-dot { + border-color: var(--ok); + background: var(--ok); +} +.resolve-throws .resolve-dot { + border-color: var(--danger); + background: var(--danger); +} +.resolve-state { + color: var(--text-2); +} +.resolve-provides .resolve-state { + color: var(--ok-text); + font-weight: 600; +} +.resolve-throws .resolve-state { + color: var(--danger-text); +} +.resolve-not-asked .resolve-state, +.resolve-skipped .resolve-state { + color: var(--text-3); +} diff --git a/app/src/pages/injectors/injector-deps.ts b/app/src/pages/injectors/injector-deps.ts new file mode 100644 index 00000000..e4d18607 --- /dev/null +++ b/app/src/pages/injectors/injector-deps.ts @@ -0,0 +1,81 @@ +import { Component, inject, input } from '@angular/core'; +import { NULL_ID, toneOf, type InjectorNode } from './injector-model'; +import { InjectorsState } from './injectors-state'; + +/** + * The "Injected here" block of the injector detail: each dependency, the injector that provides + * it, and how Angular resolves it along the lookup path. + */ +@Component({ + selector: 'div[appInjectorDeps]', + templateUrl: './injector-deps.html', + styleUrl: './injector-deps.scss', +}) +export class InjectorDeps { + private readonly state = inject(InjectorsState); + + readonly node = input.required(); + + readonly nullId = NULL_ID; + readonly resolvingKey = this.state.resolvingKey; + + depKey(dep: { from: string; token: string; tokenId?: string }, index: number): string { + return this.state.depKey(dep, index); + } + + toggleResolve(key: string) { + this.state.toggleResolve(key); + } + + /** + * The lookup path of the selected injector, read for one dependency: the + * steps Angular asked before the injector that provides the token, that + * injector, and the steps it never reached. `self` stops after the first + * step, `skipSelf` skips it, and a token nobody provides goes on to the null + * injector, which throws unless the dependency is optional. + */ + resolution(dep: { flags: string[]; providedBy: string | null }) { + const steps = this.state.path(); + const at = dep.providedBy ? steps.findIndex((step) => step.id === dep.providedBy) : -1; + const self = dep.flags.includes('self'); + const skipSelf = dep.flags.includes('skipSelf'); + const optional = dep.flags.includes('optional'); + return steps.map((step, index) => { + let state: 'asked' | 'provides' | 'skipped' | 'not-asked' | 'throws' | 'null'; + if (index === 0 && skipSelf) state = 'skipped'; + else if (at >= 0) state = index < at ? 'asked' : index === at ? 'provides' : 'not-asked'; + else if (self && index > (skipSelf ? 1 : 0)) state = 'not-asked'; + else if (step.id === NULL_ID) state = optional ? 'null' : 'throws'; + else state = 'asked'; + const text = { + asked: 'asked, not here', + provides: 'provides it', + skipped: 'skipped (skipSelf)', + 'not-asked': 'not asked', + throws: 'throws NullInjectorError', + null: 'gives null (optional)', + }[state]; + return { ...step, state, text }; + }); + } + + matches(token: string) { + return this.state.matches(token); + } + + reveal(id: string) { + this.state.reveal(id); + } + + tone(kind: string) { + return toneOf(kind); + } + + kindById(id: string) { + return this.state.kindById(id); + } + + labelById(id: string) { + return this.state.labelById(id); + } +} diff --git a/app/src/pages/injectors/injector-detail.html b/app/src/pages/injectors/injector-detail.html new file mode 100644 index 00000000..be534b4b --- /dev/null +++ b/app/src/pages/injectors/injector-detail.html @@ -0,0 +1,59 @@ +@let sel = node(); +
    + {{ kind(sel) }} +

    {{ label(sel) }}

    + @if (sel.injector.directives?.length) { +
      + @for (name of sel.injector.directives; track name) { +
    • {{ name }}
    • + } +
    + } + @if (canShowComponent() && sel.injector.componentId; as componentId) { + + } +
    + +@if (sel.injector.inactive) { +

    + Inactive: the router loaded this route's injector, but no element on the page uses it now. It + keeps the services it created until the app is reloaded. +

    +} +
    +

    Lookup path

    +

    Angular asks these injectors in order until one has the token.

    +
      + @for (step of path(); track step.id; let first = $first) { +
    1. + @if (step.id === nullId) { + + + Null injector + throws NullInjectorError + + } @else { + + } +
    2. + } +
    +
    + +@if (sel.injector.type === 'element' || sel.dependencies) { +
    +} + +
    diff --git a/app/src/pages/injectors/injector-detail.scss b/app/src/pages/injectors/injector-detail.scss new file mode 100644 index 00000000..534eca29 --- /dev/null +++ b/app/src/pages/injectors/injector-detail.scss @@ -0,0 +1,75 @@ +@use 'mixins' as m; +@use 'injectors' as inj; + +@include inj.mono; +@include inj.chips; +@include inj.dot; +@include inj.detail-head; +.badge { + padding: 2px 8px; + border-radius: 99px; + background: color-mix(in srgb, var(--tone) 14%, transparent); + color: var(--tone); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.classes { + display: flex; + flex-wrap: wrap; + gap: 4px; + width: 100%; + margin: 0; + padding: 0; + list-style: none; +} +.classes li { + padding: 1px 8px; + border: 1px solid var(--border-strong); + border-radius: 6px; + color: var(--text-2); + font-family: var(--font-mono); + font-size: 11px; +} +.classes li.is-component { + border-color: var(--accent-line); + color: var(--accent); +} +.block { + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} +.block:last-child { + border-bottom: 0; +} +@include inj.heading; +@include inj.hint; +@include inj.path; +.show-component { + display: inline-flex; + align-items: center; + height: var(--control-h); + padding: 0 14px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease); +} +.show-component:hover { + background: var(--surface-3); + border-color: var(--accent-line); +} +.show-component:focus-visible { + @include m.focus-ring; +} +@include inj.buttons; +.inactive-note { + margin: 0 0 12px; +} diff --git a/app/src/pages/injectors/injector-detail.ts b/app/src/pages/injectors/injector-detail.ts new file mode 100644 index 00000000..a9c64023 --- /dev/null +++ b/app/src/pages/injectors/injector-detail.ts @@ -0,0 +1,48 @@ +import { Component, inject, input, output } from '@angular/core'; +import { InjectorDeps } from './injector-deps'; +import { kindOf, labelOf, NULL_ID, toneOf, type InjectorNode } from './injector-model'; +import { InjectorProviders } from './injector-providers'; +import { InjectorsState } from './injectors-state'; + +/** + * The detail of the selected injector: its kind and classes, the lookup path, what it injects + * and what it provides. + */ +@Component({ + selector: 'section[appInjectorDetail]', + imports: [InjectorDeps, InjectorProviders], + templateUrl: './injector-detail.html', + styleUrl: './injector-detail.scss', +}) +export class InjectorDetail { + private readonly state = inject(InjectorsState); + + readonly node = input.required(); + /** Whether the Components tab is on, so "Show in Components" can open it. */ + readonly canShowComponent = input(false); + /** The component id of the element injector to open in the Components tab. */ + readonly showComponent = output(); + + readonly nullId = NULL_ID; + readonly path = this.state.path; + + openComponent(componentId: string) { + this.showComponent.emit(componentId); + } + + reveal(id: string) { + this.state.reveal(id); + } + + label(node: InjectorNode) { + return labelOf(node); + } + + kind(node: InjectorNode) { + return kindOf(node); + } + + tone(kind: string) { + return toneOf(kind); + } +} diff --git a/app/src/pages/injectors/injector-gone.html b/app/src/pages/injectors/injector-gone.html new file mode 100644 index 00000000..9e76db59 --- /dev/null +++ b/app/src/pages/injectors/injector-gone.html @@ -0,0 +1,13 @@ +
    +

    {{ name() }}

    +
    +
    +

    + This injector is no longer on the page. Its component or directive was destroyed. +

    + @if (rows()[0]; as first) { + + } +
    diff --git a/app/src/pages/injectors/injector-gone.scss b/app/src/pages/injectors/injector-gone.scss new file mode 100644 index 00000000..1db19e6b --- /dev/null +++ b/app/src/pages/injectors/injector-gone.scss @@ -0,0 +1,14 @@ +@use 'mixins' as m; +@use 'injectors' as inj; + +@include inj.mono; +@include inj.detail-head; +@include inj.state; +@include inj.buttons; +@include inj.state-buttons; +@include inj.state-button-focus; +// The section is the host, so the original .gone .state is :host(.gone) .state here. +:host(.gone) .state { + align-items: flex-start; + text-align: left; +} diff --git a/app/src/pages/injectors/injector-gone.ts b/app/src/pages/injectors/injector-gone.ts new file mode 100644 index 00000000..1a51b780 --- /dev/null +++ b/app/src/pages/injectors/injector-gone.ts @@ -0,0 +1,25 @@ +import { Component, inject, input } from '@angular/core'; +import { labelOf, type InjectorNode } from './injector-model'; +import { InjectorsState } from './injectors-state'; + +/** The detail of a chosen injector that has left the page, with a way back to the first row. */ +@Component({ + selector: 'section[appInjectorGone]', + templateUrl: './injector-gone.html', + styleUrl: './injector-gone.scss', +}) +export class InjectorGone { + private readonly state = inject(InjectorsState); + + /** The label the injector had while it was on the page. */ + readonly name = input.required(); + readonly rows = this.state.rows; + + reveal(id: string) { + this.state.reveal(id); + } + + label(node: InjectorNode) { + return labelOf(node); + } +} diff --git a/app/src/pages/injectors/injector-model.ts b/app/src/pages/injectors/injector-model.ts new file mode 100644 index 00000000..b705ac30 --- /dev/null +++ b/app/src/pages/injectors/injector-model.ts @@ -0,0 +1,84 @@ +import type { + InjectorTreeNode, + ProviderInfo as PanelProviderInfo, +} from '@pangular-inspector/devtools/internal/types'; + +export type ProviderInfo = PanelProviderInfo; +export type InjectorNode = InjectorTreeNode; + +/** One entry of the source scan (`get-providers`). */ +export interface SourceProvider { + token: string; + source: string; + file: string; + line: number; + providedIn?: string; + type: string; +} + +/** One shown row of the injector tree. */ +export interface Row { + node: InjectorNode; + depth: number; + hasChildren: boolean; + expanded: boolean; +} + +export type TreeView = 'element' | 'environment'; + +export interface InjectorPage { + roots?: unknown; + environment?: unknown; + truncated?: unknown; + limits?: unknown; + zone?: unknown; + reportedAt?: unknown; +} + +export interface InjectorTreeState extends InjectorPage { + pages?: Record; +} + +/** One step of the selected injector's lookup path. */ +export interface PathStep { + id: string; + label: string; + kind: string; + providers: number; +} + +export const NULL_ID = 'inj-null'; +export const HIDE_FRAMEWORK_PREF = 'injectors.hideFramework'; +export const MAX_PROVIDED_BY = 12; +/** The `type` the source scan gives a provide*() call it can't resolve to tokens. */ +export const HELPER_TYPE = 'helper'; +/** Source entries that provide a token, which the orphaned providers hint checks. */ +export const PROVIDING_TYPES = new Set(['provider', 'injectable']); +/** How long typing must pause before the search result is announced. */ +export const SEARCH_ANNOUNCE_MS = 600; + +// Text tones: these color the kind letter and badge on a 14 to 16% tint of +// themselves, so they use the stronger `-text` tokens. +const KIND_TONE: Record = { + component: 'var(--accent-text)', + directive: 'var(--directive)', + environment: 'var(--ok-text)', + null: 'var(--text-3)', +}; + +export function kindOf(node: InjectorNode): 'component' | 'directive' | 'environment' { + if (node.injector.type !== 'element') return 'environment'; + return node.injector.component ? 'component' : 'directive'; +} + +export function labelOf(node: InjectorNode): string { + return node.injector.type === 'element' ? `<${node.injector.name}>` : node.injector.name; +} + +export function toneOf(kind: string): string { + return KIND_TONE[kind] ?? KIND_TONE['null']; +} + +export function isTree(value: unknown): value is InjectorNode[] { + return Array.isArray(value); +} diff --git a/app/src/pages/injectors/injector-orphans.html b/app/src/pages/injectors/injector-orphans.html new file mode 100644 index 00000000..ec10bffd --- /dev/null +++ b/app/src/pages/injectors/injector-orphans.html @@ -0,0 +1,30 @@ + +
    +

    + Provided in your source, but nothing on this page has injected them so far. This is not an + error: a guard, an interceptor or code that has not run yet may still use them. +

    +
      + @for (p of orphaned(); track p.token + p.file + p.line) { +
    • + {{ p.token }} + @if (p.providedIn) { + providedIn: {{ p.providedIn }} + } + +
    • + } +
    +
    diff --git a/app/src/pages/injectors/injector-orphans.scss b/app/src/pages/injectors/injector-orphans.scss new file mode 100644 index 00000000..b635229b --- /dev/null +++ b/app/src/pages/injectors/injector-orphans.scss @@ -0,0 +1,47 @@ +@use 'mixins' as m; +@use 'injectors' as inj; + +@include inj.mono; +@include inj.hint; +@include inj.token; +@include inj.flag; +@include inj.buttons; +@include inj.chevron; +@include inj.toggle; +.hint-tag { + padding: 0 6px; + border-radius: 5px; + @include m.tint(accent); + font-size: 10px; + line-height: 16px; +} +.orphans-body { + padding: 0 12px 12px; +} +.orphan-list { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} +.orphan-list li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + min-width: 0; + font-size: 12px; +} +.orphans-body .hint { + margin: 0 0 8px; +} +.orphan-list .orphan-file { + min-width: 0; + color: var(--text-3); + font-family: var(--font-mono); + font-size: 11px; + overflow-wrap: anywhere; +} +@include inj.hidden; diff --git a/app/src/pages/injectors/injector-orphans.ts b/app/src/pages/injectors/injector-orphans.ts new file mode 100644 index 00000000..32c3e932 --- /dev/null +++ b/app/src/pages/injectors/injector-orphans.ts @@ -0,0 +1,18 @@ +import { Component, inject } from '@angular/core'; +import { SourceLink } from '../../ui/source-link'; +import { SourceScan } from './source-scan'; + +/** The "never injected" hint: providers the source declares that nothing on the page injected. */ +@Component({ + selector: 'div[appInjectorOrphans]', + imports: [SourceLink], + templateUrl: './injector-orphans.html', + styleUrl: './injector-orphans.scss', +}) +export class InjectorOrphans { + private readonly scan = inject(SourceScan); + + readonly orphaned = this.scan.orphaned; + readonly orphansOpen = this.scan.orphansOpen; + readonly orphanedText = this.scan.orphanedText; +} diff --git a/app/src/pages/injectors/injector-providers.html b/app/src/pages/injectors/injector-providers.html new file mode 100644 index 00000000..d68eb59b --- /dev/null +++ b/app/src/pages/injectors/injector-providers.html @@ -0,0 +1,84 @@ +@let sel = node(); +

    + Provides {{ providerCount(sel) }} +

    +@if (hiddenProviders(sel); as hidden) { +

    + {{ hidden }} framework {{ hidden === 1 ? 'provider' : 'providers' }} hidden. + + + +

    +} +@if (providerCount(sel)) { +
      + @for (p of sel.providers; track (p.tokenId ?? p.token) + $index; let index = $index) { + @if (!isHidden(p)) { +
    • +
      + {{ p.token }} + {{ + p.type === 'unknown' ? 'provider' : 'use' + capital(p.type) + }} + @if (p.isViewProvider) { + viewProviders + } + @if (p.multi) { + multi + } + @if (p.framework) { + Angular + } +
      + @if (p.importPath?.length) { +
      via {{ p.importPath!.join(' › ') }}
      + } + @if (sourceOf(p.token); as places) { +
      + Declared in + @for (place of places.shown; track place.file + place.line) { + + } + @if (places.more) { + +{{ places.more }} more + } +
      + } + + +
    • + } + } +
    +} @else if (!hiddenProviders(sel)) { +

    + @if (sel.injector.type === 'element') { + No providers or viewProviders on this element. + } @else { + This injector has no providers of its own. + } +

    +} diff --git a/app/src/pages/injectors/injector-providers.scss b/app/src/pages/injectors/injector-providers.scss new file mode 100644 index 00000000..96edfcaf --- /dev/null +++ b/app/src/pages/injectors/injector-providers.scss @@ -0,0 +1,35 @@ +@use 'mixins' as m; +@use 'injectors' as inj; + +@include inj.mono; +@include inj.pill; +@include inj.chips; +@include inj.link; +@include inj.heading; +@include inj.hint; +@include inj.dep-list; +.provider-global { + margin-top: 6px; +} +.framework-note { + margin: 0 0 10px; + color: var(--text-2); +} +@include inj.dep-main; +@include inj.token; +@include inj.flag; +.kind-flag { + @include m.tint(accent); +} +@include inj.dep-meta; +@include inj.empty-line; +@include inj.buttons; +.declared { + color: var(--text-3); +} +.decl-file { + min-width: 0; + font-family: var(--font-mono); + font-size: 11px; + overflow-wrap: anywhere; +} diff --git a/app/src/pages/injectors/injector-providers.ts b/app/src/pages/injectors/injector-providers.ts new file mode 100644 index 00000000..6631cdcb --- /dev/null +++ b/app/src/pages/injectors/injector-providers.ts @@ -0,0 +1,63 @@ +import { Component, inject, input } from '@angular/core'; +import { SourceLink } from '../../ui/source-link'; +import { StoreGlobal } from '../../ui/store-global'; +import { ProviderValue } from '../di-provider-value'; +import type { InjectorNode, ProviderInfo } from './injector-model'; +import { InjectorsState } from './injectors-state'; +import { SourceScan } from './source-scan'; + +/** + * The "Provides" block of the injector detail: each provider with its flags, where the source + * declares it, the store-as-global action and its live value. + */ +@Component({ + selector: 'div[appInjectorProviders]', + imports: [ProviderValue, SourceLink, StoreGlobal], + templateUrl: './injector-providers.html', + styleUrl: './injector-providers.scss', +}) +export class InjectorProviders { + private readonly state = inject(InjectorsState); + private readonly scan = inject(SourceScan); + + readonly node = input.required(); + + readonly rpc = this.state.rpc; + readonly shownPageId = this.state.shownPageId; + + providerCount(node: InjectorNode) { + return this.state.providerCount(node); + } + + hiddenProviders(node: InjectorNode) { + return this.state.hiddenProviders(node); + } + + isHidden(provider: ProviderInfo) { + return this.state.isHidden(provider); + } + + setHideFramework(hide: boolean) { + this.state.setHideFramework(hide); + } + + matches(token: string) { + return this.state.matches(token); + } + + sourceOf(token: string) { + return this.scan.sourceOf(token); + } + + tokenPosition(providers: readonly ProviderInfo[], index: number) { + return this.state.tokenPosition(providers, index); + } + + positionSuffix(providers: readonly ProviderInfo[], index: number) { + return this.state.positionSuffix(providers, index); + } + + capital(text: string) { + return text.charAt(0).toUpperCase() + text.slice(1); + } +} diff --git a/app/src/pages/injectors/injector-tree.html b/app/src/pages/injectors/injector-tree.html new file mode 100644 index 00000000..a145557a --- /dev/null +++ b/app/src/pages/injectors/injector-tree.html @@ -0,0 +1,68 @@ +@for (row of rows(); track row.node.injector.id; let i = $index) { +
    + @if (row.hasChildren) { + + } @else { + + } + + {{ kind(row.node) }} + {{ label(row.node) }} + @if (row.node.injector.inactive) { + inactive + } + @if (row.node.injector.component) { + {{ row.node.injector.component }} + } @else if (row.node.injector.directives?.length) { + {{ row.node.injector.directives!.join(', ') }} + } + + @if ((row.node.dependencies?.length ?? 0) > 0) { + + + injects + {{ row.node.dependencies!.length }} + + } + @if (providerCount(row.node); as provided) { + + + provides + {{ provided }} + + } + +
    +} diff --git a/app/src/pages/injectors/injector-tree.scss b/app/src/pages/injectors/injector-tree.scss new file mode 100644 index 00000000..f2b6c6fb --- /dev/null +++ b/app/src/pages/injectors/injector-tree.scss @@ -0,0 +1,131 @@ +@use 'mixins' as m; +@use 'injectors' as inj; + +@include inj.mono; +.row { + position: relative; + display: flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 0 8px 0 calc(4px + var(--depth, 0) * 18px); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background-color 120ms var(--ease); +} +.row::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 14px; + width: calc(var(--depth, 0) * 18px); + background: repeating-linear-gradient(to right, var(--border) 0 1px, transparent 1px 18px); + pointer-events: none; +} +.row:hover { + background: var(--surface-2); +} +.row:focus-visible { + @include m.focus-ring(-2px); +} +.row.selected { + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent); +} +.twisty, +.twisty-space { + flex: none; + width: 20px; + height: 20px; +} +.twisty { + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 4px; + background: none; + color: var(--text-3); + cursor: pointer; +} +.twisty:hover { + color: var(--text); + background: var(--surface-3); +} +.twisty svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; + transition: transform 150ms var(--ease); +} +.twisty.open svg { + transform: rotate(90deg); +} +.kind { + flex: none; + display: grid; + place-items: center; + width: 18px; + height: 18px; + border-radius: 5px; + background: color-mix(in srgb, var(--tone) 16%, transparent); + color: var(--tone); + font-size: 10px; + font-weight: 700; +} +.name { + min-width: 0; + color: var(--text); + font-size: 12px; + @include m.truncate; +} +.row.selected .name { + color: var(--text-strong); + font-weight: 600; +} +.sub { + flex: 0 1 auto; + min-width: 0; + color: var(--text-3); + font-size: 12px; + @include m.truncate; +} +.meta { + display: inline-flex; + flex: none; + gap: 4px; + margin-left: auto; + padding-left: 8px; +} +.count { + display: inline-flex; + align-items: center; + gap: 3px; + height: 20px; + padding: 0 7px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 11px; +} +.count.provides { + @include m.tint(ok); +} +.count svg { + width: 11px; + height: 11px; + fill: none; + stroke: currentColor; + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; +} +@include inj.flag; +.inactive-flag { + flex: none; +} diff --git a/app/src/pages/injectors/injector-tree.ts b/app/src/pages/injectors/injector-tree.ts new file mode 100644 index 00000000..a102b21d --- /dev/null +++ b/app/src/pages/injectors/injector-tree.ts @@ -0,0 +1,106 @@ +import { Component, inject } from '@angular/core'; +import { kindOf, labelOf, toneOf, type InjectorNode } from './injector-model'; +import { InjectorsState } from './injectors-state'; + +/** + * The rows of the injector tree, an ARIA tree with arrow keys: Up and Down move, Right opens or + * goes to the first child, Left closes or goes to the nearest shown parent, Home and End jump. + */ +@Component({ + selector: 'div[appInjectorTree]', + templateUrl: './injector-tree.html', + styleUrl: './injector-tree.scss', + host: { '(keydown)': 'onTreeKey($event)' }, +}) +export class InjectorTree { + private readonly state = inject(InjectorsState); + + readonly rows = this.state.rows; + readonly selected = this.state.selected; + readonly rovingId = this.state.rovingId; + readonly focusId = this.state.focusId; + + select(id: string) { + this.state.select(id); + } + + toggle(id: string, event?: Event) { + this.state.toggle(id, event); + } + + highlight(node: InjectorNode | null) { + this.state.highlight(node); + } + + providerCount(node: InjectorNode) { + return this.state.providerCount(node); + } + + label(node: InjectorNode) { + return labelOf(node); + } + + kind(node: InjectorNode) { + return kindOf(node); + } + + tone(kind: string) { + return toneOf(kind); + } + + onTreeKey(event: KeyboardEvent) { + const rows = this.rows(); + if (!rows.length) return; + const roving = this.rovingId(); + const current = rows.findIndex((r) => r.node.injector.id === roving); + const at = Math.max(current, 0); + const row = rows[at]; + let next = -1; + switch (event.key) { + case 'ArrowDown': + next = Math.min(at + 1, rows.length - 1); + break; + case 'ArrowUp': + next = Math.max(at - 1, 0); + break; + case 'Home': + next = 0; + break; + case 'End': + next = rows.length - 1; + break; + case 'ArrowRight': + if (row.hasChildren && !row.expanded) this.toggle(row.node.injector.id); + else if (row.hasChildren) next = at + 1; + break; + case 'ArrowLeft': + if (row.hasChildren && row.expanded && !this.state.normalizedQuery()) + this.toggle(row.node.injector.id); + else { + // The nearest shown ancestor, since hidden framework injectors have no row. + const { parents } = this.state.index(); + let parent = parents.get(row.node.injector.id); + next = -1; + while (parent && next < 0) { + const id = parent; + next = rows.findIndex((r) => r.node.injector.id === id); + parent = parents.get(parent); + } + } + break; + case 'Enter': + case ' ': + this.select(row.node.injector.id); + break; + default: + return; + } + event.preventDefault(); + if (next >= 0) { + const id = rows[next].node.injector.id; + this.focusId.set(id); + this.state.choose(id); + queueMicrotask(() => this.state.focusRow(id, true)); + } + } +} diff --git a/app/src/pages/injectors/injectors-state.ts b/app/src/pages/injectors/injectors-state.ts new file mode 100644 index 00000000..5da9628c --- /dev/null +++ b/app/src/pages/injectors/injectors-state.ts @@ -0,0 +1,627 @@ +import { + DestroyRef, + ElementRef, + Injectable, + Injector, + afterNextRender, + computed, + inject, + linkedSignal, + signal, + type OnDestroy, + type Signal, +} from '@angular/core'; +import type { DevframeRpcClient } from 'devframe/client'; +import type { InjectorTreeLimits } from '@pangular-inspector/devtools/internal/types'; +import { pickPage, type ReportedPage } from '../../live-pages'; +import { hostPageId } from '../../page-id'; +import type { SelectOption } from '../../ui/select'; +import { storeFlag, storedFlag } from '../../view-prefs'; +import { zoneLabel } from '../dashboard'; +import { + HIDE_FRAMEWORK_PREF, + MAX_PROVIDED_BY, + NULL_ID, + isTree, + kindOf, + labelOf, + type InjectorNode, + type InjectorPage, + type InjectorTreeState, + type PathStep, + type ProviderInfo, + type Row, + type SourceProvider, + type TreeView, +} from './injector-model'; + +/** + * The state the Injectors page and its sections share: the `injector-tree` shared state and the + * source scan, the shown page, the tree filters, the selection and the announcements. Provided + * by the page, so every section of one page reads the same signals. + */ +@Injectable() +export class InjectorsState implements OnDestroy { + private rpcSource: Signal = signal(null); + readonly rpc = computed(() => this.rpcSource()); + private readonly injector = inject(Injector); + private readonly host = inject>(ElementRef); + private readonly destroyRef = inject(DestroyRef); + + readonly hostPage = hostPageId(); + readonly treeState = signal(null); + readonly pageTitles = signal>({}); + readonly chosenPageId = signal(null); + + // Older servers send only the newest tree, without the per-page map. + readonly treePages = computed>(() => { + const state = this.treeState(); + if (!state) return {}; + const pages = state.pages && typeof state.pages === 'object' ? state.pages : {}; + if (Object.keys(pages).length || this.hostPage) return pages; + return { '': state }; + }); + + // A page that reports later never takes over from the one shown. + readonly shownPageId = linkedSignal< + { pages: Record; chosen: string | null }, + string | null + >({ + source: () => ({ pages: this.treePages(), chosen: this.chosenPageId() }), + computation: ({ pages, chosen }, previous) => { + const reported: Record = {}; + for (const [pageId, page] of Object.entries(pages)) { + const at = typeof page?.reportedAt === 'number' ? page.reportedAt : 0; + reported[pageId] = { pageId, reportedAt: at }; + } + return pickPage(reported, { + chosen, + host: this.hostPage, + previous: previous?.value ?? null, + }); + }, + }); + + private readonly shownPage = computed(() => { + const pageId = this.shownPageId(); + return pageId === null ? null : (this.treePages()[pageId] ?? null); + }); + readonly roots = computed(() => { + const roots = this.shownPage()?.roots; + return isTree(roots) ? roots : []; + }); + readonly environment = computed(() => { + const environment = this.shownPage()?.environment; + return isTree(environment) ? environment : []; + }); + readonly truncated = computed(() => this.shownPage()?.truncated === true); + /** The caps the server names with `truncated`, or the ones it has always used. */ + readonly limits = computed(() => { + const limits = this.shownPage()?.limits as Partial | undefined; + const whole = (n: unknown, fallback: number) => + typeof n === 'number' && Number.isInteger(n) && n > 0 ? n : fallback; + return { + elements: whole(limits?.elements, 2000), + injectors: whole(limits?.injectors, 2500), + depth: whole(limits?.depth, 1000), + }; + }); + readonly zone = computed(() => { + const zone = this.shownPage()?.zone; + return typeof zone === 'string' ? zoneLabel(zone) : null; + }); + + readonly multiPage = computed(() => !this.hostPage && Object.keys(this.treePages()).length > 1); + readonly pageOptions = computed(() => { + if (!this.multiPage()) return []; + const titles = this.pageTitles(); + return Object.keys(this.treePages()).map((pageId) => { + const page = titles[pageId]; + return { + value: pageId, + label: page?.title || page?.url || `Page ${pageId}`, + hint: page?.url, + }; + }); + }); + + /** Injector ids belong to one page, so each page keeps its own selection and expansion. */ + private readonly pageKey = computed(() => this.shownPageId() ?? ''); + private perPage(initial: () => T) { + const saved = new Map(); + return linkedSignal({ + source: this.pageKey, + computation: (key, previous) => { + if (previous) saved.set(previous.source, previous.value); + return saved.has(key) ? (saved.get(key) as T) : initial(); + }, + }); + } + + readonly sourceProviders = signal([]); + readonly loaded = signal(false); + /** The live injector tree or the source scan could not be read from the server. */ + readonly treeFailed = signal(false); + private readonly sourceFailed = signal(false); + readonly loadFailed = computed(() => this.treeFailed() || this.sourceFailed()); + readonly retrying = signal(false); + readonly view = signal('element'); + readonly query = signal(''); + readonly onlyProviding = signal(false); + readonly componentsOnly = signal(true); + /** Hides Angular's own injectors and providers (marked `framework` by the server). */ + readonly hideFramework = signal(storedFlag(HIDE_FRAMEWORK_PREF) ?? false); + readonly collapsed = this.perPage>(() => new Set()); + readonly selectedId = this.perPage(() => null); + readonly focusId = this.perPage(() => null); + readonly announcement = signal(''); + private readonly selectedLabel = this.perPage(() => null); + private wasGone = false; + /** The dependency whose resolution is open in the details. */ + readonly resolvingKey = signal(null); + + readonly index = computed(() => { + const map = new Map(); + const parents = new Map(); + const walk = (nodes: InjectorNode[], parent: string | null) => { + for (const node of nodes) { + map.set(node.injector.id, node); + if (parent) parents.set(node.injector.id, parent); + walk(node.children, node.injector.id); + } + }; + walk(this.roots(), null); + walk(this.environment(), null); + const byComponent = new Map(); + for (const node of map.values()) { + const componentId = node.injector.componentId; + if (componentId && node.injector.type === 'element') + byComponent.set(componentId, node.injector.id); + } + return { map, parents, byComponent }; + }); + + /** The element and environment trees without framework injectors, while they are hidden. */ + private readonly shownRoots = computed(() => this.withoutFramework(this.roots())); + private readonly shownEnvironment = computed(() => this.withoutFramework(this.environment())); + + readonly elementCount = computed(() => this.count(this.shownRoots())); + readonly environmentCount = computed(() => this.count(this.shownEnvironment())); + + /** What "Hide framework" hides in the current view: injectors, and providers on the rest. */ + readonly frameworkHiddenCount = computed(() => { + const all = this.view() === 'element' ? this.roots() : this.environment(); + let injectors = 0; + let providers = 0; + const walk = (nodes: InjectorNode[]) => { + for (const node of nodes) { + if (node.injector.framework) injectors++; + else providers += node.providers.filter((p) => p.framework).length; + walk(node.children); + } + }; + walk(all); + return { injectors, providers }; + }); + readonly frameworkHiddenText = computed(() => { + const { injectors, providers } = this.frameworkHiddenCount(); + const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`; + if (!injectors && !providers) return 'Nothing hidden'; + if (!providers) return `${plural(injectors, 'injector')} hidden`; + if (!injectors) return `${plural(providers, 'provider')} hidden`; + return `${plural(injectors, 'injector')} and ${plural(providers, 'provider')} hidden`; + }); + + private readonly visible = computed(() => { + let nodes = this.view() === 'element' ? this.shownRoots() : this.shownEnvironment(); + if (this.view() === 'element' && this.componentsOnly()) { + nodes = this.prune(nodes, (n) => !!n.injector.component); + } + if (this.onlyProviding()) nodes = this.prune(nodes, (n) => this.providerCount(n) > 0); + const q = this.normalizedQuery(); + if (q) nodes = this.prune(nodes, (n) => this.nodeMatches(n, q)); + return nodes; + }); + + readonly rows = computed(() => { + const rows: Row[] = []; + const collapsed = this.collapsed(); + const searching = !!this.normalizedQuery(); + const walk = (nodes: InjectorNode[], depth: number) => { + for (const node of nodes) { + const expanded = searching || !collapsed.has(node.injector.id); + rows.push({ node, depth, hasChildren: node.children.length > 0, expanded }); + if (expanded) walk(node.children, depth + 1); + } + }; + walk(this.visible(), 0); + return rows; + }); + + /** Falls back to the first row only while nothing is chosen, never when the choice is gone. */ + readonly selected = computed(() => { + const id = this.selectedId(); + if (!id) return this.rows()[0]?.node ?? null; + return this.index().map.get(id) ?? null; + }); + + /** The label of the chosen injector once it has left the page. */ + readonly gone = computed(() => { + const id = this.selectedId(); + return id && !this.index().map.has(id) ? (this.selectedLabel() ?? 'The injector') : null; + }); + + readonly rovingId = computed(() => { + const rows = this.rows(); + const want = this.focusId() ?? this.selected()?.injector.id; + if (want && rows.some((r) => r.node.injector.id === want)) return want; + return rows[0]?.node.injector.id ?? null; + }); + + readonly path = computed(() => { + const sel = this.selected(); + if (!sel) return []; + const { map, parents } = this.index(); + let ids = sel.injector.path ?? []; + if (!ids.length) { + ids = [sel.injector.id]; + let parent = parents.get(sel.injector.id); + while (parent) { + ids.push(parent); + parent = parents.get(parent); + } + ids.push(NULL_ID); + } + return ids.map((id) => { + const node = map.get(id); + return { + id, + label: node ? labelOf(node) : id === NULL_ID ? 'Null injector' : 'Not listed', + kind: node ? kindOf(node) : 'null', + providers: node ? this.providerCount(node) : 0, + }; + }); + }); + + /** Every injector that provides a token matching the search. */ + readonly providerHits = computed(() => { + const q = this.normalizedQuery(); + if (!q) return []; + const hits: { id: string; label: string; kind: string; tokens: string }[] = []; + for (const node of this.index().map.values()) { + if (this.isHiddenInjector(node)) continue; + const tokens = node.providers.filter( + (p) => !this.isHidden(p) && p.token.toLowerCase().includes(q), + ); + if (!tokens.length) continue; + hits.push({ + id: node.injector.id, + label: labelOf(node), + kind: kindOf(node), + tokens: tokens.map((t) => t.token).join(', '), + }); + } + return hits; + }); + readonly providedBy = computed(() => this.providerHits().slice(0, MAX_PROVIDED_BY)); + readonly providedByMore = computed(() => + Math.max(0, this.providerHits().length - MAX_PROVIDED_BY), + ); + + /** What a screen reader hears about the search results. */ + readonly searchSummary = computed(() => { + const query = this.query().trim(); + if (!query) return ''; + const hits = this.providerHits().length; + if (hits === 1) return `1 injector provides “${query}”.`; + if (hits > 1) return `${hits} injectors provide “${query}”.`; + if (!this.rows().length) return `Nothing here matches “${query}”.`; + return `No injector provides “${query}”.`; + }); + + readonly normalizedQuery = computed(() => this.query().trim().toLowerCase()); + + private stopTree: (() => void) | null = null; + private treeLoad = 0; + + connect(rpc: Signal) { + this.rpcSource = rpc; + } + + ngOnDestroy() { + this.stopTree?.(); + this.highlight(null); + } + + depKey(dep: { from: string; token: string; tokenId?: string }, index: number): string { + return `${index}|${dep.from}|${dep.tokenId ?? dep.token}`; + } + + toggleResolve(key: string) { + this.resolvingKey.update((open) => (open === key ? null : key)); + } + + /** Turns "Hide framework" on or off and remembers the choice for the next visit. */ + setHideFramework(hide: boolean) { + this.hideFramework.set(hide); + storeFlag(HIDE_FRAMEWORK_PREF, hide); + } + + /** + * `(2 of 3)` for a token that several shown providers share (multi + * providers), so their buttons and value regions get unique names; else ''. + */ + tokenPosition(providers: readonly ProviderInfo[], index: number): string { + const token = providers[index]?.token; + let at = 0; + let count = 0; + providers.forEach((p, i) => { + if (p.token !== token || this.isHidden(p)) return; + count++; + if (i <= index) at++; + }); + return count > 1 ? `(${at} of ${count})` : ''; + } + + positionSuffix(providers: readonly ProviderInfo[], index: number): string { + const position = this.tokenPosition(providers, index); + return position ? ` ${position}` : ''; + } + + /** Whether a provider is left out of the lists while framework providers are hidden. */ + isHidden(provider: ProviderInfo): boolean { + return this.hideFramework() && provider.framework === true; + } + + private isHiddenInjector(node: InjectorNode): boolean { + return this.hideFramework() && node.injector.framework === true; + } + + /** How many providers the node shows. */ + providerCount(node: InjectorNode): number { + if (!this.hideFramework()) return node.injector.providerCount; + const hidden = node.providers.filter((p) => p.framework).length; + return Math.max(0, node.injector.providerCount - hidden); + } + + hiddenProviders(node: InjectorNode): number { + return this.hideFramework() ? node.providers.filter((p) => p.framework).length : 0; + } + + /** Loads the injector tree and the source scan again. The alert stays until they answer, so + * focus stays on Retry while it is shown and moves to the toolbar when the alert goes, which + * also happens when live injectors arrive but the source scan still fails. */ + async retry() { + const client = this.rpc(); + if (!client || this.retrying()) return; + const trigger = document.activeElement; + this.retrying.set(true); + try { + await Promise.all([this.loadInjectorTree(client), this.loadSourceProviders(client)]); + } finally { + this.retrying.set(false); + } + if (this.destroyRef.destroyed) return; + afterNextRender( + () => { + if (trigger?.isConnected && document.activeElement !== document.body) return; + this.host.nativeElement + .querySelector('.toolbar input, .toolbar button') + ?.focus(); + }, + { injector: this.injector }, + ); + } + + /** Asks the shown page to read every injector again, element entries included. */ + refresh() { + const pageId = this.shownPageId(); + if (!pageId) return; + this.requestRefresh(pageId); + this.announcement.set('Reading the injectors again.'); + } + + requestRefresh(pageId: string, injectorId?: string) { + const client = this.rpc(); + if (!client) return; + void client + .scope('pangular') + .rpc.call('request-injector-refresh', injectorId ? { pageId, injectorId } : { pageId }) + .catch(() => {}); + } + + kindById(id: string) { + const node = this.index().map.get(id); + return node ? kindOf(node) : 'null'; + } + + labelById(id: string) { + const node = this.index().map.get(id); + return node ? labelOf(node) : id === NULL_ID ? 'Null injector' : 'not listed'; + } + + matches(token: string) { + const q = this.normalizedQuery(); + return !!q && token.toLowerCase().includes(q); + } + + setView(view: TreeView) { + if (this.view() === view) return; + this.view.set(view); + this.choose(null); + this.focusId.set(null); + } + + select(id: string) { + this.choose(id); + this.focusId.set(id); + } + + choose(id: string | null) { + const node = id ? this.index().map.get(id) : undefined; + this.selectedId.set(id); + this.selectedLabel.set(node ? labelOf(node) : null); + this.wasGone = false; + } + + /** Announces once when the chosen injector leaves the page, like the component tree does. */ + private syncGone() { + const gone = this.gone() !== null; + if (gone && !this.wasGone) { + // A trailing no-break space makes a repeat differ, so screen readers say it again. + const message = 'The selected injector is no longer on the page.'; + this.announcement.update((current) => (current === message ? `${message} ` : message)); + } + this.wasGone = gone; + } + + selectPage(pageId: string | null) { + if (!pageId || pageId === this.shownPageId()) return; + this.chosenPageId.set(pageId); + this.wasGone = this.gone() !== null; + const label = this.pageOptions().find((o) => o.value === pageId)?.label ?? pageId; + this.announcement.set(`Showing the injectors of ${label}.`); + } + + private notePageGone(pages: Record | undefined) { + const chosen = this.chosenPageId(); + if (!chosen || pages?.[chosen]) return; + const label = this.pageOptions().find((o) => o.value === chosen)?.label ?? 'The chosen page'; + this.chosenPageId.set(null); + this.announcement.set(`${label} closed, so this shows another page.`); + // The page that takes over brings its own selection; only a gone injector there is news. + this.wasGone = true; + } + + toggle(id: string, event?: Event) { + event?.stopPropagation(); + if (this.normalizedQuery()) return; + this.collapsed.update((set) => { + const next = new Set(set); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + reveal(id: string) { + const node = this.index().map.get(id); + if (!node) return; + this.setView(node.injector.type === 'element' ? 'element' : 'environment'); + const { parents } = this.index(); + this.collapsed.update((set) => { + const next = new Set(set); + let parent = parents.get(id); + while (parent) { + next.delete(parent); + parent = parents.get(parent); + } + return next; + }); + if (this.onlyProviding() && this.providerCount(node) === 0) this.onlyProviding.set(false); + if (node.injector.type === 'element' && !node.injector.component) + this.componentsOnly.set(false); + // Shows the target for now; the saved choice stays as it was. + if (this.isHiddenInjector(node)) this.hideFramework.set(false); + const q = this.normalizedQuery(); + if (q && !this.nodeMatches(node, q)) this.query.set(''); + this.select(id); + afterNextRender(() => this.focusRow(id, false), { injector: this.injector }); + } + + highlight(node: InjectorNode | null) { + const client = this.rpc(); + if (!client) return; + const selector = node?.injector.type === 'element' ? (node.injector.selector ?? null) : null; + const target = selector ? { pageId: this.shownPageId() || null, selector } : null; + void client + .scope('pangular') + .rpc.call('request-page-highlight', target) + .catch(() => {}); + } + + focusRow(id: string, focus: boolean) { + const el = document.querySelector(`.row[data-id="${CSS.escape(id)}"]`); + if (!el) return; + if (focus) el.focus(); + el.scrollIntoView({ block: 'nearest' }); + } + + private clearTree() { + this.stopTree?.(); + this.stopTree = null; + } + + async loadInjectorTree(client: DevframeRpcClient) { + const load = ++this.treeLoad; + this.clearTree(); + const my = client.scope('pangular'); + let state: Awaited>; + try { + state = await my.rpc.sharedState('injector-tree'); + } catch { + if (load === this.treeLoad) this.treeFailed.set(true); + return; + } + if (this.destroyRef.destroyed || this.rpc() !== client || load !== this.treeLoad) return; + this.treeFailed.set(false); + const apply = (value: unknown) => { + const next = value && typeof value === 'object' ? (value as InjectorTreeState) : null; + this.notePageGone(next?.pages); + this.treeState.set(next); + this.syncGone(); + }; + apply(state.value()); + this.clearTree(); + this.stopTree = state.on('updated', apply); + } + + async loadSourceProviders(client: DevframeRpcClient) { + const my = client.scope('pangular'); + try { + this.sourceProviders.set((await my.rpc.call('get-providers')) as SourceProvider[]); + this.sourceFailed.set(false); + } catch { + this.sourceProviders.set([]); + this.sourceFailed.set(true); + } finally { + this.loaded.set(true); + } + } + + private count(nodes: InjectorNode[]): number { + return nodes.reduce((sum, n) => sum + 1 + this.count(n.children), 0); + } + + nodeMatches(node: InjectorNode, q: string): boolean { + const i = node.injector; + return ( + i.name.toLowerCase().includes(q) || + !!i.component?.toLowerCase().includes(q) || + !!i.directives?.some((d) => d.toLowerCase().includes(q)) || + node.providers.some((p) => p.token.toLowerCase().includes(q)) || + !!node.dependencies?.some((d) => d.token.toLowerCase().includes(q)) + ); + } + + /** Leaves framework injectors out while they are hidden, moving their children up a level. */ + private withoutFramework(nodes: InjectorNode[]): InjectorNode[] { + if (!this.hideFramework()) return nodes; + const lift = (list: InjectorNode[]): InjectorNode[] => + list.flatMap((node) => + node.injector.framework + ? lift(node.children) + : [{ ...node, children: lift(node.children) }], + ); + return lift(nodes); + } + + private prune(nodes: InjectorNode[], keep: (node: InjectorNode) => boolean): InjectorNode[] { + const out: InjectorNode[] = []; + for (const node of nodes) { + const children = this.prune(node.children, keep); + if (keep(node) || children.length) out.push({ ...node, children }); + } + return out; + } +} diff --git a/app/src/pages/injectors/source-providers.html b/app/src/pages/injectors/source-providers.html new file mode 100644 index 00000000..b078caf7 --- /dev/null +++ b/app/src/pages/injectors/source-providers.html @@ -0,0 +1,55 @@ +@for (group of groupedProviders(); track group.type) { +
    + @if (group.type === helperType) { +

    + +

    +

    + provide*() calls the scan can't resolve to the tokens they register, such as your own + provider functions. +

    + } @else { +

    + {{ group.label }} {{ group.items.length }} +

    + } +
      + @for (p of group.items; track p.token + p.file + p.line) { +
    • +
      + {{ p.token }} + @if (p.providedIn) { + providedIn: {{ p.providedIn }} + } + @if (p.type === helperType) { + helper (unresolved) + } +
      +
      + + @if (p.source !== 'class' && p.source !== 'providers array' && p.type !== helperType) { + as {{ p.source }} + } +
      +
    • + } +
    +
    +} diff --git a/app/src/pages/injectors/source-providers.scss b/app/src/pages/injectors/source-providers.scss new file mode 100644 index 00000000..1532e1c8 --- /dev/null +++ b/app/src/pages/injectors/source-providers.scss @@ -0,0 +1,89 @@ +@use 'mixins' as m; +@use 'injectors' as inj; + +@include inj.hint; +// The source link of each card has the class path too, so it keeps these as it did on the page. +@include inj.path; +@include inj.token; +@include inj.flag; +@include inj.buttons; +@include inj.state-buttons; +@include inj.chevron; +@include inj.toggle; +.helper-hint { + margin: 0 0 8px; +} +@include inj.hidden; +.provider-group h2 { + @include m.label; + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 8px; +} +.section-count { + padding: 0 6px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 10px; + letter-spacing: 0; + line-height: 16px; + font-variant-numeric: tabular-nums; +} +.provider-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.provider-card { + min-width: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 12px; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease); +} +.provider-card:hover { + background: var(--surface-2); + border-color: var(--border-strong); +} +.provider-header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 8px; +} +.provider-header .token { + font-size: 13px; + font-weight: 600; +} +.provided-in { + font-size: 11px; + padding: 1px 8px; + border-radius: 99px; + @include m.tint(ok); + border-width: 1px; + border-style: solid; +} +.provider-meta { + display: flex; + flex-wrap: wrap; + gap: 0 8px; + margin-top: 4px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-3); +} +.provider-meta .path { + min-width: 0; + overflow-wrap: anywhere; +} +.provider-meta .as { + color: var(--text-2); +} diff --git a/app/src/pages/injectors/source-providers.ts b/app/src/pages/injectors/source-providers.ts new file mode 100644 index 00000000..d47ca93c --- /dev/null +++ b/app/src/pages/injectors/source-providers.ts @@ -0,0 +1,20 @@ +import { Component, inject } from '@angular/core'; +import { SourceLink } from '../../ui/source-link'; +import { HELPER_TYPE } from './injector-model'; +import { SourceScan } from './source-scan'; + +/** The providers the source scan found, by kind, shown while no page reports a live tree. */ +@Component({ + selector: 'div[appSourceProviders]', + imports: [SourceLink], + templateUrl: './source-providers.html', + styleUrl: './source-providers.scss', +}) +export class SourceProviders { + private readonly scan = inject(SourceScan); + + readonly helperType = HELPER_TYPE; + readonly groupedProviders = this.scan.groupedProviders; + readonly helpersOpen = this.scan.helpersOpen; + readonly helpersShown = this.scan.helpersShown; +} diff --git a/app/src/pages/injectors/source-scan.ts b/app/src/pages/injectors/source-scan.ts new file mode 100644 index 00000000..36475700 --- /dev/null +++ b/app/src/pages/injectors/source-scan.ts @@ -0,0 +1,104 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { HELPER_TYPE, PROVIDING_TYPES, type SourceProvider } from './injector-model'; +import { InjectorsState } from './injectors-state'; + +/** + * What the Injectors page reads from the source scan: the provider groups shown without a live + * page, the providers nothing on the page injected, and where each token is declared. Provided + * by the page next to {@link InjectorsState}. + */ +@Injectable() +export class SourceScan { + private readonly state = inject(InjectorsState); + + readonly groupedProviders = computed(() => { + const q = this.state.normalizedQuery(); + const filtered = q + ? this.state + .sourceProviders() + .filter((p) => p.token.toLowerCase().includes(q) || p.file.toLowerCase().includes(q)) + : this.state.sourceProviders(); + const groups: { type: string; label: string; items: SourceProvider[] }[] = [ + { type: 'root-provider', label: 'Root Providers (provide*)', items: [] }, + { type: 'injectable', label: 'Injectable Services', items: [] }, + { type: 'injection', label: 'inject() Calls', items: [] }, + { type: 'provider', label: 'Component Providers', items: [] }, + { type: HELPER_TYPE, label: 'Helpers (unresolved)', items: [] }, + ]; + for (const p of filtered) groups.find((g) => g.type === p.type)?.items.push(p); + return groups.filter((g) => g.items.length > 0); + }); + + readonly sourceMatchCount = computed(() => + this.groupedProviders().reduce((sum, g) => sum + g.items.length, 0), + ); + + /** The unresolved provide*() helpers start collapsed; a search shows them. */ + readonly helpersOpen = signal(false); + readonly helpersShown = computed(() => this.helpersOpen() || !!this.state.normalizedQuery()); + + /** + * Providers the source declares (`providers` entries and injectables) whose + * token nothing on the shown page injected: no element injector and no + * created service lists it among its dependencies. A `providedIn` service + * that an environment injector already created counts as injected. Matched + * by name, so this is only a hint. + */ + readonly orphaned = computed(() => { + const roots = this.state.roots(); + if (!roots.length) return []; + const injected = new Set(); + const created = new Set(); + for (const node of this.state.index().map.values()) { + for (const dep of node.dependencies ?? []) injected.add(dep.token); + if (node.injector.type !== 'element') { + for (const p of node.providers) created.add(p.token); + } + } + const out: SourceProvider[] = []; + const seen = new Set(); + for (const p of this.state.sourceProviders()) { + if (!PROVIDING_TYPES.has(p.type)) continue; + // `Feature.Token` in source is the token `Token` on the page. + const name = p.token.slice(p.token.lastIndexOf('.') + 1); + if (injected.has(name) || (p.providedIn && created.has(name))) continue; + const key = `${p.token}|${p.file}|${p.line}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(p); + } + return out; + }); + readonly orphansOpen = signal(false); + + readonly orphanedText = computed(() => { + const count = new Set(this.orphaned().map((p) => p.token)).size; + return count === 1 + ? '1 provider in source is never injected on this page' + : `${count} providers in source are never injected on this page`; + }); + + /** + * Where the source scan declares each token: `providers` entries, injectable + * classes and known provide*() functions, by token name (the last part of + * `Feature.Token`). Joins the live Provides list to the source. + */ + private readonly sourceByToken = computed(() => { + const map = new Map(); + for (const p of this.state.sourceProviders()) { + if (!PROVIDING_TYPES.has(p.type) && p.type !== 'root-provider') continue; + const name = p.token.slice(p.token.lastIndexOf('.') + 1); + let list = map.get(name); + if (!list) map.set(name, (list = [])); + if (!list.some((q) => q.file === p.file && q.line === p.line)) list.push(p); + } + return map; + }); + + /** Up to three source places that declare `token`, and how many more. */ + sourceOf(token: string): { shown: SourceProvider[]; more: number } | null { + const list = this.sourceByToken().get(token); + if (!list?.length) return null; + return { shown: list.slice(0, 3), more: Math.max(0, list.length - 3) }; + } +} diff --git a/app/src/pages/network-inspector.html b/app/src/pages/network-inspector.html new file mode 100644 index 00000000..fa108811 --- /dev/null +++ b/app/src/pages/network-inspector.html @@ -0,0 +1,59 @@ +

    + HttpClient calls made while rendering on the server and in the browser, from the live page and the + devtools server, with hydration stats, the TransferState payload and fault injection. +

    +@if (loading()) { + Loading HTTP calls… +} @else if (failed()) { + + Could not reach the devtools server. +

    Check that the app's dev server is running with the devtools plugin.

    +
    +} + +
    +
    + Page + +
    + + +

    {{ message() }}

    +
    + +
    +
    +
    +
    +

    HTTP lint

    + +
    +
    +
    +
    +
    +
    diff --git a/app/src/pages/network-inspector.scss b/app/src/pages/network-inspector.scss new file mode 100644 index 00000000..02e71d46 --- /dev/null +++ b/app/src/pages/network-inspector.scss @@ -0,0 +1,125 @@ +@use 'mixins' as m; +@use 'network' as net; + +.page-intro { + margin: 0 0 12px; + color: var(--text-2); + line-height: 1.5; +} + +.toolbar[hidden], +.grid[hidden] { + display: none; +} + +:host { + display: block; + color: var(--text); + font-size: 13px; +} +.state { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 16px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-2); +} +.state.error { + flex-direction: column; + align-items: flex-start; + gap: 4px; + @include m.tint(danger); +} +.state.error strong { + font-weight: 600; +} +.state.error span { + color: var(--text); +} +.state.error button { + margin-top: 4px; +} +.spinner { + width: 14px; + height: 14px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +.toolbar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin: 0 0 16px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--surface) 85%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} +.toolbar .page-picker { + display: flex; + flex: 0 1 auto; + align-items: center; + gap: 8px; + min-width: 0; +} +.page-picker > span { + @include m.label; +} +.toolbar app-select { + width: 280px; + max-width: 420px; +} +.message { + flex: 1 1 200px; + min-width: 0; + margin: 0; + color: var(--text-2); + font-size: 12px; + text-align: right; +} +.message:empty { + display: none; +} +.grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.panel { + @include m.panel; + @include m.enter; + min-width: 0; + padding: 16px; +} +.wide { + grid-column: 1 / -1; +} +@include net.controls; +@media (max-width: 760px) { + .grid { + grid-template-columns: minmax(0, 1fr); + } + .toolbar .page-picker { + flex: 1 1 100%; + } + .toolbar app-select { + flex: 1 1 auto; + width: auto; + max-width: none; + } + .message { + text-align: left; + } +} diff --git a/app/src/pages/network-inspector.ts b/app/src/pages/network-inspector.ts index d7038aa9..0a963b60 100644 --- a/app/src/pages/network-inspector.ts +++ b/app/src/pages/network-inspector.ts @@ -42,194 +42,8 @@ import { TransferPayload } from './network/transfer-payload'; TransferPayload, ], providers: [NetworkState, RuleState, OverrideState], - template: ` -

    - HttpClient calls made while rendering on the server and in the browser, from the live page and - the devtools server, with hydration stats, the TransferState payload and fault injection. -

    - @if (loading()) { - Loading HTTP calls… - } @else if (failed()) { - - Could not reach the devtools server. -

    Check that the app's dev server is running with the devtools plugin.

    -
    - } - -
    -
    - Page - -
    - - -

    {{ message() }}

    -
    - -
    -
    -
    -
    -

    HTTP lint

    - -
    -
    -
    -
    -
    -
    - `, - styles: ` - @use 'mixins' as m; - @use 'network' as net; - - .page-intro { - margin: 0 0 12px; - color: var(--text-2); - line-height: 1.5; - } - - .toolbar[hidden], - .grid[hidden] { - display: none; - } - - :host { - display: block; - color: var(--text); - font-size: 13px; - } - .state { - display: flex; - align-items: center; - gap: 8px; - margin: 0 0 16px; - padding: 10px 12px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--surface); - color: var(--text-2); - } - .state.error { - flex-direction: column; - align-items: flex-start; - gap: 4px; - @include m.tint(danger); - } - .state.error strong { - font-weight: 600; - } - .state.error span { - color: var(--text); - } - .state.error button { - margin-top: 4px; - } - .spinner { - width: 14px; - height: 14px; - border: 2px solid var(--border-strong); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - } - .toolbar { - position: sticky; - top: 0; - z-index: 2; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; - margin: 0 0 16px; - padding: 8px 12px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: color-mix(in srgb, var(--surface) 85%, transparent); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - } - .toolbar .page-picker { - display: flex; - flex: 0 1 auto; - align-items: center; - gap: 8px; - min-width: 0; - } - .page-picker > span { - @include m.label; - } - .toolbar app-select { - width: 280px; - max-width: 420px; - } - .message { - flex: 1 1 200px; - min-width: 0; - margin: 0; - color: var(--text-2); - font-size: 12px; - text-align: right; - } - .message:empty { - display: none; - } - .grid { - display: grid; - gap: 16px; - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .panel { - @include m.panel; - @include m.enter; - min-width: 0; - padding: 16px; - } - .wide { - grid-column: 1 / -1; - } - @include net.controls; - @media (max-width: 760px) { - .grid { - grid-template-columns: minmax(0, 1fr); - } - .toolbar .page-picker { - flex: 1 1 100%; - } - .toolbar app-select { - flex: 1 1 auto; - width: auto; - max-width: none; - } - .message { - text-align: left; - } - } - `, + templateUrl: './network-inspector.html', + styleUrl: './network-inspector.scss', }) export class NetworkInspector { readonly rpc = input(null); diff --git a/app/src/pages/network/call-copy.html b/app/src/pages/network/call-copy.html new file mode 100644 index 00000000..d5a7823e --- /dev/null +++ b/app/src/pages/network/call-copy.html @@ -0,0 +1,16 @@ +@if (call(); as c) { + + +} diff --git a/app/src/pages/network/call-copy.scss b/app/src/pages/network/call-copy.scss new file mode 100644 index 00000000..b7a55bd2 --- /dev/null +++ b/app/src/pages/network/call-copy.scss @@ -0,0 +1,36 @@ +@use 'mixins' as m; + +:host { + display: contents; +} +button { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 10px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: none; + color: var(--text-2); + font-size: 12px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; + transition: + background-color 0.2s var(--ease), + border-color 0.2s var(--ease), + color 0.2s var(--ease); + &:hover { + border-color: var(--border-strong); + background: var(--surface-2); + color: var(--text); + } + &:focus-visible { + @include m.focus-ring; + } +} +@media (prefers-reduced-motion: reduce) { + button { + transition: none; + } +} diff --git a/app/src/pages/network/call-copy.ts b/app/src/pages/network/call-copy.ts index 68e4917a..1465fe51 100644 --- a/app/src/pages/network/call-copy.ts +++ b/app/src/pages/network/call-copy.ts @@ -13,62 +13,8 @@ import { */ @Component({ selector: 'app-call-copy', - template: ` - @if (call(); as c) { - - - } - `, - styles: ` - @use 'mixins' as m; - - :host { - display: contents; - } - button { - display: inline-flex; - align-items: center; - height: 28px; - padding: 0 10px; - border: 1px solid transparent; - border-radius: var(--radius-sm); - background: none; - color: var(--text-2); - font-size: 12px; - font-weight: 500; - white-space: nowrap; - cursor: pointer; - transition: - background-color 0.2s var(--ease), - border-color 0.2s var(--ease), - color 0.2s var(--ease); - &:hover { - border-color: var(--border-strong); - background: var(--surface-2); - color: var(--text); - } - &:focus-visible { - @include m.focus-ring; - } - } - @media (prefers-reduced-motion: reduce) { - button { - transition: none; - } - } - `, + templateUrl: './call-copy.html', + styleUrl: './call-copy.scss', }) export class CallCopy { readonly call = input(null); diff --git a/app/src/pages/network/call-origin.html b/app/src/pages/network/call-origin.html new file mode 100644 index 00000000..e0b40fb3 --- /dev/null +++ b/app/src/pages/network/call-origin.html @@ -0,0 +1,3 @@ +@if (origin()) { + likely {{ label() }} +} diff --git a/app/src/pages/network/call-origin.scss b/app/src/pages/network/call-origin.scss new file mode 100644 index 00000000..bac23067 --- /dev/null +++ b/app/src/pages/network/call-origin.scss @@ -0,0 +1,16 @@ +:host { + display: contents; +} +.tag { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border: 1px dashed var(--border-strong); + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} diff --git a/app/src/pages/network/call-origin.ts b/app/src/pages/network/call-origin.ts index 94fa5ab5..634eec89 100644 --- a/app/src/pages/network/call-origin.ts +++ b/app/src/pages/network/call-origin.ts @@ -14,27 +14,8 @@ const LABEL: Record = { */ @Component({ selector: 'app-call-origin', - template: `@if (origin()) { - likely {{ label() }} - }`, - styles: ` - :host { - display: contents; - } - .tag { - display: inline-flex; - align-items: center; - height: 20px; - padding: 0 8px; - border: 1px dashed var(--border-strong); - border-radius: 99px; - background: var(--surface-3); - color: var(--text-2); - font-size: 11px; - font-weight: 600; - white-space: nowrap; - } - `, + templateUrl: './call-origin.html', + styleUrl: './call-origin.scss', }) export class CallOriginTag { readonly origin = input(undefined); diff --git a/app/src/pages/network/call-timeline.html b/app/src/pages/network/call-timeline.html new file mode 100644 index 00000000..7f648b96 --- /dev/null +++ b/app/src/pages/network/call-timeline.html @@ -0,0 +1,189 @@ +

    HTTP timeline ({{ timeline().length }})

    + +
    + + + +
    + + +
    + {{ countText() }} + {{ filterStatus() }} +
    +
    + +
    +@if (visibleCalls().length && timelineView() === 'waterfall') { + +} @else if (visibleCalls().length) { + @if (callWindow.enabled()) { +

    + Rows render as you scroll, so the browser's find only sees the ones near view; the search + field searches them all. +

    + } +
    + + + + + + + + + + + + + + @if (callWindow.padTop()) { + + + + } + @for (shown of shownCalls(); track shown.entry.side + shown.entry.id) { + @let entry = shown.entry; + + + + + + + + + + } + @if (callWindow.padBottom()) { + + + + } + +
    SideMethodURLPageStatusTimeNotes
    + {{ + entry.side === 'server' ? 'SSR' : 'Client' + }} + {{ entry.method }} + + + {{ entry.pageUrl ?? '' }} + + {{ statusLabel(entry) }} + {{ entry.durationMs }} ms +
    + @if (entry.nativeFetch) { + fetch() + } + @if (entry.origin) { + + } + @if (entry.cacheHit) { + transfer cache + } + @if (entry.delayMs) { + delayed {{ entry.delayMs }} ms + } + @if (entry.mocked) { + mocked + } + @if (entry.faulted) { + faulted + } + @if (rulePatternOf(entry); as pattern) { + rule {{ pattern }} + } +
    +
    +
    +} @else if (timeline().length) { +
    +

    No requests match.

    +

    + @if (callQuery().trim()) { + Nothing matches “{{ callQuery().trim() }}” with these filters. + } @else { + None of the {{ timeline().length }} requests fit these filters. + } +

    +
    + +
    +
    +} @else { +
    +

    No requests yet.

    +

    + Add withPangular() to provideHttpClient, then load a page of the + app. +

    +
    +} +@if (selectedCall(); as detail) { +
    +} diff --git a/app/src/pages/network/call-timeline.scss b/app/src/pages/network/call-timeline.scss new file mode 100644 index 00000000..74f16cec --- /dev/null +++ b/app/src/pages/network/call-timeline.scss @@ -0,0 +1,90 @@ +@use 'mixins' as m; +@use 'network' as net; + +@include net.controls; +.filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin: 0 0 12px; +} +.live-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin: -4px 0 12px; +} +.search { + position: relative; + flex: 1 1 220px; + 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; +} +.search:focus-within .search-icon { + stroke: var(--accent); +} +.filters input[type='search'] { + height: var(--control-h); + padding-left: 34px; +} +.filters .side-filter { + flex: 0 0 140px; +} +.failed-toggle { + flex: none; + height: var(--control-h); + font-size: 13px; + font-weight: 500; +} +.total { + flex: none; + color: var(--text-2); + font-size: 12px; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +/* "0 of 0" says nothing before the first call; the empty state below explains it. */ +.filters.no-calls .total { + display: none; +} +@include net.table; +@include net.status; +.view-toggle { + display: inline-flex; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + overflow: hidden; +} +.view-toggle button { + height: calc(var(--control-h) - 2px); + padding: 0 10px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--text-2); +} +.view-toggle button + button { + border-left: 1px solid var(--border-strong); +} +.view-toggle button[aria-pressed='true'] { + background: var(--accent-soft); + color: var(--text-strong); +} +.view-toggle button:focus-visible { + @include m.focus-ring(-2px); +} +@include net.preview; diff --git a/app/src/pages/network/call-timeline.ts b/app/src/pages/network/call-timeline.ts index 927eff6c..29190575 100644 --- a/app/src/pages/network/call-timeline.ts +++ b/app/src/pages/network/call-timeline.ts @@ -29,299 +29,8 @@ import { ResponsePreview } from './response-preview'; Select, SortHeader, ], - template: ` -

    HTTP timeline ({{ timeline().length }})

    - -
    - - - -
    - - -
    - {{ countText() }} - {{ filterStatus() }} -
    -
    - -
    - @if (visibleCalls().length && timelineView() === 'waterfall') { - - } @else if (visibleCalls().length) { - @if (callWindow.enabled()) { -

    - Rows render as you scroll, so the browser's find only sees the ones near view; the search - field searches them all. -

    - } -
    - - - - - - - - - - - - - - @if (callWindow.padTop()) { - - - - } - @for (shown of shownCalls(); track shown.entry.side + shown.entry.id) { - @let entry = shown.entry; - - - - - - - - - - } - @if (callWindow.padBottom()) { - - - - } - -
    SideMethodURLPageStatusTimeNotes
    - {{ - entry.side === 'server' ? 'SSR' : 'Client' - }} - {{ entry.method }} - - - {{ entry.pageUrl ?? '' }} - - {{ statusLabel(entry) }} - {{ entry.durationMs }} ms -
    - @if (entry.nativeFetch) { - fetch() - } - @if (entry.origin) { - - } - @if (entry.cacheHit) { - transfer cache - } - @if (entry.delayMs) { - delayed {{ entry.delayMs }} ms - } - @if (entry.mocked) { - mocked - } - @if (entry.faulted) { - faulted - } - @if (rulePatternOf(entry); as pattern) { - rule {{ pattern }} - } -
    -
    -
    - } @else if (timeline().length) { -
    -

    No requests match.

    -

    - @if (callQuery().trim()) { - Nothing matches “{{ callQuery().trim() }}” with these filters. - } @else { - None of the {{ timeline().length }} requests fit these filters. - } -

    -
    - -
    -
    - } @else { -
    -

    No requests yet.

    -

    - Add withPangular() to provideHttpClient, then load a page of the - app. -

    -
    - } - @if (selectedCall(); as detail) { -
    - } - `, - styles: ` - @use 'mixins' as m; - @use 'network' as net; - - @include net.controls; - .filters { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; - margin: 0 0 12px; - } - .live-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; - margin: -4px 0 12px; - } - .search { - position: relative; - flex: 1 1 220px; - 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; - } - .search:focus-within .search-icon { - stroke: var(--accent); - } - .filters input[type='search'] { - height: var(--control-h); - padding-left: 34px; - } - .filters .side-filter { - flex: 0 0 140px; - } - .failed-toggle { - flex: none; - height: var(--control-h); - font-size: 13px; - font-weight: 500; - } - .total { - flex: none; - color: var(--text-2); - font-size: 12px; - white-space: nowrap; - font-variant-numeric: tabular-nums; - } - /* "0 of 0" says nothing before the first call; the empty state below explains it. */ - .filters.no-calls .total { - display: none; - } - @include net.table; - @include net.status; - .view-toggle { - display: inline-flex; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - overflow: hidden; - } - .view-toggle button { - height: calc(var(--control-h) - 2px); - padding: 0 10px; - border: 0; - border-radius: 0; - background: transparent; - color: var(--text-2); - } - .view-toggle button + button { - border-left: 1px solid var(--border-strong); - } - .view-toggle button[aria-pressed='true'] { - background: var(--accent-soft); - color: var(--text-strong); - } - .view-toggle button:focus-visible { - @include m.focus-ring(-2px); - } - @include net.preview; - `, + templateUrl: './call-timeline.html', + styleUrl: './call-timeline.scss', }) export class CallTimeline { private readonly state = inject(NetworkState); diff --git a/app/src/pages/network/fault-rules.html b/app/src/pages/network/fault-rules.html new file mode 100644 index 00000000..eaaeaccf --- /dev/null +++ b/app/src/pages/network/fault-rules.html @@ -0,0 +1,182 @@ +

    Fault injection

    +@if (!canWrite()) { +

    {{ writesOff }}

    +} + +
    + + +
    +
    + Method + +
    +
    + Apply on + +
    +
    +
    +
    + Status + +
    + +
    + + +

    {{ bodyError() }}

    + @if (draftMocksOnServer()) { +

    + SSR mocks are not transferred to the client: the browser requests the URL again and gets the + real response unless the rule also applies on the client. +

    + } +

    {{ draftHint() }}

    +
    + +
    + + +

    Rules ({{ rules().length }})

    +
      + @for (rule of rules(); track rule.id) { +
    • + + + + {{ targetLabel(rule.target) }} + @if (rule.status === undefined) { + passthrough + } @else if (rule.status === 0) { + network error + } @else { + {{ rule.status }} + } + @if (rule.delayMs) { + {{ rule.delayMs }} ms + } + @if (rule.body) { + mock body + } + @if (rule.chance) { + {{ rule.chance }}% of requests + } + + @if (mocksOnServer(rule)) { +

      + The SSR mock is not written to TransferState, so the browser requests this URL again. +

      + } +
    • + } @empty { +
    • No rules. SSR rules apply on the next page load.
    • + } +
    diff --git a/app/src/pages/network/fault-rules.scss b/app/src/pages/network/fault-rules.scss new file mode 100644 index 00000000..c40a6eaa --- /dev/null +++ b/app/src/pages/network/fault-rules.scss @@ -0,0 +1,5 @@ +@use 'network' as net; + +@include net.controls; +@include net.status; +@include net.rule-form; diff --git a/app/src/pages/network/fault-rules.ts b/app/src/pages/network/fault-rules.ts index daa8801f..795efb98 100644 --- a/app/src/pages/network/fault-rules.ts +++ b/app/src/pages/network/fault-rules.ts @@ -18,197 +18,8 @@ import type { SelectOption } from '../../ui/select'; @Component({ selector: 'section[appFaultRules]', imports: [RuleMatchCount, RulePresets, Select], - template: ` -

    Fault injection

    - @if (!canWrite()) { -

    {{ writesOff }}

    - } - -
    - - -
    -
    - Method - -
    -
    - Apply on - -
    -
    -
    -
    - Status - -
    - -
    - - -

    {{ bodyError() }}

    - @if (draftMocksOnServer()) { -

    - SSR mocks are not transferred to the client: the browser requests the URL again and gets - the real response unless the rule also applies on the client. -

    - } -

    {{ draftHint() }}

    -
    - -
    - - -

    Rules ({{ rules().length }})

    -
      - @for (rule of rules(); track rule.id) { -
    • - - - - {{ targetLabel(rule.target) }} - @if (rule.status === undefined) { - passthrough - } @else if (rule.status === 0) { - network error - } @else { - {{ rule.status }} - } - @if (rule.delayMs) { - {{ rule.delayMs }} ms - } - @if (rule.body) { - mock body - } - @if (rule.chance) { - {{ rule.chance }}% of requests - } - - @if (mocksOnServer(rule)) { -

      - The SSR mock is not written to TransferState, so the browser requests this URL again. -

      - } -
    • - } @empty { -
    • No rules. SSR rules apply on the next page load.
    • - } -
    - `, - styles: ` - @use 'network' as net; - - @include net.controls; - @include net.status; - @include net.rule-form; - `, + templateUrl: './fault-rules.html', + styleUrl: './fault-rules.scss', }) export class FaultRules { private readonly state = inject(NetworkState); diff --git a/app/src/pages/network/har-export.html b/app/src/pages/network/har-export.html new file mode 100644 index 00000000..3aa44d78 --- /dev/null +++ b/app/src/pages/network/har-export.html @@ -0,0 +1,11 @@ + +Saves the timeline as a HAR file with secrets masked. diff --git a/app/src/pages/network/har-export.scss b/app/src/pages/network/har-export.scss new file mode 100644 index 00000000..82b33b93 --- /dev/null +++ b/app/src/pages/network/har-export.scss @@ -0,0 +1,36 @@ +@use 'mixins' as m; + +:host { + display: contents; +} +button { + display: inline-flex; + align-items: center; + height: 34px; + padding: 0 14px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface-2); + font-weight: 500; + white-space: nowrap; + cursor: pointer; + transition: + background-color 0.2s var(--ease), + border-color 0.2s var(--ease); + &:hover:not(:disabled) { + border-color: var(--accent-line); + background: var(--surface-3); + } + &:focus-visible { + @include m.focus-ring; + } + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} +@media (prefers-reduced-motion: reduce) { + button { + transition: none; + } +} diff --git a/app/src/pages/network/har-export.ts b/app/src/pages/network/har-export.ts index 1c1c2912..b6f1701c 100644 --- a/app/src/pages/network/har-export.ts +++ b/app/src/pages/network/har-export.ts @@ -24,57 +24,8 @@ export function harFileName(pageUrl: string | undefined): string { */ @Component({ selector: 'app-har-export', - template: ` - - Saves the timeline as a HAR file with secrets masked. - `, - styles: ` - @use 'mixins' as m; - - :host { - display: contents; - } - button { - display: inline-flex; - align-items: center; - height: 34px; - padding: 0 14px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--surface-2); - font-weight: 500; - white-space: nowrap; - cursor: pointer; - transition: - background-color 0.2s var(--ease), - border-color 0.2s var(--ease); - &:hover:not(:disabled) { - border-color: var(--accent-line); - background: var(--surface-3); - } - &:focus-visible { - @include m.focus-ring; - } - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - } - @media (prefers-reduced-motion: reduce) { - button { - transition: none; - } - } - `, + templateUrl: './har-export.html', + styleUrl: './har-export.scss', }) export class HarExport { readonly calls = input([]); diff --git a/app/src/pages/network/http-lint.html b/app/src/pages/network/http-lint.html new file mode 100644 index 00000000..d47d45ef --- /dev/null +++ b/app/src/pages/network/http-lint.html @@ -0,0 +1,32 @@ +

    + Same GET again within {{ defaults.duplicateWindowMs }} ms, {{ defaults.minChain }} or more calls + in a row that could start together, and server calls the browser made again. Transfer cache hits, + mocks and cancelled calls are left out. +

    +@if (!calls().length) { +

    No client calls to check yet.

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

    No duplicate requests, waterfalls or repeated server calls.

    +} @else { +
      + @for (finding of findings(); track finding.kind + ':' + finding.callIds.join(',')) { +
    • + {{ kindLabel(finding.kind) }} +

      + {{ finding.message }} + @if (finding.wastedMs) { + About {{ finding.wastedMs }} ms to win. + } +

      +

      {{ finding.hint }}

      + +
    • + } +
    +} diff --git a/app/src/pages/network/http-lint.scss b/app/src/pages/network/http-lint.scss new file mode 100644 index 00000000..14e53561 --- /dev/null +++ b/app/src/pages/network/http-lint.scss @@ -0,0 +1,84 @@ +@use 'mixins' as m; + +:host { + display: block; +} +.intro, +.none, +.hint { + margin: 0; + color: var(--text-2); + font-size: 12px; +} +.none { + margin-top: 8px; +} +.findings { + display: grid; + gap: 8px; + margin: 10px 0 0; + padding: 0; + list-style: none; +} +li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 12px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); +} +.kind { + @include m.label; + grid-column: 1; +} +.message { + grid-column: 1; + margin: 0; + color: var(--text); + font-size: 13px; + overflow-wrap: anywhere; +} +.cost { + color: var(--text-2); +} +.hint { + grid-column: 1; +} +button { + grid-column: 2; + grid-row: 1 / span 3; + align-self: center; + height: 28px; + padding: 0 10px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font-size: 12px; + white-space: nowrap; + cursor: pointer; + transition: background-color 0.2s var(--ease); + &:hover { + background: var(--surface-3); + } + &:focus-visible { + @include m.focus-ring; + } +} +@media (max-width: 640px) { + li { + grid-template-columns: minmax(0, 1fr); + } + button { + grid-column: 1; + grid-row: auto; + justify-self: start; + } +} +@media (prefers-reduced-motion: reduce) { + button { + transition: none; + } +} diff --git a/app/src/pages/network/http-lint.ts b/app/src/pages/network/http-lint.ts index 06a7b3e8..5516de6a 100644 --- a/app/src/pages/network/http-lint.ts +++ b/app/src/pages/network/http-lint.ts @@ -20,126 +20,8 @@ const KIND_LABEL: Record = { */ @Component({ selector: 'app-http-lint', - template: ` -

    - Same GET again within {{ defaults.duplicateWindowMs }} ms, {{ defaults.minChain }} or more - calls in a row that could start together, and server calls the browser made again. Transfer - cache hits, mocks and cancelled calls are left out. -

    - @if (!calls().length) { -

    No client calls to check yet.

    - } @else if (!findings().length) { -

    No duplicate requests, waterfalls or repeated server calls.

    - } @else { -
      - @for (finding of findings(); track finding.kind + ':' + finding.callIds.join(',')) { -
    • - {{ kindLabel(finding.kind) }} -

      - {{ finding.message }} - @if (finding.wastedMs) { - About {{ finding.wastedMs }} ms to win. - } -

      -

      {{ finding.hint }}

      - -
    • - } -
    - } - `, - styles: ` - @use 'mixins' as m; - - :host { - display: block; - } - .intro, - .none, - .hint { - margin: 0; - color: var(--text-2); - font-size: 12px; - } - .none { - margin-top: 8px; - } - .findings { - display: grid; - gap: 8px; - margin: 10px 0 0; - padding: 0; - list-style: none; - } - li { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 4px 12px; - padding: 8px 10px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--surface-2); - } - .kind { - @include m.label; - grid-column: 1; - } - .message { - grid-column: 1; - margin: 0; - color: var(--text); - font-size: 13px; - overflow-wrap: anywhere; - } - .cost { - color: var(--text-2); - } - .hint { - grid-column: 1; - } - button { - grid-column: 2; - grid-row: 1 / span 3; - align-self: center; - height: 28px; - padding: 0 10px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--surface); - color: var(--text); - font-size: 12px; - white-space: nowrap; - cursor: pointer; - transition: background-color 0.2s var(--ease); - &:hover { - background: var(--surface-3); - } - &:focus-visible { - @include m.focus-ring; - } - } - @media (max-width: 640px) { - li { - grid-template-columns: minmax(0, 1fr); - } - button { - grid-column: 1; - grid-row: auto; - justify-self: start; - } - } - @media (prefers-reduced-motion: reduce) { - button { - transition: none; - } - } - `, + templateUrl: './http-lint.html', + styleUrl: './http-lint.scss', }) export class HttpLint { /** The selected page's client calls. */ diff --git a/app/src/pages/network/hydration-stats.html b/app/src/pages/network/hydration-stats.html new file mode 100644 index 00000000..f64a9cc1 --- /dev/null +++ b/app/src/pages/network/hydration-stats.html @@ -0,0 +1,95 @@ +

    Hydration

    +@if (selected()?.hydration; as h) { +
    +
    +
    Enabled
    +
    + {{ + h.enabled ? 'yes' : 'no (client render only)' + }} +
    +
    +
    +
    Hydrated components
    +
    {{ h.hydratedComponents ?? 'n/a' }}
    +
    +
    +
    Hydrated nodes
    +
    {{ h.hydratedNodes ?? 'n/a' }}
    +
    +
    +
    Skipped components
    +
    {{ h.componentsSkippedHydration ?? 'n/a' }}
    +
    +
    +
    App stable after
    +
    {{ h.stableMs === undefined ? 'n/a' : h.stableMs + ' ms' }}
    +
    +
    +
    Incremental defer blocks
    +
    {{ h.deferBlocksWithIncrementalHydration ?? 'n/a' }}
    +
    + @if (h.nodes; as n) { +
    +
    DOM nodes hydrated
    +
    {{ n.hydrated }}
    +
    +
    +
    DOM nodes skipped
    +
    {{ n.skipped }}
    +
    +
    +
    Mismatched components
    +
    {{ n.mismatched }}
    +
    + } +
    + @if (h.mismatches?.length) { +

    Mismatches ({{ h.mismatches!.length }})

    +
      + @for (m of h.mismatches; track $index) { +
    • + {{ m.isClass ? m.component : '<' + m.component + '>' }} + @if (m.expected) { + Expected {{ m.expected }} + } + @if (m.actual) { + Actual {{ m.actual }} + } +
    • + } +
    + } + @if (h.skipHydrationHosts.length) { +

    ngSkipHydration hosts

    +
      + @for (host of h.skipHydrationHosts; track $index) { +
    • + {{ host }} +
    • + } +
    + } + @if (h.warningsCaptured === false) { +

    Warnings

    +

    + Not captured: add providePangularHttp() to the app providers. +

    + } @else { +

    Warnings ({{ h.warnings.length }})

    + @for (warning of h.warnings; track $index) { +
    {{ warning }}
    + } @empty { +

    No hydration warnings.

    + } + } +} @else { +
    +

    No hydration data for this page.

    +

    Open a server rendered page of the app to see its stats.

    +
    +} diff --git a/app/src/pages/network/hydration-stats.scss b/app/src/pages/network/hydration-stats.scss new file mode 100644 index 00000000..c5abb627 --- /dev/null +++ b/app/src/pages/network/hydration-stats.scss @@ -0,0 +1,39 @@ +@use 'network' as net; + +@include net.controls; +.mismatches li { + display: grid; + gap: 4px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); +} +.mismatch-row { + display: flex; + gap: 8px; + min-width: 0; + font-size: 12px; +} +.mismatch-row code { + min-width: 0; + overflow-wrap: anywhere; +} +.mismatch-label { + flex: none; + width: 64px; + color: var(--text-2); +} +@include net.status; +@include net.pre; +.plain { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} +.plain li { + overflow-wrap: anywhere; +} +@include net.stats; diff --git a/app/src/pages/network/hydration-stats.ts b/app/src/pages/network/hydration-stats.ts index 243ef7e0..3f664d7f 100644 --- a/app/src/pages/network/hydration-stats.ts +++ b/app/src/pages/network/hydration-stats.ts @@ -4,144 +4,8 @@ import { NetworkState } from './network-state'; /** The hydration stats, mismatches and warnings of the page selected on the Network page. */ @Component({ selector: 'section[appHydrationStats]', - template: ` -

    Hydration

    - @if (selected()?.hydration; as h) { -
    -
    -
    Enabled
    -
    - {{ - h.enabled ? 'yes' : 'no (client render only)' - }} -
    -
    -
    -
    Hydrated components
    -
    {{ h.hydratedComponents ?? 'n/a' }}
    -
    -
    -
    Hydrated nodes
    -
    {{ h.hydratedNodes ?? 'n/a' }}
    -
    -
    -
    Skipped components
    -
    {{ h.componentsSkippedHydration ?? 'n/a' }}
    -
    -
    -
    App stable after
    -
    {{ h.stableMs === undefined ? 'n/a' : h.stableMs + ' ms' }}
    -
    -
    -
    Incremental defer blocks
    -
    {{ h.deferBlocksWithIncrementalHydration ?? 'n/a' }}
    -
    - @if (h.nodes; as n) { -
    -
    DOM nodes hydrated
    -
    {{ n.hydrated }}
    -
    -
    -
    DOM nodes skipped
    -
    {{ n.skipped }}
    -
    -
    -
    Mismatched components
    -
    {{ n.mismatched }}
    -
    - } -
    - @if (h.mismatches?.length) { -

    Mismatches ({{ h.mismatches!.length }})

    -
      - @for (m of h.mismatches; track $index) { -
    • - {{ m.isClass ? m.component : '<' + m.component + '>' }} - @if (m.expected) { - Expected {{ m.expected }} - } - @if (m.actual) { - Actual {{ m.actual }} - } -
    • - } -
    - } - @if (h.skipHydrationHosts.length) { -

    ngSkipHydration hosts

    -
      - @for (host of h.skipHydrationHosts; track $index) { -
    • - {{ host }} -
    • - } -
    - } - @if (h.warningsCaptured === false) { -

    Warnings

    -

    - Not captured: add providePangularHttp() to the app providers. -

    - } @else { -

    Warnings ({{ h.warnings.length }})

    - @for (warning of h.warnings; track $index) { -
    {{ warning }}
    - } @empty { -

    No hydration warnings.

    - } - } - } @else { -
    -

    No hydration data for this page.

    -

    Open a server rendered page of the app to see its stats.

    -
    - } - `, - styles: ` - @use 'network' as net; - - @include net.controls; - .mismatches li { - display: grid; - gap: 4px; - padding: 8px 12px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--surface-2); - } - .mismatch-row { - display: flex; - gap: 8px; - min-width: 0; - font-size: 12px; - } - .mismatch-row code { - min-width: 0; - overflow-wrap: anywhere; - } - .mismatch-label { - flex: none; - width: 64px; - color: var(--text-2); - } - @include net.status; - @include net.pre; - .plain { - display: grid; - gap: 8px; - margin: 0; - padding: 0; - list-style: none; - } - .plain li { - overflow-wrap: anywhere; - } - @include net.stats; - `, + templateUrl: './hydration-stats.html', + styleUrl: './hydration-stats.scss', }) export class HydrationStats { protected readonly selected = inject(NetworkState).selected; diff --git a/app/src/pages/network/payload-insights.html b/app/src/pages/network/payload-insights.html new file mode 100644 index 00000000..db9023af --- /dev/null +++ b/app/src/pages/network/payload-insights.html @@ -0,0 +1,37 @@ +@let info = insights(); +@if (info.overBudget) { +

    + {{ bytes(info.size) }}, over the {{ bytes(info.budget) }} budget. + The payload is inlined in the HTML, so it delays the first render. Look at the largest entries + below. +

    +} @else { +

    {{ bytes(info.size) }}, within the {{ bytes(info.budget) }} budget.

    +} +@if (info.largest.length) { +

    Largest entries

    +
      + @for (item of info.largest; track item.key) { +
    1. + {{ item.label }} + {{ bytes(item.size) }}, {{ item.share }}% + +
    2. + } +
    +} +@if (info.sameUrl.length) { +

    Same URL stored more than once ({{ info.sameUrl.length }})

    +

    + Each key holds its own response, because the headers, params or body differ. Check whether one + request could serve them all. +

    +
      + @for (group of info.sameUrl; track group.url) { +
    • + {{ group.url }} + {{ group.keys.length }} keys, {{ bytes(group.size) }} +
    • + } +
    +} diff --git a/app/src/pages/network/payload-insights.scss b/app/src/pages/network/payload-insights.scss new file mode 100644 index 00000000..08a9e89c --- /dev/null +++ b/app/src/pages/network/payload-insights.scss @@ -0,0 +1,62 @@ +@use 'mixins' as m; + +:host { + display: block; + margin-bottom: 12px; +} +h3 { + @include m.label; + margin: 12px 0 6px; +} +.budget, +.hint { + margin: 0; + color: var(--text-2); + font-size: 12px; +} +.budget.over { + padding: 8px 10px; + border: 1px solid var(--border-strong); + border-left: 3px solid var(--warn); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); +} +ol, +ul { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} +li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 8px; + font-size: 12px; +} +.name { + @include m.truncate; + font-family: var(--font-mono); + color: var(--text); +} +.size { + color: var(--text-2); + font-variant-numeric: tabular-nums; +} +.bar { + grid-column: 1 / -1; + height: 4px; + border-radius: 2px; + background: var(--surface-3); + overflow: hidden; + span { + display: block; + height: 100%; + background: var(--accent); + } +} +.same-url { + margin-top: 6px; +} diff --git a/app/src/pages/network/payload-insights.ts b/app/src/pages/network/payload-insights.ts index e411c45f..ba0bc4d9 100644 --- a/app/src/pages/network/payload-insights.ts +++ b/app/src/pages/network/payload-insights.ts @@ -12,111 +12,8 @@ import { */ @Component({ selector: 'app-payload-insights', - template: ` - @let info = insights(); - @if (info.overBudget) { -

    - {{ bytes(info.size) }}, over the {{ bytes(info.budget) }} budget. - The payload is inlined in the HTML, so it delays the first render. Look at the largest - entries below. -

    - } @else { -

    {{ bytes(info.size) }}, within the {{ bytes(info.budget) }} budget.

    - } - @if (info.largest.length) { -

    Largest entries

    -
      - @for (item of info.largest; track item.key) { -
    1. - {{ item.label }} - {{ bytes(item.size) }}, {{ item.share }}% - -
    2. - } -
    - } - @if (info.sameUrl.length) { -

    - Same URL stored more than once ({{ info.sameUrl.length }}) -

    -

    - Each key holds its own response, because the headers, params or body differ. Check whether - one request could serve them all. -

    -
      - @for (group of info.sameUrl; track group.url) { -
    • - {{ group.url }} - {{ group.keys.length }} keys, {{ bytes(group.size) }} -
    • - } -
    - } - `, - styles: ` - @use 'mixins' as m; - - :host { - display: block; - margin-bottom: 12px; - } - h3 { - @include m.label; - margin: 12px 0 6px; - } - .budget, - .hint { - margin: 0; - color: var(--text-2); - font-size: 12px; - } - .budget.over { - padding: 8px 10px; - border: 1px solid var(--border-strong); - border-left: 3px solid var(--warn); - border-radius: var(--radius-sm); - background: var(--surface-2); - color: var(--text); - } - ol, - ul { - display: grid; - gap: 6px; - margin: 0; - padding: 0; - list-style: none; - } - li { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 2px 8px; - font-size: 12px; - } - .name { - @include m.truncate; - font-family: var(--font-mono); - color: var(--text); - } - .size { - color: var(--text-2); - font-variant-numeric: tabular-nums; - } - .bar { - grid-column: 1 / -1; - height: 4px; - border-radius: 2px; - background: var(--surface-3); - overflow: hidden; - span { - display: block; - height: 100%; - background: var(--accent); - } - } - .same-url { - margin-top: 6px; - } - `, + templateUrl: './payload-insights.html', + styleUrl: './payload-insights.scss', }) export class PayloadInsights { readonly size = input(0); diff --git a/app/src/pages/network/response-preview.html b/app/src/pages/network/response-preview.html new file mode 100644 index 00000000..6c936707 --- /dev/null +++ b/app/src/pages/network/response-preview.html @@ -0,0 +1,66 @@ +@let detail = call(); +
    +

    Response preview

    +
    + @if (!detail.error && detail.preview) { + + } + + @if (!detail.nativeFetch) { + + } + +
    +
    +

    + {{ detail.method }} + {{ statusLabel(detail) }} + {{ detail.url }} +

    +@if (detail.nativeFetch) { +

    + Made with fetch() directly, which skips the HttpClient interceptor, so a fault rule + cannot mock it. +

    +} +@if (rulePatternOf(detail); as pattern) { +

    + Matched rule {{ pattern }} +

    +} + + +
    {{ detail.error ?? previewText(detail) ?? '(no body)' }}
    + diff --git a/app/src/pages/network/response-preview.scss b/app/src/pages/network/response-preview.scss new file mode 100644 index 00000000..8bc7b64d --- /dev/null +++ b/app/src/pages/network/response-preview.scss @@ -0,0 +1,6 @@ +@use 'network' as net; + +@include net.controls; +@include net.status; +@include net.pre; +@include net.preview; diff --git a/app/src/pages/network/response-preview.ts b/app/src/pages/network/response-preview.ts index 0fec762c..784764cc 100644 --- a/app/src/pages/network/response-preview.ts +++ b/app/src/pages/network/response-preview.ts @@ -13,82 +13,8 @@ import { RuleState } from './rule-state'; @Component({ selector: 'div[appResponsePreview]', imports: [CallCopy, HttpHeadersView, RedactionCount], - template: ` - @let detail = call(); -
    -

    Response preview

    -
    - @if (!detail.error && detail.preview) { - - } - - @if (!detail.nativeFetch) { - - } - -
    -
    -

    - {{ detail.method }} - {{ statusLabel(detail) }} - {{ detail.url }} -

    - @if (detail.nativeFetch) { -

    - Made with fetch() directly, which skips the HttpClient interceptor, so a fault - rule cannot mock it. -

    - } - @if (rulePatternOf(detail); as pattern) { -

    - Matched rule {{ pattern }} -

    - } - - -
    {{ detail.error ?? previewText(detail) ?? '(no body)' }}
    - - `, - styles: ` - @use 'network' as net; - - @include net.controls; - @include net.status; - @include net.pre; - @include net.preview; - `, + templateUrl: './response-preview.html', + styleUrl: './response-preview.scss', }) export class ResponsePreview { readonly call = input.required(); diff --git a/app/src/pages/network/rule-match-count.html b/app/src/pages/network/rule-match-count.html new file mode 100644 index 00000000..4642161a --- /dev/null +++ b/app/src/pages/network/rule-match-count.html @@ -0,0 +1 @@ +

    {{ text() }}

    diff --git a/app/src/pages/network/rule-match-count.scss b/app/src/pages/network/rule-match-count.scss new file mode 100644 index 00000000..6fc45c86 --- /dev/null +++ b/app/src/pages/network/rule-match-count.scss @@ -0,0 +1,9 @@ +:host { + display: block; +} +.match { + margin: 0; + min-height: 1.5em; + color: var(--text-2); + font-size: 12px; +} diff --git a/app/src/pages/network/rule-match-count.ts b/app/src/pages/network/rule-match-count.ts index 870c1660..1e003367 100644 --- a/app/src/pages/network/rule-match-count.ts +++ b/app/src/pages/network/rule-match-count.ts @@ -15,18 +15,8 @@ export interface MatchCountCall { */ @Component({ selector: 'app-rule-match-count', - template: `

    {{ text() }}

    `, - styles: ` - :host { - display: block; - } - .match { - margin: 0; - min-height: 1.5em; - color: var(--text-2); - font-size: 12px; - } - `, + templateUrl: './rule-match-count.html', + styleUrl: './rule-match-count.scss', }) export class RuleMatchCount { readonly calls = input([]); diff --git a/app/src/pages/network/rule-presets.html b/app/src/pages/network/rule-presets.html new file mode 100644 index 00000000..39874eed --- /dev/null +++ b/app/src/pages/network/rule-presets.html @@ -0,0 +1,24 @@ +
    + Presets +
      + @for (preset of presets; track preset.id) { +
    • + + {{ preset.description }} +
    • + } +
    +

    + A preset adds plain rules for every browser request. Rules above it match first. Remove the + rules to stop it. +

    +
    diff --git a/app/src/pages/network/rule-presets.scss b/app/src/pages/network/rule-presets.scss new file mode 100644 index 00000000..2c7a3ec0 --- /dev/null +++ b/app/src/pages/network/rule-presets.scss @@ -0,0 +1,58 @@ +@use 'mixins' as m; + +:host { + display: block; + margin-bottom: 12px; +} +.label { + @include m.label; + display: block; + margin-bottom: 6px; +} +ul { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} +li { + display: grid; + grid-template-columns: 7.5em minmax(0, 1fr); + align-items: center; + gap: 8px; +} +button { + height: var(--control-h); + padding: 0 10px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font: inherit; + cursor: pointer; + transition: background-color 150ms var(--ease); + &:hover:not(:disabled) { + background: var(--surface-2); + } + &:focus-visible { + @include m.focus-ring; + } + &:disabled { + cursor: not-allowed; + color: var(--text-2); + } +} +.what, +.note { + color: var(--text-2); + font-size: 12px; +} +.note { + margin: 6px 0 0; +} +@media (prefers-reduced-motion: reduce) { + button { + transition: none; + } +} diff --git a/app/src/pages/network/rule-presets.ts b/app/src/pages/network/rule-presets.ts index c30ed7a8..c0201da5 100644 --- a/app/src/pages/network/rule-presets.ts +++ b/app/src/pages/network/rule-presets.ts @@ -7,92 +7,8 @@ import { HTTP_RULE_PRESETS } from '@pangular-inspector/devtools/internal/http-da */ @Component({ selector: 'app-rule-presets', - template: ` -
    - Presets -
      - @for (preset of presets; track preset.id) { -
    • - - {{ preset.description }} -
    • - } -
    -

    - A preset adds plain rules for every browser request. Rules above it match first. Remove the - rules to stop it. -

    -
    - `, - styles: ` - @use 'mixins' as m; - - :host { - display: block; - margin-bottom: 12px; - } - .label { - @include m.label; - display: block; - margin-bottom: 6px; - } - ul { - display: grid; - gap: 6px; - margin: 0; - padding: 0; - list-style: none; - } - li { - display: grid; - grid-template-columns: 7.5em minmax(0, 1fr); - align-items: center; - gap: 8px; - } - button { - height: var(--control-h); - padding: 0 10px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--surface); - color: var(--text); - font: inherit; - cursor: pointer; - transition: background-color 150ms var(--ease); - &:hover:not(:disabled) { - background: var(--surface-2); - } - &:focus-visible { - @include m.focus-ring; - } - &:disabled { - cursor: not-allowed; - color: var(--text-2); - } - } - .what, - .note { - color: var(--text-2); - font-size: 12px; - } - .note { - margin: 6px 0 0; - } - @media (prefers-reduced-motion: reduce) { - button { - transition: none; - } - } - `, + templateUrl: './rule-presets.html', + styleUrl: './rule-presets.scss', }) export class RulePresets { readonly disabled = input(false); diff --git a/app/src/pages/network/ssr-overrides.html b/app/src/pages/network/ssr-overrides.html new file mode 100644 index 00000000..787774ef --- /dev/null +++ b/app/src/pages/network/ssr-overrides.html @@ -0,0 +1,143 @@ +

    SSR overrides

    +

    + Development only. These change what the server sends for matching pages, from the next full page + load, and the request is marked overridden. +

    +
    +
    + Override + +
    + + @switch (overrideDraft().kind) { + @case ('render-error') { +

    + Throws while the server renders, so it needs providePangularHttp() and a page + the engine renders. Prerendered files served as static files skip it. +

    + + } + @case ('state-edit') { + + +

    {{ overrideValueError() }}

    + @if (overrideFromPayload(); as from) { +

    + Filled in from the TransferState payload. The value starts empty because the panel only + shows a redacted copy, so type the JSON the server should send. + @if (from === 'http') { + For a cached HTTP response this is the whole cache record, not only the response body. + } +

    + } + } + @case ('client-render') { +

    + Serves index.csr.html instead of rendering. Needs + browserDistFolder in initPangularHub(). +

    + } + } +
    + +
    +
    + +

    + Overrides ({{ overrides().length }}) +

    +
      + @for (o of overrides(); track o.id) { +
    • + + + + @if (o.message) { + {{ o.message }} + } + @if (o.key) { + {{ o.value === undefined ? 'remove' : 'set' }} {{ o.key }} + } + +
    • + } @empty { +
    • No overrides.
    • + } +
    diff --git a/app/src/pages/network/ssr-overrides.scss b/app/src/pages/network/ssr-overrides.scss new file mode 100644 index 00000000..8124ec88 --- /dev/null +++ b/app/src/pages/network/ssr-overrides.scss @@ -0,0 +1,4 @@ +@use 'network' as net; + +@include net.controls; +@include net.rule-form; diff --git a/app/src/pages/network/ssr-overrides.ts b/app/src/pages/network/ssr-overrides.ts index 137f81e6..7aeae553 100644 --- a/app/src/pages/network/ssr-overrides.ts +++ b/app/src/pages/network/ssr-overrides.ts @@ -8,160 +8,8 @@ import { OverrideState } from './override-state'; @Component({ selector: 'section[appSsrOverrides]', imports: [Select], - template: ` -

    SSR overrides

    -

    - Development only. These change what the server sends for matching pages, from the next full - page load, and the request is marked overridden. -

    -
    -
    - Override - -
    - - @switch (overrideDraft().kind) { - @case ('render-error') { -

    - Throws while the server renders, so it needs providePangularHttp() and a - page the engine renders. Prerendered files served as static files skip it. -

    - - } - @case ('state-edit') { - - -

    {{ overrideValueError() }}

    - @if (overrideFromPayload(); as from) { -

    - Filled in from the TransferState payload. The value starts empty because the panel - only shows a redacted copy, so type the JSON the server should send. - @if (from === 'http') { - For a cached HTTP response this is the whole cache record, not only the response - body. - } -

    - } - } - @case ('client-render') { -

    - Serves index.csr.html instead of rendering. Needs - browserDistFolder in initPangularHub(). -

    - } - } -
    - -
    -
    - -

    - Overrides ({{ overrides().length }}) -

    -
      - @for (o of overrides(); track o.id) { -
    • - - - - @if (o.message) { - {{ o.message }} - } - @if (o.key) { - {{ o.value === undefined ? 'remove' : 'set' }} {{ o.key }} - } - -
    • - } @empty { -
    • No overrides.
    • - } -
    - `, - styles: ` - @use 'network' as net; - - @include net.controls; - @include net.rule-form; - `, + templateUrl: './ssr-overrides.html', + styleUrl: './ssr-overrides.scss', }) export class SsrOverrides { private readonly state = inject(NetworkState); diff --git a/app/src/pages/network/ssr-requests.html b/app/src/pages/network/ssr-requests.html new file mode 100644 index 00000000..3be9b2ac --- /dev/null +++ b/app/src/pages/network/ssr-requests.html @@ -0,0 +1,209 @@ +

    SSR requests ({{ requests().length }})

    +@if (requests().length) { +
    + + + + + + + + + + + + + @for (req of requests(); track req.id) { + + + + + + + + + } + +
    RequestStatusRender modeRenderServer callsNotes
    + + + {{ req.status }} + {{ renderModeLabel(req.renderMode) }}{{ req.renderMs }} ms{{ req.fetches }} ({{ req.fetchMs }} ms) +
    + @if (req.id === selected()?.ssrRequestId) { + this page + } + @if (req.overrides?.length) { + overridden + } + @if (req.aborted) { + aborted + } +
    +
    +
    + @if (selectedRequest(); as req) { +
    +

    + {{ req.method }} {{ req.url }} +

    +
    +
    +
    Request id
    +
    + {{ req.id }} +
    +
    +
    +
    Render
    +
    {{ req.renderMs }} ms
    +
    +
    +
    Total
    +
    {{ req.durationMs }} ms
    +
    +
    +
    Sent
    +
    {{ req.bytes }} B
    +
    +
    +
    Browser page
    +
    {{ requestPage()?.title || requestPage()?.url || 'not connected' }}
    +
    +
    + @if (compressedWith(req); as encoding) { +

    + The body was already compressed ({{ encoding }}) when ssrMiddleware saw it, + so the render mode can't be read and TransferState edits can't apply. Mount + ssrMiddleware after compression(). +

    + } + @if (req.overrides?.length) { +

    Overrides from the panel

    +

    + This response was changed on purpose, so it is not what the app sends on its own. +

    + @for (o of req.overrides; track o.id + o.kind) { +

    + {{ overrideLabel(o.kind) }} + {{ o.applied ? 'applied' : 'not applied' }} + @if (o.note) { + {{ o.note }} + } +

    + } + } + @if (req.navigations?.length) { +

    Router during the render

    + @for (n of req.navigations; track $index) { + + @if (n.reason) { +

    {{ n.reason }}

    + } + } + } +

    Server calls ({{ requestCalls().length }})

    + @for (c of requestCalls(); track c.id) { +

    + {{ c.method }} + {{ c.url }} + {{ statusLabel(c) }} + {{ c.durationMs }} ms + @if (c.cacheStored) { + cached + } @else if (c.cacheSkip) { + not cached + } +

    + @if (!c.cacheStored && c.cacheSkip) { +

    Not cached: {{ skipText(c.cacheSkip) }}.

    + } + } @empty { +

    The render made no HttpClient calls.

    + } + @if (refetched().length) { +

    Fetched again in the browser ({{ refetched().length }})

    +

    + These calls ran on the server and again after hydration instead of reading the transfer + cache. +

    + @for (c of refetched(); track c.id) { +

    + {{ c.method }} + {{ c.url }} + cache miss +

    + @if (refetchReason(c); as why) { +

    Why: {{ why }}.

    + } + } + } @else if (requestPage() && requestCalls().length) { +

    No server call ran again in the browser.

    + } +
    + } +} @else { +
    +

    No SSR requests traced.

    +

    + Add app.use(devtools.ssrMiddleware) before the Angular handler in + server.ts, or wrap a web-standard handler with withPangularSsr(), + then load a server rendered page. +

    +
    +} diff --git a/app/src/pages/network/ssr-requests.scss b/app/src/pages/network/ssr-requests.scss new file mode 100644 index 00000000..82a27a2d --- /dev/null +++ b/app/src/pages/network/ssr-requests.scss @@ -0,0 +1,7 @@ +@use 'network' as net; + +@include net.controls; +@include net.table; +@include net.status; +@include net.preview; +@include net.stats; diff --git a/app/src/pages/network/ssr-requests.ts b/app/src/pages/network/ssr-requests.ts index 46a370c9..91e6b856 100644 --- a/app/src/pages/network/ssr-requests.ts +++ b/app/src/pages/network/ssr-requests.ts @@ -18,226 +18,8 @@ import { NetworkState } from './network-state'; @Component({ selector: 'section[appSsrRequests]', imports: [RovingList], - template: ` -

    SSR requests ({{ requests().length }})

    - @if (requests().length) { -
    - - - - - - - - - - - - - @for (req of requests(); track req.id) { - - - - - - - - - } - -
    RequestStatusRender modeRenderServer callsNotes
    - - - {{ req.status }} - {{ renderModeLabel(req.renderMode) }}{{ req.renderMs }} ms{{ req.fetches }} ({{ req.fetchMs }} ms) -
    - @if (req.id === selected()?.ssrRequestId) { - this page - } - @if (req.overrides?.length) { - overridden - } - @if (req.aborted) { - aborted - } -
    -
    -
    - @if (selectedRequest(); as req) { -
    -

    - {{ req.method }} {{ req.url }} -

    -
    -
    -
    Request id
    -
    - {{ req.id }} -
    -
    -
    -
    Render
    -
    {{ req.renderMs }} ms
    -
    -
    -
    Total
    -
    {{ req.durationMs }} ms
    -
    -
    -
    Sent
    -
    {{ req.bytes }} B
    -
    -
    -
    Browser page
    -
    {{ requestPage()?.title || requestPage()?.url || 'not connected' }}
    -
    -
    - @if (compressedWith(req); as encoding) { -

    - The body was already compressed ({{ encoding }}) when ssrMiddleware saw - it, so the render mode can't be read and TransferState edits can't apply. Mount - ssrMiddleware after compression(). -

    - } - @if (req.overrides?.length) { -

    Overrides from the panel

    -

    - This response was changed on purpose, so it is not what the app sends on its own. -

    - @for (o of req.overrides; track o.id + o.kind) { -

    - {{ overrideLabel(o.kind) }} - {{ o.applied ? 'applied' : 'not applied' }} - @if (o.note) { - {{ o.note }} - } -

    - } - } - @if (req.navigations?.length) { -

    Router during the render

    - @for (n of req.navigations; track $index) { - - @if (n.reason) { -

    {{ n.reason }}

    - } - } - } -

    Server calls ({{ requestCalls().length }})

    - @for (c of requestCalls(); track c.id) { -

    - {{ c.method }} - {{ c.url }} - {{ statusLabel(c) }} - {{ c.durationMs }} ms - @if (c.cacheStored) { - cached - } @else if (c.cacheSkip) { - not cached - } -

    - @if (!c.cacheStored && c.cacheSkip) { -

    Not cached: {{ skipText(c.cacheSkip) }}.

    - } - } @empty { -

    The render made no HttpClient calls.

    - } - @if (refetched().length) { -

    Fetched again in the browser ({{ refetched().length }})

    -

    - These calls ran on the server and again after hydration instead of reading the - transfer cache. -

    - @for (c of refetched(); track c.id) { -

    - {{ c.method }} - {{ c.url }} - cache miss -

    - @if (refetchReason(c); as why) { -

    Why: {{ why }}.

    - } - } - } @else if (requestPage() && requestCalls().length) { -

    No server call ran again in the browser.

    - } -
    - } - } @else { -
    -

    No SSR requests traced.

    -

    - Add app.use(devtools.ssrMiddleware) before the Angular handler in - server.ts, or wrap a web-standard handler with - withPangularSsr(), then load a server rendered page. -

    -
    - } - `, - styles: ` - @use 'network' as net; - - @include net.controls; - @include net.table; - @include net.status; - @include net.preview; - @include net.stats; - `, + templateUrl: './ssr-requests.html', + styleUrl: './ssr-requests.scss', }) export class SsrRequests { private readonly state = inject(NetworkState); diff --git a/app/src/pages/network/transfer-payload.html b/app/src/pages/network/transfer-payload.html new file mode 100644 index 00000000..094fb26d --- /dev/null +++ b/app/src/pages/network/transfer-payload.html @@ -0,0 +1,94 @@ +

    TransferState payload

    +@if (selected(); as page) { +
    + + + Renders this URL again on the server, with this tab's cookies, and shows the payload it sends + now. The tab does not reload. + +
    +} +@if (selectedPayload(); as payload) { + @if (!payload.found) { +
    +

    No TransferState script: this page was not server rendered.

    +
    + } @else { +

    + {{ payload.entries.length }} entr{{ payload.entries.length === 1 ? 'y' : 'ies' }}, + {{ payload.size }} bytes + @if (payload.refetchedAt) { + Refetched + } +

    + @if (payload.error) { + + } + + +
    + @for (entry of payload.entries; track entry.key) { +
    + + + @if (payloadLabel(entry); as label) { + {{ label }} + } + @if (entry.http; as http) { + HTTP {{ http.status }} + {{ http.url ?? entry.key }} + } @else { + {{ entry.key }} + } + {{ entry.size }} B + +
    + Key + {{ entry.key }} +
    + + @if (canEditEntry(entry)) { + + } +
    +
    + +
    {{
    +            entry.value | json
    +          }}
    +
    + } +
    + } +} @else { +
    +

    Open a page of the app to see its payload.

    +
    +} diff --git a/app/src/pages/network/transfer-payload.scss b/app/src/pages/network/transfer-payload.scss new file mode 100644 index 00000000..1a6cfe80 --- /dev/null +++ b/app/src/pages/network/transfer-payload.scss @@ -0,0 +1,103 @@ +@use 'mixins' as m; +@use 'network' as net; + +@include net.controls; +@include net.status; +@include net.pre; +.summary-line { + margin-bottom: 8px; + font-variant-numeric: tabular-nums; +} +.payload-tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-bottom: 12px; +} +.payload-tools .muted { + flex: 1 1 240px; + min-width: 0; +} +.entries { + border-top: 1px solid var(--border); +} +details { + border-bottom: 1px solid var(--border); +} +details pre { + margin: 0 0 12px; +} +summary { + display: flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 6px 4px; + border-radius: 4px; + list-style: none; + cursor: pointer; + transition: background-color 0.15s var(--ease); +} +summary::-webkit-details-marker { + display: none; +} +summary:hover { + background: var(--surface-2); +} +.chevron { + flex: none; + width: 6px; + height: 6px; + margin: 0 4px; + border-right: 1.5px solid var(--text-3); + border-bottom: 1.5px solid var(--text-3); + transform: rotate(-45deg); + transition: transform 0.15s var(--ease); +} +details[open] .chevron { + transform: rotate(45deg); +} +.entry-key { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + font-family: var(--font-mono); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} +summary:hover .entry-key { + color: var(--accent); +} +.entry-key-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + min-width: 0; + margin: 0 0 8px; + padding: 0 4px; +} +.entry-key-label { + @include m.label; + flex: none; +} +.entry-key-text { + flex: 1 1 200px; + min-width: 0; + color: var(--text-strong); + overflow-wrap: anywhere; + user-select: text; +} +.entry-actions { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.entry-size { + flex: none; + color: var(--text-2); + font-size: 12px; + font-variant-numeric: tabular-nums; +} diff --git a/app/src/pages/network/transfer-payload.ts b/app/src/pages/network/transfer-payload.ts index cbb760ce..9712c6ae 100644 --- a/app/src/pages/network/transfer-payload.ts +++ b/app/src/pages/network/transfer-payload.ts @@ -10,207 +10,8 @@ import { PayloadInsights } from './payload-insights'; @Component({ selector: 'section[appTransferPayload]', imports: [JsonPipe, PayloadInsights, RedactionCount], - template: ` -

    TransferState payload

    - @if (selected(); as page) { -
    - - - Renders this URL again on the server, with this tab's cookies, and shows the payload it - sends now. The tab does not reload. - -
    - } - @if (selectedPayload(); as payload) { - @if (!payload.found) { -
    -

    No TransferState script: this page was not server rendered.

    -
    - } @else { -

    - {{ payload.entries.length }} entr{{ payload.entries.length === 1 ? 'y' : 'ies' }}, - {{ payload.size }} bytes - @if (payload.refetchedAt) { - Refetched - } -

    - @if (payload.error) { - - } - - -
    - @for (entry of payload.entries; track entry.key) { -
    - - - @if (payloadLabel(entry); as label) { - {{ label }} - } - @if (entry.http; as http) { - HTTP {{ http.status }} - {{ http.url ?? entry.key }} - } @else { - {{ entry.key }} - } - {{ entry.size }} B - -
    - Key - {{ entry.key }} -
    - - @if (canEditEntry(entry)) { - - } -
    -
    - -
    {{
    -                entry.value | json
    -              }}
    -
    - } -
    - } - } @else { -
    -

    Open a page of the app to see its payload.

    -
    - } - `, - styles: ` - @use 'mixins' as m; - @use 'network' as net; - - @include net.controls; - @include net.status; - @include net.pre; - .summary-line { - margin-bottom: 8px; - font-variant-numeric: tabular-nums; - } - .payload-tools { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; - margin-bottom: 12px; - } - .payload-tools .muted { - flex: 1 1 240px; - min-width: 0; - } - .entries { - border-top: 1px solid var(--border); - } - details { - border-bottom: 1px solid var(--border); - } - details pre { - margin: 0 0 12px; - } - summary { - display: flex; - align-items: center; - gap: 8px; - min-height: 36px; - padding: 6px 4px; - border-radius: 4px; - list-style: none; - cursor: pointer; - transition: background-color 0.15s var(--ease); - } - summary::-webkit-details-marker { - display: none; - } - summary:hover { - background: var(--surface-2); - } - .chevron { - flex: none; - width: 6px; - height: 6px; - margin: 0 4px; - border-right: 1.5px solid var(--text-3); - border-bottom: 1.5px solid var(--text-3); - transform: rotate(-45deg); - transition: transform 0.15s var(--ease); - } - details[open] .chevron { - transform: rotate(45deg); - } - .entry-key { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - font-family: var(--font-mono); - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; - } - summary:hover .entry-key { - color: var(--accent); - } - .entry-key-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px 8px; - min-width: 0; - margin: 0 0 8px; - padding: 0 4px; - } - .entry-key-label { - @include m.label; - flex: none; - } - .entry-key-text { - flex: 1 1 200px; - min-width: 0; - color: var(--text-strong); - overflow-wrap: anywhere; - user-select: text; - } - .entry-actions { - display: flex; - flex-wrap: wrap; - gap: 4px; - } - .entry-size { - flex: none; - color: var(--text-2); - font-size: 12px; - font-variant-numeric: tabular-nums; - } - `, + templateUrl: './transfer-payload.html', + styleUrl: './transfer-payload.scss', }) export class TransferPayload { private readonly state = inject(NetworkState); diff --git a/app/src/pages/pipes-inspector.html b/app/src/pages/pipes-inspector.html new file mode 100644 index 00000000..3b408749 --- /dev/null +++ b/app/src/pages/pipes-inspector.html @@ -0,0 +1,110 @@ +

    + Pipes declared in your source, the built-in pipes your templates use and library pipes seen on the + page. Live data shows which components use each pipe on the page; turn on recording to capture + calls and the last input and output. +

    + +
    + + + + {{ filtered().length }} of {{ allPipes().length }} +
    + + +
    +
    + +@if (recordMessage(); as message) { +

    + + {{ message }} +

    +} + +@if (instrumenting()) { +

    + + + Recording on {{ plural(instrumentedPages().length, 'page') }}. + Pipe prototypes are patched in the inspected page to count calls and keep the last input and + output. Stop recording when you are done. + +

    +} + +@if (loading() && allPipes().length === 0) { +
    + +

    Scanning pipes…

    +
    +} @else if (loadFailed() && allPipes().length === 0) { + +} @else if (allPipes().length === 0) { +
    +

    No pipes found

    +

    + No @Pipe classes were found in the source, and no template uses a built-in pipe yet. +

    +
    +} @else { + @if (loadFailed()) { + + } +
    +
    + +
    +
    +} + +@if (async().length > 0) { +
    +} + +
    diff --git a/app/src/pages/pipes-inspector.scss b/app/src/pages/pipes-inspector.scss new file mode 100644 index 00000000..8119893e --- /dev/null +++ b/app/src/pages/pipes-inspector.scss @@ -0,0 +1,173 @@ +@use 'mixins' as m; +@use 'pipes'; + +:host { + display: block; + color: var(--text); + font-size: 13px; +} +@include pipes.mono; +.intro { + max-width: 720px; + margin: 0 0 12px; + color: var(--text-2); + line-height: 1.5; +} +.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); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.search { + position: relative; + flex: 1 1 220px; + 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; +} +.search:focus-within .search-icon { + stroke: var(--accent); +} +input[type='search'] { + width: 100%; + height: var(--control-h); + padding: 0 12px 0 34px; + background: var(--bg); + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 13px; + transition: + border-color 150ms var(--ease), + box-shadow 150ms var(--ease); +} +input[type='search']::placeholder { + color: var(--text-3); +} +input[type='search']:focus-visible { + @include m.field-focus; +} +.toolbar app-select { + width: 150px; +} +.total { + flex: none; + color: var(--text-2); + font-size: 12px; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.actions { + display: flex; + gap: 8px; + margin-left: auto; +} +@include pipes.buttons; +.record[aria-busy='true'] { + cursor: progress; +} +.rec-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-3); +} +.record.on { + @include m.tint(accent); +} +.record.on .rec-dot { + background: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.notice { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0 0 12px; + padding: 10px 12px; + border: 1px solid var(--accent-line); + border-radius: var(--radius-sm); + background: var(--accent-soft); + color: var(--text-2); + line-height: 1.5; + @include m.enter(0.2s); +} +.notice strong { + color: var(--text-strong); + font-weight: 600; +} +.notice svg { + flex: none; + width: 16px; + height: 16px; + margin-top: 2px; + fill: none; + stroke: var(--accent); + stroke-width: 2; + stroke-linecap: round; +} +.notice.error { + @include m.tint(danger); + align-items: center; + flex-wrap: wrap; +} +.notice.error svg { + margin-top: 0; + stroke: currentColor; +} +.notice.error span { + flex: 1 1 200px; +} +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + align-items: start; +} +@media (min-width: 880px) { + .layout { + grid-template-columns: minmax(0, 1fr) minmax(340px, 44%); + } + .detail { + position: sticky; + top: 64px; + max-height: calc(100vh - 150px); + overflow: auto; + } +} +.list-wrap { + min-width: 0; +} +.detail { + min-width: 0; + @include m.panel; + @include m.enter; +} +.section { + margin-top: 20px; + @include m.enter; +} +@include pipes.states; +@include pipes.state-text; diff --git a/app/src/pages/pipes-inspector.ts b/app/src/pages/pipes-inspector.ts index 4168b42d..b39b191e 100644 --- a/app/src/pages/pipes-inspector.ts +++ b/app/src/pages/pipes-inspector.ts @@ -1,1289 +1,40 @@ -import { - Component, - DestroyRef, - ElementRef, - Injector, - afterNextRender, - computed, - effect, - inject, - input, - output, - signal, - untracked, - viewChild, -} from '@angular/core'; +import { Component, effect, inject, input, output, untracked, viewChild } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; -import { hostPageId } from '../page-id'; -import { Select, type SelectOption } from '../ui/select'; -import { SourceLink } from '../ui/source-link'; import { plural } from '../format'; -import { RovingList } from '../ui/roving-list'; import { SearchField } from '../ui/search-field'; - -interface UsageSite { - file: string; - line: number; -} - -interface PipeInfo { - name: string; - className: string; - file: string; - line: number; - isStandalone: boolean; - isPure: boolean; - builtin?: boolean; - usageCount?: number; - usages?: UsageSite[]; - /** Panel only: seen on the page but not found by the source scan, such as a pipe from a - * library. It has no source location, so `file` is empty and `line` is 0. */ - runtime?: boolean; -} - -interface PipeTarget { - pageId: string; - id: string; -} - -interface PipeComponentUsage { - name: string; - count: number; - targets?: PipeTarget[]; -} - -interface PipeCallTiming { - totalMs?: number; - maxMs?: number; - lastMs?: number; - errorCount?: number; - lastError?: string; -} - -interface PipeInstanceCall extends PipeCallTiming { - component: string; - callCount: number; - lastArgs?: unknown[]; - lastResult?: unknown; - lastCaller?: string; - target?: PipeTarget; -} - -interface PipeCallInfo extends PipeCallTiming { - callCount: number; - instances?: PipeInstanceCall[]; - instanceTotal?: number; - lastArgs?: unknown[]; - lastResult?: unknown; - lastCaller?: string; -} - -interface StaleFinding { - detectedAt: number; -} - -interface ChurnFinding { - streak: number; - detectedAt: number; -} - -interface LivePipeInfo { - name: string; - className: string; - isPure: boolean; - instanceCount: number; - components: PipeComponentUsage[]; - call?: PipeCallInfo; - stale?: StaleFinding; - churn?: ChurnFinding; - staleCheck?: 'unavailable'; -} - -interface AsyncUsageInfo { - component: string; - hasSource: boolean; - latestValue?: string; - duplicate: boolean; - resubscribing?: boolean; - target?: PipeTarget; -} - -interface PipeLintFinding { - rule: string; - severity: 'error' | 'warning' | 'info'; - pipe: string; - file: string; - line: number; - message: string; - fix: string; -} - -interface PipesPageSnapshot { - pageId: string; - pipes: LivePipeInfo[]; - async: AsyncUsageInfo[]; - asyncTotal?: number; -} - -interface PipesSnapshot { - pipes: LivePipeInfo[]; - async: AsyncUsageInfo[]; - asyncTotal?: number; - instrumented: string[]; - pages?: PipesPageSnapshot[]; -} - -const RECORD_CONFIRM_MS = 5000; - -type PipeKind = 'all' | 'custom' | 'builtin' | 'runtime' | 'impure' | 'live' | 'called'; - -const KIND_OPTIONS: readonly SelectOption[] = [ - { value: 'all', label: 'All pipes' }, - { value: 'custom', label: 'Custom' }, - { value: 'builtin', label: 'Built-in' }, - { value: 'runtime', label: 'Runtime only' }, - { value: 'impure', label: 'Impure' }, - { value: 'live', label: 'On the page' }, - { value: 'called', label: 'Called while recording' }, -]; - -type PipeSort = 'source' | 'calls' | 'time'; - -const SORT_OPTIONS: readonly SelectOption[] = [ - { value: 'source', label: 'Source order' }, - { value: 'calls', label: 'Most calls' }, - { value: 'time', label: 'Most time' }, -]; - +import { Select } from '../ui/select'; +import { PipeAsync } from './pipes/pipe-async'; +import { PipeDetail } from './pipes/pipe-detail'; +import { PipeLint } from './pipes/pipe-lint'; +import { PipeList } from './pipes/pipe-list'; +import { + KIND_OPTIONS, + SORT_OPTIONS, + describe, + ms, + sourceLabel, + type LivePipeInfo, + type PipeInfo, + type PipeLintFinding, + type PipeTarget, +} from './pipes/pipes-model'; +import { PipesState } from './pipes/pipes-state'; + +/** + * The Pipes page: the toolbar, the load states and one section per part of the page. The + * sections live in `./pipes/` and share the page's {@link PipesState}; the page keeps the members + * its tests and the shell read on itself. + */ @Component({ selector: 'app-pipes-inspector', - imports: [RovingList, SearchField, Select, SourceLink], - template: ` -

    - Pipes declared in your source, the built-in pipes your templates use and library pipes seen on - the page. Live data shows which components use each pipe on the page; turn on recording to - capture calls and the last input and output. -

    - -
    - - - - {{ filtered().length }} of {{ allPipes().length }} -
    - - -
    -
    - - @if (recordMessage(); as message) { -

    - - {{ message }} -

    - } - - @if (instrumenting()) { -

    - - - Recording on {{ plural(instrumentedPages().length, 'page') }}. - Pipe prototypes are patched in the inspected page to count calls and keep the last input - and output. Stop recording when you are done. - -

    - } - - @if (loading() && allPipes().length === 0) { -
    - -

    Scanning pipes…

    -
    - } @else if (loadFailed() && allPipes().length === 0) { - - } @else if (allPipes().length === 0) { -
    -

    No pipes found

    -

    - No @Pipe classes were found in the source, and no template uses a built-in pipe yet. -

    -
    - } @else { - @if (loadFailed()) { - - } -
    -
    - @if (filtered().length) { -
      - @for (p of filtered(); track p.file + p.name) { - @let live = liveFor(p.name); -
    • - -
    • - } -
    - } @else { -
    -

    No pipes match

    -

    - @if (filter().trim()) { - Nothing matches “{{ filter().trim() }}” in this view. - } @else { - None of the pipes fit this filter. - } -

    - -
    - } -
    - -
    - @if (selected(); as p) { -
    -

    {{ p.name }}

    - {{ - p.isPure ? 'pure' : 'impure' - }} - @if (p.builtin) { - built-in - } - @if (p.runtime) { - runtime only - } -
    - -
    -

    Declaration

    -
    -
    Class
    -
    {{ p.className }}
    -
    Source
    -
    {{ sourceLabel(p) }}
    - @if (!p.builtin && !p.runtime) { -
    File
    -
    - } - @if (!p.runtime) { -
    Standalone
    -
    {{ p.isStandalone ? 'Yes' : 'No, declared in an NgModule' }}
    - } -
    Pure
    -
    - {{ - p.isPure - ? 'Yes, reruns only when an input changes' - : 'No, reruns on every check' - }} -
    -
    -
    - - @if (p.usages?.length) { -
    -

    - Used in templates {{ p.usageCount }} -

    -
      - @for (u of p.usages; track u.file + ':' + u.line) { -
    • - } -
    -
    - } - -
    -

    Live on the page

    - @if (liveFor(p.name); as live) { - @if (live.churn; as churn) { -

    - churning - @if (live.isPure) { - An instance ran {{ churn.streak }} times in a row and returned an equal result - each time: its input changes reference while its contents stay the same. Keep - the input stable, for example in a computed(). - } @else { - An instance returned an equal object or array as a new reference - {{ churn.streak }} times in a row, so the bindings that read it update for - nothing. Return the previous result when it is equal, or use a computed(). - } -

    - } - @if (live.stale) { -

    - experimental - This pure pipe got an argument whose contents changed while its reference stayed - the same, so it may be showing a stale value. -

    - } -
    -
    Instances
    -
    {{ live.instanceCount }}
    -
    Used by
    -
    -
      - @for (c of live.components; track c.name) { -
    • - @if (c.targets?.[0]; as target) { - - } @else { - - {{ c.name }} - {{ c.count }} - - } -
    • - } -
    -
    - @if (live.call; as call) { -
    Calls
    -
    {{ call.callCount }}
    - @if (call.totalMs !== undefined) { -
    Time
    -
    - {{ ms(call.totalMs) }} total, - {{ ms(call.totalMs / call.callCount) }} average, - {{ ms(call.maxMs ?? 0) }} slowest -
    - } - @if (call.errorCount) { -
    Errors
    -
    - {{ call.errorCount }} - @if (call.lastError) { - {{ call.lastError }} - } -
    - } -
    Last input
    -
    {{ describe(call.lastArgs) }}
    -
    Last output
    -
    {{ describe(call.lastResult) }}
    - @if (call.instances?.length) { -
    Per instance
    -
    -
      - @for (i of call.instances; track $index) { -
    • - @if (i.target; as target) { - - } @else { - - {{ i.component }} - - } - - {{ describe(i.lastArgs) }} → {{ describe(i.lastResult) }} - {{ i.callCount }}× - @if (i.totalMs !== undefined) { - {{ ms(i.totalMs) }} - } - - @if (i.lastCaller) { - {{ i.lastCaller }} - } -
    • - } -
    - @if (call.instanceTotal) { -

    - Showing {{ call.instances.length }} of - {{ call.instanceTotal }} instances. -

    - } -
    - } - @if (call.lastCaller) { -
    Last caller
    -
    {{ call.lastCaller }}
    - } - } @else if (instrumenting()) { -
    Calls
    -
    None recorded yet.
    - } -
    - @if (live.staleCheck === 'unavailable') { -

    - The stale check is off for this pipe: it couldn't read the compiled template, - which happens in minified builds. -

    - } - @if (!live.call && !instrumenting()) { -

    Turn on “Record calls” to see call counts and values.

    - } - } @else { -

    - Not in use on the connected page. Open a view that uses it, or connect the app. -

    - } -
    - } @else { -

    Pipe details

    -
    -

    Pick a pipe

    -

    - See where it is declared, which components use it and what it last returned. -

    -
    - } -
    -
    - } - - @if (async().length > 0) { -
    -

    - Async subscriptions {{ async().length }} -

    -

    - Each | async subscribes on its own. Two on the same source mean - the work runs twice. A source that changes on every check, like - getData() | async, resubscribes each time. -

    - @if (asyncTotal(); as total) { -

    Showing {{ async().length }} of {{ total }} subscriptions.

    - } -
      - @for (a of async(); track $index) { -
    • - @if (a.target; as target) { - - } @else { - {{ a.component }} - } - @if (!a.hasSource) { - no source - } - @if (a.duplicate) { - possible duplicate subscription - } - @if (a.resubscribing) { - resubscribing - } - {{ a.latestValue ?? 'no value yet' }} -
    • - } -
    -
    - } - -
    -

    - Lint - @if (lint(); as findings) { - {{ findings.length }} - } -

    - @if (lintFailed()) { - - } @else if (lint() === null) { -

    Checking…

    - } @else if (!lint()!.length) { -

    No problems found.

    - } @else { -
      - @for (f of lint(); track $index) { -
    • -
      - {{ f.severity }} - {{ f.rule }} - @let target = pipeFor(f); - - @if (target) { - - } @else { - {{ f.pipe }} - } - · - -
      -

      {{ f.message }}

      -

      Fix {{ f.fix }}

      -
    • - } -
    - } -
    - `, - styles: ` - @use 'mixins' as m; - - :host { - display: block; - color: var(--text); - font-size: 13px; - } - .mono { - font-family: var(--font-mono); - } - .intro { - max-width: 720px; - margin: 0 0 12px; - color: var(--text-2); - line-height: 1.5; - } - .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); - -webkit-backdrop-filter: blur(10px); - border: 1px solid var(--border); - border-radius: var(--radius); - } - .search { - position: relative; - flex: 1 1 220px; - 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; - } - .search:focus-within .search-icon { - stroke: var(--accent); - } - input[type='search'] { - width: 100%; - height: var(--control-h); - padding: 0 12px 0 34px; - background: var(--bg); - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - color: var(--text); - font-size: 13px; - transition: - border-color 150ms var(--ease), - box-shadow 150ms var(--ease); - } - input[type='search']::placeholder { - color: var(--text-3); - } - input[type='search']:focus-visible { - @include m.field-focus; - } - .toolbar app-select { - width: 150px; - } - .total { - flex: none; - color: var(--text-2); - font-size: 12px; - white-space: nowrap; - font-variant-numeric: tabular-nums; - } - .actions { - display: flex; - gap: 8px; - margin-left: auto; - } - button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - height: var(--control-h); - padding: 0 14px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--surface-2); - color: var(--text); - font: inherit; - font-weight: 500; - white-space: nowrap; - cursor: pointer; - transition: - background-color 150ms var(--ease), - border-color 150ms var(--ease); - } - button:hover:not(:disabled) { - border-color: var(--accent-line); - background: var(--surface-3); - } - button:focus-visible { - @include m.focus-ring; - } - button:disabled { - cursor: default; - opacity: 0.6; - } - .record[aria-busy='true'] { - cursor: progress; - } - .rec-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--text-3); - } - .record.on { - @include m.tint(accent); - } - .record.on .rec-dot { - background: var(--accent); - box-shadow: 0 0 0 3px var(--accent-soft); - } - .notice { - display: flex; - align-items: flex-start; - gap: 8px; - margin: 0 0 12px; - padding: 10px 12px; - border: 1px solid var(--accent-line); - border-radius: var(--radius-sm); - background: var(--accent-soft); - color: var(--text-2); - line-height: 1.5; - @include m.enter(0.2s); - } - .notice strong { - color: var(--text-strong); - font-weight: 600; - } - .notice svg { - flex: none; - width: 16px; - height: 16px; - margin-top: 2px; - fill: none; - stroke: var(--accent); - stroke-width: 2; - stroke-linecap: round; - } - .notice.error { - @include m.tint(danger); - align-items: center; - flex-wrap: wrap; - } - .notice.error svg { - margin-top: 0; - stroke: currentColor; - } - .notice.error span { - flex: 1 1 200px; - } - .layout { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 12px; - align-items: start; - } - @media (min-width: 880px) { - .layout { - grid-template-columns: minmax(0, 1fr) minmax(340px, 44%); - } - .detail { - position: sticky; - top: 64px; - max-height: calc(100vh - 150px); - overflow: auto; - } - } - .list-wrap { - min-width: 0; - } - .pipe-list { - display: grid; - gap: 2px; - margin: 0; - padding: 6px; - list-style: none; - @include m.panel; - @include m.enter; - } - .row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 2px 12px; - width: 100%; - height: auto; - padding: 8px 10px; - border: 0; - border-radius: var(--radius-sm); - background: none; - text-align: left; - font-weight: 400; - white-space: normal; - } - .row:hover:not(:disabled) { - border-color: transparent; - background: var(--surface-2); - } - .row:focus-visible { - @include m.focus-ring(-2px); - } - .row.selected { - background: var(--accent-soft); - box-shadow: inset 2px 0 0 var(--accent); - } - .row-main { - display: flex; - align-items: baseline; - gap: 8px; - min-width: 0; - } - .name { - flex: none; - color: var(--text-strong); - font-size: 13px; - font-weight: 600; - } - .sub { - color: var(--text-3); - font-size: 12px; - @include m.truncate; - } - .row-meta { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 4px; - } - .row-file { - grid-column: 1 / -1; - color: var(--text-3); - font-size: 11px; - overflow-wrap: anywhere; - } - .no-source { - font-family: var(--font-sans); - font-style: italic; - } - .more { - margin-left: 6px; - color: var(--text-2); - } - .chip { - display: inline-flex; - align-items: center; - height: 18px; - padding: 0 7px; - border: 1px solid var(--border-strong); - border-radius: 99px; - color: var(--text-2); - font-size: 10px; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - white-space: nowrap; - } - .chip.impure, - .chip.warn { - @include m.tint(warn); - } - .chip.live { - @include m.tint(ok); - } - .chip.hot { - @include m.tint(accent); - text-transform: none; - font-variant-numeric: tabular-nums; - } - .pill { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 16px; - padding: 0 5px; - border-radius: 99px; - background: var(--surface-3); - color: var(--text-2); - font-size: 10px; - font-weight: 600; - letter-spacing: 0; - font-variant-numeric: tabular-nums; - } - .detail { - min-width: 0; - @include m.panel; - @include m.enter; - } - .detail-head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 10px; - padding: 14px 16px; - border-bottom: 1px solid var(--border); - } - .detail-head h2 { - flex: 1 1 auto; - min-width: 0; - margin: 0; - color: var(--text-strong); - font-size: 15px; - font-weight: 600; - overflow-wrap: anywhere; - border-radius: 4px; - } - .detail-head h2:focus { - outline: none; - } - .detail-head h2:focus-visible { - @include m.focus-ring; - } - .block { - padding: 14px 16px; - border-bottom: 1px solid var(--border); - } - .block:last-child { - border-bottom: 0; - } - h3 { - @include m.label; - display: flex; - align-items: center; - gap: 6px; - margin: 0 0 10px; - } - dl { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - gap: 6px 14px; - margin: 0; - } - dt { - color: var(--text-3); - } - dd { - min-width: 0; - margin: 0; - color: var(--text); - } - .value { - font-size: 12px; - overflow-wrap: anywhere; - } - .muted { - color: var(--text-2); - } - .sites { - display: grid; - gap: 4px; - margin: 0; - padding: 0; - list-style: none; - color: var(--text-2); - font-size: 12px; - overflow-wrap: anywhere; - } - .calls { - margin-left: 6px; - color: var(--text-3); - } - .instance { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - } - .instance .component { - padding-right: 10px; - } - .caller { - flex-basis: 100%; - } - .chips { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin: 0; - padding: 0; - list-style: none; - } - .component { - display: inline-flex; - align-items: center; - gap: 6px; - height: 24px; - padding: 0 4px 0 10px; - border: 1px solid var(--control-border); - border-radius: 99px; - background: var(--bg); - font-size: 12px; - transition: - border-color 150ms var(--ease), - background-color 150ms var(--ease); - } - button.component { - height: 24px; - padding: 0 4px 0 10px; - border-radius: 99px; - background: var(--bg); - font-size: 12px; - font-weight: 400; - } - .count { - padding: 0 6px; - border-radius: 99px; - background: var(--surface-3); - color: var(--text-2); - font-size: 10px; - line-height: 16px; - font-variant-numeric: tabular-nums; - } - .warning { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 8px; - margin: 0 0 12px; - padding: 8px 10px; - border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); - border-radius: var(--radius-sm); - background: color-mix(in srgb, var(--warn) 8%, transparent); - color: var(--text); - line-height: 1.5; - } - .hint { - margin: 8px 0 0; - color: var(--text-3); - font-size: 12px; - } - .empty-line { - margin: 0; - color: var(--text-2); - line-height: 1.5; - } - .empty-line.ok { - color: var(--ok); - } - .section { - margin-top: 20px; - @include m.enter; - } - .section h2 { - @include m.label; - display: flex; - align-items: center; - gap: 8px; - margin: 0 0 8px; - } - .section > .hint { - margin: -2px 0 10px; - } - .async-list, - .findings { - display: grid; - gap: 6px; - margin: 0; - padding: 0; - list-style: none; - } - .async-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px 8px; - min-width: 0; - padding: 8px 12px; - border-radius: var(--radius-sm); - @include m.panel; - transition: - background-color 150ms var(--ease), - border-color 150ms var(--ease); - } - .async-row:hover { - border-color: var(--border-strong); - background: var(--surface-2); - } - .component-name { - color: var(--text-strong); - font-weight: 600; - } - button.component-name { - height: 24px; - padding: 0 8px; - margin-left: -8px; - border-color: transparent; - background: none; - font-size: 13px; - } - .latest { - flex: 1 1 100%; - color: var(--text-2); - } - .finding { - padding: 10px 12px; - border-radius: var(--radius-sm); - @include m.panel; - } - .finding-head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px 8px; - } - .severity { - padding: 0 7px; - border: 1px solid var(--border-strong); - border-radius: 99px; - color: var(--text-2); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.06em; - line-height: 16px; - text-transform: uppercase; - } - .severity[data-tone='warning'] { - @include m.tint(warn); - } - .severity[data-tone='error'] { - @include m.tint(danger); - } - .rule { - color: var(--accent); - font-size: 12px; - } - .where { - color: var(--text-3); - font-size: 11px; - overflow-wrap: anywhere; - } - button.pipe-link { - display: inline; - height: auto; - padding: 0; - border: 0; - border-radius: 4px; - background: none; - color: var(--accent); - font: inherit; - text-decoration: underline; - text-underline-offset: 3px; - white-space: normal; - } - button.pipe-link:hover:not(:disabled) { - border-color: transparent; - background: none; - color: var(--accent-hover); - } - button.pipe-link:focus-visible { - @include m.focus-ring; - } - .finding-message { - margin: 6px 0 0; - color: var(--text); - line-height: 1.5; - } - .finding-fix { - margin: 4px 0 0; - color: var(--text-2); - line-height: 1.5; - } - .fix-label { - @include m.label; - margin-right: 4px; - } - .state { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - padding: 40px 16px; - text-align: center; - background: var(--surface); - border: 1px dashed var(--border-strong); - border-radius: var(--radius); - @include m.enter; - } - .state.compact { - padding: 28px 16px; - border: 0; - background: none; - } - .list-wrap .state.compact { - @include m.panel; - } - .state-title { - margin: 0; - color: var(--text-strong); - font-size: 14px; - font-weight: 600; - } - .state-hint { - max-width: 440px; - margin: 0; - color: var(--text-2); - line-height: 1.5; - } - .state button { - margin-top: 8px; - } - .spinner { - width: 20px; - height: 20px; - border: 2px solid var(--border-strong); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - } - @media (prefers-reduced-motion: reduce) { - .spinner { - animation: none; - } - } - `, + imports: [PipeAsync, PipeDetail, PipeLint, PipeList, SearchField, Select], + providers: [PipesState], + templateUrl: './pipes-inspector.html', + styleUrl: './pipes-inspector.scss', }) export class PipesInspector { + private readonly state = inject(PipesState); protected readonly plural = plural; - private readonly destroyRef = inject(DestroyRef); - private readonly host = inject>(ElementRef); - private readonly injector = inject(Injector); private readonly filterInput = viewChild('filterInput'); rpc = input(null); @@ -1297,87 +48,31 @@ export class PipesInspector { protected readonly kindOptions = KIND_OPTIONS; protected readonly sortOptions = SORT_OPTIONS; - pipes = signal([]); - filter = signal(''); - kind = signal('all'); - sort = signal('source'); - loading = signal(false); - loadFailed = signal(false); - selected = signal(null); - - private readonly snapshot = signal(undefined); - /** The live data of the page this tab shows, or every page merged. */ - private readonly shown = computed(() => { - const snapshot = this.snapshot(); - // With several tabs reporting, show the one asked for or the one this panel belongs to. - const pageId = this.livePageId(); - const own = pageId ? snapshot?.pages?.find((page) => page.pageId === pageId) : undefined; - return own ?? snapshot; - }); - readonly live = computed(() => this.shown()?.pipes ?? []); - readonly async = computed(() => this.shown()?.async ?? []); + readonly pipes = this.state.pipes; + readonly filter = this.state.filter; + readonly kind = this.state.kind; + readonly sort = this.state.sort; + readonly loading = this.state.loading; + readonly loadFailed = this.state.loadFailed; + readonly selected = this.state.selected; + readonly live = this.state.live; + readonly async = this.state.async; /** Every `| async` usage, set only when the list was cut short. */ - readonly asyncTotal = computed(() => this.shown()?.asyncTotal); - lint = signal(null); - lintFailed = signal(false); - instrumentedPages = signal([]); - instrumenting = computed(() => this.instrumentedPages().length > 0); - pendingRecord = signal(null); - recordMessage = signal(null); - recordLabel = computed(() => { - const pending = this.pendingRecord(); - if (pending !== null) return pending ? 'Starting…' : 'Stopping…'; - return this.instrumenting() ? 'Stop recording' : 'Record calls'; - }); - private pendingTimer?: ReturnType; - - private readonly liveByName = computed(() => new Map(this.live().map((p) => [p.name, p]))); - + readonly asyncTotal = this.state.asyncTotal; + readonly lint = this.state.lint; + readonly lintFailed = this.state.lintFailed; + readonly instrumentedPages = this.state.instrumentedPages; + readonly instrumenting = this.state.instrumenting; + readonly pendingRecord = this.state.pendingRecord; + readonly recordMessage = this.state.recordMessage; + readonly recordLabel = this.state.recordLabel; /** The source scan plus the pipes seen only on the page, such as library pipes. */ - readonly allPipes = computed(() => { - const pipes = this.pipes(); - const scanned = new Set(pipes.map((p) => p.name)); - const runtime: PipeInfo[] = this.live() - .filter((p) => !scanned.has(p.name)) - .map((p) => ({ - name: p.name, - className: p.className, - file: '', - line: 0, - isStandalone: true, - isPure: p.isPure, - runtime: true, - })); - return runtime.length ? [...pipes, ...runtime] : pipes; - }); - - filtered = computed(() => { - const q = this.filter(); - const kind = this.kind() ?? 'all'; - const shown = this.allPipes().filter((p) => this.fitsKind(p, kind) && this.fitsQuery(p, q)); - const sort = this.sort() ?? 'source'; - if (sort === 'source') return shown; - // Hottest first; pipes with no recorded calls keep source order at the end. - const weight = (p: PipeInfo) => { - const call = this.liveFor(p.name)?.call; - if (!call) return -1; - return sort === 'calls' ? call.callCount : (call.totalMs ?? 0); - }; - return shown - .map((p, index) => ({ p, index, weight: weight(p) })) - .sort((a, b) => b.weight - a.weight || a.index - b.index) - .map(({ p }) => p); - }); - - private readonly pipesByName = computed(() => { - const byName = new Map(); - for (const p of this.allPipes()) byName.set(p.name, [...(byName.get(p.name) ?? []), p]); - return byName; - }); - - private unsubscribe?: () => void; + readonly allPipes = this.state.allPipes; + readonly filtered = this.state.filtered; constructor() { + this.state.connect(this.rpc, this.pageId); + effect(() => { const client = this.rpc(); if (client) { @@ -1390,195 +85,64 @@ export class PipesInspector { effect(() => { const name = this.focus(); if (!name) return; - const pipe = this.pipesByName().get(name)?.[0]; + const pipe = this.state.pipesByName().get(name)?.[0]; if (!pipe) return; untracked(() => { this.showPipe(pipe); this.focusHandled.emit(); }); }); - - this.destroyRef.onDestroy(() => { - this.unsubscribe?.(); - clearTimeout(this.pendingTimer); - this.highlight(null); - }); } - async refresh() { - const client = this.rpc(); - if (!client) return; - this.loading.set(true); - this.loadFailed.set(false); - try { - const my = client.scope('pangular'); - const pipes = (await my.rpc.call('get-pipes')) as PipeInfo[]; - this.pipes.set(pipes); - const sel = this.selected(); - if (sel) { - const refreshed = this.allPipes().find((p) => p.name === sel.name && p.file === sel.file); - this.selected.set(refreshed ?? null); - } - } catch { - this.loadFailed.set(true); - } finally { - this.loading.set(false); - } - this.loadLint(client); + refresh() { + return this.state.refresh(); } - async loadLint(client: DevframeRpcClient) { - this.lintFailed.set(false); - try { - const findings = (await client.scope('pangular').rpc.call('pipe-lint')) as PipeLintFinding[]; - this.lint.set(findings); - } catch { - this.lintFailed.set(true); - } + loadLint(client: DevframeRpcClient) { + return this.state.loadLint(client); } - async loadLive(client: DevframeRpcClient) { - try { - const state = await client.scope('pangular').rpc.sharedState('pipe-usage'); - if (this.destroyRef.destroyed) return; - const apply = (value: unknown) => { - const snapshot = value as PipesSnapshot | undefined; - this.snapshot.set(snapshot); - // A runtime-only pipe has nothing left to show once the page stops using it. - const sel = this.selected(); - if (sel?.runtime && !this.liveByName().has(sel.name)) this.selected.set(null); - const pages = snapshot?.instrumented ?? []; - this.instrumentedPages.set(pages); - if (this.pendingRecord() === pages.length > 0) this.settleRecord(null); - }; - apply(state.value()); - this.unsubscribe?.(); - this.unsubscribe = state.on('updated', apply); - } catch { - // RPC not available - } + loadLive(client: DevframeRpcClient) { + return this.state.loadLive(client); } - private livePageId(): string | null { - return this.pageId() ?? hostPageId(); - } - - async toggleInstrument() { - const client = this.rpc(); - if (!client || this.pendingRecord() !== null) return; - const on = !this.instrumenting(); - this.recordMessage.set(null); - this.pendingRecord.set(on); - let result: { pages?: number } | undefined; - try { - // Start on this panel's page (or the latest page); stop every page that records. - const request = on ? { pageId: this.livePageId() ?? undefined, on } : { on }; - result = (await client.scope('pangular').rpc.call('request-instrument-pipes', request)) as - { pages?: number } | undefined; - } catch { - this.settleRecord("Couldn't reach the devtools server. Try again."); - return; - } - if (this.pendingRecord() !== on) return; - if (result?.pages === 0) { - this.settleRecord( - 'No page is connected. Open your app in the browser with the devtools running, then record again.', - ); - return; - } - clearTimeout(this.pendingTimer); - this.pendingTimer = setTimeout(() => { - if (this.pendingRecord() !== on) return; - this.settleRecord( - on - ? 'No page confirmed recording. Reload the app and try again.' - : 'No page confirmed it stopped recording. Reload the app to stop it.', - ); - }, RECORD_CONFIRM_MS); - } - - private settleRecord(message: string | null) { - clearTimeout(this.pendingTimer); - this.pendingRecord.set(null); - if (message !== null) this.recordMessage.set(message); + toggleInstrument() { + return this.state.toggleInstrument(); } sourceLabel(p: PipeInfo): string { - if (p.builtin) return '@angular/common'; - if (p.runtime) return 'Seen on the page only'; - return 'This project'; + return sourceLabel(p); } liveFor(name: string): LivePipeInfo | undefined { - return this.liveByName().get(name); + return this.state.liveFor(name); } ms(value: number): string { - return `${value < 10 ? value.toFixed(2) : Math.round(value)} ms`; + return ms(value); } describe(value: unknown): string { - if (Array.isArray(value)) return value.map((v) => this.describe(v)).join(', '); - if (typeof value === 'string') return value.length > 80 ? `${value.slice(0, 80)}…` : value; - try { - const json = JSON.stringify(value); - return json && json.length > 120 ? `${json.slice(0, 120)}…` : (json ?? String(value)); - } catch { - return String(value); - } + return describe(value); } isSelected(pipe: PipeInfo): boolean { - const sel = this.selected(); - return sel !== null && sel.name === pipe.name && sel.file === pipe.file; + return this.state.isSelected(pipe); } select(pipe: PipeInfo) { - this.selected.set(this.isSelected(pipe) ? null : pipe); + this.state.select(pipe); } - /** The listed pipe a lint finding is about: the one declared in the finding's file, or else - * the first pipe with that name. Null when the pipe is not in the list. */ + /** The listed pipe a lint finding is about, or null when the pipe is not in the list. */ pipeFor(finding: PipeLintFinding): PipeInfo | null { - const matches = this.pipesByName().get(finding.pipe); - if (!matches?.length) return null; - return matches.find((p) => p.file === finding.file) ?? matches[0]; + return this.state.pipeFor(finding); } - /** Selects a pipe from a lint finding, clearing only the filters that hide it, then scrolls - * its row into view and moves focus to the detail heading. */ + /** Selects a pipe, clearing only the filters that hide it, then scrolls its row into view and + * moves focus to the detail heading. */ showPipe(pipe: PipeInfo) { - if (!this.fitsQuery(pipe, this.filter())) this.filter.set(''); - if (!this.fitsKind(pipe, this.kind() ?? 'all')) this.kind.set('all'); - this.selected.set(pipe); - afterNextRender( - () => { - const host = this.host.nativeElement; - host.querySelector('.row.selected')?.scrollIntoView?.({ block: 'nearest' }); - host.querySelector('#pipe-detail-title')?.focus(); - }, - { injector: this.injector }, - ); - } - - private fitsKind(p: PipeInfo, kind: PipeKind): boolean { - if (kind === 'custom') return !p.builtin && !p.runtime; - if (kind === 'builtin') return !!p.builtin; - if (kind === 'runtime') return !!p.runtime; - if (kind === 'impure') return !p.isPure; - if (kind === 'live') return this.liveByName().has(p.name); - if (kind === 'called') return !!this.liveByName().get(p.name)?.call; - return true; - } - - private fitsQuery(p: PipeInfo, filter: string): boolean { - const q = filter.trim().toLowerCase(); - return ( - !q || - p.name.toLowerCase().includes(q) || - p.className.toLowerCase().includes(q) || - p.file.toLowerCase().includes(q) - ); + this.state.showPipe(pipe); } clearFilters() { @@ -1588,18 +152,10 @@ export class PipesInspector { } highlightPipe(live: LivePipeInfo | undefined) { - this.highlight(live?.components.find((c) => c.targets?.length)?.targets?.[0]); + this.state.highlightPipe(live); } highlight(target: PipeTarget | null | undefined, reveal = false) { - const client = this.rpc(); - if (!client) return; - void client - .scope('pangular') - .rpc.call( - 'request-page-highlight', - target ? { ...target, ...(reveal ? { reveal } : {}) } : null, - ) - .catch(() => {}); + this.state.highlight(target, reveal); } } diff --git a/app/src/pages/pipes/pipe-async.html b/app/src/pages/pipes/pipe-async.html new file mode 100644 index 00000000..1a552dd1 --- /dev/null +++ b/app/src/pages/pipes/pipe-async.html @@ -0,0 +1,41 @@ +

    + Async subscriptions {{ async().length }} +

    +

    + Each | async subscribes on its own. Two on the same source mean the work + runs twice. A source that changes on every check, like + getData() | async, resubscribes each time. +

    +@if (asyncTotal(); as total) { +

    Showing {{ async().length }} of {{ total }} subscriptions.

    +} +
      + @for (a of async(); track $index) { +
    • + @if (a.target; as target) { + + } @else { + {{ a.component }} + } + @if (!a.hasSource) { + no source + } + @if (a.duplicate) { + possible duplicate subscription + } + @if (a.resubscribing) { + resubscribing + } + {{ a.latestValue ?? 'no value yet' }} +
    • + } +
    diff --git a/app/src/pages/pipes/pipe-async.scss b/app/src/pages/pipes/pipe-async.scss new file mode 100644 index 00000000..57ce3126 --- /dev/null +++ b/app/src/pages/pipes/pipe-async.scss @@ -0,0 +1,10 @@ +@use 'pipes'; + +@include pipes.mono; +@include pipes.buttons; +@include pipes.chips; +@include pipes.value; +@include pipes.notes; +@include pipes.section-head; +@include pipes.stacks; +@include pipes.async-list; diff --git a/app/src/pages/pipes/pipe-async.ts b/app/src/pages/pipes/pipe-async.ts new file mode 100644 index 00000000..ef9a3e4d --- /dev/null +++ b/app/src/pages/pipes/pipe-async.ts @@ -0,0 +1,19 @@ +import { Component, inject } from '@angular/core'; +import { PipesState } from './pipes-state'; + +/** The `| async` subscriptions on the page, with possible duplicates and resubscribing sources. */ +@Component({ + selector: 'section[appPipeAsync]', + templateUrl: './pipe-async.html', + styleUrl: './pipe-async.scss', +}) +export class PipeAsync { + private readonly state = inject(PipesState); + + protected readonly async = this.state.async; + protected readonly asyncTotal = this.state.asyncTotal; + + protected highlight(...args: Parameters) { + this.state.highlight(...args); + } +} diff --git a/app/src/pages/pipes/pipe-detail.html b/app/src/pages/pipes/pipe-detail.html new file mode 100644 index 00000000..046cb334 --- /dev/null +++ b/app/src/pages/pipes/pipe-detail.html @@ -0,0 +1,203 @@ +@if (selected(); as p) { +
    +

    {{ p.name }}

    + {{ p.isPure ? 'pure' : 'impure' }} + @if (p.builtin) { + built-in + } + @if (p.runtime) { + runtime only + } +
    + +
    +

    Declaration

    +
    +
    Class
    +
    {{ p.className }}
    +
    Source
    +
    {{ sourceLabel(p) }}
    + @if (!p.builtin && !p.runtime) { +
    File
    +
    + } + @if (!p.runtime) { +
    Standalone
    +
    {{ p.isStandalone ? 'Yes' : 'No, declared in an NgModule' }}
    + } +
    Pure
    +
    + {{ p.isPure ? 'Yes, reruns only when an input changes' : 'No, reruns on every check' }} +
    +
    +
    + + @if (p.usages?.length) { +
    +

    + Used in templates {{ p.usageCount }} +

    +
      + @for (u of p.usages; track u.file + ':' + u.line) { +
    • + } +
    +
    + } + +
    +

    Live on the page

    + @if (liveFor(p.name); as live) { + @if (live.churn; as churn) { +

    + churning + @if (live.isPure) { + An instance ran {{ churn.streak }} times in a row and returned an equal result each + time: its input changes reference while its contents stay the same. Keep the input + stable, for example in a computed(). + } @else { + An instance returned an equal object or array as a new reference + {{ churn.streak }} times in a row, so the bindings that read it update for nothing. + Return the previous result when it is equal, or use a computed(). + } +

    + } + @if (live.stale) { +

    + experimental + This pure pipe got an argument whose contents changed while its reference stayed the same, + so it may be showing a stale value. +

    + } +
    +
    Instances
    +
    {{ live.instanceCount }}
    +
    Used by
    +
    +
      + @for (c of live.components; track c.name) { +
    • + @if (c.targets?.[0]; as target) { + + } @else { + + {{ c.name }} + {{ c.count }} + + } +
    • + } +
    +
    + @if (live.call; as call) { +
    Calls
    +
    {{ call.callCount }}
    + @if (call.totalMs !== undefined) { +
    Time
    +
    + {{ ms(call.totalMs) }} total, {{ ms(call.totalMs / call.callCount) }} average, + {{ ms(call.maxMs ?? 0) }} slowest +
    + } + @if (call.errorCount) { +
    Errors
    +
    + {{ call.errorCount }} + @if (call.lastError) { + {{ call.lastError }} + } +
    + } +
    Last input
    +
    {{ describe(call.lastArgs) }}
    +
    Last output
    +
    {{ describe(call.lastResult) }}
    + @if (call.instances?.length) { +
    Per instance
    +
    +
      + @for (i of call.instances; track $index) { +
    • + @if (i.target; as target) { + + } @else { + + {{ i.component }} + + } + + {{ describe(i.lastArgs) }} → {{ describe(i.lastResult) }} + {{ i.callCount }}× + @if (i.totalMs !== undefined) { + {{ ms(i.totalMs) }} + } + + @if (i.lastCaller) { + {{ i.lastCaller }} + } +
    • + } +
    + @if (call.instanceTotal) { +

    + Showing {{ call.instances.length }} of {{ call.instanceTotal }} instances. +

    + } +
    + } + @if (call.lastCaller) { +
    Last caller
    +
    {{ call.lastCaller }}
    + } + } @else if (instrumenting()) { +
    Calls
    +
    None recorded yet.
    + } +
    + @if (live.staleCheck === 'unavailable') { +

    + The stale check is off for this pipe: it couldn't read the compiled template, which + happens in minified builds. +

    + } + @if (!live.call && !instrumenting()) { +

    Turn on “Record calls” to see call counts and values.

    + } + } @else { +

    + Not in use on the connected page. Open a view that uses it, or connect the app. +

    + } +
    +} @else { +

    Pipe details

    +
    +

    Pick a pipe

    +

    + See where it is declared, which components use it and what it last returned. +

    +
    +} diff --git a/app/src/pages/pipes/pipe-detail.scss b/app/src/pages/pipes/pipe-detail.scss new file mode 100644 index 00000000..d121651a --- /dev/null +++ b/app/src/pages/pipes/pipe-detail.scss @@ -0,0 +1,11 @@ +@use 'pipes'; + +@include pipes.mono; +@include pipes.buttons; +@include pipes.chips; +@include pipes.detail; +@include pipes.value; +@include pipes.detail-body; +@include pipes.notes; +@include pipes.states; +@include pipes.state-text; diff --git a/app/src/pages/pipes/pipe-detail.ts b/app/src/pages/pipes/pipe-detail.ts new file mode 100644 index 00000000..70982597 --- /dev/null +++ b/app/src/pages/pipes/pipe-detail.ts @@ -0,0 +1,29 @@ +import { Component, inject } from '@angular/core'; +import { SourceLink } from '../../ui/source-link'; +import { describe, ms, sourceLabel } from './pipes-model'; +import { PipesState } from './pipes-state'; + +/** The detail panel of the Pipes page: the selected pipe's declaration, template uses and live + * data, or a prompt to pick one. */ +@Component({ + selector: 'section[appPipeDetail]', + imports: [SourceLink], + templateUrl: './pipe-detail.html', + styleUrl: './pipe-detail.scss', +}) +export class PipeDetail { + private readonly state = inject(PipesState); + + protected readonly ms = ms; + protected readonly describe = describe; + protected readonly sourceLabel = sourceLabel; + protected readonly selected = this.state.selected; + protected readonly instrumenting = this.state.instrumenting; + + protected liveFor(name: string) { + return this.state.liveFor(name); + } + protected highlight(...args: Parameters) { + this.state.highlight(...args); + } +} diff --git a/app/src/pages/pipes/pipe-lint.html b/app/src/pages/pipes/pipe-lint.html new file mode 100644 index 00000000..e3d3508a --- /dev/null +++ b/app/src/pages/pipes/pipe-lint.html @@ -0,0 +1,37 @@ +

    + Lint + @if (lint(); as findings) { + {{ findings.length }} + } +

    +@if (lintFailed()) { + +} @else if (lint() === null) { +

    Checking…

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

    No problems found.

    +} @else { +
      + @for (f of lint(); track $index) { +
    • +
      + {{ f.severity }} + {{ f.rule }} + @let target = pipeFor(f); + + @if (target) { + + } @else { + {{ f.pipe }} + } + · + +
      +

      {{ f.message }}

      +

      Fix {{ f.fix }}

      +
    • + } +
    +} diff --git a/app/src/pages/pipes/pipe-lint.scss b/app/src/pages/pipes/pipe-lint.scss new file mode 100644 index 00000000..919c8806 --- /dev/null +++ b/app/src/pages/pipes/pipe-lint.scss @@ -0,0 +1,9 @@ +@use 'pipes'; + +@include pipes.mono; +@include pipes.buttons; +@include pipes.chips; +@include pipes.notes; +@include pipes.section-head; +@include pipes.stacks; +@include pipes.lint-list; diff --git a/app/src/pages/pipes/pipe-lint.ts b/app/src/pages/pipes/pipe-lint.ts new file mode 100644 index 00000000..15bcc383 --- /dev/null +++ b/app/src/pages/pipes/pipe-lint.ts @@ -0,0 +1,24 @@ +import { Component, inject } from '@angular/core'; +import { SourceLink } from '../../ui/source-link'; +import { PipesState } from './pipes-state'; + +/** The pipe lint findings, each with a link to the pipe it is about when the pipe is listed. */ +@Component({ + selector: 'section[appPipeLint]', + imports: [SourceLink], + templateUrl: './pipe-lint.html', + styleUrl: './pipe-lint.scss', +}) +export class PipeLint { + private readonly state = inject(PipesState); + + protected readonly lint = this.state.lint; + protected readonly lintFailed = this.state.lintFailed; + + protected pipeFor(...args: Parameters) { + return this.state.pipeFor(...args); + } + protected showPipe(...args: Parameters) { + this.state.showPipe(...args); + } +} diff --git a/app/src/pages/pipes/pipe-list.html b/app/src/pages/pipes/pipe-list.html new file mode 100644 index 00000000..6d1cec9c --- /dev/null +++ b/app/src/pages/pipes/pipe-list.html @@ -0,0 +1,78 @@ +@if (filtered().length) { +
      + @for (p of filtered(); track p.file + p.name) { + @let live = liveFor(p.name); +
    • + +
    • + } +
    +} @else { +
    +

    No pipes match

    +

    + @if (filter().trim()) { + Nothing matches “{{ filter().trim() }}” in this view. + } @else { + None of the pipes fit this filter. + } +

    + +
    +} diff --git a/app/src/pages/pipes/pipe-list.scss b/app/src/pages/pipes/pipe-list.scss new file mode 100644 index 00000000..c6173d71 --- /dev/null +++ b/app/src/pages/pipes/pipe-list.scss @@ -0,0 +1,12 @@ +@use 'mixins' as m; +@use 'pipes'; + +@include pipes.mono; +@include pipes.buttons; +@include pipes.pipe-list; +@include pipes.chips; +@include pipes.states; +:host(.list-wrap) .state.compact { + @include m.panel; +} +@include pipes.state-text; diff --git a/app/src/pages/pipes/pipe-list.ts b/app/src/pages/pipes/pipe-list.ts new file mode 100644 index 00000000..a0899bc6 --- /dev/null +++ b/app/src/pages/pipes/pipe-list.ts @@ -0,0 +1,42 @@ +import { Component, inject, output } from '@angular/core'; +import { plural } from '../../format'; +import { RovingList } from '../../ui/roving-list'; +import { ms } from './pipes-model'; +import { PipesState } from './pipes-state'; + +/** The pipe list of the Pipes page, or the no-match state when the filters hide every pipe. */ +@Component({ + selector: 'div[appPipeList]', + imports: [RovingList], + templateUrl: './pipe-list.html', + styleUrl: './pipe-list.scss', +}) +export class PipeList { + private readonly state = inject(PipesState); + /** Asks the page to clear the filters and focus the filter field. */ + readonly clear = output(); + + protected readonly plural = plural; + protected readonly ms = ms; + protected readonly filtered = this.state.filtered; + protected readonly filter = this.state.filter; + + protected liveFor(name: string) { + return this.state.liveFor(name); + } + protected isSelected(...args: Parameters) { + return this.state.isSelected(...args); + } + protected select(...args: Parameters) { + this.state.select(...args); + } + protected highlightPipe(...args: Parameters) { + this.state.highlightPipe(...args); + } + protected highlight(...args: Parameters) { + this.state.highlight(...args); + } + protected clearFilters() { + this.clear.emit(); + } +} diff --git a/app/src/pages/pipes/pipes-model.ts b/app/src/pages/pipes/pipes-model.ts new file mode 100644 index 00000000..0b282662 --- /dev/null +++ b/app/src/pages/pipes/pipes-model.ts @@ -0,0 +1,159 @@ +import type { SelectOption } from '../../ui/select'; + +export interface UsageSite { + file: string; + line: number; +} + +export interface PipeInfo { + name: string; + className: string; + file: string; + line: number; + isStandalone: boolean; + isPure: boolean; + builtin?: boolean; + usageCount?: number; + usages?: UsageSite[]; + /** Panel only: seen on the page but not found by the source scan, such as a pipe from a + * library. It has no source location, so `file` is empty and `line` is 0. */ + runtime?: boolean; +} + +export interface PipeTarget { + pageId: string; + id: string; +} + +export interface PipeComponentUsage { + name: string; + count: number; + targets?: PipeTarget[]; +} + +export interface PipeCallTiming { + totalMs?: number; + maxMs?: number; + lastMs?: number; + errorCount?: number; + lastError?: string; +} + +export interface PipeInstanceCall extends PipeCallTiming { + component: string; + callCount: number; + lastArgs?: unknown[]; + lastResult?: unknown; + lastCaller?: string; + target?: PipeTarget; +} + +export interface PipeCallInfo extends PipeCallTiming { + callCount: number; + instances?: PipeInstanceCall[]; + instanceTotal?: number; + lastArgs?: unknown[]; + lastResult?: unknown; + lastCaller?: string; +} + +export interface StaleFinding { + detectedAt: number; +} + +export interface ChurnFinding { + streak: number; + detectedAt: number; +} + +export interface LivePipeInfo { + name: string; + className: string; + isPure: boolean; + instanceCount: number; + components: PipeComponentUsage[]; + call?: PipeCallInfo; + stale?: StaleFinding; + churn?: ChurnFinding; + staleCheck?: 'unavailable'; +} + +export interface AsyncUsageInfo { + component: string; + hasSource: boolean; + latestValue?: string; + duplicate: boolean; + resubscribing?: boolean; + target?: PipeTarget; +} + +export interface PipeLintFinding { + rule: string; + severity: 'error' | 'warning' | 'info'; + pipe: string; + file: string; + line: number; + message: string; + fix: string; +} + +export interface PipesPageSnapshot { + pageId: string; + pipes: LivePipeInfo[]; + async: AsyncUsageInfo[]; + asyncTotal?: number; +} + +export interface PipesSnapshot { + pipes: LivePipeInfo[]; + async: AsyncUsageInfo[]; + asyncTotal?: number; + instrumented: string[]; + pages?: PipesPageSnapshot[]; +} + +export const RECORD_CONFIRM_MS = 5000; + +export type PipeKind = 'all' | 'custom' | 'builtin' | 'runtime' | 'impure' | 'live' | 'called'; + +export const KIND_OPTIONS: readonly SelectOption[] = [ + { value: 'all', label: 'All pipes' }, + { value: 'custom', label: 'Custom' }, + { value: 'builtin', label: 'Built-in' }, + { value: 'runtime', label: 'Runtime only' }, + { value: 'impure', label: 'Impure' }, + { value: 'live', label: 'On the page' }, + { value: 'called', label: 'Called while recording' }, +]; + +export type PipeSort = 'source' | 'calls' | 'time'; + +export const SORT_OPTIONS: readonly SelectOption[] = [ + { value: 'source', label: 'Source order' }, + { value: 'calls', label: 'Most calls' }, + { value: 'time', label: 'Most time' }, +]; + +/** Where a pipe comes from, for the detail panel. */ +export function sourceLabel(p: PipeInfo): string { + if (p.builtin) return '@angular/common'; + if (p.runtime) return 'Seen on the page only'; + return 'This project'; +} + +/** A duration in milliseconds, with two decimals under 10 ms. */ +export function ms(value: number): string { + return `${value < 10 ? value.toFixed(2) : Math.round(value)} ms`; +} + +/** A short text form of a recorded pipe input or output. */ +export function describe(value: unknown): string { + if (Array.isArray(value)) return value.map((v) => describe(v)).join(', '); + if (typeof value === 'string') return value.length > 80 ? `${value.slice(0, 80)}…` : value; + try { + const json = JSON.stringify(value); + return json && json.length > 120 ? `${json.slice(0, 120)}…` : (json ?? String(value)); + } catch { + return String(value); + } +} diff --git a/app/src/pages/pipes/pipes-state.ts b/app/src/pages/pipes/pipes-state.ts new file mode 100644 index 00000000..25de394e --- /dev/null +++ b/app/src/pages/pipes/pipes-state.ts @@ -0,0 +1,305 @@ +import { + DestroyRef, + ElementRef, + Injectable, + Injector, + afterNextRender, + computed, + inject, + signal, + type Signal, +} from '@angular/core'; +import type { DevframeRpcClient } from 'devframe/client'; +import { hostPageId } from '../../page-id'; +import { + RECORD_CONFIRM_MS, + type AsyncUsageInfo, + type LivePipeInfo, + type PipeInfo, + type PipeKind, + type PipeLintFinding, + type PipeSort, + type PipeTarget, + type PipesSnapshot, +} from './pipes-model'; + +/** + * The state the Pipes page and its sections share: the source scan, the lint findings, the + * `pipe-usage` shared state, the filters, the selected pipe and call recording. Provided by the + * page, so every section of one page reads the same signals. + */ +@Injectable() +export class PipesState { + private readonly destroyRef = inject(DestroyRef); + private readonly host = inject>(ElementRef); + private readonly injector = inject(Injector); + + private rpcSource: Signal = signal(null); + readonly rpc = computed(() => this.rpcSource()); + private pageIdSource: Signal = signal(null); + + readonly pipes = signal([]); + readonly filter = signal(''); + readonly kind = signal('all'); + readonly sort = signal('source'); + readonly loading = signal(false); + readonly loadFailed = signal(false); + readonly selected = signal(null); + + private readonly snapshot = signal(undefined); + /** The live data of the page this tab shows, or every page merged. */ + private readonly shown = computed(() => { + const snapshot = this.snapshot(); + // With several tabs reporting, show the one asked for or the one this panel belongs to. + const pageId = this.livePageId(); + const own = pageId ? snapshot?.pages?.find((page) => page.pageId === pageId) : undefined; + return own ?? snapshot; + }); + readonly live = computed(() => this.shown()?.pipes ?? []); + readonly async = computed(() => this.shown()?.async ?? []); + /** Every `| async` usage, set only when the list was cut short. */ + readonly asyncTotal = computed(() => this.shown()?.asyncTotal); + readonly lint = signal(null); + readonly lintFailed = signal(false); + readonly instrumentedPages = signal([]); + readonly instrumenting = computed(() => this.instrumentedPages().length > 0); + readonly pendingRecord = signal(null); + readonly recordMessage = signal(null); + readonly recordLabel = computed(() => { + const pending = this.pendingRecord(); + if (pending !== null) return pending ? 'Starting…' : 'Stopping…'; + return this.instrumenting() ? 'Stop recording' : 'Record calls'; + }); + private pendingTimer?: ReturnType; + + private readonly liveByName = computed(() => new Map(this.live().map((p) => [p.name, p]))); + + /** The source scan plus the pipes seen only on the page, such as library pipes. */ + readonly allPipes = computed(() => { + const pipes = this.pipes(); + const scanned = new Set(pipes.map((p) => p.name)); + const runtime: PipeInfo[] = this.live() + .filter((p) => !scanned.has(p.name)) + .map((p) => ({ + name: p.name, + className: p.className, + file: '', + line: 0, + isStandalone: true, + isPure: p.isPure, + runtime: true, + })); + return runtime.length ? [...pipes, ...runtime] : pipes; + }); + + readonly filtered = computed(() => { + const q = this.filter(); + const kind = this.kind() ?? 'all'; + const shown = this.allPipes().filter((p) => this.fitsKind(p, kind) && this.fitsQuery(p, q)); + const sort = this.sort() ?? 'source'; + if (sort === 'source') return shown; + // Hottest first; pipes with no recorded calls keep source order at the end. + const weight = (p: PipeInfo) => { + const call = this.liveFor(p.name)?.call; + if (!call) return -1; + return sort === 'calls' ? call.callCount : (call.totalMs ?? 0); + }; + return shown + .map((p, index) => ({ p, index, weight: weight(p) })) + .sort((a, b) => b.weight - a.weight || a.index - b.index) + .map(({ p }) => p); + }); + + readonly pipesByName = computed(() => { + const byName = new Map(); + for (const p of this.allPipes()) byName.set(p.name, [...(byName.get(p.name) ?? []), p]); + return byName; + }); + + private unsubscribe?: () => void; + + constructor() { + this.destroyRef.onDestroy(() => { + this.unsubscribe?.(); + clearTimeout(this.pendingTimer); + this.highlight(null); + }); + } + + /** Reads the client and the page to show from the page's inputs. */ + connect(rpc: Signal, pageId: Signal) { + this.rpcSource = rpc; + this.pageIdSource = pageId; + } + + async refresh() { + const client = this.rpc(); + if (!client) return; + this.loading.set(true); + this.loadFailed.set(false); + try { + const my = client.scope('pangular'); + const pipes = (await my.rpc.call('get-pipes')) as PipeInfo[]; + this.pipes.set(pipes); + const sel = this.selected(); + if (sel) { + const refreshed = this.allPipes().find((p) => p.name === sel.name && p.file === sel.file); + this.selected.set(refreshed ?? null); + } + } catch { + this.loadFailed.set(true); + } finally { + this.loading.set(false); + } + this.loadLint(client); + } + + async loadLint(client: DevframeRpcClient) { + this.lintFailed.set(false); + try { + const findings = (await client.scope('pangular').rpc.call('pipe-lint')) as PipeLintFinding[]; + this.lint.set(findings); + } catch { + this.lintFailed.set(true); + } + } + + async loadLive(client: DevframeRpcClient) { + try { + const state = await client.scope('pangular').rpc.sharedState('pipe-usage'); + if (this.destroyRef.destroyed) return; + const apply = (value: unknown) => { + const snapshot = value as PipesSnapshot | undefined; + this.snapshot.set(snapshot); + // A runtime-only pipe has nothing left to show once the page stops using it. + const sel = this.selected(); + if (sel?.runtime && !this.liveByName().has(sel.name)) this.selected.set(null); + const pages = snapshot?.instrumented ?? []; + this.instrumentedPages.set(pages); + if (this.pendingRecord() === pages.length > 0) this.settleRecord(null); + }; + apply(state.value()); + this.unsubscribe?.(); + this.unsubscribe = state.on('updated', apply); + } catch { + // RPC not available + } + } + + private livePageId(): string | null { + return this.pageIdSource() ?? hostPageId(); + } + + async toggleInstrument() { + const client = this.rpc(); + if (!client || this.pendingRecord() !== null) return; + const on = !this.instrumenting(); + this.recordMessage.set(null); + this.pendingRecord.set(on); + let result: { pages?: number } | undefined; + try { + // Start on this panel's page (or the latest page); stop every page that records. + const request = on ? { pageId: this.livePageId() ?? undefined, on } : { on }; + result = (await client.scope('pangular').rpc.call('request-instrument-pipes', request)) as + { pages?: number } | undefined; + } catch { + this.settleRecord("Couldn't reach the devtools server. Try again."); + return; + } + if (this.pendingRecord() !== on) return; + if (result?.pages === 0) { + this.settleRecord( + 'No page is connected. Open your app in the browser with the devtools running, then record again.', + ); + return; + } + clearTimeout(this.pendingTimer); + this.pendingTimer = setTimeout(() => { + if (this.pendingRecord() !== on) return; + this.settleRecord( + on + ? 'No page confirmed recording. Reload the app and try again.' + : 'No page confirmed it stopped recording. Reload the app to stop it.', + ); + }, RECORD_CONFIRM_MS); + } + + private settleRecord(message: string | null) { + clearTimeout(this.pendingTimer); + this.pendingRecord.set(null); + if (message !== null) this.recordMessage.set(message); + } + + liveFor(name: string): LivePipeInfo | undefined { + return this.liveByName().get(name); + } + + isSelected(pipe: PipeInfo): boolean { + const sel = this.selected(); + return sel !== null && sel.name === pipe.name && sel.file === pipe.file; + } + + select(pipe: PipeInfo) { + this.selected.set(this.isSelected(pipe) ? null : pipe); + } + + /** The listed pipe a lint finding is about: the one declared in the finding's file, or else + * the first pipe with that name. Null when the pipe is not in the list. */ + pipeFor(finding: PipeLintFinding): PipeInfo | null { + const matches = this.pipesByName().get(finding.pipe); + if (!matches?.length) return null; + return matches.find((p) => p.file === finding.file) ?? matches[0]; + } + + /** Selects a pipe from a lint finding, clearing only the filters that hide it, then scrolls + * its row into view and moves focus to the detail heading. */ + showPipe(pipe: PipeInfo) { + if (!this.fitsQuery(pipe, this.filter())) this.filter.set(''); + if (!this.fitsKind(pipe, this.kind() ?? 'all')) this.kind.set('all'); + this.selected.set(pipe); + afterNextRender( + () => { + const host = this.host.nativeElement; + host.querySelector('.row.selected')?.scrollIntoView?.({ block: 'nearest' }); + host.querySelector('#pipe-detail-title')?.focus(); + }, + { injector: this.injector }, + ); + } + + private fitsKind(p: PipeInfo, kind: PipeKind): boolean { + if (kind === 'custom') return !p.builtin && !p.runtime; + if (kind === 'builtin') return !!p.builtin; + if (kind === 'runtime') return !!p.runtime; + if (kind === 'impure') return !p.isPure; + if (kind === 'live') return this.liveByName().has(p.name); + if (kind === 'called') return !!this.liveByName().get(p.name)?.call; + return true; + } + + private fitsQuery(p: PipeInfo, filter: string): boolean { + const q = filter.trim().toLowerCase(); + return ( + !q || + p.name.toLowerCase().includes(q) || + p.className.toLowerCase().includes(q) || + p.file.toLowerCase().includes(q) + ); + } + + highlightPipe(live: LivePipeInfo | undefined) { + this.highlight(live?.components.find((c) => c.targets?.length)?.targets?.[0]); + } + + highlight(target: PipeTarget | null | undefined, reveal = false) { + const client = this.rpc(); + if (!client) return; + void client + .scope('pangular') + .rpc.call( + 'request-page-highlight', + target ? { ...target, ...(reveal ? { reveal } : {}) } : null, + ) + .catch(() => {}); + } +} diff --git a/app/src/pages/signal-inspector.html b/app/src/pages/signal-inspector.html new file mode 100644 index 00000000..98b3afd7 --- /dev/null +++ b/app/src/pages/signal-inspector.html @@ -0,0 +1,176 @@ +
    + + @if (pageOptions().length > 1) { +
    + Page + +
    + } + @if (componentOptions().length) { +
    + Graph of + +
    + } +
    +

    {{ announcement() }}

    + +@if (graph()?.component; as comp) { +

    + {{ comp.name }} + {{ comp.path }} + @if (graph()!.source) { + {{ sourceNote(graph()!.source!) }} + } +

    +} @else if (graph()?.injector; as injector) { +

    + {{ injectorLabel(injector) }} + environment injector +

    +} @else if (graph()?.element; as element) { +

    + {{ element.directives.join(', ') || '<' + element.tag + '>' }} + <{{ element.tag }}> + directives, no component +

    +} +@if (graph() && picked() && graph()!.source !== 'selected') { +

    + The picked component or injector is gone or has no signal graph, so this shows another one. +

    +} + +

    + @if (graph()?.injector) { + The effects registered on this injector and the signals they read. A signal that no effect reads + is not part of the graph. Pick a kind to filter. + } @else if (graph()) { + The live signal graph of one component. Only signals that its template or an effect has read + appear here; a signal nothing has read yet is not part of the graph. Pick a kind to filter. + } @else if (!graphKnown()) { + Loading the live signal graph… + } @else { + Every signal, computed and effect found in your source. Pick a kind to filter. + } +

    +@if (!graph()) { + + This list comes from a source scan. + +} +@if (graph()?.writeHook === false) { +

    + The signal write hook did not load. Value history shows sampled values only, with no exact set + entries. +

    +} +@if (graph()?.nodeCount; as total) { +

    + Showing {{ graph()!.nodes.length }} of {{ total }} signals. The rest and their edges are left + out. +

    +} +@if (kindChips().length) { +
    + + @for (group of kindChips(); track group.kind) { + + } +
    +} + +@if (!graph() && !sourceLoaded()) { +
    + +

    Scanning source for signals…

    +
    +} + +@if (!graph() && sourceLoaded() && loadFailed()) { + +} @else if (!graph() && sourceLoaded() && sourceSignals().length === 0) { +
    +

    No signals found.

    +

    + No signal(), computed() or effect() calls were found in + your source. To see the live graph, run Angular 20.1 or later with the overlay connected and + select a component. +

    +
    +} + +@if (!graph() && sourceSignals().length > 0) { +

    Signals from the source scan:

    +
    +} + +@if (graph() && (visibleNodes().length || frameworkCount())) { +
    +} + +@if (watchedNodes().length || watchedElsewhere()) { +
    +} + +

    {{ jumpNote() }}

    + +@if (graph() && filteredResources().length) { +

    Resources

    +
      +} + +@if (graph() && (visibleNodes().length || !filteredResources().length)) { +

      Signals

      +
        +} diff --git a/app/src/pages/signal-inspector.scss b/app/src/pages/signal-inspector.scss new file mode 100644 index 00000000..481b8f5c --- /dev/null +++ b/app/src/pages/signal-inspector.scss @@ -0,0 +1,198 @@ +@use 'mixins' as m; +@use 'signals' as sig; + +:host { + display: block; + min-width: 0; + color: var(--text); + font-size: 13px; +} +.toolbar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + margin: 0 0 16px; + padding: 10px 12px; + background: color-mix(in srgb, var(--surface) 85%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--border); + border-radius: var(--radius); +} +input { + flex: 1 1 200px; + min-width: 0; + height: 34px; + padding: 0 12px; + background: var(--bg); + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + color: var(--text); + font: inherit; + font-size: 13px; + outline: none; + transition: + border-color 0.15s var(--ease), + box-shadow 0.15s var(--ease); +} +input::placeholder { + color: var(--text-3); +} +input:hover { + border-color: color-mix(in srgb, var(--text-3) 60%, var(--control-border)); +} +input:focus-visible { + @include m.field-focus; +} +@include sig.states; +.intro { + margin: 0 0 12px; + color: var(--text-2); + font-size: 13px; + line-height: 1.5; +} +.kinds { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 16px; +} +.kind-chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + height: 28px; + padding: 0 10px; + border: 1px solid var(--control-border); + border-radius: 99px; + background: var(--surface-2); + color: var(--text-2); + font: inherit; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: + background-color 0.15s var(--ease), + border-color 0.15s var(--ease), + color 0.15s var(--ease); +} +.kind-chip:hover { + border-color: var(--text-3); + background: var(--surface-3); + color: var(--text); +} +.kind-chip:active { + transform: translateY(0.5px); +} +.kind-chip.active { + border-color: var(--accent-line); + background: var(--accent-soft); + color: var(--text-strong); +} +.kind-chip:focus-visible { + @include m.focus-ring; +} +.chip-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.kind-chip .count { + flex: none; + min-width: 18px; + padding: 0 5px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 11px; + line-height: 16px; + text-align: center; + font-variant-numeric: tabular-nums; +} +.kind-chip.active .count { + background: color-mix(in srgb, var(--accent) 22%, transparent); + color: var(--text-strong); +} +.dot { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; +} +.picker { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 260px; + min-width: 0; +} +.picker app-select { + flex: 1 1 auto; + min-width: 0; +} +.page-picker { + flex: 0 1 240px; +} +.label-key { + flex: none; + color: var(--text-2); + font-size: 12px; +} +.showing { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + margin: 0 0 8px; + font-size: 12px; + color: var(--text-2); +} +.showing-name { + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 13px; + font-weight: 600; +} +.mono { + font-family: var(--font-mono); + overflow-wrap: anywhere; +} +.source-note { + padding: 0 8px; + border: 1px solid var(--border-strong); + border-radius: 99px; + line-height: 18px; +} +.fallback { + margin: 0 0 8px; + color: var(--warn); + font-size: 12px; +} +.source-label { + @include m.label; + margin: 0 0 12px; +} +@include sig.list-heading; +.jump-note { + margin: 0; + font-size: 12px; + color: var(--text-2); +} +.jump-note:not(:empty) { + margin-bottom: 8px; +} +@media (max-width: 480px) { + .toolbar { + padding: 8px; + } + .toolbar input, + .picker { + flex-basis: 100%; + } +} diff --git a/app/src/pages/signal-inspector.ts b/app/src/pages/signal-inspector.ts index 926ae0c8..dc7621a7 100644 --- a/app/src/pages/signal-inspector.ts +++ b/app/src/pages/signal-inspector.ts @@ -1,2513 +1,149 @@ -import { - Component, - DestroyRef, - ElementRef, - Injector, - afterNextRender, - computed, - effect, - inject, - input, - linkedSignal, - signal, - untracked, - viewChild, -} from '@angular/core'; -import { DatePipe, JsonPipe, NgTemplateOutlet } from '@angular/common'; +import { Component, DestroyRef, effect, inject, input, untracked, viewChild } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; -import { pickPage, type ReportedPage } from '../live-pages'; -import { actionAllowed, actionBlockedMessage } from '../devtools-config'; -import { isNativePage } from '../native-page'; -import { isStaticReport } from '../rpc'; -import { hostPageId } from '../page-id'; -import { angularPageFor, injectAngularPages } from '../angular-pages'; import { AngularUnavailable } from '../ui/angular-unavailable'; import { RovingList } from '../ui/roving-list'; -import { Select, type SelectOption } from '../ui/select'; -import { SourceLink } from '../ui/source-link'; -import type { GraphLink } from './signal-graph-layout'; -import type { SignalFocus } from '../types/signal-focus.types'; -import { SignalGraphView } from './signal-graph-view'; +import { Select } from '../ui/select'; import { SearchField } from '../ui/search-field'; -import { jsonDiff, type JsonDiffEntry } from './json-diff'; -import type { GraphInjector, SignalGraph, SignalResource } from '../types/signal-graph.types'; -import type { SignalChange, SignalGraphNode } from '@pangular-inspector/devtools/internal/types'; - -type SignalNode = SignalGraphNode; - -interface RestoreResult { - ok?: boolean; - key?: string; - error?: string; -} - -interface LiveNode { - id: string; - name: string; - tag: string; - /** `directive` for an element that hosts directives but no component. */ - kind?: 'directive'; - children: LiveNode[]; -} - -const FOLLOW = 'follow'; - -// Older servers send only the newest graph, without the per-page map. -function graphPagesOf(state: { - graph?: SignalGraph | null; - pages: Record; -}): Record { - if (Object.keys(state.pages).length) return state.pages; - return state.graph ? { [state.graph.pageId ?? '']: state.graph } : {}; -} -const ENV = 'env:'; -const MAX_HISTORY = 50; -/** Kinds with a value, so the page can watch them. */ -const WATCHABLE = new Set(['signal', 'computed', 'linkedSignal']); -/** Changes of watched signals the panel lists. */ -const WATCH_LOG = 20; - -const SOURCE_NOTES: Record, string> = { - selected: 'picked', - routed: 'rendered by the router', - root: 'first component with signals', -}; - -const SOURCE_LABELS: Record = { - write: 'set', - sample: 'sampled', - initial: 'initial', -}; - -const RESOURCE_STATUS_TONES: Record = { - resolved: 'ok', - local: 'ok', - loading: 'warn', - reloading: 'warn', - error: 'danger', -}; - -interface SourceSignal { - name: string; - kind: string; - file: string; - line: number; - component?: string; -} - -/** Each kind's `--kind-*` token from `_palette.scss` (`$signal-kinds`). */ -const KIND_TOKENS: Record = { - signal: 'signal', - computed: 'computed', - linkedSignal: 'linked', - effect: 'effect', - template: 'template', - afterRenderEffectPhase: 'after-render', - childSignalProp: 'child-prop', - 'input (signal)': 'input', - 'input.required (signal)': 'input', - 'output (signal)': 'output', - 'model (signal)': 'model', - 'model.required (signal)': 'model', - 'viewChild (signal)': 'view-query', - 'viewChild.required (signal)': 'view-query', - 'viewChildren (signal)': 'view-query', - 'contentChild (signal)': 'content-query', - 'contentChild.required (signal)': 'content-query', - 'contentChildren (signal)': 'content-query', - resource: 'resource', -}; - +import type { SignalFocus } from '../types/signal-focus.types'; +import type { GraphInjector } from '../types/signal-graph.types'; +import { ResourceList } from './signals/resource-list'; +import { SignalDetailState } from './signals/signal-detail-state'; +import { SignalGraphSection } from './signals/signal-graph-section'; +import { SignalList } from './signals/signal-list'; +import { kindColor, sourceNote, type SignalNode } from './signals/signal-model'; +import { SignalState } from './signals/signal-state'; +import { SourceSignals } from './signals/source-signals'; +import { WatchedSignals } from './signals/watched-signals'; + +/** + * The Signals page: the toolbar, the notes on what the graph shows, the kind chips and the load + * states, and one section per part of the graph. The sections live in `./signals/` and share the + * page's {@link SignalState} and {@link SignalDetailState}; the page keeps the members its tests + * and the shell read on itself. + */ @Component({ selector: 'app-signal-inspector', imports: [ - SearchField, AngularUnavailable, - DatePipe, - JsonPipe, - NgTemplateOutlet, + ResourceList, RovingList, + SearchField, Select, - SignalGraphView, - SourceLink, + SignalGraphSection, + SignalList, + SourceSignals, + WatchedSignals, ], - template: ` -
        - - @if (pageOptions().length > 1) { -
        - Page - -
        - } - @if (componentOptions().length) { -
        - Graph of - -
        - } -
        -

        {{ announcement() }}

        - - @if (graph()?.component; as comp) { -

        - {{ comp.name }} - {{ comp.path }} - @if (graph()!.source) { - {{ sourceNote(graph()!.source!) }} - } -

        - } @else if (graph()?.injector; as injector) { -

        - {{ injectorLabel(injector) }} - environment injector -

        - } @else if (graph()?.element; as element) { -

        - {{ - element.directives.join(', ') || '<' + element.tag + '>' - }} - <{{ element.tag }}> - directives, no component -

        - } - @if (graph() && picked() && graph()!.source !== 'selected') { -

        - The picked component or injector is gone or has no signal graph, so this shows another one. -

        - } - -

        - @if (graph()?.injector) { - The effects registered on this injector and the signals they read. A signal that no effect - reads is not part of the graph. Pick a kind to filter. - } @else if (graph()) { - The live signal graph of one component. Only signals that its template or an effect has read - appear here; a signal nothing has read yet is not part of the graph. Pick a kind to filter. - } @else if (!graphKnown()) { - Loading the live signal graph… - } @else { - Every signal, computed and effect found in your source. Pick a kind to filter. - } -

        - @if (!graph()) { - - This list comes from a source scan. - - } - @if (graph()?.writeHook === false) { -

        - The signal write hook did not load. Value history shows sampled values only, with no exact - set entries. -

        - } - @if (graph()?.nodeCount; as total) { -

        - Showing {{ graph()!.nodes.length }} of {{ total }} signals. The rest and their edges are - left out. -

        - } - @if (kindChips().length) { -
        - - @for (group of kindChips(); track group.kind) { - - } -
        - } - - @if (!graph() && !sourceLoaded()) { -
        - -

        Scanning source for signals…

        -
        - } - - @if (!graph() && sourceLoaded() && loadFailed()) { - - } @else if (!graph() && sourceLoaded() && sourceSignals().length === 0) { -
        -

        No signals found.

        -

        - No signal(), computed() or effect() calls were - found in your source. To see the live graph, run Angular 20.1 or later with the overlay - connected and select a component. -

        -
        - } - - @if (!graph() && sourceSignals().length > 0) { -

        Signals from the source scan:

        -
        - @for (sig of filteredSourceSignals(); track sig.name + sig.file + sig.line) { -
        -
        - {{ - sig.kind - }} - {{ sig.name }} -
        -
        - - @if (sig.component) { - - in <{{ sig.component }}> - } -
        -
        - } @empty { -
        -

        No signals match.

        -

        Try a different name or kind.

        - -
        - } -
        - } - - - @if (selectedHistory().length) { -

        {{ label }}

        -

        {{ historySummary(id) }}

        - @let restoreNode = restorable(id); - @if (restoreNode) { - @if (restoreBlocked(); as reason) { -

        Restore is off. {{ reason }}

        - } -

        - {{ restoreResult()?.id === id ? restoreResult()?.text : '' }} -

        - } -
          - @for (change of selectedHistory(); track change.epoch) { -
        1. - - - {{ - sourceLabel(change.source) - }} - epoch {{ change.epoch }} - @if (change.missed) { - {{ change.missed }} earlier not captured - } - @if (restoreNode && !restoreBlocked() && !$first) { - @let time = change.at | date: 'HH:mm:ss.SSS'; - - } - -
          {{ change.value | json }}
          - @if (diffs().get(change.epoch); as diff) { -
          - Changes from the entry before ({{ diff.length }}) -
            - @for (d of diff; track d.path) { -
          • - {{ diffLabel(d.kind) }}: {{ d.path }}, - @if (d.kind !== 'added') { - {{ d.before | json }} - } - @if (d.kind === 'changed') { - to - } - @if (d.kind !== 'removed') { - {{ d.after | json }} - } -
          • - } -
          -
          - } - @if (change.stack?.length) { -
            - @for (frame of change.stack; track $index) { -
          1. {{ frame }}
          2. - } -
          - } -
        2. - } -
        - } -
        - - @if (graph() && (visibleNodes().length || frameworkCount())) { -
        -
        -

        Graph

        -
        - @if (frameworkCount(); as hidden) { - - } - - -
        -
        - @if (stacksOn()) { -

        - Write stacks are on for this page. Each set of a named signal keeps the first 3 app - frames of the code that wrote it, shown in its value history. Taking a stack slows every - write, so turn it off when you are done. -

        - } - @if (showGraph()) { -
        - @if (graphNodes().length) { - - } @else { -

        - Every signal in this graph was created by Angular. Show framework signals to see - them. -

        - } - @if (selectedNode(); as node) { -

        - Selected - {{ node.label ?? '(unnamed)' }} - - {{ plural(getDependencies(node).length, 'producer') }} - - {{ plural(getConsumers(node).length, 'consumer') }} - -

        - } -
        - } -
        - } - - @if (watchedNodes().length || watchedElsewhere()) { -
        -

        Watched

        - @if (watchedElsewhere(); as other) { -

        - {{ other }} watched {{ other === 1 ? 'signal is' : 'signals are' }} not in this graph. - Their changes still log to the page console. -

        - } -
          - @for (node of watchedNodes(); track node.id) { -
        • - {{ node.label ?? '(unnamed)' }} - @if (node.value !== undefined) { - {{ node.value | json }} - } - @if (changeCount(node.id); as count) { - {{ count }} {{ count === 1 ? 'change' : 'changes' }} - } - -
        • - } -
        - @if (watchLog().length) { -

        Recent changes

        -
        -
          - @for (entry of watchLog(); track entry.id + ':' + entry.change.epoch) { -
        1. - - - {{ entry.label }} - {{ - sourceLabel(entry.change.source) - }} - -
          {{ entry.change.value | json }}
          - @if (entry.change.stack?.length) { -
            - @for (frame of entry.change.stack; track $index) { -
          1. {{ frame }}
          2. - } -
          - } -
        2. - } -
        -
        - } -
        - } - -

        {{ jumpNote() }}

        - - @if (graph() && filteredResources().length) { -

        Resources

        -
          - @for (res of filteredResources(); track res.id) { -
        • - - @if (selectedId() === res.id) { -
          -

          {{ res.name }}

          - @if (!res.named) { -

          - This resource has no name. Pass debugName to - resource() or httpResource() to name it here. -

          - } -
          -
          Status
          -
          {{ res.status ?? 'unknown' }}
          - @if (res.isLoading !== undefined) { -
          Loading
          -
          {{ res.isLoading ? 'yes' : 'no' }}
          - } - @if (res.statusCode !== undefined) { -
          HTTP status
          -
          {{ res.statusCode }}
          - } - @if (res.params !== undefined) { -
          Params
          -
          -
          {{ res.params | json }}
          -
          - } - @if (res.value !== undefined) { -
          Value
          -
          -
          {{ res.value | json }}
          -
          - } - @if (res.error !== undefined) { -
          Error
          -
          -
          {{ res.error | json }}
          -
          - } -
          - - - @if (showInternals()) { -
            - @for (inner of internalsOf(res); track inner.id) { -
          • - {{ - inner.kind - }} - {{ inner.label ?? inner.id }} -
          • - } -
          - } -
          - } -
        • - } -
        - } - - @if (graph() && (visibleNodes().length || !filteredResources().length)) { -

        Signals

        -
          - @for (node of filteredNodes(); track node.id) { -
        • - - @if (selectedId() === node.id && selectedNode()) { -
          -

          {{ selectedNode()!.label ?? selectedNode()!.id }}

          -
          -
          Kind
          -
          {{ selectedNode()!.kind }}
          -
          Epoch
          -
          {{ selectedNode()!.epoch }}
          - @if (selectedNode()!.runs; as runs) { -
          Runs
          -
          - {{ runs }}, {{ selectedNode()!.runMs ?? 0 }} ms in all, last - {{ selectedNode()!.lastRunMs ?? 0 }} ms -
          - } - @if (sourceMatch(); as match) { -
          Source
          -
          - @if (match.exact) { - - } @else { - {{ match.candidates.length }} declarations match this name: -
            - @for (c of match.candidates; track c.file + c.line) { -
          • - - @if (c.component) { - in {{ c.component }} - } -
          • - } -
          - } -
          - } - @if (selectedNode()!.value !== undefined) { -
          Value
          -
          -
          {{ selectedNode()!.value | json }}
          -
          - } -
          - @if (getDependencies(selectedNode()!).length) { -

          Dependencies (producers)

          -
            - @for (dep of getDependencies(selectedNode()!); track dep.id) { -
          • - -
          • - } -
          - } - @if (getConsumers(selectedNode()!).length) { -

          Consumers

          -
            - @for (con of getConsumers(selectedNode()!); track con.id) { -
          • - -
          • - } -
          - } - @if (isWatchable(selectedNode()!)) { - -

          - A watched signal is listed under Watched and logs each change to the page - console. -

          - } - @if (writeHint(); as hint) { -

          {{ hint }}

          - } - -
          - } -
        • - } @empty { -
        • -

          - {{ hasAnySignals() ? 'No signals match.' : 'No signals in this graph.' }} -

          -

          - {{ - hasAnySignals() - ? 'Try a different name or kind.' - : 'Select a component that reads signals, or interact with the page to create some.' - }} -

          - @if (hasAnySignals() || filter() || kind()) { - - } -
        • - } -
        - } - `, - styles: ` - @use 'mixins' as m; - - :host { - display: block; - min-width: 0; - color: var(--text); - font-size: 13px; - } - .toolbar { - position: sticky; - top: 0; - z-index: 2; - display: flex; - flex-wrap: wrap; - gap: 8px 12px; - align-items: center; - margin: 0 0 16px; - padding: 10px 12px; - background: color-mix(in srgb, var(--surface) 85%, transparent); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - border: 1px solid var(--border); - border-radius: var(--radius); - } - input { - flex: 1 1 200px; - min-width: 0; - height: 34px; - padding: 0 12px; - background: var(--bg); - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - color: var(--text); - font: inherit; - font-size: 13px; - outline: none; - transition: - border-color 0.15s var(--ease), - box-shadow 0.15s var(--ease); - } - input::placeholder { - color: var(--text-3); - } - input:hover { - border-color: color-mix(in srgb, var(--text-3) 60%, var(--control-border)); - } - input:focus-visible { - @include m.field-focus; - } - .empty { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - text-align: center; - padding: 40px 24px; - @include m.panel; - @include m.enter; - } - .empty.compact { - padding: 24px 16px; - border-style: dashed; - background: transparent; - } - .empty-title { - margin: 0; - color: var(--text-strong); - font-size: 14px; - font-weight: 600; - } - .hint { - max-width: 460px; - margin: 0; - color: var(--text-2); - font-size: 13px; - line-height: 1.55; - overflow-wrap: anywhere; - } - .hint code { - padding: 1px 5px; - font-family: var(--font-mono); - font-size: 12px; - color: var(--text); - background: var(--surface-3); - border: 1px solid var(--border); - border-radius: 5px; - } - .reset { - height: 34px; - margin-top: 4px; - padding: 0 14px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--surface-2); - color: var(--text); - font: inherit; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: - background-color 0.15s var(--ease), - border-color 0.15s var(--ease); - } - .reset:hover { - background: var(--surface-3); - } - .reset:focus-visible { - @include m.focus-ring; - } - .spinner { - width: 18px; - height: 18px; - border: 2px solid var(--border-strong); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - } - @media (prefers-reduced-motion: reduce) { - .spinner { - animation: none; - border-top-color: var(--border-strong); - } - } - .intro { - margin: 0 0 12px; - color: var(--text-2); - font-size: 13px; - line-height: 1.5; - } - .kinds { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-bottom: 16px; - } - .kind-chip { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: 100%; - height: 28px; - padding: 0 10px; - border: 1px solid var(--control-border); - border-radius: 99px; - background: var(--surface-2); - color: var(--text-2); - font: inherit; - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: - background-color 0.15s var(--ease), - border-color 0.15s var(--ease), - color 0.15s var(--ease); - } - .kind-chip:hover { - border-color: var(--text-3); - background: var(--surface-3); - color: var(--text); - } - .kind-chip:active { - transform: translateY(0.5px); - } - .kind-chip.active { - border-color: var(--accent-line); - background: var(--accent-soft); - color: var(--text-strong); - } - .kind-chip:focus-visible { - @include m.focus-ring; - } - .chip-text { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .kind-chip .count { - flex: none; - min-width: 18px; - padding: 0 5px; - border-radius: 99px; - background: var(--surface-3); - color: var(--text-2); - font-size: 11px; - line-height: 16px; - text-align: center; - font-variant-numeric: tabular-nums; - } - .kind-chip.active .count { - background: color-mix(in srgb, var(--accent) 22%, transparent); - color: var(--text-strong); - } - .dot { - flex: none; - width: 8px; - height: 8px; - border-radius: 50%; - } - .node-header { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px 8px; - min-width: 0; - } - .kind-badge { - flex: none; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 10.5px; - line-height: 18px; - padding: 0 8px; - border-radius: 99px; - color: var(--kind-ink); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - } - .node-label { - min-width: 0; - font-family: var(--font-mono); - font-size: 13px; - font-weight: 500; - color: var(--text-strong); - overflow-wrap: anywhere; - } - .node-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 2px 6px; - min-width: 0; - margin-top: 6px; - font-size: 12px; - color: var(--text-3); - font-variant-numeric: tabular-nums; - } - .node-meta .file { - min-width: 0; - font-family: var(--font-mono); - font-size: 11.5px; - overflow-wrap: anywhere; - } - .sep { - color: var(--border-strong); - } - dl { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - gap: 8px 16px; - margin: 0; - font-size: 13px; - } - dt { - @include m.label; - padding-top: 2px; - } - dd { - min-width: 0; - margin: 0; - color: var(--text); - font-variant-numeric: tabular-nums; - overflow-wrap: anywhere; - } - pre { - font-family: var(--font-mono); - font-size: 12.5px; - line-height: 1.55; - white-space: pre-wrap; - overflow-wrap: anywhere; - margin: 0; - color: var(--text); - } - .nodes { - display: flex; - flex-direction: column; - gap: 8px; - list-style: none; - padding: 0; - margin: 0; - @include m.enter; - } - .node-card { - display: block; - width: 100%; - min-width: 0; - text-align: left; - font: inherit; - color: inherit; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 12px 16px; - transition: - background-color 0.15s var(--ease), - border-color 0.15s var(--ease), - box-shadow 0.15s var(--ease); - } - .node-card:hover { - background: var(--surface-2); - border-color: var(--border-strong); - } - .picker { - display: flex; - align-items: center; - gap: 8px; - flex: 1 1 260px; - min-width: 0; - } - .picker app-select { - flex: 1 1 auto; - min-width: 0; - } - .page-picker { - flex: 0 1 240px; - } - .label-key { - flex: none; - color: var(--text-2); - font-size: 12px; - } - .showing { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 4px 10px; - margin: 0 0 8px; - font-size: 12px; - color: var(--text-2); - } - .showing-name { - color: var(--text-strong); - font-family: var(--font-mono); - font-size: 13px; - font-weight: 600; - } - .mono { - font-family: var(--font-mono); - overflow-wrap: anywhere; - } - .source-note { - padding: 0 8px; - border: 1px solid var(--border-strong); - border-radius: 99px; - line-height: 18px; - } - .fallback { - margin: 0 0 8px; - color: var(--warn); - font-size: 12px; - } - .source-label { - @include m.label; - margin: 0 0 12px; - } - .nodes > li { - display: block; - min-width: 0; - padding: 0; - } - /* The empty card keeps its centred column inside the list. */ - .nodes > li.empty { - display: flex; - padding: 24px 16px; - } - button.node-card { - cursor: pointer; - } - .node-card:focus-visible { - @include m.focus-ring; - } - .node-card.selected { - background: var(--accent-soft); - border-color: var(--accent-line); - box-shadow: inset 2px 0 0 var(--accent); - } - .chevron { - flex: none; - width: 7px; - height: 7px; - margin-left: auto; - border-right: 1.5px solid var(--text-3); - border-bottom: 1.5px solid var(--text-3); - transform: translateY(-2px) rotate(45deg); - transition: - transform 0.2s var(--ease), - border-color 0.15s var(--ease); - } - .node-card:hover .chevron { - border-color: var(--text); - } - .node-card.selected .chevron { - border-color: var(--accent); - transform: translateY(2px) rotate(-135deg); - } - .node-value { - display: block; - margin-top: 8px; - padding: 4px 8px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--font-mono); - font-size: 12px; - color: var(--text-2); - background: var(--bg); - border: 1px solid var(--border); - border-radius: 6px; - } - .node-card.selected .node-value { - background: color-mix(in srgb, var(--bg) 70%, transparent); - } - .kind-badge.sm { - font-size: 10px; - line-height: 16px; - padding: 0 6px; - } - .changed-badge { - flex: none; - font-size: 11px; - font-weight: 500; - line-height: 16px; - padding: 0 8px; - border: 1px solid; - border-radius: 99px; - font-variant-numeric: tabular-nums; - } - .changed-badge { - @include m.tint(warn); - } - .detail-panel { - margin-top: 8px; - padding: 16px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - box-shadow: var(--shadow); - @include m.enter(0.25s); - } - .detail-title { - margin: 0 0 16px; - font-family: var(--font-mono); - font-size: 14px; - font-weight: 600; - color: var(--accent); - overflow-wrap: anywhere; - } - .detail-panel h4 { - @include m.label; - margin: 20px 0 8px; - } - .detail-panel dd pre { - max-height: 240px; - overflow: auto; - padding: 8px 12px; - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - } - .scroll-box:focus-visible { - @include m.focus-ring(-2px); - } - .list-heading { - @include m.label; - margin: 16px 0 8px; - } - .resources { - margin-bottom: 8px; - } - .status-tag { - flex: none; - font-size: 11px; - font-weight: 500; - line-height: 16px; - padding: 0 8px; - border: 1px solid; - border-radius: 99px; - } - .tone-ok { - @include m.tint(ok); - } - .tone-warn { - @include m.tint(warn); - } - .tone-danger { - @include m.tint(danger); - } - .tone-neutral { - color: var(--text-2); - border-color: var(--border-strong); - } - .detail-panel .hint { - margin: 0 0 12px; - } - .internals-toggle { - margin-top: 16px; - } - .detail-panel .internals-toggle + ul { - margin-top: 8px; - } - .detail-panel ul { - display: flex; - flex-direction: column; - gap: 2px; - list-style: none; - padding: 0; - margin: 0; - font-size: 13px; - } - .detail-panel ul li, - .rel-link { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - min-height: 28px; - padding: 4px 8px; - color: var(--text-2); - border-radius: 6px; - } - .detail-panel ul li:has(> .rel-link) { - padding: 0; - } - .rel-link { - width: 100%; - font: inherit; - text-align: left; - background: none; - border: 0; - cursor: pointer; - transition: - background-color 0.15s var(--ease), - color 0.15s var(--ease); - } - .rel-link:hover { - background: var(--surface-2); - color: var(--text); - } - .rel-link:focus-visible { - @include m.focus-ring; - } - .jump-note { - margin: 0; - font-size: 12px; - color: var(--text-2); - } - .jump-note:not(:empty) { - margin-bottom: 8px; - } - .rel-label { - min-width: 0; - font-family: var(--font-mono); - font-size: 12.5px; - overflow-wrap: anywhere; - } - .history-summary { - margin: 0 0 8px; - font-size: 12px; - color: var(--text-2); - font-variant-numeric: tabular-nums; - } - .history { - list-style: none; - padding: 4px; - margin: 0; - max-height: 320px; - overflow: auto; - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - } - .history li { - display: block; - padding: 8px; - border-radius: 6px; - transition: background-color 0.15s var(--ease); - } - .history li + li { - border-top: 1px solid var(--border); - } - .history li:hover { - background: var(--surface-3); - } - .history-meta { - display: flex; - flex-wrap: wrap; - gap: 4px 8px; - align-items: center; - margin-bottom: 4px; - font-size: 11px; - color: var(--text-2); - font-variant-numeric: tabular-nums; - } - .history-meta time { - font-family: var(--font-mono); - color: var(--text); - } - .source-tag { - line-height: 16px; - padding: 0 8px; - border-radius: 99px; - background: var(--surface-3); - border: 1px solid var(--border-strong); - color: var(--text); - } - .source-write { - color: var(--hue-info-text); - background: color-mix(in srgb, var(--hue-info) 12%, transparent); - border-color: color-mix(in srgb, var(--hue-info) 30%, transparent); - } - .missed { - color: var(--warn); - } - .restore { - margin-left: auto; - height: 22px; - padding: 0 8px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--surface-2); - color: var(--text); - font: inherit; - font-size: 11px; - font-weight: 500; - cursor: pointer; - transition: - background-color 150ms var(--ease), - border-color 150ms var(--ease); - } - .restore:hover:not([aria-disabled='true']) { - background: var(--surface-3); - border-color: var(--accent-line); - } - .restore[aria-disabled='true'] { - opacity: 0.6; - cursor: progress; - } - .restore:focus-visible { - @include m.focus-ring(-2px); - } - .diff { - margin-top: 6px; - font-size: 12px; - } - .diff summary { - color: var(--text-2); - cursor: pointer; - } - .diff summary:focus-visible { - @include m.focus-ring; - } - .diff-list { - display: flex; - flex-direction: column; - gap: 2px; - margin: 6px 0 0; - padding: 0; - list-style: none; - } - .diff-list li { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 4px 8px; - min-width: 0; - font-family: var(--font-mono); - font-size: 11px; - overflow-wrap: anywhere; - } - .diff-kind { - padding: 0 6px; - border-radius: 5px; - font-size: 10px; - line-height: 16px; - } - .diff-added .diff-kind { - @include m.tint(ok); - } - .diff-removed .diff-kind { - @include m.tint(danger); - } - .diff-changed .diff-kind { - @include m.tint(accent); - } - .diff-path { - color: var(--text-strong); - } - .diff-old { - color: var(--text-2); - text-decoration: line-through; - } - .diff-new { - color: var(--text); - } - .source-matches { - min-width: 0; - } - .source-candidates { - margin: 4px 0 0; - padding: 0; - list-style: none; - } - .hint-inline { - color: var(--text-2); - font-size: 12px; - } - .restore-result { - margin: 0; - font-size: 12px; - color: var(--text-2); - } - .restore-result.has-text { - margin-bottom: 8px; - } - .restore-result.restore-error { - color: var(--danger-text); - } - @media (prefers-reduced-motion: reduce) { - .restore { - transition: none; - } - } - .graph-section, - .watched { - margin-bottom: 16px; - } - .section-head { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 8px 12px; - margin: 16px 0 8px; - } - .section-head .list-heading { - margin: 0; - } - .section-actions { - display: flex; - flex-wrap: wrap; - gap: 6px; - } - .toggle { - display: inline-flex; - align-items: center; - gap: 6px; - height: var(--control-h); - padding: 0 12px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--surface-2); - color: var(--text); - font: inherit; - font-size: 12.5px; - font-weight: 500; - cursor: pointer; - transition: - background-color 0.15s var(--ease), - border-color 0.15s var(--ease); - } - .toggle:hover { - background: var(--surface-3); - } - .toggle[aria-pressed='true'] { - border-color: var(--accent-line); - background: var(--accent-soft); - color: var(--text-strong); - } - .toggle:focus-visible { - @include m.focus-ring; - } - .toggle .count { - padding: 0 6px; - border-radius: 99px; - background: var(--surface-3); - color: var(--text-2); - font-size: 11px; - line-height: 16px; - font-variant-numeric: tabular-nums; - } - .graph-hint { - margin: 0 0 8px; - max-width: none; - } - .graph-selection { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px 8px; - margin: 8px 0 0; - font-size: 12px; - color: var(--text-2); - } - .link-button { - min-height: 28px; - padding: 0 8px; - border: 0; - border-radius: 6px; - background: none; - color: var(--accent-text); - font: inherit; - font-size: 12px; - font-weight: 500; - text-decoration: underline; - text-underline-offset: 2px; - cursor: pointer; - } - .link-button:hover { - background: var(--surface-2); - } - .link-button:focus-visible { - @include m.focus-ring; - } - .watched-list { - display: flex; - flex-direction: column; - gap: 4px; - margin: 0 0 8px; - padding: 0; - list-style: none; - } - .watched-list li { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px 8px; - min-width: 0; - padding: 6px 10px; - background: var(--surface); - border: 1px solid var(--accent-line); - border-radius: var(--radius-sm); - } - .watched-value { - @include m.truncate; - flex: 1 1 120px; - font-family: var(--font-mono); - font-size: 12px; - color: var(--text-2); - } - .sub-heading { - @include m.label; - margin: 12px 0 8px; - } - .watch-log { - max-height: 320px; - overflow: auto; - border-radius: var(--radius-sm); - } - .watch-log .history { - max-height: none; - overflow: visible; - } - .stack { - margin: 6px 0 0; - padding: 0 0 0 18px; - font-family: var(--font-mono); - font-size: 11.5px; - color: var(--text-2); - overflow-wrap: anywhere; - } - .history li .stack li { - padding: 0; - border: 0; - } - .watching-badge, - .framework-badge { - flex: none; - font-size: 11px; - font-weight: 500; - line-height: 16px; - padding: 0 8px; - border: 1px solid; - border-radius: 99px; - } - .watching-badge { - @include m.tint(accent); - } - .framework-badge { - color: var(--text-2); - border-color: var(--border-strong); - } - .watch-toggle { - margin: 16px 0 8px; - } - .watch-toggle[aria-pressed='true'] { - border-color: var(--accent-line); - background: var(--accent-soft); - color: var(--text-strong); - } - @media (max-width: 480px) { - .toolbar { - padding: 8px; - } - .toolbar input, - .picker { - flex-basis: 100%; - } - .node-card, - .detail-panel { - padding: 12px; - } - dl { - grid-template-columns: minmax(0, 1fr); - gap: 4px; - } - dd + dt { - margin-top: 8px; - } - } - `, + providers: [SignalState, SignalDetailState], + templateUrl: './signal-inspector.html', + styleUrl: './signal-inspector.scss', }) export class SignalInspector { - rpc = input(null); + readonly rpc = input(null); /** A component to show, from a deep link or the Components tab's "Show in Signals". */ readonly focus = input(null); - /** The signal to select once the focused component's graph arrives. */ - private readonly focusedSignal = signal<{ componentId: string; key: string } | null>(null); - /** The page a focus asked for before it reported a graph; chosen once it does. */ - private pendingPageId: string | null = null; - private readonly destroyRef = inject(DestroyRef); - private readonly host = inject>(ElementRef); + private readonly state = inject(SignalState); + private readonly detail = inject(SignalDetailState); private readonly filterInput = viewChild('filterInput'); - private readonly injector = inject(Injector); - private readonly pageId = hostPageId(); - private readonly angularPages = injectAngularPages(() => this.rpc()); - /** The shown page's Angular version and debug API parts, for the unavailable note. */ - readonly angularPage = computed(() => - angularPageFor(this.angularPages(), this.shownPageId() ?? this.pageId), - ); - private readonly cleanups: (() => void)[] = []; - /** Counts graph loads, so only the newest one applies its result. */ - private graphLoad = 0; - private readonly treePages = signal< - Record< - string, - { roots?: LiveNode[]; reportedAt?: number; title?: string; url?: string; platform?: string } - > - >({}); - private readonly graphState = signal<{ - graph: SignalGraph | null; - pages: Record; - }>({ graph: null, pages: {} }); - readonly chosenPageId = signal(null); - readonly announcement = signal(''); - private readonly graphPages = computed(() => graphPagesOf(this.graphState())); - - // The newest graph comes as `graph`; a page that reports later never takes over. - readonly shownPageId = linkedSignal< - { pages: Record; newest: string | null; chosen: string | null }, - string | null - >({ - source: () => ({ - pages: this.graphPages(), - newest: this.graphState().graph?.pageId ?? null, - chosen: this.chosenPageId(), - }), - computation: ({ pages, newest, chosen }, previous) => { - const reported: Record = {}; - for (const pageId of Object.keys(pages)) { - reported[pageId] = { pageId, reportedAt: pageId === newest ? 1 : 0 }; - } - return pickPage(reported, { chosen, host: this.pageId, previous: previous?.value ?? null }); - }, - }); - - private readonly reported = computed(() => { - const pageId = this.shownPageId(); - return pageId === null ? null : (this.graphPages()[pageId] ?? null); - }); - readonly unsupported = computed(() => !!this.reported()?.unsupported); + readonly angularPage = this.state.angularPage; + readonly chosenPageId = this.state.chosenPageId; + readonly announcement = this.state.announcement; + readonly shownPageId = this.state.shownPageId; + readonly unsupported = this.state.unsupported; /** Whether the live graph state arrived, so the intro doesn't call it a source scan first. */ - protected readonly graphKnown = signal(false); - readonly graph = computed(() => { - const reported = this.reported(); - return reported?.unsupported ? null : reported; - }); - private readonly graphOwner = computed(() => { - const g = this.graph(); - return g - ? `${g.pageId ?? ''}|${g.component?.id ?? g.injector?.id ?? g.element?.id ?? ''}` - : null; - }); - - // Component and injector ids belong to one page, so each page keeps its own pick. - private readonly pickedByPage = signal>({}); - readonly picked = computed(() => this.pickedByPage()[this.targetPageId() ?? ''] ?? null); - - readonly pageOptions = computed(() => { - if (this.pageId) return []; - const tree = this.treePages(); - return Object.keys(this.graphPages()).map((pageId) => { - const page = tree[pageId]; - return { - value: pageId, - label: page?.title || page?.url || `Page ${pageId}`, - hint: page?.url, - }; - }); - }); - - readonly showInternals = signal(false); - sourceSignals = signal([]); - sourceLoaded = signal(false); - /** The source scan or the live graph state could not be read from the server. */ - private readonly sourceFailed = signal(false); - private readonly graphFailed = signal(false); - readonly loadFailed = computed(() => this.sourceFailed() || this.graphFailed()); - readonly retrying = signal(false); - filter = signal(''); - readonly resources = computed(() => this.graph()?.resources ?? []); - private readonly internalIds = computed( - () => new Set(this.resources().flatMap((r) => r.nodeIds)), - ); - /** Shows the nodes Angular itself created, which are hidden by default. */ - readonly showFramework = signal(false); - readonly frameworkCount = computed(() => { - const internal = this.internalIds(); - return (this.graph()?.nodes ?? []).filter((n) => n.framework && !internal.has(n.id)).length; - }); - readonly visibleNodes = computed(() => { - const internal = this.internalIds(); - const framework = this.showFramework(); - return (this.graph()?.nodes ?? []).filter( - (n) => !internal.has(n.id) && (framework || !n.framework), - ); - }); - readonly showGraph = signal(true); - /** Links between the nodes the page shows, by node id. */ - readonly graphLinks = computed(() => { - const g = this.graph(); - if (!g) return []; - const shown = new Set(this.visibleNodes().map((n) => n.id)); - const links: GraphLink[] = []; - for (const edge of g.edges) { - const producer = g.nodes[edge.producer]?.id; - const consumer = g.nodes[edge.consumer]?.id; - if (producer && consumer && shown.has(producer) && shown.has(consumer)) { - links.push({ producer, consumer }); - } - } - return links; - }); - readonly graphNodes = computed(() => - [...this.visibleNodes()].sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true })), - ); - readonly kindColorOf = (kind: string) => this.kindColor(kind); - - readonly watchedIds = computed(() => new Set(this.graph()?.watch?.ids ?? [])); - readonly stacksOn = computed(() => !!this.graph()?.watch?.stacks); - readonly watchedNodes = computed(() => { - const ids = this.watchedIds(); - return (this.graph()?.nodes ?? []).filter((n) => ids.has(n.id)); - }); - /** Watched ids the page reports that are not in the graph shown now. */ - readonly watchedElsewhere = computed(() => this.watchedIds().size - this.watchedNodes().length); - /** The newest changes of every watched signal in this graph, newest first. */ - readonly watchLog = computed(() => { - const history = this.graph()?.history ?? {}; - const entries = this.watchedNodes().flatMap((node) => - (history[node.id] ?? []) - .filter((change) => change.source !== 'initial') - .map((change) => ({ id: node.id, label: node.label ?? node.id, change })), - ); - return entries.sort((a, b) => b.change.at - a.change.at).slice(0, WATCH_LOG); - }); - readonly hasAnySignals = computed(() => this.visibleNodes().length + this.resources().length > 0); - kindCounts = computed(() => { - const kinds: string[] = this.graph() - ? [...this.visibleNodes().map((n) => n.kind), ...this.resources().map(() => 'resource')] - : this.sourceSignals().map((s) => s.kind); - const counts = new Map(); - for (const k of kinds) counts.set(k, (counts.get(k) ?? 0) + 1); - return [...counts].map(([kind, count]) => ({ kind, count })); - }); - // A kind picked on another graph stays only when this graph has it too. - kind = linkedSignal({ - source: this.graphOwner, - computation: (_owner, previous) => { - const kind = previous?.value ?? null; - return kind && untracked(this.kindCounts).some((g) => g.kind === kind) ? kind : null; - }, - }); - // An active kind with no matches still gets a pressed chip, so the filter stays visible. - kindChips = computed(() => { - const counts = this.kindCounts(); - const kind = this.kind(); - return kind && !counts.some((g) => g.kind === kind) ? [...counts, { kind, count: 0 }] : counts; - }); - kindTotal = computed(() => this.kindCounts().reduce((sum, g) => sum + g.count, 0)); - selectedId = linkedSignal({ - source: this.graphOwner, - computation: () => null, - }); - selectedNode = computed( - () => this.graph()?.nodes.find((n) => n.id === this.selectedId()) ?? null, - ); - private readonly jumpedPast = signal<{ id: string; label: string } | null>(null); - readonly jumpNote = computed(() => { - const jumped = this.jumpedPast(); - if (!jumped || this.filter() || this.kind() || this.selectedId() !== jumped.id) return ''; - return `Cleared the filters to show ${jumped.label}.`; - }); - selectedHistory = computed(() => { - const id = this.selectedId(); - return id ? [...(this.graph()?.history?.[id] ?? [])].reverse() : []; - }); - - /** - * Why history entries cannot be restored on the shown page, or '' when they - * can: the same gate as component edits (a live browser page with - * `actions.components` on). - */ - readonly restoreBlocked = computed(() => { - const client = this.rpc(); - if (isStaticReport(client)) return 'A saved report cannot write to the app.'; - const pageId = this.graph()?.pageId; - if (isNativePage(pageId ? this.treePages()[pageId] : null)) { - return 'It needs the app open in a browser.'; - } - if (!actionAllowed(client, 'components')) return actionBlockedMessage('components'); - return ''; - }); - readonly restoring = signal(false); - readonly restoreResult = signal<{ id: string; text: string; error: boolean } | null>(null); - - /** The node with this id when its history can be restored: a writable field of the shown component. */ - restorable(id: string): SignalNode | null { - const g = this.graph(); - const node = g?.nodes.find((n) => n.id === id); - if (!node?.locator) return null; - if (node.locator.owner === 'service') { - return g?.injector && node.locator.id === g.injector.id ? node : null; - } - return g?.component && node.locator.id === g.component.id ? node : null; - } - - /** Writes the value the page kept for this entry back to the signal. */ - async restore(node: SignalNode, change: SignalChange, time: string) { - const client = this.rpc(); - const pageId = this.graph()?.pageId; - const locator = node.locator; - if (!client || !pageId || !locator || this.restoring() || this.restoreBlocked()) return; - this.restoring.set(true); - let result: RestoreResult; - try { - result = ((await client.scope('pangular').rpc.call('request-signal-restore', { - pageId, - request: { - component: locator.id, - ...(locator.owner === 'service' ? { owner: 'service' } : {}), - node: node.id, - epoch: change.epoch, - }, - })) ?? {}) as RestoreResult; - } catch { - result = { error: 'The devtools server did not answer. Try again.' }; - } - this.restoring.set(false); - this.restoreResult.set( - result.ok - ? { - id: node.id, - text: `Restored ${result.key ?? locator.key} to the value from ${time}.`, - error: false, - } - : { id: node.id, text: result.error ?? 'The page did not restore it.', error: true }, - ); - } - - /** - * What changed in each history entry of the selected node against the - * entry before it, by epoch. Only for entries whose values are both - * objects or arrays; a plain value already shows whole. - */ - readonly diffs = computed(() => { - const id = this.selectedId(); - const entries = id ? (this.graph()?.history?.[id] ?? []) : []; - const out = new Map(); - for (let i = 1; i < entries.length; i++) { - const before = entries[i - 1]!.value; - const after = entries[i]!.value; - if (!before || !after || typeof before !== 'object' || typeof after !== 'object') continue; - const diff = jsonDiff(before, after); - if (diff.length) out.set(entries[i]!.epoch, diff); - } - return out; - }); - - diffLabel(kind: JsonDiffEntry['kind']): string { - return kind === 'added' ? 'added' : kind === 'removed' ? 'removed' : 'changed'; - } - - /** - * The source declaration of the selected live node, from the source scan: - * declarations with the node's name and a matching kind, narrowed to the - * shown component's selector when some are in it. One left is `exact`; - * several are listed as `candidates`, since the name alone can't tell them - * apart. - */ - readonly sourceMatch = computed<{ - exact: SourceSignal | null; - candidates: SourceSignal[]; - } | null>(() => { - const node = this.selectedNode(); - const label = node?.label; - if (!node || !label) return null; - const kindMatches = (source: string) => - source === node.kind || - source.startsWith(`${node.kind} `) || - source.startsWith(`${node.kind}.`) || - (node.kind === 'signal' && source.startsWith('model')) || - (node.kind === 'computed' && source === 'toSignal'); - let found = this.sourceSignals().filter((s) => s.name === label && kindMatches(s.kind)); - const g = this.graph(); - const owner = g?.component?.tag ?? g?.element?.tag; - const own = owner ? found.filter((s) => s.component === owner) : []; - if (own.length) found = own; - if (!found.length) return null; - return found.length === 1 - ? { exact: found[0]!, candidates: [] } - : { exact: null, candidates: found.slice(0, 5) }; - }); - - /** Why a writable signal has no exact set entries, when the panel can tell. */ - readonly writeHint = computed(() => { - const node = this.selectedNode(); - const g = this.graph(); - if (!node || !g || g.writeHook === false) return ''; - if (node.kind !== 'signal' && node.kind !== 'linkedSignal') return ''; - if (!node.label) { - return "This signal has no name, so its history holds sampled values only. From Angular 20.1 the compiler names a signal that initializes a field or variable, so this one was likely made in a factory or helper. Pass a debugName in its options, for example signal(0, { debugName: 'count' })."; - } - const history = g.history?.[node.id] ?? []; - if (history.some((c) => c.source === 'write') || !history.some((c) => c.source === 'sample')) { - return ''; - } - return 'Every change here was sampled. Writes are matched by name, kind, version and value, so another live signal with the same name and value can hide them.'; - }); - - filteredResources = computed(() => { - const q = this.filter().toLowerCase(); - const kind = this.kind(); - if (kind && kind !== 'resource') return []; - return this.resources().filter( - (r) => - !q || - r.name.toLowerCase().includes(q) || - // A short query such as `e` would match every resource through its kind. - (q.length >= 3 && 'resource'.startsWith(q)), - ); - }); - - filteredNodes = computed(() => { - const q = this.filter().toLowerCase(); - const kind = this.kind(); - const nodes = this.visibleNodes().filter( - (n) => - (!kind || n.kind === kind) && - (!q || (n.label ?? '').toLowerCase().includes(q) || n.kind.toLowerCase().includes(q)), - ); - // Angular orders nodes by last read order, which changes between polls; ids are stable. - return nodes.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true })); - }); - - filteredSourceSignals = computed(() => { - const q = this.filter().toLowerCase(); - const kind = this.kind(); - return this.sourceSignals().filter( - (s) => - (!kind || s.kind === kind) && - (!q || - s.name.toLowerCase().includes(q) || - s.kind.toLowerCase().includes(q) || - s.file.toLowerCase().includes(q)), - ); - }); - - private readonly targetPageId = computed( - () => - this.pageId ?? - (this.shownPageId() || null) ?? - this.graph()?.pageId ?? - this.latestTreePage()?.pageId ?? - null, - ); - - private readonly latestTreePage = computed(() => { - let latest: { pageId: string; reportedAt: number } | null = null; - for (const [pageId, page] of Object.entries(this.treePages())) { - const at = page.reportedAt ?? 0; - if (!latest || at > latest.reportedAt) latest = { pageId, reportedAt: at }; - } - return latest; - }); - - readonly componentOptions = computed(() => { - const pageId = this.targetPageId(); - const roots = (pageId ? this.treePages()[pageId]?.roots : undefined) ?? []; - const flat: LiveNode[] = []; - const walk = (nodes: LiveNode[]) => { - for (const node of nodes) { - flat.push(node); - walk(node.children); - } - }; - walk(roots); - const environments = this.graph()?.environments ?? []; - if (!flat.length && !environments.length) return []; - const totals = new Map(); - for (const node of flat) totals.set(node.name, (totals.get(node.name) ?? 0) + 1); - const seen = new Map(); - const options: SelectOption[] = [ - { value: FOLLOW, label: 'Follow the routed component', hint: 'automatic' }, - ...environments.map((env) => ({ - value: ENV + env.id, - label: this.injectorLabel(env), - hint: 'injector', - })), - ]; - for (const node of flat) { - const n = (seen.get(node.name) ?? 0) + 1; - seen.set(node.name, n); - options.push({ - value: node.id, - label: (totals.get(node.name) ?? 0) > 1 ? `${node.name} #${n}` : node.name, - hint: node.kind === 'directive' ? `<${node.tag}> directive` : `<${node.tag}>`, - }); - } - return options; - }); - - readonly pickerValue = computed(() => { - const picked = this.picked(); - return picked && this.componentOptions().some((o) => o.value === picked) ? picked : FOLLOW; - }); + protected readonly graphKnown = this.state.graphKnown; + readonly graph = this.state.graph; + readonly picked = this.state.picked; + readonly pageOptions = this.state.pageOptions; + readonly showInternals = this.state.showInternals; + readonly sourceSignals = this.state.sourceSignals; + readonly sourceLoaded = this.state.sourceLoaded; + readonly loadFailed = this.state.loadFailed; + readonly retrying = this.state.retrying; + readonly filter = this.state.filter; + readonly resources = this.state.resources; + readonly showFramework = this.state.showFramework; + readonly frameworkCount = this.state.frameworkCount; + readonly visibleNodes = this.state.visibleNodes; + readonly watchedNodes = this.state.watchedNodes; + readonly watchedElsewhere = this.state.watchedElsewhere; + readonly kindCounts = this.state.kindCounts; + readonly kind = this.state.kind; + readonly kindChips = this.state.kindChips; + readonly kindTotal = this.state.kindTotal; + readonly selectedId = this.state.selectedId; + readonly selectedNode = this.state.selectedNode; + readonly jumpNote = this.state.jumpNote; + readonly writeHint = this.detail.writeHint; + readonly filteredResources = this.state.filteredResources; + readonly filteredNodes = this.state.filteredNodes; + readonly filteredSourceSignals = this.state.filteredSourceSignals; + readonly componentOptions = this.state.componentOptions; + readonly pickerValue = this.state.pickerValue; + readonly sourceNote = sourceNote; + readonly kindColor = kindColor; constructor() { + this.state.connect(this.rpc, () => this.filterInput()); effect(() => { const client = this.rpc(); if (!client) return; - void this.loadSignalGraph(client); - void this.loadSourceSignals(client); + void this.state.loadSignalGraph(client); + void this.state.loadSourceSignals(client); }); effect(() => { const focus = this.focus(); - if (focus) untracked(() => this.showComponent(focus)); + if (focus) untracked(() => this.state.showComponent(focus)); }); // Selects the focused signal once the graph of its component is in. effect(() => { - const wanted = this.focusedSignal(); + const wanted = this.state.focusedSignal(); const graph = this.graph(); if (!wanted || graph?.component?.id !== wanted.componentId) return; const node = graph.nodes.find( (n) => n.locator?.id === wanted.componentId && n.locator.key === wanted.key, ); untracked(() => { - this.focusedSignal.set(null); - if (node) this.jumpTo(node); + this.state.focusedSignal.set(null); + if (node) this.state.jumpTo(node); }); }); - this.destroyRef.onDestroy(() => { - for (const cleanup of this.cleanups.splice(0)) cleanup(); + inject(DestroyRef).onDestroy(() => { + for (const cleanup of this.state.cleanups.splice(0)) cleanup(); }); } - async loadSignalGraph(client: DevframeRpcClient) { - for (const cleanup of this.cleanups.splice(0)) cleanup(); - const load = ++this.graphLoad; - /** A newer load (Retry, or a new client) started, so this one's outcome is dropped. */ - const stale = () => this.destroyRef.destroyed || load !== this.graphLoad; - const my = client.scope('pangular'); - try { - const state = await my.rpc.sharedState('signal-graph'); - if (stale()) return; - this.graphFailed.set(false); - const apply = (value: unknown) => { - const next = value as { - graph?: SignalGraph | null; - pages?: Record; - } | null; - const pages = next?.pages && typeof next.pages === 'object' ? next.pages : {}; - const state = { graph: next?.graph ?? null, pages }; - this.notePageGone(graphPagesOf(state)); - this.graphState.set(state); - const pending = this.pendingPageId; - if (pending && this.graphPages()[pending]) { - this.pendingPageId = null; - this.chosenPageId.set(pending); - } - // A state the client hasn't loaded from the server yet says nothing. - if (value !== undefined) this.graphKnown.set(true); - }; - apply(state.value()); - this.cleanups.push(state.on('updated', apply)); - } catch { - if (stale()) return; - this.graphState.set({ graph: null, pages: {} }); - this.graphKnown.set(true); - this.graphFailed.set(true); - } - try { - const tree = await my.rpc.sharedState('component-tree'); - if (stale()) return; - const applyTree = (value: unknown) => { - const pages = (value as { pages?: Record } | null)?.pages; - this.treePages.set(pages && typeof pages === 'object' ? pages : {}); - }; - applyTree(tree.value()); - this.cleanups.push(tree.on('updated', applyTree)); - } catch { - if (!stale()) this.treePages.set({}); - } - } - - private notePageGone(pages: Record) { - const gone = Object.keys(this.graphPages()).filter((pageId) => !pages[pageId]); - if (gone.length) { - this.pickedByPage.update((all) => { - const next = { ...all }; - for (const pageId of gone) delete next[pageId]; - return next; - }); - } - const chosen = this.chosenPageId(); - if (!chosen || pages[chosen]) return; - const label = this.pageOptions().find((o) => o.value === chosen)?.label ?? 'The chosen page'; - this.chosenPageId.set(null); - this.announcement.set(`${label} closed, so this shows another page.`); - } - selectPage(pageId: string | null) { - if (!pageId || pageId === this.shownPageId()) return; - this.pendingPageId = null; - this.chosenPageId.set(pageId); - const label = this.pageOptions().find((o) => o.value === pageId)?.label ?? pageId; - this.announcement.set(`Showing the signal graph of ${label}.`); - } - - /** Shows the graph of the component a focus names, on its page when it names one. */ - private showComponent(focus: SignalFocus) { - this.pendingPageId = null; - if (focus.pageId && !this.pageId) { - // A page that has not reported its graph yet is chosen when it does. - if (this.graphPages()[focus.pageId]) this.chosenPageId.set(focus.pageId); - else this.pendingPageId = focus.pageId; - } - this.focusedSignal.set(focus.key ? { componentId: focus.componentId, key: focus.key } : null); - this.pickComponent(focus.componentId, this.pageId ?? focus.pageId); + this.state.selectPage(pageId); } pickComponent(value: string | null, forPage?: string) { - const picked = value && value !== FOLLOW ? value : null; - // Another component replaces a focus that still waits for its signal. - if (this.focusedSignal()?.componentId !== picked) this.focusedSignal.set(null); - const pageId = forPage ?? this.targetPageId() ?? undefined; - this.pickedByPage.update((all) => { - const next = { ...all }; - if (picked) next[pageId ?? ''] = picked; - else delete next[pageId ?? '']; - return next; - }); - const client = this.rpc(); - if (!client) return; - const target = picked?.startsWith(ENV) - ? { pageId, env: picked.slice(ENV.length) } - : { pageId, id: picked }; - void client - .scope('pangular') - .rpc.call('select-signal-target', target) - .catch(() => {}); + this.state.pickComponent(value, forPage); } injectorLabel(injector: GraphInjector) { - return injector.name === 'Root' ? 'Root services' : injector.name; - } - - sourceNote(source: NonNullable) { - return SOURCE_NOTES[source]; - } - - async loadSourceSignals(client: DevframeRpcClient) { - const my = client.scope('pangular'); - try { - const result = (await my.rpc.call('get-signals')) as SourceSignal[]; - this.sourceSignals.set(result); - this.sourceFailed.set(false); - } catch { - this.sourceFailed.set(true); - } finally { - this.sourceLoaded.set(true); - } + return this.state.injectorLabel(injector); } - /** Loads the graph and the source scan again. The alert stays until they answer, so focus - * stays on Retry while it is shown and moves to the filter box when the alert goes, which - * also happens when the graph comes back but the source scan still fails. */ - async retry() { - const client = this.rpc(); - if (!client || this.retrying()) return; - const trigger = document.activeElement; - this.retrying.set(true); - try { - await Promise.all([this.loadSignalGraph(client), this.loadSourceSignals(client)]); - } finally { - this.retrying.set(false); - } - if (this.destroyRef.destroyed) return; - afterNextRender( - () => { - const lost = !trigger?.isConnected || document.activeElement === document.body; - if (lost) this.filterInput()?.focus(); - }, - { injector: this.injector }, - ); + retry() { + return this.state.retry(); } - /** Clears the filters from a no-match state and keeps focus on the filter box. */ clearFilters() { - this.resetFilters(); - this.filterInput()?.focus(); - } - - private resetFilters() { - this.filter.set(''); - this.kind.set(null); + this.state.clearFilters(); } select(id: string) { - this.selectedId.set(this.selectedId() === id ? null : id); - this.showInternals.set(false); - } - - /** Selects a node from the drawn graph; its detail opens in the list below. */ - selectFromGraph(id: string) { - this.selectedId.set(id); - this.showInternals.set(false); - } - - plural(count: number, word: string): string { - return `${count} ${word}${count === 1 ? '' : 's'}`; - } - - isWatchable(node: SignalNode): boolean { - return WATCHABLE.has(node.kind); - } - - /** Asks the page to watch or stop watching a signal; the page's next report shows it. */ - toggleWatch(node: SignalNode) { - const watch = !this.watchedIds().has(node.id); - const label = node.label ?? 'the unnamed signal'; - this.sendWatch({ id: node.id, watch }); - this.announcement.set( - watch - ? `Watching ${label}. Its changes also log to the page console.` - : `Stopped watching ${label}.`, - ); - } - - /** Turns write stacks on or off for the page that reported the graph. */ - toggleStacks() { - const stacks = !this.stacksOn(); - this.sendWatch({ stacks }); - this.announcement.set(stacks ? 'Recording write stacks.' : 'Stopped recording write stacks.'); - } - - private sendWatch(request: { id?: string; watch?: boolean; stacks?: boolean }) { - const client = this.rpc(); - const pageId = this.graph()?.pageId ?? this.targetPageId() ?? undefined; - if (!client) return; - void client - .scope('pangular') - .rpc.call('set-signal-watch', { ...request, pageId }) - .catch(() => {}); - } - - jumpTo(node: SignalNode) { - const resource = this.resources().find((r) => r.nodeIds.includes(node.id)); - const id = resource?.id ?? node.id; - const hidden = resource - ? !this.filteredResources().some((r) => r.id === id) - : !this.filteredNodes().some((n) => n.id === id); - if (hidden) this.resetFilters(); - this.jumpedPast.set(hidden ? { id, label: resource?.name ?? node.label ?? node.id } : null); - this.selectedId.set(id); - this.showInternals.set(!!resource); - afterNextRender( - () => { - const card = [ - ...this.host.nativeElement.querySelectorAll('.node-card[data-card]'), - ].find((el) => el.dataset['card'] === id); - card?.scrollIntoView?.({ block: 'nearest' }); - card?.focus({ preventScroll: true }); - }, - { injector: this.injector }, - ); - } - - // The page counts past the kept history; older pages only send the list. - changeCount(id: string): number { - const g = this.graph(); - const counted = - g?.nodes.find((n) => n.id === id)?.changes ?? g?.resources?.find((r) => r.id === id)?.changes; - if (counted !== undefined) return counted; - const list = g?.history?.[id] ?? []; - return list.reduce((n, c) => n + (c.source === 'initial' ? 0 : 1 + (c.missed ?? 0)), 0); - } - - historySummary(id: string): string { - const count = this.changeCount(id); - const kept = this.graph()?.history?.[id]?.length ?? 0; - if (!count) return 'No changes recorded yet. The entry below is the value seen first.'; - const changes = `${count} ${count === 1 ? 'change' : 'changes'} recorded`; - const shown = kept >= MAX_HISTORY && count >= kept ? `, showing the last ${kept}` : ''; - return `${changes}${shown}, newest first.`; - } - - statusTone(status: string) { - return RESOURCE_STATUS_TONES[status] ?? 'neutral'; - } - - internalsOf(resource: SignalResource): SignalNode[] { - const ids = new Set(resource.nodeIds); - return (this.graph()?.nodes ?? []).filter((n) => ids.has(n.id)); - } - - sourceLabel(source: SignalChange['source']) { - return SOURCE_LABELS[source]; + this.state.select(id); } - kindColor(kind: string) { - return `var(--kind-${KIND_TOKENS[kind] ?? 'unknown'})`; - } - - /** Producers and consumers of every node, built once per graph. */ - private readonly relations = computed(() => { - const map = new Map(); - const g = this.graph(); - if (!g) return map; - for (const node of g.nodes) map.set(node.id, { deps: [], consumers: [] }); - for (const edge of g.edges) { - const producer = g.nodes[edge.producer]; - const consumer = g.nodes[edge.consumer]; - if (!producer || !consumer) continue; - map.get(consumer.id)?.deps.push(producer); - map.get(producer.id)?.consumers.push(consumer); - } - return map; - }); - getDependencies(node: SignalNode): SignalNode[] { - return this.relations().get(node.id)?.deps ?? []; + return this.state.getDependencies(node); } getConsumers(node: SignalNode): SignalNode[] { - return this.relations().get(node.id)?.consumers ?? []; + return this.state.getConsumers(node); } } diff --git a/app/src/pages/signals/resource-list.html b/app/src/pages/signals/resource-list.html new file mode 100644 index 00000000..535b2697 --- /dev/null +++ b/app/src/pages/signals/resource-list.html @@ -0,0 +1,40 @@ +@for (res of filteredResources(); track res.id) { +
      • + + @if (selectedId() === res.id) { +
        + } +
      • +} diff --git a/app/src/pages/signals/resource-list.scss b/app/src/pages/signals/resource-list.scss new file mode 100644 index 00000000..9ab1ae2d --- /dev/null +++ b/app/src/pages/signals/resource-list.scss @@ -0,0 +1,37 @@ +@use 'mixins' as m; +@use 'signals' as sig; + +@include sig.cards; +@include sig.node-list; +@include sig.node-cards; +@include sig.changed-badge; +:host(.resources) { + margin-bottom: 8px; +} +.status-tag { + flex: none; + font-size: 11px; + font-weight: 500; + line-height: 16px; + padding: 0 8px; + border: 1px solid; + border-radius: 99px; +} +.tone-ok { + @include m.tint(ok); +} +.tone-warn { + @include m.tint(warn); +} +.tone-danger { + @include m.tint(danger); +} +.tone-neutral { + color: var(--text-2); + border-color: var(--border-strong); +} +@media (max-width: 480px) { + .node-card { + padding: 12px; + } +} diff --git a/app/src/pages/signals/resource-list.ts b/app/src/pages/signals/resource-list.ts new file mode 100644 index 00000000..ba53c0c3 --- /dev/null +++ b/app/src/pages/signals/resource-list.ts @@ -0,0 +1,25 @@ +import { Component, inject } from '@angular/core'; +import { JsonPipe } from '@angular/common'; +import { SignalDetail } from './signal-detail'; +import { kindColor, statusTone } from './signal-model'; +import { SignalState } from './signal-state'; + +/** The resources of the shown graph, each a card that opens its detail. */ +@Component({ + selector: 'ul[appResourceList]', + imports: [JsonPipe, SignalDetail], + templateUrl: './resource-list.html', + styleUrl: './resource-list.scss', +}) +export class ResourceList { + private readonly state = inject(SignalState); + + readonly filteredResources = this.state.filteredResources; + readonly selectedId = this.state.selectedId; + readonly kindColor = kindColor; + readonly statusTone = statusTone; + + select(id: string) { + this.state.select(id); + } +} diff --git a/app/src/pages/signals/signal-detail-state.ts b/app/src/pages/signals/signal-detail-state.ts new file mode 100644 index 00000000..dd2782a7 --- /dev/null +++ b/app/src/pages/signals/signal-detail-state.ts @@ -0,0 +1,169 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import type { SignalChange } from '@pangular-inspector/devtools/internal/types'; +import { actionAllowed, actionBlockedMessage } from '../../devtools-config'; +import { isNativePage } from '../../native-page'; +import { isStaticReport } from '../../rpc'; +import { jsonDiff, type JsonDiffEntry } from '../json-diff'; +import { + MAX_HISTORY, + type RestoreResult, + type SignalNode, + type SourceSignal, +} from './signal-model'; +import { SignalState } from './signal-state'; + +/** + * The detail of the selected signal or resource: its value history with diffs and restore, the + * source declaration it matches and why it may have no exact writes. Provided by the page next to + * {@link SignalState}. + */ +@Injectable() +export class SignalDetailState { + private readonly state = inject(SignalState); + + readonly selectedHistory = computed(() => { + const id = this.state.selectedId(); + return id ? [...(this.state.graph()?.history?.[id] ?? [])].reverse() : []; + }); + + /** + * Why history entries cannot be restored on the shown page, or '' when they + * can: the same gate as component edits (a live browser page with + * `actions.components` on). + */ + readonly restoreBlocked = computed(() => { + const client = this.state.rpc(); + if (isStaticReport(client)) return 'A saved report cannot write to the app.'; + const pageId = this.state.graph()?.pageId; + if (isNativePage(pageId ? this.state.treePages()[pageId] : null)) { + return 'It needs the app open in a browser.'; + } + if (!actionAllowed(client, 'components')) return actionBlockedMessage('components'); + return ''; + }); + readonly restoring = signal(false); + readonly restoreResult = signal<{ id: string; text: string; error: boolean } | null>(null); + + /** The node with this id when its history can be restored: a writable field of the shown component. */ + restorable(id: string): SignalNode | null { + const g = this.state.graph(); + const node = g?.nodes.find((n) => n.id === id); + if (!node?.locator) return null; + if (node.locator.owner === 'service') { + return g?.injector && node.locator.id === g.injector.id ? node : null; + } + return g?.component && node.locator.id === g.component.id ? node : null; + } + + /** Writes the value the page kept for this entry back to the signal. */ + async restore(node: SignalNode, change: SignalChange, time: string) { + const client = this.state.rpc(); + const pageId = this.state.graph()?.pageId; + const locator = node.locator; + if (!client || !pageId || !locator || this.restoring() || this.restoreBlocked()) return; + this.restoring.set(true); + let result: RestoreResult; + try { + result = ((await client.scope('pangular').rpc.call('request-signal-restore', { + pageId, + request: { + component: locator.id, + ...(locator.owner === 'service' ? { owner: 'service' } : {}), + node: node.id, + epoch: change.epoch, + }, + })) ?? {}) as RestoreResult; + } catch { + result = { error: 'The devtools server did not answer. Try again.' }; + } + this.restoring.set(false); + this.restoreResult.set( + result.ok + ? { + id: node.id, + text: `Restored ${result.key ?? locator.key} to the value from ${time}.`, + error: false, + } + : { id: node.id, text: result.error ?? 'The page did not restore it.', error: true }, + ); + } + + /** + * What changed in each history entry of the selected node against the + * entry before it, by epoch. Only for entries whose values are both + * objects or arrays; a plain value already shows whole. + */ + readonly diffs = computed(() => { + const id = this.state.selectedId(); + const entries = id ? (this.state.graph()?.history?.[id] ?? []) : []; + const out = new Map(); + for (let i = 1; i < entries.length; i++) { + const before = entries[i - 1]!.value; + const after = entries[i]!.value; + if (!before || !after || typeof before !== 'object' || typeof after !== 'object') continue; + const diff = jsonDiff(before, after); + if (diff.length) out.set(entries[i]!.epoch, diff); + } + return out; + }); + + diffLabel(kind: JsonDiffEntry['kind']): string { + return kind === 'added' ? 'added' : kind === 'removed' ? 'removed' : 'changed'; + } + + /** + * The source declaration of the selected live node, from the source scan: + * declarations with the node's name and a matching kind, narrowed to the + * shown component's selector when some are in it. One left is `exact`; + * several are listed as `candidates`, since the name alone can't tell them + * apart. + */ + readonly sourceMatch = computed<{ + exact: SourceSignal | null; + candidates: SourceSignal[]; + } | null>(() => { + const node = this.state.selectedNode(); + const label = node?.label; + if (!node || !label) return null; + const kindMatches = (source: string) => + source === node.kind || + source.startsWith(`${node.kind} `) || + source.startsWith(`${node.kind}.`) || + (node.kind === 'signal' && source.startsWith('model')) || + (node.kind === 'computed' && source === 'toSignal'); + let found = this.state.sourceSignals().filter((s) => s.name === label && kindMatches(s.kind)); + const g = this.state.graph(); + const owner = g?.component?.tag ?? g?.element?.tag; + const own = owner ? found.filter((s) => s.component === owner) : []; + if (own.length) found = own; + if (!found.length) return null; + return found.length === 1 + ? { exact: found[0]!, candidates: [] } + : { exact: null, candidates: found.slice(0, 5) }; + }); + + /** Why a writable signal has no exact set entries, when the panel can tell. */ + readonly writeHint = computed(() => { + const node = this.state.selectedNode(); + const g = this.state.graph(); + if (!node || !g || g.writeHook === false) return ''; + if (node.kind !== 'signal' && node.kind !== 'linkedSignal') return ''; + if (!node.label) { + return "This signal has no name, so its history holds sampled values only. From Angular 20.1 the compiler names a signal that initializes a field or variable, so this one was likely made in a factory or helper. Pass a debugName in its options, for example signal(0, { debugName: 'count' })."; + } + const history = g.history?.[node.id] ?? []; + if (history.some((c) => c.source === 'write') || !history.some((c) => c.source === 'sample')) { + return ''; + } + return 'Every change here was sampled. Writes are matched by name, kind, version and value, so another live signal with the same name and value can hide them.'; + }); + + historySummary(id: string): string { + const count = this.state.changeCount(id); + const kept = this.state.graph()?.history?.[id]?.length ?? 0; + if (!count) return 'No changes recorded yet. The entry below is the value seen first.'; + const changes = `${count} ${count === 1 ? 'change' : 'changes'} recorded`; + const shown = kept >= MAX_HISTORY && count >= kept ? `, showing the last ${kept}` : ''; + return `${changes}${shown}, newest first.`; + } +} diff --git a/app/src/pages/signals/signal-detail.html b/app/src/pages/signals/signal-detail.html new file mode 100644 index 00000000..0b24931f --- /dev/null +++ b/app/src/pages/signals/signal-detail.html @@ -0,0 +1,267 @@ + + @if (selectedHistory().length) { +

        {{ label }}

        +

        {{ historySummary(id) }}

        + @let restoreNode = restorable(id); + @if (restoreNode) { + @if (restoreBlocked(); as reason) { +

        Restore is off. {{ reason }}

        + } +

        + {{ restoreResult()?.id === id ? restoreResult()?.text : '' }} +

        + } +
          + @for (change of selectedHistory(); track change.epoch) { +
        1. + + + {{ + sourceLabel(change.source) + }} + epoch {{ change.epoch }} + @if (change.missed) { + {{ change.missed }} earlier not captured + } + @if (restoreNode && !restoreBlocked() && !$first) { + @let time = change.at | date: 'HH:mm:ss.SSS'; + + } + +
          {{ change.value | json }}
          + @if (diffs().get(change.epoch); as diff) { +
          + Changes from the entry before ({{ diff.length }}) +
            + @for (d of diff; track d.path) { +
          • + {{ diffLabel(d.kind) }}: {{ d.path }}, + @if (d.kind !== 'added') { + {{ d.before | json }} + } + @if (d.kind === 'changed') { + to + } + @if (d.kind !== 'removed') { + {{ d.after | json }} + } +
          • + } +
          +
          + } + @if (change.stack?.length) { +
            + @for (frame of change.stack; track $index) { +
          1. {{ frame }}
          2. + } +
          + } +
        2. + } +
        + } +
        + +@if (resource(); as res) { +

        {{ res.name }}

        + @if (!res.named) { +

        + This resource has no name. Pass debugName to resource() or + httpResource() to name it here. +

        + } +
        +
        Status
        +
        {{ res.status ?? 'unknown' }}
        + @if (res.isLoading !== undefined) { +
        Loading
        +
        {{ res.isLoading ? 'yes' : 'no' }}
        + } + @if (res.statusCode !== undefined) { +
        HTTP status
        +
        {{ res.statusCode }}
        + } + @if (res.params !== undefined) { +
        Params
        +
        +
        {{ res.params | json }}
        +
        + } + @if (res.value !== undefined) { +
        Value
        +
        +
        {{ res.value | json }}
        +
        + } + @if (res.error !== undefined) { +
        Error
        +
        +
        {{ res.error | json }}
        +
        + } +
        + + + @if (showInternals()) { +
          + @for (inner of internalsOf(res); track inner.id) { +
        • + {{ + inner.kind + }} + {{ inner.label ?? inner.id }} +
        • + } +
        + } +} @else if (selectedNode(); as node) { +

        {{ selectedNode()!.label ?? selectedNode()!.id }}

        +
        +
        Kind
        +
        {{ selectedNode()!.kind }}
        +
        Epoch
        +
        {{ selectedNode()!.epoch }}
        + @if (selectedNode()!.runs; as runs) { +
        Runs
        +
        + {{ runs }}, {{ selectedNode()!.runMs ?? 0 }} ms in all, last + {{ selectedNode()!.lastRunMs ?? 0 }} ms +
        + } + @if (sourceMatch(); as match) { +
        Source
        +
        + @if (match.exact) { + + } @else { + {{ match.candidates.length }} declarations match this name: +
          + @for (c of match.candidates; track c.file + c.line) { +
        • + + @if (c.component) { + in {{ c.component }} + } +
        • + } +
        + } +
        + } + @if (selectedNode()!.value !== undefined) { +
        Value
        +
        +
        {{ selectedNode()!.value | json }}
        +
        + } +
        + @if (getDependencies(selectedNode()!).length) { +

        Dependencies (producers)

        +
          + @for (dep of getDependencies(selectedNode()!); track dep.id) { +
        • + +
        • + } +
        + } + @if (getConsumers(selectedNode()!).length) { +

        Consumers

        +
          + @for (con of getConsumers(selectedNode()!); track con.id) { +
        • + +
        • + } +
        + } + @if (isWatchable(selectedNode()!)) { + +

        + A watched signal is listed under Watched and logs each change to the page console. +

        + } + @if (writeHint(); as hint) { +

        {{ hint }}

        + } + +} diff --git a/app/src/pages/signals/signal-detail.scss b/app/src/pages/signals/signal-detail.scss new file mode 100644 index 00000000..969471b9 --- /dev/null +++ b/app/src/pages/signals/signal-detail.scss @@ -0,0 +1,229 @@ +@use 'mixins' as m; +@use 'signals' as sig; + +@include sig.states; +@include sig.cards; +@include sig.fields; +@include sig.pre; +@include sig.badge-sm; +:host(.detail-panel) { + margin-top: 8px; + padding: 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + @include m.enter(0.25s); +} +.detail-title { + margin: 0 0 16px; + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; + color: var(--accent); + overflow-wrap: anywhere; +} +:host(.detail-panel) h4 { + @include m.label; + margin: 20px 0 8px; +} +:host(.detail-panel) dd pre { + max-height: 240px; + overflow: auto; + padding: 8px 12px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} +@include sig.scroll-box; +:host(.detail-panel) .hint { + margin: 0 0 12px; +} +.internals-toggle { + margin-top: 16px; +} +:host(.detail-panel) .internals-toggle + ul { + margin-top: 8px; +} +:host(.detail-panel) ul { + display: flex; + flex-direction: column; + gap: 2px; + list-style: none; + padding: 0; + margin: 0; + font-size: 13px; +} +:host(.detail-panel) ul li, +.rel-link { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + min-height: 28px; + padding: 4px 8px; + color: var(--text-2); + border-radius: 6px; +} +:host(.detail-panel) ul li:has(> .rel-link) { + padding: 0; +} +.rel-link { + width: 100%; + font: inherit; + text-align: left; + background: none; + border: 0; + cursor: pointer; + transition: + background-color 0.15s var(--ease), + color 0.15s var(--ease); +} +.rel-link:hover { + background: var(--surface-2); + color: var(--text); +} +.rel-link:focus-visible { + @include m.focus-ring; +} +@include sig.rel-label; +.history-summary { + margin: 0 0 8px; + font-size: 12px; + color: var(--text-2); + font-variant-numeric: tabular-nums; +} +@include sig.history; +.missed { + color: var(--warn); +} +.restore { + margin-left: auto; + height: 22px; + padding: 0 8px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease); +} +.restore:hover:not([aria-disabled='true']) { + background: var(--surface-3); + border-color: var(--accent-line); +} +.restore[aria-disabled='true'] { + opacity: 0.6; + cursor: progress; +} +.restore:focus-visible { + @include m.focus-ring(-2px); +} +.diff { + margin-top: 6px; + font-size: 12px; +} +.diff summary { + color: var(--text-2); + cursor: pointer; +} +.diff summary:focus-visible { + @include m.focus-ring; +} +.diff-list { + display: flex; + flex-direction: column; + gap: 2px; + margin: 6px 0 0; + padding: 0; + list-style: none; +} +.diff-list li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; + min-width: 0; + font-family: var(--font-mono); + font-size: 11px; + overflow-wrap: anywhere; +} +.diff-kind { + padding: 0 6px; + border-radius: 5px; + font-size: 10px; + line-height: 16px; +} +.diff-added .diff-kind { + @include m.tint(ok); +} +.diff-removed .diff-kind { + @include m.tint(danger); +} +.diff-changed .diff-kind { + @include m.tint(accent); +} +.diff-path { + color: var(--text-strong); +} +.diff-old { + color: var(--text-2); + text-decoration: line-through; +} +.diff-new { + color: var(--text); +} +.source-matches { + min-width: 0; +} +.source-candidates { + margin: 4px 0 0; + padding: 0; + list-style: none; +} +.hint-inline { + color: var(--text-2); + font-size: 12px; +} +.restore-result { + margin: 0; + font-size: 12px; + color: var(--text-2); +} +.restore-result.has-text { + margin-bottom: 8px; +} +.restore-result.restore-error { + color: var(--danger-text); +} +@media (prefers-reduced-motion: reduce) { + .restore { + transition: none; + } +} +@include sig.stack; +.watch-toggle { + margin: 16px 0 8px; +} +.watch-toggle[aria-pressed='true'] { + border-color: var(--accent-line); + background: var(--accent-soft); + color: var(--text-strong); +} +@media (max-width: 480px) { + :host(.detail-panel) { + padding: 12px; + } + dl { + grid-template-columns: minmax(0, 1fr); + gap: 4px; + } + dd + dt { + margin-top: 8px; + } +} diff --git a/app/src/pages/signals/signal-detail.ts b/app/src/pages/signals/signal-detail.ts new file mode 100644 index 00000000..3458ce68 --- /dev/null +++ b/app/src/pages/signals/signal-detail.ts @@ -0,0 +1,80 @@ +import { Component, inject, input } from '@angular/core'; +import { DatePipe, JsonPipe, NgTemplateOutlet } from '@angular/common'; +import type { SignalChange } from '@pangular-inspector/devtools/internal/types'; +import { SourceLink } from '../../ui/source-link'; +import type { SignalResource } from '../../types/signal-graph.types'; +import type { JsonDiffEntry } from '../json-diff'; +import { SignalDetailState } from './signal-detail-state'; +import { kindColor, sourceLabel, type SignalNode } from './signal-model'; +import { SignalState } from './signal-state'; + +/** + * The detail panel under a selected card: a resource's fields and internal signals, or a + * signal's fields, producers, consumers and watch toggle, each with its value history. + */ +@Component({ + selector: 'div[appSignalDetail]', + imports: [DatePipe, JsonPipe, NgTemplateOutlet, SourceLink], + templateUrl: './signal-detail.html', + styleUrl: './signal-detail.scss', +}) +export class SignalDetail { + /** The resource to show; without one the panel shows the selected signal. */ + readonly resource = input(null); + + private readonly state = inject(SignalState); + private readonly detail = inject(SignalDetailState); + + readonly selectedNode = this.state.selectedNode; + readonly showInternals = this.state.showInternals; + readonly watchedIds = this.state.watchedIds; + readonly selectedHistory = this.detail.selectedHistory; + readonly restoreBlocked = this.detail.restoreBlocked; + readonly restoring = this.detail.restoring; + readonly restoreResult = this.detail.restoreResult; + readonly diffs = this.detail.diffs; + readonly sourceMatch = this.detail.sourceMatch; + readonly writeHint = this.detail.writeHint; + readonly kindColor = kindColor; + readonly sourceLabel = sourceLabel; + + getDependencies(node: SignalNode): SignalNode[] { + return this.state.getDependencies(node); + } + + getConsumers(node: SignalNode): SignalNode[] { + return this.state.getConsumers(node); + } + + jumpTo(node: SignalNode) { + this.state.jumpTo(node); + } + + isWatchable(node: SignalNode): boolean { + return this.state.isWatchable(node); + } + + toggleWatch(node: SignalNode) { + this.state.toggleWatch(node); + } + + internalsOf(resource: SignalResource): SignalNode[] { + return this.state.internalsOf(resource); + } + + historySummary(id: string): string { + return this.detail.historySummary(id); + } + + restorable(id: string): SignalNode | null { + return this.detail.restorable(id); + } + + restore(node: SignalNode, change: SignalChange, time: string) { + return this.detail.restore(node, change, time); + } + + diffLabel(kind: JsonDiffEntry['kind']): string { + return this.detail.diffLabel(kind); + } +} diff --git a/app/src/pages/signals/signal-graph-section.html b/app/src/pages/signals/signal-graph-section.html new file mode 100644 index 00000000..2a665dc9 --- /dev/null +++ b/app/src/pages/signals/signal-graph-section.html @@ -0,0 +1,64 @@ +
        +

        Graph

        +
        + @if (frameworkCount(); as hidden) { + + } + + +
        +
        +@if (stacksOn()) { +

        + Write stacks are on for this page. Each set of a named signal keeps the first 3 app frames of + the code that wrote it, shown in its value history. Taking a stack slows every write, so turn it + off when you are done. +

        +} +@if (showGraph()) { +
        + @if (graphNodes().length) { + + } @else { +

        + Every signal in this graph was created by Angular. Show framework signals to see them. +

        + } + @if (selectedNode(); as node) { +

        + Selected + {{ node.label ?? '(unnamed)' }} + + {{ plural(getDependencies(node).length, 'producer') }} + + {{ plural(getConsumers(node).length, 'consumer') }} + +

        + } +
        +} diff --git a/app/src/pages/signals/signal-graph-section.scss b/app/src/pages/signals/signal-graph-section.scss new file mode 100644 index 00000000..7bb15653 --- /dev/null +++ b/app/src/pages/signals/signal-graph-section.scss @@ -0,0 +1,78 @@ +@use 'mixins' as m; +@use 'signals' as sig; + +@include sig.states; +@include sig.sep; +@include sig.list-heading; +@include sig.rel-label; +:host(.graph-section) { + margin-bottom: 16px; +} +.section-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 12px; + margin: 16px 0 8px; +} +.section-head .list-heading { + margin: 0; +} +.section-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.toggle { + display: inline-flex; + align-items: center; + gap: 6px; + height: var(--control-h); + padding: 0 12px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-size: 12.5px; + font-weight: 500; + cursor: pointer; + transition: + background-color 0.15s var(--ease), + border-color 0.15s var(--ease); +} +.toggle:hover { + background: var(--surface-3); +} +.toggle[aria-pressed='true'] { + border-color: var(--accent-line); + background: var(--accent-soft); + color: var(--text-strong); +} +.toggle:focus-visible { + @include m.focus-ring; +} +.toggle .count { + padding: 0 6px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 11px; + line-height: 16px; + font-variant-numeric: tabular-nums; +} +.graph-hint { + margin: 0 0 8px; + max-width: none; +} +.graph-selection { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + margin: 8px 0 0; + font-size: 12px; + color: var(--text-2); +} +@include sig.link-button; diff --git a/app/src/pages/signals/signal-graph-section.ts b/app/src/pages/signals/signal-graph-section.ts new file mode 100644 index 00000000..70395213 --- /dev/null +++ b/app/src/pages/signals/signal-graph-section.ts @@ -0,0 +1,50 @@ +import { Component, inject } from '@angular/core'; +import { SignalGraphView } from '../signal-graph-view'; +import { kindColor, plural, type SignalNode } from './signal-model'; +import { SignalState } from './signal-state'; + +/** + * The drawn signal graph of the Signals page, with the framework, write stack and show or hide + * toggles, and a line about the node selected in it. + */ +@Component({ + selector: 'section[appSignalGraphSection]', + imports: [SignalGraphView], + templateUrl: './signal-graph-section.html', + styleUrl: './signal-graph-section.scss', +}) +export class SignalGraphSection { + private readonly state = inject(SignalState); + + readonly frameworkCount = this.state.frameworkCount; + readonly showFramework = this.state.showFramework; + readonly stacksOn = this.state.stacksOn; + readonly showGraph = this.state.showGraph; + readonly graphNodes = this.state.graphNodes; + readonly graphLinks = this.state.graphLinks; + readonly selectedId = this.state.selectedId; + readonly selectedNode = this.state.selectedNode; + readonly watchedIds = this.state.watchedIds; + readonly kindColorOf = (kind: string) => kindColor(kind); + readonly plural = plural; + + toggleStacks() { + this.state.toggleStacks(); + } + + selectFromGraph(id: string) { + this.state.selectFromGraph(id); + } + + getDependencies(node: SignalNode): SignalNode[] { + return this.state.getDependencies(node); + } + + getConsumers(node: SignalNode): SignalNode[] { + return this.state.getConsumers(node); + } + + jumpTo(node: SignalNode) { + this.state.jumpTo(node); + } +} diff --git a/app/src/pages/signals/signal-list.html b/app/src/pages/signals/signal-list.html new file mode 100644 index 00000000..95c37496 --- /dev/null +++ b/app/src/pages/signals/signal-list.html @@ -0,0 +1,61 @@ +@for (node of filteredNodes(); track node.id) { +
      • + + @if (selectedId() === node.id && selectedNode()) { +
        + } +
      • +} @empty { +
      • +

        + {{ hasAnySignals() ? 'No signals match.' : 'No signals in this graph.' }} +

        +

        + {{ + hasAnySignals() + ? 'Try a different name or kind.' + : 'Select a component that reads signals, or interact with the page to create some.' + }} +

        + @if (hasAnySignals() || filter() || kind()) { + + } +
      • +} diff --git a/app/src/pages/signals/signal-list.scss b/app/src/pages/signals/signal-list.scss new file mode 100644 index 00000000..844719ca --- /dev/null +++ b/app/src/pages/signals/signal-list.scss @@ -0,0 +1,30 @@ +@use 'mixins' as m; +@use 'signals' as sig; + +@include sig.states; +@include sig.cards; +@include sig.node-list; +@include sig.node-cards; +@include sig.changed-badge; +.watching-badge, +.framework-badge { + flex: none; + font-size: 11px; + font-weight: 500; + line-height: 16px; + padding: 0 8px; + border: 1px solid; + border-radius: 99px; +} +.watching-badge { + @include m.tint(accent); +} +.framework-badge { + color: var(--text-2); + border-color: var(--border-strong); +} +@media (max-width: 480px) { + .node-card { + padding: 12px; + } +} diff --git a/app/src/pages/signals/signal-list.ts b/app/src/pages/signals/signal-list.ts new file mode 100644 index 00000000..b49f237b --- /dev/null +++ b/app/src/pages/signals/signal-list.ts @@ -0,0 +1,45 @@ +import { Component, inject } from '@angular/core'; +import { JsonPipe } from '@angular/common'; +import { SignalDetail } from './signal-detail'; +import { kindColor, type SignalNode } from './signal-model'; +import { SignalState } from './signal-state'; + +/** The signals of the shown graph, each a card that opens its detail, or the no-match state. */ +@Component({ + selector: 'ul[appSignalList]', + imports: [JsonPipe, SignalDetail], + templateUrl: './signal-list.html', + styleUrl: './signal-list.scss', +}) +export class SignalList { + private readonly state = inject(SignalState); + + readonly filteredNodes = this.state.filteredNodes; + readonly selectedId = this.state.selectedId; + readonly selectedNode = this.state.selectedNode; + readonly watchedIds = this.state.watchedIds; + readonly hasAnySignals = this.state.hasAnySignals; + readonly filter = this.state.filter; + readonly kind = this.state.kind; + readonly kindColor = kindColor; + + select(id: string) { + this.state.select(id); + } + + changeCount(id: string): number { + return this.state.changeCount(id); + } + + getDependencies(node: SignalNode): SignalNode[] { + return this.state.getDependencies(node); + } + + getConsumers(node: SignalNode): SignalNode[] { + return this.state.getConsumers(node); + } + + clearFilters() { + this.state.clearFilters(); + } +} diff --git a/app/src/pages/signals/signal-model.ts b/app/src/pages/signals/signal-model.ts new file mode 100644 index 00000000..ec1203c7 --- /dev/null +++ b/app/src/pages/signals/signal-model.ts @@ -0,0 +1,107 @@ +import type { SignalChange, SignalGraphNode } from '@pangular-inspector/devtools/internal/types'; +import type { SignalGraph } from '../../types/signal-graph.types'; + +export type SignalNode = SignalGraphNode; + +export interface RestoreResult { + ok?: boolean; + key?: string; + error?: string; +} + +export interface LiveNode { + id: string; + name: string; + tag: string; + /** `directive` for an element that hosts directives but no component. */ + kind?: 'directive'; + children: LiveNode[]; +} + +export interface SourceSignal { + name: string; + kind: string; + file: string; + line: number; + component?: string; +} + +export const FOLLOW = 'follow'; + +// Older servers send only the newest graph, without the per-page map. +export function graphPagesOf(state: { + graph?: SignalGraph | null; + pages: Record; +}): Record { + if (Object.keys(state.pages).length) return state.pages; + return state.graph ? { [state.graph.pageId ?? '']: state.graph } : {}; +} +export const ENV = 'env:'; +export const MAX_HISTORY = 50; +/** Kinds with a value, so the page can watch them. */ +export const WATCHABLE = new Set(['signal', 'computed', 'linkedSignal']); +/** Changes of watched signals the panel lists. */ +export const WATCH_LOG = 20; + +const SOURCE_NOTES: Record, string> = { + selected: 'picked', + routed: 'rendered by the router', + root: 'first component with signals', +}; + +const SOURCE_LABELS: Record = { + write: 'set', + sample: 'sampled', + initial: 'initial', +}; + +const RESOURCE_STATUS_TONES: Record = { + resolved: 'ok', + local: 'ok', + loading: 'warn', + reloading: 'warn', + error: 'danger', +}; + +/** Each kind's `--kind-*` token from `_palette.scss` (`$signal-kinds`). */ +const KIND_TOKENS: Record = { + signal: 'signal', + computed: 'computed', + linkedSignal: 'linked', + effect: 'effect', + template: 'template', + afterRenderEffectPhase: 'after-render', + childSignalProp: 'child-prop', + 'input (signal)': 'input', + 'input.required (signal)': 'input', + 'output (signal)': 'output', + 'model (signal)': 'model', + 'model.required (signal)': 'model', + 'viewChild (signal)': 'view-query', + 'viewChild.required (signal)': 'view-query', + 'viewChildren (signal)': 'view-query', + 'contentChild (signal)': 'content-query', + 'contentChild.required (signal)': 'content-query', + 'contentChildren (signal)': 'content-query', + resource: 'resource', +}; + +export function sourceNote(source: NonNullable) { + return SOURCE_NOTES[source]; +} + +export function sourceLabel(source: SignalChange['source']) { + return SOURCE_LABELS[source]; +} + +export function statusTone(status: string) { + return RESOURCE_STATUS_TONES[status] ?? 'neutral'; +} + +export function kindColor(kind: string) { + return `var(--kind-${KIND_TOKENS[kind] ?? 'unknown'})`; +} + +export function plural(count: number, word: string): string { + return `${count} ${word}${count === 1 ? '' : 's'}`; +} diff --git a/app/src/pages/signals/signal-state.ts b/app/src/pages/signals/signal-state.ts new file mode 100644 index 00000000..3a5a3477 --- /dev/null +++ b/app/src/pages/signals/signal-state.ts @@ -0,0 +1,598 @@ +import { + DestroyRef, + ElementRef, + Injectable, + Injector, + afterNextRender, + computed, + inject, + linkedSignal, + signal, + untracked, + type Signal, +} from '@angular/core'; +import type { DevframeRpcClient } from 'devframe/client'; +import { pickPage, type ReportedPage } from '../../live-pages'; +import { hostPageId } from '../../page-id'; +import { angularPageFor, injectAngularPages } from '../../angular-pages'; +import type { SelectOption } from '../../ui/select'; +import type { SearchField } from '../../ui/search-field'; +import type { GraphLink } from '../signal-graph-layout'; +import type { SignalFocus } from '../../types/signal-focus.types'; +import type { GraphInjector, SignalGraph, SignalResource } from '../../types/signal-graph.types'; +import { + ENV, + FOLLOW, + WATCHABLE, + WATCH_LOG, + graphPagesOf, + type LiveNode, + type SignalNode, + type SourceSignal, +} from './signal-model'; + +/** + * The state the Signals page and its sections share: the `signal-graph` and `component-tree` + * shared states, the source scan, the shown page and component, the filters, the selection and + * the watched signals. Provided by the page, so every section of one page reads the same signals. + */ +@Injectable() +export class SignalState { + private rpcSource: Signal = signal(null); + readonly rpc = computed(() => this.rpcSource()); + private filterField: () => SearchField | undefined = () => undefined; + + /** The signal to select once the focused component's graph arrives. */ + readonly focusedSignal = signal<{ componentId: string; key: string } | null>(null); + /** The page a focus asked for before it reported a graph; chosen once it does. */ + private pendingPageId: string | null = null; + + private readonly destroyRef = inject(DestroyRef); + private readonly host = inject>(ElementRef); + private readonly injector = inject(Injector); + readonly pageId = hostPageId(); + private readonly angularPages = injectAngularPages(() => this.rpc()); + /** The shown page's Angular version and debug API parts, for the unavailable note. */ + readonly angularPage = computed(() => + angularPageFor(this.angularPages(), this.shownPageId() ?? this.pageId), + ); + readonly cleanups: (() => void)[] = []; + /** Counts graph loads, so only the newest one applies its result. */ + private graphLoad = 0; + readonly treePages = signal< + Record< + string, + { roots?: LiveNode[]; reportedAt?: number; title?: string; url?: string; platform?: string } + > + >({}); + private readonly graphState = signal<{ + graph: SignalGraph | null; + pages: Record; + }>({ graph: null, pages: {} }); + readonly chosenPageId = signal(null); + readonly announcement = signal(''); + + private readonly graphPages = computed(() => graphPagesOf(this.graphState())); + + // The newest graph comes as `graph`; a page that reports later never takes over. + readonly shownPageId = linkedSignal< + { pages: Record; newest: string | null; chosen: string | null }, + string | null + >({ + source: () => ({ + pages: this.graphPages(), + newest: this.graphState().graph?.pageId ?? null, + chosen: this.chosenPageId(), + }), + computation: ({ pages, newest, chosen }, previous) => { + const reported: Record = {}; + for (const pageId of Object.keys(pages)) { + reported[pageId] = { pageId, reportedAt: pageId === newest ? 1 : 0 }; + } + return pickPage(reported, { chosen, host: this.pageId, previous: previous?.value ?? null }); + }, + }); + + private readonly reported = computed(() => { + const pageId = this.shownPageId(); + return pageId === null ? null : (this.graphPages()[pageId] ?? null); + }); + readonly unsupported = computed(() => !!this.reported()?.unsupported); + /** Whether the live graph state arrived, so the intro doesn't call it a source scan first. */ + readonly graphKnown = signal(false); + readonly graph = computed(() => { + const reported = this.reported(); + return reported?.unsupported ? null : reported; + }); + private readonly graphOwner = computed(() => { + const g = this.graph(); + return g + ? `${g.pageId ?? ''}|${g.component?.id ?? g.injector?.id ?? g.element?.id ?? ''}` + : null; + }); + + // Component and injector ids belong to one page, so each page keeps its own pick. + private readonly pickedByPage = signal>({}); + readonly picked = computed(() => this.pickedByPage()[this.targetPageId() ?? ''] ?? null); + + readonly pageOptions = computed(() => { + if (this.pageId) return []; + const tree = this.treePages(); + return Object.keys(this.graphPages()).map((pageId) => { + const page = tree[pageId]; + return { + value: pageId, + label: page?.title || page?.url || `Page ${pageId}`, + hint: page?.url, + }; + }); + }); + + readonly showInternals = signal(false); + readonly sourceSignals = signal([]); + readonly sourceLoaded = signal(false); + /** The source scan or the live graph state could not be read from the server. */ + private readonly sourceFailed = signal(false); + private readonly graphFailed = signal(false); + readonly loadFailed = computed(() => this.sourceFailed() || this.graphFailed()); + readonly retrying = signal(false); + readonly filter = signal(''); + readonly resources = computed(() => this.graph()?.resources ?? []); + private readonly internalIds = computed( + () => new Set(this.resources().flatMap((r) => r.nodeIds)), + ); + /** Shows the nodes Angular itself created, which are hidden by default. */ + readonly showFramework = signal(false); + readonly frameworkCount = computed(() => { + const internal = this.internalIds(); + return (this.graph()?.nodes ?? []).filter((n) => n.framework && !internal.has(n.id)).length; + }); + readonly visibleNodes = computed(() => { + const internal = this.internalIds(); + const framework = this.showFramework(); + return (this.graph()?.nodes ?? []).filter( + (n) => !internal.has(n.id) && (framework || !n.framework), + ); + }); + readonly showGraph = signal(true); + /** Links between the nodes the page shows, by node id. */ + readonly graphLinks = computed(() => { + const g = this.graph(); + if (!g) return []; + const shown = new Set(this.visibleNodes().map((n) => n.id)); + const links: GraphLink[] = []; + for (const edge of g.edges) { + const producer = g.nodes[edge.producer]?.id; + const consumer = g.nodes[edge.consumer]?.id; + if (producer && consumer && shown.has(producer) && shown.has(consumer)) { + links.push({ producer, consumer }); + } + } + return links; + }); + readonly graphNodes = computed(() => + [...this.visibleNodes()].sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true })), + ); + + readonly watchedIds = computed(() => new Set(this.graph()?.watch?.ids ?? [])); + readonly stacksOn = computed(() => !!this.graph()?.watch?.stacks); + readonly watchedNodes = computed(() => { + const ids = this.watchedIds(); + return (this.graph()?.nodes ?? []).filter((n) => ids.has(n.id)); + }); + /** Watched ids the page reports that are not in the graph shown now. */ + readonly watchedElsewhere = computed(() => this.watchedIds().size - this.watchedNodes().length); + /** The newest changes of every watched signal in this graph, newest first. */ + readonly watchLog = computed(() => { + const history = this.graph()?.history ?? {}; + const entries = this.watchedNodes().flatMap((node) => + (history[node.id] ?? []) + .filter((change) => change.source !== 'initial') + .map((change) => ({ id: node.id, label: node.label ?? node.id, change })), + ); + return entries.sort((a, b) => b.change.at - a.change.at).slice(0, WATCH_LOG); + }); + readonly hasAnySignals = computed(() => this.visibleNodes().length + this.resources().length > 0); + readonly kindCounts = computed(() => { + const kinds: string[] = this.graph() + ? [...this.visibleNodes().map((n) => n.kind), ...this.resources().map(() => 'resource')] + : this.sourceSignals().map((s) => s.kind); + const counts = new Map(); + for (const k of kinds) counts.set(k, (counts.get(k) ?? 0) + 1); + return [...counts].map(([kind, count]) => ({ kind, count })); + }); + // A kind picked on another graph stays only when this graph has it too. + readonly kind = linkedSignal({ + source: this.graphOwner, + computation: (_owner, previous) => { + const kind = previous?.value ?? null; + return kind && untracked(this.kindCounts).some((g) => g.kind === kind) ? kind : null; + }, + }); + // An active kind with no matches still gets a pressed chip, so the filter stays visible. + readonly kindChips = computed(() => { + const counts = this.kindCounts(); + const kind = this.kind(); + return kind && !counts.some((g) => g.kind === kind) ? [...counts, { kind, count: 0 }] : counts; + }); + readonly kindTotal = computed(() => this.kindCounts().reduce((sum, g) => sum + g.count, 0)); + readonly selectedId = linkedSignal({ + source: this.graphOwner, + computation: () => null, + }); + readonly selectedNode = computed( + () => this.graph()?.nodes.find((n) => n.id === this.selectedId()) ?? null, + ); + private readonly jumpedPast = signal<{ id: string; label: string } | null>(null); + readonly jumpNote = computed(() => { + const jumped = this.jumpedPast(); + if (!jumped || this.filter() || this.kind() || this.selectedId() !== jumped.id) return ''; + return `Cleared the filters to show ${jumped.label}.`; + }); + + readonly filteredResources = computed(() => { + const q = this.filter().toLowerCase(); + const kind = this.kind(); + if (kind && kind !== 'resource') return []; + return this.resources().filter( + (r) => + !q || + r.name.toLowerCase().includes(q) || + // A short query such as `e` would match every resource through its kind. + (q.length >= 3 && 'resource'.startsWith(q)), + ); + }); + + readonly filteredNodes = computed(() => { + const q = this.filter().toLowerCase(); + const kind = this.kind(); + const nodes = this.visibleNodes().filter( + (n) => + (!kind || n.kind === kind) && + (!q || (n.label ?? '').toLowerCase().includes(q) || n.kind.toLowerCase().includes(q)), + ); + // Angular orders nodes by last read order, which changes between polls; ids are stable. + return nodes.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true })); + }); + + readonly filteredSourceSignals = computed(() => { + const q = this.filter().toLowerCase(); + const kind = this.kind(); + return this.sourceSignals().filter( + (s) => + (!kind || s.kind === kind) && + (!q || + s.name.toLowerCase().includes(q) || + s.kind.toLowerCase().includes(q) || + s.file.toLowerCase().includes(q)), + ); + }); + + readonly targetPageId = computed( + () => + this.pageId ?? + (this.shownPageId() || null) ?? + this.graph()?.pageId ?? + this.latestTreePage()?.pageId ?? + null, + ); + + private readonly latestTreePage = computed(() => { + let latest: { pageId: string; reportedAt: number } | null = null; + for (const [pageId, page] of Object.entries(this.treePages())) { + const at = page.reportedAt ?? 0; + if (!latest || at > latest.reportedAt) latest = { pageId, reportedAt: at }; + } + return latest; + }); + + readonly componentOptions = computed(() => { + const pageId = this.targetPageId(); + const roots = (pageId ? this.treePages()[pageId]?.roots : undefined) ?? []; + const flat: LiveNode[] = []; + const walk = (nodes: LiveNode[]) => { + for (const node of nodes) { + flat.push(node); + walk(node.children); + } + }; + walk(roots); + const environments = this.graph()?.environments ?? []; + if (!flat.length && !environments.length) return []; + const totals = new Map(); + for (const node of flat) totals.set(node.name, (totals.get(node.name) ?? 0) + 1); + const seen = new Map(); + const options: SelectOption[] = [ + { value: FOLLOW, label: 'Follow the routed component', hint: 'automatic' }, + ...environments.map((env) => ({ + value: ENV + env.id, + label: this.injectorLabel(env), + hint: 'injector', + })), + ]; + for (const node of flat) { + const n = (seen.get(node.name) ?? 0) + 1; + seen.set(node.name, n); + options.push({ + value: node.id, + label: (totals.get(node.name) ?? 0) > 1 ? `${node.name} #${n}` : node.name, + hint: node.kind === 'directive' ? `<${node.tag}> directive` : `<${node.tag}>`, + }); + } + return options; + }); + + readonly pickerValue = computed(() => { + const picked = this.picked(); + return picked && this.componentOptions().some((o) => o.value === picked) ? picked : FOLLOW; + }); + + /** Takes the page's `rpc` input and its filter box, which the sections read through here. */ + connect(rpc: Signal, filterField: () => SearchField | undefined) { + this.rpcSource = rpc; + this.filterField = filterField; + } + + async loadSignalGraph(client: DevframeRpcClient) { + for (const cleanup of this.cleanups.splice(0)) cleanup(); + const load = ++this.graphLoad; + /** A newer load (Retry, or a new client) started, so this one's outcome is dropped. */ + const stale = () => this.destroyRef.destroyed || load !== this.graphLoad; + const my = client.scope('pangular'); + try { + const state = await my.rpc.sharedState('signal-graph'); + if (stale()) return; + this.graphFailed.set(false); + const apply = (value: unknown) => { + const next = value as { + graph?: SignalGraph | null; + pages?: Record; + } | null; + const pages = next?.pages && typeof next.pages === 'object' ? next.pages : {}; + const state = { graph: next?.graph ?? null, pages }; + this.notePageGone(graphPagesOf(state)); + this.graphState.set(state); + const pending = this.pendingPageId; + if (pending && this.graphPages()[pending]) { + this.pendingPageId = null; + this.chosenPageId.set(pending); + } + // A state the client hasn't loaded from the server yet says nothing. + if (value !== undefined) this.graphKnown.set(true); + }; + apply(state.value()); + this.cleanups.push(state.on('updated', apply)); + } catch { + if (stale()) return; + this.graphState.set({ graph: null, pages: {} }); + this.graphKnown.set(true); + this.graphFailed.set(true); + } + try { + const tree = await my.rpc.sharedState('component-tree'); + if (stale()) return; + const applyTree = (value: unknown) => { + const pages = (value as { pages?: Record } | null)?.pages; + this.treePages.set(pages && typeof pages === 'object' ? pages : {}); + }; + applyTree(tree.value()); + this.cleanups.push(tree.on('updated', applyTree)); + } catch { + if (!stale()) this.treePages.set({}); + } + } + + private notePageGone(pages: Record) { + const gone = Object.keys(this.graphPages()).filter((pageId) => !pages[pageId]); + if (gone.length) { + this.pickedByPage.update((all) => { + const next = { ...all }; + for (const pageId of gone) delete next[pageId]; + return next; + }); + } + const chosen = this.chosenPageId(); + if (!chosen || pages[chosen]) return; + const label = this.pageOptions().find((o) => o.value === chosen)?.label ?? 'The chosen page'; + this.chosenPageId.set(null); + this.announcement.set(`${label} closed, so this shows another page.`); + } + + selectPage(pageId: string | null) { + if (!pageId || pageId === this.shownPageId()) return; + this.pendingPageId = null; + this.chosenPageId.set(pageId); + const label = this.pageOptions().find((o) => o.value === pageId)?.label ?? pageId; + this.announcement.set(`Showing the signal graph of ${label}.`); + } + + /** Shows the graph of the component a focus names, on its page when it names one. */ + showComponent(focus: SignalFocus) { + this.pendingPageId = null; + if (focus.pageId && !this.pageId) { + // A page that has not reported its graph yet is chosen when it does. + if (this.graphPages()[focus.pageId]) this.chosenPageId.set(focus.pageId); + else this.pendingPageId = focus.pageId; + } + this.focusedSignal.set(focus.key ? { componentId: focus.componentId, key: focus.key } : null); + this.pickComponent(focus.componentId, this.pageId ?? focus.pageId); + } + + pickComponent(value: string | null, forPage?: string) { + const picked = value && value !== FOLLOW ? value : null; + // Another component replaces a focus that still waits for its signal. + if (this.focusedSignal()?.componentId !== picked) this.focusedSignal.set(null); + const pageId = forPage ?? this.targetPageId() ?? undefined; + this.pickedByPage.update((all) => { + const next = { ...all }; + if (picked) next[pageId ?? ''] = picked; + else delete next[pageId ?? '']; + return next; + }); + const client = this.rpc(); + if (!client) return; + const target = picked?.startsWith(ENV) + ? { pageId, env: picked.slice(ENV.length) } + : { pageId, id: picked }; + void client + .scope('pangular') + .rpc.call('select-signal-target', target) + .catch(() => {}); + } + + injectorLabel(injector: GraphInjector) { + return injector.name === 'Root' ? 'Root services' : injector.name; + } + + async loadSourceSignals(client: DevframeRpcClient) { + const my = client.scope('pangular'); + try { + const result = (await my.rpc.call('get-signals')) as SourceSignal[]; + this.sourceSignals.set(result); + this.sourceFailed.set(false); + } catch { + this.sourceFailed.set(true); + } finally { + this.sourceLoaded.set(true); + } + } + + /** Loads the graph and the source scan again. The alert stays until they answer, so focus + * stays on Retry while it is shown and moves to the filter box when the alert goes, which + * also happens when the graph comes back but the source scan still fails. */ + async retry() { + const client = this.rpc(); + if (!client || this.retrying()) return; + const trigger = document.activeElement; + this.retrying.set(true); + try { + await Promise.all([this.loadSignalGraph(client), this.loadSourceSignals(client)]); + } finally { + this.retrying.set(false); + } + if (this.destroyRef.destroyed) return; + afterNextRender( + () => { + const lost = !trigger?.isConnected || document.activeElement === document.body; + if (lost) this.filterField()?.focus(); + }, + { injector: this.injector }, + ); + } + + /** Clears the filters from a no-match state and keeps focus on the filter box. */ + clearFilters() { + this.resetFilters(); + this.filterField()?.focus(); + } + + private resetFilters() { + this.filter.set(''); + this.kind.set(null); + } + + select(id: string) { + this.selectedId.set(this.selectedId() === id ? null : id); + this.showInternals.set(false); + } + + /** Selects a node from the drawn graph; its detail opens in the list below. */ + selectFromGraph(id: string) { + this.selectedId.set(id); + this.showInternals.set(false); + } + + isWatchable(node: SignalNode): boolean { + return WATCHABLE.has(node.kind); + } + + /** Asks the page to watch or stop watching a signal; the page's next report shows it. */ + toggleWatch(node: SignalNode) { + const watch = !this.watchedIds().has(node.id); + const label = node.label ?? 'the unnamed signal'; + this.sendWatch({ id: node.id, watch }); + this.announcement.set( + watch + ? `Watching ${label}. Its changes also log to the page console.` + : `Stopped watching ${label}.`, + ); + } + + /** Turns write stacks on or off for the page that reported the graph. */ + toggleStacks() { + const stacks = !this.stacksOn(); + this.sendWatch({ stacks }); + this.announcement.set(stacks ? 'Recording write stacks.' : 'Stopped recording write stacks.'); + } + + private sendWatch(request: { id?: string; watch?: boolean; stacks?: boolean }) { + const client = this.rpc(); + const pageId = this.graph()?.pageId ?? this.targetPageId() ?? undefined; + if (!client) return; + void client + .scope('pangular') + .rpc.call('set-signal-watch', { ...request, pageId }) + .catch(() => {}); + } + + jumpTo(node: SignalNode) { + const resource = this.resources().find((r) => r.nodeIds.includes(node.id)); + const id = resource?.id ?? node.id; + const hidden = resource + ? !this.filteredResources().some((r) => r.id === id) + : !this.filteredNodes().some((n) => n.id === id); + if (hidden) this.resetFilters(); + this.jumpedPast.set(hidden ? { id, label: resource?.name ?? node.label ?? node.id } : null); + this.selectedId.set(id); + this.showInternals.set(!!resource); + afterNextRender( + () => { + const card = [ + ...this.host.nativeElement.querySelectorAll('.node-card[data-card]'), + ].find((el) => el.dataset['card'] === id); + card?.scrollIntoView?.({ block: 'nearest' }); + card?.focus({ preventScroll: true }); + }, + { injector: this.injector }, + ); + } + + // The page counts past the kept history; older pages only send the list. + changeCount(id: string): number { + const g = this.graph(); + const counted = + g?.nodes.find((n) => n.id === id)?.changes ?? g?.resources?.find((r) => r.id === id)?.changes; + if (counted !== undefined) return counted; + const list = g?.history?.[id] ?? []; + return list.reduce((n, c) => n + (c.source === 'initial' ? 0 : 1 + (c.missed ?? 0)), 0); + } + + internalsOf(resource: SignalResource): SignalNode[] { + const ids = new Set(resource.nodeIds); + return (this.graph()?.nodes ?? []).filter((n) => ids.has(n.id)); + } + + /** Producers and consumers of every node, built once per graph. */ + private readonly relations = computed(() => { + const map = new Map(); + const g = this.graph(); + if (!g) return map; + for (const node of g.nodes) map.set(node.id, { deps: [], consumers: [] }); + for (const edge of g.edges) { + const producer = g.nodes[edge.producer]; + const consumer = g.nodes[edge.consumer]; + if (!producer || !consumer) continue; + map.get(consumer.id)?.deps.push(producer); + map.get(producer.id)?.consumers.push(consumer); + } + return map; + }); + + getDependencies(node: SignalNode): SignalNode[] { + return this.relations().get(node.id)?.deps ?? []; + } + + getConsumers(node: SignalNode): SignalNode[] { + return this.relations().get(node.id)?.consumers ?? []; + } +} diff --git a/app/src/pages/signals/source-signals.html b/app/src/pages/signals/source-signals.html new file mode 100644 index 00000000..10658ef9 --- /dev/null +++ b/app/src/pages/signals/source-signals.html @@ -0,0 +1,21 @@ +@for (sig of filteredSourceSignals(); track sig.name + sig.file + sig.line) { +
        +
        + {{ sig.kind }} + {{ sig.name }} +
        +
        + + @if (sig.component) { + + in <{{ sig.component }}> + } +
        +
        +} @empty { +
        +

        No signals match.

        +

        Try a different name or kind.

        + +
        +} diff --git a/app/src/pages/signals/source-signals.scss b/app/src/pages/signals/source-signals.scss new file mode 100644 index 00000000..e82c49f8 --- /dev/null +++ b/app/src/pages/signals/source-signals.scss @@ -0,0 +1,10 @@ +@use 'signals' as sig; + +@include sig.states; +@include sig.cards; +@include sig.node-list; +@media (max-width: 480px) { + .node-card { + padding: 12px; + } +} diff --git a/app/src/pages/signals/source-signals.ts b/app/src/pages/signals/source-signals.ts new file mode 100644 index 00000000..a3333ca0 --- /dev/null +++ b/app/src/pages/signals/source-signals.ts @@ -0,0 +1,22 @@ +import { Component, inject } from '@angular/core'; +import { SourceLink } from '../../ui/source-link'; +import { kindColor } from './signal-model'; +import { SignalState } from './signal-state'; + +/** The signals the source scan found, shown while no live graph is in. */ +@Component({ + selector: 'div[appSourceSignals]', + imports: [SourceLink], + templateUrl: './source-signals.html', + styleUrl: './source-signals.scss', +}) +export class SourceSignals { + private readonly state = inject(SignalState); + + readonly filteredSourceSignals = this.state.filteredSourceSignals; + readonly kindColor = kindColor; + + clearFilters() { + this.state.clearFilters(); + } +} diff --git a/app/src/pages/signals/watched-signals.html b/app/src/pages/signals/watched-signals.html new file mode 100644 index 00000000..2dcc2932 --- /dev/null +++ b/app/src/pages/signals/watched-signals.html @@ -0,0 +1,59 @@ +

        Watched

        +@if (watchedElsewhere(); as other) { +

        + {{ other }} watched {{ other === 1 ? 'signal is' : 'signals are' }} not in this graph. Their + changes still log to the page console. +

        +} +
          + @for (node of watchedNodes(); track node.id) { +
        • + {{ node.label ?? '(unnamed)' }} + @if (node.value !== undefined) { + {{ node.value | json }} + } + @if (changeCount(node.id); as count) { + {{ count }} {{ count === 1 ? 'change' : 'changes' }} + } + +
        • + } +
        +@if (watchLog().length) { +

        Recent changes

        +
        +
          + @for (entry of watchLog(); track entry.id + ':' + entry.change.epoch) { +
        1. + + + {{ entry.label }} + {{ + sourceLabel(entry.change.source) + }} + +
          {{ entry.change.value | json }}
          + @if (entry.change.stack?.length) { +
            + @for (frame of entry.change.stack; track $index) { +
          1. {{ frame }}
          2. + } +
          + } +
        2. + } +
        +
        +} diff --git a/app/src/pages/signals/watched-signals.scss b/app/src/pages/signals/watched-signals.scss new file mode 100644 index 00000000..32172641 --- /dev/null +++ b/app/src/pages/signals/watched-signals.scss @@ -0,0 +1,54 @@ +@use 'mixins' as m; +@use 'signals' as sig; + +@include sig.states; +@include sig.pre; +@include sig.changed-badge; +@include sig.scroll-box; +@include sig.list-heading; +@include sig.rel-label; +@include sig.history; +:host(.watched) { + margin-bottom: 16px; +} +@include sig.link-button; +.watched-list { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0 0 8px; + padding: 0; + list-style: none; +} +.watched-list li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + min-width: 0; + padding: 6px 10px; + background: var(--surface); + border: 1px solid var(--accent-line); + border-radius: var(--radius-sm); +} +.watched-value { + @include m.truncate; + flex: 1 1 120px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-2); +} +.sub-heading { + @include m.label; + margin: 12px 0 8px; +} +.watch-log { + max-height: 320px; + overflow: auto; + border-radius: var(--radius-sm); +} +.watch-log .history { + max-height: none; + overflow: visible; +} +@include sig.stack; diff --git a/app/src/pages/signals/watched-signals.ts b/app/src/pages/signals/watched-signals.ts new file mode 100644 index 00000000..655a571b --- /dev/null +++ b/app/src/pages/signals/watched-signals.ts @@ -0,0 +1,28 @@ +import { Component, inject } from '@angular/core'; +import { DatePipe, JsonPipe } from '@angular/common'; +import { sourceLabel, type SignalNode } from './signal-model'; +import { SignalState } from './signal-state'; + +/** The watched signals of the Signals page and the newest changes they logged. */ +@Component({ + selector: 'section[appWatchedSignals]', + imports: [DatePipe, JsonPipe], + templateUrl: './watched-signals.html', + styleUrl: './watched-signals.scss', +}) +export class WatchedSignals { + private readonly state = inject(SignalState); + + readonly watchedNodes = this.state.watchedNodes; + readonly watchedElsewhere = this.state.watchedElsewhere; + readonly watchLog = this.state.watchLog; + readonly sourceLabel = sourceLabel; + + changeCount(id: string): number { + return this.state.changeCount(id); + } + + toggleWatch(node: SignalNode) { + this.state.toggleWatch(node); + } +} diff --git a/app/src/pages/store-inspector.html b/app/src/pages/store-inspector.html new file mode 100644 index 00000000..757462a7 --- /dev/null +++ b/app/src/pages/store-inspector.html @@ -0,0 +1,35 @@ +

        + NgRx signal stores and @ngrx/store state from the live page, with every change, plus NGXS, Elf + and MiniRx stores. Source declarations lists the stores your files declare. +

        +
        + + @if (allPages().length > 1) { + Page + + } + + + {{ !stateLoaded() ? 'Loading…' : pageConnected() ? 'Live' : 'No page connected' }} + +
        + +
        + +@if (hasEvents()) { +
        +} + +
        diff --git a/app/src/pages/store-inspector.scss b/app/src/pages/store-inspector.scss new file mode 100644 index 00000000..e89e716b --- /dev/null +++ b/app/src/pages/store-inspector.scss @@ -0,0 +1,105 @@ +@use 'mixins' as m; + +.page-intro { + margin: 0 0 12px; + color: var(--text-2); + line-height: 1.5; +} +.search { + position: relative; + display: flex; + flex: 1 1 220px; + min-width: 0; +} +.search > input { + flex: 1 1 auto; + width: 100%; + padding-left: 34px; +} +.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; +} +.search:focus-within .search-icon { + stroke: var(--accent); +} + +:host { + display: grid; + gap: 24px; + min-width: 0; + color: var(--text); + font-size: 13px; +} +.toolbar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + padding: 10px 12px; + background: color-mix(in srgb, var(--surface) 85%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.page-select { + flex: 0 1 220px; + min-width: 0; +} +.status { + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + color: var(--text-2); + font-size: 12px; + font-weight: 500; +} +.live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-3); +} +.status.on .live-dot { + background: var(--ok); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 20%, transparent); + animation: pulse 2s infinite; +} +@keyframes pulse { + 50% { + opacity: 0.4; + } +} +.block { + display: grid; + gap: 12px; + min-width: 0; +} +@media (prefers-reduced-motion: reduce) { + .status.on .live-dot { + animation: none; + } +} +@media (max-width: 480px) { + .toolbar { + padding: 8px; + } + .toolbar input, + .page-select { + flex-basis: 100%; + } +} diff --git a/app/src/pages/store-inspector.ts b/app/src/pages/store-inspector.ts index b054ce89..78bf173e 100644 --- a/app/src/pages/store-inspector.ts +++ b/app/src/pages/store-inspector.ts @@ -1,2869 +1,185 @@ -import { NgTemplateOutlet } from '@angular/common'; -import { - afterRenderEffect, - Component, - DestroyRef, - ElementRef, - computed, - effect, - inject, - input, - linkedSignal, - signal, - viewChild, -} from '@angular/core'; +import { Component, effect, inject, input, viewChild } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; -import { time } from '../format'; -import { hostPageId } from '../page-id'; -import { angularPageFor, injectAngularPages } from '../angular-pages'; -import { AngularUnavailable } from '../ui/angular-unavailable'; -import { rpcCall as call } from '../rpc'; -import { actionAllowed, actionBlockedMessage, panelConfig } from '../devtools-config'; -import { LimitNote } from '../ui/limit-note'; -import { RovingList } from '../ui/roving-list'; -import { Select, type SelectOption } from '../ui/select'; -import { VirtualWindow } from '../ui/virtual-window'; -import { EntryTabs, type EntryDetailAnswer } from './store-entry-tabs'; -import { StoreSession } from './store-session'; -import { - changeCountText, - moreChangesText, - pretty, - sameNgrxLog, - short, - traceFrame, - type LiveStore, - type NgrxActionStat, - type NgrxActionOrigin, - type NgrxLogEntry, - type NgrxLogKey, - type NgrxPage, - type NgrxState, - type NgrxStoreEntry, -} from './store-types'; -import { SourceLink } from '../ui/source-link'; -import { StoreStateTree } from './store-state-tree'; -import { StoreGlobal } from '../ui/store-global'; -import { reduxLiveStores, reduxLog, reduxPagesOf, type ReduxStoresPage } from './redux-stores'; import { SearchField } from '../ui/search-field'; -import { Splitter, storedPaneSize } from '../ui/splitter'; +import { Select } from '../ui/select'; import { rememberSelection } from '../view-memory'; -import { AppState } from '../ui/app-state'; -import { LiveControls, LiveFeed, followNewest } from '../ui/live-feed'; - -const KIND_COLORS: Record = { - action: '#f59e0b', - reducer: '#a78bfa', - effect: '#fb923c', - selector: '#60a5fa', - feature: '#34d399', - 'store-setup': '#94a3b8', - 'signal-store': '#e879f9', - 'signal-state': '#22d3ee', - 'signal-method': '#fb7185', - store: '#a78bfa', - event: 'var(--warn)', - redux: '#38bdf8', -}; - -const KIND_LABELS: Record = { - 'signal-store': 'signalStore', - 'signal-state': 'signalState', - 'signal-method': 'signalMethod', - 'store-setup': 'store setup', - store: '@ngrx/store', - event: 'event', - redux: 'Redux DevTools', -}; - -const ORIGIN_TEXT: Record = { - dispatch: 'store.dispatch(action)', - effect: 'NgRx effect (Store.next)', - reactive: 'store.dispatch(() => action)', -}; - -function parsePayload(text: string): { payload?: Record; error?: string } { - if (!text.trim()) return {}; - let value: unknown; - try { - value = JSON.parse(text); - } catch { - return { error: 'The payload is not valid JSON.' }; - } - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return { error: 'The payload must be a JSON object, like {"id": 7}.' }; - } - if (Object.prototype.hasOwnProperty.call(value, 'type')) { - return { - error: 'The payload cannot have a "type" key. Put the action type in the Type field.', - }; - } - return { payload: value as Record }; -} - -const CLASSIC_KINDS = new Set([ - 'action', - 'reducer', - 'effect', - 'selector', - 'feature', - 'store-setup', -]); - +import { StoreActions } from './store/store-actions'; +import { StoreEvents } from './store/store-events'; +import { StoreLive } from './store/store-live'; +import { StoreSource } from './store/store-source'; +import { StoreState } from './store/store-state'; +import type { NgrxLogEntry } from './store-types'; + +/** + * The Store page: the toolbar, and one section each for the live stores, the + * `@ngrx/signals/events` events and the source declarations. The sections live in `./store/` and + * share the page's {@link StoreState} and {@link StoreActions}; the page keeps the members its + * tests and the shell read on itself. + */ @Component({ selector: 'app-store-inspector', - imports: [ - SearchField, - Splitter, - AngularUnavailable, - LimitNote, - RovingList, - Select, - NgTemplateOutlet, - SourceLink, - StoreGlobal, - EntryTabs, - StoreSession, - StoreStateTree, - AppState, - LiveControls, - ], - template: ` -

        - NgRx signal stores and @ngrx/store state from the live page, with every change, plus NGXS, - Elf and MiniRx stores. Source declarations lists the stores your files declare. -

        -
        - - @if (allPages().length > 1) { - Page - - } - - - {{ !stateLoaded() ? 'Loading…' : pageConnected() ? 'Live' : 'No page connected' }} - -
        - -
        -
        -

        Live stores

        - @if (liveStores().length) { - {{ liveStores().length }} - } -
        - - @if (failed()) { - - Could not load the live store state. -

        Check that the dev server with Pangular Inspector is still running.

        -
        - } @else if (!stateLoaded()) { - Loading the live stores… - } @else if (!page()) { - - No page is reporting store state yet. -

        - Open the app in a browser. Stores show up here as soon as the page creates them. -

        -
        - } @else if (!liveStores().length) { - - The overlay finds stores through the injectors, so stores it can't reach from a component - are not listed. - -
        - @if (usesSignals()) { -

        No signal store instance on this page yet.

        -

        - A signalStore is created the first time something injects it. Navigate to - a page that uses the store and it appears here. -

        - } @else if (usesClassic()) { -

        The @ngrx/store Store was not found on this page.

        -

        - Check that provideStore() or StoreModule.forRoot() is part - of the providers of the running app. -

        - } @else { -

        This page has no store.

        -

        - Stores from @ngrx/signals (signalStore, - signalState) and @ngrx/store are listed here once the - app creates them. NGXS, Elf and MiniRx stores show up through their Redux DevTools - integration when the app loads - @pangular-inspector/devtools/store-hook - first. -

        - } -
        - } @else { -
        -
          - @for (item of filteredStores(); track item.id) { -
        • - -
        • - } @empty { -
        • No stores match this filter.
        • - } -
        - - @if (store(); as current) { -
        -
        -

        {{ current.label }}

        -
        - {{ kindLabel(current.kind) }} - @if (current.redux; as other) { - {{ other.library }} - } @else { - scope: {{ current.scope }} - } - @if (current.signal?.declaredIn; as file) { - - } - @if (current.classic) { - {{ - current.classic.devtools ? 'Store DevTools on' : 'Store DevTools off' - }} - } - @if (current.signal?.watchOnly) { - withDevtools: watched only - } -
        - @if (current.signal?.watchOnly) { -

        - This store uses withDevtools(), so the inspector does not wrap its - methods or signals. Changes are logged as patchState entries, and - method calls are not counted. -

        - } - @if (current.signal?.references?.length) { -

        - Referenced by - @for (ref of current.signal!.references; track ref; let last = $last) { - {{ ref }}{{ last ? '' : ', ' }} - } -

        - } -
        - - @if (current.classic?.paused) { -
        -

        - Viewing a past state. New actions are logged but do not change - the state until you go back to the latest state. -

        - - @if (!canRestore()) { -

        {{ restoreOff }}

        - } -
        - } - -
        -
        -
        -

        State

        -
        - @if (!current.redux) { - - } - -
        -
        - -
        - @if (current.signal; as info) { -
        -

        Computed

        - @if (computedEntries().length) { -
        - @for (entry of computedEntries(); track entry.key) { -
        {{ entry.key }}
        -
        - {{ entry.value }} -
        - } -
        - } @else { -

        No computed signals.

        - } -

        Methods

        - @if (info.methods.length) { -
          - @for (method of info.methods; track method.name) { -
        • - {{ method.name }} - @if (method.signalMethod) { - signalMethod - } @else if (method.rx) { - rxMethod - } - {{ method.calls }} {{ method.calls === 1 ? 'call' : 'calls' - }}{{ methodDuration(method) }} -
        • - } -
        - } @else { -

        No methods.

        - } -
        - @if (info.entities?.length) { -
        -

        Entities

        -
        - @for (col of info.entities!; track col.idsKey) { -
        -
        - {{ col.collection ?? 'entities' }} - {{ col.count }} {{ col.count === 1 ? 'entity' : 'entities' }} -
        -
          - @for (id of entityIds(col.ids); track id) { -
        • {{ id }}
        • - } - @if (col.count > 30) { -
        • - +{{ col.count - 30 }} more -
        • - } -
        - @if (col.selectedId !== undefined) { -
        -
        Selected
        -
        - @if (col.selected !== undefined) { - {{ - shortText(col.selected) - }} - } @else { - None selected - } -
        -
        - } -
        - } -
        -
        - } - } -
        - - @if (current.classic?.devtools) { -
        -

        Store DevTools history

        -

        - {{ - current.classic!.recordingPaused - ? 'Recording is paused: new actions change the state but are not recorded.' - : 'Recording new actions.' - }} - @if (current.classic!.skipped?.length; as skippedCount) { - {{ skippedCount }} skipped - {{ skippedCount === 1 ? 'action is' : 'actions are' }} left out of the state. - } -

        - @if (confirmReset()) { -
        -

        - Reset puts the store back on its initial state and empties the Store - DevTools history. Entries logged so far can no longer be restored. -

        -
        - - -
        -
        - } @else { -
        - - - - -
        - } - @if (!canRestore()) { -

        {{ restoreOff }}

        - } -
        - } - -
        -

        - {{ current.classic || current.redux ? 'Action log' : 'Change log' }} - {{ storeLog().length }} -

        -
        - -
        - -
        -
          - @for (shown of shownLog(); track shown.entry.seq) { - @let entry = shown.entry; -
        • - -
        • - } @empty { -
        • - @if (filter()) { - No entries match this filter. - } @else if (current.classic || current.redux) { - No actions dispatched since the page connected. - } @else { - No state changes yet. Method calls and patchState writes that change the - state are recorded here. - } -
        • - } -
        - - - @if (changeEntry(); as selected) { -
        - -
        - } -
        -
        - - @if (storeStats().length) { -
        -

        Action frequency

        -

        - How often each {{ current.classic ? 'action' : 'change' }} ran since the page - loaded, including entries the log no longer keeps. -

        -
        - - - - - - - - - - - - @for (stat of storeStats(); track stat.type) { - - - - - - - - } - -
        {{ current.classic ? 'Action' : 'Method or write' }}CountAvgLongestPeak per second
        - {{ stat.type }} - @if (stat.loop) { - Possible effect loop - } - {{ stat.count }} - {{ stat.avgMs !== undefined ? formatDuration(stat.avgMs) : 'n/a' }} - - {{ stat.maxMs !== undefined ? formatDuration(stat.maxMs) : 'n/a' }} - {{ stat.burst }}
        -
        - @if (hasLoop()) { -

        - A possible effect loop is an action an NgRx effect dispatched - {{ loopBurst }} or more times within one second. Check the effect that - dispatches it for a missing filter or a dispatch of its own trigger. -

        - } -
        - } - - @if (!current.redux) { - - } - - @if (current.classic) { -
        -

        Dispatch an action

        -
        - - - @for (type of actionTypes(); track type) { - - } - - -

        {{ payloadError() }}

        -
        - -
        - @if (!canRestore()) { -

        {{ restoreOff }}

        - } -
        -
        - } -
        - } -
        - } -

        {{ message() }}

        -
        - - @if (hasEvents()) { -
        -
        -

        Events

        - {{ pageEvents().length }} -
        -

        - Events dispatched through @ngrx/signals/events, across every store on this page. -

        -
          - @for (shown of shownEvents(); track shown.entry.seq) { - @let evt = shown.entry; -
        • - -
        • - } @empty { -
        • No events match this filter.
        • - } -
        - @if (eventEntry(); as selected) { -
        - -
        - } -
        - } - - - -
        -
        - #{{ selected.seq }} {{ selected.type }} -
        -
        -
        Time
        -
        {{ formatTime(selected.timestamp) }}
        - @if (selected.origin) { -
        Origin
        -
        {{ originText(selected.origin) }}
        - } - @if (selected.durationMs !== undefined) { -
        Duration
        -
        {{ formatDuration(selected.durationMs) }}
        - } - @if (selected.trace?.length) { -
        Called from
        -
        -
          - @for (frame of traceFrames(selected.trace); track $index) { -
        1. - @if (frame.file) { - {{ frame.name ? frame.name + ' ' : '' }}({{ frame.file }}:{{ - frame.line - }}) - } @else { - {{ frame.name }} - } -
        2. - } -
        -
        - } - @if (selected.source === 'event') { - - } -
        - - @if (selected.action !== undefined) { -
        Action
        -
        -
        {{ prettyText(selected.action) }}
        -
        - } - @if (selected.args?.length) { -
        Arguments
        -
        -
        {{ prettyText(selected.args) }}
        -
        - } - @if (selected.source === 'event' && selected.payload !== undefined) { -
        Payload
        -
        -
        {{ prettyText(selected.payload) }}
        -
        - } - @if (selected.causedByEvent; as caused) { -
        Caused by event
        -
        - {{ caused.type }} - @if (caused.payload !== undefined) { -
        {{ prettyText(caused.payload) }}
        - } -
        - } -
        - -
        -
        State diff
        - @if (selected.diff.length) { - - } -
        - @if (selected.diff.length) { -
          - @for (change of selected.diff; track change.path) { -
        • - {{ opLabel(change.op) }} - {{ change.path }} - - @if (change.op !== 'add') { - {{ shortText(change.before) }} - } - @if (change.op === 'change') { - became - } - @if (change.op !== 'remove') { - {{ shortText(change.after) }} - } - -
        • - } -
        - @if (selected.diffTruncated) { -

        {{ moreChanges(selected.diffTruncated) }}

        - } - } @else { -

        The state did not change.

        - } -
        - @if (selected.source === 'redux') { - @if (selected.action !== undefined) { -
        - -
        - } - - } @else if (selected.source !== 'event') { - -
        - @if ( - selected.action !== undefined || selected.args?.length || selected.causedByEvent - ) { -
        - -
        - } @else { -

        - No action or arguments: a restore, or a patchState write outside a method. -

        - } -
        -
        - -
        -
        - } - - @if (selected.restorable && confirmSeq() === selected.seq) { -
        -

        - @if (selected.source === 'store') { - Store DevTools jumps the app state to the state right after action #{{ - selected.seq - }}. Until you go back to the latest state, new actions are logged but do not change - the state. - } @else { - @if (store(); as current) { - This sets every state key of {{ current.label }} back to its value right after - change #{{ selected.seq }}. Components that read the store update at once, and a - new "Restore" entry is added to the log. - } @else { - This sets every state key of the store back to its value right after change #{{ - selected.seq - }}. Components that read the store update at once, and a new "Restore" entry is - added to the log. - } - } -

        -
        - - -
        -
        - } - @if ( - confirmSeq() !== selected.seq && - (selected.restorable || canAgain(selected) || canToggle(selected)) - ) { -
        - @if (canToggle(selected)) { - - } - @if (selected.restorable) { - - } - @if (canAgain(selected)) { - - } -
        - @if (!canRestore()) { -

        {{ restoreOff }}

        - } - } - @if (selected.source === 'redux') { -

        - This store reports through its Redux DevTools integration, which the panel reads but - does not write to, so its state cannot be restored from here. -

        - } - @if (!selected.restorable && selected.source === 'store') { -

        - @if (selected.unrestorable === 'dropped') { - Store DevTools no longer holds this action, so this state cannot be restored. It was - dropped past maxAge, or the Store DevTools history was committed, reset - or imported. - } @else if (selected.unrestorable === 'not-recorded') { - Store DevTools never recorded this action, so this state cannot be restored. An - actionsBlocklist, actionsSafelist or - predicate option filtered it out, or recording was paused. - } @else { - Time travel for @ngrx/store needs provideStoreDevtools(). Without it, - entries cannot be restored. - } -

        - } -
        -
        - -
        -
        -

        Source declarations

        - @if (sourceEntries().length) { - {{ sourceEntries().length }} - } -
        - @if (!sourceLoaded()) { -
        - -

        Scanning source for NgRx declarations…

        -
        - } @else if (sourceEntries().length === 0) { -
        -

        No NgRx declarations found in source.

        -

        - The scan looks for signalStore, signalState, - signalMethod, createAction, createReducer, - createEffect, createSelector and createFeature. -

        -
        - } @else { -
        - - @for (group of groupedEntries(); track group.kind) { - - } -
        - -
          - @for (entry of filteredEntries(); track entry.name + entry.file + entry.line) { -
        • -
          - - {{ entry.name }} - {{ kindLabel(entry.kind) }} -
          -
          - -
          - @if (entry.detail) { -

          {{ entry.detail }}

          - } -
        • - } @empty { -
        • -
          -

          No declarations match.

          - -
          -
        • - } -
        - } -
        - `, - styles: ` - @use 'mixins' as m; - - .page-intro { - margin: 0 0 12px; - color: var(--text-2); - line-height: 1.5; - } - .search { - position: relative; - display: flex; - flex: 1 1 220px; - min-width: 0; - } - .search > input { - flex: 1 1 auto; - width: 100%; - padding-left: 34px; - } - .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; - } - .search:focus-within .search-icon { - stroke: var(--accent); - } - - :host { - display: grid; - gap: 24px; - min-width: 0; - color: var(--text); - font-size: 13px; - } - .intro { - max-width: 720px; - margin: 0; - color: var(--text-2); - line-height: 1.5; - } - .toolbar { - position: sticky; - top: 0; - z-index: 2; - display: flex; - flex-wrap: wrap; - gap: 8px 12px; - align-items: center; - padding: 10px 12px; - background: color-mix(in srgb, var(--surface) 85%, transparent); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - border: 1px solid var(--border); - border-radius: var(--radius); - } - input { - flex: 1 1 220px; - min-width: 0; - height: 34px; - padding: 0 12px; - background: var(--bg); - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - color: var(--text); - font: inherit; - font-size: 13px; - outline: none; - transition: - border-color 0.15s var(--ease), - box-shadow 0.15s var(--ease); - } - input::placeholder { - color: var(--text-3); - } - input:focus-visible { - @include m.field-focus; - } - .page-select { - flex: 0 1 220px; - min-width: 0; - } - .status { - display: inline-flex; - flex: none; - align-items: center; - gap: 6px; - color: var(--text-2); - font-size: 12px; - font-weight: 500; - } - .live-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--text-3); - } - .status.on .live-dot { - background: var(--ok); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 20%, transparent); - animation: pulse 2s infinite; - } - @keyframes pulse { - 50% { - opacity: 0.4; - } - } - .block { - display: grid; - gap: 12px; - min-width: 0; - } - .section-head { - display: flex; - align-items: center; - gap: 8px; - min-height: 24px; - } - h2, - h4, - .sub { - @include m.label; - display: flex; - align-items: center; - gap: 8px; - margin: 0; - } - h4 { - margin-bottom: 10px; - } - .spaced { - margin-top: 16px; - } - .sub { - margin: 14px 0 8px; - } - .fact-head, - .sub-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - min-width: 0; - } - .fact-head { - margin-bottom: 10px; - } - .state-actions { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - align-items: center; - gap: 8px; - min-width: 0; - } - .fact-head h4 { - margin: 0; - } - .sub-head { - margin: 14px 0 8px; - } - .sub-head .sub { - margin: 0; - } - .btn.copy { - flex: none; - height: var(--control-h); - padding: 0 10px; - font-size: 12px; - } - .pill { - min-width: 20px; - padding: 0 7px; - border-radius: 99px; - background: var(--surface-2); - border: 1px solid var(--border); - color: var(--text-2); - font-size: 11px; - font-weight: 500; - letter-spacing: 0; - line-height: 18px; - text-align: center; - font-variant-numeric: tabular-nums; - } - .empty { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - text-align: center; - padding: 36px 24px; - @include m.panel; - @include m.enter; - } - .empty.compact { - padding: 24px 16px; - border-style: dashed; - background: transparent; - } - .empty-title { - margin: 0; - color: var(--text-strong); - font-size: 14px; - font-weight: 600; - } - .hint { - max-width: 520px; - margin: 0; - color: var(--text-2); - line-height: 1.55; - overflow-wrap: anywhere; - } - .hint.small { - margin-top: 12px; - font-size: 12px; - } - code { - font-family: var(--font-mono); - font-size: 12px; - } - .hint code, - .refs code { - padding: 1px 5px; - color: var(--text); - background: var(--surface-3); - border: 1px solid var(--border); - border-radius: 5px; - } - .muted { - margin: 0; - color: var(--text-2); - } - .pad { - padding: 20px 12px; - text-align: center; - line-height: 1.5; - } - .btn { - height: var(--control-h); - padding: 0 14px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--surface-2); - color: var(--text); - font: inherit; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: - background-color 0.15s var(--ease), - border-color 0.15s var(--ease); - } - .btn:hover { - background: var(--surface-3); - } - .btn:focus-visible { - @include m.focus-ring; - } - .btn:disabled { - opacity: 0.6; - cursor: default; - } - .btn.primary, - .btn.restore { - border-color: var(--accent-line); - background: var(--accent-soft); - color: var(--text-strong); - } - .btn.restore { - margin-top: 14px; - } - .live-layout { - display: grid; - grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); - min-width: 0; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - box-shadow: var(--shadow); - overflow: hidden; - @include m.enter; - } - ul { - list-style: none; - margin: 0; - padding: 0; - } - .store-list { - display: flex; - flex-direction: column; - gap: 2px; - padding: 8px; - border-right: 1px solid var(--border); - background: var(--surface-2); - } - .store-item, - .log-item { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 3px; - width: 100%; - padding: 8px 10px; - border: none; - border-radius: var(--radius-sm); - background: transparent; - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; - transition: background-color 0.15s var(--ease); - } - .store-item:hover, - .log-item:hover, - .event-item:hover { - background: var(--surface-3); - } - .store-item:focus-visible, - .log-item:focus-visible, - .event-item:focus-visible { - @include m.focus-ring(-2px); - } - .store-item.selected, - .log-item.selected, - .event-item.selected { - background: var(--accent-soft); - box-shadow: inset 2px 0 0 var(--accent); - } - .store-top { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - max-width: 100%; - } - .store-name { - @include m.truncate; - font-family: var(--font-mono); - font-weight: 600; - color: var(--text-strong); - } - .store-meta, - .log-meta { - color: var(--text-2); - font-size: 11.5px; - font-variant-numeric: tabular-nums; - } - .dot { - flex: none; - width: 8px; - height: 8px; - border-radius: 50%; - } - .store-detail { - display: grid; - gap: 16px; - min-width: 0; - padding: 16px; - } - .detail-head h3 { - margin: 0 0 8px; - color: var(--accent); - font-family: var(--font-mono); - font-size: 15px; - font-weight: 600; - overflow-wrap: anywhere; - } - .chips { - display: flex; - flex-wrap: wrap; - gap: 6px; - } - .chip { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: 100%; - padding: 1px 8px; - border: 1px solid var(--border); - border-radius: 99px; - background: var(--surface-2); - color: var(--text-2); - font-size: 11.5px; - line-height: 18px; - overflow-wrap: anywhere; - } - .chip.mono { - font-family: var(--font-mono); - } - .refs { - margin: 10px 0 0; - color: var(--text-2); - line-height: 1.8; - } - .facts { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 16px; - min-width: 0; - } - .fact { - min-width: 0; - } - pre { - margin: 0; - font-family: var(--font-mono); - font-size: 12px; - line-height: 1.55; - white-space: pre-wrap; - overflow-wrap: anywhere; - color: var(--text); - } - .tree, - .code { - max-height: 360px; - overflow: auto; - padding: 10px 12px; - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - } - .code { - max-height: 220px; - } - .tree:focus-visible { - @include m.focus-ring; - } - .kv { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - gap: 6px 14px; - margin: 0; - } - /* The same gap the NgRx entry tabs keep below Time. */ - .kv.redux-action { - margin-top: 10px; - } - .kv dt { - font-family: var(--font-mono); - font-size: 12px; - color: var(--text-2); - } - .kv dd { - min-width: 0; - margin: 0; - overflow-wrap: anywhere; - } - .methods { - display: flex; - flex-wrap: wrap; - gap: 6px; - } - .methods li { - flex-wrap: wrap; - row-gap: 2px; - } - .tag { - padding: 0 5px; - border-radius: 4px; - background: var(--accent-soft); - color: var(--text-strong); - font-size: 10.5px; - } - .calls { - color: var(--text-3); - font-family: var(--font-sans, inherit); - font-size: 11px; - } - .entities { - display: grid; - gap: 14px; - } - .entity-group { - display: grid; - gap: 8px; - } - .entity-head { - display: flex; - align-items: center; - gap: 8px; - } - .entity-count { - color: var(--text-2); - font-size: 11.5px; - font-variant-numeric: tabular-nums; - } - .entity-ids { - margin: 0; - } - .chip.more { - border-style: dashed; - color: var(--text-3); - } - .events-list { - display: flex; - flex-direction: column; - gap: 2px; - max-height: 260px; - overflow: auto; - padding: 6px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - @include m.enter; - } - .event-item { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px 10px; - width: 100%; - min-width: 0; - padding: 7px 10px; - border: none; - border-radius: var(--radius-sm); - background: transparent; - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; - transition: background-color 0.15s var(--ease); - } - .event-type { - flex: none; - font-weight: 600; - color: var(--text-strong); - font-size: 12.5px; - } - .event-payload { - flex: 1 1 160px; - min-width: 0; - @include m.truncate; - color: var(--text-2); - } - .event-time { - flex: none; - margin-left: auto; - color: var(--text-3); - font-size: 11.5px; - font-variant-numeric: tabular-nums; - } - .log { - min-width: 0; - padding-top: 16px; - border-top: 1px solid var(--border); - } - .live-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; - margin: 0 0 8px; - } - .log-layout { - display: grid; - grid-template-columns: min(var(--log-list-size, 300px), 60%) auto minmax(0, 1fr); - gap: 16px; - min-width: 0; - } - .log-list { - display: flex; - flex-direction: column; - gap: 2px; - max-height: 420px; - overflow: auto; - padding: 2px; - } - .log-item { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - column-gap: 8px; - row-gap: 2px; - } - .seq { - color: var(--text-3); - font-size: 11.5px; - font-variant-numeric: tabular-nums; - } - .log-type-row { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; - } - .log-type { - @include m.truncate; - min-width: 0; - font-family: var(--font-mono); - font-size: 12.5px; - color: var(--text); - } - .log-meta { - grid-column: 2; - } - .entry { - min-width: 0; - padding: 14px; - border: 1px solid var(--accent-line); - border-radius: var(--radius-sm); - @include m.enter(0.25s); - } - .entry .entry-heading { - margin: 0 0 12px; - font-family: var(--font-mono); - font-size: 13px; - font-weight: 600; - color: var(--text-strong); - overflow-wrap: anywhere; - } - .diff { - display: grid; - gap: 4px; - } - .diff-row { - display: grid; - grid-template-columns: auto minmax(0, max-content) minmax(0, 1fr); - gap: 8px; - align-items: baseline; - padding: 5px 8px; - border-radius: 6px; - background: var(--surface-2); - } - .op { - font-size: 10.5px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-2); - } - .diff-row.add .op { - color: var(--ok); - } - .diff-row.remove .op { - color: var(--danger); - } - .path { - color: var(--text-strong); - overflow-wrap: anywhere; - } - .values { - display: flex; - flex-wrap: wrap; - gap: 4px 6px; - min-width: 0; - overflow-wrap: anywhere; - } - .before { - color: var(--text-2); - text-decoration: line-through; - } - .after { - color: var(--text); - } - .arrow { - color: var(--text-3); - } - .confirm { - margin-top: 14px; - padding: 12px; - border: 1px solid var(--accent-line); - border-radius: var(--radius-sm); - background: var(--accent-soft); - } - .confirm p { - margin: 0 0 10px; - line-height: 1.5; - } - .actions, - .entry-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - } - .entry-actions { - margin-top: 14px; - } - .entry-actions .btn.restore { - margin-top: 0; - } - .dispatch { - min-width: 0; - margin-top: 16px; - padding-top: 16px; - border-top: 1px solid var(--border); - } - .dispatch-form { - display: grid; - gap: 10px; - max-width: 560px; - } - .dispatch-form label { - display: grid; - gap: 6px; - color: var(--text-2); - font-size: 12px; - font-weight: 500; - } - .optional { - color: var(--text-3); - font-weight: 400; - } - .dispatch-form input { - width: 100%; - font-family: var(--font-mono); - font-size: 12.5px; - } - textarea { - width: 100%; - min-width: 0; - padding: 8px 10px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--bg); - color: var(--text); - font-family: var(--font-mono); - font-size: 12px; - resize: vertical; - transition: - border-color 0.15s var(--ease), - box-shadow 0.15s var(--ease); - } - textarea::placeholder { - color: var(--text-3); - } - textarea:focus-visible { - @include m.field-focus; - } - textarea[aria-invalid='true'] { - border-color: color-mix(in srgb, var(--danger) 60%, transparent); - } - .field-error { - margin: -4px 0 0; - color: var(--danger); - font-size: 12px; - } - .field-error:empty { - display: none; - } - .history, - .stats { - min-width: 0; - margin-top: 16px; - } - .history h4, - .stats h4 { - margin: 0 0 6px; - } - .history .actions { - margin-top: 8px; - } - .table-wrap { - max-height: 280px; - overflow: auto; - margin-top: 8px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - } - .table-wrap:focus-visible { - @include m.focus-ring(-2px); - } - .stats table { - width: 100%; - border-collapse: collapse; - font-size: 12px; - } - .stats th, - .stats td { - padding: 5px 8px; - border-bottom: 1px solid var(--border); - text-align: left; - vertical-align: baseline; - } - .stats thead th { - position: sticky; - top: 0; - background: var(--surface-2); - color: var(--text-2); - font-weight: 600; - } - .stats tbody th { - font-weight: 400; - color: var(--text-strong); - overflow-wrap: anywhere; - } - .stats .num { - text-align: right; - white-space: nowrap; - } - .mono { - font-family: var(--font-mono); - } - .loop { - display: inline-block; - margin-left: 6px; - padding: 0 5px; - border: 1px solid; - border-radius: 4px; - font-family: var(--font-sans, inherit); - font-size: 10.5px; - @include m.tint(warn); - } - .trace { - display: grid; - gap: 2px; - margin: 0; - padding-left: 18px; - font-family: var(--font-mono); - font-size: 12px; - } - .paused { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px 14px; - margin-bottom: 14px; - padding: 10px 12px; - border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); - border-radius: var(--radius-sm); - background: color-mix(in srgb, var(--warn) 10%, transparent); - } - .paused p { - flex: 1 1 240px; - margin: 0; - line-height: 1.5; - } - .paused .hint.small { - flex-basis: 100%; - margin-top: 0; - } - .message:empty { - display: none; - } - .message { - margin: 0; - color: var(--text-2); - } - .kinds { - display: flex; - flex-wrap: wrap; - gap: 6px; - } - .kind-chip { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: 100%; - height: 28px; - padding: 0 10px; - border: 1px solid var(--control-border); - border-radius: 99px; - background: var(--surface-2); - color: var(--text-2); - font: inherit; - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: - background-color 0.15s var(--ease), - border-color 0.15s var(--ease), - color 0.15s var(--ease); - } - .kind-chip:hover { - border-color: var(--text-3); - background: var(--surface-3); - color: var(--text); - } - .kind-chip.active { - border-color: var(--accent-line); - background: var(--accent-soft); - color: var(--text-strong); - } - .kind-chip:focus-visible { - @include m.focus-ring; - } - .chip-text { - @include m.truncate; - } - .kind-chip .count { - flex: none; - min-width: 18px; - padding: 0 5px; - border-radius: 99px; - background: var(--surface-3); - color: var(--text-2); - font-size: 11px; - line-height: 16px; - text-align: center; - font-variant-numeric: tabular-nums; - } - .nodes { - display: flex; - flex-direction: column; - gap: 8px; - @include m.enter; - } - .node-card { - min-width: 0; - padding: 12px 16px; - @include m.panel; - border-radius: var(--radius-sm); - } - .node-header { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px 8px; - min-width: 0; - } - .node-label { - min-width: 0; - font-family: var(--font-mono); - font-weight: 500; - color: var(--text-strong); - overflow-wrap: anywhere; - } - .node-meta { - margin-top: 6px; - color: var(--text-3); - font-size: 12px; - } - .file { - font-family: var(--font-mono); - font-size: 11.5px; - overflow-wrap: anywhere; - } - .detail { - margin: 6px 0 0; - color: var(--text-2); - font-size: 12px; - line-height: 1.5; - overflow-wrap: anywhere; - } - .spinner { - width: 18px; - height: 18px; - border: 2px solid var(--border-strong); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - } - @media (prefers-reduced-motion: reduce) { - .spinner, - .status.on .live-dot { - animation: none; - } - } - @media (max-width: 760px) { - .live-layout, - .log-layout { - grid-template-columns: minmax(0, 1fr); - } - .log-layout > app-splitter { - display: none; - } - .store-list { - border-right: none; - border-bottom: 1px solid var(--border); - } - } - @media (max-width: 480px) { - .toolbar { - padding: 8px; - } - .toolbar input, - .page-select { - flex-basis: 100%; - } - .store-detail { - padding: 12px; - } - .kv { - grid-template-columns: minmax(0, 1fr); - } - } - `, + imports: [SearchField, Select, StoreEvents, StoreLive, StoreSource], + providers: [StoreState, StoreActions], + templateUrl: './store-inspector.html', + styleUrl: './store-inspector.scss', }) export class StoreInspector { readonly rpc = input(null); - readonly canRestore = computed(() => actionAllowed(this.rpc(), 'ngrx')); - protected readonly restoreOff = actionBlockedMessage('ngrx'); - readonly filter = signal(''); - /** The change list width on wide panels, set by the splitter and saved. */ - readonly logListSize = signal(storedPaneSize('store-log', 300)); - readonly kind = signal(null); - readonly sourceEntries = signal([]); - readonly sourceLoaded = signal(false); - readonly pages = signal([]); - /** Pages with stores that report through the Redux DevTools API (`redux-stores` shared state). */ - readonly reduxPages = signal([]); - /** The NgRx pages, plus pages that only report other stores. */ - readonly allPages = computed(() => { - const ngrx = this.pages(); - const known = new Set(ngrx.map((p) => p.pageId)); - const others = this.reduxPages() - .filter((p) => !known.has(p.pageId)) - .map((p) => ({ - pageId: p.pageId, - url: p.url, - title: p.title, - stores: [], - classic: null, - log: [], - reportedAt: p.reportedAt, - })); - return [...ngrx, ...others]; - }); - readonly failed = signal(false); - /** Whether the shared state has arrived, so the empty message doesn't show first. */ - readonly stateLoaded = signal(false); - readonly selectedPageId = signal(null); - private readonly hostPageId = hostPageId(); - private readonly angularPages = injectAngularPages(() => this.rpc()); - /** The shown page's Angular version and debug API parts, for the unavailable note. */ - readonly angularPage = computed(() => - angularPageFor(this.angularPages(), this.page()?.pageId ?? this.hostPageId), - ); - readonly maxLog = computed(() => panelConfig(this.rpc()).limits.changeLog); - readonly selectedStoreId = signal(null); - private readonly focusEventSeq = signal(null); - private readonly eventDetailHeading = viewChild>('eventDetailHeading'); - readonly busy = signal(false); - private readonly focusLatest = signal(false); - private readonly focusConfirm = signal<'cancel' | 'restore' | null>(null); - private readonly cancelButton = viewChild>('cancelButton'); - private readonly restoreButton = viewChild>('restoreButton'); - private readonly stateTree = viewChild(StoreStateTree); - private readonly resetButton = viewChild>('resetButton'); - private readonly resetCancel = viewChild>('resetCancel'); - /** The reset confirm is open; it closes when another store is shown. */ - readonly confirmReset = linkedSignal({ - source: () => this.store()?.id, - computation: () => false, - }); - private readonly focusReset = signal<'cancel' | 'reset' | null>(null); - /** How many effect dispatches within a second flag a loop (`NGRX_LOOP_BURST`). */ - protected readonly loopBurst = 20; + private readonly state = inject(StoreState); + private readonly actions = inject(StoreActions); private readonly filterInput = viewChild('filterInput'); - private readonly latestButton = viewChild>('latestButton'); - readonly message = signal(''); - - private readonly destroyRef = inject(DestroyRef); - private unsubscribe: (() => void) | null = null; - private unsubscribeOthers: (() => void) | null = null; - - readonly page = computed(() => { - const pages = this.allPages(); - return ( - pages.find((p) => p.pageId === this.selectedPageId()) ?? - pages.find((p) => p.pageId === this.hostPageId) ?? - pages[0] ?? - null - ); - }); - - /** A page is connected, with stores or without: every page reports its Angular version. */ - readonly pageConnected = computed(() => !!this.page() || !!this.angularPage()); - - /** The other stores of the shown page. */ - readonly reduxPage = computed( - () => this.reduxPages().find((p) => p.pageId === this.page()?.pageId) ?? null, - ); - - private readonly logKey = computed(() => { - const page = this.page(); - const store = this.store(); - // Another store's log has its own seq, so it is its own session. - if (store?.redux) { - return { pageId: `${page?.pageId}/${store.id}`, log: reduxLog(this.reduxPage(), store.id) }; - } - return { pageId: page?.pageId ?? null, log: page?.log ?? [] }; - }); - - /** - * Counts the change logs this view has shown. It moves on when `page()` switches to another - * page (including the fallback when the chosen page closes) and when the app reloads, which - * keeps the `pageId` but restarts `seq` at 1, so a seq picked before means another entry now. - */ - private readonly logSession = linkedSignal({ - source: this.logKey, - computation: (key, previous) => - !previous ? 0 : sameNgrxLog(previous.source, key) ? previous.value : previous.value + 1, - }); - - // Two selection cursors so clicking an event never swaps out the per-store - // change-log detail: the change log keeps its own highlighted row, the - // Events section keeps its own, and each shows its detail in-place. - // All three are seqs, so they reset whenever the change log is another one. - readonly selectedChangeSeq = linkedSignal({ - source: this.logSession, - computation: () => null, - }); - readonly selectedEventSeq = linkedSignal({ - source: this.logSession, - computation: () => null, - }); - readonly confirmSeq = linkedSignal({ - source: this.logSession, - computation: () => null, - }); - - readonly pageOptions = computed(() => - this.allPages().map((p) => ({ value: p.pageId, label: p.title || p.url, hint: p.url })), - ); - - readonly liveStores = computed(() => { - const page = this.page(); - if (!page) return []; - const stores: LiveStore[] = page.stores.map((info) => ({ - id: info.id, - label: info.name ?? info.references[0] ?? info.className, - kind: info.kind, - scope: info.scope, - signal: info, - })); - if (page.classic) { - stores.push({ - id: 'store', - label: 'Store', - kind: 'store', - scope: page.classic.scope, - classic: page.classic, - }); - } - stores.push(...reduxLiveStores(this.reduxPage())); - return stores; - }); - - readonly filteredStores = computed(() => { - const f = this.filter().trim().toLowerCase(); - if (!f) return this.liveStores(); - return this.liveStores().filter( - (s) => - s.label.toLowerCase().includes(f) || - s.kind.includes(f) || - (s.signal?.stateKeys ?? []).some((k) => k.toLowerCase().includes(f)) || - this.logOf(s).some((e) => e.type.toLowerCase().includes(f)), - ); - }); - - readonly store = computed(() => { - const stores = this.liveStores(); - return stores.find((s) => s.id === this.selectedStoreId()) ?? stores[0] ?? null; - }); - - readonly stateText = computed(() => { - const store = this.store(); - if (!store) return ''; - return pretty( - store.signal ? store.signal.state : store.redux ? store.redux.state : store.classic?.state, - ); - }); - - /** The serialized state of the selected store, for the state tree. */ - readonly stateValue = computed(() => { - const store = this.store(); - if (!store) return undefined; - return store.signal - ? store.signal.state - : store.redux - ? store.redux.state - : store.classic?.state; - }); - - /** The paths the newest change of the selected store wrote, marked in the state tree. */ - readonly changedPaths = computed(() => { - const store = this.store(); - const log = store ? this.logOf(store) : []; - let newest: NgrxLogEntry | undefined; - for (let i = log.length - 1; i >= 0 && !newest; i--) if (log[i].diff.length) newest = log[i]; - return (newest?.diff ?? []).map((d) => d.path); - }); - - /** The selected store's action stats, most frequent first. */ - readonly storeStats = computed(() => { - const store = this.store(); - if (!store || store.redux) return []; - const stats = this.page()?.stats; - return Array.isArray(stats) ? stats.filter((s) => s.storeId === store.id) : []; - }); - readonly hasLoop = computed(() => this.storeStats().some((s) => s.loop)); - - private readonly skippedSeqs = computed( - () => - new Set(Array.isArray(this.page()?.classic?.skipped) ? this.page()!.classic!.skipped : []), - ); - - readonly computedEntries = computed(() => - Object.entries(this.store()?.signal?.computed ?? {}).map(([key, value]) => ({ - key, - value: short(value, 160), - full: pretty(value), - })), - ); - - /** Pause holds the selected store's log as it is while the app keeps changing it. */ - readonly logFeed = new LiveFeed({ - name: 'store-log', - source: () => { - const store = this.store(); - return store ? this.logOf(store) : []; - }, - key: (entry) => entry.seq, - newest: 'last', - scope: () => `${this.store()?.id ?? ''}:${this.logSession()}`, - }); - - readonly storeLog = computed(() => { - const f = this.filter().trim().toLowerCase(); - return this.logFeed - .items() - .filter((e) => !f || e.type.toLowerCase().includes(f) || this.storeMatches(f)) - .slice() - .reverse(); - }); - - /** The selected store's state changes, oldest first, for the session tools. */ - readonly sessionEntries = computed(() => { - const store = this.store(); - // Sessions and generated tests are NgRx only: other stores have no past states to read. - if (!store || store.redux) return []; - return (this.page()?.log ?? []).filter((e) => e.storeId === store.id && e.source !== 'event'); - }); - - /** Reads the State and full Diff of a past entry from the shown page. */ - protected readonly loadEntry = async (seq: number): Promise => { - const pageId = this.page()?.pageId; - if (!pageId) return { error: 'No live page to read from.' }; - return ((await call(this.rpc(), 'request-ngrx-history', { - pageId, - request: { type: 'entry', seq }, - })) ?? { error: 'The devtools server is not connected.' }) as EntryDetailAnswer; - }; - - /** A store's log entries, oldest first. */ - private logOf(store: LiveStore): NgrxLogEntry[] { - if (store.redux) return reduxLog(this.reduxPage(), store.id); - return (this.page()?.log ?? []).filter((e) => e.storeId === store.id); - } - - readonly hasEvents = computed(() => (this.page()?.log ?? []).some((e) => e.source === 'event')); - - readonly pageEvents = computed(() => { - const f = this.filter().trim().toLowerCase(); - return (this.page()?.log ?? []) - .filter((e) => e.source === 'event' && (!f || e.type.toLowerCase().includes(f))) - .slice() - .reverse(); - }); - - /** Long logs render only the entries near view in their scroll box. */ - readonly logWindow = new VirtualWindow({ - count: computed(() => this.storeLog().length), - estimate: 56, - }); - readonly shownLog = computed(() => slice(this.storeLog(), this.logWindow)); - readonly eventWindow = new VirtualWindow({ - count: computed(() => this.pageEvents().length), - estimate: 36, - }); - readonly shownEvents = computed(() => slice(this.pageEvents(), this.eventWindow)); - private readonly logList = viewChild>('logList'); - private readonly eventsList = viewChild>('eventsList'); - - readonly changeEntry = computed(() => { - const seq = this.selectedChangeSeq(); - if (seq === null) return null; - return this.storeLog().find((e) => e.seq === seq) ?? null; - }); - - readonly eventEntry = computed(() => { - const seq = this.selectedEventSeq(); - if (seq === null) return null; - return this.pageEvents().find((e) => e.seq === seq) ?? null; - }); - - readonly dispatchType = signal(''); - readonly dispatchPayload = signal(''); - - readonly actionTypes = computed(() => { - const types = new Set(); - for (const entry of this.sourceEntries()) for (const type of entry.types ?? []) types.add(type); - for (const entry of this.page()?.log ?? []) { - if (entry.source === 'store' && entry.action !== undefined) types.add(entry.type); - } - return [...types].sort().slice(0, 200); - }); - readonly payloadError = computed(() => parsePayload(this.dispatchPayload()).error ?? ''); - - readonly dispatchRequest = computed(() => { - const type = this.dispatchType().trim(); - const { payload, error } = parsePayload(this.dispatchPayload()); - if (!type || error) return null; - return { type: 'dispatch' as const, action: type, ...(payload ? { payload } : {}) }; - }); - - readonly usesSignals = computed(() => - this.sourceEntries().some((e) => e.kind === 'signal-store' || e.kind === 'signal-state'), - ); - readonly usesClassic = computed(() => - this.sourceEntries().some((e) => CLASSIC_KINDS.has(e.kind)), - ); - - readonly filteredEntries = computed(() => { - const f = this.filter().trim().toLowerCase(); - const kind = this.kind(); - return this.sourceEntries().filter( - (e) => - (!kind || e.kind === kind) && - (!f || - e.name.toLowerCase().includes(f) || - e.kind.includes(f) || - (e.detail ?? '').toLowerCase().includes(f)), - ); - }); - - readonly groupedEntries = computed(() => { - const groups = new Map(); - for (const e of this.sourceEntries()) groups.set(e.kind, (groups.get(e.kind) ?? 0) + 1); - return [...groups.entries()].map(([kind, count]) => ({ kind, count })); - }); + readonly canRestore = this.state.canRestore; + readonly filter = this.state.filter; + readonly kind = this.state.kind; + readonly sourceEntries = this.state.sourceEntries; + readonly sourceLoaded = this.state.sourceLoaded; + readonly pages = this.state.pages; + readonly reduxPages = this.state.reduxPages; + readonly allPages = this.state.allPages; + readonly failed = this.state.failed; + readonly stateLoaded = this.state.stateLoaded; + readonly selectedPageId = this.state.selectedPageId; + readonly angularPage = this.state.angularPage; + readonly maxLog = this.state.maxLog; + readonly selectedStoreId = this.state.selectedStoreId; + readonly message = this.state.message; + readonly page = this.state.page; + readonly pageConnected = this.state.pageConnected; + readonly reduxPage = this.state.reduxPage; + readonly selectedChangeSeq = this.state.selectedChangeSeq; + readonly selectedEventSeq = this.state.selectedEventSeq; + readonly confirmSeq = this.state.confirmSeq; + readonly pageOptions = this.state.pageOptions; + readonly liveStores = this.state.liveStores; + readonly filteredStores = this.state.filteredStores; + readonly store = this.state.store; + readonly stateText = this.state.stateText; + readonly stateValue = this.state.stateValue; + readonly changedPaths = this.state.changedPaths; + readonly storeStats = this.state.storeStats; + readonly hasLoop = this.state.hasLoop; + readonly computedEntries = this.state.computedEntries; + readonly logFeed = this.state.logFeed; + readonly storeLog = this.state.storeLog; + readonly sessionEntries = this.state.sessionEntries; + readonly hasEvents = this.state.hasEvents; + readonly pageEvents = this.state.pageEvents; + readonly changeEntry = this.state.changeEntry; + readonly eventEntry = this.state.eventEntry; + readonly actionTypes = this.state.actionTypes; + readonly usesSignals = this.state.usesSignals; + readonly usesClassic = this.state.usesClassic; + readonly filteredEntries = this.state.filteredEntries; + readonly groupedEntries = this.state.groupedEntries; + + readonly busy = this.actions.busy; + readonly confirmReset = this.actions.confirmReset; + readonly dispatchType = this.actions.dispatchType; + readonly dispatchPayload = this.actions.dispatchPayload; + readonly payloadError = this.actions.payloadError; + readonly dispatchRequest = this.actions.dispatchRequest; constructor() { + this.state.connect(this.rpc); + this.state.focusFilter = () => this.filterInput()?.focus(); rememberSelection('store', this.selectedStoreId); - // Newest first, so following the newest change keeps the log scrolled to the top. - followNewest(this.logFeed, () => { - const list = this.logList()?.nativeElement; - if (list) list.scrollTop = 0; - }); - effect(() => { - const log = this.logList()?.nativeElement; - if (log) this.logWindow.attach(log); - }); - effect(() => { - const events = this.eventsList()?.nativeElement; - if (events) this.eventWindow.attach(events); - }); effect(() => { const client = this.rpc(); - if (client) void this.load(client); - }); - afterRenderEffect(() => { - const button = this.latestButton(); - if (!button || !this.focusLatest() || this.busy()) return; - this.focusLatest.set(false); - button.nativeElement.focus(); - }); - effect(() => { - const target = this.focusConfirm(); - const button = target === 'cancel' ? this.cancelButton() : this.restoreButton(); - if (!target || !button) return; - this.focusConfirm.set(null); - button.nativeElement.focus(); - }); - afterRenderEffect(() => { - const target = this.focusReset(); - const button = target === 'cancel' ? this.resetCancel() : this.resetButton(); - if (!target || !button) return; - this.focusReset.set(null); - button.nativeElement.focus(); - }); - afterRenderEffect(() => { - const seq = this.focusEventSeq(); - const detail = this.eventDetailHeading(); - if (seq === null || !detail) return; - this.focusEventSeq.set(null); - detail.nativeElement.focus(); - }); - this.destroyRef.onDestroy(() => { - this.unsubscribe?.(); - this.unsubscribeOthers?.(); + if (client) void this.state.load(client); }); } - private storeMatches(f: string): boolean { - const store = this.store(); - return !!store && store.label.toLowerCase().includes(f); - } - - async load(client: DevframeRpcClient) { - this.failed.set(false); - call(client, 'get-ngrx-store') - .then((entries) => this.sourceEntries.set(Array.isArray(entries) ? entries : [])) - .catch(() => this.sourceEntries.set([])) - .finally(() => this.sourceLoaded.set(true)); - try { - const state = await client.scope('pangular').rpc.sharedState('ngrx-store'); - if (this.destroyRef.destroyed) return; - const apply = (value: unknown) => { - const next = value as Partial | undefined; - this.pages.set(Array.isArray(next?.pages) ? next.pages : []); - // A state the client hasn't loaded from the server yet says nothing. - if (value !== undefined) this.stateLoaded.set(true); - }; - apply(state.value()); - this.unsubscribe?.(); - this.unsubscribe = state.on('updated', apply); - } catch { - this.failed.set(true); - } - // Other stores are an addition: a server without them leaves the NgRx view as it is. - try { - const others = await client.scope('pangular').rpc.sharedState('redux-stores'); - if (this.destroyRef.destroyed) return; - const applyOthers = (value: unknown) => this.reduxPages.set(reduxPagesOf(value)); - applyOthers(others.value()); - this.unsubscribeOthers?.(); - this.unsubscribeOthers = others.on('updated', applyOthers); - } catch { - this.reduxPages.set([]); - } + load(client: DevframeRpcClient) { + return this.state.load(client); } retry() { - const client = this.rpc(); - if (client) void this.load(client); + this.state.retry(); } selectPage(pageId: string | null) { - this.selectedPageId.set(pageId); - this.selectedStoreId.set(null); - this.selectedChangeSeq.set(null); - this.selectedEventSeq.set(null); - this.confirmSeq.set(null); - this.message.set(''); + this.state.selectPage(pageId); } selectStore(id: string) { - this.selectedStoreId.set(id); - this.selectedChangeSeq.set(null); - this.confirmSeq.set(null); - this.message.set(''); + this.state.selectStore(id); } selectChange(seq: number) { - this.selectedChangeSeq.set(this.selectedChangeSeq() === seq ? null : seq); - this.confirmSeq.set(null); - this.message.set(''); + this.state.selectChange(seq); + } + + selectEvent(seq: number) { + this.actions.selectEvent(seq); } copyState() { - return this.copyText(this.stateText()); + return this.state.copyState(); } copyPath(path: string) { - return this.copyText(path, `Copied the path ${path}.`); + return this.state.copyPath(path); } copyDiff(entry: NgrxLogEntry) { - return this.copyText(pretty(entry.diff)); - } - - private async copyText(text: string, done = 'Copied.') { - try { - await navigator.clipboard.writeText(text); - this.message.set(done); - } catch { - this.message.set('The clipboard is not available here.'); - } + return this.state.copyDiff(entry); } askRestore(seq: number) { - this.confirmSeq.set(seq); - this.focusConfirm.set('cancel'); + this.actions.askRestore(seq); } cancelRestore(event?: Event) { - event?.preventDefault(); - this.confirmSeq.set(null); - this.focusConfirm.set('restore'); + this.actions.cancelRestore(event); } - async restore(seq: number, pauses: boolean) { - const page = this.page(); - if (!page) return; - this.busy.set(true); - this.focusLatest.set(false); - let paused = false; - try { - const result = (await call(this.rpc(), 'request-ngrx-action', { - pageId: page.pageId, - request: { type: 'restore', seq }, - })) as { ok?: boolean; message?: string; error?: string; paused?: boolean } | null; - this.message.set(result?.error ?? result?.message ?? 'Restored.'); - paused = pauses && !result?.error && !!result?.paused; - } catch { - this.message.set('Could not reach the page to restore the state.'); - } finally { - this.busy.set(false); - this.confirmSeq.set(null); - this.focusLatest.set(paused); - if (!paused) this.stateTree()?.focus(); - } + restore(seq: number, pauses: boolean) { + return this.actions.restore(seq, pauses); } - selectEvent(seq: number) { - const newSeq = this.selectedEventSeq() === seq ? null : seq; - this.selectedEventSeq.set(newSeq); - if (newSeq !== null) this.focusEventSeq.set(newSeq); - } - - async backToLatest() { - const page = this.page(); - if (!page) return; - this.busy.set(true); - try { - const result = (await call(this.rpc(), 'request-ngrx-action', { - pageId: page.pageId, - request: { type: 'latest' }, - })) as { ok?: boolean; message?: string; error?: string } | null; - this.focusLatest.set(false); - this.stateTree()?.focus(); - this.message.set(result?.error ?? result?.message ?? 'Back on the latest state.'); - } catch { - this.message.set('Could not reach the page to go back to the latest state.'); - } finally { - this.busy.set(false); - } + backToLatest() { + return this.actions.backToLatest(); } isSkipped(seq: number): boolean { - return this.skippedSeqs().has(seq); + return this.state.isSkipped(seq); } - /** An @ngrx/store action Store DevTools still holds, or one it skips, can be skipped or included. */ canToggle(entry: NgrxLogEntry): boolean { - return ( - entry.source === 'store' && - entry.action !== undefined && - !!this.page()?.classic?.devtools && - (entry.restorable || this.isSkipped(entry.seq)) - ); + return this.state.canToggle(entry); } - traceFrames(trace: string[]) { - return trace.map(traceFrame); + canAgain(entry: NgrxLogEntry): boolean { + return this.state.canAgain(entry); } askReset() { - this.confirmReset.set(true); - this.focusReset.set('cancel'); + this.actions.askReset(); } cancelReset(event?: Event) { - event?.preventDefault(); - this.confirmReset.set(false); - this.focusReset.set('reset'); + this.actions.cancelReset(event); } - /** Runs a Store DevTools history operation on the shown page. */ - async devtoolsOp(request: { op: string; seq?: number; index?: number; paused?: boolean }) { - const page = this.page(); - if (!page || this.busy()) return; - const resetting = request.op === 'reset'; - this.busy.set(true); - try { - const result = (await call(this.rpc(), 'request-ngrx-action', { - pageId: page.pageId, - request: { type: 'devtools', ...request }, - })) as { ok?: boolean; message?: string; error?: string } | null; - this.message.set(result?.error ?? result?.message ?? 'Done.'); - } catch { - this.message.set('Could not reach the page to change the Store DevTools history.'); - } finally { - this.busy.set(false); - if (resetting) { - this.confirmReset.set(false); - this.focusReset.set('reset'); - } - } - } - - canAgain(entry: NgrxLogEntry): boolean { - return entry.source === 'store' && entry.action !== undefined; + devtoolsOp(request: { op: string; seq?: number; index?: number; paused?: boolean }) { + return this.actions.devtoolsOp(request); } - async dispatchAction(event: Event) { - event.preventDefault(); - const request = this.dispatchRequest(); - if (request) await this.send(request, 'Could not reach the page to dispatch the action.'); + dispatchAction(event: Event) { + return this.actions.dispatchAction(event); } dispatchAgain(seq: number) { - return this.send( - { type: 'dispatch-again', seq }, - 'Could not reach the page to dispatch the action again.', - ); + return this.actions.dispatchAgain(seq); } - private async send(request: Record, offline: string) { - const page = this.page(); - if (!page || this.busy()) return; - const session = this.logSession(); - this.busy.set(true); - try { - const result = (await call(this.rpc(), 'request-ngrx-action', { - pageId: page.pageId, - request, - })) as { ok?: boolean; message?: string; error?: string; entry?: NgrxLogEntry } | null; - this.message.set(result?.error ?? result?.message ?? 'Dispatched.'); - if (result?.entry && this.logSession() === session) { - this.selectedStoreId.set('store'); - this.selectedChangeSeq.set(result.entry.seq); - } - } catch { - this.message.set(offline); - } finally { - this.busy.set(false); - } - } - - /** Clears the filters from a no-match state and keeps focus on the filter box. */ clearFilters() { - this.filter.set(''); - this.kind.set(null); - this.filterInput()?.focus(); - } - - /** - * The row's log count. A Redux DevTools store logs what its library sent, - * `@@INIT` included, so its entries are actions, not changes. - */ - countText(item: LiveStore): string { - const n = this.changeCount(item.id); - const noun = item.redux ? (n === 1 ? 'action' : 'actions') : n === 1 ? 'change' : 'changes'; - return `${n} ${noun}`; + this.state.clearFilters(); } changeCount(id: string): number { - const store = this.liveStores().find((s) => s.id === id); - return store ? this.logOf(store).length : 0; - } - - entityIds(ids: (string | number)[]): (string | number)[] { - return ids.slice(0, 30); - } - - entityOverflowTitle(ids: (string | number)[], count: number): string { - const overflow = ids.slice(30).join(', '); - return count > ids.length ? `${overflow} (first ${ids.length} of ${count} shown)` : overflow; + return this.state.changeCount(id); } - - kindColor(kind: string) { - return KIND_COLORS[kind] ?? 'var(--text-3)'; - } - - kindLabel(kind: string) { - return KIND_LABELS[kind] ?? kind; - } - - originText(origin: NgrxActionOrigin) { - return ORIGIN_TEXT[origin] ?? origin; - } - - readonly changesLabel = changeCountText; - - readonly moreChanges = moreChangesText; - - opLabel(op: string) { - return op === 'add' ? 'added' : op === 'remove' ? 'removed' : 'changed'; - } - - prettyText(value: unknown) { - return pretty(value); - } - - shortText(value: unknown) { - return short(value); - } - - readonly formatTime = time; - - formatDuration(ms: number): string { - return ms <= 0 ? '<1ms' : `${Math.round(ms)}ms`; - } - - methodDuration(method: { lastDurationMs?: number; avgDurationMs?: number }): string { - const parts: string[] = []; - if (method.avgDurationMs !== undefined) - parts.push(`avg ${this.formatDuration(method.avgDurationMs)}`); - if (method.lastDurationMs !== undefined) - parts.push(`last ${this.formatDuration(method.lastDurationMs)}`); - return parts.length ? ` · ${parts.join(' · ')}` : ''; - } -} - -/** The entries of `list` the window renders, with their index in the whole list. */ -function slice(list: T[], window: VirtualWindow) { - const { start, end } = window.range(); - return list.slice(start, end).map((entry, i) => ({ entry, index: start + i })); } diff --git a/app/src/pages/store/store-actions.ts b/app/src/pages/store/store-actions.ts new file mode 100644 index 00000000..30db0b06 --- /dev/null +++ b/app/src/pages/store/store-actions.ts @@ -0,0 +1,170 @@ +import { Injectable, computed, inject, linkedSignal, signal } from '@angular/core'; +import { rpcCall as call } from '../../rpc'; +import type { NgrxLogEntry } from '../store-types'; +import { parsePayload } from './store-format'; +import { StoreState } from './store-state'; + +/** + * The writes the Store page sends to the live page (restore, back to latest, the Store DevTools + * history and dispatch), with the busy flag and the focus moves they ask of the sections. + * Provided by the page next to {@link StoreState}. + */ +@Injectable() +export class StoreActions { + private readonly state = inject(StoreState); + + readonly busy = signal(false); + /** Focus requests the sections carry out once the element they name is rendered. */ + readonly focusLatest = signal(false); + readonly focusConfirm = signal<'cancel' | 'restore' | null>(null); + readonly focusReset = signal<'cancel' | 'reset' | null>(null); + readonly focusEventSeq = signal(null); + /** Focuses the state tree of the shown store; the store detail sets it. */ + focusStateTree: () => void = () => {}; + + /** The reset confirm is open; it closes when another store is shown. */ + readonly confirmReset = linkedSignal({ + source: () => this.state.store()?.id, + computation: () => false, + }); + + readonly dispatchType = signal(''); + readonly dispatchPayload = signal(''); + + readonly payloadError = computed(() => parsePayload(this.dispatchPayload()).error ?? ''); + + readonly dispatchRequest = computed(() => { + const type = this.dispatchType().trim(); + const { payload, error } = parsePayload(this.dispatchPayload()); + if (!type || error) return null; + return { type: 'dispatch' as const, action: type, ...(payload ? { payload } : {}) }; + }); + + askRestore(seq: number) { + this.state.confirmSeq.set(seq); + this.focusConfirm.set('cancel'); + } + + cancelRestore(event?: Event) { + event?.preventDefault(); + this.state.confirmSeq.set(null); + this.focusConfirm.set('restore'); + } + + async restore(seq: number, pauses: boolean) { + const page = this.state.page(); + if (!page) return; + this.busy.set(true); + this.focusLatest.set(false); + let paused = false; + try { + const result = (await call(this.state.rpc(), 'request-ngrx-action', { + pageId: page.pageId, + request: { type: 'restore', seq }, + })) as { ok?: boolean; message?: string; error?: string; paused?: boolean } | null; + this.state.message.set(result?.error ?? result?.message ?? 'Restored.'); + paused = pauses && !result?.error && !!result?.paused; + } catch { + this.state.message.set('Could not reach the page to restore the state.'); + } finally { + this.busy.set(false); + this.state.confirmSeq.set(null); + this.focusLatest.set(paused); + if (!paused) this.focusStateTree(); + } + } + + selectEvent(seq: number) { + const newSeq = this.state.selectedEventSeq() === seq ? null : seq; + this.state.selectedEventSeq.set(newSeq); + if (newSeq !== null) this.focusEventSeq.set(newSeq); + } + + async backToLatest() { + const page = this.state.page(); + if (!page) return; + this.busy.set(true); + try { + const result = (await call(this.state.rpc(), 'request-ngrx-action', { + pageId: page.pageId, + request: { type: 'latest' }, + })) as { ok?: boolean; message?: string; error?: string } | null; + this.focusLatest.set(false); + this.focusStateTree(); + this.state.message.set(result?.error ?? result?.message ?? 'Back on the latest state.'); + } catch { + this.state.message.set('Could not reach the page to go back to the latest state.'); + } finally { + this.busy.set(false); + } + } + + askReset() { + this.confirmReset.set(true); + this.focusReset.set('cancel'); + } + + cancelReset(event?: Event) { + event?.preventDefault(); + this.confirmReset.set(false); + this.focusReset.set('reset'); + } + + /** Runs a Store DevTools history operation on the shown page. */ + async devtoolsOp(request: { op: string; seq?: number; index?: number; paused?: boolean }) { + const page = this.state.page(); + if (!page || this.busy()) return; + const resetting = request.op === 'reset'; + this.busy.set(true); + try { + const result = (await call(this.state.rpc(), 'request-ngrx-action', { + pageId: page.pageId, + request: { type: 'devtools', ...request }, + })) as { ok?: boolean; message?: string; error?: string } | null; + this.state.message.set(result?.error ?? result?.message ?? 'Done.'); + } catch { + this.state.message.set('Could not reach the page to change the Store DevTools history.'); + } finally { + this.busy.set(false); + if (resetting) { + this.confirmReset.set(false); + this.focusReset.set('reset'); + } + } + } + + async dispatchAction(event: Event) { + event.preventDefault(); + const request = this.dispatchRequest(); + if (request) await this.send(request, 'Could not reach the page to dispatch the action.'); + } + + dispatchAgain(seq: number) { + return this.send( + { type: 'dispatch-again', seq }, + 'Could not reach the page to dispatch the action again.', + ); + } + + private async send(request: Record, offline: string) { + const page = this.state.page(); + if (!page || this.busy()) return; + const session = this.state.logSession(); + this.busy.set(true); + try { + const result = (await call(this.state.rpc(), 'request-ngrx-action', { + pageId: page.pageId, + request, + })) as { ok?: boolean; message?: string; error?: string; entry?: NgrxLogEntry } | null; + this.state.message.set(result?.error ?? result?.message ?? 'Dispatched.'); + if (result?.entry && this.state.logSession() === session) { + this.state.selectedStoreId.set('store'); + this.state.selectedChangeSeq.set(result.entry.seq); + } + } catch { + this.state.message.set(offline); + } finally { + this.busy.set(false); + } + } +} diff --git a/app/src/pages/store/store-detail.html b/app/src/pages/store/store-detail.html new file mode 100644 index 00000000..89d370ba --- /dev/null +++ b/app/src/pages/store/store-detail.html @@ -0,0 +1,194 @@ +@if (store(); as current) { +
        +

        {{ current.label }}

        +
        + {{ kindLabel(current.kind) }} + @if (current.redux; as other) { + {{ other.library }} + } @else { + scope: {{ current.scope }} + } + @if (current.signal?.declaredIn; as file) { + + } + @if (current.classic) { + {{ + current.classic.devtools ? 'Store DevTools on' : 'Store DevTools off' + }} + } + @if (current.signal?.watchOnly) { + withDevtools: watched only + } +
        + @if (current.signal?.watchOnly) { +

        + This store uses withDevtools(), so the inspector does not wrap its methods or + signals. Changes are logged as patchState entries, and method calls are not + counted. +

        + } + @if (current.signal?.references?.length) { +

        + Referenced by + @for (ref of current.signal!.references; track ref; let last = $last) { + {{ ref }}{{ last ? '' : ', ' }} + } +

        + } +
        + + @if (current.classic?.paused) { +
        +

        + Viewing a past state. New actions are logged but do not change the state + until you go back to the latest state. +

        + + @if (!canRestore()) { +

        {{ restoreOff }}

        + } +
        + } + +
        +
        +
        +

        State

        +
        + @if (!current.redux) { + + } + +
        +
        + +
        + @if (current.signal; as info) { +
        +

        Computed

        + @if (computedEntries().length) { +
        + @for (entry of computedEntries(); track entry.key) { +
        {{ entry.key }}
        +
        + {{ entry.value }} +
        + } +
        + } @else { +

        No computed signals.

        + } +

        Methods

        + @if (info.methods.length) { +
          + @for (method of info.methods; track method.name) { +
        • + {{ method.name }} + @if (method.signalMethod) { + signalMethod + } @else if (method.rx) { + rxMethod + } + {{ method.calls }} {{ method.calls === 1 ? 'call' : 'calls' + }}{{ methodDuration(method) }} +
        • + } +
        + } @else { +

        No methods.

        + } +
        + @if (info.entities?.length) { +
        +

        Entities

        +
        + @for (col of info.entities!; track col.idsKey) { +
        +
        + {{ col.collection ?? 'entities' }} + {{ col.count }} {{ col.count === 1 ? 'entity' : 'entities' }} +
        +
          + @for (id of entityIds(col.ids); track id) { +
        • {{ id }}
        • + } + @if (col.count > 30) { +
        • + +{{ col.count - 30 }} more +
        • + } +
        + @if (col.selectedId !== undefined) { +
        +
        Selected
        +
        + @if (col.selected !== undefined) { + {{ + shortText(col.selected) + }} + } @else { + None selected + } +
        +
        + } +
        + } +
        +
        + } + } +
        + + @if (current.classic?.devtools) { +
        + } + +
        + + @if (storeStats().length) { +
        + } + + @if (!current.redux) { + + } + + @if (current.classic) { +
        + } +} diff --git a/app/src/pages/store/store-detail.scss b/app/src/pages/store/store-detail.scss new file mode 100644 index 00000000..225eebdb --- /dev/null +++ b/app/src/pages/store/store-detail.scss @@ -0,0 +1,16 @@ +@use 'store'; + +@include store.base; +@include store.lists; +@include store.detail; +@include store.code; +@include store.methods; +@include store.log; +@include store.dispatch; +@include store.history-stats; +@include store.paused; +@media (max-width: 480px) { + .kv { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/app/src/pages/store/store-detail.ts b/app/src/pages/store/store-detail.ts new file mode 100644 index 00000000..cf82d6ae --- /dev/null +++ b/app/src/pages/store/store-detail.ts @@ -0,0 +1,92 @@ +import { Component, ElementRef, afterRenderEffect, inject, viewChild } from '@angular/core'; +import { SourceLink } from '../../ui/source-link'; +import { StoreGlobal } from '../../ui/store-global'; +import { StoreSession } from '../store-session'; +import { StoreStateTree } from '../store-state-tree'; +import { pretty, short } from '../store-types'; +import { StoreActions } from './store-actions'; +import { StoreDispatch } from './store-dispatch'; +import { kindLabel, methodDuration } from './store-format'; +import { StoreHistory } from './store-history'; +import { StoreLog } from './store-log'; +import { StoreState } from './store-state'; +import { StoreStats } from './store-stats'; + +/** + * The shown store of the Store page: its head, the paused note, State, Computed, Methods and + * Entities, then the history, log, stats, session and dispatch sections. + */ +@Component({ + selector: 'div[appStoreDetail]', + imports: [ + SourceLink, + StoreDispatch, + StoreGlobal, + StoreHistory, + StoreLog, + StoreSession, + StoreStateTree, + StoreStats, + ], + templateUrl: './store-detail.html', + styleUrl: './store-detail.scss', +}) +export class StoreDetail { + private readonly state = inject(StoreState); + private readonly actions = inject(StoreActions); + private readonly latestButton = viewChild>('latestButton'); + private readonly stateTree = viewChild(StoreStateTree); + + protected readonly rpc = this.state.rpc; + protected readonly page = this.state.page; + protected readonly store = this.state.store; + protected readonly canRestore = this.state.canRestore; + protected readonly restoreOff = this.state.restoreOff; + protected readonly stateValue = this.state.stateValue; + protected readonly changedPaths = this.state.changedPaths; + protected readonly computedEntries = this.state.computedEntries; + protected readonly storeStats = this.state.storeStats; + protected readonly sessionEntries = this.state.sessionEntries; + protected readonly busy = this.actions.busy; + protected readonly kindLabel = kindLabel; + protected readonly methodDuration = methodDuration; + + constructor() { + this.actions.focusStateTree = () => this.stateTree()?.focus(); + afterRenderEffect(() => { + const button = this.latestButton(); + if (!button || !this.actions.focusLatest() || this.busy()) return; + this.actions.focusLatest.set(false); + button.nativeElement.focus(); + }); + } + + protected backToLatest() { + return this.actions.backToLatest(); + } + + protected copyState() { + return this.state.copyState(); + } + + protected copyPath(path: string) { + return this.state.copyPath(path); + } + + protected entityIds(ids: (string | number)[]): (string | number)[] { + return ids.slice(0, 30); + } + + protected entityOverflowTitle(ids: (string | number)[], count: number): string { + const overflow = ids.slice(30).join(', '); + return count > ids.length ? `${overflow} (first ${ids.length} of ${count} shown)` : overflow; + } + + protected prettyText(value: unknown) { + return pretty(value); + } + + protected shortText(value: unknown) { + return short(value); + } +} diff --git a/app/src/pages/store/store-dispatch.html b/app/src/pages/store/store-dispatch.html new file mode 100644 index 00000000..de8f15b9 --- /dev/null +++ b/app/src/pages/store/store-dispatch.html @@ -0,0 +1,46 @@ +

        Dispatch an action

        +
        + + + @for (type of actionTypes(); track type) { + + } + + +

        {{ payloadError() }}

        +
        + +
        + @if (!canRestore()) { +

        {{ restoreOff }}

        + } +
        diff --git a/app/src/pages/store/store-dispatch.scss b/app/src/pages/store/store-dispatch.scss new file mode 100644 index 00000000..eb651488 --- /dev/null +++ b/app/src/pages/store/store-dispatch.scss @@ -0,0 +1,6 @@ +@use 'store'; + +@include store.field; +@include store.base; +@include store.actions; +@include store.dispatch; diff --git a/app/src/pages/store/store-dispatch.ts b/app/src/pages/store/store-dispatch.ts new file mode 100644 index 00000000..5d0329ce --- /dev/null +++ b/app/src/pages/store/store-dispatch.ts @@ -0,0 +1,27 @@ +import { Component, inject } from '@angular/core'; +import { StoreActions } from './store-actions'; +import { StoreState } from './store-state'; + +/** The Dispatch an action form of the shown `@ngrx/store` Store. */ +@Component({ + selector: 'section[appStoreDispatch]', + templateUrl: './store-dispatch.html', + styleUrl: './store-dispatch.scss', +}) +export class StoreDispatch { + private readonly state = inject(StoreState); + private readonly actions = inject(StoreActions); + + protected readonly canRestore = this.state.canRestore; + protected readonly restoreOff = this.state.restoreOff; + protected readonly actionTypes = this.state.actionTypes; + protected readonly busy = this.actions.busy; + protected readonly dispatchType = this.actions.dispatchType; + protected readonly dispatchPayload = this.actions.dispatchPayload; + protected readonly payloadError = this.actions.payloadError; + protected readonly dispatchRequest = this.actions.dispatchRequest; + + protected dispatchAction(event: Event) { + return this.actions.dispatchAction(event); + } +} diff --git a/app/src/pages/store/store-entry.html b/app/src/pages/store/store-entry.html new file mode 100644 index 00000000..cb1146a1 --- /dev/null +++ b/app/src/pages/store/store-entry.html @@ -0,0 +1,254 @@ +@let selected = entry(); +@let headingLevel = level(); +@let idPrefix = prefix(); +
        + #{{ selected.seq }} {{ selected.type }} +
        +
        +
        Time
        +
        {{ formatTime(selected.timestamp) }}
        + @if (selected.origin) { +
        Origin
        +
        {{ originText(selected.origin) }}
        + } + @if (selected.durationMs !== undefined) { +
        Duration
        +
        {{ formatDuration(selected.durationMs) }}
        + } + @if (selected.trace?.length) { +
        Called from
        +
        +
          + @for (frame of traceFrames(selected.trace); track $index) { +
        1. + @if (frame.file) { + {{ frame.name ? frame.name + ' ' : '' }}({{ frame.file }}:{{ + frame.line + }}) + } @else { + {{ frame.name }} + } +
        2. + } +
        +
        + } + @if (selected.source === 'event') { + + } +
        + + @if (selected.action !== undefined) { +
        Action
        +
        +
        {{ prettyText(selected.action) }}
        +
        + } + @if (selected.args?.length) { +
        Arguments
        +
        +
        {{ prettyText(selected.args) }}
        +
        + } + @if (selected.source === 'event' && selected.payload !== undefined) { +
        Payload
        +
        +
        {{ prettyText(selected.payload) }}
        +
        + } + @if (selected.causedByEvent; as caused) { +
        Caused by event
        +
        + {{ caused.type }} + @if (caused.payload !== undefined) { +
        {{ prettyText(caused.payload) }}
        + } +
        + } +
        + +
        +
        State diff
        + @if (selected.diff.length) { + + } +
        + @if (selected.diff.length) { +
          + @for (change of selected.diff; track change.path) { +
        • + {{ opLabel(change.op) }} + {{ change.path }} + + @if (change.op !== 'add') { + {{ shortText(change.before) }} + } + @if (change.op === 'change') { + became + } + @if (change.op !== 'remove') { + {{ shortText(change.after) }} + } + +
        • + } +
        + @if (selected.diffTruncated) { +

        {{ moreChanges(selected.diffTruncated) }}

        + } + } @else { +

        The state did not change.

        + } +
        +@if (selected.source === 'redux') { + @if (selected.action !== undefined) { +
        + +
        + } + +} @else if (selected.source !== 'event') { + +
        + @if (selected.action !== undefined || selected.args?.length || selected.causedByEvent) { +
        + +
        + } @else { +

        + No action or arguments: a restore, or a patchState write outside a method. +

        + } +
        +
        + +
        +
        +} + +@if (selected.restorable && confirmSeq() === selected.seq) { +
        +

        + @if (selected.source === 'store') { + Store DevTools jumps the app state to the state right after action #{{ selected.seq }}. + Until you go back to the latest state, new actions are logged but do not change the state. + } @else { + @if (store(); as current) { + This sets every state key of {{ current.label }} back to its value right after change #{{ + selected.seq + }}. Components that read the store update at once, and a new "Restore" entry is added to + the log. + } @else { + This sets every state key of the store back to its value right after change #{{ + selected.seq + }}. Components that read the store update at once, and a new "Restore" entry is added to + the log. + } + } +

        +
        + + +
        +
        +} +@if ( + confirmSeq() !== selected.seq && + (selected.restorable || canAgain(selected) || canToggle(selected)) +) { +
        + @if (canToggle(selected)) { + + } + @if (selected.restorable) { + + } + @if (canAgain(selected)) { + + } +
        + @if (!canRestore()) { +

        {{ restoreOff }}

        + } +} +@if (selected.source === 'redux') { +

        + This store reports through its Redux DevTools integration, which the panel reads but does not + write to, so its state cannot be restored from here. +

        +} +@if (!selected.restorable && selected.source === 'store') { +

        + @if (selected.unrestorable === 'dropped') { + Store DevTools no longer holds this action, so this state cannot be restored. It was dropped + past maxAge, or the Store DevTools history was committed, reset or imported. + } @else if (selected.unrestorable === 'not-recorded') { + Store DevTools never recorded this action, so this state cannot be restored. An + actionsBlocklist, actionsSafelist or predicate option + filtered it out, or recording was paused. + } @else { + Time travel for @ngrx/store needs provideStoreDevtools(). Without it, entries + cannot be restored. + } +

        +} diff --git a/app/src/pages/store/store-entry.scss b/app/src/pages/store/store-entry.scss new file mode 100644 index 00000000..61e91461 --- /dev/null +++ b/app/src/pages/store/store-entry.scss @@ -0,0 +1,14 @@ +@use 'store'; + +@include store.base; +@include store.lists; +@include store.code; +@include store.entry($entry: ':host'); +@include store.actions; +@include store.history-stats; +@include store.trace; +@media (max-width: 480px) { + .kv { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/app/src/pages/store/store-entry.ts b/app/src/pages/store/store-entry.ts new file mode 100644 index 00000000..c0afe6bb --- /dev/null +++ b/app/src/pages/store/store-entry.ts @@ -0,0 +1,103 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { Component, ElementRef, effect, inject, input, viewChild } from '@angular/core'; +import { time } from '../../format'; +import { SourceLink } from '../../ui/source-link'; +import { EntryTabs } from '../store-entry-tabs'; +import { moreChangesText, pretty, short, traceFrame, type NgrxLogEntry } from '../store-types'; +import { StoreActions } from './store-actions'; +import { formatDuration, opLabel, originText } from './store-format'; +import { StoreState } from './store-state'; + +/** + * The detail of one log entry, shared by the store's change log and the page-level Events + * section (reachable with no live store at all, since a page can dispatch + * `@ngrx/signals/events` without any signalStore/signalState instance). + */ +@Component({ + selector: 'section[appStoreEntry]', + imports: [EntryTabs, NgTemplateOutlet, SourceLink], + host: { '[attr.aria-labelledby]': "prefix() + '-entry-heading'" }, + templateUrl: './store-entry.html', + styleUrl: './store-entry.scss', +}) +export class StoreEntry { + private readonly state = inject(StoreState); + private readonly actions = inject(StoreActions); + private readonly cancelButton = viewChild>('cancelButton'); + private readonly restoreButton = viewChild>('restoreButton'); + + /** Read in the template as `selected`, `headingLevel` and `idPrefix`, as before the split. */ + readonly entry = input.required({ alias: 'selected' }); + readonly level = input.required({ alias: 'headingLevel' }); + readonly prefix = input.required({ alias: 'idPrefix' }); + + protected readonly store = this.state.store; + protected readonly confirmSeq = this.state.confirmSeq; + protected readonly canRestore = this.state.canRestore; + protected readonly restoreOff = this.state.restoreOff; + protected readonly loadEntry = this.state.loadEntry; + protected readonly busy = this.actions.busy; + protected readonly formatTime = time; + protected readonly formatDuration = formatDuration; + protected readonly originText = originText; + protected readonly opLabel = opLabel; + protected readonly moreChanges = moreChangesText; + + constructor() { + effect(() => { + const target = this.actions.focusConfirm(); + const button = target === 'cancel' ? this.cancelButton() : this.restoreButton(); + if (!target || !button) return; + this.actions.focusConfirm.set(null); + button.nativeElement.focus(); + }); + } + + protected traceFrames(trace: string[]) { + return trace.map(traceFrame); + } + + protected prettyText(value: unknown) { + return pretty(value); + } + + protected shortText(value: unknown) { + return short(value); + } + + protected copyDiff(entry: NgrxLogEntry) { + return this.state.copyDiff(entry); + } + + protected isSkipped(seq: number): boolean { + return this.state.isSkipped(seq); + } + + protected canToggle(entry: NgrxLogEntry): boolean { + return this.state.canToggle(entry); + } + + protected canAgain(entry: NgrxLogEntry): boolean { + return this.state.canAgain(entry); + } + + protected askRestore(seq: number) { + this.actions.askRestore(seq); + } + + protected cancelRestore(event?: Event) { + this.actions.cancelRestore(event); + } + + protected restore(seq: number, pauses: boolean) { + return this.actions.restore(seq, pauses); + } + + protected devtoolsOp(request: { op: string; seq?: number; index?: number; paused?: boolean }) { + return this.actions.devtoolsOp(request); + } + + protected dispatchAgain(seq: number) { + return this.actions.dispatchAgain(seq); + } +} diff --git a/app/src/pages/store/store-events.html b/app/src/pages/store/store-events.html new file mode 100644 index 00000000..073ac5ff --- /dev/null +++ b/app/src/pages/store/store-events.html @@ -0,0 +1,58 @@ +
        +

        Events

        + {{ pageEvents().length }} +
        +

        + Events dispatched through @ngrx/signals/events, across every store on this page. +

        +
          + @for (shown of shownEvents(); track shown.entry.seq) { + @let evt = shown.entry; +
        • + +
        • + } @empty { +
        • No events match this filter.
        • + } +
        +@if (eventEntry(); as selected) { +
        +
        +
        +} diff --git a/app/src/pages/store/store-events.scss b/app/src/pages/store/store-events.scss new file mode 100644 index 00000000..b6120256 --- /dev/null +++ b/app/src/pages/store/store-events.scss @@ -0,0 +1,8 @@ +@use 'store'; + +@include store.intro; +@include store.base; +@include store.lists; +@include store.events; +@include store.entry; +@include store.history-stats; diff --git a/app/src/pages/store/store-events.ts b/app/src/pages/store/store-events.ts new file mode 100644 index 00000000..79790e48 --- /dev/null +++ b/app/src/pages/store/store-events.ts @@ -0,0 +1,69 @@ +import { + Component, + ElementRef, + afterRenderEffect, + computed, + effect, + inject, + viewChild, +} from '@angular/core'; +import { time } from '../../format'; +import { RovingList } from '../../ui/roving-list'; +import { VirtualWindow } from '../../ui/virtual-window'; +import { pretty, short } from '../store-types'; +import { StoreActions } from './store-actions'; +import { StoreEntry } from './store-entry'; +import { kindColor, slice } from './store-format'; +import { StoreState } from './store-state'; + +/** The Events section: events dispatched through `@ngrx/signals/events` on the shown page. */ +@Component({ + selector: 'section[appStoreEvents]', + imports: [RovingList, StoreEntry], + templateUrl: './store-events.html', + styleUrl: './store-events.scss', +}) +export class StoreEvents { + private readonly state = inject(StoreState); + private readonly actions = inject(StoreActions); + private readonly eventsList = viewChild>('eventsList'); + private readonly eventDetailHeading = viewChild>('eventDetailHeading'); + + protected readonly pageEvents = this.state.pageEvents; + protected readonly selectedEventSeq = this.state.selectedEventSeq; + protected readonly eventEntry = this.state.eventEntry; + protected readonly kindColor = kindColor; + protected readonly formatTime = time; + + protected readonly eventWindow = new VirtualWindow({ + count: computed(() => this.pageEvents().length), + estimate: 36, + }); + protected readonly shownEvents = computed(() => slice(this.pageEvents(), this.eventWindow)); + + constructor() { + effect(() => { + const events = this.eventsList()?.nativeElement; + if (events) this.eventWindow.attach(events); + }); + afterRenderEffect(() => { + const seq = this.actions.focusEventSeq(); + const detail = this.eventDetailHeading(); + if (seq === null || !detail) return; + this.actions.focusEventSeq.set(null); + detail.nativeElement.focus(); + }); + } + + protected selectEvent(seq: number) { + this.actions.selectEvent(seq); + } + + protected prettyText(value: unknown) { + return pretty(value); + } + + protected shortText(value: unknown) { + return short(value); + } +} diff --git a/app/src/pages/store/store-format.ts b/app/src/pages/store/store-format.ts new file mode 100644 index 00000000..903f02e3 --- /dev/null +++ b/app/src/pages/store/store-format.ts @@ -0,0 +1,98 @@ +import type { VirtualWindow } from '../../ui/virtual-window'; +import type { NgrxActionOrigin } from '../store-types'; + +const KIND_COLORS: Record = { + action: '#f59e0b', + reducer: '#a78bfa', + effect: '#fb923c', + selector: '#60a5fa', + feature: '#34d399', + 'store-setup': '#94a3b8', + 'signal-store': '#e879f9', + 'signal-state': '#22d3ee', + 'signal-method': '#fb7185', + store: '#a78bfa', + event: 'var(--warn)', + redux: '#38bdf8', +}; + +const KIND_LABELS: Record = { + 'signal-store': 'signalStore', + 'signal-state': 'signalState', + 'signal-method': 'signalMethod', + 'store-setup': 'store setup', + store: '@ngrx/store', + event: 'event', + redux: 'Redux DevTools', +}; + +const ORIGIN_TEXT: Record = { + dispatch: 'store.dispatch(action)', + effect: 'NgRx effect (Store.next)', + reactive: 'store.dispatch(() => action)', +}; + +export const CLASSIC_KINDS = new Set([ + 'action', + 'reducer', + 'effect', + 'selector', + 'feature', + 'store-setup', +]); + +export function parsePayload(text: string): { payload?: Record; error?: string } { + if (!text.trim()) return {}; + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return { error: 'The payload is not valid JSON.' }; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { error: 'The payload must be a JSON object, like {"id": 7}.' }; + } + if (Object.prototype.hasOwnProperty.call(value, 'type')) { + return { + error: 'The payload cannot have a "type" key. Put the action type in the Type field.', + }; + } + return { payload: value as Record }; +} + +export function kindColor(kind: string) { + return KIND_COLORS[kind] ?? 'var(--text-3)'; +} + +export function kindLabel(kind: string) { + return KIND_LABELS[kind] ?? kind; +} + +export function originText(origin: NgrxActionOrigin) { + return ORIGIN_TEXT[origin] ?? origin; +} + +export function opLabel(op: string) { + return op === 'add' ? 'added' : op === 'remove' ? 'removed' : 'changed'; +} + +export function formatDuration(ms: number): string { + return ms <= 0 ? '<1ms' : `${Math.round(ms)}ms`; +} + +export function methodDuration(method: { + lastDurationMs?: number; + avgDurationMs?: number; +}): string { + const parts: string[] = []; + if (method.avgDurationMs !== undefined) parts.push(`avg ${formatDuration(method.avgDurationMs)}`); + if (method.lastDurationMs !== undefined) + parts.push(`last ${formatDuration(method.lastDurationMs)}`); + return parts.length ? ` · ${parts.join(' · ')}` : ''; +} + +/** The entries of `list` the window renders, with their index in the whole list. */ +export function slice(list: T[], window: VirtualWindow) { + const { start, end } = window.range(); + return list.slice(start, end).map((entry, i) => ({ entry, index: start + i })); +} diff --git a/app/src/pages/store/store-history.html b/app/src/pages/store/store-history.html new file mode 100644 index 00000000..b02b7e4e --- /dev/null +++ b/app/src/pages/store/store-history.html @@ -0,0 +1,81 @@ +@if (store(); as current) { +

        Store DevTools history

        +

        + {{ + current.classic!.recordingPaused + ? 'Recording is paused: new actions change the state but are not recorded.' + : 'Recording new actions.' + }} + @if (current.classic!.skipped?.length; as skippedCount) { + {{ skippedCount }} skipped {{ skippedCount === 1 ? 'action is' : 'actions are' }} left out of + the state. + } +

        + @if (confirmReset()) { +
        +

        + Reset puts the store back on its initial state and empties the Store DevTools history. + Entries logged so far can no longer be restored. +

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

        {{ restoreOff }}

        + } +} diff --git a/app/src/pages/store/store-history.scss b/app/src/pages/store/store-history.scss new file mode 100644 index 00000000..71dcc88b --- /dev/null +++ b/app/src/pages/store/store-history.scss @@ -0,0 +1,5 @@ +@use 'store'; + +@include store.base; +@include store.actions; +@include store.history-stats($history: ':host'); diff --git a/app/src/pages/store/store-history.ts b/app/src/pages/store/store-history.ts new file mode 100644 index 00000000..c0bec133 --- /dev/null +++ b/app/src/pages/store/store-history.ts @@ -0,0 +1,44 @@ +import { Component, ElementRef, afterRenderEffect, inject, viewChild } from '@angular/core'; +import { StoreActions } from './store-actions'; +import { StoreState } from './store-state'; + +/** The Store DevTools history controls of the shown `@ngrx/store` Store. */ +@Component({ + selector: 'section[appStoreHistory]', + templateUrl: './store-history.html', + styleUrl: './store-history.scss', +}) +export class StoreHistory { + private readonly state = inject(StoreState); + private readonly actions = inject(StoreActions); + private readonly resetButton = viewChild>('resetButton'); + private readonly resetCancel = viewChild>('resetCancel'); + + protected readonly store = this.state.store; + protected readonly canRestore = this.state.canRestore; + protected readonly restoreOff = this.state.restoreOff; + protected readonly busy = this.actions.busy; + protected readonly confirmReset = this.actions.confirmReset; + + constructor() { + afterRenderEffect(() => { + const target = this.actions.focusReset(); + const button = target === 'cancel' ? this.resetCancel() : this.resetButton(); + if (!target || !button) return; + this.actions.focusReset.set(null); + button.nativeElement.focus(); + }); + } + + protected askReset() { + this.actions.askReset(); + } + + protected cancelReset(event?: Event) { + this.actions.cancelReset(event); + } + + protected devtoolsOp(request: { op: string; seq?: number; index?: number; paused?: boolean }) { + return this.actions.devtoolsOp(request); + } +} diff --git a/app/src/pages/store/store-live.html b/app/src/pages/store/store-live.html new file mode 100644 index 00000000..7ec720af --- /dev/null +++ b/app/src/pages/store/store-live.html @@ -0,0 +1,81 @@ +
        +

        Live stores

        + @if (liveStores().length) { + {{ liveStores().length }} + } +
        + +@if (failed()) { + + Could not load the live store state. +

        Check that the dev server with Pangular Inspector is still running.

        +
        +} @else if (!stateLoaded()) { + Loading the live stores… +} @else if (!page()) { + + No page is reporting store state yet. +

        Open the app in a browser. Stores show up here as soon as the page creates them.

        +
        +} @else if (!liveStores().length) { + + The overlay finds stores through the injectors, so stores it can't reach from a component are + not listed. + +
        + @if (usesSignals()) { +

        No signal store instance on this page yet.

        +

        + A signalStore is created the first time something injects it. Navigate to a + page that uses the store and it appears here. +

        + } @else if (usesClassic()) { +

        The @ngrx/store Store was not found on this page.

        +

        + Check that provideStore() or StoreModule.forRoot() is part of the + providers of the running app. +

        + } @else { +

        This page has no store.

        +

        + Stores from @ngrx/signals (signalStore, + signalState) and @ngrx/store are listed here once the app + creates them. NGXS, Elf and MiniRx stores show up through their Redux DevTools integration + when the app loads + @pangular-inspector/devtools/store-hook + first. +

        + } +
        +} @else { +
        +
          + @for (item of filteredStores(); track item.id) { +
        • + +
        • + } @empty { +
        • No stores match this filter.
        • + } +
        + + @if (store()) { +
        + } +
        +} +

        {{ message() }}

        diff --git a/app/src/pages/store/store-live.scss b/app/src/pages/store/store-live.scss new file mode 100644 index 00000000..251cad6e --- /dev/null +++ b/app/src/pages/store/store-live.scss @@ -0,0 +1,20 @@ +@use 'store'; + +@include store.base; +@include store.lists; +@include store.detail; +@include store.message; +@media (max-width: 760px) { + .live-layout { + grid-template-columns: minmax(0, 1fr); + } + .store-list { + border-right: none; + border-bottom: 1px solid var(--border); + } +} +@media (max-width: 480px) { + .store-detail { + padding: 12px; + } +} diff --git a/app/src/pages/store/store-live.ts b/app/src/pages/store/store-live.ts new file mode 100644 index 00000000..c2f00453 --- /dev/null +++ b/app/src/pages/store/store-live.ts @@ -0,0 +1,42 @@ +import { Component, inject } from '@angular/core'; +import { AngularUnavailable } from '../../ui/angular-unavailable'; +import { AppState } from '../../ui/app-state'; +import { StoreDetail } from './store-detail'; +import { kindColor, kindLabel } from './store-format'; +import { StoreState } from './store-state'; + +/** The Live stores section of the Store page: its states, the store list and the shown store. */ +@Component({ + selector: 'section[appStoreLive]', + imports: [AngularUnavailable, AppState, StoreDetail], + templateUrl: './store-live.html', + styleUrl: './store-live.scss', +}) +export class StoreLive { + private readonly state = inject(StoreState); + + protected readonly failed = this.state.failed; + protected readonly stateLoaded = this.state.stateLoaded; + protected readonly page = this.state.page; + protected readonly angularPage = this.state.angularPage; + protected readonly liveStores = this.state.liveStores; + protected readonly filteredStores = this.state.filteredStores; + protected readonly store = this.state.store; + protected readonly usesSignals = this.state.usesSignals; + protected readonly usesClassic = this.state.usesClassic; + protected readonly message = this.state.message; + protected readonly kindColor = kindColor; + protected readonly kindLabel = kindLabel; + + protected retry() { + this.state.retry(); + } + + protected selectStore(id: string) { + this.state.selectStore(id); + } + + protected countText(item: Parameters[0]) { + return this.state.countText(item); + } +} diff --git a/app/src/pages/store/store-log.html b/app/src/pages/store/store-log.html new file mode 100644 index 00000000..20fce8b1 --- /dev/null +++ b/app/src/pages/store/store-log.html @@ -0,0 +1,105 @@ +@if (store(); as current) { +

        + {{ current.classic || current.redux ? 'Action log' : 'Change log' }} + {{ storeLog().length }} +

        +
        + +
        + +
        +
          + @for (shown of shownLog(); track shown.entry.seq) { + @let entry = shown.entry; +
        • + +
        • + } @empty { +
        • + @if (filter()) { + No entries match this filter. + } @else if (current.classic || current.redux) { + No actions dispatched since the page connected. + } @else { + No state changes yet. Method calls and patchState writes that change the state are + recorded here. + } +
        • + } +
        + + + @if (changeEntry(); as selected) { +
        +
        +
        + } +
        +} diff --git a/app/src/pages/store/store-log.scss b/app/src/pages/store/store-log.scss new file mode 100644 index 00000000..0d33a609 --- /dev/null +++ b/app/src/pages/store/store-log.scss @@ -0,0 +1,15 @@ +@use 'store'; + +@include store.base; +@include store.lists; +@include store.methods; +@include store.log; +@include store.entry; +@media (max-width: 760px) { + .log-layout { + grid-template-columns: minmax(0, 1fr); + } + .log-layout > app-splitter { + display: none; + } +} diff --git a/app/src/pages/store/store-log.ts b/app/src/pages/store/store-log.ts new file mode 100644 index 00000000..d6c45a7f --- /dev/null +++ b/app/src/pages/store/store-log.ts @@ -0,0 +1,65 @@ +import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core'; +import { time } from '../../format'; +import { LimitNote } from '../../ui/limit-note'; +import { LiveControls, followNewest } from '../../ui/live-feed'; +import { RovingList } from '../../ui/roving-list'; +import { Splitter, storedPaneSize } from '../../ui/splitter'; +import { VirtualWindow } from '../../ui/virtual-window'; +import { StoreEntry } from './store-entry'; +import { formatDuration, kindLabel, slice } from './store-format'; +import { StoreState } from './store-state'; + +/** The change or action log of the shown store, with the detail of the picked entry. */ +@Component({ + selector: 'section[appStoreLog]', + imports: [LimitNote, LiveControls, RovingList, Splitter, StoreEntry], + templateUrl: './store-log.html', + styleUrl: './store-log.scss', +}) +export class StoreLog { + private readonly state = inject(StoreState); + + /** The change list width on wide panels, set by the splitter and saved. */ + protected readonly logListSize = signal(storedPaneSize('store-log', 300)); + protected readonly store = this.state.store; + protected readonly page = this.state.page; + protected readonly reduxPage = this.state.reduxPage; + protected readonly maxLog = this.state.maxLog; + protected readonly filter = this.state.filter; + protected readonly logFeed = this.state.logFeed; + protected readonly storeLog = this.state.storeLog; + protected readonly selectedChangeSeq = this.state.selectedChangeSeq; + protected readonly changeEntry = this.state.changeEntry; + protected readonly changesLabel = this.state.changesLabel; + protected readonly kindLabel = kindLabel; + protected readonly formatTime = time; + protected readonly formatDuration = formatDuration; + + /** Long logs render only the entries near view in their scroll box. */ + protected readonly logWindow = new VirtualWindow({ + count: computed(() => this.storeLog().length), + estimate: 56, + }); + protected readonly shownLog = computed(() => slice(this.storeLog(), this.logWindow)); + private readonly logList = viewChild>('logList'); + + constructor() { + // Newest first, so following the newest change keeps the log scrolled to the top. + followNewest(this.logFeed, () => { + const list = this.logList()?.nativeElement; + if (list) list.scrollTop = 0; + }); + effect(() => { + const log = this.logList()?.nativeElement; + if (log) this.logWindow.attach(log); + }); + } + + protected selectChange(seq: number) { + this.state.selectChange(seq); + } + + protected isSkipped(seq: number): boolean { + return this.state.isSkipped(seq); + } +} diff --git a/app/src/pages/store/store-source.html b/app/src/pages/store/store-source.html new file mode 100644 index 00000000..742b0983 --- /dev/null +++ b/app/src/pages/store/store-source.html @@ -0,0 +1,71 @@ +
        +

        Source declarations

        + @if (sourceEntries().length) { + {{ sourceEntries().length }} + } +
        +@if (!sourceLoaded()) { +
        + +

        Scanning source for NgRx declarations…

        +
        +} @else if (sourceEntries().length === 0) { +
        +

        No NgRx declarations found in source.

        +

        + The scan looks for signalStore, signalState, + signalMethod, createAction, createReducer, + createEffect, createSelector and createFeature. +

        +
        +} @else { +
        + + @for (group of groupedEntries(); track group.kind) { + + } +
        + +
          + @for (entry of filteredEntries(); track entry.name + entry.file + entry.line) { +
        • +
          + + {{ entry.name }} + {{ kindLabel(entry.kind) }} +
          +
          + +
          + @if (entry.detail) { +

          {{ entry.detail }}

          + } +
        • + } @empty { +
        • +
          +

          No declarations match.

          + +
          +
        • + } +
        +} diff --git a/app/src/pages/store/store-source.scss b/app/src/pages/store/store-source.scss new file mode 100644 index 00000000..a51e440e --- /dev/null +++ b/app/src/pages/store/store-source.scss @@ -0,0 +1,12 @@ +@use 'store'; + +@include store.base; +@include store.lists; +@include store.detail; +@include store.source; +@include store.spinner; +@media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } +} diff --git a/app/src/pages/store/store-source.ts b/app/src/pages/store/store-source.ts new file mode 100644 index 00000000..cd3d9b9e --- /dev/null +++ b/app/src/pages/store/store-source.ts @@ -0,0 +1,27 @@ +import { Component, inject } from '@angular/core'; +import { SourceLink } from '../../ui/source-link'; +import { kindColor, kindLabel } from './store-format'; +import { StoreState } from './store-state'; + +/** The Source declarations section: the NgRx declarations the source scan found. */ +@Component({ + selector: 'section[appStoreSource]', + imports: [SourceLink], + templateUrl: './store-source.html', + styleUrl: './store-source.scss', +}) +export class StoreSource { + private readonly state = inject(StoreState); + + protected readonly sourceEntries = this.state.sourceEntries; + protected readonly sourceLoaded = this.state.sourceLoaded; + protected readonly kind = this.state.kind; + protected readonly groupedEntries = this.state.groupedEntries; + protected readonly filteredEntries = this.state.filteredEntries; + protected readonly kindColor = kindColor; + protected readonly kindLabel = kindLabel; + + protected clearFilters() { + this.state.clearFilters(); + } +} diff --git a/app/src/pages/store/store-state.ts b/app/src/pages/store/store-state.ts new file mode 100644 index 00000000..a38e751e --- /dev/null +++ b/app/src/pages/store/store-state.ts @@ -0,0 +1,482 @@ +import { + DestroyRef, + Injectable, + computed, + inject, + linkedSignal, + signal, + type Signal, +} from '@angular/core'; +import type { DevframeRpcClient } from 'devframe/client'; +import { angularPageFor, injectAngularPages } from '../../angular-pages'; +import { actionAllowed, actionBlockedMessage, panelConfig } from '../../devtools-config'; +import { hostPageId } from '../../page-id'; +import { rpcCall as call } from '../../rpc'; +import { LiveFeed } from '../../ui/live-feed'; +import type { SelectOption } from '../../ui/select'; +import { reduxLiveStores, reduxLog, reduxPagesOf, type ReduxStoresPage } from '../redux-stores'; +import type { EntryDetailAnswer } from '../store-entry-tabs'; +import { + changeCountText, + pretty, + sameNgrxLog, + short, + type LiveStore, + type NgrxActionStat, + type NgrxLogEntry, + type NgrxLogKey, + type NgrxPage, + type NgrxState, + type NgrxStoreEntry, +} from '../store-types'; +import { CLASSIC_KINDS } from './store-format'; + +/** + * The state the Store page and its sections share: the `ngrx-store` and `redux-stores` shared + * states, the source scan, the selected page, store and log entries, the filter and the result + * message. Provided by the page, so every section of one page reads the same signals. + */ +@Injectable() +export class StoreState { + private rpcSource: Signal = signal(null); + readonly rpc = computed(() => this.rpcSource()); + readonly canRestore = computed(() => actionAllowed(this.rpc(), 'ngrx')); + readonly restoreOff = actionBlockedMessage('ngrx'); + + readonly filter = signal(''); + readonly kind = signal(null); + readonly sourceEntries = signal([]); + readonly sourceLoaded = signal(false); + readonly pages = signal([]); + /** Pages with stores that report through the Redux DevTools API (`redux-stores` shared state). */ + readonly reduxPages = signal([]); + /** The NgRx pages, plus pages that only report other stores. */ + readonly allPages = computed(() => { + const ngrx = this.pages(); + const known = new Set(ngrx.map((p) => p.pageId)); + const others = this.reduxPages() + .filter((p) => !known.has(p.pageId)) + .map((p) => ({ + pageId: p.pageId, + url: p.url, + title: p.title, + stores: [], + classic: null, + log: [], + reportedAt: p.reportedAt, + })); + return [...ngrx, ...others]; + }); + readonly failed = signal(false); + /** Whether the shared state has arrived, so the empty message doesn't show first. */ + readonly stateLoaded = signal(false); + readonly selectedPageId = signal(null); + private readonly hostPageId = hostPageId(); + private readonly angularPages = injectAngularPages(() => this.rpc()); + /** The shown page's Angular version and debug API parts, for the unavailable note. */ + readonly angularPage = computed(() => + angularPageFor(this.angularPages(), this.page()?.pageId ?? this.hostPageId), + ); + readonly maxLog = computed(() => panelConfig(this.rpc()).limits.changeLog); + readonly selectedStoreId = signal(null); + readonly message = signal(''); + + private readonly destroyRef = inject(DestroyRef); + private unsubscribe: (() => void) | null = null; + private unsubscribeOthers: (() => void) | null = null; + /** Focuses the toolbar filter; the page sets it. */ + focusFilter: () => void = () => {}; + + readonly page = computed(() => { + const pages = this.allPages(); + return ( + pages.find((p) => p.pageId === this.selectedPageId()) ?? + pages.find((p) => p.pageId === this.hostPageId) ?? + pages[0] ?? + null + ); + }); + + /** A page is connected, with stores or without: every page reports its Angular version. */ + readonly pageConnected = computed(() => !!this.page() || !!this.angularPage()); + + /** The other stores of the shown page. */ + readonly reduxPage = computed( + () => this.reduxPages().find((p) => p.pageId === this.page()?.pageId) ?? null, + ); + + private readonly logKey = computed(() => { + const page = this.page(); + const store = this.store(); + // Another store's log has its own seq, so it is its own session. + if (store?.redux) { + return { pageId: `${page?.pageId}/${store.id}`, log: reduxLog(this.reduxPage(), store.id) }; + } + return { pageId: page?.pageId ?? null, log: page?.log ?? [] }; + }); + + /** + * Counts the change logs this view has shown. It moves on when `page()` switches to another + * page (including the fallback when the chosen page closes) and when the app reloads, which + * keeps the `pageId` but restarts `seq` at 1, so a seq picked before means another entry now. + */ + readonly logSession = linkedSignal({ + source: this.logKey, + computation: (key, previous) => + !previous ? 0 : sameNgrxLog(previous.source, key) ? previous.value : previous.value + 1, + }); + + // Two selection cursors so clicking an event never swaps out the per-store + // change-log detail: the change log keeps its own highlighted row, the + // Events section keeps its own, and each shows its detail in-place. + // All three are seqs, so they reset whenever the change log is another one. + readonly selectedChangeSeq = linkedSignal({ + source: this.logSession, + computation: () => null, + }); + readonly selectedEventSeq = linkedSignal({ + source: this.logSession, + computation: () => null, + }); + readonly confirmSeq = linkedSignal({ + source: this.logSession, + computation: () => null, + }); + + readonly pageOptions = computed(() => + this.allPages().map((p) => ({ value: p.pageId, label: p.title || p.url, hint: p.url })), + ); + + readonly liveStores = computed(() => { + const page = this.page(); + if (!page) return []; + const stores: LiveStore[] = page.stores.map((info) => ({ + id: info.id, + label: info.name ?? info.references[0] ?? info.className, + kind: info.kind, + scope: info.scope, + signal: info, + })); + if (page.classic) { + stores.push({ + id: 'store', + label: 'Store', + kind: 'store', + scope: page.classic.scope, + classic: page.classic, + }); + } + stores.push(...reduxLiveStores(this.reduxPage())); + return stores; + }); + + readonly filteredStores = computed(() => { + const f = this.filter().trim().toLowerCase(); + if (!f) return this.liveStores(); + return this.liveStores().filter( + (s) => + s.label.toLowerCase().includes(f) || + s.kind.includes(f) || + (s.signal?.stateKeys ?? []).some((k) => k.toLowerCase().includes(f)) || + this.logOf(s).some((e) => e.type.toLowerCase().includes(f)), + ); + }); + + readonly store = computed(() => { + const stores = this.liveStores(); + return stores.find((s) => s.id === this.selectedStoreId()) ?? stores[0] ?? null; + }); + + readonly stateText = computed(() => { + const store = this.store(); + if (!store) return ''; + return pretty( + store.signal ? store.signal.state : store.redux ? store.redux.state : store.classic?.state, + ); + }); + + /** The serialized state of the selected store, for the state tree. */ + readonly stateValue = computed(() => { + const store = this.store(); + if (!store) return undefined; + return store.signal + ? store.signal.state + : store.redux + ? store.redux.state + : store.classic?.state; + }); + + /** The paths the newest change of the selected store wrote, marked in the state tree. */ + readonly changedPaths = computed(() => { + const store = this.store(); + const log = store ? this.logOf(store) : []; + let newest: NgrxLogEntry | undefined; + for (let i = log.length - 1; i >= 0 && !newest; i--) if (log[i].diff.length) newest = log[i]; + return (newest?.diff ?? []).map((d) => d.path); + }); + + /** The selected store's action stats, most frequent first. */ + readonly storeStats = computed(() => { + const store = this.store(); + if (!store || store.redux) return []; + const stats = this.page()?.stats; + return Array.isArray(stats) ? stats.filter((s) => s.storeId === store.id) : []; + }); + readonly hasLoop = computed(() => this.storeStats().some((s) => s.loop)); + + private readonly skippedSeqs = computed( + () => + new Set(Array.isArray(this.page()?.classic?.skipped) ? this.page()!.classic!.skipped : []), + ); + + readonly computedEntries = computed(() => + Object.entries(this.store()?.signal?.computed ?? {}).map(([key, value]) => ({ + key, + value: short(value, 160), + full: pretty(value), + })), + ); + + /** Pause holds the selected store's log as it is while the app keeps changing it. */ + readonly logFeed = new LiveFeed({ + name: 'store-log', + source: () => { + const store = this.store(); + return store ? this.logOf(store) : []; + }, + key: (entry) => entry.seq, + newest: 'last', + scope: () => `${this.store()?.id ?? ''}:${this.logSession()}`, + }); + + readonly storeLog = computed(() => { + const f = this.filter().trim().toLowerCase(); + return this.logFeed + .items() + .filter((e) => !f || e.type.toLowerCase().includes(f) || this.storeMatches(f)) + .slice() + .reverse(); + }); + + /** The selected store's state changes, oldest first, for the session tools. */ + readonly sessionEntries = computed(() => { + const store = this.store(); + // Sessions and generated tests are NgRx only: other stores have no past states to read. + if (!store || store.redux) return []; + return (this.page()?.log ?? []).filter((e) => e.storeId === store.id && e.source !== 'event'); + }); + + /** Reads the State and full Diff of a past entry from the shown page. */ + readonly loadEntry = async (seq: number): Promise => { + const pageId = this.page()?.pageId; + if (!pageId) return { error: 'No live page to read from.' }; + return ((await call(this.rpc(), 'request-ngrx-history', { + pageId, + request: { type: 'entry', seq }, + })) ?? { error: 'The devtools server is not connected.' }) as EntryDetailAnswer; + }; + + /** A store's log entries, oldest first. */ + private logOf(store: LiveStore): NgrxLogEntry[] { + if (store.redux) return reduxLog(this.reduxPage(), store.id); + return (this.page()?.log ?? []).filter((e) => e.storeId === store.id); + } + + readonly hasEvents = computed(() => (this.page()?.log ?? []).some((e) => e.source === 'event')); + + readonly pageEvents = computed(() => { + const f = this.filter().trim().toLowerCase(); + return (this.page()?.log ?? []) + .filter((e) => e.source === 'event' && (!f || e.type.toLowerCase().includes(f))) + .slice() + .reverse(); + }); + + readonly changeEntry = computed(() => { + const seq = this.selectedChangeSeq(); + if (seq === null) return null; + return this.storeLog().find((e) => e.seq === seq) ?? null; + }); + + readonly eventEntry = computed(() => { + const seq = this.selectedEventSeq(); + if (seq === null) return null; + return this.pageEvents().find((e) => e.seq === seq) ?? null; + }); + + readonly actionTypes = computed(() => { + const types = new Set(); + for (const entry of this.sourceEntries()) for (const type of entry.types ?? []) types.add(type); + for (const entry of this.page()?.log ?? []) { + if (entry.source === 'store' && entry.action !== undefined) types.add(entry.type); + } + return [...types].sort().slice(0, 200); + }); + + readonly usesSignals = computed(() => + this.sourceEntries().some((e) => e.kind === 'signal-store' || e.kind === 'signal-state'), + ); + readonly usesClassic = computed(() => + this.sourceEntries().some((e) => CLASSIC_KINDS.has(e.kind)), + ); + + readonly filteredEntries = computed(() => { + const f = this.filter().trim().toLowerCase(); + const kind = this.kind(); + return this.sourceEntries().filter( + (e) => + (!kind || e.kind === kind) && + (!f || + e.name.toLowerCase().includes(f) || + e.kind.includes(f) || + (e.detail ?? '').toLowerCase().includes(f)), + ); + }); + + readonly groupedEntries = computed(() => { + const groups = new Map(); + for (const e of this.sourceEntries()) groups.set(e.kind, (groups.get(e.kind) ?? 0) + 1); + return [...groups.entries()].map(([kind, count]) => ({ kind, count })); + }); + + constructor() { + this.destroyRef.onDestroy(() => { + this.unsubscribe?.(); + this.unsubscribeOthers?.(); + }); + } + + /** Reads the page's `rpc` input. */ + connect(rpc: Signal) { + this.rpcSource = rpc; + } + + private storeMatches(f: string): boolean { + const store = this.store(); + return !!store && store.label.toLowerCase().includes(f); + } + + async load(client: DevframeRpcClient) { + this.failed.set(false); + call(client, 'get-ngrx-store') + .then((entries) => this.sourceEntries.set(Array.isArray(entries) ? entries : [])) + .catch(() => this.sourceEntries.set([])) + .finally(() => this.sourceLoaded.set(true)); + try { + const state = await client.scope('pangular').rpc.sharedState('ngrx-store'); + if (this.destroyRef.destroyed) return; + const apply = (value: unknown) => { + const next = value as Partial | undefined; + this.pages.set(Array.isArray(next?.pages) ? next.pages : []); + // A state the client hasn't loaded from the server yet says nothing. + if (value !== undefined) this.stateLoaded.set(true); + }; + apply(state.value()); + this.unsubscribe?.(); + this.unsubscribe = state.on('updated', apply); + } catch { + this.failed.set(true); + } + // Other stores are an addition: a server without them leaves the NgRx view as it is. + try { + const others = await client.scope('pangular').rpc.sharedState('redux-stores'); + if (this.destroyRef.destroyed) return; + const applyOthers = (value: unknown) => this.reduxPages.set(reduxPagesOf(value)); + applyOthers(others.value()); + this.unsubscribeOthers?.(); + this.unsubscribeOthers = others.on('updated', applyOthers); + } catch { + this.reduxPages.set([]); + } + } + + retry() { + const client = this.rpc(); + if (client) void this.load(client); + } + + selectPage(pageId: string | null) { + this.selectedPageId.set(pageId); + this.selectedStoreId.set(null); + this.selectedChangeSeq.set(null); + this.selectedEventSeq.set(null); + this.confirmSeq.set(null); + this.message.set(''); + } + + selectStore(id: string) { + this.selectedStoreId.set(id); + this.selectedChangeSeq.set(null); + this.confirmSeq.set(null); + this.message.set(''); + } + + selectChange(seq: number) { + this.selectedChangeSeq.set(this.selectedChangeSeq() === seq ? null : seq); + this.confirmSeq.set(null); + this.message.set(''); + } + + copyState() { + return this.copyText(this.stateText()); + } + + copyPath(path: string) { + return this.copyText(path, `Copied the path ${path}.`); + } + + copyDiff(entry: NgrxLogEntry) { + return this.copyText(pretty(entry.diff)); + } + + private async copyText(text: string, done = 'Copied.') { + try { + await navigator.clipboard.writeText(text); + this.message.set(done); + } catch { + this.message.set('The clipboard is not available here.'); + } + } + + isSkipped(seq: number): boolean { + return this.skippedSeqs().has(seq); + } + + /** An @ngrx/store action Store DevTools still holds, or one it skips, can be skipped or included. */ + canToggle(entry: NgrxLogEntry): boolean { + return ( + entry.source === 'store' && + entry.action !== undefined && + !!this.page()?.classic?.devtools && + (entry.restorable || this.isSkipped(entry.seq)) + ); + } + + canAgain(entry: NgrxLogEntry): boolean { + return entry.source === 'store' && entry.action !== undefined; + } + + /** Clears the filters from a no-match state and keeps focus on the filter box. */ + clearFilters() { + this.filter.set(''); + this.kind.set(null); + this.focusFilter(); + } + + /** + * The row's log count. A Redux DevTools store logs what its library sent, + * `@@INIT` included, so its entries are actions, not changes. + */ + countText(item: LiveStore): string { + const n = this.changeCount(item.id); + const noun = item.redux ? (n === 1 ? 'action' : 'actions') : n === 1 ? 'change' : 'changes'; + return `${n} ${noun}`; + } + + changeCount(id: string): number { + const store = this.liveStores().find((s) => s.id === id); + return store ? this.logOf(store).length : 0; + } + + readonly changesLabel = changeCountText; +} diff --git a/app/src/pages/store/store-stats.html b/app/src/pages/store/store-stats.html new file mode 100644 index 00000000..96fe794b --- /dev/null +++ b/app/src/pages/store/store-stats.html @@ -0,0 +1,47 @@ +@if (store(); as current) { +

        Action frequency

        +

        + How often each {{ current.classic ? 'action' : 'change' }} ran since the page loaded, including + entries the log no longer keeps. +

        +
        + + + + + + + + + + + + @for (stat of storeStats(); track stat.type) { + + + + + + + + } + +
        {{ current.classic ? 'Action' : 'Method or write' }}CountAvgLongestPeak per second
        + {{ stat.type }} + @if (stat.loop) { + Possible effect loop + } + {{ stat.count }} + {{ stat.avgMs !== undefined ? formatDuration(stat.avgMs) : 'n/a' }} + + {{ stat.maxMs !== undefined ? formatDuration(stat.maxMs) : 'n/a' }} + {{ stat.burst }}
        +
        + @if (hasLoop()) { +

        + A possible effect loop is an action an NgRx effect dispatched + {{ loopBurst }} or more times within one second. Check the effect that dispatches it for a + missing filter or a dispatch of its own trigger. +

        + } +} diff --git a/app/src/pages/store/store-stats.scss b/app/src/pages/store/store-stats.scss new file mode 100644 index 00000000..94055b46 --- /dev/null +++ b/app/src/pages/store/store-stats.scss @@ -0,0 +1,4 @@ +@use 'store'; + +@include store.base; +@include store.history-stats($stats: ':host'); diff --git a/app/src/pages/store/store-stats.ts b/app/src/pages/store/store-stats.ts new file mode 100644 index 00000000..dce0af41 --- /dev/null +++ b/app/src/pages/store/store-stats.ts @@ -0,0 +1,20 @@ +import { Component, inject } from '@angular/core'; +import { formatDuration } from './store-format'; +import { StoreState } from './store-state'; + +/** The action frequency table of the shown store. */ +@Component({ + selector: 'div[appStoreStats]', + templateUrl: './store-stats.html', + styleUrl: './store-stats.scss', +}) +export class StoreStats { + private readonly state = inject(StoreState); + + protected readonly store = this.state.store; + protected readonly storeStats = this.state.storeStats; + protected readonly hasLoop = this.state.hasLoop; + protected readonly formatDuration = formatDuration; + /** How many effect dispatches within a second flag a loop (`NGRX_LOOP_BURST`). */ + protected readonly loopBurst = 20; +} diff --git a/app/src/styles/_analog.scss b/app/src/styles/_analog.scss index eed2dc3f..a871a3ea 100644 --- a/app/src/styles/_analog.scss +++ b/app/src/styles/_analog.scss @@ -292,3 +292,859 @@ } } } + +// The Analog page's own styles, shared by the page (`app/src/pages/analog-inspector.scss`) and +// its views (`app/src/pages/analog/`), in the order the page had them as one component, so each +// view looks as it did. Include the groups a view uses, in this order: base, count, form, +// callout, table, calls, cards, findings. + +// Shared text styles: numbers, metadata rows, empty rows and boxes, mono and muted text, code. +@mixin base { + .tnum { + font-variant-numeric: tabular-nums; + } + .meta { + display: flex; + flex-wrap: wrap; + gap: 6px 8px; + align-items: center; + min-width: 0; + } + .empty-row { + padding: 24px 16px; + color: var(--text-2); + text-align: center; + } + tbody tr:hover td.empty-row { + background: transparent; + } + .empty-box { + display: grid; + gap: 4px; + padding: 24px 16px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + text-align: center; + } + .empty-box p { + margin: 0; + line-height: 1.55; + } + .empty-lead { + color: var(--text-strong); + font-weight: 600; + } + .mono { + font-family: var(--mono); + font-size: 12.5px; + } + .muted { + color: var(--text-2); + } + .small { + font-size: 12px; + } + .nowrap { + white-space: nowrap; + } + code { + padding: 1px 6px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-2); + color: var(--text-strong); + font-family: var(--mono); + font-size: 12.5px; + } +} + +// The count badge, on the view tabs and on a lint card. +@mixin count { + .count { + min-width: 20px; + padding: 0 6px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 11px; + font-weight: 600; + line-height: 18px; + text-align: center; + font-variant-numeric: tabular-nums; + } + [role='tab'][aria-selected='true'] .count { + background: var(--border-strong); + color: var(--text); + } + .count[data-tone='warn'], + [role='tab'][aria-selected='true'] .count[data-tone='warn'] { + background: color-mix(in srgb, var(--warn) 12%, transparent); + color: var(--warn-text); + } + .count[data-tone='bad'], + [role='tab'][aria-selected='true'] .count[data-tone='bad'] { + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger-text); + } +} + +// The toolbar, inline forms, fields and buttons, with their focus rings. +@mixin form { + .toolbar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--surface) 85%, transparent); + backdrop-filter: blur(10px); + } + .explain { + flex: 1 1 320px; + min-width: 0; + } + .filter { + flex: 0 1 240px; + min-width: 0; + } + @media (max-width: 560px) { + .filter { + flex: 1 1 100%; + } + } + .inline, + .row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + .inline > label, + .playground > label:not(.check) { + color: var(--text-3); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + } + + .field { + box-sizing: border-box; + height: 34px; + padding: 0 12px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background-color: var(--bg); + color: var(--text); + font: inherit; + font-size: 13px; + transition: + border-color 180ms var(--ease), + box-shadow 180ms var(--ease); + } + .field.mono { + font-family: var(--mono); + font-size: 13px; + } + .field::placeholder { + color: var(--text-3); + } + .field:focus, + .field:focus-visible { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + outline: none; + } + .method-select { + width: 112px; + font-family: var(--mono); + } + textarea.field { + height: auto; + padding: 8px 12px; + } + .grow { + flex: 1 1 180px; + min-width: 0; + } + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + height: 34px; + padding: 0 14px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: + background-color 160ms var(--ease), + border-color 160ms var(--ease), + color 160ms var(--ease); + } + .btn:hover { + background: var(--surface-3); + border-color: var(--control-border); + } + .btn:active { + transform: translateY(1px); + } + .btn.primary { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); + font-weight: 600; + } + .btn.primary:hover { + border-color: var(--accent-hover); + background: var(--accent-hover); + } + .btn.ghost { + height: 28px; + padding: 0 12px; + font-size: 12px; + } + .btn.ghost:hover { + border-color: var(--accent-line); + color: var(--text-strong); + } + td.actions { + width: 1%; + text-align: right; + vertical-align: middle; + } + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + [role='tab']:focus-visible, + .btn:focus-visible, + .table-wrap:focus-visible, + summary:focus-visible, + .segmented label:focus-within { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +// Callouts, the route chain of an explained URL, and plain lists. +@mixin callout { + .callout { + padding: 12px 16px; + border: 1px solid color-mix(in srgb, var(--hue-info) 30%, transparent); + border-radius: var(--radius); + background: color-mix(in srgb, var(--hue-info) 8%, var(--surface)); + line-height: 1.6; + animation: enter 0.35s var(--ease) both; + } + .callout[data-tone='good'] { + border-color: color-mix(in srgb, var(--ok) 30%, transparent); + background: color-mix(in srgb, var(--ok) 12%, transparent); + } + .callout[data-tone='warn'] { + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + background: color-mix(in srgb, var(--warn) 12%, transparent); + } + .callout[data-tone='bad'] { + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); + } + .callout strong { + color: var(--text-strong); + } + .callout strong.mono { + overflow-wrap: anywhere; + } + .chain { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 8px 0 0; + padding: 0; + list-style: none; + } + .chain li { + display: inline-flex; + gap: 6px; + align-items: center; + min-width: 0; + overflow-wrap: anywhere; + } + .chain li:not(:last-child)::after { + content: '›'; + margin-left: 2px; + color: var(--text-3); + } + .callout .chips { + margin-top: 8px; + } + .plain { + margin: 8px 0 0; + padding-left: 18px; + } + .plain li { + display: flex; + flex-wrap: wrap; + gap: 8px; + } +} + +// Tables, file links, pills, chips, status codes, methods and response previews. +@mixin table { + .table-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); + animation: enter 0.35s var(--ease) both; + } + table { + width: 100%; + min-width: 560px; + border-collapse: collapse; + } + th, + td { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; + } + tbody tr:last-child td { + border-bottom: none; + } + th { + position: sticky; + top: 0; + background: var(--surface); + color: var(--text-3); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + white-space: nowrap; + } + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + td.nowrap { + font-variant-numeric: tabular-nums; + } + tbody td { + transition: background-color 160ms var(--ease); + } + tbody tr:hover td { + background: var(--surface-2); + } + tr.open td { + background: var(--accent-soft); + color: var(--text-strong); + } + tr.open:hover td { + background: color-mix(in srgb, var(--accent) 16%, transparent); + } + tr.open td:first-child { + box-shadow: inset 2px 0 0 var(--accent); + } + tr.dim .path { + color: var(--text-2); + } + .route { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + .guide { + color: var(--text-3); + font-family: var(--mono); + } + .path { + color: var(--text-strong); + overflow-wrap: anywhere; + } + .file { + font-family: var(--mono); + font-size: 12.5px; + color: var(--text); + overflow-wrap: anywhere; + } + .dir { + color: var(--text-3); + } + + .pill, + .chip, + .status, + .method { + display: inline-block; + padding: 1px 8px; + border-radius: 99px; + font-size: 11px; + font-weight: 500; + line-height: 18px; + white-space: nowrap; + } + .pill { + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text-2); + } + .chip { + margin: 0 4px 4px 0; + padding: 3px 10px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + font-size: 12px; + line-height: 16px; + } + .chip.mono { + font-size: 12px; + } + .chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .chips .pill { + padding: 3px 10px; + font-size: 12px; + } + .pill.live { + border-color: var(--accent-line); + background: color-mix(in srgb, var(--accent) 12%, var(--surface)); + color: var(--accent-text); + } + .pill[data-kind='layout'], + .pill[data-mode='redirect'] { + border-color: var(--hue-slate); + color: var(--hue-slate-text); + } + .pill[data-kind='markdown'] { + border-color: var(--hue-sky); + color: var(--hue-sky-text); + } + .pill[data-kind='load'], + .pill[data-call='load'], + .pill[data-call='fn'] { + border-color: var(--hue-teal); + color: var(--hue-teal-text); + } + .pill[data-mode='ssr'], + .pill[data-call='page'] { + border-color: var(--hue-blue); + color: var(--hue-blue-text); + } + .pill[data-mode='ssg'] { + border-color: var(--hue-green); + color: var(--hue-green-text); + } + .pill[data-mode='client'] { + border-color: var(--hue-yellow); + color: var(--hue-yellow-text); + } + .pill[data-mode='cached'] { + border-color: var(--hue-purple); + color: var(--hue-purple-text); + } + .pill[data-call='api'] { + border-color: var(--hue-orange); + color: var(--hue-orange-text); + } + .pill[data-call='action'] { + border-color: var(--hue-pink); + color: var(--hue-pink-text); + } + [data-tone='good'].pill { + @include m.tint(ok); + } + [data-tone='warn'].pill, + [data-tone='warn'].chip { + @include m.tint(warn); + } + [data-tone='bad'].pill, + [data-tone='bad'].chip { + @include m.tint(danger); + } + [data-tone='info'].pill { + @include m.tint(hue-info); + } + + .status { + border: 1px solid var(--border-strong); + font-family: var(--mono); + font-weight: 600; + font-variant-numeric: tabular-nums; + } + .status[data-status='good'] { + @include m.tint(ok); + } + .status[data-status='warn'] { + @include m.tint(warn); + } + .status[data-status='bad'] { + @include m.tint(danger); + } + .method { + min-width: 52px; + border: 1px solid var(--border-strong); + border-radius: 6px; + background: var(--surface-2); + color: var(--text); + font-family: var(--mono); + font-weight: 600; + text-align: center; + } + .method[data-method='GET'] { + @include m.tint(hue-sky); + } + .method[data-method='POST'] { + @include m.tint(ok); + } + .method[data-method='PUT'], + .method[data-method='PATCH'] { + @include m.tint(warn); + } + .method[data-method='DELETE'] { + @include m.tint(danger); + } + .request { + min-width: 260px; + } + .request .url { + color: var(--text-strong); + overflow-wrap: anywhere; + } + details { + margin-top: 8px; + } + summary { + width: fit-content; + border-radius: 6px; + color: var(--text-2); + font-size: 12px; + cursor: pointer; + transition: color 160ms var(--ease); + } + summary:hover { + color: var(--text); + } + .code { + margin: 8px 0 0; + padding: 10px 12px; + max-height: 220px; + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font-family: var(--mono); + font-size: 12.5px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; + } +} + +// The Server view call filters, notes, empty actions and the kind switch. +@mixin calls { + .calls-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + } + .calls-actions { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + } + .calls-message { + color: var(--text-2); + font-size: 12.5px; + } + .call-filters { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + } + .search { + position: relative; + flex: 1 1 220px; + min-width: 0; + } + .search .field { + width: 100%; + padding-left: 34px; + } + .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; + } + .search:focus-within .search-icon { + stroke: var(--accent); + } + .call-filters .check { + flex: none; + font-size: 13px; + font-weight: 500; + } + .total { + flex: none; + color: var(--text-2); + font-size: 12px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + .note { + margin: 0; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-2); + line-height: 1.5; + } + .empty-actions { + display: flex; + justify-content: center; + margin-top: 8px; + } + .segmented { + display: inline-flex; + flex-wrap: wrap; + gap: 2px; + justify-self: start; + margin: 0; + padding: 3px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + } + .segmented label { + display: inline-flex; + gap: 6px; + align-items: center; + padding: 5px 12px; + border-radius: 7px; + background: transparent; + color: var(--text-2); + font-weight: 500; + cursor: pointer; + transition: + background-color 180ms var(--ease), + color 180ms var(--ease), + box-shadow 180ms var(--ease); + } + .segmented label:hover { + color: var(--text); + } + .segmented label .muted { + color: var(--text-3); + font-size: 12px; + font-variant-numeric: tabular-nums; + } + .segmented label.on { + background: var(--surface-3); + color: var(--text-strong); + box-shadow: inset 0 0 0 1px var(--border-strong); + } + .segmented label.on .muted { + color: var(--text-2); + } +} + +// Section headings, cards, checkboxes, responses and fact lists. +@mixin cards { + h2 { + display: flex; + gap: 8px; + align-items: center; + margin: 8px 0 0; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + } + .card { + display: grid; + gap: 12px; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); + animation: enter 0.35s var(--ease) both; + } + .card h2 { + margin: 0; + } + .check { + display: flex; + gap: 8px; + align-items: center; + color: var(--text-2); + cursor: pointer; + } + .check input { + width: 15px; + height: 15px; + margin: 0; + accent-color: var(--accent); + } + .check input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .response { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + } + .response .code { + margin: 0; + } + .facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 10px 16px; + margin: 0; + } + .facts dt { + padding-top: 3px; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + } + .facts dd { + margin: 0; + } +} + +// Lint finding cards. +@mixin findings { + .findings { + display: grid; + gap: 12px; + margin: 0; + padding: 0; + list-style: none; + animation: enter 0.35s var(--ease) both; + } + .findings > li { + position: relative; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: + inset 3px 0 0 var(--hue-info), + var(--shadow); + transition: border-color 180ms var(--ease); + } + .findings > li:hover { + border-color: var(--border-strong); + } + .findings > li[data-tone='bad'] { + box-shadow: + inset 3px 0 0 var(--danger), + var(--shadow); + } + .findings > li[data-tone='warn'] { + box-shadow: + inset 3px 0 0 var(--warn), + var(--shadow); + } + .findings p { + margin: 8px 0 0; + line-height: 1.55; + } + .findings > li > p { + color: var(--text-2); + } + .finding-head { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + .finding-title { + color: var(--text-strong); + font-size: 14px; + font-weight: 600; + } + .where { + display: grid; + gap: 6px; + margin: 12px 0 0; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + list-style: none; + } + .where li { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + } + .where .detail { + flex-basis: 100%; + margin: 0; + color: var(--text-2); + overflow-wrap: anywhere; + } + .fix { + margin-top: 12px; + color: var(--text); + line-height: 1.55; + } + .fix strong { + display: block; + margin-bottom: 4px; + color: var(--ok); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + } + .rule { + display: block; + margin-top: 12px; + color: var(--text-3); + font-size: 12px; + } +} diff --git a/app/src/styles/_components.scss b/app/src/styles/_components.scss new file mode 100644 index 00000000..911a1773 --- /dev/null +++ b/app/src/styles/_components.scss @@ -0,0 +1,475 @@ +// Styles shared by the Components page's sections (`app/src/pages/components/`), in the order the +// page had them as one component, so each section looks as it did. Include the groups a section +// uses, in this order: base, search, buttons, narrow, detail, facts, values, states. +@use 'mixins' as m; + +// Monospace and muted text. +@mixin base { + .mono { + font-family: var(--font-mono); + } + .muted { + color: var(--text-2); + } +} + +// The search fields and the count beside them. +@mixin search { + .search { + position: relative; + flex: 1 1 220px; + 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; + } + .search:focus-within .search-icon { + stroke: var(--accent); + } + input[type='search'] { + width: 100%; + height: 34px; + padding: 0 12px 0 34px; + background: var(--bg); + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + color: var(--text); + font: inherit; + font-size: 13px; + } + input[type='search']::placeholder { + color: var(--text-3); + } + input[type='search']:focus-visible { + @include m.field-focus; + } + .count { + flex: none; + color: var(--text-2); + font-size: 12px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } +} + +// Every button of the page. +@mixin buttons { + button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 34px; + padding: 0 14px; + background: var(--surface-2); + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + color: var(--text); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 500; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease); + } + button:hover { + background: var(--surface-3); + border-color: var(--accent-line); + } + button:focus-visible { + @include m.focus-ring; + } + button svg { + flex: none; + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; + } +} + +// The toolbar actions, and the search and count on a narrow panel. +@mixin narrow { + .tree-action[aria-pressed='true'] { + background: var(--accent-soft); + border-color: var(--accent-line); + color: var(--text-strong); + } + /* A narrow panel keeps the search and count on one row and the tree + actions on a second one, as icons named by their hidden labels. */ + .tree-actions { + display: contents; + } + @media (max-width: 600px) { + .toolbar { + gap: 8px; + } + .search { + flex-basis: 100%; + } + .toolbar:has(.page-select) .search { + flex-basis: 160px; + } + .page-select { + flex: 1 1 120px; + } + .count { + flex: 1 1 80px; + min-width: 0; + white-space: normal; + line-height: 1.3; + } + .tree-actions { + display: flex; + flex: none; + gap: 8px; + margin-left: auto; + } + .tree-action, + .pick { + flex: none; + width: var(--control-h); + padding: 0; + } + .tree-action .label, + .pick .label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + } +} + +// The detail header, the source location guesses and the action rows. +@mixin detail { + .detail-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + } + .detail-head h2 { + min-width: 0; + margin: 0; + color: var(--text-strong); + font-size: 15px; + font-weight: 600; + overflow-wrap: anywhere; + } + .where { + width: 100%; + margin: 0; + color: var(--text-2); + font-size: 12px; + overflow-wrap: anywhere; + } + .directive-where { + margin: -4px 0 8px; + font-size: 12px; + } + .directive-where:empty { + display: none; + } + .directive-guess { + display: inline-flex; + align-items: center; + font-family: var(--font-sans); + } + .likely { + display: inline-block; + margin-right: 6px; + padding: 0 6px; + border: 1px solid; + border-radius: 99px; + @include m.tint(warn); + font-family: var(--font-sans); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + line-height: 16px; + text-transform: uppercase; + vertical-align: 1px; + } + .guess-text { + margin: 0; + } + .guess-list { + margin: 4px 0 0; + padding-left: 18px; + } + .badge { + padding: 2px 8px; + border-radius: 99px; + @include m.tint(accent); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + } + .actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + width: 100%; + } +} + +// The Show in Forms button and the facts list. +@mixin facts { + .show-form { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-ink); + font-weight: 600; + } + .show-form:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + } + .facts { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 8px 16px; + margin: 0; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + } + .facts dt { + @include m.label; + align-self: center; + } + .facts dd { + min-width: 0; + margin: 0; + color: var(--text); + overflow-wrap: anywhere; + } +} + +// The detail blocks: values, chips, flags and injected services. +@mixin values { + .block { + padding: 14px 16px; + border-bottom: 1px solid var(--border); + } + .block:last-child { + border-bottom: 0; + } + h3 { + @include m.label; + display: flex; + align-items: center; + gap: 6px; + margin: 0 0 8px; + } + .pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 16px; + padding: 0 5px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 10px; + font-weight: 600; + letter-spacing: 0; + } + .props, + .deps, + .chips { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + } + .chips { + display: flex; + flex-wrap: wrap; + } + .prop, + .dep { + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + } + .prop.changed { + outline: 2px solid var(--accent); + outline-offset: -1px; + animation: value-flash 1s var(--ease) forwards; + } + @keyframes value-flash { + from { + outline-color: var(--accent); + } + to { + outline-color: transparent; + } + } + @media (prefers-reduced-motion: reduce) { + .prop.changed { + animation: none; + } + } + .signal-link { + height: auto; + min-height: 0; + margin-left: 6px; + padding: 0 6px; + font-family: inherit; + font-size: 11px; + font-weight: 500; + line-height: 18px; + } + .signal-link:focus-visible { + @include m.focus-ring(-2px); + } + .prop-name { + display: block; + margin-bottom: 4px; + color: var(--text-strong); + font-size: 12px; + font-weight: 600; + } + pre { + max-height: 200px; + margin: 0; + overflow: auto; + color: var(--text); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + .chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 10px; + border: 1px solid var(--border-strong); + border-radius: 99px; + color: var(--text); + font-size: 12px; + } + .chip.on { + @include m.tint(ok); + } + .chip-note { + color: var(--text-2); + font-size: 11px; + } + .chip.on .chip-note { + color: inherit; + } + .dep { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + } + .token { + min-width: 0; + color: var(--text-strong); + font-size: 12px; + font-weight: 600; + overflow-wrap: anywhere; + } + .flag { + padding: 0 6px; + border: 1px solid var(--border-strong); + border-radius: 5px; + color: var(--text-2); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 400; + letter-spacing: 0; + line-height: 16px; + text-transform: none; + } + .from { + margin-left: auto; + color: var(--text-2); + font-size: 12px; + } + .from .mono { + color: var(--text); + } + .from.missing { + color: var(--warn); + } + .empty-line { + margin: 0; + color: var(--text-2); + } +} + +// The loading, empty and no-match states. +@mixin states { + .state { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 40px 16px; + text-align: center; + background: var(--surface); + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + } + .state.compact { + padding: 24px 12px; + border: 0; + background: none; + } + .state-title { + margin: 0; + color: var(--text-strong); + font-size: 14px; + font-weight: 600; + overflow-wrap: anywhere; + } + .state-hint { + max-width: 440px; + margin: 0 auto; + color: var(--text-2); + line-height: 1.5; + } + .spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @media (prefers-reduced-motion: reduce) { + .spinner, + .refresh.spinning svg { + animation: none; + } + } +} diff --git a/app/src/styles/_injectors.scss b/app/src/styles/_injectors.scss new file mode 100644 index 00000000..89d85800 --- /dev/null +++ b/app/src/styles/_injectors.scss @@ -0,0 +1,379 @@ +// Styles shared by the Injectors page's sections (`app/src/pages/injectors/`), in the order the +// page had them as one component, so each section looks as it did. Include the groups a section +// uses in this order: mono, pill, chips, link, dot, detail-head, heading, hint, path, dep-list, +// dep-main, token, flag, dep-meta, empty-line, state, buttons, state-buttons, state-button-focus, +// chevron, toggle, hidden. +@use 'mixins' as m; + +@mixin mono { + .mono { + font-family: var(--font-mono); + } +} + +@mixin pill { + .pill { + display: inline-flex; + align-items: center; + min-width: 18px; + height: 16px; + padding: 0 5px; + justify-content: center; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 10px; + font-weight: 600; + letter-spacing: 0; + font-variant-numeric: tabular-nums; + } +} + +@mixin chips { + .chip, + .from, + .step { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; + height: 26px; + padding: 0 10px; + border: 1px solid var(--control-border); + border-radius: 99px; + background: var(--surface); + color: var(--text); + font-size: 12px; + cursor: pointer; + transition: + border-color 150ms var(--ease), + background-color 150ms var(--ease); + } + .chip:hover, + .from:hover, + .step:hover { + border-color: var(--accent-line); + background: var(--surface-2); + } + .chip:focus-visible, + .from:focus-visible, + .step:focus-visible, + .link:focus-visible { + @include m.focus-ring; + } +} + +@mixin link { + .link { + padding: 0; + border: 0; + background: none; + color: var(--accent); + font-size: 13px; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + } +} + +@mixin dot { + .dot { + flex: none; + width: 7px; + height: 7px; + border-radius: 50%; + } +} + +@mixin detail-head { + .detail-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + } + .detail-head h2 { + flex: 1 1 auto; + min-width: 0; + margin: 0; + color: var(--text-strong); + font-size: 15px; + font-weight: 600; + overflow-wrap: anywhere; + } +} + +@mixin heading { + h3 { + @include m.label; + display: flex; + align-items: center; + gap: 6px; + margin: 0 0 8px; + } +} + +@mixin hint { + .hint { + margin: -4px 0 10px; + color: var(--text-3); + font-size: 12px; + } +} + +@mixin path { + .path { + position: relative; + display: grid; + gap: 6px; + margin: 0; + padding: 0 0 0 18px; + list-style: none; + } + .path::before { + content: ''; + position: absolute; + top: 13px; + bottom: 13px; + left: 5px; + width: 1px; + background: var(--border-strong); + } + .path li { + position: relative; + min-width: 0; + } + .path li::before { + content: ''; + position: absolute; + top: 12px; + left: -16px; + width: 9px; + height: 1px; + background: var(--border-strong); + } + .path li.current .step { + border-color: var(--accent); + background: var(--accent-soft); + } + .step.null { + cursor: default; + color: var(--text-2); + } + .step.null:hover { + border-color: var(--border-strong); + background: var(--surface); + } + .hint-inline { + color: var(--text-3); + } + .step-count { + color: var(--text-3); + font-size: 11px; + } +} + +@mixin dep-list { + .deps, + .providers { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + } + .dep, + .provider { + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + } + .dep.hit, + .provider.hit { + border-color: var(--accent-line); + box-shadow: 0 0 0 3px var(--accent-soft); + } +} + +@mixin dep-main { + .dep-main { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + } +} + +@mixin token { + .token { + min-width: 0; + color: var(--text-strong); + font-size: 12px; + font-weight: 600; + overflow-wrap: anywhere; + } +} + +@mixin flag { + .flag { + padding: 0 6px; + border: 1px solid var(--border-strong); + border-radius: 5px; + color: var(--text-2); + font-family: var(--font-mono); + font-size: 10px; + line-height: 16px; + } +} + +@mixin dep-meta { + .dep-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + margin-top: 6px; + color: var(--text-3); + font-size: 12px; + overflow-wrap: anywhere; + } +} + +@mixin empty-line { + .empty-line { + margin: 0; + color: var(--text-2); + } +} + +@mixin state { + .state { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 40px 16px; + text-align: center; + background: var(--surface); + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + @include m.enter; + } + .state.compact { + padding: 24px 12px; + border: 0; + } + .state-title { + margin: 0; + color: var(--text-strong); + font-size: 14px; + font-weight: 600; + } + .state-hint { + max-width: 440px; + margin: 0; + color: var(--text-2); + line-height: 1.5; + } +} + +@mixin buttons { + button { + font: inherit; + } +} + +@mixin state-buttons { + .state button, + .provider-group button { + height: 34px; + padding: 0 14px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + cursor: pointer; + } +} + +@mixin state-button-focus { + .state button:focus-visible { + @include m.focus-ring; + } +} + +@mixin chevron { + .refresh svg, + .chevron { + flex: none; + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + } + .chevron { + transition: transform 150ms var(--ease); + } + .chevron.open { + transform: rotate(90deg); + } + @media (prefers-reduced-motion: reduce) { + .chevron { + transition: none; + } + } +} + +@mixin toggle { + .orphans-toggle, + .provider-group .group-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: var(--control-h); + padding: 0 12px; + border: 0; + border-radius: var(--radius-sm); + background: none; + color: var(--text-2); + font-size: 12px; + text-align: left; + cursor: pointer; + } + .provider-group .group-toggle { + width: auto; + height: auto; + min-height: 0; + padding: 2px 4px; + margin-left: -4px; + color: inherit; + font: inherit; + letter-spacing: inherit; + text-transform: inherit; + } + .orphans-toggle:hover, + .provider-group .group-toggle:hover { + color: var(--text); + } + .orphans-toggle:focus-visible, + .provider-group .group-toggle:focus-visible { + @include m.focus-ring(-2px); + } +} + +@mixin hidden { + .provider-list[hidden], + .orphans-body[hidden], + .helper-hint[hidden] { + display: none; + } +} diff --git a/app/src/styles/_pipes.scss b/app/src/styles/_pipes.scss new file mode 100644 index 00000000..4820b7c6 --- /dev/null +++ b/app/src/styles/_pipes.scss @@ -0,0 +1,510 @@ +// Styles shared by the Pipes page and its sections (`app/src/pages/pipes/`), in the order the page +// had them as one component, so each part looks as it did. Include the groups a part uses, in +// this order: mono, buttons, pipe-list, chips, detail, value, detail-body, notes, section-head, +// stacks, async-list, lint-list, states, state-text. +@use 'mixins' as m; + +@mixin mono { + .mono { + font-family: var(--font-mono); + } +} + +@mixin buttons { + button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: var(--control-h); + padding: 0 14px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-weight: 500; + white-space: nowrap; + cursor: pointer; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease); + } + button:hover:not(:disabled) { + border-color: var(--accent-line); + background: var(--surface-3); + } + button:focus-visible { + @include m.focus-ring; + } + button:disabled { + cursor: default; + opacity: 0.6; + } +} + +@mixin pipe-list { + .pipe-list { + display: grid; + gap: 2px; + margin: 0; + padding: 6px; + list-style: none; + @include m.panel; + @include m.enter; + } + .row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 12px; + width: 100%; + height: auto; + padding: 8px 10px; + border: 0; + border-radius: var(--radius-sm); + background: none; + text-align: left; + font-weight: 400; + white-space: normal; + } + .row:hover:not(:disabled) { + border-color: transparent; + background: var(--surface-2); + } + .row:focus-visible { + @include m.focus-ring(-2px); + } + .row.selected { + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent); + } + .row-main { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + } + .name { + flex: none; + color: var(--text-strong); + font-size: 13px; + font-weight: 600; + } + .sub { + color: var(--text-3); + font-size: 12px; + @include m.truncate; + } + .row-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; + } + .row-file { + grid-column: 1 / -1; + color: var(--text-3); + font-size: 11px; + overflow-wrap: anywhere; + } + .no-source { + font-family: var(--font-sans); + font-style: italic; + } + .more { + margin-left: 6px; + color: var(--text-2); + } +} + +@mixin chips { + .chip { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 7px; + border: 1px solid var(--border-strong); + border-radius: 99px; + color: var(--text-2); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; + } + .chip.impure, + .chip.warn { + @include m.tint(warn); + } + .chip.live { + @include m.tint(ok); + } + .chip.hot { + @include m.tint(accent); + text-transform: none; + font-variant-numeric: tabular-nums; + } + .pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 16px; + padding: 0 5px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 10px; + font-weight: 600; + letter-spacing: 0; + font-variant-numeric: tabular-nums; + } +} + +@mixin detail { + .detail-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + } + .detail-head h2 { + flex: 1 1 auto; + min-width: 0; + margin: 0; + color: var(--text-strong); + font-size: 15px; + font-weight: 600; + overflow-wrap: anywhere; + border-radius: 4px; + } + .detail-head h2:focus { + outline: none; + } + .detail-head h2:focus-visible { + @include m.focus-ring; + } + .block { + padding: 14px 16px; + border-bottom: 1px solid var(--border); + } + .block:last-child { + border-bottom: 0; + } + h3 { + @include m.label; + display: flex; + align-items: center; + gap: 6px; + margin: 0 0 10px; + } + dl { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 6px 14px; + margin: 0; + } + dt { + color: var(--text-3); + } + dd { + min-width: 0; + margin: 0; + color: var(--text); + } +} + +@mixin value { + .value { + font-size: 12px; + overflow-wrap: anywhere; + } +} + +@mixin detail-body { + .muted { + color: var(--text-2); + } + .sites { + display: grid; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; + color: var(--text-2); + font-size: 12px; + overflow-wrap: anywhere; + } + .calls { + margin-left: 6px; + color: var(--text-3); + } + .instance { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + } + .instance .component { + padding-right: 10px; + } + .caller { + flex-basis: 100%; + } + .chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + } + .component { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 4px 0 10px; + border: 1px solid var(--control-border); + border-radius: 99px; + background: var(--bg); + font-size: 12px; + transition: + border-color 150ms var(--ease), + background-color 150ms var(--ease); + } + button.component { + height: 24px; + padding: 0 4px 0 10px; + border-radius: 99px; + background: var(--bg); + font-size: 12px; + font-weight: 400; + } + .count { + padding: 0 6px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 10px; + line-height: 16px; + font-variant-numeric: tabular-nums; + } + .warning { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + margin: 0 0 12px; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--warn) 8%, transparent); + color: var(--text); + line-height: 1.5; + } +} + +@mixin notes { + .hint { + margin: 8px 0 0; + color: var(--text-3); + font-size: 12px; + } + .empty-line { + margin: 0; + color: var(--text-2); + line-height: 1.5; + } + .empty-line.ok { + color: var(--ok); + } +} + +@mixin section-head { + :host(.section) h2 { + @include m.label; + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 8px; + } + :host(.section) > .hint { + margin: -2px 0 10px; + } +} + +@mixin stacks { + .async-list, + .findings { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + } +} + +@mixin async-list { + .async-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + min-width: 0; + padding: 8px 12px; + border-radius: var(--radius-sm); + @include m.panel; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease); + } + .async-row:hover { + border-color: var(--border-strong); + background: var(--surface-2); + } + .component-name { + color: var(--text-strong); + font-weight: 600; + } + button.component-name { + height: 24px; + padding: 0 8px; + margin-left: -8px; + border-color: transparent; + background: none; + font-size: 13px; + } + .latest { + flex: 1 1 100%; + color: var(--text-2); + } +} + +@mixin lint-list { + .finding { + padding: 10px 12px; + border-radius: var(--radius-sm); + @include m.panel; + } + .finding-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + } + .severity { + padding: 0 7px; + border: 1px solid var(--border-strong); + border-radius: 99px; + color: var(--text-2); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + line-height: 16px; + text-transform: uppercase; + } + .severity[data-tone='warning'] { + @include m.tint(warn); + } + .severity[data-tone='error'] { + @include m.tint(danger); + } + .rule { + color: var(--accent); + font-size: 12px; + } + .where { + color: var(--text-3); + font-size: 11px; + overflow-wrap: anywhere; + } + button.pipe-link { + display: inline; + height: auto; + padding: 0; + border: 0; + border-radius: 4px; + background: none; + color: var(--accent); + font: inherit; + text-decoration: underline; + text-underline-offset: 3px; + white-space: normal; + } + button.pipe-link:hover:not(:disabled) { + border-color: transparent; + background: none; + color: var(--accent-hover); + } + button.pipe-link:focus-visible { + @include m.focus-ring; + } + .finding-message { + margin: 6px 0 0; + color: var(--text); + line-height: 1.5; + } + .finding-fix { + margin: 4px 0 0; + color: var(--text-2); + line-height: 1.5; + } + .fix-label { + @include m.label; + margin-right: 4px; + } +} + +@mixin states { + .state { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 40px 16px; + text-align: center; + background: var(--surface); + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + @include m.enter; + } + .state.compact { + padding: 28px 16px; + border: 0; + background: none; + } +} + +@mixin state-text { + .state-title { + margin: 0; + color: var(--text-strong); + font-size: 14px; + font-weight: 600; + } + .state-hint { + max-width: 440px; + margin: 0; + color: var(--text-2); + line-height: 1.5; + } + .state button { + margin-top: 8px; + } + .spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } + } +} diff --git a/app/src/styles/_signals.scss b/app/src/styles/_signals.scss new file mode 100644 index 00000000..dcea5904 --- /dev/null +++ b/app/src/styles/_signals.scss @@ -0,0 +1,402 @@ +// Styles shared by the Signals page's sections (`app/src/pages/signals/`), in the order the page +// had them as one component, so each section looks as it did. Include the groups a section uses, +// in this order: states, cards (or sep alone), fields, pre, node-list, node-cards, badge-sm, changed-badge, +// scroll-box, list-heading, rel-label, history, link-button, stack. +// A rule on a list's own element is written `:host(.nodes)`, which keeps the specificity the +// `.nodes` rule had on the page. +@use 'mixins' as m; + +@mixin states { + .empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + text-align: center; + padding: 40px 24px; + @include m.panel; + @include m.enter; + } + .empty.compact { + padding: 24px 16px; + border-style: dashed; + background: transparent; + } + .empty-title { + margin: 0; + color: var(--text-strong); + font-size: 14px; + font-weight: 600; + } + .hint { + max-width: 460px; + margin: 0; + color: var(--text-2); + font-size: 13px; + line-height: 1.55; + overflow-wrap: anywhere; + } + .hint code { + padding: 1px 5px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--text); + background: var(--surface-3); + border: 1px solid var(--border); + border-radius: 5px; + } + .reset { + height: 34px; + margin-top: 4px; + padding: 0 14px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: + background-color 0.15s var(--ease), + border-color 0.15s var(--ease); + } + .reset:hover { + background: var(--surface-3); + } + .reset:focus-visible { + @include m.focus-ring; + } + .spinner { + width: 18px; + height: 18px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + border-top-color: var(--border-strong); + } + } +} + +@mixin sep { + .sep { + color: var(--border-strong); + } +} + +@mixin cards { + .node-header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 8px; + min-width: 0; + } + .kind-badge { + flex: none; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10.5px; + line-height: 18px; + padding: 0 8px; + border-radius: 99px; + color: var(--kind-ink); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + } + .node-label { + min-width: 0; + font-family: var(--font-mono); + font-size: 13px; + font-weight: 500; + color: var(--text-strong); + overflow-wrap: anywhere; + } + .node-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px 6px; + min-width: 0; + margin-top: 6px; + font-size: 12px; + color: var(--text-3); + font-variant-numeric: tabular-nums; + } + .node-meta .file { + min-width: 0; + font-family: var(--font-mono); + font-size: 11.5px; + overflow-wrap: anywhere; + } + @include sep; +} + +@mixin fields { + dl { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 8px 16px; + margin: 0; + font-size: 13px; + } + dt { + @include m.label; + padding-top: 2px; + } + dd { + min-width: 0; + margin: 0; + color: var(--text); + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; + } +} + +@mixin pre { + pre { + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; + margin: 0; + color: var(--text); + } +} + +@mixin node-list { + :host(.nodes) { + display: flex; + flex-direction: column; + gap: 8px; + list-style: none; + padding: 0; + margin: 0; + @include m.enter; + } + .node-card { + display: block; + width: 100%; + min-width: 0; + text-align: left; + font: inherit; + color: inherit; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 16px; + transition: + background-color 0.15s var(--ease), + border-color 0.15s var(--ease), + box-shadow 0.15s var(--ease); + } + .node-card:hover { + background: var(--surface-2); + border-color: var(--border-strong); + } +} + +@mixin node-cards { + :host(.nodes) > li { + display: block; + min-width: 0; + padding: 0; + } + /* The empty card keeps its centred column inside the list. */ + :host(.nodes) > li.empty { + display: flex; + padding: 24px 16px; + } + button.node-card { + cursor: pointer; + } + .node-card:focus-visible { + @include m.focus-ring; + } + .node-card.selected { + background: var(--accent-soft); + border-color: var(--accent-line); + box-shadow: inset 2px 0 0 var(--accent); + } + .chevron { + flex: none; + width: 7px; + height: 7px; + margin-left: auto; + border-right: 1.5px solid var(--text-3); + border-bottom: 1.5px solid var(--text-3); + transform: translateY(-2px) rotate(45deg); + transition: + transform 0.2s var(--ease), + border-color 0.15s var(--ease); + } + .node-card:hover .chevron { + border-color: var(--text); + } + .node-card.selected .chevron { + border-color: var(--accent); + transform: translateY(2px) rotate(-135deg); + } + .node-value { + display: block; + margin-top: 8px; + padding: 4px 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-2); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + } + .node-card.selected .node-value { + background: color-mix(in srgb, var(--bg) 70%, transparent); + } +} + +@mixin badge-sm { + .kind-badge.sm { + font-size: 10px; + line-height: 16px; + padding: 0 6px; + } +} + +@mixin changed-badge { + .changed-badge { + flex: none; + font-size: 11px; + font-weight: 500; + line-height: 16px; + padding: 0 8px; + border: 1px solid; + border-radius: 99px; + font-variant-numeric: tabular-nums; + } + .changed-badge { + @include m.tint(warn); + } +} + +@mixin scroll-box { + .scroll-box:focus-visible { + @include m.focus-ring(-2px); + } +} + +@mixin list-heading { + .list-heading { + @include m.label; + margin: 16px 0 8px; + } +} + +@mixin rel-label { + .rel-label { + min-width: 0; + font-family: var(--font-mono); + font-size: 12.5px; + overflow-wrap: anywhere; + } +} + +@mixin history { + .history { + list-style: none; + padding: 4px; + margin: 0; + max-height: 320px; + overflow: auto; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + } + .history li { + display: block; + padding: 8px; + border-radius: 6px; + transition: background-color 0.15s var(--ease); + } + .history li + li { + border-top: 1px solid var(--border); + } + .history li:hover { + background: var(--surface-3); + } + .history-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + align-items: center; + margin-bottom: 4px; + font-size: 11px; + color: var(--text-2); + font-variant-numeric: tabular-nums; + } + .history-meta time { + font-family: var(--font-mono); + color: var(--text); + } + .source-tag { + line-height: 16px; + padding: 0 8px; + border-radius: 99px; + background: var(--surface-3); + border: 1px solid var(--border-strong); + color: var(--text); + } + .source-write { + color: var(--hue-info-text); + background: color-mix(in srgb, var(--hue-info) 12%, transparent); + border-color: color-mix(in srgb, var(--hue-info) 30%, transparent); + } +} + +@mixin link-button { + .link-button { + min-height: 28px; + padding: 0 8px; + border: 0; + border-radius: 6px; + background: none; + color: var(--accent-text); + font: inherit; + font-size: 12px; + font-weight: 500; + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + } + .link-button:hover { + background: var(--surface-2); + } + .link-button:focus-visible { + @include m.focus-ring; + } +} + +@mixin stack { + .stack { + margin: 6px 0 0; + padding: 0 0 0 18px; + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--text-2); + overflow-wrap: anywhere; + } + .history li .stack li { + padding: 0; + border: 0; + } +} diff --git a/app/src/styles/_store.scss b/app/src/styles/_store.scss new file mode 100644 index 00000000..0bce9628 --- /dev/null +++ b/app/src/styles/_store.scss @@ -0,0 +1,915 @@ +// Styles shared by the Store page's sections (`app/src/pages/store/`), cut from the page's one +// stylesheet in its order, so each section looks as it did. Include the groups a section uses, in +// the order of this file. A parameter names an ancestor that is the section's own element, so the +// section passes `:host` for it. +@use 'mixins' as m; + +@mixin intro { + .intro { + max-width: 720px; + margin: 0; + color: var(--text-2); + line-height: 1.5; + } +} + +@mixin field { + input { + flex: 1 1 220px; + min-width: 0; + height: 34px; + padding: 0 12px; + background: var(--bg); + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + color: var(--text); + font: inherit; + font-size: 13px; + outline: none; + transition: + border-color 0.15s var(--ease), + box-shadow 0.15s var(--ease); + } + input::placeholder { + color: var(--text-3); + } + input:focus-visible { + @include m.field-focus; + } +} + +@mixin base { + .section-head { + display: flex; + align-items: center; + gap: 8px; + min-height: 24px; + } + h2, + h4, + .sub { + @include m.label; + display: flex; + align-items: center; + gap: 8px; + margin: 0; + } + h4 { + margin-bottom: 10px; + } + .spaced { + margin-top: 16px; + } + .sub { + margin: 14px 0 8px; + } + .fact-head, + .sub-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + } + .fact-head { + margin-bottom: 10px; + } + .state-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 8px; + min-width: 0; + } + .fact-head h4 { + margin: 0; + } + .sub-head { + margin: 14px 0 8px; + } + .sub-head .sub { + margin: 0; + } + .btn.copy { + flex: none; + height: var(--control-h); + padding: 0 10px; + font-size: 12px; + } + .pill { + min-width: 20px; + padding: 0 7px; + border-radius: 99px; + background: var(--surface-2); + border: 1px solid var(--border); + color: var(--text-2); + font-size: 11px; + font-weight: 500; + letter-spacing: 0; + line-height: 18px; + text-align: center; + font-variant-numeric: tabular-nums; + } + .empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + text-align: center; + padding: 36px 24px; + @include m.panel; + @include m.enter; + } + .empty.compact { + padding: 24px 16px; + border-style: dashed; + background: transparent; + } + .empty-title { + margin: 0; + color: var(--text-strong); + font-size: 14px; + font-weight: 600; + } + .hint { + max-width: 520px; + margin: 0; + color: var(--text-2); + line-height: 1.55; + overflow-wrap: anywhere; + } + .hint.small { + margin-top: 12px; + font-size: 12px; + } + code { + font-family: var(--font-mono); + font-size: 12px; + } + .hint code, + .refs code { + padding: 1px 5px; + color: var(--text); + background: var(--surface-3); + border: 1px solid var(--border); + border-radius: 5px; + } + .muted { + margin: 0; + color: var(--text-2); + } + .pad { + padding: 20px 12px; + text-align: center; + line-height: 1.5; + } + .btn { + height: var(--control-h); + padding: 0 14px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: + background-color 0.15s var(--ease), + border-color 0.15s var(--ease); + } + .btn:hover { + background: var(--surface-3); + } + .btn:focus-visible { + @include m.focus-ring; + } + .btn:disabled { + opacity: 0.6; + cursor: default; + } + .btn.primary, + .btn.restore { + border-color: var(--accent-line); + background: var(--accent-soft); + color: var(--text-strong); + } + .btn.restore { + margin-top: 14px; + } +} + +@mixin lists { + .live-layout { + display: grid; + grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); + min-width: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + @include m.enter; + } + ul { + list-style: none; + margin: 0; + padding: 0; + } + .store-list { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px; + border-right: 1px solid var(--border); + background: var(--surface-2); + } + .store-item, + .log-item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; + width: 100%; + padding: 8px 10px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background-color 0.15s var(--ease); + } + .store-item:hover, + .log-item:hover, + .event-item:hover { + background: var(--surface-3); + } + .store-item:focus-visible, + .log-item:focus-visible, + .event-item:focus-visible { + @include m.focus-ring(-2px); + } + .store-item.selected, + .log-item.selected, + .event-item.selected { + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent); + } + .store-top { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + max-width: 100%; + } + .store-name { + @include m.truncate; + font-family: var(--font-mono); + font-weight: 600; + color: var(--text-strong); + } + .store-meta, + .log-meta { + color: var(--text-2); + font-size: 11.5px; + font-variant-numeric: tabular-nums; + } + .dot { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + } +} + +@mixin detail { + .store-detail { + display: grid; + gap: 16px; + min-width: 0; + padding: 16px; + } + .detail-head h3 { + margin: 0 0 8px; + color: var(--accent); + font-family: var(--font-mono); + font-size: 15px; + font-weight: 600; + overflow-wrap: anywhere; + } + .chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 1px 8px; + border: 1px solid var(--border); + border-radius: 99px; + background: var(--surface-2); + color: var(--text-2); + font-size: 11.5px; + line-height: 18px; + overflow-wrap: anywhere; + } + .chip.mono { + font-family: var(--font-mono); + } + .refs { + margin: 10px 0 0; + color: var(--text-2); + line-height: 1.8; + } + .facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; + min-width: 0; + } + .fact { + min-width: 0; + } +} + +@mixin code { + pre { + margin: 0; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--text); + } + .tree, + .code { + max-height: 360px; + overflow: auto; + padding: 10px 12px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + } + .code { + max-height: 220px; + } + .tree:focus-visible { + @include m.focus-ring; + } + .kv { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 6px 14px; + margin: 0; + } + /* The same gap the NgRx entry tabs keep below Time. */ + .kv.redux-action { + margin-top: 10px; + } + .kv dt { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-2); + } + .kv dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + } +} + +@mixin methods { + .methods { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .methods li { + flex-wrap: wrap; + row-gap: 2px; + } + .tag { + padding: 0 5px; + border-radius: 4px; + background: var(--accent-soft); + color: var(--text-strong); + font-size: 10.5px; + } + .calls { + color: var(--text-3); + font-family: var(--font-sans, inherit); + font-size: 11px; + } + .entities { + display: grid; + gap: 14px; + } + .entity-group { + display: grid; + gap: 8px; + } + .entity-head { + display: flex; + align-items: center; + gap: 8px; + } + .entity-count { + color: var(--text-2); + font-size: 11.5px; + font-variant-numeric: tabular-nums; + } + .entity-ids { + margin: 0; + } + .chip.more { + border-style: dashed; + color: var(--text-3); + } +} + +@mixin events { + .events-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 260px; + overflow: auto; + padding: 6px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + @include m.enter; + } + .event-item { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 10px; + width: 100%; + min-width: 0; + padding: 7px 10px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background-color 0.15s var(--ease); + } + .event-type { + flex: none; + font-weight: 600; + color: var(--text-strong); + font-size: 12.5px; + } + .event-payload { + flex: 1 1 160px; + min-width: 0; + @include m.truncate; + color: var(--text-2); + } + .event-time { + flex: none; + margin-left: auto; + color: var(--text-3); + font-size: 11.5px; + font-variant-numeric: tabular-nums; + } +} + +@mixin log { + .log { + min-width: 0; + padding-top: 16px; + border-top: 1px solid var(--border); + } + .live-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin: 0 0 8px; + } + .log-layout { + display: grid; + grid-template-columns: min(var(--log-list-size, 300px), 60%) auto minmax(0, 1fr); + gap: 16px; + min-width: 0; + } + .log-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 420px; + overflow: auto; + padding: 2px; + } + .log-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + column-gap: 8px; + row-gap: 2px; + } + .seq { + color: var(--text-3); + font-size: 11.5px; + font-variant-numeric: tabular-nums; + } + .log-type-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + } + .log-type { + @include m.truncate; + min-width: 0; + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--text); + } + .log-meta { + grid-column: 2; + } +} + +@mixin entry($entry: '.entry') { + .entry { + min-width: 0; + padding: 14px; + border: 1px solid var(--accent-line); + border-radius: var(--radius-sm); + @include m.enter(0.25s); + } + #{$entry} .entry-heading { + margin: 0 0 12px; + font-family: var(--font-mono); + font-size: 13px; + font-weight: 600; + color: var(--text-strong); + overflow-wrap: anywhere; + } + .diff { + display: grid; + gap: 4px; + } + .diff-row { + display: grid; + grid-template-columns: auto minmax(0, max-content) minmax(0, 1fr); + gap: 8px; + align-items: baseline; + padding: 5px 8px; + border-radius: 6px; + background: var(--surface-2); + } + .op { + font-size: 10.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-2); + } + .diff-row.add .op { + color: var(--ok); + } + .diff-row.remove .op { + color: var(--danger); + } + .path { + color: var(--text-strong); + overflow-wrap: anywhere; + } + .values { + display: flex; + flex-wrap: wrap; + gap: 4px 6px; + min-width: 0; + overflow-wrap: anywhere; + } + .before { + color: var(--text-2); + text-decoration: line-through; + } + .after { + color: var(--text); + } + .arrow { + color: var(--text-3); + } +} + +@mixin actions { + .confirm { + margin-top: 14px; + padding: 12px; + border: 1px solid var(--accent-line); + border-radius: var(--radius-sm); + background: var(--accent-soft); + } + .confirm p { + margin: 0 0 10px; + line-height: 1.5; + } + .actions, + .entry-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .entry-actions { + margin-top: 14px; + } + .entry-actions .btn.restore { + margin-top: 0; + } +} + +@mixin dispatch { + .dispatch { + min-width: 0; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); + } + .dispatch-form { + display: grid; + gap: 10px; + max-width: 560px; + } + .dispatch-form label { + display: grid; + gap: 6px; + color: var(--text-2); + font-size: 12px; + font-weight: 500; + } + .optional { + color: var(--text-3); + font-weight: 400; + } + .dispatch-form input { + width: 100%; + font-family: var(--font-mono); + font-size: 12.5px; + } + textarea { + width: 100%; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font-family: var(--font-mono); + font-size: 12px; + resize: vertical; + transition: + border-color 0.15s var(--ease), + box-shadow 0.15s var(--ease); + } + textarea::placeholder { + color: var(--text-3); + } + textarea:focus-visible { + @include m.field-focus; + } + textarea[aria-invalid='true'] { + border-color: color-mix(in srgb, var(--danger) 60%, transparent); + } + .field-error { + margin: -4px 0 0; + color: var(--danger); + font-size: 12px; + } + .field-error:empty { + display: none; + } +} + +@mixin history-stats($history: '.history', $stats: '.stats') { + .history, + .stats { + min-width: 0; + margin-top: 16px; + } + #{$history} h4, + #{$stats} h4 { + margin: 0 0 6px; + } + #{$history} .actions { + margin-top: 8px; + } + .table-wrap { + max-height: 280px; + overflow: auto; + margin-top: 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + } + .table-wrap:focus-visible { + @include m.focus-ring(-2px); + } + #{$stats} table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + } + #{$stats} th, + #{$stats} td { + padding: 5px 8px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: baseline; + } + #{$stats} thead th { + position: sticky; + top: 0; + background: var(--surface-2); + color: var(--text-2); + font-weight: 600; + } + #{$stats} tbody th { + font-weight: 400; + color: var(--text-strong); + overflow-wrap: anywhere; + } + #{$stats} .num { + text-align: right; + white-space: nowrap; + } + .mono { + font-family: var(--font-mono); + } + .loop { + display: inline-block; + margin-left: 6px; + padding: 0 5px; + border: 1px solid; + border-radius: 4px; + font-family: var(--font-sans, inherit); + font-size: 10.5px; + @include m.tint(warn); + } +} + +@mixin trace { + .trace { + display: grid; + gap: 2px; + margin: 0; + padding-left: 18px; + font-family: var(--font-mono); + font-size: 12px; + } +} + +@mixin paused { + .paused { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; + margin-bottom: 14px; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--warn) 10%, transparent); + } + .paused p { + flex: 1 1 240px; + margin: 0; + line-height: 1.5; + } + .paused .hint.small { + flex-basis: 100%; + margin-top: 0; + } +} + +@mixin message { + .message:empty { + display: none; + } + .message { + margin: 0; + color: var(--text-2); + } +} + +@mixin source { + .kinds { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .kind-chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + height: 28px; + padding: 0 10px; + border: 1px solid var(--control-border); + border-radius: 99px; + background: var(--surface-2); + color: var(--text-2); + font: inherit; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: + background-color 0.15s var(--ease), + border-color 0.15s var(--ease), + color 0.15s var(--ease); + } + .kind-chip:hover { + border-color: var(--text-3); + background: var(--surface-3); + color: var(--text); + } + .kind-chip.active { + border-color: var(--accent-line); + background: var(--accent-soft); + color: var(--text-strong); + } + .kind-chip:focus-visible { + @include m.focus-ring; + } + .chip-text { + @include m.truncate; + } + .kind-chip .count { + flex: none; + min-width: 18px; + padding: 0 5px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 11px; + line-height: 16px; + text-align: center; + font-variant-numeric: tabular-nums; + } + .nodes { + display: flex; + flex-direction: column; + gap: 8px; + @include m.enter; + } + .node-card { + min-width: 0; + padding: 12px 16px; + @include m.panel; + border-radius: var(--radius-sm); + } + .node-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + min-width: 0; + } + .node-label { + min-width: 0; + font-family: var(--font-mono); + font-weight: 500; + color: var(--text-strong); + overflow-wrap: anywhere; + } + .node-meta { + margin-top: 6px; + color: var(--text-3); + font-size: 12px; + } + .file { + font-family: var(--font-mono); + font-size: 11.5px; + overflow-wrap: anywhere; + } + .detail { + margin: 6px 0 0; + color: var(--text-2); + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; + } +} + +@mixin spinner { + .spinner { + width: 18px; + height: 18px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } +} diff --git a/apps/docs/src/content/contributing/development.md b/apps/docs/src/content/contributing/development.md index 45756c33..2a3935a1 100644 --- a/apps/docs/src/content/contributing/development.md +++ b/apps/docs/src/content/contributing/development.md @@ -282,7 +282,7 @@ The panel's devframe client caches the functions listed in `PANEL_CACHED_RPC` in 2. Import and add it to `app/src/app.ts` (imports array, tabs array, template switch). 3. Add a card to `app/src/pages/dashboard.ts`. -`pnpm lint` keeps the TypeScript sources in `app/src` and `packages/devtools/src` short with a ratchet (tests are left out): a new file can't go over 1,500 lines, and a file already over it in `lint-baseline.json` can't grow. Split a large page into a folder next to it, as `app/src/pages/network/` does for the SSR & HTTP tab. Each section is a component that attaches to the page's existing `
        ` element, so the DOM stays the same. State the sections share lives in services the page provides, and shared styles are SCSS mixins in `app/src/styles/`, such as `_network.scss`. +`pnpm lint` keeps the TypeScript sources in `app/src` and `packages/devtools/src` short with a ratchet (tests are left out): a new file can't go over 1,500 lines, and a file already over it in `lint-baseline.json` can't grow. Split a large page into a folder next to it, as `app/src/pages/network/` does for the SSR & HTTP tab. Each section is a component that attaches to the page's existing `
        ` element, so the DOM stays the same. State the sections share lives in services the page provides, and shared styles are SCSS mixins in `app/src/styles/`, such as `_network.scss`. Each component keeps its template and styles in its own files, `templateUrl: './x.html'` and `styleUrl: './x.scss'` next to `x.ts`, not inline. ### Add an agent tool diff --git a/extension/ui/assets/browser-agent-rpc-KH4HHvHf-DF8dhgzY.js b/extension/ui/assets/browser-agent-rpc-KH4HHvHf-uq6ycX2u.js similarity index 95% rename from extension/ui/assets/browser-agent-rpc-KH4HHvHf-DF8dhgzY.js rename to extension/ui/assets/browser-agent-rpc-KH4HHvHf-uq6ycX2u.js index 02088227..05714ed2 100644 --- a/extension/ui/assets/browser-agent-rpc-KH4HHvHf-DF8dhgzY.js +++ b/extension/ui/assets/browser-agent-rpc-KH4HHvHf-uq6ycX2u.js @@ -1 +1 @@ -import{t as e}from"./index-fAMsQf9b.js";var t=Symbol.for(`devframe:browser-agent-registry`),{tools:n,listeners:r}=globalThis[t]??={tools:new Map,listeners:new Set};function i(){return[...n.values()]}function a(e){return r.add(e),()=>r.delete(e)}var o=`devframe:client-id`,s;function c(t=globalThis.window){try{let n=t?.sessionStorage;if(n){let t=n.getItem(o);return t||(t=e(),n.setItem(o,t)),t}}catch{}return s??=e(),s}function l(){let e=typeof document>`u`?void 0:document;return{url:e?.location?.href??``,title:e?.title??``,visible:!e||e.visibilityState===`visible`,focused:e?.hasFocus()??!0}}function u(e){e.client.register({name:`devframe:agent:invoke-client-tool`,type:`action`,jsonSerializable:!0,handler:async(e,t)=>{let n=i().find(t=>t.id===e);if(!n)throw Error(`[devframe/agent] browser tool "${e}" not found`);return await n.invoke(t)}});let t=!1,n=!1,r=0,o=()=>{t||n||(t=!0,queueMicrotask(async()=>{if(t=!1,n)return;let a=i().map(({invoke:e,...t})=>t);(a.length!==0||r!==0)&&(r=a.length,await e.callOptional(`devframe:agent:sync-client-tools`,c(),a,l()).catch(()=>{}))}))},s=a(o),u=e.events.on(`connection:status`,e=>{e===`connected`&&o()}),d=typeof window>`u`?void 0:window;return d?.addEventListener(`focus`,o),d?.addEventListener(`blur`,o),d?.document.addEventListener(`visibilitychange`,o),o(),()=>{n=!0,s(),u(),d?.removeEventListener(`focus`,o),d?.removeEventListener(`blur`,o),d?.document.removeEventListener(`visibilitychange`,o)}}export{u as setupBrowserAgentRpcBridge}; \ No newline at end of file +import{t as e}from"./index-xQ_9JX3C.js";var t=Symbol.for(`devframe:browser-agent-registry`),{tools:n,listeners:r}=globalThis[t]??={tools:new Map,listeners:new Set};function i(){return[...n.values()]}function a(e){return r.add(e),()=>r.delete(e)}var o=`devframe:client-id`,s;function c(t=globalThis.window){try{let n=t?.sessionStorage;if(n){let t=n.getItem(o);return t||(t=e(),n.setItem(o,t)),t}}catch{}return s??=e(),s}function l(){let e=typeof document>`u`?void 0:document;return{url:e?.location?.href??``,title:e?.title??``,visible:!e||e.visibilityState===`visible`,focused:e?.hasFocus()??!0}}function u(e){e.client.register({name:`devframe:agent:invoke-client-tool`,type:`action`,jsonSerializable:!0,handler:async(e,t)=>{let n=i().find(t=>t.id===e);if(!n)throw Error(`[devframe/agent] browser tool "${e}" not found`);return await n.invoke(t)}});let t=!1,n=!1,r=0,o=()=>{t||n||(t=!0,queueMicrotask(async()=>{if(t=!1,n)return;let a=i().map(({invoke:e,...t})=>t);(a.length!==0||r!==0)&&(r=a.length,await e.callOptional(`devframe:agent:sync-client-tools`,c(),a,l()).catch(()=>{}))}))},s=a(o),u=e.events.on(`connection:status`,e=>{e===`connected`&&o()}),d=typeof window>`u`?void 0:window;return d?.addEventListener(`focus`,o),d?.addEventListener(`blur`,o),d?.document.addEventListener(`visibilitychange`,o),o(),()=>{n=!0,s(),u(),d?.removeEventListener(`focus`,o),d?.removeEventListener(`blur`,o),d?.document.removeEventListener(`visibilitychange`,o)}}export{u as setupBrowserAgentRpcBridge}; \ No newline at end of file diff --git a/extension/ui/assets/index-fAMsQf9b.js b/extension/ui/assets/index-fAMsQf9b.js deleted file mode 100644 index 5ade7d63..00000000 --- a/extension/ui/assets/index-fAMsQf9b.js +++ /dev/null @@ -1,30660 +0,0 @@ -(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e=Object.defineProperty,t=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,r=Object.prototype.propertyIsEnumerable,i=(t,n,r)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[n]=r,a=(e,a)=>{for(var o in a||={})n.call(a,o)&&i(e,o,a[o]);if(t)for(var o of t(a))r.call(a,o)&&i(e,o,a[o]);return e},o=(e,t,n)=>(i(e,typeof t==`symbol`?t:t+``,n),n),s=globalThis;function c(e){let t=s.__Zone_symbol_prefix;return(typeof t==`string`?t:`__zone_symbol__`)+e}function l(){let e=s.performance;function t(t){e&&e.mark&&e.mark(t)}function n(t,n){e&&e.measure&&e.measure(t,n)}t(`Zone`);let r=class e{constructor(e,t){o(this,`_parent`),o(this,`_name`),o(this,`_properties`),o(this,`_zoneDelegate`),this._parent=e,this._name=t?t.name||`unnamed`:``,this._properties=t&&t.properties||{},this._zoneDelegate=new l(this,this._parent&&this._parent._zoneDelegate,t)}static assertZonePatched(){if(s.Promise!==me.ZoneAwarePromise)throw Error("Zone.js has detected that ZoneAwarePromise `(window|global).Promise` has been overwritten.\nMost likely cause is that a Promise polyfill has been loaded after Zone.js (Polyfilling Promise api is not necessary when zone.js is loaded. If you must load one, do so before loading zone.js.)")}static get root(){let t=e.current;for(;t.parent;)t=t.parent;return t}static get current(){return ge.zone}static get currentTask(){return _e}static __load_patch(r,i,a=!1){if(Object.hasOwn(me,r)){let e=s[c(`forceDuplicateZoneCheck`)]===!0;if(!a&&e)throw Error(`Already loaded patch: `+r)}else if(!s[`__Zone_disable_`+r]){let a=`Zone:`+r;t(a),me[r]=i(s,e,he),n(a,a)}}get parent(){return this._parent}get name(){return this._name}get(e){let t=this.getZoneWith(e);if(t)return t._properties[e]}getZoneWith(e){let t=this;for(;t;){if(Object.hasOwn(t._properties,e))return t;t=t._parent}return null}fork(e){if(!e)throw Error(`ZoneSpec required!`);return this._zoneDelegate.fork(this,e)}wrap(e,t){if(typeof e!=`function`)throw Error(`Expecting function got: `+e);let n=this._zoneDelegate.intercept(this,e,t),r=this;return function(){return r.runGuarded(n,this,arguments,t)}}run(e,t,n,r){ge={parent:ge,zone:this};try{return this._zoneDelegate.invoke(this,e,t,n,r)}finally{ge=ge.parent}}runGuarded(e,t=null,n,r){ge={parent:ge,zone:this};try{try{return this._zoneDelegate.invoke(this,e,t,n,r)}catch(e){if(this._zoneDelegate.handleError(this,e))throw e}}finally{ge=ge.parent}}runTask(e,t,n){if(e.zone!=this)throw Error(`A task can only be run in the zone of creation! (Creation: `+(e.zone||ie).name+`; Execution: `+this.name+`)`);let r=e,{type:i,data:{isPeriodic:a=!1,isRefreshable:o=!1}={}}=e;if(e.state===ae&&(i===pe||i===fe))return;let s=e.state!=ce;s&&r._transitionTo(ce,se);let c=_e;_e=r,ge={parent:ge,zone:this};try{i==fe&&e.data&&!a&&!o&&(e.cancelFn=void 0);try{return this._zoneDelegate.invokeTask(this,r,t,n)}catch(e){if(this._zoneDelegate.handleError(this,e))throw e}}finally{let t=e.state;if(t!==ae&&t!==ue){if(i==pe||a||o&&t===oe)s&&r._transitionTo(se,ce,oe);else{let e=r._zoneDelegates;this._updateTaskCount(r,-1),s&&r._transitionTo(ae,ce,ae),o&&(r._zoneDelegates=e)}}ge=ge.parent,_e=c}}scheduleTask(e){if(e.zone&&e.zone!==this){let t=this;for(;t;){if(t===e.zone)throw Error(`can not reschedule task to ${this.name} which is descendants of the original zone ${e.zone.name}`);t=t.parent}}e._transitionTo(oe,ae);let t=[];e._zoneDelegates=t,e._zone=this;try{e=this._zoneDelegate.scheduleTask(this,e)}catch(t){throw e._transitionTo(ue,oe,ae),this._zoneDelegate.handleError(this,t),t}return e._zoneDelegates===t&&this._updateTaskCount(e,1),e.state==oe&&e._transitionTo(se,oe),e}scheduleMicroTask(e,t,n,r){return this.scheduleTask(new u(de,e,t,n,r,void 0))}scheduleMacroTask(e,t,n,r,i){return this.scheduleTask(new u(fe,e,t,n,r,i))}scheduleEventTask(e,t,n,r,i){return this.scheduleTask(new u(pe,e,t,n,r,i))}cancelTask(e){if(e.zone!=this)throw Error(`A task can only be cancelled in the zone of creation! (Creation: `+(e.zone||ie).name+`; Execution: `+this.name+`)`);if(e.state===se||e.state===ce){e._transitionTo(le,se,ce);try{this._zoneDelegate.cancelTask(this,e)}catch(t){throw e._transitionTo(ue,le),this._zoneDelegate.handleError(this,t),t}return this._updateTaskCount(e,-1),e._transitionTo(ae,le),e.runCount=-1,e}}_updateTaskCount(e,t){let n=e._zoneDelegates;t==-1&&(e._zoneDelegates=null);for(let r=0;re.hasTask(n,r),onScheduleTask:(e,t,n,r)=>e.scheduleTask(n,r),onInvokeTask:(e,t,n,r,i,a)=>e.invokeTask(n,r,i,a),onCancelTask:(e,t,n,r)=>e.cancelTask(n,r)};class l{constructor(e,t,n){o(this,`_zone`),o(this,`_taskCounts`,{microTask:0,macroTask:0,eventTask:0}),o(this,`_forkDlgt`),o(this,`_forkZS`),o(this,`_forkCurrZone`),o(this,`_interceptDlgt`),o(this,`_interceptZS`),o(this,`_interceptCurrZone`),o(this,`_invokeDlgt`),o(this,`_invokeZS`),o(this,`_invokeCurrZone`),o(this,`_handleErrorDlgt`),o(this,`_handleErrorZS`),o(this,`_handleErrorCurrZone`),o(this,`_scheduleTaskDlgt`),o(this,`_scheduleTaskZS`),o(this,`_scheduleTaskCurrZone`),o(this,`_invokeTaskDlgt`),o(this,`_invokeTaskZS`),o(this,`_invokeTaskCurrZone`),o(this,`_cancelTaskDlgt`),o(this,`_cancelTaskZS`),o(this,`_cancelTaskCurrZone`),o(this,`_hasTaskDlgt`),o(this,`_hasTaskDlgtOwner`),o(this,`_hasTaskZS`),o(this,`_hasTaskCurrZone`),this._zone=e,this._forkZS=n&&(n&&n.onFork?n:t._forkZS),this._forkDlgt=n&&(n.onFork?t:t._forkDlgt),this._forkCurrZone=n&&(n.onFork?this._zone:t._forkCurrZone),this._interceptZS=n&&(n.onIntercept?n:t._interceptZS),this._interceptDlgt=n&&(n.onIntercept?t:t._interceptDlgt),this._interceptCurrZone=n&&(n.onIntercept?this._zone:t._interceptCurrZone),this._invokeZS=n&&(n.onInvoke?n:t._invokeZS),this._invokeDlgt=n&&(n.onInvoke?t:t._invokeDlgt),this._invokeCurrZone=n&&(n.onInvoke?this._zone:t._invokeCurrZone),this._handleErrorZS=n&&(n.onHandleError?n:t._handleErrorZS),this._handleErrorDlgt=n&&(n.onHandleError?t:t._handleErrorDlgt),this._handleErrorCurrZone=n&&(n.onHandleError?this._zone:t._handleErrorCurrZone),this._scheduleTaskZS=n&&(n.onScheduleTask?n:t._scheduleTaskZS),this._scheduleTaskDlgt=n&&(n.onScheduleTask?t:t._scheduleTaskDlgt),this._scheduleTaskCurrZone=n&&(n.onScheduleTask?this._zone:t._scheduleTaskCurrZone),this._invokeTaskZS=n&&(n.onInvokeTask?n:t._invokeTaskZS),this._invokeTaskDlgt=n&&(n.onInvokeTask?t:t._invokeTaskDlgt),this._invokeTaskCurrZone=n&&(n.onInvokeTask?this._zone:t._invokeTaskCurrZone),this._cancelTaskZS=n&&(n.onCancelTask?n:t._cancelTaskZS),this._cancelTaskDlgt=n&&(n.onCancelTask?t:t._cancelTaskDlgt),this._cancelTaskCurrZone=n&&(n.onCancelTask?this._zone:t._cancelTaskCurrZone),this._hasTaskZS=null,this._hasTaskDlgt=null,this._hasTaskDlgtOwner=null,this._hasTaskCurrZone=null;let r=n&&n.onHasTask,i=t&&t._hasTaskZS;(r||i)&&(this._hasTaskZS=r?n:a,this._hasTaskDlgt=t,this._hasTaskDlgtOwner=this,this._hasTaskCurrZone=this._zone,n.onScheduleTask||(this._scheduleTaskZS=a,this._scheduleTaskDlgt=t,this._scheduleTaskCurrZone=this._zone),n.onInvokeTask||(this._invokeTaskZS=a,this._invokeTaskDlgt=t,this._invokeTaskCurrZone=this._zone),n.onCancelTask||(this._cancelTaskZS=a,this._cancelTaskDlgt=t,this._cancelTaskCurrZone=this._zone))}get zone(){return this._zone}fork(e,t){return this._forkZS?this._forkZS.onFork(this._forkDlgt,this.zone,e,t):new i(e,t)}intercept(e,t,n){return this._interceptZS?this._interceptZS.onIntercept(this._interceptDlgt,this._interceptCurrZone,e,t,n):t}invoke(e,t,n,r,i){return this._invokeZS?this._invokeZS.onInvoke(this._invokeDlgt,this._invokeCurrZone,e,t,n,r,i):t.apply(n,r)}handleError(e,t){return!this._handleErrorZS||this._handleErrorZS.onHandleError(this._handleErrorDlgt,this._handleErrorCurrZone,e,t)}scheduleTask(e,t){let n=t;if(this._scheduleTaskZS)this._hasTaskZS&&n._zoneDelegates.push(this._hasTaskDlgtOwner),n=this._scheduleTaskZS.onScheduleTask(this._scheduleTaskDlgt,this._scheduleTaskCurrZone,e,t),n||=t;else if(t.scheduleFn)t.scheduleFn(t);else if(t.type==de)ne(t);else throw Error(`Task is missing scheduleFn.`);return n}invokeTask(e,t,n,r){return this._invokeTaskZS?this._invokeTaskZS.onInvokeTask(this._invokeTaskDlgt,this._invokeTaskCurrZone,e,t,n,r):t.callback.apply(n,r)}cancelTask(e,t){let n;if(this._cancelTaskZS)n=this._cancelTaskZS.onCancelTask(this._cancelTaskDlgt,this._cancelTaskCurrZone,e,t);else{if(!t.cancelFn)throw Error(`Task is not cancelable`);n=t.cancelFn(t)}return n}hasTask(e,t){try{this._hasTaskZS&&this._hasTaskZS.onHasTask(this._hasTaskDlgt,this._hasTaskCurrZone,e,t)}catch(t){this.handleError(e,t)}}_updateTaskCount(e,t){let n=this._taskCounts,r=n[e],i=n[e]=r+t;if(i<0)throw Error(`More tasks executed then were scheduled.`);if(r==0||i==0){let t={microTask:n.microTask>0,macroTask:n.macroTask>0,eventTask:n.eventTask>0,change:e};this.hasTask(this._zone,t)}}}class u{constructor(e,t,n,r,i,a){if(o(this,`type`),o(this,`source`),o(this,`invoke`),o(this,`callback`),o(this,`data`),o(this,`scheduleFn`),o(this,`cancelFn`),o(this,`_zone`,null),o(this,`runCount`,0),o(this,`_zoneDelegates`,null),o(this,`_state`,`notScheduled`),this.type=e,this.source=t,this.data=r,this.scheduleFn=i,this.cancelFn=a,!n)throw Error(`callback is not defined`);this.callback=n;let c=this;this.invoke=e===pe&&r&&r.useG?u.invokeTask:function(){return u.invokeTask.call(s,c,this,arguments)}}static invokeTask(e,t,n){e||=this,ve++;try{return e.runCount++,e.zone.runTask(e,t,n)}finally{try{ve===1&&!s[m]&&re()}finally{ve--}}}get zone(){return this._zone}get state(){return this._state}cancelScheduleRequest(){this._transitionTo(ae,oe)}_transitionTo(e,t,n){if(this._state===t||this._state===n)this._state=e,e==ae&&(this._zoneDelegates=null);else throw Error(`${this.type} '${this.source}': can not transition to '${e}', expecting state '${t}'${n?` or '`+n+`'`:``}, was '${this._state}'.`)}toString(){return this.data&&this.data.handleId!==void 0?this.data.handleId.toString():Object.prototype.toString.call(this)}toJSON(){return{type:this.type,state:this.state,source:this.source,zone:this.zone.name,runCount:this.runCount}}}let d=c(`setTimeout`),f=c(`Promise`),p=c(`then`),m=c(`enable_native_microtask_draining`),ee=[],h=!1,g;function te(e){!g&&s[f]&&(g=s[f].resolve(0)),g?(g[p]??g.then).call(g,e):s[d](e,0)}function ne(e){let t=s[m],n=t&&ee.length===0&&!h,r=!t&&ve===0&&ee.length===0;(n||r)&&te(re),e&&ee.push(e)}function re(){if(!h){h=!0;try{for(;ee.length;){let e=ee;ee=[];for(let t of e)try{t.zone.runTask(t,null,null)}catch(e){he.onUnhandledError(e)}}}finally{if(s[m])h=!1,he.microtaskDrainDone();else try{he.microtaskDrainDone()}finally{h=!1}}}}let ie={name:`NO ZONE`},ae=`notScheduled`,oe=`scheduling`,se=`scheduled`,ce=`running`,le=`canceling`,ue=`unknown`,de=`microTask`,fe=`macroTask`,pe=`eventTask`,me=Object.create(null),he={symbol:c,currentZoneFrame:()=>ge,onUnhandledError:ye,microtaskDrainDone:ye,scheduleMicroTask:ne,showUncaughtError:()=>!i[c(`ignoreConsoleErrorUncaughtError`)],patchEventTarget:()=>[],patchOnProperties:ye,patchMethod:()=>ye,bindArguments:()=>[],patchThen:()=>ye,patchMacroTask:()=>ye,patchEventPrototype:()=>ye,getGlobalObjects:()=>void 0,ObjectDefineProperty:()=>ye,ObjectGetOwnPropertyDescriptor:()=>void 0,ObjectCreate:()=>void 0,ArraySlice:()=>[],patchClass:()=>ye,wrapWithCurrentZone:()=>ye,filterProperties:()=>[],attachOriginToPatched:()=>ye,_redefineProperty:()=>ye,patchCallbacks:()=>ye,nativeScheduleMicroTask:te},ge={parent:null,zone:new i(null,null)},_e=null,ve=0;function ye(){}return n(`Zone`,`Zone`),i}function u(){let e=globalThis,t=e[c(`forceDuplicateZoneCheck`)]===!0;if(e.Zone&&(t||typeof e.Zone.__symbol__!=`function`))throw Error(`Zone already loaded.`);return e.Zone??=l(),e.Zone}var d=Object.getOwnPropertyDescriptor,f=Object.defineProperty,p=Object.getPrototypeOf,m=Object.create,ee=Array.prototype.slice,h=`addEventListener`,g=`removeEventListener`,te=c(h),ne=c(g),re=`true`,ie=`false`,ae=c(``);function oe(e,t){return Zone.current.wrap(e,t)}function se(e,t,n,r,i){return Zone.current.scheduleMacroTask(e,t,n,r,i)}var ce=c,le=typeof window<`u`,ue=le?window:void 0,de=le&&ue||globalThis,fe=`removeAttribute`;function pe(e,t){for(let n=e.length-1;n>=0;n--)typeof e[n]==`function`&&(e[n]=oe(e[n],t+`_`+n));return e}function me(e,t){let n=e.constructor.name;for(let r=0;r{let t=function(){return e.apply(this,pe(arguments,n+`.`+i))};return ke(t,e),t})(a)}}}function he(e){return e?e.writable===!1?!1:typeof e.get!=`function`||e.set!==void 0:!0}var ge=typeof WorkerGlobalScope<`u`&&self instanceof WorkerGlobalScope,_e=!(`nw`in de)&&de.process!==void 0&&de.process.toString()===`[object process]`,ve=!_e&&!ge&&!!(le&&ue.HTMLElement),ye=de.process!==void 0&&de.process.toString()===`[object process]`&&!ge&&!!(le&&ue.HTMLElement),be=Object.create(null),xe=ce(`enable_beforeunload`),Se=function(e){if(e||=de.event,!e)return;let t=be[e.type];t||=be[e.type]=ce(`ON_PROPERTY`+e.type);let n=this||e.target||de,r=n[t],i;if(ve&&n===ue&&e.type===`error`){let t=e;i=r&&r.call(this,t.message,t.filename,t.lineno,t.colno,t.error),i===!0&&e.preventDefault()}else i=r&&r.apply(this,arguments),e.type===`beforeunload`&&de[xe]&&typeof i==`string`?e.returnValue=i:i!=null&&!i&&e.preventDefault();return i};function Ce(e,t,n){let r=d(e,t);if(!r&&n&&d(n,t)&&(r={enumerable:!0,configurable:!0}),!r||!r.configurable)return;let i=ce(`on`+t+`patched`);if(Object.hasOwn(e,i)&&e[i])return;delete r.writable,delete r.value;let a=r.get,o=r.set,s=t.slice(2),c=be[s];c||=be[s]=ce(`ON_PROPERTY`+s),r.set=function(t){let n=this;!n&&e===de&&(n=de),n&&(typeof n[c]==`function`&&n.removeEventListener(s,Se),o?.call(n,null),n[c]=t,typeof t==`function`&&n.addEventListener(s,Se,!1))},r.get=function(){let n=this;if(!n&&e===de&&(n=de),!n)return null;let i=n[c];if(i)return i;if(a){let e=a.call(this);if(e)return r.set.call(this,e),typeof n[fe]==`function`&&n.removeAttribute(t),e}return null},f(e,t,r),e[i]=!0}function we(e,t,n){if(t)for(let r=0;rfunction(t,r){let a=n(t,r);return a.cbIdx>=0&&typeof r[a.cbIdx]==`function`?se(a.name,r[a.cbIdx],a,i):e.apply(t,r)})}function ke(e,t){e[ce(`OriginalDelegate`)]=t}function Ae(e){return typeof e==`function`}function je(e){return typeof e==`number`}var Me={useG:!0},Ne=Object.create(null),Pe={},Fe=RegExp(`^`+ae+`(\\w+)(true|false)$`),Ie=ce(`propagationStopped`),Le=[`capture`,`once`,`passive`,`signal`];function Re(e,t){let n=(t?t(e):e)+ie,r=(t?t(e):e)+re,i=ae+n,a=ae+r;Ne[e]={[ie]:i,[re]:a}}function ze(e,t,n,r){let i=r&&r.add||h,o=r&&r.rm||g,s=r&&r.listeners||`eventListeners`,c=r&&r.rmAll||`removeAllListeners`,l=ce(i),u=`.`+i+`:`,d=function(e,t,n){if(e.isRemoved)return;let r=e.callback;typeof r==`object`&&r.handleEvent&&(e.callback=e=>r.handleEvent(e),e.originalDelegate=r);let i;try{e.invoke(e,t,[n])}catch(e){i=e}let a=e.options;if(a&&typeof a==`object`&&a.once){let r=e.originalDelegate?e.originalDelegate:e.callback;t[o].call(t,n.type,r,a)}return i};function f(n,r,i){if(r||=e.event,!r)return;let a=n||r.target||e,o=a[Ne[r.type][i?re:ie]];if(o){let e=[];if(o.length===1){let t=d(o[0],a,r);t&&e.push(t)}else{let t=o.slice();for(let n=0;n{throw r})}}}let m=function(e){return f(this,e,!1)},ee=function(e){return f(this,e,!0)};function te(t,n){if(!t)return!1;let r=!0;n&&n.useG!==void 0&&(r=n.useG);let d=n&&n.vh,f=!0;n&&n.chkDup!==void 0&&(f=n.chkDup);let h=!1;n&&n.rt!==void 0&&(h=n.rt);let g=t;for(;g&&!Object.hasOwn(g,i);)g=p(g);if(!g&&t[i]&&(g=t),!g||g[l])return!1;let te=n&&n.eventNameToString,ne={},oe=g[l]=g[i],se=g[ce(o)]=g[o],le=g[ce(s)]=g[s],ue=g[ce(c)]=g[c],de;n&&n.prepend&&(de=g[ce(n.prepend)]=g[n.prepend]);function fe(e,t){return t?typeof e==`boolean`?{capture:e,passive:!0}:e?(typeof e==`object`&&e.passive!==!1&&(e.passive=!0),e):{passive:!0}:e}let pe=function(e){if(!ne.isExisting)return oe.call(ne.target,ne.eventName,ne.capture?ee:m,ne.options)},me=function(e){if(!e.isRemoved){let t=Ne[e.eventName],n;t&&(n=t[e.capture?re:ie]);let r=n&&e.target[n];if(r){for(let t=0;tve.zone.cancelTask(ve);t.call(g,`abort`,e,{once:!0}),ve.removeAbortListener=()=>g.removeEventListener(`abort`,e)}if(ne.target=null,ge&&(ge.taskData=null),oe&&(ne.options.once=!0),typeof ve.options!=`boolean`&&(ve.options=h),ve.target=l,ve.capture=ae,ve.eventName=u,m&&(ve.originalDelegate=p),c?ue.unshift(ve):ue.push(ve),s)return l}};return g[i]=Te(oe,u,ye,be,h),de&&(g.prependListener=Te(de,`.prependListener:`,ge,be,h,!0)),g[o]=function(){let t=this||e,r=arguments[0];n&&n.transferEventName&&(r=n.transferEventName(r));let i=arguments[2],a=i?typeof i==`boolean`||i.capture:!1,o=arguments[1];if(!o)return se.apply(this,arguments);if(d&&!d(se,o,t,arguments))return;let s=Ne[r],c;s&&(c=s[a?re:ie]);let l=c&&t[c];if(l)for(let e=0;efunction(t,n){t[Ie]=!0,e&&e.apply(t,n)})}function He(e,t){t.patchMethod(e,`queueMicrotask`,e=>function(e,t){Zone.current.scheduleMicroTask(`queueMicrotask`,t[0])})}var Ue=ce(`zoneTask`);function We(e,t,n,r){let i=null,a=null;t+=r,n+=r;let o={};function s(t){let n=t.data;n.args[0]=function(){return t.invoke.apply(this,arguments)};let r=i.apply(e,n.args);return je(r)?n.handleId=r:(n.handle=r,n.isRefreshable=Ae(r?.refresh)),t}function c(t){let{handle:n,handleId:r}=t.data;return a.call(e,n??r)}i=De(e,t,n=>function(i,a){if(Ae(a[0])){let e={isRefreshable:!1,isPeriodic:r===`Interval`,delay:r===`Timeout`||r===`Interval`?a[1]||0:void 0,args:a},n=a[0];a[0]=function(){try{return n.apply(this,arguments)}finally{let{handle:t,handleId:n,isPeriodic:r,isRefreshable:i}=e;!r&&!i&&(n?delete o[n]:t&&(t[Ue]=null))}};let i=se(t,a[0],e,s,c);if(!i)return i;let{handleId:l,handle:u,isRefreshable:d,isPeriodic:f}=i.data;if(l)o[l]=i;else if(u&&(u[Ue]=i,d&&!f)){let e=u.refresh;u.refresh=function(){let{zone:t,state:n}=i;return n===`notScheduled`?(i._state=`scheduled`,t._updateTaskCount(i,1)):n===`running`&&(i._state=`scheduling`),e.call(this)}}return u??l??i}return n.apply(e,a)}),a=De(e,n,t=>function(n,r){let i=r[0],a;je(i)?(a=o[i],delete o[i]):(a=i?.[Ue],a?i[Ue]=null:a=i),a?.type?a.cancelFn&&a.zone.cancelTask(a):t.apply(e,r)})}function Ge(e,t){let{isBrowser:n,isMix:r}=t.getGlobalObjects();(n||r)&&e.customElements&&`customElements`in e&&t.patchCallbacks(t,e.customElements,`customElements`,`define`,[`connectedCallback`,`disconnectedCallback`,`adoptedCallback`,`attributeChangedCallback`,`formAssociatedCallback`,`formDisabledCallback`,`formResetCallback`,`formStateRestoreCallback`])}function Ke(e,t){if(Zone[t.symbol(`patchEventTarget`)])return;let{eventNames:n,zoneSymbolEventNames:r,TRUE_STR:i,FALSE_STR:a,ZONE_SYMBOL_PREFIX:o}=t.getGlobalObjects();for(let e=0;et.target===e);if(r.length===0)return t;let i=r[0].ignoreProperties;return t.filter(e=>i.indexOf(e)===-1)}function Ye(e,t,n,r){e&&we(e,Je(e,t,n),r)}function Xe(e){return Object.getOwnPropertyNames(e).filter(e=>e.startsWith(`on`)&&e.length>2).map(e=>e.substring(2))}function eee(e,t){if(_e&&!ye||Zone[e.symbol(`patchEvents`)])return;let n=t.__Zone_ignore_on_properties,r=[];if(ve){let e=window;r=r.concat([`Document`,`SVGElement`,`Element`,`HTMLElement`,`HTMLBodyElement`,`HTMLMediaElement`,`HTMLFrameSetElement`,`HTMLFrameElement`,`HTMLIFrameElement`,`HTMLMarqueeElement`,`Worker`]),Ye(e,Xe(e),n,p(e))}r=r.concat([`XMLHttpRequest`,`XMLHttpRequestEventTarget`,`IDBIndex`,`IDBRequest`,`IDBOpenDBRequest`,`IDBDatabase`,`IDBTransaction`,`IDBCursor`,`WebSocket`]);for(let e=0;e{let t=`clear`;We(e,`set`,t,`Timeout`),We(e,`set`,t,`Interval`),We(e,`set`,t,`Immediate`)}),e.__load_patch(`requestAnimationFrame`,e=>{We(e,`request`,`cancel`,`AnimationFrame`),We(e,`mozRequest`,`mozCancel`,`AnimationFrame`),We(e,`webkitRequest`,`webkitCancel`,`AnimationFrame`)}),e.__load_patch(`blocking`,(e,t)=>{let n=[`alert`,`prompt`,`confirm`];for(let r=0;rfunction(r,a){return t.current.run(n,e,a,i)})}}),e.__load_patch(`EventTarget`,(e,t,n)=>{qe(e,n),Ke(e,n);let r=e.XMLHttpRequestEventTarget;r&&r.prototype&&n.patchEventTarget(e,n,[r.prototype])}),e.__load_patch(`MutationObserver`,(e,t,n)=>{Ee(`MutationObserver`),Ee(`WebKitMutationObserver`)}),e.__load_patch(`IntersectionObserver`,(e,t,n)=>{Ee(`IntersectionObserver`)}),e.__load_patch(`FileReader`,(e,t,n)=>{Ee(`FileReader`)}),e.__load_patch(`on_property`,(e,t,n)=>{eee(n,e)}),e.__load_patch(`customElements`,(e,t,n)=>{Ge(e,n)}),e.__load_patch(`XHR`,(e,t)=>{c(e);let n=ce(`xhrTask`),r=ce(`xhrSync`),i=ce(`xhrListener`),a=ce(`xhrScheduled`),o=ce(`xhrURL`),s=ce(`xhrErrorBeforeScheduled`);function c(e){let c=e.XMLHttpRequest;if(!c)return;let l=c.prototype;function u(e){return e[n]}let d=l[te],f=l[ne];if(!d){let t=e.XMLHttpRequestEventTarget;if(t){let e=t.prototype;d=e[te],f=e[ne]}}let p=`readystatechange`,m=`scheduled`;function ee(e){let r=e.data,o=r.target;o[a]=!1,o[s]=!1;let c=o[i];d||(d=o[te],f=o[ne]),c&&f.call(o,p,c);let l=o[i]=()=>{if(o.readyState===o.DONE){if(!r.aborted&&o[a]&&e.state===m){let n=o[t.__symbol__(`loadfalse`)];if(o.status!==0&&n&&n.length>0){let i=e.invoke;e.invoke=function(){let n=o[t.__symbol__(`loadfalse`)];for(let t=0;tfunction(e,t){return e[r]=t[2]==0,e[o]=t[1],re.apply(e,t)}),ie=ce(`fetchTaskAborting`),ae=ce(`fetchTaskScheduling`),oe=De(l,`send`,()=>function(e,n){if(t.current[ae]===!0||e[r])return oe.apply(e,n);{let t={target:e,url:e[o],isPeriodic:!1,args:n,aborted:!1},r=se(`XMLHttpRequest.send`,h,t,ee,g);e&&e[s]===!0&&!t.aborted&&r.state===m&&r.invoke()}}),le=De(l,`abort`,()=>function(e,n){let r=u(e);if(r&&typeof r.type==`string`){if(r.cancelFn==null||r.data&&r.data.aborted)return;r.zone.cancelTask(r)}else if(t.current[ie]===!0)return le.apply(e,n)})}}),e.__load_patch(`geolocation`,e=>{e.navigator&&e.navigator.geolocation&&me(e.navigator.geolocation,[`getCurrentPosition`,`watchPosition`])}),e.__load_patch(`PromiseRejectionEvent`,(e,t)=>{function n(t){return function(n){Be(e,t).forEach(r=>{let i=e.PromiseRejectionEvent;if(i){let e=new i(t,{promise:n.promise,reason:n.rejection});r.invoke(e)}})}}e.PromiseRejectionEvent&&(t[ce(`unhandledPromiseRejectionHandler`)]=n(`unhandledrejection`),t[ce(`rejectionHandledHandler`)]=n(`rejectionhandled`))}),e.__load_patch(`queueMicrotask`,(e,t,n)=>{He(e,n)})}function nee(e){e.__load_patch(`ZoneAwarePromise`,(e,t,n)=>{let r=Object.getOwnPropertyDescriptor,i=Object.defineProperty;function a(e){return e&&e.toString===Object.prototype.toString?(e.constructor&&e.constructor.name||``)+`: `+JSON.stringify(e):e?e.toString():Object.prototype.toString.call(e)}let o=n.symbol,s=[],c=e[o(`DISABLE_WRAPPING_UNCAUGHT_PROMISE_REJECTION`)]!==!1,l=o(`Promise`),u=o(`then`);n.onUnhandledError=e=>{if(n.showUncaughtError()){let t=e&&e.rejection;t&&e.zone&&e.task?console.error(`Unhandled Promise rejection:`,t instanceof Error?t.message:t,`; Zone:`,e.zone.name,`; Task:`,e.task&&e.task.source,`; Value:`,t,t instanceof Error?t.stack:void 0):console.error(e)}},n.microtaskDrainDone=()=>{for(;s.length;){let e=s.shift();try{e.zone.runGuarded(()=>{throw e.throwOriginal?e.rejection:e})}catch(e){f(e)}}};let d=o(`unhandledPromiseRejectionHandler`);function f(e){n.onUnhandledError(e);try{let n=t[d];typeof n==`function`&&n.call(this,e)}catch{}}function p(e){return e&&typeof e.then==`function`}function m(e){return e}function ee(e){return pe.reject(e)}let h=o(`state`),g=o(`value`),te=o(`finally`),ne=o(`parentPromiseValue`),re=o(`parentPromiseState`);function ie(e,t){return n=>{try{se(e,t,n)}catch(t){se(e,!1,t)}}}let ae=function(){let e=!1;return function(t){return function(){e||(e=!0,t.apply(null,arguments))}}},oe=o(`currentTaskTrace`);function se(e,r,o){let l=ae();if(e===o)throw TypeError(`Promise resolved with itself`);if(e[h]===null){let u=null;try{(typeof o==`object`||typeof o==`function`)&&(u=o&&o.then)}catch(t){return l(()=>{se(e,!1,t)})(),e}if(r!==!1&&o instanceof pe&&Object.hasOwn(o,h)&&Object.hasOwn(o,g)&&o[h]!==null)le(o),se(e,o[h],o[g]);else if(r!==!1&&typeof u==`function`)try{u.call(o,l(ie(e,r)),l(ie(e,!1)))}catch(t){l(()=>{se(e,!1,t)})()}else{e[h]=r;let l=e[g];if(e[g]=o,e[te]===te&&r===!0&&(e[h]=e[re],e[g]=e[ne]),r===!1&&o instanceof Error){let e=t.currentTask&&t.currentTask.data&&t.currentTask.data.__creationTrace__;e&&i(o,oe,{configurable:!0,enumerable:!1,writable:!0,value:e})}for(let t=0;t{try{let r=e[g],i=!!n&&te===n[te];i&&(n[ne]=r,n[re]=a),se(n,!0,t.run(o,void 0,i&&o!==ee&&o!==m?[]:[r]))}catch(e){se(n,!1,e)}},n)}let de=function(){},fe=e.AggregateError;class pe{static toString(){return`function ZoneAwarePromise() { [native code] }`}static resolve(e){return e instanceof pe?e:se(new this(null),!0,e)}static reject(e){return se(new this(null),!1,e)}static withResolvers(){let e={};return e.promise=new pe((t,n)=>{e.resolve=t,e.reject=n}),e}static any(e){if(!e||typeof e[Symbol.iterator]!=`function`)return Promise.reject(new fe([],`All promises were rejected`));let t=[],n=0;try{for(let r of e)n++,t.push(pe.resolve(r))}catch{return Promise.reject(new fe([],`All promises were rejected`))}if(n===0)return Promise.reject(new fe([],`All promises were rejected`));let r=!1,i=[];return new pe((e,a)=>{for(let o=0;o{r||(r=!0,e(t))},e=>{i.push(e),n--,n===0&&(r=!0,a(new fe(i,`All promises were rejected`)))})})}static race(e){let t,n,r=new this((e,r)=>{t=e,n=r});function i(e){t(e)}function a(e){n(e)}for(let t of e)p(t)||(t=this.resolve(t)),t.then(i,a);return r}static all(e){return pe.allWithCallback(e)}static allSettled(e){return(this&&this.prototype instanceof pe?this:pe).allWithCallback(e,{thenCallback:e=>({status:`fulfilled`,value:e}),errorCallback:e=>({status:`rejected`,reason:e})})}static allWithCallback(e,t){let n,r,i=new this((e,t)=>{n=e,r=t}),a=2,o=0,s=[];for(let i of e){p(i)||(i=this.resolve(i));let e=o;try{i.then(r=>{s[e]=t?t.thenCallback(r):r,a--,a===0&&n(s)},i=>{t?(s[e]=t.errorCallback(i),a--,a===0&&n(s)):r(i)})}catch(e){r(e)}a++,o++}return a-=2,a===0&&n(s),i}constructor(e){let t=this;if(!(t instanceof pe))throw Error(`Must be an instanceof Promise.`);t[h]=null,t[g]=[];try{let n=ae();e&&e(n(ie(t,!0)),n(ie(t,!1)))}catch(e){se(t,!1,e)}}get[Symbol.toStringTag](){return`Promise`}get[Symbol.species](){return pe}then(e,n){let r=this.constructor?.[Symbol.species];(!r||typeof r!=`function`)&&(r=this.constructor||pe);let i=new r(de),a=t.current;return this[h]==null?this[g].push(a,i,e,n):ue(this,a,i,e,n),i}catch(e){return this.then(null,e)}finally(e){let n=this.constructor?.[Symbol.species];(!n||typeof n!=`function`)&&(n=pe);let r=new n(de);r[te]=te;let i=t.current;return this[h]==null?this[g].push(i,r,e,e):ue(this,i,r,e,e),r}}pe.resolve=pe.resolve,pe.reject=pe.reject,pe.race=pe.race,pe.all=pe.all;let me=e[l]=e.Promise;e.Promise=pe;let he=o(`thenPatched`);function ge(e){let t=e.prototype,n=r(t,`then`);if(n&&(n.writable===!1||!n.configurable))return;let i=t.then;t[u]=i,e.prototype.then=function(e,t){return new pe((e,t)=>{i.call(this,e,t)}).then(e,t)},e[he]=!0}n.patchThen=ge;function _e(e){return function(t,n){let r=e.apply(t,n);if(r instanceof pe)return r;let i=r.constructor;return i[he]||ge(i),r}}if(me){ge(me);let t=me.try;t&&typeof t==`function`&&(pe.try=t),De(e,`fetch`,e=>_e(e))}return Promise[t.__symbol__(`uncaughtPromiseErrors`)]=s,pe})}function Ze(e){e.__load_patch(`toString`,e=>{let t=Function.prototype.toString,n=ce(`OriginalDelegate`),r=ce(`Promise`),i=ce(`Error`),a=function(){if(typeof this==`function`){let a=this[n];if(a)return typeof a==`function`?t.call(a):Object.prototype.toString.call(a);if(this===Promise){let n=e[r];if(n)return t.call(n)}if(this===Error){let n=e[i];if(n)return t.call(n)}}return t.call(this)};a[n]=t,Function.prototype.toString=a;let o=Object.prototype.toString;Object.prototype.toString=function(){return typeof Promise==`function`&&this instanceof Promise?`[object Promise]`:o.call(this)}})}function Qe(e,t,n,r,i){let a=Zone.__symbol__(r);if(t[a])return;let o=t[a]=t[r];t[r]=function(a,s,c){return s&&s.prototype&&i.forEach(function(t){let i=`${n}.${r}::`+t,a=s.prototype;try{if(Object.hasOwn(a,t)){let n=e.ObjectGetOwnPropertyDescriptor(a,t);n&&n.value?(n.value=e.wrapWithCurrentZone(n.value,i),e._redefineProperty(s.prototype,t,n)):a[t]&&(a[t]=e.wrapWithCurrentZone(a[t],i))}else a[t]&&(a[t]=e.wrapWithCurrentZone(a[t],i))}catch{}}),o.call(t,a,s,c)},e.attachOriginToPatched(t[r],o)}function $e(e){e.__load_patch(`util`,(e,t,n)=>{let r=Xe(e);n.patchOnProperties=we,n.patchMethod=De,n.bindArguments=pe,n.patchMacroTask=Oe;let i=t.__symbol__(`BLACK_LISTED_EVENTS`),a=t.__symbol__(`UNPATCHED_EVENTS`);e[a]&&(e[i]=e[a]),e[i]&&(t[i]=t[a]=e[i]),n.patchEventPrototype=Ve,n.patchEventTarget=ze,n.ObjectDefineProperty=f,n.ObjectGetOwnPropertyDescriptor=d,n.ObjectCreate=m,n.ArraySlice=ee,n.patchClass=Ee,n.wrapWithCurrentZone=oe,n.filterProperties=Je,n.attachOriginToPatched=ke,n._redefineProperty=Object.defineProperty,n.patchCallbacks=Qe,n.getGlobalObjects=()=>({globalSources:Pe,zoneSymbolEventNames:Ne,eventNames:r,isBrowser:ve,isMix:ye,isNode:_e,TRUE_STR:re,FALSE_STR:ie,ZONE_SYMBOL_PREFIX:ae,ADD_EVENT_LISTENER_STR:h,REMOVE_EVENT_LISTENER_STR:g})})}function et(e){nee(e),Ze(e),$e(e)}var tt=u();et(tt),tee(tt);var nt=(function(e){return e[e.NONE=0]=`NONE`,e[e.HTML=1]=`HTML`,e[e.STYLE=2]=`STYLE`,e[e.SCRIPT=3]=`SCRIPT`,e[e.URL=4]=`URL`,e[e.RESOURCE_URL=5]=`RESOURCE_URL`,e[e.ATTRIBUTE_NO_BINDING=6]=`ATTRIBUTE_NO_BINDING`,e})(nt||{}),rt=(function(e){return e[e.None=0]=`None`,e[e.Const=1]=`Const`,e})(rt||{}),it=class{modifiers;constructor(e=rt.None){this.modifiers=e}hasModifier(e){return(this.modifiers&e)!==0}},at=(function(e){return e[e.Dynamic=0]=`Dynamic`,e[e.Bool=1]=`Bool`,e[e.String=2]=`String`,e[e.Int=3]=`Int`,e[e.Number=4]=`Number`,e[e.Function=5]=`Function`,e[e.Inferred=6]=`Inferred`,e[e.None=7]=`None`,e})(at||{}),ot=class extends it{name;constructor(e,t){super(t),this.name=e}visitType(e,t){return e.visitBuiltinType(this,t)}};at.Dynamic;var st=new ot(at.Inferred);at.Bool,at.Int,at.Number,at.String,at.Function,at.None;var _=(function(e){return e[e.Equals=0]=`Equals`,e[e.NotEquals=1]=`NotEquals`,e[e.Assign=2]=`Assign`,e[e.Identical=3]=`Identical`,e[e.NotIdentical=4]=`NotIdentical`,e[e.Minus=5]=`Minus`,e[e.Plus=6]=`Plus`,e[e.Divide=7]=`Divide`,e[e.Multiply=8]=`Multiply`,e[e.Modulo=9]=`Modulo`,e[e.And=10]=`And`,e[e.Or=11]=`Or`,e[e.BitwiseOr=12]=`BitwiseOr`,e[e.BitwiseAnd=13]=`BitwiseAnd`,e[e.Lower=14]=`Lower`,e[e.LowerEquals=15]=`LowerEquals`,e[e.Bigger=16]=`Bigger`,e[e.BiggerEquals=17]=`BiggerEquals`,e[e.NullishCoalesce=18]=`NullishCoalesce`,e[e.Exponentiation=19]=`Exponentiation`,e[e.In=20]=`In`,e[e.InstanceOf=21]=`InstanceOf`,e[e.AdditionAssignment=22]=`AdditionAssignment`,e[e.SubtractionAssignment=23]=`SubtractionAssignment`,e[e.MultiplicationAssignment=24]=`MultiplicationAssignment`,e[e.DivisionAssignment=25]=`DivisionAssignment`,e[e.RemainderAssignment=26]=`RemainderAssignment`,e[e.ExponentiationAssignment=27]=`ExponentiationAssignment`,e[e.AndAssignment=28]=`AndAssignment`,e[e.OrAssignment=29]=`OrAssignment`,e[e.NullishCoalesceAssignment=30]=`NullishCoalesceAssignment`,e})(_||{});function ct(e,t){return e==null||t==null?e==t:e.isEquivalent(t)}function lt(e,t,n){let r=e.length;if(r!==t.length)return!1;for(let i=0;ie.isEquivalent(t))}var dt=class{leadingComments;type;sourceSpan;constructor(e,t,n){this.leadingComments=n,this.type=e||null,this.sourceSpan=t||null}prop(e,t){return new xt(this,e,null,t)}key(e,t,n){return new ree(this,e,t,n)}callFn(e,t,n,r){return new mt(this,e,null,t,n,r)}instantiate(e,t,n,r){return new ht(this,e,t,n)}conditional(e,t=null,n,r){return new yt(this,e,t,null,n)}equals(e,t){return new bt(_.Equals,this,e,null,t)}notEquals(e,t){return new bt(_.NotEquals,this,e,null,t)}identical(e,t){return new bt(_.Identical,this,e,null,t)}notIdentical(e,t){return new bt(_.NotIdentical,this,e,null,t)}minus(e,t){return new bt(_.Minus,this,e,null,t)}plus(e,t){return new bt(_.Plus,this,e,null,t)}divide(e,t){return new bt(_.Divide,this,e,null,t)}multiply(e,t){return new bt(_.Multiply,this,e,null,t)}modulo(e,t){return new bt(_.Modulo,this,e,null,t)}power(e,t){return new bt(_.Exponentiation,this,e,null,t)}and(e,t){return new bt(_.And,this,e,null,t)}bitwiseOr(e,t){return new bt(_.BitwiseOr,this,e,null,t)}bitwiseAnd(e,t){return new bt(_.BitwiseAnd,this,e,null,t)}or(e,t){return new bt(_.Or,this,e,null,t)}lower(e,t){return new bt(_.Lower,this,e,null,t)}lowerEquals(e,t){return new bt(_.LowerEquals,this,e,null,t)}bigger(e,t){return new bt(_.Bigger,this,e,null,t)}biggerEquals(e,t){return new bt(_.BiggerEquals,this,e,null,t)}isBlank(e){return this.equals(cee,e)}nullishCoalesce(e,t){return new bt(_.NullishCoalesce,this,e,null,t)}toStmt(e){return new uee(this,null,e)}},ft=class e extends dt{name;constructor(e,t,n,r){super(t,n,r),this.name=e}isEquivalent(t){return t instanceof e&&this.name===t.name}isConstant(){return!1}visitExpression(e,t){return e.visitReadVarExpr(this,t)}clone(){return new e(this.name,this.type,this.sourceSpan)}set(e){return new bt(_.Assign,this,e,null,this.sourceSpan)}},pt=class e extends dt{expr;constructor(e,t,n,r){super(t,n,r),this.expr=e}visitExpression(e,t){return e.visitTypeofExpr(this,t)}isEquivalent(t){return t instanceof e&&t.expr.isEquivalent(this.expr)}isConstant(){return this.expr.isConstant()}clone(){return new e(this.expr.clone())}},mt=class e extends dt{fn;args;pure;isOptional;constructor(e,t,n,r,i=!1,a,o=!1){super(n,r,a),this.fn=e,this.args=t,this.pure=i,this.isOptional=o}get receiver(){return this.fn}isEquivalent(t){return t instanceof e&&this.fn.isEquivalent(t.fn)&&ut(this.args,t.args)&&this.pure===t.pure}isConstant(){return!1}visitExpression(e,t){return e.visitInvokeFunctionExpr(this,t)}clone(){return new e(this.fn.clone(),this.args.map(e=>e.clone()),this.type,this.sourceSpan,this.pure,[],this.isOptional)}},ht=class e extends dt{classExpr;args;constructor(e,t,n,r,i){super(n,r,i),this.classExpr=e,this.args=t}isEquivalent(t){return t instanceof e&&this.classExpr.isEquivalent(t.classExpr)&&ut(this.args,t.args)}isConstant(){return!1}visitExpression(e,t){return e.visitInstantiateExpr(this,t)}clone(){return new e(this.classExpr.clone(),this.args.map(e=>e.clone()),this.type,this.sourceSpan)}},gt=class e extends dt{body;flags;constructor(e,t,n,r){super(null,n,r),this.body=e,this.flags=t}isEquivalent(t){return t instanceof e&&this.body===t.body&&this.flags===t.flags}isConstant(){return!0}visitExpression(e,t){return e.visitRegularExpressionLiteral(this,t)}clone(){return new e(this.body,this.flags,this.sourceSpan)}},_t=class e extends dt{value;constructor(e,t,n,r){super(t,n,r),this.value=e}isEquivalent(t){return t instanceof e&&this.value===t.value}isConstant(){return!0}visitExpression(e,t){return e.visitLiteralExpr(this,t)}clone(){return new e(this.value,this.type,this.sourceSpan)}},vt=class e extends dt{value;typeParams;constructor(e,t,n=null,r,i){super(t,r,i),this.value=e,this.typeParams=n}isEquivalent(t){return t instanceof e&&this.value.name===t.value.name&&this.value.moduleName===t.value.moduleName}isConstant(){return!1}visitExpression(e,t){return e.visitExternalExpr(this,t)}clone(){return new e(this.value,this.type,this.typeParams,this.sourceSpan)}},yt=class e extends dt{condition;falseCase;trueCase;constructor(e,t,n=null,r,i,a){super(r||t.type,i,a),this.condition=e,this.falseCase=n,this.trueCase=t}isEquivalent(t){return t instanceof e&&this.condition.isEquivalent(t.condition)&&this.trueCase.isEquivalent(t.trueCase)&&ct(this.falseCase,t.falseCase)}isConstant(){return!1}visitExpression(e,t){return e.visitConditionalExpr(this,t)}clone(){return new e(this.condition.clone(),this.trueCase.clone(),this.falseCase?.clone(),this.type,this.sourceSpan)}},bt=class e extends dt{operator;rhs;lhs;constructor(e,t,n,r,i,a){super(r||t.type,i,a),this.operator=e,this.rhs=n,this.lhs=t}isEquivalent(t){return t instanceof e&&this.operator===t.operator&&this.lhs.isEquivalent(t.lhs)&&this.rhs.isEquivalent(t.rhs)}isConstant(){return!1}visitExpression(e,t){return e.visitBinaryOperatorExpr(this,t)}clone(){return new e(this.operator,this.lhs.clone(),this.rhs.clone(),this.type,this.sourceSpan)}isAssignment(){let e=this.operator;return e===_.Assign||e===_.AdditionAssignment||e===_.SubtractionAssignment||e===_.MultiplicationAssignment||e===_.DivisionAssignment||e===_.RemainderAssignment||e===_.ExponentiationAssignment||e===_.AndAssignment||e===_.OrAssignment||e===_.NullishCoalesceAssignment}},xt=class e extends dt{receiver;name;isOptional;constructor(e,t,n,r,i,a=!1){super(n,r,i),this.receiver=e,this.name=t,this.isOptional=a}get index(){return this.name}isEquivalent(t){return t instanceof e&&this.receiver.isEquivalent(t.receiver)&&this.name===t.name&&this.isOptional===t.isOptional}isConstant(){return!1}visitExpression(e,t){return e.visitReadPropExpr(this,t)}set(e){return new bt(_.Assign,this.receiver.prop(this.name),e,null,this.sourceSpan)}clone(){return new e(this.receiver.clone(),this.name,this.type,this.sourceSpan,[],this.isOptional)}},ree=class e extends dt{receiver;index;isOptional;constructor(e,t,n,r,i,a=!1){super(n,r,i),this.receiver=e,this.index=t,this.isOptional=a}isEquivalent(t){return t instanceof e&&this.receiver.isEquivalent(t.receiver)&&this.index.isEquivalent(t.index)&&this.isOptional===t.isOptional}isConstant(){return!1}visitExpression(e,t){return e.visitReadKeyExpr(this,t)}set(e){return new bt(_.Assign,this.receiver.key(this.index),e,null,this.sourceSpan)}clone(){return new e(this.receiver.clone(),this.index.clone(),this.type,this.sourceSpan,[],this.isOptional)}},iee=class e extends dt{entries;constructor(e,t,n,r){super(t,n,r),this.entries=e}isConstant(){return this.entries.every(e=>e.isConstant())}isEquivalent(t){return t instanceof e&&ut(this.entries,t.entries)}visitExpression(e,t){return e.visitLiteralArrayExpr(this,t)}clone(){return new e(this.entries.map(e=>e.clone()),this.type,this.sourceSpan)}},aee=class e{expression;constructor(e){this.expression=e}isEquivalent(t){return t instanceof e&&this.expression.isEquivalent(t.expression)}clone(){return new e(this.expression.clone())}isConstant(){return this.expression.isConstant()}},oee=class e extends dt{entries;valueType=null;constructor(e,t,n,r){super(t,n,r),this.entries=e,t&&(this.valueType=t.valueType)}isEquivalent(t){return t instanceof e&&ut(this.entries,t.entries)}isConstant(){return this.entries.every(e=>e.isConstant())}visitExpression(e,t){return e.visitLiteralMapExpr(this,t)}clone(){let t=this.entries.map(e=>e.clone());return new e(t,this.type,this.sourceSpan)}},see=class e extends dt{expression;constructor(e,t,n){super(null,t,n),this.expression=e}isEquivalent(t){return t instanceof e&&this.expression.isEquivalent(t.expression)}isConstant(){return this.expression.isConstant()}visitExpression(e,t){return e.visitSpreadElementExpr(this,t)}clone(){return new e(this.expression.clone(),this.sourceSpan)}},cee=new _t(null,st,null),St=(function(e){return e[e.None=0]=`None`,e[e.Final=1]=`Final`,e[e.Private=2]=`Private`,e[e.Exported=4]=`Exported`,e[e.Static=8]=`Static`,e})(St||{}),lee=class{modifiers;sourceSpan;leadingComments;constructor(e=St.None,t=null,n){this.modifiers=e,this.sourceSpan=t,this.leadingComments=n}hasModifier(e){return(this.modifiers&e)!==0}addLeadingComment(e){this.leadingComments=this.leadingComments??[],this.leadingComments.push(e)}},uee=class e extends lee{expr;constructor(e,t,n){super(St.None,t,n),this.expr=e}isEquivalent(t){return t instanceof e&&this.expr.isEquivalent(t.expr)}visitStatement(e,t){return e.visitExpressionStmt(this,t)}};(class e{static INSTANCE=new e;keyOf(e){if(e instanceof _t&&typeof e.value==`string`)return`"${e.value}"`;if(e instanceof _t)return String(e.value);if(e instanceof gt)return`/${e.body}/${e.flags??``}`;if(e instanceof iee){let t=[];for(let n of e.entries)t.push(this.keyOf(n));return`[${t.join(`,`)}]`}if(e instanceof oee){let t=[];for(let n of e.entries)if(n instanceof aee)t.push(`...`+this.keyOf(n.expression));else{let e=n.key;n.quoted&&(e=`"${e}"`),t.push(e+`:`+this.keyOf(n.value))}return`{${t.join(`,`)}}`}if(e instanceof vt)return`import("${e.value.moduleName}", ${e.value.name})`;if(e instanceof ft)return`read(${e.name})`;if(e instanceof pt)return`typeof(${this.keyOf(e.expr)})`;if(e instanceof see)return`...${this.keyOf(e.expression)}`;throw Error(`${this.constructor.name} does not handle expressions of type ${e.constructor.name}`)}});var v=`@angular/core`,y=(()=>{class e{static core={name:null,moduleName:v};static namespaceHTML={name:`ɵɵnamespaceHTML`,moduleName:v};static namespaceMathML={name:`ɵɵnamespaceMathML`,moduleName:v};static namespaceSVG={name:`ɵɵnamespaceSVG`,moduleName:v};static element={name:`ɵɵelement`,moduleName:v};static elementStart={name:`ɵɵelementStart`,moduleName:v};static elementEnd={name:`ɵɵelementEnd`,moduleName:v};static foreignComponent={name:`ɵɵforeignComponent`,moduleName:v};static foreignContent={name:`ɵɵforeignContent`,moduleName:v};static foreignContentFn={name:`ɵɵforeignContentFn`,moduleName:v};static domElement={name:`ɵɵdomElement`,moduleName:v};static domElementStart={name:`ɵɵdomElementStart`,moduleName:v};static domElementEnd={name:`ɵɵdomElementEnd`,moduleName:v};static domElementContainer={name:`ɵɵdomElementContainer`,moduleName:v};static domElementContainerStart={name:`ɵɵdomElementContainerStart`,moduleName:v};static domElementContainerEnd={name:`ɵɵdomElementContainerEnd`,moduleName:v};static domTemplate={name:`ɵɵdomTemplate`,moduleName:v};static domListener={name:`ɵɵdomListener`,moduleName:v};static advance={name:`ɵɵadvance`,moduleName:v};static syntheticHostProperty={name:`ɵɵsyntheticHostProperty`,moduleName:v};static syntheticHostListener={name:`ɵɵsyntheticHostListener`,moduleName:v};static attribute={name:`ɵɵattribute`,moduleName:v};static classProp={name:`ɵɵclassProp`,moduleName:v};static elementContainerStart={name:`ɵɵelementContainerStart`,moduleName:v};static elementContainerEnd={name:`ɵɵelementContainerEnd`,moduleName:v};static elementContainer={name:`ɵɵelementContainer`,moduleName:v};static styleMap={name:`ɵɵstyleMap`,moduleName:v};static classMap={name:`ɵɵclassMap`,moduleName:v};static styleProp={name:`ɵɵstyleProp`,moduleName:v};static interpolate={name:`ɵɵinterpolate`,moduleName:v};static interpolate1={name:`ɵɵinterpolate1`,moduleName:v};static interpolate2={name:`ɵɵinterpolate2`,moduleName:v};static interpolate3={name:`ɵɵinterpolate3`,moduleName:v};static interpolate4={name:`ɵɵinterpolate4`,moduleName:v};static interpolate5={name:`ɵɵinterpolate5`,moduleName:v};static interpolate6={name:`ɵɵinterpolate6`,moduleName:v};static interpolate7={name:`ɵɵinterpolate7`,moduleName:v};static interpolate8={name:`ɵɵinterpolate8`,moduleName:v};static interpolateV={name:`ɵɵinterpolateV`,moduleName:v};static nextContext={name:`ɵɵnextContext`,moduleName:v};static resetView={name:`ɵɵresetView`,moduleName:v};static templateCreate={name:`ɵɵtemplate`,moduleName:v};static defer={name:`ɵɵdefer`,moduleName:v};static deferWhen={name:`ɵɵdeferWhen`,moduleName:v};static deferOnIdle={name:`ɵɵdeferOnIdle`,moduleName:v};static deferOnImmediate={name:`ɵɵdeferOnImmediate`,moduleName:v};static deferOnTimer={name:`ɵɵdeferOnTimer`,moduleName:v};static deferOnHover={name:`ɵɵdeferOnHover`,moduleName:v};static deferOnInteraction={name:`ɵɵdeferOnInteraction`,moduleName:v};static deferOnViewport={name:`ɵɵdeferOnViewport`,moduleName:v};static deferPrefetchWhen={name:`ɵɵdeferPrefetchWhen`,moduleName:v};static deferPrefetchOnIdle={name:`ɵɵdeferPrefetchOnIdle`,moduleName:v};static deferPrefetchOnImmediate={name:`ɵɵdeferPrefetchOnImmediate`,moduleName:v};static deferPrefetchOnTimer={name:`ɵɵdeferPrefetchOnTimer`,moduleName:v};static deferPrefetchOnHover={name:`ɵɵdeferPrefetchOnHover`,moduleName:v};static deferPrefetchOnInteraction={name:`ɵɵdeferPrefetchOnInteraction`,moduleName:v};static deferPrefetchOnViewport={name:`ɵɵdeferPrefetchOnViewport`,moduleName:v};static deferHydrateWhen={name:`ɵɵdeferHydrateWhen`,moduleName:v};static deferHydrateNever={name:`ɵɵdeferHydrateNever`,moduleName:v};static deferHydrateOnIdle={name:`ɵɵdeferHydrateOnIdle`,moduleName:v};static deferHydrateOnImmediate={name:`ɵɵdeferHydrateOnImmediate`,moduleName:v};static deferHydrateOnTimer={name:`ɵɵdeferHydrateOnTimer`,moduleName:v};static deferHydrateOnHover={name:`ɵɵdeferHydrateOnHover`,moduleName:v};static deferHydrateOnInteraction={name:`ɵɵdeferHydrateOnInteraction`,moduleName:v};static deferHydrateOnViewport={name:`ɵɵdeferHydrateOnViewport`,moduleName:v};static deferEnableTimerScheduling={name:`ɵɵdeferEnableTimerScheduling`,moduleName:v};static enableIncrementalHydrationRuntime={name:`ɵɵenableIncrementalHydrationRuntime`,moduleName:v};static conditionalCreate={name:`ɵɵconditionalCreate`,moduleName:v};static conditionalBranchCreate={name:`ɵɵconditionalBranchCreate`,moduleName:v};static conditional={name:`ɵɵconditional`,moduleName:v};static repeater={name:`ɵɵrepeater`,moduleName:v};static repeaterCreate={name:`ɵɵrepeaterCreate`,moduleName:v};static repeaterTrackByIndex={name:`ɵɵrepeaterTrackByIndex`,moduleName:v};static repeaterTrackByIdentity={name:`ɵɵrepeaterTrackByIdentity`,moduleName:v};static componentInstance={name:`ɵɵcomponentInstance`,moduleName:v};static text={name:`ɵɵtext`,moduleName:v};static enableBindings={name:`ɵɵenableBindings`,moduleName:v};static disableBindings={name:`ɵɵdisableBindings`,moduleName:v};static getCurrentView={name:`ɵɵgetCurrentView`,moduleName:v};static textInterpolate={name:`ɵɵtextInterpolate`,moduleName:v};static textInterpolate1={name:`ɵɵtextInterpolate1`,moduleName:v};static textInterpolate2={name:`ɵɵtextInterpolate2`,moduleName:v};static textInterpolate3={name:`ɵɵtextInterpolate3`,moduleName:v};static textInterpolate4={name:`ɵɵtextInterpolate4`,moduleName:v};static textInterpolate5={name:`ɵɵtextInterpolate5`,moduleName:v};static textInterpolate6={name:`ɵɵtextInterpolate6`,moduleName:v};static textInterpolate7={name:`ɵɵtextInterpolate7`,moduleName:v};static textInterpolate8={name:`ɵɵtextInterpolate8`,moduleName:v};static textInterpolateV={name:`ɵɵtextInterpolateV`,moduleName:v};static restoreView={name:`ɵɵrestoreView`,moduleName:v};static pureFunction0={name:`ɵɵpureFunction0`,moduleName:v};static pureFunction1={name:`ɵɵpureFunction1`,moduleName:v};static pureFunction2={name:`ɵɵpureFunction2`,moduleName:v};static pureFunction3={name:`ɵɵpureFunction3`,moduleName:v};static pureFunction4={name:`ɵɵpureFunction4`,moduleName:v};static pureFunction5={name:`ɵɵpureFunction5`,moduleName:v};static pureFunction6={name:`ɵɵpureFunction6`,moduleName:v};static pureFunction7={name:`ɵɵpureFunction7`,moduleName:v};static pureFunction8={name:`ɵɵpureFunction8`,moduleName:v};static pureFunctionV={name:`ɵɵpureFunctionV`,moduleName:v};static pipeBind1={name:`ɵɵpipeBind1`,moduleName:v};static pipeBind2={name:`ɵɵpipeBind2`,moduleName:v};static pipeBind3={name:`ɵɵpipeBind3`,moduleName:v};static pipeBind4={name:`ɵɵpipeBind4`,moduleName:v};static pipeBindV={name:`ɵɵpipeBindV`,moduleName:v};static domProperty={name:`ɵɵdomProperty`,moduleName:v};static ariaProperty={name:`ɵɵariaProperty`,moduleName:v};static property={name:`ɵɵproperty`,moduleName:v};static control={name:`ɵɵcontrol`,moduleName:v};static controlCreate={name:`ɵɵcontrolCreate`,moduleName:v};static animationEnterListener={name:`ɵɵanimateEnterListener`,moduleName:v};static animationLeaveListener={name:`ɵɵanimateLeaveListener`,moduleName:v};static animationEnter={name:`ɵɵanimateEnter`,moduleName:v};static animationLeave={name:`ɵɵanimateLeave`,moduleName:v};static i18n={name:`ɵɵi18n`,moduleName:v};static i18nAttributes={name:`ɵɵi18nAttributes`,moduleName:v};static i18nExp={name:`ɵɵi18nExp`,moduleName:v};static i18nStart={name:`ɵɵi18nStart`,moduleName:v};static i18nEnd={name:`ɵɵi18nEnd`,moduleName:v};static i18nApply={name:`ɵɵi18nApply`,moduleName:v};static i18nPostprocess={name:`ɵɵi18nPostprocess`,moduleName:v};static pipe={name:`ɵɵpipe`,moduleName:v};static projection={name:`ɵɵprojection`,moduleName:v};static projectionDef={name:`ɵɵprojectionDef`,moduleName:v};static reference={name:`ɵɵreference`,moduleName:v};static inject={name:`ɵɵinject`,moduleName:v};static injectAttribute={name:`ɵɵinjectAttribute`,moduleName:v};static directiveInject={name:`ɵɵdirectiveInject`,moduleName:v};static invalidFactory={name:`ɵɵinvalidFactory`,moduleName:v};static invalidFactoryDep={name:`ɵɵinvalidFactoryDep`,moduleName:v};static templateRefExtractor={name:`ɵɵtemplateRefExtractor`,moduleName:v};static forwardRef={name:`forwardRef`,moduleName:v};static resolveForwardRef={name:`resolveForwardRef`,moduleName:v};static replaceMetadata={name:`ɵɵreplaceMetadata`,moduleName:v};static getReplaceMetadataURL={name:`ɵɵgetReplaceMetadataURL`,moduleName:v};static ɵɵdefineInjectable={name:`ɵɵdefineInjectable`,moduleName:v};static declareInjectable={name:`ɵɵngDeclareInjectable`,moduleName:v};static InjectableDeclaration={name:`ɵɵInjectableDeclaration`,moduleName:v};static defineService={name:`ɵɵdefineService`,moduleName:v};static declareService={name:`ɵɵngDeclareService`,moduleName:v};static resolveWindow={name:`ɵɵresolveWindow`,moduleName:v};static resolveDocument={name:`ɵɵresolveDocument`,moduleName:v};static resolveBody={name:`ɵɵresolveBody`,moduleName:v};static getComponentDepsFactory={name:`ɵɵgetComponentDepsFactory`,moduleName:v};static defineComponent={name:`ɵɵdefineComponent`,moduleName:v};static declareComponent={name:`ɵɵngDeclareComponent`,moduleName:v};static setComponentScope={name:`ɵɵsetComponentScope`,moduleName:v};static ChangeDetectionStrategy={name:`ChangeDetectionStrategy`,moduleName:v};static ViewEncapsulation={name:`ViewEncapsulation`,moduleName:v};static ComponentDeclaration={name:`ɵɵComponentDeclaration`,moduleName:v};static FactoryDeclaration={name:`ɵɵFactoryDeclaration`,moduleName:v};static declareFactory={name:`ɵɵngDeclareFactory`,moduleName:v};static FactoryTarget={name:`ɵɵFactoryTarget`,moduleName:v};static defineDirective={name:`ɵɵdefineDirective`,moduleName:v};static declareDirective={name:`ɵɵngDeclareDirective`,moduleName:v};static DirectiveDeclaration={name:`ɵɵDirectiveDeclaration`,moduleName:v};static InjectorDef={name:`ɵɵInjectorDef`,moduleName:v};static InjectorDeclaration={name:`ɵɵInjectorDeclaration`,moduleName:v};static defineInjector={name:`ɵɵdefineInjector`,moduleName:v};static declareInjector={name:`ɵɵngDeclareInjector`,moduleName:v};static NgModuleDeclaration={name:`ɵɵNgModuleDeclaration`,moduleName:v};static ModuleWithProviders={name:`ModuleWithProviders`,moduleName:v};static defineNgModule={name:`ɵɵdefineNgModule`,moduleName:v};static declareNgModule={name:`ɵɵngDeclareNgModule`,moduleName:v};static setNgModuleScope={name:`ɵɵsetNgModuleScope`,moduleName:v};static registerNgModuleType={name:`ɵɵregisterNgModuleType`,moduleName:v};static PipeDeclaration={name:`ɵɵPipeDeclaration`,moduleName:v};static definePipe={name:`ɵɵdefinePipe`,moduleName:v};static declarePipe={name:`ɵɵngDeclarePipe`,moduleName:v};static declareClassMetadata={name:`ɵɵngDeclareClassMetadata`,moduleName:v};static declareClassMetadataAsync={name:`ɵɵngDeclareClassMetadataAsync`,moduleName:v};static setClassMetadata={name:`ɵsetClassMetadata`,moduleName:v};static setClassMetadataAsync={name:`ɵsetClassMetadataAsync`,moduleName:v};static setClassDebugInfo={name:`ɵsetClassDebugInfo`,moduleName:v};static queryRefresh={name:`ɵɵqueryRefresh`,moduleName:v};static viewQuery={name:`ɵɵviewQuery`,moduleName:v};static loadQuery={name:`ɵɵloadQuery`,moduleName:v};static contentQuery={name:`ɵɵcontentQuery`,moduleName:v};static viewQuerySignal={name:`ɵɵviewQuerySignal`,moduleName:v};static contentQuerySignal={name:`ɵɵcontentQuerySignal`,moduleName:v};static queryAdvance={name:`ɵɵqueryAdvance`,moduleName:v};static twoWayProperty={name:`ɵɵtwoWayProperty`,moduleName:v};static twoWayBindingSet={name:`ɵɵtwoWayBindingSet`,moduleName:v};static twoWayListener={name:`ɵɵtwoWayListener`,moduleName:v};static declareLet={name:`ɵɵdeclareLet`,moduleName:v};static storeLet={name:`ɵɵstoreLet`,moduleName:v};static readContextLet={name:`ɵɵreadContextLet`,moduleName:v};static arrowFunction={name:`ɵɵarrowFunction`,moduleName:v};static attachSourceLocations={name:`ɵɵattachSourceLocations`,moduleName:v};static NgOnChangesFeature={name:`ɵɵNgOnChangesFeature`,moduleName:v};static ControlFeature={name:`ɵɵControlFeature`,moduleName:v};static InheritDefinitionFeature={name:`ɵɵInheritDefinitionFeature`,moduleName:v};static ProvidersFeature={name:`ɵɵProvidersFeature`,moduleName:v};static HostDirectivesFeature={name:`ɵɵHostDirectivesFeature`,moduleName:v};static ExternalStylesFeature={name:`ɵɵExternalStylesFeature`,moduleName:v};static listener={name:`ɵɵlistener`,moduleName:v};static getInheritedFactory={name:`ɵɵgetInheritedFactory`,moduleName:v};static sanitizeHtml={name:`ɵɵsanitizeHtml`,moduleName:v};static sanitizeStyle={name:`ɵɵsanitizeStyle`,moduleName:v};static validateAttribute={name:`ɵɵvalidateAttribute`,moduleName:v};static sanitizeResourceUrl={name:`ɵɵsanitizeResourceUrl`,moduleName:v};static sanitizeScript={name:`ɵɵsanitizeScript`,moduleName:v};static sanitizeUrl={name:`ɵɵsanitizeUrl`,moduleName:v};static sanitizeUrlOrResourceUrl={name:`ɵɵsanitizeUrlOrResourceUrl`,moduleName:v};static trustConstantHtml={name:`ɵɵtrustConstantHtml`,moduleName:v};static trustConstantResourceUrl={name:`ɵɵtrustConstantResourceUrl`,moduleName:v};static inputDecorator={name:`Input`,moduleName:v};static outputDecorator={name:`Output`,moduleName:v};static viewChildDecorator={name:`ViewChild`,moduleName:v};static viewChildrenDecorator={name:`ViewChildren`,moduleName:v};static contentChildDecorator={name:`ContentChild`,moduleName:v};static contentChildrenDecorator={name:`ContentChildren`,moduleName:v};static InputSignalBrandWriteType={name:`ɵINPUT_SIGNAL_BRAND_WRITE_TYPE`,moduleName:v};static UnwrapDirectiveSignalInputs={name:`ɵUnwrapDirectiveSignalInputs`,moduleName:v};static unwrapWritableSignal={name:`ɵunwrapWritableSignal`,moduleName:v};static assertType={name:`ɵassertType`,moduleName:v}}return e})();_.And,_.Bigger,_.BiggerEquals,_.BitwiseOr,_.BitwiseAnd,_.Divide,_.Assign,_.Equals,_.Identical,_.Lower,_.LowerEquals,_.Minus,_.Modulo,_.Exponentiation,_.Multiply,_.NotEquals,_.NotIdentical,_.NullishCoalesce,_.Or,_.Plus,_.In,_.InstanceOf,_.AdditionAssignment,_.SubtractionAssignment,_.MultiplicationAssignment,_.DivisionAssignment,_.RemainderAssignment,_.ExponentiationAssignment,_.AndAssignment,_.OrAssignment,_.NullishCoalesceAssignment;var Ct=class{span;sourceSpan;constructor(e,t){this.span=e,this.sourceSpan=t}toString(){return`AST`}},wt=class extends Ct{receiver;args;argumentSpan;constructor(e,t,n,r,i){super(e,t),this.receiver=n,this.args=r,this.argumentSpan=i}visit(e,t=null){return e.visitCall(this,t)}},Tt=(function(e){return e[e.Property=0]=`Property`,e[e.Attribute=1]=`Attribute`,e[e.Class=2]=`Class`,e[e.Style=3]=`Style`,e[e.LegacyAnimation=4]=`LegacyAnimation`,e[e.TwoWay=5]=`TwoWay`,e[e.Animation=6]=`Animation`,e})(Tt||{}),Et=`(:(where|is)\\()?`,Dt=`-shadowcsshost`,Ot=`-shadowcsscontext`,kt=`[^)(]*`,At=String.raw`(?:\(${kt}\)|${kt})+?`,jt=String.raw`(?:\(${At}\)|${kt})+?`,Mt=String.raw`(?:\((${jt})\))`;String.raw`(:nth-[-\w]+)`+Mt,Dt+Mt+``,`${Et}`,Ot+Mt+``;var Nt=(function(e){return e[e.ListEnd=0]=`ListEnd`,e[e.Statement=1]=`Statement`,e[e.Variable=2]=`Variable`,e[e.ElementStart=3]=`ElementStart`,e[e.Element=4]=`Element`,e[e.ForeignComponent=5]=`ForeignComponent`,e[e.Template=6]=`Template`,e[e.ElementEnd=7]=`ElementEnd`,e[e.ContainerStart=8]=`ContainerStart`,e[e.Container=9]=`Container`,e[e.ContainerEnd=10]=`ContainerEnd`,e[e.DisableBindings=11]=`DisableBindings`,e[e.ConditionalCreate=12]=`ConditionalCreate`,e[e.ConditionalBranchCreate=13]=`ConditionalBranchCreate`,e[e.Conditional=14]=`Conditional`,e[e.EnableBindings=15]=`EnableBindings`,e[e.Text=16]=`Text`,e[e.Listener=17]=`Listener`,e[e.InterpolateText=18]=`InterpolateText`,e[e.Binding=19]=`Binding`,e[e.Property=20]=`Property`,e[e.StyleProp=21]=`StyleProp`,e[e.ClassProp=22]=`ClassProp`,e[e.StyleMap=23]=`StyleMap`,e[e.ClassMap=24]=`ClassMap`,e[e.Advance=25]=`Advance`,e[e.Pipe=26]=`Pipe`,e[e.Attribute=27]=`Attribute`,e[e.ExtractedAttribute=28]=`ExtractedAttribute`,e[e.Defer=29]=`Defer`,e[e.DeferOn=30]=`DeferOn`,e[e.DeferWhen=31]=`DeferWhen`,e[e.I18nMessage=32]=`I18nMessage`,e[e.DomProperty=33]=`DomProperty`,e[e.Namespace=34]=`Namespace`,e[e.ProjectionDef=35]=`ProjectionDef`,e[e.EnableIncrementalHydrationRuntime=36]=`EnableIncrementalHydrationRuntime`,e[e.Projection=37]=`Projection`,e[e.Content=38]=`Content`,e[e.RepeaterCreate=39]=`RepeaterCreate`,e[e.Repeater=40]=`Repeater`,e[e.TwoWayProperty=41]=`TwoWayProperty`,e[e.TwoWayListener=42]=`TwoWayListener`,e[e.DeclareLet=43]=`DeclareLet`,e[e.StoreLet=44]=`StoreLet`,e[e.I18nStart=45]=`I18nStart`,e[e.I18n=46]=`I18n`,e[e.I18nEnd=47]=`I18nEnd`,e[e.I18nExpression=48]=`I18nExpression`,e[e.I18nApply=49]=`I18nApply`,e[e.IcuStart=50]=`IcuStart`,e[e.IcuEnd=51]=`IcuEnd`,e[e.IcuPlaceholder=52]=`IcuPlaceholder`,e[e.I18nContext=53]=`I18nContext`,e[e.I18nAttributes=54]=`I18nAttributes`,e[e.SourceLocation=55]=`SourceLocation`,e[e.Animation=56]=`Animation`,e[e.AnimationString=57]=`AnimationString`,e[e.AnimationBinding=58]=`AnimationBinding`,e[e.AnimationListener=59]=`AnimationListener`,e[e.Control=60]=`Control`,e[e.ControlCreate=61]=`ControlCreate`,e})(Nt||{}),Pt=(function(e){return e[e.LexicalRead=0]=`LexicalRead`,e[e.Context=1]=`Context`,e[e.TrackContext=2]=`TrackContext`,e[e.ReadVariable=3]=`ReadVariable`,e[e.NextContext=4]=`NextContext`,e[e.Reference=5]=`Reference`,e[e.StoreLet=6]=`StoreLet`,e[e.ContextLetReference=7]=`ContextLetReference`,e[e.GetCurrentView=8]=`GetCurrentView`,e[e.RestoreView=9]=`RestoreView`,e[e.ResetView=10]=`ResetView`,e[e.PureFunctionExpr=11]=`PureFunctionExpr`,e[e.PureFunctionParameterExpr=12]=`PureFunctionParameterExpr`,e[e.PipeBinding=13]=`PipeBinding`,e[e.PipeBindingVariadic=14]=`PipeBindingVariadic`,e[e.SafePropertyRead=15]=`SafePropertyRead`,e[e.SafeKeyedRead=16]=`SafeKeyedRead`,e[e.SafeNavigationMigration=17]=`SafeNavigationMigration`,e[e.SafeTernaryExpr=18]=`SafeTernaryExpr`,e[e.EmptyExpr=19]=`EmptyExpr`,e[e.AssignTemporaryExpr=20]=`AssignTemporaryExpr`,e[e.ReadTemporaryExpr=21]=`ReadTemporaryExpr`,e[e.SlotLiteralExpr=22]=`SlotLiteralExpr`,e[e.ConditionalCase=23]=`ConditionalCase`,e[e.ConstCollected=24]=`ConstCollected`,e[e.TwoWayBindingSet=25]=`TwoWayBindingSet`,e[e.ForeignContent=26]=`ForeignContent`,e[e.ArrowFunction=27]=`ArrowFunction`,e})(Pt||{}),Ft=(function(e){return e[e.None=0]=`None`,e[e.AlwaysInline=1]=`AlwaysInline`,e})(Ft||{}),It=(function(e){return e[e.Context=0]=`Context`,e[e.Identifier=1]=`Identifier`,e[e.SavedView=2]=`SavedView`,e[e.Alias=3]=`Alias`,e})(It||{}),Lt=(function(e){return e[e.Attribute=0]=`Attribute`,e[e.ClassName=1]=`ClassName`,e[e.StyleProperty=2]=`StyleProperty`,e[e.Property=3]=`Property`,e[e.Template=4]=`Template`,e[e.I18n=5]=`I18n`,e[e.LegacyAnimation=6]=`LegacyAnimation`,e[e.TwoWayProperty=7]=`TwoWayProperty`,e[e.Animation=8]=`Animation`,e})(Lt||{}),Rt=(function(e){return e[e.Creation=0]=`Creation`,e[e.Postproccessing=1]=`Postproccessing`,e})(Rt||{}),zt=(function(e){return e[e.I18nText=0]=`I18nText`,e[e.I18nAttribute=1]=`I18nAttribute`,e})(zt||{}),Bt=(function(e){return e[e.None=0]=`None`,e[e.ElementTag=1]=`ElementTag`,e[e.TemplateTag=2]=`TemplateTag`,e[e.OpenTag=4]=`OpenTag`,e[e.CloseTag=8]=`CloseTag`,e[e.ExpressionIndex=16]=`ExpressionIndex`,e})(Bt||{}),Vt=(function(e){return e[e.HTML=0]=`HTML`,e[e.SVG=1]=`SVG`,e[e.Math=2]=`Math`,e})(Vt||{}),Ht=(function(e){return e[e.Idle=0]=`Idle`,e[e.Immediate=1]=`Immediate`,e[e.Timer=2]=`Timer`,e[e.Hover=3]=`Hover`,e[e.Interaction=4]=`Interaction`,e[e.Viewport=5]=`Viewport`,e[e.Never=6]=`Never`,e})(Ht||{}),Ut=(function(e){return e[e.RootI18n=0]=`RootI18n`,e[e.Icu=1]=`Icu`,e[e.Attr=2]=`Attr`,e})(Ut||{}),Wt=(function(e){return e[e.NgTemplate=0]=`NgTemplate`,e[e.Structural=1]=`Structural`,e[e.Block=2]=`Block`,e})(Wt||{}),Gt=(function(e){return e[e.None=0]=`None`,e[e.InChildOperation=1]=`InChildOperation`,e[e.InArrowFunctionOperation=2]=`InArrowFunctionOperation`,e[e.InSafeNavigationMigration=4]=`InSafeNavigationMigration`,e})(Gt||{});Nt.Element,Nt.ElementStart,Nt.Container,Nt.ContainerStart,Nt.Template,Nt.RepeaterCreate,Nt.ConditionalCreate,Nt.ConditionalBranchCreate;var b=(function(e){return e[e.Tmpl=0]=`Tmpl`,e[e.Host=1]=`Host`,e[e.Both=2]=`Both`,e})(b||{}),Kt=(function(e){return e[e.Full=0]=`Full`,e[e.DomOnly=1]=`DomOnly`,e})(Kt||{});y.ariaProperty,y.ariaProperty,y.attribute,y.attribute,y.classProp,y.classProp,y.element,y.element,y.elementContainer,y.elementContainer,y.elementContainerEnd,y.elementContainerEnd,y.elementContainerStart,y.elementContainerStart,y.elementEnd,y.elementEnd,y.elementStart,y.elementStart,y.domProperty,y.domProperty,y.i18nExp,y.i18nExp,y.listener,y.listener,y.listener,y.listener,y.property,y.property,y.styleProp,y.styleProp,y.syntheticHostListener,y.syntheticHostListener,y.syntheticHostProperty,y.syntheticHostProperty,y.templateCreate,y.templateCreate,y.twoWayProperty,y.twoWayProperty,y.twoWayListener,y.twoWayListener,y.declareLet,y.declareLet,y.conditionalCreate,y.conditionalBranchCreate,y.conditionalBranchCreate,y.conditionalBranchCreate,y.domElement,y.domElement,y.domElementStart,y.domElementStart,y.domElementEnd,y.domElementEnd,y.domElementContainer,y.domElementContainer,y.domElementContainerStart,y.domElementContainerStart,y.domElementContainerEnd,y.domElementContainerEnd,y.domListener,y.domListener,y.domTemplate,y.domTemplate,y.animationEnter,y.animationEnter,y.animationLeave,y.animationLeave,y.animationEnterListener,y.animationEnterListener,y.animationLeaveListener,y.animationLeaveListener,_.And,_.Bigger,_.BiggerEquals,_.BitwiseOr,_.BitwiseAnd,_.Divide,_.Assign,_.Equals,_.Identical,_.Lower,_.LowerEquals,_.Minus,_.Modulo,_.Exponentiation,_.Multiply,_.NotEquals,_.NotIdentical,_.NullishCoalesce,_.Or,_.Plus,_.In,_.InstanceOf,_.AdditionAssignment,_.SubtractionAssignment,_.MultiplicationAssignment,_.DivisionAssignment,_.RemainderAssignment,_.ExponentiationAssignment,_.AndAssignment,_.OrAssignment,_.NullishCoalesceAssignment,Nt.Property,Nt.Property,Nt.Property,Nt.Attribute,Nt.Attribute,Nt.Property,Nt.TwoWayProperty,Nt.Container,Nt.ContainerStart,Nt.ContainerEnd,Nt.Element,Nt.ElementStart,Nt.ElementEnd,Nt.Template,Nt.ElementEnd,Nt.ElementStart,Nt.Element,Nt.ContainerEnd,Nt.ContainerStart,Nt.Container,Nt.I18nEnd,Nt.I18nStart,Nt.I18n,Nt.Pipe;var qt=` \f -\r \v ᠎ - \u2028\u2029   `;`${qt}`,`${qt}`;var Jt=(function(e){return e[e.Character=0]=`Character`,e[e.Identifier=1]=`Identifier`,e[e.PrivateIdentifier=2]=`PrivateIdentifier`,e[e.Keyword=3]=`Keyword`,e[e.String=4]=`String`,e[e.Operator=5]=`Operator`,e[e.Number=6]=`Number`,e[e.RegExpBody=7]=`RegExpBody`,e[e.RegExpFlags=8]=`RegExpFlags`,e[e.Error=9]=`Error`,e})(Jt||{}),Yt=(function(e){return e[e.Plain=0]=`Plain`,e[e.TemplateLiteralPart=1]=`TemplateLiteralPart`,e[e.TemplateLiteralEnd=2]=`TemplateLiteralEnd`,e})(Yt||{});Jt.Character,Nt.StyleMap,Nt.ClassMap,Nt.StyleProp,Nt.ClassProp,Nt.Attribute,Nt.Property,Nt.Attribute,Nt.Control,Nt.DomProperty,Nt.DomProperty,Nt.Attribute,Nt.StyleMap,Nt.ClassMap,Nt.StyleProp,Nt.ClassProp,Nt.Listener,Nt.TwoWayListener,Nt.AnimationListener,Nt.StyleMap,Nt.ClassMap,Nt.StyleProp,Nt.ClassProp,Nt.Property,Nt.TwoWayProperty,Nt.DomProperty,Nt.Attribute,Nt.Animation,Nt.Control,Ht.Idle,y.deferOnIdle,y.deferPrefetchOnIdle,y.deferHydrateOnIdle,Ht.Immediate,y.deferOnImmediate,y.deferPrefetchOnImmediate,y.deferHydrateOnImmediate,Ht.Timer,y.deferOnTimer,y.deferPrefetchOnTimer,y.deferHydrateOnTimer,Ht.Hover,y.deferOnHover,y.deferPrefetchOnHover,y.deferHydrateOnHover,Ht.Interaction,y.deferOnInteraction,y.deferPrefetchOnInteraction,y.deferHydrateOnInteraction,Ht.Viewport,y.deferOnViewport,y.deferPrefetchOnViewport,y.deferHydrateOnViewport,Ht.Never,y.deferHydrateNever,y.deferHydrateNever,y.deferHydrateNever,y.pipeBind1,y.pipeBind2,y.pipeBind3,y.pipeBind4,y.textInterpolate,y.textInterpolate1,y.textInterpolate2,y.textInterpolate3,y.textInterpolate4,y.textInterpolate5,y.textInterpolate6,y.textInterpolate7,y.textInterpolate8,y.textInterpolateV,y.interpolate,y.interpolate1,y.interpolate2,y.interpolate3,y.interpolate4,y.interpolate5,y.interpolate6,y.interpolate7,y.interpolate8,y.interpolateV,y.pureFunction0,y.pureFunction1,y.pureFunction2,y.pureFunction3,y.pureFunction4,y.pureFunction5,y.pureFunction6,y.pureFunction7,y.pureFunction8,y.pureFunctionV,y.resolveWindow,y.resolveDocument,y.resolveBody,nt.HTML,y.sanitizeHtml,nt.RESOURCE_URL,y.sanitizeResourceUrl,nt.SCRIPT,y.sanitizeScript,nt.STYLE,y.sanitizeStyle,nt.URL,y.sanitizeUrl,nt.ATTRIBUTE_NO_BINDING,y.validateAttribute,nt.HTML,y.trustConstantHtml,nt.RESOURCE_URL,y.trustConstantResourceUrl;var Xt=(function(e){return e[e.None=0]=`None`,e[e.ViewContextRead=1]=`ViewContextRead`,e[e.ViewContextWrite=2]=`ViewContextWrite`,e[e.SideEffectful=4]=`SideEffectful`,e})(Xt||{});b.Tmpl,b.Tmpl,b.Both,b.Host,b.Tmpl,b.Tmpl,b.Tmpl,b.Both,b.Both,b.Both,b.Tmpl,b.Both,b.Both,b.Tmpl,b.Both,b.Tmpl,b.Both,b.Both,b.Tmpl,b.Tmpl,b.Tmpl,b.Tmpl,b.Tmpl,b.Both,b.Both,b.Tmpl,b.Tmpl,b.Tmpl,b.Tmpl,b.Both,b.Both,b.Both,b.Tmpl,b.Tmpl,b.Both,b.Tmpl,b.Tmpl,b.Tmpl,b.Both,b.Both,b.Tmpl,b.Both,b.Both,b.Both,b.Both,b.Both,b.Tmpl,b.Tmpl,b.Tmpl,b.Tmpl,b.Tmpl,b.Tmpl,b.Tmpl,b.Tmpl,b.Tmpl,b.Tmpl,b.Tmpl,b.Tmpl,b.Both,b.Tmpl,b.Both,b.Tmpl,b.Both,b.Tmpl,b.Tmpl,b.Tmpl,b.Tmpl,b.Tmpl,b.Tmpl,b.Both,b.Both,b.Both,Tt.Property,Lt.Property,Tt.TwoWay,Lt.TwoWayProperty,Tt.Attribute,Lt.Attribute,Tt.Class,Lt.ClassName,Tt.Style,Lt.StyleProperty,Tt.LegacyAnimation,Lt.LegacyAnimation,Tt.Animation,Lt.Animation;var Zt=`%COMP%`;`${Zt}`,`${Zt}`,class e{static SINGLETON=new e;static veWillInferAnyFor(t){let n=e.SINGLETON;return t instanceof wt?t.visit(n):t.receiver.visit(n)}visitUnary(e){return e.expr.visit(this)}visitBinary(e){return e.left.visit(this)||e.right.visit(this)}visitChain(){return!1}visitConditional(e){return e.condition.visit(this)||e.trueExp.visit(this)||e.falseExp.visit(this)}visitCall(){return!0}visitSafeCall(){return!1}visitImplicitReceiver(){return!1}visitThisReceiver(){return!1}visitInterpolation(e){return e.expressions.some(e=>e.visit(this))}visitKeyedRead(){return!1}visitLiteralArray(){return!0}visitLiteralMap(){return!0}visitLiteralPrimitive(){return!1}visitPipe(){return!0}visitPrefixNot(e){return e.expression.visit(this)}visitTypeofExpression(e){return e.expression.visit(this)}visitVoidExpression(e){return e.expression.visit(this)}visitNonNullAssert(e){return e.expression.visit(this)}visitPropertyRead(){return!1}visitSafePropertyRead(){return!1}visitSafeKeyedRead(){return!1}visitTemplateLiteral(){return!1}visitTemplateLiteralElement(){return!1}visitTaggedTemplateLiteral(){return!1}visitParenthesizedExpression(e){return e.expression.visit(this)}visitRegularExpressionLiteral(){return!1}visitSpreadElement(e){return e.expression.visit(this)}visitArrowFunction(e,t){return!1}};var Qt=null,$t=!1,en=1,tn=null,nn=Symbol(`SIGNAL`);function rn(e){let t=Qt;return Qt=e,t}function an(){return Qt}var on={version:0,lastCleanEpoch:0,dirty:!1,producers:void 0,producersTail:void 0,consumers:void 0,consumersTail:void 0,recomputing:!1,consumerAllowSignalWrites:!1,consumerIsAlwaysLive:!1,kind:`unknown`,producerMustRecompute:()=>!1,producerRecomputeValue:()=>{},consumerMarkedDirty:()=>{},consumerOnSignalRead:()=>{}};function sn(e){if($t)throw Error(``);if(Qt===null)return;Qt.consumerOnSignalRead(e);let t=Qt.producersTail;if(t!==void 0&&t.producer===e)return;let n,r=Qt.recomputing;if(r&&(n=t===void 0?Qt.producers:t.nextProducer,n!==void 0&&n.producer===e)){Qt.producersTail=n,n.lastReadVersion=e.version,n.knownValidAtEpoch=en;return}let i=e.consumersTail;if(i!==void 0&&i.consumer===Qt&&(!r||i.knownValidAtEpoch===en))return;let a=Sn(Qt),o={producer:e,consumer:Qt,nextProducer:n,prevConsumer:void 0,knownValidAtEpoch:en,lastReadVersion:e.version,nextConsumer:void 0};Qt.producersTail=o,t===void 0?Qt.producers=o:t.nextProducer=o,a&&bn(e,o)}function cn(){en++}function ln(e){if((!Sn(e)||e.dirty)&&(e.dirty||e.lastCleanEpoch!==en)){if(!e.producerMustRecompute(e)&&!vn(e)){pn(e);return}e.producerRecomputeValue(e),pn(e)}}function un(e){if(e.consumers===void 0)return;let t=$t;$t=!0;try{for(let t=e.consumers;t!==void 0;t=t.nextConsumer){let e=t.consumer;e.dirty||fn(e)}}finally{$t=t}}function dn(){return Qt?.consumerAllowSignalWrites!==!1}function fn(e){e.dirty=!0,un(e),e.consumerMarkedDirty?.(e)}function pn(e){e.dirty=!1,e.lastCleanEpoch=en}function mn(e){return e&&hn(e),rn(e)}function hn(e){if(e.producersTail?.knownValidAtEpoch===en){let t=e.producers;for(;t!==void 0;)t.knownValidAtEpoch=null,t=t.nextProducer}e.producersTail=void 0,e.recomputing=!0}function gn(e,t){rn(t),e&&_n(e)}function _n(e){e.recomputing=!1;let t=e.producersTail,n=t===void 0?e.producers:t.nextProducer;if(n!==void 0){if(Sn(e))do n=xn(n);while(n!==void 0);t===void 0?e.producers=void 0:t.nextProducer=void 0}}function vn(e){for(let t=e.producers;t!==void 0;t=t.nextProducer){let e=t.producer,n=t.lastReadVersion;if(n!==e.version||(ln(e),n!==e.version))return!0}return!1}function yn(e){if(Sn(e)){let t=e.producers;for(;t!==void 0;)t=xn(t)}e.producers=void 0,e.producersTail=void 0,e.consumers=void 0,e.consumersTail=void 0}function bn(e,t){let n=e.consumersTail,r=Sn(e);if(n===void 0?(t.nextConsumer=void 0,e.consumers=t):(t.nextConsumer=n.nextConsumer,n.nextConsumer=t),t.prevConsumer=n,e.consumersTail=t,!r)for(let t=e.producers;t!==void 0;t=t.nextProducer)bn(t.producer,t)}function xn(e){let t=e.producer,n=e.nextProducer,r=e.nextConsumer,i=e.prevConsumer;if(e.nextConsumer=void 0,e.prevConsumer=void 0,r===void 0?t.consumersTail=i:r.prevConsumer=i,i!==void 0)i.nextConsumer=r;else if(t.consumers=r,!Sn(t)){let e=t.producers;for(;e!==void 0;)e=xn(e)}return n}function Sn(e){return e.consumerIsAlwaysLive||e.consumers!==void 0}function Cn(e){tn?.(e)}function wn(e,t){return Object.is(e,t)}function Tn(e,t){let n=Object.create(dee);n.computation=e,t!==void 0&&(n.equal=t);let r=()=>{if(ln(n),sn(n),n.value===On)throw n.error;return n.value};return r[nn]=n,Cn(n),r}var En=Symbol(`UNSET`),Dn=Symbol(`COMPUTING`),On=Symbol(`ERRORED`),dee={...on,value:En,dirty:!0,error:null,equal:wn,kind:`computed`,producerMustRecompute(e){return e.value===En||e.value===Dn},producerRecomputeValue(e){if(e.value===Dn)throw Error(``);let t=e.value;e.value=Dn;let n=mn(e),r,i=!1;try{r=e.computation(),rn(null),i=t!==En&&t!==On&&r!==On&&e.equal(t,r)}catch(t){r=On,e.error=t}finally{gn(e,n)}if(i){e.value=t;return}e.value=r,e.version++}};function fee(){throw Error()}var kn=fee;function An(e){kn(e)}function pee(e){kn=e}var mee=null;function hee(e,t){let n=Object.create(Nn);n.value=e,t!==void 0&&(n.equal=t);let r=()=>gee(n);return r[nn]=n,Cn(n),[r,e=>jn(n,e),e=>Mn(n,e)]}function gee(e){return sn(e),e.value}function jn(e,t){dn()||An(e),e.equal(e.value,t)||(e.value=t,Pn(e))}function Mn(e,t){dn()||An(e),jn(e,t(e.value))}var Nn={...on,equal:wn,value:void 0,kind:`signal`};function Pn(e){e.version++,cn(),un(e),mee?.(e)}var Fn={...on,consumerIsAlwaysLive:!0,consumerAllowSignalWrites:!0,dirty:!0,kind:`effect`};function In(e){if(e.dirty=!1,e.version>0&&!vn(e))return;e.version++;let t=mn(e);try{e.cleanup(),e.fn()}finally{gn(e,t)}}var Ln=void 0;function Rn(){return Ln}function zn(e){let t=Ln;return Ln=e,t}var Bn=Symbol(`NotFound`);function Vn(e){return e===Bn||e?.name===`ɵNotFound`}function Hn(e,t,n){let r=Object.create(Gn);r.source=e,r.computation=t,n!=null&&(r.equal=n);let i=()=>{if(ln(r),sn(r),r.value===On)throw r.error;return r.value};return i[nn]=r,Cn(r),i}function Un(e,t){ln(e),jn(e,t),pn(e)}function Wn(e,t){if(ln(e),e.value===On)throw e.error;Mn(e,t),pn(e)}var Gn={...on,value:En,dirty:!0,error:null,equal:wn,kind:`linkedSignal`,producerMustRecompute(e){return e.value===En||e.value===Dn},producerRecomputeValue(e){if(e.value===Dn)throw Error(``);let t=e.value;e.value=Dn;let n=mn(e),r,i=!1;try{let n=e.source(),a=t!==En&&t!==On,o=a?{source:e.sourceValue,value:t}:void 0;r=e.computation(n,o),e.sourceValue=n,rn(null),i=a&&r!==On&&e.equal(t,r)}catch(t){r=On,e.error=t}finally{gn(e,n)}if(i){e.value=t;return}e.value=r,e.version++}};function Kn(e){let t=rn(null);try{return e()}finally{rn(t)}}var qn=function(e,t){return qn=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&(e[n]=t[n])},qn(e,t)};function Jn(e,t){if(typeof t!=`function`&&t!==null)throw TypeError(`Class extends value `+String(t)+` is not a constructor or null`);qn(e,t);function n(){this.constructor=e}e.prototype=t===null?Object.create(t):(n.prototype=t.prototype,new n)}function Yn(e,t,n,r){function i(e){return e instanceof n?e:new n(function(t){t(e)})}return new(n||=Promise)(function(n,a){function o(e){try{c(r.next(e))}catch(e){a(e)}}function s(e){try{c(r.throw(e))}catch(e){a(e)}}function c(e){e.done?n(e.value):i(e.value).then(o,s)}c((r=r.apply(e,t||[])).next())})}function Xn(e,t){var n={label:0,sent:function(){if(a[0]&1)throw a[1];return a[1]},trys:[],ops:[]},r,i,a,o=Object.create((typeof Iterator==`function`?Iterator:Object).prototype);return o.next=s(0),o.throw=s(1),o.return=s(2),typeof Symbol==`function`&&(o[Symbol.iterator]=function(){return this}),o;function s(e){return function(t){return c([e,t])}}function c(s){if(r)throw TypeError(`Generator is already executing.`);for(;o&&(o=0,s[0]&&(n=0)),n;)try{if(r=1,i&&(a=s[0]&2?i.return:s[0]?i.throw||((a=i.return)&&a.call(i),0):i.next)&&!(a=a.call(i,s[1])).done)return a;switch(i=0,a&&(s=[s[0]&2,a.value]),s[0]){case 0:case 1:a=s;break;case 4:return n.label++,{value:s[1],done:!1};case 5:n.label++,i=s[1],s=[0];continue;case 7:s=n.ops.pop(),n.trys.pop();continue;default:if(a=n.trys,!(a=a.length>0&&a[a.length-1])&&(s[0]===6||s[0]===2)){n=0;continue}if(s[0]===3&&(!a||s[1]>a[0]&&s[1]=e.length&&(e=void 0),{value:e&&e[r++],done:!e}}};throw TypeError(t?`Object is not iterable.`:`Symbol.iterator is not defined.`)}function Qn(e,t){var n=typeof Symbol==`function`&&e[Symbol.iterator];if(!n)return e;var r=n.call(e),i,a=[],o;try{for(;(t===void 0||t-->0)&&!(i=r.next()).done;)a.push(i.value)}catch(e){o={error:e}}finally{try{i&&!i.done&&(n=r.return)&&n.call(r)}finally{if(o)throw o.error}}return a}function $n(e,t,n){if(n||arguments.length===2)for(var r=0,i=t.length,a;r1||c(e,t)})},t&&(i[e]=t(i[e])))}function c(e,t){try{l(r[e](t))}catch(e){f(a[0][3],e)}}function l(e){e.value instanceof er?Promise.resolve(e.value.v).then(u,d):f(a[0][2],e)}function u(e){c(`next`,e)}function d(e){c(`throw`,e)}function f(e,t){e(t),a.shift(),a.length&&c(a[0][0],a[0][1])}}function nr(e){if(!Symbol.asyncIterator)throw TypeError(`Symbol.asyncIterator is not defined.`);var t=e[Symbol.asyncIterator],n;return t?t.call(e):(e=typeof Zn==`function`?Zn(e):e[Symbol.iterator](),n={},r(`next`),r(`throw`),r(`return`),n[Symbol.asyncIterator]=function(){return this},n);function r(t){n[t]=e[t]&&function(n){return new Promise(function(r,a){n=e[t](n),i(r,a,n.done,n.value)})}}function i(e,t,n,r){Promise.resolve(r).then(function(t){e({value:t,done:n})},t)}}function rr(e){return typeof e==`function`}function ir(e){var t=e(function(e){Error.call(e),e.stack=Error().stack});return t.prototype=Object.create(Error.prototype),t.prototype.constructor=t,t}var ar=ir(function(e){return function(t){e(this),this.message=t?t.length+` errors occurred during unsubscription: -`+t.map(function(e,t){return t+1+`) `+e.toString()}).join(` - `):``,this.name=`UnsubscriptionError`,this.errors=t}});function or(e,t){if(e){var n=e.indexOf(t);0<=n&&e.splice(n,1)}}var sr=function(){function e(e){this.initialTeardown=e,this.closed=!1,this._parentage=null,this._finalizers=null}return e.prototype.unsubscribe=function(){var e,t,n,r,i;if(!this.closed){this.closed=!0;var a=this._parentage;if(a){if(this._parentage=null,Array.isArray(a))try{for(var o=Zn(a),s=o.next();!s.done;s=o.next())s.value.remove(this)}catch(t){e={error:t}}finally{try{s&&!s.done&&(t=o.return)&&t.call(o)}finally{if(e)throw e.error}}else a.remove(this)}var c=this.initialTeardown;if(rr(c))try{c()}catch(e){i=e instanceof ar?e.errors:[e]}var l=this._finalizers;if(l){this._finalizers=null;try{for(var u=Zn(l),d=u.next();!d.done;d=u.next()){var f=d.value;try{ur(f)}catch(e){i??=[],e instanceof ar?i=$n($n([],Qn(i)),Qn(e.errors)):i.push(e)}}}catch(e){n={error:e}}finally{try{d&&!d.done&&(r=u.return)&&r.call(u)}finally{if(n)throw n.error}}}if(i)throw new ar(i)}},e.prototype.add=function(t){if(t&&t!==this){if(this.closed)ur(t);else{if(t instanceof e){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=this._finalizers??[]).push(t)}}},e.prototype._hasParent=function(e){var t=this._parentage;return t===e||Array.isArray(t)&&t.includes(e)},e.prototype._addParent=function(e){var t=this._parentage;this._parentage=Array.isArray(t)?(t.push(e),t):t?[t,e]:e},e.prototype._removeParent=function(e){var t=this._parentage;t===e?this._parentage=null:Array.isArray(t)&&or(t,e)},e.prototype.remove=function(t){var n=this._finalizers;n&&or(n,t),t instanceof e&&t._removeParent(this)},e.EMPTY=(function(){var t=new e;return t.closed=!0,t})(),e}(),cr=sr.EMPTY;function lr(e){return e instanceof sr||e&&`closed`in e&&rr(e.remove)&&rr(e.add)&&rr(e.unsubscribe)}function ur(e){rr(e)?e():e.unsubscribe()}var dr={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1},fr={setTimeout:function(e,t){var n=[...arguments].slice(2),r=fr.delegate;return r?.setTimeout?r.setTimeout.apply(r,$n([e,t],Qn(n))):setTimeout.apply(void 0,$n([e,t],Qn(n)))},clearTimeout:function(e){return(fr.delegate?.clearTimeout||clearTimeout)(e)},delegate:void 0};function pr(e){fr.setTimeout(function(){var t=dr.onUnhandledError;if(t)t(e);else throw e})}function mr(){}var hr=(function(){return vr(`C`,void 0,void 0)})();function gr(e){return vr(`E`,void 0,e)}function _r(e){return vr(`N`,e,void 0)}function vr(e,t,n){return{kind:e,value:t,error:n}}var yr=null;function br(e){if(dr.useDeprecatedSynchronousErrorHandling){var t=!yr;if(t&&(yr={errorThrown:!1,error:null}),e(),t){var n=yr,r=n.errorThrown,i=n.error;if(yr=null,r)throw i}}else e()}function xr(e){dr.useDeprecatedSynchronousErrorHandling&&yr&&(yr.errorThrown=!0,yr.error=e)}var Sr=function(e){Jn(t,e);function t(t){var n=e.call(this)||this;return n.isStopped=!1,t?(n.destination=t,lr(t)&&t.add(n)):n.destination=Ar,n}return t.create=function(e,t,n){return new Er(e,t,n)},t.prototype.next=function(e){this.isStopped?kr(_r(e),this):this._next(e)},t.prototype.error=function(e){this.isStopped?kr(gr(e),this):(this.isStopped=!0,this._error(e))},t.prototype.complete=function(){this.isStopped?kr(hr,this):(this.isStopped=!0,this._complete())},t.prototype.unsubscribe=function(){this.closed||(this.isStopped=!0,e.prototype.unsubscribe.call(this),this.destination=null)},t.prototype._next=function(e){this.destination.next(e)},t.prototype._error=function(e){try{this.destination.error(e)}finally{this.unsubscribe()}},t.prototype._complete=function(){try{this.destination.complete()}finally{this.unsubscribe()}},t}(sr),Cr=Function.prototype.bind;function wr(e,t){return Cr.call(e,t)}var Tr=function(){function e(e){this.partialObserver=e}return e.prototype.next=function(e){var t=this.partialObserver;if(t.next)try{t.next(e)}catch(e){Dr(e)}},e.prototype.error=function(e){var t=this.partialObserver;if(t.error)try{t.error(e)}catch(e){Dr(e)}else Dr(e)},e.prototype.complete=function(){var e=this.partialObserver;if(e.complete)try{e.complete()}catch(e){Dr(e)}},e}(),Er=function(e){Jn(t,e);function t(t,n,r){var i=e.call(this)||this,a;if(rr(t)||!t)a={next:t??void 0,error:n??void 0,complete:r??void 0};else{var o;i&&dr.useDeprecatedNextContext?(o=Object.create(t),o.unsubscribe=function(){return i.unsubscribe()},a={next:t.next&&wr(t.next,o),error:t.error&&wr(t.error,o),complete:t.complete&&wr(t.complete,o)}):a=t}return i.destination=new Tr(a),i}return t}(Sr);function Dr(e){dr.useDeprecatedSynchronousErrorHandling?xr(e):pr(e)}function Or(e){throw e}function kr(e,t){var n=dr.onStoppedNotification;n&&fr.setTimeout(function(){return n(e,t)})}var Ar={closed:!0,next:mr,error:Or,complete:mr},jr=(function(){return typeof Symbol==`function`&&Symbol.observable||`@@observable`})();function Mr(e){return e}function Nr(e){return e.length===0?Mr:e.length===1?e[0]:function(t){return e.reduce(function(e,t){return t(e)},t)}}var Pr=function(){function e(e){e&&(this._subscribe=e)}return e.prototype.lift=function(t){var n=new e;return n.source=this,n.operator=t,n},e.prototype.subscribe=function(e,t,n){var r=this,i=Lr(e)?e:new Er(e,t,n);return br(function(){var e=r,t=e.operator,n=e.source;i.add(t?t.call(i,n):n?r._subscribe(i):r._trySubscribe(i))}),i},e.prototype._trySubscribe=function(e){try{return this._subscribe(e)}catch(t){e.error(t)}},e.prototype.forEach=function(e,t){var n=this;return t=Fr(t),new t(function(t,r){var i=new Er({next:function(t){try{e(t)}catch(e){r(e),i.unsubscribe()}},error:r,complete:t});n.subscribe(i)})},e.prototype._subscribe=function(e){return this.source?.subscribe(e)},e.prototype[jr]=function(){return this},e.prototype.pipe=function(){return Nr([...arguments])(this)},e.prototype.toPromise=function(e){var t=this;return e=Fr(e),new e(function(e,n){var r;t.subscribe(function(e){return r=e},function(e){return n(e)},function(){return e(r)})})},e.create=function(t){return new e(t)},e}();function Fr(e){return e??dr.Promise??Promise}function Ir(e){return e&&rr(e.next)&&rr(e.error)&&rr(e.complete)}function Lr(e){return e&&e instanceof Sr||Ir(e)&&lr(e)}function Rr(e){return rr(e?.lift)}function zr(e){return function(t){if(Rr(t))return t.lift(function(t){try{return e(t,this)}catch(e){this.error(e)}});throw TypeError(`Unable to lift unknown Observable type`)}}function Br(e,t,n,r,i){return new Vr(e,t,n,r,i)}var Vr=function(e){Jn(t,e);function t(t,n,r,i,a,o){var s=e.call(this,t)||this;return s.onFinalize=a,s.shouldUnsubscribe=o,s._next=n?function(e){try{n(e)}catch(e){t.error(e)}}:e.prototype._next,s._error=i?function(e){try{i(e)}catch(e){t.error(e)}finally{this.unsubscribe()}}:e.prototype._error,s._complete=r?function(){try{r()}catch(e){t.error(e)}finally{this.unsubscribe()}}:e.prototype._complete,s}return t.prototype.unsubscribe=function(){var t;if(!this.shouldUnsubscribe||this.shouldUnsubscribe()){var n=this.closed;e.prototype.unsubscribe.call(this),!n&&((t=this.onFinalize)==null||t.call(this))}},t}(Sr),Hr=ir(function(e){return function(){e(this),this.name=`ObjectUnsubscribedError`,this.message=`object unsubscribed`}}),Ur=function(e){Jn(t,e);function t(){var t=e.call(this)||this;return t.closed=!1,t.currentObservers=null,t.observers=[],t.isStopped=!1,t.hasError=!1,t.thrownError=null,t}return t.prototype.lift=function(e){var t=new Wr(this,this);return t.operator=e,t},t.prototype._throwIfClosed=function(){if(this.closed)throw new Hr},t.prototype.next=function(e){var t=this;br(function(){var n,r;if(t._throwIfClosed(),!t.isStopped){t.currentObservers||=Array.from(t.observers);try{for(var i=Zn(t.currentObservers),a=i.next();!a.done;a=i.next())a.value.next(e)}catch(e){n={error:e}}finally{try{a&&!a.done&&(r=i.return)&&r.call(i)}finally{if(n)throw n.error}}}})},t.prototype.error=function(e){var t=this;br(function(){if(t._throwIfClosed(),!t.isStopped){t.hasError=t.isStopped=!0,t.thrownError=e;for(var n=t.observers;n.length;)n.shift().error(e)}})},t.prototype.complete=function(){var e=this;br(function(){if(e._throwIfClosed(),!e.isStopped){e.isStopped=!0;for(var t=e.observers;t.length;)t.shift().complete()}})},t.prototype.unsubscribe=function(){this.isStopped=this.closed=!0,this.observers=this.currentObservers=null},Object.defineProperty(t.prototype,"observed",{get:function(){return this.observers?.length>0},enumerable:!1,configurable:!0}),t.prototype._trySubscribe=function(t){return this._throwIfClosed(),e.prototype._trySubscribe.call(this,t)},t.prototype._subscribe=function(e){return this._throwIfClosed(),this._checkFinalizedStatuses(e),this._innerSubscribe(e)},t.prototype._innerSubscribe=function(e){var t=this,n=this,r=n.hasError,i=n.isStopped,a=n.observers;return r||i?cr:(this.currentObservers=null,a.push(e),new sr(function(){t.currentObservers=null,or(a,e)}))},t.prototype._checkFinalizedStatuses=function(e){var t=this,n=t.hasError,r=t.thrownError,i=t.isStopped;n?e.error(r):i&&e.complete()},t.prototype.asObservable=function(){var e=new Pr;return e.source=this,e},t.create=function(e,t){return new Wr(e,t)},t}(Pr),Wr=function(e){Jn(t,e);function t(t,n){var r=e.call(this)||this;return r.destination=t,r.source=n,r}return t.prototype.next=function(e){var t,n;(n=(t=this.destination)?.next)==null||n.call(t,e)},t.prototype.error=function(e){var t,n;(n=(t=this.destination)?.error)==null||n.call(t,e)},t.prototype.complete=function(){var e,t;(t=(e=this.destination)?.complete)==null||t.call(e)},t.prototype._subscribe=function(e){return this.source?.subscribe(e)??cr},t}(Ur),Gr=function(e){Jn(t,e);function t(t){var n=e.call(this)||this;return n._value=t,n}return Object.defineProperty(t.prototype,"value",{get:function(){return this.getValue()},enumerable:!1,configurable:!0}),t.prototype._subscribe=function(t){var n=e.prototype._subscribe.call(this,t);return!n.closed&&t.next(this._value),n},t.prototype.getValue=function(){var e=this,t=e.hasError,n=e.thrownError,r=e._value;if(t)throw n;return this._throwIfClosed(),r},t.prototype.next=function(t){e.prototype.next.call(this,this._value=t)},t}(Ur);function Kr(e){return e[e.length-1]}function qr(e){return rr(Kr(e))?e.pop():void 0}var Jr=(function(e){return e&&typeof e.length==`number`&&typeof e!=`function`});function Yr(e){return rr(e?.then)}function Xr(e){return rr(e[jr])}function Zr(e){return Symbol.asyncIterator&&rr(e?.[Symbol.asyncIterator])}function Qr(e){return TypeError(`You provided `+(typeof e==`object`&&e?`an invalid object`:`'`+e+`'`)+` where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.`)}function $r(){return typeof Symbol!=`function`||!Symbol.iterator?`@@iterator`:Symbol.iterator}var ei=$r();function ti(e){return rr(e?.[ei])}function ni(e){return tr(this,arguments,function(){var t,n,r,i;return Xn(this,function(a){switch(a.label){case 0:t=e.getReader(),a.label=1;case 1:a.trys.push([1,,9,10]),a.label=2;case 2:return[4,er(t.read())];case 3:return n=a.sent(),r=n.value,i=n.done,i?[4,er(void 0)]:[3,5];case 4:return[2,a.sent()];case 5:return[4,er(r)];case 6:return[4,a.sent()];case 7:return a.sent(),[3,2];case 8:return[3,10];case 9:return t.releaseLock(),[7];case 10:return[2]}})})}function ri(e){return rr(e?.getReader)}function ii(e){if(e instanceof Pr)return e;if(e!=null){if(Xr(e))return _ee(e);if(Jr(e))return vee(e);if(Yr(e))return yee(e);if(Zr(e))return ai(e);if(ti(e))return bee(e);if(ri(e))return xee(e)}throw Qr(e)}function _ee(e){return new Pr(function(t){var n=e[jr]();if(rr(n.subscribe))return n.subscribe(t);throw TypeError(`Provided object does not correctly implement Symbol.observable`)})}function vee(e){return new Pr(function(t){for(var n=0;n=0?n.slice(0,r):n}function gi(e,t){return e?t?`${e} ${t}`:e:t||``}var Uee=mi({__forward_ref__:mi});function _i(e){return e.__forward_ref__=_i,e}function vi(e){return yi(e)?e():e}function yi(e){return typeof e==`function`&&Object.hasOwn(e,Uee)&&e.__forward_ref__===_i}function bi(e){return{token:e.token,providedIn:e.providedIn||null,factory:e.factory,value:void 0}}function xi(e){return Si(e,Ti)}function Si(e,t){return Object.hasOwn(e,t)&&e[t]||null}function Ci(e){return(e?.[Ti]??null)||null}function wi(e){return e&&Object.hasOwn(e,Ei)?e[Ei]:null}var Ti=mi({ɵprov:mi}),Ei=mi({ɵinj:mi}),Di=class{_desc;ngMetadataName=`InjectionToken`;ɵprov;constructor(e,t){this._desc=e,this.ɵprov=void 0,typeof t==`number`?this.__NG_ELEMENT_ID__=t:t!==void 0&&(this.ɵprov=bi({token:this,providedIn:t.providedIn||`root`,factory:t.factory}))}get multi(){return this}toString(){return`InjectionToken ${this._desc}`}};function Oi(e){return e&&!!e.ɵproviders}var ki=mi({ɵcmp:mi}),Ai=mi({ɵdir:mi}),ji=mi({ɵpipe:mi}),Mi=mi({ɵfac:mi}),Ni=mi({__NG_ELEMENT_ID__:mi}),Pi=mi({__NG_ENV_ID__:mi});function Fi(e){return Ri(e,`@Component`),e[ki]||null}function Ii(e){return Ri(e,`@Directive`),e[Ai]||null}function Li(e){return Ri(e,`@Pipe`),e[ji]||null}function Ri(e,t){if(e==null)throw new fi(-919,!1)}function zi(e){return typeof e==`string`?e:e==null?``:String(e)}var Bi=mi({ngErrorCode:mi}),Vi=mi({ngErrorMessage:mi}),Hi=mi({ngTokenPath:mi});function Ui(e,t){return Gi(``,-200,t)}function Wi(e,t){throw new fi(-201,!1)}function Gi(e,t,n){let r=new fi(t,e);return r[Bi]=t,r[Vi]=e,n&&(r[Hi]=n),r}function Ki(e){return e[Bi]}var qi;function Ji(){return qi}function Yi(e){let t=qi;return qi=e,t}function Xi(e,t,n){let r=xi(e);if(r&&r.providedIn==`root`)return r.value===void 0?r.value=r.factory():r.value;if(n&8)return null;if(t!==void 0)return t;Wi(e,``)}var Zi=globalThis,Qi={},$i=`__NG_DI_FLAG__`,ea=class{injector;constructor(e){this.injector=e}retrieve(e,t){let n=ra(t)||0;try{return this.injector.get(e,n&8?null:Qi,n)}catch(e){if(Vn(e))return e;throw e}}};function ta(e,t=0){let n=Rn();if(n===void 0)throw new fi(-203,!1);if(n===null)return Xi(e,void 0,t);{let r=ia(t),i=n.retrieve(e,r);if(Vn(i)){if(r.optional)return null;throw i}return i}}function na(e,t=0){return(Ji()||ta)(vi(e),t)}function x(e,t){return na(e,ra(t))}function ra(e){return e===void 0||typeof e==`number`?e:0|(e.optional&&8)|(e.host&&1)|(e.self&&2)|(e.skipSelf&&4)}function ia(e){return{optional:!!(e&8),host:!!(e&1),self:!!(e&2),skipSelf:!!(e&4)}}function aa(e){let t=[];for(let n=0;nArray.isArray(e)?ua(e,t):t(e))}function da(e,t,n){t>=e.length?e.push(n):e.splice(t,0,n)}function fa(e,t){return t>=e.length-1?e.pop():e.splice(t,1)[0]}function pa(e,t){let n=[];for(let r=0;rt;){let t=i-2;e[i]=e[t],i--}e[t]=n,e[t+1]=r}}function ha(e,t,n){let r=_a(e,t);return r>=0?e[r|1]=n:(r=~r,ma(e,r,t,n)),r}function ga(e,t){let n=_a(e,t);if(n>=0)return e[n|1]}function _a(e,t){return va(e,t,1)}function va(e,t,n){let r=0,i=e.length>>n;for(;i!==r;){let a=r+(i-r>>1),o=e[a<t?i=a:r=a+1}return~(i<{n.push(e)};return ua(t,e=>{let t=e;Oa(t,a,[],r)&&(i||=[],i.push(t))}),i!==void 0&&Da(i,a),n}function Da(e,t){for(let n=0;n{t(e,r)})}}function Oa(e,t,n,r){if(e=vi(e),!e)return!1;let i=null,a=wi(e),o=!a&&Fi(e);if(!a&&!o){let t=e.ngModule;if(a=wi(t),a)i=t;else return!1}else if(o&&!o.standalone)return!1;else i=e;let s=r.has(i);if(o){if(s)return!1;if(r.add(i),o.dependencies){let e=typeof o.dependencies==`function`?o.dependencies():o.dependencies;for(let i of e)Oa(i,t,n,r)}}else if(a){if(a.imports!=null&&!s){r.add(i);let e;try{ua(a.imports,i=>{Oa(i,t,n,r)&&(e||=[],e.push(i))})}finally{}e!==void 0&&Da(e,t)}if(!s){let e=sa(i)||(()=>new i);t({provide:i,useFactory:e,deps:ba},i),t({provide:Ca,useValue:i,multi:!0},i),t({provide:xa,useValue:()=>na(i),multi:!0},i)}let o=a.providers;if(o!=null&&!s){let n=e;ka(o,e=>{t(e,n)})}}else return!1;return i!==e&&e.providers!==void 0}function ka(e,t){for(let n of e)Oi(n)&&(n=n.ɵproviders),Array.isArray(n)?ka(n,t):t(n)}var Aa=mi({provide:String,useValue:mi});function ja(e){return typeof e==`object`&&!!e&&Aa in e}function Ma(e){return!!(e&&e.useExisting)}function Na(e){return!!(e&&e.useFactory)}function Pa(e){return typeof e==`function`}function Fa(e){return!!e.useClass}var Ia=new Di(``),La={},Ra={},za=void 0;function Ba(){return za===void 0&&(za=new wa),za}var Va=class{},Ha=class extends Va{parent;source;scopes;records=new Map;_ngOnDestroyHooks=new Set;_onDestroyHooks=[];get destroyed(){return this._destroyed}_destroyed=!1;injectorDefTypes;constructor(e,t,n,r){super(),this.parent=t,this.source=n,this.scopes=r,Qa(e,e=>this.processProvider(e)),this.records.set(Sa,Ja(void 0,this)),r.has(`environment`)&&this.records.set(Va,Ja(void 0,this));let i=this.records.get(Ia);i!=null&&typeof i.value==`string`&&this.scopes.add(i.value),this.injectorDefTypes=new Set(this.get(Ca,ba,{self:!0}))}retrieve(e,t){let n=ra(t)||0;try{return this.get(e,Qi,n)}catch(e){if(Vn(e))return e;throw e}}destroy(){qa(this),this._destroyed=!0;let e=rn(null);try{for(let e of this._ngOnDestroyHooks)e.ngOnDestroy();let e=this._onDestroyHooks;this._onDestroyHooks=[];for(let t of e)t()}finally{this.records.clear(),this._ngOnDestroyHooks.clear(),this.injectorDefTypes.clear(),rn(e)}}onDestroy(e){return qa(this),this._onDestroyHooks.push(e),()=>this.removeOnDestroy(e)}runInContext(e){qa(this);let t=zn(this),n=Yi(void 0);try{return e()}finally{zn(t),Yi(n)}}get(e,t=Qi,n){if(qa(this),Object.hasOwn(e,Pi))return e[Pi](this);let r=ra(n),i=zn(this),a=Yi(void 0);try{if(!(r&4)){let t=this.records.get(e);if(t===void 0){let n=Za(e)&&xi(e);t=n&&this.injectableDefInScope(n)?Ja(Ua(e),La):null,this.records.set(e,t)}if(t!=null)return this.hydrate(e,t,r)}let n=r&2?Ba():this.parent;return t=r&8&&t===Qi?null:t,n.get(e,t)}catch(e){let t=Ki(e);throw t===-200||t===-201?new fi(t,null):e}finally{Yi(a),zn(i)}}resolveInjectorInitializers(){let e=rn(null),t=zn(this),n=Yi(void 0);try{let e=this.get(xa,ba,{self:!0});for(let t of e)t()}finally{zn(t),Yi(n),rn(e)}}toString(){return`R3Injector[...]`}processProvider(e){e=vi(e);let t=Pa(e)?e:vi(e&&e.provide),n=Ga(e);if(!Pa(e)&&e.multi===!0){let n=this.records.get(t);n||(n=Ja(void 0,La,!0),n.factory=()=>aa(n.multi),this.records.set(t,n)),t=e,n.multi.push(e)}this.records.set(t,n)}hydrate(e,t,n){let r=rn(null);try{if(t.value===Ra)throw Ui(``);return t.value===La&&(t.value=Ra,t.value=t.factory(void 0,n)),typeof t.value==`object`&&t.value&&Xa(t.value)&&this._ngOnDestroyHooks.add(t.value),t.value}finally{rn(r)}}injectableDefInScope(e){if(!e.providedIn)return!1;let t=vi(e.providedIn);return typeof t==`string`?t===`any`||this.scopes.has(t):this.injectorDefTypes.has(t)}removeOnDestroy(e){let t=this._onDestroyHooks.indexOf(e);t!==-1&&this._onDestroyHooks.splice(t,1)}};function Ua(e){let t=xi(e),n=t===null?sa(e):t.factory;if(n!==null)return n;if(e instanceof Di)throw new fi(-204,!1);if(e instanceof Function)return Wa(e);throw new fi(-204,!1)}function Wa(e){if(e.length>0)throw new fi(-204,!1);let t=Ci(e);return t===null?()=>new e:()=>t.factory(e)}function Ga(e){return ja(e)?Ja(void 0,e.useValue):Ja(Ka(e),La)}function Ka(e,t,n){let r;if(Pa(e)){let t=vi(e);return sa(t)||Ua(t)}if(ja(e))r=()=>vi(e.useValue);else if(Na(e))r=()=>e.useFactory(...aa(e.deps||[]));else if(Ma(e))r=(t,n)=>na(vi(e.useExisting),n!==void 0&&n&8?8:void 0);else{let t=vi(e&&(e.useClass||e.provide));if(Ya(e))r=()=>new t(...aa(e.deps));else return sa(t)||Ua(t)}return r}function qa(e){if(e.destroyed)throw new fi(-205,!1)}function Ja(e,t,n=!1){return{factory:e,value:t,multi:n?[]:void 0}}function Ya(e){return!!e.deps}function Xa(e){return typeof e==`object`&&!!e&&typeof e.ngOnDestroy==`function`}function Za(e){return typeof e==`function`||typeof e==`object`&&e.ngMetadataName===`InjectionToken`}function Qa(e,t){for(let n of e)Array.isArray(n)?Qa(n,t):n&&Oi(n)?Qa(n.ɵproviders,t):t(n)}function $a(e,t){let n;e instanceof Ha?(qa(e),n=e):n=new ea(e);let r=zn(n),i=Yi(void 0);try{return t()}finally{zn(r),Yi(i)}}function eo(){return Ji()!==void 0||Rn()!=null}var to=1;function no(e){return Array.isArray(e)&&typeof e[to]==`object`}function ro(e){return Array.isArray(e)&&e[to]===!0}function io(e){return!!(e.flags&4)}function ao(e){return e.componentOffset>-1}function oo(e){return(e.flags&1)==1}function so(e){return!!e.template}function co(e){return!!(e[2]&512)}function lo(e){return(e[2]&256)==256}var uo=(function(e){return e[e.NONE=0]=`NONE`,e[e.HTML=1]=`HTML`,e[e.STYLE=2]=`STYLE`,e[e.SCRIPT=3]=`SCRIPT`,e[e.URL=4]=`URL`,e[e.RESOURCE_URL=5]=`RESOURCE_URL`,e[e.ATTRIBUTE_NO_BINDING=6]=`ATTRIBUTE_NO_BINDING`,e})(uo||{}),fo=`math`;function po(e){for(;Array.isArray(e);)e=e[0];return e}function mo(e,t){return po(t[e])}function ho(e,t){return po(t[e.index])}function go(e,t){return e.data[t]}function _o(e,t){return e[t]}function vo(e,t,n,r){n>=e.data.length&&(e.data[n]=null,e.blueprint[n]=null),t[n]=r}function yo(e,t){let n=t[e];return no(n)?n:n[0]}function bo(e){return(e[2]&128)==128}function xo(e){return ro(e[3])}function So(e,t){return t==null?null:e[t]}function Co(e){e[17]=0}function wo(e){e[2]&1024||(e[2]|=1024,bo(e)&&Oo(e))}function To(e,t){for(;e>0;)t=t[14],e--;return t}function Eo(e){return!!(e[2]&9216||e[24]?.dirty)}function Do(e){e[10].changeDetectionScheduler?.notify(8),e[2]&64&&(e[2]|=1024),Eo(e)&&Oo(e)}function Oo(e){e[10].changeDetectionScheduler?.notify(0);let t=Ao(e);for(;t!==null&&!(t[2]&8192||(t[2]|=8192,!bo(t)));)t=Ao(t)}function ko(e,t){if(lo(e))throw new fi(911,!1);e[21]===null&&(e[21]=[]),e[21].push(t)}function Wee(e,t){if(e[21]===null)return;let n=e[21].indexOf(t);n!==-1&&e[21].splice(n,1)}function Ao(e){let t=e[3];return ro(t)?t[3]:t}function jo(e){return e[7]??=[]}function Mo(e){return e.cleanup??=[]}function Gee(e,t,n,r){let i=jo(t);i.push(n),e.firstCreatePass&&Mo(e).push(r,i.length-1)}var No={lFrame:as(null),bindingsEnabled:!0,skipHydrationRootTNode:null},Po=!1;function Kee(){return No.lFrame.elementDepthCount}function qee(){No.lFrame.elementDepthCount++}function Fo(){No.lFrame.elementDepthCount--}function Io(){return No.bindingsEnabled}function Lo(){return No.skipHydrationRootTNode!==null}function Ro(e){return No.skipHydrationRootTNode===e}function zo(){No.skipHydrationRootTNode=null}function Bo(){return No.lFrame.lView}function Vo(){return No.lFrame.tView}function S(e){return No.lFrame.contextLView=e,e[8]}function C(e){return No.lFrame.contextLView=null,e}function Ho(){let e=Uo();for(;e!==null&&e.type===64;)e=e.parent;return e}function Uo(){return No.lFrame.currentTNode}function Jee(){let e=No.lFrame,t=e.currentTNode;return e.isParent?t:t.parent}function Wo(e,t){let n=No.lFrame;n.currentTNode=e,n.isParent=t}function Go(){return No.lFrame.isParent}function Ko(){No.lFrame.isParent=!1}function qo(){return No.lFrame.contextLView}function Jo(){return Po}function Yo(e){let t=Po;return Po=e,t}function Xo(){let e=No.lFrame,t=e.bindingRootIndex;return t===-1&&(t=e.bindingRootIndex=e.tView.bindingStartIndex),t}function Zo(){return No.lFrame.bindingIndex}function Yee(e){return No.lFrame.bindingIndex=e}function Qo(){return No.lFrame.bindingIndex++}function $o(e){let t=No.lFrame,n=t.bindingIndex;return t.bindingIndex+=e,n}function Xee(){return No.lFrame.inI18n}function Zee(e,t){let n=No.lFrame;n.bindingIndex=n.bindingRootIndex=e,es(t)}function Qee(){return No.lFrame.currentDirectiveIndex}function es(e){No.lFrame.currentDirectiveIndex=e}function $ee(e){let t=No.lFrame.currentDirectiveIndex;return t===-1?null:e[t]}function ete(){return No.lFrame.currentQueryIndex}function ts(e){No.lFrame.currentQueryIndex=e}function tte(e){let t=e[1];return t.type===2?t.declTNode:t.type===1?e[5]:null}function ns(e,t,n){if(n&4){let r=t,i=e;for(;r=r.parent,r===null&&!(n&1)&&(r=tte(i),!(r===null||(i=i[14],r.type&10))););if(r===null)return!1;t=r,e=i}let r=No.lFrame=is();return r.currentTNode=t,r.lView=e,!0}function rs(e){let t=is(),n=e[1];No.lFrame=t,t.currentTNode=n.firstChild,t.lView=e,t.tView=n,t.contextLView=e,t.bindingIndex=n.bindingStartIndex,t.inI18n=!1}function is(){let e=No.lFrame,t=e===null?null:e.child;return t===null?as(e):t}function as(e){let t={currentTNode:null,isParent:!0,lView:null,tView:null,selectedIndex:-1,contextLView:null,elementDepthCount:0,currentNamespace:null,currentDirectiveIndex:-1,bindingRootIndex:-1,bindingIndex:-1,currentQueryIndex:0,parent:e,child:null,inI18n:!1};return e!==null&&(e.child=t),t}function os(){let e=No.lFrame;return No.lFrame=e.parent,e.currentTNode=null,e.lView=null,e}var ss=os;function cs(){let e=os();e.isParent=!0,e.tView=null,e.selectedIndex=-1,e.contextLView=null,e.elementDepthCount=0,e.currentDirectiveIndex=-1,e.currentNamespace=null,e.bindingRootIndex=-1,e.bindingIndex=-1,e.currentQueryIndex=0}function nte(e){return(No.lFrame.contextLView=To(e,No.lFrame.contextLView))[8]}function ls(){return No.lFrame.selectedIndex}function us(e){No.lFrame.selectedIndex=e}function ds(){let e=No.lFrame;return go(e.tView,e.selectedIndex)}function w(){No.lFrame.currentNamespace=`svg`}function fs(){ps()}function ps(){No.lFrame.currentNamespace=null}function ms(){return No.lFrame.currentNamespace}var hs=!0;function gs(){return hs}function _s(e){hs=e}function vs(e,t=null,n=null,r){let i=ys(e,t,n,r);return i.resolveInjectorInitializers(),i}function ys(e,t=null,n=null,r,i=new Set){return new Ha([n||ba,Ta(e)],t||Ba(),null,i)}var bs=class e{static THROW_IF_NOT_FOUND=Qi;static NULL=new wa;static create(e,t){if(Array.isArray(e))return vs({name:``},t,e,``);{let t=e.name??``;return vs({name:t},e.parent,e.providers,t)}}static ɵprov=bi({token:e,providedIn:`any`,factory:()=>na(Sa)});static __NG_ELEMENT_ID__=-1},xs=new Di(``),Ss=class{static __NG_ELEMENT_ID__=ws;static __NG_ENV_ID__=e=>e},Cs=class extends Ss{_lView;constructor(e){super(),this._lView=e}get destroyed(){return lo(this._lView)}onDestroy(e){let t=this._lView;return ko(t,e),()=>Wee(t,e)}};function ws(){return new Cs(Bo())}var Ts=new Di(``),Es=(()=>{class e{taskId=0;pendingTasks=new Set;destroyed=!1;pendingTask=new Gr(!1);debugTaskTracker=x(Ts,{optional:!0});get hasPendingTasks(){return!this.destroyed&&this.pendingTask.value}get hasPendingTasksObservable(){return this.destroyed?new Pr(e=>{e.next(!1),e.complete()}):this.pendingTask}add(){!this.hasPendingTasks&&!this.destroyed&&this.pendingTask.next(!0);let e=this.taskId++;return this.pendingTasks.add(e),this.debugTaskTracker?.add(e),e}has(e){return this.pendingTasks.has(e)}remove(e){this.pendingTasks.delete(e),this.debugTaskTracker?.remove(e),this.pendingTasks.size===0&&this.hasPendingTasks&&this.pendingTask.next(!1)}ngOnDestroy(){this.pendingTasks.clear(),this.hasPendingTasks&&this.pendingTask.next(!1),this.destroyed=!0,this.pendingTask.unsubscribe()}static ɵprov=bi({token:e,providedIn:`root`,factory:()=>new e})}return e})(),Ds=class extends Ur{__isAsync;destroyRef=void 0;pendingTasks=void 0;constructor(e=!1){super(),this.__isAsync=e,eo()&&(this.destroyRef=x(Ss,{optional:!0})??void 0,this.pendingTasks=x(Es,{optional:!0})??void 0)}emit(e){let t=rn(null);try{super.next(e)}finally{rn(t)}}subscribe(e,t,n){let r=e,i=t||(()=>null),a=n;if(e&&typeof e==`object`){let t=e;r=t.next?.bind(t),i=t.error?.bind(t),a=t.complete?.bind(t)}this.__isAsync&&(i=this.wrapInTimeout(i),r&&=this.wrapInTimeout(r),a&&=this.wrapInTimeout(a));let o=super.subscribe({next:r,error:i,complete:a});return e instanceof sr&&e.add(o),o}wrapInTimeout(e){return t=>{let n=this.pendingTasks?.add();setTimeout(()=>{try{e(t)}finally{n!==void 0&&this.pendingTasks?.remove(n)}})}}};function Os(...e){}function ks(e){let t,n;function r(){e=Os;try{n!==void 0&&typeof cancelAnimationFrame==`function`&&cancelAnimationFrame(n),t!==void 0&&clearTimeout(t)}catch{}}return t=setTimeout(()=>{e(),r()}),typeof requestAnimationFrame==`function`&&(n=requestAnimationFrame(()=>{e(),r()})),()=>r()}function As(e){return queueMicrotask(()=>e()),()=>{e=Os}}var js=`isAngularZone`,Ms=`isAngularZone_ID`,Ns=0,Ps=class e{hasPendingMacrotasks=!1;hasPendingMicrotasks=!1;isStable=!0;onUnstable=new Ds(!1);onMicrotaskEmpty=new Ds(!1);onStable=new Ds(!1);onError=new Ds(!1);constructor(e){let{enableLongStackTrace:t=!1,shouldCoalesceEventChangeDetection:n=!1,shouldCoalesceRunChangeDetection:r=!1,scheduleInRootZone:i=!1}=e;if(typeof Zone>`u`)throw new fi(908,!1);Zone.assertZonePatched();let a=this;a._nesting=0,a._outer=a._inner=Zone.current,Zone.TaskTrackingZoneSpec&&(a._inner=a._inner.fork(new Zone.TaskTrackingZoneSpec)),t&&Zone.longStackTraceZoneSpec&&(a._inner=a._inner.fork(Zone.longStackTraceZoneSpec)),a.shouldCoalesceEventChangeDetection=!r&&n,a.shouldCoalesceRunChangeDetection=r,a.callbackScheduled=!1,a.scheduleInRootZone=i,Rs(a)}static isInAngularZone(){return typeof Zone<`u`&&Zone.current.get(js)===!0}static assertInAngularZone(){if(!e.isInAngularZone())throw new fi(909,!1)}static assertNotInAngularZone(){if(e.isInAngularZone())throw new fi(909,!1)}run(e,t,n){return this._inner.run(e,t,n)}runTask(e,t,n,r){let i=this._inner,a=i.scheduleEventTask(`NgZoneEvent: `+r,e,Fs,Os,Os);try{return i.runTask(a,t,n)}finally{i.cancelTask(a)}}runGuarded(e,t,n){return this._inner.runGuarded(e,t,n)}runOutsideAngular(e){return this._outer.run(e)}},Fs={};function Is(e){if(e._nesting==0&&!e.hasPendingMicrotasks&&!e.isStable)try{e._nesting++,e.onMicrotaskEmpty.emit(null)}finally{if(e._nesting--,!e.hasPendingMicrotasks)try{e.runOutsideAngular(()=>e.onStable.emit(null))}finally{e.isStable=!0}}}function Ls(e){if(e.isCheckStableRunning||e.callbackScheduled)return;e.callbackScheduled=!0;function t(){ks(()=>{e.callbackScheduled=!1,zs(e),e.isCheckStableRunning=!0,Is(e),e.isCheckStableRunning=!1})}e.scheduleInRootZone?Zone.root.run(()=>{t()}):e._outer.run(()=>{t()}),zs(e)}function Rs(e){let t=()=>{Ls(e)},n=Ns++;e._inner=e._inner.fork({name:`angular`,properties:{[js]:!0,[Ms]:n,[Ms+n]:!0},onInvokeTask:(n,r,i,a,o,s)=>{if(Us(s))return n.invokeTask(i,a,o,s);try{return Bs(e),n.invokeTask(i,a,o,s)}finally{(e.shouldCoalesceEventChangeDetection&&a.type===`eventTask`||e.shouldCoalesceRunChangeDetection)&&t(),Vs(e)}},onInvoke:(n,r,i,a,o,s,c)=>{try{return Bs(e),n.invoke(i,a,o,s,c)}finally{e.shouldCoalesceRunChangeDetection&&!e.callbackScheduled&&!Ws(s)&&t(),Vs(e)}},onHasTask:(t,n,r,i)=>{t.hasTask(r,i),n===r&&(i.change==`microTask`?(e._hasPendingMicrotasks=i.microTask,zs(e),Is(e)):i.change==`macroTask`&&(e.hasPendingMacrotasks=i.macroTask))},onHandleError:(t,n,r,i)=>(t.handleError(r,i),e.runOutsideAngular(()=>e.onError.emit(i)),!1)})}function zs(e){e.hasPendingMicrotasks=!!(e._hasPendingMicrotasks||(e.shouldCoalesceEventChangeDetection||e.shouldCoalesceRunChangeDetection)&&e.callbackScheduled===!0)}function Bs(e){e._nesting++,e.isStable&&(e.isStable=!1,e.onUnstable.emit(null))}function Vs(e){e._nesting--,Is(e)}var Hs=class{hasPendingMicrotasks=!1;hasPendingMacrotasks=!1;isStable=!0;onUnstable=new Ds;onMicrotaskEmpty=new Ds;onStable=new Ds;onError=new Ds;run(e,t,n){return e.apply(t,n)}runGuarded(e,t,n){return e.apply(t,n)}runOutsideAngular(e){return e()}runTask(e,t,n,r){return e.apply(t,n)}};function Us(e){return Gs(e,`__ignore_ng_zone__`)}function Ws(e){return Gs(e,`__scheduler_tick__`)}function Gs(e,t){return!Array.isArray(e)||e.length!==1?!1:e[0]?.data?.[t]===!0}var Ks=class{_console=console;handleError(e){this._console.error(`ERROR`,e)}},qs=new Di(``,{factory:()=>{let e=x(Ps),t=x(Va),n;return r=>{e.runOutsideAngular(()=>{t.destroyed&&!n?setTimeout(()=>{throw r}):(n??=t.get(Ks),n.handleError(r))})}}}),Js={provide:xa,useValue:()=>{x(Ks,{optional:!0})},multi:!0};function T(e,t){let[n,r,i]=hee(e,t?.equal),a=n;return a[nn],a.set=r,a.update=i,a.asReadonly=Ys.bind(a),a}function Ys(){let e=this[nn];if(e.readonlyFn===void 0){let t=()=>this();t[nn]=e,e.readonlyFn=t}return e.readonlyFn}var Xs=new Di(``,{factory:()=>Zs}),Zs=`ng`,Qs=new Di(``),$s=new Di(``,{providedIn:`platform`,factory:()=>`unknown`}),ec=new Di(``,{factory:()=>x(xs).body?.querySelector(`[ngCspNonce]`)?.getAttribute(`ngCspNonce`)||null}),tc=(()=>{class e{view;node;constructor(e,t){this.view=e,this.node=t}static __NG_ELEMENT_ID__=nc}return e})();function nc(){return new tc(Bo(),Ho())}var rc=class{},ic=new Di(``,{factory:()=>!0}),ac=new Di(``),oc=(()=>{class e{static ɵprov=bi({token:e,providedIn:`root`,factory:()=>new sc})}return e})(),sc=class{dirtyEffectCount=0;queues=new Map;add(e){this.enqueue(e),this.schedule(e)}schedule(e){e.dirty&&this.dirtyEffectCount++}remove(e){let t=e.zone,n=this.queues.get(t);n.has(e)&&(n.delete(e),e.dirty&&this.dirtyEffectCount--)}enqueue(e){let t=e.zone;this.queues.has(t)||this.queues.set(t,new Set);let n=this.queues.get(t);n.has(e)||n.add(e)}flush(){for(;this.dirtyEffectCount>0;){let e=!1;for(let[t,n]of this.queues)e||=t===null?this.flushQueue(n):t.run(()=>this.flushQueue(n));e||(this.dirtyEffectCount=0)}}flushQueue(e){let t=!1;for(let n of e)n.dirty&&(this.dirtyEffectCount--,t=!0,n.run());return t}},cc=class{[nn];constructor(e){this[nn]=e}destroy(){this[nn].destroy()}};function E(e,t){let n=t?.injector??x(bs),r=t?.manualCleanup===!0?null:n.get(Ss),i,a=n.get(tc,null,{optional:!0}),o=n.get(rc);return a===null?i=pc(e,n.get(oc),o):(i=fc(a.view,o,e),r instanceof Cs&&r._lView===a.view&&(r=null)),i.injector=n,r!==null&&(i.onDestroyFns=[r.onDestroy(()=>i.destroy())]),new cc(i)}var lc={...Fn,cleanupFns:void 0,zone:null,onDestroyFns:null,run(){let e=Yo(!1);try{In(this)}finally{Yo(e)}},cleanup(){if(!this.cleanupFns?.length)return;let e=rn(null);try{for(;this.cleanupFns.length;)this.cleanupFns.pop()()}finally{this.cleanupFns=[],rn(e)}}},uc={...lc,consumerMarkedDirty(){this.scheduler.schedule(this),this.notifier.notify(12)},destroy(){if(yn(this),this.onDestroyFns!==null)for(let e of this.onDestroyFns)e();this.cleanup(),this.scheduler.remove(this)}},dc={...lc,consumerMarkedDirty(){this.view[2]|=8192,Oo(this.view),this.notifier.notify(13)},destroy(){if(yn(this),this.onDestroyFns!==null)for(let e of this.onDestroyFns)e();this.cleanup(),this.view[23]?.delete(this)}};function fc(e,t,n){let r=Object.create(dc);return r.view=e,r.zone=typeof Zone<`u`?Zone.current:null,r.notifier=t,r.fn=mc(r,n),e[23]??=new Set,e[23].add(r),r.consumerMarkedDirty(r),r}function pc(e,t,n){let r=Object.create(uc);return r.fn=mc(r,e),r.scheduler=t,r.notifier=n,r.zone=typeof Zone<`u`?Zone.current:null,r.scheduler.add(r),r.notifier.notify(12),r}function mc(e,t){return()=>{t(t=>(e.cleanupFns??=[]).push(t))}}function hc(e){return typeof e==`function`&&e[nn]!==void 0}function gc(e){return hc(e)&&typeof e.set==`function`}var _c=Symbol(`InputSignalNode#UNSET`),vc={...Nn,transformFn:void 0,applyValueToInputSignal(e,t){jn(e,t)}};function yc(e){return{toString:e}.toString()}var bc=(function(e){return e[e.TemplateCreateStart=0]=`TemplateCreateStart`,e[e.TemplateCreateEnd=1]=`TemplateCreateEnd`,e[e.TemplateUpdateStart=2]=`TemplateUpdateStart`,e[e.TemplateUpdateEnd=3]=`TemplateUpdateEnd`,e[e.LifecycleHookStart=4]=`LifecycleHookStart`,e[e.LifecycleHookEnd=5]=`LifecycleHookEnd`,e[e.OutputStart=6]=`OutputStart`,e[e.OutputEnd=7]=`OutputEnd`,e[e.BootstrapApplicationStart=8]=`BootstrapApplicationStart`,e[e.BootstrapApplicationEnd=9]=`BootstrapApplicationEnd`,e[e.BootstrapComponentStart=10]=`BootstrapComponentStart`,e[e.BootstrapComponentEnd=11]=`BootstrapComponentEnd`,e[e.ChangeDetectionStart=12]=`ChangeDetectionStart`,e[e.ChangeDetectionEnd=13]=`ChangeDetectionEnd`,e[e.ChangeDetectionSyncStart=14]=`ChangeDetectionSyncStart`,e[e.ChangeDetectionSyncEnd=15]=`ChangeDetectionSyncEnd`,e[e.AfterRenderHooksStart=16]=`AfterRenderHooksStart`,e[e.AfterRenderHooksEnd=17]=`AfterRenderHooksEnd`,e[e.ComponentStart=18]=`ComponentStart`,e[e.ComponentEnd=19]=`ComponentEnd`,e[e.DeferBlockStateStart=20]=`DeferBlockStateStart`,e[e.DeferBlockStateEnd=21]=`DeferBlockStateEnd`,e[e.DynamicComponentStart=22]=`DynamicComponentStart`,e[e.DynamicComponentEnd=23]=`DynamicComponentEnd`,e[e.HostBindingsUpdateStart=24]=`HostBindingsUpdateStart`,e[e.HostBindingsUpdateEnd=25]=`HostBindingsUpdateEnd`,e})(bc||{}),xc=class{previousValue;currentValue;firstChange;constructor(e,t,n){this.previousValue=e,this.currentValue=t,this.firstChange=n}isFirstChange(){return this.firstChange}};function Sc(e,t,n,r){t===null?e[n]=r:t.applyValueToInputSignal(t,r)}var Cc=null,wc=(()=>{Cc=Ec;let e=()=>Ec;return e.ngInherit=!0,e})();function Tc(){return Cc}function Ec(e){return e.type.prototype.ngOnChanges&&(e.setInput=Oc),Dc}function Dc(){let e=Ac(this),t=e?.current;if(t){let n=e.previous;if(n===ya)e.previous=t;else for(let e in t)n[e]=t[e];e.current=null,this.ngOnChanges(t)}}function Oc(e,t,n,r,i){let a=this.declaredInputs[r],o=Ac(e)||jc(e,{previous:ya,current:null}),s=o.current||={},c=o.previous,l=c[a];s[a]=new xc(l&&l.currentValue,n,c===ya),Sc(e,t,i,n)}var kc=`__ngSimpleChanges__`;function Ac(e){return Object.hasOwn(e,kc)&&e[kc]||null}function jc(e,t){return e[kc]=t}var Mc=[],Nc=function(e,t=null,n){for(let r=0;r=r)break}else t[c]<0&&(e[17]+=65536),(s>14>16&&(e[2]&3)===t&&(e[2]+=16384,Bc(o,a)):Bc(o,a)}var Hc=-1,Uc=class{factory;name;injectImpl;resolving=!1;canSeeViewProviders;multi;componentProviders;index;providerFactory;constructor(e,t,n,r){this.factory=e,this.name=r,this.canSeeViewProviders=t,this.injectImpl=n}};function Wc(e){return!!(e.flags&8)}function Gc(e){return!!(e.flags&16)}function Kc(e,t,n){let r=0;for(;rt){o=a-1;break}}}for(;a>16}function el(e,t){let n=$c(e),r=t;for(;n>0;)r=r[14],n--;return r}var tl=!0;function nl(e){let t=tl;return tl=e,t}var rl=255,il=5,al=0,ol={};function sl(e,t,n){let r;typeof n==`string`?r=n.charCodeAt(0)||0:Object.hasOwn(n,Ni)&&(r=n[Ni]),r??=n[Ni]=al++;let i=r&rl,a=1<>il)]|=a}function cl(e,t){let n=ul(e,t);if(n!==-1)return n;let r=t[1];r.firstCreatePass&&(e.injectorIndex=t.length,ll(r.data,e),ll(t,null),ll(r.blueprint,null));let i=dl(e,t),a=e.injectorIndex;if(Zc(i)){let e=Qc(i),n=el(i,t),r=n[1].data;for(let i=0;i<8;i++)t[a+i]=n[e+i]|r[e+i]}return t[a+8]=i,a}function ll(e,t){e.push(0,0,0,0,0,0,0,0,t)}function ul(e,t){return e.injectorIndex===-1||e.parent&&e.parent.injectorIndex===e.injectorIndex||t[e.injectorIndex+8]===null?-1:e.injectorIndex}function dl(e,t){if(e.parent&&e.parent.injectorIndex!==-1)return e.parent.injectorIndex;let n=0,r=null,i=t;for(;i!==null;){if(r=Ol(i),r===null)return Hc;if(n++,i=i[14],r.injectorIndex!==-1)return r.injectorIndex|n<<16}return Hc}function fl(e,t,n){sl(e,t,n)}function pl(e,t,n){if(n&8||e!==void 0)return e;Wi(t,`NodeInjector`)}function ml(e,t,n,r){if(n&8&&r===void 0&&(r=null),!(n&3)){let i=e[9],a=Yi(void 0);try{return i?i.get(t,r,n&8):Xi(t,r,n&8)}finally{Yi(a)}}return pl(r,t,n)}function hl(e,t,n,r=0,i){if(e!==null){if(t[2]&2048&&!(r&2)){let i=Dl(e,t,n,r,ol);if(i!==ol)return i}let i=gl(e,t,n,r,ol);if(i!==ol)return i}return ml(t,n,r,i)}function gl(e,t,n,r,i){let a=bl(n);if(typeof a==`function`){if(!ns(t,e,r))return r&1?pl(i,n,r):ml(t,n,r,i);try{let e;if(e=a(r),e==null&&!(r&8))Wi(n);else return e}finally{ss()}}else if(typeof a==`number`){let i=null,o=ul(e,t),s=Hc,c=r&1?t[15][5]:null;for((o===-1||r&4)&&(s=o===-1?dl(e,t):t[o+8],s===Hc||!Sl(r,!1)?o=-1:(i=t[1],o=Qc(s),t=el(s,t)));o!==-1;){let e=t[1];if(xl(a,o,e.data)){let e=_l(o,t,n,i,r,c);if(e!==ol)return e}s=t[o+8],s!==Hc&&Sl(r,t[1].data[o+8]===c)&&xl(a,o,t)?(i=e,o=Qc(s),t=el(s,t)):o=-1}}return i}function _l(e,t,n,r,i,a){let o=t[1],s=o.data[e+8],c=vl(s,o,n,r==null?ao(s)&&tl:r!=o&&!!(s.type&3),i&1&&a===s);return c===null?ol:yl(t,o,c,s,i)}function vl(e,t,n,r,i){let a=e.providerIndexes,o=t.data,s=a&1048575,c=e.directiveStart,l=e.directiveEnd,u=a>>20,d=r?s:s+u,f=i?s+u:l;for(let e=d;e=c&&t.type===n)return e}if(i){let e=o[c];if(e&&so(e)&&e.type===n)return c}return null}function yl(e,t,n,r,i){let a=e[n],o=t.data;if(a instanceof Uc){let s=a;if(s.resolving)throw Ui(``);let c=nl(s.canSeeViewProviders);s.resolving=!0,o[n].type||o[n];let l=s.injectImpl?Yi(s.injectImpl):null;ns(e,r,0);try{a=e[n]=s.factory(void 0,i,o,e,r),t.firstCreatePass&&n>=r.directiveStart&&Pc(n,o[n],t)}finally{l!==null&&Yi(l),nl(c),s.resolving=!1,ss()}}return a}function bl(e){if(typeof e==`string`)return e.charCodeAt(0)||0;let t=Object.hasOwn(e,Ni)?e[Ni]:void 0;return typeof t==`number`?t>=0?t&rl:wl:t}function xl(e,t,n){let r=1<>il)]&r)}function Sl(e,t){return!(e&2)&&!(e&1&&t)}var Cl=class{_tNode;_lView;constructor(e,t){this._tNode=e,this._lView=t}get(e,t,n){return hl(this._tNode,this._lView,e,ra(n),t)}};function wl(){return new Cl(Ho(),Bo())}function Tl(e){return yc(()=>{let t=e.prototype.constructor,n=t[Mi]||El(t),r=Object.prototype,i=Object.getPrototypeOf(e.prototype).constructor;for(;i&&i!==r;){let e=i[Mi]||El(i);if(e&&e!==n)return e;i=Object.getPrototypeOf(i)}return e=>new e})}function El(e){return yi(e)?()=>{let t=El(vi(e));return t&&t()}:sa(e)}function Dl(e,t,n,r,i){let a=e,o=t;for(;a!==null&&o!==null&&o[2]&2048&&!co(o);){let e=gl(a,o,n,r|2,ol);if(e!==ol)return e;r&=-5;let t=a.parent;if(!t){let e=o[20];if(e){let t=e.get(n,ol,r);if(t!==ol)return t}t=Ol(o),o=o[14]}a=t}return i}function Ol(e){let t=e[1],n=t.type;return n===2?t.declTNode:n===1?e[5]:null}function kl(e){return{token:e.token,providedIn:e.autoProvided===!1?null:`root`,factory:e.factory,value:void 0}}function Al(){return jl(Ho(),Bo())}function jl(e,t){return new Ml(ho(e,t))}var Ml=(()=>{class e{nativeElement;constructor(e){this.nativeElement=e}static __NG_ELEMENT_ID__=Al}return e})();function Nl(e){return e instanceof Ml?e.nativeElement:e}function Pl(){return this._results[Symbol.iterator]()}var Fl=class{_emitDistinctChangesOnly;dirty=!0;_onDirty=void 0;_results=[];_changesDetected=!1;_changes=void 0;length=0;first=void 0;last=void 0;get changes(){return this._changes??=new Ur}constructor(e=!1){this._emitDistinctChangesOnly=e}get(e){return this._results[e]}map(e){return this._results.map(e)}filter(e){return this._results.filter(e)}find(e){return this._results.find(e)}reduce(e,t){return this._results.reduce(e,t)}forEach(e){this._results.forEach(e)}some(e){return this._results.some(e)}toArray(){return this._results.slice()}toString(){return this._results.toString()}reset(e,t){this.dirty=!1;let n=la(e);(this._changesDetected=!ca(this._results,n,t))&&(this._results=n,this.length=n.length,this.last=n[this.length-1],this.first=n[0])}notifyOnChanges(){this._changes!==void 0&&(this._changesDetected||!this._emitDistinctChangesOnly)&&this._changes.next(this)}onDirty(e){this._onDirty=e}setDirty(){this.dirty=!0,this._onDirty?.()}destroy(){this._changes!==void 0&&(this._changes.complete(),this._changes.unsubscribe())}[Symbol.iterator]=Pl};function Il(e){return(e.flags&128)==128}var Ll=(function(e){return e[e.OnPush=0]=`OnPush`,e[e.Eager=1]=`Eager`,e[e.Default=1]=`Default`,e})(Ll||{}),Rl=new Map,zl=0;function Bl(){return zl++}function Vl(e){Rl.set(e[19],e)}function Hl(e){Rl.delete(e[19])}var Ul=`__ngContext__`;function Wl(e,t){no(t)?(e[Ul]=t[19],Vl(t)):e[Ul]=t}function Gl(e){return ql(e[12])}function Kl(e){return ql(e[4])}function ql(e){for(;e!==null&&!ro(e);)e=e[4];return e}var Jl=void 0;function Yl(e){Jl=e}function Xl(){if(Jl!==void 0)return Jl;if(typeof document<`u`)return document;throw new fi(210,!1)}var Zl=!1,Ql=new Di(``,{factory:()=>Zl}),$l=new WeakMap;function eu(e,t){if(typeof e!=`object`||!e)return;let n=$l.get(e);n||(n=new WeakSet,$l.set(e,n)),n.add(t)}function tu(e){return(e.flags&32)==32}var nu=()=>null;function ru(e,t,n=!1){return nu(e,t,n)}function iu(e,t){let n=e.contentQueries;if(n!==null){let r=rn(null);try{for(let r=0;r|^->||--!>|)/g,gu=`​$1​`;function _u(e){return e.replace(mu,e=>e.replace(hu,gu))}function vu(e,t){return e.createText(t)}function yu(e,t,n){e.setValue(t,n)}function bu(e,t){return e.createComment(_u(t))}function xu(e,t,n){return e.createElement(t,n)}function Su(e,t,n,r,i){e.insertBefore(t,n,r,i)}function Cu(e,t,n){e.appendChild(t,n)}function wu(e,t,n,r,i){r===null?Cu(e,t,n):Su(e,t,n,r,i)}function Tu(e,t,n,r){e.removeChild(null,t,n,r)}function rte(e,t,n){e.setAttribute(t,`style`,n)}function ite(e,t,n){n===``?e.removeAttribute(t,`class`):e.setAttribute(t,`class`,n)}function Eu(e,t,n){let{mergedAttrs:r,classes:i,styles:a}=n;r!==null&&Kc(e,t,r),i!==null&&ite(e,t,i),a!==null&&rte(e,t,a)}function Du(e){let t=ate();return t?t.sanitize(uo.URL,e)||``:uu(e,`URL`)?lu(e):pu(zi(e))}function ate(){let e=Bo();return e&&e[10].sanitizer}function Ou(e){return e.ownerDocument.defaultView}function ku(e){return e.ownerDocument}function ote(e,t,n){let r=e.length;for(;;){let i=e.indexOf(t,n);if(i===-1)return i;if(i===0||e.charCodeAt(i-1)<=32){let n=t.length;if(i+n===r||e.charCodeAt(i+n)<=32)return i}n=i+1}}var Au=`ng-template`;function ste(e,t,n,r){let i=0;if(r){for(;i-1){let e;for(;++ia?``:i[u+1].toLowerCase(),r&2&&l!==e){if(Mu(r))return!1;o=!0}}}}}return Mu(r)||o}function Mu(e){return!(e&1)}function ute(e,t,n,r){if(t===null)return-1;let i=0;if(r||!n){let n=!1;for(;i-1)for(n++;n0?`="`+t+`"`:``)+`]`}else r&8?i+=`.`+o:r&4&&(i+=` `+o)}else i!==``&&!Mu(o)&&(t+=Pu(a,i),i=``),r=o,a||=!Mu(r);n++}return i!==``&&(t+=Pu(a,i)),t}function gte(e){return e.map(hte).join(`,`)}function _te(e){let t=[],n=[],r=1,i=2;for(;r!1});function Ru(e){if(!e)return 0;let t=e.toLowerCase().indexOf(`ms`)>-1?1:1e3;return parseFloat(e)*t}function zu(e,t){return e.getPropertyValue(t).split(`,`).map(e=>e.trim())}function bte(e){let t=zu(e,`transition-property`),n=zu(e,`transition-duration`),r=zu(e,`transition-delay`),i={propertyName:``,duration:0,animationName:void 0};for(let e=0;ei.duration&&(i.propertyName=t[e],i.duration=a)}return i}function xte(e){let t=zu(e,`animation-name`),n=zu(e,`animation-delay`),r=zu(e,`animation-duration`),i=zu(e,`animation-iteration-count`),a={animationName:``,propertyName:void 0,duration:0};for(let e=0;ea.duration&&s!==`infinite`&&(a.animationName=t[e],a.duration=o)}return a}function Bu(e,t){return e!==void 0&&e.duration>t.duration}function Vu(e){return(e.animationName!=null||e.propertyName!=null)&&e.duration>0}function Hu(e){let t=e.effect?.getTiming();if(t===void 0)return;let n=typeof t.duration==`number`?t.duration:0,r=(t.delay??0)+n,i=e.playbackRate;return i!==void 0&&i!==0&&i!==1&&(r/=Math.abs(i)),r}function Ste(e,t){let n=getComputedStyle(e),r=xte(n),i=bte(n),a=r.duration>i.duration?r:i;Bu(t.get(e),a)||Vu(a)&&t.set(e,a)}function Cte(e,t,n){if(!n)return;let r=e.getAnimations();return r.length===0?Ste(e,t):wte(e,t,r)}function wte(e,t,n){let r={animationName:void 0,propertyName:void 0,duration:0};for(let e of n){if(e.effect?.getTiming()?.iterations===1/0)continue;let t=Hu(e)??0,n,i;e.animationName?i=e.animationName:n=e.transitionProperty,t>=r.duration&&(r={animationName:i,propertyName:n,duration:t})}Bu(t.get(e),r)||Vu(r)&&t.set(e,r)}var Uu=new Set,Tte=!1,Ete=1,Wu=typeof document<`u`&&typeof document?.documentElement?.getAnimations==`function`;function Dte(e){return e[9].get(yte,Tte)}function Ote(e,t,n){let r=Ku.get(e);if(r){for(let e of t)r.classList.push(e);for(let e of n)r.cleanupFns.push(e)}else Ku.set(e,{classList:t,cleanupFns:n})}function Gu(e){let t=Ku.get(e);if(t){for(let e of t.cleanupFns)e();Ku.delete(e)}qu.delete(e)}var Ku=new WeakMap,qu=new WeakMap,Ju=new WeakMap;function Yu(e){return e?e[14]??e:null}var Xu=new WeakSet;function kte(e,t,n){let r=Ju.get(e);if(!r||r.length===0)return;let i=t.parentNode,a=t.previousSibling,o=Yu(n);for(let e=r.length-1;e>=0;e--){let{el:n,declarationView:s}=r[e],c=n.parentNode;n===t?(r.splice(e,1),Xu.add(n),n.dispatchEvent(new CustomEvent(`animationend`,{detail:{cancel:!0}}))):(a&&n===a||c&&i&&c!==i&&(o===null||s===null||o===s))&&(r.splice(e,1),n.dispatchEvent(new CustomEvent(`animationend`,{detail:{cancel:!0}})),n.parentNode?.removeChild(n))}}function Ate(e,t,n){let r=Yu(n),i=Ju.get(e);i?i.some(e=>e.el===t)||i.push({el:t,declarationView:r}):Ju.set(e,[{el:t,declarationView:r}])}function Zu(e){let t=e[26]??={};return t.enter??=new Map}function jte(e){let t=typeof e==`function`?e():e,n=Array.isArray(t)?t:null;return typeof t==`string`&&(n=t.trim().split(/\s+/).filter(e=>e)),n}function Qu(e){return e.composedPath?e.composedPath()[0]:e.target}function $u(e,t){let n=qu.get(t);if(n===void 0)return!0;if(t!==Qu(e))return!1;let r=e.animation;if(r){let e=Hu(r);if(e!==void 0&&e+Ete{class e{impl=null;execute(){this.impl?.execute()}static ɵprov=bi({token:e,providedIn:`root`,factory:()=>new e})}return e})(),ad=[0,1,2,3],od=(()=>{class e{ngZone=x(Ps);scheduler=x(rc);errorHandler=x(Ks,{optional:!0});sequences=new Set;deferredRegistrations=new Set;executing=!1;constructor(){x(td,{optional:!0})}execute(){let e=this.sequences.size>0;e&&Nc(bc.AfterRenderHooksStart),this.executing=!0;for(let e of ad)for(let t of this.sequences)if(!t.erroredOrDestroyed&&t.hooks[e])try{t.pipelinedValue=this.ngZone.runOutsideAngular(()=>this.maybeTrace(()=>{let n=t.hooks[e];return n(t.pipelinedValue)},t.snapshot))}catch(e){t.erroredOrDestroyed=!0,this.errorHandler?.handleError(e)}this.executing=!1;for(let e of this.sequences)e.afterRun(),e.once&&(this.sequences.delete(e),e.destroy());for(let e of this.deferredRegistrations)this.sequences.add(e);this.deferredRegistrations.size>0&&this.scheduler.notify(7),this.deferredRegistrations.clear(),e&&Nc(bc.AfterRenderHooksEnd)}register(e){let{view:t}=e;t===void 0?this.executing?this.deferredRegistrations.add(e):this.addSequence(e):((t[25]??=[]).push(e),Oo(t),t[2]|=8192)}addSequence(e){this.sequences.add(e),this.scheduler.notify(7)}unregister(e){this.executing&&this.sequences.has(e)?(e.erroredOrDestroyed=!0,e.pipelinedValue=void 0,e.once=!0):(this.sequences.delete(e),this.deferredRegistrations.delete(e))}maybeTrace(e,t){return t?t.run(ed.AFTER_NEXT_RENDER,e):e()}static ɵprov=bi({token:e,providedIn:`root`,factory:()=>new e})}return e})(),sd=class{impl;hooks;view;once;snapshot;erroredOrDestroyed=!1;pipelinedValue=void 0;unregisterOnDestroy;constructor(e,t,n,r,i,a=null){this.impl=e,this.hooks=t,this.view=n,this.once=r,this.snapshot=a,this.unregisterOnDestroy=i?.onDestroy(()=>this.destroy())}afterRun(){this.erroredOrDestroyed=!1,this.pipelinedValue=void 0,this.snapshot?.dispose(),this.snapshot=null}destroy(){this.impl.unregister(this),this.unregisterOnDestroy?.();let e=this.view?.[25];e&&(this.view[25]=e.filter(e=>e!==this))}};function cd(e,t){let n=t?.injector??x(bs);return rd(`NgAfterNextRender`),Pte(e,n,t,!0)}function Nte(e){return e instanceof Function?[void 0,void 0,e,void 0]:[e.earlyRead,e.write,e.mixedReadWrite,e.read]}function Pte(e,t,n,r){let i=t.get(id);i.impl??=t.get(od);let a=t.get(td,null,{optional:!0}),o=n?.manualCleanup===!0?null:t.get(Ss),s=t.get(tc,null,{optional:!0}),c=new sd(i.impl,Nte(e),s?.view,r,o,a?.snapshot(null));return i.impl.register(c),c}var ld=new Di(``,{factory:()=>{let e=x(Va),t=new Set;return e.onDestroy(()=>t.clear()),{queue:t,isScheduled:!1,scheduler:null,injector:e}}});function ud(e,t,n){let r=e.get(ld);if(Array.isArray(t))for(let e of t)r.queue.add(e),n?.detachedLeaveAnimationFns?.push(e);else r.queue.add(t),n?.detachedLeaveAnimationFns?.push(t);r.scheduler&&r.scheduler(e)}function Fte(e,t){let n=e.get(ld);if(Array.isArray(t))for(let e of t)n.queue.delete(e);else n.queue.delete(t)}function Ite(e,t){let n=e.get(ld);if(t.detachedLeaveAnimationFns){for(let e of t.detachedLeaveAnimationFns)n.queue.delete(e);t.detachedLeaveAnimationFns=void 0}}function Lte(e){let t=e.get(ld);t.isScheduled||=(cd(()=>{t.isScheduled=!1;for(let e of t.queue)e();t.queue.clear()},{injector:t.injector}),!0)}function Rte(e){let t=e.get(ld);t.scheduler=Lte,t.scheduler(e)}function dd(e,t){for(let[n,r]of t)ud(e,r.animateFns)}function fd(e,t,n,r){let i=e?.[26]?.enter;t!==null&&i&&i.has(n.index)&&dd(r,i)}function pd(e,t,n,r){try{n.get(Sa)}catch{return r(!1)}let i=e?.[26];i?.enter?.has(t.index)&&Fte(n,i.enter.get(t.index).animateFns);let a=zte(e,t,i);if(a.size===0){let n=!1;if(e){let r=[];md(e,t,r),n=r.length>0}if(!n)return r(!1)}e&&Uu.add(e[19]),ud(n,()=>Bte(e,t,i||void 0,a,r),i||void 0)}function zte(e,t,n){let r=new Map,i=n?.leave;if(i&&i.has(t.index)&&r.set(t.index,i.get(t.index)),e&&i)for(let[n,a]of i){if(r.has(n))continue;let i=e[1].data[n].parent;for(;i;){if(i===t){r.set(n,a);break}i=i.parent}}return r}function Bte(e,t,n,r,i){let a=[];if(n&&n.leave)for(let[e]of r){if(!n.leave.has(e))continue;let t=n.leave.get(e);for(let e of t.animateFns){let{promise:t}=e();a.push(t)}n.detachedLeaveAnimationFns=void 0}if(e&&md(e,t,a),a.length>0){let t=n||e?.[26];if(t){let n=t.running;n&&a.push(n),t.running=Promise.allSettled(a),Hte(e,t.running,i)}else Promise.allSettled(a).then(()=>{e&&Uu.delete(e[19]),i(!0)})}else e&&Uu.delete(e[19]),i(!1)}function md(e,t,n){if(t.type&12){let r=e[t.index];if(ro(r))for(let e=10;e{e[26]?.running===t&&(e[26].running=void 0,Uu.delete(e[19])),n(!0)})}function hd(e,t,n,r,i,a,o,s){if(i!=null){let c,l=!1;ro(i)?c=i:no(i)&&(l=!0,i=i[0]);let u=po(i);e===0&&r!==null?(fd(s,r,a,n),o==null?Cu(t,r,u):Su(t,r,u,o||null,!0)):e===1&&r!==null?(fd(s,r,a,n),Su(t,r,u,o||null,!0),kte(a,u,s)):e===2?(s?.[26]?.leave?.has(a.index)&&Ate(a,u,s),Xu.delete(u),pd(s,a,n,e=>{if(Xu.has(u)){Xu.delete(u);return}Tu(t,u,l,e)})):e===3&&(Xu.delete(u),pd(s,a,n,()=>{t.destroyNode(u)})),c!=null&&$te(t,e,n,c,a,r,o)}}function Ute(e,t){gd(e,t),t[0]=null,t[5]=null}function Wte(e,t,n,r,i,a){r[0]=i,r[5]=t,Dd(e,r,n,1,i,a)}function gd(e,t){t[10].changeDetectionScheduler?.notify(9),Dd(e,t,t[11],2,null,null)}function Gte(e){let t=e[12];if(!t)return yd(e[1],e);for(;t;){let n=null;if(no(t))n=t[12];else{let e=t[10];e&&(n=e)}if(!n){for(;t&&!t[4]&&t!==e;)no(t)&&yd(t[1],t),t=t[3];t===null&&(t=e),no(t)&&yd(t[1],t),n=t&&t[4]}t=n}}function _d(e,t){let n=e[9],r=n.indexOf(t);n.splice(r,1)}function vd(e,t){if(lo(t))return;let n=t[11];n.destroyNode&&Dd(e,t,n,3,null,null),Gte(t)}function yd(e,t){if(lo(t))return;let n=rn(null);try{t[2]&=-129,t[2]|=256,t[24]&&yn(t[24]),qte(e,t),Kte(e,t),t[1].type===1&&t[11].destroy();let n=t[16];if(n!==null&&ro(t[3])){n!==t[3]&&_d(n,t);let r=t[18];r!==null&&r.detachView(e)}Hl(t)}finally{rn(n)}}function Kte(e,t){let n=e.cleanup,r=t[7];if(n!==null)for(let e=0;e=0?r[t]():r[-t].unsubscribe(),e+=2}else{let t=r[n[e+1]];n[e].call(t)}r!==null&&(t[7]=null);let i=t[21];if(i!==null){t[21]=null;for(let e=0;e27&&Rd(e,t,27,!1),Nc(o?bc.TemplateUpdateStart:bc.TemplateCreateStart,i,n),n(r,i)}finally{us(a),Nc(o?bc.TemplateUpdateEnd:bc.TemplateCreateEnd,i,n)}}function Hd(e,t,n){Yd(e,t,n),(n.flags&64)==64&&Xd(e,t,n)}function Ud(e,t,n=ho){let r=t.localNames;if(r!==null){let i=t.index+1;for(let a=0;a=s&&i<=c){let o=t.data[i],s=r[e+1];Bd(o,n[i],s,a),l=!0}else if(i>c)break}}return o!==null&&Object.hasOwn(r.inputs,i)&&(Bd(r,n[o],i,a),l=!0),l}function cf(e,t){let n=yo(t,e),r=n[1];lf(r,n);let i=n[0];i!==null&&n[6]===null&&(n[6]=ru(i,n[9])),Nc(bc.ComponentStart);try{uf(r,n,n[8])}finally{Nc(bc.ComponentEnd,n[8])}}function lf(e,t){for(let n=t.length;n{Oo(e.lView)},consumerOnSignalRead(){this.lView[24]=this}};function Sf(e){let t=e[24]??Object.create(Cf);return t.lView=e,t}var Cf={...on,consumerIsAlwaysLive:!0,kind:`template`,consumerMarkedDirty:e=>{let t=Ao(e.lView);for(;t&&!wf(t[1]);)t=Ao(t);t&&wo(t)},consumerOnSignalRead(){this.lView[24]=this}};function wf(e){return e.type!==2}function Tf(e){if(e[23]===null)return;let t=!0;for(;t;){let n=!1;for(let t of e[23])if(t.dirty&&(n=!0,t.zone===null||Zone.current===t.zone?t.run():t.zone.run(()=>t.run()),e[23]===null))return;t=n&&!!(e[2]&8192)}}var Ef=100;function Df(e,t=0){let n=e[10].rendererFactory;n.begin?.();try{Of(e,t)}finally{n.end?.()}}function Of(e,t){let n=Jo();try{Yo(!0),Pf(e,t);let n=0;for(;Eo(e);){if(n===Ef)throw new fi(103,!1);n++,Pf(e,1)}}finally{Yo(n)}}function kf(e,t,n,r){if(lo(t))return;let i=t[2];rs(t);let a=!0,o=null,s=null;wf(e)?(s=vf(t),o=mn(s)):an()===null?(a=!1,s=Sf(t),o=mn(s)):t[24]&&=(yn(t[24]),null);try{Co(t),Yee(e.bindingStartIndex),n!==null&&Vd(e,t,n,2,r);let a=(i&3)==3;if(a){let n=e.preOrderCheckHooks;n!==null&&Ic(t,n,null)}else{let n=e.preOrderHooks;n!==null&&Lc(t,n,0,null),Rc(t,0)}if(jf(t),Tf(t),Af(t,0),e.contentQueries!==null&&iu(e,t),a){let n=e.contentCheckHooks;n!==null&&Ic(t,n)}else{let n=e.contentHooks;n!==null&&Lc(t,n,1),Rc(t,1)}If(e,t);let o=e.components;o!==null&&Ff(t,o,0);let s=e.viewQuery;if(s!==null&&au(2,s,r),a){let n=e.viewCheckHooks;n!==null&&Ic(t,n)}else{let n=e.viewHooks;n!==null&&Lc(t,n,2),Rc(t,2)}if(e.firstUpdatePass===!0&&(e.firstUpdatePass=!1),t[22]){for(let e of t[22])e();t[22]=null}gf(t),t[2]&=-73}catch(e){throw Oo(t),e}finally{s!==null&&(gn(s,o),a&&bf(s)),cs()}}function Af(e,t){for(let n=Gl(e);n!==null;n=Kl(n))for(let e=10;e0&&(e[n-1][4]=r[4]);let a=fa(e,10+t);Ute(r[1],r);let o=a[18];o!==null&&o.detachView(a[1]),r[3]=null,r[4]=null,r[2]&=-129}return r}function Uf(e,t,n,r){let i=10+r,a=n.length;r>0&&(n[i-1][4]=t),r-1&&(Hf(e,n),fa(t,n))}this._attachedToViewContainer=!1}vd(this._lView[1],this._lView)}onDestroy(e){ko(this._lView,e)}markForCheck(){Lf(this._cdRefInjectingView||this._lView,4)}detach(){this._lView[2]&=-129}reattach(){Do(this._lView),this._lView[2]|=128}detectChanges(){this._lView[2]|=1024,Df(this._lView)}checkNoChanges(){}attachToViewContainerRef(){if(this._appRef)throw new fi(902,!1);this._attachedToViewContainer=!0}detachFromAppRef(){this._appRef=null;let e=co(this._lView),t=this._lView[16];t!==null&&!e&&_d(t,this._lView),gd(this._lView[1],this._lView)}attachToAppRef(e){if(this._attachedToViewContainer)throw new fi(902,!1);this._appRef=e;let t=co(this._lView),n=this._lView[16];n!==null&&!t&&Wf(n,this._lView),Do(this._lView)}},Kf=(()=>{class e{_declarationLView;_declarationTContainer;elementRef;static __NG_ELEMENT_ID__=qf;constructor(e,t,n){this._declarationLView=e,this._declarationTContainer=t,this.elementRef=n}get ssrId(){return this._declarationTContainer.tView?.ssrId||null}createEmbeddedView(e,t){return this.createEmbeddedViewImpl(e,t)}createEmbeddedViewImpl(e,t,n){return new Gf(ff(this._declarationLView,this._declarationTContainer,e,{embeddedViewInjector:t,dehydratedView:n}))}}return e})();function qf(){return Jf(Ho(),Bo())}function Jf(e,t){return e.type&4?new Kf(t,e,jl(e,t)):null}function Yf(e,t,n,r,i){let a=e.data[t];if(a===null)a=Xf(e,t,n,r,i),Xee()&&(a.flags|=32);else if(a.type&64){a.type=n,a.value=r,a.attrs=i;let e=Jee();a.injectorIndex=e===null?-1:e.injectorIndex}return Wo(a,!0),a}function Xf(e,t,n,r,i){let a=Uo(),o=Go(),s=o?a:a&&a.parent,c=e.data[t]=Qf(e,s,n,t,r,i);return Zf(e,c,a,o),c}function Zf(e,t,n,r){e.firstChild===null&&(e.firstChild=t),n!==null&&(r?n.child==null&&t.parent!==null&&(n.child=t):n.next===null&&(n.next=t,t.prev=n))}function Qf(e,t,n,r,i,a){let o=t?t.injectorIndex:-1,s=0;return Lo()&&(s|=128),{type:n,index:r,insertBeforeIndex:null,injectorIndex:o,directiveStart:-1,directiveEnd:-1,directiveStylingLast:-1,componentOffset:-1,controlDirectiveIndex:-1,customControlIndex:-1,propertyBindings:null,flags:s,providerIndexes:0,value:i,namespace:ms(),attrs:a,mergedAttrs:null,localNames:null,initialInputs:null,inputs:null,hostDirectiveInputs:null,outputs:null,hostDirectiveOutputs:null,directiveToIndex:null,tView:null,next:null,prev:null,projectionNext:null,child:null,parent:t,projection:null,styles:null,stylesWithoutHost:null,residualStyles:void 0,classes:null,classesWithoutHost:null,residualClasses:void 0,classBindings:0,styleBindings:0}}function $f(e){let t=e[6]??[],n=e[3][11],r=[];for(let e of t)e.data.di===void 0?ep(e,n):r.push(e);e[6]=r}function ep(e,t){let n=0,r=e.firstChild;if(r){let i=e.data.r;for(;nnull,np=()=>null;function rp(e,t){return tp(e,t)}function ip(e,t,n){return np(e,t,n)}var ap=class{},op=class{},sp=class{destroyNode=null;static __NG_ELEMENT_ID__=()=>cp()};function cp(){let e=Bo(),t=yo(Ho().index,e);return(no(t)?t:e)[11]}var lp=(()=>{class e{static ɵprov=bi({token:e,providedIn:`root`,factory:()=>null})}return e})();function up(e){return e.debugInfo?.className||e.type.name||null}var dp={},fp=class{injector;parentInjector;constructor(e,t){this.injector=e,this.parentInjector=t}get(e,t,n){let r=this.injector.get(e,dp,n);return r!==dp||t===dp?r:this.parentInjector.get(e,t,n)}};function pp(e,t,n){return e[t]=n}function mp(e,t){return e[t]}function hp(e,t,n){if(n===Fu)return!1;let r=e[t];return!Object.is(r,n)&&(e[t]=n,!0)}function gp(e,t,n,r){let i=hp(e,t,n);return hp(e,t+1,r)||i}function _p(e,t,n,r,i){let a=gp(e,t,n,r);return hp(e,t+2,i)||a}function vp(e,t,n,r,i,a){let o=gp(e,t,n,r);return gp(e,t+2,i,a)||o}function yp(e,t,n){return function r(i){let a=r.__ngNativeEl__;a!==void 0&&eu(i,a),Lf(ao(e)?yo(e.index,t):t,5);let o=t[8],s=bp(t,o,n,i),c=r.__ngNextListenerFn__;for(;c;)s=bp(t,o,c,i)&&s,c=c.__ngNextListenerFn__;return s}}function bp(e,t,n,r){let i=rn(null);try{return Nc(bc.OutputStart,t,n),n(r)!==!1}catch(t){return af(e,t),!1}finally{Nc(bc.OutputEnd,t,n),rn(i)}}function xp(e,t,n,r,i,a,o,s){let c=oo(e),l=!1,u=null;if(!r&&c&&(u=Cp(t,n,a,e.index)),u!==null){let e=u.__ngLastListenerFn__||u;e.__ngNextListenerFn__=o,u.__ngLastListenerFn__=o,l=!0}else{let o=ho(e,n),c=r?r(o):o;r||(s.__ngNativeEl__=o);let l=i.listen(c,a,s);Sp(a)||wp(r?t=>r(po(t[e.index])):e.index,t,n,a,s,l,!1)}return l}function Sp(e){return e.startsWith(`animation`)||e.startsWith(`transition`)}function Cp(e,t,n,r){let i=e.cleanup;if(i!=null)for(let e=0;er?n[r]:null}typeof a==`string`&&(e+=2)}return null}function wp(e,t,n,r,i,a,o){let s=t.firstCreatePass?Mo(t):null,c=jo(n),l=c.length;c.push(i,a),s&&s.push(r,e,l,(l+1)*(o?-1:1))}function Tp(e,t,n,r,i){let a=null,o=null,s=null,c=!1,l=e.directiveToIndex.get(n.type);if(typeof l==`number`?a=l:[a,o,s]=l,o!==null&&s!==null&&e.hostDirectiveOutputs&&Object.hasOwn(e.hostDirectiveOutputs,r)){let n=e.hostDirectiveOutputs[r];for(let a=0;a=o&&l<=s)c=!0,Ep(e,t,l,n[a+1],r,i);else if(l>s)break}}return Object.hasOwn(n.outputs,r)&&(c=!0,Ep(e,t,a,r,r,i)),c}function Ep(e,t,n,r,i,a){let o=t[n],s=t[1],c=o[s.data[n].outputs[r]].subscribe(a);wp(e.index,s,t,i,a,c,!0)}function Dp(){Op()}function Op(){let e=Bo(),t=Vo(),n=Ho();if(t.firstCreatePass&&Pp(t,n),n.controlDirectiveIndex===-1)return;rd(`NgSignalForms`);let r=e[n.controlDirectiveIndex];t.data[n.controlDirectiveIndex].controlDef.create(r,new jp(e,t,n))}function kp(){Ap()}function Ap(){let e=Bo(),t=Vo(),n=ds();if(n.controlDirectiveIndex===-1)return;let r=t.data[n.controlDirectiveIndex].controlDef,i=e[n.controlDirectiveIndex];r.update(i,new jp(e,t,n))}var jp=class{lView;tView;tNode;hasPassThrough;constructor(e,t,n){this.lView=e,this.tView=t,this.tNode=n,this.hasPassThrough=!!(n.flags&4096)}get customControl(){return this.tNode.customControlIndex===-1?void 0:this.lView[this.tNode.customControlIndex]}get nativeElement(){return ho(this.tNode,this.lView)}get descriptor(){return`<${this.tNode.value}>`}listenToCustomControlOutput(e,t){let n=this.tView.data[this.tNode.customControlIndex];Tp(this.tNode,this.lView,n,e,yp(this.tNode,this.lView,t))}listenToCustomControlModel(e){let t=this.tNode.flags&1024?`valueChange`:`checkedChange`,n=this.tView.data[this.tNode.customControlIndex];Tp(this.tNode,this.lView,n,t,yp(this.tNode,this.lView,e))}listenToDom(e,t){xp(this.tNode,this.tView,this.lView,void 0,this.lView[11],e,t,yp(this.tNode,this.lView,t))}setInputOnDirectives(e,t,n){let r=this.tNode.inputs?.[e],i=this.tNode.hostDirectiveInputs?.[e];if(!r&&!i)return!1;let a=!1;if(r)for(let i of r){if(i===this.tNode.controlDirectiveIndex)continue;let r=this.lView[i],o=this.tView.data[i];(!n||n(Np(r,o,e)))&&(Bd(o,r,e,t),a=!0)}if(i)for(let r=0;r0;){let e=n.shift();if(typeof e!=`function`){for(let n in e.inputs)t[e.inputs[n]]=!0;let r=Mp(e.directive);r!==null&&n.push(...r);continue}for(let r of e()){if(typeof r==`function`)continue;if(r.inputs)for(let e=0;e1){t.flags|=4096;return}Fp(e,t)}function Fp(e,t){for(let n=t.directiveStart;n{let r=t.hostDirectiveInputs[e],i=t.hostDirectiveOutputs[e+`Change`];if(!r||!i)return!1;for(let e=0;e=i&&a<=o)return t.flags|=n,t.customControlIndex=r,!0}}return!1};if(e(`value`,1024)||e(`checked`,2048))return}}function Ip(e,t){return Lp(e,t)&&Rp(e,t+`Change`)}function Lp(e,t){return t in e.inputs}function Rp(e,t){return t in e.outputs}var zp=Symbol(`BINDING`),Bp=new Di(``);function Vp(e,t,n){let r=n?e.styles:null,i=n?e.classes:null,a=0;if(t!==null)for(let e=0;e0&&(n.directiveToIndex=new Map);for(let c=0;c0;){let n=e[--t];if(typeof n==`number`&&n<0)return n}return 0}function tm(e,t,n){if(n){if(t.exportAs)for(let r=0;r{let[n,r,i]=e[t],a={propName:n,templateName:t,isSignal:(r&zd.SignalBased)!==0};return i&&(a.transform=i),a})}function lm(e){return Object.keys(e).map(t=>({propName:e[t],templateName:t}))}function um(e,t,n){let r=t instanceof Va?t:t?.injector;return r&&e.getStandaloneInjector!==null&&(r=e.getStandaloneInjector(r)||r),r?new fp(n,r):n}function dm(e){let t=e.get(op,null);if(t===null)throw new fi(407,!1);return{rendererFactory:t,sanitizer:e.get(lp,null),changeDetectionScheduler:e.get(rc,null),ngReflect:!1,tracingService:e.get(td,null,{optional:!0})}}function fm(e,t,n){let r=mm(e);return xu(t,r,r===`svg`?`svg`:r===`math`?fo:n)}function pm(e){if((e&&`localName`in e&&typeof e.localName==`string`?e.localName:e?.tagName)?.toLowerCase()===`script`)throw new fi(905,!1)}function mm(e){return(e.selectors[0][0]||`div`).toLowerCase()}var hm=class{componentDef;ngModule;selector;componentType;ngContentSelectors;isBoundToModule;cachedInputs=null;cachedOutputs=null;get inputs(){return this.cachedInputs??=cm(this.componentDef.inputs),this.cachedInputs}get outputs(){return this.cachedOutputs??=lm(this.componentDef.outputs),this.cachedOutputs}constructor(e,t){this.componentDef=e,this.ngModule=t,this.componentType=e.type,this.selector=gte(e.selectors),this.ngContentSelectors=e.ngContentSelectors??[],this.isBoundToModule=!!t}create(e,t,n,r,i,a,o){Nc(bc.DynamicComponentStart);let s=rn(null);try{let s=this.componentDef,c=um(s,r||this.ngModule,e),l=dm(c),u=l.tracingService;return u&&u.componentCreate?u.componentCreate(up(s),()=>this.createComponentRef(l,c,t,n,i,a,o)):this.createComponentRef(l,c,t,n,i,a,o)}finally{rn(s)}}createComponentRef(e,t,n,r,i,a,o){let s=this.componentDef,c=gm(r,s,a,i),l=e.rendererFactory.createRenderer(null,s),u=r?Wd(l,r,s.encapsulation,t):fm(s,l,o??null);pm(u);let d=t.get(Bp,null),f=_m(u,()=>t.get(xs,null)??Xl());d&&d.addHost(f);let p=a?.some(ym)||i?.some(e=>typeof e!=`function`&&e.bindings.some(ym)),m=Nd(null,c,null,512|Fd(s),null,null,e,l,t,null,ru(u,t,!0));d&&om&&f instanceof ShadowRoot&&ko(m,()=>{d.removeHost(f)}),m[27]=u,rs(m);let ee=null;try{let e=rm(27,m,2,`#host`,()=>c.directiveRegistry,!0,0);Eu(l,u,e),Wl(u,m),Hd(c,m,e),ou(c,e,m),im(c,e),n!==void 0&&xm(e,this.ngContentSelectors,n),ee=yo(e.index,m),m[8]=ee[8],uf(c,m,null)}catch(e){throw ee!==null&&Hl(ee),Hl(m),e}finally{Nc(bc.DynamicComponentEnd),cs()}return new bm(this.componentType,m,!!p)}};function gm(e,t,n,r){let i=e?[`ng-version`,`22.1.7`]:_te(t.selectors[0]),a=null,o=null,s=0;if(n)for(let e of n)s+=e[zp].requiredVars,e.create&&(e.targetIdx=0,(a??=[]).push(e)),e.update&&(e.targetIdx=0,(o??=[]).push(e));if(r)for(let e=0;e{if(n&1&&e)for(let t of e)t.create();if(n&2&&t)for(let e of t)e.update()}}function ym(e){let t=e[zp].kind;return t===`input`||t===`twoWay`}var bm=class extends ap{_rootLView;_hasInputBindings;instance;hostView;changeDetectorRef;componentType;location;previousInputValues=null;_tNode;constructor(e,t,n){super(),this._rootLView=t,this._hasInputBindings=n,this._tNode=go(t[1],27),this.location=jl(this._tNode,t),this.instance=yo(this._tNode.index,t)[8],this.hostView=this.changeDetectorRef=new Gf(t,void 0),this.componentType=e}setInput(e,t){this._hasInputBindings;let n=this._tNode;if(this.previousInputValues??=new Map,this.previousInputValues.has(e)&&Object.is(this.previousInputValues.get(e),t))return;let r=this._rootLView;of(n,r[1],r,e,t),this.previousInputValues.set(e,t),Lf(yo(n.index,r),1)}get injector(){return new Cl(this._tNode,this._rootLView)}destroy(){this.hostView.destroy()}onDestroy(e){this.hostView.onDestroy(e)}};function xm(e,t,n){let r=e.projection=[];for(let e=0;e{class e{static __NG_ELEMENT_ID__=Cm}return e})();function Cm(){return Dm(Ho(),Bo())}var wm=class e extends Sm{_lContainer;_hostTNode;_hostLView;constructor(e,t,n){super(),this._lContainer=e,this._hostTNode=t,this._hostLView=n}get element(){return jl(this._hostTNode,this._hostLView)}get injector(){return new Cl(this._hostTNode,this._hostLView)}get parentInjector(){let e=dl(this._hostTNode,this._hostLView);if(Zc(e)){let t=el(e,this._hostLView),n=Qc(e),r=t[1].data[n+8];return new Cl(r,t)}return new Cl(null,this._hostLView)}clear(){for(;this.length>0;)this.remove(this.length-1)}get(e){let t=Tm(this._lContainer);return t!==null&&t[e]||null}get length(){return this._lContainer.length-10}createEmbeddedView(e,t,n){let r,i;typeof n==`number`?r=n:n!=null&&(r=n.index,i=n.injector);let a=rp(this._lContainer,e.ssrId),o=e.createEmbeddedViewImpl(t||{},i,a);return this.insertImpl(o,r,pf(this._hostTNode,a)),o}createComponent(e,t,n,r,i,a,o){let s,c=t||{};s=c.index,n=c.injector,r=c.projectableNodes,i=c.environmentInjector||c.ngModuleRef,a=c.directives,o=c.bindings;let l=new hm(Fi(e)),u=n||this.parentInjector;if(!i&&l.ngModule==null){let e=this.parentInjector.get(Va,null);e&&(i=e)}let d=Fi(l.componentType??{}),f=rp(this._lContainer,d?.id??null),p=f?.firstChild??null,m=l.create(u,r,p,i,a,o,this._getHostElementNamespace());return this.insertImpl(m.hostView,s,pf(this._hostTNode,f)),m}_getHostElementNamespace(){if(this._hostTNode.type&2){let e=this._hostTNode.parent??this._hostLView[5];return e!==null&&e.type&2&&typeof e.value==`string`&&e.value.toLowerCase()===`foreignobject`?null:e?.namespace??null}return this._hostTNode.namespace}insert(e,t){return this.insertImpl(e,t,!0)}insertImpl(t,n,r){let i=t._lView;if(xo(i)){let n=this.indexOf(t);if(n!==-1)this.detach(n);else{let n=i[3],r=new e(n,n[5],n[3]);r.detach(r.indexOf(t))}}let a=this._adjustIndex(n),o=this._lContainer;return Bf(o,i,a,r),t.attachToViewContainerRef(),da(Em(o),a,t),t}move(e,t){return this.insert(e,t)}indexOf(e){let t=Tm(this._lContainer);return t===null?-1:t.indexOf(e)}remove(e){let t=this._adjustIndex(e,-1),n=Hf(this._lContainer,t);n&&(fa(Em(this._lContainer),t),vd(n[1],n))}detach(e){let t=this._adjustIndex(e,-1),n=Hf(this._lContainer,t);return n&&fa(Em(this._lContainer),t)!=null?new Gf(n):null}_adjustIndex(e,t=0){return e??this.length+t}};function Tm(e){return e[8]}function Em(e){return e[8]||=[]}function Dm(e,t){let n,r=t[e.index];return ro(r)?n=r:(n=Rf(r,t,null,e),t[e.index]=n,Ld(t,n)),km(n,t,e,r),new wm(n,e,t)}function Om(e,t){let n=e[11],r=n.createComment(``),i=ho(t,e);return Su(n,n.parentNode(i),r,n.nextSibling(i),!1),r}var km=Mm,Am=()=>!1;function jm(e,t,n){return Am(e,t,n)}function Mm(e,t,n,r){if(e[7])return;let i;i=n.type&8?po(r):Om(t,n),e[7]=i}var Nm=class e{queryList;matches=null;constructor(e){this.queryList=e}clone(){return new e(this.queryList)}setDirty(){this.queryList.setDirty()}},Pm=class e{queries;constructor(e=[]){this.queries=e}createEmbeddedView(t){let n=t.queries;if(n!==null){let r=t.contentQueries===null?n.length:t.contentQueries[0],i=[];for(let e=0;e0)r.push(o[e/2]);else{let i=a[e+1],o=t[-n];for(let e=10;ee.trim())}function Jm(e,t,n){e.queries===null&&(e.queries=new Im),e.queries.track(new Lm(t,n))}function Ym(e,t){return e.queries.getByIndex(t)}function Xm(e,t){let n=e[1],r=Ym(n,t);return r.crossesNgTemplate?Um(n,e,t,[]):Hm(n,e,r,t)}function Zm(e,t,n){let r,i=Tn(()=>{r._dirtyCounter();let n=th(r,e);if(t&&n===void 0)throw new fi(-951,!1);return n});return r=i[nn],r._dirtyCounter=T(0),r._flatValue=void 0,i}function Qm(e){return Zm(!0,!1,e)}function $m(e){return Zm(!0,!0,e)}function eh(e,t){let n=e[nn];n._lView=Bo(),n._queryIndex=t,n._queryList=Wm(n._lView,t),n._queryList.onDirty(()=>n._dirtyCounter.update(e=>e+1))}function th(e,t){let n=e._lView,r=e._queryIndex;if(n===void 0||r===void 0||n[2]&4)return t?void 0:ba;let i=Wm(n,r),a=Xm(n,r);return i.reset(a,Nl),t?i.first:i._changesDetected||e._flatValue===void 0?e._flatValue=i.toArray():e._flatValue}function nh(e){return!!e&&typeof e.then==`function`}function rh(e){return!!e&&typeof e.subscribe==`function`}var ih=class{},ah=class extends ih{injector;instance=null;constructor(e){super();let t=new Ha([...e.providers,{provide:ih,useValue:this}],e.parent||Ba(),e.debugName,new Set([`environment`]));this.injector=t,e.runEnvironmentInitializers&&t.resolveInjectorInitializers()}destroy(){this.injector.destroy()}onDestroy(e){this.injector.onDestroy(e)}};function oh(e,t,n=null){return new ah({providers:e,parent:t,debugName:n,runEnvironmentInitializers:!0}).injector}var sh=(()=>{class e{_injector;cachedInjectors=new Map;constructor(e){this._injector=e}getOrCreateStandaloneInjector(e){if(!e.standalone)return null;if(!this.cachedInjectors.has(e)){let t=Ea(!1,e.type),n=t.length>0?oh([t],this._injector,``):null;this.cachedInjectors.set(e,n)}return this.cachedInjectors.get(e)}ngOnDestroy(){try{for(let e of this.cachedInjectors.values())e!==null&&e.destroy()}finally{this.cachedInjectors.clear()}}static ɵprov=bi({token:e,providedIn:`environment`,factory:()=>new e(na(Va))})}return e})();function O(e){return yc(()=>{let t=ph(e),n={...t,decls:e.decls,vars:e.vars,template:e.template,consts:e.consts||null,ngContentSelectors:e.ngContentSelectors,onPush:e.changeDetection!==Ll.Eager,directiveDefs:null,pipeDefs:null,dependencies:t.standalone&&e.dependencies||null,getStandaloneInjector:t.standalone?e=>e.get(sh).getOrCreateStandaloneInjector(n):null,getExternalStyles:null,signals:e.signals??!1,data:e.data||{},encapsulation:e.encapsulation||su.Emulated,styles:e.styles||ba,_:null,schemas:e.schemas||null,tView:null,id:``};t.standalone&&rd(`NgStandalone`),mh(n);let r=e.dependencies;return n.directiveDefs=hh(r,ch),n.pipeDefs=hh(r,Li),n.id=gh(n),n})}function ch(e){return Fi(e)||Ii(e)}function lh(e,t){if(e==null)return ya;let n={};for(let r in e)if(Object.hasOwn(e,r)){let i=e[r],a,o,s,c;Array.isArray(i)?(s=i[0],a=i[1],o=i[2]??a,c=i[3]||null):(a=i,o=i,s=zd.None,c=null),n[a]=[r,s,c],t[a]=o}return n}function uh(e){if(e==null)return ya;let t={};for(let n in e)Object.hasOwn(e,n)&&(t[e[n]]=n);return t}function dh(e){return yc(()=>{let t=ph(e);return mh(t),t})}function fh(e){return{type:e.type,name:e.name,factory:null,pure:e.pure!==!1,standalone:e.standalone??!0,onDestroy:e.type.prototype.ngOnDestroy||null}}function ph(e){let t={};return{type:e.type,providersResolver:null,viewProvidersResolver:null,factory:null,hostBindings:e.hostBindings||null,hostVars:e.hostVars||0,hostAttrs:e.hostAttrs||null,contentQueries:e.contentQueries||null,declaredInputs:t,inputConfig:e.inputs||ya,exportAs:e.exportAs||null,standalone:e.standalone??!0,signals:e.signals===!0,selectors:e.selectors||ba,viewQuery:e.viewQuery||null,features:e.features||null,setInput:null,resolveHostDirectives:null,hostDirectives:null,controlDef:null,signalFormsInputPresence:null,inputs:lh(e.inputs,t),outputs:uh(e.outputs),debugInfo:null}}function mh(e){e.features?.forEach(t=>t(e))}function hh(e,t){return e?()=>{let n=typeof e==`function`?e():e,r=[];for(let e of n){let n=t(e);n!==null&&r.push(n)}return r}:null}function gh(e){let t=0,n=typeof e.consts==`function`?``:e.consts,r=[e.selectors,e.ngContentSelectors,e.hostVars,e.hostAttrs,n,e.vars,e.decls,e.encapsulation,e.standalone,e.signals,e.exportAs,JSON.stringify(e.inputs),JSON.stringify(e.outputs),Object.getOwnPropertyNames(e.type.prototype),!!e.contentQueries,!!e.viewQuery];for(let e of r.join(`|`))t=Math.imul(31,t)+e.charCodeAt(0)<<0;return t+=2147483648,`c`+t}var _h=new Di(``),vh=(()=>{class e{resolve;reject;initialized=!1;done=!1;donePromise=new Promise((e,t)=>{this.resolve=e,this.reject=t});appInits=x(_h,{optional:!0})??[];injector=x(bs);constructor(){}runInitializers(){if(this.initialized)return;let e=[];for(let t of this.appInits){let n=$a(this.injector,t);if(nh(n))e.push(n);else if(rh(n)){let t=new Promise((e,t)=>{n.subscribe({complete:e,error:t})});e.push(t)}}let t=()=>{this.done=!0,this.resolve()};Promise.all(e).then(()=>{t()}).catch(e=>{this.reject(e)}),e.length===0&&t(),this.initialized=!0}static ɵfac=function(t){return new(t||e)};static ɵprov=kl({token:e,factory:e.ɵfac})}return e})();function yh(e){return t=>{t.controlDef={create:(e,t)=>{e?.ɵngControlCreate(t)},update:(e,t)=>{e?.ɵngControlUpdate?.(t)},passThroughInput:e}}}function bh(e){return Object.getPrototypeOf(e.prototype).constructor}function xh(e){let t=bh(e.type),n=!0,r=[e];for(;t&&t!==Function.prototype&&t!==Object.prototype;){let i,a=Object.hasOwn(t,ki)?t[ki]:void 0,o=Object.hasOwn(t,Ai)?t[Ai]:void 0;if(so(e))i=a??o;else{if(a)throw new fi(903,!1);i=o}if(i){if(n){r.push(i);let t=e;t.inputs=wh(e.inputs),t.declaredInputs=wh(e.declaredInputs),t.outputs=wh(e.outputs);let n=i.hostBindings;n&&Dh(e,n);let a=i.viewQuery,o=i.contentQueries;if(a&&Th(e,a),o&&Eh(e,o),Sh(e,i),Hee(e.outputs,i.outputs),so(i)&&i.data.animation){let t=e.data;t.animation=(t.animation||[]).concat(i.data.animation)}}let t=i.features;if(t)for(let r=0;r=0;r--){let i=e[r];i.hostVars=t+=i.hostVars,i.hostAttrs=Yc(i.hostAttrs,n=Yc(n,i.hostAttrs))}}function wh(e){return e===ya?{}:e===ba?[]:e}function Th(e,t){let n=e.viewQuery;e.viewQuery=n?(e,r)=>{t(e,r),n(e,r)}:t}function Eh(e,t){let n=e.contentQueries;e.contentQueries=n?(e,r,i)=>{t(e,r,i),n(e,r,i)}:t}function Dh(e,t){let n=e.hostBindings;e.hostBindings=n?(e,r)=>{t(e,r),n(e,r)}:t}function Oh(e,t,n,r,i,a,o,s){if(n.firstCreatePass){e.mergedAttrs=Yc(e.mergedAttrs,e.attrs);let t=e.tView=Ad(2,e,i,a,o,n.directiveRegistry,n.pipeRegistry,null,n.schemas,n.consts,null);n.queries!==null&&(n.queries.template(n,e),t.queries=n.queries.embeddedTView(e))}s&&(e.flags|=s),Wo(e,!1);let c=Mh(n,t,e,r);gs()&&Sd(n,t,c,e),Wl(c,t);let l=Rf(c,t,c,e);t[r+27]=l,Ld(t,l),jm(l,e,t)}function kh(e,t,n,r,i,a,o,s,c,l,u){let d=n+27,f;return t.firstCreatePass?(f=Yf(t,d,4,o||null,s||null),Io()&&Up(t,e,f,So(t.consts,l),Qd),Fc(t,f)):f=t.data[d],Oh(f,e,t,n,r,i,a,c),oo(f)&&Hd(t,e,f),l!=null&&Ud(e,f,u),f}function Ah(e,t,n,r,i,a,o,s,c,l,u){let d=n+27,f;if(t.firstCreatePass){if(f=Yf(t,d,4,o||null,s||null),l!=null){let e=So(t.consts,l);f.localNames=[];for(let t=0;t{throw new fi(600,``)})}var Lh=10,Rh=(()=>{class e{_runningTick=!1;_destroyed=!1;_destroyListeners=[];_views=[];internalErrorHandler=x(qs);afterRenderManager=x(id);zonelessEnabled=x(ic);rootEffectScheduler=x(oc);dirtyFlags=0;tracingSnapshot=null;allTestViews=new Set;autoDetectTestViews=new Set;includeAllTestViews=!1;afterTick=new Ur;get allViews(){return[...(this.includeAllTestViews?this.allTestViews:this.autoDetectTestViews).keys(),...this._views]}get destroyed(){return this._destroyed}componentTypes=[];components=[];internalPendingTask=x(Es);get isStable(){return this.internalPendingTask.hasPendingTasksObservable.pipe(ui(e=>!e))}constructor(){x(td,{optional:!0})}whenStable(){let e;return new Promise(t=>{e=this.isStable.subscribe({next:e=>{e&&t()}})}).finally(()=>{e.unsubscribe()})}_injector=x(Va);_rendererFactory=null;get injector(){return this._injector}bootstrap(e,t){return this.bootstrapImpl(e,t)}bootstrapImpl(e,t,n=bs.NULL){return this._injector.get(Ps).run(()=>{if(Nc(bc.BootstrapComponentStart),!this._injector.get(vh).done)throw new fi(405,``);let r=Fi(e),i=this._injector.get(ih),a=new hm(r,i);this.componentTypes.push(e);let{hostElement:o,directives:s,bindings:c}=zh(t),l=o||a.selector,u=a.create(n,[],l,i.injector,s,c),d=u.location.nativeElement,f=u.injector.get(Ph,null);return f?.registerApplication(d),u.onDestroy(()=>{this.detachView(u.hostView),Bh(this.components,u),f?.unregisterApplication(d)}),this._loadComponent(u),Nc(bc.BootstrapComponentEnd,u),u})}tick(){this.zonelessEnabled||(this.dirtyFlags|=1),this._tick()}_tick(){Nc(bc.ChangeDetectionStart),this.tracingSnapshot===null?this.tickImpl():this.tracingSnapshot.run(ed.CHANGE_DETECTION,this.tickImpl)}tickImpl=()=>{if(this._runningTick)throw Nc(bc.ChangeDetectionEnd),new fi(101,!1);let e=rn(null);try{this._runningTick=!0,this.synchronize()}finally{this._runningTick=!1,this.tracingSnapshot?.dispose(),this.tracingSnapshot=null,rn(e),this.afterTick.next(),Nc(bc.ChangeDetectionEnd)}};synchronize(){this._rendererFactory===null&&!this._injector.destroyed&&(this._rendererFactory=this._injector.get(op,null,{optional:!0}));let e=0;for(;this.dirtyFlags!==0&&e++Eo(e))){this.dirtyFlags|=2;return}this.dirtyFlags&=-8}attachView(e){let t=e;this._views.push(t),t.attachToAppRef(this)}detachView(e){let t=e;Bh(this._views,t),t.detachFromAppRef()}_loadComponent(e){this.attachView(e.hostView);try{this.tick()}catch(e){this.internalErrorHandler(e)}this.components.push(e),this._injector.get(Fh,[]).forEach(t=>t(e))}ngOnDestroy(){if(!this._destroyed)try{this._destroyListeners.forEach(e=>e()),this._views.slice().forEach(e=>e.destroy())}finally{this._destroyed=!0,this._views=[],this._destroyListeners=[]}}onDestroy(e){return this._destroyListeners.push(e),()=>Bh(this._destroyListeners,e)}destroy(){if(this._destroyed)throw new fi(406,!1);let e=this._injector;e.destroy&&!e.destroyed&&e.destroy()}get viewCount(){return this._views.length}static ɵfac=function(t){return new(t||e)};static ɵprov=kl({token:e,factory:e.ɵfac})}return e})();function zh(e){return e===void 0||typeof e==`string`||e instanceof Element?{hostElement:e}:e}function Bh(e,t){let n=e.indexOf(t);n>-1&&e.splice(n,1)}function k(e,t,n,r){let i=Bo();return hp(i,Qo(),t)&&(Vo(),$d(ds(),i,e,t,n,r)),k}function Vh(e){if(rd(`NgAnimateEnter`),!Wu)return Vh;let t=Bo();if(Dte(t))return Vh;let n=Ho(),r=t[9].get(Ps);return Mte(Zu(t),n,()=>Hh(t,n,e,r)),Rte(t[9]),dd(t[9],Zu(t)),Vh}function Hh(e,t,n,r){let i=ho(t,e),a=e[11],o=jte(n),s=[],c=!1,l=e=>{if(Qu(e)!==i)return;let t=e instanceof AnimationEvent?`animationend`:`transitionend`;r.runOutsideAngular(()=>{a.listen(i,t,u)})},u=e=>{Qu(e)===i&&($u(e,i)&&(c=!0),Uh(e,i,a))};if(o&&o.length>0){r.runOutsideAngular(()=>{s.push(a.listen(i,`animationstart`,l)),s.push(a.listen(i,`transitionstart`,l))}),Ote(i,o,s);for(let e of o)a.addClass(i,e);r.runOutsideAngular(()=>{requestAnimationFrame(()=>{if(!c&&(Cte(i,qu,Wu),!qu.has(i))){for(let e of o)a.removeClass(i,e);Gu(i)}})})}}function Uh(e,t,n){let r=Ku.get(t);if(Qu(e)===t&&r&&$u(e,t)){e.stopPropagation();for(let e of r.classList)n.removeClass(t,e);Gu(t)}}var Wh=class{destroy(e){}updateValue(e,t){}swap(e,t){let n=Math.min(e,t),r=Math.max(e,t),i=this.detach(r);if(r-n>1){let e=this.detach(n);this.attach(n,i),this.attach(r,e)}else this.attach(n,i)}move(e,t){this.attach(t,this.detach(e))}};function Gh(e,t,n,r,i){return e===n&&Object.is(t,r)?1:Object.is(i(e,t),i(n,r))?-1:0}function Kh(e,t,n,r){let i,a,o=0,s=e.length-1;if(Array.isArray(t)){rn(r);let c=t.length-1;for(rn(null);o<=s&&o<=c;){let r=e.at(o),l=t[o],u=Gh(o,r,o,l,n);if(u!==0){u<0&&e.updateValue(o,l),o++;continue}let d=e.at(s),f=t[c],p=Gh(s,d,c,f,n);if(p!==0){p<0&&e.updateValue(s,f),s--,c--;continue}let m=n(o,r),ee=n(s,d),h=n(o,l);if(Object.is(h,ee)){let t=n(c,f);Object.is(t,m)?(e.swap(o,s),e.updateValue(s,f),c--,s--):e.move(s,o),e.updateValue(o,l),o++;continue}if(i??=new Xh,a??=Yh(e,o,s,n),qh(e,i,o,h))e.updateValue(o,l),o++,s++;else if(a.has(h))i.set(m,e.detach(o)),s--;else{let n=e.create(o,t[o]);e.attach(o,n),o++,s++}}for(;o<=c;)Jh(e,i,n,o,t[o]),o++}else if(t!=null){rn(r);let c=t[Symbol.iterator]();rn(null);let l=c.next();for(;!l.done&&o<=s;){let t=e.at(o),r=l.value,u=Gh(o,t,o,r,n);if(u!==0)u<0&&e.updateValue(o,r),o++,l=c.next();else{i??=new Xh,a??=Yh(e,o,s,n);let u=n(o,r);if(qh(e,i,o,u))e.updateValue(o,r),o++,s++,l=c.next();else if(!a.has(u))e.attach(o,e.create(o,r)),o++,s++,l=c.next();else{let r=n(o,t);i.set(r,e.detach(o)),s--}}}for(;!l.done;)Jh(e,i,n,e.length,l.value),l=c.next()}for(;o<=s;)e.destroy(e.detach(s--));i?.forEach(t=>{e.destroy(t)})}function qh(e,t,n,r){return t!==void 0&&t.has(r)?(e.attach(n,t.get(r)),t.delete(r),!0):!1}function Jh(e,t,n,r,i){if(qh(e,t,r,n(r,i)))e.updateValue(r,i);else{let t=e.create(r,i);e.attach(r,t)}}function Yh(e,t,n,r){let i=new Set;for(let a=t;a<=n;a++)i.add(r(a,e.at(a)));return i}var Xh=class{kvMap=new Map;_vMap=void 0;has(e){return this.kvMap.has(e)}delete(e){if(!this.has(e))return!1;let t=this.kvMap.get(e);return this._vMap!==void 0&&this._vMap.has(t)?(this.kvMap.set(e,this._vMap.get(t)),this._vMap.delete(t)):this.kvMap.delete(e),!0}get(e){return this.kvMap.get(e)}set(e,t){if(this.kvMap.has(e)){let n=this.kvMap.get(e);this._vMap===void 0&&(this._vMap=new Map);let r=this._vMap;for(;r.has(n);)n=r.get(n);r.set(n,t)}else this.kvMap.set(e,t)}forEach(e){for(let[t,n]of this.kvMap)if(e(n,t),this._vMap!==void 0){let r=this._vMap;for(;r.has(n);)n=r.get(n),e(n,t)}}};function A(e,t,n,r,i,a,o,s){rd(`NgControlFlow`);let c=Bo(),l=Vo();return Ah(c,l,e,t,n,r,i,So(l.consts,a),256,o,s),Zh}function Zh(e,t,n,r,i,a,o,s){rd(`NgControlFlow`);let c=Bo(),l=Vo();return Ah(c,l,e,t,n,r,i,So(l.consts,a),512,o,s),Zh}function j(e,t){rd(`NgControlFlow`);let n=Bo(),r=Qo(),i=n[r]===Fu?-1:n[r],a=i===-1?void 0:rg(n,27+i);if(hp(n,r,e)){let r=rn(null);try{if(a!==void 0&&Vf(a,0),e!==-1){let r=27+e,i=rg(n,r),a=cg(n[1],r),o=ip(i,a,n);Bf(i,ff(n,a,t,{dehydratedView:o}),0,pf(a,o))}}finally{rn(r)}}else if(a!==void 0){let e=zf(a,0);e!==void 0&&(e[8]=t)}}var Qh=class{lContainer;$implicit;$index;constructor(e,t,n){this.lContainer=e,this.$implicit=t,this.$index=n}get $count(){return this.lContainer.length-10}};function $h(e){return e}function eg(e,t){return t}var tg=class{hasEmptyBlock;trackByFn;liveCollection;constructor(e,t,n){this.hasEmptyBlock=e,this.trackByFn=t,this.liveCollection=n}};function M(e,t,n,r,i,a,o,s,c,l,u,d,f){rd(`NgControlFlow`);let p=Bo(),m=Vo(),ee=c!==void 0,h=Bo(),g=new tg(ee,s?o.bind(h[15][8]):o);h[27+e]=g,Ah(p,m,e+1,t,n,r,i,So(m.consts,a),256),ee&&Ah(p,m,e+2,c,l,u,d,So(m.consts,f),512)}var ng=class extends Wh{lContainer;hostLView;templateTNode;operationsCounter=void 0;needsIndexUpdate=!1;constructor(e,t,n){super(),this.lContainer=e,this.hostLView=t,this.templateTNode=n}get length(){return this.lContainer.length-10}at(e){return this.getLView(e)[8].$implicit}attach(e,t){let n=t[6];this.needsIndexUpdate||=e!==this.length,Bf(this.lContainer,t,e,pf(this.templateTNode,n)),ig(this.lContainer,e)}detach(e){return this.needsIndexUpdate||=e!==this.length-1,ag(this.lContainer,e),og(this.lContainer,e)}create(e,t){let n=rp(this.lContainer,this.templateTNode.tView.ssrId);return ff(this.hostLView,this.templateTNode,new Qh(this.lContainer,t,e),{dehydratedView:n})}destroy(e){vd(e[1],e)}updateValue(e,t){this.getLView(e)[8].$implicit=t}reset(){this.needsIndexUpdate=!1}updateIndexes(){if(this.needsIndexUpdate)for(let e=0;e0){let e=n[9];Ite(e,r),Uu.delete(n[19]),r.detachedLeaveAnimationFns=void 0}}function ag(e,t){if(e.length<=10)return;let n=e[10+t],r=n?n[26]:void 0;r&&r.leave&&r.leave.size>0&&(r.detachedLeaveAnimationFns=[])}function og(e,t){return Hf(e,t)}function sg(e,t){return zf(e,t)}function cg(e,t){return go(e,t)}function P(e,t,n){let r=Bo();return hp(r,Qo(),t)&&(Vo(),Kd(ds(),r,e,t,r[11],n)),P}function lg(e,t,n,r,i){of(t,e,n,i?`class`:`style`,r)}function F(e,t,n,r){let i=Bo(),a=i[1],o=e+27,s=a.firstCreatePass?rm(o,i,2,t,Qd,Io(),n,r):a.data[o];if(ao(s)){let n=i[10].tracingService;if(n&&n.componentCreate){let o=a.data[s.directiveStart+s.componentOffset];return n.componentCreate(up(o),()=>(ug(e,t,i,s,r),F))}}return ug(e,t,i,s,r),F}function ug(e,t,n,r,i){if(nf(r,n,e,t,fg),oo(r)){let e=n[1];Hd(e,n,r),ou(e,r,n)}i!=null&&Ud(n,r)}function I(){let e=Vo(),t=rf(Ho());return e.firstCreatePass&&im(e,t),Ro(t)&&zo(),Fo(),t.classesWithoutHost!=null&&Wc(t)&&lg(e,t,Bo(),t.classesWithoutHost,!0),t.stylesWithoutHost!=null&&Gc(t)&&lg(e,t,Bo(),t.stylesWithoutHost,!1),I}function L(e,t,n,r){return F(e,t,n,r),I(),L}function R(e,t,n,r){let i=Bo(),a=i[1],o=e+27,s=a.firstCreatePass?am(o,a,2,t,n,r):a.data[o];return nf(s,i,e,t,fg),r!=null&&Ud(i,s),R}function z(){return Ro(rf(Ho()))&&zo(),Fo(),z}function dg(e,t,n,r){return R(e,t,n,r),z(),dg}var fg=(e,t,n,r,i)=>(_s(!0),xu(t[11],r,ms()));function pg(e,t,n){let r=Bo(),i=r[1],a=e+27,o=i.firstCreatePass?rm(a,r,8,`ng-container`,Qd,Io(),t,n):i.data[a];if(nf(o,r,e,`ng-container`,gg),oo(o)){let e=r[1];Hd(e,r,o),ou(e,o,r)}return n!=null&&Ud(r,o),pg}function mg(){let e=Vo(),t=rf(Ho());return e.firstCreatePass&&im(e,t),mg}function hg(e,t,n){return pg(e,t,n),mg(),hg}var gg=(e,t,n,r,i)=>(_s(!0),bu(t[11],``));function B(){return Bo()}function V(e,t,n){let r=Bo();return hp(r,Qo(),t)&&(Vo(),qd(ds(),r,e,t,r[11],n)),V}var _g=void 0;function vg(e){let t=Math.floor(Math.abs(e)),n=e.toString().replace(/^[^.]*\.?/,``).length;return t===1&&n===0?1:5}var yg=[`en`,[[`a`,`p`],[`AM`,`PM`]],[[`AM`,`PM`]],[[`S`,`M`,`T`,`W`,`T`,`F`,`S`],[`Sun`,`Mon`,`Tue`,`Wed`,`Thu`,`Fri`,`Sat`],[`Sunday`,`Monday`,`Tuesday`,`Wednesday`,`Thursday`,`Friday`,`Saturday`],[`Su`,`Mo`,`Tu`,`We`,`Th`,`Fr`,`Sa`]],_g,[[`J`,`F`,`M`,`A`,`M`,`J`,`J`,`A`,`S`,`O`,`N`,`D`],[`Jan`,`Feb`,`Mar`,`Apr`,`May`,`Jun`,`Jul`,`Aug`,`Sep`,`Oct`,`Nov`,`Dec`],[`January`,`February`,`March`,`April`,`May`,`June`,`July`,`August`,`September`,`October`,`November`,`December`]],_g,[[`B`,`A`],[`BC`,`AD`],[`Before Christ`,`Anno Domini`]],0,[6,0],[`M/d/yy`,`MMM d, y`,`MMMM d, y`,`EEEE, MMMM d, y`],[`h:mm a`,`h:mm:ss a`,`h:mm:ss a z`,`h:mm:ss a zzzz`],[`{1}, {0}`,_g,_g,_g],[`.`,`,`,`;`,`%`,`+`,`-`,`E`,`×`,`‰`,`∞`,`NaN`,`:`],[`#,##0.###`,`#,##0%`,`¤#,##0.00`,`#E0`],`USD`,`$`,`US Dollar`,{},`ltr`,vg],bg=Object.create(null);function xg(e){let t=wg(e),n=Sg(t);if(n)return n;let r=t.split(`-`)[0];if(n=Sg(r),n)return n;if(r===`en`)return yg;throw new fi(701,!1)}function Sg(e){if(!(e in bg)){let t=Zi.ng&&Zi.ng.common&&Zi.ng.common.locales&&Zi.ng.common.locales[e];return t!==void 0&&(bg[e]=t),t}return bg[e]}var Cg={LocaleId:0,DayPeriodsFormat:1,DayPeriodsStandalone:2,DaysFormat:3,DaysStandalone:4,MonthsFormat:5,MonthsStandalone:6,Eras:7,FirstDayOfWeek:8,WeekendRange:9,DateFormat:10,TimeFormat:11,DateTimeFormat:12,NumberSymbols:13,NumberFormats:14,CurrencyCode:15,CurrencySymbol:16,CurrencyName:17,Currencies:18,Directionality:19,PluralCase:20,ExtraData:21};function wg(e){return e.toLowerCase().replace(/_/g,`-`)}var Tg=`en-US`;function Eg(e){typeof e==`string`&&e.toLowerCase().replace(/_/g,`-`)}function H(e,t,n){let r=Bo(),i=Vo(),a=Ho();return Dg(i,r,r[11],a,e,t,n),H}function U(e,t,n){let r=Bo(),i=Vo(),a=Ho();return(a.type&3||n)&&xp(a,i,r,n,r[11],e,t,yp(a,r,t)),U}function Dg(e,t,n,r,i,a,o){let s=!0,c=null;if((r.type&3||o)&&(c??=yp(r,t,a),xp(r,e,t,o,n,i,a,c)&&(s=!1)),s){let e=r.outputs?.[i],n=r.hostDirectiveOutputs?.[i];if(n&&n.length)for(let e=0;e>17&32767}function Lg(e){return(e&2)==2}function Rg(e,t){return e&131071|t<<17}function zg(e){return e|2}function Bg(e){return(e&131068)>>2}function Vg(e,t){return e&-131069|t<<2}function Hg(e){return(e&1)==1}function Ug(e){return e|1}function Wg(e,t,n,r,i,a){let o=a?t.classBindings:t.styleBindings,s=Ig(o),c=Bg(o);e[r]=n;let l=!1,u;if(Array.isArray(n)){let e=n;u=e[1],(u===null||_a(e,u)>0)&&(l=!0)}else u=n;if(i){if(c!==0){let t=Ig(e[s+1]);e[r+1]=Fg(t,s),t!==0&&(e[t+1]=Vg(e[t+1],r)),e[s+1]=Rg(e[s+1],r)}else e[r+1]=Fg(s,0),s!==0&&(e[s+1]=Vg(e[s+1],r)),s=r}else e[r+1]=Fg(c,0),s===0?s=r:e[c+1]=Vg(e[c+1],r),c=r;l&&(e[r+1]=zg(e[r+1])),Kg(e,u,r,!0),Kg(e,u,r,!1),Gg(t,u,e,r,a),o=Fg(s,c),a?t.classBindings=o:t.styleBindings=o}function Gg(e,t,n,r,i){let a=i?e.residualClasses:e.residualStyles;a!=null&&typeof t==`string`&&_a(a,t)>=0&&(n[r+1]=Ug(n[r+1]))}function Kg(e,t,n,r){let i=e[n+1],a=t===null,o=r?Ig(i):Bg(i),s=!1;for(;o!==0&&(s===!1||a);){let n=e[o],i=e[o+1];qg(n,t)&&(s=!0,e[o+1]=r?Ug(i):zg(i)),o=r?Ig(i):Bg(i)}s&&(e[n+1]=r?zg(i):Ug(i))}function qg(e,t){return e===null||t==null||(Array.isArray(e)?e[1]:e)===t?!0:Array.isArray(e)&&typeof t==`string`?_a(e,t)>=0:!1}var Jg={textEnd:0,key:0,keyEnd:0,value:0,valueEnd:0};function Yg(e){return e.substring(Jg.key,Jg.keyEnd)}function Xg(e){return Qg(e),Zg(e,$g(e,0,Jg.textEnd))}function Zg(e,t){let n=Jg.textEnd;return n===t?-1:(t=Jg.keyEnd=e_(e,Jg.key=t,n),$g(e,t,n))}function Qg(e){Jg.key=0,Jg.keyEnd=0,Jg.value=0,Jg.valueEnd=0,Jg.textEnd=e.length}function $g(e,t,n){for(;t32;)t++;return t}function t_(e,t,n){return i_(e,t,n,!1),t_}function G(e,t){return i_(e,t,null,!0),G}function n_(e){a_(h_,r_,e,!0)}function r_(e,t){for(let n=Xg(t);n>=0;n=Zg(t,n))ha(e,Yg(t),!0)}function i_(e,t,n,r){let i=Bo(),a=Vo(),o=$o(2);if(a.firstUpdatePass&&s_(a,e,o,r),t!==Fu&&hp(i,o,t)){let s=a.data[ls()];__(a,s,i,i[11],e,i[o+1]=b_(t,n),r,o)}}function a_(e,t,n,r){let i=Vo(),a=$o(2);i.firstUpdatePass&&s_(i,null,a,r);let o=Bo();if(n!==Fu&&hp(o,a,n)){let s=i.data[ls()];if(x_(s,r)&&!o_(i,a)){let e=r?s.classesWithoutHost:s.stylesWithoutHost;e!==null&&(n=gi(e,n||``)),lg(i,s,o,n,r)}else g_(i,s,o,o[11],o[a+1],o[a+1]=m_(e,t,n),r,a)}}function o_(e,t){return t>=e.expandoStartIndex}function s_(e,t,n,r){let i=e.data;if(i[n+1]===null){let a=i[ls()],o=o_(e,n);x_(a,r)&&t===null&&!o&&(t=!1),t=c_(i,a,t,r),Wg(i,a,t,n,o,r)}}function c_(e,t,n,r){let i=$ee(e),a=r?t.residualClasses:t.residualStyles;if(i===null)(r?t.classBindings:t.styleBindings)===0&&(n=f_(null,e,t,n,r),n=p_(n,t.attrs,r),a=null);else{let o=t.directiveStylingLast;if(o===-1||e[o]!==i){if(n=f_(i,e,t,n,r),a===null){let n=l_(e,t,r);n!==void 0&&Array.isArray(n)&&(n=f_(null,e,t,n[1],r),n=p_(n,t.attrs,r),u_(e,t,r,n))}else a=d_(e,t,r)}}return a!==void 0&&(r?t.residualClasses=a:t.residualStyles=a),n}function l_(e,t,n){let r=n?t.classBindings:t.styleBindings;if(Bg(r)!==0)return e[Ig(r)]}function u_(e,t,n,r){let i=n?t.classBindings:t.styleBindings;e[Ig(i)]=r}function d_(e,t,n){let r,i=t.directiveEnd;for(let a=1+t.directiveStylingLast;a0;){let t=e[i],a=Array.isArray(t),c=a?t[1]:t,l=c===null,u=n[i+1];u===Fu&&(u=l?ba:void 0);let d=l?ga(u,r):c===r?u:void 0;if(a&&!y_(d)&&(d=ga(t,r)),y_(d)&&(s=d,o))return s;let f=e[i+1];i=o?Ig(f):Bg(f)}if(t!==null){let e=a?t.residualClasses:t.residualStyles;e!=null&&(s=ga(e,r))}return s}function y_(e){return e!==void 0}function b_(e,t){return e==null||e===``||(typeof t==`string`?e=lu(e)+t:typeof e==`object`&&(e=hi(lu(e)))),e}function x_(e,t){return!!(e.flags&(t?8:16))}function K(e,t=``){let n=Bo(),r=Vo(),i=e+27,a=r.firstCreatePass?Yf(r,i,1,t,null):r.data[i],o=S_(r,n,a,t);n[i]=o,gs()&&Sd(r,n,o,a),Wo(a,!1)}var S_=(e,t,n,r)=>(_s(!0),vu(t[11],r));function C_(e,t,n,r=``){return hp(e,Qo(),n)?t+zi(n)+r:Fu}function w_(e,t,n,r,i,a=``){let o=gp(e,Zo(),n,i);return $o(2),o?t+zi(n)+r+zi(i)+a:Fu}function T_(e,t,n,r,i,a,o,s=``){let c=_p(e,Zo(),n,i,o);return $o(3),c?t+zi(n)+r+zi(i)+a+zi(o)+s:Fu}function E_(e,t,n,r,i,a,o,s,c,l=``){let u=vp(e,Zo(),n,i,o,c);return $o(4),u?t+zi(n)+r+zi(i)+a+zi(o)+s+zi(c)+l:Fu}function q(e){return J(``,e),q}function J(e,t,n){let r=Bo(),i=C_(r,e,t,n);return i!==Fu&&k_(r,ls(),i),J}function Y(e,t,n,r,i){let a=Bo(),o=w_(a,e,t,n,r,i);return o!==Fu&&k_(a,ls(),o),Y}function D_(e,t,n,r,i,a,o){let s=Bo(),c=T_(s,e,t,n,r,i,a,o);return c!==Fu&&k_(s,ls(),c),D_}function O_(e,t,n,r,i,a,o,s,c){let l=Bo(),u=E_(l,e,t,n,r,i,a,o,s,c);return u!==Fu&&k_(l,ls(),u),O_}function k_(e,t,n){let r=mo(t,e);yu(e[11],r,n)}function A_(e,t,n){gc(t)&&(t=t());let r=Bo();return hp(r,Qo(),t)&&(Vo(),Kd(ds(),r,e,t,r[11],n)),A_}function j_(e,t){let n=gc(e);return n&&e.set(t),n}function M_(e,t){let n=Bo(),r=Vo(),i=Ho();return Dg(r,n,n[11],i,e,t),M_}var N_={};function P_(e){rd(`NgLet`);let t=Vo(),n=Bo(),r=e+27;return Wo(Yf(t,r,128,null,null),!1),vo(t,n,r,N_),P_}function F_(e){return vo(Vo(),Bo(),ls(),e),e}function I_(e){let t=_o(qo(),27+e);if(t===N_)throw new fi(314,!1);return t}function L_(e,t,n=``){return C_(Bo(),e,t,n)}function R_(e,t,n){let r=Vo();r.firstCreatePass&&z_(t,r.data,r.blueprint,so(e),n)}function z_(e,t,n,r,i){if(e=vi(e),Array.isArray(e))for(let a=0;a>20;if(Pa(e)||!e.multi){let r=new Uc(l,i,Hp,null),p=H_(c,t,i?u:u+f,d);p===-1?(fl(cl(s,o),a,c),B_(a,e,t.length),t.push(c),s.directiveStart++,s.directiveEnd++,i&&(s.providerIndexes+=1048576),n.push(r),o.push(r)):(n[p]=r,o[p]=r)}else{let p=H_(c,t,u+f,d),m=H_(c,t,u,u+f),ee=p>=0&&n[p],h=m>=0&&n[m];if(i&&!h||!i&&!ee){fl(cl(s,o),a,c);let u=nne(i?tne:ene,n.length,i,r,l,e);!i&&h&&(n[m].providerFactory=u),B_(a,e,t.length,0),t.push(c),s.directiveStart++,s.directiveEnd++,i&&(s.providerIndexes+=1048576),n.push(u),o.push(u)}else{let t=V_(n[i?m:p],l,!i&&r);B_(a,e,p>-1?p:m,t)}!i&&r&&h&&n[m].componentProviders++}}}function B_(e,t,n,r){let i=Pa(t),a=Fa(t);if(i||a){let o=(a?vi(t.useClass):t).prototype.ngOnDestroy;if(o){let a=e.destroyHooks||=[];if(!i&&t.multi){let e=a.indexOf(n);e===-1?a.push(n,[r,o]):a[e+1].push(r,o)}else a.push(n,o)}}}function V_(e,t,n){return n&&e.componentProviders++,e.multi.push(t)-1}function H_(e,t,n,r){for(let i=n;i{n.providersResolver=(t,n)=>R_(t,n?n(e):e,!1),t&&(n.viewProvidersResolver=(e,n)=>R_(e,n?n(t):t,!0))}}function G_(e,t){let n=Xo()+e,r=Bo();return r[n]===Fu?pp(r,n,t()):mp(r,n)}function K_(e,t,n){return Y_(Bo(),Xo(),e,t,n)}function q_(e,t,n,r){return X_(Bo(),Xo(),e,t,n,r)}function rne(e,t,n,r,i,a,o){return ine(Bo(),Xo(),e,t,n,r,i,a)}function J_(e,t){let n=e[t];return n===Fu?void 0:n}function Y_(e,t,n,r,i,a){let o=t+n;return hp(e,o,i)?pp(e,o+1,a?r.call(a,i):r(i)):J_(e,o+1)}function X_(e,t,n,r,i,a,o){let s=t+n;return gp(e,s,i,a)?pp(e,s+2,o?r.call(o,i,a):r(i,a)):J_(e,s+2)}function ine(e,t,n,r,i,a,o,s,c){let l=t+n;return vp(e,l,i,a,o,s)?pp(e,l+4,c?r.call(c,i,a,o,s):r(i,a,o,s)):J_(e,l+4)}function Z_(e,t){let n=Vo(),r,i=e+27;n.firstCreatePass?(r=ane(t,n.pipeRegistry),n.data[i]=r,r.onDestroy&&(n.destroyHooks??=[]).push(i,r.onDestroy)):r=n.data[i];let a=r.factory||(r.factory=sa(r.type,!0)),o=Yi(Hp);try{let e=nl(!1),t=a();return nl(e),vo(n,Bo(),i,t),t}finally{Yi(o)}}function ane(e,t){if(t)for(let n=t.length-1;n>=0;n--){let r=t[n];if(e===r.name)return r}}function Q_(e,t,n){let r=e+27,i=Bo(),a=_o(i,r);return ev(i,r)?Y_(i,Xo(),t,a.transform,n,a):a.transform(n)}function $_(e,t,n,r){let i=e+27,a=Bo(),o=_o(a,i);return ev(a,i)?X_(a,Xo(),t,o.transform,n,r,o):o.transform(n,r)}function ev(e,t){return e[1].data[t].pure}function tv(e,t){return Jf(e,t)}var one=(()=>{class e{applicationErrorHandler=x(qs);appRef=x(Rh);taskService=x(Es);ngZone=x(Ps);zonelessEnabled=x(ic);tracing=x(td,{optional:!0});zoneIsDefined=typeof Zone<`u`&&!!Zone.root.run;schedulerTickApplyArgs=[{data:{__scheduler_tick__:!0}}];subscriptions=new sr;angularZoneId=this.zoneIsDefined?this.ngZone._inner?.get(Ms):null;scheduleInRootZone=!this.zonelessEnabled&&this.zoneIsDefined&&(x(ac,{optional:!0})??!1);cancelScheduledCallback=null;useMicrotaskScheduler=!1;runningTick=!1;pendingRenderTaskId=null;constructor(){this.subscriptions.add(this.appRef.afterTick.subscribe(()=>{let e=this.taskService.add();if(!this.runningTick&&(this.cleanup(),!this.zonelessEnabled||this.appRef.includeAllTestViews)){this.taskService.remove(e);return}this.switchToMicrotaskScheduler(),this.taskService.remove(e)})),this.subscriptions.add(this.ngZone.onUnstable.subscribe(()=>{this.runningTick||this.cleanup()}))}switchToMicrotaskScheduler(){this.ngZone.runOutsideAngular(()=>{let e=this.taskService.add();this.useMicrotaskScheduler=!0,queueMicrotask(()=>{this.useMicrotaskScheduler=!1,this.taskService.remove(e)})})}notify(e){if(!this.zonelessEnabled&&e===5)return;switch(e){case 0:case 2:this.appRef.dirtyFlags|=2;break;case 3:case 4:case 5:case 1:this.appRef.dirtyFlags|=4;break;case 6:this.appRef.dirtyFlags|=2;break;case 12:this.appRef.dirtyFlags|=16;break;case 13:this.appRef.dirtyFlags|=2;break;case 11:break;default:this.appRef.dirtyFlags|=8}if(this.appRef.tracingSnapshot=this.tracing?.snapshot(this.appRef.tracingSnapshot)??null,!this.shouldScheduleTick())return;let t=this.useMicrotaskScheduler?As:ks;this.pendingRenderTaskId=this.taskService.add(),this.cancelScheduledCallback=this.scheduleInRootZone?Zone.root.run(()=>t(()=>this.tick())):this.ngZone.runOutsideAngular(()=>t(()=>this.tick()))}shouldScheduleTick(){return!(this.appRef.destroyed||this.pendingRenderTaskId!==null||this.runningTick||this.appRef._runningTick||!this.zonelessEnabled&&this.zoneIsDefined&&Zone.current.get(`isAngularZone_ID`+this.angularZoneId))}tick(){if(this.runningTick||this.appRef.destroyed)return;if(this.appRef.dirtyFlags===0){this.cleanup();return}!this.zonelessEnabled&&this.appRef.dirtyFlags&7&&(this.appRef.dirtyFlags|=1);let e=this.taskService.add();try{this.ngZone.run(()=>{this.runningTick=!0,this.appRef._tick()},void 0,this.schedulerTickApplyArgs)}catch(e){this.applicationErrorHandler(e)}finally{this.taskService.remove(e),this.cleanup()}}ngOnDestroy(){this.subscriptions.unsubscribe(),this.cleanup()}cleanup(){if(this.runningTick=!1,this.cancelScheduledCallback?.(),this.cancelScheduledCallback=null,this.pendingRenderTaskId!==null){let e=this.pendingRenderTaskId;this.pendingRenderTaskId=null,this.taskService.remove(e)}}static ɵfac=function(t){return new(t||e)};static ɵprov=kl({token:e,factory:e.ɵfac})}return e})();function sne(){return[{provide:rc,useExisting:one},{provide:Ps,useClass:Hs},{provide:ic,useValue:!0}]}function cne(){return typeof $localize<`u`&&$localize.locale||`en-US`}var nv=new Di(``,{factory:()=>x(nv,{optional:!0,skipSelf:!0})||cne()}),rv=class{destroyed=!1;listeners=null;errorHandler=x(Ks,{optional:!0});isEmitting=!1;hasNullListeners=!1;destroyRef=x(Ss);constructor(){this.destroyRef.onDestroy(()=>{this.destroyed=!0,this.listeners=null})}subscribe(e){if(this.destroyed)throw new fi(953,!1);return(this.listeners??=[]).push(e),{unsubscribe:()=>{let t=this.listeners?this.listeners.indexOf(e):-1;t>-1&&(this.isEmitting?(this.hasNullListeners=!0,this.listeners[t]=null):this.listeners.splice(t,1))}}}emit(e){if(this.destroyed){console.warn(pi(953,!1));return}if(this.listeners===null)return;this.isEmitting=!0;let t=rn(null);try{for(let t of this.listeners)try{t!==null&&t(e)}catch(e){this.errorHandler?.handleError(e)}}finally{this.hasNullListeners&&(this.hasNullListeners=!1,this.listeners&&lne(this.listeners)),rn(t),this.isEmitting=!1}}};function lne(e){let t=e.length-1;for(;t>-1;)e[t]===null&&e.splice(t,1),t--}function X(e,t){return Tn(e,t?.equal)}function Z(e){return Kn(e)}(class e extends Error{_brand;constructor(e){super(e)}static IDLE=new e(`IDLE`);static LOADING=new e(`LOADING`)});var une=e=>e;function Q(e,t){return typeof e==`function`?iv(Hn(e,une,t?.equal),t?.debugName,t?.set):iv(Hn(e.source,e.computation,e.equal),e.debugName,e.set)}function iv(e,t,n){let r=e[nn],i=e;if(n!==void 0){let t=e=>Un(r,e);i.set=e=>n(e,t),i.update=r=>n(r(Z(e)),t)}else i.set=e=>Un(r,e),i.update=e=>Wn(r,e);return i.asReadonly=Ys.bind(e),i}var av=!1;function dne(){return av}function ov(e){av=e}function sv(e,t){let n=Object.create(vc);n.value=e,n.transformFn=t?.transform;function r(){if(sn(n),n.value===_c)throw new fi(-950,null);return n.value}return r[nn]=n,r}function cv(e){return new rv}function lv(e,t){return sv(e,t)}function fne(e){return sv(_c,e)}var $=(lv.required=fne,lv);function uv(e,t){let n=Object.create(vc),r=new rv;n.value=e;function i(){return sn(n),dv(n.value),n.value}return i[nn]=n,i.asReadonly=Ys.bind(i),i.set=e=>{n.equal(n.value,e)||(jn(n,e),r.emit(e))},i.update=e=>{dv(n.value),i.set(e(n.value))},i.subscribe=r.subscribe.bind(r),i.destroyRef=r.destroyRef,i}function dv(e){if(e===_c)throw new fi(952,!1)}function fv(e,t){return uv(e,t)}function pne(e){return uv(_c,e)}var pv=(fv.required=pne,fv);function mv(e,t){return Qm(t)}function mne(e,t){return $m(t)}var hv=(mv.required=mne,mv),hne=(()=>{class e{static __NG_ELEMENT_ID__=gne}return e})();function gne(e){return _ne(Ho(),Bo(),(e&16)==16)}function _ne(e,t,n){if(ao(e)&&!n){let n=yo(e.index,t);return new Gf(n,n)}if(e.type&175){let e=t[15];return new Gf(e,t)}return null}var gv=new Di(``),vne=new Di(``);function _v(e){return!e.moduleRef}function yne(e){let t=_v(e)?e.r3Injector:e.moduleRef.injector,n=t.get(Ps);return n.run(()=>{_v(e)?e.r3Injector.resolveInjectorInitializers():e.moduleRef.resolveInjectorInitializers();let r=t.get(qs),i;if(n.runOutsideAngular(()=>{i=n.onError.subscribe({next:r})}),_v(e)){let n=()=>t.destroy(),r=e.platformInjector.get(gv);r.add(n),t.onDestroy(()=>{i.unsubscribe(),r.delete(n)})}else{let t=()=>e.moduleRef.destroy(),n=e.platformInjector.get(gv);n.add(t),e.moduleRef.onDestroy(()=>{Bh(e.allPlatformModules,e.moduleRef),i.unsubscribe(),n.delete(t)})}return xne(r,n,()=>{let n=t.get(Es),r=n.add(),i=t.get(vh);return i.runInitializers(),i.donePromise.then(()=>{if(Eg(t.get(nv,Tg)||`en-US`),!t.get(vne,!0))return _v(e)?t.get(Rh):(e.allPlatformModules.push(e.moduleRef),e.moduleRef);if(_v(e)){let n=t.get(Rh);return e.rootComponent!==void 0&&n.bootstrap(e.rootComponent),n}return bne?.(e.moduleRef,e.allPlatformModules),e.moduleRef}).finally(()=>void n.remove(r))})})}var bne;function xne(e,t,n){try{let r=n();return nh(r)?r.catch(n=>{throw t.runOutsideAngular(()=>e(n)),n}):r}catch(n){throw t.runOutsideAngular(()=>e(n)),n}}var vv=null;function Sne(e=[],t){return bs.create({name:t,providers:[{provide:Ia,useValue:`platform`},{provide:gv,useValue:new Set([()=>vv=null])},...e]})}function Cne(e=[]){if(vv)return vv;let t=Sne(e);return vv=t,Ih(),wne(t),t}function wne(e){let t=e.get(Qs,null);$a(e,()=>{t?.forEach(e=>e())})}function Tne(e){let{rootComponent:t,appProviders:n,platformProviders:r,platformRef:i}=e;Nc(bc.BootstrapApplicationStart);try{let e=i?.injector??Cne(r);return yne({r3Injector:new ah({providers:[sne(),Js,...n||[]],parent:e,debugName:``,runEnvironmentInitializers:!1}).injector,platformInjector:e,rootComponent:t})}catch(e){return Promise.reject(e)}finally{Nc(bc.BootstrapApplicationEnd)}}function yv(e){return typeof e==`boolean`?e:e!=null&&e!==`false`}var bv=Symbol(`NOT_SET`),xv=new Set,Ene={...Nn,kind:`afterRenderEffectPhase`,consumerIsAlwaysLive:!0,consumerAllowSignalWrites:!0,value:bv,cleanup:null,consumerMarkedDirty(){if(this.sequence.impl.executing){if(this.sequence.lastPhase===null||this.sequence.lastPhase(sn(r),r.value),r.signal[nn]=r,r.registerCleanupFn=e=>(r.cleanup??=new Set).add(e),this.nodes[e]=r,this.hooks[e]=e=>r.phaseFn(e)}}afterRun(){super.afterRun(),this.lastPhase=null}destroy(){if(this.onDestroyFns!==null)for(let e of this.onDestroyFns)e();super.destroy();for(let e of this.nodes)if(e)try{for(let t of e.cleanup??xv)t()}finally{yn(e)}}};function Sv(e,t){let n=t?.injector??x(bs),r=n.get(rc),i=n.get(id),a=n.get(td,null,{optional:!0});i.impl??=n.get(od);let o=e;typeof o==`function`&&(o={mixedReadWrite:e});let s=n.get(tc,null,{optional:!0}),c=new Dne(i.impl,[o.earlyRead,o.write,o.mixedReadWrite,o.read],s?.view,r,n,a?.snapshot(null));return i.impl.register(c),c}var Cv=null;function wv(){return Cv}function One(e){Cv??=e}var kne=class{},Tv=(function(e){return e[e.Format=0]=`Format`,e[e.Standalone=1]=`Standalone`,e})(Tv||{}),Ev=(function(e){return e[e.Narrow=0]=`Narrow`,e[e.Abbreviated=1]=`Abbreviated`,e[e.Wide=2]=`Wide`,e[e.Short=3]=`Short`,e})(Ev||{}),Dv=(function(e){return e[e.Short=0]=`Short`,e[e.Medium=1]=`Medium`,e[e.Long=2]=`Long`,e[e.Full=3]=`Full`,e})(Dv||{}),Ov={Decimal:0,Group:1,List:2,PercentSign:3,PlusSign:4,MinusSign:5,Exponential:6,SuperscriptingExponent:7,PerMille:8,Infinity:9,NaN:10,TimeSeparator:11,CurrencyDecimal:12,CurrencyGroup:13};function Ane(e){return xg(e)[Cg.LocaleId]}function jne(e,t,n){let r=xg(e);return Pv(Pv([r[Cg.DayPeriodsFormat],r[Cg.DayPeriodsStandalone]],t),n)}function Mne(e,t,n){let r=xg(e);return Pv(Pv([r[Cg.DaysFormat],r[Cg.DaysStandalone]],t),n)}function Nne(e,t,n){let r=xg(e);return Pv(Pv([r[Cg.MonthsFormat],r[Cg.MonthsStandalone]],t),n)}function Pne(e,t){let n=xg(e)[Cg.Eras];return Pv(n,t)}function kv(e,t){return Pv(xg(e)[Cg.DateFormat],t)}function Av(e,t){return Pv(xg(e)[Cg.TimeFormat],t)}function jv(e,t){let n=xg(e)[Cg.DateTimeFormat];return Pv(n,t)}function Mv(e,t){let n=xg(e),r=n[Cg.NumberSymbols][t];if(r===void 0){if(t===Ov.CurrencyDecimal)return n[Cg.NumberSymbols][Ov.Decimal];if(t===Ov.CurrencyGroup)return n[Cg.NumberSymbols][Ov.Group]}return r}function Nv(e){if(!e[Cg.ExtraData])throw new fi(2303,!1)}function Fne(e){let t=xg(e);return Nv(t),(t[Cg.ExtraData][2]||[]).map(e=>typeof e==`string`?Fv(e):[Fv(e[0]),Fv(e[1])])}function Ine(e,t,n){let r=xg(e);return Nv(r),Pv(Pv([r[Cg.ExtraData][0],r[Cg.ExtraData][1]],t)||[],n)||[]}function Pv(e,t){for(let n=t;n>-1;n--)if(e[n]!==void 0)return e[n];throw new fi(2304,!1)}function Fv(e){let[t,n]=e.split(`:`);return{hours:+t,minutes:+n}}var Lne=/^(\d{4,})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/,Iv=Object.create(null),Rne=/((?:[^BEGHLMOSWYZabcdhmswyz']+)|(?:'(?:[^']|'')*')|(?:G{1,5}|y{1,4}|Y{1,4}|M{1,5}|L{1,5}|w{1,2}|W{1}|d{1,2}|E{1,6}|c{1,6}|a{1,5}|b{1,5}|B{1,5}|h{1,2}|H{1,2}|m{1,2}|s{1,2}|S{1,3}|z{1,4}|Z{1,5}|O{1,4}))([\s\S]*)/,zne=256;function Bne(e,t,n,r){let i=Xne(e);Vne(t),t=Rv(n,t)||t;let a=[],o;for(;t;)if(o=Rne.exec(t),o){a=a.concat(o.slice(1));let e=a.pop();if(!e)break;t=e}else{a.push(t);break}let s=i.getTimezoneOffset();r&&(s=Yv(r,s),i=Yne(i,r));let c=``;return a.forEach(e=>{let t=qne(e);c+=t?t(i,n,s):e===`''`?`'`:e.replace(/(^'|'$)/g,``).replace(/''/g,`'`)}),c}function Vne(e){if(e.length>zne)throw new fi(2300,!1)}function Lv(e,t,n){let r=new Date(0);return r.setFullYear(e,t,n),r.setHours(0,0,0),r}function Rv(e,t){let n=Ane(e);if(Iv[n]??=Object.create(null),Iv[n][t])return Iv[n][t];let r=``;switch(t){case`shortDate`:r=kv(e,Dv.Short);break;case`mediumDate`:r=kv(e,Dv.Medium);break;case`longDate`:r=kv(e,Dv.Long);break;case`fullDate`:r=kv(e,Dv.Full);break;case`shortTime`:r=Av(e,Dv.Short);break;case`mediumTime`:r=Av(e,Dv.Medium);break;case`longTime`:r=Av(e,Dv.Long);break;case`fullTime`:r=Av(e,Dv.Full);break;case`short`:let t=Rv(e,`shortTime`),n=Rv(e,`shortDate`);r=zv(jv(e,Dv.Short),[t,n]);break;case`medium`:let i=Rv(e,`mediumTime`),a=Rv(e,`mediumDate`);r=zv(jv(e,Dv.Medium),[i,a]);break;case`long`:let o=Rv(e,`longTime`),s=Rv(e,`longDate`);r=zv(jv(e,Dv.Long),[o,s]);break;case`full`:let c=Rv(e,`fullTime`),l=Rv(e,`fullDate`);r=zv(jv(e,Dv.Full),[c,l])}return r&&(Iv[n][t]=r),r}function zv(e,t){return t&&(e=e.replace(/\{([^}]+)}/g,function(e,n){return Object.hasOwn(t,n)?t[n]:e})),e}function Bv(e,t,n=`-`,r,i){let a=``;(e<0||i&&e<=0)&&(i?e=-e+1:(e=-e,a=n));let o=String(e);for(;o.length0||s>-n)&&(s+=n),e===3)s===0&&n===-12&&(s=12);else if(e===6)return Hne(s,t);let c=Mv(o,Ov.MinusSign);return Bv(s,t,c,r,i)}}function Une(e,t){switch(e){case 0:return t.getFullYear();case 1:return t.getMonth();case 2:return t.getDate();case 3:return t.getHours();case 4:return t.getMinutes();case 5:return t.getSeconds();case 6:return t.getMilliseconds();case 7:return t.getDay();default:throw new fi(2301,!1)}}function Hv(e,t,n=Tv.Format,r=!1){return function(i,a){return Wne(i,a,e,t,n,r)}}function Wne(e,t,n,r,i,a){switch(n){case 2:return Nne(t,i,r)[e.getMonth()];case 1:return Mne(t,i,r)[e.getDay()];case 0:let n=e.getHours(),o=e.getMinutes();if(a){let e=Fne(t),a=Ine(t,i,r),s=e.findIndex(e=>{if(Array.isArray(e)){let[t,r]=e,i=n>=t.hours&&o>=t.minutes,a=n0?Math.floor(i/60):Math.ceil(i/60);switch(e){case 0:return(i>=0?`+`:``)+Bv(o,2,a)+Bv(Math.abs(i%60),2,a);case 1:return`GMT`+(i>=0?`+`:``)+Bv(o,1,a);case 2:return`GMT`+(i>=0?`+`:``)+Bv(o,2,a)+`:`+Bv(Math.abs(i%60),2,a);case 3:return r===0?`Z`:(i>=0?`+`:``)+Bv(o,2,a)+`:`+Bv(Math.abs(i%60),2,a);default:throw new fi(2310,!1)}}}var Gne=0,Wv=4;function Kne(e){let t=Lv(e,Gne,1).getDay();return Lv(e,0,1+(t<=Wv?Wv:11)-t)}function Gv(e){let t=e.getDay(),n=t===0?-3:Wv-t;return Lv(e.getFullYear(),e.getMonth(),e.getDate()+n)}function Kv(e,t=!1){return function(n,r){let i;if(t){let e=new Date(n.getFullYear(),n.getMonth(),1).getDay()-1,t=n.getDate();i=1+Math.floor((t+e)/7)}else{let e=Gv(n),t=Kne(e.getFullYear()),r=e.getTime()-t.getTime();i=1+Math.round(r/6048e5)}return Bv(i,e,Mv(r,Ov.MinusSign))}}function qv(e,t=!1){return function(n,r){return Bv(Gv(n).getFullYear(),e,Mv(r,Ov.MinusSign),t)}}var Jv=Object.create(null);function qne(e){if(Jv[e])return Jv[e];let t;switch(e){case`G`:case`GG`:case`GGG`:t=Hv(3,Ev.Abbreviated);break;case`GGGG`:t=Hv(3,Ev.Wide);break;case`GGGGG`:t=Hv(3,Ev.Narrow);break;case`y`:t=Vv(0,1,0,!1,!0);break;case`yy`:t=Vv(0,2,0,!0,!0);break;case`yyy`:t=Vv(0,3,0,!1,!0);break;case`yyyy`:t=Vv(0,4,0,!1,!0);break;case`Y`:t=qv(1);break;case`YY`:t=qv(2,!0);break;case`YYY`:t=qv(3);break;case`YYYY`:t=qv(4);break;case`M`:case`L`:t=Vv(1,1,1);break;case`MM`:case`LL`:t=Vv(1,2,1);break;case`MMM`:t=Hv(2,Ev.Abbreviated);break;case`MMMM`:t=Hv(2,Ev.Wide);break;case`MMMMM`:t=Hv(2,Ev.Narrow);break;case`LLL`:t=Hv(2,Ev.Abbreviated,Tv.Standalone);break;case`LLLL`:t=Hv(2,Ev.Wide,Tv.Standalone);break;case`LLLLL`:t=Hv(2,Ev.Narrow,Tv.Standalone);break;case`w`:t=Kv(1);break;case`ww`:t=Kv(2);break;case`W`:t=Kv(1,!0);break;case`d`:t=Vv(2,1);break;case`dd`:t=Vv(2,2);break;case`c`:case`cc`:t=Vv(7,1);break;case`ccc`:t=Hv(1,Ev.Abbreviated,Tv.Standalone);break;case`cccc`:t=Hv(1,Ev.Wide,Tv.Standalone);break;case`ccccc`:t=Hv(1,Ev.Narrow,Tv.Standalone);break;case`cccccc`:t=Hv(1,Ev.Short,Tv.Standalone);break;case`E`:case`EE`:case`EEE`:t=Hv(1,Ev.Abbreviated);break;case`EEEE`:t=Hv(1,Ev.Wide);break;case`EEEEE`:t=Hv(1,Ev.Narrow);break;case`EEEEEE`:t=Hv(1,Ev.Short);break;case`a`:case`aa`:case`aaa`:t=Hv(0,Ev.Abbreviated);break;case`aaaa`:t=Hv(0,Ev.Wide);break;case`aaaaa`:t=Hv(0,Ev.Narrow);break;case`b`:case`bb`:case`bbb`:t=Hv(0,Ev.Abbreviated,Tv.Standalone,!0);break;case`bbbb`:t=Hv(0,Ev.Wide,Tv.Standalone,!0);break;case`bbbbb`:t=Hv(0,Ev.Narrow,Tv.Standalone,!0);break;case`B`:case`BB`:case`BBB`:t=Hv(0,Ev.Abbreviated,Tv.Format,!0);break;case`BBBB`:t=Hv(0,Ev.Wide,Tv.Format,!0);break;case`BBBBB`:t=Hv(0,Ev.Narrow,Tv.Format,!0);break;case`h`:t=Vv(3,1,-12);break;case`hh`:t=Vv(3,2,-12);break;case`H`:t=Vv(3,1);break;case`HH`:t=Vv(3,2);break;case`m`:t=Vv(4,1);break;case`mm`:t=Vv(4,2);break;case`s`:t=Vv(5,1);break;case`ss`:t=Vv(5,2);break;case`S`:t=Vv(6,1);break;case`SS`:t=Vv(6,2);break;case`SSS`:t=Vv(6,3);break;case`Z`:case`ZZ`:case`ZZZ`:t=Uv(0);break;case`ZZZZZ`:t=Uv(3);break;case`O`:case`OO`:case`OOO`:case`z`:case`zz`:case`zzz`:t=Uv(1);break;case`OOOO`:case`ZZZZ`:case`zzzz`:t=Uv(2);break;default:return null}return Jv[e]=t,t}function Yv(e,t){e=e.replace(/:/g,``);let n=Date.parse(`Jan 01, 1970 00:00:00 `+e)/6e4;return isNaN(n)?t:n}function Jne(e,t){return e=new Date(e.getTime()),e.setMinutes(e.getMinutes()+t),e}function Yne(e,t,n){let r=e.getTimezoneOffset();return Jne(e,-1*(Yv(t,r)-r))}function Xne(e){if(Xv(e))return e;if(typeof e==`number`&&!isNaN(e))return new Date(e);if(typeof e==`string`){if(e=e.trim(),/^(\d{4}(-\d{1,2}(-\d{1,2})?)?)$/.test(e)){let[t,n=1,r=1]=e.split(`-`).map(e=>+e);return Lv(t,n-1,r)}let t=parseFloat(e);if(!isNaN(e-t))return new Date(t);let n;if(n=e.match(Lne))return Zne(n)}let t=new Date(e);if(!Xv(t))throw new fi(2311,!1);return t}function Zne(e){let t=new Date(0),n=0,r=0,i=e[8]?t.setUTCFullYear:t.setFullYear,a=e[8]?t.setUTCHours:t.setHours;e[9]&&(n=Number(e[9]+e[10]),r=Number(e[9]+e[11])),i.call(t,Number(e[1]),Number(e[2])-1,Number(e[3]));let o=Number(e[4]||0)-n,s=Number(e[5]||0)-r,c=Number(e[6]||0),l=Math.floor(parseFloat(`0.`+(e[7]||0))*1e3);return a.call(t,o,s,c,l),t}function Xv(e){return e instanceof Date&&!isNaN(e.valueOf())}var Zv=(()=>{class e{_viewContainerRef;_viewRef=null;ngTemplateOutletContext=null;ngTemplateOutlet=null;ngTemplateOutletInjector=null;injector=x(bs);constructor(e){this._viewContainerRef=e}ngOnChanges(e){if(this._shouldRecreateView(e)){let e=this._viewContainerRef;if(this._viewRef&&e.remove(e.indexOf(this._viewRef)),!this.ngTemplateOutlet){this._viewRef=null;return}let t=this._createContextForwardProxy();this._viewRef=e.createEmbeddedView(this.ngTemplateOutlet,t,{injector:this._getInjector()})}}_getInjector(){return this.ngTemplateOutletInjector===`outlet`?this.injector:this.ngTemplateOutletInjector??void 0}_shouldRecreateView(e){return!!e.ngTemplateOutlet||!!e.ngTemplateOutletInjector}_createContextForwardProxy(){return new Proxy({},{set:(e,t,n)=>this.ngTemplateOutletContext?Reflect.set(this.ngTemplateOutletContext,t,n):!1,get:(e,t,n)=>{if(this.ngTemplateOutletContext)return Reflect.get(this.ngTemplateOutletContext,t,n)}})}static ɵfac=function(t){return new(t||e)(Hp(Sm))};static ɵdir=dh({type:e,selectors:[[``,`ngTemplateOutlet`,``]],inputs:{ngTemplateOutletContext:`ngTemplateOutletContext`,ngTemplateOutlet:`ngTemplateOutlet`,ngTemplateOutletInjector:`ngTemplateOutletInjector`},features:[wc]})}return e})();function Qne(e,t){return new fi(2100,!1)}var $ne=`mediumDate`,ere=new Di(``),tre=new Di(``),Qv=(()=>{class e{locale;defaultTimezone;defaultOptions;constructor(e,t,n){this.locale=e,this.defaultTimezone=t,this.defaultOptions=n}transform(t,n,r,i){if(t==null||t===``||t!==t)return null;try{let e=n??this.defaultOptions?.dateFormat??$ne,a=r??this.defaultOptions?.timezone??this.defaultTimezone??void 0;return Bne(t,e,i||this.locale,a)}catch(t){throw Qne(e,t.message)}}static ɵfac=function(t){return new(t||e)(Hp(nv,16),Hp(ere,24),Hp(tre,24))};static ɵpipe=fh({name:`date`,type:e,pure:!0})}return e})(),$v=(()=>{class e{transform(e){return JSON.stringify(e,null,2)}static ɵfac=function(t){return new(t||e)};static ɵpipe=fh({name:`json`,type:e,pure:!1})}return e})();function nre(e,t){t=encodeURIComponent(t);for(let n of e.split(`;`)){let e=n.indexOf(`=`),[r,i]=e==-1?[n,``]:[n.slice(0,e),n.slice(e+1)];if(r.trim()!==t)continue;let a=i;try{a=decodeURIComponent(i)}catch{}return a.length>1&&a[0]===`"`&&a[a.length-1]===`"`&&(a=a.slice(1,-1)),a}return null}var rre=`browser`,ey=class{_doc;constructor(e){this._doc=e}manager},ty=(()=>{class e extends ey{constructor(e){super(e)}supports(e){return!0}addEventListener(e,t,n,r){return e.addEventListener(t,n,r),()=>this.removeEventListener(e,t,n,r)}removeEventListener(e,t,n,r){return e.removeEventListener(t,n,r)}static ɵfac=function(t){return new(t||e)(na(xs))};static ɵprov=bi({token:e,factory:e.ɵfac})}return e})(),ny=new Di(``),ry=(()=>{class e{_zone;_plugins;_eventNameToPlugin=new Map;constructor(e,t){this._zone=t,e.forEach(e=>{e.manager=this});let n=e.filter(e=>!(e instanceof ty));this._plugins=n.slice().reverse();let r=e.find(e=>e instanceof ty);r&&this._plugins.push(r)}addEventListener(e,t,n,r){return this._findPluginFor(t).addEventListener(e,t,n,r)}getZone(){return this._zone}_findPluginFor(e){let t=this._eventNameToPlugin.get(e);if(t)return t;if(t=this._plugins.find(t=>t.supports(e)),!t)throw new fi(-5101,!1);return this._eventNameToPlugin.set(e,t),t}static ɵfac=function(t){return new(t||e)(na(ny),na(Ps))};static ɵprov=bi({token:e,factory:e.ɵfac})}return e})(),iy=`ng-app-id`;function ay(e){for(let t of e)t.remove()}function oy(e,t){let n=t.createElement(`style`);return n.textContent=e,n}function ire(e,t,n,r){let i=e.head?.querySelectorAll(`style[${iy}="${t}"],link[${iy}="${t}"]`);if(!i||i.length===0)return!1;for(let e of i)e.removeAttribute(iy),e instanceof HTMLLinkElement?r.set(e.href.slice(e.href.lastIndexOf(`/`)+1),{usage:0,elements:[e]}):e.textContent&&n.set(e.textContent,{usage:0,elements:[e]});return!0}function sy(e,t){let n=t.createElement(`link`);return n.setAttribute(`rel`,`stylesheet`),n.setAttribute(`href`,e),n}var cy=(()=>{class e{doc;appId;nonce;inline=new Map;external=new Map;hosts=new Set;constructor(e,t,n,r={}){this.doc=e,this.appId=t,this.nonce=n,ire(e,t,this.inline,this.external)&&this.hosts.add(e.head)}addStyles(e,t){for(let t of e)this.addUsage(t,this.inline,oy);t?.forEach(e=>this.addUsage(e,this.external,sy))}removeStyles(e,t){for(let t of e)this.removeUsage(t,this.inline);t?.forEach(e=>this.removeUsage(e,this.external))}addUsage(e,t,n){let r=t.get(e);r?r.usage++:t.set(e,{usage:1,elements:[...this.hosts].map(t=>this.addElement(t,n(e,this.doc)))})}removeUsage(e,t){let n=t.get(e);n&&(n.usage--,n.usage<=0&&(ay(n.elements),t.delete(e)))}ngOnDestroy(){for(let[,{elements:e}]of[...this.inline,...this.external])ay(e);this.hosts.clear()}addHost(e){if(!this.hosts.has(e)){this.hosts.add(e);for(let[t,{elements:n}]of this.inline)n.push(this.addElement(e,oy(t,this.doc)));for(let[t,{elements:n}]of this.external)n.push(this.addElement(e,sy(t,this.doc)))}}removeHost(e){this.hosts.delete(e);for(let t of[...this.inline.values(),...this.external.values()]){let n=[];for(let r of t.elements)r.parentNode===e?r.remove():n.push(r);t.elements=n}}addElement(e,t){return this.nonce&&t.setAttribute(`nonce`,this.nonce),e.appendChild(t)}static ɵfac=function(t){return new(t||e)(na(xs),na(Xs),na(ec,8),na($s))};static ɵprov=bi({token:e,factory:e.ɵfac})}return e})(),ly={svg:`http://www.w3.org/2000/svg`,xhtml:`http://www.w3.org/1999/xhtml`,xlink:`http://www.w3.org/1999/xlink`,xml:`http://www.w3.org/XML/1998/namespace`,xmlns:`http://www.w3.org/2000/xmlns/`,math:`http://www.w3.org/1998/Math/MathML`},uy=/%COMP%/g,dy=`%COMP%`,are=`_nghost-${dy}`,ore=`_ngcontent-${dy}`,sre=!0,cre=new Di(``,{factory:()=>sre}),lre=new Di(``);function ure(e){return ore.replace(uy,e)}function dre(e){return are.replace(uy,e)}function fy(e,t){return t.map(t=>t.replace(uy,e))}var py=(()=>{class e{eventManager;sharedStylesHost;appId;removeStylesOnCompDestroy;doc;ngZone;nonce;tracingService;rendererByCompId=new Map;defaultRenderer;cssVarNamespace;constructor(e,t,n,r,i,a,o=null,s=null,c=null){this.eventManager=e,this.sharedStylesHost=t,this.appId=n,this.removeStylesOnCompDestroy=r,this.doc=i,this.ngZone=a,this.nonce=o,this.tracingService=s,this.cssVarNamespace=c??``,this.defaultRenderer=new my(e,i,a,this.tracingService,this.cssVarNamespace)}createRenderer(e,t){if(!e||!t)return this.defaultRenderer;let n=this.getOrCreateRenderer(e,t);return n instanceof vy?n.applyToHost(e):n instanceof _y&&n.applyStyles(),n}getOrCreateRenderer(e,t){let n=this.rendererByCompId,r=n.get(t.id);if(!r){let i=this.doc,a=this.ngZone,o=this.eventManager,s=this.sharedStylesHost,c=this.removeStylesOnCompDestroy,l=this.tracingService;switch(t.encapsulation){case su.Emulated:r=new vy(o,s,t,this.appId,c,i,a,l,this.cssVarNamespace);break;case su.ShadowDom:return new gy(o,e,t,i,a,this.nonce,l,this.cssVarNamespace,s);case su.ExperimentalIsolatedShadowDom:return new gy(o,e,t,i,a,this.nonce,l,this.cssVarNamespace);default:r=new _y(o,s,t,c,i,a,l,this.cssVarNamespace)}n.set(t.id,r)}return r}ngOnDestroy(){this.rendererByCompId.clear()}componentReplaced(e){this.rendererByCompId.delete(e)}static ɵfac=function(t){return new(t||e)(na(ry),na(Bp),na(Xs),na(cre),na(xs),na(Ps),na(ec),na(td,8),na(lre,8))};static ɵprov=bi({token:e,factory:e.ɵfac})}return e})(),my=class{eventManager;doc;ngZone;tracingService;cssVarNamespace;data=Object.create(null);throwOnSyntheticProps=!0;constructor(e,t,n,r,i=``){this.eventManager=e,this.doc=t,this.ngZone=n,this.tracingService=r,this.cssVarNamespace=i}destroy(){}destroyNode=null;createElement(e,t){return t?this.doc.createElementNS(ly[t]||t,e):this.doc.createElement(e)}createComment(e){return this.doc.createComment(e)}createText(e){return this.doc.createTextNode(e)}appendChild(e,t){(hy(e)?e.content:e).appendChild(t)}insertBefore(e,t,n){if(e){let r=hy(e)?e.content:e;if(n!=null&&n.parentNode!==r)throw new fi(-5106,!1);r.insertBefore(t,n)}}removeChild(e,t){t.remove()}selectRootElement(e,t){let n=typeof e==`string`?this.doc.querySelector(e):e;if(!n)throw new fi(-5104,!1);return t||(n.textContent=``),n}parentNode(e){return e.parentNode}nextSibling(e){return e.nextSibling}setAttribute(e,t,n,r){if(r){t=r+`:`+t;let i=ly[r];i?e.setAttributeNS(i,t,n):e.setAttribute(t,n)}else e.setAttribute(t,n)}removeAttribute(e,t,n){if(n){let r=ly[n];r?e.removeAttributeNS(r,t):e.removeAttribute(`${n}:${t}`)}else e.removeAttribute(t)}addClass(e,t){e.classList.add(t)}removeClass(e,t){e.classList.remove(t)}setStyle(e,t,n,r){let i=t.startsWith(`--`);i&&(t=t.replace(`%NS%`,this.cssVarNamespace)),i||r&(Iu.DashCase|Iu.Important)?e.style.setProperty(t,n,r&Iu.Important?`important`:``):e.style[t]=n}removeStyle(e,t,n){let r=t.startsWith(`--`);r&&(t=t.replace(`%NS%`,this.cssVarNamespace)),r||n&Iu.DashCase?e.style.removeProperty(t):e.style[t]=``}setProperty(e,t,n){e!=null&&(e[t]=n)}setValue(e,t){e.nodeValue=t}listen(e,t,n,r){if(typeof e==`string`&&(e=wv().getGlobalEventTarget(this.doc,e),!e))throw new fi(-5102,!1);let i=this.decoratePreventDefault(n);return this.tracingService?.wrapEventListener&&(i=this.tracingService.wrapEventListener(e,t,i)),this.eventManager.addEventListener(e,t,i,r)}decoratePreventDefault(e){return t=>{if(t===`__ngUnwrap__`)return e;e(t)===!1&&t.preventDefault()}}};function hy(e){return e.tagName===`TEMPLATE`&&e.content!==void 0}var gy=class extends my{hostEl;sharedStylesHost;shadowRoot;constructor(e,t,n,r,i,a,o,s,c){super(e,r,i,o,s),this.hostEl=t,this.sharedStylesHost=c,this.shadowRoot=t.attachShadow({mode:`open`}),this.sharedStylesHost&&this.sharedStylesHost.addHost(this.shadowRoot);let l=n.styles;l=fy(n.id,l).map(e=>e.replace(/%NS%/g,s));for(let e of l){let t=document.createElement(`style`);a&&t.setAttribute(`nonce`,a),t.textContent=e,this.shadowRoot.appendChild(t)}let u=n.getExternalStyles?.();if(u)for(let e of u){let t=sy(e,r);a&&t.setAttribute(`nonce`,a),this.shadowRoot.appendChild(t)}}nodeOrShadowRoot(e){return e===this.hostEl?this.shadowRoot:e}appendChild(e,t){return super.appendChild(this.nodeOrShadowRoot(e),t)}insertBefore(e,t,n){return super.insertBefore(this.nodeOrShadowRoot(e),t,n)}removeChild(e,t){return super.removeChild(null,t)}parentNode(e){return this.nodeOrShadowRoot(super.parentNode(this.nodeOrShadowRoot(e)))}destroy(){this.sharedStylesHost&&this.sharedStylesHost.removeHost(this.shadowRoot)}},_y=class extends my{sharedStylesHost;removeStylesOnCompDestroy;styles;styleUrls;constructor(e,t,n,r,i,a,o,s,c){super(e,i,a,o,s),this.sharedStylesHost=t,this.removeStylesOnCompDestroy=r;let l=n.styles,u=c?fy(c,l):l;this.styles=u.map(e=>e.replace(/%NS%/g,s)),this.styleUrls=n.getExternalStyles?.(c)}applyStyles(){this.sharedStylesHost.addStyles(this.styles,this.styleUrls)}destroy(){this.removeStylesOnCompDestroy&&Uu.size===0&&this.sharedStylesHost.removeStyles(this.styles,this.styleUrls)}},vy=class extends _y{contentAttr;hostAttr;constructor(e,t,n,r,i,a,o,s,c){let l=r+`-`+n.id;super(e,t,n,i,a,o,s,c,l),this.contentAttr=ure(l),this.hostAttr=dre(l)}applyToHost(e){this.applyStyles(),this.setAttribute(e,this.hostAttr,``)}createElement(e,t){let n=super.createElement(e,t);return super.setAttribute(n,this.contentAttr,``),n}},fre=class e extends kne{supportsDOMEvents=!0;static makeCurrent(){One(new e)}onAndCancel(e,t,n,r){return e.addEventListener(t,n,r),()=>{e.removeEventListener(t,n,r)}}dispatchEvent(e,t){e.dispatchEvent(t)}remove(e){e.remove()}createElement(e,t){return t||=this.getDefaultDocument(),t.createElement(e)}createHtmlDocument(){return document.implementation.createHTMLDocument(`fakeTitle`)}getDefaultDocument(){return document}isElementNode(e){return e.nodeType===Node.ELEMENT_NODE}isShadowRoot(e){return e instanceof DocumentFragment}getGlobalEventTarget(e,t){return t===`window`?window:t===`document`?e:t===`body`?e.body:null}getBaseHref(e){let t=pre();return t==null?null:mre(t)}resetBaseElement(){yy=null}getUserAgent(){return window.navigator.userAgent}getCookie(e){return nre(document.cookie,e)}},yy=null;function pre(){return yy||=document.head.querySelector(`base`),yy?yy.getAttribute(`href`):null}function mre(e){return new URL(e,document.baseURI).pathname}var by=[`alt`,`control`,`meta`,`shift`],hre={"\b":`Backspace`," ":`Tab`,"":`Delete`,"\x1B":`Escape`,Del:`Delete`,Esc:`Escape`,Left:`ArrowLeft`,Right:`ArrowRight`,Up:`ArrowUp`,Down:`ArrowDown`,Menu:`ContextMenu`,Scroll:`ScrollLock`,Win:`OS`},gre={alt:e=>e.altKey,control:e=>e.ctrlKey,meta:e=>e.metaKey,shift:e=>e.shiftKey},_re=(()=>{class e extends ey{constructor(e){super(e)}supports(t){return e.parseEventName(t)!=null}addEventListener(t,n,r,i){let a=e.parseEventName(n),o=e.eventCallback(a.fullKey,r,this.manager.getZone());return this.manager.getZone().runOutsideAngular(()=>wv().onAndCancel(t,a.domEventName,o,i))}static parseEventName(t){let n=t.toLowerCase().split(`.`),r=n.shift();if(n.length===0||r!==`keydown`&&r!==`keyup`)return null;let i=e._normalizeKey(n.pop()),a=``,o=n.indexOf(`code`);if(o>-1&&(n.splice(o,1),a=`code.`),by.forEach(e=>{let t=n.indexOf(e);t>-1&&(n.splice(t,1),a+=e+`.`)}),a+=i,n.length!=0||i.length===0)return null;let s={};return s.domEventName=r,s.fullKey=a,s}static matchEventFullKeyCode(e,t){let n=hre[e.key]||e.key,r=``;return t.indexOf(`code.`)>-1&&(n=e.code,r=`code.`),n==null||!n?!1:(n=n.toLowerCase(),n===` `?n=`space`:n===`.`&&(n=`dot`),by.forEach(t=>{if(t!==n){let n=gre[t];n(e)&&(r+=t+`.`)}}),r+=n,r===t)}static eventCallback(t,n,r){return i=>{e.matchEventFullKeyCode(i,t)&&r.runGuarded(()=>n(i))}}static _normalizeKey(e){return e===`esc`?`escape`:e}static ɵfac=function(t){return new(t||e)(na(xs))};static ɵprov=bi({token:e,factory:e.ɵfac})}return e})();async function vre(e,t,n){return Tne({rootComponent:e,...yre(t,n)})}function yre(e,t){return{platformRef:t?.platformRef,appProviders:[...wre,...e?.providers??[]],platformProviders:Cre}}function bre(){fre.makeCurrent()}function xre(){return new Ks}function Sre(){return Yl(document),document}var Cre=[{provide:$s,useValue:rre},{provide:Qs,useValue:bre,multi:!0},{provide:xs,useFactory:Sre}],wre=[{provide:Ia,useValue:`root`},{provide:Ks,useFactory:xre},{provide:ny,useClass:ty,multi:!0},{provide:ny,useClass:_re,multi:!0},py,{provide:Bp,useClass:cy},{provide:cy,useExisting:Bp},ry,{provide:op,useExisting:py},[]];function xy(e,t){let n=`\x1B[${e}m`,r=`\x1B[${t}m`;return((e,...t)=>{if(Array.isArray(e)&&`raw`in e){let i=e,a=``;for(let e=0;e{let r=new Dre({code:i,why:Sy(a.why,e),fix:Sy(a.fix,e),docs:o,cause:e.cause,sources:e.sources,data:Sy(a.data,e)},s);for(let e of t)e(r,n);return r};n[i]=s}return n}function Are(e){return t=>{let n=`${e.bold(e.red(`[${t.name}]`))} ${t.message}`,r=[];return t.fix&&r.push(`${e.dim(`fix:`)} ${t.fix}`),t.sources?.length&&r.push(`${e.dim(`sources:`)} ${t.sources.join(`, `)}`),t.docs&&r.push(`${e.dim(`see:`)} ${e.cyan(t.docs)}`),r.length===0?n:[n,...r.map((t,n)=>`${e.dim(n{e=n,t=r}),resolve:e,reject:t}}var Fre=Math.random.bind(Math),Ire=`useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict`;function Lre(e=21){let t=``,n=e;for(;n--;)t+=Ire[Fre()*64|0];return t}var Rre=6e4,wy=e=>e,zre=wy,{clearTimeout:Ty,setTimeout:Bre}=globalThis;function Vre(e,t){let{post:n,on:r,off:i=()=>{},eventNames:a=[],serialize:o=wy,deserialize:s=zre,resolver:c,bind:l=`rpc`,timeout:u=Rre,proxify:d=!0}=t,f=!1,p=new Map,m,ee;async function h(e,r,i,a){if(f)throw Error(`[birpc] rpc is closed, cannot call "${e}"`);let s={m:e,a:r,t:`q`};a&&(s.o=!0);let c=async e=>n(o(e));if(i){await c(s);return}if(m)try{await m}finally{m=void 0}let{promise:l,resolve:d,reject:h}=Pre(),g=Lre();s.i=g;let te;async function ne(n=s){return u>=0&&(te=Bre(()=>{try{if(t.onTimeoutError?.call(ee,e,r)!==!0)throw Error(`[birpc] timeout on calling "${e}"`)}catch(e){h(e)}p.delete(g)},u),typeof te==`object`&&(te=te.unref?.())),p.set(g,{resolve:d,reject:h,timeoutId:te,method:e}),await c(n),l}try{t.onRequest?await t.onRequest.call(ee,s,ne,d):await ne()}catch(e){if(t.onGeneralError?.call(ee,e)!==!0)throw e;return}finally{Ty(te),p.delete(g)}return l}let g={$call:(e,...t)=>h(e,t,!1),$callOptional:(e,...t)=>h(e,t,!1,!0),$callEvent:(e,...t)=>h(e,t,!0),$callRaw:e=>h(e.method,e.args,e.event,e.optional),$rejectPendingCalls:ne,get $closed(){return f},get $meta(){return t.meta},$close:te,$functions:e};ee=d?new Proxy({},{get(t,n){if(Object.hasOwn(g,n))return g[n];if(n===`then`&&!a.includes(`then`)&&!(`then`in e))return;let r=(...e)=>h(n,e,!0);if(a.includes(n))return r.asEvent=r,r;let i=(...e)=>h(n,e,!1);return i.asEvent=r,i}}):g;function te(e){f=!0,p.forEach(({reject:t,method:n})=>{let r=Error(`[birpc] rpc is closed, cannot call "${n}"`);if(e)return e.cause??=r,t(e);t(r)}),p.clear(),i(re)}function ne(e){let t=Array.from(p.values()).map(({method:t,reject:n})=>e?e({method:t,reject:n}):n(Error(`[birpc]: rejected pending call "${t}".`)));return p.clear(),t}async function re(r,...i){let a;try{a=s(r)}catch(e){if(t.onGeneralError?.call(ee,e)!==!0)throw e;return}if(a.t===`q`){let{m:r,a:s,o:u}=a,d,f,p=await(c?c.call(ee,r,e[r]):e[r]);if(u&&(p||=()=>void 0),!p)f=Error(`[birpc] function "${r}" not found`);else try{d=await p.apply(l===`rpc`?ee:e,s)}catch(e){f=e}if(a.i){if(f&&t.onFunctionError&&t.onFunctionError.call(ee,f,r,s)===!0)return;if(!f)try{await n(o({t:`s`,i:a.i,r:d}),...i);return}catch(e){if(f=e,t.onGeneralError?.call(ee,e,r,s)!==!0)throw e}try{await n(o({t:`s`,i:a.i,e:f}),...i)}catch(e){if(t.onGeneralError?.call(ee,e,r,s)!==!0)throw e}}}else{let{i:e,r:t,e:n}=a,r=p.get(e);r&&(Ty(r.timeoutId),n?r.reject(n):r.resolve(t)),p.delete(e)}}return m=r(re),ee}function Ey(e,t){return t.safety?t.safety:e===`static`||e===`query`||e==null?`read`:`action`}var Dy=Object.freeze({type:`object`,additionalProperties:!0});function Oy(e){let t=e[`~standard`];if(t.jsonSchema)try{return t.jsonSchema.input({target:`draft-2020-12`})}catch{return Dy}return Dy}function ky(e){if(!e||e.length===0)return{type:`object`,properties:{}};let t={},n=[];for(let r=0;rn[`arg${t}`]);if(`arg0`in n){let e=[];for(;`arg${e.length}`in n;)e.push(n[`arg${e.length}`]);return e}return Object.keys(n).length===0?[]:void 0}function jy(e,t){return Ay(e,t)??[e]}function My(e){return typeof e==`string`?`'${e}'`:new Iy().serialize(e)}var Ny=` _-,;:!?.'"()[]{}@*/\\&#%\`^+<=>|~$0123456789abcdefghijklmnopqrstuvwxyz`,Py=(function(){let e=new Uint8Array(128);for(let t=0;t<69;t++)e[Ny.charCodeAt(t)]=t+1;for(let t=65;t<=90;t++)e[t]=e[t+32];return e})();function Fy(e,t){if(e===t)return 0;let n=Math.min(e.length,t.length),r=0;for(let i=0;ia?-1:1)}return e.length===t.length?r:e.lengththis.compare(e[0],t[0])),r=`${e}{`;for(let e=0;ethis.compare(e,t)))}`}$Map(e){return this.serializeObjectEntries(`Map`,e.entries())}}for(let t of[`Error`,`RegExp`,`URL`])e.prototype[`$`+t]=function(e){return`${t}(${e})`};for(let t of[`Int8Array`,`Uint8Array`,`Uint8ClampedArray`,`Int16Array`,`Uint16Array`,`Int32Array`,`Uint32Array`,`Float32Array`,`Float64Array`])e.prototype[`$`+t]=function(e){return`${t}[${e.join(`,`)}]`};for(let t of[`BigInt64Array`,`BigUint64Array`])e.prototype[`$`+t]=function(e){return`${t}[${e.join(`n,`)}${e.length>0?`n`:``}]`};return e})(),Ly=[1779033703,-1150833019,1013904242,-1521486534,1359893119,-1694144372,528734635,1541459225],Ry=[1116352408,1899447441,-1245643825,-373957723,961987163,1508970993,-1841331548,-1424204075,-670586216,310598401,607225278,1426881987,1925078388,-2132889090,-1680079193,-1046744716,-459576895,-272742522,264347078,604807628,770255983,1249150122,1555081692,1996064986,-1740746414,-1473132947,-1341970488,-1084653625,-958395405,-710438585,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,-2117940946,-1838011259,-1564481375,-1474664885,-1035236496,-949202525,-778901479,-694614492,-200395387,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,-2067236844,-1933114872,-1866530822,-1538233109,-1090935817,-965641998],zy=`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_`,By=[],Vy=class{_data=new Hy;_hash=new Hy([...Ly]);_nDataBytes=0;_minBufferSize=0;finalize(e){e&&this._append(e);let t=this._nDataBytes*8,n=this._data.sigBytes*8;return this._data.words[n>>>5]|=128<<24-n%32,this._data.words[(n+64>>>9<<4)+14]=Math.floor(t/4294967296),this._data.words[(n+64>>>9<<4)+15]=t,this._data.sigBytes=this._data.words.length*4,this._process(),this._hash}_doProcessBlock(e,t){let n=this._hash.words,r=n[0],i=n[1],a=n[2],o=n[3],s=n[4],c=n[5],l=n[6],u=n[7];for(let n=0;n<64;n++){if(n<16)By[n]=e[t+n]|0;else{let e=By[n-15],t=(e<<25|e>>>7)^(e<<14|e>>>18)^e>>>3,r=By[n-2],i=(r<<15|r>>>17)^(r<<13|r>>>19)^r>>>10;By[n]=t+By[n-7]+i+By[n-16]}let d=s&c^~s&l,f=r&i^r&a^i&a,p=(r<<30|r>>>2)^(r<<19|r>>>13)^(r<<10|r>>>22),m=(s<<26|s>>>6)^(s<<21|s>>>11)^(s<<7|s>>>25),ee=u+m+d+Ry[n]+By[n],h=p+f;u=l,l=c,c=s,s=o+ee|0,o=a,a=i,i=r,r=ee+h|0}n[0]=n[0]+r|0,n[1]=n[1]+i|0,n[2]=n[2]+a|0,n[3]=n[3]+o|0,n[4]=n[4]+s|0,n[5]=n[5]+c|0,n[6]=n[6]+l|0,n[7]=n[7]+u|0}_append(e){typeof e==`string`&&(e=Hy.fromUtf8(e)),this._data.concat(e),this._nDataBytes+=e.sigBytes}_process(e){let t,n=this._data.sigBytes/64;n=e?Math.ceil(n):Math.max((n|0)-this._minBufferSize,0);let r=n*16,i=Math.min(r*4,this._data.sigBytes);if(r){for(let e=0;e>>2]|=(n.charCodeAt(e)&255)<<24-e%4*8;return new e(i,r)}toBase64(){let e=[];for(let t=0;t>>2]>>>24-t%4*8&255,r=this.words[t+1>>>2]>>>24-(t+1)%4*8&255,i=this.words[t+2>>>2]>>>24-(t+2)%4*8&255,a=n<<16|r<<8|i;for(let n=0;n<4&&t*8+n*6>>6*(3-n)&63))}return e.join(``)}concat(e){if(this.words[this.sigBytes>>>2]&=4294967295<<32-this.sigBytes%4*8,this.words.length=Math.ceil(this.sigBytes/4),this.sigBytes%4)for(let t=0;t>>2]>>>24-t%4*8&255;this.words[this.sigBytes+t>>>2]|=n<<24-(this.sigBytes+t)%4*8}else for(let t=0;t>>2]=e.words[t>>>2];this.sigBytes+=e.sigBytes}};function Uy(e){return new Vy().finalize(e).toBase64()}function Wy(e){return Uy(My(e))}function Gy(e){return Wy(e)}function Ky(){let e={};function t(t,...n){let r=e[t]||[];for(let e=0,t=r.length;e{e[t]=e[t]?.filter(e=>n!==e)}}function i(e,t){let n=r(e,((...e)=>(n(),t(...e))));return n}return{_listeners:e,emit:t,emitOnce:n,on:r,once:i}}var qy=/^[\w+.-]{2,}:\/\//;function Jy(e){return e.endsWith(`/`)?e:`${e}/`}function Yy(e){return(e.endsWith(`/`)?e.slice(0,-1):e)||`/`}function Xy(e,...t){let n=e;for(let e of t)e&&e!==`/`&&(n=n?Jy(n)+e.replace(/^\.?\//,``):e);return n}function Zy(e,t){if(!t||t===`/`||qy.test(e))return e;let n=Yy(t);return e.startsWith(n)?e:Xy(n,e)}function Qy(e,t){let n=e.match(qy);return t+(n?e.slice(n[0].length):e)}var $y=`useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict`;function eb(e=21){let t=``,n=e;for(;n--;)t+=$y[Math.random()*64|0];return t}var tb=Symbol.for(`immer-nothing`),nb=Symbol.for(`immer-draftable`),rb=Symbol.for(`immer-state`),ib=[function(e){return`The plugin for '${e}' has not been loaded into Immer. To enable the plugin, import and call \`enable${e}()\` when initializing your application.`},function(e){return`produce can only be called on things that are draftable: plain objects, arrays, Map, Set or classes that are marked with '[immerable]: true'. Got '${e}'`},`This object has been frozen and should not be mutated`,function(e){return`Cannot use a proxy that has been revoked. Did you pass an object from inside an immer function to an async process? `+e},`An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft.`,`Immer forbids circular references`,"The first or second argument to `produce` must be a function","The third argument to `produce` must be a function or undefined","First argument to `createDraft` must be a plain object, an array, or an immerable object","First argument to `finishDraft` must be a draft returned by `createDraft`",function(e){return`'current' expects a draft, got: ${e}`},`Object.defineProperty() cannot be used on an Immer draft`,`Object.setPrototypeOf() cannot be used on an Immer draft`,`Immer only supports deleting array indices`,`Immer only supports setting array indices and the 'length' property`,function(e){return`'original' expects a draft, got: ${e}`}];function ab(e,...t){{let n=ib[e],r=kb(n)?n.apply(null,t):n;throw Error(`[Immer] ${r}`)}}var ob=Object,sb=ob.getPrototypeOf,cb=`constructor`,lb=`prototype`,ub=`configurable`,db=`enumerable`,fb=`writable`,pb=`value`,mb=e=>!!e&&!!e[rb];function hb(e){return e?vb(e)||Tb(e)||!!e[nb]||!!e[cb]?.[nb]||Eb(e)||Db(e):!1}var gb=ob[lb][cb].toString(),_b=new WeakMap;function vb(e){if(!e||!Ob(e))return!1;let t=sb(e);if(t===null||t===ob[lb])return!0;let n=ob.hasOwnProperty.call(t,cb)&&t[cb];if(n===Object)return!0;if(!kb(n))return!1;let r=_b.get(n);return r===void 0&&(r=Function.toString.call(n),_b.set(n,r)),r===gb}function yb(e,t,n=!0){bb(e)===0?(n?Reflect.ownKeys(e):ob.keys(e)).forEach(n=>{t(n,e[n],e)}):e.forEach((n,r)=>t(r,n,e))}function bb(e){let t=e[rb];return t?t.type_:Tb(e)?1:Eb(e)?2:Db(e)?3:0}var xb=(e,t,n=bb(e))=>n===2?e.has(t):ob[lb].hasOwnProperty.call(e,t),Sb=(e,t,n=bb(e))=>n===2?e.get(t):e[t],Cb=(e,t,n,r=bb(e))=>{r===2?e.set(t,n):r===3?e.add(n):e[t]=n};function wb(e,t){return e===t?e!==0||1/e==1/t:e!==e&&t!==t}var Tb=Array.isArray,Eb=e=>e instanceof Map,Db=e=>e instanceof Set,Ob=e=>typeof e==`object`,kb=e=>typeof e==`function`,Ab=e=>typeof e==`boolean`;function jb(e){let t=+e;return Number.isInteger(t)&&String(t)===e}var Mb=e=>Ob(e)?e?.[rb]:null,Nb=e=>e.copy_||e.base_,Pb=e=>e.modified_?e.copy_:e.base_;function Fb(e,t){if(Eb(e))return new Map(e);if(Db(e))return new Set(e);if(Tb(e))return Array[lb].slice.call(e);let n=vb(e);if(t===!0||t===`class_only`&&!n){let t=ob.getOwnPropertyDescriptors(e);delete t[rb];let n=Reflect.ownKeys(t);for(let r=0;r1&&ob.defineProperties(e,{set:Rb,add:Rb,clear:Rb,delete:Rb}),ob.freeze(e),t&&yb(e,(e,t)=>{Ib(t,!0)},!1),e)}function Lb(){ab(2)}var Rb={[pb]:Lb};function zb(e){return e===null||!Ob(e)||ob.isFrozen(e)}var Bb=`MapSet`,Vb=`Patches`,Hb=`ArrayMethods`,Ub={};function Wb(e){let t=Ub[e];return t||ab(0,e),t}var Gb=e=>!!Ub[e];function Kb(e,t){Ub[e]||(Ub[e]=t)}var qb,Jb=()=>qb,Yb=(e,t)=>({drafts_:[],parent_:e,immer_:t,canAutoFreeze_:!0,unfinalizedDrafts_:0,handledSet_:new Set,processedForPatches_:new Set,mapSetPlugin_:Gb(Bb)?Wb(Bb):void 0,arrayMethodsPlugin_:Gb(Hb)?Wb(Hb):void 0});function Xb(e,t){t&&(e.patchPlugin_=Wb(Vb),e.patches_=[],e.inversePatches_=[],e.patchListener_=t)}function Zb(e){Qb(e),e.drafts_.forEach(ex),e.drafts_=null}function Qb(e){e===qb&&(qb=e.parent_)}var $b=e=>qb=Yb(qb,e);function ex(e){let t=e[rb];t.type_===0||t.type_===1?t.revoke_():t.revoked_=!0}function tx(e,t){t.unfinalizedDrafts_=t.drafts_.length;let n=t.drafts_[0];if(e!==void 0&&e!==n){n[rb].modified_&&(Zb(t),ab(4)),hb(e)&&(e=nx(t,e));let{patchPlugin_:r}=t;r&&r.generateReplacementPatches_(n[rb].base_,e,t)}else e=nx(t,n);return rx(t,e,!0),Zb(t),t.patches_&&t.patchListener_(t.patches_,t.inversePatches_),e===tb?void 0:e}function nx(e,t){if(zb(t))return t;let n=t[rb];if(!n)return dx(t,e.handledSet_,e);if(!ax(n,e))return t;if(!n.modified_)return n.base_;if(!n.finalized_){let{callbacks_:t}=n;if(t)for(;t.length>0;)t.pop()(e);lx(n,e)}return n.copy_}function rx(e,t,n=!1){!e.parent_&&e.immer_.autoFreeze_&&e.canAutoFreeze_&&Ib(t,n)}function ix(e){e.finalized_=!0,e.scope_.unfinalizedDrafts_--}var ax=(e,t)=>e.scope_===t,ox=[];function sx(e,t,n,r){let i=Nb(e),a=e.type_;if(r!==void 0&&Sb(i,r,a)===t){Cb(i,r,n,a);return}if(!e.draftLocations_){let t=e.draftLocations_=new Map;yb(i,(e,n)=>{if(mb(n)){let r=t.get(n)||[];r.push(e),t.set(n,r)}})}let o=e.draftLocations_.get(t)??ox;for(let e of o)Cb(i,e,n,a)}function cx(e,t,n){e.callbacks_.push(function(r){let i=t;if(!i||!ax(i,r))return;r.mapSetPlugin_?.fixSetContents(i);let a=Pb(i);sx(e,i.draft_??i,a,n),lx(i,r)})}function lx(e,t){if(e.modified_&&!e.finalized_&&(e.type_===3||e.type_===1&&e.allIndicesReassigned_||(e.assigned_?.size??0)>0)){let{patchPlugin_:n}=t;if(n){let r=n.getPath(e);r&&n.generatePatches_(e,r,t)}ix(e)}}function ux(e,t,n){let{scope_:r}=e;if(mb(n)){let i=n[rb];ax(i,r)&&i.callbacks_.push(function(){bx(e),sx(e,n,Pb(i),t)})}else hb(n)&&e.callbacks_.push(function(){let i=Nb(e);e.type_===3?i.has(n)&&dx(n,r.handledSet_,r):Sb(i,t,e.type_)===n&&r.drafts_.length>1&&(e.assigned_.get(t)??!1)===!0&&e.copy_&&dx(Sb(e.copy_,t,e.type_),r.handledSet_,r)})}function dx(e,t,n){return!n.immer_.autoFreeze_&&n.unfinalizedDrafts_<1||mb(e)||t.has(e)||!hb(e)||zb(e)?e:(t.add(e),yb(e,(r,i)=>{if(mb(i)){let t=i[rb];ax(t,n)&&(Cb(e,r,Pb(t),e.type_),ix(t))}else hb(i)&&dx(i,t,n)}),e)}function fx(e,t){let n=Tb(e),r={type_:+!!n,scope_:t?t.scope_:Jb(),modified_:!1,finalized_:!1,assigned_:void 0,parent_:t,base_:e,draft_:null,copy_:null,revoke_:null,isManual_:!1,callbacks_:void 0},i=r,a=px;n&&(i=[r],a=mx);let{revoke:o,proxy:s}=Proxy.revocable(i,a);return r.draft_=s,r.revoke_=o,[s,r]}var px={get(e,t){if(t===rb)return e;let n=e.scope_.arrayMethodsPlugin_,r=e.type_===1&&typeof t==`string`;if(r&&n?.isArrayOperationMethod(t))return n.createMethodInterceptor(e,t);let i=Nb(e);if(!xb(i,t,e.type_))return _x(e,i,t);let a=i[t];if(e.finalized_||!hb(a)||r&&e.operationMethod&&n?.isMutatingArrayMethod(e.operationMethod)&&jb(t))return a;if(a===hx(e.base_,t)||gx(e,t,a)){bx(e);let n=e.type_===1?+t:t,r=Sx(e.scope_,a,e,n);return e.copy_[n]=r}return a},has(e,t){return t in Nb(e)},ownKeys(e){return Reflect.ownKeys(Nb(e))},set(e,t,n){let r=vx(Nb(e),t);if(r?.set)return r.set.call(e.draft_,n),!0;if(!e.modified_){let r=hx(Nb(e),t),i=r?.[rb];if(i&&i.base_===n)return e.copy_[t]=n,e.assigned_.delete(t),!0;if(wb(n,r)&&(n!==void 0||xb(e.base_,t,e.type_)))return!0;bx(e),yx(e)}return e.copy_[t]===n&&(n!==void 0||xb(e.copy_,t,e.type_))||Number.isNaN(n)&&Number.isNaN(e.copy_[t])?!0:(e.copy_[t]=n,e.assigned_.set(t,!0),ux(e,t,n),!0)},deleteProperty(e,t){return bx(e),hx(e.base_,t)!==void 0||t in e.base_?(e.assigned_.set(t,!1),yx(e)):e.assigned_.delete(t),e.copy_&&delete e.copy_[t],!0},getOwnPropertyDescriptor(e,t){let n=Nb(e),r=Reflect.getOwnPropertyDescriptor(n,t);return r&&{[fb]:!0,[ub]:e.type_!==1||t!==`length`,[db]:r[db],[pb]:n[t]}},defineProperty(){ab(11)},getPrototypeOf(e){return sb(e.base_)},setPrototypeOf(){ab(12)}},mx={};for(let e in px){let t=px[e];mx[e]=function(){let e=arguments;return e[0]=e[0][0],t.apply(this,e)}}mx.deleteProperty=function(e,t){return isNaN(parseInt(t))&&ab(13),mx.set.call(this,e,t,void 0)},mx.set=function(e,t,n){return t!==`length`&&isNaN(parseInt(t))&&ab(14),px.set.call(this,e[0],t,n,e[0])};function hx(e,t){let n=e[rb];return(n?Nb(n):e)[t]}function gx(e,t,n){return e.type_!==1||!e.allIndicesReassigned_||e.assigned_?.get(t)||!hb(n)||n[rb]?!1:e.baseRefs_.has(n)}function _x(e,t,n){let r=vx(t,n);return r?pb in r?r[pb]:r.get?.call(e.draft_):void 0}function vx(e,t){if(!(t in e))return;let n=sb(e);for(;n;){let e=Object.getOwnPropertyDescriptor(n,t);if(e)return e;n=sb(n)}}function yx(e){e.modified_||(e.modified_=!0,e.parent_&&yx(e.parent_))}function bx(e){e.copy_||=(e.assigned_=new Map,Fb(e.base_,e.scope_.immer_.useStrictShallowCopy_))}var xx=class{constructor(e){this.autoFreeze_=!0,this.useStrictShallowCopy_=!1,this.useStrictIteration_=!1,this.produce=(e,t,n)=>{if(kb(e)&&!kb(t)){let n=t;t=e;let r=this;return function(e=n,...i){return r.produce(e,e=>t.call(this,e,...i))}}kb(t)||ab(6),n!==void 0&&!kb(n)&&ab(7);let r;if(hb(e)){let i=$b(this),a=Sx(i,e,void 0),o=!0;try{r=t(a),o=!1}finally{o?Zb(i):Qb(i)}return Xb(i,n),tx(r,i)}if(!e||!Ob(e)){if(r=t(e),r===void 0&&(r=e),r===tb&&(r=void 0),this.autoFreeze_&&Ib(r,!0),n){let t=[],i=[];Wb(Vb).generateReplacementPatches_(e,r,{patches_:t,inversePatches_:i}),n(t,i)}return r}ab(1,e)},this.produceWithPatches=(e,t)=>{if(kb(e))return(t,...n)=>this.produceWithPatches(t,t=>e(t,...n));let n,r;return[this.produce(e,t,(e,t)=>{n=e,r=t}),n,r]},Ab(e?.autoFreeze)&&this.setAutoFreeze(e.autoFreeze),Ab(e?.useStrictShallowCopy)&&this.setUseStrictShallowCopy(e.useStrictShallowCopy),Ab(e?.useStrictIteration)&&this.setUseStrictIteration(e.useStrictIteration)}createDraft(e){hb(e)||ab(8),mb(e)&&(e=Cx(e));let t=$b(this),n=Sx(t,e,void 0);return n[rb].isManual_=!0,Qb(t),n}finishDraft(e,t){let n=e&&e[rb];(!n||!n.isManual_)&&ab(9);let{scope_:r}=n;return Xb(r,t),tx(void 0,r)}setAutoFreeze(e){this.autoFreeze_=e}setUseStrictShallowCopy(e){this.useStrictShallowCopy_=e}setUseStrictIteration(e){this.useStrictIteration_=e}shouldUseStrictIteration(){return this.useStrictIteration_}applyPatches(e,t){let n;for(n=t.length-1;n>=0;n--){let r=t[n];if(r.path.length===0&&r.op===`replace`){e=r.value;break}}n>-1&&(t=t.slice(n+1));let r=Wb(Vb).applyPatches_;return mb(e)?r(e,t):this.produce(e,e=>r(e,t))}};function Sx(e,t,n,r){let[i,a]=Eb(t)?Wb(Bb).proxyMap_(t,n):Db(t)?Wb(Bb).proxySet_(t,n):fx(t,n);return(n?.scope_??Jb()).drafts_.push(i),a.callbacks_=n?.callbacks_??[],a.key_=r,n&&r!==void 0?cx(n,a,r):a.callbacks_.push(function(e){e.mapSetPlugin_?.fixSetContents(a);let{patchPlugin_:t}=e;a.modified_&&t&&t.generatePatches_(a,[],e)}),i}function Cx(e){return mb(e)||ab(10,e),wx(e)}function wx(e){if(!hb(e)||zb(e))return e;let t=e[rb],n,r=!0;if(t){if(!t.modified_)return t.base_;t.finalized_=!0,n=Fb(e,t.scope_.immer_.useStrictShallowCopy_),r=t.scope_.immer_.shouldUseStrictIteration()}else n=Fb(e,!0);let i=n;return Db(n)&&(n=new Set),yb(i,(e,t)=>{Cb(n,e,wx(t))},r),t&&(t.finalized_=!1),n}function Tx(){ib.push(`Sets cannot have "replace" patches.`,function(e){return`Unsupported patch operation: `+e},function(e){return`Cannot apply patch, path doesn't resolve: `+e},`Patching reserved attributes like __proto__, prototype and constructor is not allowed`);function e(n,r=[]){if(n.key_!==void 0){let e=n.parent_.copy_??n.parent_.base_,t=Mb(Sb(e,n.key_)),i=Sb(e,n.key_);if(i===void 0||i!==n.draft_&&i!==n.base_&&i!==n.copy_||t!=null&&t.base_!==n.base_)return null;let a=n.parent_.type_===3,o;if(a){let e=n.parent_;o=Array.from(e.drafts_.keys()).indexOf(n.key_)}else o=n.key_;if(!(a&&e.size>o||xb(e,o))||n.parent_.type_===1&&n.parent_.allIndicesReassigned_)return null;r.push(o)}if(n.parent_)return e(n.parent_,r);r.reverse();try{t(n.copy_,r)}catch{return null}return r}function t(e,t){let n=e;for(let e=0;e{let u=Sb(o,e,c),f=Sb(s,e,c),p=l?xb(o,e)?n:`add`:r;if(u===f&&p===n)return;let m=t.concat(e);i.push(p===r?{op:p,path:m}:{op:p,path:m,value:d(f)}),a.push(p===`add`?{op:r,path:m}:p===r?{op:`add`,path:m,value:d(u)}:{op:n,path:m,value:d(u)})})}function s(e,t,n,i){let{base_:a,copy_:o}=e,s=0;a.forEach(e=>{if(!o.has(e)){let a=t.concat([s]);n.push({op:r,path:a,value:e}),i.unshift({op:`add`,path:a,value:e})}s++}),s=0,o.forEach(e=>{if(!a.has(e)){let a=t.concat([s]);n.push({op:`add`,path:a,value:e}),i.unshift({op:r,path:a,value:e})}s++})}function c(e,t,r){let{patches_:i,inversePatches_:a}=r;i.push({op:n,path:[],value:t===tb?void 0:t}),a.push({op:n,path:[],value:e})}function l(e,t){return t.forEach(t=>{let{path:i,op:a}=t,o=e;for(let e=0;e[e,u(t)]));if(Db(e))return new Set(Array.from(e).map(u));let t=Object.create(sb(e));for(let n in e)t[n]=u(e[n]);return xb(e,nb)&&(t[nb]=e[nb]),t}function d(e){return mb(e)?u(e):e}Kb(Vb,{applyPatches_:l,generatePatches_:i,generateReplacementPatches_:c,getPath:e})}globalThis.Iterator?.from;var Ex=new xx,Dx=Ex.produce,Ox=Ex.produceWithPatches.bind(Ex),kx=Ex.applyPatches.bind(Ex),Ax=1e3;function jx(e,t){if(e.add(t),e.size>Ax){let t=e.values().next().value;t!==void 0&&e.delete(t)}}function Mx(e){let{enablePatches:t=!1}=e;t&&Tx();let n=Ky(),r=e.initialValue,i=new Set;return{on:n.on,value:()=>r,patch:(e,t=eb())=>{i.has(t)||(Tx(),r=kx(r,e),jx(i,t),n.emit(`updated`,r,void 0,t))},mutate:(e,a=eb())=>{if(!i.has(a)){if(jx(i,a),t){let[t,i]=Ox(r,e);if(t===r)return;r=t,n.emit(`updated`,r,i,a)}else{let t=Dx(r,e);if(t===r)return;r=t,n.emit(`updated`,r,void 0,a)}}},syncIds:i}}var Nx=typeof self==`object`?self:globalThis,Px=new Set([`Error`,`EvalError`,`RangeError`,`ReferenceError`,`SyntaxError`,`TypeError`,`URIError`,`AggregateError`]),Fx=new Set([`Boolean`,`Number`,`String`,`Int8Array`,`Uint8Array`,`Uint8ClampedArray`,`Int16Array`,`Uint16Array`,`Int32Array`,`Uint32Array`,`Float16Array`,`Float32Array`,`Float64Array`,`BigInt64Array`,`BigUint64Array`]);function Ix(e,t){let n=(t,n)=>(e.set(n,t),t),r=i=>{if(e.has(i))return e.get(i);let[a,o]=t[i];switch(a){case 0:case-1:return n(o,i);case 1:{let e=n([],i);for(let t of o)e.push(r(t));return e}case 2:{let e=n({},i);for(let[t,n]of o)e[r(t)]=r(n);return e}case 3:return n(new Date(o),i);case 4:{let{source:e,flags:t}=o;return n(new RegExp(e,t),i)}case 5:{let e=n(new Map,i);for(let[t,n]of o)e.set(r(t),r(n));return e}case 6:{let e=n(new Set,i);for(let t of o)e.add(r(t));return e}case 7:{let{name:e,message:t}=o,r=Px.has(e)?Nx[e]:void 0;return n(new(r??Nx.Error)(t),i)}case 8:return n(BigInt(o),i);case`BigInt`:return n(Object(BigInt(o)),i);case`ArrayBuffer`:return n(new Uint8Array(o).buffer,o);case`DataView`:{let{buffer:e}=new Uint8Array(o);return n(new DataView(e),o)}}if(typeof a==`string`&&Fx.has(a))return n(new Nx[a](o),i);throw TypeError(`unable to deserialize unsafe or unknown type: ${String(a)}`)};return r}function Lx(e){return Ix(new Map,e)(0)}var Rx=``,{toString:zx}={},{keys:Bx}=Object;function Vx(e){let t=typeof e;if(t!==`object`||!e)return[0,t];let n=zx.call(e).slice(8,-1);switch(n){case`Array`:return[1,Rx];case`Object`:return[2,Rx];case`Date`:return[3,Rx];case`RegExp`:return[4,Rx];case`Map`:return[5,Rx];case`Set`:return[6,Rx];case`DataView`:return[1,n]}return n.includes(`Array`)?[1,n]:n.includes(`Error`)?[7,n]:[2,n]}function Hx([e,t]){return e===0&&(t===`function`||t===`symbol`)}function Ux(e,t,n,r){let i=(e,t)=>{let i=r.push(e)-1;return n.set(t,i),i},a=r=>{if(n.has(r))return n.get(r);let[o,s]=Vx(r);switch(o){case 0:{let t=r;switch(s){case`bigint`:o=8,t=r.toString();break;case`function`:case`symbol`:if(e)throw TypeError(`unable to serialize ${s}`);t=null;break;case`undefined`:return i([-1],r)}return i([o,t],r)}case 1:{if(s){let e=r;return s===`DataView`?e=new Uint8Array(r.buffer):s===`ArrayBuffer`&&(e=new Uint8Array(r)),i([s,[...e]],r)}let e=[],t=i([o,e],r);for(let t of r)e.push(a(t));return t}case 2:{if(s)switch(s){case`BigInt`:return i([s,r.toString()],r);case`Boolean`:case`Number`:case`String`:return i([s,r.valueOf()],r)}if(t&&`toJSON`in r)return a(r.toJSON());let n=[],c=i([o,n],r);for(let t of Bx(r))(e||!Hx(Vx(r[t])))&&n.push([a(t),a(r[t])]);return c}case 3:return i([o,r.toISOString()],r);case 4:{let{source:e,flags:t}=r;return i([o,{source:e,flags:t}],r)}case 5:{let t=[],n=i([o,t],r);for(let[n,i]of r)(e||!(Hx(Vx(n))||Hx(Vx(i))))&&t.push([a(n),a(i)]);return n}case 6:{let t=[],n=i([o,t],r);for(let n of r)(e||!Hx(Vx(n)))&&t.push(a(n));return n}}let{message:c}=r;return i([o,{name:s,message:c}],r)};return a}function Wx(e,t={}){let n=[];return Ux(!(t.json||t.lossy),!!t.json,new Map,n)(e),n}var{parse:Gx,stringify:Kx}=JSON,qx={json:!0,lossy:!0};function Jx(e){return Lx(Gx(e))}function Yx(e){return Kx(Wx(e,qx))}function Xx(e){return Lx(e)}function Zx(e){return Yx(e)}function Qx(e){return Jx(e)}var $x=256,eS=class extends Error{name=`StreamClosedError`};function tS(e={}){let t=e.id??eb(),n=Math.max(0,e.replayWindow??0),r=Ky(),i=new AbortController,a=[],o=!1,s=0;function c(e){if(o)throw new eS(`Cannot write to a closed stream "${t}"`);s+=1,n>0&&(a.push({seq:s,chunk:e}),a.length>n&&(a.length-n===1?a.shift():a.splice(0,a.length-n))),r.emit(`chunk`,s,e)}function l(e){if(o)return;o=!0;let t=rS(e);i.abort(e),r.emit(`end`,t)}function u(){o||(o=!0,i.signal.aborted||i.abort(`stream closed`),r.emit(`end`,void 0))}function d(e){o||i.signal.aborted||i.abort(e??`aborted`)}let f=new WritableStream({write(e){c(e)},close(){u()},abort(e){l(e)}});return{id:t,signal:i.signal,get closed(){return o},get lastSeq(){return s},write:c,error:l,close:u,abort:d,writable:f,events:r,buffer:a}}function nS(e={}){let t=e.id??eb(),n=Math.max(1,e.highWaterMark??$x),r=[],i=0,a=!1,o=!1,s,c,l,u;function d(){if(c){if(r.length>0){let e=r.shift(),t=c;c=void 0,t.resolve({value:e,done:!1});return}if(a){let e=c;if(c=void 0,s){let t=Error(s.message);t.name=s.name,e.reject(t)}else e.resolve({value:void 0,done:!0})}}}function f(){if(l){for(;r.length>0;){let e=r.shift();try{l.enqueue(e)}catch{break}}if(a&&l){try{if(s){let e=Error(s.message);e.name=s.name,l.error(e)}else l.close()}catch{}l=void 0}}}function p(t,s){if(!(a||o)&&!(t<=i)){if(i=t,r.push(s),r.length>n){let t=r.length-n;r.splice(0,t),e.onOverflow?.(t)}d(),u&&f()}}function m(e){a||(a=!0,s=e,d(),u&&f())}function ee(){o||a||(o=!0,e.onCancel?.(),m(void 0))}function h(){return u||(u=new ReadableStream({start(e){l=e,f()},cancel(){ee()}}),u)}return{id:t,get cancelled(){return o},get done(){return a},get lastSeenSeq(){return i},get readable(){return h()},cancel:ee,_push:p,_end:m,[Symbol.asyncIterator](){return{next(){if(r.length>0)return Promise.resolve({value:r.shift(),done:!1});if(a){if(s){let e=Error(s.message);return e.name=s.name,Promise.reject(e)}return Promise.resolve({value:void 0,done:!0})}return new Promise((e,t)=>{c={resolve:e,reject:t}})},return(){return ee(),Promise.resolve({value:void 0,done:!0})}}}}}function rS(e){if(e instanceof Error)return{name:e.name||`Error`,message:e.message};if(typeof e==`string`)return{name:`Error`,message:e};try{return{name:`Error`,message:JSON.stringify(e)}}catch{return{name:`Error`,message:String(e)}}}var iS=128;function aS(e){return e.replace(/[^\w-]+/g,`_`).slice(0,iS)}var oS=`modulepreload`,sS=function(e,t){return new URL(e,t).href},cS={},lS=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=sS(t,n),t=s(t),t in cS)return;cS[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:oS,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}).filter(e=>e!==void 0))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},uS=`__connection.json`,dS=`__DEVFRAME_CONNECTION__`,fS=`x-birpc-session`,pS=`__rpc-dump/index.json`,mS=`devframe:services`,hS=`devframe_otp`,gS=`devframe_auth_token`;Cy.postMessage.remoteAssetsError;var _S=class{cacheMap=new Map;options;keySerializer;constructor(e){this.options=e,this.keySerializer=e.keySerializer||(e=>Gy(e))}updateOptions(e){this.options={...this.options,...e}}cached(e,t){let n=this.cacheMap.get(e);if(n)return n.get(this.keySerializer(t))}has(e,t){return this.cacheMap.get(e)?.has(this.keySerializer(t))??!1}apply(e,t){let n=this.cacheMap.get(e.m)||new Map;n.set(this.keySerializer(e.a),t),this.cacheMap.set(e.m,n)}validate(e){return this.options.functions.includes(e)}clear(e){e?this.cacheMap.delete(e):this.cacheMap.clear()}},vS=Nre({docsBase:`https://devfra.me/errors`,codes:{DF0019:{why:e=>`RPC function "${e.name}" has \`agent\` set but \`jsonSerializable\` is \`false\`; MCP requires JSON-serializable data.`,fix:"Remove `jsonSerializable: false`, or remove `agent` to keep it RPC-only."},DF0020:{why:e=>`RPC function "${e.name}" declares \`jsonSerializable: true\` but the value at "${e.path}" is a ${e.type}.`,fix:"Either drop `jsonSerializable: true` (falls back to structured-clone) or change the value to a JSON-safe shape."},DF0021:{why:e=>`RPC function "${e.name}" is already registered`,fix:"Use the `force` parameter to overwrite an existing registration."},DF0022:{why:e=>`RPC function "${e.name}" is not registered. Use register() to add new functions.`},DF0023:{why:e=>`RPC function "${e.name}" is not registered`},DF0024:{why:e=>`Either handler or setup function must be provided for RPC function "${e.name}"`},DF0025:{why:e=>`Function "${e.name}" not found in dump store`},DF0026:{why:e=>`No dump match for "${e.name}" with args: ${e.args}`},DF0027:{why:e=>`Function "${e.name}" with type "${e.type}" cannot have dump configuration. Only "static" and "query" types support dumps.`},DF0028:{why:e=>`Function "${e.name}" with type "${e.type}" cannot use \`snapshot: true\`. Only "query" functions support this sugar; "static" functions have equivalent default behavior already.`,fix:"Remove `snapshot: true`, or change the function type to `query`."},DF0043:{why:e=>`RPC function "${e.name}" received an invalid argument at position ${e.index}: ${e.issues}`,fix:"Pass a value that satisfies the `args` schema declared for this function."},DF0044:{why:e=>`RPC function "${e.name}" returned a value that failed its \`returns\` schema: ${e.issues}`,fix:"Make the handler return a value that satisfies the `returns` schema, or relax the schema."}}});function yS(e){if(e.agent&&e.jsonSerializable===!1)throw vS.DF0019({name:e.name});e.agent&&!e.jsonSerializable&&(e.jsonSerializable=!0)}async function bS(e,t){let n=e[`~standard`].validate(t);return n instanceof Promise?await n:n}function xS(e){return e.map(e=>{let t=e.path?.map(e=>typeof e==`object`?e.key:e).join(`.`);return t?`${t}: ${e.message}`:e.message}).join(`; `)}async function SS(e,t,n){let r=n.slice();if(!t||t.length===0)return r;for(let r=0;r{n.get(t)===r&&n.delete(t)}),n.set(t,r)),await r}if(!e.__promise){let n=Promise.resolve(e.setup(t));n.catch(()=>{e.__promise===n&&(e.__promise=void 0)}),e.__promise=n}return await e.__promise}async function TS(e,t){let n=e.handler;if(!n){let r=await wS(e,t);if(!r.handler)throw vS.DF0024({name:e.name});n=r.handler}let r=e.args,i=e.returns;if(!r&&!i)return n;let a=n;return async(...t)=>{let n=await SS(e.name,r,t),o=await a(...n);return await CS(e.name,i,o)}}var ES=class{context;definitions=new Map;functions;_onChanged=[];constructor(e){this.context=e;let t=this.definitions,n=this;this.functions=new Proxy({},{get(e,r){let i=t.get(r);if(i)return TS(i,n.context)},has(e,n){return t.has(n)},getOwnPropertyDescriptor(e,n){return{value:t.get(n)?.handler,configurable:!0,enumerable:!0}},ownKeys(){return Array.from(t.keys())}})}register(e,t=!1){if(this.definitions.has(e.name)&&!t)throw vS.DF0021({name:e.name});yS(e),this.definitions.set(e.name,e),this._onChanged.forEach(t=>t(e.name))}update(e,t=!1){if(!this.definitions.has(e.name)&&!t)throw vS.DF0022({name:e.name});yS(e),this.definitions.set(e.name,e),this._onChanged.forEach(t=>t(e.name))}onChanged(e){return this._onChanged.push(e),()=>{let t=this._onChanged.indexOf(e);t!==-1&&this._onChanged.splice(t,1)}}async getHandler(e){return await TS(this.definitions.get(e),this.context)}getSchema(e){let t=this.definitions.get(e);if(!t)throw vS.DF0023({name:String(e)});return{args:t.args,returns:t.returns}}has(e){return this.definitions.has(e)}get(e){return this.definitions.get(e)}list(){return Array.from(this.definitions.keys())}};function DS(e,t=``){return JSON.stringify(e,function(e,n){let r=this,i=r==null?n:r[e];if(i===void 0){if(Array.isArray(r))throw kS(t,`undefined`,r,e);return n}return i!==null&&OS(i,r,e,t),n})}function OS(e,t,n,r){if(typeof e==`bigint`)throw kS(r,`BigInt`,t,n);if(typeof e!=`object`)return;if(e instanceof Map)throw kS(r,`Map`,t,n);if(e instanceof Set)throw kS(r,`Set`,t,n);if(e instanceof Date)throw kS(r,`Date`,t,n);if(Array.isArray(e))return;let i=Object.getPrototypeOf(e);if(i!==null&&i!==Object.prototype)throw kS(r,e.constructor?.name??`class instance`,t,n)}function kS(e,t,n,r){let i=AS(n,r);return vS.DF0020({name:e||``,type:t,path:i})}function AS(e,t){return Array.isArray(e)?`[${t}]`:t===``?``:t}var jS=`__DEVFRAME_CONNECTION_META__`,MS=`__DEVFRAME_CONNECTION_AUTH_TOKEN__`;function NS(e){let t=[()=>window?.[e],()=>globalThis?.[e],()=>parent.window?.[e]];for(let e of t)try{let t=e();if(t)return t}catch{}}function PS(){return NS(dS)}function FS(){return NS(jS)}function IS(e){if(e)return e;try{let e=localStorage.getItem(MS);if(e)return e}catch{}return NS(MS)}function LS(e){globalThis[dS]=e,globalThis[jS]={...e.connectionMeta,baseUrl:e.metaBaseUrl},e.authToken&&RS(e.authToken)}function RS(e){try{localStorage.setItem(MS,e)}catch{}globalThis[MS]=e;let t=PS();t&&(globalThis[dS]={...t,authToken:e})}function zS(e){let t=Zy(uS,e);try{return new URL(t,globalThis.location?.href).href}catch{return t}}function BS(e,t){return t&&t!==e.authToken?{...e,authToken:t}:e}function VS(){let e=PS();if(e)return BS(e,IS()??e.authToken??e.connectionMeta.authToken);let t=FS();if(t)return{connectionMeta:t,metaBaseUrl:t.baseUrl??zS(`./`),authToken:IS(t.authToken)}}async function HS(e={}){if(e.connection){let t=BS(e.connection,IS(e.authToken??e.connection.authToken??e.connection.connectionMeta.authToken));return LS(t),t}let t=Array.isArray(e.baseURL)?e.baseURL:[e.baseURL??`./`];if(e.connectionMeta){let n={connectionMeta:e.connectionMeta,metaBaseUrl:zS(t[0]??`./`),authToken:IS(e.authToken??e.connectionMeta.authToken)};return LS(n),n}let n=VS();if(n){let t=BS(n,IS(e.authToken??n.authToken??n.connectionMeta.authToken));return LS(t),t}let r=[];for(let n of t){let t=Zy(uS,n),i=zS(n);try{let n=await fetch(t);if(!n.ok)throw Error(`Failed to fetch connection meta from ${i}: ${n.status}`);let r=await n.json(),a=n.url||i,o={connectionMeta:r,metaBaseUrl:r.baseUrl?new URL(r.baseUrl,a).href:a,authToken:IS(e.authToken??r.authToken)};return LS(o),o}catch(e){r.push(e)}}throw Error(`Failed to get connection meta from ${t.join(`, `)}`,{cause:r})}var US=class extends Error{name=`DevframeConnectionError`;kind;constructor(e,t,n){super(t,n),this.kind=e}};function WS(e=hS){try{let t=globalThis.location?.hash?.replace(/^#/,``)??``;return new URLSearchParams(t).get(e)||void 0}catch{return}}function GS(e){try{let t=new URL(globalThis.location.href),n=new URLSearchParams(t.hash.replace(/^#/,``));if(!n.has(e))return;n.delete(e),t.hash=n.toString(),globalThis.history?.replaceState(globalThis.history.state,``,t.href)}catch{}}function KS(e=hS){let t=WS(e);return t&&GS(e),t}async function qS(e,t={}){let n=KS(t.param??`devframe_otp`);return n?e.isTrusted?!0:e.requestTrustWithCode(n):!1}function JS(e){let t={},n=new WeakMap,r,i=()=>(r??=e.sharedState.get(mS,{initialValue:{}}).then(e=>(t=e.value(),e.on(`updated`,e=>{t=e}),e)),r);return i(),{state:i,has:e=>e in t,keys:()=>Object.keys(t),get:r=>{let i=t[r];if(!i)return;let a=n.get(i);return a||(a={...i,rpc:e.scope(i.scope).rpc},n.set(i,a)),a}}}function YS(e){let t=new Map,n=new Map,r=new Map,i=new Set,a=e.connectionMeta.backend===`static`;function o(e,t){let n=r.get(e);return n&&typeof n==`object`&&!Array.isArray(n)&&typeof t==`object`&&!Array.isArray(t)?{...n,...t}:t}e.client.register({name:Cy.broadcast.clientStateUpdated,type:`event`,handler:(e,n,r)=>{let i=t.get(e);i&&!i.syncIds.has(r)&&i.mutate(()=>o(e,n),r)}}),e.client.register({name:Cy.broadcast.clientStatePatch,type:`event`,handler:(e,n,r)=>{let i=t.get(e);i&&!i.syncIds.has(r)&&i.patch(n,r)}});function s(t,n){let r=[];return r.push(n.on(`updated`,(n,r,i)=>{a||(r?e.callEvent(`devframe:rpc:server-state:patch`,t,r,i):e.callEvent(`devframe:rpc:server-state:set`,t,n,i))})),()=>{for(let e of r)e()}}return{keys:()=>Array.from(t.keys()),onKeyAdded(e){return i.add(e),()=>{i.delete(e)}},delete(e){let i=n.get(e);n.delete(e);let a=t.delete(e);return r.delete(e),i?.(),a},get:async(c,l)=>{if(l?.initialValue!==void 0&&r.set(c,l.initialValue),t.has(c))return t.get(c);let u=Mx({initialValue:l?.initialValue,enablePatches:!1});async function d(){if(a||e.callEvent(`devframe:rpc:server-state:subscribe`,c),l?.initialValue!==void 0){t.set(c,u);for(let e of i)e(c);return e.call(`devframe:rpc:server-state:get`,c).then(e=>{e!==void 0&&u.mutate(()=>o(c,e))}).catch(e=>{console.error(`Error getting server state`,e)}),n.set(c,s(c,u)),u}{let r=await e.call(`devframe:rpc:server-state:get`,c);u.mutate(()=>o(c,r)),t.set(c,u);for(let e of i)e(c);return n.set(c,s(c,u)),u}}return new Promise((t,n)=>{if(e.isTrusted)d().then(t,n);else{t(u);let n=!1;e.events.on(Cy.client.isTrustedUpdated,e=>{e&&!n&&(n=!0,d())})}})}}}var XS=new Map;function ZS(e=XS){let t=new Map;return{serialize:n=>{let r;return n.t===`q`?r=n.m:(r=t.get(n.i),t.delete(n.i)),!(n.t===`s`&&`e`in n)&&r&&e.get(r)?.jsonSerializable===!0?DS(n,r??``):`s:${Zx(n)}`},deserialize:e=>{let n=e.startsWith(`s:`)?Qx(e.slice(2)):JSON.parse(e);return n.t===`q`&&n.i&&n.m&&t.set(n.i,n.m),n}}}function QS(){}function $S(e){let t=e.search(/\n\n|\r\n\r\n/);if(!(t<0))return{frame:e.slice(0,t),rest:e.slice(t+(e[t]===`\r`?4:2))}}function eC(e){let t=`message`,n=[];for(let r of e.split(/\r?\n/))r.startsWith(`:`)||(r.startsWith(`event:`)?t=r.slice(6).trimStart():r.startsWith(`data:`)&&n.push(r.slice(5).replace(/^ /,``)));return{event:t,data:n}}function tC(e){let{onConnected:t=QS,onError:n=QS,onDisconnected:r=QS,definitions:i,fetch:a=globalThis.fetch.bind(globalThis)}=e,o=e.url;e.authToken&&(o=`${o}${o.includes(`?`)?`&`:`?`}${gS}=${encodeURIComponent(e.authToken)}`);let s=ZS(i),c=new AbortController,l=!1,u,d,f,p,m=new Promise((e,t)=>{f=e,p=t});m.catch(()=>{});function ee(e){l||(l=!0,p(e),n(e),r())}function h(){l||(l=!0,p(Error(`Devframe SSE stream closed`)),r())}function g(e,n){if(e===`session`){f(n),t();return}u?.(n)}async function te(e){let t=e.getReader();d=t;let n=new TextDecoder,r=``;for(;;){let{done:e,value:i}=await t.read();if(e)break;for(r+=n.decode(i,{stream:!0});;){let e=$S(r);if(!e)break;r=e.rest;let{event:t,data:n}=eC(e.frame);n.length>0&&g(t,n.join(` -`))}}h()}return(async()=>{try{let e=await a(o,{headers:{accept:`text/event-stream`},signal:c.signal});if(!e.ok||!e.body)throw Error(`Devframe SSE stream request failed: ${e.status}`);await te(e.body)}catch(e){if(c.signal.aborted){h();return}ee(e instanceof Error?e:Error(String(e)))}})(),{close:()=>{l=!0,c.abort(),d?.cancel().catch(()=>{})},on:e=>{u=e},post:async e=>{let t;try{t=await m}catch{return}if(l){n(Error(`Devframe SSE channel is closed; message dropped`));return}try{let n=await a(o,{method:`POST`,headers:{"content-type":`text/plain; charset=utf-8`,[fS]:t},body:e});if(n.status===200){let e=await n.text();e&&u?.(e);return}if(!n.ok)throw Error(`Devframe SSE POST failed: ${n.status}`)}catch(e){n(e instanceof Error?e:Error(String(e)))}},serialize:s.serialize,deserialize:s.deserialize}}function nC(e,t){let{channel:n,rpcOptions:r={}}=t;return Vre(e,{...n,timeout:-1,...r,proxify:!1})}function rC(e){let{transport:t,authToken:n,connectionMeta:r,events:i,clientRpc:a,rpcOptions:o={},callTimeout:s=0}=e,c=!1,l=`connecting`,u=null,d=Promise.withResolvers();function f(e,t=null){if(t?u=t:e===`connected`&&(u=null),e===l)return;let n=l;l=e,i.emit(Cy.client.connectionStatus,e,n)}let p=new Set;function m(e){for(let t of[...p])t.reject(e)}function ee(){return l===`disconnected`||l===`error`?new US(`connection`,`[devframe] Not connected to the devframe server`,{cause:u??void 0}):l===`unauthorized`?new US(`auth`,`[devframe] Not authorized by the devframe server`,{cause:u??void 0}):null}function h(e,t){return new Promise((n,r)=>{let a=!1,o,c={reject(e){a||(l(),i.emit(Cy.client.error,e,t),r(e))}};function l(){a=!0,p.delete(c),o&&clearTimeout(o)}p.add(c),s>0&&(o=setTimeout(()=>{c.reject(new US(`timeout`,`[devframe] RPC call "${t}" timed out after ${s}ms`))},s)),e.then(e=>{a||(l(),n(e))},e=>{if(a)return;l();let n=e instanceof Error?e:Error(String(e));i.emit(Cy.client.error,n,t),r(n)})})}let g=new Map;for(let e of r.jsonSerializableMethods??[])g.set(e,{jsonSerializable:!0});let te=e.createChannel({definitions:g,onError(e){f(`error`,e),i.emit(Cy.client.connectionError,e),m(new US(`connection`,`[devframe] Connection to the devframe server failed`,{cause:e}))},onDisconnected(){l!==`error`&&f(`disconnected`),m(new US(`connection`,`[devframe] Disconnected from the devframe server`,{cause:u??void 0}))}}),ne=nC(a.functions,{channel:te,rpcOptions:o});a.register({name:Cy.broadcast.authRevoked,type:`event`,handler:()=>{c=!1;let e=new US(`auth`,`[devframe] The devframe server revoked this client's trust`);f(`unauthorized`,e),i.emit(Cy.client.connectionError,e),m(e),i.emit(Cy.client.isTrustedUpdated,!1)}});let re=n;async function ie(e){re=e;let t=await ne.$call(`anonymous:devframe:auth`,{authToken:e,ua:navigator.userAgent,origin:location.origin});if(c=t.isTrusted,c)d.resolve(!0),f(`connected`);else{let e=new US(`auth`,`[devframe] The devframe server refused this client's credentials`);f(`unauthorized`,e),i.emit(Cy.client.connectionError,e)}return i.emit(Cy.client.isTrustedUpdated,c),t.isTrusted}async function ae(e){let t=(await ne.$call(`anonymous:devframe:auth:exchange`,{code:e,ua:navigator.userAgent,origin:location.origin}))?.authToken??null;return t&&(re=t,c=!0,d.resolve(!0),f(`connected`),i.emit(Cy.client.isTrustedUpdated,!0)),t}async function oe(e={}){await ne.$call(`anonymous:devframe:auth:request-code`,{ua:navigator.userAgent,origin:location.origin,...e.reissue?{reissue:!0}:{}})}async function se(){return c?!0:ie(re??``)}async function ce(e=6e4){if(c&&d.resolve(!0),e<=0)return d.promise;let t;try{return await Promise.race([d.promise,new Promise((n,r)=>{t=setTimeout(()=>{r(Error(`[devframe] Timeout waiting for rpc to be trusted`))},e)})]),c}finally{clearTimeout(t)}}return{transport:t,get isTrusted(){return c},get status(){return l},get connectionError(){return u},requestTrust:se,requestTrustWithToken:ie,requestTrustWithCode:ae,requestAuthCode:oe,ensureTrusted:ce,call:(...e)=>{let t=String(e[0]),n=ee();return n?(i.emit(Cy.client.error,n,t),Promise.reject(n)):h(ne.$call(...e),t)},callEvent:(...e)=>{let t=ee();if(t){i.emit(Cy.client.error,t,String(e[0]));return}return ne.$callEvent(...e)},callOptional:(...e)=>{let t=String(e[0]),n=ee();return n?(i.emit(Cy.client.error,n,t),Promise.reject(n)):h(ne.$callOptional(...e),t)},close:()=>{te.close()}}}function iC(e,t,n){let r=(()=>{try{return new URL(t,n.href)}catch{return new URL(n.href)}})();if(e&&typeof e==`object`){if(e.host!=null||e.port!=null){let t=e.host??`${r.hostname}:${e.port}`;return new URL(e.path??`/`,`${r.protocol}//${t}`).href}return new URL(e.path??``,r).href}let i=e??``;return/^https?:\/\//i.test(i)?i:new URL(i,r).href}function aC(e){let{authToken:t,connectionMeta:n,metaBaseUrl:r,events:i,clientRpc:a,rpcOptions:o={},sseOptions:s={},callTimeout:c=0}=e,l=iC(n.sse,r??`./`,location);return rC({transport:`sse`,authToken:t,connectionMeta:n,events:i,clientRpc:a,rpcOptions:o,callTimeout:c,createChannel:e=>tC({url:l,authToken:t,definitions:e.definitions,...s,onConnected(){s.onConnected?.()},onError(t){e.onError(t),s.onError?.(t)},onDisconnected(){e.onDisconnected(),s.onDisconnected?.()}})})}function oC(e){let{name:t,message:n,cause:r,...i}=e,a=r instanceof Error?r:sC(r)?oC(r):r,o=a===void 0?Error(n):Error(n,{cause:a});return o.name=t,Object.assign(o,i),o}function sC(e){return typeof e==`object`&&!!e&&typeof e.message==`string`&&typeof e.name==`string`}function cC(e){return typeof e==`object`&&!!e&&e.type===`static`&&typeof e.path==`string`}function lC(e){return typeof e==`object`&&!!e&&e.type===`query`&&typeof e.records==`object`&&e.records!==null}function uC(e){return typeof e==`object`&&!!e&&(`output`in e||`error`in e)}function dC(e){if(e.error)throw oC(e.error);return e.output}function fC(e){return e.some(e=>e!=null)}function pC(e){return typeof e==`object`&&e&&`serialization`in e&&`data`in e?e.data:e}function mC(e,t){let n=new Map,r=new Map;function i(e,t){return t===`structured-clone`&&Array.isArray(e)?Xx(e):e}function a(e,t){return i(pC(e),t)}async function o(e){n.has(e.path)||n.set(e.path,t(e.path).then(t=>a(t,e.serialization)));let r=await n.get(e.path);return uC(r)?dC(r):r}async function s(e,n){return r.has(e)||r.set(e,t(e).then(e=>a(e,n))),await r.get(e)}async function c(t,n){if(!(t in e))throw Error(`[devframe-rpc] Function "${t}" not found in dump store`);let r=e[t];if(cC(r)){if(fC(n))throw Error(`[devframe-rpc] No dump match for "${t}" with args: ${JSON.stringify(n)}`);return await o(r)}if(lC(r)){let e=Gy(n),i=r.records[e];if(i)return dC(await s(i,r.serialization));if(r.fallback)return dC(await s(r.fallback,r.serialization));throw Error(`[devframe-rpc] No dump match for "${t}" with args: ${JSON.stringify(n)}`)}if(!fC(n))return r;throw Error(`[devframe-rpc] No dump match for "${t}" with args: ${JSON.stringify(n)}`)}return{call:async(e,t)=>await c(e,t),callOptional:async(t,n)=>{if(t in e)return await c(t,n)},callEvent:async(e,t)=>{}}}async function hC(e){let t=mC(await e.fetchJsonFromBases(pS),e.fetchJsonFromBases);return{transport:`static`,isTrusted:!0,status:`connected`,connectionError:null,requestTrust:async()=>!0,requestTrustWithToken:async()=>!0,requestTrustWithCode:async()=>null,requestAuthCode:async()=>{},ensureTrusted:async()=>!0,call:(...e)=>t.call(e[0],e.slice(1)),callEvent:(...e)=>t.callEvent(e[0],e.slice(1)),callOptional:(...e)=>t.callOptional(e[0],e.slice(1)),close:()=>{}}}var gC=``;function _C(e,t){return`${e}${gC}${t}`}function vC(e){let t=new Map,n=new Map;e.client.register({name:Cy.broadcast.streamingChunk,type:`event`,handler(e,n,r,i){t.get(_C(e,n))?._push(r,i)}}),e.client.register({name:Cy.broadcast.streamingEnd,type:`event`,handler(e,n,r){let i=_C(e,n),a=t.get(i);a&&(a._end(r),t.delete(i))}}),e.client.register({name:Cy.broadcast.streamingUploadCancel,type:`event`,handler(e,t){let r=_C(e,t),i=n.get(r);i&&(i.abort(`server cancelled upload`),n.delete(r))}}),e.events.on(Cy.client.isTrustedUpdated,n=>{if(n)for(let[n,r]of t){if(r.cancelled||r.done)continue;let t=n.indexOf(gC);if(t<0)continue;let i=n.slice(0,t),a=n.slice(t+1);e.callEvent(`devframe:streaming:subscribe`,i,a,{afterSeq:r.lastSeenSeq})}});function r(n,r,i={}){let a=_C(n,r),o=t.get(a);if(o)return o;let s=nS({id:r,highWaterMark:i.highWaterMark,onOverflow(e){console.warn(`[devframe] DF0029: Stream "${n}#${r}" dropped ${e} chunk(s) after exceeding the client high-water mark.`)},onCancel(){e.callEvent(`devframe:streaming:cancel`,n,r),t.delete(a)}});if(t.set(a,s),e.isTrusted)e.callEvent(`devframe:streaming:subscribe`,n,r,{afterSeq:0});else{let i=e.events.on(Cy.client.isTrustedUpdated,o=>{o&&(i(),t.has(a)&&!s.cancelled&&!s.done&&e.callEvent(`devframe:streaming:subscribe`,n,r,{afterSeq:s.lastSeenSeq}))})}return s}function i(t,r){let i=_C(t,r),a=n.get(i);if(a)return a;let o=tS({id:r});return o.events.on(`chunk`,(n,i)=>{e.callEvent(`devframe:streaming:upload-chunk`,t,r,n,i)}),o.events.on(`end`,a=>{e.callEvent(`devframe:streaming:upload-end`,t,r,a),n.delete(i)}),n.set(i,o),o}return{subscribe:r,upload:i}}function yC(){}var bC=new Map;function xC(e){let t=e.url;e.authToken&&(t=`${t}?${gS}=${encodeURIComponent(e.authToken)}`);let n=new WebSocket(t),{onConnected:r=yC,onError:i=yC,onDisconnected:a=yC,definitions:o=bC}=e;n.addEventListener(`open`,e=>{r(e)}),n.addEventListener(`error`,e=>{let t=e instanceof Error?e:Error(e.type);i(t)}),n.addEventListener(`close`,e=>{a(e)});let s=ZS(o);return{close:()=>{n.close()},on:e=>{n.addEventListener(`message`,t=>{e(t.data)})},post:e=>{if(n.readyState===WebSocket.OPEN){n.send(e);return}if(n.readyState===WebSocket.CONNECTING){let t=()=>{i(),n.readyState===WebSocket.OPEN&&n.send(e)},r=()=>i();function i(){n.removeEventListener(`open`,t),n.removeEventListener(`close`,r)}n.addEventListener(`open`,t),n.addEventListener(`close`,r);return}i(Error(`Devframe WebSocket is not open; message dropped`))},serialize:s.serialize,deserialize:s.deserialize}}function SC(e,t,n){let r=(()=>{try{return new URL(t,n.href)}catch{return new URL(n.href)}})(),i=r.protocol===`https:`?`wss:`:`ws:`;if(e&&typeof e==`object`){if(e.host!=null||e.port!=null){let t=e.host??`${r.hostname}:${e.port}`,n=new URL(e.path??`/`,`${i}//${t}`);return n.protocol=i,n.href}let t=new URL(e.path??``,r);return t.protocol=i,t.href}if(typeof e==`number`)return`${i}//${r.hostname}:${e}`;let a=e??``;if(/^wss?:\/\//i.test(a))return a;if(/^https?:\/\//i.test(a))return Qy(a,/^https/i.test(a)?`wss://`:`ws://`);let o=new URL(a,r);return o.protocol=i,o.href}function CC(e){let{authToken:t,connectionMeta:n,metaBaseUrl:r,events:i,clientRpc:a,rpcOptions:o={},wsOptions:s={},callTimeout:c=0}=e,l=SC(n.websocket,r??`./`,location);return rC({transport:`websocket`,authToken:t,connectionMeta:n,events:i,clientRpc:a,rpcOptions:o,callTimeout:c,createChannel:e=>xC({url:l,authToken:t,definitions:e.definitions,...s,onConnected(e){s.onConnected?.(e)},onError(t){e.onError(t),s.onError?.(t)},onDisconnected(t){e.onDisconnected(),s.onDisconnected?.(t)}})})}function wC(e){return e.includes(`:`)}function TC(e,t){return wC(t)?t:`${e}:${t}`}function EC(e){return{async get(t){return(await e()).value()?.[t]},async set(t,n){(await e()).mutate(e=>{if(e===void 0)return{[t]:n};e[t]=n})},async delete(t){(await e()).mutate(e=>{e!==void 0&&delete e[t]})},async all(){return(await e()).value()??{}},async onChange(t){return(await e()).on(`updated`,e=>t(e??{}))}}}function DC(e,t,n){let r=`devframe:settings:${n}:${t}`,i;function a(){return i||=(async()=>(await e.ensureTrusted(),e.sharedState.get(r)))().catch(e=>{throw i=void 0,e}),i}return EC(a)}function OC(e,t){return{global:DC(e,t,`global`),project:DC(e,t,`project`)}}function kC(e,t){return{namespace:t,base:e,rpc:{namespace:t,register(n){if(wC(n.name))throw Error(`[devframe] Scoped client RPC registration for namespace "${t}" received an already-namespaced function name "${n.name}". Pass a bare name without a ":" separator.`);e.client.register({...n,name:`${t}:${n.name}`})},call:((n,...r)=>e.call(TC(t,n),...r)),callEvent:((n,...r)=>e.callEvent(TC(t,n),...r)),callOptional:((n,...r)=>e.callOptional(TC(t,n),...r)),sharedState:((n,r)=>e.sharedState.get(TC(t,n),r)),streaming:{subscribe:(n,r,i)=>e.streaming.subscribe(TC(t,n),r,i),upload:(n,r)=>e.streaming.upload(TC(t,n),r)}},settings:OC(e,t),scope:e.scope}}function AC(){if(typeof document<`u`){let e=document.modelContext;if(e)return e}if(typeof navigator<`u`){let e=navigator.modelContext;if(e)return e}}function jC(e,t={}){let n=t.modelContext??AC();if(!n)return()=>{};let r=n,i=new Map,a=new Map;function o(t,n){let o=aS(t.name),s=a.get(o);if(s&&s!==t.name){console.warn(`[devframe] WebMCP tool name "${o}" (from "${t.name}") collides with "${s}"; keeping the first registration.`);return}let c=new AbortController,l=Ey(t.type,n),u=r.registerTool({name:o,description:n.description,inputSchema:ky(t.args),annotations:{title:n.title??t.name,readOnlyHint:l===`read`,destructiveHint:l===`destructive`},execute:n=>MC(t,e.context,n)},{signal:c.signal});u&&`then`in u&&u.then(()=>{},()=>{}),a.set(o,t.name),i.set(t.name,()=>{c.abort(),u&&`unregister`in u&&typeof u.unregister==`function`&&u.unregister(),a.delete(o)})}function s(t){let n=t?[t]:[...e.definitions.keys()];for(let t of n){i.get(t)?.(),i.delete(t);let n=e.definitions.get(t),r=n?.agent;n&&r&&o(n,r)}}s();let c=e.onChanged(e=>s(e));return()=>{c();for(let e of i.values())e();i.clear()}}async function MC(e,t,n){try{let r=jy(n,e.args?.length);return{content:[{type:`text`,text:NC(await(await TS(e,t))(...r))}]}}catch(e){return{isError:!0,content:[{type:`text`,text:PC(e)}]}}}function NC(e){return e===void 0?`undefined`:typeof e==`string`?e:JSON.stringify(e,null,2)}function PC(e){if(!(e instanceof Error))return String(e);let t=e.cause instanceof Error?` (cause: ${e.cause.message})`:``;return`${e.name}: ${e.message}${t}`}function FC(e,t){if(t.backend===`static`)return`static`;let n=t.websocket!==void 0,r=t.sse!==void 0;if(e===`websocket`){if(!n)throw Error(`[devframe] transport: 'websocket' was requested, but this server does not advertise a WebSocket endpoint`);return`websocket`}if(e===`sse`){if(!r)throw Error(`[devframe] transport: 'sse' was requested, but this server does not advertise an SSE endpoint`);return`sse`}if(t.backend===`sse`&&r)return`sse`;if(n)return`websocket`;if(r)return`sse`;throw Error(`[devframe] This server advertises no RPC transport (backend "none"), so there is nothing to connect to. Enable the WebSocket or SSE endpoint on the server, or use its static/MCP surfaces instead.`)}async function IC(e={}){let{baseURL:t=`./`,rpcOptions:n={},cacheOptions:r=!1}=e,i=Ky(),a=Array.isArray(t)?t:[t],o=await HS(e),{connectionMeta:s,metaBaseUrl:c,authToken:l}=o,u=a[0]??`./`;try{u=new URL(`.`,c).href}catch{}let d=new _S({functions:[],...typeof e.cacheOptions==`object`?e.cacheOptions:{}}),f={rpc:void 0},p=new ES(f),m=e.webmcp===!1?void 0:jC(p),ee,h=!1,g=0,te;function ne(){g++,te=void 0,d.clear(),r===!0&&d.updateOptions({functions:[]})}r&&(p.register({name:Cy.broadcast.cacheInvalidate,type:`event`,handler:ne}),i.on(Cy.client.connectionStatus,ne),i.on(Cy.client.isTrustedUpdated,ne));async function re(e){let t=[u,...a.filter(e=>e!==u)].filter(e=>e!=null),n=[];for(let r of t)try{return await fetch(Zy(e,r)).then(t=>{if(!t.ok)throw Error(`Failed to fetch ${e} from ${r}: ${t.status}`);return t.json()})}catch(e){n.push(e)}throw Error(`Failed to load ${e} from ${t.join(`, `)}`,{cause:n})}let ie=e.callTimeout??0,ae=ie>0?ie:1/0,oe,se={authToken:l,connectionMeta:s,metaBaseUrl:c,events:i,clientRpc:p,callTimeout:e.callTimeout,rpcOptions:{...n,async onRequest(e,t,i){let a=performance.now()+ae;if(await n.onRequest?.call(this,e,t,i),r===!0&&e.i&&oe.isTrusted&&!e.m.startsWith(`anonymous:`)&&e.m!==`devframe:rpc:cacheable-functions`){function t(){if(performance.now()>=a)throw new US(`timeout`,`[devframe] RPC call "${e.m}" timed out after ${ie}ms`)}for(;!te&&(t(),!(h||oe.status!==`connected`));){let e=g;te=oe.callOptional(`devframe:rpc:cacheable-functions`).then(t=>{e===g&&d.updateOptions({functions:t??[]})}).catch(t=>{throw e===g&&(te=void 0),t}),await te}await te,t()}let o=g;if(r&&e.i&&!h&&oe.isTrusted&&d.validate(e.m)){if(d.has(e.m,e.a))return i(d.cached(e.m,e.a));let n=await t(e);o===g&&!h&&d.apply(e,n)}else await t(e)}}},ce=FC(e.transport??`auto`,s);oe=ce===`static`?await hC({fetchJsonFromBases:re}):ce===`sse`?aC({...se,sseOptions:e.sseOptions}):CC({...se,wsOptions:e.wsOptions});let le;try{le=new BroadcastChannel(`devframe-auth`)}catch{}let ue,de=!1;function fe(e){return((...t)=>de||!ue?e(...t):ue.then(()=>e(...t)))}function pe(){h=!0,ne();try{ee?.(),m?.()}finally{try{le?.close()}finally{oe.close?.()}}}let me={events:i,get isTrusted(){return oe.isTrusted},get status(){return oe.status},get connectionError(){return oe.connectionError},get transport(){return oe.transport??ce},get connection(){return o},connectionMeta:s,ensureTrusted:oe.ensureTrusted,requestTrust:oe.requestTrust,requestTrustWithToken:async e=>(RS(e),o={...o,authToken:e},oe.requestTrustWithToken(e)),requestTrustWithCode:async e=>{let t=await oe.requestTrustWithCode(e);if(!t)return!1;RS(t),o={...o,authToken:t};try{le?.postMessage({type:`auth-update`,authToken:t})}catch{}return!0},requestAuthCode:e=>oe.requestAuthCode(e),call:fe(oe.call),callEvent:fe(oe.callEvent),callOptional:fe(oe.callOptional),client:p,sharedState:void 0,services:void 0,streaming:void 0,cacheManager:d,scope:void 0,close:pe};me.sharedState=YS(me),me.streaming=vC(me),me.services=JS(me);let he=new Map;me.scope=(e=>{if(!e)return me;let t=he.get(e);return t||(t=kC(me,e),he.set(e,t)),t}),f.rpc=me;function ge(){try{return typeof window<`u`&&window.self===window.top}catch{return!1}}async function _e(){if(e.simpleAuth!==!1&&ge()&&typeof globalThis.prompt==`function`)for(await me.requestAuthCode().catch(()=>{});!me.isTrusted;){let e=globalThis.prompt(`devframe: enter the authentication code shown in your terminal`);if(e==null)return;let t=e.trim();if(t&&await me.requestTrustWithCode(t))return}}async function ve(){let t=await oe.requestTrust(),n=e.otpParam??`devframe_otp`,r=n?await qS(me,{param:n}):!1;t||r||me.isTrusted||await _e()}return ue=ve().then(()=>{de=!0},()=>{de=!0}),s.mcp&&lS(async()=>{let{setupBrowserAgentRpcBridge:e}=await import(`./browser-agent-rpc-KH4HHvHf-DF8dhgzY.js`);return{setupBrowserAgentRpcBridge:e}},[],import.meta.url).then(({setupBrowserAgentRpcBridge:e})=>{h||(ee=e(me))}).catch(()=>{}),le&&(le.onmessage=e=>{e.data?.type===`auth-update`&&e.data.authToken&&me.requestTrustWithToken(e.data.authToken)}),me}var LC=IC,RC=[`components`,`injectors`,`signals`,`ngrx`,`tanstackQuery`,`forms`,`router`,`pipes`,`http`,`analog`,`capacitor`,`errors`,`plugins`,`performance`,`build`],zC=[`angular`,`ngrx`,`analog`,`angular-native`,`nativescript`,`capacitor`];function BC(e){return`pangular:${e}`}var VC=[`forms`,`router`,`ngrx`,`http`,`analog`,`components`,`tanstackQuery`,`plugins`],HC={refreshMs:{default:3e3,min:500,max:8e3},navigations:{default:50,min:5,max:500},formTimeline:{default:200,min:10,max:2e3},httpCalls:{default:200,min:10,max:2e3},changeLog:{default:200,min:10,max:2e3},cdCycles:{default:200,min:10,max:2e3},cdSlowMs:{default:16,min:1,max:1e3},capacitorCalls:{default:200,min:10,max:2e3},idleAfterMs:{default:6e4,min:0,max:6e5}},UC={"opted-out":`the request sets transferCache: false`,post:`POST requests are left out unless includePostRequests is set`,method:`only GET, HEAD and POST requests are cached`,"auth-headers":`it sends an Authorization, Proxy-Authorization or Cookie header`,credentials:`it sends credentials (withCredentials or credentials: include/same-origin)`,"no-cache-request":`the request asks for no-store, no-cache or private`,"no-cache-response":`the response has Cache-Control no-store, no-cache or private`,"set-cookie":`the response sets a cookie`,error:`failed responses are not stored`,mocked:`a fault rule answered before the transfer cache ran`,filter:`the transferCache filter passed to withPangular() left it out`,"cache-off-or-filter":`the transfer cache is off, or its filter (or another withHttpTransferCacheOptions setting) left it out`},WC=[[0,`Network error (offline)`],[200,`OK`],[201,`Created`],[202,`Accepted`],[203,`Non-Authoritative Information`],[204,`No Content`],[205,`Reset Content`],[206,`Partial Content`],[207,`Multi-Status`],[208,`Already Reported`],[226,`IM Used`],[300,`Multiple Choices`],[301,`Moved Permanently`],[302,`Found`],[303,`See Other`],[304,`Not Modified`],[307,`Temporary Redirect`],[308,`Permanent Redirect`],[400,`Bad Request`],[401,`Unauthorized`],[402,`Payment Required`],[403,`Forbidden`],[404,`Not Found`],[405,`Method Not Allowed`],[406,`Not Acceptable`],[407,`Proxy Authentication Required`],[408,`Request Timeout`],[409,`Conflict`],[410,`Gone`],[411,`Length Required`],[412,`Precondition Failed`],[413,`Content Too Large`],[414,`URI Too Long`],[415,`Unsupported Media Type`],[416,`Range Not Satisfiable`],[417,`Expectation Failed`],[418,`I'm a Teapot`],[421,`Misdirected Request`],[422,`Unprocessable Content`],[423,`Locked`],[424,`Failed Dependency`],[425,`Too Early`],[426,`Upgrade Required`],[428,`Precondition Required`],[429,`Too Many Requests`],[431,`Request Header Fields Too Large`],[451,`Unavailable For Legal Reasons`],[499,`Client Closed Request`],[500,`Internal Server Error`],[501,`Not Implemented`],[502,`Bad Gateway`],[503,`Service Unavailable`],[504,`Gateway Timeout`],[505,`HTTP Version Not Supported`],[506,`Variant Also Negotiates`],[507,`Insufficient Storage`],[508,`Loop Detected`],[510,`Not Extended`],[511,`Network Authentication Required`],[520,`Web Server Returned an Unknown Error`],[521,`Web Server Is Down`],[522,`Connection Timed Out`],[523,`Origin Is Unreachable`],[524,`A Timeout Occurred`]];function GC(e){return WC.some(([t])=>t===e)}var KC=/^[a-z][a-z0-9-]{0,39}$/;function qC(e,t){let n=t.plugins?.[e],r=t.inspectors.plugins&&KC.test(e)&&(n?.enabled??!0);return{enabled:r,tools:r&&t.agent.tools.plugins&&(n?.tools??!0),actions:r&&t.actions.plugins&&(n?.actions??!0)}}var JC=`pangular`,YC={mod:`Mod`,ctrl:`Ctrl`,control:`Ctrl`,meta:`Meta`,cmd:`Meta`,command:`Meta`,alt:`Alt`,option:`Alt`,opt:`Alt`,shift:`Shift`},XC=[`Mod`,`Ctrl`,`Meta`,`Alt`,`Shift`];function ZC(e){if(typeof e!=`string`||e.length>40)return;let t=e.split(`+`).map(e=>e.trim());if(t.some(e=>!e))return;let n=new Set,r;for(let e of t){let t=YC[e.toLowerCase()];if(t){if(n.has(t))return;n.add(t);continue}if(r!==void 0)return;if(/^[a-z0-9]$/i.test(e))r=e.toUpperCase();else if(/^f([1-9]|1[0-2])$/i.test(e))r=e.toUpperCase();else return}if(r&&[...n].some(e=>e!==`Shift`)&&!(n.has(`Mod`)&&(n.has(`Ctrl`)||n.has(`Meta`))))return[...XC.filter(e=>n.has(e)),r].join(`+`)}function QC(e,t,n){let r=t.split(`+`),i=r.pop(),a=new Set(r),o=a.has(`Ctrl`)||a.has(`Mod`)&&!n,s=a.has(`Meta`)||a.has(`Mod`)&&n;if(e.ctrlKey!==o||e.metaKey!==s||e.altKey!==a.has(`Alt`)||e.shiftKey!==a.has(`Shift`))return!1;if(/^F\d+$/.test(i))return e.key.toUpperCase()===i;let c=/^\d$/.test(i)?`Digit${i}`:`Key${i}`;return e.code?e.code===c:e.key.toUpperCase()===i}function $C(e,t){if(!t)return e.replace(/^Mod\b/,`Ctrl`);let n={Mod:`⌘`,Meta:`⌘`,Ctrl:`⌃`,Alt:`⌥`,Shift:`⇧`};return e.split(`+`).map(e=>n[e]??e).join(``)}var ew=[{id:`dashboard`,label:`Dashboard`},{id:`timeline`,label:`Timeline`},{id:`audit`,label:`Audit`},{id:`analog`,label:`Analog`,inspector:`analog`},{id:`components`,label:`Components`,inspector:`components`},{id:`routes`,label:`Routes`,inspector:`router`},{id:`signals`,label:`Signals`,inspector:`signals`},{id:`injectors`,label:`Injectors`,inspector:`injectors`},{id:`store`,label:`Store`,inspector:`ngrx`},{id:`queries`,label:`Queries`,inspector:`tanstackQuery`},{id:`forms`,label:`Forms`,inspector:`forms`},{id:`pipes`,label:`Pipes`,inspector:`pipes`},{id:`network`,label:`SSR & HTTP`,inspector:`http`},{id:`errors`,label:`Errors`,inspector:`errors`},{id:`performance`,label:`Performance`,inspector:`performance`},{id:`build`,label:`Build`,inspector:`build`},{id:`capacitor`,label:`Capacitor`,inspector:`capacitor`},{id:`plugins`,label:`Plugins`,inspector:`plugins`}],tw=1e4;function nw(e){return ew.filter(t=>!(`inspector`in t)||e.inspectors[t.inspector])}var rw={components:`a component instance, by its id in the component tree`,injectors:`a component's element injector, by the component's id`,signals:`a component's signal graph, by the component's id`,forms:`a form, by its id`},iw=200;function aw(e){let t=new URLSearchParams({tab:e.tab});return e.id&&t.set(`id`,e.id),e.page&&t.set(`page`,e.page),`#${t.toString()}`}function ow(e){let t=new URLSearchParams(e.replace(/^#/,``)),n=e=>{let n=t.get(e)?.trim();return n&&n.length<=iw?n:void 0},r=n(`tab`);if(!r)return null;let i=n(`id`),a=n(`page`);return{tab:r,...i?{id:i}:{},...a?{page:a}:{}}}var sw=[{id:`settings`,title:`Open settings`,group:`Go to`,keywords:`preferences tabs`},{id:`pick-component`,title:`Pick component on page`,group:`Actions`,keywords:`select inspect element`,inspector:`components`},{id:`cd-record-start`,title:`Start recording change detection`,group:`Actions`,keywords:`profiler cycles`,inspector:`components`},{id:`cd-record-stop`,title:`Stop recording change detection`,group:`Actions`,keywords:`profiler cycles`,inspector:`components`},{id:`cd-clear`,title:`Clear change detection recording`,group:`Actions`,keywords:`reset recordings cycles`,inspector:`components`},{id:`theme:system`,title:`Theme: follow the system`,group:`Theme`,keywords:`auto`},{id:`theme:light`,title:`Theme: light`,group:`Theme`},{id:`theme:dark`,title:`Theme: dark`,group:`Theme`},{id:`dock:float`,title:`Popup: float`,group:`Popup`,keywords:`dock`,popupOnly:!0},{id:`dock:bottom`,title:`Popup: dock to the bottom`,group:`Popup`,keywords:`dock`,popupOnly:!0},{id:`dock:right`,title:`Popup: dock to the right`,group:`Popup`,keywords:`dock`,popupOnly:!0},{id:`dock:reset-size`,title:`Popup: reset the docked size`,group:`Popup`,keywords:`splitter resize height width`,popupOnly:!0},{id:`popout`,title:`Popup: open in its own window`,group:`Popup`,keywords:`pop out detach new window`,popupOnly:!0},{id:`popout:return`,title:`Popup: back into the page`,group:`Popup`,keywords:`pop in attach dock window`,popupOnly:!0}];function cw(e){let t=nw(e).map(e=>({id:`tab:${e.id}`,title:`Go to ${e.label}`,group:`Go to`,keywords:`tab open`})),n=sw.filter(t=>!t.inspector||e.inspectors[t.inspector]).map(({inspector:e,...t})=>t);return[...t,...n]}function lw(e){return e&&typeof e==`object`&&!Array.isArray(e)?e:{}}var uw=Object.freeze({minLength:24,threshold:4.5}),dw={min:16,max:512},fw={min:2,max:6};function pw(e){return typeof e==`number`&&Number.isInteger(e)&&e>=dw.min&&e<=dw.max}function mw(e){return typeof e==`number`&&Number.isFinite(e)&&e>=fw.min&&e<=fw.max}function hw(e){if(e===!0)return{...uw};if(!e||typeof e!=`object`||Array.isArray(e))return;let{minLength:t,threshold:n}=e;return{minLength:pw(t)?t:uw.minLength,threshold:mw(n)?n:uw.threshold}}function gw(e){let t=hw(e);return t?{entropy:t}:{}}function _w(e,t){return typeof e==`boolean`?e:t}var vw=100,yw=100;function bw(e){if(!Array.isArray(e))return[];let t=e.filter(e=>typeof e==`string`).map(e=>e.trim()).filter(e=>e&&e.length<=yw);return[...new Set(t)].slice(0,vw)}function xw(e,t){let{default:n,min:r,max:i}=HC[t];return typeof e!=`number`||!Number.isFinite(e)?n:Math.min(i,Math.max(r,Math.round(e)))}function Sw(e,t){return Object.fromEntries(e.map(e=>[e,t(e)]))}function Cw(e){let t=lw(e),n=lw(t.agent),r=lw(t.inspectors),i=lw(n.tools),a=lw(t.actions),o=_w(t.actions,!0),s=lw(t.redaction),c=lw(t.limits),l=Sw(RC,e=>_w(r[e],!0));return{inspectors:l,agent:{readOnly:_w(n.readOnly,!1),tools:Sw(RC,e=>l[e]&&_w(i[e],!0)),browserTools:_w(n.browserTools,!1)},actions:Sw(VC,e=>l[e]&&_w(a[e],o)),redaction:{secretNames:bw(s.secretNames),unmask:bw(s.unmask),...gw(s.entropy)},limits:Object.fromEntries(Object.keys(HC).map(e=>[e,xw(c[e],e)])),popup:{hotkey:Aw(lw(t.popup).hotkey)},plugins:kw(t.plugins),lint:{rules:Dw(t.lint)},analog:Tw(t.analog),ngrx:{trace:_w(lw(t.ngrx).trace,!1)}}}var ww=500;function Tw(e){let t=lw(e).contentSchema;return typeof t==`string`&&t.trim()&&t.length<=ww?{contentSchema:t.trim()}:{}}var Ew=200;function Dw(e){let t=lw(lw(e).rules),n={};for(let[e,r]of Object.entries(t).slice(0,Ew))typeof r==`boolean`&&(n[e]=r);return n}var Ow=100;function kw(e){let t={};for(let[n,r]of Object.entries(lw(e)).slice(0,Ow)){if(!KC.test(n))continue;if(typeof r==`boolean`){t[n]={enabled:r,tools:!0,actions:!0};continue}let e=lw(r);r!==void 0&&Object.keys(e).length&&(t[n]={enabled:_w(e.enabled,!0),tools:_w(e.tools,!0),actions:_w(e.actions,!0)})}return t}function Aw(e){return e===!1?!1:ZC(e)??`Alt+Shift+P`}function jw(e){let t=e?.configs;return Cw(t?.[JC])}function Mw(e){return`${{forms:`Writing to forms`,router:`Navigating and clearing navigations`,ngrx:`Restoring NgRx state, dispatching actions and changing the Store DevTools history`,http:`Changing HTTP mock rules and SSR overrides, refetching the TransferState payload, and clearing HTTP calls`,analog:`Calling API routes, clearing server calls, running Nitro tasks and purging the Nitro cache`,components:`Editing component properties and signals`,tanstackQuery:`Invalidating and refetching TanStack queries`,plugins:`Running plugin actions`}[e]} is turned off in the devtools config (actions.${e}).`}var Nw=[`pangular:build-meta`],Pw={components:`Components`,injectors:`Injectors`,signals:`Signals`,ngrx:`NgRx`,tanstackQuery:`TanStack Query`,forms:`Forms`,router:`Router`,pipes:`Pipes`,http:`HTTP`,analog:`Analog`,capacitor:`Capacitor`,errors:`Errors`,plugins:`Plugins`,performance:`Performance`,build:`Build`},Fw={forms:`Form writes`,router:`Navigation`,ngrx:`NgRx restore, dispatch and Store DevTools`,http:`HTTP mocking`,analog:`Analog API calls`,components:`Component edits`,tanstackQuery:`TanStack Query invalidate and refetch`,plugins:`Plugin actions`},Iw={refreshMs:e=>`poll every ${e} ms`,navigations:e=>`${e} navigations`,formTimeline:e=>`${e} form events`,httpCalls:e=>`${e} HTTP calls`,changeLog:e=>`${e} NgRx changes`,cdCycles:e=>`${e} change detection cycles`,cdSlowMs:e=>`slow cycles over ${e} ms`,capacitorCalls:e=>`${e} Capacitor plugin calls`,idleAfterMs:e=>e?`idle ${e/1e3} s after the last agent call`:`never idle`};function Lw(e){if(!e)return[];let t=[`On`];return e.minLength!==uw.minLength&&t.push(`${e.minLength} characters or more`),e.threshold!==uw.threshold&&t.push(`up to ${e.threshold} bits per character`),t}function Rw(e){let t=[],n=(e,n)=>{n.length&&t.push({label:e,value:n.join(`, `)})},r=RC.filter(t=>e.inspectors[t]);n(`Inspectors off`,RC.filter(t=>!e.inspectors[t]).map(e=>Pw[e])),n(`Agent`,e.agent.readOnly?[`Read-only`]:[]),n(`Hidden from the agent`,r.filter(t=>!e.agent.tools[t]).map(e=>Pw[e])),n(`Blocked actions`,VC.filter(t=>e.inspectors[t]&&!e.actions[t]).map(e=>Fw[e])),n(`Extra secret names`,e.redaction.secretNames),n(`Unmasked names`,e.redaction.unmask),n(`Entropy masking`,Lw(e.redaction.entropy)),n(`Limits`,Object.keys(HC).filter(t=>e.limits[t]!==HC[t].default).map(t=>Iw[t](e.limits[t]))),e.popup.hotkey!==`Alt+Shift+P`&&n(`Popup shortcut`,[e.popup.hotkey===!1?`Off`:e.popup.hotkey]);let i=Object.entries(e.plugins??{});return n(`Plugins off`,i.filter(([,e])=>!e.enabled).map(([e])=>e)),n(`Plugin tools or actions off`,i.filter(([,e])=>e.enabled&&(!e.tools||!e.actions)).map(([e,t])=>`${e} (${[!t.tools&&`tools`,!t.actions&&`actions`].filter(Boolean).join(`, `)})`)),n(`Lint rules off`,Object.entries(e.lint?.rules??{}).filter(([,e])=>!e).map(([e])=>e)),t}function zw(e){let t={tags:[],directives:[],text:``},n=e.toLowerCase();return n=n.replace(/<\s*([^<>\s]+)\s*>/g,(e,n)=>(t.tags.push({value:n,exact:!0}),` `)),n=n.replace(/\[\s*([^[\]\s]+)\s*\]/g,(e,n)=>(t.directives.push({value:n,exact:!0}),` `)),n=n.replace(/(^|\s)([<[])([^<>[\]\s]*)$/,(e,n,r,i)=>(i&&(r===`<`?t.tags:t.directives).push({value:i,exact:!1}),n)),t.text=n.replace(/\s+/g,` `).trim(),t}function Bw(e){return!e.tags.length&&!e.directives.length&&!e.text}function Vw(e,t){let n=e.tag.toLowerCase(),r=(e.directives??[]).map(e=>e.toLowerCase()),i=(e.attributes??[]).map(e=>e.toLowerCase()),a=[...r,...r.map(e=>e.replace(/directive$/,``)),...i];for(let{value:e,exact:r}of t.tags)if(r?n!==e:!n.startsWith(e))return!1;for(let{value:e,exact:n}of t.directives)if(!a.some(t=>n?t===e:t.includes(e)))return!1;if(t.text){let a=t.text;if(!(e.name.toLowerCase().includes(a)||n.includes(a)||r.some(e=>e.includes(a))||i.some(e=>e.includes(a))))return!1}return!0}var Hw={debugApi:{subject:`Live data`,since:`12.0`},injectorProviders:{subject:`Reading providers and environment injectors`,since:`16.2`},injectorMetadata:{subject:`The full injector tree`,since:`17.0`},isSignal:{subject:`Telling signals apart in component values`,since:`17.1`},signalGraph:{subject:`The live signal graph`,since:`20.1`},deferBlocks:{subject:`Listing @defer blocks`,since:`19.1`},forBlocks:{subject:`Listing @for blocks`,since:`22.0`},profiler:{subject:`Recording change detection`,since:`20.0`},angularProfiling:{subject:`Angular profiling`,since:`20.0`},routerInstance:{subject:`The router debug util`,since:`20.3`}};function Uw(e){let t=/^(\d+)\.(\d+)/.exec(e.trim());return t?`${t[1]}.${t[2]}`:e.trim()}function Ww(e){let t=/^(\d+)(?:\.(\d+))?/.exec(e.trim());return t?[Number(t[1]),Number(t[2]??0)]:null}function Gw(e,t){let n=Ww(e),r=Ww(t);return!n||!r?!1:n[0]===r[0]?n[1]>=r[1]:n[0]>r[0]}function Kw(e,t){return e?.capabilities?.[t]===!1}function qw(e,t){let n=Hw[e],r=`${n.subject} needs Angular ${n.since} or later`;if(!t)return`${r}; this page's Angular version is unknown.`;let i=Uw(t);return Gw(t,n.since)?`${r} in a development build; this page runs Angular ${i} without it, which usually means a production build.`:`${r}; this page runs Angular ${i}.`}function Jw(e){return e.hydration===`hydrated`&&e.hydratedAt===void 0?`hydrated before inspection`:e.hydration}var Yw=/^on (viewport|interaction|hover)\b/;function Xw(e){let t=e.filter(e=>!/^(prefetch|hydrate) /.test(e));return t.length>0&&t.every(e=>Yw.test(e))}function Zw(e,t){let n=[],r=e.since>0?Math.max(0,Math.round((t-e.since)/1e3)):0;return e.state===`error`&&n.push({kind:`failed`,text:`failed to load`,label:`failed`}),(e.state===`placeholder`||e.state===`initial`)&&r*1e3>=1e4&&n.push(Xw(e.triggers)?{kind:`waiting`,text:`waiting for its trigger for ${r}s`,label:`waiting ${r}s`,seconds:r}:{kind:`stuck`,text:`on its placeholder for ${r}s`,label:`stuck ${r}s`,seconds:r}),e.hydrateNever?n.push({kind:`hydrate-never`,text:`hydrate never: the server HTML stays static`,label:`hydrate never`}):e.hydration===`dehydrated`&&n.push({kind:`dehydrated`,text:`still dehydrated`,label:`dehydrated`}),n}var Qw=`pangular-page-id`,$w=null;function eT(e){$w=e}function tT(){if($w)return $w;try{return new URLSearchParams(location.search).get(`pageId`)||(window.top===window?null:sessionStorage.getItem(Qw)||null)}catch{return null}}function nT(e,t){if(t.chosen&&e[t.chosen])return t.chosen;if(t.host)return t.host;if(t.previous&&e[t.previous])return t.previous;let n=null;for(let t of Object.values(e))(!n||t.reportedAt>n.reportedAt)&&(n=t);return n?.pageId??null}function rT(e,t){return e?t?e.pages?.[t]??null:e:null}function iT(e,t){return e?t?e.pages?.[t]??null:e.graph??null:null}function aT(){return{functions:[...Nw]}}function oT(e,t,n){return e?e.scope(`pangular`).rpc.call(t,...n===void 0?[]:[n]):Promise.resolve(null)}function sT(e,t,n){return oT(e,t,n).then(e=>e,()=>null)}function cT(e){return e?.connectionMeta.backend===`static`}function lT(e){let t=()=>{let t=e()?.scope(`pangular`).rpc;t&&(t.callEvent(`request-page-highlight`,null),t.callEvent(`request-form-highlight`,null))};return addEventListener(`pagehide`,t),()=>{removeEventListener(`pagehide`,t),t()}}function uT(e=globalThis){delete e.__DEVFRAME_CONNECTION__,delete e.__DEVFRAME_CONNECTION_META__}function dT(e){return e.split(`@`)[1]??``}var fT={signal:`Signal Forms`,reactive:`Reactive`,template:`Template-driven`},pT={own:`validator`,directive:`template attribute`,tree:`cross-field rule`,async:`async`,parse:`parse`,submission:`server`,schema:`schema`,manual:`setErrors`},mT=sT,hT={key:`name looks secret`,"input-type":`password input`,autocomplete:`autocomplete is a secret kind`,marker:`marked as mask`,parent:`inside a secret group`,config:`listed in mask`};function gT(e){return hT[e]??e}var _T=`https://pangular-inspector.dev/security/#opt-fields-in-or-out`;async function vT(e,t){return await mT(e,`request-form-action`,t)??{ok:!1,error:`The devtools server did not answer.`}}function yT(e){return`${e.ok?e.message??`Done.`:e.error??`Failed.`}${e.skipped?.length?` Skipped: ${e.skipped.map(e=>`${e.path} (${e.reason})`).join(`, `)}.`:``}${e.status?` Status: ${e.status}.`:``}`}var bT=/"(?:[^"\\\n]|\\.)*"|`([^`\n]*)`|\*\*/g;function xT(e){return(e??``).replace(/^_Labels, paths.*_\n\n/,``).replace(bT,(e,t)=>t??(e===`**`?``:e))}function ST(e,t){let n=Math.max(0,e-t),r=Math.ceil(n/6e4);if(r<60)return`in ${r} min`;let i=Math.round(r/60);return i<48?`in ${i} ${i===1?`hour`:`hours`}`:`in ${Math.round(i/24)} days`}function CT(e,t){return e.length?e.join(`, `):t}function wT(e,t){let n=[];if(n.push({label:`One-time code`,value:e.auth?e.oneTimeCode.state===`locked`?`Locked after too many wrong codes: run pangular token --rotate or restart the server for a new code`:e.oneTimeCode.expiresAt?`On, current code expires ${ST(e.oneTimeCode.expiresAt,t)}`:`On`:`Off: every client is trusted`}),e.auth){let{count:r,nextExpiresAt:i,lastExpiresAt:a}=e.tokens;n.push({label:`Trusted clients`,value:r?r===1||i===a?`${r} ${r===1?`token`:`tokens`}, expires ${ST(i??t,t)}`:`${r} tokens, the first expires ${ST(i??t,t)}, the last ${ST(a??t,t)}`:`None`}),e.pairingTokens&&n.push({label:`Pairing tokens`,value:`${e.pairingTokens} accepted instead of the code`})}return e.reported?(n.push({label:`Reach`,value:e.network?`This machine and other machines`:`This machine only`}),n.push({label:`Allowed origins`,value:e.originCheck?`${CT(e.allowedOrigins,`This machine and the Chrome extension`)}${e.customOrigins?`, plus a custom origin check`:``}`:`Any: the origin check is off`}),(e.allowedHosts===`any`||e.allowedHosts.length)&&n.push({label:`Allowed hosts`,value:e.allowedHosts===`any`?`Any`:e.allowedHosts.join(`, `)}),n):n}function TT(e){return jw(e?.connectionMeta)}function ET(e){return Array.isArray(e)&&e.every(e=>typeof e==`string`)?[...e]:void 0}function DT(e,t){if(!t||typeof t!=`object`)return e;let n=ET(t.secretNames),r=ET(t.unmask);if(!n||!r)return e;let i=hw(t.entropy);return{...e,redaction:i?{secretNames:n,unmask:r,entropy:i}:{secretNames:n,unmask:r}}}function OT(e,t){return TT(e).actions[t]}var kT={components:`components`,routes:`router`,signals:`signals`,injectors:`injectors`,store:`ngrx`,queries:`tanstackQuery`,forms:`forms`,pipes:`pipes`,network:`http`,analog:`analog`,capacitor:`capacitor`,errors:`errors`,plugins:`plugins`,performance:`performance`,build:`build`};function AT(e){return kT[e]}var jT=`https://pangular-inspector.dev/getting-started/configuration/#inspectors-and-agent-tools`;function MT(e,t){let n=kT[e];return!n||t.inspectors[n]}function NT(e,t){e&1&&(w(),dg(0,`rect`,5)(1,`rect`,6)(2,`rect`,7)(3,`rect`,8))}function PT(e,t){e&1&&(w(),dg(0,`path`,9)(1,`circle`,10)(2,`circle`,11)(3,`path`,12))}function FT(e,t){e&1&&(w(),dg(0,`path`,13)(1,`path`,14))}function IT(e,t){e&1&&(w(),dg(0,`path`,15)(1,`path`,16)(2,`path`,17))}function LT(e,t){e&1&&(w(),dg(0,`circle`,18)(1,`path`,19)(2,`circle`,20))}function RT(e,t){e&1&&(w(),dg(0,`path`,1))}function zT(e,t){e&1&&(w(),dg(0,`path`,21)(1,`path`,22)(2,`path`,23)(3,`path`,24))}function BT(e,t){e&1&&(w(),dg(0,`ellipse`,25)(1,`path`,26)(2,`path`,27))}function VT(e,t){e&1&&(w(),dg(0,`rect`,28)(1,`path`,29)(2,`path`,30)(3,`path`,31))}function HT(e,t){e&1&&(w(),dg(0,`circle`,32)(1,`path`,9)(2,`path`,33))}function UT(e,t){e&1&&(w(),dg(0,`path`,34)(1,`path`,35)(2,`path`,36)(3,`path`,37)(4,`circle`,38))}function WT(e,t){e&1&&(w(),dg(0,`circle`,39)(1,`path`,40))}function GT(e,t){e&1&&(w(),dg(0,`ellipse`,41)(1,`path`,42)(2,`path`,43)(3,`path`,44)(4,`path`,45))}function KT(e,t){e&1&&(w(),dg(0,`circle`,32)(1,`path`,46)(2,`path`,47))}function qT(e,t){e&1&&(w(),dg(0,`path`,48)(1,`path`,49))}function JT(e,t){e&1&&(w(),dg(0,`path`,50)(1,`path`,51)(2,`path`,52))}function YT(e,t){e&1&&(w(),dg(0,`path`,2))}function XT(e,t){e&1&&(w(),dg(0,`path`,3))}function ZT(e,t){e&1&&(w(),dg(0,`path`,4))}var QT=class e{name=$.required();static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-tab-icon`]],inputs:{name:[1,`name`]},decls:20,vars:1,consts:[[`viewBox`,`0 0 24 24`,`width`,`16`,`height`,`16`,`fill`,`none`,`stroke`,`currentColor`,`stroke-width`,`1.8`,`stroke-linecap`,`round`,`stroke-linejoin`,`round`,`aria-hidden`,`true`],[`d`,`M22 12h-4l-3 9L9 3l-3 9H2`],[`d`,`M10 3h4v3a2 2 0 1 0 4 0V5h1a2 2 0 0 1 2 2v4h-2a2 2 0 1 0 0 4h2v4a2 2 0 0 1-2 2h-4v-2a2 2 0 1 0-4 0v2H7a2 2 0 0 1-2-2v-4H4a2 2 0 1 1 0-4h1V7a2 2 0 0 1 2-2h3Z`],[`d`,`m8 3 4 8 5-5 5 15H2L8 3z`],[`fill`,`currentColor`,`stroke`,`none`,`transform`,`translate(1.8 1.8) scale(0.85)`,`d`,`M24 3.7l-5.766 5.766 5.725 5.736-3.713 3.712L5.073 3.742 8.786.03l5.736 5.726L20.284 0 24 3.7zM.029 8.785l3.713-3.713 15.173 15.173-3.713 3.714-5.732-5.726L3.7 24 0 20.285l5.754-5.764L.029 8.785z`],[`x`,`3`,`y`,`3`,`width`,`7`,`height`,`9`,`rx`,`1.5`],[`x`,`14`,`y`,`3`,`width`,`7`,`height`,`5`,`rx`,`1.5`],[`x`,`14`,`y`,`12`,`width`,`7`,`height`,`9`,`rx`,`1.5`],[`x`,`3`,`y`,`16`,`width`,`7`,`height`,`5`,`rx`,`1.5`],[`d`,`M3 12h18`],[`cx`,`7`,`cy`,`12`,`r`,`2`],[`cx`,`14`,`cy`,`12`,`r`,`2`],[`d`,`M18 7v10`],[`d`,`M9 11l2 2 4-4`],[`d`,`M12 3 4 6v6c0 4.5 3.4 8.3 8 9 4.6-.7 8-4.5 8-9V6l-8-3Z`],[`d`,`M12 2 3 7l9 5 9-5-9-5Z`],[`d`,`m3 12 9 5 9-5`],[`d`,`m3 17 9 5 9-5`],[`cx`,`6`,`cy`,`19`,`r`,`3`],[`d`,`M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15`],[`cx`,`18`,`cy`,`5`,`r`,`3`],[`d`,`M12 22v-5`],[`d`,`M9 8V2`],[`d`,`M15 8V2`],[`d`,`M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z`],[`cx`,`12`,`cy`,`5`,`rx`,`9`,`ry`,`3`],[`d`,`M3 5v14a9 3 0 0 0 18 0V5`],[`d`,`M3 12a9 3 0 0 0 18 0`],[`x`,`3`,`y`,`4`,`width`,`18`,`height`,`16`,`rx`,`2`],[`d`,`M7 9h10`],[`d`,`M7 13h6`],[`d`,`M7 17h8`],[`cx`,`12`,`cy`,`12`,`r`,`9`],[`d`,`M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18Z`],[`d`,`M4 7h10`],[`d`,`M4 17h6`],[`d`,`m14 13 4 4-4 4`],[`d`,`M10 17h8`],[`cx`,`18`,`cy`,`7`,`r`,`3`],[`cx`,`12`,`cy`,`12`,`r`,`3`],[`d`,`M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z`],[`cx`,`11`,`cy`,`5`,`rx`,`8`,`ry`,`3`],[`d`,`M3 5v6c0 1.66 3.58 3 8 3`],[`d`,`M3 11v6c0 1.66 3.58 3 8 3`],[`d`,`M21 14a4 4 0 1 1-1.2-2.85L21 12.3`],[`d`,`M21 9v3.3h-3.3`],[`d`,`M12 7v6`],[`d`,`M12 16.5v.01`],[`d`,`M12 14l4-4`],[`d`,`M3.34 19a10 10 0 1 1 17.32 0`],[`d`,`M21 16V8l-9-5-9 5v8l9 5 9-5Z`],[`d`,`m3.3 7 8.7 5 8.7-5`],[`d`,`M12 22V12`]],template:function(e,t){if(e&1&&(w(),R(0,`svg`,0),A(1,NT,4,0)(2,PT,4,0)(3,FT,2,0)(4,IT,3,0)(5,LT,3,0)(6,RT,1,0,`:svg:path`,1)(7,zT,4,0)(8,BT,3,0)(9,VT,4,0)(10,HT,3,0)(11,UT,5,0)(12,WT,2,0)(13,GT,5,0)(14,KT,3,0)(15,qT,2,0)(16,JT,3,0)(17,YT,1,0,`:svg:path`,2)(18,XT,1,0,`:svg:path`,3)(19,ZT,1,0,`:svg:path`,4),z()),e&2){let e;D(),j((e=t.name())===`dashboard`?1:e===`timeline`?2:e===`audit`?3:e===`components`?4:e===`routes`?5:e===`signals`?6:e===`injectors`?7:e===`store`?8:e===`forms`?9:e===`network`?10:e===`pipes`?11:e===`settings`?12:e===`queries`?13:e===`errors`?14:e===`performance`?15:e===`build`?16:e===`plugins`?17:e===`analog`?18:e===`capacitor`?19:-1)}},styles:[`[_nghost-%COMP%] { - display: inline-flex; - flex-shrink: 0; -}`]})},$T=(e,t)=>t.tab,eE=(e,t)=>t.label;function tE(e,t){e&1&&K(0),e&2&&J(` `,W().meta()?.projectName,` `)}function nE(e,t){e&1&&K(0,` Project details unavailable `)}function rE(e,t){e&1&&K(0,` Loading… `)}function iE(e,t){e&1&&(K(0,` Run `),F(1,`code`),K(2,`pangular build`),I(),K(3,` again to rebuild the report. `))}function aE(e,t){e&1&&K(0,` Check that the dev server is running. `)}function oE(e,t){if(e&1){let e=B();F(0,`button`,13),H(`click`,function(){return S(e),C(W(2).loadMeta())}),K(1,`Try again`),I()}}function sE(e,t){if(e&1&&(F(0,`p`,11),A(1,iE,4,0)(2,aE,1,0),I(),A(3,oE,2,0,`button`,12)),e&2){let e=W();D(),j(e.staticReport()?1:2),D(2),j(e.staticReport()?-1:3)}}function cE(e,t){e&1&&(F(0,`li`,5)(1,`span`),K(2,`Analog`),I(),K(3),I()),e&2&&(D(3),q(t))}function lE(e,t){e&1&&(F(0,`li`)(1,`span`),K(2,`Change detection`),I(),K(3),I()),e&2&&(D(3),q(t))}function uE(e,t){if(e&1&&(F(0,`span`,20),K(1),I(),F(2,`span`,21),K(3),I()),e&2){let e=W().$implicit,t=W();D(),q(t.cards()[e.tab]?.value),D(2),q(t.cards()[e.tab]?.sub)}}function dE(e,t){e&1&&(F(0,`span`,22),K(1,`–`),I(),F(2,`span`,21),K(3,`Count unavailable`),I())}function fE(e,t){e&1&&(L(0,`span`,23),F(1,`span`,21),K(2,`Counting…`),I())}function pE(e,t){if(e&1){let e=B();F(0,`button`,14),H(`click`,function(){let t=S(e).$implicit;return C(W().navigate.emit(t.tab))}),F(1,`span`,15)(2,`span`,16),L(3,`app-tab-icon`,17),I(),F(4,`span`,18),K(5,`→`),I()(),F(6,`span`,19),K(7),I(),A(8,uE,4,2)(9,dE,4,0)(10,fE,3,0),I()}if(e&2){let e,n=t.$implicit,r=t.$index,i=W().stateOf(n.tab);t_(`animation-delay`,60*r,`ms`),k(`aria-busy`,i===`loading`),D(3),P(`name`,n.tab),D(4),q(n.label),D(),j((e=i)===`ready`?8:e===`error`?9:10)}}function mE(e,t){e&1&&(F(0,`p`),K(1,`Loading…`),I())}function hE(e,t){if(e&1&&(F(0,`div`)(1,`dt`),K(2),I(),F(3,`dd`),K(4),I()()),e&2){let e=t.$implicit;D(2),q(e.label),D(2),q(e.value)}}function gE(e,t){if(e&1&&(F(0,`dl`),M(1,hE,5,2,`div`,null,eE),I()),e&2){let e=W();D(),N(e.configItems())}}function _E(e,t){e&1&&(F(0,`p`),K(1,`Defaults`),I())}function vE(e,t){if(e&1&&(F(0,`div`)(1,`dt`),K(2),I(),F(3,`dd`),K(4),I()()),e&2){let e=t.$implicit;D(2),q(e.label),D(2),q(e.value)}}function yE(e,t){e&1&&(F(0,`section`,10)(1,`h2`,24),K(2,`Security`),I(),F(3,`dl`),M(4,vE,5,2,`div`,null,eE),I()()),e&2&&(D(4),N(t))}var bE=[{tab:`components`,label:`Components`},{tab:`routes`,label:`Routes`},{tab:`signals`,label:`Signals`},{tab:`injectors`,label:`Injectors`},{tab:`store`,label:`NgRx declarations`},{tab:`pipes`,label:`Pipes`},{tab:`forms`,label:`Forms`},{tab:`network`,label:`SSR & HTTP`}];function xE(e){return bE.filter(t=>MT(t.tab,e))}var SE={zoneless:`Zoneless`,zone:`zone.js`,"zone-unused":`Zoneless, zone.js loaded`};function CE(e){return e&&Object.hasOwn(SE,e)?SE[e]:null}var wE=new Set([`signal`,`computed`,`linkedSignal`,`effect`]),TE={action:[`action`,`actions`],reducer:[`reducer`,`reducers`],effect:[`effect`,`effects`],selector:[`selector`,`selectors`],feature:[`feature`,`features`],"store-setup":[`setup call`,`setup calls`],"signal-store":[`signal store`,`signal stores`],"signal-state":[`signal state`,`signal states`],"signal-method":[`signal method`,`signal methods`]};function EE(e,t,n){return`${e} ${e===1?t:n}`}function DE(e,t){for(let n of e??[])t(n),DE(n.children,t)}function OE(e){if(!e.some(e=>e.kind))return{value:e.length,sub:`discovered in source`};let t=e.filter(e=>e.kind===`directive`).length;return{value:e.length-t,sub:`components · ${EE(t,`directive`,`directives`)}`}}function kE(e){if(!e.some(e=>e.kind))return{value:e.length,sub:`route entries in source`};let t=e.filter(e=>e.kind===`redirect`).length;return{value:new Set(e.filter(e=>e.kind===`page`).map(e=>e.fullPath??e.path??``)).size,sub:`navigable paths · ${EE(t,`redirect`,`redirects`)}`}}function AE(e){let t=new Map;for(let n of e)n.kind&&t.set(n.kind,(t.get(n.kind)??0)+1);if(!t.size)return{value:e.length,sub:`declarations in source`};let n=[...t].sort((e,t)=>t[1]-e[1]).map(([e,t])=>{let[n,r]=TE[e]??[e,e];return EE(t,n,r)});return{value:e.length,sub:n.join(` · `)}}function jE(e,t){let n=e?.forms??[],r=t?n.filter(e=>dT(e.id)===t):n;if(!r.length)return{value:0,sub:`no forms on the page yet`};let i=r.filter(e=>e.root?.status===`INVALID`).length;return{value:r.length,sub:`${r.length===1?`form`:`forms`} · ${i} invalid`}}function ME(e){return e.cancelled||e.status===void 0?!1:e.status===0||e.status>=400}function NE(e,t){let n=e?.pages??[],r=t?n.find(e=>e.pageId===t)??null:n.reduce((e,t)=>!e||t.reportedAt>e.reportedAt?t:e,null),i=t&&!r?[]:e?.serverCalls??[],a=r?.initialUrl,o=[...a?i.filter(e=>!e.pageUrl||e.pageUrl===a):i,...r?.calls??[]];if(!o.length)return{value:0,sub:`no calls recorded yet`};let s=o.filter(ME).length;return{value:o.length,sub:`${o.length===1?`call`:`calls`} · ${s} failed`}}var PE=class e{rpc=$(null);navigate=cv();staticReport=X(()=>cT(this.rpc()));meta=T(null);security=T(null);redaction=T(null);config=X(()=>DT(TT(this.rpc()),this.redaction()));stats=X(()=>this.rpc()?xE(this.config()):[]);configItems=X(()=>Rw(this.config()));metaState=T(`loading`);states=T({});rows=T({});injectorTree=T(null);signalGraph=T(null);formsState=T(null);httpState=T(null);pageId=tT();destroyRef=x(Ss);stopLive=[];zone=X(()=>CE(rT(this.injectorTree(),this.pageId)?.zone));liveInjectors=X(()=>{let e=rT(this.injectorTree(),this.pageId);if(!e?.roots?.length)return null;let t=0,n=0,r=e=>{t++,n+=e.providers?.length??0};return DE(e.roots,r),DE(e.environment,r),{injectors:t,providers:n}});liveSignals=X(()=>{let e=iT(this.signalGraph(),this.pageId);return e?.nodes?.length?e.nodes.filter(e=>wE.has(e.kind??``)).length:null});cards=X(()=>{let e=this.rows(),t={};if(e.components&&(t.components=OE(e.components)),e.routes&&(t.routes=kE(e.routes)),e.store&&(t.store=AE(e.store)),e.pipes){let n=e.pipes.filter(e=>e.builtin).length;t.pipes={value:e.pipes.length-n,sub:n?`custom pipes · ${n} built-in in use`:`custom pipes in source`}}let n=this.liveSignals();n===null?e.signals&&(t.signals={value:e.signals.length,sub:`signal declarations in source`}):t.signals={value:n,sub:e.signals?`live on the page · ${e.signals.length} declared in source`:`live on the page`};let r=this.liveInjectors();r?t.injectors={value:r.injectors,sub:`live injectors · ${EE(r.providers,`provider`,`providers`)}`}:e.injectors&&(t.injectors={value:e.injectors.filter(e=>e.type!==`injection`).length,sub:`provider declarations in source`});let i=this.formsState();i&&(t.forms=jE(i,this.pageId));let a=this.httpState();return a&&(t.network=NE(a,this.pageId)),t});loadMeta(e=this.rpc()){e&&(this.metaState.set(`loading`),e.scope(`pangular`).rpc.call(`build-meta`).then(t=>{this.rpc()===e&&(this.meta.set(t),this.metaState.set(`ready`))}).catch(()=>{this.rpc()===e&&this.metaState.set(`error`)}))}loadRedaction(e){this.redaction.set(null),!cT(e)&&oT(e,`redaction-config`).then(t=>{this.rpc()===e&&this.redaction.set(t)}).catch(()=>{})}loadSecurity(e){this.security.set(null),!cT(e)&&sT(e,`security-status`).then(t=>{this.rpc()===e&&t&&this.security.set(wT(t,Date.now()))})}constructor(){E(()=>{let e=this.rpc();if(!e)return;let t=e.scope(`pangular`);this.states.set({}),this.rows.set({}),this.formsState.set(null),this.httpState.set(null),this.loadMeta(e),this.loadRedaction(e),this.loadSecurity(e);let n=TT(e),r=e=>MT(e,n);r(`components`)&&this.load(t.rpc.call(`get-components`),`components`),r(`routes`)&&this.load(t.rpc.call(`get-routes`),`routes`),r(`signals`)&&this.load(t.rpc.call(`get-signals`),`signals`),r(`injectors`)&&this.load(t.rpc.call(`get-providers`),`injectors`),r(`store`)&&this.load(t.rpc.call(`get-ngrx-store`),`store`),r(`pipes`)&&this.load(t.rpc.call(`get-pipes`),`pipes`),this.watchLive(e,n)}),this.destroyRef.onDestroy(()=>this.unwatch())}stateOf(e){return(e===`signals`?this.liveSignals():e===`injectors`?this.liveInjectors():null)===null?this.states()[e]??`loading`:`ready`}load(e,t){let n=e=>this.states.update(n=>({...n,[t]:e}));e.then(e=>{this.rows.update(n=>({...n,[t]:Array.isArray(e)?e:[]})),n(`ready`)}).catch(()=>n(`error`))}async watchLive(e,t){this.unwatch();let n=e.scope(`pangular`).rpc,r=async(t,r,i)=>{let a=e=>i&&this.states.update(t=>({...t,[i]:e}));try{let i=await n.sharedState(t);if(this.destroyRef.destroyed||this.rpc()!==e)return;r(i.value()),a(`ready`),this.stopLive.push(i.on(`updated`,e=>r(e)))}catch{if(this.destroyRef.destroyed||this.rpc()!==e)return;r(null),a(`error`)}};await Promise.all([t.inspectors.injectors&&r(`injector-tree`,e=>this.injectorTree.set(e)),t.inspectors.signals&&r(`signal-graph`,e=>this.signalGraph.set(e)),MT(`forms`,t)&&r(`forms`,e=>this.formsState.set(e??{}),`forms`),MT(`network`,t)&&r(`http`,e=>this.httpState.set(e??{}),`network`)])}unwatch(){for(let e of this.stopLive.splice(0))e()}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-dashboard`]],inputs:{rpc:[1,`rpc`]},outputs:{navigate:`navigate`},decls:34,vars:15,consts:[[`aria-labelledby`,`project-title`,1,`project`],[1,`project-head`],[1,`eyebrow`],[`id`,`project-title`],[1,`chips`],[1,`analog`],[1,`grid`],[`type`,`button`,1,`stat`,3,`animation-delay`],[`aria-labelledby`,`config-title`,1,`config`],[`id`,`config-title`],[`aria-labelledby`,`security-title`,1,`config`],[1,`hint`],[`type`,`button`,1,`retry`],[`type`,`button`,1,`retry`,3,`click`],[`type`,`button`,1,`stat`,3,`click`],[1,`top`],[1,`icon`],[3,`name`],[`aria-hidden`,`true`,1,`go`],[1,`label`],[1,`big`],[1,`sub`],[1,`big`,`muted`],[`aria-hidden`,`true`,1,`big`,`skeleton`],[`id`,`security-title`]],template:function(e,t){if(e&1&&(F(0,`section`,0)(1,`div`,1)(2,`p`,2),K(3,`Project`),I(),F(4,`h2`,3),A(5,tE,1,1)(6,nE,1,0)(7,rE,1,0),I(),A(8,sE,4,2),I(),F(9,`ul`,4)(10,`li`)(11,`span`),K(12,`Angular`),I(),K(13),I(),F(14,`li`)(15,`span`),K(16,`TypeScript`),I(),K(17),I(),F(18,`li`)(19,`span`),K(20,`SSR`),I(),K(21),I(),A(22,cE,4,1,`li`,5),A(23,lE,4,1,`li`),I()(),F(24,`div`,6),M(25,pE,11,6,`button`,7,$T),I(),F(27,`section`,8)(28,`h2`,9),K(29,`Configuration`),I(),A(30,mE,2,0,`p`)(31,gE,3,0,`dl`)(32,_E,2,0,`p`),I(),A(33,yE,6,0,`section`,10)),e&2){let e,n,r,i;k(`aria-busy`,t.metaState()===`loading`),D(4),G(`muted`,t.metaState()!==`ready`),D(),j((e=t.metaState())===`ready`?5:e===`error`?6:7),D(3),j(t.metaState()===`error`?8:-1),D(),G(`pending`,t.metaState()===`loading`),D(4),q(t.meta()?.angularVersion??`…`),D(4),q(t.meta()?.typescript??`…`),D(4),q(t.meta()?t.meta()?.ssr?`On`:`Off`:`…`),D(),j((n=t.meta()?.analog)?22:-1,n),D(),j((r=t.zone())?23:-1,r),D(2),N(t.stats()),D(2),k(`aria-busy`,!t.rpc()),D(3),j(t.rpc()?t.configItems().length?31:32:30),D(3),j((i=t.security())?33:-1,i)}},dependencies:[QT],styles:[`[_nghost-%COMP%] { - display: block; - max-width: 1200px; -} - -.project[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - justify-content: space-between; - gap: 16px 24px; - margin-bottom: 16px; - padding: 24px; - border: 1px solid var(--%NS%border); - border-radius: var(--%NS%radius); - background: radial-gradient(420px 180px at 100% 0%, var(--%NS%accent-soft), transparent 70%), linear-gradient(180deg, var(--%NS%surface-2), var(--%NS%surface)); - box-shadow: var(--%NS%shadow); -} - -.project-head[_ngcontent-%COMP%] { - flex: 1 1 240px; - min-width: 0; -} - -.eyebrow[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - margin: 0 0 4px; - color: var(--%NS%accent); -} - -h2[_ngcontent-%COMP%] { - margin: 0; - color: var(--%NS%text-strong); - font-size: 24px; - line-height: 1.25; - letter-spacing: -0.02em; - overflow-wrap: anywhere; -} - -h2.muted[_ngcontent-%COMP%] { - color: var(--%NS%text-2); - font-weight: 600; -} - -.hint[_ngcontent-%COMP%] { - margin: 8px 0 0; - color: var(--%NS%text-2); - font-size: 13px; - line-height: 1.5; -} - -.retry[_ngcontent-%COMP%] { - display: inline-flex; - margin-top: 8px; - align-items: center; - height: var(--%NS%control-h); - padding: 0 14px; - border: 1px solid var(--%NS%control-border); - border-radius: var(--%NS%radius-sm); - background: var(--%NS%surface-2); - color: var(--%NS%text); - font: inherit; - font-weight: 500; - cursor: pointer; -} -.retry[_ngcontent-%COMP%]:hover { - background: var(--%NS%surface-3); - border-color: var(--%NS%accent-line); -} -.retry[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.chips[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - gap: 8px; - min-width: 0; - margin: 0; - padding: 0; - list-style: none; -} - -.chips[_ngcontent-%COMP%] li[_ngcontent-%COMP%] { - display: inline-flex; - align-items: center; - gap: 8px; - height: 28px; - max-width: 100%; - padding: 0 12px; - border: 1px solid var(--%NS%border-strong); - border-radius: 99px; - background: var(--%NS%bg); - color: var(--%NS%text-strong); - font-size: 12px; - font-weight: 600; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.chips[_ngcontent-%COMP%] span[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-weight: 500; -} - -.chips.pending[_ngcontent-%COMP%] li[_ngcontent-%COMP%] { - color: var(--%NS%text-3); -} - -.chips[_ngcontent-%COMP%] .analog[_ngcontent-%COMP%] { - border-color: color-mix(in srgb, #dd0330 55%, transparent); -} - -.grid[_ngcontent-%COMP%] { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(100%, 150px), 1fr)); - gap: 12px; -} - -.stat[_ngcontent-%COMP%] { - display: flex; - flex-direction: column; - align-items: flex-start; - min-width: 0; - padding: 16px; - border: 1px solid var(--%NS%border); - border-radius: var(--%NS%radius); - background: var(--%NS%surface); - color: inherit; - text-align: left; - font: inherit; - cursor: pointer; - animation: enter 0.4s var(--%NS%ease) both; - transition: border-color 0.2s var(--%NS%ease), transform 0.2s var(--%NS%ease), background-color 0.2s var(--%NS%ease), box-shadow 0.2s var(--%NS%ease); -} - -.stat[_ngcontent-%COMP%]:hover { - border-color: var(--%NS%accent-line); - background: var(--%NS%surface-2); - box-shadow: var(--%NS%shadow); - transform: translateY(-2px); -} - -.stat[_ngcontent-%COMP%]:active { - transform: translateY(0); - background: var(--%NS%surface-3); -} - -.stat[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.top[_ngcontent-%COMP%] { - display: flex; - align-items: center; - justify-content: space-between; - align-self: stretch; - margin-bottom: 12px; -} - -.icon[_ngcontent-%COMP%] { - display: grid; - place-items: center; - width: 32px; - height: 32px; - border: 1px solid var(--%NS%accent-line); - border-radius: var(--%NS%radius-sm); - background: var(--%NS%accent-soft); - color: var(--%NS%accent); -} - -.go[_ngcontent-%COMP%] { - display: grid; - place-items: center; - width: 24px; - height: 24px; - color: var(--%NS%text-3); - transition: transform 0.2s var(--%NS%ease), color 0.2s var(--%NS%ease); -} - -.stat[_ngcontent-%COMP%]:hover .go[_ngcontent-%COMP%], -.stat[_ngcontent-%COMP%]:focus-visible .go[_ngcontent-%COMP%] { - color: var(--%NS%accent); - transform: translateX(3px); -} - -.label[_ngcontent-%COMP%] { - max-width: 100%; - overflow: hidden; - color: var(--%NS%text-2); - font-size: 13px; - font-weight: 500; - line-height: 16px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.big[_ngcontent-%COMP%] { - margin: 4px 0; - color: var(--%NS%text-strong); - font-size: 32px; - font-weight: 750; - line-height: 40px; - letter-spacing: -0.03em; - font-variant-numeric: tabular-nums; -} - -.big.muted[_ngcontent-%COMP%] { - color: var(--%NS%text-3); -} - -.skeleton[_ngcontent-%COMP%] { - display: block; - width: 56px; - height: 32px; - margin-block: 8px; - border-radius: var(--%NS%radius-sm); - background: linear-gradient(90deg, var(--%NS%surface-2), var(--%NS%surface-3), var(--%NS%surface-2)) 0 0/200% 100%; - animation: _ngcontent-%COMP%_shimmer 1.4s linear infinite; -} - -.sub[_ngcontent-%COMP%] { - max-width: 100%; - color: var(--%NS%text-3); - font-size: 12px; - line-height: 16px; -} - -.config[_ngcontent-%COMP%] { - margin-top: 16px; - padding: 16px; - border: 1px solid var(--%NS%border); - border-radius: var(--%NS%radius); - background: var(--%NS%surface); -} - -.config[_ngcontent-%COMP%] h2[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - margin: 0 0 8px; - color: var(--%NS%text-2); -} - -.config[_ngcontent-%COMP%] p[_ngcontent-%COMP%], -.config[_ngcontent-%COMP%] dl[_ngcontent-%COMP%] { - margin: 0; - color: var(--%NS%text); - font-size: 13px; - line-height: 1.5; -} - -.config[_ngcontent-%COMP%] dl[_ngcontent-%COMP%] { - display: grid; - gap: 4px; -} - -.config[_ngcontent-%COMP%] dl[_ngcontent-%COMP%] div[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - gap: 4px 8px; -} - -.config[_ngcontent-%COMP%] dt[_ngcontent-%COMP%] { - color: var(--%NS%text-2); -} - -.config[_ngcontent-%COMP%] dt[_ngcontent-%COMP%]::after { - content: ":"; -} - -.config[_ngcontent-%COMP%] dd[_ngcontent-%COMP%] { - margin: 0; - color: var(--%NS%text-strong); - overflow-wrap: anywhere; -} - -@keyframes _ngcontent-%COMP%_shimmer { - to { - background-position: -200% 0; - } -} -@media (max-width: 480px) { - .project[_ngcontent-%COMP%] { - padding: 16px; - } - h2[_ngcontent-%COMP%] { - font-size: 20px; - } - .stat[_ngcontent-%COMP%] { - padding: 12px; - } - .big[_ngcontent-%COMP%] { - font-size: 28px; - line-height: 36px; - } -} -@media (prefers-reduced-motion: reduce) { - .stat[_ngcontent-%COMP%], - .stat[_ngcontent-%COMP%]:hover, - .stat[_ngcontent-%COMP%]:active { - transform: none; - } - .stat[_ngcontent-%COMP%]:hover .go[_ngcontent-%COMP%] { - transform: none; - } - .skeleton[_ngcontent-%COMP%] { - animation: none; - } -}`]})},FE=`pangular:panel-action-result`,IE=6e3,LE=0;function RE(e=window){return e.location.protocol===`chrome-extension:`&&e.parent!==e}function zE(e,t=window){let n=`pa-${Date.now().toString(36)}-${++LE}`;return new Promise(r=>{let i=e=>{t.clearTimeout(o),t.removeEventListener(`message`,a),r(e)},a=({source:e,origin:r,data:a})=>{if(e!==t.parent||r!==t.location.origin)return;let o=a;if(o?.type!==FE||o.requestId!==n)return;let{opened:s,error:c}=a;i({ok:o.ok===!0,...s===`file`||s===`class`?{opened:s}:{},...typeof c==`string`?{error:c}:{}})},o=t.setTimeout(()=>i({ok:!1,error:`timeout`}),IE);t.addEventListener(`message`,a),t.parent.postMessage({...e,requestId:n},t.location.origin)})}var BE=class e{available=RE();reveal(e,t){return zE({type:`pangular:reveal-element`,pageId:e,id:t})}openSource(e,t,n){return zE({type:`pangular:open-source`,pageId:e,id:t,...n?{file:n.file,line:n.line}:{}})}static ɵfac=function(t){return new(t||e)};static ɵprov=kl({token:e,factory:e.ɵfac})};function VE(e){return new Date(e).toLocaleTimeString()}var HE=/[ \t\n\r]/;function UE(e){let t=e.trim(),n;try{n=JSON.parse(t)}catch{return null}if(typeof n!=`object`||!n)return null;let r=``,i=0,a=()=>`\n${` `.repeat(i)}`;for(let e=0;et.name;function KE(e,t){e&1&&(R(0,`span`,11),K(1,`impure`),z())}function qE(e,t){e&1&&(R(0,`span`,11),K(1,`churning`),z())}function JE(e,t){e&1&&(R(0,`span`,11),K(1,`stale?`),z())}function YE(e,t){if(e&1&&(R(0,`span`,10),K(1),z()),e&2){let e=W(2).$implicit,t=W(3);D(),q(t.plural(e.calls,`call`))}}function XE(e,t){if(e&1){let e=B();R(0,`button`,7),U(`click`,function(){S(e);let t=W().$implicit;return C(W(3).showPipe.emit(t.name))}),R(1,`span`,8),K(2,`Show in Pipes:`),z(),R(3,`span`,9),K(4),z(),R(5,`span`,10),K(6),z(),A(7,KE,2,0,`span`,11),A(8,qE,2,0,`span`,11),A(9,JE,2,0,`span`,11),A(10,YE,2,1,`span`,10),z()}if(e&2){let e=W().$implicit;D(4),q(e.name),D(2),J(``,e.uses,`×`),D(),j(e.isPure?-1:7),D(),j(e.churn?8:-1),D(),j(e.stale?9:-1),D(),j(e.calls===void 0?-1:10)}}function ZE(e,t){e&1&&(R(0,`span`,11),K(1,`impure`),z())}function QE(e,t){if(e&1&&(R(0,`span`,6)(1,`span`,9),K(2),z(),R(3,`span`,10),K(4),z(),A(5,ZE,2,0,`span`,11),z()),e&2){let e=W().$implicit;D(2),q(e.name),D(2),J(``,e.uses,`×`),D(),j(e.isPure?-1:5)}}function $E(e,t){if(e&1&&(R(0,`li`),A(1,XE,11,6,`button`,5)(2,QE,6,3,`span`,6),z()),e&2){let e=W(3);D(),j(e.canShowPipes()?1:2)}}function eD(e,t){if(e&1&&(R(0,`ul`,3),M(1,$E,3,1,`li`,null,GE),z(),R(3,`p`,4),K(4,` Call counts cover every component that uses the pipe. Record calls in the Pipes tab to see them. `),z()),e&2){let e=W(2);D(),N(e.rows())}}function tD(e,t){e&1&&(R(0,`p`,2),K(1,`No pipe is in use in the rendered part of its template.`),z())}function nD(e,t){if(e&1&&(R(0,`div`,0)(1,`h3`),K(2,` Pipes in template `),R(3,`span`,1),K(4),z()(),A(5,eD,5,0)(6,tD,2,0,`p`,2),z()),e&2){let e=W();D(4),q(e.rows().length),D(),j(e.rows().length?5:6)}}function rD(e,t,n){let r=(t?e?.pages?.find(e=>e.pageId===t):void 0)?.pipes??e?.pipes??[],i=[];for(let e of r){let t=e.components.find(e=>e.name===n);t&&i.push({name:e.name,isPure:e.isPure,uses:t.count,...e.call?{calls:e.call.callCount}:{},churn:!!e.churn,stale:!!e.stale})}return i.sort((e,t)=>e.name.localeCompare(t.name))}var iD=class e{plural=WE;destroyRef=x(Ss);rpc=$(null);pageId=$(void 0);component=$.required();canShowPipes=$(!1);showPipe=cv();snapshot=T(void 0);loaded=T(!1);rows=X(()=>rD(this.snapshot(),this.pageId(),this.component()));unsubscribe;constructor(){E(()=>{let e=this.rpc();e&&this.subscribe(e)}),this.destroyRef.onDestroy(()=>this.unsubscribe?.())}async subscribe(e){try{let t=await e.scope(`pangular`).rpc.sharedState(`pipe-usage`);if(this.destroyRef.destroyed)return;let n=e=>{this.snapshot.set(e),this.loaded.set(!0)};n(t.value()),this.unsubscribe?.(),this.unsubscribe=t.on(`updated`,n)}catch{}}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-component-pipes`]],inputs:{rpc:[1,`rpc`],pageId:[1,`pageId`],component:[1,`component`],canShowPipes:[1,`canShowPipes`]},outputs:{showPipe:`showPipe`},decls:1,vars:1,consts:[[1,`block`],[1,`pill`],[1,`empty-line`],[1,`chips`],[1,`hint`],[`type`,`button`,1,`pipe`],[1,`pipe`],[`type`,`button`,1,`pipe`,3,`click`],[1,`sr-only`],[1,`mono`],[1,`note`],[1,`flag`,`warn`]],template:function(e,t){e&1&&A(0,nD,7,2,`div`,0),e&2&&j(t.loaded()?0:-1)},styles:[`[_nghost-%COMP%] { - display: block; -} - -.block[_ngcontent-%COMP%] { - padding: 14px 16px; -} - -h3[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - display: flex; - align-items: center; - gap: 6px; - margin: 0 0 8px; -} - -.pill[_ngcontent-%COMP%] { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 16px; - padding: 0 5px; - border-radius: 99px; - background: var(--%NS%surface-3); - color: var(--%NS%text-2); - font-size: 10px; - font-weight: 600; - letter-spacing: 0; -} - -.chips[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin: 0; - padding: 0; - list-style: none; -} - -.pipe[_ngcontent-%COMP%] { - display: inline-flex; - align-items: center; - gap: 6px; - min-height: var(--%NS%control-h); - padding: 0 10px; - border: 1px solid var(--%NS%control-border); - border-radius: var(--%NS%radius-sm); - background: var(--%NS%surface-2); - color: var(--%NS%text); - font: inherit; - font-size: 12px; -} - -button.pipe[_ngcontent-%COMP%] { - cursor: pointer; - transition: background-color 150ms var(--%NS%ease), border-color 150ms var(--%NS%ease); -} - -button.pipe[_ngcontent-%COMP%]:hover { - border-color: var(--%NS%accent-line); - background: var(--%NS%surface-3); -} - -button.pipe[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.mono[_ngcontent-%COMP%] { - font-family: var(--%NS%font-mono); - color: var(--%NS%text-strong); -} - -.note[_ngcontent-%COMP%] { - color: var(--%NS%text-2); - font-variant-numeric: tabular-nums; -} - -.flag[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.flag.warn[_ngcontent-%COMP%] { - border-color: color-mix(in srgb, var(--%NS%warn) 30%, transparent); - background: color-mix(in srgb, var(--%NS%warn) 12%, transparent); - color: var(--%NS%warn-text); - padding: 0 6px; - border-radius: 99px; -} - -.hint[_ngcontent-%COMP%], -.empty-line[_ngcontent-%COMP%] { - margin: 8px 0 0; - color: var(--%NS%text-2); - font-size: 12px; - line-height: 1.5; -} - -@media (prefers-reduced-motion: reduce) { - button.pipe[_ngcontent-%COMP%] { - transition: none; - } -}`]})};function aD(e){let t=e?.pages;return t&&typeof t==`object`?t:{}}function oD(e){let t=x(Ss),n=T({}),r=null,i=null,a=async e=>{i?.(),i=null,r=e;try{let a=await e.scope(`pangular`).rpc.sharedState(`page-angular`);if(t.destroyed||r!==e)return;let o=e=>n.set(aD(e));o(a.value()),i=a.on(`updated`,o)}catch{r===e&&n.set({})}};return E(()=>{let t=e();t&&t!==r&&Z(()=>void a(t))}),t.onDestroy(()=>i?.()),n.asReadonly()}function sD(e,t){if(t)return e[t]??null;let n=null;for(let t of Object.values(e))(!n||t.reportedAt>n.reportedAt)&&(n=t);return n}var cD=[`*`];function lD(e,t){if(e&1&&(R(0,`div`,1)(1,`p`,2),K(2),z(),R(3,`p`,3),K(4),Ag(5),R(6,`a`,4),K(7,`Supported versions`),z()()()),e&2){let e=W();G(`compact`,e.compact()),D(2),q(e.title()),D(2),J(` `,t,` `),D(2),V(`href`,e.docsUrl,Du)}}var uD=`https://pangular-inspector.dev/getting-started/installation/#supported-angular-versions`,dD=class e{page=$(null);capability=$.required();missing=$(!1,{transform:yv});compact=$(!1,{transform:yv});docsUrl=uD;text=X(()=>{let e=this.page();return!this.missing()&&!Kw(e,this.capability())?null:qw(this.capability(),e?.version)});title=X(()=>{let e=this.page()?.version;return e?`Not available on Angular ${Uw(e)}`:`Not available on this page`});static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-angular-unavailable`]],inputs:{page:[1,`page`],capability:[1,`capability`],missing:[1,`missing`],compact:[1,`compact`]},ngContentSelectors:cD,decls:1,vars:1,consts:[[`role`,`status`,1,`unavailable`,3,`compact`],[`role`,`status`,1,`unavailable`],[1,`title`],[1,`hint`],[`target`,`_blank`,`rel`,`noopener noreferrer`,3,`href`]],template:function(e,t){if(e&1&&(kg(),A(0,lD,8,5,`div`,0)),e&2){let e;j((e=t.text())?0:-1,e)}},styles:[`[_nghost-%COMP%] { - display: block; -} - -[_nghost-%COMP%]:empty { - display: none; -} - -.unavailable[_ngcontent-%COMP%] { - display: flex; - flex-direction: column; - gap: 6px; - margin: 0 0 12px; - padding: 16px; - border: 1px solid var(--%NS%border); - border-radius: var(--%NS%radius); - background: var(--%NS%surface); - border-style: dashed; -} - -.unavailable.compact[_ngcontent-%COMP%] { - padding: 8px 12px; -} - -.title[_ngcontent-%COMP%] { - margin: 0; - color: var(--%NS%text-strong); - font-size: 14px; - font-weight: 600; -} - -.hint[_ngcontent-%COMP%] { - margin: 0; - color: var(--%NS%text-2); - font-size: 13px; - line-height: 1.55; - overflow-wrap: anywhere; -} - -a[_ngcontent-%COMP%] { - color: var(--%NS%accent); - border-radius: 2px; -} - -a[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -}`]})};function fD(e){return e?.platform===`angular-native`}function pD(e){return fD(e)||e?.platform===`nativescript`}function mD(e,t){let n=Object.values(e??{}).filter(fD);if(t&&n.some(e=>e.pageId===t))return t;let r=null;for(let e of n)(!r||e.reportedAt>r.reportedAt)&&(r=e);return r?.pageId??null}var hD=[`trigger`],gD=[`list`],_D=(e,t)=>t.value;function vD(e,t){if(e&1&&(R(0,`span`,4),K(1),z()),e&2){let e=W();D(),q(e.current().hint)}}function yD(e,t){if(e&1&&(R(0,`span`,15),K(1),z()),e&2){let e=W().$implicit;D(),q(e.hint)}}function bD(e,t){if(e&1){let e=B();R(0,`li`,11),U(`pointerenter`,function(){let t=S(e).$index;return C(W(2).active.set(t))})(`pointerdown`,function(e){return e.preventDefault()})(`click`,function(){let t=S(e).$index;return C(W(2).choose(t))}),w(),R(1,`svg`,12),dg(2,`path`,13),z(),fs(),R(3,`span`,14),K(4),z(),A(5,yD,2,1,`span`,15),z()}if(e&2){let e=t.$implicit,n=t.$index,r=W(2);G(`active`,n===r.active())(`selected`,e.value===r.value()),k(`id`,r.uid+`-opt-`+n)(`aria-selected`,e.value===r.value())(`aria-disabled`,e.disabled||null),D(4),q(e.label),D(),j(e.hint?5:-1)}}function xD(e,t){if(e&1&&(R(0,`li`,10),K(1),z()),e&2){let e=W(2);D(),q(e.emptyText())}}function SD(e,t){if(e&1&&(R(0,`ul`,8,1),Vh(`list-in`),U(`pointerdown`,function(e){return e.preventDefault()}),M(2,bD,6,9,`li`,9,_D,!1,xD,2,1,`li`,10),z()),e&2){let e=W();G(`up`,e.dropUp()),k(`id`,e.uid+`-list`)(`aria-labelledby`,e.labelledBy()||e.uid+`-trigger`),D(2),N(e.options())}}var CD=0,wD=class e{options=$.required();value=pv(null);placeholder=$(`Select…`);ariaLabel=$(``);labelledBy=$(``);describedBy=$(``);disabled=$(!1);emptyText=$(`Nothing to choose`);uid=`app-select-${++CD}`;open=T(!1);active=T(-1);dropUp=T(!1);current=X(()=>this.options().find(e=>e.value===this.value())??null);host=x(Ml);injector=x(bs);trigger=hv.required(`trigger`);list=hv(`list`);typed=``;typedAt=0;toggle(){this.open()?this.close():this.show()}choose(e){let t=this.options()[e];t&&!t.disabled&&(this.value.set(t.value),this.close(),this.trigger().nativeElement.focus())}onKey(e){let t=this.options(),n=this.open();switch(e.key){case`ArrowDown`:case`ArrowUp`:if(e.preventDefault(),!n){this.show();return}this.move(e.key===`ArrowDown`?1:-1);return;case`Home`:case`End`:if(!n)return;e.preventDefault(),this.setActive(e.key===`Home`?this.firstEnabled(0,1):this.firstEnabled(t.length-1,-1));return;case`Enter`:case` `:e.preventDefault(),n&&this.active()>=0?this.choose(this.active()):this.show();return;case`Escape`:if(!n)return;e.preventDefault(),e.stopPropagation(),this.close();return;case`Tab`:n&&this.close();return;default:e.key.length===1&&!e.metaKey&&!e.ctrlKey&&!e.altKey&&this.typeAhead(e.key)}}onBlur(e){let t=e.relatedTarget;t&&this.host.nativeElement.contains(t)||this.close()}onOutside(e){this.open()&&!this.host.nativeElement.contains(e.target)&&this.close()}show(){if(this.disabled())return;let e=this.host.nativeElement.getBoundingClientRect(),t=window.innerHeight-e.bottom;this.dropUp.set(t<220&&e.top>t);let n=this.options().findIndex(e=>e.value===this.value());this.open.set(!0),this.setActive(n>=0?n:this.firstEnabled(0,1))}close(){this.open.set(!1),this.active.set(-1)}move(e){let t=this.firstEnabled(this.active()+e,e);t>=0&&this.setActive(t)}firstEnabled(e,t){let n=this.options();for(let r=e;r>=0&&r{(this.list()?.nativeElement.querySelector(`#${this.uid}-opt-${e}`))?.scrollIntoView({block:`nearest`})},{injector:this.injector})}typeAhead(e){let t=performance.now();this.typed=t-this.typedAt>600?e.toLowerCase():this.typed+e.toLowerCase(),this.typedAt=t;let n=this.options().findIndex(e=>!e.disabled&&e.label.toLowerCase().startsWith(this.typed));n<0||(this.open()?this.setActive(n):this.value.set(this.options()[n].value))}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-select`]],viewQuery:function(e,t){e&1&&Mg(t.trigger,hD,5)(t.list,gD,5),e&2&&Ng(2)},hostAttrs:[1,`app-select`],hostVars:2,hostBindings:function(e,t){e&1&&H(`pointerdown`,function(e){return t.onOutside(e)},ku),e&2&&G(`open`,t.open())},inputs:{options:[1,`options`],value:[1,`value`],placeholder:[1,`placeholder`],ariaLabel:[1,`ariaLabel`],labelledBy:[1,`labelledBy`],describedBy:[1,`describedBy`],disabled:[1,`disabled`],emptyText:[1,`emptyText`]},outputs:{value:`valueChange`},decls:8,vars:13,consts:[[`trigger`,``],[`list`,``],[`type`,`button`,`role`,`combobox`,`aria-haspopup`,`listbox`,1,`trigger`,3,`click`,`keydown`,`blur`,`disabled`],[1,`value`],[1,`value-hint`],[`viewBox`,`0 0 24 24`,`aria-hidden`,`true`,1,`chevron`],[`d`,`m6 9 6 6 6-6`],[`role`,`listbox`,`tabindex`,`-1`,1,`list`,3,`up`],[`role`,`listbox`,`tabindex`,`-1`,1,`list`,3,`pointerdown`],[`role`,`option`,3,`active`,`selected`],[`role`,`option`,`aria-disabled`,`true`,1,`empty`],[`role`,`option`,3,`pointerenter`,`pointerdown`,`click`],[`viewBox`,`0 0 24 24`,`aria-hidden`,`true`,1,`check`],[`d`,`m5 12 5 5 9-10`],[1,`label`],[1,`hint`]],template:function(e,t){e&1&&(R(0,`button`,2,0),U(`click`,function(){return t.toggle()})(`keydown`,function(e){return t.onKey(e)})(`blur`,function(e){return t.onBlur(e)}),R(2,`span`,3),K(3),z(),A(4,vD,2,1,`span`,4),w(),R(5,`svg`,5),dg(6,`path`,6),z()(),A(7,SD,5,5,`ul`,7)),e&2&&(V(`disabled`,t.disabled()),k(`id`,t.uid+`-trigger`)(`aria-controls`,t.uid+`-list`)(`aria-expanded`,t.open())(`aria-label`,t.ariaLabel()||null)(`aria-labelledby`,t.labelledBy()||null)(`aria-describedby`,t.describedBy()||null)(`aria-activedescendant`,t.open()&&t.active()>=0?t.uid+`-opt-`+t.active():null),D(2),G(`placeholder`,!t.current()),D(),J(` `,t.current()?.label??t.placeholder(),` `),D(),j(t.current()?.hint?4:-1),D(3),j(t.open()?7:-1))},styles:[`[_nghost-%COMP%] { - position: relative; - display: inline-flex; - min-width: 0; - vertical-align: middle; -} - -.trigger[_ngcontent-%COMP%] { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - min-width: 0; - height: var(--%NS%control-h, 34px); - padding: 0 10px 0 12px; - border: 1px solid var(--%NS%control-border); - border-radius: var(--%NS%radius-sm); - background: var(--%NS%bg); - color: var(--%NS%text); - font: inherit; - font-size: 13px; - text-align: left; - cursor: pointer; - transition: border-color 150ms var(--%NS%ease), box-shadow 150ms var(--%NS%ease), background-color 150ms var(--%NS%ease); -} - -.trigger[_ngcontent-%COMP%]:hover:not(:disabled) { - border-color: color-mix(in srgb, var(--%NS%control-border) 55%, var(--%NS%text-3)); -} - -.trigger[_ngcontent-%COMP%]:focus-visible, -.open[_nghost-%COMP%] .trigger[_ngcontent-%COMP%] { - outline: none; - border-color: var(--%NS%accent); - box-shadow: 0 0 0 3px var(--%NS%accent-soft); -} - -.trigger[_ngcontent-%COMP%]:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.value[_ngcontent-%COMP%] { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.value.placeholder[_ngcontent-%COMP%] { - color: var(--%NS%text-3); -} - -.value-hint[_ngcontent-%COMP%] { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - color: var(--%NS%text-3); - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chevron[_ngcontent-%COMP%] { - flex: none; - width: 14px; - height: 14px; - fill: none; - stroke: var(--%NS%text-3); - stroke-width: 2.2; - stroke-linecap: round; - stroke-linejoin: round; - transition: transform 150ms var(--%NS%ease); -} - -.open[_nghost-%COMP%] .chevron[_ngcontent-%COMP%] { - transform: rotate(180deg); - stroke: var(--%NS%accent); -} - -.list[_ngcontent-%COMP%] { - position: absolute; - top: calc(100% + 6px); - left: 0; - z-index: 50; - box-sizing: border-box; - min-width: 100%; - max-width: min(480px, 100vw - 24px); - max-height: 280px; - margin: 0; - padding: 4px; - overflow: auto; - list-style: none; - border: 1px solid var(--%NS%border-strong); - border-radius: var(--%NS%radius-sm); - background: var(--%NS%surface-2); - box-shadow: 0 16px 40px -12px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(0, 0, 0, 0.2); - overscroll-behavior: contain; -} -@media (prefers-color-scheme: light) { - :root:not([data-theme=dark]) [_nghost-%COMP%] .list[_ngcontent-%COMP%] { - box-shadow: 0 12px 32px -12px rgba(0, 0, 0, 0.25); - } -} -:root[data-theme=light] [_nghost-%COMP%] .list[_ngcontent-%COMP%] { - box-shadow: 0 12px 32px -12px rgba(0, 0, 0, 0.25); -} - -.list.up[_ngcontent-%COMP%] { - top: auto; - bottom: calc(100% + 6px); -} - -.list-in[_ngcontent-%COMP%] { - animation: _ngcontent-%COMP%_list-in 140ms var(--%NS%ease) both; -} - -@keyframes _ngcontent-%COMP%_list-in { - from { - opacity: 0; - transform: translateY(-4px) scale(0.98); - } -} -li[_ngcontent-%COMP%] { - display: flex; - align-items: center; - gap: 8px; - min-height: 32px; - padding: 0 10px 0 8px; - border-radius: 6px; - color: var(--%NS%text); - font-size: 13px; - cursor: pointer; - white-space: nowrap; -} - -li.active[_ngcontent-%COMP%] { - background: var(--%NS%surface-3); -} - -li.selected[_ngcontent-%COMP%] { - color: var(--%NS%text-strong); - font-weight: 600; -} - -li[aria-disabled=true][_ngcontent-%COMP%] { - color: var(--%NS%text-3); - cursor: default; -} - -.check[_ngcontent-%COMP%] { - flex: none; - width: 14px; - height: 14px; - fill: none; - stroke: var(--%NS%accent); - stroke-width: 2.4; - stroke-linecap: round; - stroke-linejoin: round; - visibility: hidden; -} - -li.selected[_ngcontent-%COMP%] .check[_ngcontent-%COMP%] { - visibility: visible; -} - -.label[_ngcontent-%COMP%] { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; -} - -.hint[_ngcontent-%COMP%] { - flex: none; - max-width: 50%; - overflow: hidden; - color: var(--%NS%text-3); - font-size: 12px; - font-weight: 400; - text-overflow: ellipsis; -} - -.empty[_ngcontent-%COMP%] { - justify-content: center; -} - -@media (prefers-reduced-motion: reduce) { - .list-in[_ngcontent-%COMP%] { - animation: none; - } - .chevron[_ngcontent-%COMP%] { - transition: none; - } -}`]})};function TD(e,t){let n=zw(t);if(Bw(n))return{nodes:e,matches:0};let r=0,i=e=>e.flatMap(e=>{let t=Vw(e,n);t&&r++;let a=i(e.children);return t||a.length?[{...e,children:a}]:[]});return{nodes:i(e),matches:r}}function ED(e){return e.flatMap(e=>e.kind===`directive`?ED(e.children):[{...e,children:ED(e.children)}])}function DD(e){return(e.attributes?.length?e.attributes:e.directives??[e.name]).map(e=>`[${e}]`).join(` `)}var OD={hydrated:`Hydrated`,skipped:`Skipped`,mismatched:`Mismatch`,client:`Client`};function kD(e){return OD[e]}function AD(e,t,n,r=!1,i=0){let a=r?`${e}+`:`${e}`;if(n)return`${t} of ${r?`${e+i}+`:e+i}`;let o=i?` · ${i} directive ${i===1?`host`:`hosts`}`:``;return`${a} ${e===1&&!r?`instance`:`instances`}${o}`}function jD(e,t,n,r=!1,i=0){if(!n.trim())return``;if(!t)return`No components match.`;let a=e+i,o=r?`${a}+`:`${a}`,s=!r&&a===1;return`${t} of ${o} ${i?s?`row`:`rows`:s?`instance`:`instances`} match.`}function MD(e){let t=e?.components,n=e?.depth;return t&&n?`Showing the first ${t} component instances, and none nested more than ${n} elements deep. Others are not listed or searchable.`:t?`Showing the first ${t} component instances. Others are not listed or searchable.`:n?`Components nested more than ${n} elements deep are not listed or searchable.`:`The page has more components than the tree shows.`}function ND(e,t){let n=t?.detail?.id??null,r=t?.pageId??null;if(r!==e.pageId)return{pageId:r,selectedId:n,detailId:n,destroyed:null};let i=e.selectedId;return n&&n!==e.detailId&&n!==i&&(i=n),i&&t&&!t.truncated&&!PD(t.roots,i)?{pageId:r,selectedId:null,detailId:n,destroyed:i}:{pageId:r,selectedId:i,detailId:n,destroyed:null}}function PD(e,t){return e.some(e=>e.id===t||PD(e.children,t))}function FD(e,t,n){let r=e.indexOf(n);if(r<0)return null;for(let n=r+1;n=0;n--)if(t.has(e[n]))return e[n];return null}var ID=(e,t)=>t.key,LD=(e,t)=>t.id;function RD(e,t){e&1&&(R(0,`p`,2),K(1,` The HTML came from the server, but no component was hydrated, so the app rendered again on the client. Add `),R(2,`code`),K(3,`provideClientHydration()`),z(),K(4,` and use a development build to see each component's status. `),z())}function zD(e,t){if(e&1&&(R(0,`li`)(1,`span`,9),K(2),z(),K(3),z()),e&2){let e=t.$implicit;n_(`count count-`+e.key),D(2),q(e.value),D(),J(` `,e.label,` `)}}function BD(e,t){if(e&1&&(R(0,`span`,15),K(1,`Expected `),R(2,`code`),K(3),z()()),e&2){let e=W().$implicit;D(3),q(e.expected)}}function VD(e,t){if(e&1&&(R(0,`span`,15),K(1,`Found `),R(2,`code`),K(3),z()()),e&2){let e=W().$implicit;D(3),q(e.actual)}}function HD(e,t){if(e&1){let e=B();R(0,`li`)(1,`button`,12),U(`click`,function(){let t=S(e).$implicit;return C(W(4).showNode.emit(t.id))})(`focus`,function(){let t=S(e).$implicit;return C(W(4).highlight.emit(t.id))})(`blur`,function(){return S(e),C(W(4).highlight.emit(null))})(`mouseenter`,function(){let t=S(e).$implicit;return C(W(4).highlight.emit(t.id))})(`mouseleave`,function(){return S(e),C(W(4).highlight.emit(null))}),R(2,`span`,13),K(3),z(),R(4,`span`,14),K(5),z(),A(6,BD,4,1,`span`,15),A(7,VD,4,1,`span`,15),z()()}if(e&2){let e=t.$implicit;D(3),q(e.name),D(2),J(`<`,e.tag,`>`),D(),j(e.expected?6:-1),D(),j(e.actual?7:-1)}}function UD(e,t){if(e&1&&(R(0,`h3`,10),K(1,`Mismatches`),z(),R(2,`ul`,11),M(3,HD,8,4,`li`,null,LD),z()),e&2){let e=W(2);D(3),N(e.mismatches)}}function WD(e,t){if(e&1){let e=B();R(0,`div`,3)(1,`ul`,4),M(2,zD,4,4,`li`,5,ID),z(),R(4,`button`,6),U(`click`,function(){return S(e),C(W(2).toggleOverlay())}),w(),R(5,`svg`,7),dg(6,`rect`,8),z(),K(7,` Outline on page `),z()(),A(8,UD,5,0)}if(e&2){let e=W(),t=W();D(2),N(t.counts()),D(2),k(`aria-pressed`,t.overlayOn()),D(4),j(e.mismatches.length?8:-1)}}function GD(e,t){e&1&&A(0,RD,5,0,`p`,2)(1,WD,9,2),e&2&&j(+!!t.hydrated)}function KD(e,t){e&1&&(R(0,`p`,2),K(1,` This page was not rendered on the server, so there is nothing to hydrate. With server-side rendering and `),R(2,`code`),K(3,`provideClientHydration()`),z(),K(4,`, each component shows whether it was hydrated, skipped or mismatched. `),z())}var qD=class e{report=$(void 0);overlay=cv();showNode=cv();highlight=cv();overlayOn=Q(()=>this.report()?.overlay===!0);counts=X(()=>{let e=this.report()?.counts;return e?[{key:`hydrated`,label:`hydrated`,value:e.hydrated},{key:`skipped`,label:`skipped`,value:e.skipped},{key:`mismatched`,label:`mismatched`,value:e.mismatched},{key:`client`,label:`created on the client`,value:e.client},{key:`dehydratedBlocks`,label:e.dehydratedBlocks===1?`dehydrated @defer block`:`dehydrated @defer blocks`,value:e.dehydratedBlocks}]:[]});toggleOverlay(){let e=!this.overlayOn();this.overlayOn.set(e),this.overlay.emit(e)}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-hydration-section`]],inputs:{report:[1,`report`]},outputs:{overlay:`overlay`,showNode:`showNode`,highlight:`highlight`},decls:5,vars:1,consts:[[`aria-labelledby`,`ct-hydration-title`,1,`hydration`],[`id`,`ct-hydration-title`,1,`title`],[1,`empty`],[1,`summary`],[`aria-label`,`Hydration summary`,1,`counts`],[3,`class`],[`type`,`button`,1,`outline`,3,`click`],[`viewBox`,`0 0 24 24`,`aria-hidden`,`true`],[`x`,`3`,`y`,`3`,`width`,`18`,`height`,`18`,`rx`,`2`,`stroke-dasharray`,`4 3`],[1,`num`],[1,`label`],[1,`mismatches`],[`type`,`button`,1,`mismatch`,3,`click`,`focus`,`blur`,`mouseenter`,`mouseleave`],[1,`name`,`mono`],[1,`mono`,`muted`],[1,`detail-line`]],template:function(e,t){if(e&1&&(R(0,`section`,0)(1,`h2`,1),K(2,`Hydration`),z(),A(3,GD,2,1)(4,KD,5,0,`p`,2),z()),e&2){let e;D(3),j((e=t.report())?3:4,e)}},styles:[`.hydration[_ngcontent-%COMP%] { - margin-top: 16px; -} - -.title[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - margin: 0 0 8px; -} - -.label[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - margin: 12px 0 6px; -} - -.empty[_ngcontent-%COMP%] { - margin: 0; - padding: 10px 12px; - border: 1px dashed var(--%NS%border); - border-radius: var(--%NS%radius-sm); - color: var(--%NS%text-2); - line-height: 1.5; -} - -code[_ngcontent-%COMP%], -.mono[_ngcontent-%COMP%] { - font-family: var(--%NS%font-mono); -} - -code[_ngcontent-%COMP%] { - font-size: 12px; - overflow-wrap: anywhere; -} - -.muted[_ngcontent-%COMP%] { - color: var(--%NS%text-2); -} - -.summary[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; -} - -.counts[_ngcontent-%COMP%] { - display: flex; - flex: 1 1 auto; - flex-wrap: wrap; - gap: 6px; - margin: 0; - padding: 0; - list-style: none; -} - -.count[_ngcontent-%COMP%] { - padding: 2px 10px; - border: 1px solid var(--%NS%border-strong); - border-radius: 99px; - color: var(--%NS%text); - font-size: 12px; -} - -.num[_ngcontent-%COMP%] { - font-weight: 600; - font-variant-numeric: tabular-nums; -} - -.count-hydrated[_ngcontent-%COMP%] { - border-color: color-mix(in srgb, var(--%NS%ok) 30%, transparent); - background: color-mix(in srgb, var(--%NS%ok) 12%, transparent); - color: var(--%NS%ok-text); -} - -.count-skipped[_ngcontent-%COMP%], -.count-dehydratedBlocks[_ngcontent-%COMP%] { - border-color: color-mix(in srgb, var(--%NS%warn) 30%, transparent); - background: color-mix(in srgb, var(--%NS%warn) 12%, transparent); - color: var(--%NS%warn-text); -} - -.count-mismatched[_ngcontent-%COMP%] { - border-color: color-mix(in srgb, var(--%NS%danger) 30%, transparent); - background: color-mix(in srgb, var(--%NS%danger) 12%, transparent); - color: var(--%NS%danger-text); -} - -button[_ngcontent-%COMP%] { - display: inline-flex; - align-items: center; - gap: 8px; - height: var(--%NS%control-h); - padding: 0 14px; - background: var(--%NS%surface-2); - border: 1px solid var(--%NS%control-border); - border-radius: var(--%NS%radius-sm); - color: var(--%NS%text); - cursor: pointer; - font: inherit; - font-size: 13px; - font-weight: 500; - transition: background-color 150ms var(--%NS%ease), border-color 150ms var(--%NS%ease); -} - -button[_ngcontent-%COMP%]:hover { - background: var(--%NS%surface-3); - border-color: var(--%NS%accent-line); -} - -button[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.outline[aria-pressed=true][_ngcontent-%COMP%] { - background: var(--%NS%accent-soft); - border-color: var(--%NS%accent-line); - color: var(--%NS%text-strong); -} - -svg[_ngcontent-%COMP%] { - width: 14px; - height: 14px; - fill: none; - stroke: currentColor; - stroke-width: 2.2; -} - -.mismatches[_ngcontent-%COMP%] { - display: grid; - gap: 6px; - margin: 0; - padding: 0; - list-style: none; -} - -.mismatch[_ngcontent-%COMP%] { - flex-wrap: wrap; - justify-content: flex-start; - width: 100%; - height: auto; - min-height: var(--%NS%control-h); - padding: 6px 12px; - background: var(--%NS%surface); - border-color: var(--%NS%border); - text-align: left; -} - -.name[_ngcontent-%COMP%] { - color: var(--%NS%text-strong); - font-size: 12px; - font-weight: 600; -} - -.detail-line[_ngcontent-%COMP%] { - width: 100%; - color: var(--%NS%text-2); - font-size: 12px; -} - -@media (prefers-reduced-motion: reduce) { - button[_ngcontent-%COMP%] { - transition: none; - } -}`]})};function JD(e,t){if(e&1&&(R(0,`p`,0),K(1),R(2,`code`),K(3),z(),K(4,` to keep more. `),R(5,`a`,1),K(6,`About limits`),z()()),e&2){let e=W();D(),O_(` Showing the latest `,e.max(),` `,e.what(),`. `,e.dropped(),` older `,e.dropped()===1?`entry was`:`entries were`,` dropped. Raise `),D(2),J(`limits.`,e.limit()),D(2),V(`href`,e.docsUrl,Du)}}var YD=`https://pangular-inspector.dev/getting-started/configuration/#limits`,XD=class e{dropped=$(0);max=$.required();what=$.required();limit=$.required();docsUrl=YD;static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-limit-note`]],inputs:{dropped:[1,`dropped`],max:[1,`max`],what:[1,`what`],limit:[1,`limit`]},decls:1,vars:1,consts:[[1,`note`],[`target`,`_blank`,`rel`,`noopener noreferrer`,3,`href`]],template:function(e,t){e&1&&A(0,JD,7,6,`p`,0),e&2&&j(t.dropped()>0?0:-1)},styles:[`[_nghost-%COMP%] { - display: block; -} - -.note[_ngcontent-%COMP%] { - margin: 0 0 12px; - padding: 8px 12px; - border: 1px solid var(--%NS%border); - border-radius: var(--%NS%radius-sm); - background: var(--%NS%surface); - color: var(--%NS%text-2); - line-height: 1.5; -} - -code[_ngcontent-%COMP%] { - font-family: var(--%NS%font-mono); - color: var(--%NS%text); -} - -a[_ngcontent-%COMP%] { - color: var(--%NS%accent); - border-radius: 2px; -} - -a[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -}`]})},ZD=1e3;function QD(e){return typeof e==`number`&&Number.isFinite(e)&&e>0&&e<=ZD?e:16}var $D=864e13,eO=200,tO=300,nO=100,rO=10,iO=100,aO=2e3,oO=2e3,sO=4e3,cO=1e3,lO=100,uO=3,dO=50,fO={component:`c`,hook:`h`,afterRender:`r`,output:`o`},pO=Object.values(fO),mO=e=>!!e&&typeof e==`object`&&!Array.isArray(e),hO=e=>typeof e==`number`&&Number.isFinite(e)&&e>=0?e:0,gO=(e,t)=>typeof e==`string`?e.slice(0,t):``,_O=e=>hO(e)>0?hO(e):void 0,vO=(e,t)=>Array.isArray(e)?e.slice(0,t):[];function yO(e){if(!mO(e))return null;let t=gO(e.name,eO);return t?{name:t,checks:hO(e.checks),ms:hO(e.ms)}:null}function bO(e,t){if(!Array.isArray(e)||e.length!==5)return null;let[n,r,i,a,o]=e;return typeof n!=`number`||!Number.isInteger(n)||n<0||n>=t||typeof r!=`number`||!Number.isFinite(r)||Math.abs(r)>1e7||typeof a!=`number`||!Number.isInteger(a)||a<0||a>cO||typeof o!=`string`||!pO.includes(o)?null:[n,r,hO(i),a,o]}function xO(e,t,n){if(!mO(e)||typeof e.id!=`number`)return null;let r=vO(e.components,rO).map(yO).filter(e=>!!e),i=gO(e.trigger,tO),a=vO(e.spans,40).map(e=>bO(e,t)).filter(e=>!!e),o=Array.isArray(e.spans)?e.spans.length-40:0,s=Math.floor(hO(e.droppedSpans))+Math.max(0,o),c={t:typeof e.t==`number`?hO(e.t):void 0,hookMs:_O(e.hookMs),afterRenderMs:_O(e.afterRenderMs),outputMs:_O(e.outputMs)},l=TO(e.cause),u=_O(e.refreshed),d=_O(e.unchanged);return{id:hO(e.id),at:hO(e.at),ms:hO(e.ms),passes:hO(e.passes),...i?{trigger:i}:{},checks:hO(e.checks),components:r,...Object.fromEntries(Object.entries(c).filter(([,e])=>e!==void 0)),...a.length?{spans:a}:{},...s?{droppedSpans:s}:{},...l?{cause:l}:{},...u?{refreshed:u}:{},...d?{unchanged:d}:{},...hO(e.ms)>n?{slow:!0}:{}}}var SO=[`event`,`http`,`timer`,`signal`,`effect`,`markForCheck`,`input`,`view`,`tick`,`zone`,`unknown`],CO={event:`Event`,http:`HTTP response`,timer:`Timer`,signal:`Signal write`,effect:`Effect`,markForCheck:`markForCheck`,input:`setInput`,view:`View change`,tick:`ApplicationRef.tick`,zone:`zone.js only`,unknown:`Unknown`},wO=e=>typeof e==`string`&&SO.includes(e);function TO(e){if(!mO(e)||!wO(e.kind))return;let t=[`component`,`listener`,`event`,`target`,`signal`,`task`],n={kind:e.kind};wO(e.via)&&(n.via=e.via);for(let r of t){let t=gO(e[r],eO);t&&(n[r]=t)}e.zone===!0&&(n.zone=!0);let r=vO(e.stack,uO).map(e=>gO(e,tO)).filter(Boolean);r.length&&(n.stack=r);let i=Math.floor(hO(e.more));return i&&(n.more=i),n}function EO(e){if(!mO(e))return null;let t=gO(e.name,eO);if(!t)return null;let n=e.strategy;return{name:t,refreshed:Math.floor(hO(e.refreshed)),unchanged:Math.floor(hO(e.unchanged)),...n===`OnPush`||n===`Eager`?{strategy:n}:{}}}function DO(e){if(!mO(e))return null;let t=gO(e.name,eO),n=gO(e.input,eO);if(!t||!n)return null;let r=gO(e.parent,eO);return{name:t,input:n,...r?{parent:r}:{},inPlace:Math.floor(hO(e.inPlace)),sameContents:Math.floor(hO(e.sameContents))}}function OO(e){if(!mO(e))return null;let t=gO(e.name,eO);if(!t)return null;let n=vO(e.tasks,3).map(e=>gO(e,eO)).filter(Boolean);return{name:t,ticks:Math.floor(hO(e.ticks)),...n.length?{tasks:n}:{}}}function kO(e){if(!mO(e))return;let t={};if(mO(e.causes))for(let n of SO){let r=Math.floor(hO(e.causes[n]));r&&(t[n]=r)}let n=vO(e.components,iO).map(EO).filter(e=>!!e),r=vO(e.inputs,dO).map(DO).filter(e=>!!e),i=vO(e.zone,dO).map(OO).filter(e=>!!e),a=Math.floor(hO(e.major));return{triggers:e.triggers===!0,waste:e.waste===!0,...a&&a<1e3?{major:a}:{},causes:t,components:n,...r.length?{inputs:r}:{},...i.length?{zone:i}:{},...typeof e.zoneJs==`boolean`?{zoneJs:e.zoneJs}:{}}}function AO(e){let t=[],n=e.component?` in ${e.component}${e.listener?` (${e.listener})`:``}`:``;switch(e.kind){case`event`:t.push(`${e.event??`Template listener`}${e.target?` on ${e.target}`:``}${n}`);break;case`http`:t.push(`HTTP response${n}`);break;case`timer`:t.push(`${e.task??`Timer`}${n}`);break;case`zone`:t.push(`zone.js tick with no Angular notification${e.task?` after ${e.task}`:``}`);break;case`tick`:t.push(`ApplicationRef.tick()${n}`);break;default:t.push(`${CO[e.kind]}${n}`)}return e.signal?t.push(e.signal===`?`?`wrote an unnamed signal`:`wrote signal ${e.signal}`):e.via&&e.via!==e.kind&&t.push(`via ${CO[e.via]}`),e.zone&&e.kind!==`zone`&&t.push(`run by zone.js`),e.more&&t.push(`${e.more} more notification${e.more===1?``:`s`}`),t.join(`, `)}function jO(e){if(!e)return[];let t=[],n=(e.major??0)>=22;for(let r of e.components){if(r.refreshed<10)continue;let e=r.refreshed-r.unchanged;if(e/r.refreshed>.2)continue;let i=`refreshed ${r.refreshed} times and changed something in ${e}`;r.strategy===`Eager`?t.push({name:r.name,kind:`on-push`,text:n?`${r.name} was ${i}. It opts out of OnPush, which is the default from Angular 22: remove \`changeDetection: ChangeDetectionStrategy.Eager\` (or \`Default\`) so Angular skips it until an input, event, signal or markForCheck marks it.`:`${r.name} was ${i}. Add \`changeDetection: ChangeDetectionStrategy.OnPush\` so Angular skips it until an input, event, signal or markForCheck marks it.`}):r.strategy===`OnPush`&&t.push({name:r.name,kind:`already-on-push`,text:`${r.name} was ${i}, and it is already OnPush${n?` (the default from Angular 22)`:``}. Something keeps marking it: an input that gets a new reference each time, an event or output in it, markForCheck, or a signal it reads that is set to an equal value.`})}for(let n of e.inputs??[]){let e=n.parent?` set by ${n.parent}`:``;n.inPlace&&t.push({name:n.name,kind:`in-place`,text:`Input ${n.input} of ${n.name}${e} changed in place ${n.inPlace} time${n.inPlace===1?``:`s`}: same reference, different contents. OnPush components, computed and signal equality don't see that. Pass a new object or array instead of mutating it.`}),n.sameContents&&t.push({name:n.name,kind:`same-contents`,text:`Input ${n.input} of ${n.name}${e} got a new reference with the same contents ${n.sameContents} time${n.sameContents===1?``:`s`}, so Angular refreshed ${n.name} for nothing. Keep the reference when nothing changed, for example with a computed instead of a method call in the template.`})}for(let n of e.zone??[]){let e=n.tasks?.length?` (after ${n.tasks.join(`, `)})`:``;t.push({name:n.name,kind:`zone`,text:`${n.name} changed in ${n.ticks} tick${n.ticks===1?``:`s`} that only zone.js started${e}. Without zone.js it would not update: keep that state in a signal, or call markForCheck after changing it.`})}return t}function MO(e){let t=yO(e);return!t||!mO(e)?null:{...t,maxMs:hO(e.maxMs),cycles:hO(e.cycles)}}function NO(e){if(!mO(e))return null;let t=gO(e.name,eO),n=gO(e.hook,nO);if(!t||!n)return null;let{calls:r,ms:i,maxMs:a}=e;return{name:t,hook:n,calls:hO(r),ms:hO(i),maxMs:hO(a)}}function PO(e){if(!mO(e))return null;let t=gO(e.name,eO);if(!t)return null;let n=gO(e.listener,eO);return{name:t,...n?{listener:n}:{},calls:hO(e.calls),ms:hO(e.ms),maxMs:hO(e.maxMs)}}function FO(e){if(mO(e)&&hO(e.runs))return{runs:hO(e.runs),ms:hO(e.ms),maxMs:hO(e.maxMs)}}function IO(e,t){if(!mO(e))return null;let n=vO(e.spanNames,sO).map(e=>gO(e,tO)),r=QD(e.slowMs),i=Array.isArray(e.cycles)?e.cycles.slice(-Math.min(t,oO)).map(e=>xO(e,n.length,r)).filter(e=>!!e):[],a={},o=0;if(mO(e.hosts)){let t=Object.entries(e.hosts);o=Math.max(0,t.length-aO);for(let[e,n]of t.slice(0,aO))e.length<50&&hO(n)&&(a[e]=hO(n))}let s=Array.isArray(e.components)?e.components:[],c=Math.floor(hO(e.droppedClasses))+Math.max(0,s.length-iO),l=Math.floor(hO(e.droppedHosts))+o,u=vO(e.hooks,lO).map(NO).filter(e=>!!e),d=vO(e.outputs,lO).map(PO).filter(e=>!!e),f=Math.max(0,vO(e.hooks,1/0).length-lO)+Math.max(0,vO(e.outputs,1/0).length-lO),p=Math.floor(hO(e.droppedTimings))+f,m=FO(e.afterRender),ee=i.some(e=>e.spans?.length),h=kO(e.diagnosis);return{recording:e.recording===!0,startedAt:typeof e.startedAt==`number`&&e.startedAt<=$D?hO(e.startedAt):null,dropped:hO(e.dropped),slowMs:r,cycles:i,components:s.slice(0,iO).map(MO).filter(e=>!!e),...c?{droppedClasses:c}:{},hosts:a,...l?{droppedHosts:l}:{},...u.length?{hooks:u}:{},...d.length?{outputs:d}:{},...p?{droppedTimings:p}:{},...m?{afterRender:m}:{},...ee?{spanNames:n}:{},...h?{diagnosis:h}:{}}}var LO=`pangular-inspector/change-detection`;function RO(e,t,n=Date.now()){return{format:LO,version:1,exportedAt:n,pageId:t,recording:{...IO(e,oO)??e,recording:!1,hosts:{}}}}function zO(e,t){if(e.length>2e7||e.length*3>2e7&&new TextEncoder().encode(e).byteLength>2e7)return{ok:!1,error:`The file is larger than 20 MB.`};let n;try{n=JSON.parse(e)}catch{return{ok:!1,error:`The file is not valid JSON.`}}if(!mO(n))return{ok:!1,error:`The file is not a recording.`};if(n.format!==void 0&&n.format!==`pangular-inspector/change-detection`)return{ok:!1,error:`The file is not a change detection recording.`};let r=mO(n.recording)?n.recording:n;if(!Array.isArray(r.cycles))return{ok:!1,error:`The file has no change detection cycles.`};let i=IO(r,t);if(!i)return{ok:!1,error:`The file is not a recording.`};let a=gO(n.pageId??r.pageId,49)||`imported`,o=_O(n.exportedAt);return{ok:!0,pageId:a,...o?{exportedAt:o}:{},recording:{...i,recording:!1}}}var BO=1,VO=1,HO=2,UO={c:`component`,h:`lifecycle-hook`,r:`after-render`,o:`output`},WO=e=>Math.round(e*1e6)/1e3;function GO(e,t){let n=e.spanNames??[],r=e.cycles[0]?.at??0,i=e.startedAt??r,a=[],o=0,s=0;for(let t of e.cycles){let e=t.t??Math.max(0,t.at-i),r=e+t.ms;a.push({name:`Change detection #${t.id}`,cat:`change-detection`,ph:`X`,pid:BO,tid:VO,ts:WO(e),dur:WO(t.ms),args:{checks:t.checks,passes:t.passes,...t.trigger?{trigger:t.trigger}:{},...t.hookMs?{hookMs:t.hookMs}:{},...t.afterRenderMs?{afterRenderMs:t.afterRenderMs}:{},...t.outputMs?{outputMs:t.outputMs}:{},...t.cause?{cause:AO(t.cause)}:{},...t.unchanged?{unchanged:t.unchanged}:{},slowest:t.components.map(e=>`${e.name} ${e.checks}x ${e.ms}ms`)}}),s+=t.droppedSpans??0;let c=[{end:r}],l=[...t.spans??[]].sort((e,t)=>e[1]-t[1]||t[2]-e[2]);for(let[r,i,s,u,d]of l){o++;let l=n[r]??`?`;if(d===fO.output){let n=e+i;a.push({name:l,cat:UO[d],ph:`X`,pid:BO,tid:HO,ts:WO(n),dur:WO(i<0?Math.max(0,Math.min(s,-i)):s),args:{cycle:t.id}});continue}let f=Math.max(e,e+i);for(;c.length>1&&c.at(-1).end<=f;)c.pop();let p=c.at(-1).end,m=Math.min(f+s,p);me.ts??0));if(c<0)for(let e of a)e.ts=WO((e.ts-c)/1e3);a.sort((e,t)=>e.ts-t.ts||t.dur-e.dur);let l=(e,t,n)=>({name:t,ph:`M`,pid:BO,tid:e,args:{name:n}});return{traceEvents:[l(0,`process_name`,`Angular change detection (page ${t})`),l(VO,`thread_name`,`Change detection`),l(HO,`thread_name`,`Outputs`),...a],displayTimeUnit:`ms`,otherData:{source:`Pangular Inspector change detection recording`,pageId:t,cycles:e.cycles.length,droppedCycles:e.dropped,spans:o,droppedSpans:s,...e.startedAt&&e.startedAt<=$D?{startedAt:new Date(e.startedAt).toISOString()}:{}}}}function KO(e,t=16){return e.ms>t}function qO(e){return`${e<10?e.toFixed(2):Math.round(e)} ms`}function JO(e,t){if(!t)return[...e];let n=Math.min(t.from,t.to),r=Math.max(t.from,t.to),i=e.filter(e=>e.id>=n&&e.id<=r);return i.length?i:[...e]}function YO(e){let t=new Map;for(let n of e)for(let e of n.components){let n=t.get(e.name);n?(n.checks+=e.checks,n.ms+=e.ms,n.maxMs=Math.max(n.maxMs,e.ms),n.cycles+=1):t.set(e.name,{name:e.name,checks:e.checks,ms:e.ms,maxMs:e.ms,cycles:1})}return[...t.values()].sort((e,t)=>t.ms-e.ms||e.name.localeCompare(t.name))}function XO(e,t=16){let n=e.reduce((e,t)=>Math.max(e,t.ms),0);return Math.max(n,t*1.25)}function ZO(e,t,n=3){let r=e.map(e=>({item:e,value:t(e)})).filter(e=>e.value>0).sort((e,t)=>t.value-e.value),i=r.reduce((e,t)=>e+t.value,0);if(!i)return[];let a=n/i,o=[],s=0,c=0,l=n,u=1,d=[],f=(e,t)=>{let n=e.reduce((e,t)=>e+t,0),r=Math.max(...e),i=Math.min(...e);return Math.max(t*t*r/(n*n),n*n/(t*t*i))},p=()=>{let e=d.reduce((e,t)=>e+t.area,0);if(l>=u){let t=e/u,n=c;for(let e of d){let r=e.area/t;o.push({item:e.item,x:s,y:n,w:t,h:r}),n+=r}s+=t,l-=t}else{let t=e/l,n=s;for(let e of d){let r=e.area/t;o.push({item:e.item,x:n,y:c,w:r,h:t}),n+=r}c+=t,u-=t}d=[]};for(let e of r){let t=e.value*a,n=Math.min(l,u),r=d.map(e=>e.area);d.length&&f([...r,t],n)>f(r,n)&&p(),d.push({item:e.item,area:t})}return d.length&&p(),o.map(e=>({item:e.item,x:e.x/n*100,y:e.y*100,w:e.w/n*100,h:e.h*100}))}var QO={c:`component`,h:`hook`,r:`afterRender`,o:`output`};function $O(e,t){if(t===`c`)return e||null;if(t===`h`){let t=e.lastIndexOf(`.`);return t>0?e.slice(0,t):null}if(t===`o`){let t=e.indexOf(` (`),n=t>0?e.slice(0,t):e;return n&&n!==`?`?n:null}return null}function ek(e,t){let n=e.filter(e=>e.spans?.length);if(!n.length)return null;let r=n.map(e=>{let t=e.spans,n=Math.min(0,...t.map(e=>e[1])),r=Math.max(e.ms,...t.map(e=>e[1]+e[2]));return{cycle:e,spans:t,from:n,width:Math.max(r-n,.001)}}),i=r.reduce((e,t)=>e+t.width,0),a=[],o=[],s=0,c=0;for(let{cycle:e,spans:n,from:l,width:u}of r){o.push({id:e.id,x:s/i*100,w:u/i*100,droppedSpans:e.droppedSpans??0});let r=[...n].sort((e,t)=>e[1]-t[1]||e[3]-t[3]),d=[];for(let[n,o,u,f,p]of r){let r=t[n]??`unknown`,m=d[f-1],ee=f>0&&m!==void 0&&ot.kind,nk=(e,t)=>t.name,rk=(e,t)=>t.name+`.`+t.input;function ik(e,t){e&1&&(R(0,`p`,3),K(1,` The page has no Angular change detection scheduler the overlay could find (it needs Angular 18 or later), so cycles have no cause. `),z())}function ak(e,t){if(e&1&&(R(0,`tr`)(1,`td`),K(2),z(),R(3,`td`,6),K(4),z()()),e&2){let e=t.$implicit;D(2),q(e.label),D(2),q(e.count)}}function ok(e,t){if(e&1&&(R(0,`table`)(1,`thead`)(2,`tr`)(3,`th`,4),K(4,`Trigger`),z(),R(5,`th`,5),K(6,`Cycles`),z()()(),R(7,`tbody`),M(8,ak,5,2,`tr`,null,tk),z()()),e&2){let e=W(2);D(8),N(e.causes())}}function sk(e,t){e&1&&(R(0,`p`,3),K(1,`No cycle has a cause yet.`),z())}function ck(e,t){if(e&1&&(R(0,`tr`)(1,`td`,7),K(2),z(),R(3,`td`,6),K(4),z(),R(5,`td`,7),K(6),z()()),e&2){let e=t.$implicit;D(2),q(e.name),D(2),q(e.ticks),D(2),q(e.tasks?.join(`, `))}}function lk(e,t){if(e&1&&(R(0,`table`)(1,`thead`)(2,`tr`)(3,`th`,4),K(4,`Component`),z(),R(5,`th`,5),K(6,`Zone-only ticks`),z(),R(7,`th`,4),K(8,`After`),z()()(),R(9,`tbody`),M(10,ck,7,3,`tr`,null,nk),z()()),e&2){let e=W(3);D(10),N(e.zone())}}function uk(e,t){if(e&1&&(R(0,`p`,3),K(1),z()),e&2){let e=W(3);D(),q(e.zoneNote())}}function dk(e,t){if(e&1&&(R(0,`div`)(1,`h4`),K(2,`Depends on zone.js`),z(),A(3,lk,12,0,`table`)(4,uk,2,1,`p`,3),z()),e&2){let e=W(),t=W();D(3),j(t.zone().length?3:e.triggers?4:-1)}}function fk(e,t){e&1&&(R(0,`p`,3),K(1,`The page can't watch DOM updates, so wasted checks are not counted.`),z())}function pk(e,t){if(e&1&&(R(0,`tr`)(1,`td`,7),K(2),z(),R(3,`td`,6),K(4),z(),R(5,`td`,6),K(6),z(),R(7,`td`),K(8),z()()),e&2){let e=t.$implicit,n=W(3);D(2),q(e.name),D(2),q(e.refreshed),D(2),Y(``,e.unchanged,` (`,n.share(e.unchanged,e.refreshed),`)`),D(2),q(e.strategy??`Unknown`)}}function mk(e,t){if(e&1&&(R(0,`table`)(1,`thead`)(2,`tr`)(3,`th`,4),K(4,`Component`),z(),R(5,`th`,5),K(6,`Refreshed`),z(),R(7,`th`,5),K(8,`Changed nothing`),z(),R(9,`th`,4),K(10,`Strategy`),z()()(),R(11,`tbody`),M(12,pk,9,5,`tr`,null,nk),z()()),e&2){let e=W(2);D(12),N(e.wasted())}}function hk(e,t){e&1&&(R(0,`p`,3),K(1,`Every refreshed check changed something.`),z())}function gk(e,t){if(e&1&&(R(0,`tr`)(1,`td`,7),K(2),z(),R(3,`td`,7),K(4),z(),R(5,`td`,6),K(6),z(),R(7,`td`,6),K(8),z()()),e&2){let e=t.$implicit;D(2),Y(``,e.name,`.`,e.input),D(2),q(e.parent??`Unknown`),D(2),q(e.inPlace),D(2),q(e.sameContents)}}function _k(e,t){if(e&1&&(R(0,`h4`),K(1,`Inputs that break OnPush`),z(),R(2,`table`)(3,`thead`)(4,`tr`)(5,`th`,4),K(6,`Input`),z(),R(7,`th`,4),K(8,`Set by`),z(),R(9,`th`,5),K(10,`Changed in place`),z(),R(11,`th`,5),K(12,`Same contents, new reference`),z()()(),R(13,`tbody`),M(14,gk,9,5,`tr`,null,rk),z()()),e&2){let e=W(2);D(14),N(e.inputs())}}function vk(e,t){if(e&1&&(R(0,`li`),K(1),z()),e&2){let e=t.$implicit;D(),q(e.text)}}function yk(e,t){if(e&1&&(R(0,`h4`),K(1,`Hints`),z(),R(2,`ul`,8),M(3,vk,2,1,`li`,null,$h),z()),e&2){let e=W(2);D(3),N(e.hints())}}function bk(e,t){if(e&1&&(R(0,`section`,0)(1,`h3`,1),K(2,`Why it ran and what it wasted`),z(),R(3,`div`,2)(4,`div`)(5,`h4`),K(6,`What started the cycles`),z(),A(7,ik,2,0,`p`,3)(8,ok,10,0,`table`)(9,sk,2,0,`p`,3),z(),A(10,dk,5,1,`div`),z(),R(11,`h4`),K(12,`Wasted checks`),z(),A(13,fk,2,0,`p`,3)(14,mk,14,0,`table`)(15,hk,2,0,`p`,3),A(16,_k,16,0),A(17,yk,5,0),z()),e&2){let e=t,n=W();D(7),j(e.triggers?n.causes().length?8:9:7),D(3),j(n.hasZone()?10:-1),D(3),j(e.waste?n.wasted().length?14:15:13),D(3),j(n.inputs().length?16:-1),D(),j(n.hints().length?17:-1)}}var xk=10,Sk=class e{page=$(null);diagnosis=X(()=>this.page()?.diagnosis??null);causes=X(()=>{let e=this.diagnosis()?.causes??{};return SO.filter(t=>e[t]).map(t=>({kind:t,label:CO[t],count:e[t]}))});zone=X(()=>(this.diagnosis()?.zone??[]).slice(0,xk));zoneTicks=X(()=>this.diagnosis()?.causes.zone??0);hasZone=X(()=>this.diagnosis()?.zoneJs??(this.zone().length>0||this.zoneTicks()>0));zoneNote=X(()=>{let e=this.zoneTicks();return e?`${e} ${e===1?`tick`:`ticks`} came from zone.js alone, and no component changed in them.`:`No component changed in a tick only zone.js started.`});wasted=X(()=>(this.diagnosis()?.components??[]).filter(e=>e.unchanged>0).slice(0,xk));inputs=X(()=>(this.diagnosis()?.inputs??[]).slice(0,xk));hints=X(()=>jO(this.diagnosis()??void 0).slice(0,xk));share(e,t){return t?`${Math.round(e/t*100)}%`:`0%`}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-cd-diagnosis`]],inputs:{page:[1,`page`]},decls:1,vars:1,consts:[[`aria-labelledby`,`cd-diagnosis-heading`,1,`diagnosis`],[`id`,`cd-diagnosis-heading`],[1,`grid`],[1,`note`],[`scope`,`col`],[`scope`,`col`,1,`num`],[1,`num`],[1,`mono`],[1,`hints`]],template:function(e,t){if(e&1&&A(0,bk,18,5,`section`,0),e&2){let e;j((e=t.diagnosis())?0:-1,e)}},styles:[`[_nghost-%COMP%] { - display: block; -} - -h3[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - margin: 16px 0 6px; -} - -h4[_ngcontent-%COMP%] { - margin: 12px 0 6px; - color: var(--%NS%text-2); - font-size: 12px; - font-weight: 600; -} - -.grid[_ngcontent-%COMP%] { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 0 16px; -} - -@media (min-width: 880px) { - .grid[_ngcontent-%COMP%] { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - } -} -table[_ngcontent-%COMP%] { - width: 100%; - border-collapse: collapse; - font-size: 12px; -} - -th[_ngcontent-%COMP%], -td[_ngcontent-%COMP%] { - padding: 4px 6px; - border-bottom: 1px solid var(--%NS%border); - text-align: left; -} - -th[_ngcontent-%COMP%] { - color: var(--%NS%text-2); - font-weight: 500; -} - -td.mono[_ngcontent-%COMP%] { - overflow-wrap: anywhere; -} - -.num[_ngcontent-%COMP%] { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.mono[_ngcontent-%COMP%] { - font-family: var(--%NS%font-mono); -} - -.note[_ngcontent-%COMP%] { - margin: 4px 0 8px; - color: var(--%NS%text-2); - font-size: 12px; - line-height: 1.5; -} - -.hints[_ngcontent-%COMP%] { - margin: 0; - padding-left: 18px; - color: var(--%NS%text); - font-size: 12px; - line-height: 1.5; -} - -.hints[_ngcontent-%COMP%] li[_ngcontent-%COMP%] + li[_ngcontent-%COMP%] { - margin-top: 4px; -}`]})},Ck=(e,t)=>t.cycle.id,wk=(e,t)=>t.id;function Tk(e,t){if(e&1){let e=B();R(0,`button`,27),U(`click`,function(){return S(e),C(W().showAll())}),K(1,`Show all cycles`),z()}}function Ek(e,t){if(e&1&&dg(0,`div`,28),e&2){let e=t;t_(`left`,e.left,`%`)(`width`,e.width,`%`)}}function Dk(e,t){if(e&1&&(R(0,`div`,29),dg(1,`span`,30),z()),e&2){let e=t.$implicit,n=W();G(`over`,e.over)(`out`,!n.inRange(e.index)&&!!n.range())(`active`,n.focused()&&n.active()===e.index),V(`id`,n.optionId(e.index)),k(`data-index`,e.index)(`aria-selected`,n.inRange(e.index))(`aria-label`,e.label),D(),t_(`height`,e.height,`%`)}}function Ok(e,t){e&1&&(R(0,`span`,33),K(1,`over budget`),z())}function kk(e,t){if(e&1&&(R(0,`p`),K(1,` after `),R(2,`span`,35),K(3),z()()),e&2){let e=W();D(3),q(e.cycle.trigger)}}function Ak(e,t){if(e&1&&(R(0,`p`,34)(1,`span`,36),K(2),z(),R(3,`span`),K(4),z()()),e&2){let e=t.$implicit,n=W(2);D(2),q(e.name),D(2),q(n.fmt(e.ms))}}function jk(e,t){if(e&1&&(R(0,`div`,31)(1,`p`,32)(2,`strong`),K(3),z(),R(4,`span`),K(5),z(),A(6,Ok,2,0,`span`,33),z(),A(7,kk,4,1,`p`),R(8,`p`),K(9),z(),M(10,Ak,5,2,`p`,34,$h),z()),e&2){let e=t,n=W();t_(`left`,e.left)(`right`,e.right),D(3),q(n.fmt(e.cycle.ms)),D(2),J(`cycle `,e.cycle.id),D(),j(e.over?6:-1),D(),j(e.cycle.trigger?7:-1),D(2),O_(` `,e.cycle.checks,` `,e.cycle.checks===1?`check`:`checks`,`, `,e.cycle.passes,` `,e.cycle.passes===1?`pass`:`passes`,` `),D(),N(e.top)}}function Mk(e,t){if(e&1&&dg(0,`span`),e&2){let e=t.$implicit;G(`over`,e.over)}}function Nk(e,t){if(e&1&&K(0),e&2){let e=t,n=W(2);Y(` `,e.name,` (`,n.fmt(e.ms),`) `)}}function Pk(e,t){if(e&1&&(R(0,`tr`)(1,`td`,37),K(2),z(),R(3,`td`,37),K(4),z(),R(5,`td`),K(6),z(),R(7,`td`,37),K(8),z(),R(9,`td`,37),K(10),z(),R(11,`td`,35),K(12),z(),R(13,`td`,35),A(14,Nk,1,2),z()()),e&2){let e,n=t.$implicit,r=W();D(2),q(n.id),D(2),q(r.fmt(n.ms)),D(2),q(r.isOver(n)?`Over`:`Within`),D(2),q(n.checks),D(2),q(n.passes),D(2),q(n.trigger??``),D(2),j((e=n.components[0])?14:-1,e)}}var Fk=0,Ik=80,Lk=10,Rk=class e{cycles=$([]);range=pv(null);slowMs=$(16);gapUntil=Ik;budgetLabel=X(()=>`${this.slowMs()} ms budget`);uid=`cd-chart-${Fk++}`;hintId=`${this.uid}-hint`;active=T(null);anchor=null;dragging=!1;hover=T(null);focused=T(!1);max=X(()=>XO(this.cycles(),this.slowMs()));budgetAt=X(()=>this.slowMs()/this.max()*100);bars=X(()=>{let e=this.max();return this.cycles().map((t,n)=>({cycle:t,index:n,height:t.ms/e*100,over:KO(t,this.slowMs()),label:this.describe(t)}))});firstId=X(()=>this.cycles()[0]?.id??0);lastId=X(()=>this.cycles().at(-1)?.id??0);span=X(()=>{let e=this.range();if(!e)return null;let t=this.cycles(),n=Math.min(e.from,e.to),r=Math.max(e.from,e.to),i=t.findIndex(e=>e.id>=n),a=-1;for(let e=t.length-1;e>=0;e--)if(t[e].id<=r){a=e;break}return i<0||aJO(this.cycles(),this.range()));startId=X(()=>this.selected()[0]?.id??``);endId=X(()=>this.selected().at(-1)?.id??``);band=X(()=>{let e=this.span(),t=this.cycles().length;return!e||!t?null:{left:e.start/t*100,width:(e.end-e.start+1)/t*100}});summary=X(()=>{let e=this.selected(),t=this.slowMs(),n=e.filter(e=>KO(e,t)).length;return`${this.span()?e.length===1?`Cycle ${e[0].id}`:`Cycles ${e[0].id} to ${e.at(-1).id} (${e.length})`:`All ${e.length} ${e.length===1?`cycle`:`cycles`}`}. ${n} over the ${t} ms budget.`});tableLabel=X(()=>this.span()?`Picked cycles, ${this.summary()}`:`All recorded cycles`);activeOptionId=X(()=>{let e=this.active();return this.focused()&&e!==null&&e{let e=this.hover()??(this.focused()?this.active():null),t=e===null?void 0:this.cycles()[e];return!t||e===null?null:{cycle:t,over:KO(t,this.slowMs()),top:t.components.slice(0,3),...this.tipSide(e)}});tipSide(e){let t=this.cycles().length;return e+.5=t.start&&e<=t.end}describe(e){let t=[`Cycle ${e.id}`,qO(e.ms)];KO(e,this.slowMs())&&t.push(`over budget`),e.trigger&&t.push(`after ${e.trigger}`),t.push(`${e.checks} ${e.checks===1?`check`:`checks`}`);let n=e.components[0];return n&&t.push(`slowest ${n.name} ${qO(n.ms)}`),t.join(`, `)}select(e,t){let n=this.cycles(),r=n[Math.min(e,t)],i=n[Math.max(e,t)];r&&i&&this.range.set({from:r.id,to:i.id})}showAll(){this.range.set(null),this.anchor=null}setEnd(e,t){if(!this.cycles().length)return;let n=t.target,r=Number.parseInt(n.value,10);if(Number.isNaN(r)){n.value=String(e===`from`?this.startId():this.endId());return}let i=Math.min(Math.max(r,this.firstId()),this.lastId()),a=this.selected(),o=e===`from`?i:a[0]?.id??this.firstId(),s=e===`to`?i:a.at(-1)?.id??this.lastId();this.range.set({from:Math.min(o,s),to:Math.max(o,s)}),n.value=String(e===`from`?Math.min(o,s):Math.max(o,s))}onFocus(){this.focused.set(!0),(this.active()===null||this.active()>=this.cycles().length)&&this.active.set(this.span()?.start??Math.max(this.cycles().length-1,0)),this.anchor??=this.active()}onKey(e){let t=this.cycles().length;if(!t)return;let n=Math.min(this.active()??t-1,t-1),r=null;switch(e.key){case`ArrowRight`:case`ArrowDown`:r=n+1;break;case`ArrowLeft`:case`ArrowUp`:r=n-1;break;case`PageDown`:r=n+Lk;break;case`PageUp`:r=n-Lk;break;case`Home`:r=0;break;case`End`:r=t-1;break;case`Enter`:case` `:e.preventDefault(),this.anchor=n,this.select(n,n);return;case`Escape`:this.range()&&(e.preventDefault(),this.showAll());return;default:return}e.preventDefault(),r=Math.min(Math.max(r,0),t-1),this.active.set(r),this.hover.set(null),e.shiftKey?(this.anchor??=n,this.select(this.anchor,r)):this.anchor=r}indexAt(e){let t=e.target?.closest?.(`[data-index]`);if(t)return Number(t.getAttribute(`data-index`));let n=e.currentTarget?.getBoundingClientRect(),r=this.cycles().length;if(!n?.width||!r)return null;let i=(e.clientX-n.left)/n.width;return Math.min(Math.max(Math.floor(i*r),0),r-1)}startDrag(e){if(e.button!==0)return;let t=this.indexAt(e);t!==null&&(this.dragging=!0,this.anchor=t,this.active.set(t),this.select(t,t))}onPointerMove(e){let t=this.indexAt(e);this.hover.set(t),this.dragging&&t!==null&&this.anchor!==null&&(this.active.set(t),this.select(this.anchor,t))}endDrag(){this.dragging=!1}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-cd-cycle-chart`]],hostBindings:function(e,t){e&1&&H(`pointerup`,function(){return t.endDrag()},Ou)(`pointercancel`,function(){return t.endDrag()},Ou)},inputs:{cycles:[1,`cycles`],range:[1,`range`],slowMs:[1,`slowMs`]},outputs:{range:`rangeChange`},decls:69,vars:25,consts:[[1,`range`],[`type`,`number`,`inputmode`,`numeric`,3,`change`,`min`,`max`,`value`],[`type`,`button`],[1,`summary`],[1,`hint`,3,`id`],[1,`plot`],[`aria-hidden`,`true`,1,`axis`],[1,`tick`,`top`],[1,`tick`,`zero`],[1,`area`],[`aria-hidden`,`true`,1,`band`,3,`left`,`width`],[`aria-hidden`,`true`,1,`budget`],[`role`,`listbox`,`aria-multiselectable`,`true`,`aria-orientation`,`horizontal`,`tabindex`,`0`,1,`bars`,3,`keydown`,`focus`,`blur`,`pointerdown`,`pointermove`,`pointerleave`],[`role`,`option`,1,`col`,3,`id`,`over`,`out`,`active`],[`aria-hidden`,`true`,1,`tip`,3,`left`,`right`],[`aria-hidden`,`true`,1,`markers`],[3,`over`],[`aria-label`,`Chart key`,1,`legend`],[`aria-hidden`,`true`,1,`key`,`ok`],[`aria-hidden`,`true`,1,`key`,`over`],[`aria-hidden`,`true`,1,`key-marker`],[`aria-hidden`,`true`,1,`key`,`line`],[1,`table`],[`tabindex`,`0`,`role`,`region`,1,`table-wrap`],[1,`sr-only`],[`scope`,`col`,1,`num`],[`scope`,`col`],[`type`,`button`,3,`click`],[`aria-hidden`,`true`,1,`band`],[`role`,`option`,1,`col`,3,`id`],[1,`bar`],[`aria-hidden`,`true`,1,`tip`],[1,`tip-head`],[1,`flag`],[1,`tip-row`],[1,`mono`],[1,`mono`,`name`],[1,`num`]],template:function(e,t){if(e&1&&(R(0,`div`,0)(1,`label`)(2,`span`),K(3,`Start`),z(),R(4,`input`,1),U(`change`,function(e){return t.setEnd(`from`,e)}),z()(),R(5,`label`)(6,`span`),K(7,`End`),z(),R(8,`input`,1),U(`change`,function(e){return t.setEnd(`to`,e)}),z()(),A(9,Tk,2,0,`button`,2),R(10,`p`,3),K(11),z()(),R(12,`p`,4),K(13,` Drag across the bars to pick a range of cycles. With the chart focused, the arrow keys move between cycles, Shift with an arrow key extends the range, Enter picks one cycle and Escape shows all cycles again. `),z(),R(14,`div`,5)(15,`div`,6)(16,`span`,7),K(17),z(),R(18,`span`,8),K(19,`0 ms`),z()(),R(20,`div`,9),A(21,Ek,1,4,`div`,10),R(22,`div`,11)(23,`span`),K(24),z()(),R(25,`div`,12),U(`keydown`,function(e){return t.onKey(e)})(`focus`,function(){return t.onFocus()})(`blur`,function(){return t.focused.set(!1)})(`pointerdown`,function(e){return t.startDrag(e)})(`pointermove`,function(e){return t.onPointerMove(e)})(`pointerleave`,function(){return t.hover.set(null)}),M(26,Dk,2,12,`div`,13,Ck),z(),A(28,jk,12,12,`div`,14),z()(),R(29,`div`,15),M(30,Mk,1,2,`span`,16,Ck),z(),R(32,`ul`,17)(33,`li`),dg(34,`span`,18),K(35,`Within budget`),z(),R(36,`li`),dg(37,`span`,19)(38,`span`,20),K(39,`Over budget `),z(),R(40,`li`),dg(41,`span`,21),K(42),z()(),R(43,`details`,22)(44,`summary`),K(45,`Show the cycles as a table`),z(),R(46,`div`,23)(47,`table`)(48,`caption`,24),K(49),z(),R(50,`thead`)(51,`tr`)(52,`th`,25),K(53,`Cycle`),z(),R(54,`th`,25),K(55,`Duration`),z(),R(56,`th`,26),K(57,`Budget`),z(),R(58,`th`,25),K(59,`Checks`),z(),R(60,`th`,25),K(61,`Passes`),z(),R(62,`th`,26),K(63,`After`),z(),R(64,`th`,26),K(65,`Slowest component`),z()()(),R(66,`tbody`),M(67,Pk,15,7,`tr`,null,wk),z()()()()),e&2){let e,n;D(4),V(`min`,t.firstId())(`max`,t.lastId())(`value`,t.startId()),D(4),V(`min`,t.firstId())(`max`,t.lastId())(`value`,t.endId()),D(),j(t.range()?9:-1),D(2),q(t.summary()),D(),V(`id`,t.hintId),D(2),G(`flush`,t.bars().length>t.gapUntil),D(3),q(t.fmt(t.max())),D(4),j((e=t.band())?21:-1,e),D(),t_(`bottom`,t.budgetAt(),`%`),D(2),q(t.budgetLabel()),D(),k(`aria-label`,`Change detection cycles, `+t.summary())(`aria-describedby`,t.hintId)(`aria-activedescendant`,t.activeOptionId()),D(),N(t.bars()),D(2),j((n=t.tip())?28:-1,n),D(),G(`flush`,t.bars().length>t.gapUntil),D(),N(t.bars()),D(12),q(t.budgetLabel()),D(4),k(`aria-label`,t.tableLabel()),D(3),J(` `,t.tableLabel(),` `),D(18),N(t.selected())}},styles:[`[_nghost-%COMP%] { - display: block; - min-width: 0; -} - -.range[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; -} - -label[_ngcontent-%COMP%] { - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--%NS%text-2); - font-size: 12px; -} - -input[_ngcontent-%COMP%] { - width: 76px; - height: var(--%NS%control-h); - padding: 0 8px; - background: var(--%NS%bg); - border: 1px solid var(--%NS%control-border); - border-radius: var(--%NS%radius-sm); - color: var(--%NS%text); - font: inherit; - font-size: 13px; -} - -input[_ngcontent-%COMP%]:focus-visible { - outline: none; - border-color: var(--%NS%accent); - box-shadow: 0 0 0 3px var(--%NS%accent-soft); -} - -button[_ngcontent-%COMP%] { - height: var(--%NS%control-h); - padding: 0 12px; - background: var(--%NS%surface-2); - border: 1px solid var(--%NS%control-border); - border-radius: var(--%NS%radius-sm); - color: var(--%NS%text); - cursor: pointer; - font: inherit; - font-size: 13px; -} - -button[_ngcontent-%COMP%]:hover { - background: var(--%NS%surface-3); - border-color: var(--%NS%accent-line); -} - -button[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.summary[_ngcontent-%COMP%], -.hint[_ngcontent-%COMP%] { - margin: 0; - color: var(--%NS%text-2); - font-size: 12px; - line-height: 1.5; -} - -.hint[_ngcontent-%COMP%] { - margin: 6px 0 8px; -} - -.plot[_ngcontent-%COMP%] { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 6px; -} - -.axis[_ngcontent-%COMP%] { - position: relative; - width: 44px; - color: var(--%NS%text-2); - font-size: 11px; - text-align: right; -} - -.tick[_ngcontent-%COMP%] { - position: absolute; - right: 0; -} - -.tick.top[_ngcontent-%COMP%] { - top: 0; -} - -.tick.zero[_ngcontent-%COMP%] { - bottom: -6px; -} - -.area[_ngcontent-%COMP%] { - position: relative; - height: 140px; - border-bottom: 1px solid var(--%NS%border-strong); -} - -.bars[_ngcontent-%COMP%] { - position: absolute; - inset: 0; - display: flex; - align-items: stretch; - column-gap: 2px; - touch-action: none; - cursor: crosshair; - user-select: none; -} - -.flush[_ngcontent-%COMP%] .bars[_ngcontent-%COMP%], -.markers.flush[_ngcontent-%COMP%] { - column-gap: 0; -} - -.bars[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.col[_ngcontent-%COMP%] { - position: relative; - display: flex; - flex: 1 1 0; - align-items: flex-end; - justify-content: center; - min-width: 0; -} - -.bar[_ngcontent-%COMP%] { - width: min(24px, 100%); - min-height: 1px; - background: var(--%NS%accent); - border-radius: 4px 4px 0 0; - transition: height 150ms var(--%NS%ease); -} - -.flush[_ngcontent-%COMP%] .bar[_ngcontent-%COMP%] { - border-radius: 1px 1px 0 0; -} - -.col.over[_ngcontent-%COMP%] .bar[_ngcontent-%COMP%] { - background: repeating-linear-gradient(135deg, var(--%NS%danger) 0 3px, color-mix(in srgb, var(--%NS%danger) 55%, var(--%NS%surface)) 3px 6px); -} - -.col.out[_ngcontent-%COMP%] .bar[_ngcontent-%COMP%] { - opacity: 0.4; -} - -.col[_ngcontent-%COMP%]:hover .bar[_ngcontent-%COMP%], -.col.active[_ngcontent-%COMP%] .bar[_ngcontent-%COMP%] { - outline: 2px solid var(--%NS%text-strong); - outline-offset: 1px; -} - -.band[_ngcontent-%COMP%] { - position: absolute; - top: 0; - bottom: 0; - background: var(--%NS%accent-soft); - border-inline: 2px solid var(--%NS%accent); - pointer-events: none; -} - -.budget[_ngcontent-%COMP%] { - position: absolute; - left: 0; - right: 0; - height: 0; - border-top: 1px solid var(--%NS%text-2); - pointer-events: none; - z-index: 1; -} - -.budget[_ngcontent-%COMP%] span[_ngcontent-%COMP%] { - position: absolute; - right: 0; - bottom: 2px; - padding: 0 4px; - background: var(--%NS%surface); - border-radius: var(--%NS%radius-sm); - color: var(--%NS%text-2); - font-size: 11px; -} - -.markers[_ngcontent-%COMP%] { - display: flex; - column-gap: 2px; - height: 8px; - margin: 3px 0 0 50px; -} - -.markers[_ngcontent-%COMP%] span[_ngcontent-%COMP%] { - display: flex; - flex: 1 1 0; - justify-content: center; - min-width: 0; -} - -.markers[_ngcontent-%COMP%] span.over[_ngcontent-%COMP%]::before { - content: ""; - width: min(10px, 100%); -} - -.markers[_ngcontent-%COMP%] span.over[_ngcontent-%COMP%]::before, -.key-marker[_ngcontent-%COMP%] { - background: var(--%NS%danger); - clip-path: polygon(50% 0, 100% 100%, 0 100%); -} - -.key-marker[_ngcontent-%COMP%] { - display: inline-block; - width: 8px; - height: 8px; - margin-right: 6px; -} - -.tip[_ngcontent-%COMP%] { - position: absolute; - top: 4px; - z-index: 2; - width: min(240px, 100% - 8px); - padding: 8px 10px; - background: var(--%NS%surface-3); - border: 1px solid var(--%NS%border-strong); - border-radius: var(--%NS%radius-sm); - color: var(--%NS%text-2); - font-size: 12px; - pointer-events: none; -} - -.tip[_ngcontent-%COMP%] p[_ngcontent-%COMP%] { - margin: 2px 0; -} - -.tip[_ngcontent-%COMP%] strong[_ngcontent-%COMP%] { - color: var(--%NS%text-strong); - font-size: 13px; -} - -.tip-head[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 4px 8px; -} - -.flag[_ngcontent-%COMP%] { - padding: 0 6px; - border: 1px solid; - border-radius: 999px; - border-color: color-mix(in srgb, var(--%NS%danger) 30%, transparent); - background: color-mix(in srgb, var(--%NS%danger) 12%, transparent); - color: var(--%NS%danger-text); -} - -.tip-row[_ngcontent-%COMP%] { - display: flex; - justify-content: space-between; - gap: 8px; -} - -.tip-row[_ngcontent-%COMP%] .name[_ngcontent-%COMP%] { - overflow: hidden; - min-width: 0; - text-overflow: ellipsis; - white-space: nowrap; -} - -.legend[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - gap: 4px 16px; - margin: 8px 0 0; - padding: 0; - list-style: none; - color: var(--%NS%text-2); - font-size: 12px; -} - -.legend[_ngcontent-%COMP%] li[_ngcontent-%COMP%] { - display: inline-flex; - align-items: center; -} - -.key[_ngcontent-%COMP%] { - display: inline-block; - width: 10px; - height: 10px; - margin-right: 6px; - border-radius: 2px; -} - -.key.ok[_ngcontent-%COMP%] { - background: var(--%NS%accent); -} - -.key.over[_ngcontent-%COMP%] { - background: repeating-linear-gradient(135deg, var(--%NS%danger) 0 3px, color-mix(in srgb, var(--%NS%danger) 55%, var(--%NS%surface)) 3px 6px); -} - -.key.line[_ngcontent-%COMP%] { - width: 14px; - height: 0; - border-top: 1px solid var(--%NS%text-2); - border-radius: 0; -} - -.table[_ngcontent-%COMP%] { - margin-top: 8px; - font-size: 12px; -} - -summary[_ngcontent-%COMP%] { - width: fit-content; - color: var(--%NS%text); - cursor: pointer; -} - -summary[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.table-wrap[_ngcontent-%COMP%] { - max-height: 320px; - margin-top: 6px; - overflow: auto; -} - -.table-wrap[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: -2px; -} - -table[_ngcontent-%COMP%] { - width: 100%; - min-width: 600px; - border-collapse: collapse; -} - -th[_ngcontent-%COMP%], -td[_ngcontent-%COMP%] { - padding: 4px 6px; - border-bottom: 1px solid var(--%NS%border); - text-align: left; - vertical-align: top; -} - -th[_ngcontent-%COMP%] { - position: sticky; - top: 0; - background: var(--%NS%surface); - color: var(--%NS%text-2); - font-weight: 500; -} - -td.mono[_ngcontent-%COMP%] { - overflow-wrap: anywhere; -} - -.num[_ngcontent-%COMP%] { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.mono[_ngcontent-%COMP%] { - font-family: var(--%NS%font-mono); -} - -@media (prefers-reduced-motion: reduce) { - .bar[_ngcontent-%COMP%] { - transition: none; - } -}`]})},zk=(e,t)=>t.id,Bk=(e,t)=>t.index,Vk=(e,t)=>t.kind;function Hk(e,t){if(e&1&&(R(0,`p`,0),K(1),z()),e&2){let e=W(2);D(),Y(` Pick `,e.max,` cycles or fewer to see their spans. `,e.count(),` are picked. `)}}function Uk(e,t){if(e&1&&(R(0,`span`),K(1),z()),e&2){let e=t.$implicit;t_(`left`,e.x,`%`)(`width`,e.w,`%`),D(),J(`cycle `,e.id)}}function Wk(e,t){if(e&1){let e=B();R(0,`button`,11),U(`focus`,function(){let t=S(e).$implicit;return C(W(3).current.set(t.index))})(`mouseenter`,function(){let t=S(e).$implicit;return C(W(3).hover.set(t.index))})(`mouseleave`,function(){return S(e),C(W(3).hover.set(null))})(`click`,function(){let t=S(e).$implicit;return C(W(3).choose(t))}),R(1,`span`,12),K(2),z()()}if(e&2){let e=t.$implicit,n=W(3);n_(L_(`block kind-`,e.kind)),t_(`left`,e.x,`%`)(`width`,e.w,`%`)(`top`,e.depth*n.row,`px`),G(`narrow`,e.w<4),k(`tabindex`,e.index===n.tabStop()?0:-1)(`data-index`,e.index)(`aria-label`,n.describe(e))(`aria-disabled`,e.component?null:`true`),D(2),q(e.name)}}function Gk(e,t){if(e&1&&(R(0,`li`),dg(1,`span`,13),K(2),z()),e&2){let e=t.$implicit;D(),n_(L_(`key kind-`,e.kind)),D(),q(e.label)}}function Kk(e,t){if(e&1&&(R(0,`p`,0),K(1),z()),e&2){let e=W(3);D(),Y(` Each cycle keeps its longest spans. `,e.dropped(),` shorter `,e.dropped()===1?`span was`:`spans were`,` left out. `)}}function qk(e,t){if(e&1&&(R(0,`tr`)(1,`td`,17),K(2),z(),R(3,`td`,18),K(4),z(),R(5,`td`),K(6),z(),R(7,`td`,17),K(8),z(),R(9,`td`,17),K(10),z(),R(11,`td`,17),K(12),z(),R(13,`td`,17),K(14),z()()),e&2){let e=t.$implicit,n=W(4);D(2),q(e.cycleId),D(),t_(`padding-left`,6+e.depth*8,`px`),D(),q(e.name),D(2),q(n.kindLabel(e)),D(2),q(e.depth),D(2),q(n.fmt(e.start)),D(2),q(n.fmt(e.ms)),D(2),q(n.fmt(e.selfMs))}}function Jk(e,t){if(e&1&&(R(0,`div`,10)(1,`table`)(2,`caption`,14),K(3),z(),R(4,`thead`)(5,`tr`)(6,`th`,15),K(7,`Cycle`),z(),R(8,`th`,16),K(9,`Span`),z(),R(10,`th`,16),K(11,`Kind`),z(),R(12,`th`,15),K(13,`Depth`),z(),R(14,`th`,15),K(15,`Start`),z(),R(16,`th`,15),K(17,`Total`),z(),R(18,`th`,15),K(19,`Self`),z()()(),R(20,`tbody`),M(21,qk,15,9,`tr`,null,Bk),z()()()),e&2){let e=W(),t=W(2);k(`aria-label`,t.tableLabel()),D(3),J(` `,t.tableLabel(),` `),D(18),N(e.blocks)}}function Yk(e,t){if(e&1){let e=B();R(0,`div`,1),U(`keydown`,function(t){return S(e),C(W(2).onKey(t))}),R(1,`div`,2),M(2,Uk,2,5,`span`,3,zk),z(),R(4,`div`,4),M(5,Wk,3,16,`button`,5,Bk),z()(),R(7,`p`,6),K(8),z(),R(9,`ul`,7),M(10,Gk,3,4,`li`,null,Vk),z(),R(12,`p`,8),K(13,` Arrow keys move along a row, Up goes to the enclosing span and Down to the first nested one. Enter on a component shows it in the tree. `),z(),A(14,Kk,2,2,`p`,0),R(15,`details`,9),U(`toggle`,function(t){return S(e),C(W(2).onToggle(t))}),R(16,`summary`),K(17,`Show the spans as a table`),z(),A(18,Jk,23,2,`div`,10),z()}if(e&2){let e=t,n=W(2);k(`aria-label`,n.label()),D(2),N(e.cycles),D(2),t_(`height`,e.depth*n.row,`px`),D(),N(e.blocks),D(3),q(n.readout()),D(2),N(n.kinds),D(4),j(n.dropped()?14:-1),D(4),j(n.tableOpen()?18:-1)}}function Xk(e,t){if(e&1&&(R(0,`h3`),K(1,`Spans in the picked cycles`),z(),A(2,Hk,2,2,`p`,0)(3,Yk,19,6)),e&2){let e,t=W();D(2),j(t.tooMany()?2:(e=t.graph())?3:-1,e)}}var Zk=22;function Qk(e,t){for(let n=e.length-1;n>=0;n--)if(t(e[n]))return e[n]}var $k=class e{cycles=$([]);names=$([]);scope=$(`the recording`);pick=cv();row=Zk;max=30;kinds=[`c`,`h`,`r`,`o`].map(e=>({kind:e,label:QO[e]}));current=T(0);tableOpen=T(!1);hover=T(null);hasSpans=X(()=>this.cycles().some(e=>e.spans?.length));count=X(()=>this.cycles().length);tooMany=X(()=>this.count()>30);graph=X(()=>this.tooMany()?null:ek(this.cycles(),this.names()));tabStop=X(()=>Math.min(this.current(),Math.max((this.graph()?.blocks.length??1)-1,0)));dropped=X(()=>this.graph()?.cycles.reduce((e,t)=>e+t.droppedSpans,0)??0);label=X(()=>`Spans in ${this.scope()} over time, nested by depth. Pick a component to show it in the tree.`);readout=X(()=>{let e=(this.graph()?.blocks??[])[this.hover()??this.tabStop()];return e?this.describe(e):``});tableLabel=X(()=>`Timed spans in ${this.scope()}`);fmt(e){return qO(e)}kindLabel(e){return QO[e.kind]}onToggle(e){this.tableOpen.set(e.target.open)}describe(e){let t=[e.name,QO[e.kind],`${qO(e.ms)} total`];return e.selfMs!==e.ms&&t.push(`${qO(e.selfMs)} self`),t.push(e.start<0?`cycle ${e.cycleId}, ${qO(-e.start)} before it started`:`cycle ${e.cycleId} at ${qO(e.start)}`),t.join(`, `)}choose(e){this.current.set(e.index),e.component&&this.pick.emit(e.component)}onKey(e){let t=this.graph()?.blocks??[];if(!t.length)return;let n=t[this.tabStop()],r;switch(e.key){case`ArrowRight`:r=t.find(e=>e.index>n.index&&e.depth===n.depth);break;case`ArrowLeft`:r=Qk(t,e=>e.indexe.indexe.parent===n.index)??t.find(e=>e.index>n.index&&e.depth===n.depth+1);break;case`Home`:r=t[0];break;case`End`:r=t.at(-1);break;default:return}e.preventDefault(),this.hover.set(null),r&&(this.current.set(r.index),e.currentTarget.querySelector(`button[data-index="${r.index}"]`)?.focus())}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-cd-flame-graph`]],inputs:{cycles:[1,`cycles`],names:[1,`names`],scope:[1,`scope`]},outputs:{pick:`pick`},decls:1,vars:1,consts:[[1,`note`],[`role`,`group`,1,`flame`,3,`keydown`],[`aria-hidden`,`true`,1,`cycles`],[3,`left`,`width`],[1,`rows`],[`type`,`button`,3,`class`,`narrow`,`left`,`width`,`top`],[`aria-hidden`,`true`,1,`readout`],[`aria-label`,`Span kinds`,1,`legend`],[1,`hint`],[1,`table`,3,`toggle`],[`tabindex`,`0`,`role`,`region`,1,`table-wrap`],[`type`,`button`,3,`focus`,`mouseenter`,`mouseleave`,`click`],[1,`text`],[`aria-hidden`,`true`],[1,`sr-only`],[`scope`,`col`,1,`num`],[`scope`,`col`],[1,`num`],[1,`mono`]],template:function(e,t){e&1&&A(0,Xk,4,1),e&2&&j(t.hasSpans()?0:-1)},styles:[`[_nghost-%COMP%] { - display: block; - min-width: 0; -} - -h3[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - margin: 12px 0 6px; -} - -.flame[_ngcontent-%COMP%] { - position: relative; -} - -.cycles[_ngcontent-%COMP%] { - position: relative; - height: 18px; - color: var(--%NS%text-2); - font-size: 11px; -} - -.cycles[_ngcontent-%COMP%] span[_ngcontent-%COMP%] { - position: absolute; - top: 0; - padding-left: 4px; - border-left: 1px solid var(--%NS%border-strong); - overflow: hidden; - min-width: 0; - text-overflow: ellipsis; - white-space: nowrap; -} - -.rows[_ngcontent-%COMP%] { - position: relative; - min-height: 22px; -} - -.block[_ngcontent-%COMP%] { - position: absolute; - height: 20px; - min-width: 2px; - padding: 0 4px; - overflow: hidden; - border: 0; - border-radius: 3px; - color: var(--%NS%text-strong); - font: inherit; - font-family: var(--%NS%font-mono); - font-size: 11px; - line-height: 20px; - text-align: left; - cursor: default; -} - -.block.narrow[_ngcontent-%COMP%] { - padding: 0; -} - -.block.narrow[_ngcontent-%COMP%] .text[_ngcontent-%COMP%] { - display: none; -} - -.text[_ngcontent-%COMP%] { - display: block; - overflow: hidden; - min-width: 0; - text-overflow: ellipsis; - white-space: nowrap; -} - - -.kind-c[_ngcontent-%COMP%] { - background: color-mix(in srgb, var(--%NS%accent) 35%, var(--%NS%surface)); -} - -.block[_ngcontent-%COMP%]:not([aria-disabled]) { - cursor: pointer; -} - -.kind-h[_ngcontent-%COMP%] { - background: repeating-linear-gradient(45deg, color-mix(in srgb, var(--%NS%accent) 30%, var(--%NS%surface)) 0 4px, color-mix(in srgb, var(--%NS%accent) 10%, var(--%NS%surface)) 4px 8px); -} - -.kind-r[_ngcontent-%COMP%] { - background-color: var(--%NS%surface-3); - background-image: radial-gradient(circle, var(--%NS%text-3) 1px, transparent 1.6px); - background-size: 6px 6px; -} - -.kind-o[_ngcontent-%COMP%] { - background-color: var(--%NS%surface-2); - background-image: repeating-linear-gradient(0deg, var(--%NS%border-strong) 0 1px, transparent 1px 5px); - box-shadow: inset 0 0 0 1px var(--%NS%text-2); -} - -.block[_ngcontent-%COMP%]:hover { - outline: 2px solid var(--%NS%text-strong); - outline-offset: -2px; -} - -.block[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: -2px; - outline-color: var(--%NS%text-strong); - z-index: 1; -} - -.readout[_ngcontent-%COMP%], -.hint[_ngcontent-%COMP%], -.note[_ngcontent-%COMP%] { - margin: 6px 0 0; - color: var(--%NS%text-2); - font-size: 12px; - line-height: 1.5; -} - -.readout[_ngcontent-%COMP%] { - min-height: 18px; - color: var(--%NS%text); - overflow-wrap: anywhere; -} - -.legend[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - gap: 4px 16px; - margin: 6px 0 0; - padding: 0; - list-style: none; - color: var(--%NS%text-2); - font-size: 12px; -} - -.legend[_ngcontent-%COMP%] li[_ngcontent-%COMP%] { - display: inline-flex; - align-items: center; -} - -.key[_ngcontent-%COMP%] { - display: inline-block; - width: 18px; - height: 12px; - margin-right: 6px; - border-radius: 2px; -} - -.table[_ngcontent-%COMP%] { - margin-top: 6px; - font-size: 12px; -} - -summary[_ngcontent-%COMP%] { - width: fit-content; - color: var(--%NS%text); - cursor: pointer; -} - -summary[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.table-wrap[_ngcontent-%COMP%] { - max-height: 320px; - margin-top: 6px; - overflow: auto; -} - -.table-wrap[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: -2px; -} - -table[_ngcontent-%COMP%] { - width: 100%; - min-width: 560px; - border-collapse: collapse; -} - -th[_ngcontent-%COMP%], -td[_ngcontent-%COMP%] { - padding: 4px 6px; - border-bottom: 1px solid var(--%NS%border); - text-align: left; - vertical-align: top; -} - -th[_ngcontent-%COMP%] { - position: sticky; - top: 0; - background: var(--%NS%surface); - color: var(--%NS%text-2); - font-weight: 500; -} - -td.mono[_ngcontent-%COMP%] { - font-family: var(--%NS%font-mono); - overflow-wrap: anywhere; -} - -.num[_ngcontent-%COMP%] { - text-align: right; - font-variant-numeric: tabular-nums; -}`]})};function eA(e,t){if(e&1&&(R(0,`tr`)(1,`td`,5),K(2),z(),R(3,`td`,6),K(4),z(),R(5,`td`,6),K(6),z(),R(7,`td`,6),K(8),z()()),e&2){let e=t.$implicit,n=W(3);D(2),Y(``,e.name,`.`,e.hook),D(2),q(e.calls),D(2),q(n.ms(e.ms)),D(2),q(n.ms(e.maxMs))}}function tA(e,t){if(e&1&&(R(0,`div`)(1,`h3`),K(2,`Lifecycle hooks`),z(),R(3,`table`)(4,`thead`)(5,`tr`)(6,`th`,3),K(7,`Hook`),z(),R(8,`th`,4),K(9,`Calls`),z(),R(10,`th`,4),K(11,`Time`),z(),R(12,`th`,4),K(13,`Slowest`),z()()(),R(14,`tbody`),M(15,eA,9,5,`tr`,null,$h),z()()()),e&2){let e=W(2);D(15),N(e.hooks())}}function nA(e,t){if(e&1&&K(0),e&2){let e=W().$implicit;J(` (`,e.listener,`) `)}}function rA(e,t){if(e&1&&(R(0,`tr`)(1,`td`,5),K(2),A(3,nA,1,1),z(),R(4,`td`,6),K(5),z(),R(6,`td`,6),K(7),z(),R(8,`td`,6),K(9),z()()),e&2){let e=t.$implicit,n=W(3);D(2),J(` `,e.name,` `),D(),j(e.listener?3:-1),D(2),q(e.calls),D(2),q(n.ms(e.ms)),D(2),q(n.ms(e.maxMs))}}function iA(e,t){if(e&1&&(R(0,`div`)(1,`h3`),K(2,`Output listeners`),z(),R(3,`table`)(4,`thead`)(5,`tr`)(6,`th`,3),K(7,`Listener`),z(),R(8,`th`,4),K(9,`Calls`),z(),R(10,`th`,4),K(11,`Time`),z(),R(12,`th`,4),K(13,`Slowest`),z()()(),R(14,`tbody`),M(15,rA,10,5,`tr`,null,$h),z()()()),e&2){let e=W(2);D(15),N(e.outputs())}}function aA(e,t){if(e&1&&(R(0,`p`,1),K(1),z()),e&2){let e=t,n=W(2);D(),O_(` afterRender callbacks ran `,e.runs,` `,e.runs===1?`time`:`times`,`, `,n.ms(e.ms),` in total, slowest `,n.ms(e.maxMs),`. `)}}function oA(e,t){if(e&1&&(R(0,`p`,2),K(1),z()),e&2){let e=W(2);D(),Y(` `,e.dropped(),` more hook or output `,e.dropped()===1?`row is`:`rows are`,` left out. A recording lists at most 100 of each. `)}}function sA(e,t){if(e&1&&(R(0,`div`,0),A(1,tA,17,0,`div`),A(2,iA,17,0,`div`),z(),A(3,aA,2,4,`p`,1),A(4,oA,2,2,`p`,2)),e&2){let e,t=W();D(),j(t.hooks().length?1:-1),D(),j(t.outputs().length?2:-1),D(),j((e=t.afterRender())?3:-1,e),D(),j(t.dropped()?4:-1)}}var cA=10;function lA(e){return`${e<10?e.toFixed(2):Math.round(e)} ms`}var uA=class e{page=$(null);hooks=X(()=>(this.page()?.hooks??[]).slice(0,cA));outputs=X(()=>(this.page()?.outputs??[]).slice(0,cA));afterRender=X(()=>{let e=this.page()?.afterRender;return e?.runs?e:null});dropped=X(()=>this.page()?.droppedTimings??0);ms(e){return lA(e)}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-cd-timing`]],inputs:{page:[1,`page`]},decls:1,vars:1,consts:[[1,`timing`],[1,`after-render`],[1,`note`],[`scope`,`col`],[`scope`,`col`,1,`num`],[1,`mono`],[1,`num`]],template:function(e,t){e&1&&A(0,sA,5,4),e&2&&j(t.hooks().length||t.outputs().length||t.afterRender()?0:-1)},styles:[`[_nghost-%COMP%] { - display: block; -} - -.timing[_ngcontent-%COMP%] { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 0 16px; -} - -@media (min-width: 880px) { - .timing[_ngcontent-%COMP%] { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - } -} -h3[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - margin: 12px 0 6px; -} - -table[_ngcontent-%COMP%] { - width: 100%; - border-collapse: collapse; - font-size: 12px; -} - -th[_ngcontent-%COMP%], -td[_ngcontent-%COMP%] { - padding: 4px 6px; - border-bottom: 1px solid var(--%NS%border); - text-align: left; -} - -th[_ngcontent-%COMP%] { - color: var(--%NS%text-2); - font-weight: 500; -} - -td.mono[_ngcontent-%COMP%] { - overflow-wrap: anywhere; -} - -.num[_ngcontent-%COMP%] { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.mono[_ngcontent-%COMP%] { - font-family: var(--%NS%font-mono); -} - -.after-render[_ngcontent-%COMP%], -.note[_ngcontent-%COMP%] { - margin: 8px 0 0; - color: var(--%NS%text-2); - font-size: 12px; -}`]})};function dA(e,t){if(e&1&&(R(0,`p`,5),K(1),z()),e&2){let e=W();D(),q(e.error())}}function fA(e,t){let n=new Blob([JSON.stringify(e,null,2)],{type:`application/json`}),r=URL.createObjectURL(n),i=document.createElement(`a`);i.href=r,i.download=t,document.body.append(i),i.click(),i.remove(),setTimeout(()=>URL.revokeObjectURL(r),0)}var pA=class e{page=$(null);maxCycles=$(200);imported=cv();error=T(``);hasCycles(){return(this.page()?.cycles.length??0)>0}exportJson(){let e=this.page();e&&fA(RO(e,e.pageId),`change-detection-${e.pageId}.json`)}exportTrace(){let e=this.page();e&&fA(GO(e,e.pageId),`change-detection-${e.pageId}.trace.json`)}async read(e){let t=e.files?.[0];if(e.value=``,!t)return;if(t.size>2e7){this.error.set(`${t.name}: The file is larger than 20 MB.`);return}let n;try{n=await t.text()}catch{this.error.set(`Could not read the file.`);return}let r=zO(n,this.maxCycles());if(!r.ok){this.error.set(`${t.name}: ${r.error}`);return}this.error.set(``),this.imported.emit({fileName:t.name,page:{...r.recording,pageId:r.pageId,supported:!0}})}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-cd-transfer`]],inputs:{page:[1,`page`],maxCycles:[1,`maxCycles`]},outputs:{imported:`imported`},decls:10,vars:3,consts:[[`file`,``],[`role`,`group`,`aria-label`,`Export and import the recording`,1,`transfer`],[`type`,`button`,3,`click`,`disabled`],[`type`,`button`,3,`click`],[`type`,`file`,`accept`,`.json,application/json`,`hidden`,``,3,`change`],[`role`,`alert`,1,`error`]],template:function(e,t){if(e&1){let e=B();R(0,`div`,1)(1,`button`,2),U(`click`,function(){return t.exportJson()}),K(2,`Export JSON`),z(),R(3,`button`,2),U(`click`,function(){return t.exportTrace()}),K(4,` Export trace (Perfetto) `),z(),R(5,`button`,3),U(`click`,function(){return S(e),C(Pg(8).click())}),K(6,`Import`),z(),R(7,`input`,4,0),U(`change`,function(){S(e);let n=Pg(8);return C(t.read(n))}),z()(),A(9,dA,2,1,`p`,5)}e&2&&(D(),V(`disabled`,!t.hasCycles()),D(2),V(`disabled`,!t.hasCycles()),D(6),j(t.error()?9:-1))},styles:[`[_nghost-%COMP%] { - display: contents; -} - -.transfer[_ngcontent-%COMP%] { - display: inline-flex; - flex-wrap: wrap; - gap: 8px; -} - -button[_ngcontent-%COMP%] { - height: var(--%NS%control-h); - padding: 0 12px; - background: var(--%NS%surface-2); - border: 1px solid var(--%NS%control-border); - border-radius: var(--%NS%radius-sm); - color: var(--%NS%text); - cursor: pointer; - font: inherit; - font-size: 13px; -} - -button[_ngcontent-%COMP%]:hover:not(:disabled) { - background: var(--%NS%surface-3); - border-color: var(--%NS%accent-line); -} - -button[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -button[_ngcontent-%COMP%]:disabled { - cursor: not-allowed; - opacity: 0.6; -} - -.error[_ngcontent-%COMP%] { - flex-basis: 100%; - margin: 0; - color: var(--%NS%danger-text); - font-size: 12px; -}`]})},mA=(e,t)=>t.stat.name;function hA(e,t){if(e&1&&(R(0,`span`,8),K(1),z()),e&2){let e=W(2).$implicit;D(),J(``,e.other,` more`)}}function gA(e,t){if(e&1&&(R(0,`div`,6)(1,`span`,7),K(2),z(),A(3,hA,2,1,`span`,8),z()),e&2){let e=W().$implicit;t_(`--%NS%tone`,e.tone+`%`),D(2),q(e.label),D(),j(e.roomy?3:-1)}}function _A(e,t){if(e&1&&(R(0,`span`,10),K(1),z(),R(2,`span`,11),K(3),z()),e&2){let e=W(2).$implicit,t=W(2);D(),q(e.stat.name),D(2),q(t.fmt(e.stat.ms))}}function vA(e,t){if(e&1){let e=B();R(0,`button`,9),U(`focus`,function(){S(e);let t=W().$index;return C(W(2).current.set(t))})(`click`,function(){S(e);let t=W().$implicit;return C(W(2).pick.emit(t.stat.name))}),A(1,_A,4,2),z()}if(e&2){let e=W(),t=e.$implicit,n=e.$index,r=W(2);t_(`--%NS%tone`,t.tone+`%`),k(`tabindex`,n===r.tabStop()?0:-1)(`aria-label`,t.label)(`data-index`,n),D(),j(t.roomy?1:-1)}}function yA(e,t){if(e&1&&(R(0,`li`),A(1,gA,4,4,`div`,4)(2,vA,2,6,`button`,5),z()),e&2){let e=t.$implicit;t_(`left`,e.x,`%`)(`top`,e.y,`%`)(`width`,e.w,`%`)(`height`,e.h,`%`),D(),j(e.other?1:2)}}function bA(e,t){if(e&1){let e=B();R(0,`ul`,2),U(`keydown`,function(t){return S(e),C(W().onKey(t))}),M(1,yA,3,9,`li`,3,mA),z()}if(e&2){let e=W();k(`aria-label`,e.label()),D(),N(e.cells())}}function xA(e,t){e&1&&(R(0,`p`,1),K(1,`No component time in these cycles.`),z())}var SA=24,CA=3,wA=class e{stats=$([]);scope=$(`the recording`);pick=cv();current=T(0);label=X(()=>`Self time per component in ${this.scope()}, largest first. Pick one to show it in the tree.`);cells=X(()=>{let e=this.stats().filter(e=>e.ms>0),t=e.slice(0,SA),n=e.slice(SA),r=n.reduce((e,t)=>e+t.ms,0),i=t.map(e=>({stat:e,other:0}));n.length&&i.push({stat:{name:`Other components`,checks:0,ms:r,maxMs:0,cycles:0},other:n.length});let a=e.reduce((e,t)=>e+t.ms,0),o=t[0]?.ms??1,s=ZO(i,e=>e.stat.ms,CA);return s.sort((e,t)=>Number(e.item.other>0)-Number(t.item.other>0)),s.map(e=>{let{stat:t,other:n}=e.item,r=a?Math.round(t.ms/a*100):0;return{stat:t,other:n,x:e.x,y:e.y,w:e.w,h:e.h,tone:n?0:14+Math.round(26*Math.min(t.ms/o,1)),roomy:e.w>=10&&e.h>=22,label:n?`${n} more components, ${qO(t.ms)}, ${r}% of self time`:`${t.name}, ${qO(t.ms)} self time, ${r}%, ${t.checks} ${t.checks===1?`check`:`checks`}`}})});tabStop=X(()=>Math.min(this.current(),Math.max(this.cells().filter(e=>!e.other).length-1,0)));fmt(e){return qO(e)}onKey(e){let t=this.cells().filter(e=>!e.other).length;if(!t)return;let n=this.tabStop(),r;switch(e.key){case`ArrowRight`:case`ArrowDown`:r=Math.min(n+1,t-1);break;case`ArrowLeft`:case`ArrowUp`:r=Math.max(n-1,0);break;case`Home`:r=0;break;case`End`:r=t-1;break;default:return}e.preventDefault(),this.current.set(r),e.currentTarget.querySelector(`button[data-index="${r}"]`)?.focus()}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-cd-treemap`]],inputs:{stats:[1,`stats`],scope:[1,`scope`]},outputs:{pick:`pick`},decls:2,vars:1,consts:[[1,`map`],[1,`empty`],[1,`map`,3,`keydown`],[3,`left`,`top`,`width`,`height`],[1,`cell`,`other`,3,`--%NS%tone`],[`type`,`button`,1,`cell`,3,`--%NS%tone`],[1,`cell`,`other`],[1,`sr-only`],[`aria-hidden`,`true`,1,`name`],[`type`,`button`,1,`cell`,3,`focus`,`click`],[1,`name`,`mono`],[1,`ms`]],template:function(e,t){e&1&&A(0,bA,3,1,`ul`,0)(1,xA,2,0,`p`,1),e&2&&j(+!t.cells().length)},styles:[`[_nghost-%COMP%] { - display: block; - min-width: 0; -} - -.map[_ngcontent-%COMP%] { - position: relative; - height: 180px; - margin: 0; - padding: 0; - list-style: none; -} - -li[_ngcontent-%COMP%] { - position: absolute; - padding: 1px; -} - -.cell[_ngcontent-%COMP%] { - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: flex-start; - gap: 2px; - width: 100%; - height: 100%; - padding: 4px 6px; - overflow: hidden; - background: color-mix(in srgb, var(--%NS%accent) var(--%NS%tone, 20%), var(--%NS%surface)); - border: 0; - border-radius: var(--%NS%radius-sm); - color: var(--%NS%text-strong); - font: inherit; - font-size: 12px; - text-align: left; -} - -button.cell[_ngcontent-%COMP%] { - cursor: pointer; -} - -button.cell[_ngcontent-%COMP%]:hover { - outline: 2px solid var(--%NS%text-strong); - outline-offset: -2px; -} - -button.cell[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: -2px; - outline-color: var(--%NS%text-strong); -} - -.cell.other[_ngcontent-%COMP%] { - background: var(--%NS%surface-3); - color: var(--%NS%text-2); -} - -.name[_ngcontent-%COMP%] { - overflow: hidden; - min-width: 0; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 100%; -} - -.mono[_ngcontent-%COMP%] { - font-family: var(--%NS%font-mono); -} - -.ms[_ngcontent-%COMP%] { - color: var(--%NS%text); -} - -.empty[_ngcontent-%COMP%] { - margin: 8px 0; - color: var(--%NS%text-2); -}`]})},TA=(e,t)=>t.id;function EA(e,t){if(e&1){let e=B();F(0,`button`,14),H(`click`,function(){return S(e),C(W(2).clear())}),K(1,`Clear`),I()}}function DA(e,t){if(e&1){let e=B();F(0,`button`,11),H(`click`,function(){return S(e),C(W().toggle())}),L(1,`span`,12),K(2),I(),A(3,EA,2,0,`button`,13)}if(e&2){let e=W();G(`on`,e.recording()),P(`disabled`,!e.pageId()),D(2),J(` `,e.recording()?`Stop recording`:`Record`,` `),D(),j(e.cycles().length?3:-1)}}function OA(e,t){if(e&1){let e=B();F(0,`p`,6)(1,`span`,15),K(2,`Imported recording`),I(),F(3,`span`,16),K(4),I(),F(5,`button`,14),H(`click`,function(){return S(e),C(W().closeImport())}),K(6,`Back to live recording`),I()()}e&2&&(D(4),q(t.fileName))}function kA(e,t){e&1&&K(0,` An app on a device has no DOM, so checks that changed nothing are not counted there. `)}function AA(e,t){e&1&&K(0,` A recording keeps running when the tab reloads, so it catches the next start of the app. The cycles from before the reload are gone with the old page. `)}function jA(e,t){e&1&&(F(0,`p`,9),K(1,` The page has Angular's profiler hook, but the recording could not attach to it yet. The page tries again on its next update while recording is on. If this stays, stop and start the recording, or reload the page. `),I())}function MA(e,t){e&1&&L(0,`app-angular-unavailable`,17),e&2&&P(`page`,W(2).angularPage())}function NA(e,t){e&1&&A(0,jA,2,0,`p`,9)(1,MA,1,1,`app-angular-unavailable`,17),e&2&&j(+!W().attachPending())}function PA(e,t){e&1&&(F(0,`p`,9),K(1,`Angular profiling stays on until the page reloads.`),I())}function FA(e,t){e&1&&L(0,`app-angular-unavailable`,21),e&2&&P(`page`,W(2).angularPage())}function IA(e,t){if(e&1){let e=B();F(0,`div`,18)(1,`span`,19),K(2,`Live views`),I(),F(3,`button`,20),H(`click`,function(){S(e);let t=W();return C(t.setView(`flash`,!t.views().flash))}),L(4,`span`,12),K(5,` Flash checks `),I(),F(6,`button`,20),H(`click`,function(){S(e);let t=W();return C(t.setView(`track`,!t.views().track))}),L(7,`span`,12),K(8,` Performance track `),I(),F(9,`button`,20),H(`click`,function(){return S(e),C(W().setView(`angularProfiling`,!0))}),L(10,`span`,12),K(11,` Angular profiling `),I()(),F(12,`p`,7),K(13,` Flash checks draws a box over each component on the page as Angular checks it, with its name and how often it was checked, fading over a second. Performance track adds a Pangular track to the Chrome Performance panel with every component check, hook and output. Angular profiling turns on Angular's own track there. `),I(),A(14,PA,2,0,`p`,9)(15,FA,1,1,`app-angular-unavailable`,21)}if(e&2){let e=W();D(3),P(`disabled`,!e.canShowViews()),k(`aria-pressed`,e.views().flash),D(3),P(`disabled`,!e.canShowViews()),k(`aria-pressed`,e.views().track),D(3),P(`disabled`,!e.canShowViews()||e.views().angularProfiling||!e.views().angularProfilingAvailable),k(`aria-pressed`,e.views().angularProfiling),D(5),j(e.views().angularProfiling?14:e.views().angularProfilingAvailable?-1:15)}}function LA(e,t){if(e&1&&(F(0,`p`,9),K(1),I()),e&2){let e=t.$implicit;D(),q(e)}}function RA(e,t){if(e&1&&(F(0,`p`,24),K(1),I()),e&2){let e=W(2);D(),J(` `,e.range()?`The picked cycles have no timed spans, so there is no flame graph for them.`:`This recording has no timed spans, so there is no flame graph. A recording from an older version, or a file saved without spans, has none.`,` `)}}function zA(e,t){e&1&&(F(0,`p`,9),K(1,` For picked cycles, totals come from the 10 slowest components each cycle lists. `),I())}function BA(e,t){if(e&1){let e=B();F(0,`h3`),K(1,`Cycles`),I(),F(2,`app-cd-cycle-chart`,22),M_(`rangeChange`,function(t){S(e);let n=W();return j_(n.range,t)||(n.range=t),C(t)}),I(),F(3,`app-cd-flame-graph`,23),H(`pick`,function(t){return S(e),C(W().showComponent.emit(t))}),I(),A(4,RA,2,1,`p`,24),F(5,`h3`),K(6,`Self time by component`),I(),A(7,zA,2,0,`p`,9),F(8,`app-cd-treemap`,25),H(`pick`,function(t){return S(e),C(W().showComponent.emit(t))}),I()}if(e&2){let e=W();D(2),P(`cycles`,e.cycles())(`slowMs`,e.slowMs()),A_(`range`,e.range),D(),P(`cycles`,e.picked())(`names`,e.spanNames())(`scope`,e.scope()),D(),j(e.hasSpans()?-1:4),D(3),j(e.range()?7:-1),D(),P(`stats`,e.stats())(`scope`,e.scope())}}function VA(e,t){if(e&1){let e=B();F(0,`tr`)(1,`td`,16)(2,`button`,31),H(`click`,function(){let t=S(e).$implicit;return C(W(2).showComponent.emit(t.name))}),K(3),I()(),F(4,`td`,32),K(5),I(),F(6,`td`,32),K(7),I(),F(8,`td`,32),K(9),I()()}if(e&2){let e=t.$implicit,n=W(2);D(3),J(` `,e.name,` `),D(2),q(e.checks),D(2),q(n.ms(e.ms)),D(2),q(n.ms(e.maxMs))}}function HA(e,t){e&1&&(F(0,`tr`)(1,`td`,33),K(2,`No component time in the picked cycles.`),I()())}function UA(e,t){if(e&1){let e=B();F(0,`tr`)(1,`td`,16)(2,`button`,31),H(`click`,function(){let t=S(e).$implicit;return C(W(2).showComponent.emit(t.name))}),K(3),I()(),F(4,`td`,32),K(5),I(),F(6,`td`,32),K(7),I(),F(8,`td`,32),K(9),I()()}if(e&2){let e=t.$implicit,n=W(2);D(3),J(` `,e.name,` `),D(2),q(e.checks),D(2),q(e.cycles),D(2),q(n.ms(e.ms))}}function WA(e,t){e&1&&(F(0,`tr`)(1,`td`,33),K(2,`No component checks in the picked cycles.`),I()())}function GA(e,t){if(e&1&&(F(0,`span`,35),K(1,`from `),F(2,`span`,16),K(3),I()()),e&2){let e=W(3);D(3),q(e.causeText(t))}}function KA(e,t){if(e&1&&(F(0,`span`,35),K(1,`after `),F(2,`span`,16),K(3),I()()),e&2){let e=W().$implicit;D(3),q(e.trigger)}}function qA(e,t){if(e&1&&(F(0,`span`,36),K(1),I()),e&2){let e=W().$implicit;D(),J(``,e.unchanged,` changed nothing`)}}function JA(e,t){if(e&1&&(F(0,`span`,36),K(1),I()),e&2){let e=W().$implicit,t=W(2);D(),J(`hooks `,t.ms(e.hookMs))}}function YA(e,t){if(e&1&&(F(0,`span`,36),K(1),I()),e&2){let e=W().$implicit,t=W(2);D(),J(`afterRender `,t.ms(e.afterRenderMs))}}function XA(e,t){if(e&1&&(F(0,`span`,37),K(1,` slowest `),F(2,`span`,16),K(3),I(),K(4),I()),e&2){let e=t,n=W(3);D(3),q(e.name),D(),J(` `,n.ms(e.ms),` `)}}function ZA(e,t){if(e&1&&(F(0,`li`)(1,`span`,34),K(2),I(),F(3,`span`,16),K(4),I(),F(5,`span`),K(6),I(),A(7,GA,4,1,`span`,35)(8,KA,4,1,`span`,35),A(9,qA,2,1,`span`,36),A(10,JA,2,1,`span`,36),A(11,YA,2,1,`span`,36),A(12,XA,5,2,`span`,37),I()),e&2){let e,n,r=t.$implicit,i=W(2);D(2),q(i.clock(r.at)),D(2),q(i.ms(r.ms)),D(2),O_(` `,r.checks,` `,r.checks===1?`check`:`checks`,`, `,r.passes,` `,r.passes===1?`pass`:`passes`,` `),D(),j((e=r.cause)?7:r.trigger?8:-1,e),D(2),j(r.unchanged?9:-1),D(),j(r.hookMs?10:-1),D(),j(r.afterRenderMs?11:-1),D(),j((n=r.components[0])?12:-1,n)}}function QA(e,t){if(e&1&&(F(0,`div`,26)(1,`div`)(2,`h3`),K(3),I(),F(4,`table`)(5,`thead`)(6,`tr`)(7,`th`,27),K(8,`Component`),I(),F(9,`th`,28),K(10,`Checks`),I(),F(11,`th`,28),K(12,`Self time`),I(),F(13,`th`,28),K(14,`Slowest`),I()()(),F(15,`tbody`),M(16,VA,10,4,`tr`,null,$h,!1,HA,3,0,`tr`),I()(),F(19,`h3`),K(20),I(),F(21,`table`)(22,`thead`)(23,`tr`)(24,`th`,27),K(25,`Component`),I(),F(26,`th`,28),K(27,`Checks`),I(),F(28,`th`,28),K(29,`Cycles`),I(),F(30,`th`,28),K(31,`Self time`),I()()(),F(32,`tbody`),M(33,UA,10,4,`tr`,null,$h,!1,WA,3,0,`tr`),I()(),F(36,`p`,7),K(37,` Select a component name to show its most checked instance in the tree. `),I()(),F(38,`div`)(39,`h3`),K(40,`Latest cycles`),I(),F(41,`ol`,29),M(42,ZA,13,11,`li`,null,TA),I()()(),L(44,`app-cd-timing`,30)(45,`app-cd-diagnosis`,30)),e&2){let e=W();D(3),J(` `,e.range()?`Slowest components in the picked cycles`:`Slowest components`,` `),D(13),N(e.slowest()),D(4),J(` `,e.range()?`Most often checked in the picked cycles`:`Most often checked`,` `),D(13),N(e.busiest()),D(9),N(e.latest()),D(2),P(`page`,e.page()),D(),P(`page`,e.page())}}function $A(e,t){e&1&&(F(0,`p`,10),K(1,`Use the app. Cycles show up here as Angular runs change detection.`),I())}var ej=3e3,tj=10,nj=class e{livePage=$(null,{alias:`page`});pageId=$(null);rpc=$(null);native=$(!1);angularPages=oD(()=>this.rpc());angularPage=X(()=>sD(this.angularPages(),this.pageId()));attachPending=X(()=>this.angularPage()?.capabilities.profiler===!0);showComponent=cv();range=T(null);imported=T(null);page=X(()=>this.imported()?.page??this.livePage());views=Q(()=>this.livePage()?.options??{flash:!1,track:!1,angularProfiling:!1,angularProfilingAvailable:!0});canShowViews=X(()=>!!this.pageId()&&this.livePage()?.supported!==!1);message=T(``);announcement=T(``);recording=X(()=>this.page()?.recording===!0);cycles=X(()=>this.page()?.cycles??[]);slowMs=X(()=>this.page()?.slowMs??16);maxCycles=X(()=>TT(this.rpc()).limits.cdCycles);picked=X(()=>JO(this.cycles(),this.range()));hasSpans=X(()=>this.picked().some(e=>e.spans?.length));spanNames=X(()=>this.page()?.spanNames??[]);stats=X(()=>this.range()?YO(this.picked()):[...this.page()?.components??[]].sort((e,t)=>t.ms-e.ms));scope=X(()=>{let e=this.range()?this.picked():[];return e.length?e.length===1?`cycle ${e[0].id}`:`cycles ${e[0].id} to ${e.at(-1).id}`:`the recording`});slowest=X(()=>this.stats().slice(0,tj));busiest=X(()=>[...this.stats()].sort((e,t)=>t.checks-e.checks).slice(0,tj));latest=X(()=>this.cycles().slice(-30).reverse());capNotes=X(()=>{let e=this.page()?.droppedClasses??0,t=this.page()?.droppedHosts??0,n=[];return e>0&&n.push(`${e} more component ${e===1?`class was`:`classes were`} checked than a recording keeps, so ${e===1?`it is`:`they are`} left out of these totals.`),t>0&&n.push(`${t} component ${t===1?`instance was`:`instances were`} checked past the 2000 a recording counts, so ${t===1?`its tree row shows`:`their tree rows show`} no check count.`),n});status=X(()=>{if(this.message())return this.message();let e=this.cycles().length;return this.imported()?`${e} ${e===1?`cycle`:`cycles`} imported.`:this.recording()?`Recording. ${e} ${e===1?`cycle`:`cycles`} so far.`:e?`${e} ${e===1?`cycle`:`cycles`} recorded.`:``});ms(e){return`${e<10?e.toFixed(2):Math.round(e)} ms`}clock(e){return VE(e)}causeText(e){return AO(e)}confirming=T(null);confirmTimer;constructor(){E(()=>{let e=this.confirming();e!==null&&this.livePage()?.recording===!0===e&&Z(()=>this.confirmed(e))}),x(Ss).onDestroy(()=>clearTimeout(this.confirmTimer))}confirmed(e){clearTimeout(this.confirmTimer),this.confirming.set(null),this.message.set(``);let t=this.livePage()?.cycles.length??0;this.announce(e?`Recording started.`:`Recording stopped. ${t} ${t===1?`cycle`:`cycles`} recorded.`)}unconfirmed(e){let t=`The page did not confirm that the recording ${e?`started`:`stopped`}. The tab may be in the background, closed or reloading.`;this.message.set(t),this.announce(t)}async toggle(){let e=!this.recording();clearTimeout(this.confirmTimer),this.confirming.set(null),await this.send({on:e},e?`Starting…`:`Stopping…`,()=>``,`request-change-detection-record`,{quiet:!0,keepPending:!0})&&(this.confirming.set(e),this.confirmTimer=setTimeout(()=>{this.confirming()===e&&this.unconfirmed(e)},ej))}showImport(e){this.message.set(``),this.range.set(null),this.imported.set(e);let t=e.page.cycles.length;this.announce(`Imported ${e.fileName}: ${t} ${t===1?`cycle`:`cycles`}. Showing the imported recording.`)}closeImport(){this.imported.set(null),this.range.set(null),this.announce(`Showing the live recording.`)}async clear(){this.range.set(null),await this.send({clear:!0},``,()=>`Cleared the recorded cycles.`)}async setView(e,t){let n=this.views();this.views.set({...n,[e]:t});let r={flash:t?`Flash checks on.`:`Flash checks off.`,track:t?`Performance track on. Record in the Chrome Performance panel to see it.`:`Performance track off.`,angularProfiling:`Angular profiling on. It stays on until the page reloads.`}[e];await this.send({[e]:t},``,()=>r,`request-change-detection-options`)||this.views.set(n)}async send(e,t,n,r=`request-change-detection-record`,{quiet:i=!1,keepPending:a=!1}={}){this.message.set(t);try{if(!this.rpc())throw Error(`no client`);return await oT(this.rpc(),r,{pageId:this.pageId(),...e}),a||this.message.set(``),i||this.announce(n()),!0}catch{let e=`Could not reach the page.`;return this.message.set(e),this.announce(e),!1}}announce(e){this.announcement.update(t=>t===e?`${e} `:e)}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-cd-recording`]],inputs:{livePage:[1,`page`,`livePage`],pageId:[1,`pageId`],rpc:[1,`rpc`],native:[1,`native`]},outputs:{showComponent:`showComponent`},decls:23,vars:13,consts:[[`aria-labelledby`,`cd-heading`,1,`cd`],[1,`cd-head`],[`id`,`cd-heading`],[3,`imported`,`page`,`maxCycles`],[1,`status`],[`role`,`status`,1,`sr-only`],[1,`imported`],[1,`hint`],[`what`,`cycles`,`limit`,`cdCycles`,3,`dropped`,`max`],[1,`note`],[1,`empty`],[`type`,`button`,3,`click`,`disabled`],[`aria-hidden`,`true`,1,`dot`],[`type`,`button`],[`type`,`button`,3,`click`],[1,`badge`],[1,`mono`],[`capability`,`profiler`,`missing`,``,`compact`,``,3,`page`],[`role`,`group`,`aria-labelledby`,`cd-views-label`,1,`views`],[`id`,`cd-views-label`,1,`views-label`],[`type`,`button`,1,`view`,3,`click`,`disabled`],[`capability`,`angularProfiling`,`missing`,``,`compact`,``,3,`page`],[3,`rangeChange`,`cycles`,`slowMs`,`range`],[3,`pick`,`cycles`,`names`,`scope`],[1,`spans-note`],[3,`pick`,`stats`,`scope`],[1,`cd-grid`],[`scope`,`col`],[`scope`,`col`,1,`num`],[`aria-label`,`Latest change detection cycles, newest first`,1,`cycles`],[3,`page`],[`type`,`button`,1,`name-link`,3,`click`],[1,`num`],[`colspan`,`4`],[1,`when`],[1,`trigger`],[1,`timing`],[1,`top`]],template:function(e,t){if(e&1&&(F(0,`section`,0)(1,`div`,1)(2,`h2`,2),K(3,`Change detection`),I(),A(4,DA,4,5),F(5,`app-cd-transfer`,3),H(`imported`,function(e){return t.showImport(e)}),I(),F(6,`p`,4),K(7),I(),F(8,`p`,5),K(9),I()(),A(10,OA,7,1,`p`,6),F(11,`p`,7),K(12,` Records each change detection cycle with Angular's profiler: how long it took, how many components it checked, what started it and which checks changed nothing. Tree rows show how often each instance was checked. Times come from a development build. While it records, the page also wraps Angular's scheduler, adds a signal write hook and watches DOM updates; all of it stops with the recording. `),A(13,kA,1,0)(14,AA,1,0),I(),A(15,NA,2,1),A(16,IA,16,7),L(17,`app-limit-note`,8),M(18,LA,2,1,`p`,9,eg),A(20,BA,9,10),A(21,QA,46,6)(22,$A,2,0,`p`,10),I()),e&2){let e;D(4),j(t.imported()?-1:4),D(),P(`page`,t.page())(`maxCycles`,t.maxCycles()),D(2),q(t.status()),D(2),q(t.announcement()),D(),j((e=t.imported())?10:-1,e),D(3),j(t.native()?13:14),D(2),j(t.page()?.supported===!1?15:-1),D(),j(!t.imported()&&!t.native()?16:-1),D(),P(`dropped`,t.page()?.dropped??0)(`max`,t.maxCycles()),D(),N(t.capNotes()),D(2),j(t.cycles().length?20:-1),D(),j(t.page()?.components?.length?21:t.recording()?22:-1)}},dependencies:[dD,XD,uA,Sk,pA,Rk,$k,wA],styles:[`[_nghost-%COMP%] { - display: block; - margin-top: 16px; -} - -.cd[_ngcontent-%COMP%] { - border: 1px solid var(--%NS%border); - border-radius: var(--%NS%radius); - background: var(--%NS%surface); - padding: 12px 14px; -} - -.cd-head[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; -} - -h2[_ngcontent-%COMP%] { - margin: 0; - font-size: 14px; - color: var(--%NS%text-strong); -} - -h3[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - margin: 12px 0 6px; -} - -button[_ngcontent-%COMP%] { - display: inline-flex; - align-items: center; - gap: 8px; - height: var(--%NS%control-h); - padding: 0 12px; - background: var(--%NS%surface-2); - border: 1px solid var(--%NS%control-border); - border-radius: var(--%NS%radius-sm); - color: var(--%NS%text); - cursor: pointer; - font: inherit; - font-size: 13px; -} - -button[_ngcontent-%COMP%]:hover:not(:disabled) { - background: var(--%NS%surface-3); - border-color: var(--%NS%accent-line); -} - -button[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -button[_ngcontent-%COMP%]:disabled { - cursor: not-allowed; - opacity: 0.6; -} - -.views[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - margin-top: 8px; -} - -.views-label[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -button.view[aria-pressed=true][_ngcontent-%COMP%] { - border-color: var(--%NS%accent-line); -} - -button.view[aria-pressed=true][_ngcontent-%COMP%] .dot[_ngcontent-%COMP%] { - background: var(--%NS%accent); -} - -.dot[_ngcontent-%COMP%] { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--%NS%text-3); -} - -button.on[_ngcontent-%COMP%] .dot[_ngcontent-%COMP%] { - background: var(--%NS%danger); -} - -.status[_ngcontent-%COMP%] { - margin: 0; - color: var(--%NS%text-2); - font-size: 12px; -} - -.hint[_ngcontent-%COMP%], -.note[_ngcontent-%COMP%], -.spans-note[_ngcontent-%COMP%], -.empty[_ngcontent-%COMP%] { - margin: 8px 0; - color: var(--%NS%text-2); - line-height: 1.5; -} - -.cd-grid[_ngcontent-%COMP%] { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 0 16px; -} - -@media (min-width: 880px) { - .cd-grid[_ngcontent-%COMP%] { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - } -} -table[_ngcontent-%COMP%] { - width: 100%; - border-collapse: collapse; - font-size: 12px; -} - -th[_ngcontent-%COMP%], -td[_ngcontent-%COMP%] { - padding: 4px 6px; - border-bottom: 1px solid var(--%NS%border); - text-align: left; -} - -th[_ngcontent-%COMP%] { - color: var(--%NS%text-2); - font-weight: 500; -} - -td.mono[_ngcontent-%COMP%] { - overflow-wrap: anywhere; -} - -.name-link[_ngcontent-%COMP%] { - height: auto; - min-height: 0; - padding: 0; - border: 0; - background: none; - color: var(--%NS%text-strong); - font: inherit; - text-align: left; - text-decoration: underline; - text-underline-offset: 2px; - cursor: pointer; -} - -.name-link[_ngcontent-%COMP%]:hover { - background: none; - text-decoration-thickness: 2px; -} - -.name-link[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: -2px; -} - -.num[_ngcontent-%COMP%] { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.mono[_ngcontent-%COMP%] { - font-family: var(--%NS%font-mono); -} - -.cycles[_ngcontent-%COMP%] { - margin: 0; - padding: 0; - list-style: none; - font-size: 12px; -} - -.cycles[_ngcontent-%COMP%] li[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - gap: 4px 10px; - padding: 4px 0; - border-bottom: 1px solid var(--%NS%border); - color: var(--%NS%text); -} - -.when[_ngcontent-%COMP%], -.trigger[_ngcontent-%COMP%], -.timing[_ngcontent-%COMP%], -.top[_ngcontent-%COMP%] { - color: var(--%NS%text-2); -} - -.imported[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 12px; - margin: 8px 0; - color: var(--%NS%text-2); -} - -.badge[_ngcontent-%COMP%] { - border-color: color-mix(in srgb, var(--%NS%accent) 30%, transparent); - background: color-mix(in srgb, var(--%NS%accent) 12%, transparent); - color: var(--%NS%accent-text); - padding: 2px 8px; - border: 1px solid; - border-radius: var(--%NS%radius-sm); - font-size: 12px; - font-weight: 600; -} - -.trigger[_ngcontent-%COMP%] .mono[_ngcontent-%COMP%], -.top[_ngcontent-%COMP%] .mono[_ngcontent-%COMP%] { - overflow-wrap: anywhere; -}`]})};function rj(e){return Object.fromEntries(e.map(e=>[e.name,e.value??null]))}function ij(e){return e.map(e=>({name:e.name,listened:!!e.listened}))}function aj(e){let t={component:e.name,tag:e.tag,hostPath:e.path};e.selector&&(t.selector=e.selector),e.source&&(t.source=e.source),e.changeDetection&&(t.changeDetection=e.changeDetection),e.encapsulation&&(t.encapsulation=e.encapsulation),t.inputs=rj(e.inputs),t.outputs=ij(e.outputs),t.properties=rj(e.properties??[]),e.listeners.length&&(t.listeners=e.listeners),e.directives.length&&(t.directives=e.directives.map(e=>({name:e.name,inputs:rj(e.inputs),outputs:ij(e.outputs)}))),t.injected=e.dependencies.map(e=>({token:e.token,providedBy:e.providedBy?e.providedByName??`an injector`:null}));try{return JSON.stringify(t,null,2)}catch{return JSON.stringify({component:e.name,tag:e.tag,hostPath:e.path})}}function oj(e){return e.selector||e.path}var sj=1e3;function cj(e,t){let n=[];for(let[r,i]of t){let t=e.get(r);t!==void 0&&t!==i&&n.push(r)}return n}function lj(e){try{return JSON.stringify(e)??`undefined`}catch{return String(e)}}var uj=class{ms;changed=T(new Set);owner=null;last=new Map;timers=new Map;constructor(e=sj){this.ms=e}update(e,t){let n=new Map([...t].map(([e,t])=>[e,lj(t)]));if(e!==this.owner){this.clear(),this.owner=e,this.last=n;return}let r=cj(this.last,n);if(this.last=n,r.length){this.changed.update(e=>new Set([...e,...r]));for(let e of r)clearTimeout(this.timers.get(e)),this.timers.set(e,setTimeout(()=>{this.timers.delete(e),this.changed.update(t=>{let n=new Set(t);return n.delete(e),n})},this.ms))}}clear(){for(let e of this.timers.values())clearTimeout(e);this.timers.clear(),this.changed().size&&this.changed.set(new Set)}};function dj(e,t,n=0){return e===`directive`?`directive:${n}:${t}`:`${e}:${t}`}var fj=[`*`];function pj(e,t){e&1&&K(0),e&2&&q(W(2).label())}function mj(e,t){e&1&&Ag(0,0,null,pj,1,1)}function hj(e,t){if(e&1&&(F(0,`span`,4),K(1),I()),e&2){let e=W(2);D(),q(e.message())}}function gj(e,t){if(e&1){let e=B();F(0,`button`,2),H(`click`,function(){return S(e),C(W().open())}),hg(1,3),I(),A(2,hj,2,1,`span`,4)}if(e&2){let e=W(),t=Pg(1);P(`title`,`Open `+e.label()+` in your editor`),k(`aria-label`,`Open `+e.label()+` in your editor`),D(),P(`ngTemplateOutlet`,t),D(),j(e.message()?2:-1)}}function _j(e,t){if(e&1&&(F(0,`span`,1),hg(1,3),I()),e&2){let e=W(),t=Pg(1);P(`title`,e.label()),D(),P(`ngTemplateOutlet`,t)}}var vj=class e{source=T(T(null));client=X(()=>this.source()());available=X(()=>{let e=this.client();return!!e&&!cT(e)});use(e){this.source.set(e)}async open(e,t){if(!this.available())return{ok:!1,error:`No server is connected.`};try{let n=await oT(this.client(),`open-in-editor`,{file:e,...t?{line:t}:{}});return n?.ok?{ok:!0}:{ok:!1,error:n?.error??`The server did not open it.`}}catch{return{ok:!1,error:`The server did not open it.`}}}static ɵfac=function(t){return new(t||e)};static ɵprov=kl({token:e,factory:e.ɵfac})};function yj(e,t){return t?`${e}:${t}`:e}var bj=class e{editor=x(vj);file=$.required();line=$(void 0);label=X(()=>yj(this.file(),this.line()));message=T(``);async open(){this.message.set(``);let e=await this.editor.open(this.file(),this.line()??void 0);e.ok||this.message.set(`Not opened: ${e.error}`)}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-source-link`]],inputs:{file:[1,`file`],line:[1,`line`]},ngContentSelectors:fj,decls:4,vars:1,consts:[[`text`,``],[1,`source-text`,3,`title`],[`type`,`button`,1,`source-link`,3,`click`,`title`],[3,`ngTemplateOutlet`],[`role`,`alert`,1,`message`]],template:function(e,t){e&1&&(kg(),jh(0,mj,2,0,`ng-template`,null,0,tv),A(2,gj,3,4)(3,_j,2,2,`span`,1)),e&2&&(D(2),j(t.editor.available()?2:3))},dependencies:[Zv],styles:[`[_nghost-%COMP%] { - display: inline; - min-width: 0; -} - -.source-link[_ngcontent-%COMP%] { - display: inline; - padding: 0; - border: 0; - border-radius: 2px; - background: none; - color: inherit; - font: inherit; - text-align: inherit; - text-decoration: underline dotted; - text-underline-offset: 2px; - cursor: pointer; - overflow-wrap: anywhere; -} - -.source-link[_ngcontent-%COMP%]:hover { - color: var(--%NS%accent-text); - text-decoration-style: solid; -} - -.source-link[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.message[_ngcontent-%COMP%] { - margin-left: 6px; - color: var(--%NS%danger-text); - font-family: var(--%NS%font-sans); - font-size: 0.92em; -}`]})},xj=[`field`],Sj=[`editButton`],Cj=[`valueBox`];function wj(e,t){if(e&1){let e=B();R(0,`div`,5)(1,`label`,6),K(2),z(),R(3,`input`,7,0),U(`input`,function(t){return S(e),C(W().draft.set(t.target.value))})(`keydown.enter`,function(t){S(e);let n=W();return t.preventDefault(),C(n.apply())})(`keydown.escape`,function(t){S(e);let n=W();return t.preventDefault(),t.stopPropagation(),C(n.cancel())}),z(),R(5,`button`,8),U(`click`,function(){return S(e),C(W().apply())}),K(6,`Apply`),z(),R(7,`button`,9),U(`click`,function(){return S(e),C(W().cancel())}),K(8,`Cancel`),z()(),R(9,`p`,10),K(10),z()}if(e&2){let e=W();D(),V(`htmlFor`,e.fieldId),D(),J(`New value of `,e.prop().name,`, as JSON`),D(),V(`id`,e.fieldId)(`value`,e.draft())(`readOnly`,e.busy()),k(`aria-busy`,e.busy()?`true`:null)(`aria-invalid`,e.error()?`true`:null)(`aria-describedby`,e.hintId+(e.error()?` `+e.errorId:``)),D(2),V(`disabled`,e.busy()),D(4),V(`id`,e.hintId),D(),J(` JSON, such as "text", 42, true or `,e.objectSample,`. Enter applies, Escape cancels. `)}}function Tj(e,t){if(e&1&&(R(0,`pre`,null,1),K(2),Z_(3,`json`),z()),e&2){let e=W(2);k(`tabindex`,e.scrollable()?0:null)(`role`,e.scrollable()?`region`:null)(`aria-label`,e.scrollable()?`Value of `+e.prop().name:null),D(2),q(Q_(3,4,e.prop().value))}}function Ej(e,t){if(e&1){let e=B();R(0,`button`,14,2),U(`click`,function(){return S(e),C(W(2).start())}),K(2,` Edit `),z()}if(e&2){let e=W(2);V(`disabled`,e.busy()),k(`aria-label`,`Edit `+e.prop().name)}}function Dj(e,t){if(e&1&&(R(0,`p`,13),K(1),z()),e&2){let e=W(2);D(),q(e.prop().locked)}}function Oj(e,t){if(e&1&&(R(0,`div`,11),A(1,Tj,4,6,`pre`),A(2,Ej,3,2,`button`,12),z(),A(3,Dj,2,1,`p`,13)),e&2){let e=W();D(),j(e.bare()?-1:1),D(),j(e.editable()?2:-1),D(),j(e.writes()&&e.prop().locked?3:-1)}}function kj(e,t){if(e&1&&(R(0,`p`,3),K(1),z()),e&2){let e=W();V(`id`,e.errorId),D(),q(e.error())}}var Aj=0;function jj(e){let t=e.trim();if(!t)return{error:`Type a JSON value, such as "text", 42, true, null or [1, 2].`};try{return{value:JSON.parse(t)}}catch{return{error:`Not valid JSON. Put text in double quotes, such as "Paris".`}}}var Mj=class e{prop=$.required();componentId=$.required();pageId=$(null);rpc=$(null);writes=$(!1);bare=$(!1);readDraft=$(null);fieldId=`prop-edit-${++Aj}`;hintId=`${this.fieldId}-hint`;errorId=`${this.fieldId}-error`;objectSample=`{"a": 1}`;editing=T(!1);draft=T(``);busy=T(!1);error=T(``);done=T(``);editable=X(()=>this.writes()&&!!this.prop().edit&&!!this.pageId()&&!!this.rpc());field=hv(`field`);editButton=hv(`editButton`);valueBox=hv(`valueBox`);injector=x(bs);scrollable=T(!1);constructor(){Sv(()=>{this.prop();let e=this.valueBox()?.nativeElement;this.scrollable.set(!!e&&e.scrollHeight>e.clientHeight+1)})}async start(){let e=this.prop().value;this.error.set(``),this.done.set(``);let t=this.readDraft();if(t){this.busy.set(!0);let n=await t();if(this.busy.set(!1),n.error!==void 0){this.error.set(n.error);return}e=n.value}this.draft.set(e===void 0?``:JSON.stringify(e)),this.editing.set(!0),cd(()=>{let e=this.field()?.nativeElement;e?.focus(),e?.select()},{injector:this.injector})}cancel(){this.editing()&&(this.editing.set(!1),this.busy.set(!1),this.error.set(``),this.focusEdit())}async apply(){if(this.busy())return;let e=jj(this.draft());if(`error`in e){this.error.set(e.error);return}let t=this.prop(),n=this.rpc(),r=this.pageId();if(!n||!r||!t.edit)return;this.busy.set(!0),this.error.set(``);let i;try{i=await n.scope(`pangular`).rpc.call(`request-component-edit`,{pageId:r,request:{id:this.componentId(),path:t.prop,value:e.value,kind:t.edit}})??{}}catch{i={error:`The devtools server did not answer. Try again.`}}this.busy.set(!1),this.editing()&&(i.ok?(this.editing.set(!1),this.done.set(`Set ${t.name}.`),this.focusEdit()):(this.error.set(i.error??`The page did not write it.`),this.field()?.nativeElement.focus()))}focusEdit(){cd(()=>this.editButton()?.nativeElement.focus(),{injector:this.injector})}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-prop-value`]],viewQuery:function(e,t){e&1&&Mg(t.field,xj,5)(t.editButton,Sj,5)(t.valueBox,Cj,5),e&2&&Ng(3)},inputs:{prop:[1,`prop`],componentId:[1,`componentId`],pageId:[1,`pageId`],rpc:[1,`rpc`],writes:[1,`writes`],bare:[1,`bare`],readDraft:[1,`readDraft`]},decls:5,vars:3,consts:[[`field`,``],[`valueBox`,``],[`editButton`,``],[`role`,`alert`,1,`error`,3,`id`],[`role`,`status`,1,`sr-only`],[1,`editor`],[1,`sr-only`,3,`for`],[`type`,`text`,`autocomplete`,`off`,`spellcheck`,`false`,1,`field`,`mono`,3,`input`,`keydown.enter`,`keydown.escape`,`id`,`value`,`readOnly`],[`type`,`button`,1,`primary`,3,`click`,`disabled`],[`type`,`button`,3,`click`],[1,`hint`,3,`id`],[1,`view`],[`type`,`button`,1,`edit`,3,`disabled`],[1,`locked`],[`type`,`button`,1,`edit`,3,`click`,`disabled`]],template:function(e,t){e&1&&(A(0,wj,11,11)(1,Oj,4,3),A(2,kj,2,2,`p`,3),R(3,`p`,4),K(4),z()),e&2&&(j(+!t.editing()),D(2),j(t.error()?2:-1),D(2),q(t.done()))},dependencies:[$v],styles:[`[_nghost-%COMP%] { - display: block; - min-width: 0; -} - -.view[_ngcontent-%COMP%] { - display: flex; - align-items: flex-start; - gap: 8px; -} - -pre[_ngcontent-%COMP%] { - flex: 1 1 auto; - min-width: 0; - max-height: 200px; - margin: 0; - overflow: auto; - color: var(--%NS%text); - font-family: var(--%NS%font-mono); - font-size: 12px; - line-height: 1.5; - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -.editor[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - gap: 6px; - align-items: center; -} - -.field[_ngcontent-%COMP%] { - flex: 1 1 160px; - min-width: 0; - height: var(--%NS%control-h); - padding: 0 10px; - background: var(--%NS%bg); - border: 1px solid var(--%NS%control-border); - border-radius: var(--%NS%radius-sm); - color: var(--%NS%text); - font-family: var(--%NS%font-mono); - font-size: 12px; -} - -.field[_ngcontent-%COMP%]:focus-visible { - outline: none; - border-color: var(--%NS%accent); - box-shadow: 0 0 0 3px var(--%NS%accent-soft); -} - -.field[aria-invalid=true][_ngcontent-%COMP%] { - border-color: var(--%NS%danger); -} - -button[_ngcontent-%COMP%] { - display: inline-flex; - flex: none; - align-items: center; - height: var(--%NS%control-h); - padding: 0 12px; - background: var(--%NS%surface-2); - border: 1px solid var(--%NS%control-border); - border-radius: var(--%NS%radius-sm); - color: var(--%NS%text); - cursor: pointer; - font: inherit; - font-size: 13px; - font-weight: 500; - transition: background-color 150ms var(--%NS%ease), border-color 150ms var(--%NS%ease); -} - -button[_ngcontent-%COMP%]:hover:not(:disabled) { - background: var(--%NS%surface-3); - border-color: var(--%NS%accent-line); -} - -button[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -pre[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: -2px; -} - -button[_ngcontent-%COMP%]:disabled { - opacity: 0.6; -} - -button.primary[_ngcontent-%COMP%] { - background: var(--%NS%accent); - border-color: var(--%NS%accent); - color: var(--%NS%accent-ink); -} - -button.primary[_ngcontent-%COMP%]:hover:not(:disabled) { - background: var(--%NS%accent-hover); - border-color: var(--%NS%accent-hover); -} - -button.edit[_ngcontent-%COMP%] { - height: 26px; - padding: 0 10px; - font-size: 12px; -} - -.hint[_ngcontent-%COMP%], -.locked[_ngcontent-%COMP%], -.error[_ngcontent-%COMP%] { - margin: 6px 0 0; - font-size: 12px; - line-height: 1.4; -} - -.hint[_ngcontent-%COMP%], -.locked[_ngcontent-%COMP%] { - color: var(--%NS%text-2); -} - -.error[_ngcontent-%COMP%] { - color: var(--%NS%danger-text); -}`]})},Nj=1e3,Pj={depth:5,limit:100},Fj=`[redacted]`;function Ij(e){return!!e&&typeof e==`object`&&!Array.isArray(e)}function Lj(e){return Ij(e)&&typeof e.$more==`number`&&Object.keys(e).length===1}function Rj(e){return Ij(e)&&typeof e.$collapsed==`string`&&typeof e.size==`number`&&Object.keys(e).length===2}function zj(e){return e===`Array`?`array`:e===`Map`?`map`:e===`Set`?`set`:`object`}function Bj(e){return e.$type===`Map`&&Array.isArray(e.entries)}function Vj(e){return e.$type===`Set`&&Array.isArray(e.values)}function Hj(e){return Object.keys(e).filter(e=>e!==`$type`&&e!==`$more`)}function Uj(e){if(!e||typeof e!=`object`)return null;if(Rj(e))return e.size<=0?null:{kind:zj(e.$collapsed),type:e.$collapsed,size:e.size,collapsed:!0};if(Lj(e))return null;if(Array.isArray(e)){let t=e.at(-1),n=Lj(t)?t.$more:0,r=e.length-+!!n+n;return r?{kind:`array`,type:`Array`,size:r,collapsed:!1}:null}let t=e;if(Bj(t)||Vj(t)){let e=typeof t.size==`number`?t.size:0,n=Bj(t)?`map`:`set`;return e?{kind:n,type:n===`map`?`Map`:`Set`,size:e,collapsed:!1}:null}let n=typeof t.$more==`number`?t.$more:0,r=Hj(t).length+n,i=typeof t.$type==`string`?t.$type:`Object`;return r?{kind:`object`,type:i,size:r,collapsed:!1}:null}function Wj(e){if(typeof e==`string`)return JSON.stringify(e);let t=Uj(e);if(t)return qj(t);try{return JSON.stringify(e)??String(e)}catch{return String(e)}}function Gj(e){let t=Uj(e);if(!t||t.collapsed)return{children:[],more:0};if(Array.isArray(e)){let n=(Lj(e.at(-1))?e.slice(0,-1):e).map((e,t)=>({key:String(t),label:String(t),value:e}));return{children:n,more:t.size-n.length}}let n=e;if(t.kind===`map`){let e=n.entries.map((e,t)=>{let[n,r]=Array.isArray(e)?e:[void 0,e];return{key:String(t),label:Wj(n),value:r}});return{children:e,more:Math.max(0,t.size-e.length)}}if(t.kind===`set`){let e=n.values.map((e,t)=>({key:String(t),label:String(t),value:e}));return{children:e,more:Math.max(0,t.size-e.length)}}return{children:Hj(n).map(e=>({key:e,label:e,value:n[e]})),more:typeof n.$more==`number`?n.$more:0}}function Kj(e,t,n){return`${e} ${e===1?t:n}`}function qj(e){let t={array:Kj(e.size,`item`,`items`),object:Kj(e.size,`key`,`keys`),map:Kj(e.size,`entry`,`entries`),set:Kj(e.size,`value`,`values`)}[e.kind];return`${e.type} (${t})`}function Jj(e,t){return JSON.stringify(t===void 0?e:[t,...e])}function Yj(e,t){return e.map((e,n)=>n===0?e:(t?t[n-1]!==`object`:/^\d+$/.test(e))?`[${e}]`:`.${e}`).join(``)}function Xj(e){return e.every(e=>e&&e===e.trim()&&!/[.[\]]/.test(e))?e.join(`.`):null}var Zj=/^\[(Getter|Unreadable|Circular|Truncated|Function\b.*|Promise)\]$/;function Qj(e){return e===null||typeof e==`boolean`?!0:typeof e==`number`?Number.isFinite(e):typeof e==`string`&&e!==Fj&&!Zj.test(e)&&!e.endsWith(`…`)}function $j(e,t){return!e||t.kind===`map`||t.kind===`set`||e===`signal`&&t.kind===`object`&&t.type!==`Object`?null:e}function eM(e,t,n,r,i=500){let a=[],o=r.trim().toLowerCase();if(!o)return a;let s=new Set,c=(e,t)=>{let r=Jj(e);if(!(s.has(r)||a.length>=i)){s.add(r);for(let r of Gj(t).children){let t=[...e,r.key];if(r.label.toLowerCase().includes(o)&&a.push(t),!Uj(r.value))continue;let i=n(t)??(Uj(r.value)?.collapsed?void 0:r.value);i!==void 0&&c(t,i)}}};return c(e,n(e)??t),a}var tM=(e,t)=>t.id;function nM(e,t){if(e&1){let e=B();F(0,`button`,2),H(`click`,function(){S(e);let t=W().$implicit;return C(W(3).toggle(t))}),w(),F(1,`svg`,3),L(2,`path`,4),I(),fs(),F(3,`span`,14),K(4),I(),F(5,`span`,5),K(6),I()()}if(e&2){let e=W().$implicit;k(`aria-expanded`,e.open)(`aria-label`,e.label+`: `+e.text),D(4),J(``,e.label,`:`),D(2),q(e.text)}}function rM(e,t){if(e&1&&L(0,`app-prop-value`,15),e&2){let e=W(5);P(`prop`,t)(`componentId`,e.componentId())(`pageId`,e.pageId())(`rpc`,e.rpc())(`writes`,e.writes())}}function iM(e,t){if(e&1&&(F(0,`span`,16),K(1),I()),e&2){let e=W(2).$implicit;D(),q(e.text)}}function aM(e,t){if(e&1&&(F(0,`span`,14),K(1),I(),A(2,rM,1,5,`app-prop-value`,15)(3,iM,2,1,`span`,16)),e&2){let e,t=W().$implicit;D(),J(``,t.label,`:`),D(),j((e=t.edit)?2:3,e)}}function oM(e,t){if(e&1){let e=B();F(0,`button`,17),H(`click`,function(){S(e);let t=W().$implicit;return C(W(3).more(t))}),K(1),F(2,`span`,18),K(3),I()()}if(e&2){let e=W().$implicit,t=W(3);D(),J(` Show `,t.step(e),` more `),D(2),J(`(`,e.more,` not listed)`)}}function sM(e,t){e&1&&(F(0,`span`,12),K(1,`Reading from the page…`),I())}function cM(e,t){if(e&1&&(F(0,`span`,13),K(1),I()),e&2){let e=W().$implicit;D(),q(e.text)}}function lM(e,t){if(e&1&&(F(0,`li`,9),A(1,nM,7,4,`button`,10)(2,aM,4,2)(3,oM,4,2,`button`,11)(4,sM,2,0,`span`,12)(5,cM,2,1,`span`,13),I()),e&2){let e,n=t.$implicit;t_(`padding-left`,n.depth*14,`px`),D(),j((e=n.kind)===`node`?1:e===`leaf`?2:e===`more`?3:e===`loading`?4:e===`error`?5:-1)}}function uM(e,t){if(e&1&&(F(0,`ul`,7),M(1,lM,6,3,`li`,8,tM),I()),e&2){let e=W(2);P(`id`,e.listId),k(`aria-label`,e.prop().name+` entries`),D(),N(e.rows())}}function dM(e,t){if(e&1){let e=B();F(0,`div`,1)(1,`button`,2),H(`click`,function(){return S(e),C(W().toggleRoot())}),w(),F(2,`svg`,3),L(3,`path`,4),I(),fs(),F(4,`span`,5),K(5),I()(),L(6,`app-prop-value`,6),I(),A(7,uM,3,2,`ul`,7)}if(e&2){let e=t,n=W();D(),k(`aria-expanded`,n.rootOpen())(`aria-controls`,n.rootOpen()?n.listId:null)(`aria-label`,n.prop().name+`: `+n.summaryOf(e)),D(4),q(n.summaryOf(e)),D(),P(`bare`,!0)(`prop`,n.prop())(`componentId`,n.componentId())(`pageId`,n.pageId())(`rpc`,n.rpc())(`writes`,n.writes())(`readDraft`,n.readWhole),D(),j(n.rootOpen()?7:-1)}}function fM(e,t){if(e&1&&L(0,`app-prop-value`,0),e&2){let e=W();P(`prop`,e.prop())(`componentId`,e.componentId())(`pageId`,e.pageId())(`rpc`,e.rpc())(`writes`,e.writes())}}var pM=0;function mM(e){if(e===void 0)return`undefined`;try{return JSON.stringify(e)??String(e)}catch{return String(e)}}function hM(e){if(Array.isArray(e))return e.every(hM);if(e&&typeof e==`object`){let t=e;return![`$collapsed`,`$more`,`$type`].some(e=>Object.hasOwn(t,e))&&Object.values(t).every(hM)}return Qj(e)}var gM=class e{prop=$.required();store=$.required();componentId=$.required();pageId=$(null);rpc=$(null);writes=$(!1);directive=$(void 0);query=$(``);listId=`value-tree-${++pM}`;host=x(Ml);pendingMore=null;constructor(){Sv(()=>{let e=this.rows(),t=this.pendingMore;if(!t)return;let n=e.findIndex(e=>(e.kind===`leaf`||e.kind===`node`)&&!t.has(e.id));if(n<0)return;this.pendingMore=null;let r=document.activeElement;r&&r!==document.body&&r.isConnected||this.focusNear(n)})}rootPath=X(()=>[this.prop().prop]);rootValue=X(()=>this.store().level(this.rootPath(),this.directive())??this.prop().value);root=X(()=>Uj(this.rootValue()));needle=X(()=>this.query().trim().toLowerCase());matches=X(()=>{let e=this.needle(),t=this.prop();if(!e||[t.name,t.prop].some(t=>t.toLowerCase().includes(e)))return null;let n=this.store(),r=this.directive();return eM(this.rootPath(),this.rootValue(),e=>n.level(e,r),e)});rootOpen=X(()=>!!this.matches()?.length||this.store().isOpen(this.rootPath(),this.directive()));rows=X(()=>{let e=this.root();if(!e)return[];let t=this.store(),n=this.directive(),r=this.matches(),i=new Set,a=new Set;for(let e of r??[]){a.add(Jj(e));for(let t=2;t<=e.length;t++)i.add(Jj(e.slice(0,t)))}let o=this.prop(),s=this.writes()&&n===void 0,c=[],l=(e,o,d,f,p,m)=>{let{children:ee,more:h}=Gj(e);for(let e of ee){let ee=[...o,e.key],h=Jj(ee);if(r&&!m&&!i.has(h))continue;let g=Uj(e.value),te=Jj(ee,n);if(!g){let t=p&&Qj(e.value)?Xj(ee):null;c.push({id:te,kind:`leaf`,depth:f,path:ee,label:e.label,text:mM(e.value),edit:s&&t&&p?{name:Yj(ee,[...d]),prop:t,value:e.value,edit:p}:void 0});continue}let ne=!!r&&!m&&i.has(h)&&!a.has(h)||t.isOpen(ee,n);if(c.push({id:te,kind:`node`,depth:f,path:ee,label:e.label,text:qj(g),container:g,open:ne}),!ne)continue;let re=t.level(ee,n)??(g.collapsed?void 0:e.value),ie=[...d,g.kind];re===void 0?t.errors().has(te)?c.push({...u(ee,f+1,`error`),text:t.errors().get(te)}):c.push(u(ee,f+1,`loading`)):l(re,ee,ie,f+1,$j(p,g),m||a.has(h))}h>0&&(!r||m)&&c.push({...u(o,f,`more`),more:h,listed:ee.length})},u=(e,t,r)=>({id:`${Jj(e,n)}:${r}`,kind:r,depth:t,path:e,label:``,text:``});return l(this.rootValue(),this.rootPath(),[e.kind],0,s?$j(o.editInside??null,e):null,!r),c});readWhole=async()=>{let e=await this.store().read(this.rootPath(),Pj.depth,Pj.limit);return e.ok?hM(e.value)?{value:e.value}:{error:`Too large to edit as a whole. Open it to edit the values inside.`}:{error:e.error??`The page did not read it.`}};summaryOf(e){return qj(e)}toggleRoot(){this.pendingMore=null,this.store().toggle(this.rootPath(),this.directive(),!1)}toggle(e){this.pendingMore=null,this.store().toggle(e.path,this.directive(),!!e.container?.collapsed)}more(e){this.pendingMore=new Set(this.rows().map(e=>e.id)),this.store().more(e.path,this.directive(),e.listed??0)}focusNear(e){let t=this.host.nativeElement,n=Array.from(t.querySelectorAll(`#${this.listId} > li`)),r=e=>e?.querySelector(`button:not([disabled]), input, textarea, [tabindex="0"]`);(n.slice(e).map(r).find(Boolean)??n.slice(0,e).reverse().map(r).find(Boolean)??t.querySelector(`.root > .toggle`))?.focus()}step(e){return Math.min(100,e.more??0)}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-value-tree`]],inputs:{prop:[1,`prop`],store:[1,`store`],componentId:[1,`componentId`],pageId:[1,`pageId`],rpc:[1,`rpc`],writes:[1,`writes`],directive:[1,`directive`],query:[1,`query`]},decls:2,vars:1,consts:[[3,`prop`,`componentId`,`pageId`,`rpc`,`writes`],[1,`root`],[`type`,`button`,1,`toggle`,3,`click`],[`viewBox`,`0 0 24 24`,`aria-hidden`,`true`,1,`chevron`],[`d`,`m9 6 6 6-6 6`],[1,`summary`,`mono`],[1,`whole`,3,`bare`,`prop`,`componentId`,`pageId`,`rpc`,`writes`,`readDraft`],[1,`entries`,3,`id`],[1,`entry`,3,`padding-left`],[1,`entry`],[`type`,`button`,1,`toggle`],[`type`,`button`,1,`more`],[`role`,`status`,1,`muted`],[`role`,`alert`,1,`error`],[1,`key`,`mono`],[1,`leaf-edit`,3,`prop`,`componentId`,`pageId`,`rpc`,`writes`],[1,`leaf`,`mono`],[`type`,`button`,1,`more`,3,`click`],[1,`muted`]],template:function(e,t){if(e&1&&A(0,dM,8,12)(1,fM,1,5,`app-prop-value`,0),e&2){let e;j((e=t.root())?0:1,e)}},dependencies:[Mj],styles:[`[_nghost-%COMP%] { - display: block; - min-width: 0; -} - -.root[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - gap: 8px; -} - -.whole[_ngcontent-%COMP%] { - flex: 1 1 auto; -} - -.entries[_ngcontent-%COMP%] { - margin: 4px 0 0; - padding: 0 0 0 6px; - border-left: 1px solid var(--%NS%border); - list-style: none; -} - -.entry[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 6px; - min-width: 0; - padding-top: 2px; - padding-bottom: 2px; - font-size: 12px; - line-height: 1.5; -} - -.toggle[_ngcontent-%COMP%], -.more[_ngcontent-%COMP%] { - display: inline-flex; - align-items: center; - gap: 4px; - min-height: 24px; - max-width: 100%; - padding: 0 6px 0 2px; - background: none; - border: 1px solid transparent; - border-radius: var(--%NS%radius-sm); - color: var(--%NS%text); - cursor: pointer; - font: inherit; - font-size: 12px; - text-align: left; - transition: background-color 150ms var(--%NS%ease); -} - -.toggle[_ngcontent-%COMP%]:hover, -.more[_ngcontent-%COMP%]:hover { - background: var(--%NS%surface-2); -} - -.toggle[_ngcontent-%COMP%]:focus-visible, -.more[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.more[_ngcontent-%COMP%] { - padding-left: 6px; - color: var(--%NS%text); - text-decoration: underline; -} - -.chevron[_ngcontent-%COMP%] { - flex: none; - width: 14px; - height: 14px; - fill: none; - stroke: currentColor; - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; - transition: transform 150ms var(--%NS%ease); -} - -.toggle[aria-expanded=true][_ngcontent-%COMP%] .chevron[_ngcontent-%COMP%] { - transform: rotate(90deg); -} - -.key[_ngcontent-%COMP%] { - flex: none; - max-width: 50%; - overflow-wrap: anywhere; - color: var(--%NS%text-2); -} - -.summary[_ngcontent-%COMP%] { - color: var(--%NS%text-2); -} - -.leaf[_ngcontent-%COMP%] { - min-width: 0; - color: var(--%NS%text); - overflow-wrap: anywhere; -} - -.leaf-edit[_ngcontent-%COMP%] { - flex: 1 1 160px; -} - -.muted[_ngcontent-%COMP%] { - color: var(--%NS%text-2); -} - -.error[_ngcontent-%COMP%] { - color: var(--%NS%danger-text); -} - -@media (prefers-reduced-motion: reduce) { - .chevron[_ngcontent-%COMP%], - .toggle[_ngcontent-%COMP%], - .more[_ngcontent-%COMP%] { - transition: none; - } -}`]})};function _M(e,t){return e.length>t.length&&e.startsWith(`${t.slice(0,-1)},`)}var vM=class{sender;target={rpc:null,pageId:null,componentId:null};fetched=T(new Map);pushed=T({});open=T(new Set);loading=T(new Set);errors=T(new Map);limits=new Map;generation=0;constructor(e){this.sender=e}follow(e,t){let n=e.componentId===this.target.componentId&&e.pageId===this.target.pageId;this.target=e,n||(this.generation++,this.fetched.set(new Map),this.open.set(new Set),this.loading.set(new Set),this.errors.set(new Map),this.limits.clear()),this.pushed.set(t??{})}level(e,t){let n=Jj(e,t),r=this.pushed();return Object.hasOwn(r,n)?r[n]:this.fetched().get(n)}isOpen(e,t){return this.open().has(Jj(e,t))}toggle(e,t,n){let r=Jj(e,t),i=new Set(this.open());if(i.has(r)){for(let e of i)(e===r||_M(e,r))&&i.delete(e);this.open.set(i),this.send({path:e,directive:t,forget:!0});return}i.add(r),this.open.set(i),n&&this.load(e,t,this.limits.get(r)??30)}more(e,t,n){let r=Jj(e,t),i=Math.min(Nj,Math.max(n,this.limits.get(r)??0)+100);this.limits.set(r,i),this.load(e,t,i)}async read(e,t,n){return this.send({path:e,depth:t,limit:n})}async load(e,t,n){let r=Jj(e,t),i=this.generation;this.loading.update(e=>new Set(e).add(r));let a=await this.send({path:e,directive:t,limit:n,watch:!0});i===this.generation&&(this.loading.update(e=>{let t=new Set(e);return t.delete(r),t}),this.errors.update(e=>{let t=new Map(e);return a.ok?t.delete(r):t.set(r,a.error??`The page did not read it.`),t}),a.ok&&this.fetched.update(e=>new Map(e).set(r,a.value)))}async send(e){if(this.sender){if(e.forget)return{ok:!0};let{directive:t,watch:n,...r}=e;try{return await this.sender(r)}catch{return{error:`The devtools server did not answer. Try again.`}}}let{rpc:t,pageId:n,componentId:r}=this.target;if(!t||!n||!r)return{error:`No live page to read from.`};let{directive:i,...a}=e;try{return await t.scope(`pangular`).rpc.call(`request-component-value`,{pageId:n,request:{id:r,...a,...i===void 0?{}:{directive:i}}})??{}}catch{return{error:`The devtools server did not answer. Try again.`}}}};function yM(e,t){if(e&1&&(R(0,`span`,1),K(1),z()),e&2){let e=W(2);D(),J(`: `,e.label())}}function bM(e,t){if(e&1&&(R(0,`span`,1),K(1),z()),e&2){let e=W(3);D(),J(`: `,e.label())}}function xM(e,t){if(e&1){let e=B();R(0,`button`,4),U(`click`,function(){return S(e),C(W(2).store(`log`))}),K(1,` Log to console `),A(2,bM,2,1,`span`,1),z()}if(e&2){let e=W(2);V(`disabled`,e.busy()||!e.rpc()||!e.pageId()),D(2),j(e.label()?2:-1)}}function SM(e,t){if(e&1){let e=B();R(0,`button`,0),U(`click`,function(){return S(e),C(W().store())}),K(1,` Store as global `),A(2,yM,2,1,`span`,1),z(),A(3,xM,3,2,`button`,2),R(4,`span`,3),K(5),z()}if(e&2){let e=W();V(`disabled`,e.busy()||!e.rpc()||!e.pageId()),D(2),j(e.label()?2:-1),D(),j(e.canLog()?3:-1),D(2),q(e.message())}}var CM=class e{rpc=$(null);pageId=$(null);target=$.required();label=$(``);canLog=$(!1);staticReport=X(()=>cT(this.rpc()));busy=T(!1);message=T(``);async store(e=`store`){let t=this.rpc(),n=this.pageId();if(t&&n){this.busy.set(!0),this.message.set(``);try{let r=await t.scope(`pangular`).rpc.call(`request-console-global`,{pageId:n,target:this.target(),...e===`log`?{mode:e}:{}}),i=e===`log`?r?.ok?`Logged in the page console.`:`Not logged.`:r?.ok&&r.name?`Stored as ${r.name}.`:`Not stored.`;this.message.set(r?.message||i)}catch{this.message.set(`Could not reach the page.`)}finally{this.busy.set(!1)}}}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-store-global`]],inputs:{rpc:[1,`rpc`],pageId:[1,`pageId`],target:[1,`target`],label:[1,`label`],canLog:[1,`canLog`]},decls:1,vars:1,consts:[[`type`,`button`,1,`store`,3,`click`,`disabled`],[1,`sr-only`],[`type`,`button`,1,`store`,`log`,3,`disabled`],[`role`,`status`,1,`status`],[`type`,`button`,1,`store`,`log`,3,`click`,`disabled`]],template:function(e,t){e&1&&A(0,SM,6,4),e&2&&j(t.staticReport()?-1:0)},styles:[`[_nghost-%COMP%] { - display: inline-flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - min-width: 0; -} - - -.store[_ngcontent-%COMP%] { - height: var(--%NS%control-h); - padding: 0 14px; - border: 1px solid var(--%NS%control-border); - border-radius: var(--%NS%radius-sm); - background: var(--%NS%surface-2); - color: var(--%NS%text); - font: inherit; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: background-color 150ms var(--%NS%ease); -} - -.store[_ngcontent-%COMP%]:hover:not(:disabled) { - background: var(--%NS%surface-3); -} - -.store[_ngcontent-%COMP%]:disabled { - cursor: not-allowed; - opacity: 0.6; -} - -.store[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -@media (prefers-reduced-motion: reduce) { - .store[_ngcontent-%COMP%] { - transition: none; - } -} -.status[_ngcontent-%COMP%] { - color: var(--%NS%text-2); - font-size: 12px; -} - -.status[_ngcontent-%COMP%]:empty { - display: none; -}`]})},wM=(e,t)=>t.block.id,TM=(e,t)=>t.kind,EM=(e,t)=>t.id;function DM(e,t){if(e&1&&K(0),e&2){let e=W().$implicit;J(` `,e.label,` `)}}function OM(e,t){if(e&1&&(K(0),R(1,`span`,18),K(2),z()),e&2){let e=W().$implicit;J(` `,e.kind,` `),D(2),J(``,e.seconds,`s`)}}function kM(e,t){if(e&1&&(R(0,`span`,9),A(1,DM,1,1)(2,OM,3,2),z()),e&2){let e=t.$implicit;n_(`note-`+e.kind),k(`title`,e.text),D(),j(e.seconds===void 0?1:2)}}function AM(e,t){if(e&1&&(R(0,`span`,9),K(1),z()),e&2){let e=W().$implicit;k(`title`,e.block.hydratedAt===void 0?`The block hydrated before the devtools attached to the page.`:null),D(),q(e.hydrated)}}function jM(e,t){e&1&&(R(0,`span`,9),K(1,`@error block`),z())}function MM(e,t){if(e&1&&(R(0,`span`,10),K(1),z()),e&2){let e=W().$implicit;D(),q(e.timings)}}function NM(e,t){e&1&&(R(0,`span`,11),K(1,`mismatch`),z())}function PM(e,t){if(e&1&&(R(0,`dt`),K(1,`Template of`),z(),R(2,`dd`,19),K(3),z()),e&2){let e=t;D(3),Y(``,e.name,` <`,e.tag,`>`)}}function FM(e,t){if(e&1&&(R(0,`dt`),K(1,`In this state since`),z(),R(2,`dd`),K(3),z()),e&2){let e=W(2).$implicit,t=W(2);D(3),q(t.clock(e.block.since))}}function IM(e,t){if(e&1&&(R(0,`dt`),K(1,`Hydrated`),z(),R(2,`dd`),K(3),z()),e&2){let e=W(2).$implicit,t=W(2);D(3),J(` `,e.block.hydratedAt===void 0?`Before the devtools attached`:t.clock(e.block.hydratedAt),` `)}}function LM(e,t){if(e&1&&(R(0,`span`,23),K(1),z()),e&2){let e=W().$implicit;D(),q(e.hydration)}}function RM(e,t){if(e&1&&(R(0,`li`)(1,`span`,22),K(2),z(),R(3,`span`,19),K(4),z(),A(5,LM,2,1,`span`,23),z()),e&2){let e=t.$implicit,n=W(5);D(2),q(n.clock(e.at)),D(2),q(e.state),D(),j(e.hydration===`not-configured`?-1:5)}}function zM(e,t){if(e&1&&(R(0,`h3`,20),K(1,`Transitions`),z(),R(2,`ol`,21),M(3,RM,6,3,`li`,null,$h),z()),e&2){let e=W(2).$implicit;D(3),N(e.block.transitions)}}function BM(e,t){if(e&1){let e=B();R(0,`li`)(1,`button`,25),U(`click`,function(){let t=S(e).$implicit;return C(W(5).showNode.emit(t.id))}),K(2),R(3,`span`,19),K(4),z()()()}if(e&2){let e=t.$implicit;D(2),J(` Show `,e.name,` `),D(2),J(`<`,e.tag,`>`)}}function VM(e,t){if(e&1&&(R(0,`h3`,20),K(1,`Hydration mismatches`),z(),R(2,`ul`,24),M(3,BM,5,2,`li`,null,EM),z()),e&2){let e=W(2).$implicit;D(3),N(e.mismatches)}}function HM(e,t){if(e&1&&(R(0,`div`,17)(1,`dl`)(2,`dt`),K(3,`Block`),z(),R(4,`dd`,19),K(5),z(),A(6,PM,4,2),A(7,FM,4,1),A(8,IM,4,1),R(9,`dt`),K(10,`Root elements`),z(),R(11,`dd`),K(12),z(),R(13,`dt`),K(14,`Triggers`),z(),R(15,`dd`,19),K(16),z()(),A(17,zM,5,0),A(18,VM,5,0),z()),e&2){let e,t=W().$implicit;V(`id`,`defer-detail-`+t.block.id),D(5),q(t.block.id),D(),j((e=t.block.owner)?6:-1,e),D(),j(t.block.since?7:-1),D(),j(t.block.hydration===`hydrated`?8:-1),D(4),q(t.block.rootIds.length||`None rendered`),D(4),q(t.block.triggers.join(`, `)||`None`),D(),j(t.block.transitions?.length?17:-1),D(),j(t.mismatches.length?18:-1)}}function UM(e,t){if(e&1){let e=B();R(0,`li`,4)(1,`button`,5),U(`click`,function(){let t=S(e).$implicit;return C(W(2).showOwner.emit(t.block))})(`focus`,function(){let t=S(e).$implicit;return C(W(2).highlight.emit(t.block))})(`blur`,function(){return S(e),C(W(2).highlight.emit(null))})(`mouseenter`,function(){let t=S(e).$implicit;return C(W(2).highlight.emit(t.block))})(`mouseleave`,function(){return S(e),C(W(2).highlight.emit(null))}),R(2,`span`,6),K(3),z(),R(4,`span`,7),K(5),z(),M(6,kM,3,4,`span`,8,TM),A(8,AM,2,2,`span`,9),A(9,jM,2,0,`span`,9),A(10,MM,2,1,`span`,10),A(11,NM,2,0,`span`,11),R(12,`span`,12),K(13),z()(),R(14,`button`,13),U(`click`,function(){let t=S(e).$implicit;return C(W(2).toggle(t.block.id))}),w(),R(15,`svg`,14),dg(16,`path`,15),z(),fs(),R(17,`span`,16),K(18),z()(),A(19,HM,19,9,`div`,17),z()}if(e&2){let e=t.$implicit,n=W(2);D(),k(`aria-disabled`,e.block.owner?null:`true`),D(2),q(e.block.owner?.name??`Unknown component`),D(),n_(`defer-state-`+e.block.state),D(),q(e.block.state),D(),N(e.notes),D(2),j(e.hydrated?8:-1),D(),j(e.block.hasErrorBlock?9:-1),D(),j(e.timings?10:-1),D(),j(e.mismatches.length?11:-1),D(2),q(e.block.triggers.join(`, `)||`no triggers`),D(),k(`aria-expanded`,n.isOpen(e.block.id))(`aria-controls`,n.isOpen(e.block.id)?`defer-detail-`+e.block.id:null),D(4),J(`Details of the block in `,e.block.owner?.name??`an unknown component`),D(),j(n.isOpen(e.block.id)?19:-1)}}function WM(e,t){if(e&1&&(R(0,`section`,0)(1,`h2`,1),K(2,` Defer blocks `),R(3,`span`,2),K(4),z()(),R(5,`ul`,3),M(6,UM,20,14,`li`,4,wM),z()()),e&2){let e=W();D(4),Y(``,e.rows().length,``,e.truncated()?`+`:``),D(2),N(e.rows())}}function GM(e){return e<1e3?`${e}ms`:`${Number((e/1e3).toFixed(2))}s`}function KM(e){let t=[],n=e.placeholder?.minimumTime;n!=null&&t.push(`placeholder min ${GM(n)}`);let r=[e.loading?.afterTime==null?``:`after ${GM(e.loading.afterTime)}`,e.loading?.minimumTime==null?``:`min ${GM(e.loading.minimumTime)}`].filter(Boolean);return r.length&&t.push(`loading ${r.join(`, `)}`),t.join(` · `)}var qM=class e{blocks=$([]);truncated=$(!1);clockOffset=$(0);showOwner=cv();mismatches=$([]);showNode=cv();open=T(new Set);highlight=cv();now=T(Date.now());waiting=X(()=>this.blocks().some(e=>!!e.since&&(e.state===`placeholder`||e.state===`initial`)));rows=X(()=>{let e=this.now()-this.clockOffset();return this.blocks().map(t=>({block:t,notes:Zw({...t,since:t.since||e},e),timings:KM(t),mismatches:this.mismatches().filter(e=>t.rootIds.includes(e.id)),hydrated:t.hydration===`hydrated`?Jw(t):``}))});isOpen(e){return this.open().has(e)}toggle(e){this.open.update(t=>{let n=new Set(t);return n.delete(e)||n.add(e),n})}clock(e){return VE(e)}constructor(){let e,t=()=>{clearInterval(e),e=void 0};E(()=>{if(!this.waiting())return t();e===void 0&&(this.now.set(Date.now()),e=setInterval(()=>this.now.set(Date.now()),1e3))}),x(Ss).onDestroy(t)}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-defer-blocks`]],inputs:{blocks:[1,`blocks`],truncated:[1,`truncated`],clockOffset:[1,`clockOffset`],mismatches:[1,`mismatches`]},outputs:{showOwner:`showOwner`,showNode:`showNode`,highlight:`highlight`},decls:1,vars:1,consts:[[`aria-labelledby`,`ct-defer-title`,1,`defer`],[`id`,`ct-defer-title`,1,`defer-title`],[1,`pill`],[1,`defer-list`],[1,`defer-item`],[`type`,`button`,1,`defer-row`,3,`click`,`focus`,`blur`,`mouseenter`,`mouseleave`],[1,`name`,`mono`],[1,`defer-state`],[1,`flag`,3,`class`],[1,`flag`],[1,`timings`],[1,`flag`,`note-failed`],[1,`triggers`,`mono`],[`type`,`button`,1,`defer-toggle`,3,`click`],[`viewBox`,`0 0 24 24`,`aria-hidden`,`true`],[`d`,`m9 6 6 6-6 6`],[1,`sr-only`],[1,`defer-detail`,3,`id`],[`aria-hidden`,`true`],[1,`mono`],[1,`detail-label`],[1,`transitions`],[1,`when`],[1,`muted`],[1,`mismatch-links`],[`type`,`button`,3,`click`]],template:function(e,t){e&1&&A(0,WM,8,2,`section`,0),e&2&&j(t.rows().length?0:-1)},styles:[`[_nghost-%COMP%] { - display: block; -} - -.defer[_ngcontent-%COMP%] { - margin-top: 16px; -} - -.defer-title[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - display: flex; - align-items: center; - gap: 6px; - margin: 0 0 8px; -} - -.pill[_ngcontent-%COMP%] { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 16px; - padding: 0 5px; - border-radius: 99px; - background: var(--%NS%surface-3); - color: var(--%NS%text-2); - font-size: 10px; - font-weight: 600; - letter-spacing: 0; -} - -.defer-list[_ngcontent-%COMP%] { - display: grid; - gap: 6px; - margin: 0; - padding: 0; - list-style: none; -} - -.defer-item[_ngcontent-%COMP%] { - display: grid; - grid-template-columns: minmax(0, 1fr) var(--%NS%control-h); - gap: 6px; -} - -.defer-toggle[_ngcontent-%COMP%] { - width: var(--%NS%control-h); - padding: 0; - background: var(--%NS%surface); -} - -.defer-toggle[_ngcontent-%COMP%] svg[_ngcontent-%COMP%] { - width: 14px; - height: 14px; - fill: none; - stroke: currentColor; - stroke-width: 2; - transition: transform 150ms var(--%NS%ease); -} - -.defer-toggle[aria-expanded=true][_ngcontent-%COMP%] svg[_ngcontent-%COMP%] { - transform: rotate(90deg); -} - -@media (prefers-reduced-motion: reduce) { - .defer-toggle[_ngcontent-%COMP%] svg[_ngcontent-%COMP%] { - transition: none; - } -} -.defer-toggle[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.defer-detail[_ngcontent-%COMP%] { - grid-column: 1/-1; - padding: 8px 12px; - border: 1px solid var(--%NS%border); - border-radius: var(--%NS%radius-sm); - background: var(--%NS%bg); - font-size: 12px; -} - -.defer-detail[_ngcontent-%COMP%] dl[_ngcontent-%COMP%] { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - gap: 4px 12px; - margin: 0; -} - -.defer-detail[_ngcontent-%COMP%] dt[_ngcontent-%COMP%] { - color: var(--%NS%text-2); -} - -.defer-detail[_ngcontent-%COMP%] dd[_ngcontent-%COMP%] { - min-width: 0; - margin: 0; - color: var(--%NS%text); - overflow-wrap: anywhere; -} - -.detail-label[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - margin: 10px 0 4px; -} - -.transitions[_ngcontent-%COMP%], -.mismatch-links[_ngcontent-%COMP%] { - display: grid; - gap: 4px; - margin: 0; - padding: 0; - list-style: none; -} - -.transitions[_ngcontent-%COMP%] li[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.when[_ngcontent-%COMP%], -.muted[_ngcontent-%COMP%] { - color: var(--%NS%text-2); -} - -.mismatch-links[_ngcontent-%COMP%] button[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.defer-row[_ngcontent-%COMP%] { - justify-content: flex-start; - flex-wrap: wrap; - width: 100%; - height: auto; - min-height: var(--%NS%control-h); - padding: 6px 12px; - background: var(--%NS%surface); - border-color: var(--%NS%border); - text-align: left; -} - -.defer-row[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.defer-row[aria-disabled=true][_ngcontent-%COMP%] { - cursor: default; -} - -.name[_ngcontent-%COMP%] { - min-width: 0; - color: var(--%NS%text-strong); - font-size: 12px; - font-weight: 600; - overflow: hidden; - min-width: 0; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mono[_ngcontent-%COMP%] { - font-family: var(--%NS%font-mono); -} - -.defer-state[_ngcontent-%COMP%] { - padding: 0 8px; - border-radius: 99px; - background: var(--%NS%surface-3); - color: var(--%NS%text-2); - font-size: 11px; - line-height: 18px; -} - -.defer-state-complete[_ngcontent-%COMP%] { - border-color: color-mix(in srgb, var(--%NS%ok) 30%, transparent); - background: color-mix(in srgb, var(--%NS%ok) 12%, transparent); - color: var(--%NS%ok-text); -} - -.defer-state-error[_ngcontent-%COMP%] { - border-color: color-mix(in srgb, var(--%NS%danger) 30%, transparent); - background: color-mix(in srgb, var(--%NS%danger) 12%, transparent); - color: var(--%NS%danger-text); -} - -.defer-state-loading[_ngcontent-%COMP%], -.defer-state-placeholder[_ngcontent-%COMP%] { - border-color: color-mix(in srgb, var(--%NS%warn) 30%, transparent); - background: color-mix(in srgb, var(--%NS%warn) 12%, transparent); - color: var(--%NS%warn-text); -} - -.flag[_ngcontent-%COMP%] { - padding: 0 6px; - border: 1px solid var(--%NS%border-strong); - border-radius: 5px; - color: var(--%NS%text-2); - font-family: var(--%NS%font-mono); - font-size: 10px; - line-height: 16px; -} - -.note-failed[_ngcontent-%COMP%] { - border-color: color-mix(in srgb, var(--%NS%danger) 30%, transparent); - background: color-mix(in srgb, var(--%NS%danger) 12%, transparent); - color: var(--%NS%danger-text); -} - -.note-stuck[_ngcontent-%COMP%] { - border-color: color-mix(in srgb, var(--%NS%warn) 30%, transparent); - background: color-mix(in srgb, var(--%NS%warn) 12%, transparent); - color: var(--%NS%warn-text); -} - -.timings[_ngcontent-%COMP%] { - color: var(--%NS%text-2); - font-size: 11px; -} - -.triggers[_ngcontent-%COMP%] { - min-width: 0; - color: var(--%NS%text-2); - font-size: 12px; - overflow: hidden; - min-width: 0; - text-overflow: ellipsis; - white-space: nowrap; -}`]})},JM=(e,t)=>t.id;function YM(e,t){if(e&1&&(F(0,`p`,4),K(1),I()),e&2){let e=W(2);D(),J(`The page has more @for blocks than the `,e.length,` listed.`)}}function XM(e,t){e&1&&(F(0,`span`,9),K(1,`@empty showing`),I())}function ZM(e,t){e&1&&(F(0,`span`,10),K(1,`@empty`),I())}function QM(e,t){e&1&&(F(0,`span`,10),K(1,`renders nothing`),I())}function $M(e,t){if(e&1){let e=B();F(0,`li`)(1,`button`,6),H(`click`,function(){let t=S(e).$implicit;return C(W(3).showOwner.emit(t))})(`focus`,function(){let t=S(e).$implicit,n=W(3);return C(n.highlight.emit(n.target(t)))})(`blur`,function(){return S(e),C(W(3).highlight.emit(null))})(`mouseenter`,function(){let t=S(e).$implicit,n=W(3);return C(n.highlight.emit(n.target(t)))})(`mouseleave`,function(){return S(e),C(W(3).highlight.emit(null))}),F(2,`span`,7),K(3),I(),F(4,`span`,8),K(5),I(),A(6,XM,2,0,`span`,9)(7,ZM,2,0,`span`,10)(8,QM,2,0,`span`,10),F(9,`span`,11),K(10),I()()()}if(e&2){let e=t.$implicit,n=W(3);D(),k(`aria-disabled`,e.owner?null:`true`),D(2),q(e.owner?.name??`Unknown component`),D(2),Y(` `,e.count,` `,e.count===1?`item`:`items`,` `),D(),j(e.showingEmpty?6:e.hasEmptyBlock?7:e.count===0?8:-1),D(4),q(n.trackLabel(e))}}function eN(e,t){if(e&1&&(F(0,`section`,1)(1,`h2`,2),K(2,` @for blocks `),F(3,`span`,3),K(4),I()(),A(5,YM,2,1,`p`,4),F(6,`ul`,5),M(7,$M,11,6,`li`,null,JM),I()()),e&2){let e=W(),t=W();D(4),q(t.truncated()?e.length+`+`:e.length),D(),j(t.truncated()?5:-1),D(2),N(e)}}function tN(e,t){e&1&&A(0,eN,9,2,`section`,1),e&2&&j(t.length?0:-1)}function nN(e,t){e&1&&L(0,`app-angular-unavailable`,0),e&2&&P(`page`,W().angular())}var rN={$index:`track $index`,item:`track item`,function:`track expression`},iN=class e{blocks=$(void 0);truncated=$(!1);supported=$(null);angular=$(null);showOwner=cv();highlight=cv();target(e){return e.rootIds[0]??e.owner?.id??null}trackLabel(e){return e.track?rN[e.track]:`track unknown`}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-for-blocks`]],inputs:{blocks:[1,`blocks`],truncated:[1,`truncated`],supported:[1,`supported`],angular:[1,`angular`]},outputs:{showOwner:`showOwner`,highlight:`highlight`},decls:2,vars:1,consts:[[`capability`,`forBlocks`,`missing`,``,`compact`,``,1,`for`,3,`page`],[`aria-labelledby`,`ct-for-title`,1,`for`],[`id`,`ct-for-title`,1,`for-title`],[1,`pill`],[1,`note`],[1,`for-list`],[`type`,`button`,1,`for-row`,3,`click`,`focus`,`blur`,`mouseenter`,`mouseleave`],[1,`name`,`mono`],[1,`count`],[1,`flag`,`empty`],[1,`flag`],[1,`track`,`mono`]],template:function(e,t){if(e&1&&A(0,tN,1,1)(1,nN,1,1,`app-angular-unavailable`,0),e&2){let e;j((e=t.blocks())?0:t.supported()===!1?1:-1,e)}},dependencies:[dD],styles:[`[_nghost-%COMP%] { - display: block; -} - -.for[_ngcontent-%COMP%] { - margin-top: 16px; -} - -.for-title[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - display: flex; - align-items: center; - gap: 6px; - margin: 0 0 8px; -} - -.pill[_ngcontent-%COMP%] { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 16px; - padding: 0 5px; - border-radius: 99px; - background: var(--%NS%surface-3); - color: var(--%NS%text-2); - font-size: 10px; - font-weight: 600; - letter-spacing: 0; -} - -.note[_ngcontent-%COMP%] { - margin: 0 0 8px; - color: var(--%NS%text-2); - font-size: 12px; - line-height: 1.5; -} - -.for-list[_ngcontent-%COMP%] { - display: grid; - gap: 6px; - margin: 0; - padding: 0; - list-style: none; -} - -.for-row[_ngcontent-%COMP%] { - justify-content: flex-start; - flex-wrap: wrap; - width: 100%; - height: auto; - min-height: var(--%NS%control-h); - padding: 6px 12px; - background: var(--%NS%surface); - border-color: var(--%NS%border); - text-align: left; -} - -.for-row[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.for-row[aria-disabled=true][_ngcontent-%COMP%] { - cursor: default; -} - -.name[_ngcontent-%COMP%] { - min-width: 0; - color: var(--%NS%text-strong); - font-size: 12px; - font-weight: 600; - overflow: hidden; - min-width: 0; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mono[_ngcontent-%COMP%] { - font-family: var(--%NS%font-mono); -} - -.count[_ngcontent-%COMP%] { - padding: 0 8px; - border-radius: 99px; - background: var(--%NS%surface-3); - color: var(--%NS%text-2); - font-size: 11px; - line-height: 18px; -} - -.flag[_ngcontent-%COMP%] { - padding: 0 6px; - border: 1px solid var(--%NS%border-strong); - border-radius: 5px; - color: var(--%NS%text-2); - font-family: var(--%NS%font-mono); - font-size: 10px; -} - -.flag.empty[_ngcontent-%COMP%] { - border-color: color-mix(in srgb, var(--%NS%warn) 30%, transparent); - background: color-mix(in srgb, var(--%NS%warn) 12%, transparent); - color: var(--%NS%warn-text); -} - -.track[_ngcontent-%COMP%] { - min-width: 0; - color: var(--%NS%text-2); - font-size: 12px; - overflow: hidden; - min-width: 0; - text-overflow: ellipsis; - white-space: nowrap; -}`]})},aN=(e,t)=>t.id;function oN(e,t){if(e&1&&(F(0,`p`,11),K(1),I(),F(2,`h3`,7),K(3,`Still retained`),I(),F(4,`p`,6),K(5,` In a development build, Angular's DI debug data keeps every destroyed component in memory, so Pangular can't tell a leak from that. Check retainers in the Chrome DevTools Memory panel: take a heap snapshot after a few navigations, search for the class and look for a retainer other than Angular's DI debug records. Open subscriptions after destroy are still tracked below. `),I()),e&2){let e=W(),t=W();D(),q(t.plural(e.navigations,`navigation`))}}function sN(e,t){e&1&&(F(0,`p`,6),K(1,` This page cannot track destroyed components. It needs WeakRef, FinalizationRegistry and the Angular debug API of a development build. `),I())}function cN(e,t){if(e&1&&(F(0,`span`,18),K(1),I()),e&2){let e=W().$implicit,t=W(4);D(),q(t.plural(e.subscriptions,`open subscription`))}}function lN(e,t){if(e&1&&(F(0,`li`,13)(1,`span`,14),K(2),I(),F(3,`span`,15),K(4),I(),F(5,`span`,16),K(6),I(),F(7,`span`,17),K(8),I(),A(9,cN,2,1,`span`,18),I()),e&2){let e=t.$implicit,n=W(4);D(2),q(e.name),D(2),q(e.route||`/`),D(2),J(``,n.plural(e.navigations,`navigation`),` ago`),D(2),q(n.ageOf(e)),D(),j(e.subscriptions?9:-1)}}function uN(e,t){if(e&1&&(F(0,`p`,6),K(1),I(),F(2,`ul`,12),M(3,lN,10,5,`li`,13,aN),I()),e&2){let e=W(3);D(),Y(` Destroyed, yet still in memory after at least `,e.minNavigations,` navigations and `,e.minSeconds,` seconds. Take a heap snapshot, search for the class and read its retainers. `),D(2),N(e.retained().slice(0,e.shown))}}function dN(e,t){e&1&&(F(0,`p`,6),K(1,`No destroyed component is still in memory after a few navigations.`),I())}function fN(e,t){if(e&1&&(F(0,`p`,6),K(1),F(2,`span`,19),K(3),I(),K(4,`. Navigate a few times to see whether they stay. `),I()),e&2){let e=W(3);D(),J(` `,e.plural(e.recent().length,`instance`),` destroyed recently and not collected yet: `),D(2),q(e.names(e.recent()))}}function pN(e,t){if(e&1&&(A(0,sN,2,0,`p`,6),F(1,`p`,11),K(2),I(),F(3,`h3`,7),K(4,` Still retained `),F(5,`span`,8),K(6),I()(),A(7,uN,5,2)(8,dN,2,0,`p`,6),A(9,fN,5,2,`p`,6)),e&2){let e=W(),t=W();j(e.support.retention?-1:0),D(2),D_(` `,t.plural(e.navigations,`navigation`),` · `,t.retained().length,` still retained · `,t.plural(e.totals.collected,`instance`),` collected `),D(4),q(t.retained().length),D(),j(t.retained().length?7:8),D(2),j(t.recent().length?9:-1)}}function mN(e,t){if(e&1&&(F(0,`p`,6),K(1,` Off the page but not destroyed (detached views, such as a route reuse cache): `),F(2,`span`,19),K(3),I(),K(4,`. `),I()),e&2){let e=W(2);D(3),q(e.names(e.detached()))}}function hN(e,t){if(e&1&&(F(0,`span`,8),K(1),I()),e&2){let e=W();D(),q(e.subscriptions.length)}}function gN(e,t){if(e&1&&(F(0,`span`,15),K(1),I()),e&2){let e=W().$implicit;D(),q(e.source)}}function _N(e,t){if(e&1&&(F(0,`li`,13)(1,`span`,14),K(2),I(),F(3,`span`,16),K(4),I(),F(5,`span`,18),K(6),I(),A(7,gN,2,1,`span`,15),I()),e&2){let e=t.$implicit;D(2),q(e.name),D(2),J(``,e.count,` open`),D(2),J(` `,e.reason===`instance-destroyed`?`instance destroyed`:`no instance left`,` `),D(),j(e.source?7:-1)}}function vN(e,t){if(e&1&&(F(0,`p`,6),K(1,` Still open although the component that made them is gone. Tear them down with takeUntilDestroyed(), DestroyRef.onDestroy or the async pipe. `),I(),F(2,`ul`,12),M(3,_N,8,4,`li`,13,$h),I()),e&2){let e=W(2),t=W();D(3),N(e.subscriptions.slice(0,t.shown))}}function yN(e,t){e&1&&(F(0,`p`,6),K(1,`No subscription is still open after its component went away.`),I())}function bN(e,t){e&1&&(F(0,`p`,6),K(1,` Subscriptions are tied to component classes only; Angular 20 profiler hooks tie them to instances. `),I())}function xN(e,t){if(e&1&&(A(0,vN,5,0)(1,yN,2,0,`p`,6),A(2,bN,2,0,`p`,6)),e&2){let e=W();j(+!e.subscriptions.length),D(2),j(e.support.instances?-1:2)}}function SN(e,t){e&1&&(F(0,`p`,9),K(1,` Tracking could not start: the page has no RxJS the overlay can load. `),I())}function CN(e,t){e&1&&(F(0,`p`,6),K(1,` Off. Track subscriptions wraps Observable.prototype.subscribe on the page to record which component makes each subscription, until you stop it. Subscriptions made before you start are not seen, so reload or navigate again after starting. `),I())}function wN(e,t){e&1&&L(0,`app-angular-unavailable`,10),e&2&&P(`page`,W(2).angular())}function TN(e,t){if(e&1&&(F(0,`span`,16),K(1),I()),e&2){let e=W().$implicit,t=W(3);D(),q(t.plural(e.subscriptions,`subscription`))}}function EN(e,t){if(e&1){let e=B();F(0,`li`)(1,`button`,20),H(`click`,function(){let t=S(e).$implicit;return C(W(3).showComponent.emit(t.id))})(`focus`,function(){let t=S(e).$implicit;return C(W(3).highlight.emit(t.id))})(`blur`,function(){return S(e),C(W(3).highlight.emit(null))})(`mouseenter`,function(){let t=S(e).$implicit;return C(W(3).highlight.emit(t.id))})(`mouseleave`,function(){return S(e),C(W(3).highlight.emit(null))}),F(2,`span`,14),K(3),I(),F(4,`span`,16),K(5),I(),A(6,TN,2,1,`span`,16),I()()}if(e&2){let e=t.$implicit,n=W(2),r=W();D(3),q(e.name),D(2),q(r.effectsText(e)),D(),j(n.support.subscriptions===`on`?6:-1)}}function DN(e,t){e&1&&(F(0,`p`,6),K(1,` Angular files a component's effects under the template its host sits in, so effects of components side by side in one template show as shared. `),I())}function ON(e,t){if(e&1&&(F(0,`ul`,12),M(1,EN,7,3,`li`,null,aN),I(),A(3,DN,2,0,`p`,6)),e&2){let e=W(),t=W();D(),N(e.components.slice(0,t.shown)),D(2),j(e.components.some(t.hasShared)?3:-1)}}function kN(e,t){e&1&&(F(0,`p`,6),K(1,`No component on the page has a live effect or a tracked subscription.`),I())}function AN(e,t){if(e&1&&(A(0,oN,6,1)(1,pN,10,7),A(2,mN,5,1,`p`,6),F(3,`h3`,7),K(4,` Open subscriptions after destroy `),A(5,hN,2,1,`span`,8),I(),A(6,xN,3,2)(7,SN,2,0,`p`,9)(8,CN,2,0,`p`,6),F(9,`h3`,7),K(10,` Live effects and subscriptions `),F(11,`span`,8),K(12),I()(),A(13,wN,1,1,`app-angular-unavailable`,10),A(14,ON,4,1)(15,kN,2,0,`p`,6)),e&2){let e,n=t,r=W();j(n.support.retention===`dev-di-debug`?0:1),D(2),j(r.detached().length?2:-1),D(3),j(n.support.subscriptions===`on`?5:-1),D(),j((e=n.support.subscriptions)===`on`?6:e===`unavailable`?7:8),D(6),q(n.components.length),D(),j(n.support.effects?-1:13),D(),j(n.components.length?14:15)}}function jN(e,t){e&1&&(F(0,`p`,6),K(1,`No leak data from this page yet. It comes with the next report.`),I())}var MN=20;function NN(e){let t=Math.max(0,Math.round(e/1e3));if(t<120)return`${t} s`;let n=Math.round(t/60);return n<120?`${n} min`:`${Math.round(n/60)} h`}var PN=(e,t,n=`${t}s`)=>`${e} ${e===1?t:n}`,FN=class e{report=$(null);pageId=$(null);rpc=$(null);angular=$(null);now=$(()=>Date.now());showComponent=cv();highlight=cv();shown=MN;minNavigations=2;minSeconds=tw/1e3;plural=PN;busy=T(!1);announcement=T(``);tracking=X(()=>this.report()?.support.subscriptions===`on`);split=X(()=>{let e=this.report(),t=[],n=[],r=[];if(!e)return{retained:t,recent:n,detached:r};let i=e.support.retention===`dev-di-debug`;for(let a of e.destroyed)if(a.detached)r.push(a);else if(i)continue;else this.isRetained(e,a)?t.push(a):n.push(a);return{retained:t,recent:n,detached:r}});retained=X(()=>this.split().retained);recent=X(()=>this.split().recent);detached=X(()=>this.split().detached);ageMs(e,t){return Math.max(0,this.now()()-e.reportedAt+(e.at-t.destroyedAt))}isRetained(e,t){return t.navigations>=2&&this.ageMs(e,t)>=1e4}ageOf(e){let t=this.report();return t?`${NN(this.ageMs(t,e))} old`:``}names(e){let t=[...new Set(e.map(e=>e.name))];return t.length>6?`${t.slice(0,6).join(`, `)} and more`:t.join(`, `)}hasShared(e){return!!e.sharedEffects}effectsText(e){let t=PN(e.effects,`effect`);return e.sharedEffects?`${t}, ${e.sharedEffects} shared`:t}async toggle(){let e=this.pageId();if(!e||this.busy())return;let t=!this.tracking();this.busy.set(!0);try{await oT(this.rpc(),`request-leak-tracking`,{pageId:e,on:t}),this.announcement.set(t?`Tracking subscriptions. Reload or navigate to see subscriptions made at start.`:`Stopped tracking subscriptions.`)}catch{this.announcement.set(`Could not reach the page. Try again.`)}finally{this.busy.set(!1)}}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-leaks-section`]],inputs:{report:[1,`report`],pageId:[1,`pageId`],rpc:[1,`rpc`],angular:[1,`angular`],now:[1,`now`]},outputs:{showComponent:`showComponent`,highlight:`highlight`},decls:12,vars:5,consts:[[`aria-labelledby`,`ct-leaks-title`,1,`leaks`],[1,`head`],[`id`,`ct-leaks-title`],[`type`,`button`,1,`track`,3,`click`,`disabled`],[`role`,`status`,1,`sr-only`],[1,`hint`],[1,`note`],[1,`sub`],[1,`pill`],[`role`,`status`,1,`note`],[`capability`,`signalGraph`,`missing`,``,`compact`,``,3,`page`],[1,`stats`],[1,`rows`],[1,`row`],[1,`name`,`mono`],[1,`meta`,`mono`],[1,`count`],[1,`meta`],[1,`flag`,`warn`],[1,`mono`],[`type`,`button`,1,`row`,`live`,3,`click`,`focus`,`blur`,`mouseenter`,`mouseleave`]],template:function(e,t){if(e&1&&(F(0,`section`,0)(1,`div`,1)(2,`h2`,2),K(3,`Leaks`),I(),F(4,`button`,3),H(`click`,function(){return t.toggle()}),K(5,` Track subscriptions `),I(),F(6,`p`,4),K(7),I()(),F(8,`p`,5),K(9,` Components that left the page but are still in memory, and the live effects and RxJS subscriptions of each component. The browser collects garbage when it decides, so a retained instance is a hint: check what holds it in the Chrome DevTools Memory panel. `),I(),A(10,AN,16,7)(11,jN,2,0,`p`,6),I()),e&2){let e;D(4),P(`disabled`,!t.pageId()||!t.report()),k(`aria-pressed`,t.tracking())(`aria-disabled`,t.busy()||null),D(3),q(t.announcement()),D(3),j((e=t.report())?10:11,e)}},dependencies:[dD],styles:[`[_nghost-%COMP%] { - display: block; -} - -.leaks[_ngcontent-%COMP%] { - margin-top: 16px; -} - -.head[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; -} - -h2[_ngcontent-%COMP%] { - color: var(--%NS%text-3); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - margin: 0; -} - -.track[_ngcontent-%COMP%] { - height: var(--%NS%control-h); -} - -.track[aria-pressed=true][_ngcontent-%COMP%] { - border-color: color-mix(in srgb, var(--%NS%accent) 30%, transparent); - background: color-mix(in srgb, var(--%NS%accent) 12%, transparent); - color: var(--%NS%accent-text); -} - -.track[aria-disabled=true][_ngcontent-%COMP%] { - cursor: progress; -} - -.track[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.hint[_ngcontent-%COMP%], -.note[_ngcontent-%COMP%], -.stats[_ngcontent-%COMP%] { - margin: 8px 0; - color: var(--%NS%text-2); - font-size: 12px; - line-height: 1.5; -} - -.stats[_ngcontent-%COMP%] { - color: var(--%NS%text); -} - -.sub[_ngcontent-%COMP%] { - display: flex; - align-items: center; - gap: 6px; - margin: 14px 0 6px; - color: var(--%NS%text-strong); - font-size: 12px; - font-weight: 600; -} - -.pill[_ngcontent-%COMP%] { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 16px; - padding: 0 5px; - border-radius: 99px; - background: var(--%NS%surface-3); - color: var(--%NS%text-2); - font-size: 10px; - font-weight: 600; -} - -.rows[_ngcontent-%COMP%] { - display: grid; - gap: 6px; - margin: 0; - padding: 0; - list-style: none; -} - -.row[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - min-height: var(--%NS%control-h); - padding: 6px 12px; - border: 1px solid var(--%NS%border); - border-radius: var(--%NS%radius-sm); - background: var(--%NS%surface); - font-size: 12px; -} - -.row.live[_ngcontent-%COMP%] { - justify-content: flex-start; - width: 100%; - height: auto; - text-align: left; -} - -.row.live[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: 2px; -} - -.name[_ngcontent-%COMP%] { - min-width: 0; - color: var(--%NS%text-strong); - font-weight: 600; - overflow: hidden; - min-width: 0; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mono[_ngcontent-%COMP%] { - font-family: var(--%NS%font-mono); -} - -.meta[_ngcontent-%COMP%] { - min-width: 0; - color: var(--%NS%text-2); - overflow: hidden; - min-width: 0; - text-overflow: ellipsis; - white-space: nowrap; -} - -.count[_ngcontent-%COMP%] { - padding: 0 8px; - border-radius: 99px; - background: var(--%NS%surface-3); - color: var(--%NS%text-2); - font-size: 11px; - line-height: 18px; -} - -.flag[_ngcontent-%COMP%] { - padding: 0 6px; - border-radius: 5px; - font-size: 11px; - line-height: 18px; -} - -.flag.warn[_ngcontent-%COMP%] { - border-color: color-mix(in srgb, var(--%NS%warn) 30%, transparent); - background: color-mix(in srgb, var(--%NS%warn) 12%, transparent); - color: var(--%NS%warn-text); -}`]})},IN=12,LN=900;function RN(e){for(let t=e.parentElement;t;t=t.parentElement){let e=getComputedStyle(t);if(/(auto|scroll|overlay)/.test(e.overflowY))return t}return null}var zN=class{options;injector=x(bs);visibleTop=T(0);viewport=T(LN);rowHeight;host=null;scroller=null;detach=null;enabled;range;padTop;padBottom;constructor(e){this.options=e,this.rowHeight=T(e.estimate);let t=e.min??300;this.enabled=X(()=>e.count()>t),this.range=X(()=>{let t=e.count();if(!this.enabled())return{start:0,end:t};let n=this.rowHeight(),r=this.viewport(),i=Math.max(0,Math.min(this.visibleTop(),t*n-r)),a=Math.max(0,Math.min(t-1,Math.floor(i/n)-IN));return{start:a,end:Math.min(t,Math.max(a,Math.ceil((i+r)/n)+IN))}}),this.padTop=X(()=>this.enabled()?this.range().start*this.rowHeight():0),this.padBottom=X(()=>this.enabled()?(e.count()-this.range().end)*this.rowHeight():0),E(()=>{e.count(),cd(()=>this.update(),{injector:this.injector})}),x(Ss).onDestroy(()=>this.detach?.())}get count(){return this.options.count()}rendered(e){let{start:t,end:n}=this.range();return e>=t&&ethis.update();n.addEventListener(`scroll`,r,{passive:!0}),window.addEventListener(`resize`,r),this.detach=()=>{n.removeEventListener(`scroll`,r),window.removeEventListener(`resize`,r)},this.update()}update(){let e=this.host;if(e){if(this.scroller===e)this.place(e.scrollTop,e.clientHeight);else{let t=this.scroller?this.scroller.getBoundingClientRect().top:0,n=this.scroller?this.scroller.clientHeight:window.innerHeight;this.place(t-e.getBoundingClientRect().top,n)}this.options.fixed||this.measure()}}place(e,t){this.visibleTop.set(e),this.viewport.set(t>0?t:LN)}measure(){let e=this.host?.querySelectorAll(`[data-vindex]`);if(!e||e.length<2)return;let t=e[0].getBoundingClientRect(),n=(e[e.length-1].getBoundingClientRect().bottom-t.top)/e.length;n>4&&Math.abs(n-this.rowHeight())>1&&this.rowHeight.set(n)}reveal(e,t){if(this.enabled()&&!this.rendered(e)){let t=e*this.rowHeight(),n=this.viewport(),r=Math.max(0,t-n/2);if(this.place(r,n),this.host&&this.scroller===this.host)this.host.scrollTop=r;else if(this.host){let e=this.scroller?this.scroller.getBoundingClientRect().top:0,t=this.host.getBoundingClientRect().top-e+r;this.scroller?this.scroller.scrollTop+=t:window.scrollBy(0,t)}}t&&cd(t,{injector:this.injector})}};function BN(e){let t=e.ownerDocument.defaultView;if(!t)return null;for(let n=e.parentElement;n&&n!==e.ownerDocument.documentElement&&n!==e.ownerDocument.body;n=n.parentElement)if(/(auto|scroll|overlay)/.test(t.getComputedStyle(n).overflowY))return n;return null}var VN=e=>{let t=parseFloat(e);return Number.isFinite(t)?t:0};function HN(e,t=8){let n=e.ownerDocument.defaultView;if(!n)return e.offsetHeight+t;let r=BN(e),i=r?n.getComputedStyle(r):null,a=i?VN(i.paddingTop):0,o=i?VN(i.scrollPaddingTop):0,s=VN(n.getComputedStyle(e).top);return Math.max(0,Math.ceil(a+s+e.offsetHeight+t-o))}function UN(e,t,n=8){let r=BN(e),i=t.getBoundingClientRect().bottom+n-e.getBoundingClientRect().top;i<=0||(r?r.scrollTop-=i:e.ownerDocument.defaultView?.scrollBy(0,-i))}var WN=[`input`];function GN(e,t){if(e&1){let e=B();R(0,`button`,9),U(`click`,function(){return S(e),C(W().save())}),w(),R(1,`svg`,10),dg(2,`path`,11),z(),fs(),R(3,`span`,12),K(4,`Save`),z()()}e&2&&k(`aria-label`,`Save filter `+W().query())}function KN(e,t){if(e&1){let e=B();R(0,`li`,14)(1,`button`,15),U(`click`,function(){let t=S(e).$implicit;return C(W(2).apply(t))}),K(2),z(),R(3,`button`,16),U(`click`,function(){let t=S(e).$implicit;return C(W(2).remove(t))}),w(),R(4,`svg`,10),dg(5,`path`,17),z()()()}if(e&2){let e=t.$implicit,n=W(2);G(`active`,e===n.query()),D(),k(`aria-pressed`,e===n.query())(`title`,e),D(),J(` `,e,` `),D(),k(`aria-label`,`Remove saved filter `+e)}}function qN(e,t){if(e&1&&(R(0,`ul`,7),M(1,KN,6,6,`li`,13,eg),z()),e&2){let e=W();D(),N(e.saved())}}function JN(e,t){if(e&1&&(R(0,`span`,8),K(1),z()),e&2){let e=W();D(),q(e.announcement())}}var YN=`pangular-pref:filters`,XN=200;function ZN(e){try{let t=localStorage.getItem(`${YN}:${e}`),n=t?JSON.parse(t):[];if(!Array.isArray(n))return[];let r=n.filter(e=>typeof e==`string`&&!!e.trim()&&e.length<=XN);return[...new Set(r)].slice(-8)}catch{return[]}}function QN(e,t){try{t.length?localStorage.setItem(`${YN}:${e}`,JSON.stringify(t)):localStorage.removeItem(`${YN}:${e}`)}catch{}}var $N=class e{value=pv(``);label=$(`Filter`);placeholder=$(`Filter`);inputId=$(null);describedBy=$(null);saveKey=$(null);input=hv.required(`input`);host=x(Ml);injector=x(bs);announcement=T(``);query=X(()=>this.value().trim());saved=Q(()=>{let e=this.saveKey();return e?ZN(e):[]});canSave=X(()=>{let e=this.query();return!!this.saveKey()&&!!e&&e.length<=XN&&!this.saved().includes(e)&&this.saved().length<8});focus(){this.input().nativeElement.focus()}clear(e){this.value()&&(e.preventDefault(),e.stopPropagation(),this.value.set(``))}save(){let e=this.saveKey(),t=this.query();if(!e||!this.canSave())return;let n=[...this.saved(),t];this.saved.set(n),QN(e,n),this.say(`Filter ${t} saved.`),this.focusAfterRender(`.chip:last-child .apply`)}apply(e){this.value.set(this.query()===e?``:e)}remove(e){let t=this.saveKey();if(!t)return;let n=this.saved().indexOf(e),r=this.saved().filter(t=>t!==e);if(this.saved.set(r),QN(t,r),this.say(`Saved filter ${e} removed.`),!r.length){this.focus();return}let i=Math.min(n,r.length-1)+1;this.focusAfterRender(`.chip:nth-child(${i}) .apply`)}focusAfterRender(e){cd(()=>{let t=this.host.nativeElement.querySelector(e);t?t.focus():this.focus()},{injector:this.injector})}say(e){this.announcement.update(t=>t===e?`${e} `:e)}static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-search-field`]],viewQuery:function(e,t){e&1&&Mg(t.input,WN,5),e&2&&Ng()},inputs:{value:[1,`value`],label:[1,`label`],placeholder:[1,`placeholder`],inputId:[1,`inputId`],describedBy:[1,`describedBy`],saveKey:[1,`saveKey`]},outputs:{value:`valueChange`},decls:9,vars:8,consts:[[`input`,``],[1,`box`],[`viewBox`,`0 0 24 24`,`aria-hidden`,`true`,1,`icon`],[`cx`,`11`,`cy`,`11`,`r`,`7`],[`d`,`m20 20-3.5-3.5`],[`type`,`search`,`autocomplete`,`off`,`spellcheck`,`false`,3,`input`,`keydown.escape`,`placeholder`,`value`],[`type`,`button`,1,`save`],[`aria-label`,`Saved filters`,1,`chips`],[`role`,`status`,1,`sr-only`],[`type`,`button`,1,`save`,3,`click`],[`viewBox`,`0 0 24 24`,`aria-hidden`,`true`],[`d`,`M6 4h12v16l-6-4-6 4z`],[1,`save-text`],[1,`chip`,3,`active`],[1,`chip`],[`type`,`button`,1,`apply`,3,`click`],[`type`,`button`,1,`remove`,3,`click`],[`d`,`M7 7l10 10M17 7 7 17`]],template:function(e,t){e&1&&(R(0,`div`,1),w(),R(1,`svg`,2),dg(2,`circle`,3)(3,`path`,4),z(),fs(),R(4,`input`,5,0),U(`input`,function(e){return t.value.set(e.target.value)})(`keydown.escape`,function(e){return t.clear(e)}),z()(),A(6,GN,5,1,`button`,6),A(7,qN,3,0,`ul`,7),A(8,JN,2,1,`span`,8)),e&2&&(D(4),V(`placeholder`,t.placeholder())(`value`,t.value()),k(`id`,t.inputId())(`aria-label`,t.label())(`aria-describedby`,t.describedBy()),D(2),j(t.canSave()?6:-1),D(),j(t.saveKey()&&t.saved().length?7:-1),D(),j(t.saveKey()?8:-1))},styles:[`[_nghost-%COMP%] { - display: flex; - flex: 1 1 220px; - flex-wrap: wrap; - align-items: center; - gap: 6px; - min-width: 0; -} - -.box[_ngcontent-%COMP%] { - position: relative; - flex: 1 1 180px; - min-width: 0; -} - -.icon[_ngcontent-%COMP%] { - position: absolute; - top: 50%; - left: 12px; - width: 14px; - height: 14px; - transform: translateY(-50%); - fill: none; - stroke: var(--%NS%text-3); - stroke-width: 2.2; - stroke-linecap: round; - pointer-events: none; -} - -input[_ngcontent-%COMP%] { - width: 100%; - height: var(--%NS%control-h); - padding: 0 12px 0 34px; - background: var(--%NS%bg); - border: 1px solid var(--%NS%control-border); - border-radius: var(--%NS%radius-sm); - color: var(--%NS%text); - font: inherit; - font-size: 13px; -} - -input[_ngcontent-%COMP%]::placeholder { - color: var(--%NS%text-3); -} - -input[_ngcontent-%COMP%]:focus { - outline: none; - border-color: var(--%NS%accent); - box-shadow: 0 0 0 3px var(--%NS%accent-soft); -} - -.save[_ngcontent-%COMP%] { - display: inline-flex; - flex: none; - align-items: center; - gap: 6px; - height: var(--%NS%control-h); - padding: 0 10px; - border: 1px solid var(--%NS%control-border); - border-radius: var(--%NS%radius-sm); - background: var(--%NS%surface); - color: var(--%NS%text); - font: inherit; - font-size: 12px; - cursor: pointer; - transition: background 150ms var(--%NS%ease); -} - -.save[_ngcontent-%COMP%]:hover { - background: var(--%NS%surface-2); -} - -.save[_ngcontent-%COMP%] svg[_ngcontent-%COMP%], -.remove[_ngcontent-%COMP%] svg[_ngcontent-%COMP%] { - width: 14px; - height: 14px; - fill: none; - stroke: currentColor; - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; -} - -.chips[_ngcontent-%COMP%] { - display: flex; - flex-wrap: wrap; - gap: 6px; - min-width: 0; - margin: 0; - padding: 0; - list-style: none; -} - -.chip[_ngcontent-%COMP%] { - display: inline-flex; - align-items: center; - max-width: 100%; - height: 26px; - border: 1px solid var(--%NS%control-border); - border-radius: 999px; - background: var(--%NS%surface); - overflow: hidden; -} - -.chip.active[_ngcontent-%COMP%] { - border-color: color-mix(in srgb, var(--%NS%accent) 30%, transparent); - background: color-mix(in srgb, var(--%NS%accent) 12%, transparent); - color: var(--%NS%accent-text); -} - -.chip[_ngcontent-%COMP%] button[_ngcontent-%COMP%] { - height: 100%; - border: 0; - background: transparent; - color: inherit; - font: inherit; - cursor: pointer; -} - -.apply[_ngcontent-%COMP%] { - overflow: hidden; - min-width: 0; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 22ch; - padding: 0 4px 0 10px; - color: var(--%NS%text); - font-family: var(--%NS%font-mono); - font-size: 12px; -} - -.chip.active[_ngcontent-%COMP%] .apply[_ngcontent-%COMP%] { - color: var(--%NS%accent-text); -} - -.remove[_ngcontent-%COMP%] { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - padding: 0; - color: var(--%NS%text-2); -} - -.remove[_ngcontent-%COMP%]:hover { - color: var(--%NS%text-strong); -} - -.save[_ngcontent-%COMP%]:focus-visible, -.chip[_ngcontent-%COMP%] button[_ngcontent-%COMP%]:focus-visible { - outline: 2px solid var(--%NS%accent); - outline-offset: -2px; -} - -@media (max-width: 420px) { - .save-text[_ngcontent-%COMP%] { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - } -} -@media (prefers-reduced-motion: reduce) { - .save[_ngcontent-%COMP%] { - transition: none; - } -}`]})},eP=[`[redacted]`,`[email]`],tP=2e4;function nP(e){let t=0;for(let n of eP)for(let r=e.indexOf(n);r>=0;r=e.indexOf(n,r+n.length))t++;return t}function rP(e,t=tP){let n=0,r=t,i=new Set,a=[e];for(;a.length;){let e=a.pop();if(typeof e==`string`){if(--r<0)return{count:n,capped:!0};n+=nP(e);continue}if(typeof e==`object`&&e&&!i.has(e)){if(i.add(e),Array.isArray(e)){for(let t of e)a.push(t);continue}for(let[t,i]of Object.entries(e)){if(--r<0)return{count:n,capped:!0};n+=nP(t),a.push(i)}}}return{count:n,capped:!1}}function iP(e,t){if(e&1&&(R(0,`p`,0),K(1),R(2,`span`,1),K(3),z()()),e&2){let e=W();D(),D_(` `,e.result().capped?`At least `:``,``,e.result().count,` `,e.result().count===1?`value`:`values`,` masked `),D(2),q(e.why())}}var aP=class e{value=$(void 0);why=$(`by redaction (secret names, tokens and emails).`);result=X(()=>rP(this.value()));static ɵfac=function(t){return new(t||e)};static ɵcmp=O({type:e,selectors:[[`app-redaction-count`]],inputs:{value:[1,`value`],why:[1,`why`]},decls:1,vars:1,consts:[[`data-redaction-count`,``,1,`count`],[1,`why`]],template:function(e,t){e&1&&A(0,iP,4,4,`p`,0),e&2&&j(t.result().count>0?0:-1)},styles:[`[_nghost-%COMP%] { - display: block; -} - -.count[_ngcontent-%COMP%] { - margin: 0 0 8px; - color: var(--%NS%text-2); - font-size: 12px; - line-height: 1.5; - font-variant-numeric: tabular-nums; -} - -.why[_ngcontent-%COMP%] { - color: var(--%NS%text-2); -}`]})},oP=[`filterInput`],sP=[`treeEl`],cP=[`propFilterInput`],lP=()=>[],uP=e=>({$implicit:e}),dP=e=>({kind:`component`,id:e}),fP=(e,t)=>t.row.node.id,pP=(e,t)=>t.outlet+t.route,mP=(e,t)=>t+e;function hP(e,t){return this.sourceKey(t)}var gP=(e,t)=>t.formId,_P=(e,t)=>t.name+t.index,vP=(e,t)=>t.name,yP=(e,t)=>t.id,bP=(e,t)=>t.token+e;function xP(e,t){if(e&1){let e=B();F(0,`span`,38),K(1,`Page`),I(),F(2,`app-select`,39),H(`valueChange`,function(t){return S(e),C(W(2).selectPage(t))}),I()}if(e&2){let e=W(2);D(2),P(`options`,e.pageOptions())(`value`,e.page()?.pageId??null)}}function SP(e,t){if(e&1){let e=B();F(0,`button`,40),H(`click`,function(){return S(e),C(W(2).toggleDirectiveHosts())}),w(),F(1,`svg`,12),L(2,`path`,41)(3,`path`,42),I(),fs(),F(4,`span`,15),K(5,`Directive hosts`),I()()}e&2&&k(`aria-pressed`,W(2).showDirectiveHosts())}function CP(e,t){if(e&1&&(F(0,`p`,22),K(1),I()),e&2){let e=W(2);D(),q(e.pickMessage())}}function wP(e,t){if(e&1&&(F(0,`p`,23),K(1),I()),e&2){let e=W(2);D(),q(e.truncationText())}}function TP(e,t){e&1&&(F(0,`p`,44),K(1,`The filter only searches the components the tree lists.`),I())}function EP(e,t){e&1&&(F(0,`p`,44),K(1,`Directive hosts are hidden, so they are not searched.`),I())}function DP(e,t){if(e&1){let e=B();F(0,`div`,27)(1,`p`,43),K(2),I(),A(3,TP,2,0,`p`,44),A(4,EP,2,0,`p`,44),F(5,`button`,45),H(`click`,function(){return S(e),C(W(2).clearFilter())}),K(6,`Clear filters`),I()()}if(e&2){let e=W(2);D(2),J(`No components match “`,e.filter().trim(),`”`),D(),j(e.page().truncated?3:-1),D(),j(!e.showDirectiveHosts()&&e.page().directiveHostCount?4:-1)}}function OP(e,t){if(e&1&&(F(0,`p`,28),K(1),I()),e&2){let e=W(2);D(),J(` `,e.rows().length,` rows. Rows render as you scroll, so the browser's find only sees the ones near view; the filter above searches them all. `)}}function kP(e,t){if(e&1){let e=B();F(0,`span`,53),H(`click`,function(t){S(e),W();let n=I_(0);return C(W(2).toggle(n.node.id,t))}),w(),F(1,`svg`,54),L(2,`path`,55),I()()}e&2&&(W(),G(`open`,I_(0).expanded))}function AP(e,t){e&1&&L(0,`span`,48)}function jP(e,t){if(e&1&&(F(0,`span`,56),K(1),I(),F(2,`span`,57),K(3),I(),F(4,`span`,26),K(5,`directive host`),I()),e&2){W();let e=I_(0),t=W(2);D(),J(`<`,e.node.tag,`>`),D(),k(`title`,e.node.directives?.join(`, `)),D(),q(t.hostLabel(e.node))}}function MP(e,t){if(e&1&&(F(0,`span`,58),K(1),I(),F(2,`span`,59),K(3),I()),e&2){W();let e=I_(0);k(`title`,e.node.name),D(),q(e.node.name),D(),k(`title`,`<`+e.node.tag+`>`),D(),J(`<`,e.node.tag,`>`)}}function NP(e,t){if(e&1&&(F(0,`span`)(1,`span`,26),K(2,`Hydration: `),I(),K(3),I()),e&2){let e=t,n=W(3);n_(`hyd hyd-`+e),D(3),q(n.hydrationText(e))}}function PP(e,t){if(e&1&&(F(0,`span`,50),K(1),I()),e&2){let e=t.$implicit;D(),q(e.route||`/`)}}function FP(e,t){if(e&1&&(F(0,`span`,51),K(1),F(2,`span`,26),K(3),I()()),e&2){let e=t;D(),J(` `,e),D(2),J(` `,e===1?`check`:`checks`,` while recording`)}}function IP(e,t){if(e&1&&(F(0,`span`,52),K(1),F(2,`span`,26),K(3),I()()),e&2){W();let e=I_(0);k(`title`,e.node.directives.join(`, `)),D(),J(` +`,e.node.directives.length,` `),D(2),J(` `,e.node.directives.length===1?`directive`:`directives`,` `)}}function LP(e,t){if(e&1){let e=B();P_(0),F(1,`div`,46),H(`click`,function(){S(e);let t=I_(0);return C(W(2).select(t.node.id))})(`focus`,function(t){S(e);let n=I_(0),r=W(2);return r.focusId.set(n.node.id),r.highlight(n.node.id),C(r.showNameTip(t))})(`blur`,function(){S(e);let t=W(2);return t.highlight(null),C(t.nameTip.set(null))})(`keydown.escape`,function(){return S(e),C(W(2).nameTip.set(null))})(`mouseenter`,function(){S(e);let t=I_(0);return C(W(2).highlight(t.node.id))})(`mouseleave`,function(){return S(e),C(W(2).highlight(null))}),A(2,kP,3,2,`span`,47)(3,AP,1,0,`span`,48),A(4,jP,6,3)(5,MP,4,4),A(6,NP,4,3,`span`,49),M(7,PP,2,1,`span`,50,pP),A(9,FP,4,2,`span`,51),A(10,IP,4,3,`span`,52),I()}if(e&2){let e,n,r=t.$implicit,i=W(2),a=F_(r.row);D(),t_(`--%NS%depth`,a.depth),G(`selected`,i.selectedId()===a.node.id)(`dir-host`,a.node.kind===`directive`),k(`aria-level`,a.depth+1)(`aria-setsize`,a.setsize)(`aria-posinset`,a.posinset)(`aria-expanded`,a.hasChildren?a.expanded:null)(`aria-selected`,i.selectedId()===a.node.id)(`tabindex`,i.tabStop()===a.node.id?0:-1)(`data-vindex`,r.index)(`data-id`,a.node.id),D(),j(a.hasChildren?2:3),D(2),j(a.node.kind===`directive`?4:5),D(2),j((e=a.node.hydration)?6:-1,e),D(),N(i.routedById().get(a.node.id)??G_(20,lP)),D(2),j((n=i.cdHosts()[a.node.id])?9:-1,n),D(),j(a.node.kind!==`directive`&&a.node.directives?.length?10:-1)}}function RP(e,t){if(e&1&&(F(0,`div`,60),K(1),I()),e&2){let e=t;t_(`top`,e.top,`px`),D(),J(` `,e.text,` `)}}function zP(e,t){if(e&1&&(F(0,`li`,68),K(1),hg(2,71),I()),e&2){let e=t.$implicit;W(3);let n=Pg(3);D(),J(` `,e,` `),D(),P(`ngTemplateOutlet`,n)(`ngTemplateOutletContext`,K_(3,uP,e))}}function BP(e,t){if(e&1){let e=B();F(0,`div`,70)(1,`button`,45),H(`click`,function(){let t=S(e);return C(W(3).reveal(t.id,!0))}),K(2),I()()}e&2&&(D(2),J(` Show `,t.name,` `))}function VP(e,t){if(e&1&&(F(0,`section`,32)(1,`header`,61)(2,`span`,62),K(3,`directive host`),I(),F(4,`h2`,63),K(5),I(),F(6,`span`,64),K(7),I()(),F(8,`div`,65)(9,`h3`),K(10,` Directives `),F(11,`span`,66),K(12),I()(),F(13,`ul`,67),M(14,zP,3,5,`li`,68,mP),I()(),F(16,`div`,65)(17,`p`,69),K(18,` This element hosts directives but no component. Their inputs are bound in the template of the component that renders it. `),I(),A(19,BP,3,1,`div`,70),I()()),e&2){let e,n=t,r=W(2);D(5),J(`<`,n.tag,`>`),D(2),q(r.hostLabel(n)),D(5),q(n.directives?.length??0),D(2),N(n.directives??G_(4,lP)),D(5),j((e=r.ownerOf(n.id))?19:-1,e)}}function HP(e,t){if(e&1&&(F(0,`p`,72),L(1,`app-source-link`,76),I()),e&2){let e=t;D(),P(`file`,e.file)(`line`,e.line)}}function UP(e,t){if(e&1&&(F(0,`p`,77)(1,`span`,79),K(2,`Likely`),I(),F(3,`span`,26),K(4,`, matched by class name: `),I(),L(5,`app-source-link`,76),I()),e&2){W();let e=I_(0);P(`title`,W(3).guessTitle),D(5),P(`file`,e[0].file)(`line`,e[0].line)}}function WP(e,t){if(e&1&&(F(0,`li`),L(1,`app-source-link`,76),I()),e&2){let e=t.$implicit;D(),P(`file`,e.file)(`line`,e.line)}}function GP(e,t){if(e&1&&(F(0,`div`,78)(1,`p`,80)(2,`span`,79),K(3,`Not certain`),I(),K(4),I(),F(5,`ul`,81),M(6,WP,2,2,`li`,null,hP,!0),I()()),e&2){W();let e=I_(0),t=W(),n=W(2);P(`title`,n.guessTitle),D(4),Y(` `,e.length,` source files define `,n.bareName(t.name),`: `),D(2),N(e)}}function KP(e,t){if(e&1&&(P_(0),A(1,UP,6,3,`p`,77)(2,GP,8,3,`div`,78)),e&2){let e=W(),t=F_(W(2).sourceCandidates(e));D(),j(t.length===1?1:t.length>1?2:-1)}}function qP(e,t){if(e&1){let e=B();F(0,`div`,82)(1,`button`,45),H(`click`,function(){S(e);let t=W();return C(W(2).revealInElements(t))}),K(2,`Reveal in Elements`),I(),F(3,`button`,45),H(`click`,function(){S(e);let t=W();return C(W(2).openSource(t))}),K(4,`Open source`),I()(),F(5,`p`,83),K(6),I()}if(e&2){let e=W(3);D(6),q(e.actionMessage())}}function JP(e,t){if(e&1){let e=B();F(0,`button`,85),H(`click`,function(){let t=S(e).$implicit;return C(W(5).showForm.emit(t.formId))}),K(1),I()}if(e&2){let e=t.$implicit;D(),J(` Show `,e.label,` in Forms `)}}function YP(e,t){if(e&1&&(F(0,`div`,73),M(1,JP,2,1,`button`,84,gP),I()),e&2){let e=W(),t=W(3);D(),N(t.formsIn(e.file))}}function XP(e,t){e&1&&A(0,YP,3,0,`div`,73),e&2&&j(W(3).formsIn(t.file).length?0:-1)}function ZP(e,t){if(e&1){let e=B();F(0,`button`,45),H(`click`,function(){S(e);let t=W(2);return C(W(2).openInjector(t))}),K(1,` Show in Injectors `),F(2,`span`,26),K(3),I()()}if(e&2){let e=W(2);D(3),J(`: `,e.name)}}function QP(e,t){if(e&1){let e=B();F(0,`button`,45),H(`click`,function(){S(e);let t=W(2);return C(W(2).openSignals(t))}),K(1,` Show in Signals `),F(2,`span`,26),K(3),I()()}if(e&2){let e=W(2);D(3),J(`: `,e.name)}}function $P(e,t){if(e&1&&(F(0,`div`,73),A(1,ZP,4,1,`button`,86),A(2,QP,4,1,`button`,86),I()),e&2){let e=W(3);D(),j(e.canShowInjector()?1:-1),D(),j(e.canShowSignals()?2:-1)}}function eF(e,t){if(e&1){let e=B();F(0,`div`,87)(1,`button`,45),H(`click`,function(){let t=S(e);return C(W(3).copyJson(t))}),K(2,`Copy as JSON`),I(),F(3,`button`,45),H(`click`,function(){let t=S(e);return C(W(3).copyHostPath(t))}),K(4,`Copy host path`),I()(),F(5,`p`,83),K(6),I()}if(e&2){let e=W(3);D(6),q(e.copyMessage())}}function tF(e,t){if(e&1&&L(0,`app-store-global`,74),e&2){let e=W(),t=W(2);P(`rpc`,t.rpc())(`pageId`,t.page()?.pageId)(`target`,K_(4,dP,e.id))(`label`,e.name)}}function nF(e,t){if(e&1&&(F(0,`dt`),K(1,`Routed`),I(),F(2,`dd`,89),K(3),I()),e&2){let e=t.$implicit;D(3),Y(``,e.route||`/`,` in outlet `,e.outlet)}}function rF(e,t){if(e&1&&(F(0,`span`,8),K(1),I()),e&2){let e=W(4);D(),Y(``,e.shownValues()?.count??0,` of `,e.valueTotal())}}function iF(e,t){if(e&1){let e=B();F(0,`div`,95)(1,`p`,43),K(2),I(),F(3,`p`,44),K(4,` The filter searches names, and the nested keys of values you opened. `),I(),F(5,`button`,45),H(`click`,function(){return S(e),C(W(5).clearPropFilter())}),K(6,`Clear filters`),I()()}if(e&2){let e=W(5);D(2),J(` No inputs, outputs or properties match “`,e.propFilter().trim(),`” `)}}function aF(e,t){if(e&1&&(F(0,`span`,100),K(1),I()),e&2){let e=W().$implicit;D(),J(`(`,e.prop,`)`)}}function oF(e,t){if(e&1&&(F(0,`li`,98)(1,`span`,99),K(2),A(3,aF,2,1,`span`,100),I(),L(4,`app-value-tree`,101),I()),e&2){let e=t.$implicit,n=W(4),r=W(3);G(`changed`,r.flashed(`input`,e.name)),D(2),J(` `,e.name,` `),D(),j(e.prop===e.name?-1:3),D(),P(`prop`,e)(`store`,r.values)(`query`,r.propFilter())(`componentId`,n.id)(`pageId`,r.page()?.pageId??null)(`rpc`,r.rpc())(`writes`,r.canEdit())}}function sF(e,t){if(e&1&&(F(0,`ul`,96),M(1,oF,5,11,`li`,97,vP),I()),e&2){let e=W(2);D(),N(e.inputs)}}function cF(e,t){e&1&&(F(0,`p`,69),K(1,`No inputs.`),I())}function lF(e,t){if(e&1&&(F(0,`div`,65)(1,`h3`),K(2,` Inputs `),F(3,`span`,66),K(4),I()(),A(5,sF,3,0,`ul`,96)(6,cF,2,0,`p`,69),I()),e&2){let e=W();D(4),q(e.inputs.length),D(),j(e.inputs.length?5:6)}}function uF(e,t){if(e&1&&(F(0,`li`,103)(1,`span`,89),K(2),I(),F(3,`span`,104),K(4),I()()),e&2){let e=t.$implicit;G(`on`,e.listened),D(2),q(e.name),D(2),q(e.listened?`listened`:`no listener`)}}function dF(e,t){if(e&1&&(F(0,`ul`,67),M(1,uF,5,4,`li`,102,vP),I()),e&2){let e=W(2);D(),N(e.outputs)}}function fF(e,t){e&1&&(F(0,`p`,69),K(1,`No outputs.`),I())}function pF(e,t){if(e&1&&(F(0,`div`,65)(1,`h3`),K(2,` Outputs `),F(3,`span`,66),K(4),I()(),A(5,dF,3,0,`ul`,67)(6,fF,2,0,`p`,69),I()),e&2){let e=W();D(4),q(e.outputs.length),D(),j(e.outputs.length?5:6)}}function mF(e,t){if(e&1&&(F(0,`span`,105),K(1),I()),e&2){let e=W().$implicit;D(),q(e.kind)}}function hF(e,t){if(e&1){let e=B();F(0,`button`,107),H(`click`,function(){S(e);let t=W().$implicit,n=W(5);return C(W(2).openSignals(n,t.prop))}),K(1,` Show in Signals`),F(2,`span`,26),K(3),I()()}if(e&2){let e=W().$implicit;D(3),J(`: `,e.name)}}function gF(e,t){if(e&1&&(F(0,`li`,98)(1,`span`,99),K(2),A(3,mF,2,1,`span`,105),A(4,hF,4,1,`button`,106),I(),L(5,`app-value-tree`,101),I()),e&2){let e=t.$implicit,n=W(4),r=W(3);G(`changed`,r.flashed(`property`,e.name)),D(2),J(` `,e.name,` `),D(),j(e.kind?3:-1),D(),j(e.kind===`signal`&&r.canShowSignals()&&!r.staticReport()?4:-1),D(),P(`prop`,e)(`store`,r.values)(`query`,r.propFilter())(`componentId`,n.id)(`pageId`,r.page()?.pageId??null)(`rpc`,r.rpc())(`writes`,r.canEdit())}}function _F(e,t){if(e&1&&(F(0,`ul`,96),M(1,gF,6,12,`li`,97,vP),I()),e&2){let e=W(2);D(),N(e.properties)}}function vF(e,t){e&1&&(F(0,`p`,69),K(1,`No other properties.`),I())}function yF(e,t){if(e&1&&(F(0,`div`,65)(1,`h3`),K(2,` Properties `),F(3,`span`,66),K(4),I()(),A(5,_F,3,0,`ul`,96)(6,vF,2,0,`p`,69),I()),e&2){let e=W();D(4),q(e.properties.length),D(),j(e.properties.length?5:6)}}function bF(e,t){if(e&1&&(F(0,`li`,68),K(1),I()),e&2){let e=t.$implicit;D(),q(e)}}function xF(e,t){if(e&1&&(F(0,`div`,65)(1,`h3`),K(2,` DOM listeners `),F(3,`span`,66),K(4),I()(),F(5,`ul`,67),M(6,bF,2,1,`li`,68,eg),I()()),e&2){let e=W(2);D(4),q(e.listeners.length),D(2),N(e.listeners)}}function SF(e,t){if(e&1&&(F(0,`li`,103)(1,`span`,89),K(2),I(),F(3,`span`,104),K(4),I()()),e&2){let e=t.$implicit;D(2),q(e.state),D(2),q(e.triggers.join(`, `)||`no triggers`)}}function CF(e,t){if(e&1&&(F(0,`div`,65)(1,`h3`),K(2,` Defer blocks `),F(3,`span`,66),K(4),I()(),F(5,`ul`,67),M(6,SF,5,2,`li`,103,yP),I()()),e&2){let e=W(5);D(4),q(e.ownedDeferBlocks().length),D(2),N(e.ownedDeferBlocks())}}function wF(e,t){if(e&1&&(F(0,`li`,98)(1,`span`,99),K(2),I(),L(3,`app-value-tree`,109),I()),e&2){let e=t.$implicit,n=W(2).$implicit,r=W(2),i=W(3);G(`changed`,i.flashed(`directive`,e.name,n.index)),D(2),q(e.name),D(),P(`prop`,e)(`store`,i.values)(`query`,i.propFilter())(`directive`,n.index)(`componentId`,r.id)(`pageId`,i.page()?.pageId??null)(`rpc`,i.rpc())}}function TF(e,t){if(e&1&&(F(0,`ul`,96),M(1,wF,4,10,`li`,97,vP),I()),e&2){let e=W().$implicit;D(),N(e.inputs)}}function EF(e,t){if(e&1&&(F(0,`li`,103)(1,`span`,89),K(2),I(),F(3,`span`,104),K(4),I()()),e&2){let e=t.$implicit;G(`on`,e.listened),D(2),q(e.name),D(2),q(e.listened?`listened`:`no listener`)}}function DF(e,t){if(e&1&&(F(0,`ul`,67),M(1,EF,5,4,`li`,102,vP),I()),e&2){let e=W().$implicit;D(),N(e.outputs)}}function OF(e,t){e&1&&(F(0,`p`,69),K(1,`No inputs or outputs.`),I())}function kF(e,t){if(e&1&&(F(0,`div`,65)(1,`h3`)(2,`span`,89),K(3),I(),F(4,`span`,105),K(5,`directive on host`),I()(),F(6,`p`,108),hg(7,71),I(),A(8,TF,3,0,`ul`,96),A(9,DF,3,0,`ul`,67),A(10,OF,2,0,`p`,69),I()),e&2){let e=t.$implicit;W(5);let n=Pg(3);D(3),q(e.name),D(4),P(`ngTemplateOutlet`,n)(`ngTemplateOutletContext`,K_(6,uP,e.name)),D(),j(e.inputs.length?8:-1),D(),j(e.outputs.length?9:-1),D(),j(!e.inputs.length&&!e.outputs.length?10:-1)}}function AF(e,t){if(e&1&&(L(0,`app-redaction-count`,94),A(1,iF,7,1,`div`,95),A(2,lF,7,2,`div`,65),A(3,pF,7,2,`div`,65),A(4,yF,7,2,`div`,65),A(5,xF,8,1,`div`,65),A(6,CF,8,1,`div`,65),M(7,kF,11,8,`div`,65,_P)),e&2){let e=t,n=W(),r=W(3);P(`value`,e),D(),j(r.propQuery()&&!e.count?1:-1),D(),j(!r.propQuery()||e.inputs.length?2:-1),D(),j(!r.propQuery()||e.outputs.length?3:-1),D(),j(!r.propQuery()||e.properties.length?4:-1),D(),j(!r.propQuery()&&n.listeners.length?5:-1),D(),j(!r.propQuery()&&r.ownedDeferBlocks().length?6:-1),D(),N(e.directives)}}function jF(e,t){if(e&1&&(F(0,`span`,105),K(1),I()),e&2){let e=t.$implicit;D(),q(e)}}function MF(e,t){if(e&1&&(F(0,`span`,112),K(1,`from `),F(2,`span`,89),K(3),I()()),e&2){let e=W().$implicit;D(3),q(e.providedByName??`an injector`)}}function NF(e,t){e&1&&(F(0,`span`,113),K(1,`not provided`),I())}function PF(e,t){if(e&1&&(F(0,`li`,110)(1,`span`,111),K(2),I(),M(3,jF,2,1,`span`,105,eg),A(5,MF,4,1,`span`,112)(6,NF,2,0,`span`,113),I()),e&2){let e=t.$implicit;D(2),q(e.token),D(),N(e.flags),D(2),j(e.providedBy?5:6)}}function FF(e,t){if(e&1&&(F(0,`ul`,92),M(1,PF,7,2,`li`,110,bP),I()),e&2){let e=W();D(),N(e.dependencies)}}function IF(e,t){e&1&&(F(0,`p`,69),K(1,`Nothing is injected through the constructor or inject().`),I())}function LF(e,t){if(e&1){let e=B();F(0,`app-component-pipes`,114),H(`showPipe`,function(t){S(e);let n=W(4);return C(n.showPipe.emit({name:t,pageId:n.page()?.pageId}))}),I()}if(e&2){let e=W(2),t=W(2);P(`rpc`,t.rpc())(`pageId`,t.page()?.pageId)(`component`,t.bareName(e.name))(`canShowPipes`,!t.staticReport())}}function RF(e,t){if(e&1){let e=B();F(0,`dl`,88)(1,`dt`),K(2,`Change detection`),I(),F(3,`dd`),K(4),I(),F(5,`dt`),K(6,`Encapsulation`),I(),F(7,`dd`),K(8),I(),F(9,`dt`),K(10,`Host path`),I(),F(11,`dd`,89),K(12),I(),M(13,nF,4,2,null,null,pP),I(),F(15,`div`,90)(16,`app-search-field`,91,3),M_(`valueChange`,function(t){S(e);let n=W(3);return j_(n.propFilter,t)||(n.propFilter=t),C(t)}),I(),A(18,rF,2,2,`span`,8),F(19,`p`,21),K(20),I()(),A(21,AF,9,7),F(22,`div`,65)(23,`h3`),K(24,` Injected `),F(25,`span`,66),K(26),I()(),A(27,FF,3,0,`ul`,92)(28,IF,2,0,`p`,69),I(),A(29,LF,1,4,`app-component-pipes`,93)}if(e&2){let e,n=t,r=W(),i=W(2);D(4),q(n.changeDetection??`Unknown`),D(4),q(n.encapsulation??`Unknown`),D(4),q(n.path),D(),N(i.routedById().get(r.id)??G_(10,lP)),D(3),A_(`value`,i.propFilter),D(2),j(i.propQuery()?18:-1),D(2),q(i.propFilterStatus()),D(),j((e=i.shownValues())?21:-1,e),D(5),q(n.dependencies.length),D(),j(n.dependencies.length?27:28),D(2),j(i.canShowPipes()?29:-1)}}function zF(e,t){e&1&&(F(0,`div`,75),L(1,`span`,115),F(2,`p`,44),K(3,`Reading live values from the page…`),I()())}function BF(e,t){if(e&1&&(F(0,`section`,32)(1,`header`,61)(2,`span`,62),K(3,`component`),I(),F(4,`h2`,63),K(5),I(),F(6,`span`,64),K(7),I(),A(8,HP,2,2,`p`,72)(9,KP,3,2),A(10,qP,7,1),A(11,XP,1,1),A(12,$P,3,2,`div`,73),A(13,eF,7,1),A(14,tF,1,6,`app-store-global`,74),I(),A(15,RF,30,11)(16,zF,4,0,`div`,75),I()),e&2){let e,n,r,i,a=t,o=W(2);D(5),q(a.name),D(2),J(`<`,a.tag,`>`),D(),j((e=o.detail()?.source)?8:9,e),D(2),j(o.devtoolsActions()?10:-1),D(),j((n=o.sourceFor(a))?11:-1,n),D(),j((o.canShowInjector()||o.canShowSignals())&&!o.staticReport()?12:-1),D(),j((r=o.detail())?13:-1,r),D(),j(!o.staticReport()&&!o.native()?14:-1),D(),j((i=o.detail())?15:16,i)}}function VF(e,t){e&1&&(F(0,`p`,43),K(1,`The selected component was destroyed`),I(),F(2,`p`,44)(3,`span`,89),K(4),I(),K(5,` is no longer on the page. Select another component to see its live values. `),I()),e&2&&(D(4),q(t))}function HF(e,t){e&1&&(F(0,`p`,44),K(1,` Select a component to see its live inputs, properties, outputs, change detection and injected services. `),I())}function UF(e,t){if(e&1&&(F(0,`div`,33),A(1,VF,6,1)(2,HF,2,0,`p`,44),I()),e&2){let e,t=W(2);D(),j((e=t.destroyed())?1:2,e)}}function WF(e,t){e&1&&L(0,`app-angular-unavailable`,35),e&2&&P(`page`,W(2).angularPage())}function GF(e,t){if(e&1){let e=B();F(0,`app-hydration-section`,116),H(`overlay`,function(t){return S(e),C(W(2).setHydrationOverlay(t))})(`showNode`,function(t){return S(e),C(W(2).revealNode(t))})(`highlight`,function(t){return S(e),C(W(2).highlight(t))}),I()}e&2&&P(`report`,W(2).page()?.hydration)}function KF(e,t){if(e&1){let e=B();F(0,`app-cd-recording`,117),H(`showComponent`,function(t){return S(e),C(W(2).showCdComponent(t))}),I(),F(1,`app-leaks-section`,118),H(`showComponent`,function(t){return S(e),C(W(2).showLeakComponent(t))})(`highlight`,function(t){return S(e),C(W(2).highlight(t))}),I()}if(e&2){let e=W(2);P(`page`,e.cdPage())(`pageId`,e.page().pageId)(`rpc`,e.rpc())(`native`,e.native()),D(),P(`report`,e.leaksPage())(`pageId`,e.page().pageId)(`rpc`,e.rpc())(`angular`,e.angularPage())}}function qF(e,t){if(e&1){let e=B();F(0,`p`,4),K(1,` Every component instance on the page, in the order Angular rendered them. Hover a row to highlight its host in the page, and select it to read its live inputs, properties, outputs and injected services. `),I(),F(2,`div`,5)(3,`app-search-field`,6,1),H(`valueChange`,function(t){return S(e),C(W().setFilter(t))}),I(),F(5,`span`,7),K(6,` Type plain text, a tag such as