diff --git a/.claude/agents/a11y-reviewer.md b/.claude/agents/a11y-reviewer.md new file mode 100644 index 0000000..7c3d02a --- /dev/null +++ b/.claude/agents/a11y-reviewer.md @@ -0,0 +1,11 @@ +--- +name: a11y-reviewer +description: Audits the devtools panel and the demo app for accessibility and visual consistency with axe, contrast checks and keyboard walkthroughs. Use before a pull request that changes UI, or when asked to review a page. +tools: Read, Grep, Glob, Bash +--- + +You review accessibility and visual consistency. You don't edit files; you report. + +Follow the browser checks in the `devtools-verify` skill: run axe on each page in dark and light color schemes, check horizontal overflow at 1280px and 360px, and walk every interactive element with the keyboard (focus visible, arrow keys in trees and lists, `Escape` closes popups and clears search). Check text contrast by hand where axe can't (gradients, text over images) and compare each page against `docs/contributing/ui-guidelines.md`. + +Report findings ranked by user impact, each with the page, the element, what fails (rule or measured contrast), and a concrete fix. Say which pages you checked and how. diff --git a/.claude/agents/devtools-reviewer.md b/.claude/agents/devtools-reviewer.md new file mode 100644 index 0000000..3f5bec5 --- /dev/null +++ b/.claude/agents/devtools-reviewer.md @@ -0,0 +1,17 @@ +--- +name: devtools-reviewer +description: Reviews a change or pull request against this repository's coding standards, commit guidelines and data-collection rules. Use before opening a pull request or when asked to review a diff. +tools: Read, Grep, Glob, Bash +--- + +You review changes to the Angular devtools. You don't edit files; you report. + +Check the diff against: + +- `docs/contributing/coding-standards.md`: TypeScript and Angular rules, and the page-side rules (debug APIs, stable ids, no DOM writes, `pageId`, expiry, cheap pushes, safe serialization). +- `docs/contributing/ui-guidelines.md` for anything under `app/`. +- `docs/contributing/commit-message-guidelines.md` for commit messages and the pull request title. +- Tests: every behavior change has one, and agent tools changed together with their tests and descriptions. +- Generated output: `extension/ui` rebuilt and committed when `app/` changed. + +Verify claims by reading the code, and run `pnpm test:devtools` and the `ngc` template check when in doubt. Report only real problems, ranked by impact, each with file:line, what is wrong, why it matters and a concrete fix. diff --git a/.claude/agents/inspector-engineer.md b/.claude/agents/inspector-engineer.md new file mode 100644 index 0000000..1ba46c5 --- /dev/null +++ b/.claude/agents/inspector-engineer.md @@ -0,0 +1,12 @@ +--- +name: inspector-engineer +description: Owns how inspectors collect data from the running app and serve it to the panel and to agents. Use for new inspectors, wrong or noisy data, unstable ids, tabs overwriting each other, heavy polling, and new MCP tools. +--- + +You are the inspector engineer for the Angular devtools. + +Follow the `devtools-inspector` skill and the "Reading data from the page" section of `docs/contributing/coding-standards.md`. Read Angular through its debug APIs and check every field you rely on against `node_modules/@angular/core/fesm2022`. Keep ids stable with `WeakMap`s, never write to the app's DOM, send `pageId` with every report, expire and forget pages on the server, and skip unchanged pushes. + +Put new logic in its own module and keep edits to `overlay.ts` and `devframe.ts` small. Add jsdom tests with a fake `ng` for every collector change and keep `pnpm test:devtools` green. + +Return a short summary: what was wrong, what you changed (file:line), the tests you added, and anything left undone. diff --git a/.claude/agents/ui-engineer.md b/.claude/agents/ui-engineer.md new file mode 100644 index 0000000..9e9d631 --- /dev/null +++ b/.claude/agents/ui-engineer.md @@ -0,0 +1,12 @@ +--- +name: ui-engineer +description: Builds and restyles pages in the devtools panel (app/) so they match the design system, work with the keyboard and pass axe. Use for new inspector pages, UI polish, dropdowns, toolbars, empty states and theme changes. +--- + +You are the UI engineer for the Angular devtools panel. + +Follow the `devtools-ui` skill and `docs/contributing/ui-guidelines.md`. Use the theme variables and SCSS mixins, the shared `app-select` dropdown and the page anatomy (intro, sticky toolbar, list or tree with a detail panel, loading, error, empty and no-match states). Headings start at `h2`. + +Don't change RPC names, data shapes or behavior. If the data a page shows is wrong, stop and report it for the inspector engineer instead of working around it in the template. + +Before you finish, run the checks in the `devtools-verify` skill that apply to UI (format, `ngc` template check, the axe and 360px audit on the pages you touched) and list what you ran. Return a short summary of what changed, per file. diff --git a/.claude/skills/devtools-commit/SKILL.md b/.claude/skills/devtools-commit/SKILL.md new file mode 100644 index 0000000..8a7ec5a --- /dev/null +++ b/.claude/skills/devtools-commit/SKILL.md @@ -0,0 +1,34 @@ +--- +name: devtools-commit +description: Write commit messages and pull request titles and descriptions for this repository, following this project's commit format and scopes. Use whenever you commit, split work into commits, or open or update a pull request. +--- + +# Commits and pull requests + +The rules are in `docs/contributing/commit-message-guidelines.md`. Summary: + +``` +(): + + + + +``` + +- Types: `feat`, `fix`, `perf`, `refactor`, `test`, `docs`, `build`, `ci`, `revert`. +- Scopes: `hub`, `ui`, `popup`, `overlay`, `components`, `signals`, `injectors`, `router`, `forms`, `store`, `pipes`, `http`, `analog`, `mcp`, `extension`, `vite`, `demo`, `deps`. Leave the scope out for cross-cutting changes. +- Summary: imperative, lowercase first letter, no period, header under 100 characters. + +## Pull requests + +- Pull requests are squash merged; the title becomes the commit on `main`, so it must follow the header format. +- A `commit-msg` hook (`scripts/commit-message.mjs`, enabled by `pnpm install`) warns about a bad message as you commit; CI rejects it, checking the title and every commit in the pull request. Run `pnpm commit:check` before pushing, and fix flagged messages with `git commit --amend` or a reword rebase. +- Address review feedback with fixup commits (`docs/contributing/using-fixup-commits.md`). +- One feature per pull request, with its tests (including agent tool tests when tools change). +- Rebuild and commit `extension/ui` when `app/` changed. +- Fill in `.github/PULL_REQUEST_TEMPLATE.md`: what changed and why, how it was verified (see the `devtools-verify` skill), screenshots for UI changes. +- Don't add AI attribution lines to commits or pull requests unless the maintainers ask for them. + +## Splitting work + +Group commits by area (the scope), keep each one building and passing tests where practical, and put generated output (`extension/ui`, lockfile) in the commit that needs it. diff --git a/.claude/skills/devtools-inspector/SKILL.md b/.claude/skills/devtools-inspector/SKILL.md new file mode 100644 index 0000000..8016d8b --- /dev/null +++ b/.claude/skills/devtools-inspector/SKILL.md @@ -0,0 +1,53 @@ +--- +name: devtools-inspector +description: Add or fix how an inspector collects data, from the page-side overlay through the devframe server to the panel and the MCP tools. Use for new inspectors, wrong or noisy data, unstable selection, tabs overwriting each other, heavy polling, and new agent tools. +--- + +# Inspector data pipeline + +Every inspector follows the same path: + +``` +app page (overlay.ts + -collector.ts) + -> rpc.call('push-', { pageId, ... }) + -> devframe.ts: per-page Map, shared state '', expiry, forget--page + -> panel page (app/src/pages/.ts) via rpc.sharedState('') + -> agent tools (ctx.agent.registerTool) and MCP resources +``` + +Read `docs/contributing/coding-standards.md` ("Reading data from the page") before you start. + +## Page side (`packages/ng-devtools/src`) + +- Put collection logic in its own module (`-collector.ts`) and keep `overlay.ts` changes to wiring: import, attach, push, `leave()` and the returned cleanup. +- Read Angular through the debug APIs on `window.ng` and verify each field against `node_modules/@angular/core/fesm2022` (or the library's fesm build). Known helpers: + - `element-id.ts`: stable `WeakMap` ids for elements (`elementId`, `elementById`). + - `injector-tree.ts`: `className()` strips bundler `_` prefixes, `tokenName()`, `dependenciesOf()`, environment injector walk. + - `serialize.ts`: safe, size-limited serialization. + - `router.ts` `providerOf()`: find a service through the injector resolution path. +- Never write attributes into the app's DOM. Never run app code (validators, guards) on a timer unless the user turned recording on. +- Pushes: send on change, skip unchanged payloads (compare with the last JSON), re-send every few cycles so the server doesn't expire the page, and avoid full DOM scans on a timer (cache, rescan after a `MutationObserver` signal). +- Every report carries `pageId` from `claimPageId()`. Call `forget--page` from `leave()`. + +## Server side (`devframe.ts`, `rpc/`) + +- Keep a `Map` with `reportedAt`, drop pages older than 15 seconds in the shared expiry interval, and write the combined value into the shared state. +- Page actions that the panel triggers (restore, highlight, run) go panel -> `request--action` -> broadcast `-action` to the page -> `-action-result`, keyed by `requestId`, with a timeout. +- Source scans in `rpc/` enrich or stand in for live data. They must return a `kind` for anything the Dashboard counts. + +## Panel side + +- Subscribe with `const state = await rpc.sharedState(''); apply(state.value()); state.on('updated', apply)` and remove the listener through `DestroyRef`. There is no `subscribe()` on shared state. +- Filter to the current page with the `?pageId` host parameter when the page can show several tabs; offer an "All pages" option. +- Then follow the `devtools-ui` skill for the page itself. + +## Agent tools + +- Describe what the tool returns, where the data comes from and what an empty answer means. Answer in markdown. +- Update descriptions in `devframe.ts` when data shapes change, and the README tool list. + +## Tests + +- Collector tests run in jsdom with a fake `ng` (see `__tests__/injector-tree.test.ts`, `component-tree.test.ts`, `ngrx-collector.test.ts`). Cover stable ids, dedupe, expiry and the Angular shapes you rely on. +- Server tests call the RPC handlers directly (see `http-server.test.ts`, `agent-tools.test.ts`). +- `pnpm test:devtools` must stay green. diff --git a/.claude/skills/devtools-ui/SKILL.md b/.claude/skills/devtools-ui/SKILL.md new file mode 100644 index 0000000..de0c4b8 --- /dev/null +++ b/.claude/skills/devtools-ui/SKILL.md @@ -0,0 +1,36 @@ +--- +name: devtools-ui +description: Build or change any page, component or style in the devtools panel (app/). Use for new inspector pages, restyles, dropdowns, toolbars, empty states, theme or palette changes, and any UI/UX or accessibility work in the panel. +--- + +# Devtools panel UI + +Read `docs/contributing/ui-guidelines.md` first; it is the source of truth for the theme, tokens and page anatomy. This skill is the working checklist. + +## Before you write code + +1. Open two recently built pages as references: `app/src/pages/di-inspector.ts` (tree + detail, keyboard, highlight) and `app/src/pages/network-inspector.ts` (toolbar, tables, forms, `app-select`). +2. Check which data the page gets and from where (`client.scope('ng-devtools').rpc.call(...)` or `rpc.sharedState(...)`). UI work must not change RPC names or data shapes; if the data is wrong, use the `devtools-inspector` skill. + +## Rules + +- Styles are SCSS in the component `styles` field, starting with `@use 'mixins' as m;`. +- Colors only through CSS variables (`--surface`, `--text-2`, `--accent`, ...). No hex values except brand colors on their own view (NgRx purple, Angular gradient, Analog, NativeScript, Capacitor). +- Controls are `var(--control-h)` tall. Use `app/src/ui/select.ts` for every dropdown; never a native ` - + @if (m.matched) { - {{ testUrl() }} renders + {{ testUrl() }} renders
    @for (r of m.chain; track r.id) {
  1. @@ -357,8 +369,8 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth } } @else { - {{ testUrl() }} matches no file route. Angular throws NG04002 - "Cannot match any routes". + {{ testUrl() }} matches no file route. Angular + throws NG04002 "Cannot match any routes". @if (m.rejected.length) {
      @for (r of m.rejected.slice(0, 5); track $index) { @@ -389,7 +401,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth [class.dim]="row.route.kind === 'group' || row.route.kind === 'implicit'" > -
      +
      @if (row.depth) { } @@ -413,9 +425,11 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth } - @for (e of serverExports(row.route); track e) { - {{ e }}() - } +
      + @for (e of serverExports(row.route); track e) { + {{ e }}() + } +
      @if (row.route.title) { @@ -430,7 +444,13 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth } @empty { - No route matches the filter. + + @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. + } + } @@ -450,31 +470,41 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth server result.
      } -
      - Show calls of kind - @for (k of kinds; track k) { - - } -
      +
      +
      + Show calls of kind + @for (k of kinds; track k) { + + } +
      + +
      @if (calls().length) {
      - + - + @@ -488,15 +518,17 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth }}
      TimeAt Kind Request StatusTimeDuration From
      - {{ c.method }} - {{ c.url }} - @if (c.render) { - {{ c.render === 'ssr' ? 'server rendered' : 'client only' }} - } +
      + {{ c.method }} + {{ c.url }} + @if (c.render) { + {{ c.render === 'ssr' ? 'server rendered' : 'client only' }} + } +
      @if (c.preview) {
      Response @@ -517,12 +549,21 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth
      } @else { -

      - No calls yet. Navigate in the app to see page renders, load() fetches and API calls. -

      +
      +

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

      +

      + Navigate in the app to see page renders, load() fetches and API calls. +

      +
      } -

      API routes

      +

      API routes

      @@ -546,7 +587,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth >{{ base(api.file) }} - + } @empty { + + + }
      +
      + No API routes. Add a handler under src/server/routes to test it here. +
      -

      Request playground

      +

      Request playground

      - - + (valueChange)="method.set($event ?? 'GET')" + /> - +
      @if (method() !== 'GET') { @@ -609,8 +652,10 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth @if (response(); as r) {
      @if (r.error) { - Refused - {{ r.error }} +
      + Refused + {{ r.error }} +
      } @else {
      {{ @@ -626,11 +671,13 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth } @case ('render') { -
      - @for (m of modeCounts(); track m.mode) { - {{ m.label }} · {{ m.count }} - } -
      + @if (modeCounts().length) { +
      + @for (m of modeCounts(); track m.mode) { + {{ m.label }} · {{ m.count }} + } +
      + }
      @@ -646,23 +693,27 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth + } @empty { + + + }
      {{ row.path }} - {{ - modeLabel(row.mode) - }} - {{ row.reason }} +
      + {{ + 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 - } +
      + {{ + last.status + }} + {{ last.render === 'client' ? 'client only' : 'server rendered' }} · + {{ last.ms }} ms + @if (mismatch(row)) { + differs from config + } +
      } @else { not requested yet } @@ -676,13 +727,19 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth }
      + No page routes to render yet. Add a .page.ts file under src/app/pages. +
      @if (plan(); as p) {
      -

      Prerender plan

      +

      Prerender plan

      @if (p.dynamicConfig) {

      prerender.routes is a function, so the list is known only at build time. @@ -715,7 +772,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth }

      Build output
      -
      +
      @if (p.built.length) { {{ p.built.length }} page(s) in dist/analog/public @if (p.notBuilt.length) { @@ -765,7 +822,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth {{ contentUrl(f.file) ?? '' }} {{ f.slug }} - {{ f.attributes['date'] || '' }} + {{ f.attributes['date'] || '' }} {{ dir(f.file) }}
      } @else { -

      No markdown files under src/content.

      +
      +

      No markdown files under src/content.

      +

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

      +
      } } @@ -822,7 +884,10 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth }
    } @else { -
    No Analog problems found.
    +
    + No Analog problems found. Routes, server files, prerender config + and content all check out. +
    } } } @@ -831,119 +896,257 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth `, styles: ` :host { - --good: #4ade80; - --warn: #facc15; - --bad: #f87171; + --good: var(--ok); + --bad: var(--danger); --info: #60a5fa; - --line: #27272a; - --soft: #18181b; + --mono: var(--font-mono); display: grid; - gap: 14px; - color: #e4e4e7; + gap: 16px; + min-width: 0; + color: var(--text); font-size: 13px; } - .pad { + .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; + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } + @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: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; + font-family: var(--mono); + font-size: 12.5px; } .muted { - color: #a1a1aa; + color: var(--text-2); } .small { font-size: 12px; } - .pill + .small, - .status + .small { - margin-left: 8px; - } .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(120px, 1fr)); - gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 12px; + animation: enter 0.35s var(--ease) both; } .stat { display: grid; - gap: 2px; - padding: 10px 12px; - border: 1px solid var(--line); - border-radius: 10px; - background: var(--soft); + 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: #a1a1aa; + color: var(--text-3); font-size: 11px; + font-weight: 600; text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.08em; } .stat .value { - font-size: 18px; + 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 { - font-size: 14px; + 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(--good); + color: var(--ok); } + .tabs { - display: flex; + display: inline-flex; flex-wrap: wrap; - gap: 4px; - border-bottom: 1px solid var(--line); + 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: 6px; + gap: 8px; align-items: center; - padding: 8px 12px; + min-height: 28px; + padding: 4px 12px; border: none; - border-bottom: 2px solid transparent; - background: none; - color: #d4d4d8; + 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'] { - border-bottom-color: var(--accent); - color: #fafafa; + background: var(--surface-3); + color: var(--text-strong); + box-shadow: inset 0 0 0 1px var(--border-strong); } .count { - min-width: 18px; + min-width: 20px; padding: 0 6px; - border-radius: 999px; - background: #27272a; - color: #d4d4d8; + 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'] { - background: #422006; + .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); } + .panel { display: grid; - gap: 12px; + 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: 10px; + 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 { @@ -952,95 +1155,195 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth 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 { - padding: 6px 10px; - border: 1px solid #3f3f46; - border-radius: 8px; - background: var(--soft); - color: #e4e4e7; + box-sizing: border-box; + height: 34px; + padding: 0 12px; + border: 1px solid var(--border-strong); + 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 { - width: 100%; - box-sizing: border-box; - resize: vertical; + height: auto; + padding: 8px 12px; } .grow { - flex: 1; - min-width: 180px; + flex: 1 1 180px; + min-width: 0; } + .btn { - padding: 6px 12px; - border: 1px solid #52525b; - border-radius: 8px; - background: #27272a; - color: #fafafa; + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + height: 34px; + padding: 0 14px; + border: 1px solid var(--border-strong); + 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(--border-strong); + } + .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 { - padding: 2px 10px; - background: transparent; + 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, - .field:focus-visible, .table-wrap:focus-visible, summary:focus-visible, .segmented label:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; } + .callout { - padding: 10px 12px; - border: 1px solid var(--line); - border-left: 3px solid var(--info); - border-radius: 8px; - background: var(--soft); + padding: 12px 16px; + border: 1px solid color-mix(in srgb, var(--info) 30%, transparent); + border-radius: var(--radius); + background: color-mix(in srgb, var(--info) 8%, var(--surface)); line-height: 1.6; + animation: enter 0.35s var(--ease) both; } .callout[data-tone='good'] { - border-left-color: var(--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-left-color: var(--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-left-color: var(--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: 6px; - margin: 6px 0 0; + 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: 6px; - color: #71717a; + margin-left: 2px; + color: var(--text-3); + } + .callout .chips { + margin-top: 8px; } .plain { - margin: 6px 0 0; + 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(--line); - border-radius: 10px; + 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: 8px 10px; - border-bottom: 1px solid var(--line); + padding: 10px 12px; + border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; } @@ -1048,89 +1351,120 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth border-bottom: none; } th { - background: var(--soft); - color: #a1a1aa; + position: sticky; + top: 0; + background: var(--surface); + color: var(--text-3); font-size: 11px; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.04em; + 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: #141417; + background: var(--surface-2); } tr.open td { - background: #1c1917; + 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 3px 0 0 var(--accent); + box-shadow: inset 2px 0 0 var(--accent); } tr.dim .path { - color: #a1a1aa; + color: var(--text-2); } .route { display: flex; flex-wrap: wrap; - gap: 6px; + gap: 8px; align-items: center; } .guide { - color: #52525b; + color: var(--text-3); + font-family: var(--mono); } .path { - color: #f0abfc; + color: var(--text-strong); + overflow-wrap: anywhere; } .file { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; - color: #e4e4e7; + font-family: var(--mono); + font-size: 12.5px; + color: var(--text); overflow-wrap: anywhere; } .dir { - color: #a1a1aa; + color: var(--text-3); } + .pill, .chip, .status, .method { display: inline-block; padding: 1px 8px; - border-radius: 999px; + border-radius: 99px; font-size: 11px; + font-weight: 500; line-height: 18px; white-space: nowrap; } - .pill, - .chip { - border: 1px solid #3f3f46; - color: #d4d4d8; + .pill { + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text-2); } .chip { - border-radius: 6px; 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: 6px; + gap: 8px; + } + .chips .pill { + padding: 3px 10px; + font-size: 12px; } .pill.live { - border-color: var(--accent); - color: #fda4af; + border-color: var(--accent-line); + background: var(--accent-soft); + color: var(--accent); } .pill[data-kind='layout'] { - border-color: #6366f1; - color: #c7d2fe; + border-color: #94a3b8; + color: #e2e8f0; } .pill[data-kind='markdown'] { border-color: #0ea5e9; color: #bae6fd; } .pill[data-kind='load'] { - border-color: #a855f7; - color: #e9d5ff; + border-color: #2dd4bf; + color: #99f6e4; } .pill[data-mode='ssr'] { border-color: #3b82f6; @@ -1149,8 +1483,8 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth color: #bfdbfe; } .pill[data-call='load'] { - border-color: #a855f7; - color: #e9d5ff; + border-color: #2dd4bf; + color: #99f6e4; } .pill[data-call='fn'] { border-color: #14b8a6; @@ -1162,173 +1496,269 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth } [data-tone='warn'].pill, [data-tone='warn'].chip { - border-color: #a16207; - color: #fef08a; + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + background: color-mix(in srgb, var(--warn) 12%, transparent); + color: var(--warn); } [data-tone='bad'].pill, [data-tone='bad'].chip { - border-color: #b91c1c; - color: #fecaca; + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); } [data-tone='info'].pill { - border-color: #1d4ed8; + border-color: color-mix(in srgb, var(--info) 30%, transparent); + background: color-mix(in srgb, var(--info) 12%, transparent); color: #bfdbfe; } + .status { + border: 1px solid var(--border-strong); + font-family: var(--mono); font-weight: 600; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-variant-numeric: tabular-nums; } .status[data-status='good'] { - background: #052e16; - color: #86efac; + border-color: color-mix(in srgb, var(--ok) 30%, transparent); + background: color-mix(in srgb, var(--ok) 12%, transparent); + color: var(--ok); } .status[data-status='warn'] { - background: #422006; - color: #fde68a; + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + background: color-mix(in srgb, var(--warn) 12%, transparent); + color: var(--warn); } .status[data-status='bad'] { - background: #450a0a; - color: #fecaca; + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); } .method { - min-width: 44px; - margin-right: 6px; - background: #27272a; - color: #e4e4e7; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + 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'] { - background: #082f49; + border-color: color-mix(in srgb, #7dd3fc 30%, transparent); + background: color-mix(in srgb, #7dd3fc 12%, transparent); color: #7dd3fc; } .method[data-method='POST'] { - background: #052e16; + border-color: color-mix(in srgb, var(--ok) 30%, transparent); + background: color-mix(in srgb, var(--ok) 12%, transparent); color: #86efac; } .method[data-method='PUT'], .method[data-method='PATCH'] { - background: #422006; + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + background: color-mix(in srgb, var(--warn) 12%, transparent); color: #fde68a; } .method[data-method='DELETE'] { - background: #450a0a; + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); color: #fecaca; } .request { min-width: 260px; } - .request .pill { - margin-left: 6px; + .request .url { + color: var(--text-strong); + overflow-wrap: anywhere; } details { - margin-top: 6px; + margin-top: 8px; } summary { - cursor: pointer; - color: #a1a1aa; + 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: 6px 0 0; - padding: 8px 10px; + margin: 8px 0 0; + padding: 10px 12px; max-height: 220px; overflow: auto; - border-radius: 8px; - background: #09090b; - color: #e4e4e7; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; + 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; + } .segmented { display: inline-flex; flex-wrap: wrap; gap: 2px; + justify-self: start; margin: 0; padding: 3px; - border: 1px solid var(--line); + border: 1px solid var(--border); border-radius: 10px; - background: var(--soft); - justify-self: start; + background: var(--bg); } .segmented label { - padding: 4px 10px; + 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: #3f3f46; - color: #fafafa; + background: var(--surface-3); + color: var(--text-strong); + box-shadow: inset 0 0 0 1px var(--border-strong); } .segmented label.on .muted { - color: #d4d4d8; + color: var(--text-2); } - h3 { + + h2 { display: flex; gap: 8px; align-items: center; - margin: 6px 0 0; - color: #e4e4e7; - font-size: 13px; + 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: 8px; - padding: 12px; - border: 1px solid var(--line); - border-radius: 10px; - background: var(--soft); + 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 h3 { + .card h2 { margin: 0; } .check { display: flex; - gap: 6px; + 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: 6px; + 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: 6px 14px; + gap: 10px 16px; margin: 0; } .facts dt { - color: #a1a1aa; + 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: 8px; + gap: 12px; margin: 0; padding: 0; list-style: none; + animation: enter 0.35s var(--ease) both; } - .findings li { - padding: 10px 12px; - border: 1px solid var(--line); - border-left: 3px solid var(--info); - border-radius: 8px; - background: var(--soft); - } - .findings li[data-tone='bad'] { - border-left-color: var(--bad); - } - .findings li[data-tone='warn'] { - border-left-color: var(--warn); + .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(--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: 6px 0 0; - line-height: 1.5; + margin: 8px 0 0; + line-height: 1.55; + } + .findings > li > p { + color: var(--text-2); } .finding-head { display: flex; @@ -1337,47 +1767,67 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth align-items: center; } .finding-title { + color: var(--text-strong); font-size: 14px; - color: #fafafa; + font-weight: 600; } .where { display: grid; - gap: 4px; - margin: 8px 0 0; - padding: 8px 10px; - border-radius: 8px; - background: #0f0f11; + 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: 10px; + gap: 12px; } .fix { - margin-top: 8px; - color: #e4e4e7; - line-height: 1.5; + margin-top: 12px; + color: var(--text); + line-height: 1.55; } .fix strong { display: block; - margin-bottom: 2px; - color: var(--good); + margin-bottom: 4px; + color: var(--ok); font-size: 11px; + font-weight: 600; text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.08em; } .rule { display: block; - margin-top: 8px; - color: #a1a1aa; - } - .findings > li > p { - color: #d4d4d8; + margin-top: 12px; + color: var(--text-3); + font-size: 12px; } + .empty { - padding: 32px; + 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; } .sr-only { position: absolute; @@ -1394,6 +1844,7 @@ export class AnalogInspector { readonly kinds: Kind[] = ['all', 'page', 'load', 'fn', 'api']; readonly methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; + readonly methodOptions = this.methods.map((m) => ({ value: m, label: m })); readonly view = signal('routes'); readonly project = signal(null); readonly state = signal({}); @@ -1438,18 +1889,9 @@ export class AnalogInspector { !!r.route.file?.toLowerCase().includes(needle), ); }); - readonly duplicates = computed(() => { - const seen = new Map(); - const out = new Set(); - for (const c of this.allCalls()) { - if (c.kind !== 'load') continue; - const route = c.url.replace(/^.*\/_analog\/pages/, '').replace(/\/index$/, '') || '/'; - if (c.from === 'ssr') seen.set(route, c.at); - else if (c.from === 'browser' && (seen.get(route) ?? -Infinity) > c.at - 15_000) - out.add(route); - } - return Array.from(out); - }); + readonly duplicates = computed(() => + Array.from(new Set((this.state().duplicates ?? []).map((d) => d.route))), + ); readonly modeCounts = computed(() => (['ssr', 'ssg', 'client'] as const) .map((mode) => ({ @@ -1513,9 +1955,12 @@ export class AnalogInspector { } private async load(client: DevframeRpcClient) { - this.project.set(await call(client, 'analog-project')); + const project = await call(client, 'analog-project'); + if (this.destroyRef.destroyed) return; + this.project.set(project); try { const shared = await client.scope('ng-devtools').rpc.sharedState('analog'); + if (this.destroyRef.destroyed) return; const apply = (value: unknown) => this.state.set((value as AnalogState) ?? {}); apply(shared.value()); this.unsubscribe?.(); @@ -1523,6 +1968,7 @@ export class AnalogInspector { } catch { this.state.set({}); } + if (this.destroyRef.destroyed) return; await this.refresh(this.view()); } @@ -1639,6 +2085,10 @@ export class AnalogInspector { queueMicrotask(() => document.getElementById('api-path')?.focus()); } + async clearCalls() { + await call(this.rpc(), 'analog-clear-calls'); + } + async explain() { const url = this.testUrl().trim(); if (!url) return; diff --git a/app/src/pages/coming-soon.ts b/app/src/pages/coming-soon.ts new file mode 100644 index 0000000..65db35d --- /dev/null +++ b/app/src/pages/coming-soon.ts @@ -0,0 +1,483 @@ +import { Component, input } from '@angular/core'; + +export interface ComingSoonInfo { + id: 'nativescript' | 'capacitor' | 'analog'; + name: string; + badge: string; + heading: string; + summary: string; + color: string; + plans: string[]; + pr?: number; + author?: { name: string; login: string }; + link?: { label: string; href: string }; +} + +@Component({ + selector: 'app-coming-soon', + host: { '[style.--brand]': 'info().color' }, + template: ` +
    + + +

    {{ info().badge }}

    +

    {{ info().heading }}

    +

    {{ info().summary }}

    + + @if (info().author; as author) { + + + + Being built by + {{ author.name }} + + + + (opens in a new tab) + + } + @if (info().link; as link) { + + {{ link.label }} + + (opens in a new tab) + + } + +

    + {{ info().link ? 'In an ' + info().name + ' app' : 'On the roadmap' }} +

    +
      + @for (plan of info().plans; track plan; let i = $index) { +
    • {{ plan }}
    • + } +
    +
    + `, + styles: ` + @use 'mixins' as m; + + :host { + display: block; + min-height: 100%; + background: radial-gradient( + 60% 50% at 50% 18%, + color-mix(in srgb, var(--brand) 22%, transparent), + transparent 70% + ); + } + .soon { + display: flex; + flex-direction: column; + align-items: center; + max-width: 560px; + margin: 0 auto; + padding: 24px 0 40px; + color: var(--text-2); + text-align: center; + } + .stage { + position: relative; + display: grid; + place-items: center; + width: 180px; + height: 180px; + margin: 0 auto 16px; + } + .logo { + position: relative; + z-index: 2; + display: grid; + place-items: center; + width: 116px; + height: 116px; + border-radius: 32px; + background: var(--surface-2); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--brand) 45%, transparent), + 0 20px 60px -10px color-mix(in srgb, var(--brand) 70%, transparent); + animation: float 4s ease-in-out infinite; + } + .ring { + position: absolute; + inset: 30px; + border-radius: 50%; + border: 1px solid color-mix(in srgb, var(--brand) 60%, transparent); + animation: pulse 3s ease-out infinite; + } + .r2 { + animation-delay: 1.5s; + } + .orbit { + position: absolute; + inset: 0; + animation: spin 9s linear infinite; + } + .o2 { + inset: 18px; + animation-duration: 6s; + animation-direction: reverse; + } + .o3 { + inset: 40px; + animation-duration: 12s; + } + .dot { + position: absolute; + top: -4px; + left: 50%; + width: 8px; + height: 8px; + margin-left: -4px; + border-radius: 50%; + background: var(--brand); + box-shadow: 0 0 12px var(--brand); + } + .o2 .dot { + width: 6px; + height: 6px; + background: var(--accent); + box-shadow: 0 0 10px var(--accent); + } + .o3 .dot { + width: 4px; + height: 4px; + background: var(--text-strong); + } + .badge { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + margin: 0 0 12px; + padding: 0 12px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--brand) 55%, transparent); + border-radius: 99px; + background: color-mix(in srgb, var(--brand) 14%, var(--surface)); + color: var(--text-strong); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + } + .badge::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 100deg, + transparent 20%, + rgba(255, 255, 255, 0.2) 50%, + transparent 80% + ); + transform: translateX(-100%); + animation: shimmer 2.8s ease-in-out infinite; + } + .spark { + color: var(--accent); + animation: twinkle 1.6s ease-in-out infinite; + } + h2 { + margin: 0; + color: var(--text-strong); + font-size: 28px; + line-height: 1.2; + letter-spacing: -0.02em; + text-wrap: balance; + overflow-wrap: anywhere; + } + .summary { + max-width: 480px; + margin: 12px 0 24px; + font-size: 14px; + line-height: 1.6; + text-wrap: pretty; + } + .author { + display: inline-flex; + align-items: center; + gap: 12px; + max-width: 100%; + min-height: 44px; + padding: 8px 16px 8px 8px; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + text-align: left; + text-decoration: none; + transition: + border-color 0.2s var(--ease), + background-color 0.2s var(--ease), + transform 0.2s var(--ease); + } + .author:hover { + border-color: color-mix(in srgb, var(--brand) 70%, transparent); + background: var(--surface-2); + transform: translateY(-2px); + } + .author:active { + transform: none; + } + .author:focus-visible { + @include m.focus-ring; + } + .cta { + padding: 8px 16px; + } + .author img { + flex: none; + border-radius: 50%; + background: var(--surface-3); + } + .who { + display: grid; + min-width: 0; + line-height: 1.35; + } + .who strong { + overflow: hidden; + color: var(--text-strong); + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; + } + .by, + .login { + overflow: hidden; + color: var(--text-2); + font-size: 12px; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; + } + .go { + flex: none; + margin-left: 4px; + color: var(--brand); + font-size: 16px; + transition: + transform 0.2s var(--ease), + color 0.2s var(--ease); + } + .author:hover .go, + .author:focus-visible .go { + transform: translateX(3px); + } + .plans-title { + @include m.label; + width: 100%; + max-width: 440px; + margin: 32px 0 12px; + text-align: left; + } + .plans { + display: grid; + gap: 8px; + width: 100%; + max-width: 440px; + margin: 0 auto; + padding: 0; + list-style: none; + text-align: left; + } + .plans li { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font-size: 13px; + line-height: 20px; + overflow-wrap: anywhere; + opacity: 0; + animation: rise 0.5s var(--ease) forwards; + } + .plans li::before { + content: ''; + flex: none; + width: 8px; + height: 8px; + margin-top: 6px; + border-radius: 50%; + background: var(--brand); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 20%, transparent); + } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + } + @media (max-height: 640px) { + .soon { + padding-top: 12px; + } + .stage { + width: 120px; + height: 120px; + margin-bottom: 8px; + } + .logo { + width: 80px; + height: 80px; + border-radius: 22px; + } + .logo svg { + width: 60px; + height: auto; + } + .o3 { + display: none; + } + h2 { + font-size: 22px; + } + } + @media (max-width: 420px) { + h2 { + font-size: 22px; + } + .author { + align-self: stretch; + } + .who { + flex: 1; + } + } + @keyframes float { + 50% { + transform: translateY(-10px) rotate(-2deg); + } + } + @keyframes pulse { + from { + transform: scale(0.8); + opacity: 0.9; + } + to { + transform: scale(1.6); + opacity: 0; + } + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } + @keyframes shimmer { + 60%, + 100% { + transform: translateX(100%); + } + } + @keyframes twinkle { + 50% { + opacity: 0.35; + transform: scale(0.8); + } + } + @keyframes rise { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: none; + } + } + @media (prefers-reduced-motion: reduce) { + .logo, + .ring, + .orbit, + .badge::after, + .spark { + animation: none; + } + .ring { + opacity: 0.3; + } + .plans li { + opacity: 1; + animation: none; + } + .author:hover, + .author:hover .go { + transform: none; + } + } + `, +}) +export class ComingSoon { + readonly info = input.required(); +} diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts index f1e478e..bcb99f6 100644 --- a/app/src/pages/component-tree.ts +++ b/app/src/pages/component-tree.ts @@ -1,3 +1,4 @@ +import { JsonPipe } from '@angular/common'; import { Component, DestroyRef, @@ -9,327 +10,1201 @@ import { signal, } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; +import { hostPageId } from '../page-id'; -interface ComponentInfo { +interface SourceComponent { selector: string; + className: string; + kind: 'component' | 'directive'; file: string; + line: number; inputs: string[]; outputs: string[]; isStandalone: boolean; } +interface LiveNode { + id: string; + name: string; + tag: string; + directives?: string[]; + children: LiveNode[]; +} + +interface Prop { + name: string; + prop: string; + value?: unknown; + listened?: boolean; +} + +interface Dependency { + from: string; + token: string; + flags: string[]; + providedBy: string | null; + providedByName?: string; +} + +interface Detail { + id: string; + name: string; + tag: string; + path: string; + changeDetection?: string; + encapsulation?: string; + inputs: Prop[]; + outputs: Prop[]; + listeners: string[]; + directives: { name: string; inputs: Prop[]; outputs: Prop[] }[]; + dependencies: Dependency[]; +} + +interface Page { + pageId: string; + roots: LiveNode[]; + count: number; + truncated?: boolean; + detail: Detail | null; + reportedAt: number; +} + interface OutletInfo { outlet: string; route?: string; element?: string; + devtoolsId?: string; activated: boolean; children?: OutletInfo[]; } -interface ProviderEntry { - token: string; - source: string; - file: string; - line: number; - providedIn?: string; - type: string; +interface RoutedHit { + route: string; + outlet: string; +} + +interface Row { + node: LiveNode; + depth: number; + hasChildren: boolean; + expanded: boolean; +} + +function bare(name: string): string { + return name.replace(/^_(?=[A-Z])/, ''); } @Component({ selector: 'app-component-tree', + imports: [JsonPipe], template: ` -
    - - -
    - - @if (loading()) { -

    Scanning components…

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

    No components found.

    - } @else { -
      - @for (comp of filtered(); track comp.selector) { -
    • - + + } +
      + @for (row of rows(); track row.node.id) { +
      + @if (row.hasChildren) { + + } @else { + + } + {{ row.node.name }} + <{{ row.node.tag }}> + @for (hit of routedById().get(row.node.id) ?? []; track hit.outlet + hit.route) { + {{ hit.route || '/' }} + } + @if (row.node.directives?.length) { + + +{{ row.node.directives!.length }} + + {{ row.node.directives!.length === 1 ? 'directive' : 'directives' }} + + + } +
      + } +
      + + + @if (selectedNode(); as sel) { +
      +
      + component +

      {{ sel.name }}

      + <{{ sel.tag }}> + @if (sourceFor(sel); as src) { +

      {{ src.file }}:{{ src.line }}

      + @if (formsIn(src.file).length) { +
      + @for (form of formsIn(src.file); track form.formId) { + + } +
      + } } - - @if (isSelected(comp)) { -
      -
      -
      File
      -
      {{ comp.file }}
      -
      Standalone
      -
      {{ comp.isStandalone ? 'Yes' : 'No' }}
      -
      - @for (form of formsIn(comp.file); track form.formId) { - +
      + + @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 (comp.inputs.length) { -

      Inputs

      -
        - @for (inp of comp.inputs; track inp) { -
      • {{ inp }}
      • +
      + +
      +

      + Inputs {{ d.inputs.length }} +

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

      No inputs.

      } - @if (comp.outputs.length) { -

      Outputs

      -
        - @for (out of comp.outputs; track out) { -
      • {{ out }}
      • +
      + +
      +

      + Outputs {{ d.outputs.length }} +

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

      No outputs.

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

      Injected Providers

      -
        - @for (p of selectedProviders(); track p.token + p.line) { -
      • - {{ p.token }} - {{ p.type }} - @if (p.source && p.source !== 'class' && p.source !== 'providers array') { - → {{ p.source }} +
      + + @if (d.listeners.length) { +
      +

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

      +
        + @for (name of d.listeners; track name) { +
      • {{ name }}
      • + } +
      +
      + } + + @for (dir of d.directives; track dir.name + $index) { +
      +

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

      + @if (dir.inputs.length) { +
        + @for (p of dir.inputs; track p.name) { +
      • + {{ p.name }} +
        {{ p.value | json }}
        +
      • + } +
      + } + @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 { -

      No injected providers detected.

      +

      Nothing is injected through the constructor or inject().

      }
      + } @else { +
      + +

      Reading live values from the page…

      +
      + } +
      + } @else { +
      +

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

      +
      + } + + } @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 { +

      + 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
      +
      {{ comp.file }}:{{ comp.line }}
      +
      Standalone
      +
      {{ comp.isStandalone ? 'Yes' : 'No' }}
      +
      Inputs
      +
      {{ comp.inputs.join(', ') || 'None' }}
      +
      Outputs
      +
      {{ comp.outputs.join(', ') || 'None' }}
      +
      + @for (form of formsIn(comp.file); track form.formId) { + + } +
      + } +
    • + } +
    + } } `, 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); + } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + } + .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; - gap: 8px; - margin-bottom: 16px; + 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); } - input { - flex: 1; - padding: 8px 12px; - background: #18181b; - border: 1px solid #27272a; - border-radius: 6px; - color: #e4e4e7; - font-size: 14px; - outline: none; + .search { + position: relative; + flex: 1 1 220px; + min-width: 0; } - input:focus { - border-color: var(--accent); + .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(--border-strong); + 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 { - padding: 8px 16px; - background: #3f3f46; - border: none; - border-radius: 6px; - color: #e4e4e7; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 34px; + padding: 0 14px; + background: var(--surface-2); + border: 1px solid var(--border-strong); + 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: #52525b; + background: var(--surface-3); + border-color: var(--accent-line); } - .muted { - color: #71717a; - font-size: 14px; + button:focus-visible { + @include m.focus-ring; } - .component-list { - list-style: none; - padding: 0; - display: flex; - flex-direction: column; - gap: 8px; + button svg { + flex: none; + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; } - .component-item { - background: #18181b; - border: 1px solid #27272a; - border-radius: 8px; - padding: 0; - transition: border-color 0.15s; + .refresh.spinning svg { + animation: spin 0.8s linear infinite; } - .component-item:has(.component-toggle:hover) { - border-color: var(--accent); + @keyframes spin { + to { + transform: rotate(360deg); + } } - .component-item.expanded { - border-color: var(--accent); + .layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + align-items: start; } - .component-toggle { - display: block; - width: 100%; - padding: 12px 16px; - background: none; - border: none; - color: inherit; - text-align: left; + @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; + } + .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; - font: inherit; + transition: background-color 120ms var(--ease); } - .selector { - font-family: monospace; - font-size: 15px; - color: var(--accent); + .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; } - .file { + .tag { + flex: 0 1 auto; + min-width: 0; + color: var(--text-2); font-size: 12px; - color: #71717a; - margin-top: 2px; + @include m.truncate; } - .io { - font-size: 13px; - color: #a1a1aa; - margin-top: 4px; + .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; } - .io .label { - color: #71717a; + .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; } - .show-form { - margin: 0 8px 8px 0; + .detail { + min-width: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + @include m.enter; } - .inline-detail { - padding: 0 16px 12px; - border-top: 1px solid #27272a; - margin-top: 0; - padding-top: 12px; + .detail.placeholder { + padding: 24px 16px; + border-style: dashed; + text-align: center; } - .prop-list { - list-style: none; - padding: 0; + .detail-head { display: flex; flex-wrap: wrap; - gap: 6px; - margin-bottom: 12px; + align-items: center; + gap: 6px 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); } - .prop-chip { - font-family: monospace; + .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; - padding: 3px 8px; - border-radius: 4px; + overflow-wrap: anywhere; } - .input-chip { - background: #1e3a5f; - color: #93c5fd; + .badge { + padding: 2px 8px; + border-radius: 99px; + @include m.soft(var(--accent)); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; } - .output-chip { - background: #3b1d1d; - color: #fca5a5; + .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; } - dl { + .show-form:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + } + .facts { display: grid; - grid-template-columns: auto 1fr; - gap: 4px 12px; - font-size: 13px; - margin-bottom: 16px; + grid-template-columns: max-content minmax(0, 1fr); + gap: 8px 16px; + margin: 0; + padding: 14px 16px; + border-bottom: 1px solid var(--border); } - dt { - color: #71717a; + .facts dt { + @include m.label; + align-self: center; } - dd { - color: #e4e4e7; + .facts dd { + min-width: 0; + margin: 0; + color: var(--text); + overflow-wrap: anywhere; } - h4 { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #71717a; - margin-bottom: 8px; + .block { + padding: 14px 16px; + border-bottom: 1px solid var(--border); } - .provider-list { - list-style: none; + .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-direction: column; + 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-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.soft(var(--ok)); } - .provider-item { + .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: 6px 10px; - background: #09090b; - border: 1px solid #27272a; - border-radius: 6px; - font-size: 13px; + padding: 40px 16px; + text-align: center; + background: var(--surface); + border: 1px dashed var(--border-strong); + border-radius: var(--radius); } - .provider-token { - font-family: monospace; - color: #e4e4e7; + .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; } - .provider-type { - font-size: 11px; - padding: 1px 6px; - border-radius: 4px; - background: #3f3f46; - color: #a1a1aa; + .state-hint { + max-width: 440px; + margin: 0 auto; + color: var(--text-2); + line-height: 1.5; } - .routed { - display: inline-block; - margin-top: 4px; - padding: 1px 6px; - border: 1px solid #52525b; - border-radius: 4px; - color: #d4d4d8; - font-size: 11px; - font-family: monospace; + .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.soft(var(--accent)); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + } + .kind-flag.directive { + @include m.soft(var(--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; } - .provider-source { + .file { + color: var(--text-2); font-size: 12px; - color: #a1a1aa; + @include m.truncate; } - .no-providers { - font-size: 13px; - color: #52525b; + .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; } `, }) export class ComponentTree { - rpc = input(null); + readonly rpc = input(null); readonly showForm = output(); - formOwners = signal<{ formId: string; label: string; file: string | null }[]>([]); - components = signal([]); - allProviders = signal([]); - filter = signal(''); - loading = signal(false); - selected = signal(null); - selectedProviders = signal([]); - - filtered = signal([]); + 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 selectedId = signal(null); + readonly focusId = signal(null); + readonly collapsed = signal>(new Set()); + readonly openKey = signal(null); private readonly outlets = signal([]); - private unsubscribeRouter: (() => void) | null = null; + private readonly pageId = hostPageId(); private readonly destroyRef = inject(DestroyRef); + private readonly cleanups: (() => void)[] = []; + + readonly page = computed(() => { + const pages = this.pages(); + if (this.pageId) return pages[this.pageId] ?? null; + let latest: Page | null = null; + for (const page of Object.values(pages)) { + if (!latest || page.reportedAt > latest.reportedAt) latest = page; + } + return latest; + }); + + readonly live = computed(() => (this.page()?.roots.length ?? 0) > 0); - readonly routedBy = computed(() => { - const map = new Map(); + private readonly index = computed(() => { + const map = new Map(); + const parents = new Map(); + const walk = (nodes: LiveNode[], parent: string | null) => { + for (const node of nodes) { + map.set(node.id, node); + if (parent) parents.set(node.id, parent); + walk(node.children, node.id); + } + }; + walk(this.page()?.roots ?? [], null); + return { map, parents }; + }); + + private readonly query = computed(() => this.filter().trim().toLowerCase()); + + private readonly visible = computed(() => { + const roots = this.page()?.roots ?? []; + const q = this.query(); + if (!q) return roots; + const keep = (node: LiveNode) => + node.name.toLowerCase().includes(q) || + node.tag.toLowerCase().includes(q) || + !!node.directives?.some((d) => d.toLowerCase().includes(q)); + const prune = (nodes: LiveNode[]): LiveNode[] => + nodes.flatMap((node) => { + const children = prune(node.children); + return keep(node) || children.length ? [{ ...node, children }] : []; + }); + return prune(roots); + }); + + readonly rows = computed(() => { + const rows: Row[] = []; + const collapsed = this.collapsed(); + const searching = !!this.query(); + const walk = (nodes: LiveNode[], depth: number) => { + for (const node of nodes) { + const expanded = searching || !collapsed.has(node.id); + rows.push({ node, depth, hasChildren: node.children.length > 0, expanded }); + if (expanded) walk(node.children, depth + 1); + } + }; + walk(this.visible(), 0); + return rows; + }); + + readonly selectedNode = computed(() => { + const id = this.selectedId(); + return id ? (this.index().map.get(id) ?? null) : 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; + }); + + readonly routedById = computed(() => { + const map = new Map(); const visit = (outlets: OutletInfo[]) => { for (const outlet of outlets) { - if (outlet.activated && outlet.element && outlet.route) { - map.set(outlet.element, [ - ...(map.get(outlet.element) ?? []), - { route: outlet.route, outlet: outlet.outlet }, - ]); + 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); } @@ -338,36 +1213,74 @@ export class ComponentTree { return map; }); - constructor() { - effect(() => { - const q = this.filter().toLowerCase(); - const all = this.components(); - this.filtered.set(q ? all.filter((c) => c.selector.includes(q) || c.file.includes(q)) : all); - }); + 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 }; + }); + constructor() { effect(() => { const client = this.rpc(); - if (client) { - this.refresh(); - void this.watchRouter(client); - } + if (!client) return; + void this.refresh(); + void this.watch(client); + }); + this.destroyRef.onDestroy(() => { + this.highlight(null); + for (const cleanup of this.cleanups.splice(0)) cleanup(); }); - this.destroyRef.onDestroy(() => this.unsubscribeRouter?.()); } - private async watchRouter(client: DevframeRpcClient) { + private async watch(client: DevframeRpcClient) { + for (const cleanup of this.cleanups.splice(0)) cleanup(); + const my = client.scope('ng-devtools'); try { - const state = await client.scope('ng-devtools').rpc.sharedState('router'); + const tree = await my.rpc.sharedState('component-tree'); if (this.destroyRef.destroyed) return; - const apply = (value: unknown) => { + const applyTree = (value: unknown) => { + const pages = (value as { pages?: Record } | null)?.pages; + this.pages.set(pages && typeof pages === 'object' ? pages : {}); + }; + applyTree(tree.value()); + this.cleanups.push(tree.on('updated', applyTree)); + } catch { + this.pages.set({}); + } + try { + const router = await my.rpc.sharedState('router'); + if (this.destroyRef.destroyed) return; + const applyRouter = (value: unknown) => { const pages = - (value as { pages?: { outlets?: OutletInfo[]; snapshot?: unknown }[] })?.pages ?? []; - const page = pages.find((p) => p.snapshot) ?? pages[0]; + (value as { pages?: { pageId?: string; outlets?: OutletInfo[]; snapshot?: unknown }[] }) + ?.pages ?? []; + const page = this.pageId + ? pages.find((p) => p.pageId === this.pageId) + : (pages.find((p) => p.snapshot) ?? pages[0]); this.outlets.set(page?.outlets ?? []); }; - apply(state.value()); - this.unsubscribeRouter?.(); - this.unsubscribeRouter = state.on('updated', apply); + applyRouter(router.value()); + this.cleanups.push(router.on('updated', applyRouter)); } catch { this.outlets.set([]); } @@ -377,59 +1290,129 @@ export class ComponentTree { const client = this.rpc(); if (!client) return; this.loading.set(true); + const my = client.scope('ng-devtools'); try { - const my = client.scope('ng-devtools'); - const [comps, providers] = await Promise.all([ - my.rpc.call('get-components') as Promise, - my.rpc.call('get-providers') as Promise, - ]); - this.components.set(comps); - const owners = (await my.rpc.call('forms-owners').catch(() => [])) as { - formId: string; - label: string; - file: string | null; - }[]; - this.formOwners.set(owners ?? []); - this.allProviders.set(providers); - const sel = this.selected(); - if (sel) { - const refreshed = comps.find((c) => c.selector === sel.selector); - if (refreshed) { - this.selected.set(refreshed); - this.selectedProviders.set(providers.filter((p) => p.file === refreshed.file)); - } else { - this.selected.set(null); - this.selectedProviders.set([]); - } - } + 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 ?? []); } formsIn(file: string) { return this.formOwners().filter((form) => form.file === file); } - isSelected(comp: ComponentInfo): boolean { - return this.selected()?.selector === comp.selector; + sourceFor(node: LiveNode): SourceComponent | null { + const matches = (this.sourceByClass().get(bare(node.name)) ?? []).filter( + (c) => c.kind === 'component', + ); + return matches.find((c) => c.selector === node.tag) ?? matches[0] ?? null; } - select(comp: ComponentInfo) { - if (this.isSelected(comp)) { - this.selected.set(null); - this.selectedProviders.set([]); - const client = this.rpc(); - if (client) { - client.scope('ng-devtools').rpc.callEvent('select-component', null); - } - return; - } - this.selected.set(comp); - this.selectedProviders.set(this.allProviders().filter((p) => p.file === comp.file)); + 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); + } + + select(id: string) { + const next = this.selectedId() === id ? null : id; + this.selectedId.set(next); + this.focusId.set(id); const client = this.rpc(); - if (client) { - client.scope('ng-devtools').rpc.callEvent('select-component', comp.selector); + if (!client) return; + void client + .scope('ng-devtools') + .rpc.call('select-component', { pageId: this.page()?.pageId, id: next }) + .catch(() => {}); + } + + 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; + }); + } + + highlight(id: string | null) { + const client = this.rpc(); + if (!client) return; + const target = id ? { pageId: this.page()?.pageId, id } : null; + void client + .scope('ng-devtools') + .rpc.call('request-page-highlight', target) + .catch(() => {}); + } + + 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; + default: + return; + } + event.preventDefault(); + if (next >= 0) { + const id = rows[next].node.id; + this.focusId.set(id); + queueMicrotask(() => { + const el = document.querySelector(`.row[data-id="${CSS.escape(id)}"]`); + el?.focus(); + el?.scrollIntoView({ block: 'nearest' }); + }); } } } diff --git a/app/src/pages/dashboard.ts b/app/src/pages/dashboard.ts index 8daf0d4..2402fe1 100644 --- a/app/src/pages/dashboard.ts +++ b/app/src/pages/dashboard.ts @@ -1,122 +1,498 @@ -import { Component, input, signal, effect, output } from '@angular/core'; +import { + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal, +} from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; -import type { Tab } from '../types/tab.types'; +import { hostPageId } from '../page-id'; +import { TabIcon } from './tab-icon'; + +type LoadState = 'loading' | 'ready' | 'error'; + +interface BuildMeta { + projectName?: string; + angularVersion?: string; + typescript?: string; + ssr?: boolean; + analog?: string; +} + +const STATS = [ + { 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' }, +] as const; + +type StatTab = (typeof STATS)[number]['tab']; + +interface Card { + value: number; + sub: string; +} + +interface Row { + builtin?: boolean; + kind?: string; + type?: string; + redirectTo?: string; + fullPath?: string; + path?: string; +} + +interface InjectorNode { + providers?: unknown[]; + children?: InjectorNode[]; +} + +interface InjectorSnapshot { + roots?: InjectorNode[]; + environment?: InjectorNode[]; +} + +interface GraphSnapshot { + graph?: { nodes?: { kind?: string }[] } | null; + pages?: Record; +} + +const LIVE_SIGNAL_KINDS = new Set(['signal', 'computed', 'linkedSignal', 'effect']); + +const NGRX_NAMES: Record = { + 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 plural(count: number, one: string, many: string): string { + return `${count} ${count === 1 ? one : many}`; +} + +function walkInjectors(nodes: InjectorNode[] | undefined, visit: (node: InjectorNode) => void) { + for (const node of nodes ?? []) { + visit(node); + walkInjectors(node.children, visit); + } +} + +export function componentsCard(rows: Row[]): Card { + if (!rows.some((row) => row.kind)) return { value: rows.length, sub: 'discovered in source' }; + const directives = rows.filter((row) => row.kind === 'directive').length; + return { + value: rows.length - directives, + sub: `components · ${plural(directives, 'directive', 'directives')}`, + }; +} + +export function routesCard(rows: Row[]): Card { + if (!rows.some((row) => row.kind)) return { value: rows.length, sub: 'route entries in source' }; + const redirects = rows.filter((row) => row.kind === 'redirect').length; + const paths = new Set( + rows.filter((row) => row.kind === 'page').map((row) => row.fullPath ?? row.path ?? ''), + ); + return { + value: paths.size, + sub: `navigable paths · ${plural(redirects, 'redirect', 'redirects')}`, + }; +} + +export function storeCard(rows: Row[]): Card { + const counts = new Map(); + for (const row of rows) if (row.kind) counts.set(row.kind, (counts.get(row.kind) ?? 0) + 1); + if (!counts.size) return { value: rows.length, sub: 'declarations in source' }; + const parts = [...counts] + .sort((a, b) => b[1] - a[1]) + .map(([kind, count]) => { + const [one, many] = NGRX_NAMES[kind] ?? [kind, kind]; + return plural(count, one, many); + }); + return { value: rows.length, sub: parts.join(' · ') }; +} @Component({ selector: 'app-dashboard', + imports: [TabIcon], template: ` -
    -
    -

    Project

    -
    -
    Name
    -
    {{ meta()?.projectName ?? '…' }}
    -
    Angular
    -
    {{ meta()?.angularVersion ?? '…' }}
    -
    TypeScript
    -
    {{ meta()?.typescript ?? '…' }}
    -
    SSR
    -
    {{ meta()?.ssr ? 'Yes' : 'No' }}
    - @if (meta()?.analog; as analog) { -
    Analog
    -
    {{ analog }}
    +
    +
    +

    Project

    +

    + @switch (metaState()) { + @case ('ready') { + {{ meta()?.projectName }} + } + @case ('error') { + Project details unavailable + } + @default { + Loading… + } } -

    -
    -
    -

    Components

    -

    {{ componentCount() }}

    -

    discovered in source

    -
    -
    -

    Routes

    -

    {{ routeCount() }}

    -

    registered paths

    -
    -
    -

    Signals

    -

    {{ signalCount() }}

    -

    reactive primitives

    -
    -
    -

    Injectors

    -

    {{ providerCount() }}

    -

    DI providers

    -
    -
    -

    NgRx Store

    -

    {{ storeCount() }}

    -

    store entries

    -
    -
    -

    Pipes

    -

    {{ pipeCount() }}

    -

    template transformers

    + + @if (metaState() === 'error') { +

    Check that the dev server is running, then reload the panel.

    + }
    +
      +
    • Angular{{ meta()?.angularVersion ?? '…' }}
    • +
    • TypeScript{{ meta()?.typescript ?? '…' }}
    • +
    • SSR{{ meta() ? (meta()?.ssr ? 'On' : 'Off') : '…' }}
    • + @if (meta()?.analog; as analog) { +
    • Analog{{ analog }}
    • + } +
    + + +
    + @for (stat of stats; track stat.tab; let i = $index) { + @let state = stateOf(stat.tab); + + }
    `, styles: ` - .grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 16px; + @use 'mixins' as m; + + :host { + display: block; + max-width: 1200px; } - .card { - background: #18181b; - border: 1px solid #27272a; - border-radius: 10px; - padding: 20px; + .project { + 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(--border); + border-radius: var(--radius); + background: + radial-gradient(420px 180px at 100% 0%, var(--accent-soft), transparent 70%), + linear-gradient(180deg, var(--surface-2), var(--surface)); + box-shadow: var(--shadow); } - .card.clickable { - cursor: pointer; - transition: border-color 0.15s; + .project-head { + flex: 1 1 240px; + min-width: 0; } - .card.clickable:hover { - border-color: var(--accent); + .eyebrow { + @include m.label; + margin: 0 0 4px; + color: var(--accent); } h2 { + margin: 0; + color: var(--text-strong); + font-size: 24px; + line-height: 1.25; + letter-spacing: -0.02em; + overflow-wrap: anywhere; + } + h2.muted { + color: var(--text-2); + font-weight: 600; + } + .hint { + margin: 8px 0 0; + color: var(--text-2); font-size: 13px; - text-transform: uppercase; - color: #71717a; + line-height: 1.5; + } + .chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-width: 0; + margin: 0; + padding: 0; + list-style: none; + } + .chips li { + display: inline-flex; + align-items: center; + gap: 8px; + height: 28px; + max-width: 100%; + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: 99px; + background: var(--bg); + color: var(--text-strong); + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + .chips span { + color: var(--text-3); + font-weight: 500; + } + .chips.pending li { + color: var(--text-3); + } + .chips .analog { + border-color: color-mix(in srgb, #dd0330 55%, transparent); + } + .grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 150px), 1fr)); + gap: 12px; + } + .stat { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 0; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: inherit; + text-align: left; + font: inherit; + cursor: pointer; + animation: enter 0.4s var(--ease) both; + transition: + border-color 0.2s var(--ease), + transform 0.2s var(--ease), + background-color 0.2s var(--ease), + box-shadow 0.2s var(--ease); + } + .stat:hover { + border-color: var(--accent-line); + background: var(--surface-2); + box-shadow: var(--shadow); + transform: translateY(-2px); + } + .stat:active { + transform: translateY(0); + background: var(--surface-3); + } + .stat:focus-visible { + @include m.focus-ring; + } + .top { + display: flex; + align-items: center; + justify-content: space-between; + align-self: stretch; margin-bottom: 12px; - letter-spacing: 0.05em; } - dl { + .icon { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border: 1px solid var(--accent-line); + border-radius: var(--radius-sm); + background: var(--accent-soft); + color: var(--accent); + } + .go { display: grid; - grid-template-columns: auto 1fr; - gap: 6px 12px; - font-size: 14px; + place-items: center; + width: 24px; + height: 24px; + color: var(--text-3); + transition: + transform 0.2s var(--ease), + color 0.2s var(--ease); } - dt { - color: #a1a1aa; + .stat:hover .go, + .stat:focus-visible .go { + color: var(--accent); + transform: translateX(3px); } - dd { - color: #e4e4e7; + .label { + max-width: 100%; + overflow: hidden; + color: var(--text-2); + font-size: 13px; font-weight: 500; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; } .big { - font-size: 36px; - font-weight: 700; - color: var(--accent); + margin: 4px 0; + color: var(--text-strong); + font-size: 32px; + font-weight: 750; + line-height: 40px; + letter-spacing: -0.03em; + font-variant-numeric: tabular-nums; + } + .big.muted { + color: var(--text-3); + } + .skeleton { + display: block; + width: 56px; + height: 32px; + margin-block: 8px; + border-radius: var(--radius-sm); + background: linear-gradient(90deg, var(--surface-2), var(--surface-3), var(--surface-2)) 0 0 / + 200% 100%; + animation: shimmer 1.4s linear infinite; } .sub { - font-size: 13px; - color: #71717a; - margin-top: 4px; + max-width: 100%; + color: var(--text-3); + font-size: 12px; + line-height: 16px; + } + @keyframes shimmer { + to { + background-position: -200% 0; + } + } + @media (max-width: 480px) { + .project { + padding: 16px; + } + h2 { + font-size: 20px; + } + .stat { + padding: 12px; + } + .big { + font-size: 28px; + line-height: 36px; + } + } + @media (prefers-reduced-motion: reduce) { + .stat, + .stat:hover, + .stat:active { + transform: none; + } + .stat:hover .go { + transform: none; + } + .skeleton { + animation: none; + } } `, }) export class Dashboard { rpc = input(null); - navigate = output(); + navigate = output(); + + meta = signal(null); + protected readonly stats = STATS; + protected readonly metaState = signal('loading'); + protected readonly states = signal>>({}); + private readonly rows = signal>>({}); + private readonly injectorTree = signal(null); + private readonly signalGraph = signal(null); + private readonly pageId = hostPageId(); + private readonly destroyRef = inject(DestroyRef); + private stopLive: (() => void)[] = []; + + private readonly liveInjectors = computed(() => { + const tree = this.injectorTree(); + if (!tree?.roots?.length) return null; + let injectors = 0; + let providers = 0; + const visit = (node: InjectorNode) => { + injectors++; + providers += node.providers?.length ?? 0; + }; + walkInjectors(tree.roots, visit); + walkInjectors(tree.environment, visit); + return { injectors, providers }; + }); + + private readonly liveSignals = computed(() => { + const state = this.signalGraph(); + const graph = (this.pageId && state?.pages?.[this.pageId]) || state?.graph; + if (!graph?.nodes?.length) return null; + return graph.nodes.filter((node) => LIVE_SIGNAL_KINDS.has(node.kind ?? '')).length; + }); - meta = signal(null); - componentCount = signal(0); - routeCount = signal(0); - signalCount = signal(0); - providerCount = signal(0); - storeCount = signal(0); - pipeCount = signal(0); + protected readonly cards = computed>>(() => { + const rows = this.rows(); + const out: Partial> = {}; + if (rows.components) out.components = componentsCard(rows.components); + if (rows.routes) out.routes = routesCard(rows.routes); + if (rows.store) out.store = storeCard(rows.store); + if (rows.pipes) { + const builtin = rows.pipes.filter((row) => row.builtin).length; + out.pipes = { + value: rows.pipes.length - builtin, + sub: builtin ? `custom pipes · ${builtin} built-in in use` : 'custom pipes in source', + }; + } + const liveSignals = this.liveSignals(); + if (liveSignals !== null) { + out.signals = { + value: liveSignals, + sub: rows.signals + ? `live on the page · ${rows.signals.length} declared in source` + : 'live on the page', + }; + } else if (rows.signals) { + out.signals = { value: rows.signals.length, sub: 'signal declarations in source' }; + } + const live = this.liveInjectors(); + if (live) { + out.injectors = { + value: live.injectors, + sub: `live injectors · ${plural(live.providers, 'provider', 'providers')}`, + }; + } else if (rows.injectors) { + out.injectors = { + value: rows.injectors.filter((row) => row.type !== 'injection').length, + sub: 'provider declarations in source', + }; + } + return out; + }); constructor() { effect(() => { @@ -124,34 +500,66 @@ export class Dashboard { if (!client) return; const my = client.scope('ng-devtools'); + this.metaState.set('loading'); + this.states.set({}); + this.rows.set({}); my.rpc .call('build-meta') - .then((m: any) => this.meta.set(m)) - .catch(() => {}); - my.rpc - .call('get-components') - .then((c: any[]) => this.componentCount.set(c.length)) - .catch(() => {}); - my.rpc - .call('get-routes') - .then((r: any[]) => this.routeCount.set(r.length)) - .catch(() => {}); - my.rpc - .call('get-signals') - .then((s: any[]) => this.signalCount.set(s.length)) - .catch(() => {}); - my.rpc - .call('get-providers') - .then((p: any[]) => this.providerCount.set(p.length)) - .catch(() => {}); - my.rpc - .call('get-ngrx-store') - .then((s: any[]) => this.storeCount.set(s.length)) - .catch(() => {}); - my.rpc - .call('get-pipes') - .then((s: any[]) => this.pipeCount.set(s.length)) - .catch(() => {}); + .then((m) => { + this.meta.set(m as BuildMeta); + this.metaState.set('ready'); + }) + .catch(() => this.metaState.set('error')); + this.load(my.rpc.call('get-components'), 'components'); + this.load(my.rpc.call('get-routes'), 'routes'); + this.load(my.rpc.call('get-signals'), 'signals'); + this.load(my.rpc.call('get-providers'), 'injectors'); + this.load(my.rpc.call('get-ngrx-store'), 'store'); + this.load(my.rpc.call('get-pipes'), 'pipes'); + void this.watchLive(client); }); + this.destroyRef.onDestroy(() => this.unwatch()); + } + + protected stateOf(tab: StatTab): LoadState { + const live = + tab === 'signals' ? this.liveSignals() : tab === 'injectors' ? this.liveInjectors() : null; + return live !== null ? 'ready' : (this.states()[tab] ?? 'loading'); + } + + private load(request: Promise, tab: StatTab) { + const mark = (state: LoadState) => this.states.update((all) => ({ ...all, [tab]: state })); + request + .then((rows) => { + this.rows.update((all) => ({ ...all, [tab]: Array.isArray(rows) ? (rows as Row[]) : [] })); + mark('ready'); + }) + .catch(() => mark('error')); + } + + private async watchLive(client: DevframeRpcClient) { + this.unwatch(); + const rpc = client.scope('ng-devtools').rpc; + const follow = async ( + name: 'injector-tree' | 'signal-graph', + target: (value: T) => void, + ) => { + try { + const state = await rpc.sharedState(name); + if (this.destroyRef.destroyed || this.rpc() !== client) return; + target(state.value() as T); + this.stopLive.push(state.on('updated', (value: unknown) => target(value as T))); + } catch { + target(null as T); + } + }; + await Promise.all([ + follow('injector-tree', (value) => this.injectorTree.set(value)), + follow('signal-graph', (value) => this.signalGraph.set(value)), + ]); + } + + private unwatch() { + for (const stop of this.stopLive.splice(0)) stop(); } } diff --git a/app/src/pages/di-inspector.ts b/app/src/pages/di-inspector.ts index 078cea3..a0e95e7 100644 --- a/app/src/pages/di-inspector.ts +++ b/app/src/pages/di-inspector.ts @@ -1,11 +1,36 @@ -import { NgTemplateOutlet } from '@angular/common'; -import { Component, input, signal, effect, computed } from '@angular/core'; +import { Component, DestroyRef, computed, effect, inject, input, signal } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; +import { hostPageId } from '../page-id'; interface ProviderInfo { token: string; type: string; isViewProvider: boolean; + multi?: boolean; + importPath?: string[]; +} + +interface DependencyInfo { + from: string; + token: string; + flags: string[]; + providedBy: string | null; +} + +interface InjectorNode { + injector: { + id: string; + type: string; + name: string; + providerCount: number; + component?: string; + directives?: string[]; + selector?: string; + path?: string[]; + }; + providers: ProviderInfo[]; + children: InjectorNode[]; + dependencies?: DependencyInfo[]; } interface SourceProvider { @@ -17,439 +42,1517 @@ interface SourceProvider { type: string; } -interface InjectorNode { - injector: { id: string; type: string; name: string; providerCount: number }; - providers: ProviderInfo[]; - children: InjectorNode[]; +interface Row { + node: InjectorNode; + depth: number; + hasChildren: boolean; + expanded: boolean; } -const TYPE_COLORS: Record = { - element: '#60a5fa', - environment: '#34d399', - null: '#71717a', +type TreeView = 'element' | 'environment'; + +const NULL_ID = 'inj-null'; + +const KIND_TONE: Record = { + component: 'var(--accent)', + directive: '#7cb4ff', + environment: 'var(--ok)', + 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); +} + @Component({ selector: 'app-di-inspector', - imports: [NgTemplateOutlet], template: ` -
    - - -
    - - @if (roots().length === 0 && sourceProviders().length === 0) { -
    -

    No DI data found.

    -

    - No providers, injectables, or inject() calls found. Runtime tree requires Angular 17+ with - the overlay connected. -

    + @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. +

    + +
    +
    + + +
    + + @if (view() === 'element') { + + } +
    - } - @if (roots().length === 0 && sourceProviders().length > 0) { -

    DI from source scan (static analysis):

    -
    - @for (group of groupedProviders(); track group.type) { -
    -

    {{ 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 }} - } -
    -
    - {{ p.file }}:{{ p.line }} - @if (p.source !== 'class' && p.source !== 'providers array') { - · as {{ p.source }} - } -
    -
    + @if (providedBy().length) { +
    + Provided by + @for (hit of providedBy(); track hit.id) { + + } +
    + } @else if (query().trim() && !rows().length) { +
    + Nothing here matches “{{ query().trim() }}”. + +
    + } + +
    + @if (!rows().length) { +
    +

    No injectors to show

    +

    + @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 (roots().length > 0) { -
    - @for (root of filteredRoots(); track root.injector.id) { - - @defer { -
    - @for (item of flattenTree(root); track item.node.injector.id) { -
    + @for (row of rows(); track row.node.injector.id; let i = $index) { +
    + @if (row.hasChildren) { +
    + } +
    + + @if (selected(); as sel) { +
    +
    + {{ kind(sel) }} +

    {{ label(sel) }}

    + @if (sel.injector.directives?.length) { +
      + @for (name of sel.injector.directives; track name) { +
    • {{ name }}
    • } -
    + } + + +
    +

    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') { +
    +

    + Injected here {{ sel.dependencies?.length ?? 0 }} +

    + @if (sel.dependencies?.length) { +
      + @for (dep of sel.dependencies; track dep.from + dep.token + $index) { +
    • +
      + {{ dep.token }} + @for (flag of dep.flags; track flag) { + {{ flag }} + } +
      +
      + @if (sel.injector.directives && sel.injector.directives.length > 1) { + for {{ dep.from }} + } + @if (dep.providedBy; as by) { + + } @else { + not provided anywhere + } +
      +
    • + } +
    + } @else { +

    Nothing is injected through the constructor or inject().

    + } +
    + } + +
    +

    + Provides {{ sel.providers.length }} +

    + @if (sel.providers.length) { +
      + @for (p of sel.providers; track p.token + $index) { +
    • +
      + {{ p.token }} + {{ + p.type === 'unknown' ? 'provider' : 'use' + capital(p.type) + }} + @if (p.isViewProvider) { + viewProviders + } + @if (p.multi) { + multi + } +
      + @if (p.importPath?.length) { +
      via {{ p.importPath!.join(' › ') }}
      + } +
    • + } +
    + } @else { +

    + @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 (sourceProviders().length === 0) { +
    +

    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 (selectedInjector()) { - +
    } } `, 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; - gap: 12px; + flex-wrap: wrap; + gap: 8px 12px; align-items: center; - margin-bottom: 16px; + 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); } - input[type='text'] { - flex: 1; - padding: 8px 12px; - background: #18181b; - border: 1px solid #27272a; + .segmented { + display: inline-flex; + flex: none; + padding: 3px; + gap: 2px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + } + .segmented button { + display: inline-flex; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 10px; + border: 0; border-radius: 6px; - color: #e4e4e7; - font-size: 14px; - outline: none; + background: none; + color: var(--text-2); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: + background-color 150ms var(--ease), + color 150ms var(--ease); } - input[type='text']:focus { - border-color: var(--accent); + .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(--border-strong); + 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: 6px; - font-size: 13px; - color: #a1a1aa; + gap: 8px; + height: 34px; + color: var(--text-2); white-space: nowrap; cursor: pointer; } - .empty { - text-align: center; - padding: 48px 16px; + .checkbox:hover { + color: var(--text); } - .muted { - color: #71717a; - font-size: 14px; + .checkbox input { + width: 15px; + height: 15px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; } - .hint { - color: #52525b; + .checkbox input:focus-visible { + @include m.focus-ring; + } + .total { + flex: none; + color: var(--text-2); font-size: 12px; - margin-top: 8px; + white-space: nowrap; } - .tree-container { + .where { display: flex; - flex-direction: column; + 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(--border-strong); + 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; + } + .link { + padding: 0; + border: 0; + background: none; + color: var(--accent); + font-size: 13px; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; } - .injector-row { + .dot { + flex: none; + width: 7px; + height: 7px; + border-radius: 50%; + } + .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; + } + .row { + position: relative; display: flex; align-items: center; - gap: 8px; - padding: 8px 12px; + gap: 6px; + min-height: 32px; + padding: 0 8px 0 calc(4px + var(--depth, 0) * 18px); + border-radius: var(--radius-sm); cursor: pointer; - border-bottom: 1px solid #1e1e22; - transition: background 0.1s; + transition: background-color 120ms var(--ease); } - .injector-row:hover { - background: #18181b; + .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; } - .injector-row.selected { - background: color-mix(in srgb, var(--accent) 22%, transparent); - border-color: var(--accent); + .row:hover { + background: var(--surface-2); } - .type-badge { - font-size: 10px; - padding: 2px 6px; + .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; - color: #fff; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; + 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 { - font-family: monospace; - font-size: 13px; - color: #e4e4e7; + min-width: 0; + color: var(--text); + font-size: 12px; + @include m.truncate; } - .provider-count { - font-size: 11px; - color: #71717a; + .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.soft(var(--ok)); } - .detail-panel { - margin-top: 16px; - padding: 16px; - background: #18181b; - border: 1px solid #27272a; - border-radius: 8px; + .count svg { + width: 11px; + height: 11px; + fill: none; + stroke: currentColor; + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; } - .detail-header { + .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; - margin-bottom: 12px; + gap: 8px 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); } - .detail-header h3 { - font-family: monospace; - color: #e4e4e7; + .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; } - table { + .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%; - border-collapse: collapse; - font-size: 13px; + margin: 0; + padding: 0; + list-style: none; } - th { - text-align: left; - padding: 6px 10px; - background: #0f0f11; - color: #71717a; + .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; - text-transform: uppercase; - letter-spacing: 0.05em; - border-bottom: 1px solid #27272a; } - td { + .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-bottom: 1px solid #1e1e22; + 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); + } + .dep-main { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; } .token { - font-family: monospace; - color: var(--accent); + 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; } - .source-label { + .kind-flag { + @include m.soft(var(--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.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; + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } + @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(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + cursor: pointer; + } + .state-title + .state-hint + button { + margin-top: 8px; + } + .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; - color: #71717a; - margin-bottom: 12px; + 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; } .source-providers { display: flex; flex-direction: column; - gap: 20px; + gap: 24px; + @include m.enter; } - .provider-group h3 { - font-size: 13px; - color: #71717a; - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 8px; + .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 { - background: #18181b; - border: 1px solid #27272a; - border-radius: 8px; - padding: 10px 14px; + 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; - gap: 8px; + flex-wrap: wrap; + gap: 4px 8px; } .provider-header .token { - font-size: 14px; - font-weight: 500; + font-size: 13px; + font-weight: 600; } .provided-in { font-size: 11px; - padding: 1px 6px; - border-radius: 4px; - background: #14532d; - color: #4ade80; + padding: 1px 8px; + border-radius: 99px; + @include m.soft(var(--ok)); + border-width: 1px; + border-style: solid; } .provider-meta { - font-size: 11px; - color: #52525b; + 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); } `, }) export class DiInspector { rpc = input(null); - roots = signal([]); - sourceProviders = signal([]); - filter = signal(''); - hideEmpty = signal(false); - selectedId = signal(null); + readonly nullId = NULL_ID; + readonly roots = signal([]); + readonly environment = signal([]); + readonly sourceProviders = signal([]); + readonly loaded = signal(false); + readonly view = signal('element'); + readonly query = signal(''); + readonly onlyProviding = signal(false); + readonly componentsOnly = signal(true); + readonly collapsed = signal>(new Set()); + readonly selectedId = signal(null); + readonly focusId = signal(null); + + 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); + return { map, parents }; + }); - selectedInjector = computed(() => { + readonly elementCount = computed(() => this.count(this.roots())); + readonly environmentCount = computed(() => this.count(this.environment())); + + private readonly visible = computed(() => { + let nodes = this.view() === 'element' ? this.roots() : this.environment(); + if (this.view() === 'element' && this.componentsOnly()) { + nodes = this.prune(nodes, (n) => !!n.injector.component); + } + if (this.onlyProviding()) nodes = this.prune(nodes, (n) => n.providers.length > 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; + }); + + readonly selected = computed(() => { const id = this.selectedId(); - if (!id) return null; - return this.findNode(this.roots(), id); + const found = id ? this.index().map.get(id) : undefined; + return found ?? this.rows()[0]?.node ?? 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; }); - filteredRoots = computed(() => { - let roots = this.roots(); - if (this.hideEmpty()) { - roots = this.filterEmpty(roots); + 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); } - const q = this.filter().toLowerCase(); - if (q) { - roots = this.filterByQuery(roots, q); + return ids.map((id) => { + const node = map.get(id); + return { + id, + label: node ? labelOf(node) : 'Null injector', + kind: node ? kindOf(node) : 'null', + providers: node?.providers.length ?? 0, + }; + }); + }); + + readonly providedBy = 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()) { + const tokens = node.providers.filter((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 roots; + return hits.slice(0, 12); }); - groupedProviders = computed(() => { - const all = this.sourceProviders(); - const q = this.filter().toLowerCase(); + readonly groupedProviders = computed(() => { + const q = this.normalizedQuery(); const filtered = q - ? all.filter((p) => p.token.toLowerCase().includes(q) || p.file.includes(q)) - : all; - + ? 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: [] }, ]; - for (const p of filtered) { - const group = groups.find((g) => g.type === p.type); - if (group) group.items.push(p); - } + 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), + ); + + private readonly normalizedQuery = computed(() => this.query().trim().toLowerCase()); + + private readonly destroyRef = inject(DestroyRef); + private stopTree: (() => void) | null = null; + constructor() { effect(() => { const client = this.rpc(); if (!client) return; - this.loadInjectorTree(client); - this.loadSourceProviders(client); + void this.loadInjectorTree(client); + void this.loadSourceProviders(client); + }); + this.destroyRef.onDestroy(() => { + this.stopTree?.(); + this.highlight(null); }); } - async loadInjectorTree(client: DevframeRpcClient) { - const my = client.scope('ng-devtools'); - const state = await my.rpc.sharedState('injector-tree'); - const val = state.value() as any; - if (val?.roots?.length) this.roots.set(val.roots); - state.on('updated', (next: any) => { - if (next?.roots) this.roots.set(next.roots); - }); + label(node: InjectorNode) { + return labelOf(node); } - async loadSourceProviders(client: DevframeRpcClient) { - const my = client.scope('ng-devtools'); - try { - const result = (await my.rpc.call('get-providers')) as SourceProvider[]; - this.sourceProviders.set(result); - } catch { - // RPC not available - } + kind(node: InjectorNode) { + return kindOf(node); } - select(node: InjectorNode) { - this.selectedId.set(this.selectedId() === node.injector.id ? null : node.injector.id); + tone(kind: string) { + return KIND_TONE[kind] ?? KIND_TONE['null']; } - typeColor(type: string) { - return TYPE_COLORS[type] ?? TYPE_COLORS['null']; + kindById(id: string) { + const node = this.index().map.get(id); + return node ? kindOf(node) : 'null'; } - flattenTree(root: InjectorNode): { node: InjectorNode; depth: number }[] { - const result: { node: InjectorNode; depth: number }[] = []; - const walk = (node: InjectorNode, depth: number) => { - result.push({ node, depth }); - for (const child of node.children) { - walk(child, depth + 1); + labelById(id: string) { + const node = this.index().map.get(id); + return node ? labelOf(node) : 'Null injector'; + } + + capital(text: string) { + return text.charAt(0).toUpperCase() + text.slice(1); + } + + 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.selectedId.set(null); + this.focusId.set(null); + } + + select(id: string) { + this.selectedId.set(id); + this.focusId.set(id); + } + + 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() && node.providers.length === 0) this.onlyProviding.set(false); + if (node.injector.type === 'element' && !node.injector.component) + this.componentsOnly.set(false); + this.select(id); + queueMicrotask(() => this.focusRow(id, false)); + } + + highlight(node: InjectorNode | null) { + const client = this.rpc(); + if (!client) return; + const selector = node?.injector.type === 'element' ? (node.injector.selector ?? null) : null; + void client + .scope('ng-devtools') + .rpc.call('request-page-highlight', selector) + .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 { + const parent = this.index().parents.get(row.node.injector.id); + next = rows.findIndex((r) => r.node.injector.id === 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.selectedId.set(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 async loadInjectorTree(client: DevframeRpcClient) { + this.stopTree?.(); + this.stopTree = null; + const my = client.scope('ng-devtools'); + let state: Awaited>; + try { + state = await my.rpc.sharedState('injector-tree'); + } catch { + return; + } + if (this.destroyRef.destroyed) return; + const pageId = hostPageId(); + const apply = (value: unknown) => { + const shared = value as { + roots?: unknown; + environment?: unknown; + pages?: Record; + } | null; + const next = (pageId ? shared?.pages?.[pageId] : undefined) ?? shared; + if (isTree(next?.roots)) this.roots.set(next.roots); + if (isTree(next?.environment)) this.environment.set(next.environment); }; - walk(root, 0); - return result; + apply(state.value()); + this.stopTree = state.on('updated', apply); } - private findNode(nodes: InjectorNode[], id: string): InjectorNode | null { - for (const n of nodes) { - if (n.injector.id === id) return n; - const found = this.findNode(n.children, id); - if (found) return found; + private async loadSourceProviders(client: DevframeRpcClient) { + const my = client.scope('ng-devtools'); + try { + this.sourceProviders.set((await my.rpc.call('get-providers')) as SourceProvider[]); + } catch { + this.sourceProviders.set([]); + } finally { + this.loaded.set(true); } - return null; } - private filterEmpty(nodes: InjectorNode[]): InjectorNode[] { - return nodes - .map((n) => ({ - ...n, - children: this.filterEmpty(n.children), - })) - .filter((n) => n.injector.providerCount > 0 || n.children.length > 0); + private count(nodes: InjectorNode[]): number { + return nodes.reduce((sum, n) => sum + 1 + this.count(n.children), 0); } - private filterByQuery(nodes: InjectorNode[], q: string): InjectorNode[] { - return nodes - .map((n) => ({ - ...n, - children: this.filterByQuery(n.children, q), - })) - .filter( - (n) => - n.injector.name.toLowerCase().includes(q) || - n.providers.some((p) => p.token.toLowerCase().includes(q)) || - n.children.length > 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)) + ); + } + + 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/forms-field-detail.ts b/app/src/pages/forms-field-detail.ts index f769aae..aeb1003 100644 --- a/app/src/pages/forms-field-detail.ts +++ b/app/src/pages/forms-field-detail.ts @@ -13,22 +13,30 @@ import { @Component({ selector: 'app-forms-field-detail', template: ` -

    {{ node().path || '(form)' }}

    -
    {{ text() || 'Loading…' }}
    -
    - @if (node().type === 'control' && !node().redacted) { +
    + +

    {{ node().path || '(form)' }}

    + {{ node().type }} +
    +
    {{ text() || 'Loading…' }}
    + @if (node().type === 'control' && !node().redacted) { +
    - - } + +
    + } +
    @@ -39,31 +47,56 @@ import { styles: ` ${FORMS_STYLES} :host { - display: grid; - gap: 8px; - padding: 10px; - border: 1px solid #3f3f46; - border-radius: 8px; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + padding: 16px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + animation: enter 0.35s var(--ease) both; + } + .head { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + align-items: center; + min-width: 0; + } + .head .section-label { + flex-basis: 100%; } h3 { + min-width: 0; margin: 0; - color: #e4e4e7; + color: var(--text-strong); + font-family: var(--font-mono); font-size: 14px; - font-family: ui-monospace, monospace; + font-weight: 600; + line-height: 1.4; + overflow-wrap: anywhere; + } + .editor { + display: flex; + gap: 8px; + align-items: center; + } + .editor .field-input { + flex: 1 1 auto; } .row { display: flex; flex-wrap: wrap; - gap: 6px; + gap: 8px; align-items: center; } - .sr-only { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; + .status { + margin-top: -4px; + } + .status:empty { + height: 0; + margin: -12px 0 0; } `, }) @@ -105,18 +138,12 @@ export class FormsFieldDetail { } async setValue() { - const raw = this.draft(); - let value: unknown = raw; - try { - value = JSON.parse(raw); - } catch { - value = raw; - } const result = await formAction(this.rpc(), { action: 'set-value', formId: this.form().id, path: this.node().path, - value, + value: this.draft(), + coerce: true, mode: 'user', }); this.message.set(actionMessage(result)); diff --git a/app/src/pages/forms-inspector.ts b/app/src/pages/forms-inspector.ts index 5f62e48..f8d9de7 100644 --- a/app/src/pages/forms-inspector.ts +++ b/app/src/pages/forms-inspector.ts @@ -11,6 +11,7 @@ import { } from '@angular/core'; import { JsonPipe } from '@angular/common'; import type { DevframeRpcClient } from 'devframe/client'; +import { hostPageId } from '../page-id'; import { FormsFieldDetail } from './forms-field-detail'; import { FormsLint, FormsSubmit } from './forms-report'; import { FormsTimeline } from './forms-timeline'; @@ -20,6 +21,7 @@ import { SOURCE_LABELS, actionMessage, formAction, + pageOf, type CollectedForm, type FormEvent, type FormFieldError, @@ -75,7 +77,8 @@ function countErrors(node: FormFieldNode): number { } function countFields(node: FormFieldNode): number { - return 1 + (node.children ?? []).reduce((sum, c) => sum + countFields(c), 0); + if (!node.children) return node.type === 'control' && node.materialized !== false ? 1 : 0; + return node.children.reduce((sum, c) => sum + countFields(c), 0); } @Component({ @@ -83,47 +86,101 @@ function countFields(node: FormFieldNode): number { imports: [JsonPipe, FormsFieldDetail, FormsTimeline, FormsSubmit, FormsLint], template: ` @if (!rpc()) { -

    Connecting…

    +
    + +

    Connecting to the devtools server…

    +
    } @else if (failed()) { -

    Could not load forms from the devtools server. Reload to try again.

    + } @else if (loading()) { -

    Loading forms…

    +
    + +

    Loading forms…

    +
    } @else if (!forms().length) {
    -

    No forms on the page yet.

    +

    No forms on the page yet

    Open a page that renders a form. Signal Forms, reactive and template-driven forms all show up here, in development builds.

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

    No forms on this page

    +

    + Other connected tabs report {{ forms().length }} + {{ forms().length === 1 ? 'form' : 'forms' }}. +

    + +
    } @else {
    -
      - @for (form of forms(); track form.id) { -
    • - -
    • +
    +
      + @for (form of visible(); track form.id) { +
    • + +
    • + } +
    +
    - @if (selected(); as form) { -
    + @if (missing(); as label) { +
    +

    {{ label }}

    +

    + This form is no longer on the page. It comes back here if the page renders it again. +

    + +
    + } @else if (selected(); as form) { +
    +
    +

    {{ form.label }}

    +

    + {{ kindLabel(form.kind) }} · {{ form.id }} +

    +
    {{ form.root.status @@ -136,33 +193,69 @@ function countFields(node: FormFieldNode): number { @if (form.root.submitting) { submitting } - {{ counts().get(form.id)?.fields }} fields, - {{ counts().get(form.id)?.errors }} errors{{ counts().get(form.id)?.fields }} fields · + {{ + counts().get(form.id)?.errors + }} + errors
    + @if (form.errorSummary?.length) { +
    + Error summary ({{ form.errorSummary!.length }}) +
      + @for (entry of form.errorSummary!; track $index) { +
    • + {{ entry.path || '(form)' }} {{ entry.message }} + {{ entry.kind }} +
    • + } +
    +
    + } -
    - - - - - - @if (snapshot()) { - + + - } - - + + + + @if (snapshot()) { + + } + + + +
    +

    {{ message() }}

    -

    {{ message() }}

    @for (tab of tabs; track tab.id) { @@ -187,27 +280,31 @@ function countFields(node: FormFieldNode): number { > @switch (tab_()) { @case ('fields') { -
    - Show only fields that are - @for (chip of chips; track chip.id) { - - } -
    - +
    + +
    + Show only fields that are + @for (chip of chips; track chip.id) { + + } +
    +
    @@ -227,7 +324,11 @@ function countFields(node: FormFieldNode): number { (mouseenter)="highlight(form.id, row.node.path)" (mouseleave)="highlight(null, '')" > - - @for (row of rows(); track $index) { - - + @@ -138,7 +152,7 @@ interface OutletRow {

    Outlets

      @for (row of outletRows(); track $index) { -
    • +
    • {{ row.outlet.outlet }} @if (row.outlet.activated) { {{ row.outlet.component ?? '?' }} for @@ -157,71 +171,125 @@ interface OutletRow {
    } } @else { -

    This page reports no Router.

    +
    +

    This page reports no Router.

    +

    + Add provideRouter() to the app config and navigate once to see the active + routes here. +

    +
    } `, styles: ` ${SHARED_STYLES} :host { display: grid; + gap: 16px; + min-width: 0; + } + .url { + display: flex; + align-items: baseline; gap: 12px; + min-width: 0; + margin: 0; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); + } + .url-label { + flex: none; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; } .url code { - font-size: 14px; + min-width: 0; color: var(--accent); - } - .note { - margin: 0; - padding: 8px 10px; - border-left: 3px solid #fef08a; - background: #27272a; - color: #e4e4e7; - font-size: 13px; + font-size: 14px; + font-weight: 500; } .pending { display: flex; flex-wrap: wrap; - gap: 8px; + gap: 8px 12px; align-items: center; + padding: 6px 6px 6px 14px; + border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--warn) 10%, transparent); + color: var(--warn); font-size: 13px; - color: #fef08a; } - .facts { - display: grid; - grid-template-columns: max-content 1fr; - gap: 4px 12px; - margin: 0; - font-size: 13px; + .pending-text { + flex: 1 1 200px; + min-width: 0; } - dt { - color: #a1a1aa; + .pending code { + color: var(--text-strong); } - dd { - margin: 0; - color: #e4e4e7; + .pulse { + flex: none; + width: 8px; + height: 8px; + border-radius: 99px; + background: var(--warn); + animation: pulse 1.2s ease-in-out infinite; } - .path { - font-family: monospace; - color: var(--accent); - white-space: nowrap; + @keyframes pulse { + 50% { + opacity: 0.35; + } + } + @media (prefers-reduced-motion: reduce) { + .pulse { + animation: none; + } } .sub { - font-family: inherit; - color: #a1a1aa; + margin-top: 4px; + color: var(--text-2); + font-family: var(--font-sans); font-size: 12px; white-space: normal; } + .component { + color: var(--text-strong); + } + .entry + .entry { + margin-top: 2px; + } .data { max-width: 360px; } .outlets { - list-style: none; - margin: 0; - padding: 0; display: grid; - gap: 6px; + gap: 2px; + margin: 0; + padding: 6px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); font-size: 13px; - color: #e4e4e7; + list-style: none; + animation: enter 0.35s var(--ease) both; + } + .outlets li { + padding-top: 6px; + padding-right: 10px; + padding-bottom: 6px; + border-radius: var(--radius-sm); + line-height: 1.8; + overflow-wrap: anywhere; + transition: background-color 0.15s var(--ease); + } + .outlets li:hover { + background: var(--surface-2); } `, }) diff --git a/app/src/pages/route-inspector.ts b/app/src/pages/route-inspector.ts index 23f523a..b650e93 100644 --- a/app/src/pages/route-inspector.ts +++ b/app/src/pages/route-inspector.ts @@ -1,156 +1,227 @@ -import { Component, input, signal, effect, computed } from '@angular/core'; +import { Component, computed, effect, input, linkedSignal, signal, viewChild } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; import { LiveRoute } from './live-route'; - -interface RouteInfo { - path: string; - component?: string; - redirectTo?: string; - title?: string; - hasChildren: boolean; - file: string; -} +import { SHARED_STYLES, sourceLocation, type SourceRoute } from './router-types'; @Component({ selector: 'app-route-inspector', imports: [LiveRoute], template: ` - + -

    Route config

    -
    - - -
    +
    +
    +

    Source route config

    + + Read from your source files. + @if (liveHasConfig()) { + The Routes tab above shows what the router uses. + } + + @if (liveHasConfig()) { + + } +
    +
    +
    + + +
    - @if (loading()) { -

    Scanning routes…

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

    No routes found.

    - } @else { -
    + + @if (row.node.materialized === false) { not created yet } @else { @@ -316,6 +418,12 @@ function countFields(node: FormFieldNode): number { @if (row.node.accessor) { {{ row.node.accessor }} } + @if (row.node.name) { + name {{ row.node.name }} + } + @for (value of row.node.metadata ?? []; track $index) { + metadata {{ value | json }} + } @for (reason of row.node.disabledReasons ?? []; track $index) { disabled: {{ reason }} } @@ -325,6 +433,9 @@ function countFields(node: FormFieldNode): number {
    {{ errorText(row.node, error) }} {{ error.kind }} + @if (error.kind === 'standardSchema' && error.params?.['path']) { + at {{ error.params!['path'] }} + } @if (error.source) { {{ sourceText(error) }} } @@ -339,8 +450,8 @@ function countFields(node: FormFieldNode): number {
    {{ row.node.truncated }} more fields under {{ row.node.path || 'the form' }} not shown @@ -349,7 +460,7 @@ function countFields(node: FormFieldNode): number { } } @empty {
    + @if (filter()) { No field path matches "{{ filter() }}". } @else { @@ -393,78 +504,47 @@ function countFields(node: FormFieldNode): number { `, styles: ` ${FORMS_STYLES} - .actions { - display: flex; - flex-wrap: wrap; - gap: 6px; - } - .tabs { - display: flex; - gap: 4px; - border-bottom: 1px solid #27272a; + :host { + display: block; } - .tabs [role='tab'] { - padding: 6px 12px; - border: none; - border-bottom: 2px solid transparent; - background: none; - color: #d4d4d8; - font: inherit; - font-size: 13px; - cursor: pointer; - } - .tabs [role='tab'][aria-selected='true'] { - border-bottom-color: var(--accent); - color: #fafafa; - } - .tabs [role='tab']:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } - .panel { + .layout { display: grid; - gap: 12px; - } - .chips { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin: 0; - padding: 0; - border: 0; - color: #d4d4d8; - font-size: 13px; - } - .field[aria-pressed='true'] { - color: var(--accent); - text-decoration: underline; + grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); + gap: 16px; + align-items: start; } - .flags span.warn { - border-color: #a16207; - color: #fef08a; + .sidebar { + min-width: 0; + padding: 8px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + animation: enter 0.35s var(--ease) both; } - .source { - margin-left: 6px; - color: #a1a1aa; + .list-heading { + display: flex; + gap: 8px; + align-items: center; + margin: 4px 8px 8px; + color: var(--text-3); font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; } - .unseen { - color: #fde68a !important; + .list-count { + padding: 0 6px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); font-size: 11px; - } - .layout { - display: grid; - grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); - gap: 16px; - } - @media (max-width: 720px) { - .layout { - grid-template-columns: 1fr; - } + letter-spacing: 0; + line-height: 18px; + font-variant-numeric: tabular-nums; } .form-list { display: grid; - gap: 4px; + gap: 2px; align-content: start; margin: 0; padding: 0; @@ -473,187 +553,636 @@ function countFields(node: FormFieldNode): number { .form-item { width: 100%; display: grid; - grid-template-columns: auto 1fr auto; + grid-template-columns: 8px minmax(0, 1fr) auto; grid-template-areas: 'dot label count' '. kind kind'; - gap: 2px 8px; + gap: 2px 10px; align-items: center; padding: 8px 10px; - border: 1px solid #27272a; - border-radius: 6px; + border: 0; + border-radius: var(--radius-sm); background: transparent; - color: #e4e4e7; + color: var(--text); + font: inherit; text-align: left; cursor: pointer; + transition: + background-color 150ms var(--ease), + color 150ms var(--ease), + box-shadow 150ms var(--ease); + } + .form-item:hover { + background: var(--surface-2); + } + .form-item:active { + background: var(--surface-3); } .form-item.active { - border-color: var(--accent); - background: #18181b; + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent); + color: var(--text-strong); + } + .form-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .form-item .dot { grid-area: dot; } .form-item .label { grid-area: label; - overflow-wrap: anywhere; + min-width: 0; + overflow: hidden; font-size: 13px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; } .form-item .kind { grid-area: kind; - color: #a1a1aa; + min-width: 0; + overflow: hidden; + color: var(--text-3); font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + } + .form-item .id { + font-family: var(--font-mono); + font-size: 11.5px; + } + .form-item.active .kind { + color: var(--text-2); } .form-item .count { grid-area: count; - padding: 0 6px; - border-radius: 999px; - background: #7f1d1d; - color: #fecaca; - font-size: 12px; + min-width: 20px; + padding: 0 7px; + border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); + border-radius: 99px; + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); + font-size: 11px; + font-weight: 600; + line-height: 18px; + text-align: center; + font-variant-numeric: tabular-nums; } .dot { width: 8px; height: 8px; border-radius: 50%; - background: #22c55e; + background: var(--ok); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 15%, transparent); } .dot[data-status='INVALID'] { - background: #ef4444; + background: var(--danger); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 15%, transparent); } .dot[data-status='PENDING'] { - background: #eab308; + background: var(--warn); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 15%, transparent); } .dot[data-status='DISABLED'] { - background: #71717a; + background: var(--text-3); + box-shadow: none; } .detail { display: grid; - gap: 12px; + gap: 16px; + min-width: 0; + padding: 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + animation: enter 0.35s var(--ease) both; + } + .detail-head { + display: grid; + gap: 4px; + min-width: 0; + } + .detail-head h2 { + margin: 0; + color: var(--text-strong); + font-size: 16px; + font-weight: 600; + line-height: 1.3; + overflow-wrap: anywhere; + } + .detail-meta { + margin: 0; + color: var(--text-3); + font-size: 12px; + overflow-wrap: anywhere; + } + .detail-meta code { + color: var(--text-2); + font-size: 11.5px; + } + .gone { + justify-items: start; + } + .gone h2 { + margin: 0; + color: var(--text-strong); + font-size: 16px; + font-weight: 600; + overflow-wrap: anywhere; + } + .gone p { + margin: 0; + } + .page-toggle { + display: flex; + gap: 8px; + align-items: center; + min-height: 32px; + margin: 0 8px 8px; + color: var(--text-2); + font-size: 13px; + cursor: pointer; + } + .page-toggle input { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--accent); + } + .page-toggle input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .error-summary { min-width: 0; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + font-size: 13px; + } + .error-summary summary { + color: var(--text); + font-weight: 500; + cursor: pointer; + } + .error-summary summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .error-summary ul { + display: grid; + gap: 4px; + margin: 8px 0 0; + padding: 0 0 0 16px; + color: var(--text); + overflow-wrap: anywhere; + } + .error-summary .kind-tag { + margin-left: 6px; + color: var(--text-2); + font-size: 11px; } .summary { display: flex; flex-wrap: wrap; - gap: 8px 14px; + gap: 6px; align-items: center; - color: #d4d4d8; - font-size: 13px; + color: var(--text-2); + font-size: 12px; + } + .summary > span:not(.badge):not(.totals) { + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 99px; + background: var(--surface-2); + color: var(--text); + line-height: 22px; + } + .totals { + margin-left: auto; + color: var(--text-3); + font-variant-numeric: tabular-nums; + } + .totals b { + color: var(--text); + font-weight: 600; + } + .totals b.has-errors { + color: var(--danger); } .badge { - padding: 1px 6px; - border-radius: 4px; - background: #14532d; - color: #bbf7d0; + display: inline-block; + padding: 0 8px; + border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent); + border-radius: 99px; + background: color-mix(in srgb, var(--ok) 12%, transparent); + color: var(--ok); font-size: 11px; font-weight: 600; + letter-spacing: 0.04em; + line-height: 20px; + white-space: nowrap; + } + .summary > .badge { + line-height: 22px; } .badge[data-status='INVALID'] { - background: #7f1d1d; - color: #fecaca; + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); } .badge[data-status='PENDING'] { - background: #713f12; - color: #fef08a; + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + background: color-mix(in srgb, var(--warn) 12%, transparent); + color: var(--warn); } .badge[data-status='DISABLED'] { - background: #3f3f46; - color: #e4e4e7; + border-color: var(--border-strong); + background: var(--surface-2); + color: var(--text-2); } - .filter { - padding: 8px 12px; - background: #18181b; - border: 1px solid #52525b; - border-radius: 6px; - color: #e4e4e7; - font-size: 14px; + .action-bar { + display: flex; + flex-direction: column; + gap: 8px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); } - .filter:focus-visible, - .form-item:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; + .actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; } - .table-scroll { + .divider { + width: 1px; + height: 20px; + background: var(--border-strong); + } + .spacer { + flex: 1 1 0; + } + .actions .small:not(.primary):not(.danger) { + max-width: 240px; + } + .status:empty { + height: 0; + margin-top: -8px; + } + .tabs { + display: inline-flex; + justify-self: start; + gap: 2px; + max-width: 100%; + height: 34px; + padding: 2px; overflow-x: auto; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); } - .table-scroll:focus-visible, - .field:focus-visible { + .tabs [role='tab'] { + flex: none; + height: 28px; + padding: 0 14px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--text-2); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: + background-color 150ms var(--ease), + color 150ms var(--ease), + box-shadow 150ms var(--ease); + } + .tabs [role='tab']:hover { + color: var(--text); + background: var(--surface-2); + } + .tabs [role='tab'][aria-selected='true'] { + background: var(--surface-3); + color: var(--text-strong); + box-shadow: inset 0 0 0 1px var(--border-strong); + } + .tabs [role='tab']:focus-visible { outline: 2px solid var(--accent); - outline-offset: 2px; + outline-offset: -2px; } - .field { + .panel { + display: grid; + gap: 12px; + min-width: 0; + animation: enter 0.35s var(--ease) both; + } + .filters { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + } + .chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; + margin: 0; padding: 0; - border: none; - background: none; - color: inherit; - font: inherit; + border: 0; + } + .chips label { + position: relative; + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: 99px; + background: var(--surface-2); + color: var(--text-2); + font-size: 12px; + font-weight: 500; + white-space: nowrap; cursor: pointer; + user-select: none; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease), + color 150ms var(--ease); + } + .chips label:hover { + color: var(--text); + border-color: var(--border-strong); } - .sr-only { + .chips input { position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; + } + .chips label:has(input:checked) { + border-color: var(--accent-line); + background: var(--accent-soft); + color: var(--accent); + } + .chips label:has(input:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .filter { + flex: 1 1 220px; + min-width: 0; + height: 34px; + padding: 0 12px 0 34px; + background-color: var(--bg); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238e8e99' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: 12px center; + background-size: 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + font: inherit; + font-size: 13px; + transition: + border-color 150ms var(--ease), + box-shadow 150ms var(--ease); + } + .filter::placeholder { + color: var(--text-3); + } + .filter:hover { + border-color: color-mix(in srgb, var(--text-3) 55%, var(--border-strong)); + } + .filter:focus, + .filter:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + } + .table-scroll { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + } + .table-scroll:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .fields { width: 100%; + min-width: 680px; border-collapse: collapse; font-size: 13px; } .fields th, .fields td { - padding: 6px 8px; - border-bottom: 1px solid #27272a; + padding: 8px 12px; + border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; + line-height: 20px; + } + .fields tbody tr:last-child > * { + border-bottom: 0; } .fields thead th { - color: #a1a1aa; - font-weight: 500; + background: var(--surface-2); + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + line-height: 16px; + text-transform: uppercase; + white-space: nowrap; } .fields tbody th { - color: #e4e4e7; + color: var(--text); font-weight: 500; white-space: nowrap; } + .fields tbody tr { + transition: background-color 150ms var(--ease); + } .fields tbody tr:hover { - background: #18181b; + background: var(--surface-2); + } + .fields tbody tr.invalid > th { + box-shadow: inset 2px 0 0 color-mix(in srgb, var(--danger) 60%, transparent); + } + .fields tbody tr:has(.field[aria-pressed='true']) { + background: var(--accent-soft); + } + .fields tbody tr:has(.field[aria-pressed='true']) > th { + box-shadow: inset 2px 0 0 var(--accent); + color: var(--text-strong); + } + .name { + max-width: 280px; + } + .field { + display: inline-block; + max-width: 200px; + padding: 0; + overflow: hidden; + border: none; + border-radius: 4px; + background: none; + color: inherit; + font-family: var(--font-mono); + font-size: 12.5px; + font-weight: 500; + line-height: 20px; + text-align: left; + text-overflow: ellipsis; + vertical-align: top; + white-space: nowrap; + cursor: pointer; + transition: color 150ms var(--ease); + } + .field:hover { + color: var(--accent-hover); + text-decoration: underline; + text-underline-offset: 3px; + } + .field[aria-pressed='true'] { + color: var(--accent); + } + .field:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .type { - margin-left: 6px; - color: #a1a1aa; + margin-left: 8px; + color: var(--text-3); font-size: 11px; font-weight: 400; + vertical-align: top; + } + .value { + min-width: 140px; + max-width: 320px; } .value code, .errors code { - color: #c4b5fd; + color: #fde68a; overflow-wrap: anywhere; } + .value .muted { + margin-top: 2px; + font-size: 12px; + } + .status-cell { + white-space: nowrap; + } + .flags { + min-width: 160px; + } .flags span { display: inline-block; - margin: 0 4px 2px 0; - padding: 0 5px; - border: 1px solid #3f3f46; - border-radius: 4px; - color: #d4d4d8; + margin: 0 4px 4px 0; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 99px; + background: var(--surface-2); + color: var(--text-2); font-size: 11px; + line-height: 18px; + white-space: nowrap; + } + .flags span.warn { + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + background: color-mix(in srgb, var(--warn) 12%, transparent); + color: var(--warn); + } + .errors { + min-width: 180px; } .errors div { - color: #fca5a5; + color: var(--danger); + overflow-wrap: anywhere; + } + .errors div + div { + margin-top: 4px; + } + .errors .kind-tag { + margin-left: 6px; + color: var(--text-3); + font-size: 11px; } - .kind-tag { + .source { margin-left: 6px; - color: #a1a1aa; + color: var(--text-2); + font-size: 11px; + } + .errors .unseen { + color: var(--warn); font-size: 11px; } - .muted { - color: #a1a1aa; + .truncated { + color: var(--text-3); + font-size: 12px; + font-style: italic; + } + .fields .no-match { + padding: 24px 12px; + color: var(--text-2); + text-align: center; } .empty { - padding: 32px; + display: grid; + justify-items: center; + gap: 8px; + margin: 0; + padding: 48px 24px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-2); + font-size: 13px; + line-height: 1.5; text-align: center; - color: #d4d4d8; + animation: enter 0.35s var(--ease) both; + } + .empty p { + max-width: 52ch; + margin: 0; + } + .empty .small { + margin-top: 8px; + } + .empty-title { + color: var(--text-strong); + font-size: 15px; + font-weight: 600; + } + @media (max-width: 720px) { + .layout { + grid-template-columns: minmax(0, 1fr); + } + .form-list { + max-height: 240px; + overflow-y: auto; + } + } + @media (max-width: 480px) { + .detail { + padding: 12px; + } + .totals { + flex-basis: 100%; + margin-left: 0; + } + .spacer, + .divider { + display: none; + } } `, }) @@ -667,6 +1196,15 @@ export class FormsInspector { readonly loading = signal(true); readonly failed = signal(false); readonly selectedId = signal(null); + readonly selectedLabel = signal(null); + readonly hostPageId = hostPageId(); + readonly allPages = signal(false); + readonly visible = computed(() => { + const forms = this.forms(); + const page = this.hostPageId; + return page && !this.allPages() ? forms.filter((f) => pageOf(f.id) === page) : forms; + }); + readonly otherPages = computed(() => this.forms().some((f) => pageOf(f.id) !== this.hostPageId)); readonly instrumented = signal([]); readonly recording = computed(() => { const id = this.selected()?.id ?? ''; @@ -689,7 +1227,7 @@ export class FormsInspector { readonly counts = computed( () => new Map( - this.forms().map((form) => [ + this.visible().map((form) => [ form.id, { fields: countFields(form.root), errors: countErrors(form.root) }, ]), @@ -697,10 +1235,25 @@ export class FormsInspector { ); readonly selected = computed(() => { - const forms = this.forms(); - return forms.find((f) => f.id === this.selectedId()) ?? forms[0] ?? null; + const forms = this.visible(); + const id = this.selectedId(); + if (id === null) return forms[0] ?? null; + const label = this.selectedLabel(); + const page = pageOf(id); + return ( + forms.find((f) => f.id === id) ?? + forms.find((f) => f.label === label && pageOf(f.id) === page) ?? + forms.find((f) => f.label === label) ?? + null + ); }); + readonly missing = computed(() => + this.selectedId() !== null && !this.selected() && this.visible().length + ? (this.selectedLabel() ?? this.selectedId()) + : null, + ); + readonly rows = computed(() => { const form = this.selected(); if (!form) return []; @@ -778,6 +1331,11 @@ export class FormsInspector { } } + retry() { + const client = this.rpc(); + if (client) void this.load(client); + } + async pick() { const form = this.selected(); if (!form) return; @@ -806,7 +1364,9 @@ export class FormsInspector { } selectForm(id: string) { + if (this.hostPageId && pageOf(id) && pageOf(id) !== this.hostPageId) this.allPages.set(true); this.selectedId.set(id); + this.selectedLabel.set(this.forms().find((f) => f.id === id)?.label ?? null); this.filter.set(''); this.fieldPath.set(null); this.snapshot.set(null); diff --git a/app/src/pages/forms-report.ts b/app/src/pages/forms-report.ts index ba3ca64..ccb7591 100644 --- a/app/src/pages/forms-report.ts +++ b/app/src/pages/forms-report.ts @@ -5,11 +5,19 @@ import { FORMS_STYLES, formsCall, plain, type FormLintFinding } from './forms-ty @Component({ selector: 'app-forms-submit', template: ` -

    Submit

    -
    {{ submit() || 'Loading…' }}
    -

    Payload

    -
    {{ payload() || 'Loading…' }}
    -
    +
    + +
    {{
    +        submit() || 'Loading…'
    +      }}
    +
    +
    + +
    {{
    +        payload() || 'Loading…'
    +      }}
    +
    +
    {{ message() }}
    @@ -17,16 +25,25 @@ import { FORMS_STYLES, formsCall, plain, type FormLintFinding } from './forms-ty styles: ` ${FORMS_STYLES} :host { + display: grid; + gap: 16px; + min-width: 0; + animation: enter 0.35s var(--ease) both; + } + .block { display: grid; gap: 8px; + min-width: 0; } - h3 { - margin: 4px 0 0; - color: #d4d4d8; - font-size: 13px; + .explain { + max-height: 420px; + overflow: auto; } - .status { - margin-left: 8px; + .fixture { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; } `, }) @@ -79,26 +96,54 @@ export class FormsSubmit { selector: 'app-forms-lint', template: ` @if (findings() === null) { -

    Checking…

    +
    + +

    Checking the form for problems…

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

    No problems found. For generic accessibility, run axe on the page.

    +
    + +

    No problems found

    +

    For generic accessibility checks, run axe on the page.

    +
    } @else { +

    + {{ findings()!.length }} + {{ findings()!.length === 1 ? 'finding' : 'findings' }} +

      @for (f of findings(); track $index) { -
    • - {{ f.severity }} - {{ f.rule }} - @if (f.path) { - at {{ f.path }} - } -
      {{ f.message }}
      -
      Fix: {{ f.fix }}
      +
    • +
      + {{ f.severity }} + {{ f.rule }} + @if (f.path) { + at {{ f.path }} + } +
      +

      {{ f.message }}

      +

      Fix {{ f.fix }}

    • }
    @@ -106,22 +151,101 @@ export class FormsSubmit { `, styles: ` ${FORMS_STYLES} + :host { + display: grid; + gap: 12px; + min-width: 0; + } + .ok-icon { + display: grid; + place-items: center; + width: 32px; + height: 32px; + margin-bottom: 4px; + border-radius: 50%; + background: color-mix(in srgb, var(--ok) 12%, transparent); + color: var(--ok); + } + .summary { + margin: 0; + color: var(--text-2); + font-size: 12.5px; + } + .tabular { + color: var(--text-strong); + font-weight: 600; + font-variant-numeric: tabular-nums; + } .findings { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; + color: var(--text); font-size: 13px; - color: #e4e4e7; + animation: enter 0.35s var(--ease) both; } .findings li { - padding: 8px; - border: 1px solid #27272a; - border-radius: 6px; + display: grid; + gap: 6px; + min-width: 0; + padding: 12px 16px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: inset 3px 0 0 var(--border-strong); + transition: border-color 150ms var(--ease); + } + .findings li[data-severity='error'] { + box-shadow: inset 3px 0 0 var(--danger); + } + .findings li[data-severity='warning'] { + box-shadow: inset 3px 0 0 var(--warn); + } + .findings li:hover { + border-color: var(--border-strong); + } + .meta { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + align-items: center; + min-width: 0; + } + .severity { + text-transform: uppercase; + letter-spacing: 0.06em; + } + .rule { + color: var(--text-strong); + font-weight: 600; + overflow-wrap: anywhere; + } + .path { + font-size: 12.5px; + overflow-wrap: anywhere; + } + .path code { + color: #fde68a; + } + .message, + .fix { + margin: 0; + line-height: 1.5; + overflow-wrap: anywhere; + } + .fix { + color: var(--text-2); + font-size: 12.5px; } - code { - color: #c4b5fd; + .fix-label { + margin-right: 4px; + color: var(--accent); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; } `, }) diff --git a/app/src/pages/forms-timeline.ts b/app/src/pages/forms-timeline.ts index 0bbcd55..f41d99a 100644 --- a/app/src/pages/forms-timeline.ts +++ b/app/src/pages/forms-timeline.ts @@ -6,137 +6,329 @@ const ORIGINS = ['all', 'user', 'code', 'devtools'] as const; @Component({ selector: 'app-forms-timeline', template: ` - -
    - Show changes from - @for (origin of origins; track origin) { - - } -
    +
    + +
    + Show changes from + @for (origin of origins; track origin) { + + } +
    +
    @if (shown().length) {
      @for (event of shown(); track event.formId + '#' + event.seq) {
    1. - - {{ event.path || '(form)' }} - {{ event.type }} - @if (event.outcome) { - {{ - event.outcome - }} - } - - @if (event.prev !== undefined) { - {{ event.prev }} → + +
      +
      + {{ event.path || '(form)' }} + {{ event.type }} + @if (event.outcome) { + {{ + event.outcome + }} + } + @if (event.count && event.count > 1) { + ×{{ event.count }} + } + @if (event.origin) { + {{ event.origin }} + } + @if (event.ms !== undefined) { + pending {{ event.ms }}ms + } + @if (event.renders) { + {{ event.renders }} renders: {{ (event.rendered ?? []).join(', ') }} + } +
      + @if (event.prev !== undefined || event.detail) { +
      + @if (event.prev !== undefined) { + {{ event.prev }} + changed to + } + {{ event.detail }} +
      } - {{ event.detail }} - - @if (event.count && event.count > 1) { - ×{{ event.count }} - } - @if (event.origin) { - {{ event.origin }} - } - @if (event.ms !== undefined) { - pending {{ event.ms }}ms - } - @if (event.renders) { - {{ event.renders }} renders: {{ (event.rendered ?? []).join(', ') }} - } - @if (event.caller) { - from {{ event.caller }} - } + @if (event.caller) { + from {{ event.caller }} + } +
    2. }
    + } @else if (events().length) { +
    +

    No changes from {{ filter() }}

    +

    Pick another source above, or interact with the form to record more.

    +
    } @else { -

    No changes yet. Type into the form to see them here.

    +
    +

    No changes yet

    +

    Type into the form to see them here.

    +
    } `, styles: ` ${FORMS_STYLES} :host { display: grid; - gap: 8px; + gap: 12px; + min-width: 0; } - .chips { + .toolbar { display: flex; flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + } + .record { + display: inline-flex; + flex: 1 1 260px; gap: 10px; - margin: 0; - padding: 0; - border: 0; - color: #d4d4d8; + align-items: flex-start; + min-width: 0; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + cursor: pointer; + transition: + border-color 150ms var(--ease), + background-color 150ms var(--ease); + } + .record:hover { + border-color: var(--border-strong); + } + .record.on { + border-color: var(--accent-line); + background: var(--accent-soft); + } + .record:has(input:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .record input { + flex: none; + width: 16px; + height: 16px; + margin: 2px 0 0; + accent-color: var(--accent); + cursor: pointer; + } + .record input:focus-visible { + outline: none; + } + .record-text { + display: grid; + gap: 2px; + min-width: 0; + } + .record-title { + display: inline-flex; + gap: 6px; + align-items: center; + color: var(--text-strong); font-size: 13px; + font-weight: 500; + } + .record-hint { + color: var(--text-2); + font-size: 12px; + line-height: 1.4; + } + .rec-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--danger); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 20%, transparent); + animation: rec-pulse 1.6s ease-in-out infinite; + } + @keyframes rec-pulse { + 50% { + opacity: 0.45; + } + } + .chips { + display: inline-flex; + flex: none; + gap: 2px; + min-width: 0; + height: 34px; + margin: 0; + padding: 2px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + } + .chips label { + position: relative; + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 12px; + border-radius: 7px; + color: var(--text-2); + font-size: 12.5px; + font-weight: 500; + text-transform: capitalize; + cursor: pointer; + user-select: none; + transition: + background-color 150ms var(--ease), + color 150ms var(--ease), + box-shadow 150ms var(--ease); + } + .chips label:hover { + color: var(--text); + background: var(--surface-2); } - .sr-only { + .chips input { position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; + } + .chips label:has(input:checked) { + background: var(--surface-3); + color: var(--text-strong); + box-shadow: inset 0 0 0 1px var(--border-strong); + } + .chips label:has(input:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; } .events { display: grid; - gap: 4px; + gap: 2px; margin: 0; - padding: 0; + padding: 4px; list-style: none; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); font-size: 13px; + animation: enter 0.35s var(--ease) both; } .events li { - display: flex; - flex-wrap: wrap; - gap: 8px; + display: grid; + grid-template-columns: 11ch minmax(0, 1fr); + gap: 12px; align-items: baseline; - color: #d4d4d8; + padding: 8px 12px; + border-radius: 6px; + color: var(--text); + transition: background-color 150ms var(--ease); + } + .events li:hover { + background: var(--surface-3); } .events li[data-type='submit'] { - border-left: 3px solid var(--accent); - padding-left: 6px; + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent); + color: var(--text-strong); } time { - color: #a1a1aa; + color: var(--text-3); + font-family: var(--font-mono); + font-size: 12px; font-variant-numeric: tabular-nums; + white-space: nowrap; } - code { - color: #c4b5fd; + .body { + display: grid; + gap: 4px; + min-width: 0; + } + .line { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + align-items: center; + min-width: 0; + } + .path { + color: #fde68a; + overflow-wrap: anywhere; } .event-type { - color: #93c5fd; + color: var(--text-strong); + font-size: 12px; + font-weight: 600; + } + .tag { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; } .detail { + color: var(--text); + font-family: var(--font-mono); + font-size: 12.5px; overflow-wrap: anywhere; } - .record { - color: #d4d4d8; - font-size: 13px; + .prev { + color: var(--text-2); + text-decoration: line-through; + text-decoration-color: color-mix(in srgb, var(--text-2) 50%, transparent); + } + .arrow { + margin: 0 6px; + color: var(--text-3); } .caller { - flex-basis: 100%; - padding-left: 16px; - color: #a1a1aa; - font-family: ui-monospace, monospace; + color: var(--text-3); + font-family: var(--font-mono); font-size: 12px; overflow-wrap: anywhere; } + @media (max-width: 480px) { + .events li { + grid-template-columns: minmax(0, 1fr); + gap: 4px; + } + .chips { + width: 100%; + } + .chips label { + flex: 1; + justify-content: center; + padding: 0 8px; + } + } `, }) export class FormsTimeline { @@ -155,6 +347,10 @@ export class FormsTimeline { .reverse(); }); + iso(timestamp: number) { + return new Date(timestamp).toISOString(); + } + time(timestamp: number) { return new Date(timestamp).toLocaleTimeString(); } diff --git a/app/src/pages/forms-types.ts b/app/src/pages/forms-types.ts index 386354c..7d7a174 100644 --- a/app/src/pages/forms-types.ts +++ b/app/src/pages/forms-types.ts @@ -5,6 +5,7 @@ export type FieldStatus = 'VALID' | 'INVALID' | 'PENDING' | 'DISABLED'; export interface FormFieldError { kind: string; message: string; + params?: Record; source?: string; from?: string; } @@ -39,6 +40,8 @@ export interface FormFieldNode { changed?: boolean; redacted?: string; pendingSince?: number; + name?: string; + metadata?: unknown[]; dom?: { errorShown?: boolean; drift?: string | true; labelled?: boolean }; } @@ -51,9 +54,14 @@ export interface CollectedForm { submitted?: boolean; submit?: { hasAction: boolean; willRun: boolean; submitting: boolean }; submitDom?: { reasons: string[] }; + errorSummary?: { path: string; kind: string; message: string }[]; root: FormFieldNode; } +export function pageOf(formId: string): string { + return formId.split('@')[1] ?? ''; +} + export interface FormEvent { formId: string; path: string; @@ -148,65 +156,207 @@ export function plain(text: string | null): string { export const FORMS_STYLES = ` .muted { - color: #a1a1aa; + color: var(--text-2); + } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + code { + font-family: var(--font-mono); + font-size: 12.5px; } .small { - padding: 4px 10px; - border: 1px solid #52525b; - border-radius: 6px; - background: #18181b; - color: #e4e4e7; - font-size: 12px; + display: inline-block; + max-width: 100%; + height: 34px; + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-size: 13px; + font-weight: 500; + line-height: 32px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; cursor: pointer; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease), + color 150ms var(--ease), + box-shadow 150ms var(--ease); } .small:hover { - border-color: var(--accent); + background: var(--surface-3); + border-color: color-mix(in srgb, var(--text-3) 55%, var(--border-strong)); } - .small:focus-visible, - .field-input:focus-visible { + .small:active { + background: var(--border-strong); + } + .small:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + .small:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .small.primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-ink); + font-weight: 600; + } + .small.primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + } + .small.danger { + border-color: color-mix(in srgb, var(--danger) 40%, transparent); + color: var(--danger); + } + .small.danger:hover { + background: color-mix(in srgb, var(--danger) 12%, transparent); + border-color: color-mix(in srgb, var(--danger) 55%, transparent); + } + .small.armed { + box-shadow: 0 0 0 3px var(--accent-soft); + } + .small.danger.armed { + background: var(--danger); + border-color: var(--danger); + color: #1f0707; + font-weight: 600; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent); + } .field-input { - padding: 4px 8px; - background: #18181b; - border: 1px solid #52525b; - border-radius: 6px; - color: #e4e4e7; + min-width: 0; + height: 34px; + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font-family: var(--font-mono); font-size: 13px; + transition: + border-color 150ms var(--ease), + box-shadow 150ms var(--ease); + } + .field-input::placeholder { + color: var(--text-3); + font-family: var(--font-sans); + } + .field-input:hover { + border-color: color-mix(in srgb, var(--text-3) 55%, var(--border-strong)); + } + .field-input:focus, + .field-input:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); } .explain { margin: 0; - padding: 10px; - border: 1px solid #27272a; - border-radius: 6px; - color: #e4e4e7; - font-size: 12px; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; - font-family: ui-monospace, monospace; + tab-size: 2; + } + .explain[aria-busy='true'] { + color: var(--text-3); + } + .section-label { + margin: 0; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; } .tag { display: inline-block; - margin: 0 4px 2px 0; - padding: 0 5px; - border: 1px solid #3f3f46; - border-radius: 4px; - color: #d4d4d8; + margin: 0; + padding: 1px 8px; + border: 1px solid var(--border); + border-radius: 99px; + background: var(--surface-2); + color: var(--text-2); font-size: 11px; + font-weight: 500; + line-height: 18px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + .tag[data-tone='ok'] { + border-color: color-mix(in srgb, var(--ok) 30%, transparent); + background: color-mix(in srgb, var(--ok) 12%, transparent); + color: var(--ok); } .tag[data-tone='warn'] { - border-color: #a16207; - color: #fef08a; + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + background: color-mix(in srgb, var(--warn) 12%, transparent); + color: var(--warn); } .tag[data-tone='bad'] { - border-color: #b91c1c; - color: #fecaca; + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); } .status { - min-height: 1.2em; margin: 0; - color: #d4d4d8; + color: var(--text-2); + font-size: 12.5px; + line-height: 1.5; + overflow-wrap: anywhere; + } + .empty-state { + display: grid; + justify-items: center; + gap: 6px; + margin: 0; + padding: 40px 24px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text-2); font-size: 13px; + line-height: 1.5; + text-align: center; + } + .empty-state p { + max-width: 52ch; + margin: 0; + } + .empty-state .empty-title { + color: var(--text-strong); + font-size: 14px; + font-weight: 600; + } + .spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: forms-spin 0.8s linear infinite; + } + @keyframes forms-spin { + to { + transform: rotate(360deg); + } } `; diff --git a/app/src/pages/live-route.ts b/app/src/pages/live-route.ts index 4bdddc8..de3ff76 100644 --- a/app/src/pages/live-route.ts +++ b/app/src/pages/live-route.ts @@ -9,12 +9,14 @@ import { signal, } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; +import { hostPageId } from '../page-id'; import { RouteCurrent } from './route-current'; import { RouteLint } from './route-lint'; import { RouteSetup } from './route-setup'; import { RouteTimeline } from './route-timeline'; import { RouteTree } from './route-tree'; -import type { RouterPage } from './router-types'; +import type { RouterPage, SourceRoute } from './router-types'; +import { Select } from '../ui/select'; const TABS = [ { id: 'current', label: 'Current' }, @@ -28,25 +30,35 @@ type TabId = (typeof TABS)[number]['id']; @Component({ selector: 'app-live-route', - imports: [RouteCurrent, RouteLint, RouteSetup, RouteTimeline, RouteTree], + imports: [RouteCurrent, RouteLint, RouteSetup, RouteTimeline, RouteTree, Select], template: ` +
    +

    Live router

    + @if (page(); as current) { + @if (pages().length > 1) { +
    + Page + +
    + } + } +
    @if (failed()) { -

    Could not load the live router state.

    + } @else if (loading()) { -

    Loading the live router state…

    +

    Loading the live router state…

    } @else if (page(); as current) { - @if (pages().length > 1) { - - }
    @for (tab of tabs; track tab.id) { } @@ -81,7 +92,7 @@ type TabId = (typeof TABS)[number]['id']; } @case ('routes') { - + } @case ('setup') { @@ -92,76 +103,183 @@ type TabId = (typeof TABS)[number]['id']; }
    } @else { -

    No page is reporting router state yet. Open the app in a browser.

    +
    +

    No page is reporting router state yet.

    +

    + Open the app in a browser. This view updates as soon as a page connects. +

    +
    } `, styles: ` :host { display: grid; - gap: 12px; - margin-bottom: 28px; + gap: 16px; + min-width: 0; + margin-bottom: 32px; + } + .section-head { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + align-items: center; + justify-content: space-between; + min-height: 34px; + } + h2 { + margin: 0; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; } .muted { - color: #a1a1aa; + color: var(--text-2); font-size: 13px; + line-height: 1.5; + } + .empty { + display: grid; + gap: 6px; + justify-items: center; + margin: 0; + 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: 480px; + } + .empty-title { + color: var(--text-strong); + font-size: 14px; + font-weight: 600; + } + .retry { + height: 34px; + margin-top: 8px; + padding: 0 12px; + border: 1px solid var(--border-strong); + 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); + } + .retry:hover { + background: var(--surface-3); + } + .retry:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .page-pick { display: flex; - flex-wrap: wrap; - gap: 8px; + flex: 0 1 420px; + gap: 10px; align-items: center; - font-size: 13px; - color: #d4d4d8; - } - select { - max-width: 100%; min-width: 0; - padding: 4px 8px; - background: #18181b; - border: 1px solid #52525b; - border-radius: 6px; - color: #e4e4e7; + } + .page-label { + flex: none; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + } + .page-pick app-select { + flex: 1 1 auto; + width: 100%; } .tabs { display: flex; - flex-wrap: wrap; - gap: 4px; - border-bottom: 1px solid #27272a; + justify-self: start; + max-width: 100%; + gap: 2px; + padding: 3px; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); } [role='tab'] { - padding: 6px 12px; - background: none; + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 12px; border: none; - border-bottom: 2px solid transparent; - color: #a1a1aa; - cursor: pointer; + border-radius: 7px; + background: transparent; + color: var(--text-2); + font: inherit; font-size: 13px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; + transition: + background-color 0.15s var(--ease), + color 0.15s var(--ease), + box-shadow 0.15s var(--ease); + } + [role='tab']:hover { + background: var(--surface-2); + color: var(--text); } [role='tab'][aria-selected='true'] { - color: #e4e4e7; - border-bottom-color: var(--accent); + background: var(--surface-3); + color: var(--text-strong); + box-shadow: inset 0 0 0 1px var(--border-strong); } - [role='tab']:focus-visible, - select:focus-visible { + [role='tab']:focus-visible { outline: 2px solid var(--accent); - outline-offset: 2px; + outline-offset: -2px; + } + .panel { + min-width: 0; + animation: enter 0.35s var(--ease) both; } .count { - margin-left: 4px; - padding: 0 5px; - border-radius: 8px; - background: #7f1d1d; - color: #fecaca; + min-width: 18px; + padding: 0 6px; + border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); + border-radius: 99px; + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); font-size: 11px; + font-weight: 600; + line-height: 16px; + text-align: center; + font-variant-numeric: tabular-nums; + } + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } `, }) export class LiveRoute { rpc = input(null); + sources = input([]); readonly tabs = TABS; readonly selected = signal('current'); readonly pages = signal([]); + private readonly hostPageId = hostPageId(); readonly loading = signal(true); readonly failed = signal(false); readonly pageId = linkedSignal({ @@ -169,7 +287,10 @@ export class LiveRoute { computation: (pages, previous) => previous?.value && pages.some((p) => p.pageId === previous.value) ? previous.value - : (pages.find((p) => p.snapshot)?.pageId ?? pages[0]?.pageId ?? null), + : (pages.find((p) => p.pageId === this.hostPageId && p.snapshot)?.pageId ?? + pages.find((p) => p.snapshot)?.pageId ?? + pages[0]?.pageId ?? + null), }); private unsubscribe: (() => void) | null = null; @@ -214,10 +335,19 @@ export class LiveRoute { } } - pickPage(event: Event) { - this.pageId.set((event.target as HTMLSelectElement).value); + retry() { + const client = this.rpc(); + if (client) this.load(client); } + readonly pageOptions = computed(() => + this.pages().map((p) => ({ + value: p.pageId, + label: p.snapshot?.url ?? p.pageId, + hint: p.pageId, + })), + ); + onKey(event: KeyboardEvent) { const order = this.tabs.map((tab) => tab.id); const index = order.indexOf(this.selected()); diff --git a/app/src/pages/network-inspector.ts b/app/src/pages/network-inspector.ts index f874974..7e15be6 100644 --- a/app/src/pages/network-inspector.ts +++ b/app/src/pages/network-inspector.ts @@ -1,6 +1,17 @@ -import { Component, DestroyRef, computed, effect, inject, input, signal } from '@angular/core'; +import { + Component, + DestroyRef, + computed, + effect, + inject, + input, + linkedSignal, + signal, +} from '@angular/core'; import { JsonPipe } from '@angular/common'; import type { DevframeRpcClient } from 'devframe/client'; +import { hostPageId } from '../page-id'; +import { Select, type SelectOption } from '../ui/select'; type HttpSide = 'client' | 'server'; @@ -34,27 +45,39 @@ interface HttpCall { interface PayloadEntry { key: string; http?: { url?: string; status?: number; statusText?: string; responseType?: string }; + source?: 'http' | 'analog' | 'hydration'; size: number; value: unknown; } +interface HydrationMismatch { + component: string; + expected?: string; + actual?: string; +} + interface HydrationStats { enabled: boolean; hydratedComponents?: number; hydratedNodes?: number; componentsSkippedHydration?: number; deferBlocksWithIncrementalHydration?: number; + nodes?: { hydrated: number; skipped: number; mismatched: number }; + mismatches?: HydrationMismatch[]; skipHydrationHosts: string[]; warnings: string[]; + warningsCaptured?: boolean; } interface HttpPage { pageId: string; url: string; + initialUrl?: string; title: string; payload: { found: boolean; size: number; entries: PayloadEntry[]; error?: string }; hydration: HydrationStats | null; calls: HttpCall[]; + firstSeenAt?: number; reportedAt: number; } @@ -92,32 +115,42 @@ function call(client: DevframeRpcClient | null, name: string, arg?: unknown): Pr @Component({ selector: 'app-network-inspector', - imports: [JsonPipe], + imports: [JsonPipe, Select], template: ` @if (loading()) { -

    Loading…

    +
    + + Loading… +
    } @else if (failed()) { - + }
    - +
    + Page + +

    {{ message() }}

    -

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

    +

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

    @if (timeline().length) {
    @@ -126,8 +159,9 @@ function call(client: DevframeRpcClient | null, name: string, arg?: unknown): Pr + - + @@ -142,23 +176,39 @@ function call(client: DevframeRpcClient | null, name: string, arg?: unknown): Pr entry.side === 'server' ? 'SSR' : 'Client' }} - + - + - - + } @@ -166,54 +216,78 @@ function call(client: DevframeRpcClient | null, name: string, arg?: unknown): Pr
    Side Method URLPage StatusTimeTime Notes
    {{ entry.method }}{{ entry.method }} - - {{ entry.status || 'ERR' }} + + {{ entry.pageUrl ?? '' }} + + {{ entry.status || 'ERR' }} {{ entry.durationMs }} ms - @if (entry.cacheHit) { - transfer cache - } - @if (entry.faulted) { - faulted - } + {{ entry.durationMs }} ms +
    + @if (entry.cacheHit) { + transfer cache + } + @if (entry.faulted) { + faulted + } +
    } @else { -

    - No requests yet. Add withNgDevtools() to provideHttpClient. -

    +
    +

    No requests yet.

    +

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

    +
    } @if (selectedCall(); as detail) { -

    Response preview

    -
    {{ detail.error ?? detail.preview ?? '(no body)' }}
    +
    +
    +

    Response preview

    + +
    +

    + {{ detail.method }} + {{ detail.status || 'ERR' }} + {{ detail.url }} +

    +
    {{
    +              detail.error ?? detail.preview ?? '(no body)'
    +            }}
    +
    }
    -

    Fault injection

    +

    Fault injection

    - - +
    + Method + +
    +
    + Apply on + +
    -

    {{ bodyError() }}

    - +

    {{ 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. +

    + } +
    + +
    +

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

      @for (rule of rules(); track rule.id) { -
    • +
    • - - {{ rule.target }} · {{ rule.status ?? 200 }} - @if (rule.delayMs) { - · {{ rule.delayMs }} ms - } - @if (rule.body) { - · mock body - } - + + {{ targetLabel(rule.target) }} + @if (rule.status === undefined) { + passthrough + } @else { + {{ rule.status }} + } + @if (rule.delayMs) { + {{ rule.delayMs }} ms + } + @if (rule.body) { + mock body + } + + @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.
    • +
    • No rules. SSR rules apply on the next page load.
    • }
    -

    Hydration

    +

    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' }}
    -
    Incremental defer blocks
    -
    {{ h.deferBlocksWithIncrementalHydration ?? 'n/a' }}
    +
    +
    +
    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' }}
    +
    +
    +
    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.component + '>' }} + @if (m.expected) { + Expected + {{ m.expected }} + } + @if (m.actual) { + Actual {{ m.actual }} + } +
    • + } +
    + } @if (h.skipHydrationHosts.length) { -

    ngSkipHydration hosts

    +

    ngSkipHydration hosts

      @for (host of h.skipHydrationHosts; track $index) {
    • @@ -308,262 +464,802 @@ function call(client: DevframeRpcClient | null, name: string, arg?: unknown): Pr }
    } -

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

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

    No hydration warnings.

    + @if (h.warningsCaptured === false) { +

    Warnings

    +

    + Not captured: add provideNgDevtoolsHttp() 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.

    +
    +

    No hydration data for this page.

    +

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

    +
    }
    -

    TransferState payload

    +

    TransferState payload

    @if (selected()?.payload; as payload) { @if (!payload.found) { -

    No TransferState script: this page was not server rendered.

    +
    +

    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.error) { } - @for (entry of payload.entries; track entry.key) { -
    - - @if (entry.http; as http) { - HTTP {{ http.status }} - {{ http.url ?? entry.key }} - } @else { - {{ entry.key }} - } - {{ entry.size }} B - -
    {{ entry.value | json }}
    -
    - } +
    + @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 + +
    {{ entry.value | json }}
    +
    + } +
    } } @else { -

    Open a page of the app to see its payload.

    +
    +

    Open a page of the app to see its payload.

    +
    }
    `, styles: ` + @use 'mixins' as m; + :host { display: block; - color: #e4e4e7; + 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.soft(var(--danger)); + } + .state.error strong { + font-weight: 600; + } + .state.error span { + color: var(--text); + } + .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; + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } .toolbar { + position: sticky; + top: 0; + z-index: 2; display: flex; flex-wrap: wrap; - align-items: end; - gap: 12px; - margin-bottom: 16px; + 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; + } + .field-group { + display: grid; + gap: 6px; + min-width: 0; + color: var(--text-2); + font-size: 12px; + font-weight: 500; + } + .field-group app-select { + width: 100%; + } + .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(auto-fit, minmax(320px, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); } .panel { - padding: 14px; - border: 1px solid #27272a; - border-radius: 10px; - background: #18181b; + @include m.panel; + @include m.enter; min-width: 0; + padding: 16px; } .wide { grid-column: 1 / -1; } - h3 { - margin: 0 0 12px; - font-size: 13px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: #d4d4d8; + h2 { + @include m.label; + margin: 0 0 16px; } - h4 { - margin: 12px 0 6px; + h3 { + margin: 16px 0 8px; + color: var(--text-strong); font-size: 12px; - color: #d4d4d8; + font-weight: 600; + } + p { + margin: 0; } label { display: grid; - gap: 4px; - color: #a1a1aa; + gap: 6px; + color: var(--text-2); font-size: 12px; + font-weight: 500; } label.inline { display: flex; align-items: center; gap: 8px; - color: #e4e4e7; + min-width: 0; + color: var(--text); + cursor: pointer; + } + .hint { + color: var(--text-3); + font-weight: 400; } input, select, textarea, button { font: inherit; - color: #e4e4e7; + color: var(--text); } input:not([type='checkbox']), select, textarea { - padding: 6px 8px; - border: 1px solid #3f3f46; - border-radius: 6px; - background: #09090b; + width: 100%; + min-width: 0; + padding: 0 10px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background-color: var(--bg); + transition: + border-color 0.2s var(--ease), + box-shadow 0.2s var(--ease); + } + input:not([type='checkbox']), + select { + height: 34px; + } + select { + padding-right: 36px; + } + textarea { + padding: 8px 10px; + font-family: var(--font-mono); + font-size: 12px; + } + input::placeholder, + textarea::placeholder { + color: var(--text-3); + } + input:not([type='checkbox']):hover, + select:hover, + textarea:hover { + border-color: color-mix(in srgb, var(--border-strong) 60%, var(--text-3)); + } + input:not([type='checkbox']):focus-visible, + select:focus-visible, + textarea:focus-visible { + @include m.field-focus; + } + textarea[aria-invalid='true'] { + border-color: color-mix(in srgb, var(--danger) 60%, transparent); + } + input[type='checkbox'] { + flex: none; + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; } button { - padding: 6px 12px; - border: 1px solid #3f3f46; - border-radius: 6px; - background: #27272a; + display: inline-flex; + align-items: center; + justify-content: center; + height: 34px; + padding: 0 14px; + border: 1px solid var(--border-strong); + 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), + color 0.2s var(--ease), + transform 0.1s var(--ease); } button:hover:not(:disabled) { - background: #3f3f46; + border-color: var(--accent-line); + background: var(--surface-3); + } + button:active:not(:disabled) { + transform: translateY(1px); + } + button[type='submit'] { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); + font-weight: 600; + } + button[type='submit']:hover:not(:disabled) { + border-color: var(--accent-hover); + background: var(--accent-hover); } button:disabled { - opacity: 0.6; + opacity: 0.5; cursor: not-allowed; } + button.ghost { + height: 28px; + padding: 0 10px; + border-color: transparent; + background: none; + color: var(--text-2); + font-size: 12px; + } + button.ghost:hover:not(:disabled) { + border-color: var(--border-strong); + background: var(--surface-2); + color: var(--text); + } button.link { + display: inline; + height: auto; padding: 0; border: none; + border-radius: 4px; background: none; - color: #93c5fd; + color: var(--text); text-align: left; - word-break: break-all; } - :focus-visible { - outline: 2px solid #93c5fd; - outline-offset: 2px; + button.link:hover:not(:disabled) { + border: none; + background: none; + color: var(--accent); } - .message { - margin: 0; - color: #a1a1aa; + button.link:active:not(:disabled) { + transform: none; + } + :focus-visible { + @include m.focus-ring; } .muted { - color: #a1a1aa; + color: var(--text-2); } .small { font-size: 12px; } .bad { - color: #fca5a5; + color: var(--danger); + } + .empty { + display: grid; + gap: 4px; + padding: 24px 16px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + text-align: center; + line-height: 1.5; } .table-wrap { overflow-x: auto; + margin: 0 -16px; + padding: 0 16px; } table { width: 100%; + min-width: 560px; border-collapse: collapse; + font-variant-numeric: tabular-nums; } th, td { - padding: 6px 8px; - border-bottom: 1px solid #27272a; + height: 36px; + padding: 6px 12px; + border-bottom: 1px solid var(--border); text-align: left; - vertical-align: top; + vertical-align: middle; + white-space: nowrap; + } + th:first-child, + td:first-child { + padding-left: 8px; } th { - color: #a1a1aa; - font-weight: 500; + @include m.label; + height: 32px; + border-bottom-color: var(--border-strong); + } + .num { + text-align: right; + } + td.num { + color: var(--text-2); + } + tbody tr { + cursor: pointer; + } + tbody td { + transition: background-color 0.15s var(--ease); + } + tbody tr:hover td { + background: var(--surface-2); + } + tr.selected td, + tr.selected:hover td { + background: var(--accent-soft); } - tr.selected td { - background: #27272a; + tr.selected td:first-child { + box-shadow: inset 2px 0 0 var(--accent); + } + .method { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-strong); } td.url { - max-width: 420px; + width: 100%; + max-width: 0; + font-family: var(--font-mono); + font-size: 12px; + } + td.url button.link { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + tr.selected td.url button.link { + color: var(--text-strong); + } + td.page-url { + max-width: 160px; + overflow: hidden; + color: var(--text-2); + font-family: var(--font-mono); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + } + .rule-note { + grid-column: 1 / -1; + margin: 0; + padding-left: 24px; + color: var(--text-2); + font-size: 12px; + } + .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); + } + .status { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-2); + } + .status.ok { + color: var(--ok); + } + .status.bad { + color: var(--danger); + } + .dot { + display: inline-block; + width: 6px; + height: 6px; + margin-right: 6px; + border-radius: 50%; + background: currentColor; + vertical-align: middle; } .notes { display: flex; - flex-wrap: wrap; gap: 4px; } .tag { - display: inline-block; - padding: 1px 6px; + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border: 1px solid var(--border-strong); border-radius: 99px; - background: #3f3f46; + background: var(--surface-3); + color: var(--text-2); font-size: 11px; + font-weight: 600; white-space: nowrap; } .tag.server { - background: #1e3a8a; - color: #dbeafe; + @include m.soft(var(--accent)); } .tag.fault { - background: #7f1d1d; - color: #fee2e2; + @include m.soft(var(--danger)); + } + .tag.on { + @include m.soft(var(--ok)); + } + .tag.off-tag { + @include m.soft(var(--warn)); } pre { - margin: 4px 0 0; - padding: 8px; + margin: 0; + padding: 10px 12px; max-height: 240px; overflow: auto; - border-radius: 6px; - background: #09090b; - color: #a1a1aa; - font: - 12px ui-monospace, - monospace; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text-2); + font: 12px/1.5 var(--font-mono); white-space: pre-wrap; - word-break: break-all; + overflow-wrap: anywhere; + } + pre + pre { + margin-top: 8px; } pre.warn { - color: #fde68a; + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + color: var(--warn); + } + pre.error { + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + color: var(--danger); + } + .preview { + @include m.enter(0.2s); + margin-top: 16px; + } + .preview-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; + } + .preview-head h3 { + margin: 0; + } + .preview-meta { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + margin-bottom: 8px; + } + .preview-url { + min-width: 0; + overflow: hidden; + color: var(--text-2); + font-family: var(--font-mono); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; } .rule-form { display: grid; - gap: 8px; + gap: 12px; } .row { display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } + @media (max-width: 420px) { + .row { + grid-template-columns: minmax(0, 1fr); + } + } + .field-error { + margin-top: -4px; + color: var(--danger); + font-size: 12px; + } + .field-error:empty { + display: none; + } + .form-actions { + display: flex; + } + .rules-heading { + margin-top: 24px; } .rules, .plain { display: grid; gap: 8px; - margin: 12px 0 0; + margin: 0; padding: 0; list-style: none; } .rules li { display: grid; - gap: 2px; - padding: 8px; - border: 1px solid #27272a; - border-radius: 6px; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 4px 12px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + transition: + border-color 0.2s var(--ease), + opacity 0.2s var(--ease); + } + .rules li:hover { + border-color: var(--border-strong); + } + .rules li.off .rule-pattern { + color: var(--text-2); + text-decoration: line-through; + } + .rules li.off .rule-method, + .rules li.off .rule-meta, + .rules li.off .rule-meta .status { + color: var(--text-3); } - dl { + .rules li.empty-rule { + display: block; + padding: 16px 12px; + border-style: dashed; + border-color: var(--border-strong); + background: none; + color: var(--text-2); + text-align: center; + } + .rule-pattern { + min-width: 0; + overflow: hidden; + color: var(--text-strong); + text-overflow: ellipsis; + white-space: nowrap; + } + .rule-method { + color: var(--accent); + font-weight: 600; + } + .rule-meta { + display: flex; + flex-wrap: wrap; + grid-column: 1 / -1; + gap: 4px 8px; + padding-left: 24px; + color: var(--text-2); + font-size: 12px; + font-variant-numeric: tabular-nums; + } + .rule-meta > span + span::before { + content: '·'; + margin-right: 8px; + color: var(--text-3); + } + .rule-meta .status { + font-size: 11px; + } + .rules button.remove { + padding: 2px 4px; + color: var(--text-2); + font-size: 12px; + } + .rules button.remove:hover:not(:disabled) { + color: var(--danger); + } + .plain li { + overflow-wrap: anywhere; + } + code { + font-family: var(--font-mono); + font-size: 12px; + } + .stats { display: grid; - grid-template-columns: max-content 1fr; - gap: 4px 12px; margin: 0; } + .stats > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 36px; + padding: 6px 0; + border-bottom: 1px solid var(--border); + } + .stats > div:last-child { + border-bottom: none; + } dt { - color: #a1a1aa; + color: var(--text-2); } dd { margin: 0; + color: var(--text-strong); + font-weight: 600; + font-variant-numeric: tabular-nums; + text-align: right; + } + .ok-note { + color: var(--text-2); + } + .summary-line { + margin-bottom: 8px; + font-variant-numeric: tabular-nums; + } + .entries { + border-top: 1px solid var(--border); } details { - border-bottom: 1px solid #27272a; - padding: 6px 0; + 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; - word-break: break-all; + 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-size { + flex: none; + color: var(--text-2); + font-size: 12px; + font-variant-numeric: tabular-nums; + } + @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; + } } `, }) @@ -575,27 +1271,50 @@ export class NetworkInspector { readonly serverCalls = signal([]); readonly pages = signal([]); readonly rules = signal([]); - readonly selectedPageId = signal(null); + private readonly hostPageId = hostPageId(); + readonly selectedPageId = linkedSignal({ + source: () => this.pages(), + computation: (pages, previous) => + previous?.value && pages.some((p) => p.pageId === previous.value) + ? previous.value + : (pages.find((p) => p.pageId === this.hostPageId)?.pageId ?? pages[0]?.pageId ?? null), + }); readonly selectedCallId = signal(null); readonly draft = signal({ ...EMPTY_DRAFT }); readonly message = signal(''); + readonly bodyPlaceholder = '{ "error": "Service unavailable" }'; private unsubscribe: (() => void) | null = null; private readonly destroyRef = inject(DestroyRef); - readonly selected = computed(() => { - const pages = this.pages(); - return pages.find((p) => p.pageId === this.selectedPageId()) ?? pages[0] ?? null; + readonly selected = computed( + () => this.pages().find((p) => p.pageId === this.selectedPageId()) ?? null, + ); + + readonly pageServerCalls = computed(() => { + const initialUrl = this.selected()?.initialUrl; + if (!initialUrl) return this.serverCalls(); + return this.serverCalls().filter((c) => !c.pageUrl || c.pageUrl === initialUrl); }); readonly timeline = computed(() => - [...this.serverCalls(), ...(this.selected()?.calls ?? [])].sort((a, b) => b.at - a.at), + [...this.pageServerCalls(), ...(this.selected()?.calls ?? [])].sort((a, b) => b.at - a.at), ); readonly selectedCall = computed( () => this.timeline().find((c) => c.id === this.selectedCallId()) ?? null, ); + readonly draftMocksOnServer = computed(() => { + const draft = this.draft(); + const status = Number(draft.status); + return ( + !!draft.status && + Number.isFinite(status) && + this.mocksOnServer({ target: draft.target, status }) + ); + }); + readonly bodyError = computed(() => { const body = this.draft().body.trim(); if (!body) return ''; @@ -637,11 +1356,52 @@ export class NetworkInspector { } } - selectPage(event: Event) { - this.selectedPageId.set((event.target as HTMLSelectElement).value || null); + statusTone(status: number | undefined): 'ok' | 'redirect' | 'bad' | 'neutral' { + if (status === undefined) return 'neutral'; + if (status === 0 || status >= 400) return 'bad'; + if (status >= 300) return 'redirect'; + if (status >= 200) return 'ok'; + return 'neutral'; + } + + mocksOnServer(rule: Pick): boolean { + return rule.target !== 'client' && rule.status !== undefined && rule.status < 400; + } + + payloadLabel(entry: PayloadEntry): string | null { + if (entry.source === 'hydration') return 'hydration annotations'; + if (entry.source === 'analog') return 'Analog'; + return null; + } + + targetLabel(target: HttpRule['target']): string { + return target === 'both' ? 'SSR + client' : target === 'server' ? 'SSR only' : 'Client only'; + } + + readonly pageOptions = computed(() => + this.pages().map((page) => ({ value: page.pageId, label: page.title || page.url })), + ); + + readonly methodOptions: SelectOption[] = [ + { value: '', label: 'Any' }, + ...['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((m) => ({ value: m, label: m })), + ]; + + readonly targetOptions: SelectOption[] = [ + { value: 'both', label: 'SSR + client' }, + { value: 'server', label: 'SSR only' }, + { value: 'client', label: 'Client only' }, + ]; + + selectPage(pageId: string | null) { + if (pageId) this.selectedPageId.set(pageId); this.selectedCallId.set(null); } + setDraft(key: K, value: RuleDraft[K]) { + this.draft.update((draft) => ({ ...draft, [key]: value })); + } + patch(key: keyof RuleDraft, event: Event) { const value = (event.target as HTMLInputElement).value; this.draft.update((draft) => ({ ...draft, [key]: value })); diff --git a/app/src/pages/pipes-inspector.ts b/app/src/pages/pipes-inspector.ts index 489938e..83a2dbf 100644 --- a/app/src/pages/pipes-inspector.ts +++ b/app/src/pages/pipes-inspector.ts @@ -1,5 +1,15 @@ -import { Component, DestroyRef, effect, inject, input, signal } from '@angular/core'; +import { + Component, + DestroyRef, + ElementRef, + computed, + effect, + inject, + input, + signal, +} from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; +import { Select, type SelectOption } from '../ui/select'; interface UsageSite { file: string; @@ -18,9 +28,15 @@ interface PipeInfo { usages?: UsageSite[]; } +interface PipeTarget { + pageId: string; + id: string; +} + interface PipeComponentUsage { name: string; count: number; + targets?: PipeTarget[]; } interface PipeInstanceCall { @@ -57,6 +73,7 @@ interface AsyncUsageInfo { hasSource: boolean; latestValue?: string; duplicate: boolean; + target?: PipeTarget; } interface PipeLintFinding { @@ -75,467 +92,971 @@ interface PipesSnapshot { instrumented: string[]; } +type PipeKind = 'all' | 'custom' | 'builtin' | 'impure' | 'live'; + +const KIND_OPTIONS: readonly SelectOption[] = [ + { value: 'all', label: 'All pipes' }, + { value: 'custom', label: 'Custom' }, + { value: 'builtin', label: 'Built-in' }, + { value: 'impure', label: 'Impure' }, + { value: 'live', label: 'On the page' }, +]; + @Component({ selector: 'app-pipes-inspector', + imports: [Select], template: ` +

    + Pipes declared in your source and the built-in pipes your templates use. 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 {{ pipes().length }} +
    + + +
    + @if (instrumenting()) { -

    - Recording live call counts, instance counts and last input/output. This patches pipe - prototypes in the inspected page, so turn it off when you're done. +

    + + + Recording on {{ instrumentedPages().length }} page(s). + 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()) { -

    Scanning pipes…

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

    No pipes found.

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

    Scanning pipes…

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

    No pipes found

    +

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

    +
    } @else { -
      - @for (p of filtered(); track p.file + 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 (p.builtin) { - built-in +

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

    {{ p.name }}

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

    Declaration

    +
    +
    Class
    +
    {{ p.className }}
    +
    Source
    +
    {{ p.builtin ? '@angular/common' : 'This project' }}
    + @if (!p.builtin) { +
    File
    +
    {{ p.file }}:{{ p.line }}
    } +
    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.builtin && p.usages?.length) { +
    +

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

    +
      + @for (u of p.usages; track u.file + ':' + u.line) { +
    • {{ u.file }}:{{ u.line }}
    • + } +
    -
    - {{ p.file }}:{{ p.line }} - @if (p.builtin && (p.usageCount ?? 0) > 1) { - +{{ (p.usageCount ?? 1) - 1 }} more + } + +
    +

    Live on the page

    + @if (liveFor(p.name); as live) { + @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. +

    } -
    - - @if (isSelected(p)) { -
    -
    Class
    -
    {{ p.className }}
    -
    Source
    -
    {{ p.builtin ? '@angular/common' : 'This project' }}
    - @if (!p.builtin) { -
    File
    -
    {{ p.file }}:{{ p.line }}
    - } -
    Standalone
    -
    {{ p.isStandalone ? 'Yes' : 'No' }}
    -
    Pure
    -
    {{ p.isPure ? 'Yes' : 'No' }}
    - @if (p.builtin && p.usages?.length) { -
    Used in ({{ p.usageCount }})
    -
    -
      - @for (u of p.usages; track u.file + ':' + u.line) { -
    • {{ u.file }}:{{ u.line }}
    • - } -
    -
    - } -
    - @if (liveFor(p.name); as live) { -
    -
    Live
    - @if (live.stale) { -

    - experimental - Fed an argument that changed contents without changing reference — this pure - pipe may be showing a stale value. -

    - } -
    -
    Instances
    -
    {{ live.instanceCount }}
    -
    Used by
    +
    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 }}
    +
    Last input
    +
    {{ describe(call.lastArgs) }}
    +
    Last output
    +
    {{ describe(call.lastResult) }}
    + @if (call.instances?.length) { +
    Per instance
    -
      - @for (c of live.components; track c.name) { -
    • {{ c.name }} ({{ c.count }})
    • +
        + @for (i of call.instances; track $index) { +
      • + {{ describe(i.lastArgs) }} → {{ describe(i.lastResult) }} + {{ i.callCount }}× +
      • }
    - @if (live.call; as call) { -
    Calls
    -
    {{ call.callCount }}
    -
    Last input
    -
    {{ describe(call.lastArgs) }}
    -
    Last output
    -
    {{ describe(call.lastResult) }}
    - @if (call.instances?.length) { -
    Per instance
    -
    -
      - @for (i of call.instances; track $index) { -
    • - {{ describe(i.lastArgs) }} → {{ describe(i.lastResult) }} ({{ - i.callCount - }}) -
    • - } -
    -
    - } - @if (call.lastCaller) { -
    Last caller
    -
    {{ call.lastCaller }}
    - } - } @else if (instrumenting()) { -
    Calls
    -
    None recorded yet.
    - } -
    -
    - } @else if (instrumenting()) { -

    Not seen on the page yet.

    + } + @if (call.lastCaller) { +
    Last caller
    +
    {{ call.lastCaller }}
    + } + } @else if (instrumenting()) { +
    Calls
    +
    None recorded yet.
    + } + + @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 pipes ({{ async().length }})

    -
      +
      +

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

      +

      + Each | async subscribes on its own. Two on the same source mean + the work runs twice. +

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

    Lint

    +
    +

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

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

    Checking…

    +

    Checking…

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

    No problems found.

    +

    No problems found.

    } @else { -
      +
        @for (f of lint(); track $index) {
      • - {{ f.severity }} - {{ f.rule }} - on {{ f.pipe }} at {{ f.file }}:{{ f.line }} -
        {{ f.message }}
        -
        Fix: {{ f.fix }}
        +
        + {{ f.severity }} + {{ f.rule }} + {{ f.pipe }} · {{ f.file }}:{{ f.line }} +
        +

        {{ 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); + } + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } + .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; - gap: 8px; - margin-bottom: 16px; + 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); } - input { - flex: 1; - padding: 8px 12px; - background: #18181b; - border: 1px solid #27272a; - border-radius: 6px; - color: #e4e4e7; - font-size: 14px; - outline: none; + .search { + position: relative; + flex: 1 1 220px; + min-width: 0; } - input:focus { - border-color: var(--accent); + .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; } - button { - padding: 8px 16px; - background: #3f3f46; - border: none; - border-radius: 6px; - color: #e4e4e7; - cursor: pointer; + .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(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); font-size: 13px; + transition: + border-color 150ms var(--ease), + box-shadow 150ms var(--ease); } - button:hover { - background: #52525b; + input[type='search']::placeholder { + color: var(--text-3); } - button.instrument.on { - background: #7c2d12; - color: #fdba74; + input[type='search']:focus-visible { + @include m.field-focus; } - .muted { - color: #71717a; - font-size: 14px; + .toolbar app-select { + width: 150px; } - .instrument-hint { - margin: -8px 0 16px; + .total { + flex: none; + color: var(--text-2); + font-size: 12px; + white-space: nowrap; + font-variant-numeric: tabular-nums; } - .live-section { - padding: 12px 16px 4px; - border-top: 1px solid #27272a; + .actions { + display: flex; + gap: 8px; + margin-left: auto; } - .live-heading { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #71717a; - margin-bottom: 8px; + button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: var(--control-h); + padding: 0 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-weight: 500; + white-space: nowrap; + cursor: pointer; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease); } - .live-hint { - padding: 0 16px 12px; - margin: 0; + 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; + } + .rec-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-3); } - .stale-warning { + .record.on { + @include m.soft(var(--accent)); + } + .record.on .rec-dot { + background: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + } + .notice { display: flex; - align-items: baseline; + align-items: flex-start; gap: 8px; - font-size: 12px; - color: #fdba74; - background: #431407; - border-radius: 6px; - padding: 8px 10px; 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); } - .mono { - font-family: monospace; - font-size: 12px; - word-break: break-all; + .notice strong { + color: var(--text-strong); + font-weight: 600; } - .pipe-list { - list-style: none; - padding: 0; - display: flex; - flex-direction: column; - gap: 8px; + .notice svg { + flex: none; + width: 16px; + height: 16px; + margin-top: 2px; + fill: none; + stroke: var(--accent); + stroke-width: 2; + stroke-linecap: round; } - .pipe-item { - background: #18181b; - border: 1px solid #27272a; - border-radius: 8px; - padding: 0; - transition: border-color 0.15s; + .layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + align-items: start; } - .pipe-item:has(.pipe-toggle:hover) { - border-color: var(--accent); + @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; + } } - .pipe-item.expanded { - border-color: var(--accent); + .list-wrap { + min-width: 0; } - .pipe-toggle { - display: block; + .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%; - padding: 12px 16px; + height: auto; + padding: 8px 10px; + border: 0; + border-radius: var(--radius-sm); background: none; - border: none; - color: inherit; text-align: left; - cursor: pointer; - font: inherit; + 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); } - .name-row { + .row.selected { + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent); + } + .row-main { display: flex; - align-items: center; + align-items: baseline; gap: 8px; + min-width: 0; } .name { - font-family: monospace; - font-size: 15px; - color: var(--accent); + flex: none; + color: var(--text-strong); + font-size: 13px; font-weight: 600; } - .badge { + .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; - padding: 2px 8px; - border-radius: 4px; - background: #14532d; - color: #4ade80; + overflow-wrap: anywhere; + } + .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; - letter-spacing: 0.05em; + white-space: nowrap; } - .badge.impure { - background: #7c2d12; - color: #fdba74; + .chip.impure, + .chip.warn { + @include m.soft(var(--warn)); } - .badge.module { - background: #3f3f46; - color: #a1a1aa; + .chip.live { + @include m.soft(var(--ok)); } - .badge.builtin { - background: #1e3a8a; - color: #93c5fd; + .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; } - .file { - font-size: 12px; - color: #71717a; - margin-top: 2px; + .detail { + min-width: 0; + @include m.panel; + @include m.enter; } - .usage-count { - margin-left: 6px; - color: #52525b; + .detail-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); } - .usage-list { - list-style: none; + .detail-head h2 { + flex: 1 1 auto; + min-width: 0; margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 2px; + color: var(--text-strong); + font-size: 15px; + font-weight: 600; + overflow-wrap: anywhere; } - .inline-detail { - padding: 0 16px 12px; - border-top: 1px solid #27272a; - margin-top: 0; - padding-top: 12px; + .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: auto 1fr; - gap: 4px 12px; - font-size: 13px; + grid-template-columns: max-content minmax(0, 1fr); + gap: 6px 14px; + margin: 0; } dt { - color: #71717a; + color: var(--text-3); } dd { - color: #e4e4e7; + min-width: 0; + margin: 0; + color: var(--text); } - .async-panel { - margin-top: 20px; + .value { + font-size: 12px; + overflow-wrap: anywhere; } - .async-heading { - font-size: 13px; - font-weight: 600; - color: #a1a1aa; - margin: 0 0 8px; + .muted { + color: var(--text-2); } - .async-list { + .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); + } + .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(--border-strong); + 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-direction: column; + 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; } - .async-item { - background: #18181b; - border: 1px solid #27272a; - border-radius: 8px; - padding: 10px 16px; + .empty-line.ok { + color: var(--ok); } - .async-row { + .section { + margin-top: 20px; + @include m.enter; + } + .section h2 { + @include m.label; display: flex; align-items: center; gap: 8px; + margin: 0 0 8px; } - .component { - font-family: monospace; + .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.soft(var(--warn)); + } + .severity[data-tone='error'] { + @include m.soft(var(--danger)); + } + .rule { color: var(--accent); + font-size: 12px; } - .async-value { - margin-top: 4px; - color: #a1a1aa; + .where { + color: var(--text-3); + font-size: 11px; + overflow-wrap: anywhere; } - .lint-panel { - margin-top: 20px; + .finding-message { + margin: 6px 0 0; + color: var(--text); + line-height: 1.5; } - .findings { - list-style: none; - padding: 0; + .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; - font-size: 13px; + padding: 40px 16px; + text-align: center; + background: var(--surface); + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + @include m.enter; } - .finding { - background: #18181b; - border: 1px solid #27272a; - border-radius: 8px; - padding: 10px 16px; + .state.compact { + padding: 28px 16px; + border: 0; + background: none; } - .finding-rule { - color: #c4b5fd; + .list-wrap .state.compact { + @include m.panel; } - .finding-meta, - .finding-message, - .finding-fix { - display: block; - margin-top: 0.5rem; + .state-title { + margin: 0; + color: var(--text-strong); + font-size: 14px; + font-weight: 600; } - .tag { - display: inline-block; - margin: 0 4px 2px 0; - padding: 0 5px; - border: 1px solid #3f3f46; - border-radius: 4px; - color: #d4d4d8; - font-size: 11px; + .state-hint { + max-width: 440px; + margin: 0; + color: var(--text-2); + line-height: 1.5; + } + .state button { + margin-top: 8px; } - .tag[data-tone='warn'] { - border-color: #a16207; - color: #fef08a; + .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; } - .tag[data-tone='bad'] { - border-color: #b91c1c; - color: #fca5a5; + @keyframes spin { + to { + transform: rotate(360deg); + } + } + @media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } } `, }) export class PipesInspector { private readonly destroyRef = inject(DestroyRef); + private readonly host = inject>(ElementRef); rpc = input(null); + protected readonly kindOptions = KIND_OPTIONS; + pipes = signal([]); filter = signal(''); + kind = signal('all'); loading = signal(false); + loadFailed = signal(false); selected = signal(null); - filtered = signal([]); - live = signal([]); async = signal([]); lint = signal(null); @@ -543,24 +1064,29 @@ export class PipesInspector { instrumentedPages = signal([]); instrumenting = signal(false); - private unsubscribe?: () => void; + private readonly liveByName = computed(() => new Map(this.live().map((p) => [p.name, p]))); - constructor() { - effect(() => { - const q = this.filter().toLowerCase(); - const all = this.pipes(); - this.filtered.set( - q - ? all.filter( - (p) => - p.name.toLowerCase().includes(q) || - p.className.toLowerCase().includes(q) || - p.file.toLowerCase().includes(q), - ) - : all, + filtered = computed(() => { + const q = this.filter().trim().toLowerCase(); + const kind = this.kind() ?? 'all'; + const live = this.liveByName(); + return this.pipes().filter((p) => { + if (kind === 'custom' && p.builtin) return false; + if (kind === 'builtin' && !p.builtin) return false; + if (kind === 'impure' && p.isPure) return false; + if (kind === 'live' && !live.has(p.name)) return false; + return ( + !q || + p.name.toLowerCase().includes(q) || + p.className.toLowerCase().includes(q) || + p.file.toLowerCase().includes(q) ); }); + }); + private unsubscribe?: () => void; + + constructor() { effect(() => { const client = this.rpc(); if (client) { @@ -569,13 +1095,17 @@ export class PipesInspector { } }); - this.destroyRef.onDestroy(() => this.unsubscribe?.()); + this.destroyRef.onDestroy(() => { + this.unsubscribe?.(); + 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('ng-devtools'); const pipes = (await my.rpc.call('get-pipes')) as PipeInfo[]; @@ -586,7 +1116,7 @@ export class PipesInspector { this.selected.set(refreshed ?? null); } } catch { - // RPC not available + this.loadFailed.set(true); } finally { this.loading.set(false); } @@ -638,7 +1168,7 @@ export class PipesInspector { } liveFor(name: string): LivePipeInfo | undefined { - return this.live().find((p) => p.name === name); + return this.liveByName().get(name); } describe(value: unknown): string { @@ -660,4 +1190,39 @@ export class PipesInspector { select(pipe: PipeInfo) { this.selected.set(this.isSelected(pipe) ? null : pipe); } + + clearFilters() { + this.filter.set(''); + this.kind.set('all'); + } + + highlightPipe(live: LivePipeInfo | undefined) { + this.highlight(live?.components.find((c) => c.targets?.length)?.targets?.[0]); + } + + highlight(target: PipeTarget | null | undefined) { + const client = this.rpc(); + if (!client) return; + void client + .scope('ng-devtools') + .rpc.call('request-page-highlight', target ?? null) + .catch(() => {}); + } + + onListKey(event: KeyboardEvent) { + const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End']; + if (!keys.includes(event.key)) return; + const rows = Array.from( + this.host.nativeElement.querySelectorAll('[data-pipe-row]'), + ); + if (!rows.length) return; + const at = rows.indexOf(document.activeElement as HTMLButtonElement); + let next = at; + if (event.key === 'ArrowDown') next = Math.min(at + 1, rows.length - 1); + else if (event.key === 'ArrowUp') next = Math.max(at - 1, 0); + else if (event.key === 'Home') next = 0; + else next = rows.length - 1; + event.preventDefault(); + rows[next]?.focus(); + } } diff --git a/app/src/pages/route-current.ts b/app/src/pages/route-current.ts index 9d2ac89..e17cbdb 100644 --- a/app/src/pages/route-current.ts +++ b/app/src/pages/route-current.ts @@ -25,6 +25,7 @@ interface OutletRow { template: ` @if (page().snapshot; as snapshot) {

    + URL {{ snapshot.url }}

    @if (snapshot.urlDrift && snapshot.browserUrl) { @@ -36,7 +37,10 @@ interface OutletRow { } @if (snapshot.pending; as pending) {
    - Navigating to {{ pending.url }} (#{{ pending.id }}) + + Navigating to {{ pending.url }} (#{{ pending.id }})
    } @@ -77,7 +81,7 @@ interface OutletRow {
    + {{ row.depth === 0 && !row.route.path ? '(root)' : '/' + row.route.path }} @if (row.route.outlet !== 'primary') { {{ row.route.outlet }} @@ -92,29 +96,38 @@ interface OutletRow { } {{ row.route.component ?? '—' }} + @if (row.route.component) { + {{ row.route.component }} + } @else { + none + } + @for (entry of entries(row.route.params); track entry[0]) { -
    +
    {{ entry[0] }}: {{ entry[1] | json }} @if (row.route.paramSources?.[entry[0]] === 'inherited') { inherited }
    } @empty { - — + none }
    @for (entry of entries(row.route.data); track entry[0]) { -
    +
    {{ entry[0] }}: {{ entry[1] | json }} @if (row.route.dataSources?.[entry[0]]; as source) { {{ source }} }
    } @empty { - — + none }
    @@ -124,8 +137,9 @@ interface OutletRow { @for (resolver of row.route.resolvers ?? []; track resolver) { resolve {{ resolver }} } - @if (!guardList(row.route).length && !row.route.resolvers) { - — + @if (!guardList(row.route).length && !row.route.resolvers?.length) { + none }
    - - - - - - - - - - - @for (route of filtered(); track $index) { - - - - - - - + @if (error()) { + + } @else if (loading() && routes().length === 0) { +

    Scanning routes…

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

    No routes match the filter.

    +

    Search by path, component, guard, title or file name.

    + +
    + } @else { +
    +

    No routes found.

    +

    + Routes are read from *.routes.ts and *routing.module.ts + files, the files they lazy load, plus Analog pages. Add one and refresh. +

    +
    } - -
    PathComponent / TargetTitleFileChildren
    /{{ route.path }} - @if (route.redirectTo !== undefined) { - ➜ {{ route.redirectTo }} - } @else { - {{ route.component ?? '—' }} - } - {{ route.title ?? '—' }}{{ route.file }}{{ route.hasChildren ? 'Yes' : '—' }}
    - } + } @else { +

    + {{ filtered().length }} of {{ routes().length }} route entries, + {{ navigable() }} navigable +

    +
    + + + + + + + + + + + + @for (route of filtered(); track $index) { + + + + + + + + } + +
    PathComponent / TargetGuards and resolversTitleDeclared in
    + {{ route.fullPath }} + @if (route.kind !== 'page') { + {{ route.kind }} + } + + @if (route.redirectTo !== undefined) { + redirects to + {{ route.redirectTo }} + } @else if (route.component) { + {{ route.component }} + } @else { + none + } + + @for (guard of checks(route); track $index) { + {{ guard }} + } @empty { + none + } + + @if (route.title) { + {{ route.title }} + } @else { + none + } + {{ location(route) }}
    +
    + } +
    + `, styles: ` - .config-heading { - margin: 0 0 8px; - font-size: 15px; - color: #e4e4e7; + ${SHARED_STYLES} + :host { + display: block; + min-width: 0; } - .toolbar { - display: flex; - gap: 8px; - margin-bottom: 16px; - } - input { - flex: 1; - padding: 8px 12px; - background: #18181b; - border: 1px solid #27272a; - border-radius: 6px; - color: #e4e4e7; - font-size: 14px; - outline: none; + .config { + display: grid; + gap: 12px; + min-width: 0; } - input:focus { - border-color: var(--accent); + .section-head { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + align-items: baseline; } - button { - padding: 8px 16px; - background: #3f3f46; - border: none; - border-radius: 6px; - color: #e4e4e7; - cursor: pointer; - font-size: 13px; + h2 { + margin: 0; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; } - button:hover { - background: #52525b; + .hint, + .count { + margin: 0; + font-size: 12px; + font-variant-numeric: tabular-nums; } - .muted { - color: #a1a1aa; - font-size: 14px; + .count { + margin-bottom: -4px; + padding: 0 4px; } - table { - width: 100%; - border-collapse: collapse; - font-size: 14px; + .config-body { + display: grid; + gap: 12px; + min-width: 0; } - thead { - position: sticky; - top: 0; + .config-body[hidden] { + display: none; } - th { - text-align: left; - padding: 8px 12px; - background: #18181b; - color: #a1a1aa; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.05em; - border-bottom: 1px solid #27272a; + .toggle { + margin-left: auto; } - td { - padding: 10px 12px; - border-bottom: 1px solid #1e1e22; + .toolbar { + display: flex; + gap: 8px; + align-items: center; } - tr:hover td { - background: #18181b; + .toolbar .field { + flex: 1; + min-width: 0; } - .path { - font-family: monospace; - color: var(--accent); - font-weight: 500; + .component { + color: var(--text-strong); } .redirect { - font-family: monospace; - color: #38bdf8; + color: var(--text-2); + font-family: var(--font-mono); + font-size: 12.5px; + overflow-wrap: anywhere; } .file { + min-width: 160px; + color: var(--text-2); + font-family: var(--font-mono); font-size: 12px; - color: #a1a1aa; + overflow-wrap: anywhere; } `, }) export class RouteInspector { rpc = input(null); - routes = signal([]); + routes = signal([]); filter = signal(''); loading = signal(false); + error = signal(false); + + private readonly live = viewChild(LiveRoute); + readonly liveHasConfig = computed(() => !!this.live()?.page()?.config); + readonly expanded = linkedSignal(() => !this.liveHasConfig()); + + readonly navigable = computed(() => this.filtered().filter((r) => r.kind === 'page').length); filtered = computed(() => { const q = this.filter().toLowerCase().trim(); @@ -158,10 +229,11 @@ export class RouteInspector { if (!q) return all; return all.filter( (r) => - r.path.toLowerCase().includes(q) || + r.fullPath.toLowerCase().includes(q) || (r.component && r.component.toLowerCase().includes(q)) || (r.redirectTo && r.redirectTo.toLowerCase().includes(q)) || (r.title && r.title.toLowerCase().includes(q)) || + this.checks(r).some((check) => check.toLowerCase().includes(q)) || r.file.toLowerCase().includes(q), ); }); @@ -173,6 +245,19 @@ export class RouteInspector { }); } + checks(route: SourceRoute) { + return [ + ...Object.entries(route.guards ?? {}).flatMap(([kind, names]) => + names.map((name) => `${kind} ${name}`), + ), + ...(route.resolvers ?? []).map((resolver) => `resolve ${resolver}`), + ]; + } + + location(route: SourceRoute) { + return sourceLocation(route); + } + onFilterInput(event: Event) { const target = event.target as HTMLInputElement | null; this.filter.set(target?.value ?? ''); @@ -182,10 +267,13 @@ export class RouteInspector { const client = this.rpc(); if (!client) return; this.loading.set(true); + this.error.set(false); try { const my = client.scope('ng-devtools'); - const result = (await my.rpc.call('get-routes')) as RouteInfo[]; + const result = (await my.rpc.call('get-routes')) as SourceRoute[]; this.routes.set(result); + } catch { + this.error.set(true); } finally { this.loading.set(false); } diff --git a/app/src/pages/route-lint.ts b/app/src/pages/route-lint.ts index 2ebad33..96e657f 100644 --- a/app/src/pages/route-lint.ts +++ b/app/src/pages/route-lint.ts @@ -6,94 +6,188 @@ import { SHARED_STYLES, routerCall, type LintFinding, type RouterPage } from './ selector: 'app-route-lint', template: `
    - + Checks the live config, links and recent navigations. Lazy routes that have not loaded are skipped. + @if (!loading() && findings().length) { + + @if (counts().error) { + {{ counts().error }} error(s) + } + @if (counts().warning) { + {{ counts().warning }} warning(s) + } + @if (counts().info) { + {{ counts().info }} info + } + + }
    @if (loading()) { -

    Checking…

    +

    Checking…

    } @else if (findings().length) {
      @for (finding of findings(); track $index) { -
    • +
    • - {{ finding.severity }} - {{ finding.rule }} + {{ + finding.severity + }} + {{ finding.rule }} {{ finding.route }}
      -

      {{ finding.message }}

      +

      {{ finding.message }}

      - Fix: {{ finding.fix }} - (Angular - {{ - finding.angular === 'throws' - ? 'throws' - : finding.angular === 'warns' - ? 'warns' - : 'does not warn' - }})Fix + {{ finding.fix }} + (Angular + {{ + finding.angular === 'throws' + ? 'throws' + : finding.angular === 'warns' + ? 'warns' + : 'does not warn' + }})

    • }
    } @else { -

    No route config problems found.

    +
    + +

    No route config problems found.

    +

    The check runs again after each navigation or config change.

    +
    } `, styles: ` ${SHARED_STYLES} :host { display: grid; - gap: 10px; + gap: 12px; + min-width: 0; } .toolbar { display: flex; flex-wrap: wrap; - gap: 10px; + gap: 8px 12px; align-items: center; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + } + .toolbar .muted { + flex: 1 1 240px; + font-size: 12px; + } + .summary { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; + font-variant-numeric: tabular-nums; + } + .ok-mark { + display: grid; + place-items: center; + width: 32px; + height: 32px; + margin-bottom: 4px; + border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent); + border-radius: 99px; + background: color-mix(in srgb, var(--ok) 12%, transparent); + color: var(--ok); } .findings { - list-style: none; - margin: 0; - padding: 0; display: grid; gap: 8px; + margin: 0; + padding: 0; + list-style: none; } .findings li { - padding: 10px; - border: 1px solid #27272a; - border-radius: 6px; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); font-size: 13px; - color: #e4e4e7; + line-height: 1.5; + animation: enter 0.35s var(--ease) both; + transition: border-color 0.15s var(--ease); + } + .findings li:hover { + border-color: var(--border-strong); + } + .findings li[data-severity='error'] { + box-shadow: inset 3px 0 0 var(--danger); + } + .findings li[data-severity='warning'] { + box-shadow: inset 3px 0 0 var(--warn); } .head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; + min-width: 0; + } + .rule { + color: var(--text-strong); + font-weight: 500; } .route { color: var(--accent); } - p { - margin: 6px 0 0; + .message { + margin: 8px 0 0; } .fix { - color: #d4d4d8; + display: flex; + gap: 10px; + align-items: baseline; + margin: 10px 0 0; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + } + .fix-label { + flex: none; + color: var(--accent); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + } + .fix-text { + min-width: 0; + overflow-wrap: anywhere; + } + .fix .muted { + font-size: 12px; } `, }) @@ -103,6 +197,11 @@ export class RouteLint { readonly findings = signal([]); readonly loading = signal(false); + readonly counts = computed(() => { + const counts = { error: 0, warning: 0, info: 0 }; + for (const finding of this.findings()) counts[finding.severity]++; + return counts; + }); private readonly key = computed( () => `${this.page().pageId}:${this.page().generation}:${this.page().navigations.length}`, ); @@ -114,6 +213,10 @@ export class RouteLint { }); } + severityTone(severity: LintFinding['severity']) { + return severity === 'error' ? 'bad' : severity === 'warning' ? 'warn' : ''; + } + async run() { this.loading.set(true); this.findings.set( diff --git a/app/src/pages/route-setup.ts b/app/src/pages/route-setup.ts index 6b6e31d..bf0c3b5 100644 --- a/app/src/pages/route-setup.ts +++ b/app/src/pages/route-setup.ts @@ -45,13 +45,13 @@ import { SHARED_STYLES, type RouterPage } from './router-types'; @for (option of setup.options; track option.name) { - + {{ option.name }} {{ option.value }} - + {{ option.set ? 'set' : 'default' }} @@ -61,56 +61,51 @@ import { SHARED_STYLES, type RouterPage } from './router-types';
    -

    Features

    -
      - @for (feature of entries(setup.features); track feature[0]) { -
    • - {{ feature[0] }}: {{ feature[1] }} -
    • - } -
    -

    Strategies

    -
    - @for (strategy of entries(setup.strategies); track strategy[0]) { -
    {{ strategy[0] }}
    -
    - {{ strategy[1] }} -
    - } -
    + @if (entries(setup.features).length) { +

    Features

    +
      + @for (feature of entries(setup.features); track feature[0]) { +
    • + {{ feature[0] }}: {{ feature[1] }} +
    • + } +
    + } + @if (entries(setup.strategies).length) { +

    Strategies

    +
    + @for (strategy of entries(setup.strategies); track strategy[0]) { +
    {{ strategy[0] }}
    +
    + {{ strategy[1] }} +
    + } +
    + } } @else { -

    The page has not reported its router setup yet.

    +
    +

    The page has not reported its router setup yet.

    +

    + It appears after the app finishes bootstrapping. Reload the app if it stays empty. +

    +
    } `, styles: ` ${SHARED_STYLES} :host { display: grid; - gap: 12px; - } - .note { - margin: 0; - padding: 8px 10px; - border-left: 3px solid #fef08a; - background: #27272a; - color: #e4e4e7; - font-size: 13px; - } - .facts { - display: grid; - grid-template-columns: max-content 1fr; - gap: 4px 12px; - margin: 0; - font-size: 13px; + gap: 16px; + min-width: 0; } - dt { - color: #a1a1aa; + .name code { + color: var(--text-strong); } - dd { - margin: 0; - color: #e4e4e7; + .source { + width: 1%; + white-space: nowrap; } .chips { display: flex; @@ -120,6 +115,13 @@ import { SHARED_STYLES, type RouterPage } from './router-types'; padding: 0; list-style: none; } + .chips .badge { + font-family: var(--font-mono); + font-weight: 500; + } + .facts code { + color: var(--text-strong); + } `, }) export class RouteSetup { diff --git a/app/src/pages/route-timeline.ts b/app/src/pages/route-timeline.ts index 4f07684..4930a05 100644 --- a/app/src/pages/route-timeline.ts +++ b/app/src/pages/route-timeline.ts @@ -13,7 +13,7 @@ const PHASES = ['recognize', 'guards', 'resolve', 'activate'] as const; const PHASE_COLORS: Record = { recognize: '#60a5fa', guards: '#f59e0b', - resolve: '#a78bfa', + resolve: '#2dd4bf', activate: '#34d399', }; @@ -21,15 +21,8 @@ const PHASE_COLORS: Record = { selector: 'app-route-timeline', template: `
    - = { /> Only problems - + +
    @if (message()) { -

    {{ message() }}

    +

    {{ message() }}

    } - @if (items().length) { +
    + {{ items().length }} of {{ page().navigations.length }} navigation(s) + +
    @if (bars(nav).length) {
} @else { -

- No navigations since DevTools connected; earlier ones are not visible. Click a link in the - app. -

+ @if (page().navigations.length) { +
+

No navigations match the current filters.

+

+ {{ page().navigations.length }} navigation(s) are hidden. Clear the filters to see them. +

+ +
+ } @else { +
+

No navigations since DevTools connected.

+

Earlier ones are not visible. Click a link in the app to record one.

+
+ } } `, styles: ` ${SHARED_STYLES} :host { display: grid; - gap: 10px; + gap: 12px; + min-width: 0; } .toolbar { display: flex; flex-wrap: wrap; - gap: 8px 14px; + gap: 8px 16px; align-items: center; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); font-size: 13px; - color: #e4e4e7; + } + .toolbar .field { + flex: 1 1 200px; + min-width: 0; + } + .export { + margin-left: auto; } .check { + display: inline-flex; + gap: 8px; + align-items: center; + min-height: 34px; + color: var(--text-2); + cursor: pointer; + user-select: none; + transition: color 0.15s var(--ease); + } + .check:hover { + color: var(--text); + } + .check input { + flex: none; + width: 14px; + height: 14px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; + } + .message { + margin: 0; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font-size: 13px; + overflow-wrap: anywhere; + } + .meta-row { display: flex; - gap: 6px; + flex-wrap: wrap; + gap: 6px 16px; align-items: center; + justify-content: space-between; + padding: 0 4px; + } + .count { + font-size: 12px; + font-variant-numeric: tabular-nums; } .legend { display: flex; - gap: 12px; - font-size: 12px; - color: #a1a1aa; + flex-wrap: wrap; + gap: 6px 16px; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + } + .legend span { + display: inline-flex; + align-items: center; + gap: 6px; } .legend i { display: inline-block; - width: 10px; - height: 10px; - margin-right: 4px; - border-radius: 2px; + width: 8px; + height: 8px; + border-radius: 99px; } .navs { - list-style: none; - margin: 0; - padding: 0; display: grid; gap: 8px; + margin: 0; + padding: 0; + list-style: none; } .navs > li { - padding: 10px; - border: 1px solid #27272a; - border-radius: 6px; + min-width: 0; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); font-size: 13px; + animation: enter 0.35s var(--ease) both; + transition: border-color 0.15s var(--ease); + } + .navs > li:hover { + border-color: var(--border-strong); } .head { display: flex; flex-wrap: wrap; - gap: 8px; + gap: 6px 8px; align-items: center; + min-width: 0; + font-variant-numeric: tabular-nums; + } + .head .url { + min-width: 0; + color: var(--text-strong); + font-weight: 500; + } + .id { + color: var(--text-3); + font-family: var(--font-mono); + font-size: 12px; + } + .arrow { + color: var(--text-3); + } + .meta { + display: inline-flex; + flex-wrap: wrap; + gap: 6px 8px; + align-items: center; + margin-left: auto; + } + .ms { + font-family: var(--font-mono); + font-size: 12px; } time { - color: #a1a1aa; + color: var(--text-3); + font-family: var(--font-mono); font-size: 12px; + font-variant-numeric: tabular-nums; } .bar { display: flex; + gap: 2px; height: 6px; - margin: 8px 0 4px; - border-radius: 3px; + margin: 12px 0 4px; overflow: hidden; - background: #27272a; + border-radius: 99px; + background: var(--surface-3); } .bar span { display: block; @@ -292,27 +406,42 @@ const PHASE_COLORS: Record = { } .details { display: grid; - grid-template-columns: max-content 1fr; - gap: 3px 12px; - margin: 6px 0 0; - font-size: 12px; + grid-template-columns: max-content minmax(0, 1fr); + align-items: baseline; + gap: 6px 16px; + margin: 12px 0 0; + font-size: 12.5px; + line-height: 1.5; } - dt { - color: #a1a1aa; + .details:empty { + display: none; } - dd { - margin: 0; - color: #e4e4e7; - overflow-wrap: anywhere; + .details dt { + letter-spacing: 0.06em; } .reason, .bad { - color: #fecaca; + color: var(--danger); } .actions { display: flex; + flex-wrap: wrap; gap: 8px; - margin-top: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); + } + @media (max-width: 480px) { + .details { + grid-template-columns: minmax(0, 1fr); + gap: 2px; + } + .details dd + dt { + margin-top: 6px; + } + .export { + margin-left: 0; + } } `, }) @@ -338,6 +467,15 @@ export class RouteTimeline { ); }); + reasonText(nav: NavigationRecord) { + return [nav.code, nav.reason].filter(Boolean).join(': '); + } + + clearFilters() { + this.filter.set(''); + this.onlyProblems.set(false); + } + tone(outcome: string) { return tone(outcome); } diff --git a/app/src/pages/route-tree.ts b/app/src/pages/route-tree.ts index 89b30a3..db5cdd1 100644 --- a/app/src/pages/route-tree.ts +++ b/app/src/pages/route-tree.ts @@ -5,13 +5,17 @@ import { SHARED_STYLES, routerAction, routerCall, + sourceLocation, + sourceOf, type RouteNode, type RouterPage, + type SourceRoute, } from './router-types'; interface NodeRow { node: RouteNode; depth: number; + source?: string; } interface MatchResult { @@ -36,20 +40,22 @@ interface MatchResult { [value]="testUrl()" (input)="testUrl.set($any($event.target).value)" /> - + @if (match(); as result) { -
+
@if (result.matched) { - Matches {{ chainText(result) }} + match + Matches {{ chainText(result) }} @if (hasKeys(result.params)) { with {{ result.params | json }} } } @else { + no match Matches no route (NG04002). @if (result.nearest.length) { - Nearest: {{ result.nearest.join(', ') }} + Nearest: {{ result.nearest.join(', ') }} } } @for (note of result.notes; track note) { @@ -58,30 +64,52 @@ interface MatchResult {
} @if (message()) { -

{{ message() }}

+

{{ message() }}

} - +
+ + @if (page().config) { +

+ Generation {{ page().generation }} · {{ rows().length }} route(s). Lazy routes show their + children once loaded. +

+ } +
@if (!page().config) { -

- {{ - page().setup?.mode === 'events-only' - ? 'This build has no debug utils, so the live config cannot be read.' - : 'The page has not reported its route config yet.' - }} -

+
+

+ {{ + page().setup?.mode === 'events-only' + ? 'This build has no debug utils, so the live config cannot be read.' + : 'The page has not reported its route config yet.' + }} +

+

+ {{ + page().setup?.mode === 'events-only' + ? 'Run the app with the development build to inspect the live config.' + : 'It appears once the router initializes. Navigate in the app if it stays empty.' + }} +

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

No routes match the filter.

+

Try a shorter path or a component name.

+ +
} @else { -

- Generation {{ page().generation }} · {{ rows().length }} route(s). Lazy routes show their - children once loaded. -

@@ -96,7 +124,7 @@ interface MatchResult { @for (row of rows(); track row.node.id) { - + - } @@ -169,46 +218,142 @@ interface MatchResult { ${SHARED_STYLES} :host { display: grid; - gap: 10px; + gap: 12px; + min-width: 0; } .test { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); + color: var(--text); font-size: 13px; - color: #e4e4e7; + } + .test label { + margin-right: 4px; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; + } + .test input { + flex: 1 1 200px; + min-width: 0; + font-family: var(--font-mono); + font-size: 12.5px; } .result { - padding: 8px 10px; - border: 1px solid #27272a; - border-radius: 6px; + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + align-items: center; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); font-size: 13px; - color: #e4e4e7; + line-height: 1.5; + animation: enter 0.35s var(--ease) both; } - .filter { - max-width: 320px; + .result[data-matched='true'] { + box-shadow: inset 3px 0 0 var(--ok); + } + .result[data-matched='false'] { + box-shadow: inset 3px 0 0 var(--danger); + } + .result .muted { + flex-basis: 100%; + font-size: 12px; } - .path { - font-family: monospace; + .chain { color: var(--accent); - white-space: nowrap; + } + .message { + margin: 0; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font-size: 13px; + overflow-wrap: anywhere; + } + .filter-row { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + align-items: center; + } + .filter { + flex: 0 1 320px; + min-width: 0; + } + .summary { + flex: 1 1 240px; + margin: 0; + font-size: 12px; + font-variant-numeric: tabular-nums; + } + td { + vertical-align: middle; + } + .redirect { + color: var(--text-2); + } + .src { + display: block; + margin-top: 2px; + color: var(--text-2); + font-family: var(--font-mono); + font-size: 11.5px; + overflow-wrap: anywhere; } tr.active td { - background: #1c1917; + background: var(--accent-soft); + color: var(--text-strong); + } + tr.active:hover td { + background: color-mix(in srgb, var(--accent) 16%, transparent); + } + tr.active td:first-child { + box-shadow: inset 3px 0 0 var(--accent); } .actions { + width: 1%; + padding-top: 6px; + padding-bottom: 6px; white-space: nowrap; } + .actions-inner { + display: flex; + gap: 6px; + align-items: center; + justify-content: flex-end; + } .param { - width: 80px; - margin-right: 4px; + width: 96px; + font-family: var(--font-mono); + font-size: 12px; + } + @media (max-width: 480px) { + .filter { + flex-basis: 100%; + } } `, }) export class RouteTree { page = input.required(); rpc = input(null); + sources = input([]); readonly filter = signal(''); readonly testUrl = signal(''); @@ -220,15 +365,19 @@ export class RouteTree { readonly rows = computed(() => { const needle = this.filter().toLowerCase(); + const sources = this.sources(); const rows: NodeRow[] = []; const visit = (nodes: RouteNode[], depth: number) => { for (const node of nodes) { + const found = sourceOf(node, sources); + const source = found && sourceLocation(found); if ( !needle || node.fullPath.toLowerCase().includes(needle) || - !!node.component?.toLowerCase().includes(needle) + !!node.component?.toLowerCase().includes(needle) || + !!source?.toLowerCase().includes(needle) ) { - rows.push({ node, depth }); + rows.push({ node, depth, source }); } if (node.children) visit(node.children, depth + 1); } @@ -294,8 +443,8 @@ export class RouteTree { } this.message.set( result['matched'] - ? `The app matched ${url} (see the probe entry in Navigations).` - : `The app did not match ${url}: ${String(result['reason'] ?? '')}`, + ? `The app recognized ${url} and its canMatch guards passed. The probe stopped before canActivate guards and resolvers, so those did not run. See the probe entry in Navigations.` + : `The app could not recognize ${url}: ${String(result['reason'] ?? '')}`, ); } diff --git a/app/src/pages/router-types.ts b/app/src/pages/router-types.ts index 7bbe07a..f641832 100644 --- a/app/src/pages/router-types.ts +++ b/app/src/pages/router-types.ts @@ -77,6 +77,36 @@ export interface RouteNode { children?: RouteNode[]; } +export interface SourceRoute { + path: string; + fullPath: string; + kind: 'page' | 'group' | 'redirect' | 'wildcard'; + component?: string; + redirectTo?: string; + title?: string; + guards?: Record; + resolvers?: string[]; + hasChildren: boolean; + file: string; + line?: number; +} + +export function sourceLocation(source: SourceRoute): string { + return source.line ? `${source.file}:${source.line}` : source.file; +} + +export function sourceOf(node: RouteNode, sources: SourceRoute[]): SourceRoute | undefined { + const candidates = sources.filter( + (s) => + s.fullPath === node.fullPath && + (s.redirectTo !== undefined) === (node.redirectTo !== undefined), + ); + return ( + candidates.find((s) => !!node.component && s.component === node.component) ?? + candidates.find((s) => !s.component || !node.component) + ); +} + export interface OutletInfo { outlet: string; route?: string; @@ -168,95 +198,263 @@ export function routerAction( export const SHARED_STYLES = ` .muted { - color: #a1a1aa; + color: var(--text-2); font-size: 13px; + line-height: 1.5; + } + .nil { + color: var(--text-3); } code { - font-family: monospace; - color: #d4d4d8; + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--text); overflow-wrap: anywhere; } .tag { - display: inline-block; - margin: 0 4px 4px 0; - padding: 1px 6px; - border: 1px solid #52525b; - border-radius: 4px; - color: #d4d4d8; - font-size: 11px; - font-family: monospace; + display: inline-flex; + align-items: center; + max-width: 100%; + min-height: 20px; + margin: 2px 4px 2px 0; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 99px; + background: var(--surface-2); + color: var(--text); + font-family: var(--font-mono); + font-size: 11.5px; + line-height: 1.4; + vertical-align: middle; + overflow-wrap: anywhere; + white-space: normal; } .badge { - padding: 1px 6px; - border-radius: 4px; - background: #3f3f46; - color: #e4e4e7; + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 0 8px; + border: 1px solid var(--border-strong); + border-radius: 99px; + background: var(--surface-2); + color: var(--text); font-size: 11px; font-weight: 600; + line-height: 1.4; + white-space: nowrap; + vertical-align: middle; } .badge[data-tone='good'] { - background: #14532d; - color: #bbf7d0; + border-color: color-mix(in srgb, var(--ok) 30%, transparent); + background: color-mix(in srgb, var(--ok) 12%, transparent); + color: var(--ok); } .badge[data-tone='warn'] { - background: #713f12; - color: #fef08a; + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + background: color-mix(in srgb, var(--warn) 12%, transparent); + color: var(--warn); } .badge[data-tone='bad'] { - background: #7f1d1d; - color: #fecaca; + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); } button.small { - padding: 3px 10px; - background: #3f3f46; - border: none; - border-radius: 6px; - color: #e4e4e7; + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + gap: 6px; + height: 34px; + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-size: 13px; + font-weight: 500; + white-space: nowrap; cursor: pointer; - font-size: 12px; + transition: + background-color 0.15s var(--ease), + border-color 0.15s var(--ease), + transform 0.1s var(--ease); + } + button.small:hover:not(:disabled) { + background: var(--surface-3); + border-color: color-mix(in srgb, var(--text-3) 50%, var(--border-strong)); } - button.small:hover { - background: #52525b; + button.small:active:not(:disabled) { + transform: translateY(1px); + } + button.small:disabled { + opacity: 0.55; + cursor: not-allowed; + } + button.small.primary { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); + font-weight: 600; + } + button.small.primary:hover:not(:disabled) { + border-color: var(--accent-hover); + background: var(--accent-hover); } button.small:focus-visible, - input:focus-visible, - select:focus-visible, - .table-scroll:focus-visible { + .table-scroll:focus-visible, + input[type='checkbox']:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + input.field, + select { + height: 34px; + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background-color: var(--bg); + color: var(--text); + font: inherit; + font-size: 13px; + transition: + border-color 0.15s var(--ease), + box-shadow 0.15s var(--ease); + } input.field { - padding: 6px 10px; - background: #18181b; - border: 1px solid #52525b; - border-radius: 6px; - color: #e4e4e7; + text-overflow: ellipsis; + } + input.field::placeholder { + color: var(--text-3); + } + input.field:hover:not(:focus), + select:hover:not(:focus) { + border-color: color-mix(in srgb, var(--text-3) 50%, var(--border-strong)); + } + input.field:focus-visible, + select:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + } + .note { + margin: 0; + padding: 10px 14px; + border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--warn) 10%, transparent); + color: var(--text); + font-size: 13px; + line-height: 1.5; + } + .empty { + display: grid; + gap: 6px; + justify-items: center; + margin: 0; + 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: 480px; + line-height: 1.6; + } + .empty-title { + color: var(--text-strong); + font-size: 14px; + font-weight: 600; + } + .empty .small { + margin-top: 8px; + } + .facts { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + align-items: baseline; + gap: 8px 24px; + margin: 0; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); font-size: 13px; } + .facts:empty { + display: none; + } + dt { + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + } + dd { + min-width: 0; + margin: 0; + color: var(--text); + overflow-wrap: anywhere; + font-variant-numeric: tabular-nums; + } .table-scroll { 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%; border-collapse: collapse; font-size: 13px; + font-variant-numeric: tabular-nums; } th { + padding: 10px 14px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--surface-2) 60%, var(--surface)); + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; text-align: left; - padding: 6px 12px; - color: #a1a1aa; - font-size: 12px; - border-bottom: 1px solid #27272a; + text-transform: uppercase; + white-space: nowrap; } td { - padding: 8px 12px; - border-bottom: 1px solid #1e1e22; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + color: var(--text); + line-height: 1.5; vertical-align: top; + transition: background-color 0.15s var(--ease); + } + tbody tr:last-child td { + border-bottom: none; + } + tbody tr:hover td { + background: var(--surface-2); + } + .path { + color: var(--accent); + font-family: var(--font-mono); + font-size: 12.5px; + white-space: nowrap; } h3 { - margin: 0 0 8px; - font-size: 14px; - color: #e4e4e7; + margin: 8px 0 -4px; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; } .visually-hidden { position: absolute; @@ -266,6 +464,15 @@ export const SHARED_STYLES = ` clip-path: inset(50%); white-space: nowrap; } + @media (max-width: 480px) { + .facts { + grid-template-columns: minmax(0, 1fr); + gap: 2px; + } + .facts dd + dt { + margin-top: 8px; + } + } `; export function tone(outcome: string): string { diff --git a/app/src/pages/signal-inspector.ts b/app/src/pages/signal-inspector.ts index c89dffa..a6ddb33 100644 --- a/app/src/pages/signal-inspector.ts +++ b/app/src/pages/signal-inspector.ts @@ -1,6 +1,8 @@ -import { Component, input, signal, effect, computed } from '@angular/core'; +import { Component, DestroyRef, computed, effect, inject, input, signal } from '@angular/core'; import { DatePipe, JsonPipe } from '@angular/common'; import type { DevframeRpcClient } from 'devframe/client'; +import { hostPageId } from '../page-id'; +import { Select, type SelectOption } from '../ui/select'; interface SignalNode { id: string; @@ -8,7 +10,6 @@ interface SignalNode { label?: string; epoch: number; value?: unknown; - watched: boolean; } interface SignalEdge { @@ -28,9 +29,27 @@ interface SignalGraph { nodes: SignalNode[]; edges: SignalEdge[]; componentSelector?: string; + component?: { id: string; name: string; tag: string; path: string }; + source?: 'selected' | 'routed' | 'root'; + pageId?: string; history?: Record; } +interface LiveNode { + id: string; + name: string; + tag: string; + children: LiveNode[]; +} + +const FOLLOW = 'follow'; + +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', @@ -46,48 +65,117 @@ interface SourceSignal { } const KIND_COLORS: Record = { - signal: '#a78bfa', + signal: '#facc15', computed: '#60a5fa', linkedSignal: '#34d399', effect: '#fb923c', template: '#94a3b8', afterRenderEffectPhase: '#f472b6', - childSignalProp: '#c084fc', + childSignalProp: '#fef08a', 'input (signal)': '#f59e0b', 'input.required (signal)': '#f59e0b', 'output (signal)': '#ec4899', 'model (signal)': '#14b8a6', 'model.required (signal)': '#14b8a6', - 'viewChild (signal)': '#8b5cf6', - 'viewChild.required (signal)': '#8b5cf6', - 'viewChildren (signal)': '#8b5cf6', - 'contentChild (signal)': '#6366f1', - 'contentChild.required (signal)': '#6366f1', - 'contentChildren (signal)': '#6366f1', + 'viewChild (signal)': '#a3e635', + 'viewChild.required (signal)': '#a3e635', + 'viewChildren (signal)': '#a3e635', + 'contentChild (signal)': '#fda4af', + 'contentChild.required (signal)': '#fda4af', + 'contentChildren (signal)': '#fda4af', resource: '#06b6d4', - unknown: '#71717a', + unknown: 'var(--text-2)', }; @Component({ selector: 'app-signal-inspector', - imports: [DatePipe, JsonPipe], + imports: [DatePipe, JsonPipe, Select], template: `
- Component: {{ graph()?.componentSelector ?? '—' }} + @if (componentOptions().length) { +
+ Component + +
+ }
- @if (!graph() && sourceSignals().length === 0) { + @if (graph()?.component; as comp) { +

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

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

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

+ } + } + +

+ @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 { + Every signal, computed and effect found in your source. Pick a kind to filter. + } +

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

Scanning source for signals…

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

No signals found.

+

No signals found.

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

} @@ -104,26 +192,26 @@ const KIND_COLORS: Record = { {{ sig.name }}
- {{ sig.file }}:{{ sig.line }} + {{ sig.file }}:{{ sig.line }} @if (sig.component) { - · in <{{ sig.component }}> + + in <{{ sig.component }}> }
+ } @empty { +
+

No signals match.

+

Try a different name or kind.

+ +
} } @if (graph()) { -
- @for (entry of kindLegend; track entry.kind) { - - - {{ entry.kind }} - - } -
-
    @for (node of filteredNodes(); track node.id) {
  • @@ -140,31 +228,31 @@ const KIND_COLORS: Record = { node.kind }} {{ node.label ?? '(unnamed)' }} - @if (node.watched) { - watching - } @if (changeCount(node.id); as count) { {{ count }} {{ count === 1 ? 'change' : 'changes' }} } + @if (node.value !== undefined) { {{ node.value | json }} } - Epoch: {{ node.epoch }} + Epoch: {{ node.epoch }} @if (getDependencies(node).length) { - · Deps: {{ getDependencies(node).length }} + + Deps: {{ getDependencies(node).length }} } @if (getConsumers(node).length) { - · Consumers: {{ getConsumers(node).length }} + + Consumers: {{ getConsumers(node).length }} } @if (selectedId() === node.id && selectedNode()) {
    -

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

    +

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

    Kind
    {{ selectedNode()!.kind }}
    @@ -178,33 +266,33 @@ const KIND_COLORS: Record = { }
    @if (getDependencies(selectedNode()!).length) { -

    Dependencies (producers)

    +

    Dependencies (producers)

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

    Consumers

    +

    Consumers

      @for (con of getConsumers(selectedNode()!); track con.id) {
    • {{ con.kind }} - {{ con.label ?? con.id }} + {{ con.label ?? con.id }}
    • }
    } @if (selectedHistory().length) { -

    Value history

    +

    Value history

    {{ changeCount(selectedNode()!.id) }} changes recorded, newest first.

    @@ -229,71 +317,308 @@ const KIND_COLORS: Record = {
    }
  • + } @empty { +
  • +

    + {{ graph()!.nodes.length ? 'No signals match.' : 'No signals in this component.' }} +

    +

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

    + @if (graph()!.nodes.length) { + + } +
  • }
} `, 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; - gap: 12px; + flex-wrap: wrap; + gap: 8px 12px; align-items: center; - margin-bottom: 16px; + 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; - padding: 8px 12px; - background: #18181b; - border: 1px solid #27272a; - border-radius: 6px; - color: #e4e4e7; - font-size: 14px; + flex: 1 1 200px; + min-width: 0; + height: 34px; + padding: 0 12px; + background: var(--bg); + border: 1px solid var(--border-strong); + 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:focus { - border-color: var(--accent); + input::placeholder { + color: var(--text-3); } - .label { - font-size: 13px; - color: #71717a; - white-space: nowrap; + input:hover { + border-color: color-mix(in srgb, var(--text-3) 60%, var(--border-strong)); + } + input:focus-visible { + @include m.field-focus; } .empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; text-align: center; - padding: 48px 16px; + padding: 40px 24px; + @include m.panel; + @include m.enter; + } + .empty.compact { + padding: 24px 16px; + border-style: dashed; + background: transparent; } - .muted { - color: #71717a; + .empty-title { + margin: 0; + color: var(--text-strong); font-size: 14px; + font-weight: 600; } .hint { - color: #52525b; + 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; - margin-top: 8px; + color: var(--text); + background: var(--surface-3); + border: 1px solid var(--border); + border-radius: 5px; } - .source-label { + .reset { + height: 34px; + margin-top: 4px; + padding: 0 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; font-size: 13px; - color: #71717a; - margin-bottom: 12px; + 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; } - .legend { + @keyframes spin { + to { + transform: rotate(360deg); + } + } + @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; - gap: 12px; flex-wrap: wrap; + gap: 6px; margin-bottom: 16px; } - .legend-item { - display: flex; + .kind-chip { + display: inline-flex; align-items: center; - gap: 4px; + gap: 6px; + max-width: 100%; + height: 28px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 99px; + background: var(--surface-2); + color: var(--text-2); + font: inherit; font-size: 12px; - color: #a1a1aa; + 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(--border-strong); + 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(--bg); + 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; @@ -301,179 +626,312 @@ const KIND_COLORS: Record = { 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: #18181b; - border: 1px solid #27272a; - border-radius: 8px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 12px 16px; - cursor: pointer; - transition: border-color 0.15s; + transition: + background-color 0.15s var(--ease), + border-color 0.15s var(--ease), + box-shadow 0.15s var(--ease); } .node-card:hover { - border-color: #3f3f46; + background: var(--surface-2); + border-color: var(--border-strong); } - .node-card:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; + .picker { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 260px; + min-width: 0; } - .node-value, - .node-meta { - display: block; + .picker app-select { + flex: 1 1 auto; + min-width: 0; } - .changed-badge { - font-size: 10px; - padding: 1px 6px; - border-radius: 4px; - background: #422006; - color: #fbbf24; + .label-key { + flex: none; + color: var(--text-2); + font-size: 12px; } - .history-summary { + .showing { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + margin: 0 0 8px; font-size: 12px; - color: #a1a1aa; - margin: 0 0 6px; + color: var(--text-2); } - .history { - list-style: none; - padding: 0; - margin: 0; - max-height: 320px; - overflow: auto; + .showing-name { + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 13px; + font-weight: 600; } - .history li { - display: block; - padding: 6px 0; - border-top: 1px solid #27272a; + .mono { + font-family: var(--font-mono); + overflow-wrap: anywhere; } - .history-meta { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - font-size: 11px; - color: #a1a1aa; - margin-bottom: 2px; + .source-note { + padding: 0 8px; + border: 1px solid var(--border-strong); + border-radius: 99px; + line-height: 18px; } - .source-tag { - padding: 0 5px; - border-radius: 3px; - background: #27272a; - color: #e4e4e7; + .fallback { + margin: 0 0 8px; + color: var(--warn); + font-size: 12px; } - .source-write { - background: #1e3a8a; - color: #dbeafe; + .source-label { + @include m.label; + margin: 0 0 12px; } - .missed { - color: #fbbf24; + .nodes > li { + display: block; + min-width: 0; + padding: 0; } - .node-card.selected { - border-color: var(--accent); + button.node-card { + cursor: pointer; } - .node-header { - display: flex; - align-items: center; - gap: 8px; + .node-card:focus-visible { + @include m.focus-ring; } - .kind-badge { - font-size: 11px; - padding: 2px 8px; - border-radius: 4px; - color: #fff; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; + .node-card.selected { + background: var(--accent-soft); + border-color: var(--accent-line); + box-shadow: inset 2px 0 0 var(--accent); } - .kind-badge.sm { - font-size: 10px; - padding: 1px 5px; + .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-label { - font-family: monospace; - font-size: 14px; - color: #e4e4e7; + .node-card:hover .chevron { + border-color: var(--text); } - .watched-badge { - font-size: 10px; - padding: 1px 6px; - border-radius: 4px; - background: #14532d; - color: #4ade80; + .node-card.selected .chevron { + border-color: var(--accent); + transform: translateY(2px) rotate(-135deg); } .node-value { - font-family: monospace; - font-size: 12px; - color: #a1a1aa; - margin-top: 4px; - max-height: 40px; + 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-meta { + .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; - color: #52525b; - margin-top: 4px; + font-weight: 500; + line-height: 16px; + padding: 0 8px; + border: 1px solid; + border-radius: 99px; + font-variant-numeric: tabular-nums; + } + .changed-badge { + @include m.soft(var(--warn)); } .detail-panel { - margin-top: 16px; + margin-top: 8px; padding: 16px; - background: #18181b; - border: 1px solid #27272a; - border-radius: 8px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + @include m.enter(0.25s); } - .detail-panel h3 { - font-family: monospace; + .detail-panel h2 { + margin: 0 0 16px; + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; color: var(--accent); - margin-bottom: 12px; + overflow-wrap: anywhere; } - .detail-panel h4 { - font-size: 12px; - color: #71717a; - margin: 12px 0 4px; - text-transform: uppercase; - letter-spacing: 0.05em; + .detail-panel h3 { + @include m.label; + margin: 20px 0 8px; } - dl { - display: grid; - grid-template-columns: auto 1fr; - gap: 4px 12px; + .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); + } + .detail-panel ul { + display: flex; + flex-direction: column; + gap: 2px; + list-style: none; + padding: 0; + margin: 0; font-size: 13px; } - dt { - color: #71717a; + .detail-panel ul li { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + min-height: 28px; + padding: 4px 8px; + color: var(--text-2); + border-radius: 6px; + transition: background-color 0.15s var(--ease); } - dd { - color: #e4e4e7; + .detail-panel ul li:hover { + background: var(--surface-2); + color: var(--text); } - pre { + .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; - white-space: pre-wrap; - margin: 0; + color: var(--text-2); + font-variant-numeric: tabular-nums; } - ul { + .history { list-style: none; - padding: 0; - font-size: 13px; + 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); } - li { - padding: 2px 0; - color: #a1a1aa; + .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; - gap: 6px; + 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: #93c5fd; + background: color-mix(in srgb, #60a5fa 12%, transparent); + border-color: color-mix(in srgb, #60a5fa 30%, transparent); + } + .missed { + color: var(--warn); + } + @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; + } } `, }) export class SignalInspector { rpc = input(null); + private readonly destroyRef = inject(DestroyRef); + private readonly pageId = hostPageId(); + private readonly cleanups: (() => void)[] = []; + private readonly treePages = signal>( + {}, + ); + readonly picked = signal(null); + graph = signal(null); sourceSignals = signal([]); + sourceLoaded = signal(false); filter = signal(''); + kind = signal(null); + kindCounts = computed(() => { + const kinds: string[] = this.graph() + ? (this.graph()?.nodes ?? []).map((n) => n.kind) + : 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 })); + }); + kindTotal = computed(() => this.kindCounts().reduce((sum, g) => sum + g.count, 0)); selectedId = signal(null); selectedNode = computed( () => this.graph()?.nodes.find((n) => n.id === this.selectedId()) ?? null, @@ -489,44 +947,133 @@ export class SignalInspector { const g = this.graph(); if (!g) return []; const q = this.filter().toLowerCase(); - const nodes = q - ? g.nodes.filter((n) => (n.label ?? '').toLowerCase().includes(q) || n.kind.includes(q)) - : [...g.nodes]; + const kind = this.kind(); + const nodes = g.nodes.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 all = this.sourceSignals(); - return q - ? all.filter( - (s) => s.name.toLowerCase().includes(q) || s.kind.includes(q) || s.file.includes(q), - ) - : all; + 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.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); + if (!flat.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' }, + ]; + 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.tag}>`, + }); + } + return options; + }); + + readonly pickerValue = computed(() => { + const picked = this.picked(); + return picked && this.componentOptions().some((o) => o.value === picked) ? picked : FOLLOW; }); constructor() { effect(() => { const client = this.rpc(); if (!client) return; - this.loadSignalGraph(client); - this.loadSourceSignals(client); + void this.loadSignalGraph(client); + void this.loadSourceSignals(client); + }); + this.destroyRef.onDestroy(() => { + for (const cleanup of this.cleanups.splice(0)) cleanup(); }); } async loadSignalGraph(client: DevframeRpcClient) { + for (const cleanup of this.cleanups.splice(0)) cleanup(); const my = client.scope('ng-devtools'); - const state = await my.rpc.sharedState('signal-graph'); - // Each open page pushes its own graph; show the one hosting this panel. - const pageId = new URLSearchParams(location.search).get('pageId'); - const pick = (val: any) => (pageId && val?.pages?.[pageId]) || val?.graph; - const initial = pick(state.value()); - if (initial) this.graph.set(initial); - state.on('updated', (next: any) => { - const graph = pick(next); - if (graph) this.graph.set(graph); - }); + try { + const state = await my.rpc.sharedState('signal-graph'); + if (this.destroyRef.destroyed) return; + const apply = (value: unknown) => { + const next = value as { graph?: SignalGraph | null; pages?: Record }; + const graph = (this.pageId ? next?.pages?.[this.pageId] : next?.graph) ?? null; + if (graph?.component?.id !== this.graph()?.component?.id) this.selectedId.set(null); + this.graph.set(graph); + }; + apply(state.value()); + this.cleanups.push(state.on('updated', apply)); + } catch { + this.graph.set(null); + } + 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.treePages.set(pages && typeof pages === 'object' ? pages : {}); + }; + applyTree(tree.value()); + this.cleanups.push(tree.on('updated', applyTree)); + } catch { + this.treePages.set({}); + } + } + + pickComponent(value: string | null) { + const id = value && value !== FOLLOW ? value : null; + this.picked.set(id); + const client = this.rpc(); + if (!client) return; + void client + .scope('ng-devtools') + .rpc.call('select-signal-target', { pageId: this.targetPageId() ?? undefined, id }) + .catch(() => {}); + } + + sourceNote(source: NonNullable) { + return SOURCE_NOTES[source]; } async loadSourceSignals(client: DevframeRpcClient) { @@ -536,9 +1083,16 @@ export class SignalInspector { this.sourceSignals.set(result); } catch { // RPC not available + } finally { + this.sourceLoaded.set(true); } } + clearFilters() { + this.filter.set(''); + this.kind.set(null); + } + selectNode(node: SignalNode) { this.selectedId.set(this.selectedId() === node.id ? null : node.id); } diff --git a/app/src/pages/store-inspector.ts b/app/src/pages/store-inspector.ts index 460266d..7480243 100644 --- a/app/src/pages/store-inspector.ts +++ b/app/src/pages/store-inspector.ts @@ -1,35 +1,16 @@ -import { Component, input, signal, effect, computed } from '@angular/core'; -import { JsonPipe } from '@angular/common'; +import { Component, DestroyRef, computed, effect, inject, input, signal } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; - -interface NgrxStoreEntry { - name: string; - kind: - | 'action' - | 'reducer' - | 'effect' - | 'selector' - | 'feature' - | 'store-setup' - | 'signal-store' - | 'signal-state' - | 'signal-method'; - file: string; - line: number; - detail?: string; -} - -interface NgrxRuntimeAction { - type: string; - payload?: unknown; - timestamp: number; -} - -interface NgrxRuntimeState { - state: unknown; - actions: NgrxRuntimeAction[]; - connected: boolean; -} +import { hostPageId } from '../page-id'; +import { Select, type SelectOption } from '../ui/select'; +import { + pretty, + short, + type LiveStore, + type NgrxLogEntry, + type NgrxPage, + type NgrxState, + type NgrxStoreEntry, +} from './store-types'; const KIND_COLORS: Record = { action: '#f59e0b', @@ -41,435 +22,1332 @@ const KIND_COLORS: Record = { 'signal-store': '#e879f9', 'signal-state': '#22d3ee', 'signal-method': '#fb7185', + store: '#a78bfa', }; +const KIND_LABELS: Record = { + 'signal-store': 'signalStore', + 'signal-state': 'signalState', + 'signal-method': 'signalMethod', + 'store-setup': 'store setup', + store: '@ngrx/store', +}; + +const CLASSIC_KINDS = new Set([ + 'action', + 'reducer', + 'effect', + 'selector', + 'feature', + 'store-setup', +]); + +function call(client: DevframeRpcClient | null, name: string, arg?: unknown): Promise { + if (!client) return Promise.resolve(null); + const rpc = client.scope('ng-devtools').rpc as unknown as { + call: (name: string, ...args: unknown[]) => Promise; + }; + return rpc.call(name, ...(arg === undefined ? [] : [arg])); +} + @Component({ selector: 'app-store-inspector', - imports: [JsonPipe], + imports: [Select], template: `
-
- - -
+ @if (pages().length > 1) { + Page + + } + + + {{ page() ? 'Live' : 'No page connected' }} +
- @if (mode() === 'source') { - @if (sourceEntries().length === 0) { +
+
+

Live stores

+ @if (liveStores().length) { + {{ liveStores().length }} + } +
+ + @if (failed()) { + + } @else if (!page()) {
-

No NgRx store patterns found.

+

No page is reporting NgRx state yet.

- No createAction, createReducer, createEffect, createSelector, or createFeature calls - found in source. Make sure your app uses @ngrx/store. + Open the app in a browser. Stores show up here as soon as the page creates them.

- } @else { -
- @for (entry of kindLegend; track entry.kind) { - - - {{ entry.kind }} - - } -
- -
- @for (group of groupedEntries(); track group.kind) { - - {{ group.count }} {{ group.kind }}{{ group.count !== 1 ? 's' : '' }} - + } @else if (!liveStores().length) { +
+ @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 NgRx store.

+

+ Stores from @ngrx/signals (signalStore, + signalState) and @ngrx/store are listed here once the + app creates them. +

}
+ } @else { +
+
    + @for (item of filteredStores(); track item.id) { +
  • + +
  • + } @empty { +
  • No stores match this filter.
  • + } +
-
- @for (entry of filteredEntries(); track entry.name + entry.file + entry.line) { -
-
- - {{ entry.kind }} - - {{ entry.name }} + @if (store(); as current) { +
+
+

{{ current.label }}

+
+ {{ kindLabel(current.kind) }} + scope: {{ current.scope }} + @if (current.signal?.declaredIn; as file) { + {{ file }} + } + @if (current.classic) { + {{ + current.classic.devtools ? 'Store DevTools on' : 'read-only' + }} + } +
+ @if (current.signal?.references?.length) { +

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

+ }
-
- {{ entry.file }}:{{ entry.line }} - @if (entry.detail) { - · {{ entry.detail }} + +
+
+

State

+
{{
+                    stateText()
+                  }}
+
+ @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.rx) { + rxMethod + } + {{ method.calls }} {{ method.calls === 1 ? 'call' : 'calls' }} +
  • + } +
+ } @else { +

No methods.

+ } +
}
+ +
+

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

+
+
    + @for (entry of storeLog(); track entry.seq) { +
  • + +
  • + } @empty { +
  • + @if (filter()) { + No entries match this filter. + } @else if (current.classic) { + 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 (entry(); as selected) { +
+
#{{ selected.seq }} {{ selected.type }}
+
+
Time
+
{{ formatTime(selected.timestamp) }}
+ @if (selected.action !== undefined) { +
Action
+
+
{{ prettyText(selected.action) }}
+
+ } + @if (selected.args?.length) { +
Arguments
+
+
{{ prettyText(selected.args) }}
+
+ } +
+
State diff
+ @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) }} + } + +
  • + } +
+ } @else { +

The state did not change.

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

+ @if (selected.source === 'store') { + Store DevTools jumps the app state to the state right after action + #{{ selected.seq }}. New actions continue from there. + } @else { + 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 { + + } + } @else if (selected.source === 'store') { +

+ Time travel for @ngrx/store needs provideStoreDevtools(). + Without it this log is read-only. +

+ } +
+ } +
+
}
} - } +

{{ message() }}

+
- @if (mode() === 'runtime') { - @if (!runtimeState()?.connected) { -
-

No NgRx store connection detected.

+
+
+

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.

- Runtime inspection requires @ngrx/store-devtools to be configured in your app. The - store devtools use the Redux DevTools protocol to expose state. + The scan looks for signalStore, signalState, + signalMethod, createAction, createReducer, + createEffect, createSelector and createFeature.

} @else { -
-
-

Current State

-
{{ runtimeState()?.state | json }}
-
- -
-

- Recent Actions - {{ filteredActions().length }} -

-
- @for (action of filteredActions(); track $index) { -
-
{{ action.type }}
-
{{ formatTime(action.timestamp) }}
-
- } @empty { -

No actions dispatched yet.

- } -
-
+
+ + @for (group of groupedEntries(); track group.kind) { + + }
- @if (selectedAction()) { - - } + + } @empty { +
  • +

    No declarations match.

    + +
  • + } + } - } +
    `, styles: ` + @use 'mixins' as m; + + :host { + display: grid; + gap: 24px; + min-width: 0; + color: var(--text); + font-size: 13px; + } .toolbar { + position: sticky; + top: 0; + z-index: 2; display: flex; - gap: 12px; + flex-wrap: wrap; + gap: 8px 12px; align-items: center; - margin-bottom: 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; - padding: 8px 12px; - background: #18181b; - border: 1px solid #27272a; - border-radius: 6px; - color: #e4e4e7; - font-size: 14px; + flex: 1 1 220px; + min-width: 0; + height: 34px; + padding: 0 12px; + background: var(--bg); + border: 1px solid var(--border-strong); + 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:focus { - border-color: var(--accent); + input::placeholder { + color: var(--text-3); } - .toggle-group { - display: flex; - border: 1px solid #27272a; - border-radius: 6px; - overflow: hidden; + input:focus-visible { + @include m.field-focus; } - .toggle-group button { - padding: 6px 14px; - border: none; - background: transparent; - color: #a1a1aa; - cursor: pointer; - font-size: 13px; - display: flex; + .page-select { + flex: 0 1 220px; + min-width: 0; + } + .status { + display: inline-flex; + flex: none; align-items: center; gap: 6px; - } - .toggle-group button.active { - background: #3f3f46; - color: #fff; + color: var(--text-2); + font-size: 12px; + font-weight: 500; } .live-dot { width: 6px; height: 6px; border-radius: 50%; - background: #4ade80; + 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 { - 0%, - 100% { - opacity: 1; - } 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; + } + .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: 48px 16px; + padding: 36px 24px; + @include m.panel; + @include m.enter; } - .muted { - color: #71717a; + .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 { - color: #52525b; + 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; - margin-top: 8px; } - .legend { + .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: 32px; + padding: 0 14px; + border: 1px solid var(--border-strong); + 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; - gap: 12px; - flex-wrap: wrap; - margin-bottom: 12px; + flex-direction: column; + gap: 2px; + padding: 8px; + border-right: 1px solid var(--border); + background: var(--surface-2); } - .legend-item { + .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 { + background: var(--surface-3); + } + .store-item:focus-visible, + .log-item:focus-visible { + @include m.focus-ring(-2px); + } + .store-item.selected, + .log-item.selected { + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent); + } + .store-top { display: flex; align-items: center; - gap: 4px; - font-size: 12px; - color: #a1a1aa; + 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%; } - .summary { + .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; - gap: 8px; flex-wrap: wrap; - margin-bottom: 16px; + gap: 6px; } - .summary-badge { - font-size: 12px; - padding: 3px 10px; + .chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 1px 8px; + border: 1px solid var(--border); border-radius: 99px; - border: 1px solid; - color: #e4e4e7; + background: var(--surface-2); + color: var(--text-2); + font-size: 11.5px; + line-height: 18px; + overflow-wrap: anywhere; } - .nodes { - display: flex; - flex-direction: column; - gap: 8px; + .chip.mono { + font-family: var(--font-mono); } - .node-card { - background: #18181b; - border: 1px solid #27272a; - border-radius: 8px; - padding: 12px 16px; - transition: border-color 0.15s; + .refs { + margin: 10px 0 0; + color: var(--text-2); + line-height: 1.8; } - .node-card:hover { - border-color: #3f3f46; + .facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; + min-width: 0; } - .node-header { + .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; + } + .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; - align-items: center; - gap: 8px; + flex-wrap: wrap; + gap: 6px; } - .kind-badge { - font-size: 11px; - padding: 2px 8px; + .tag { + padding: 0 5px; border-radius: 4px; - color: #fff; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; + background: var(--accent-soft); + color: var(--text-strong); + font-size: 10.5px; } - .node-label { - font-family: monospace; - font-size: 14px; - color: #e4e4e7; + .calls { + color: var(--text-3); + font-family: var(--font-sans, inherit); + font-size: 11px; } - .node-meta { - font-size: 12px; - color: #71717a; - margin-top: 4px; + .log { + min-width: 0; + padding-top: 16px; + border-top: 1px solid var(--border); } - .runtime-layout { + .log-layout { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: minmax(200px, 300px) minmax(0, 1fr); gap: 16px; + min-width: 0; } - .state-panel, - .actions-panel { - background: #18181b; - border: 1px solid #27272a; - border-radius: 10px; - padding: 16px; + .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; } - h3 { + .log-type { + @include m.truncate; + max-width: 100%; + 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 h5 { + 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; - color: #71717a; - margin-bottom: 12px; - letter-spacing: 0.05em; + 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 { display: flex; - align-items: center; gap: 8px; } - .action-count { - font-size: 11px; - padding: 1px 6px; - border-radius: 99px; - background: #3f3f46; - color: #a1a1aa; + .message:empty { + display: none; } - .state-tree { - font-family: monospace; - font-size: 12px; - color: #a1a1aa; - white-space: pre-wrap; - word-break: break-all; - max-height: 500px; - overflow: auto; + .message { + margin: 0; + color: var(--text-2); } - .action-list { + .kinds { display: flex; - flex-direction: column; + flex-wrap: wrap; gap: 6px; - max-height: 500px; - overflow: auto; } - .action-card { - display: flex; - justify-content: space-between; + .kind-chip { + display: inline-flex; align-items: center; - padding: 8px 12px; - background: #09090b; - border: 1px solid #27272a; - border-radius: 6px; + gap: 6px; + max-width: 100%; + height: 28px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 99px; + background: var(--surface-2); + color: var(--text-2); + font: inherit; + font-size: 12px; + font-weight: 500; cursor: pointer; - transition: border-color 0.15s; + transition: + background-color 0.15s var(--ease), + border-color 0.15s var(--ease), + color 0.15s var(--ease); } - .action-card:hover { - border-color: #3f3f46; + .kind-chip:hover { + border-color: var(--border-strong); + background: var(--surface-3); + color: var(--text); } - .action-card.selected { - border-color: var(--accent); + .kind-chip.active { + border-color: var(--accent-line); + background: var(--accent-soft); + color: var(--text-strong); } - .action-type { - font-family: monospace; - font-size: 13px; - color: #e4e4e7; + .kind-chip:focus-visible { + @include m.focus-ring; + } + .chip-text { + @include m.truncate; } - .action-time { + .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; - color: #71717a; + line-height: 16px; + text-align: center; + font-variant-numeric: tabular-nums; } - .detail-panel { - margin-top: 16px; - background: #18181b; - border: 1px solid var(--accent); - border-radius: 10px; - padding: 16px; + .nodes { + display: flex; + flex-direction: column; + gap: 8px; + @include m.enter; } - dl { - display: grid; - grid-template-columns: auto 1fr; - gap: 6px 12px; - font-size: 14px; + .node-card { + min-width: 0; + padding: 12px 16px; + @include m.panel; + border-radius: var(--radius-sm); } - dt { - color: #a1a1aa; + .node-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + min-width: 0; } - dd { - color: #e4e4e7; + .node-label { + min-width: 0; + font-family: var(--font-mono); + font-weight: 500; + color: var(--text-strong); + overflow-wrap: anywhere; } - pre { - font-family: monospace; + .node-meta { + margin-top: 6px; + color: var(--text-3); font-size: 12px; - white-space: pre-wrap; - word-break: break-all; + } + .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; + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + @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); + } + .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); + } } `, }) export class StoreInspector { - rpc = input(null); + readonly rpc = input(null); - filter = signal(''); - mode = signal<'source' | 'runtime'>('source'); - sourceEntries = signal([]); - runtimeState = signal(null); - selectedAction = signal(null); + readonly filter = signal(''); + readonly kind = signal(null); + readonly sourceEntries = signal([]); + readonly sourceLoaded = signal(false); + readonly pages = signal([]); + readonly failed = signal(false); + readonly selectedPageId = signal(null); + private readonly hostPageId = hostPageId(); + readonly selectedStoreId = signal(null); + readonly selectedSeq = signal(null); + readonly confirmSeq = signal(null); + readonly busy = signal(false); + readonly message = signal(''); - readonly kindLegend = Object.entries(KIND_COLORS).map(([kind, color]) => ({ kind, color })); + private readonly destroyRef = inject(DestroyRef); + private unsubscribe: (() => void) | null = null; - filteredEntries = computed(() => { - const f = this.filter().toLowerCase(); - return this.sourceEntries().filter( - (e) => e.name.toLowerCase().includes(f) || e.kind.toLowerCase().includes(f), + readonly page = computed(() => { + const pages = this.pages(); + return ( + pages.find((p) => p.pageId === this.selectedPageId()) ?? + pages.find((p) => p.pageId === this.hostPageId) ?? + pages[0] ?? + null ); }); - groupedEntries = computed(() => { - const entries = this.sourceEntries(); - const groups = new Map(); - for (const e of entries) { - groups.set(e.kind, (groups.get(e.kind) ?? 0) + 1); + readonly pageOptions = computed(() => + this.pages().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, + }); } - return [...groups.entries()].map(([kind, count]) => ({ kind, count })); + 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.page()?.log ?? []).some( + (e) => e.storeId === s.id && 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.classic?.state); }); - filteredActions = computed(() => { - const f = this.filter().toLowerCase(); - const actions = this.runtimeState()?.actions ?? []; - // Show newest first - const sorted = [...actions].reverse(); - if (!f) return sorted; - return sorted.filter((a) => a.type.toLowerCase().includes(f)); + readonly computedEntries = computed(() => + Object.entries(this.store()?.signal?.computed ?? {}).map(([key, value]) => ({ + key, + value: short(value, 160), + full: pretty(value), + })), + ); + + readonly storeLog = computed(() => { + const id = this.store()?.id; + const f = this.filter().trim().toLowerCase(); + return (this.page()?.log ?? []) + .filter( + (e) => e.storeId === id && (!f || e.type.toLowerCase().includes(f) || this.storeMatches(f)), + ) + .reverse(); + }); + + readonly entry = computed( + () => this.storeLog().find((e) => e.seq === this.selectedSeq()) ?? null, + ); + + 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() { effect(() => { const client = this.rpc(); - if (!client) return; - const my = client.scope('ng-devtools'); - - my.rpc - .call('get-ngrx-store') - .then((entries: NgrxStoreEntry[]) => { - this.sourceEntries.set(entries); - if (entries.length === 0) this.mode.set('runtime'); - }) - .catch(() => this.sourceEntries.set([])); - - // Subscribe to runtime state - my.rpc.sharedState('ngrx-store').then((state: any) => { - if (state?.subscribe) { - state.subscribe((val: NgrxRuntimeState) => this.runtimeState.set(val)); - } - }); + if (client) void this.load(client); }); + this.destroyRef.onDestroy(() => this.unsubscribe?.()); + } + + 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('ng-devtools').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 : []); + }; + apply(state.value()); + this.unsubscribe?.(); + this.unsubscribe = state.on('updated', apply); + } catch { + this.failed.set(true); + } + } + + retry() { + const client = this.rpc(); + if (client) void this.load(client); + } + + selectPage(pageId: string | null) { + this.selectedPageId.set(pageId); + this.selectedStoreId.set(null); + this.selectedSeq.set(null); + this.confirmSeq.set(null); + } + + selectStore(id: string) { + this.selectedStoreId.set(id); + this.selectedSeq.set(null); + this.confirmSeq.set(null); + } + + selectEntry(seq: number) { + this.selectedSeq.set(this.selectedSeq() === seq ? null : seq); + this.confirmSeq.set(null); + } + + async restore(seq: number) { + 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: 'restore', seq }, + })) as { ok?: boolean; message?: string; error?: string } | null; + this.message.set(result?.error ?? result?.message ?? 'Restored.'); + } catch { + this.message.set('Could not reach the page to restore the state.'); + } finally { + this.busy.set(false); + this.confirmSeq.set(null); + } + } + + clearFilters() { + this.filter.set(''); + this.kind.set(null); + } + + changeCount(id: string): number { + return (this.page()?.log ?? []).filter((e) => e.storeId === id).length; } kindColor(kind: string) { - return KIND_COLORS[kind] ?? '#71717a'; + return KIND_COLORS[kind] ?? 'var(--text-3)'; + } + + kindLabel(kind: string) { + return KIND_LABELS[kind] ?? kind; + } + + opLabel(op: string) { + return op === 'add' ? 'added' : op === 'remove' ? 'removed' : 'changed'; + } + + prettyText(value: unknown) { + return pretty(value); + } + + shortText(value: unknown) { + return short(value); } formatTime(ts: number) { diff --git a/app/src/pages/store-types.ts b/app/src/pages/store-types.ts new file mode 100644 index 0000000..c2608f5 --- /dev/null +++ b/app/src/pages/store-types.ts @@ -0,0 +1,144 @@ +export type NgrxKind = + | 'action' + | 'reducer' + | 'effect' + | 'selector' + | 'feature' + | 'store-setup' + | 'signal-store' + | 'signal-state' + | 'signal-method'; + +export interface NgrxStoreEntry { + name: string; + kind: NgrxKind; + file: string; + line: number; + detail?: string; +} + +export interface NgrxSignalStoreInfo { + id: string; + kind: 'signal-store' | 'signal-state'; + className: string; + name?: string; + declaredIn?: string; + scope: string; + stateKeys: string[]; + state: Record; + computed: Record; + methods: { name: string; calls: number; rx?: boolean }[]; + references: string[]; + writable: boolean; +} + +export interface NgrxClassicStoreInfo { + state: unknown; + devtools: boolean; + scope: string; +} + +export interface NgrxDiffEntry { + path: string; + op: 'add' | 'remove' | 'change'; + before?: unknown; + after?: unknown; +} + +export interface NgrxLogEntry { + seq: number; + source: 'signal-store' | 'store'; + storeId: string; + type: string; + args?: unknown[]; + action?: unknown; + timestamp: number; + diff: NgrxDiffEntry[]; + restorable: boolean; +} + +export interface NgrxPage { + pageId: string; + url: string; + title: string; + stores: NgrxSignalStoreInfo[]; + classic: NgrxClassicStoreInfo | null; + log: NgrxLogEntry[]; + reportedAt: number; +} + +export interface NgrxState { + pages: NgrxPage[]; +} + +export interface LiveStore { + id: string; + label: string; + kind: 'signal-store' | 'signal-state' | 'store'; + scope: string; + signal?: NgrxSignalStoreInfo; + classic?: NgrxClassicStoreInfo; +} + +const TYPE = '@type'; + +function tagged(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) && TYPE in value; +} + +function key(name: string): string { + return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name); +} + +export function pretty(value: unknown, indent = ''): string { + if (value === null) return 'null'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value !== 'object') return String(value); + const inner = indent + ' '; + if (Array.isArray(value)) { + if (!value.length) return '[]'; + return `[\n${value.map((v) => inner + pretty(v, inner)).join(',\n')}\n${indent}]`; + } + if (tagged(value)) { + const v = value as Record; + switch (v[TYPE]) { + case 'undefined': + return 'undefined'; + case 'Date': + return `Date(${v['value']})`; + case 'bigint': + return `${v['value']}n`; + case 'symbol': + return `Symbol(${v['value']})`; + case 'function': + return `ƒ ${v['name']}()`; + case 'Error': + return `${v['name'] ?? 'Error'}: ${v['message']}`; + case 'Element': + return `<${v['value']}>`; + case 'Map': { + const entries = (v['entries'] ?? []) as [unknown, unknown][]; + if (!entries.length) return `Map(${v['size']}) {}`; + const lines = entries.map( + ([k, val]) => `${inner}${pretty(k, inner)} => ${pretty(val, inner)}`, + ); + return `Map(${v['size']}) {\n${lines.join(',\n')}\n${indent}}`; + } + case 'Set': { + const values = (v['values'] ?? []) as unknown[]; + if (!values.length) return `Set(${v['size']}) {}`; + return `Set(${v['size']}) {\n${values.map((x) => inner + pretty(x, inner)).join(',\n')}\n${indent}}`; + } + default: + return String(v['value'] ?? v[TYPE]); + } + } + const entries = Object.entries(value as Record); + if (!entries.length) return '{}'; + return `{\n${entries.map(([k, v]) => `${inner}${key(k)}: ${pretty(v, inner)}`).join(',\n')}\n${indent}}`; +} + +export function short(value: unknown, max = 120): string { + const text = pretty(value).replace(/\s*\n\s*/g, ' '); + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} diff --git a/app/src/pages/tab-icon.ts b/app/src/pages/tab-icon.ts new file mode 100644 index 0000000..22bb0c7 --- /dev/null +++ b/app/src/pages/tab-icon.ts @@ -0,0 +1,81 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'app-tab-icon', + template: ` + + `, + styles: ` + :host { + display: inline-flex; + flex-shrink: 0; + } + `, +}) +export class TabIcon { + readonly name = input.required(); +} diff --git a/app/src/styles/_base.scss b/app/src/styles/_base.scss new file mode 100644 index 0000000..0ceb31c --- /dev/null +++ b/app/src/styles/_base.scss @@ -0,0 +1,95 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; +} + +* { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} + +html { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +body { + min-width: 320px; + font-family: var(--font-sans); + background: var(--bg); + color: var(--text); + font-feature-settings: 'cv11', 'ss01'; + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +:where(button, input, select, textarea) { + font-family: inherit; +} + +:where(button) { + -webkit-tap-highlight-color: transparent; +} + +:where(button:disabled, [aria-disabled='true']) { + cursor: not-allowed; +} + +:where(:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +textarea { + box-sizing: border-box; + display: block; + width: 100%; + max-width: 100%; + min-height: 5.5em; + max-height: 20em; + resize: vertical; + field-sizing: content; + line-height: 1.5; +} + +select:not([multiple]) { + appearance: none; + -webkit-appearance: none; + padding-right: 36px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238e8e99' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 14px; + text-overflow: ellipsis; + cursor: pointer; +} + +::selection { + background: color-mix(in srgb, var(--accent) 35%, transparent); +} + +@keyframes enter { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/app/src/styles/_mixins.scss b/app/src/styles/_mixins.scss new file mode 100644 index 0000000..d3ad5f0 --- /dev/null +++ b/app/src/styles/_mixins.scss @@ -0,0 +1,45 @@ +@mixin focus-ring($offset: 2px) { + outline: 2px solid var(--accent); + outline-offset: $offset; +} + +@mixin field-focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +@mixin panel($level: 1) { + border: 1px solid var(--border); + border-radius: var(--radius); + @if $level == 1 { + background: var(--surface); + } @else { + background: var(--surface-#{$level}); + } +} + +@mixin label { + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +@mixin soft($color) { + border-color: color-mix(in srgb, #{$color} 30%, transparent); + background: color-mix(in srgb, #{$color} 12%, transparent); + color: #{$color}; +} + +@mixin truncate { + overflow: hidden; + min-width: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +@mixin enter($duration: 0.35s) { + animation: enter $duration var(--ease) both; +} diff --git a/app/src/styles/_palette.scss b/app/src/styles/_palette.scss new file mode 100644 index 0000000..f0f3439 --- /dev/null +++ b/app/src/styles/_palette.scss @@ -0,0 +1,42 @@ +@use 'sass:map'; + +$neutrals: ( + bg: #0b0b0e, + surface: #131317, + surface-2: #1a1a20, + surface-3: #222229, + border: #25252c, + border-strong: #34343d, + text: #ececf1, + text-strong: #fafafa, + text-2: #a1a1aa, + text-3: #8e8e99, +); + +$accents: ( + amber: ( + base: #f5a524, + hover: #fbbf4d, + ink: #1c1300, + ), + ember: ( + base: #fb7a3c, + hover: #fd9a67, + ink: #1f0a00, + ), + gold: ( + base: #eab308, + hover: #facc15, + ink: #1a1400, + ), +); + +$status: ( + ok: #4ade80, + warn: #fbbf24, + danger: #f87171, +); + +@function accent($name, $key) { + @return map.get(map.get($accents, $name), $key); +} diff --git a/app/src/styles/_theme.scss b/app/src/styles/_theme.scss new file mode 100644 index 0000000..e6ab20d --- /dev/null +++ b/app/src/styles/_theme.scss @@ -0,0 +1,35 @@ +@use 'sass:map'; +@use 'palette' as p; + +$accent: amber !default; + +$radius: 14px; +$radius-sm: 9px; +$ease: cubic-bezier(0.2, 0.8, 0.2, 1); + +:root { + color-scheme: dark; + + @each $name, $value in p.$neutrals { + --#{$name}: #{$value}; + } + + --accent: #{p.accent($accent, base)}; + --accent-hover: #{p.accent($accent, hover)}; + --accent-ink: #{p.accent($accent, ink)}; + --accent-soft: color-mix(in srgb, var(--accent) 12%, transparent); + --accent-line: color-mix(in srgb, var(--accent) 45%, transparent); + + @each $name, $value in p.$status { + --#{$name}: #{$value}; + } + + --radius: #{$radius}; + --radius-sm: #{$radius-sm}; + --control-h: 34px; + --header-h: 45px; + --shadow: 0 10px 30px -16px rgba(0, 0, 0, 0.8); + --ease: #{$ease}; + --font-sans: system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; +} diff --git a/app/src/styles/main.scss b/app/src/styles/main.scss new file mode 100644 index 0000000..f5bedb4 --- /dev/null +++ b/app/src/styles/main.scss @@ -0,0 +1,4 @@ +@use 'theme' with ( + $accent: amber +); +@use 'base'; diff --git a/app/src/ui/select.ts b/app/src/ui/select.ts new file mode 100644 index 0000000..f94c728 --- /dev/null +++ b/app/src/ui/select.ts @@ -0,0 +1,414 @@ +import { + Component, + ElementRef, + Injector, + afterNextRender, + computed, + inject, + input, + model, + signal, + viewChild, +} from '@angular/core'; + +export interface SelectOption { + value: T; + label: string; + hint?: string; + disabled?: boolean; +} + +let nextId = 0; + +@Component({ + selector: 'app-select', + host: { + class: 'app-select', + '[class.open]': 'open()', + '(document:pointerdown)': 'onOutside($event)', + }, + template: ` + + @if (open()) { +
      + @for (opt of options(); track opt.value; let i = $index) { +
    • + + {{ opt.label }} + @if (opt.hint) { + {{ opt.hint }} + } +
    • + } @empty { +
    • {{ emptyText() }}
    • + } +
    + } + `, + styles: ` + :host { + position: relative; + display: inline-flex; + min-width: 0; + vertical-align: middle; + } + .trigger { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + height: var(--control-h, 34px); + padding: 0 10px 0 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; + transition: + border-color 150ms var(--ease), + box-shadow 150ms var(--ease), + background-color 150ms var(--ease); + } + .trigger:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--border-strong) 55%, var(--text-3)); + } + .trigger:focus-visible, + :host(.open) .trigger { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + } + .trigger:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .value { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .value.placeholder { + color: var(--text-3); + } + .value-hint { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + color: var(--text-3); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + } + .chevron { + flex: none; + width: 14px; + height: 14px; + fill: none; + stroke: var(--text-3); + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; + transition: transform 150ms var(--ease); + } + :host(.open) .chevron { + transform: rotate(180deg); + stroke: var(--accent); + } + .list { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 50; + box-sizing: border-box; + min-width: 100%; + max-width: min(480px, calc(100vw - 24px)); + max-height: 280px; + margin: 0; + padding: 4px; + overflow: auto; + list-style: none; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + box-shadow: + 0 16px 40px -12px rgb(0 0 0 / 0.7), + 0 0 0 1px rgb(0 0 0 / 0.2); + overscroll-behavior: contain; + } + .list.up { + top: auto; + bottom: calc(100% + 6px); + } + .list-in { + animation: list-in 140ms var(--ease) both; + } + @keyframes list-in { + from { + opacity: 0; + transform: translateY(-4px) scale(0.98); + } + } + li { + display: flex; + align-items: center; + gap: 8px; + min-height: 32px; + padding: 0 10px 0 8px; + border-radius: 6px; + color: var(--text); + font-size: 13px; + cursor: pointer; + white-space: nowrap; + } + li.active { + background: var(--surface-3); + } + li.selected { + color: var(--text-strong); + font-weight: 600; + } + li[aria-disabled='true'] { + color: var(--text-3); + cursor: default; + } + .check { + flex: none; + width: 14px; + height: 14px; + fill: none; + stroke: var(--accent); + stroke-width: 2.4; + stroke-linecap: round; + stroke-linejoin: round; + visibility: hidden; + } + li.selected .check { + visibility: visible; + } + .label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + .hint { + flex: none; + max-width: 50%; + overflow: hidden; + color: var(--text-3); + font-size: 12px; + font-weight: 400; + text-overflow: ellipsis; + } + .empty { + justify-content: center; + } + @media (prefers-reduced-motion: reduce) { + .list-in { + animation: none; + } + .chevron { + transition: none; + } + } + `, +}) +export class Select { + readonly options = input.required[]>(); + readonly value = model(null); + readonly placeholder = input('Select…'); + readonly ariaLabel = input(''); + readonly labelledBy = input(''); + readonly disabled = input(false); + readonly emptyText = input('Nothing to choose'); + + protected readonly uid = `app-select-${++nextId}`; + protected readonly open = signal(false); + protected readonly active = signal(-1); + protected readonly dropUp = signal(false); + protected readonly current = computed( + () => this.options().find((opt) => opt.value === this.value()) ?? null, + ); + + private readonly host = inject>(ElementRef); + private readonly injector = inject(Injector); + private readonly trigger = viewChild.required>('trigger'); + private readonly list = viewChild>('list'); + private typed = ''; + private typedAt = 0; + + protected toggle() { + if (this.open()) this.close(); + else this.show(); + } + + protected choose(index: number) { + const opt = this.options()[index]; + if (!opt || opt.disabled) return; + this.value.set(opt.value); + this.close(); + this.trigger().nativeElement.focus(); + } + + protected onKey(event: KeyboardEvent) { + const opts = this.options(); + const open = this.open(); + switch (event.key) { + case 'ArrowDown': + case 'ArrowUp': { + event.preventDefault(); + if (!open) { + this.show(); + return; + } + this.move(event.key === 'ArrowDown' ? 1 : -1); + return; + } + case 'Home': + case 'End': + if (!open) return; + event.preventDefault(); + this.setActive( + event.key === 'Home' ? this.firstEnabled(0, 1) : this.firstEnabled(opts.length - 1, -1), + ); + return; + case 'Enter': + case ' ': + event.preventDefault(); + if (open && this.active() >= 0) this.choose(this.active()); + else this.show(); + return; + case 'Escape': + if (!open) return; + event.preventDefault(); + event.stopPropagation(); + this.close(); + return; + case 'Tab': + if (open) this.close(); + return; + default: + if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) { + this.typeAhead(event.key); + } + } + } + + protected onBlur(event: FocusEvent) { + const next = event.relatedTarget as Node | null; + if (next && this.host.nativeElement.contains(next)) return; + this.close(); + } + + protected onOutside(event: PointerEvent) { + if (this.open() && !this.host.nativeElement.contains(event.target as Node)) this.close(); + } + + private show() { + if (this.disabled()) return; + const rect = this.host.nativeElement.getBoundingClientRect(); + const below = window.innerHeight - rect.bottom; + this.dropUp.set(below < 220 && rect.top > below); + const selected = this.options().findIndex((opt) => opt.value === this.value()); + this.open.set(true); + this.setActive(selected >= 0 ? selected : this.firstEnabled(0, 1)); + } + + private close() { + this.open.set(false); + this.active.set(-1); + } + + private move(step: 1 | -1) { + const opts = this.options(); + let i = this.active(); + for (let n = 0; n < opts.length; n++) { + i = Math.min(Math.max(i + step, 0), opts.length - 1); + if (!opts[i].disabled) break; + } + this.setActive(i); + } + + private firstEnabled(from: number, step: 1 | -1) { + const opts = this.options(); + for (let i = from; i >= 0 && i < opts.length; i += step) if (!opts[i].disabled) return i; + return -1; + } + + private setActive(index: number) { + this.active.set(index); + if (index < 0) return; + afterNextRender( + () => { + const el = this.list()?.nativeElement.querySelector( + `#${this.uid}-opt-${index}`, + ); + el?.scrollIntoView({ block: 'nearest' }); + }, + { injector: this.injector }, + ); + } + + private typeAhead(key: string) { + const now = performance.now(); + this.typed = now - this.typedAt > 600 ? key.toLowerCase() : this.typed + key.toLowerCase(); + this.typedAt = now; + const match = this.options().findIndex( + (opt) => !opt.disabled && opt.label.toLowerCase().startsWith(this.typed), + ); + if (match < 0) return; + if (this.open()) this.setActive(match); + else this.value.set(this.options()[match].value); + } +} diff --git a/app/vite.config.ts b/app/vite.config.ts index c5fd1d4..56b638a 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -8,6 +8,11 @@ export default defineConfig({ base: './', root: import.meta.dirname, build: { outDir: '../dist/devtools-ui', emptyOutDir: true }, + css: { + preprocessorOptions: { + scss: { loadPaths: [join(import.meta.dirname, 'src/styles')] }, + }, + }, optimizeDeps: { entries: [], exclude: [ @@ -19,7 +24,10 @@ export default defineConfig({ ], }, plugins: [ - angular({ tsconfig: join(import.meta.dirname, 'tsconfig.json') }), + angular({ + tsconfig: join(import.meta.dirname, 'tsconfig.json'), + inlineStylesExtension: 'scss', + }), devframeViteBridge(ngDevtools, { base: '/__ng-devtools/', auth: false }), ], }); diff --git a/docs/contributing/coding-standards.md b/docs/contributing/coding-standards.md new file mode 100644 index 0000000..a0ea087 --- /dev/null +++ b/docs/contributing/coding-standards.md @@ -0,0 +1,50 @@ +# Coding standards + +These rules go together with the Angular rules in [`AGENTS.md`](../../AGENTS.md). Prettier formats everything; run `pnpm format` before you commit. + +## TypeScript + +- Strict mode everywhere. Avoid `any`; use `unknown` and narrow it, or a small local interface. +- Prefer `const`. Use `readonly` for members that never change. +- Names describe what a thing is or does. Spell words out. Booleans start with `is`, `has` or `should`. +- No `I` prefix on interfaces and no `$` suffix on observables. +- Prefer `for...of` over `forEach` when the loop has side effects. +- Only catch errors you expect, and say what happens instead (fall back, skip, report). +- Comment the why, not the what. Public functions and exported types in `packages/ng-devtools` get a short doc comment when the name alone doesn't explain them. + +## Angular (panel UI and demo app) + +- Standalone components, `input()`, `output()`, `model()`, `computed()`, `linkedSignal()` and `inject()`. +- Don't set `standalone: true` or `changeDetection: OnPush`; both are defaults. +- Native control flow (`@if`, `@for`, `@switch`); `class` and `style` bindings instead of `ngClass` and `ngStyle`. +- Host bindings go in the `host` object, never `@HostBinding` or `@HostListener`. +- Derived state is a `computed()`. Don't write signals from an `effect()` when a `computed()` or `linkedSignal()` would do. +- Clean up subscriptions and listeners with `DestroyRef`. + +The panel's visual rules are in the [UI guidelines](./ui-guidelines.md). + +## Reading data from the page (`packages/ng-devtools`) + +The overlay runs inside the user's app, so it must be correct, cheap and invisible. + +- **Use Angular's debug APIs**, not guesses: `ng.getComponent`, `ng.getDirectives`, `ng.getDirectiveMetadata`, `ng.getListeners`, `ng.getInjector`, `ng.ɵgetInjectorProviders`, `ng.ɵgetInjectorResolutionPath`, `ng.ɵgetDependenciesFromInjectable`, `ng.ɵgetSignalGraph`. Check the shapes in `node_modules/@angular/core/fesm2022` before relying on a field. +- **Never write to the app's DOM.** Give elements and objects ids through a `WeakMap` (`element-id.ts`, `idFor` in `injector-tree.ts`), so ids stay stable between pushes and selection survives a refresh. +- **Strip bundler prefixes** from class names (`className()` in `injector-tree.ts`), so `_App` shows as `App`. +- **Don't match `_nghost-*` or `_ngcontent-*` attributes** with exact selectors; their names carry a suffix. Start from `[ng-version]` roots and the debug APIs. +- **Keep pushes cheap.** Skip unchanged reports (compare with the last JSON sent), re-send now and then so the server doesn't expire the page, and avoid `querySelectorAll('*')` on a timer; cache and rescan on a `MutationObserver` signal. +- **Serialize safely** with `serialize()` from `serialize.ts`: it limits depth and size and handles `Map`, `Set`, `Date`, class instances, cycles and Angular's signal sentinels. +- **Every report carries a `pageId`.** The server keeps one entry per page, expires it after 15 seconds without a push, and forgets it on `pagehide` (`forget--page`). Two open tabs must never overwrite each other. +- **Don't change app behavior.** Don't run the app's validators, guards or effects to learn something unless the user asked for it (recording, instrumenting), and say so in the UI. + +## Server side and agent tools + +- Register RPC functions with `defineRpcFunction` and valibot schemas; give them an `agent` description when an agent should call them. +- Agent tools (`ctx.agent.registerTool`) answer in markdown, say where the data came from (live page or source scan), and say what to do when there is no data. +- Source scans (`rpc/source-scan.ts` and friends) are a fallback and an enrichment for live data, not a replacement. + +## Tests + +- Every behavior change comes with a test in `packages/ng-devtools/src/__tests__` or `packages/ng-devtools/src/rpc/__tests__`. +- Page-side collectors are tested in jsdom with a fake `ng` object; see `injector-tree.test.ts` and `component-tree.test.ts`. +- Test names read as sentences: `it('keeps ids stable between collections')`. +- Run `pnpm test:devtools` and `pnpm test` before you push. diff --git a/docs/contributing/commit-message-guidelines.md b/docs/contributing/commit-message-guidelines.md new file mode 100644 index 0000000..8b3f8d2 --- /dev/null +++ b/docs/contributing/commit-message-guidelines.md @@ -0,0 +1,104 @@ +# Commit message guidelines + +Every commit follows one format, with scopes that match the areas of this repository. A consistent history makes the changelog easy to write and lets reviewers see what a change touches at a glance. + +Every commit is checked twice: + +- **Locally**, by a `commit-msg` git hook that `pnpm install` turns on (it sets `core.hooksPath` to `.githooks` and `commit.template` to [`.gitmessage`](../../.gitmessage)). It warns and lets the commit through; fix the message with `git commit --amend` before you push. +- **In CI**, on every pull request: each commit and the pull request title. Pull requests are squash merged, so **the title becomes the commit on `main`**. + +Run `pnpm commit:check` to check the commits on your branch before you push. To fix an older commit, reword it with `git rebase -i` and force-push your branch. While a review is in progress, prefer [fixup commits](./using-fixup-commits.md). `fixup!` and `squash!` commits and merge commits are skipped. + +## Format + +``` +(): + + + +
    +``` + +The header is required. The body is required for every type except `docs`. The footer is optional. + +### Type + +| Type | Use it for | +| ---------- | --------------------------------------------------------- | +| `feat` | A new feature | +| `fix` | A bug fix | +| `perf` | A change that improves performance | +| `refactor` | A code change that neither fixes a bug nor adds a feature | +| `test` | Adding missing tests or correcting existing ones | +| `docs` | Documentation only | +| `build` | Build system, packaging or dependencies | +| `ci` | CI configuration and scripts | +| `revert` | Reverting an earlier commit | + +### Scope + +Use the area of the devtools the change is about. Leave the scope out when a change spans many areas. + +| Scope | Area | +| ------------ | ------------------------------------------------------------------------------------ | +| `hub` | `@devframes/hub` integration, docks, `hub.ts`, `hub-docks.ts` | +| `ui` | The panel shell, theme, shared UI (`app/src/app.ts`, `app/src/styles`, `app/src/ui`) | +| `popup` | The in-page launcher and popup (`popup.ts`) | +| `overlay` | The page-side script and its collectors (`overlay.ts`) | +| `components` | Components inspector | +| `signals` | Signals inspector | +| `injectors` | Injectors (DI) inspector | +| `router` | Router inspector | +| `forms` | Forms inspector | +| `store` | NgRx store inspector | +| `pipes` | Pipes inspector | +| `http` | SSR & HTTP inspector | +| `analog` | Analog support | +| `mcp` | Agent tools and MCP resources | +| `extension` | The Chrome extension | +| `vite` | The Vite plugin | +| `demo` | The Angular Travel demo app (`src/`) and `examples/` | +| `deps` | Dependency updates (with `build`) | + +### Summary + +- Use the imperative, present tense: "add", not "added" or "adds". +- Don't capitalize the first letter, unless it starts a name (Analog, NgRx, SSR). +- No period at the end. +- Keep the whole header under 100 characters. + +### Body + +- Use the imperative, present tense. +- Explain why the change is needed. Compare the old behavior with the new one when that helps. +- At least 20 characters. + +### Footer + +- Reference issues and pull requests: `Fixes #123`, `Closes #45`. +- Breaking changes start with `BREAKING CHANGE: ` followed by a summary, a blank line, and a description with migration steps. +- Deprecations start with `DEPRECATED: ` followed by the recommended replacement. + +### Revert commits + +Start the header with `revert: ` followed by the header of the reverted commit. The body must say `This reverts commit ` and why. + +## Examples + +``` +feat(injectors): show which injector supplies each dependency + +The Injectors page listed every DOM element and the same built-in +tokens on each row. Collect only elements that carry a component or +directive, and report what each one injected and where Angular found it. + +Fixes #40 +``` + +``` +fix(router): pass match options to isActive when paths is set +``` + +``` +docs: document the panel design tokens +``` diff --git a/docs/contributing/ui-guidelines.md b/docs/contributing/ui-guidelines.md new file mode 100644 index 0000000..cb5a372 --- /dev/null +++ b/docs/contributing/ui-guidelines.md @@ -0,0 +1,69 @@ +# UI guidelines + +The devtools panel (`app/`) is a dark, dense tool that people keep open next to their app. Every page should look like it belongs to the same product, work with the keyboard, and pass axe with WCAG AA contrast. + +## Theme + +The palette lives in `app/src/styles/_palette.scss` and becomes CSS variables in `_theme.scss`. Change the brand in one place, `app/src/styles/main.scss`: + +```scss +@use 'theme' with ( + $accent: amber +); +``` + +Accents available: `amber` (default), `ember`, `gold`. Add one by extending `$accents` in `_palette.scss`. + +Always use the variables, never hex values: + +| Variable | Use | +| ------------------------------------------------- | ----------------------------------------------------------------------- | +| `--bg` | Page background, input fields | +| `--surface`, `--surface-2`, `--surface-3` | Cards and panels, hover, pressed or nested | +| `--border`, `--border-strong` | Dividers, control borders | +| `--text-strong`, `--text`, `--text-2`, `--text-3` | Headings, body, secondary, hints | +| `--accent`, `--accent-hover`, `--accent-ink` | Primary actions and selection; `--accent-ink` is text on an accent fill | +| `--accent-soft`, `--accent-line` | Selected backgrounds, focus glow, accent borders | +| `--ok`, `--warn`, `--danger` | Status | +| `--radius`, `--radius-sm` | Cards, controls | +| `--control-h` | 34px, the height of every input, select and button | +| `--ease`, `--font-mono` | Motion curve, code and tokens | + +Brand colors belong only to the brand they represent: NgRx keeps its purple on the NgRx view, the Angular view uses the Angular gradient for its logo and title, and Analog, NativeScript and Capacitor use their own colors on their pages. Everything else is amber. + +## Shared building blocks + +- **SCSS mixins** in `app/src/styles/_mixins.scss`, used with `@use 'mixins' as m;` in component styles: `m.focus-ring($offset)`, `m.field-focus`, `m.panel($level)`, `m.label`, `m.soft($color)`, `m.truncate`, `m.enter($duration)`. +- **Dropdown**: `app/src/ui/select.ts` (``). Never use a native ``, +})(Unbound); + +class OneWay { + city = 'Ankara'; +} +Component({ + selector: 'one-way-model', + imports: [FormsModule], + template: `
    `, +})(OneWay); + +class Twin { + a = new FormGroup({ name: new FormControl('') }); + b = new FormGroup({ name: new FormControl('') }); +} +Component({ + selector: 'twin-forms', + imports: [ReactiveFormsModule], + template: `
    +
    `, +})(Twin); + +class Nested { + form = form(signal({ name: '', address: { city: '' } }), (p) => required(p.name), { + submission: { action: async () => undefined }, + }); +} +Component({ + selector: 'nested-form', + imports: [FormField, FormRoot], + template: `
    + + `, +})(Nested); + +describe('form ids', () => { + it('builds ids from the owner, property and ordinal', async () => { + await mount(Twin); + const forms = collectForms(findForms(ngApi(), document.querySelectorAll('*'))); + expect(forms.map((f) => f.id)).toEqual(['Twin.a', 'Twin.b']); + expect(formIdFor('Page.ngForm #2')).toBe('Page.ngForm~2'); + expect(formIdFor('Page.ngForm#user@home')).toBe('Page.ngForm#user_home'); + }); + + it('keeps an id with its form, and never hands a removed form id to another', () => { + class GuestForm {} + const owner = new GuestForm(); + const make = () => ({ kind: 'reactive' as const, root: {}, owner, property: 'form' }); + const [a, b, c] = [make(), make(), make()]; + const assign = createFormIds('p'); + const idsFor = (forms: (typeof a)[]) => { + const ids = assign(forms as never, formLabels(forms as never)); + return forms.map((f) => ids.get(f.root)); + }; + expect(idsFor([a, b])).toEqual(['GuestForm.form@p', 'GuestForm.form~2@p']); + expect(idsFor([b])).toEqual(['GuestForm.form~2@p']); + expect(idsFor([b, c])).toEqual(['GuestForm.form~2@p', 'GuestForm.form~3@p']); + expect(idsFor([c, b])).toEqual(['GuestForm.form~3@p', 'GuestForm.form~2@p']); + }); + + it('keeps the same id across collectors, so a reload does not reuse another form id', async () => { + await mount(Twin); + const ids: string[][] = []; + for (let i = 0; i < 2; i++) { + const calls: any[] = []; + const collector = attachForms( + { + rpc: { + call: async (_: string, report: unknown) => void calls.push(report), + register() {}, + }, + }, + 'pg', + ngApi, + { show() {}, clear() {} }, + ); + collector.push(); + await new Promise((resolve) => setTimeout(resolve, 20)); + collector.stop(); + ids.push(calls.at(-1).forms.map((f: { id: string }) => f.id)); + } + expect(ids[0]).toEqual(['Twin.a@pg', 'Twin.b@pg']); + expect(ids[1]).toEqual(ids[0]); + }); +}); + +describe('ngModel drift', () => { + it('ignores an unbound ngModel the user edits', async () => { + await mount(Unbound); + type(document.getElementById('note') as HTMLInputElement, 'typed'); + const collect = () => + byKey(collectForms(findForms(ngApi(), document.querySelectorAll('*')))[0].root)['note']; + collect(); + expect(collect().modelDrift).toBeUndefined(); + }); + + it('reports a one-way [ngModel] only when the mismatch lasts two collects', async () => { + await mount(OneWay); + type(document.getElementById('city') as HTMLInputElement, 'Izmir'); + const collect = () => + byKey(collectForms(findForms(ngApi(), document.querySelectorAll('*')))[0].root)['city']; + expect(collect().modelDrift).toBeUndefined(); + expect(collect().modelDrift).toEqual({ model: 'Ankara', viewModel: 'Izmir' }); + }); +}); + +describe('view out of sync', () => { + it('needs the same drift on two collects in a row', async () => { + const fixture = await mount(Twin); + const input = document.getElementById('b-name') as HTMLInputElement; + fixture.componentInstance.b.controls.name.setValue('model', { emitEvent: false }); + input.value = 'screen'; + const collect = () => + byKey(collectForms(findForms(ngApi(), document.querySelectorAll('*')))[1].root)['name']; + expect(collect().dom.drift).toBeUndefined(); + const second = collect(); + expect(second.dom.drift).toBe('screen'); + expect(second.binding.kind).toBe('native'); + }); +}); + +describe('Signal Forms details', () => { + it('shows standard schema issue messages and paths, the error summary, names and metadata', () => { + const HINT = createMetadataKey(); + const tree = signalForm({ name: '', age: 1 }, (p: any) => { + required(p.name); + metadata(p.age, HINT, () => 'years'); + validate(p.age, () => ({ + kind: 'standardSchema', + issue: { message: 'Too young', path: [{ key: 'age' }] }, + })); + }); + tree.name(); + tree.age(); + const root = serializeField(tree()); + const age = byKey(root)['age']; + expect(age.errors[0]).toMatchObject({ + kind: 'standardSchema', + message: 'Too young', + params: { path: 'age' }, + }); + expect(age.metadata).toEqual(['years']); + expect(typeof age.name).toBe('string'); + const [collected] = collectForms({ + forms: [{ kind: 'signal', root: tree(), owner: null }], + elements: new WeakMap(), + }); + expect(collected.errorSummary?.map((e) => [e.path, e.kind])).toEqual([ + ['name', 'required'], + ['age', 'standardSchema'], + ]); + }); + + it('records a submit of a child field', async () => { + const fixture = await mount(Nested); + const calls: any[] = []; + const collector = attachForms( + { + rpc: { call: async (_: string, report: unknown) => void calls.push(report), register() {} }, + }, + 'pg', + ngApi, + { show() {}, clear() {} }, + ); + stops.push(collector.stop); + collector.push(); + await new Promise((resolve) => setTimeout(resolve, 20)); + await submit(fixture.componentInstance.form.address, async () => undefined); + await new Promise((resolve) => setTimeout(resolve, 150)); + const submits = calls.at(-1).events.filter((e: { type: string }) => e.type === 'submit'); + expect(submits).toEqual([expect.objectContaining({ path: 'address', outcome: 'ran' })]); + }); +}); + +describe('validator probing', () => { + it('runs validators again only when the value or the validators change', () => { + const check = vi.fn(() => null); + const control = new FormControl('a', check); + check.mockClear(); + controlFacts(control as any); + controlFacts(control as any); + expect(check).toHaveBeenCalledTimes(2); + check.mockClear(); + control.setValue('b'); + check.mockClear(); + controlFacts(control as any); + expect(check).toHaveBeenCalledTimes(2); + }); + + it('flags stale errors when a validator reads a sibling that changed', () => { + const password = new FormControl('secret'); + const confirm = new FormControl('secret', (c) => + c.value === c.parent?.get('password')?.value ? null : { mismatch: true }, + ); + new FormGroup({ password, confirm }); + confirm.updateValueAndValidity(); + expect(controlFacts(confirm as any).stale ?? []).toEqual([]); + password.setValue('changed'); + expect(controlFacts(confirm as any).stale).toContain('mismatch'); + }); +}); + +describe('submit reachability', () => { + it('is info, and skips a disabled button on an invalid form', () => { + const findings = lintForm({ + id: 'A.form@pg', + kind: 'reactive', + owner: 'A', + label: 'A.form', + submitDom: { + tag: 'form', + buttons: 0, + disabledButtons: 0, + novalidate: true, + nativeInvalid: 0, + reasons: ['No submit button.'], + }, + root: { + key: '', + path: '', + type: 'group', + status: 'VALID', + touched: false, + dirty: false, + bound: false, + errors: [], + }, + }); + expect(findings.find((f) => f.rule === 'submit-unreachable')?.severity).toBe('info'); + }); +}); + +describe('set-value coercion', () => { + it('keeps the type of the current value', () => { + expect(coerceToCurrent('123', 'abc')).toEqual({ value: '123' }); + expect(coerceToCurrent('"quoted"', 'abc')).toEqual({ value: 'quoted' }); + expect(coerceToCurrent('42', 1)).toEqual({ value: 42 }); + expect(coerceToCurrent('x', 1)).toHaveProperty('error'); + expect(coerceToCurrent('TRUE', false)).toEqual({ value: true }); + expect(coerceToCurrent('yes', false)).toHaveProperty('error'); + expect(coerceToCurrent('2024-01-02', new Date(0))).toEqual({ value: new Date('2024-01-02') }); + expect(coerceToCurrent('[1,2]', [])).toEqual({ value: [1, 2] }); + expect(coerceToCurrent('text', { a: 1 })).toHaveProperty('error'); + expect(coerceToCurrent('plain', null)).toEqual({ value: 'plain' }); + }); + + it('keeps text as text when the current value is null, unless it is explicit JSON', () => { + expect(coerceToCurrent('12345', null)).toEqual({ value: '12345' }); + expect(coerceToCurrent('02134', undefined)).toEqual({ value: '02134' }); + expect(coerceToCurrent('true', null)).toEqual({ value: 'true' }); + expect(coerceToCurrent('null', 'x')).toEqual({ value: 'null' }); + expect(coerceToCurrent('null', null)).toEqual({ value: null }); + expect(coerceToCurrent('"7"', null)).toEqual({ value: '7' }); + expect(coerceToCurrent('{"a":1}', null)).toEqual({ value: { a: 1 } }); + expect(coerceToCurrent('[1]', null)).toEqual({ value: [1] }); + const number = document.createElement('input'); + number.type = 'number'; + expect(coerceToCurrent('7', null, number)).toEqual({ value: 7 }); + expect(coerceToCurrent('x', null, number)).toHaveProperty('error'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + expect(coerceToCurrent('true', null, checkbox)).toEqual({ value: true }); + }); +}); + +class SubmitHosts { + bare = new FormGroup({ a: new FormControl('') }); + withOutput = new FormGroup({ b: new FormControl('') }); + withDom = new FormGroup({ c: new FormControl('') }); + save() {} +} +Component({ + selector: 'app-submit-hosts', + imports: [ReactiveFormsModule], + template: ` +
    +
    +
    + `, +})(SubmitHosts); + +describe('submit listeners on non-form hosts', () => { + it('ignores the form directive own host listener and counts template listeners', async () => { + await mount(SubmitHosts); + const { forms } = findForms(ngApi(), document.querySelectorAll('*')); + const byProperty = Object.fromEntries( + forms.map((f) => [propertyHolding(f), !!f.submitListener]), + ); + expect(byProperty).toEqual({ bare: false, withOutput: true, withDom: true }); + }); +}); diff --git a/packages/ng-devtools/src/__tests__/forms-read.test.ts b/packages/ng-devtools/src/__tests__/forms-read.test.ts index 0666dba..f479c09 100644 --- a/packages/ng-devtools/src/__tests__/forms-read.test.ts +++ b/packages/ng-devtools/src/__tests__/forms-read.test.ts @@ -19,7 +19,7 @@ import { validateTree, } from '@angular/forms/signals'; import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { collectForms, forgetFormSecrets, @@ -147,6 +147,21 @@ describe('reactive internals', () => { expect(validatorNames(email as any)).toEqual(['email']); }); + it('re-probes a cached validator after a while, so outside state changes show as stale', () => { + vi.useFakeTimers(); + try { + let max = 10; + const guests = new FormControl(5, (c) => ((c.value ?? 0) > max ? { max: true } : null)); + expect(controlFacts(guests as any).stale).toBeUndefined(); + max = 3; + expect(controlFacts(guests as any).stale).toBeUndefined(); + vi.advanceTimersByTime(6000); + expect(controlFacts(guests as any).stale).toEqual(['max']); + } finally { + vi.useRealTimers(); + } + }); + it('drops control events caused by probing a validator', async () => { const group = new FormGroup({ a: new FormControl(''), noisy: new FormControl('x') }); group.controls.noisy.setValidators(() => { @@ -212,18 +227,22 @@ describe('real components', () => { expect(collected.submitDom?.reasons.join(' ')).toContain('type="button"'); }); - it('labels template validators and flags a disabled submit button', async () => { + it('labels template validators and flags a disabled submit button only on a valid form', async () => { const fixture = TestBed.createComponent(ModelForm); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); - const [collected] = collectForms( - findForms(ngApi(), fixture.nativeElement.querySelectorAll('*')), - ); + const collect = () => + collectForms(findForms(ngApi(), fixture.nativeElement.querySelectorAll('*')))[0]; + const collected = collect(); const city = byKey(collected.root)['city']; expect(city.errors[0]).toMatchObject({ kind: 'minlength', source: 'directive' }); expect(city.validatorNames).toEqual(expect.arrayContaining(['required (template)'])); - expect(collected.submitDom?.reasons.join(' ')).toContain('disabled'); + expect(collected.submitDom?.reasons).toEqual([]); + const input = fixture.nativeElement.querySelector('input') as HTMLInputElement; + input.value = 'Izmir'; + input.dispatchEvent(new Event('input')); + expect(collect().submitDom?.reasons.join(' ')).toContain('disabled'); }); }); @@ -336,8 +355,10 @@ describe('DOM facts', () => { expect(domFacts(el, { value: 'old', secret: true }).drift).toBeUndefined(); }); - it('explains why a non-form host never submits', () => { + it('explains why a non-form host never submits, only when something listens for submit', () => { document.body.innerHTML = '
    '; - expect(submitDom(document.getElementById('f')!).reasons[0]).toContain('not a
    '); + const host = document.getElementById('f')!; + expect(submitDom(host).reasons).toEqual([]); + expect(submitDom(host, { submitListener: true }).reasons[0]).toContain('not a '); }); }); diff --git a/packages/ng-devtools/src/__tests__/forms-source.test.ts b/packages/ng-devtools/src/__tests__/forms-source.test.ts index 2a4f367..3dcfd29 100644 --- a/packages/ng-devtools/src/__tests__/forms-source.test.ts +++ b/packages/ng-devtools/src/__tests__/forms-source.test.ts @@ -36,4 +36,39 @@ describe('form source scan', () => { expect(formSourceIn(file, 'a.ts', 'Account', 'account', '0')?.rules).toEqual([]); expect(formSourceIn(file, 'a.ts', 'Missing', 'x', '')).toBeNull(); }); + + it('follows schema constants declared outside the class, in the file or elsewhere', () => { + const page = `import { ADDRESS } from './address'; +const PROFILE = schema((p) => { + required(p.email); + apply(p.address, ADDRESS); +}); + +export class ProfilePage { + profile = form(this.model, PROFILE); +} +`; + const other = `export const ADDRESS = schema
    ((a) => { + required(a.city); +}); +`; + const lookup = (name: string) => + name === 'ADDRESS' ? { content: other, file: 'src/address.ts' } : null; + const email = formSourceIn(page, 'src/profile.ts', 'ProfilePage', 'profile', 'email', lookup); + expect(email?.schemas?.map((s) => [s.name, s.file, s.line])).toEqual([ + ['PROFILE', 'src/profile.ts', 2], + ['ADDRESS', 'src/address.ts', 1], + ]); + expect(email?.rules.map((r) => [r.file, r.line])).toEqual([['src/profile.ts', 3]]); + const city = formSourceIn( + page, + 'src/profile.ts', + 'ProfilePage', + 'profile', + 'address.city', + lookup, + ); + expect(city?.rules.map((r) => [r.file, r.line])).toEqual([['src/address.ts', 2]]); + expect(sourceText(city)).toContain('Schema ADDRESS at src/address.ts:1'); + }); }); diff --git a/packages/ng-devtools/src/__tests__/http-server.test.ts b/packages/ng-devtools/src/__tests__/http-server.test.ts new file mode 100644 index 0000000..74134a5 --- /dev/null +++ b/packages/ng-devtools/src/__tests__/http-server.test.ts @@ -0,0 +1,79 @@ +import { createHostContext } from 'devframe/node'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import ngDevtools from '../devframe.ts'; +import type { HttpState } from '../types.ts'; + +async function boot() { + const host = { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost', + getStorageDir: () => '', + }; + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: host as never }); + await ngDevtools.setup(ctx as never); + const push = (name: string, payload: unknown) => + ctx.rpc.invokeLocal( + `ng-devtools:${name}` as never, + ...([payload] as never), + ) as Promise; + const state = async () => + ( + await ( + ctx.rpc as unknown as { + sharedState: { get: (key: string) => Promise<{ value: () => HttpState }> }; + } + ).sharedState.get('ng-devtools:http') + ).value(); + return { push, state }; +} + +const report = (pageId: string, extra: Record = {}) => ({ + pageId, + url: `/${pageId}`, + initialUrl: `/${pageId}`, + title: pageId, + hydration: { enabled: true, warnings: [], skipHydrationHosts: [] }, + calls: [], + ...extra, +}); + +describe('push-http', () => { + afterEach(() => vi.useRealTimers()); + + it('keeps pages in first-seen order and keeps the payload between pushes', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const { push, state } = await boot(); + vi.setSystemTime(1000); + expect( + await push('push-http', report('a', { payload: { found: true, size: 2, entries: [] } })), + ).toEqual({ needPayload: false }); + vi.setSystemTime(2000); + await push('push-http', report('b', { payload: { found: false, size: 0, entries: [] } })); + vi.setSystemTime(3000); + expect(await push('push-http', report('a', { url: '/a/next' }))).toEqual({ + needPayload: false, + }); + vi.setSystemTime(4000); + await push('push-http', report('b')); + const pages = (await state()).pages; + expect(pages.map((p) => p.pageId)).toEqual(['a', 'b']); + expect(pages[0]).toMatchObject({ + url: '/a/next', + initialUrl: '/a', + firstSeenAt: 1000, + reportedAt: 3000, + payload: { found: true, size: 2 }, + }); + expect(pages[1]).toMatchObject({ firstSeenAt: 2000, reportedAt: 4000 }); + }); + + it('asks for the payload of an unknown page without listing it as not server rendered', async () => { + const { push, state } = await boot(); + expect(await push('push-http', report('c'))).toEqual({ needPayload: true }); + expect((await state()).pages).toEqual([]); + expect( + await push('push-http', report('c', { payload: { found: true, size: 1, entries: [] } })), + ).toEqual({ needPayload: false }); + expect((await state()).pages).toMatchObject([{ pageId: 'c', payload: { found: true } }]); + }); +}); diff --git a/packages/ng-devtools/src/__tests__/http.test.ts b/packages/ng-devtools/src/__tests__/http.test.ts index fc931a8..f911bcd 100644 --- a/packages/ng-devtools/src/__tests__/http.test.ts +++ b/packages/ng-devtools/src/__tests__/http.test.ts @@ -1,5 +1,18 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it } from 'vitest'; +import '@angular/compiler'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApplicationRef, Injector, PLATFORM_ID, runInInjectionContext } from '@angular/core'; +import { + HttpHeaders, + HttpRequest, + HttpResponse, + type HttpEvent, + type HttpHandlerFn, +} from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ngDevtoolsHttpInterceptor, parseBody, resetTransferEntries } from '../http.ts'; +import { appIdOf, isHydrationMessage, scanHydration } from '../http-hydration.ts'; +import { attachHttp } from '../http-overlay.ts'; import { MAX_DELAY_MS, MAX_RULES, @@ -186,3 +199,244 @@ describe('decodePayload', () => { expect(summary.error).toBeTruthy(); }); }); + +describe('TransferState payload labels', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('decodes Analog entries and labels hydration annotations', () => { + const script = document.createElement('script'); + script.id = 'ng-state'; + script.type = 'application/json'; + script.textContent = JSON.stringify({ + analog_123: { body: { ok: true }, status: 200, statusText: 'OK', url: '/api/x', headers: {} }, + __nghData__: [{ c: 1 }], + __nghDeferData__: {}, + '9': { b: 'hi', s: 200, u: '/api/y' }, + }); + document.body.append(script); + const entries = decodePayload(document).entries; + const byKey = Object.fromEntries(entries.map((e) => [e.key, e])); + expect(byKey['analog_123']).toMatchObject({ + source: 'analog', + http: { url: '/api/x', status: 200, statusText: 'OK' }, + value: { ok: true }, + }); + expect(byKey['__nghData__'].source).toBe('hydration'); + expect(byKey['__nghDeferData__'].source).toBe('hydration'); + expect(byKey['9'].source).toBe('http'); + expect(sanitizePayload({ found: true, entries }).entries.map((e) => e.source)).toEqual([ + 'http', + 'analog', + 'hydration', + 'hydration', + ]); + }); +}); + +describe('hydration helpers', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('matches only hydration error codes', () => { + expect(isHydrationMessage('NG0500: During hydration Angular expected')).toBe(true); + expect(isHydrationMessage('NG0507: HTML was modified')).toBe(true); + expect(isHydrationMessage('NG05104: The selector did not match')).toBe(false); + expect(isHydrationMessage('NG0200: circular dependency')).toBe(false); + }); + + it('reads the app id and counts node statuses with mismatch details', () => { + document.body.innerHTML = + '

    a

    '; + const patch = (selector: string, info: unknown) => + ((document.querySelector(selector) as unknown as Record)[ + '__ngDebugHydrationInfo__' + ] = info); + patch('app-shop', { status: 'hydrated' }); + patch('p', { status: 'skipped' }); + patch('app-card', { + status: 'mismatched', + expectedNodeDetails: '
    ', + actualNodeDetails: '', + }); + expect(appIdOf(document)).toBe('shop'); + expect(scanHydration(document)).toEqual({ + hydrated: 1, + skipped: 1, + mismatched: 1, + mismatches: [{ component: 'app-card', expected: '
    ', actual: '' }], + }); + }); + + it('sanitizes the new hydration fields', () => { + expect( + sanitizeHydration({ + enabled: true, + nodes: { hydrated: 3, skipped: 'x' }, + mismatches: [{ component: 'app-card', expected: '
    ' }, { expected: 'x' }], + warningsCaptured: true, + }), + ).toMatchObject({ + nodes: { hydrated: 3, skipped: 0, mismatched: 0 }, + mismatches: [{ component: 'app-card', expected: '
    ' }], + warningsCaptured: true, + }); + expect(sanitizeHydration({ enabled: false })?.warningsCaptured).toBe(false); + }); +}); + +describe('interceptor', () => { + const g = globalThis as { ngDevMode?: unknown }; + let saved: unknown; + beforeEach(() => { + saved = g.ngDevMode; + g.ngDevMode ??= true; + httpRegistry().calls = []; + httpRegistry().rules = []; + resetTransferEntries(); + }); + afterEach(() => { + g.ngDevMode = saved; + vi.useRealTimers(); + document.body.innerHTML = ''; + delete httpRegistry().rules; + sessionStorage.clear(); + }); + + const injector = Injector.create({ providers: [{ provide: PLATFORM_ID, useValue: 'browser' }] }); + const run = (req: HttpRequest, next: HttpHandlerFn) => + runInInjectionContext(injector, () => ngDevtoolsHttpInterceptor(req, next)); + const later = + (body: unknown): HttpHandlerFn => + () => + new Observable>((subscriber) => { + const t = setTimeout(() => { + subscriber.next(new HttpResponse({ status: 200, body })); + subscriber.complete(); + }, 5); + return () => clearTimeout(t); + }); + + it('keeps text mock bodies as text and parses JSON ones', () => { + expect(parseBody('{"a":1}', 'text')).toBe('{"a":1}'); + expect(parseBody(undefined, 'text')).toBe(''); + expect(parseBody('{"a":1}')).toEqual({ a: 1 }); + }); + + it('records a request cancelled during a delay rule', () => { + vi.useFakeTimers(); + storeRules([rule({ status: undefined, delayMs: 1000 })]); + const sub = run(new HttpRequest('GET', '/api/products'), later([])).subscribe(); + vi.advanceTimersByTime(100); + sub.unsubscribe(); + expect(httpRegistry().calls).toEqual([ + expect.objectContaining({ error: 'cancelled during the delay', status: 0, faulted: true }), + ]); + }); + + it('counts a TransferState match as a cache hit once', async () => { + document.body.innerHTML = + ''; + const get = () => + new Promise((resolve) => + run(new HttpRequest('GET', '/api/products'), later([])).subscribe({ + complete: resolve, + }), + ); + await get(); + await get(); + expect(httpRegistry().calls?.map((c) => c.cacheHit)).toEqual([true, false]); + }); + + it('does not tag a payload URL as a cache hit when Angular would skip the cache', async () => { + document.body.innerHTML = + ''; + const get = (req: HttpRequest, via = run) => + new Promise((resolve) => via(req, later([])).subscribe({ complete: resolve })); + await get( + new HttpRequest('GET', '/api/trips', null, { + headers: new HttpHeaders({ Authorization: 'Bearer x' }), + }), + ); + await get(new HttpRequest('GET', '/api/trips', null, { transferCache: false })); + await get(new HttpRequest('GET', '/api/trips', null, { withCredentials: true })); + expect(httpRegistry().calls?.map((c) => c.cacheHit)).toEqual([false, false, false]); + + const stable = Injector.create({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: ApplicationRef, useValue: { whenStable: () => Promise.resolve() } }, + ], + }); + const runStable = (req: HttpRequest, next: HttpHandlerFn) => + runInInjectionContext(stable, () => ngDevtoolsHttpInterceptor(req, next)); + runStable(new HttpRequest('GET', '/other'), later([])).subscribe().unsubscribe(); + await Promise.resolve(); + await get(new HttpRequest('GET', '/api/trips'), runStable); + expect(httpRegistry().calls?.at(-1)?.cacheHit).toBe(false); + await get(new HttpRequest('GET', '/api/trips')); + expect(httpRegistry().calls?.at(-1)?.cacheHit).toBe(true); + }); +}); + +describe('attachHttp', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('sends the payload once, then again only when the server asks for it', async () => { + const sent: Record[] = []; + let answer: unknown = { needPayload: false }; + const my = { + rpc: { + call: async (name: string, report?: unknown) => { + if (name === 'push-http') sent.push(report as Record); + if (name === 'get-http-rules') return []; + return name === 'push-http' ? answer : undefined; + }, + register: () => {}, + }, + }; + const http = attachHttp(my, 'p1'); + await http.push(); + httpRegistry().calls = [ + { + id: 'c', + url: '/a', + method: 'GET', + status: 200, + durationMs: 1, + side: 'client', + cacheHit: false, + faulted: false, + at: 1, + }, + ]; + answer = { needPayload: true }; + await http.push(); + answer = { needPayload: false }; + await http.push(); + expect(sent.map((r) => 'payload' in r)).toEqual([true, false, true]); + expect(sent[0]).toMatchObject({ pageId: 'p1', initialUrl: '/' }); + expect(sent[0]['hydration']).toMatchObject({ warningsCaptured: false, mismatches: [] }); + httpRegistry().calls = []; + }); +}); + +describe('hydration scanner', () => { + it('reuses the DOM scan until the hydration counters change', async () => { + const { createHydrationScanner } = await import('../http-overlay.ts'); + let calls = 0; + const scanner = createHydrationScanner(() => { + calls++; + return { hydrated: 3, skipped: 0, mismatched: 0, mismatches: [] }; + }); + const counters = { hydratedNodes: 3 }; + for (let i = 0; i < 10; i++) scanner(counters); + expect(calls).toBe(3); + scanner({ hydratedNodes: 5 }); + expect(calls).toBe(4); + }); +}); diff --git a/packages/ng-devtools/src/__tests__/hub.test.ts b/packages/ng-devtools/src/__tests__/hub.test.ts new file mode 100644 index 0000000..cf68b59 --- /dev/null +++ b/packages/ng-devtools/src/__tests__/hub.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { initNgDevtoolsHub } from '../hub.ts'; +import { makeProject } from './analog-fixture.ts'; + +const hubs: { close: () => Promise }[] = []; + +async function boot(cwd: string) { + const hub = initNgDevtoolsHub({ cwd, ws: false, auth: false, allowedOrigins: false }); + hubs.push(hub); + await hub.ready; + const ctx = await hub.context; + const docks = [...ctx.docks.views.values()].map((dock) => ({ + id: dock.id, + title: dock.title, + url: 'url' in dock ? dock.url : undefined, + frameId: 'frameId' in dock ? dock.frameId : undefined, + badge: dock.badge, + groupId: dock.groupId, + visibility: dock.visibility, + })); + return { hub, ctx, docks }; +} + +afterEach(async () => { + for (const hub of hubs.splice(0)) await hub.close(); +}); + +describe('ng-devtools hub', () => { + it('adds a dock per tool, Analog included even outside Analog apps', async () => { + const { docks } = await boot(makeProject({ 'package.json': '{}' })); + const ours = docks.filter((d) => d.id.startsWith('ng-devtools:')); + expect(ours.map((d) => [d.id, d.url])).toEqual([ + ['ng-devtools:angular', '/__devframes/ng-devtools/?view=angular'], + ['ng-devtools:ngrx', '/__devframes/ng-devtools/?view=ngrx'], + ['ng-devtools:analog', '/__devframes/ng-devtools/?view=analog'], + ['ng-devtools:nativescript', '/__devframes/ng-devtools/?view=nativescript'], + ['ng-devtools:capacitor', '/__devframes/ng-devtools/?view=capacitor'], + ]); + expect(ours.filter((d) => d.title.endsWith('Coming Soon')).map((d) => d.id)).toEqual([ + 'ng-devtools:nativescript', + 'ng-devtools:capacitor', + ]); + expect(ours.some((d) => d.badge || d.groupId)).toBe(false); + expect(new Set(ours.map((d) => d.frameId))).toEqual(new Set(['ng-devtools'])); + expect(docks.find((d) => d.id === 'ng-devtools')?.visibility).toBe('false'); + }); + + it('serves the dock script, the viewer and the MCP tools', async () => { + const { hub, ctx } = await boot(makeProject({ 'package.json': '{}' })); + const get = (path: string) => hub.handler(new Request(`http://localhost${path}`)); + expect((await get('/__devframes/embedded.js')).status).toBe(200); + expect((await get('/__devframes/')).status).toBe(200); + const meta = await (await get('/__devframes/ng-devtools/__connection.json')).json(); + expect(meta.configs.ui.branding.primaryColor).toBe('#f5a524'); + const forms = (await ctx.agent.invoke('ng-devtools:inspect-forms', {})) as { markdown: string }; + expect(forms.markdown).toContain('No forms'); + }); +}); diff --git a/packages/ng-devtools/src/__tests__/injector-tree.test.ts b/packages/ng-devtools/src/__tests__/injector-tree.test.ts new file mode 100644 index 0000000..d288b50 --- /dev/null +++ b/packages/ng-devtools/src/__tests__/injector-tree.test.ts @@ -0,0 +1,201 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { + MAX_INJECTOR_NODES, + collectInjectorTree, + providerKind, + tokenName, +} from '../injector-tree.ts'; + +class ElementRef { + static __NG_ELEMENT_ID__ = 1; +} +class Store {} +class Logger {} +class App {} +class Card {} +class Tooltip {} +const API_URL = { _desc: 'API_URL', toString: () => 'InjectionToken API_URL' }; + +function fakeNg() { + document.body.innerHTML = ` + +
    Home
    +
    +
    `; + const root = document.querySelector('app-root')!; + const card = document.querySelector('app-card')!; + + const nullInjector = { kind: 'null' }; + const platform = { kind: 'env', scopes: new Set(['platform']), source: 'Platform: core' }; + const rootEnv = { kind: 'env', scopes: new Set(['root']), source: 'Environment Injector' }; + const routeEnv = { kind: 'env', scopes: new Set(), source: 'Route: cards' }; + const node = (el: Element) => ({ kind: 'node', el }); + + const meta = (inj: any) => + inj.kind === 'node' + ? { type: 'element', source: inj.el } + : inj.kind === 'null' + ? { type: 'null', source: null } + : { type: 'environment', source: inj.source }; + + return { + root, + card, + ng: { + getInjector: (el: Element) => node(el), + getComponent: (el: Element) => (el === root ? new App() : el === card ? new Card() : null), + getDirectives: (el: Element) => + el === root ? [new App()] : el === card ? [new Card(), new Tooltip()] : [], + ɵgetInjectorMetadata: meta, + ɵgetInjectorResolutionPath: (inj: any) => + inj.el === card + ? [inj, node(root), routeEnv, rootEnv, platform, nullInjector] + : [inj, rootEnv, platform, nullInjector], + ɵgetInjectorProviders: (inj: any) => { + if (inj === rootEnv) + return [ + { token: Store, provider: Store, importPath: [App] }, + { token: API_URL, provider: { provide: API_URL, useValue: '/api' } }, + ]; + if (inj.el === card) + return [ + { token: ElementRef, provider: ElementRef }, + { + token: Logger, + provider: { provide: Logger, useFactory: () => 0 }, + isViewProvider: true, + }, + ]; + return [{ token: ElementRef, provider: ElementRef }]; + }, + ɵgetDependenciesFromInjectable: (_inj: any, ctor: unknown) => + ctor === Card + ? { + dependencies: [ + { token: Store, flags: { optional: false }, providedIn: rootEnv }, + { token: Logger, flags: { self: true }, providedIn: node(card) }, + { token: API_URL, flags: { optional: true } }, + ], + } + : { dependencies: [] }, + } as any, + }; +} + +describe('collectInjectorTree', () => { + it('only lists elements that carry a component or directive', () => { + const { ng } = fakeNg(); + const { roots } = collectInjectorTree(ng); + expect(roots).toHaveLength(1); + expect(roots[0].injector).toMatchObject({ name: 'app-root', component: 'App' }); + expect(roots[0].children.map((c) => c.injector.name)).toEqual(['app-card']); + expect(roots[0].children[0].injector.directives).toEqual(['Card', 'Tooltip']); + }); + + it('drops built-in element tokens and names provider kinds', () => { + const { ng } = fakeNg(); + const card = collectInjectorTree(ng).roots[0].children[0]; + expect(card.providers).toEqual([{ token: 'Logger', type: 'factory', isViewProvider: true }]); + expect(card.injector.providerCount).toBe(1); + }); + + it('builds the environment chain from the platform down', () => { + const { ng } = fakeNg(); + const { environment } = collectInjectorTree(ng); + expect(environment.map((e) => e.injector.name)).toEqual(['Platform']); + const root = environment[0].children[0]; + expect(root.injector.name).toBe('Root'); + expect(root.children.map((c) => c.injector.name)).toEqual(['Route: cards']); + expect(root.providers).toEqual([ + { token: 'Store', type: 'class', isViewProvider: false, importPath: ['App'] }, + { token: 'API_URL', type: 'value', isViewProvider: false }, + ]); + }); + + it('reports what each component injected and which injector supplied it', () => { + const { ng } = fakeNg(); + const { roots, environment } = collectInjectorTree(ng); + const card = roots[0].children[0]; + const rootId = environment[0].children[0].injector.id; + expect(card.dependencies).toEqual([ + { from: 'Card', token: 'Store', flags: [], providedBy: rootId }, + { from: 'Card', token: 'Logger', flags: ['self'], providedBy: card.injector.id }, + { from: 'Card', token: 'API_URL', flags: ['optional'], providedBy: null }, + ]); + expect(card.injector.path).toEqual([ + card.injector.id, + roots[0].injector.id, + environment[0].children[0].children[0].injector.id, + rootId, + environment[0].injector.id, + 'inj-null', + ]); + }); + + it('keeps ids stable between collections and gives each element a selector', () => { + const { ng, card } = fakeNg(); + const first = collectInjectorTree(ng).roots[0].children[0].injector; + const again = collectInjectorTree(ng).roots[0].children[0].injector; + expect(again.id).toBe(first.id); + expect(document.querySelector(first.selector!)).toBe(card); + }); + + it('reads element providers and dependencies once per element', () => { + const { ng } = fakeNg(); + let calls = 0; + const providers = ng.ɵgetInjectorProviders; + ng.ɵgetInjectorProviders = (inj: any) => { + if (inj.kind === 'node') calls++; + return providers(inj); + }; + const first = collectInjectorTree(ng); + const afterFirst = calls; + const again = collectInjectorTree(ng); + expect(afterFirst).toBe(2); + expect(calls).toBe(afterFirst); + expect(again).toEqual(first); + }); + + it('caps the number of element nodes and says so', () => { + document.body.innerHTML = `
      ${'
    • '.repeat(MAX_INJECTOR_NODES + 5)}
    `; + const env = { kind: 'env' }; + const ng = { + getInjector: (el: Element) => ({ kind: 'node', el }), + getComponent: () => null, + getDirectives: (el: Element) => (el.tagName === 'LI' ? [new Tooltip()] : []), + ɵgetInjectorMetadata: (inj: any) => + inj.kind === 'node' + ? { type: 'element', source: inj.el } + : { type: 'environment', source: 'R' }, + ɵgetInjectorResolutionPath: (inj: any) => [inj, env], + ɵgetInjectorProviders: () => [], + } as any; + const report = collectInjectorTree(ng); + expect(report.roots).toHaveLength(MAX_INJECTOR_NODES); + expect(report.truncated).toBe(true); + expect(document.querySelector(report.roots[1].injector.selector!)).toBe( + document.querySelectorAll('li')[1], + ); + }); + + it('returns nothing without the Angular debug APIs', () => { + expect(collectInjectorTree(undefined)).toEqual({ roots: [], environment: [] }); + }); +}); + +describe('token and provider naming', () => { + it('reads class names and injection token descriptions', () => { + expect(tokenName(Store)).toBe('Store'); + expect(tokenName(API_URL)).toBe('API_URL'); + expect(tokenName({ toString: () => 'InjectionToken X' })).toBe('X'); + }); + + it('tells provider kinds apart', () => { + expect(providerKind(Store)).toBe('class'); + expect(providerKind({ useValue: 0 })).toBe('value'); + expect(providerKind({ useFactory: () => 0 })).toBe('factory'); + expect(providerKind({ useExisting: Store })).toBe('existing'); + expect(providerKind({ useClass: Store })).toBe('class'); + }); +}); diff --git a/packages/ng-devtools/src/__tests__/ngrx-collector.test.ts b/packages/ng-devtools/src/__tests__/ngrx-collector.test.ts new file mode 100644 index 0000000..2017df2 --- /dev/null +++ b/packages/ng-devtools/src/__tests__/ngrx-collector.test.ts @@ -0,0 +1,562 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest'; +import { createNgrxCollector } from '../ngrx-collector.ts'; +import { diff, serialize } from '../ngrx-shared.ts'; +import { mergeNgrxReport, nameStore, ngrxStateOf, type NgrxPages } from '../rpc/ngrx-tools.ts'; + +const SIGNAL = Symbol('SIGNAL'); +const STATE_SOURCE = Symbol('STATE_SOURCE'); + +function writable(initial: T) { + const node = { value: initial }; + const getter = (() => node.value) as (() => T) & { + set(v: T): void; + update(fn: (v: T) => T): void; + }; + (getter as any)[SIGNAL] = node; + getter.set = (v: T) => { + node.value = v; + }; + getter.update = (fn: (v: T) => T) => { + node.value = fn(node.value); + }; + return getter; +} + +function computedOf(fn: () => T) { + const getter = () => fn(); + (getter as any)[SIGNAL] = { computation: fn }; + return getter; +} + +function patchState(store: any, patch: Record) { + for (const [key, value] of Object.entries(patch)) store[STATE_SOURCE][key].set(value); +} + +class SignalStore { + [STATE_SOURCE] = { query: writable(''), saved: writable([]) }; + query = computedOf(() => (this as any)[STATE_SOURCE].query()); + saved = computedOf(() => (this as any)[STATE_SOURCE].saved()); + savedCount = computedOf(() => (this as any)[STATE_SOURCE].saved().length); + setQuery = (query: string) => patchState(this, { query }); + isSaved = (id: string) => (this as any)[STATE_SOURCE].saved().includes(id); + toggle = (id: string) => { + const saved = (this as any)[STATE_SOURCE].saved(); + patchState(this, { saved: saved.includes(id) ? [] : [...saved, id] }); + }; +} + +class App { + store: SignalStore; + constructor(store: SignalStore) { + this.store = store; + } +} + +function setup() { + document.body.innerHTML = ''; + const root = document.querySelector('app-root')!; + const store = new SignalStore(); + const app = new App(store); + const rootEnv = { + scopes: new Set(['root']), + records: new Map([ + [class Router {}, { value: {} }], + [SignalStore, { value: store }], + ]), + get: () => null, + }; + const node = { el: root, get: () => null }; + const ng = { + getInjector: () => node, + getComponent: (el: Element) => (el === root ? app : null), + ɵgetInjectorMetadata: (inj: unknown) => + inj === rootEnv ? { type: 'environment', source: 'R3Injector' } : { type: 'element' }, + ɵgetInjectorResolutionPath: () => [node, rootEnv], + ɵgetInjectorProviders: () => [], + }; + const onChange = vi.fn(); + const collector = createNgrxCollector(() => ng as any, onChange); + return { store, app, rootEnv, collector, onChange }; +} + +describe('ngrx collector', () => { + it('finds a root signal store and splits state, computed and methods', () => { + const { collector } = setup(); + const { stores, classic } = collector.collect(); + expect(classic).toBeNull(); + expect(stores).toHaveLength(1); + const [info] = stores; + expect(info.kind).toBe('signal-store'); + expect(info.className).toBe('SignalStore'); + expect(info.scope).toBe('root'); + expect(info.stateKeys).toEqual(['query', 'saved']); + expect(info.state).toEqual({ query: '', saved: [] }); + expect(info.computed).toEqual({ savedCount: 0 }); + expect(info.methods.map((m) => m.name)).toEqual(['setQuery', 'isSaved', 'toggle']); + expect(info.references).toEqual(['App.store']); + expect(info.writable).toBe(true); + }); + + it('unwraps a store that is no longer found and wraps it once when it comes back', () => { + const { store, app, rootEnv, collector } = setup(); + const original = store.setQuery; + collector.collect(); + expect(store.setQuery).not.toBe(original); + + rootEnv.records.delete(SignalStore); + (app as any).store = null; + expect(collector.collect().stores).toHaveLength(0); + expect(store.setQuery).toBe(original); + + rootEnv.records.set(SignalStore, { value: store }); + collector.collect(); + store.setQuery('rome'); + expect(collector.logSince(0).filter((entry) => entry.type === 'setQuery')).toHaveLength(1); + }); + + it('keeps a stable id across collections', () => { + const { collector } = setup(); + const first = collector.collect().stores[0].id; + expect(collector.collect().stores[0].id).toBe(first); + }); + + it('logs method calls with args and a diff, and calls that change nothing at most once a second', () => { + const { store, collector, onChange } = setup(); + collector.collect(); + store.setQuery('rome'); + store.isSaved('a'); + store.isSaved('b'); + const log = collector.logSince(0); + expect(log).toHaveLength(2); + expect(log[1]).toMatchObject({ type: 'isSaved', args: ['a'], diff: [] }); + expect(log[0]).toMatchObject({ + seq: 1, + type: 'setQuery', + args: ['rome'], + restorable: true, + diff: [{ path: 'query', op: 'change', before: '', after: 'rome' }], + }); + expect(onChange).toHaveBeenCalled(); + expect(collector.collect().stores[0].methods.find((m) => m.name === 'isSaved')?.calls).toBe(2); + }); + + it('logs writes made outside a method as patchState', async () => { + const { store, collector } = setup(); + collector.collect(); + patchState(store, { saved: ['x'] }); + await Promise.resolve(); + const [entry] = collector.logSince(0); + expect(entry.type).toBe('patchState'); + expect(entry.diff).toEqual([{ path: 'saved[0]', op: 'add', after: 'x' }]); + }); + + it('restores a snapshot from the log', () => { + const { store, collector } = setup(); + collector.collect(); + store.setQuery('a'); + store.setQuery('b'); + const result = collector.run({ type: 'restore', seq: 1 }); + expect(result.ok).toBe(true); + expect((store as any)[STATE_SOURCE].query()).toBe('a'); + const last = collector.logSince(2)[0]; + expect(last.type).toBe('Restore #1 (watchState listeners not notified)'); + expect(collector.run({ type: 'restore', seq: 99 }).error).toBeTruthy(); + }); + + it('logs a change past the serialize limits by reference and restores the raw value', () => { + const { store, collector } = setup(); + const many = Array.from({ length: 150 }, (_, i) => `id${i}`); + (store as any).replace = (next: string[]) => patchState(store, { saved: next }); + patchState(store, { saved: many }); + collector.collect(); + const changed = many.map((id, i) => (i === 140 ? 'changed' : id)); + (store as any).replace(changed); + const [entry] = collector.logSince(0); + expect(entry).toMatchObject({ + type: 'replace', + diff: [{ path: 'saved[140]', op: 'change', before: 'id140', after: 'changed' }], + }); + (store as any).replace([...changed]); + expect(collector.run({ type: 'restore', seq: entry.seq }).ok).toBe(true); + expect((store as any)[STATE_SOURCE].saved()).toBe(changed); + }); + + it('finds a changed entity past the first hundred map keys', () => { + const { store, collector } = setup(); + const entityMap = Object.fromEntries( + Array.from({ length: 150 }, (_, i) => [`e${i}`, { id: i, done: false }]), + ); + patchState(store, { saved: entityMap }); + collector.collect(); + (store as any)[STATE_SOURCE].saved.set({ ...entityMap, e149: { id: 149, done: true } }); + return Promise.resolve().then(() => { + const [entry] = collector.logSince(0); + expect(entry.type).toBe('patchState'); + expect(entry.diff).toEqual([ + { path: 'saved.e149.done', op: 'change', before: false, after: true }, + ]); + }); + }); + + it('redacts secret-looking state keys and nested fields', () => { + const { store, collector } = setup(); + patchState(store, { saved: [{ user: 'ann', password: 'hunter2' }] }); + collector.collect(); + expect(collector.collect().stores[0].state).toEqual({ + query: '', + saved: [{ user: 'ann', password: '[redacted]' }], + }); + patchState(store, { saved: [{ user: 'ann', password: 'swordfish' }] }); + return Promise.resolve().then(() => { + expect(JSON.stringify(collector.logSince(0))).not.toMatch(/hunter2|swordfish/); + }); + }); + + it('only rescans the page when asked to rediscover stores', () => { + const { store, collector } = setup(); + const spy = vi.spyOn(document, 'createTreeWalker'); + collector.collect(); + expect(spy).toHaveBeenCalledTimes(1); + store.setQuery('a'); + expect(collector.collect(false).stores[0].state).toMatchObject({ query: 'a' }); + expect(spy).toHaveBeenCalledTimes(1); + collector.collect(); + expect(spy).toHaveBeenCalledTimes(2); + spy.mockRestore(); + }); + + it('only returns entries after a sequence number', () => { + const { store, collector } = setup(); + collector.collect(); + store.setQuery('a'); + store.toggle('x'); + expect(collector.logSince(1).map((e) => e.seq)).toEqual([2]); + }); + + it('reads classic store state and logs actions through ScannedActionsSubject', () => { + document.body.innerHTML = ''; + const root = document.querySelector('app-root')!; + let state = { count: 0 }; + const listeners: ((a: unknown) => void)[] = []; + class _Store { + source = { getValue: () => state }; + dispatch(action: { type: string }) { + if (action.type === 'inc') state = { count: state.count + 1 }; + for (const l of listeners) l(action); + } + select() {} + } + class ScannedActionsSubject { + subscribe(fn: (a: unknown) => void) { + listeners.push(fn); + return { unsubscribe: () => {} }; + } + } + const store = new _Store(); + const scanned = new ScannedActionsSubject(); + const values = new Map([ + [_Store, store], + [ScannedActionsSubject, scanned], + ]); + const rootEnv = { + scopes: new Set(['root']), + records: new Map([...values.keys()].map((k) => [k, { value: undefined }])), + }; + const node = { get: (token: unknown) => values.get(token) ?? null }; + const ng = { + getInjector: () => node, + getComponent: (el: Element) => (el === root ? {} : null), + ɵgetInjectorResolutionPath: () => [node, rootEnv], + ɵgetInjectorProviders: () => [], + }; + const collector = createNgrxCollector( + () => ng as any, + () => {}, + ); + expect(collector.collect().classic).toEqual({ + state: { count: 0 }, + devtools: false, + scope: 'root', + }); + store.dispatch({ type: 'inc' }); + const [entry] = collector.logSince(0); + expect(entry).toMatchObject({ + source: 'store', + type: 'inc', + action: { type: 'inc' }, + restorable: false, + diff: [{ path: 'count', op: 'change', before: 0, after: 1 }], + }); + expect(collector.run({ type: 'restore', seq: entry.seq }).error).toMatch( + /provideStoreDevtools/, + ); + }); +}); + +describe('serialize', () => { + it('handles Map, Set, Date, circular and depth limits', () => { + const circular: Record = { a: 1 }; + circular['self'] = circular; + expect( + serialize( + { + map: new Map([['k', 1]]), + set: new Set([1]), + date: new Date(0), + circular, + deep: { a: { b: { c: 1 } } }, + none: undefined, + }, + { depth: 3 }, + ), + ).toEqual({ + map: { '@type': 'Map', size: 1, entries: [['k', 1]] }, + set: { '@type': 'Set', size: 1, values: [1] }, + date: { '@type': 'Date', value: '1970-01-01T00:00:00.000Z' }, + circular: { a: 1, self: '[Circular]' }, + deep: { a: { b: '[Object]' } }, + none: { '@type': 'undefined' }, + }); + }); + + it('diffs nested objects and arrays by path', () => { + expect(diff({ a: { b: 1 }, list: [1] }, { a: { b: 2 }, list: [1, 2], c: true })).toEqual([ + { path: 'a.b', op: 'change', before: 1, after: 2 }, + { path: 'list[1]', op: 'add', after: 2 }, + { path: 'c', op: 'add', after: true }, + ]); + }); +}); + +describe('ngrx tools', () => { + const store = { + id: 'ngrx-1', + kind: 'signal-store' as const, + className: 'SignalStore', + scope: 'root', + stateKeys: ['query', 'saved'], + state: {}, + computed: {}, + methods: [{ name: 'setQuery', calls: 0 }], + references: [], + writable: true, + }; + + it('names a store from the matching withState keys', () => { + expect( + nameStore(store, [ + { name: 'OtherStore', kind: 'signal-store', file: 'a.ts', members: { state: ['x'] } }, + { + name: 'TravelStore', + kind: 'signal-store', + file: 'travel.store.ts', + members: { state: ['query', 'saved'] }, + }, + ]), + ).toEqual({ name: 'TravelStore', declaredIn: 'travel.store.ts' }); + }); + + it('appends only new log entries and restarts on a new session', () => { + const pages: NgrxPages = new Map(); + const entry = (seq: number) => ({ + seq, + source: 'signal-store' as const, + storeId: 'ngrx-1', + type: 't', + timestamp: 0, + diff: [], + restorable: true, + }); + const report = (session: string, log: ReturnType[]) => ({ + pageId: 'p1', + session, + url: '/', + title: '', + stores: [store], + classic: null, + log, + }); + expect(mergeNgrxReport(pages, report('s1', [entry(1), entry(2)]), [])).toBe(2); + expect(mergeNgrxReport(pages, report('s1', [entry(2), entry(3)]), [])).toBe(3); + expect(ngrxStateOf(pages).pages[0].log.map((e) => e.seq)).toEqual([1, 2, 3]); + expect(mergeNgrxReport(pages, report('s2', [entry(1)]), [])).toBe(1); + expect(ngrxStateOf(pages).pages[0]).not.toHaveProperty('session'); + }); +}); + +describe('ngrx collector with @ngrx/signals', () => { + it('reads, logs and restores a real signal store from an injector', async () => { + await import('@angular/compiler'); + const { Injector, computed } = await import('@angular/core'); + const { + signalStore, + withState, + withComputed, + withMethods, + patchState: patch, + } = await import('@ngrx/signals'); + const TravelStore = signalStore( + { providedIn: 'root' }, + withState({ query: '', saved: [] as string[] }), + withComputed(({ saved }) => ({ savedCount: computed(() => saved().length) })), + withMethods((store) => ({ + setQuery(query: string) { + patch(store, { query }); + }, + })), + ); + const injector = Injector.create({ providers: [TravelStore] }); + const store = injector.get(TravelStore); + document.body.innerHTML = ''; + const root = document.querySelector('app-root')!; + const node = { get: () => null }; + const ng = { + getInjector: () => node, + getComponent: (el: Element) => (el === root ? { store } : null), + ɵgetInjectorResolutionPath: () => [node, injector], + ɵgetInjectorProviders: () => [], + }; + const collector = createNgrxCollector( + () => ng as any, + () => {}, + ); + const [info] = collector.collect().stores; + expect(info).toMatchObject({ + className: 'SignalStore', + stateKeys: ['query', 'saved'], + state: { query: '', saved: [] }, + computed: { savedCount: 0 }, + methods: [{ name: 'setQuery', calls: 0 }], + references: ['Object.store'], + }); + store.setQuery('rome'); + store.setQuery('oslo'); + expect(collector.logSince(0).map((e) => e.type)).toEqual(['setQuery', 'setQuery']); + expect(collector.run({ type: 'restore', seq: 1 }).ok).toBe(true); + expect(store.query()).toBe('rome'); + }); + + it('restores without notifying watchState listeners and says so', async () => { + await import('@angular/compiler'); + const { Injector } = await import('@angular/core'); + const { + signalStore, + withState, + withMethods, + watchState, + patchState: patch, + } = await import('@ngrx/signals'); + const Store = signalStore( + withState({ query: '' }), + withMethods((store) => ({ + setQuery(query: string) { + patch(store, { query }); + }, + })), + ); + const injector = Injector.create({ providers: [Store] }); + const store = injector.get(Store); + const seen: string[] = []; + watchState(store, (state) => seen.push(state.query), { injector }); + const collector = realCollector(store); + collector.collect(); + store.setQuery('rome'); + store.setQuery('oslo'); + const original = WeakMap.prototype.get; + const result = collector.run({ type: 'restore', seq: 1 }); + expect(result.ok).toBe(true); + expect(result.message).toMatch(/watchState listeners were not notified/); + expect(store.query()).toBe('rome'); + expect(seen).toEqual(['', 'rome', 'oslo']); + expect(collector.logSince(2)[0].type).toMatch(/watchState listeners not notified/); + expect(WeakMap.prototype.get).toBe(original); + }); + + it('notifies watchState listeners on restore once the app registers patchState', async () => { + await import('@angular/compiler'); + const { Injector } = await import('@angular/core'); + const { signalStore, withState, withMethods, watchState, patchState } = + await import('@ngrx/signals'); + const { registerNgrxSignals } = await import('../ngrx-register.ts'); + const Store = signalStore( + withState({ query: '' }), + withMethods((store) => ({ + setQuery(query: string) { + patchState(store, { query }); + }, + })), + ); + const injector = Injector.create({ providers: [Store] }); + const store = injector.get(Store); + const seen: string[] = []; + watchState(store, (state) => seen.push(state.query), { injector }); + const collector = realCollector(store); + collector.collect(); + store.setQuery('rome'); + store.setQuery('oslo'); + registerNgrxSignals({ patchState }); + try { + const result = collector.run({ type: 'restore', seq: 1 }); + expect(result.ok).toBe(true); + expect(result.message).not.toMatch(/not notified/); + expect(store.query()).toBe('rome'); + expect(seen).toEqual(['', 'rome', 'oslo', 'rome']); + expect(collector.logSince(2)[0].type).toBe('Restore #1'); + } finally { + delete (globalThis as Record)['__NG_DEVTOOLS_NGRX_SIGNALS__']; + } + }); + + it('does not add reactive dependencies when a method runs inside a computed', async () => { + await import('@angular/compiler'); + const { Injector, computed } = await import('@angular/core'); + const { + signalStore, + withState, + withLinkedState, + withMethods, + patchState: patch, + } = await import('@ngrx/signals'); + const Store = signalStore( + withState({ base: 1 }), + withLinkedState(({ base }) => ({ doubled: () => base() * 2 })), + withMethods((store) => ({ + ping() {}, + bump() { + patch(store, { base: store.base() + 1 }); + }, + })), + ); + const store = Injector.create({ providers: [Store] }).get(Store); + const collector = realCollector(store); + collector.collect(); + let runs = 0; + const outer = computed(() => { + runs++; + store.ping(); + return runs; + }); + outer(); + store.bump(); + outer(); + expect(store.doubled()).toBe(4); + expect(runs).toBe(1); + }); +}); + +function realCollector(store: object) { + document.body.innerHTML = ''; + const root = document.querySelector('app-root')!; + const node = { get: () => null }; + const ng = { + getInjector: () => node, + getComponent: (el: Element) => (el === root ? { store } : null), + ɵgetInjectorResolutionPath: () => [node], + ɵgetInjectorProviders: () => [], + }; + return createNgrxCollector( + () => ng as any, + () => {}, + ); +} diff --git a/packages/ng-devtools/src/__tests__/overlay-signal-target.test.ts b/packages/ng-devtools/src/__tests__/overlay-signal-target.test.ts deleted file mode 100644 index acb08fd..0000000 --- a/packages/ng-devtools/src/__tests__/overlay-signal-target.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, describe, expect, it } from 'vitest'; -import { collectSignalGraph } from '../overlay.ts'; - -function installNg() { - const components = new Set(['APP-ROOT', 'APP-SHELL', 'APP-PAGE', 'APP-CARD']); - (window as any).ng = { - getComponent: (el: Element) => (components.has(el.tagName) ? {} : null), - getInjector: (el: Element) => el, - ɵgetSignalGraph: (el: Element) => ({ - nodes: [{ id: '1', kind: 'signal', label: el.tagName.toLowerCase(), epoch: 1 }], - edges: [], - }), - }; -} - -describe('collectSignalGraph target', () => { - afterEach(() => { - delete (window as any).ng; - }); - - it('follows the component under the deepest router outlet', () => { - document.body.innerHTML = ` - - - - - `; - installNg(); - expect(collectSignalGraph()?.componentSelector).toBe('app-page'); - }); - - it('prefers an explicit target', () => { - document.body.innerHTML = ` - - - `; - installNg(); - expect(collectSignalGraph('app-card')?.componentSelector).toBe('app-card'); - }); - - it('falls back when the target is missing or invalid', () => { - document.body.innerHTML = ``; - installNg(); - expect(collectSignalGraph('app-gone')?.componentSelector).toBe('app-root'); - expect(collectSignalGraph('[[bad')?.componentSelector).toBe('app-root'); - }); -}); diff --git a/packages/ng-devtools/src/__tests__/overlay-tree.test.ts b/packages/ng-devtools/src/__tests__/overlay-tree.test.ts deleted file mode 100644 index 3d55ff7..0000000 --- a/packages/ng-devtools/src/__tests__/overlay-tree.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -// @vitest-environment jsdom -import { describe, expect, it } from 'vitest'; -import { - AngularDebugApi, - ComponentTreeNode, - collectComponentTree, - walkAngularTree, -} from '../overlay.ts'; - -describe('overlay component tree traversal', () => { - it('traverses direct component children', () => { - document.body.innerHTML = ` - - - - - `; - - const componentsMap = new Map(); - const rootEl = document.querySelector('app-root')!; - const headerEl = document.querySelector('app-header')!; - const footerEl = document.querySelector('app-footer')!; - - componentsMap.set(rootEl, { name: 'Root' }); - componentsMap.set(headerEl, { name: 'Header' }); - componentsMap.set(footerEl, { name: 'Footer' }); - - const mockNg: AngularDebugApi = { - getComponent: (el: Element) => componentsMap.get(el) ?? null, - }; - - const nodes: ComponentTreeNode[] = []; - walkAngularTree(rootEl, nodes, mockNg); - - expect(nodes.length).toBe(1); - expect(nodes[0].selector).toBe('app-root'); - expect(nodes[0].children.map((c) => c.selector)).toEqual(['app-header', 'app-footer']); - }); - - it('traverses through non-component HTML wrapper elements (fix for #11)', () => { - document.body.innerHTML = ` - -
    -
    - -
    -
    -
    - -
    -
    -
    - -
    -
    -
    - `; - - const componentsMap = new Map(); - const rootEl = document.querySelector('app-root')!; - const navEl = document.querySelector('app-navbar')!; - const prodEl = document.querySelector('app-product-list')!; - const footEl = document.querySelector('app-footer')!; - - componentsMap.set(rootEl, { name: 'Root' }); - componentsMap.set(navEl, { name: 'Navbar' }); - componentsMap.set(prodEl, { name: 'ProductList' }); - componentsMap.set(footEl, { name: 'Footer' }); - - const mockNg: AngularDebugApi = { - getComponent: (el: Element) => componentsMap.get(el) ?? null, - }; - - const nodes: ComponentTreeNode[] = []; - walkAngularTree(rootEl, nodes, mockNg); - - expect(nodes.length).toBe(1); - expect(nodes[0].selector).toBe('app-root'); - expect(nodes[0].children.map((c) => c.selector)).toEqual([ - 'app-navbar', - 'app-product-list', - 'app-footer', - ]); - }); - - it('handles multi-level nested components separated by HTML tags', () => { - document.body.innerHTML = ` - -
    - -
    - -
    -
    -
    -
    - `; - - const componentsMap = new Map(); - const rootEl = document.querySelector('app-root')!; - const cardEl = document.querySelector('app-card')!; - const itemEl = document.querySelector('app-card-item')!; - - componentsMap.set(rootEl, { name: 'Root' }); - componentsMap.set(cardEl, { name: 'Card' }); - componentsMap.set(itemEl, { name: 'CardItem' }); - - const mockNg: AngularDebugApi = { - getComponent: (el: Element) => componentsMap.get(el) ?? null, - }; - - const nodes: ComponentTreeNode[] = []; - walkAngularTree(rootEl, nodes, mockNg); - - expect(nodes.length).toBe(1); - expect(nodes[0].selector).toBe('app-root'); - expect(nodes[0].children.length).toBe(1); - expect(nodes[0].children[0].selector).toBe('app-card'); - expect(nodes[0].children[0].children.length).toBe(1); - expect(nodes[0].children[0].children[0].selector).toBe('app-card-item'); - }); - - it('collects component tree using global ng when available', () => { - document.body.innerHTML = ` - -
    - -
    -
    - `; - - const componentsMap = new Map(); - const rootEl = document.querySelector('app-root')!; - const sidebarEl = document.querySelector('app-sidebar')!; - - componentsMap.set(rootEl, { name: 'Root' }); - componentsMap.set(sidebarEl, { name: 'Sidebar' }); - - (window as unknown as { ng?: AngularDebugApi }).ng = { - getComponent: (el: Element) => componentsMap.get(el) ?? null, - }; - - const nodes = collectComponentTree(); - expect(nodes.length).toBe(1); - expect(nodes[0].selector).toBe('app-root'); - expect(nodes[0].children.map((c) => c.selector)).toEqual(['app-sidebar']); - }); - - it('deduplicates nested roots and avoids adding nested roots twice', () => { - document.body.innerHTML = ` - -
    - -
    -
    - `; - - const componentsMap = new Map(); - const rootEl = document.querySelector('app-root')!; - const childEl = document.querySelector('app-child')!; - - componentsMap.set(rootEl, { name: 'Root' }); - componentsMap.set(childEl, { name: 'Child' }); - - (window as unknown as { ng?: AngularDebugApi }).ng = { - getComponent: (el: Element) => componentsMap.get(el) ?? null, - }; - - const nodes = collectComponentTree(); - expect(nodes.length).toBe(1); - expect(nodes[0].selector).toBe('app-root'); - expect(nodes[0].children.length).toBe(1); - expect(nodes[0].children[0].selector).toBe('app-child'); - }); - - it('falls back to walking document.body when window.ng is unavailable and roots is empty', () => { - delete (window as unknown as { ng?: AngularDebugApi }).ng; - - document.body.innerHTML = ` - - - - `; - - const nodes = collectComponentTree(); - expect(nodes.length).toBe(1); - expect(nodes[0].selector).toBe('custom-widget'); - expect(nodes[0].children.length).toBe(1); - expect(nodes[0].children[0].selector).toBe('nested-item'); - }); - - it('serializes signal results and non-function plain fields in tryGetInputs', () => { - let arbitraryMethodCalled = false; - const signalFn = function signalValueFn() { - return { nested: 'value', count: 10 }; - }; - - const regularMethod = () => { - arbitraryMethodCalled = true; - return 'should-not-run'; - }; - - document.body.innerHTML = ``; - const widgetEl = document.querySelector('app-widget')!; - - const mockComp = { - signalInput: signalFn, - plainObject: { theme: 'dark', active: true }, - plainPrimitive: 'hello', - actionMethod: regularMethod, - }; - - (window as unknown as { ng?: AngularDebugApi }).ng = { - getComponent: (el: Element) => (el === widgetEl ? mockComp : null), - }; - - const nodes = collectComponentTree(); - expect(nodes.length).toBe(1); - expect(nodes[0].inputs).toEqual({ - signalInput: { nested: 'value', count: 10 }, - plainObject: { theme: 'dark', active: true }, - plainPrimitive: 'hello', - }); - expect(arbitraryMethodCalled).toBe(false); - }); -}); diff --git a/packages/ng-devtools/src/__tests__/pipes-collector.test.ts b/packages/ng-devtools/src/__tests__/pipes-collector.test.ts index 6f17593..02bf6bc 100644 --- a/packages/ng-devtools/src/__tests__/pipes-collector.test.ts +++ b/packages/ng-devtools/src/__tests__/pipes-collector.test.ts @@ -9,10 +9,10 @@ import { type PipeTransform, } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { AsyncPipe, CurrencyPipe } from '@angular/common'; +import { AsyncPipe, CurrencyPipe, UpperCasePipe } from '@angular/common'; import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { BehaviorSubject } from 'rxjs'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { attachPipes } from '../pipes-collector.ts'; import type { PipePageReport } from '../rpc/pipes-tools.ts'; @@ -90,7 +90,9 @@ describe('pipes collector', () => { expect(report.instrumented).toBe(false); const currency = report.pipes.find((p) => p.name === 'currency')!; expect(currency).toMatchObject({ className: 'CurrencyPipe', isPure: true, instanceCount: 2 }); - expect(currency.components).toEqual([{ name: 'Receipt', count: 2 }]); + expect(currency.components).toEqual([ + { name: 'Receipt', count: 2, targets: [{ pageId: 'pg', id: expect.any(String) }] }, + ]); expect(currency.call).toBeUndefined(); }); @@ -254,6 +256,163 @@ describe('pipes collector', () => { ).toBeUndefined(); }); + it('keeps a stale finding, with its first detection time, until the argument changes', async () => { + class ListView { + items = signal(['a', 'b']); + cdr = inject(ChangeDetectorRef); + } + Component({ + selector: 'app-list4', + imports: [JoinPipe], + template: `

    {{ items() | join }}

    `, + })(ListView); + + const fixture = await mount(ListView); + const h = harness(); + h.handlers.get('instrument-pipes')!(true); + h.collector.push(); + await Promise.resolve(); + + fixture.componentInstance.items().push('c'); + fixture.componentInstance.cdr.markForCheck(); + fixture.detectChanges(); + h.collector.push(); + await Promise.resolve(); + const staleOf = () => + h + .reports() + .at(-1)! + .pipes.find((p) => p.name === 'join')?.stale; + const first = staleOf()?.detectedAt; + expect(first).toBeGreaterThan(0); + + h.collector.resume(); + await Promise.resolve(); + expect(staleOf()?.detectedAt).toBe(first); + + fixture.componentInstance.items.set(['d']); + fixture.detectChanges(); + h.collector.resume(); + await Promise.resolve(); + expect(staleOf()).toBeUndefined(); + }); + + it('does not push an unchanged report again before the heartbeat', async () => { + await mount(Receipt); + const h = harness(); + h.collector.push(); + await Promise.resolve(); + h.collector.push(); + await Promise.resolve(); + expect(h.reports()).toHaveLength(1); + }); + + it('walks the whole DOM once, then only again when it changes', async () => { + await mount(Receipt); + const h = harness(); + const spy = vi.spyOn(document, 'querySelectorAll'); + const fullScans = () => spy.mock.calls.filter(([selector]) => selector === '*').length; + h.collector.push(); + await Promise.resolve(); + expect(fullScans()).toBe(1); + + h.collector.resume(); + await Promise.resolve(); + expect(fullScans()).toBe(1); + + class Extra { + label = 'late'; + } + Component({ + selector: 'app-extra', + imports: [UpperCasePipe], + template: `{{ label | uppercase }}`, + })(Extra); + await mount(Extra); + await new Promise((resolve) => setTimeout(resolve)); + h.collector.resume(); + await Promise.resolve(); + expect(fullScans()).toBe(1); + expect( + h + .reports() + .at(-1)! + .pipes.map((p) => p.name), + ).toEqual(['uppercase']); + spy.mockRestore(); + }); + + it('picks up a text-only view that appears later without walking the whole DOM', async () => { + class Toggle { + show = signal(false); + label = 'late'; + } + Component({ + selector: 'app-toggle', + imports: [UpperCasePipe], + template: `

    @if (show()) {{{ label | uppercase }}}

    `, + })(Toggle); + const fixture = await mount(Toggle); + const h = harness(); + const spy = vi.spyOn(document, 'querySelectorAll'); + h.collector.push(); + await Promise.resolve(); + expect(h.reports().at(-1)!.pipes).toEqual([]); + + fixture.componentInstance.show.set(true); + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve)); + h.collector.resume(); + await Promise.resolve(); + expect(spy.mock.calls.filter(([selector]) => selector === '*')).toHaveLength(1); + expect( + h + .reports() + .at(-1)! + .pipes.map((p) => p.name), + ).toEqual(['uppercase']); + spy.mockRestore(); + }); + + it('strips the bundler underscore prefix from component names', async () => { + class _Invoice { + total = 3; + } + Component({ + selector: 'app-invoice', + imports: [CurrencyPipe], + template: `

    {{ total | currency }}

    `, + })(_Invoice); + await mount(_Invoice); + const h = harness(); + h.collector.push(); + await Promise.resolve(); + const currency = h + .reports() + .at(-1)! + .pipes.find((p) => p.name === 'currency')!; + expect(currency.components.map((c) => c.name)).toEqual(['Invoice']); + }); + + it('reports no latest value for an async pipe with no source yet', async () => { + class Empty { + source$: BehaviorSubject | null = null; + } + Component({ + selector: 'app-empty', + imports: [AsyncPipe], + template: `

    {{ source$ | async }}

    `, + })(Empty); + await mount(Empty); + const h = harness(); + h.collector.push(); + await Promise.resolve(); + const [usage] = h.reports().at(-1)!.async ?? []; + expect(usage).toMatchObject({ hasSource: false, duplicate: false }); + expect(usage.latestValue).toBeUndefined(); + expect(usage.target).toEqual({ pageId: 'pg', id: expect.any(String) }); + }); + it('does nothing harmful when Angular has no debug API on the page', async () => { const my = { rpc: { call: async () => {}, register: () => {} }, diff --git a/packages/ng-devtools/src/__tests__/pipes-pages.test.ts b/packages/ng-devtools/src/__tests__/pipes-pages.test.ts index d1fd928..7fe05c9 100644 --- a/packages/ng-devtools/src/__tests__/pipes-pages.test.ts +++ b/packages/ng-devtools/src/__tests__/pipes-pages.test.ts @@ -1,7 +1,7 @@ import { createHostContext } from 'devframe/node'; import { afterEach, describe, expect, it, vi } from 'vitest'; import ngDevtools from '../devframe.ts'; -import type { PipePageReport } from '../rpc/pipes-tools.ts'; +import { isPipePageReport, mergePipePageReport, type PipePageReport } from '../rpc/pipes-tools.ts'; async function boot() { const host = { @@ -81,3 +81,53 @@ describe('pipe pages and their connections', () => { expect(await explain()).toContain('3 instance(s)'); }); }); + +describe('pipe page targets', () => { + it('keeps each page host element when merging the same component across tabs', () => { + const pages = new Map(); + const withTarget = (pageId: string): PipePageReport => ({ + ...page(pageId), + pipes: [ + { + ...page(pageId).pipes[0], + components: [{ name: 'ExamplePage', count: 3, targets: [{ pageId, id: 'c1' }] }], + }, + ], + }); + mergePipePageReport(pages, withTarget('tab-1'), 0); + const state = mergePipePageReport(pages, withTarget('tab-2'), 0); + expect(state.pipes[0].components).toEqual([ + { + name: 'ExamplePage', + count: 6, + targets: [ + { pageId: 'tab-1', id: 'c1' }, + { pageId: 'tab-2', id: 'c1' }, + ], + }, + ]); + }); + + it('lists a host element once when two pipes with the same name use it', () => { + const pages = new Map(); + const pipe = { + ...page('tab-1').pipes[0], + components: [{ name: 'ExamplePage', count: 1, targets: [{ pageId: 'tab-1', id: 'c1' }] }], + }; + const state = mergePipePageReport(pages, { ...page('tab-1'), pipes: [pipe, pipe] }, 0); + expect(state.pipes[0].components[0].targets).toEqual([{ pageId: 'tab-1', id: 'c1' }]); + }); + + it('rejects a report whose targets are malformed', () => { + const report = page('tab-1'); + expect(isPipePageReport(report)).toBe(true); + const bad = { + ...report, + pipes: [{ ...report.pipes[0], components: [{ name: 'X', count: 1, targets: [{ id: 1 }] }] }], + }; + expect(isPipePageReport(bad)).toBe(false); + expect(isPipePageReport({ ...report, async: [{ ...report.async![0], target: 'c1' }] })).toBe( + false, + ); + }); +}); diff --git a/packages/ng-devtools/src/__tests__/pipes-runtime.test.ts b/packages/ng-devtools/src/__tests__/pipes-runtime.test.ts index b244acd..b138ad5 100644 --- a/packages/ng-devtools/src/__tests__/pipes-runtime.test.ts +++ b/packages/ng-devtools/src/__tests__/pipes-runtime.test.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom import '@angular/compiler'; -import { Component, signal } from '@angular/core'; +import { Component, Pipe, TemplateRef, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { CurrencyPipe, UpperCasePipe } from '@angular/common'; +import { CurrencyPipe, NgTemplateOutlet, UpperCasePipe } from '@angular/common'; import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { afterEach, describe, expect, it } from 'vitest'; import { @@ -10,6 +10,7 @@ import { instrumentPipes, pipeSlotsIn, readBoundArg, + scanPipeViews, staleCheckFor, } from '../pipes-runtime.ts'; @@ -139,3 +140,104 @@ describe('stale pure-pipe check (experimental, real Angular render)', () => { expect(staleCheckFor(fakeSlot)).toBeNull(); }); }); + +class Badge {} +Component({ selector: 'app-badge', template: `badge` })(Badge); + +class Board { + show = signal(true); + items = signal(['x', 'y']); + title = 'board'; +} +Component({ + selector: 'app-board', + imports: [Badge, UpperCasePipe], + template: `@if (show()) {{{ title | uppercase }}}@for (item of items(); track item) {{{ item | uppercase }}}`, +})(Board); + +class _ShoutPipe { + transform(value: string) { + return `${value}!`; + } +} +Pipe({ name: 'shout' })(_ShoutPipe); + +class _Shell { + a = signal('one'); + b = signal('two'); +} +Component({ + selector: 'app-shell', + imports: [_ShoutPipe], + template: `

    {{ a() | shout }}

    {{ b() | shout }}

    `, +})(_Shell); + +class _Frame { + tpl: TemplateRef | null = null; +} +Component({ + selector: 'app-frame', + inputs: ['tpl'], + imports: [NgTemplateOutlet], + template: `
    `, +})(_Frame); + +class _Declarer { + label = 'row'; +} +Component({ + selector: 'app-declarer', + imports: [_Frame, UpperCasePipe], + template: `{{ label | uppercase }}`, +})(_Declarer); + +describe('pipe discovery through the view tree', () => { + it('attributes a template rendered by another component to the component that declares it', async () => { + const fixture = await mount(_Declarer); + for (const elements of [document.querySelectorAll('*'), document.querySelectorAll('span')]) { + const usages = findPipeUsages(ng(), elements).filter((u) => u.name === 'uppercase'); + expect(usages).toHaveLength(1); + expect(usages[0].component).toBe(fixture.componentInstance); + } + }); + + it('finds pipes in views that hold only text nodes', async () => { + await mount(Board); + const usages = findPipeUsages(ng(), document.querySelectorAll('*')); + expect(usages.filter((u) => u.name === 'uppercase')).toHaveLength(3); + }); + + it('finds them from the root element alone, without walking the DOM', async () => { + await mount(Board); + const scan = scanPipeViews(ng(), document.querySelectorAll('[ng-version]')); + expect(scan.usages.filter((u) => u.name === 'uppercase')).toHaveLength(3); + expect(scan.entries).toHaveLength(1); + }); + + it('attributes a usage to the template it is written in, not a child host element', async () => { + const fixture = await mount(Board); + const usages = findPipeUsages(ng(), document.querySelectorAll('*')); + for (const usage of usages) expect(usage.component).toBe(fixture.componentInstance); + }); + + it('strips the bundler underscore prefix from pipe class names', async () => { + await mount(_Shell); + const usages = findPipeUsages(ng(), document.querySelectorAll('*')); + expect(usages.map((u) => u.className)).toEqual(['ShoutPipe', 'ShoutPipe']); + }); + + it('recovers each binding slot when one pipe is used twice in a template', async () => { + const fixture = await mount(_Shell); + const usages = findPipeUsages(ng(), document.querySelectorAll('*')) + .filter((u) => u.name === 'shout') + .sort((x, y) => x.index - y.index); + const [first, second] = usages.map((u) => staleCheckFor(u)); + expect(readBoundArg(usages[0], first!)).toBe('one'); + expect(readBoundArg(usages[1], second!)).toBe('two'); + + fixture.componentInstance.b.set('three'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(readBoundArg(usages[1], second!)).toBe('three'); + }); +}); diff --git a/packages/ng-devtools/src/__tests__/popup.test.ts b/packages/ng-devtools/src/__tests__/popup.test.ts index 8992e10..31cc163 100644 --- a/packages/ng-devtools/src/__tests__/popup.test.ts +++ b/packages/ng-devtools/src/__tests__/popup.test.ts @@ -3,10 +3,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; /** The module auto-creates on import and keeps a single instance, so each test * needs it loaded afresh. */ -async function loadPopup() { +async function loadPopup(hub = false) { document.body.innerHTML = ''; + document.head.innerHTML = ''; vi.resetModules(); - await import('../popup.ts'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + hub + ? new Response('{}', { headers: { 'content-type': 'application/json' } }) + : new Response('', { status: 404 }), + ), + ); + const popup = await import('../popup.ts'); + await popup.showDevtools(); } function parts() { @@ -22,6 +32,38 @@ function parts() { describe.sequential('devtools popup', () => { beforeEach(() => localStorage.clear()); + it('opens the whole hub from the launcher when a hub is mounted', async () => { + await loadPopup(true); + const { fab } = parts(); + fab.click(); + const frame = document + .getElementById('ng-devtools-popup-root')! + .shadowRoot!.querySelector('iframe') as HTMLIFrameElement; + expect(frame.src).toBe(`${location.origin}/__devframes/`); + expect(document.head.querySelector('script[src="/__devframes/embedded.js"]')).toBeNull(); + }); + + it('closes on Escape pressed inside a dock frame nested in the hub', async () => { + await loadPopup(true); + const { fab, panel } = parts(); + fab.click(); + const frame = document + .getElementById('ng-devtools-popup-root')! + .shadowRoot!.querySelector('iframe') as HTMLIFrameElement; + const viewerFrame = document.createElement('iframe'); + document.body.appendChild(viewerFrame); + const viewer = viewerFrame.contentDocument!; + Object.defineProperty(frame, 'contentDocument', { get: () => viewer }); + frame.dispatchEvent(new Event('load')); + const dock = viewer.createElement('iframe'); + viewer.body.appendChild(dock); + await new Promise((resolve) => setTimeout(resolve)); + dock.contentDocument!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + expect(panel.classList.contains('open')).toBe(false); + }); + it('mounts even when the stored state is unusable', async () => { for (const stored of ['null', '123', '"float"', '[]', '{not json', '{"launcher":{}}']) { localStorage.setItem('ng-devtools-popup', stored); diff --git a/packages/ng-devtools/src/__tests__/router-audit.test.ts b/packages/ng-devtools/src/__tests__/router-audit.test.ts new file mode 100644 index 0000000..8312c92 --- /dev/null +++ b/packages/ng-devtools/src/__tests__/router-audit.test.ts @@ -0,0 +1,215 @@ +// @vitest-environment jsdom +import '@angular/compiler'; +import { Component, inject } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { Router, RouterLink, RouterLinkActive, provideRouter, type Routes } from '@angular/router'; +import { RouterTestingHarness } from '@angular/router/testing'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { watchRouter, type NavigationRecord, type RouterDebugApi } from '../router.ts'; +import { ConfigTracker } from '../router-config.ts'; +import { linksOf, matchOptionsOf } from '../router-links.ts'; +import { + captureCallers, + instrument, + onGuardsCheckStart, + routerLinkOf, + runAction, + storeInstrumented, + storedInstrumented, +} from '../router-actions.ts'; +import { touchRouterPage } from '../rpc/router-tools.ts'; + +TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); + +class Nav { + readonly router = inject(Router); + go() { + void this.router.navigateByUrl('/b'); + } +} +Component({ + selector: 'app-nav', + imports: [RouterLink, RouterLinkActive], + template: ` + A + + Partial + Never + `, +})(Nav); + +const lazyGuard = () => true; +const lazyResolver = () => 'loaded'; + +const routes: Routes = [ + { path: 'a', component: Nav }, + { path: 'a/child', component: Nav }, + { path: 'b', component: Nav }, + { + path: 'lazy', + loadChildren: () => + Promise.resolve([ + { + path: 'guarded', + component: Nav, + canActivate: [lazyGuard], + resolve: { value: lazyResolver }, + }, + ]), + }, +]; + +describe('router audit fixes on a real Router', () => { + let router: Router; + let navigations: NavigationRecord[]; + let cleanup: (() => void)[]; + const ng = () => (globalThis as { ng?: RouterDebugApi }).ng!; + const last = () => navigations[navigations.length - 1]; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ providers: [provideRouter(routes)] }); + router = TestBed.inject(Router); + navigations = []; + cleanup = []; + const stop = watchRouter(router as never, navigations, () => {}); + if (stop) cleanup.push(stop); + }); + + afterEach(() => { + for (const fn of cleanup) fn(); + }); + + it('checks a link against the IsActiveMatchOptions of its RouterLinkActive', async () => { + const harness = await RouterTestingHarness.create(); + await harness.navigateByUrl('/a/child'); + harness.detectChanges(); + const link = linksOf(ng(), router as never).find((l) => l.href === '/a')!; + expect(link).toMatchObject({ active: false, linkActive: false, exact: true }); + expect(matchOptionsOf({ exact: true })).toBe(true); + expect(matchOptionsOf(undefined)).toBe(false); + expect(matchOptionsOf({ paths: 'subset', queryParams: 'exact' })).toEqual({ + paths: 'subset', + matrixParams: 'ignored', + queryParams: 'exact', + fragment: 'ignored', + }); + expect(matchOptionsOf({ queryParams: 'exact' })).toEqual({ + paths: 'subset', + matrixParams: 'ignored', + queryParams: 'exact', + fragment: 'ignored', + }); + expect(matchOptionsOf({ fragment: 'exact' })).toMatchObject({ fragment: 'exact' }); + expect(matchOptionsOf(null)).toBeNull(); + }); + + it('agrees with RouterLinkActive for partial match options and null options', async () => { + const harness = await RouterTestingHarness.create(); + await harness.navigateByUrl('/b?tab=1'); + harness.detectChanges(); + const links = linksOf(ng(), router as never); + const partial = links.find((l) => l.text === 'Partial')!; + const never = links.find((l) => l.text === 'Never')!; + expect(partial).toMatchObject({ active: false, linkActive: false }); + expect(never).toMatchObject({ active: false, linkActive: false }); + }); + + it('only credits a click to RouterLink when the element carries the directive', async () => { + cleanup.push(captureCallers(router as never, navigations, ng())); + const harness = await RouterTestingHarness.create(); + await harness.navigateByUrl('/a'); + harness.detectChanges(); + const anchor = document.getElementById('link')!; + const button = document.getElementById('button')!; + expect(routerLinkOf(document.getElementById('inner'), ng())).toBe(anchor); + expect(routerLinkOf(button, ng())).toBeNull(); + + button.click(); + await harness.fixture.whenStable(); + expect(last().url).toBe('/b'); + expect(last().caller).toMatch(/^navigateByUrl\(\)/); + + document.getElementById('inner')!.click(); + await harness.fixture.whenStable(); + expect(last().url).toBe('/a'); + expect(last().caller).toBe('RouterLink a "A"'); + }); + + it('records guards and resolvers of lazy routes on the first navigation into them', async () => { + const tracker = new ConfigTracker(); + tracker.update(router as never); + let stop = instrument(router as never, navigations); + cleanup.push(() => stop()); + cleanup.push( + onGuardsCheckStart(router as never, () => { + if (!tracker.update(router as never)) return; + const previous = stop; + const next = instrument(router as never, navigations); + stop = () => { + next(); + previous(); + }; + }), + ); + await router.navigateByUrl('/lazy/guarded'); + expect(last().runs?.map((run) => [run.kind, run.guard])).toEqual([ + ['canActivate', 'lazyGuard'], + ['resolve', 'value: lazyResolver'], + ]); + }); + + it('says what a probe did and did not run', async () => { + const probe = (await runAction( + router as never, + navigations, + { action: 'probe', url: '/b' }, + () => {}, + )) as Record; + expect(probe).toMatchObject({ matched: true }); + expect(String(probe['note'])).toMatch(/canActivate.*resolvers did not run/); + }); +}); + +describe('router instrumentation flag', () => { + it('is on by default and survives a reload through sessionStorage', () => { + sessionStorage.clear(); + expect(storedInstrumented()).toBe(true); + storeInstrumented(true); + expect(storedInstrumented()).toBe(true); + storeInstrumented(false); + expect(storedInstrumented()).toBe(false); + }); +}); + +describe('router heartbeat', () => { + it('bumps reportedAt without touching the rest of the page', () => { + const page = { + pageId: 'p1', + snapshot: null, + navigations: [], + reportedAt: 1, + changedAt: 1, + }; + const pages = new Map([['p1', page]]); + expect(touchRouterPage(pages as never, 'p1', 50)).toBe(true); + expect(pages.get('p1')).toEqual({ ...page, reportedAt: 50 }); + expect(page.reportedAt).toBe(1); + expect(touchRouterPage(pages as never, 'missing', 60)).toBe(false); + expect(touchRouterPage(pages as never, 42, 60)).toBe(false); + }); +}); diff --git a/packages/ng-devtools/src/__tests__/router-extras.test.ts b/packages/ng-devtools/src/__tests__/router-extras.test.ts index 3809f93..5d35c59 100644 --- a/packages/ng-devtools/src/__tests__/router-extras.test.ts +++ b/packages/ng-devtools/src/__tests__/router-extras.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { noteFailedCall, redactText, type NavigationRecord } from '../router.ts'; import type { RouteNode } from '../router-config.ts'; -import { lintRoutes, matchUrl, renderModeFor } from '../rpc/router-config-tools.ts'; +import { lintRoutes, listRoutesText, matchUrl, renderModeFor } from '../rpc/router-config-tools.ts'; import { describeNavigation, explainNavigationText, @@ -253,3 +253,25 @@ describe('failed calls', () => { expect(list.every((n) => n.id < 0)).toBe(true); }); }); + +describe('route sources', () => { + it('cites the child, not its componentless parent, when both share a full path', () => { + const child = node('/admin', { id: '0.0', path: '', component: 'AdminHome' }); + const parent = node('/admin', { + id: '0', + path: 'admin', + kind: 'children', + component: undefined, + children: [child], + }); + const text = listRoutesText({ pages: [page({ config: [parent] })] }, {}, [ + { path: 'admin', fullPath: '/admin', file: 'app.routes.ts', line: 3 }, + { path: '', fullPath: '/admin', component: 'AdminHome', file: 'app.routes.ts', line: 6 }, + ]); + const lines = text.split('\n'); + expect(lines.find((l) => l.includes('AdminHome'))).toContain('app.routes.ts:6'); + expect(lines.find((l) => l.includes('/admin') && !l.includes('AdminHome'))).toContain( + 'app.routes.ts:3', + ); + }); +}); diff --git a/packages/ng-devtools/src/__tests__/router-guard-results.test.ts b/packages/ng-devtools/src/__tests__/router-guard-results.test.ts new file mode 100644 index 0000000..6480d76 --- /dev/null +++ b/packages/ng-devtools/src/__tests__/router-guard-results.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment jsdom +import '@angular/compiler'; +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { Router, provideRouter, type Routes } from '@angular/router'; +import { + BehaviorSubject, + Observable, + Subject, + first, + map, + type Operator, + type Subscriber, +} from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { watchRouter, type NavigationRecord } from '../router.ts'; +import { instrument } from '../router-actions.ts'; + +TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); + +class StateObservable extends Observable { + constructor(state: Observable) { + super(); + this.source = state; + } +} + +class FakeStore extends Observable { + constructor(state$: Observable, _actions: unknown, _reducers: unknown) { + super(); + this.source = state$; + } + override lift(operator: Operator): FakeStore { + const store = new FakeStore(this as never, null, null); + store.operator = operator as never; + return store; + } +} + +class Page {} +Component({ selector: 'app-page', template: 'page' })(Page); + +const state = new BehaviorSubject({ authed: true }); +const behavior = new BehaviorSubject(true); +let subject = new Subject(); +let asyncValue: Observable; + +const storeGuard = () => + new FakeStore(new StateObservable(state), null, null).pipe(map((s) => s.authed)); +const behaviorGuard = () => behavior; +const subjectGuard = () => subject; +const asyncGuard = () => asyncValue; +const asyncResolver = () => asyncValue.pipe(map((v) => (v ? 'yes' : 'no'))); + +const routes: Routes = [ + { path: '', component: Page }, + { path: 'store', component: Page, canActivate: [storeGuard] }, + { path: 'behavior', component: Page, canActivate: [behaviorGuard] }, + { path: 'subject', component: Page, canActivate: [subjectGuard] }, + { path: 'async', component: Page, canActivate: [asyncGuard], resolve: { v: asyncResolver } }, +]; + +function later(value: T): Observable { + return new Observable((subscriber: Subscriber) => { + const timer = setTimeout(() => subscriber.next(value), 5); + return () => clearTimeout(timer); + }); +} + +function wrapGuard(value: Observable) { + const guard = () => value; + const local: Routes = [{ path: 'x', component: Page, canActivate: [guard] }]; + const nav = [ + { id: 1, url: '/x', trigger: 'imperative', startedAt: 0, outcome: 'pending' }, + ] as NavigationRecord[]; + const stop = instrument({ config: local, serializeUrl: String } as never, nav); + const wrapped = (local[0].canActivate![0] as () => Observable)(); + stop(); + return { wrapped, nav }; +} + +describe('instrumented guards keep the observable they return working', () => { + let router: Router; + let navigations: NavigationRecord[]; + let cleanup: (() => void)[]; + const last = () => navigations[navigations.length - 1]; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ providers: [provideRouter(routes)] }); + router = TestBed.inject(Router); + navigations = []; + cleanup = []; + const stop = watchRouter(router as never, navigations, () => {}); + if (stop) cleanup.push(stop); + cleanup.push(instrument(router as never, navigations)); + subject = new Subject(); + asyncValue = later(true); + }); + + afterEach(() => { + for (const fn of cleanup) fn(); + }); + + it('passes a Store-like observable with its own lift and constructor', async () => { + expect(storeGuard()).toBeInstanceOf(FakeStore); + await expect(router.navigateByUrl('/store')).resolves.toBe(true); + expect(router.url).toBe('/store'); + expect(last().runs).toEqual([ + expect.objectContaining({ guard: 'storeGuard', kind: 'canActivate', result: 'true' }), + ]); + }); + + it('passes the current value of a BehaviorSubject, not a function', async () => { + behavior.next(false); + await expect(router.navigateByUrl('/behavior')).resolves.toBe(false); + expect(last().runs?.[0]).toMatchObject({ guard: 'behaviorGuard', result: 'false' }); + behavior.next(true); + await expect(router.navigateByUrl('/behavior')).resolves.toBe(true); + expect(last().runs?.[0]).toMatchObject({ result: 'true' }); + }); + + it('waits for a Subject to emit', async () => { + const navigation = router.navigateByUrl('/subject'); + await new Promise((resolve) => setTimeout(resolve, 5)); + subject.next(true); + await expect(navigation).resolves.toBe(true); + expect(last().runs?.[0]).toMatchObject({ guard: 'subjectGuard', result: 'true' }); + }); + + it('records an async guard and resolver on their first value', async () => { + await expect(router.navigateByUrl('/async')).resolves.toBe(true); + expect(last().runs?.map((run) => [run.kind, run.guard, run.result])).toEqual([ + ['canActivate', 'asyncGuard', 'true'], + ['resolve', 'v: asyncResolver', 'string'], + ]); + }); +}); + +describe('tracked guard observables', () => { + it('records the first value of an async source read through first()', async () => { + const { wrapped, nav } = wrapGuard(later(true)); + const value = await new Promise((resolve) => wrapped.pipe(first()).subscribe(resolve)); + expect(value).toBe(true); + expect(nav[0].runs).toEqual([expect.objectContaining({ result: 'true' })]); + }); + + it('records a subscription dropped before any value as cancelled', () => { + const { wrapped, nav } = wrapGuard(later(true)); + wrapped.subscribe().unsubscribe(); + expect(nav[0].runs).toEqual([expect.objectContaining({ result: 'cancelled before a value' })]); + }); + + it('records an error once', () => { + const { wrapped, nav } = wrapGuard( + new Observable((s) => s.error(new Error('denied'))), + ); + wrapped.subscribe({ error: () => {} }); + expect(nav[0].runs).toEqual([expect.objectContaining({ result: 'threw denied' })]); + }); +}); diff --git a/packages/ng-devtools/src/__tests__/router-mcp.test.ts b/packages/ng-devtools/src/__tests__/router-mcp.test.ts index 66b88a2..40bc042 100644 --- a/packages/ng-devtools/src/__tests__/router-mcp.test.ts +++ b/packages/ng-devtools/src/__tests__/router-mcp.test.ts @@ -302,8 +302,8 @@ describe('router MCP tools', () => { it('explain-render-mode reads the workspace server routes', async () => { const { push, call } = await boot(); - await push('push-router', report({ snapshot: { ...report().snapshot, url: '/products/9' } })); - const text = await call('explain-render-mode', { url: '/products/9' }); + await push('push-router', report({ snapshot: { ...report().snapshot, url: '/book/lisbon' } })); + const text = await call('explain-render-mode', { url: '/book/lisbon' }); expect(text).toContain('renders with `Client`'); expect(text).toContain('app.routes.server.ts'); }); diff --git a/packages/ng-devtools/src/__tests__/serialize.test.ts b/packages/ng-devtools/src/__tests__/serialize.test.ts new file mode 100644 index 0000000..4be2e90 --- /dev/null +++ b/packages/ng-devtools/src/__tests__/serialize.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { serialize, serializeNamed } from '../serialize.ts'; + +class _Trip { + city = 'Paris'; +} + +describe('serialize', () => { + it('handles Map, Set, Date and class instances', () => { + expect(serialize(new Map([['a', 1]]))).toEqual({ $type: 'Map', size: 1, entries: [['a', 1]] }); + expect(serialize(new Set([1, 2]))).toEqual({ $type: 'Set', size: 2, values: [1, 2] }); + expect(serialize(new Date(0))).toBe('1970-01-01T00:00:00.000Z'); + expect(serialize(new _Trip())).toEqual({ $type: 'Trip', city: 'Paris' }); + }); + + it('marks cycles but keeps shared references', () => { + const shared = { n: 1 }; + const loop: Record = { shared, again: shared }; + loop['self'] = loop; + expect(serialize(loop)).toEqual({ shared: { n: 1 }, again: { n: 1 }, self: '[Circular]' }); + }); + + it('limits depth, keys, items and text', () => { + expect(serialize({ a: { b: { c: 1 } } }, { depth: 1 })).toEqual({ a: '[Object]' }); + expect(serialize([1, 2, 3], { items: 2 })).toEqual([1, 2, '… 1 more']); + expect(serialize({ a: 1, b: 2 }, { keys: 1 })).toEqual({ a: 1, '…': '1 more' }); + expect(serialize('abcdef', { text: 3 })).toBe('abc…'); + }); + + it('turns non-JSON values into readable text', () => { + expect(serialize(Symbol('ERRORED'))).toBe('(threw an error)'); + expect(serialize(Symbol('UNSET'))).toBe('(not computed yet)'); + expect(serialize(10n)).toBe('10n'); + expect(serialize(NaN)).toBe('NaN'); + expect(serialize(function load() {})).toBe('[Function load]'); + expect(serialize(document.createElement('app-card'))).toBe(''); + const throwing = { + get boom() { + throw new Error('no'); + }, + }; + expect(serialize(throwing)).toEqual({ boom: '[Unreadable]' }); + }); + + it('redacts secret keys and token-looking strings', () => { + const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abcdefghijk'; + expect( + serialize({ + password: 'x', + token: jwt, + user: { accessToken: 'abc', name: 'Ana', hasPassword: true, apiKey: null }, + auth: `Bearer ${jwt}`, + note: `id ${jwt}`, + secrets: new Map([['clientSecret', 's3']]), + }), + ).toEqual({ + password: '[redacted]', + token: '[redacted]', + user: { accessToken: '[redacted]', name: 'Ana', hasPassword: true, apiKey: null }, + auth: 'Bearer [redacted]', + note: 'id [redacted]', + secrets: '[redacted]', + }); + expect(serialize(new Map([['clientSecret', 's3']]))).toEqual({ + $type: 'Map', + size: 1, + entries: [['clientSecret', '[redacted]']], + }); + expect(serializeNamed('password', 'hunter2')).toBe('[redacted]'); + expect(serializeNamed('email', 'a@b.c')).toBe('a@b.c'); + }); + + it('honors the mask and unmask config', () => { + const g = globalThis as { __NG_DEVTOOLS_FORMS__?: unknown }; + g.__NG_DEVTOOLS_FORMS__ = { mask: ['email'], unmask: ['pin'] }; + try { + expect(serialize({ email: 'a@b.c', pin: 1234 })).toEqual({ email: '[redacted]', pin: 1234 }); + } finally { + delete g.__NG_DEVTOOLS_FORMS__; + } + }); + + it('stops after a total node budget', () => { + const row = Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`f${i}`, i])); + const shared = Array.from({ length: 40 }, () => row); + const big = Array.from({ length: 40 }, () => shared); + const out = JSON.stringify(serialize(big, { budget: 100 })); + expect(out).toContain('[Truncated]'); + expect(out.match(/"f\d+":\d+/g)!.length).toBeLessThan(100); + expect(JSON.stringify(serialize(big))).toContain('[Truncated]'); + }); +}); diff --git a/packages/ng-devtools/src/__tests__/signal-graph.test.ts b/packages/ng-devtools/src/__tests__/signal-graph.test.ts new file mode 100644 index 0000000..2fe8395 --- /dev/null +++ b/packages/ng-devtools/src/__tests__/signal-graph.test.ts @@ -0,0 +1,220 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { elementId } from '../element-id.ts'; +import { + collectSignalGraph, + graphKey, + routedComponent, + toSignalTarget, + type SignalDebugNg, +} from '../signal-graph.ts'; + +const SIGNAL = Symbol('SIGNAL'); +const UNSET = Symbol('UNSET'); + +class Root {} +class Shell {} +class Page {} +class Card {} +class Popup {} + +function fakeNg( + components: Record, + graphOf: (el: Element) => ReturnType> = (el) => ({ + nodes: [{ id: '1', kind: 'signal', label: el.tagName.toLowerCase(), epoch: 1, value: 1 }], + edges: [], + }), +): SignalDebugNg { + return { + getComponent: (el) => components[el.tagName] ?? null, + getInjector: (el) => el, + ɵgetSignalGraph: (injector) => graphOf(injector as Element), + isSignal: (v) => typeof v === 'function' && SIGNAL in (v as object), + }; +} + +const standard = { + 'APP-ROOT': new Root(), + 'APP-SHELL': new Shell(), + 'APP-PAGE': new Page(), + 'APP-CARD': new Card(), + 'APP-POPUP': new Popup(), +}; + +describe('collectSignalGraph target', () => { + it('follows the deepest component of the primary outlet chain', () => { + document.body.innerHTML = ` + + + + + + `; + const ng = fakeNg(standard); + expect(routedComponent(ng)?.tagName).toBe('APP-PAGE'); + const graph = collectSignalGraph(ng)!; + expect(graph.componentSelector).toBe('app-page'); + expect(graph.source).toBe('routed'); + expect(graph.component).toMatchObject({ + name: 'Page', + tag: 'app-page', + path: 'app-root > app-shell > app-page', + }); + }); + + it('ignores a primary outlet nested inside an auxiliary route', () => { + document.body.innerHTML = ` + + + +
    +
    +
    `; + expect(routedComponent(fakeNg(standard))?.tagName).toBe('APP-PAGE'); + }); + + it('targets one instance by id, so the second of a list can be inspected', () => { + document.body.innerHTML = ` + `; + const second = document.querySelectorAll('app-card')[1]; + const ng = fakeNg(standard, (el) => ({ + nodes: [ + { + id: '1', + kind: 'signal', + label: el === second ? 'second' : 'other', + epoch: 1, + value: 0, + }, + ], + edges: [], + })); + const graph = collectSignalGraph(ng, { id: elementId(second) })!; + expect(graph.source).toBe('selected'); + expect(graph.nodes[0].label).toBe('second'); + expect(graph.component?.path).toBe('app-root > app-card[2]'); + }); + + it('falls back when the target is not a component host, missing or invalid', () => { + document.body.innerHTML = `
    `; + const ng = fakeNg(standard); + const plain = document.querySelector('.plain')!; + expect(collectSignalGraph(ng, { id: elementId(plain) })?.componentSelector).toBe('app-root'); + expect(collectSignalGraph(ng, { selector: 'app-gone' })?.componentSelector).toBe('app-root'); + expect(collectSignalGraph(ng, { selector: '[[bad' })?.componentSelector).toBe('app-root'); + expect(collectSignalGraph(ng, { selector: 'app-root' })?.source).toBe('selected'); + }); + + it('finds a component host without relying on _nghost attributes', () => { + document.body.innerHTML = ``; + const ng = fakeNg(standard, (el) => + el.tagName === 'APP-CARD' + ? { nodes: [{ id: '9', kind: 'signal', epoch: 0 }], edges: [] } + : { nodes: [], edges: [] }, + ); + expect(collectSignalGraph(ng)?.componentSelector).toBe('app-card'); + }); + + it('keeps an empty root graph only when no host has signals', () => { + document.body.innerHTML = ``; + const ng = fakeNg(standard, () => ({ nodes: [], edges: [] })); + const graph = collectSignalGraph(ng)!; + expect(graph.componentSelector).toBe('app-root'); + expect(graph.nodes).toEqual([]); + }); + + it('falls back to a dialog host outside the ng-version root', () => { + document.body.innerHTML = ` + +
    `; + const ng = fakeNg(standard, (el) => + el.tagName === 'APP-POPUP' + ? { nodes: [{ id: '1', kind: 'signal', label: 'open', epoch: 1, value: true }], edges: [] } + : { nodes: [], edges: [] }, + ); + expect(collectSignalGraph(ng)?.componentSelector).toBe('app-popup'); + }); +}); + +describe('collectSignalGraph values', () => { + it('reads linkedSignal values from the component fields and names sentinels', () => { + document.body.innerHTML = ``; + const selection = () => 'Paris'; + (selection as unknown as Record)[SIGNAL] = { + kind: 'linkedSignal', + debugName: 'selection', + }; + const other = () => 'nope'; + (other as unknown as Record)[SIGNAL] = { kind: 'signal', debugName: 'other' }; + const root = Object.assign(new Root(), { selection, other }); + const ng = fakeNg({ 'APP-ROOT': root }, () => ({ + nodes: [ + { id: '1', kind: 'linkedSignal', label: 'selection', epoch: 2 }, + { id: '2', kind: 'computed', label: 'total', epoch: 0, value: UNSET }, + { id: '3', kind: 'computed', label: 'map', epoch: 1, value: new Map([['a', 1]]) }, + { id: '4', kind: 'effect', label: 'log', epoch: 1 }, + ], + edges: [{ consumer: 3, producer: 0 }], + })); + const graph = collectSignalGraph(ng)!; + expect(graph.nodes[0].value).toBe('Paris'); + expect(graph.nodes[1].value).toBe('(not computed yet)'); + expect(graph.nodes[2].value).toEqual({ $type: 'Map', size: 1, entries: [['a', 1]] }); + expect('value' in graph.nodes[3]).toBe(false); + expect(graph.nodes.some((n) => 'watched' in n)).toBe(false); + }); + + it('redacts secret-named signals and tokens inside values', () => { + document.body.innerHTML = ``; + const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abcdefghijk'; + const password = () => 'hunter2'; + (password as unknown as Record)[SIGNAL] = { + kind: 'linkedSignal', + debugName: 'password', + }; + const root = Object.assign(new Root(), { password }); + const ng = fakeNg({ 'APP-ROOT': root }, () => ({ + nodes: [ + { + id: '1', + kind: 'signal', + label: 'model', + epoch: 1, + value: { email: 'a@b.c', password: 'x' }, + }, + { id: '2', kind: 'signal', label: 'accessToken', epoch: 1, value: 'abc123' }, + { id: '3', kind: 'linkedSignal', label: 'password', epoch: 1 }, + { id: '4', kind: 'computed', label: 'header', epoch: 1, value: `Bearer ${jwt}` }, + ], + edges: [], + })); + const graph = collectSignalGraph(ng)!; + expect(graph.nodes[0].value).toEqual({ email: 'a@b.c', password: '[redacted]' }); + expect(graph.nodes[1].value).toBe('[redacted]'); + expect(graph.nodes[2].value).toBe('[redacted]'); + expect(graph.nodes[3].value).not.toContain('eyJ'); + }); + + it('keys a graph by component, node ids and epochs', () => { + document.body.innerHTML = ``; + let epoch = 1; + const ng = fakeNg(standard, () => ({ + nodes: [{ id: '1', kind: 'signal', label: 'count', epoch, value: epoch }], + edges: [], + })); + const first = graphKey(collectSignalGraph(ng)!); + expect(graphKey(collectSignalGraph(ng)!)).toBe(first); + epoch = 2; + expect(graphKey(collectSignalGraph(ng)!)).not.toBe(first); + }); +}); + +describe('toSignalTarget', () => { + it('ignores requests for another page and accepts agent selectors', () => { + expect(toSignalTarget({ pageId: 'b', id: 'c1' }, 'a')).toBeUndefined(); + expect(toSignalTarget({ pageId: 'a', id: 'c1' }, 'a')).toEqual({ id: 'c1' }); + expect(toSignalTarget({ id: null }, 'a')).toBeNull(); + expect(toSignalTarget('app-card', 'a')).toEqual({ selector: 'app-card' }); + expect(toSignalTarget(null, 'a')).toBeNull(); + }); +}); diff --git a/packages/ng-devtools/src/__tests__/signal-history.test.ts b/packages/ng-devtools/src/__tests__/signal-history.test.ts index 6d5858b..714891c 100644 --- a/packages/ng-devtools/src/__tests__/signal-history.test.ts +++ b/packages/ng-devtools/src/__tests__/signal-history.test.ts @@ -1,7 +1,12 @@ // @vitest-environment jsdom import { describe, expect, it, vi } from 'vitest'; import { installSignalWriteHook } from '../overlay.ts'; -import { MAX_CHANGES, createSignalHistory, type RawSignalNode } from '../signal-history.ts'; +import { + MAX_CHANGES, + MAX_NODES, + createSignalHistory, + type RawSignalNode, +} from '../signal-history.ts'; import type { SignalGraphNode } from '../types.ts'; const identity = (v: unknown) => v; @@ -13,7 +18,7 @@ function graphNode( value: unknown, kind: SignalGraphNode['kind'] = 'signal', ): SignalGraphNode { - return { id, kind, label, epoch, value, watched: false }; + return { id, kind, label, epoch, value }; } function write(onWrite: (n: RawSignalNode) => void, raw: RawSignalNode, value: unknown) { @@ -66,13 +71,22 @@ describe('createSignalHistory', () => { expect(list.at(-1)?.value).toBe('last'); }); - it('prunes nodes that left the graph', () => { + it('keeps history of nodes that left the graph, so switching components back keeps it', () => { const h = createSignalHistory(identity); h.collect([graphNode('a', 'x', 1, 1)]); - h.collect([]); - expect(h.collect([graphNode('a', 'x', 1, 1)])['a']).toEqual([ + h.collect([graphNode('a', 'x', 2, 2)]); + expect(h.collect([graphNode('b', 'y', 0, 0)])['a']).toBeUndefined(); + expect(h.collect([graphNode('a', 'x', 2, 2)])['a'].map((c) => c.value)).toEqual([1, 2]); + }); + + it('forgets the least recently seen nodes past the cap', () => { + const h = createSignalHistory(identity); + h.collect([graphNode('first', 'x', 1, 1)]); + for (let i = 0; i < MAX_NODES; i++) h.collect([graphNode(`n${i}`, 'y', 1, i)]); + expect(h.collect([graphNode('first', 'x', 1, 1)])['first']).toEqual([ expect.objectContaining({ source: 'initial' }), ]); + expect(h.collect([graphNode('n1', 'y', 1, 1)])['n1']).toHaveLength(1); }); it('skips effects and unnamed writes', () => { @@ -104,6 +118,32 @@ describe('createSignalHistory', () => { write(h.onWrite, raw, 5); expect(h.collect([graphNode('a', 'n', 1, 's:5')])['a'][1].value).toBe('s:5'); }); + + it('passes the signal name so secret-named writes can be redacted', () => { + const h = createSignalHistory((v, name) => (name === 'password' ? '[redacted]' : v)); + const raw: RawSignalNode = { debugName: 'password', kind: 'signal', version: 0 }; + h.collect([graphNode('a', 'password', 0, '[redacted]')]); + write(h.onWrite, raw, 'hunter2'); + const out = h.collect([graphNode('a', 'password', 1, '[redacted]')]); + expect(out['a'].map((c) => c.value)).toEqual(['[redacted]', '[redacted]']); + }); + + it('sends only changes newer than the last push unless asked for everything', () => { + let at = 0; + const h = createSignalHistory(identity, () => ++at); + const first = h.collectDelta([graphNode('a', 'count', 0, 0), graphNode('b', 'other', 0, 'x')]); + expect(Object.keys(first)).toEqual(['a', 'b']); + const quiet = h.collectDelta([graphNode('a', 'count', 0, 0), graphNode('b', 'other', 0, 'x')]); + expect(quiet).toEqual({}); + const next = h.collectDelta([graphNode('a', 'count', 2, 2), graphNode('b', 'other', 0, 'x')]); + expect(next).toEqual({ a: [expect.objectContaining({ epoch: 2, value: 2 })] }); + const full = h.collectDelta( + [graphNode('a', 'count', 2, 2), graphNode('b', 'other', 0, 'x')], + true, + ); + expect(full['a'].map((c) => c.epoch)).toEqual([0, 2]); + expect(full['b']).toHaveLength(1); + }); }); describe('installSignalWriteHook', () => { diff --git a/packages/ng-devtools/src/__tests__/vite-upgrade-guard.test.ts b/packages/ng-devtools/src/__tests__/vite-upgrade-guard.test.ts new file mode 100644 index 0000000..ca5ce7f --- /dev/null +++ b/packages/ng-devtools/src/__tests__/vite-upgrade-guard.test.ts @@ -0,0 +1,164 @@ +import { Server } from 'node:http'; +import { describe, expect, it, vi } from 'vitest'; +import { + guardNewUpgrades, + hubOriginRegistry, + hubRequestGate, + isAllowedHubOrigin, +} from '../vite.ts'; + +function upgrade(server: Server, remoteAddress: string, url = '/__devframes/ws', origin?: string) { + const socket = { destroy: vi.fn() }; + const headers = origin === undefined ? {} : { origin }; + server.emit('upgrade', { url, headers, socket: { remoteAddress } }, socket, Buffer.alloc(0)); + return socket; +} + +function guarded() { + const server = new Server(); + const before = server.listeners('upgrade'); + const hub = vi.fn(); + server.on('upgrade', hub); + guardNewUpgrades(server, before, '/__devframes/'); + return { server, hub }; +} + +function request(url: string, remoteAddress = '127.0.0.1', origin?: string) { + const res = { statusCode: 200, end: vi.fn() }; + const next = vi.fn(); + const headers = origin === undefined ? {} : { origin }; + hubRequestGate('/__devframes/')( + { url, headers, socket: { remoteAddress } } as never, + res as never, + next, + ); + return { status: next.mock.calls.length ? 'next' : res.statusCode }; +} + +describe('guardNewUpgrades', () => { + it('only lets loopback clients reach the listeners added after the snapshot', () => { + const server = new Server(); + const vite = vi.fn(); + server.on('upgrade', vite); + const before = server.listeners('upgrade'); + const hub = vi.fn(); + server.on('upgrade', hub); + + guardNewUpgrades(server, before, '/__devframes/'); + + const remote = upgrade(server, '192.168.1.20'); + expect(hub).not.toHaveBeenCalled(); + expect(remote.destroy).toHaveBeenCalled(); + expect(vite).toHaveBeenCalledTimes(1); + + for (const address of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) upgrade(server, address); + expect(hub).toHaveBeenCalledTimes(3); + + const hmr = upgrade(server, '192.168.1.20', '/?token=abc'); + expect(hmr.destroy).not.toHaveBeenCalled(); + expect(hub).toHaveBeenCalledTimes(4); + }); + + it('refuses hub upgrades from a web page on another origin, even over loopback', () => { + const { server, hub } = guarded(); + const evil = upgrade(server, '127.0.0.1', '/__devframes/__ws', 'https://evil.example'); + expect(evil.destroy).toHaveBeenCalled(); + for (const origin of ['http://127.attacker.example', 'null', 'file://']) { + expect(upgrade(server, '127.0.0.1', '/__devframes/__ws', origin).destroy).toHaveBeenCalled(); + } + expect(hub).not.toHaveBeenCalled(); + + for (const origin of [ + 'http://localhost:5173', + 'https://127.0.0.1:4200', + 'http://[::1]:3000', + 'chrome-extension://abcdefghijklmnop', + undefined, + ]) { + expect( + upgrade(server, '127.0.0.1', '/__devframes/__ws', origin).destroy, + ).not.toHaveBeenCalled(); + } + expect(hub).toHaveBeenCalledTimes(5); + }); + + it('normalizes the path before deciding whether an upgrade targets the hub', () => { + const { server, hub } = guarded(); + for (const url of [ + '/./__devframes/__ws', + '/x/../__devframes/__ws', + '/%2e/__devframes/__ws', + '/__devframes', + ]) { + expect(upgrade(server, '192.168.1.20', url).destroy).toHaveBeenCalled(); + } + expect(hub).not.toHaveBeenCalled(); + }); +}); + +describe('hub origin policy', () => { + it('allows loopback http(s) pages and the extension, and rejects other sites', () => { + expect(isAllowedHubOrigin(undefined)).toBe(true); + expect(isAllowedHubOrigin('http://localhost:5173')).toBe(true); + expect(isAllowedHubOrigin('chrome-extension://abcdefghijklmnop')).toBe(true); + expect(isAllowedHubOrigin('https://evil.example')).toBe(false); + expect(isAllowedHubOrigin('ws://localhost:5173')).toBe(false); + expect(hubOriginRegistry.isAllowed('https://evil.example')).toBe(false); + expect(hubOriginRegistry.isAllowed('http://127.0.0.1:9777')).toBe(true); + }); +}); + +describe('hubRequestGate', () => { + it('forbids hub requests from other machines, including dot-segment and encoded paths', () => { + for (const url of [ + '/__devframes/__mcp', + '/./__devframes/__mcp', + '/a/../__devframes/__mcp', + '/%2e/__devframes/__sse', + '/__devframes', + ]) { + expect(request(url, '192.168.1.20')).toEqual({ status: 403 }); + } + expect(request('/src/main.ts', '192.168.1.20')).toEqual({ status: 'next' }); + }); + + it('forbids hub requests carrying a foreign Origin from this machine', () => { + expect(request('/__devframes/__sse', '127.0.0.1', 'https://evil.example')).toEqual({ + status: 403, + }); + expect(request('/./__devframes/__sse', '127.0.0.1', 'https://evil.example')).toEqual({ + status: 403, + }); + expect(request('/__devframes/__sse', '127.0.0.1', 'http://localhost:5173')).toEqual({ + status: 'next', + }); + expect(request('/__devframes/__mcp', '::1')).toEqual({ status: 'next' }); + expect(request('/src/main.ts', '127.0.0.1', 'https://evil.example')).toEqual({ + status: 'next', + }); + }); + + it('hides connection files outside the hub base', () => { + expect(request('/other/__connection.json')).toEqual({ status: 404 }); + expect(request('/__devframes/__connection.json')).toEqual({ status: 'next' }); + }); +}); + +describe('hub origin policy', () => { + it('trusts hosts from Vite allowedHosts and explicit origins, and nothing else', async () => { + const { isAllowedHubOrigin } = await import('../vite.ts'); + const policy = { + allowedHosts: ['myapp.test', '.dev.local'] as const, + allowedOrigins: ['https://tunnel.example:8443'], + }; + expect(isAllowedHubOrigin('http://myapp.test:5173', policy)).toBe(true); + expect(isAllowedHubOrigin('http://api.dev.local:5173', policy)).toBe(true); + expect(isAllowedHubOrigin('http://dev.local', policy)).toBe(true); + expect(isAllowedHubOrigin('https://tunnel.example:8443', policy)).toBe(true); + expect(isAllowedHubOrigin('https://tunnel.example', policy)).toBe(false); + expect(isAllowedHubOrigin('http://myapp.test.evil.example', policy)).toBe(false); + expect(isAllowedHubOrigin('https://evil.example', policy)).toBe(false); + expect(isAllowedHubOrigin('http://myapp.test:5173')).toBe(false); + expect(isAllowedHubOrigin('https://evil.example', { allowedHosts: true })).toBe(true); + }); +}); diff --git a/packages/ng-devtools/src/analog-runtime.ts b/packages/ng-devtools/src/analog-runtime.ts index 70bed61..fdfeaf5 100644 --- a/packages/ng-devtools/src/analog-runtime.ts +++ b/packages/ng-devtools/src/analog-runtime.ts @@ -1,3 +1,6 @@ +import { hasStateScript, scanHydration } from './http-hydration.ts'; +import { createHydrationScanner } from './http-overlay.ts'; + type AnyRecord = Record; export const ANALOG_META_DESCRIPTION = '@analogjs/router Analog Route Metadata Key'; @@ -14,6 +17,7 @@ export interface AnalogRuntimeReport { analog: boolean; chain: AnalogPageInfo[]; load?: { preview: string; bytes: number; keys: string[] }; + loadFrom?: number; serverContext?: string; hydrated: number; transferState: boolean; @@ -23,6 +27,8 @@ export interface AnalogRuntimeReport { const MAX_PREVIEW = 1000; const MAX_PATHS = 500; +const MAX_ERRORS = 20; +const HEARTBEAT_MS = 8000; const SECRET = /pass|pwd|secret|token|api.?key|card|cvv|cvc|ssn|iban|otp|session|cookie|auth/i; function read(fn: () => T, fallback: T): T { @@ -66,9 +72,23 @@ function redact(value: unknown, depth = 0): unknown { return out; } -export function chainOf(root: AnyRecord | null): { chain: AnalogPageInfo[]; data: unknown } { +function isEmptyObject(value: unknown): boolean { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value as object).length === 0 + ); +} + +export function chainOf(root: AnyRecord | null): { + chain: AnalogPageInfo[]; + data: unknown; + loadFrom?: number; +} { const chain: AnalogPageInfo[] = []; let data: unknown; + let loadFrom: number | undefined; const segments: string[] = []; for (let node = root, guard = 0; node && guard < 40; guard++) { const config = read(() => node!['routeConfig'] as AnyRecord | null, null); @@ -78,11 +98,14 @@ export function chainOf(root: AnyRecord | null): { chain: AnalogPageInfo[]; data if (meta) { chain.push({ path: `/${segments.join('/')}`, ...fileOfEndpoint(meta.endpointKey) }); const load = read(() => node!['data']?.['load'], undefined); - if (load !== undefined) data = load; + if (load !== undefined && (loadFrom === undefined || !isEmptyObject(load))) { + data = load; + loadFrom = chain.length - 1; + } } node = read(() => node!['firstChild'] as AnyRecord | null, null); } - return { chain, data }; + return { chain, data, loadFrom }; } export function configPathsOf( @@ -132,7 +155,7 @@ export function hydrationErrorOf(args: unknown[]): string | null { const text = args .map((arg) => (arg instanceof Error ? arg.message : typeof arg === 'string' ? arg : '')) .join(' '); - const match = text.match(/NG0?5\d{2}[^\n]*/); + const match = text.match(/\bNG05\d\d\b[^\n]*/); return match ? match[0].slice(0, 300) : null; } @@ -162,15 +185,28 @@ export function hasAnalogMeta(routes: unknown, depth = 0): boolean { ); } +type HydrationScanner = (counters: Record | undefined) => { hydrated: number }; + +function hydratedNodes(scanner?: HydrationScanner): number { + const counters = (globalThis as { ngDevMode?: unknown }).ngDevMode; + const record = + counters && typeof counters === 'object' ? (counters as Record) : undefined; + const scanned = scanner ? scanner(record).hydrated : scanHydration(document).hydrated; + if (scanned) return scanned; + const counted = record?.['hydratedNodes']; + return typeof counted === 'number' ? counted : 0; +} + export function collectAnalog( ng: AnyRecord | undefined, pageId: string, hydrationErrors: string[], + scanner?: HydrationScanner, ): AnalogRuntimeReport | null { const router = routerOf(ng); if (!router) return null; const root = read(() => router['routerState']['snapshot']['root'] as AnyRecord, null); - const { chain, data } = chainOf(root); + const { chain, data, loadFrom } = chainOf(root); const paths = configPathsOf(read(() => router['config'], [])); const analog = chain.length > 0 || hasAnalogMeta(read(() => router['config'], null)); const rootEl = document.querySelector('[ng-version]'); @@ -179,15 +215,18 @@ export function collectAnalog( url: read(() => String(router['url']), location.pathname), analog, chain, - hydrated: document.querySelectorAll('[ngh]').length, - transferState: !!document.getElementById('ng-state'), - hydrationErrors: hydrationErrors.slice(-20), + hydrated: analog ? hydratedNodes(scanner) : 0, + transferState: hasStateScript(document), + hydrationErrors: hydrationErrors.slice(-MAX_ERRORS), configPaths: paths, }; const context = rootEl?.getAttribute('ng-server-context'); if (context) report.serverContext = context; const load = loadSummary(data); - if (load) report.load = load; + if (load) { + report.load = load; + if (loadFrom !== undefined) report.loadFrom = loadFrom; + } return report; } @@ -197,22 +236,28 @@ interface Rpc { export function attachAnalog(my: Rpc, pageId: string, getNg: () => AnyRecord | undefined) { const hydrationErrors: string[] = []; + const scanner = createHydrationScanner(); let last = ''; + let lastAt = 0; let subscription: { unsubscribe(): void } | null = null; const original = console.error; const patched = function (this: unknown, ...args: unknown[]) { const error = read(() => hydrationErrorOf(args), null); - if (error && !hydrationErrors.includes(error)) hydrationErrors.push(error); + if (error && !hydrationErrors.includes(error)) { + hydrationErrors.push(error); + if (hydrationErrors.length > MAX_ERRORS) hydrationErrors.shift(); + } return original.apply(this, args as []); }; console.error = patched; const push = () => { - const report = read(() => collectAnalog(getNg(), pageId, hydrationErrors), null); + const report = read(() => collectAnalog(getNg(), pageId, hydrationErrors, scanner), null); if (!report || !report.analog) return; const text = JSON.stringify(report); - if (text === last) return; + if (text === last && Date.now() - lastAt < HEARTBEAT_MS) return; last = text; + lastAt = Date.now(); void my.rpc.call('push-analog', report).catch(() => {}); }; diff --git a/packages/ng-devtools/src/analog-server-log.ts b/packages/ng-devtools/src/analog-server-log.ts index f0939b1..08afe95 100644 --- a/packages/ng-devtools/src/analog-server-log.ts +++ b/packages/ng-devtools/src/analog-server-log.ts @@ -73,6 +73,13 @@ export function recordCall(call: Omit): AnalogCall { export function clearCalls() { calls.length = 0; + for (const listener of listeners) { + try { + listener([]); + } catch { + // a broken listener must not break the dev server + } + } } export function setDevOrigin(value: string | undefined) { @@ -106,6 +113,16 @@ export function previewOf(body: string, type: string | undefined): string | unde return text.length > MAX_PREVIEW ? `${text.slice(0, MAX_PREVIEW)}…` : text; } +/** Maps an Analog load endpoint path back to the page URL it serves. */ +export function loadRoute(endpoint: string): string { + return ( + endpoint + .replace(/\/\([^/]*\)(?=\/|$)/g, '') + .replace(/\/-[^/]+-$/, '') + .replace(/\/index$/, '') || '/' + ); +} + export function classify( url: string, method: string, @@ -116,7 +133,7 @@ export function classify( const prefix = apiPrefix ? `/${apiPrefix}` : ''; for (const base of [`${prefix}/_analog/pages`, '/_analog/pages']) { if (path.startsWith(`${base}/`) || path === base) { - return { kind: 'load', route: path.slice(base.length).replace(/\/index$/, '') || '/' }; + return { kind: 'load', route: loadRoute(path.slice(base.length)) }; } } for (const base of [`${prefix}/_analog/fn`, '/_analog/fn']) { @@ -219,17 +236,39 @@ export interface DuplicateLoad { browserAt: number; } -export function duplicateLoads(list: AnalogCall[], windowMs = 15_000): DuplicateLoad[] { +/** + * Pairs the SSR load() of a server rendered page with the first browser load() + * of the same route that follows the render. Any other navigation resets it. + */ +function rendersRoute(loadRoute: string, pageRoute: string): boolean { + const load = loadRoute.replace(/\/+$/, '') || '/'; + const page = pageRoute.replace(/\/+$/, '') || '/'; + return load === page || load === '/' || page.startsWith(`${load}/`); +} + +export function duplicateLoads(list: AnalogCall[], windowMs = 10_000): DuplicateLoad[] { const out: DuplicateLoad[] = []; - const lastSsr = new Map(); + let ssrLoads = new Map(); + let armed = new Map(); + let armedAt = 0; for (const call of list) { - if (call.kind !== 'load' || !call.route) continue; - if (call.from === 'ssr') lastSsr.set(call.route, call.at); - else if (call.from === 'browser') { - const ssrAt = lastSsr.get(call.route); - if (ssrAt !== undefined && call.at - ssrAt <= windowMs) { - out.push({ route: call.route, ssrAt, browserAt: call.at }); - lastSsr.delete(call.route); + if (call.from === 'devtools' || !call.route) continue; + if (call.kind === 'page') { + const page = call.route; + armed = + call.from === 'browser' && call.render === 'ssr' + ? new Map([...ssrLoads].filter(([route]) => rendersRoute(route, page))) + : new Map(); + armedAt = call.at; + ssrLoads = new Map(); + } else if (call.kind === 'load') { + if (call.from === 'ssr') { + ssrLoads.set(call.route, call.at); + } else if (armed.has(call.route) && call.at - armedAt <= windowMs) { + out.push({ route: call.route, ssrAt: armed.get(call.route)!, browserAt: call.at }); + armed.delete(call.route); + } else { + armed = new Map(); } } } diff --git a/packages/ng-devtools/src/component-tree.ts b/packages/ng-devtools/src/component-tree.ts new file mode 100644 index 0000000..3abc5bd --- /dev/null +++ b/packages/ng-devtools/src/component-tree.ts @@ -0,0 +1,239 @@ +import { elementById, elementId, pruneElementIds } from './element-id.ts'; +import { className, dependenciesOf, type DebugNg } from './injector-tree.ts'; +import { isSecretName, serializeNamed } from './serialize.ts'; +import type { + ComponentDetail, + ComponentProp, + ComponentTreeReport, + LiveComponentNode, +} from './types.ts'; + +export interface ComponentDebugNg extends DebugNg { + getComponent?(el: Element): unknown; + getDirectives?(el: Element): unknown[]; + getDirectiveMetadata?(instance: unknown): { + inputs?: Record; + outputs?: Record; + changeDetection?: number; + encapsulation?: number; + } | null; + getListeners?(el: Element): { name: string; type?: string }[]; + isSignal?(value: unknown): boolean; +} + +const MAX_COMPONENTS = 2000; +const MAX_DEPTH = 256; +const MAX_PROPS = 60; +const VALUE_LIMITS = { depth: 3, keys: 30, items: 30, text: 300 }; + +const CHANGE_DETECTION: Record = { 0: 'OnPush', 1: 'Default' }; +const ENCAPSULATION: Record = { + 0: 'Emulated', + 2: 'None', + 3: 'ShadowDom', + 4: 'IsolatedShadowDom', +}; + +function read(fn: () => T, fallback: T): T { + try { + return fn(); + } catch { + return fallback; + } +} + +function nameOf(instance: unknown): string { + const ctor = (instance as { constructor?: unknown } | null)?.constructor; + return typeof ctor === 'function' ? className(ctor) : 'Anonymous'; +} + +function componentAt(ng: ComponentDebugNg, el: Element): object | null { + const found = read(() => ng.getComponent?.(el) ?? null, null); + return found && typeof found === 'object' ? found : null; +} + +function directivesAt(ng: ComponentDebugNg, el: Element): object[] { + const found = read(() => ng.getDirectives?.(el) ?? [], [] as unknown[]); + return found.filter((d): d is object => !!d && typeof d === 'object'); +} + +export function childElements(el: Element): Element[] { + const children = Array.from(el.children); + const shadow = (el as Element & { shadowRoot?: ShadowRoot | null }).shadowRoot; + if (shadow) children.push(...Array.from(shadow.children)); + return children; +} + +function parentOf(el: Element): Element | null { + if (el.parentElement) return el.parentElement; + const root = el.parentNode; + return root && 'host' in root ? ((root as ShadowRoot).host ?? null) : null; +} + +export function angularRoots(doc: Document = document): Element[] { + const tagged = Array.from(doc.querySelectorAll('[ng-version]')); + const roots = tagged.filter((root) => !tagged.some((o) => o !== root && o.contains(root))); + if (!doc.body) return roots; + if (!roots.length) return [doc.body]; + const outside: Element[] = []; + const collect = (el: Element) => { + for (const child of childElements(el)) { + if (roots.includes(child)) continue; + if (roots.some((root) => child.contains(root))) collect(child); + else outside.push(child); + } + }; + collect(doc.body); + return [...roots, ...outside]; +} + +export function componentHosts( + ng: ComponentDebugNg, + doc: Document = document, + limit = MAX_COMPONENTS, +): Element[] { + const out: Element[] = []; + const visit = (el: Element, depth: number) => { + if (out.length >= limit || depth > MAX_DEPTH) return; + if (componentAt(ng, el)) out.push(el); + for (const child of childElements(el)) visit(child, depth + 1); + }; + for (const root of angularRoots(doc)) visit(root, 0); + return out; +} + +export function hostPath(ng: ComponentDebugNg, el: Element): string { + const parts: string[] = []; + for (let node: Element | null = el; node; node = parentOf(node)) { + if (!componentAt(ng, node)) continue; + const tag = node.tagName.toLowerCase(); + const parent = node.parentNode; + const twins = parent + ? Array.from(parent.children).filter((c) => c.tagName === node!.tagName) + : []; + parts.unshift(twins.length > 1 ? `${tag}[${twins.indexOf(node) + 1}]` : tag); + } + return parts.join(' > '); +} + +function unwrap(ng: ComponentDebugNg, value: unknown): unknown { + if (typeof value !== 'function') return value; + const signal = read(() => !!ng.isSignal?.(value), false); + return signal ? read(() => (value as () => unknown)(), undefined) : value; +} + +function propName(entry: unknown, fallback: string): string { + if (typeof entry === 'string') return entry; + if (Array.isArray(entry) && typeof entry[0] === 'string') return entry[0]; + return fallback; +} + +function readInputs( + ng: ComponentDebugNg, + instance: object, + inputs: Record | undefined, +): ComponentProp[] { + return Object.entries(inputs ?? {}) + .slice(0, MAX_PROPS) + .map(([name, entry]) => { + const prop = propName(entry, name); + const raw = read(() => (instance as Record)[prop], undefined); + const key = isSecretName(prop) ? prop : name; + return { name, prop, value: serializeNamed(key, unwrap(ng, raw), VALUE_LIMITS) }; + }); +} + +function readOutputs( + outputs: Record | undefined, + listened: Set, +): ComponentProp[] { + return Object.entries(outputs ?? {}) + .slice(0, MAX_PROPS) + .map(([name, entry]) => ({ name, prop: propName(entry, name), listened: listened.has(name) })); +} + +export function componentDetail(ng: ComponentDebugNg, el: Element): ComponentDetail | null { + const instance = componentAt(ng, el); + if (!instance) return null; + const meta = read(() => ng.getDirectiveMetadata?.(instance) ?? null, null); + const listeners = read( + () => ng.getListeners?.(el) ?? [], + [] as { name: string; type?: string }[], + ); + const listened = new Set(listeners.filter((l) => l.type === 'output').map((l) => l.name)); + const dom = [...new Set(listeners.filter((l) => l.type !== 'output').map((l) => l.name))]; + const directives = directivesAt(ng, el).filter((d) => d !== instance); + + const detail: ComponentDetail = { + id: elementId(el), + name: nameOf(instance), + tag: el.tagName.toLowerCase(), + path: hostPath(ng, el), + inputs: readInputs(ng, instance, meta?.inputs), + outputs: readOutputs(meta?.outputs, listened), + listeners: dom.slice(0, MAX_PROPS), + directives: directives.map((directive) => { + const dirMeta = read(() => ng.getDirectiveMetadata?.(directive) ?? null, null); + return { + name: nameOf(directive), + inputs: readInputs(ng, directive, dirMeta?.inputs), + outputs: readOutputs(dirMeta?.outputs, listened), + }; + }), + dependencies: [], + }; + const cd = meta?.changeDetection; + if (typeof cd === 'number' && CHANGE_DETECTION[cd]) detail.changeDetection = CHANGE_DETECTION[cd]; + const enc = meta?.encapsulation; + if (typeof enc === 'number' && ENCAPSULATION[enc]) detail.encapsulation = ENCAPSULATION[enc]; + + const injector = read(() => ng.getInjector?.(el) ?? null, null); + if (injector) { + detail.dependencies = dependenciesOf(ng, injector, [instance.constructor], true); + } + return detail; +} + +export function collectComponentTree( + ng: ComponentDebugNg | undefined, + options: { doc?: Document; selectedId?: string | null } = {}, +): Omit { + const doc = options.doc ?? document; + const report: Omit = { roots: [], count: 0, detail: null }; + if (!ng?.getComponent) return report; + + const visit = (el: Element, out: LiveComponentNode[], depth: number) => { + if (depth > MAX_DEPTH) { + report.truncated = true; + return; + } + const instance = componentAt(ng, el); + let target = out; + if (instance) { + if (report.count >= MAX_COMPONENTS) { + report.truncated = true; + return; + } + report.count++; + const node: LiveComponentNode = { + id: elementId(el), + name: nameOf(instance), + tag: el.tagName.toLowerCase(), + children: [], + }; + const directives = directivesAt(ng, el) + .filter((d) => d !== instance) + .map(nameOf); + if (directives.length) node.directives = directives; + out.push(node); + target = node.children; + } + for (const child of childElements(el)) visit(child, target, depth + 1); + }; + for (const root of angularRoots(doc)) visit(root, report.roots, 0); + + pruneElementIds(); + const selected = options.selectedId ? elementById(options.selectedId) : null; + if (selected) report.detail = componentDetail(ng, selected); + return report; +} diff --git a/packages/ng-devtools/src/devframe.ts b/packages/ng-devtools/src/devframe.ts index 4265d92..72c2b73 100644 --- a/packages/ng-devtools/src/devframe.ts +++ b/packages/ng-devtools/src/devframe.ts @@ -9,8 +9,31 @@ import { trackPageSessions } from './rpc/page-sessions.ts'; import { getBuildMeta } from './rpc/build-meta.ts'; import { getSignals } from './rpc/get-signals.ts'; import { getProviders } from './rpc/get-providers.ts'; -import { getNgrxStore } from './rpc/get-ngrx-store.ts'; -import type { NgrxRuntimeAction, SignalGraph } from './types.ts'; +import { getNgrxStore, scanNgrxStore } from './rpc/get-ngrx-store.ts'; +import { + expireNgrxPages, + isNgrxReport, + mergeNgrxReport, + ngrxStateOf, + type NgrxDeclaration, + type NgrxPages, +} from './rpc/ngrx-tools.ts'; +import type { NgrxState } from './ngrx-shared.ts'; +import type { + ComponentPage, + InjectorPage, + InjectorTreeNode, + LiveComponentNode, + SignalChange, + SignalGraph, +} from './types.ts'; +import { + expireComponentPages, + findComponents, + isComponentReport, + latestComponentPage, + toComponentPage, +} from './rpc/component-tools.ts'; import { explainFormsText, formsResourceText, @@ -56,6 +79,7 @@ import { isRouterReport, mergeRouterReport, routerResourceText, + touchRouterPage, type RouterPage, type RouterState, } from './rpc/router-tools.ts'; @@ -82,6 +106,7 @@ import { sanitizeHydration, sanitizePayload } from './http-payload.ts'; import type { HttpPage, HttpState } from './types.ts'; import { registerAnalog } from './rpc/analog-register.ts'; +import { registerHubDocks } from './hub-docks.ts'; import pkg from '../package.json' with { type: 'json' }; @@ -104,6 +129,7 @@ const ngDevtools = defineDevframe({ icon: 'ph:angular-logo-duotone', importMetaUrl: import.meta.url, clientAssets, + dock: { visibility: 'false' }, async setup(ctx) { const my = ctx.scope('ng-devtools'); @@ -118,11 +144,18 @@ const ngDevtools = defineDevframe({ const componentTree = await my.rpc.sharedState('component-tree', { initialValue: { - nodes: [], - selectedId: null, - highlightedId: null, + nodes: [] as LiveComponentNode[], + pages: {} as Record, + selectedId: null as string | null, + highlightedId: null as string | null, }, }); + const componentPages = new Map(); + const applyComponentPages = () => + componentTree.mutate((draft) => { + draft.pages = Object.fromEntries(componentPages); + draft.nodes = latestComponentPage(componentPages.values())?.roots ?? []; + }); await my.rpc.sharedState('routes', { initialValue: { @@ -142,18 +175,44 @@ const ngDevtools = defineDevframe({ const injectorTreeState = await my.rpc.sharedState('injector-tree', { initialValue: { - roots: [] as any[], + roots: [] as InjectorTreeNode[], + environment: [] as InjectorTreeNode[], + pages: {} as Record, selectedInjectorId: null as string | null, }, }); + const injectorPages = new Map(); + const latestInjectorPage = () => + [...injectorPages.values()].sort((a, b) => b.reportedAt - a.reportedAt)[0]; + const applyInjectorPages = () => + injectorTreeState.mutate((draft) => { + const latest = latestInjectorPage(); + draft.pages = Object.fromEntries(injectorPages); + draft.roots = latest?.roots ?? []; + draft.environment = latest?.environment ?? []; + }); const ngrxStoreState = await my.rpc.sharedState('ngrx-store', { - initialValue: { - state: null as unknown, - actions: [] as NgrxRuntimeAction[], - connected: false, - }, + initialValue: { pages: [] } as NgrxState, }); + const ngrxPages: NgrxPages = new Map(); + let ngrxDeclarations: { at: number; list: NgrxDeclaration[] } = { at: 0, list: [] }; + const ngrxNames = () => { + if (Date.now() - ngrxDeclarations.at > 10_000) { + let list: NgrxDeclaration[] = []; + try { + list = scanNgrxStore(ctx.cwd).filter((e) => e.kind === 'signal-store'); + } catch { + list = []; + } + ngrxDeclarations = { at: Date.now(), list }; + } + return ngrxDeclarations.list; + }; + const applyNgrx = () => + ngrxStoreState.mutate((draft) => { + draft.pages = ngrxStateOf(ngrxPages).pages as never; + }); const formPages = new Map(); const formsState = await my.rpc.sharedState('forms', { @@ -268,6 +327,21 @@ const ngDevtools = defineDevframe({ }, }); + my.rpc.register({ + name: 'ping-router', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + if (!touchRouterPage(routerPages, pageId)) return { known: false }; + const reportedAt = routerPages.get(pageId as string)!.reportedAt; + routerState.mutate((draft) => { + const page = draft.pages.find((p) => p.pageId === pageId); + if (page) page.reportedAt = reportedAt; + }); + return { known: true }; + }, + }); + const pendingActions = new Map void>(); let actionSeq = 0; @@ -373,6 +447,7 @@ const ngDevtools = defineDevframe({ initialValue: { serverCalls: [], pages: [], rules: [] } as HttpState, }); const registry = httpRegistry(); + registry.dispose?.(); registry.rules ??= []; let pendingServerCalls: HttpCall[] = []; let flushTimer: ReturnType | undefined; @@ -394,7 +469,7 @@ const ngDevtools = defineDevframe({ }; const applyHttpPages = () => httpState.mutate((draft) => { - draft.pages = [...httpPages.values()].sort((a, b) => b.reportedAt - a.reportedAt); + draft.pages = [...httpPages.values()].sort((a, b) => a.firstSeenAt - b.firstSeenAt); }); const setHttpRules = (rules: HttpRule[]) => { registry.rules = rules; @@ -412,16 +487,23 @@ const ngDevtools = defineDevframe({ handler: (report: unknown) => { const page = report as Partial | null; if (!page || typeof page.pageId !== 'string' || typeof page.url !== 'string') return; + const known = httpPages.get(page.pageId); + const url = page.url.slice(0, 2000); + const hasPayload = page.payload !== undefined; + if (!hasPayload && !known) return { needPayload: true }; httpPages.set(page.pageId, { pageId: page.pageId, - url: page.url.slice(0, 2000), + url, + initialUrl: typeof page.initialUrl === 'string' ? page.initialUrl.slice(0, 2000) : url, title: typeof page.title === 'string' ? page.title.slice(0, 200) : '', - payload: sanitizePayload(page.payload), + payload: hasPayload ? sanitizePayload(page.payload) : known!.payload, hydration: sanitizeHydration(page.hydration), calls: sanitizeCalls(page.calls), + firstSeenAt: known?.firstSeenAt ?? Date.now(), reportedAt: Date.now(), }); applyHttpPages(); + return { needPayload: false }; }, }); @@ -479,14 +561,28 @@ const ngDevtools = defineDevframe({ } }); } + if (expireComponentPages(componentPages)) applyComponentPages(); + const staleInjectors = [...injectorPages].filter( + ([, p]) => Date.now() - p.reportedAt > 15_000, + ); + if (staleInjectors.length) { + for (const [id] of staleInjectors) injectorPages.delete(id); + applyInjectorPages(); + } const next = expirePages(formPages); if (next) applyForms(next); const nextRouter = expireRouterPages(routerPages); if (nextRouter) applyRouter(nextRouter); const nextPipes = expirePipePages(pipePages); if (nextPipes) applyPipes(nextPipes); + if (expireNgrxPages(ngrxPages)) applyNgrx(); }, 5000); expiry.unref?.(); + registry.dispose = () => { + clearInterval(expiry); + clearTimeout(flushTimer); + registry.record = undefined; + }; my.rpc.register({ name: 'forget-forms-page', @@ -512,6 +608,24 @@ const ngDevtools = defineDevframe({ }, }); + my.rpc.register({ + name: 'request-page-highlight', + type: 'action', + jsonSerializable: true, + handler: (selector: string | { pageId?: unknown; id?: unknown } | null) => { + const target = + selector && typeof selector === 'object' && typeof selector.id === 'string' + ? { + id: selector.id.slice(0, 50), + ...(typeof selector.pageId === 'string' ? { pageId: selector.pageId } : {}), + } + : typeof selector === 'string' + ? selector + : ''; + void my.rpc.broadcast({ method: 'highlight-in-page', args: [target], optional: true }); + }, + }); + const pendingFormActions = new Map void>(); let formActionSeq = 0; @@ -616,10 +730,19 @@ const ngDevtools = defineDevframe({ name: 'push-component-tree', type: 'action', jsonSerializable: true, - handler: (nodes: unknown[]) => { - componentTree.mutate((draft) => { - draft.nodes = nodes as any; - }); + handler: (report: unknown) => { + if (!isComponentReport(report)) return; + componentPages.set(report.pageId, toComponentPage(report)); + applyComponentPages(); + }, + }); + + my.rpc.register({ + name: 'forget-component-page', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + if (typeof pageId === 'string' && componentPages.delete(pageId)) applyComponentPages(); }, }); @@ -627,13 +750,38 @@ const ngDevtools = defineDevframe({ name: 'select-component', type: 'action', jsonSerializable: true, - handler: (id: string | null) => { + handler: (target: string | { pageId?: unknown; id?: unknown } | null) => { + if (typeof target === 'string') { + void my.rpc.broadcast({ + method: 'select-signal-component', + args: [target.slice(0, 500)], + optional: true, + }); + return; + } + const id = typeof target?.id === 'string' ? target.id.slice(0, 50) : null; + const pageId = typeof target?.pageId === 'string' ? target.pageId : undefined; componentTree.mutate((draft) => { draft.selectedId = id; }); + void my.rpc.broadcast({ + method: 'inspect-component-in-page', + args: [{ pageId, id }], + optional: true, + }); + }, + }); + + my.rpc.register({ + name: 'select-signal-target', + type: 'action', + jsonSerializable: true, + handler: (target: { pageId?: unknown; id?: unknown } | null) => { + const id = typeof target?.id === 'string' ? target.id.slice(0, 50) : null; + const pageId = typeof target?.pageId === 'string' ? target.pageId : undefined; void my.rpc.broadcast({ method: 'select-signal-component', - args: [id], + args: [{ pageId, id }], optional: true, }); }, @@ -643,16 +791,29 @@ const ngDevtools = defineDevframe({ name: 'push-signal-graph', type: 'action', jsonSerializable: true, - handler: (graph: PageGraph) => { - const pageId = graph?.pageId; - if (typeof pageId === 'string' && pageId.length < 50) { - signalPages.set(pageId, { graph, reportedAt: Date.now() }); + handler: (incoming: PageGraph & { historyDelta?: Record }) => { + const pageId = incoming?.pageId; + const known = typeof pageId === 'string' && pageId.length < 50; + const prev = known ? signalPages.get(pageId)?.graph : undefined; + let graph: PageGraph = incoming; + let delta = true; + if (incoming?.historyDelta) { + const { historyDelta, ...rest } = incoming; + if (!prev) delta = false; + const history: Record = {}; + for (const node of rest.nodes ?? []) { + const list = [...(prev?.history?.[node.id] ?? []), ...(historyDelta[node.id] ?? [])]; + if (list.length) history[node.id] = list.slice(-50); + } + graph = { ...rest, history }; } + if (known) signalPages.set(pageId, { graph, reportedAt: Date.now() }); signalGraphState.mutate((draft) => { draft.graph = graph; // Every open page pushes, so one shared graph would flip between them. draft.pages = Object.fromEntries([...signalPages].map(([id, page]) => [id, page.graph])); }); + return { delta }; }, }); @@ -660,10 +821,27 @@ const ngDevtools = defineDevframe({ name: 'push-injector-tree', type: 'action', jsonSerializable: true, - handler: (roots: unknown[]) => { - injectorTreeState.mutate((draft) => { - draft.roots = roots as any; + handler: (report: { pageId?: unknown; roots?: unknown; environment?: unknown } | null) => { + const pageId = report?.pageId; + if (typeof pageId !== 'string' || !pageId || pageId.length >= 50) return; + injectorPages.set(pageId, { + pageId, + roots: (Array.isArray(report?.roots) ? report.roots : []) as InjectorTreeNode[], + environment: (Array.isArray(report?.environment) + ? report.environment + : []) as InjectorTreeNode[], + reportedAt: Date.now(), }); + applyInjectorPages(); + }, + }); + + my.rpc.register({ + name: 'forget-injector-page', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + if (typeof pageId === 'string' && injectorPages.delete(pageId)) applyInjectorPages(); }, }); @@ -671,21 +849,71 @@ const ngDevtools = defineDevframe({ name: 'push-ngrx-state', type: 'action', jsonSerializable: true, - handler: (data: { state: unknown; actions: NgrxRuntimeAction[]; connected: boolean }) => { - ngrxStoreState.mutate((draft) => { - draft.state = data.state; - draft.actions = data.actions; - draft.connected = data.connected; - }); + handler: (report: unknown) => { + if (!isNgrxReport(report)) return { seq: 0 }; + const seq = mergeNgrxReport(ngrxPages, report, ngrxNames()); + applyNgrx(); + return { seq }; + }, + }); + + my.rpc.register({ + name: 'forget-ngrx-page', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + if (typeof pageId === 'string' && ngrxPages.delete(pageId)) applyNgrx(); }, }); + const pendingNgrx = new Map void>(); + let ngrxSeq = 0; + + my.rpc.register({ + name: 'ngrx-action-result', + type: 'action', + jsonSerializable: true, + handler: (message: { requestId?: unknown; result?: unknown }) => { + if (typeof message?.requestId !== 'string') return; + pendingNgrx.get(message.requestId)?.(message.result); + }, + }); + + my.rpc.register({ + name: 'request-ngrx-action', + type: 'action', + jsonSerializable: true, + handler: (message: { pageId?: unknown; request?: unknown }) => + new Promise((resolve) => { + const pageId = + typeof message?.pageId === 'string' + ? message.pageId + : ngrxStateOf(ngrxPages).pages[0]?.pageId; + const requestId = `n${++ngrxSeq}`; + const timer = setTimeout(() => { + pendingNgrx.delete(requestId); + resolve({ error: 'No page answered within 10s. Is the app open in a browser?' }); + }, 10_000); + timer.unref?.(); + pendingNgrx.set(requestId, (result) => { + clearTimeout(timer); + pendingNgrx.delete(requestId); + resolve(result); + }); + void my.rpc.broadcast({ + method: 'ngrx-action', + args: [{ requestId, pageId, request: message?.request }], + optional: true, + }); + }), + }); + // Agent resources ctx.agent.registerResource({ id: 'ng-devtools:component-tree', name: 'Angular Component Tree', description: - 'Component hierarchy last reported by a connected page, as JSON. Empty when no page is connected.', + 'Live component instances per connected page, as JSON: `pages[pageId].roots` is a tree with one node per rendered instance (`id` instance id, `name` class name, `tag` host tag, `directives` on the host), `count`, and `detail` (live input values, outputs, listeners, change detection, encapsulation and injected dependencies) for the instance selected in the panel. `nodes` repeats the roots of the most recent page. Empty when no page is connected.', mimeType: 'application/json', read: () => ({ text: JSON.stringify(componentTree.value(), null, 2) }), }); @@ -694,7 +922,7 @@ const ngDevtools = defineDevframe({ id: 'ng-devtools:signal-graph', name: 'Angular Signal Graph', description: - 'Live signal dependency graph: nodes (signal, computed, effect, linkedSignal), edges (producer→consumer) and recent value history per node. Read this to understand reactive data flow.', + 'Live signal dependency graph per connected page (`pages[pageId]`, `graph` is the latest): nodes (signal, computed, effect, linkedSignal), edges (producer→consumer), `component` (instance id, class name, host tag and host path) and recent value history per node. Only signals a template or an effect has read appear. Read this to understand reactive data flow.', mimeType: 'application/json', read: () => ({ text: JSON.stringify(signalGraphState.value(), null, 2) }), }); @@ -712,7 +940,7 @@ const ngDevtools = defineDevframe({ id: 'ng-devtools:ngrx-store', name: 'NgRx Store State', description: - 'NgRx store state and recent actions last reported by a connected page. Empty when no page is connected.', + 'Live NgRx state per connected page: each @ngrx/signals store (state, computed values, methods, the component fields that reference it) and the @ngrx/store state, plus a change log with a per-entry state diff (method calls, patchState writes and dispatched actions). Empty when no page is connected.', mimeType: 'application/json', read: () => ({ text: JSON.stringify(ngrxStoreState.value(), null, 2) }), }); @@ -738,32 +966,39 @@ const ngDevtools = defineDevframe({ // Agent tools ctx.agent.registerTool({ id: 'ng-devtools:highlight', - description: 'Highlight a component in the running Angular app by its selector.', + description: + 'Highlight a component in the running Angular app and make it the target of ng-devtools:inspect-signals. Pass an instance id from the ng-devtools:component-tree resource (targets that exact instance, e.g. the second card of a list), a class name, a host tag, or any CSS selector.', safety: 'action', inputSchema: { type: 'object', properties: { selector: { type: 'string', - description: 'CSS selector of the component to highlight, e.g. app-root.', + description: + 'Instance id (e.g. c12), class name (e.g. ProductCard), host tag (e.g. app-root) or CSS selector.', }, }, required: ['selector'], }, handler: async (args: { selector: string }) => { - if (!componentTree.value().nodes.length) { + const pages = [...componentPages.values()]; + if (!pages.some((page) => page.roots.length)) { return { markdown: `No component tree has been reported, so nothing was highlighted. This is what a page that has never connected reports, and also what a connected page reports when its components are not readable. Live data needs a page: connect through the MCP endpoint of the server that runs the app, with the app open in a browser. The stdio server has no page attached and only ever reports this.`, }; } - await ctx.rpc.invokeLocal('ng-devtools:select-component' as any, args.selector); + const [hit] = findComponents(pages, args.selector); + const target = hit ? { pageId: hit.pageId, id: hit.node.id } : args.selector; + void my.rpc.broadcast({ method: 'highlight-in-page', args: [target], optional: true }); void my.rpc.broadcast({ - method: 'highlight-in-page', - args: [args.selector], + method: 'select-signal-component', + args: [target], optional: true, }); return { - markdown: `Sent a highlight request for \`${args.selector}\`. It only shows if the selector matches an element on the page.`, + markdown: hit + ? `Sent a highlight request for \`${hit.node.name}\` (\`<${hit.node.tag}>\`, instance \`${hit.node.id}\` on page \`${hit.pageId}\`).` + : `Sent a highlight request for \`${args.selector}\`. It only shows if the selector matches an element on the page.`, }; }, }); @@ -771,14 +1006,14 @@ const ngDevtools = defineDevframe({ ctx.agent.registerTool({ id: 'ng-devtools:inspect-signals', description: - 'Get the signal graph the running page last reported: signal nodes (signal, computed, linkedSignal, effect), their dependency edges, and `history` (recent value changes per node id; `write` entries are exact, `sample` entries come from polling and `missed` counts values that went unseen). The page reports one graph: the component selected in the Components tab (or via ng-devtools:highlight), otherwise the component rendered by the deepest router outlet, otherwise the root. A selector that does not match it returns what is available instead; call ng-devtools:highlight with the selector first to switch the graph to it.', + 'Get the signal graph the running page last reported: signal nodes (signal, computed, linkedSignal, effect), their dependency edges, `component` (instance id, class name, host tag and host path), and `history` (recent value changes per node id; `write` entries are exact, `sample` entries come from polling and `missed` counts values that went unseen). Only signals a template or an effect has read appear. The page reports one graph: the component picked on the Signals page (or via ng-devtools:highlight), otherwise the component the primary router outlet renders deepest, otherwise the first component with a graph. A selector that does not match it returns what is available instead; call ng-devtools:highlight with it first to switch the graph to it.', safety: 'read', inputSchema: { type: 'object', properties: { selector: { type: 'string', - description: 'CSS selector of the component to inspect, e.g. app-root.', + description: 'Host tag, class name or instance id of the component, e.g. app-root.', }, }, required: ['selector'], @@ -786,14 +1021,19 @@ const ngDevtools = defineDevframe({ handler: async (args: { selector: string }) => { // `broadcast` resolves with nothing, so the page cannot answer a // question. Read the graph the overlay pushes into shared state. - const graph = signalGraphState.value().graph; + const matches = (g: PageGraph) => + [g.componentSelector, g.component?.name, g.component?.id].includes(args.selector); + const matched = [...signalPages.values()] + .filter((page) => matches(page.graph)) + .sort((a, b) => b.reportedAt - a.reportedAt)[0]?.graph; + const graph = matched ?? signalGraphState.value().graph; if (!graph) { return { markdown: `No signal graph available. Live data needs a page: connect through the MCP endpoint of the server that runs the app, with the app open in a browser. The stdio server has no page attached and only ever reports this.`, }; } const json = JSON.stringify(graph, null, 2); - if (graph.componentSelector && graph.componentSelector !== args.selector) { + if (graph.componentSelector && !matches(graph)) { return { markdown: `No signal graph for \`${args.selector}\`. The live graph covers \`${graph.componentSelector}\`:\n\n${json}`, }; @@ -805,7 +1045,7 @@ const ngDevtools = defineDevframe({ ctx.agent.registerTool({ id: 'ng-devtools:inspect-providers', description: - 'Get the DI injector hierarchy the running page last reported, with the providers at each level. The page reports the whole tree rather than one component, so the selector only labels the answer.', + 'Get the DI injector hierarchy a running page reported, with the providers at each level. Each open tab reports its own tree; `pageId` picks one and defaults to the most recent. The page reports the whole tree rather than one component, so the selector only labels the answer.', safety: 'read', inputSchema: { type: 'object', @@ -815,18 +1055,24 @@ const ngDevtools = defineDevframe({ description: 'Optional CSS selector, e.g. app-root. It only labels the answer: the page reports the whole tree either way.', }, + pageId: { + type: 'string', + description: 'Page id, when more than one tab reports. Defaults to the most recent.', + }, }, }, - handler: async (args: { selector?: string }) => { - const roots = injectorTreeState.value().roots; - if (!roots.length) { + handler: async (args: { selector?: string; pageId?: string }) => { + const page = + (args.pageId ? injectorPages.get(args.pageId) : undefined) ?? latestInjectorPage(); + if (!page?.roots.length) { return { markdown: `No injector data available. Live data needs a page: connect through the MCP endpoint of the server that runs the app, with the app open in a browser. The stdio server has no page attached and only ever reports this.`, }; } + const { pageId, roots, environment } = page; const scope = args.selector ? `, not filtered to \`${args.selector}\`` : ''; return { - markdown: `This is the injector tree for the whole page${scope}:\n\n${JSON.stringify(roots, null, 2)}`, + markdown: `This is the injector tree for the whole page \`${pageId}\`${scope}. Element injectors list what each component and directive injected and which injector supplied it (\`providedBy\` is an injector id). Environment injectors run from the platform down to the root and any route injectors.\n\nElement injectors:\n\n${JSON.stringify(roots, null, 2)}\n\nEnvironment injectors:\n\n${JSON.stringify(environment, null, 2)}`, }; }, }); @@ -927,7 +1173,7 @@ const ngDevtools = defineDevframe({ }) => { const state = routerState.value() as RouterState; if (!state.pages.length) return { markdown: noRouter }; - let sources: { path: string; component?: string; redirectTo?: string; file: string }[] = []; + let sources: ReturnType = []; try { sources = extractRoutes(ctx.cwd); } catch { @@ -1080,7 +1326,8 @@ const ngDevtools = defineDevframe({ const noForms = `No forms have been reported. Live data needs a page: connect through the MCP endpoint of the server that runs the app, with the app open in a browser, on a page that renders a form. The stdio server has no page attached and only ever reports this.`; const formProperty = { type: 'string', - description: 'Form id (form-1) or part of its label (Component.property).', + description: + 'Form id (Checkout.form@ab12, or Checkout.form without the page) or part of its label (Component.property).', }; ctx.agent.registerTool({ @@ -1432,7 +1679,7 @@ const ngDevtools = defineDevframe({ 'instrument', ], }, - form: { type: 'string', description: 'Full form id, e.g. form-1@ab12.' }, + form: { type: 'string', description: 'Full form id, e.g. Checkout.form@ab12.' }, path: pathProperty, value: { description: 'New value for set-value.' }, mode: { type: 'string', enum: ['code', 'user'] }, @@ -1461,7 +1708,7 @@ const ngDevtools = defineDevframe({ type: 'object', required: ['form', 'values'], properties: { - form: { type: 'string', description: 'Full form id, e.g. form-1@ab12.' }, + form: { type: 'string', description: 'Full form id, e.g. Checkout.form@ab12.' }, values: { type: 'object', description: 'Map of field path to value.' }, mode: { type: 'string', enum: ['code', 'user'] }, submit: { type: 'boolean' }, @@ -1486,6 +1733,7 @@ const ngDevtools = defineDevframe({ }); await registerAnalog(my as never, ctx as never); + registerHubDocks(ctx, 'ng-devtools'); }, }); diff --git a/packages/ng-devtools/src/element-id.ts b/packages/ng-devtools/src/element-id.ts new file mode 100644 index 0000000..3ed993c --- /dev/null +++ b/packages/ng-devtools/src/element-id.ts @@ -0,0 +1,30 @@ +const ids = new WeakMap(); +const byId = new Map>(); +const load = Math.random().toString(36).slice(2, 6).padEnd(4, '0'); +let nextId = 0; + +export function elementId(el: Element): string { + let id = ids.get(el); + if (!id) { + id = `c${load}-${++nextId}`; + ids.set(el, id); + } + if (!byId.has(id)) byId.set(id, new WeakRef(el)); + return id; +} + +export function elementById(id: string): Element | null { + const el = byId.get(id)?.deref(); + if (!el) { + byId.delete(id); + return null; + } + return el.isConnected ? el : null; +} + +export function pruneElementIds() { + for (const [id, ref] of byId) { + const el = ref.deref(); + if (!el || !el.isConnected) byId.delete(id); + } +} diff --git a/packages/ng-devtools/src/forms-actions.ts b/packages/ng-devtools/src/forms-actions.ts index 4223d5b..66559a7 100644 --- a/packages/ng-devtools/src/forms-actions.ts +++ b/packages/ng-devtools/src/forms-actions.ts @@ -49,6 +49,7 @@ export interface FormActionRequest { mode?: 'code' | 'user'; confirm?: boolean; force?: boolean; + coerce?: boolean; submit?: boolean; snapshot?: string; selector?: string; @@ -185,7 +186,7 @@ export function isFormAction(value: unknown): value is FormActionRequest { typeof request.action === 'string' && (FORM_ACTIONS as readonly string[]).includes(request.action) && (request.formId === undefined || - (typeof request.formId === 'string' && request.formId.length < 80)) && + (typeof request.formId === 'string' && request.formId.length < 200)) && (request.path === undefined || (typeof request.path === 'string' && request.path.length < 500)) && (request.selector === undefined || @@ -197,6 +198,75 @@ export function isFormAction(value: unknown): value is FormActionRequest { ); } +function parseJson(text: string): { value: unknown } | null { + try { + return { value: JSON.parse(text) }; + } catch { + return null; + } +} + +export function coerceToCurrent( + text: string, + current: unknown, + element?: Element | null, +): { value: unknown } | { error: string } { + const trimmed = text.trim(); + if (current === null || current === undefined) { + if (trimmed === 'null') return { value: null }; + const type = + typeof HTMLInputElement !== 'undefined' && element instanceof HTMLInputElement + ? element.type + : ''; + if (type === 'number' || type === 'range') { + return trimmed === '' ? { value: null } : coerceToCurrent(text, 0); + } + if (type === 'checkbox') return coerceToCurrent(text, false); + const json = /^("[\s\S]*"|\{[\s\S]*\}|\[[\s\S]*\])$/.test(trimmed) ? parseJson(trimmed) : null; + return json ?? { value: text }; + } + if (typeof current === 'string') { + const quoted = /^".*"$/s.test(trimmed) ? parseJson(trimmed) : null; + return { value: typeof quoted?.value === 'string' ? quoted.value : text }; + } + if (typeof current === 'number') { + if (trimmed === 'null') return { value: null }; + const number = Number(trimmed); + return trimmed !== '' && !Number.isNaN(number) + ? { value: number } + : { error: `holds a number; "${clipText(text)}" is not a number` }; + } + if (typeof current === 'boolean') { + if (/^(true|false)$/i.test(trimmed)) return { value: trimmed.toLowerCase() === 'true' }; + return { error: 'holds a boolean; type true or false' }; + } + if (typeof current === 'bigint') { + try { + return { value: BigInt(trimmed) }; + } catch { + return { error: `holds a bigint; "${clipText(text)}" is not an integer` }; + } + } + if (current instanceof Date) { + const date = new Date(trimmed); + return Number.isNaN(date.getTime()) + ? { error: `holds a Date; "${clipText(text)}" is not a date` } + : { value: date }; + } + const parsed = parseJson(trimmed); + if (current && typeof current === 'object') { + if (!parsed || !parsed.value || typeof parsed.value !== 'object') { + return { error: `holds ${Array.isArray(current) ? 'an array' : 'an object'}; pass JSON` }; + } + return parsed; + } + return parsed ?? { value: text }; +} + +function clipText(text: string): string { + return text.length > 40 ? `${text.slice(0, 40)}…` : text; +} + function fail(error: string): FormActionResult { return { ok: false, message: error, error }; } @@ -548,14 +618,24 @@ async function perform( }; switch (request.action) { case 'set-value': { - const problem = writeValue( - ctx, - found, - path, - request.value, - request.mode ?? 'code', - !!request.force, - ); + let value = request.value; + if (request.coerce && typeof value === 'string') { + const current = node(); + const raw = current ? rawValue(found, current) : undefined; + const coerced = coerceToCurrent( + value, + raw, + raw === null || raw === undefined ? elementFor(ctx, found, path) : null, + ); + if ('error' in coerced) { + return { + ...fail(`${path || '(form)'} ${coerced.error}.`), + skipped: [{ path, reason: coerced.error }], + }; + } + value = coerced.value; + } + const problem = writeValue(ctx, found, path, value, request.mode ?? 'code', !!request.force); return problem ? { ...fail(`${path || '(form)'} ${problem}.`), skipped: [{ path, reason: problem }] } : { ok: true, message: `Set ${path || '(form)'}.` }; diff --git a/packages/ng-devtools/src/forms-collector.ts b/packages/ng-devtools/src/forms-collector.ts index ae564c7..b4f9c56 100644 --- a/packages/ng-devtools/src/forms-collector.ts +++ b/packages/ng-devtools/src/forms-collector.ts @@ -5,7 +5,8 @@ import { diffForms, findFieldElement, findForms, - formIdFor, + createFormIds, + formLabels, propertyHolding, serializeControl, watchControlEvents, @@ -34,6 +35,7 @@ import { type RenderCounter, } from './forms-instrument.ts'; import { redactMessage } from './forms-privacy.ts'; +import { fieldPath, probeEveryTime } from './forms-read.ts'; type AnyRecord = Record; @@ -78,7 +80,7 @@ function isFieldTarget(target: unknown): target is { formId: string; path: strin return ( typeof t?.formId === 'string' && typeof t.path === 'string' && - t.formId.length < 50 && + t.formId.length < 200 && t.path.length < 500 ); } @@ -114,7 +116,18 @@ export function attachForms( getNg: () => any, highlight: { show(el: HTMLElement): void; clear(): void }, ): FormsCollector { - const idOf = (root: object) => `${formIdFor(root)}@${pageId}`; + const assignIds = createFormIds(pageId); + let ids = new WeakMap(); + const idOf = (root: object) => ids.get(root) ?? `form@${pageId}`; + let hosts: Element[] | null = null; + let hostsStale = true; + const observer = + typeof MutationObserver === 'function' + ? new MutationObserver(() => { + hostsStale = true; + }) + : null; + observer?.observe(document.documentElement, { childList: true, subtree: true }); let lastForms: CollectedForm[] = []; let lastPayload = ''; let lastPushAt = 0; @@ -205,13 +218,20 @@ export function attachForms( continue; } live.add(form.root); - if (watched.has(form.root)) continue; const formId = idOf(form.root); + const existing = watched.get(form.root); + if (existing?.formId === formId) continue; + if (existing) { + existing.stop(); + watched.delete(form.root); + } const stop = watchControlEvents( form.root, formId, (event) => { - if (event.type !== 'touched' && event.type !== 'dirty') recordFormEvent(event); + if (event.type !== 'touched' && event.type !== 'dirty') { + recordFormEvent({ ...event, formId: idOf(form.root) }); + } schedulePush(); }, { @@ -237,10 +257,20 @@ export function attachForms( } function wrapSignalSubmit(form: FoundForm) { - const flag = read(() => form.root['submitState']['selfSubmitting'] as AnyRecord, null); + const visit = (node: AnyRecord, depth: number) => { + wrapSubmitFlag(form.root, node); + if (depth >= 8) return; + const children = read(() => node['structure'].materializedChildren() as AnyRecord[], []); + for (const child of children.slice(0, 200)) visit(child, depth + 1); + }; + visit(form.root, 0); + } + + function wrapSubmitFlag(root: AnyRecord, node: AnyRecord) { + const flag = read(() => node['submitState']['selfSubmitting'] as AnyRecord, null); if (!flag || wrappedSubmits.has(flag) || typeof flag['set'] !== 'function') return; wrappedSubmits.add(flag); - const formId = idOf(form.root); + const path = node === root ? '' : read(() => fieldPath(node), ''); const original = flag['set']; unwrapSubmits.push(() => { flag['set'] = original; @@ -249,12 +279,13 @@ export function attachForms( let started = false; flag['set'] = function (this: unknown, value: boolean) { const result = original.call(this, value); + const formId = idOf(root); if (value) { started = true; submittingForm = formId; recordFormEvent({ formId, - path: '', + path, type: 'submit', outcome: 'ran', timestamp: Date.now(), @@ -263,7 +294,7 @@ export function attachForms( if (!started) { recordFormEvent({ formId, - path: '', + path, type: 'submit', outcome: 'blocked', detail: 'invalid, so the action did not run', @@ -291,11 +322,21 @@ export function attachForms( try { const ng = getNg(); if (!ng?.getDirectives) return; - const found = findForms(ng, document.querySelectorAll('*')); + const rescan = hostsStale || !hosts || !observer; + const found = findForms( + ng, + rescan ? document.querySelectorAll('*') : hosts!.filter((el) => el.isConnected), + ); + if (rescan) { + hosts = found.hosts ?? null; + hostsStale = false; + } + const labels = formLabels(found.forms); + ids = assignIds(found.forms, labels); foundById = new Map(found.forms.map((form) => [idOf(form.root), form])); fieldElements = found.elements; watchRoots(found.forms); - const forms = collectForms(found, idOf); + const forms = collectForms(found, (root) => idOf(root)); const now = Date.now(); for (const form of forms) stampPending(form.id, form.root, now); const streamed = new Set(Array.from(watched.values(), ({ formId }) => formId)); @@ -403,6 +444,7 @@ export function attachForms( function setInstrumented(on: boolean): FormActionResult { if (on && !instrumentation) { + probeEveryTime(true); instrumentation = instrumentForms(onInstrumentedCall, ownerNames); renders = countRenders(getNg()); void pushForms(); @@ -414,6 +456,7 @@ export function attachForms( }; } if (!on && instrumentation) { + probeEveryTime(false); instrumentation.stop(); renders?.stop(); instrumentation = null; @@ -609,6 +652,8 @@ export function attachForms( return { push: () => void pushForms(), stop() { + observer?.disconnect(); + probeEveryTime(false); clearTimeout(pushTimer); clearTimeout(userTimer); for (const type of USER_EVENTS) document.removeEventListener(type, onUserEvent, true); diff --git a/packages/ng-devtools/src/forms-dom.ts b/packages/ng-devtools/src/forms-dom.ts index dd573fd..e85ad4f 100644 --- a/packages/ng-devtools/src/forms-dom.ts +++ b/packages/ng-devtools/src/forms-dom.ts @@ -126,13 +126,20 @@ function isSubmitter(el: Element): boolean { return false; } -export function submitDom(el: Element): SubmitDom { +export interface SubmitContext { + valid?: boolean; + submitListener?: boolean; +} + +export function submitDom(el: Element, context: SubmitContext = {}): SubmitDom { const tag = el.tagName.toLowerCase(); const reasons: string[] = []; if (!(el instanceof HTMLFormElement)) { - reasons.push( - `The form directive sits on a <${tag}>, not a , so the browser never fires submit or ngSubmit.`, - ); + if (context.submitListener) { + reasons.push( + `The form directive sits on a <${tag}>, not a , so the browser never fires submit or ngSubmit.`, + ); + } return { tag, buttons: 0, disabledButtons: 0, novalidate: false, nativeInvalid: 0, reasons }; } const inside = Array.from(el.querySelectorAll('button, input')); @@ -152,8 +159,10 @@ export function submitDom(el: Element): SubmitDom { ? 'No submit button: the buttons in this form are type="button", so clicking them does not submit.' : 'No submit button in the form (or linked with form="id"). Enter submits only with a single text input or a submit button.', ); - } else if (disabledButtons === submitters.length) { - reasons.push('Every submit button is disabled, so the form cannot be submitted by clicking.'); + } else if (disabledButtons === submitters.length && context.valid) { + reasons.push( + 'The form is valid but every submit button is disabled, so it cannot be submitted by clicking.', + ); } const nativeInvalid = el.noValidate ? 0 diff --git a/packages/ng-devtools/src/forms-read.ts b/packages/ng-devtools/src/forms-read.ts index f8c7556..c02e17b 100644 --- a/packages/ng-devtools/src/forms-read.ts +++ b/packages/ng-devtools/src/forms-read.ts @@ -47,6 +47,26 @@ export interface ControlFacts { } let probing = 0; +let probeEveryCollect = false; + +interface Probe { + validator: unknown; + raw: unknown; + dirFn: unknown; + value: unknown; + rootValue: unknown; + at: number; + fresh: string[]; + own: string[]; + dir: string[]; +} + +const probes = new WeakMap(); +const PROBE_TTL_MS = 5000; + +export function probeEveryTime(on: boolean) { + probeEveryCollect = on; +} export function isProbing(): boolean { return probing > 0; @@ -196,6 +216,37 @@ export function metadataKeyCount(node: AnyRecord): number { return read(() => Array.from(node['logicNode']['logic'].getMetadataKeys()).length, 0); } +function isInternalMetadata(value: unknown): boolean { + if (typeof value === 'function') return true; + if (!value || typeof value !== 'object') return false; + const record = value as AnyRecord; + return ( + 'reducer' in record || + typeof record['reload'] === 'function' || + (typeof record['value'] === 'function' && typeof record['status'] === 'function') + ); +} + +export function metadataValues(node: AnyRecord): unknown[] { + const keys = read(() => Array.from(node['logicNode']['logic'].getMetadataKeys()) as object[], []); + if (!keys.length) return []; + const builtIn = new Set( + ['required', 'minLength', 'maxLength', 'pattern', 'min', 'max'].map((name) => + read(() => node[name], undefined), + ), + ); + const out: unknown[] = []; + for (const key of keys) { + const entry = read(() => node['metadata'](key) as unknown, undefined); + if (entry === undefined || builtIn.has(entry)) continue; + const value = + typeof entry === 'function' ? read(() => (entry as () => unknown)(), undefined) : entry; + if (value === undefined || isInternalMetadata(value)) continue; + out.push(value); + } + return out; +} + export function submitSetup(root: AnyRecord): SubmitSetup { const options = read( () => root['structure']['fieldManager']['submitOptions'] as AnyRecord | undefined, @@ -260,6 +311,49 @@ function keysOf(errors: unknown): string[] { return errors && typeof errors === 'object' ? Object.keys(errors) : []; } +function probeValidators( + control: AnyRecord, + validator: unknown, + dirFn: unknown, + parseFn: unknown, +): Probe { + const raw = read(() => control['_rawValidators'], null); + const value = read(() => control['value'], undefined); + const rootValue = read(() => (control['root'] as AnyRecord | undefined)?.['value'], undefined); + const cached = probes.get(control); + const now = Date.now(); + if ( + !probeEveryCollect && + cached && + now - cached.at < PROBE_TTL_MS && + cached.validator === validator && + cached.raw === raw && + cached.dirFn === dirFn && + Object.is(cached.value, value) && + Object.is(cached.rootValue, rootValue) + ) { + return cached; + } + const run = (fn: unknown) => + typeof fn === 'function' + ? keysOf(withoutEvents(() => read(() => (fn as (c: unknown) => unknown)(control), null))) + : []; + const ownFns = asList(raw).filter((fn) => fn !== dirFn && fn !== parseFn); + const probe: Probe = { + validator, + raw, + dirFn, + value, + rootValue, + at: now, + fresh: run(validator), + own: ownFns.flatMap(run), + dir: run(dirFn), + }; + probes.set(control, probe); + return probe; +} + export function controlFacts( control: AnyRecord, dir?: AnyRecord | null, @@ -275,17 +369,11 @@ export function controlFacts( const hasAsync = !!read(() => control['asyncValidator'], null); const status = read(() => String(control['status']), ''); const canProbe = isLeaf && typeof validator === 'function' && status !== 'DISABLED'; - const run = (fn: unknown) => - typeof fn === 'function' - ? keysOf(withoutEvents(() => read(() => (fn as (c: unknown) => unknown)(control), null))) - : []; - const fresh = canProbe ? new Set(run(validator)) : null; const dirFn = dir ? read(() => dir['validator'], null) : null; - const ownFns = asList(read(() => control['_rawValidators'], null)).filter( - (fn) => fn !== dirFn && fn !== parseFn, - ); - const ownKeys = new Set(canProbe ? ownFns.flatMap(run) : []); - const dirKeys = new Set(canProbe ? run(dirFn) : []); + const probe = canProbe ? probeValidators(control, validator, dirFn, parseFn) : null; + const fresh = probe ? new Set(probe.fresh) : null; + const ownKeys = new Set(probe?.own ?? []); + const dirKeys = new Set(probe?.dir ?? []); for (const key of errors) { if (parseKeys.has(key)) facts.origins[key] = { source: 'parse' }; else if (ownKeys.has(key)) facts.origins[key] = { source: 'own' }; diff --git a/packages/ng-devtools/src/forms.ts b/packages/ng-devtools/src/forms.ts index d0b87b2..49d7e76 100644 --- a/packages/ng-devtools/src/forms.ts +++ b/packages/ng-devtools/src/forms.ts @@ -11,7 +11,9 @@ import { controlFacts, directiveBinding, fieldBinding, + fieldPath, isProbing, + metadataValues, signalErrorOrigins, signalFacts, submitSetup, @@ -77,6 +79,14 @@ export interface FormFieldNode { dom?: DomFacts; redacted?: RedactReason; pendingSince?: number; + name?: string; + metadata?: unknown[]; +} + +export interface FormErrorSummary { + path: string; + kind: string; + message: string; } export interface CollectedForm { @@ -88,6 +98,7 @@ export interface CollectedForm { submitted?: boolean; submit?: SubmitSetup; submitDom?: SubmitDom; + errorSummary?: FormErrorSummary[]; root: FormFieldNode; } @@ -124,6 +135,7 @@ export interface FormsDebugApi { getComponent(el: Element): unknown; getDirectives(el: Element): unknown[]; getOwningComponent(el: Element): unknown; + getListeners?(el: Element): { name: string; type?: string; callback?: unknown }[]; } export interface FoundForm { @@ -134,11 +146,13 @@ export interface FoundForm { directive?: AnyRecord; element?: Element; formElement?: Element; + submitListener?: boolean; } export interface FoundForms { forms: FoundForm[]; elements: WeakMap; + hosts?: Element[]; } const MAX_DEPTH = 8; @@ -157,6 +171,31 @@ const baselines = new WeakMap(); let nextUid = 0; let secrets: SecretSet | null = null; let formSecrets = new Map(); +let collectRound = 0; +let collecting = false; +const viewDrift = new WeakMap(); +const modelDrift = new WeakMap(); + +function steady( + seen: WeakMap, + target: object, + key: string | undefined, +): boolean { + if (key === undefined) { + if (collecting) seen.delete(target); + return false; + } + if (!collecting) return true; + const previous = seen.get(target); + seen.set(target, { round: collectRound, key }); + return !!previous && previous.key === key && previous.round === collectRound - 1; +} + +function keepViewDrift(target: object, dom: DomFacts, binding: BindingInfo | undefined) { + const native = binding?.kind === 'native' || binding?.kind === 'signal-field'; + const key = native && dom.drift !== undefined ? JSON.stringify(dom.drift) : undefined; + if (!steady(viewDrift, target, key)) delete dom.drift; +} function uidOf(target: object): string { let uid = uids.get(target); @@ -469,7 +508,12 @@ export function serializeControl( if (facts.pending) { node.uncommitted = secret ? REDACTED : serializeFormValue(facts.pending.value); } - if (facts.model) { + const unbound = !!element?.hasAttribute('ngmodel'); + const driftKey = + facts.model && !unbound + ? (JSON.stringify(serializeFormValue(facts.model)) ?? 'undefined') + : undefined; + if (facts.model && steady(modelDrift, control, driftKey)) { node.modelDrift = secret ? { model: REDACTED, viewModel: REDACTED } : { @@ -488,6 +532,7 @@ export function serializeControl( secret, hasErrors: node.errors.length > 0, }); + keepViewDrift(control, node.dom, node.binding); } return node; } @@ -529,12 +574,47 @@ function fieldErrors( ): FormFieldError[] { const errors = read(() => state['errors']() as AnyRecord[], []); const origins = errors.length ? read(() => signalErrorOrigins(state), new Map()) : new Map(); - return errors.map((error) => { - const message = typeof error['message'] === 'string' ? error['message'] : undefined; - return withOrigin( - toError(String(error['kind']), withoutValues(errorParams(error), secret), type, message), - origins.get(error), - ); + return errors.map((error) => withOrigin(signalError(error, type, secret), origins.get(error))); +} + +function issuePath(issue: AnyRecord): string | undefined { + const parts = read(() => (Array.isArray(issue['path']) ? (issue['path'] as unknown[]) : []), []); + const keys = parts.map((part) => + part && typeof part === 'object' ? String((part as AnyRecord)['key']) : String(part), + ); + return keys.length ? keys.join('.') : undefined; +} + +function signalError( + error: AnyRecord, + type: FormFieldNode['type'], + secret: boolean, +): FormFieldError { + const kind = String(read(() => error['kind'], 'error')); + const issue = + kind === 'standardSchema' ? read(() => error['issue'] as AnyRecord | null, null) : null; + const own = read(() => error['message'], undefined); + const fromIssue = read(() => issue?.['message'], undefined); + const message = + typeof own === 'string' && own + ? own + : typeof fromIssue === 'string' && fromIssue + ? fromIssue + : undefined; + let params = withoutValues(errorParams(error), secret); + const path = issue ? issuePath(issue) : undefined; + if (path) params = { ...params, path }; + return toError(kind, params, type, message); +} + +export function errorSummaryOf(root: AnyRecord): FormErrorSummary[] { + const errors = read(() => root['errorSummary']() as AnyRecord[], []); + return errors.slice(0, 50).map((error) => { + const target = read(() => error['fieldTree']() as AnyRecord, null); + const path = target ? fieldPath(target) : ''; + const secret = path.split('.').some((key) => isSecretKey(key)); + const { kind, message } = signalError(error, 'control', secret); + return { path, kind, message }; }); } @@ -621,6 +701,14 @@ export function serializeField( }; if (reason && !parentSecret) node.redacted = reason; if (element) node.binding = read(() => fieldBinding(state), { kind: 'none' as const }); + const name = read(() => state['name']() as unknown, undefined); + if (typeof name === 'string' && name) node.name = name; + const metadata = read(() => metadataValues(state), []); + if (metadata.length) { + node.metadata = secret + ? metadata.map(() => REDACTED) + : metadata.map((v) => serializeFormValue(v)); + } const reasons = read(() => state['disabledReasons']() as AnyRecord[], []); if (reasons.length) { node.disabledReasons = reasons.map((r) => @@ -650,6 +738,7 @@ export function serializeField( secret, hasErrors: node.errors.length > 0, }); + keepViewDrift(state, node.dom, node.binding); } return node; } @@ -676,16 +765,29 @@ export function serializeField( return node; } -const formIds = new WeakMap(); -let nextFormId = 0; +export function formIdFor(label: string): string { + const id = label + .replace(/ #(\d+)$/, '~$1') + .replace(/[@\s]+/g, '_') + .slice(0, 120); + return id || 'form'; +} -export function formIdFor(root: object): string { - let id = formIds.get(root); - if (!id) { - id = `form-${++nextFormId}`; - formIds.set(root, id); - } - return id; +export function createFormIds(pageId: string) { + const assigned = new WeakMap(); + const used = new Set(); + return (forms: FoundForm[], labels: Map): WeakMap => { + for (const form of forms) { + if (assigned.has(form.root)) continue; + const base = formIdFor(labels.get(form.root) ?? 'form'); + const stem = base.replace(/~\d+$/, ''); + let id = `${base}@${pageId}`; + for (let n = 2; used.has(id); n++) id = `${stem}~${n}@${pageId}`; + used.add(id); + assigned.set(form.root, id); + } + return assigned; + }; } function ownerName(owner: unknown): string { @@ -729,6 +831,23 @@ export function findForms(ng: FormsDebugApi, elements: Iterable): Found const byRoot = new Map(); const controlElements = new WeakMap(); const components = new Set(); + const hosts: Element[] = []; + const listensForSubmit = (el: Element) => + !(typeof HTMLFormElement !== 'undefined' && el instanceof HTMLFormElement) && + read( + () => + (ng.getListeners?.(el) ?? []).some((listener, _index, all) => + listener.name === 'ngSubmit' + ? listener.type !== 'dom' + : listener.name === 'submit' && + listener.type !== 'output' && + (typeof (listener.callback as AnyRecord | undefined)?.['__ngNextListenerFn__'] === + 'function' || + all.filter((other) => other.name === 'submit' && other.type !== 'output').length > + 1), + ), + false, + ); const add = (found: FoundForm) => { const existing = byRoot.get(found.root); @@ -740,12 +859,14 @@ export function findForms(ng: FormsDebugApi, elements: Iterable): Found existing.directive ??= found.directive; existing.element ??= found.element; existing.formElement ??= found.formElement; + existing.submitListener ||= found.submitListener; }; for (const el of elements) { const component = read(() => ng.getComponent(el), null); if (component) components.add(component); const directives = read(() => ng.getDirectives(el) ?? [], [] as unknown[]); + if (component || directives.length) hosts.push(el); const selectorSets = directives.map(selectorsOf); let owner: unknown; const ownerOf = () => (owner ??= read(() => ng.getOwningComponent(el) ?? null, null)); @@ -760,7 +881,16 @@ export function findForms(ng: FormsDebugApi, elements: Iterable): Found const state = isFieldTree(tree) ? tree() : read(() => dir['state']() as AnyRecord, null); const root = read(() => state?.['structure'].root as AnyRecord, null); const element = selectors.has('formRoot') ? el : undefined; - if (root) add({ kind: 'signal', root, owner: ownerOf(), element, formElement: element }); + if (root) { + add({ + kind: 'signal', + root, + owner: ownerOf(), + element, + formElement: element, + submitListener: element ? listensForSubmit(element) : undefined, + }); + } return; } const ownControl = read(() => dir['control'] as unknown, undefined); @@ -790,6 +920,7 @@ export function findForms(ng: FormsDebugApi, elements: Iterable): Found directive: isRootDirective ? dir : undefined, element: el, formElement: isRootDirective ? el : undefined, + submitListener: isRootDirective ? listensForSubmit(el) : undefined, }); }); } @@ -806,7 +937,7 @@ export function findForms(ng: FormsDebugApi, elements: Iterable): Found if (form.kind === 'signal') for (const control of compatControls(form.root)) owned.add(control); } const forms = Array.from(byRoot.values()).filter((form) => !owned.has(form.root)); - return { forms, elements: controlElements }; + return { forms, elements: controlElements, hosts }; } function fallbackName(found: FoundForm): string { @@ -818,50 +949,87 @@ function fallbackName(found: FoundForm): string { return attr ? `${base}#${attr}` : base; } -export function collectForms( - { forms, elements }: FoundForms, - idOf: (root: object) => string = formIdFor, -): CollectedForm[] { +export function formLabels(forms: FoundForm[]): Map { const seen = new Map(); - const kept = new Map(); - const collected = forms.map((found) => { + const labels = new Map(); + for (const found of forms) { const property = found.property ?? propertyHolding(found); - const owner = ownerName(found.owner); - let label = `${owner}.${property ?? fallbackName(found)}`; + const label = `${ownerName(found.owner)}.${property ?? fallbackName(found)}`; const count = (seen.get(label) ?? 0) + 1; seen.set(label, count); - if (count > 1) label = `${label} #${count}`; - const id = idOf(found.root); - secrets = new SecretSet(); - kept.set(id, secrets); - let root: FormFieldNode; - try { - root = - found.kind === 'signal' - ? serializeField(found.root, elements, property ?? '') - : serializeControl(found.root, elements, property ?? ''); - if (secrets.size) redactTree(root, secrets); - } finally { - secrets = null; + labels.set(found.root, count > 1 ? `${label} #${count}` : label); + } + return labels; +} + +export function collectForms( + { forms, elements }: FoundForms, + idOf?: (root: object, label: string) => string, +): CollectedForm[] { + const kept = new Map(); + const labels = formLabels(forms); + collectRound++; + collecting = true; + try { + const collected = forms.map((found) => collectOne(found, elements, labels, kept, idOf)); + formSecrets = kept; + return collected; + } finally { + collecting = false; + } +} + +function collectOne( + found: FoundForm, + elements: WeakMap, + labels: Map, + kept: Map, + idOf?: (root: object, label: string) => string, +): CollectedForm { + const property = found.property ?? propertyHolding(found); + const owner = ownerName(found.owner); + const label = labels.get(found.root) ?? `${owner}.${property ?? fallbackName(found)}`; + const id = idOf ? idOf(found.root, label) : formIdFor(label); + const set = new SecretSet(); + secrets = set; + kept.set(id, set); + let root: FormFieldNode; + let errorSummary: FormErrorSummary[] = []; + try { + root = + found.kind === 'signal' + ? serializeField(found.root, elements, property ?? '') + : serializeControl(found.root, elements, property ?? ''); + if (found.kind === 'signal') errorSummary = read(() => errorSummaryOf(found.root), []); + if (set.size) { + redactTree(root, set); + errorSummary = errorSummary.map((entry) => ({ + ...entry, + message: set.redact(entry.message), + })); } - const form: CollectedForm = { - id, - kind: found.kind, - owner, - property, - label, - submitted: - found.kind === 'signal' - ? undefined - : read(() => found.directive?.['submitted'] as boolean | undefined, undefined), - root, - }; - if (found.kind === 'signal') form.submit = read(() => submitSetup(found.root), undefined); - if (found.formElement) form.submitDom = read(() => submitDom(found.formElement!), undefined); - return form; - }); - formSecrets = kept; - return collected; + } finally { + secrets = null; + } + const form: CollectedForm = { + id, + kind: found.kind, + owner, + property, + label, + submitted: + found.kind === 'signal' + ? undefined + : read(() => found.directive?.['submitted'] as boolean | undefined, undefined), + root, + }; + if (found.kind === 'signal') form.submit = read(() => submitSetup(found.root), undefined); + if (errorSummary.length) form.errorSummary = errorSummary; + if (found.formElement) { + const context = { valid: root.status === 'VALID', submitListener: found.submitListener }; + form.submitDom = read(() => submitDom(found.formElement!, context), undefined); + } + return form; } export function forgetFormSecrets(formIds: Iterable) { diff --git a/packages/ng-devtools/src/http-hydration.ts b/packages/ng-devtools/src/http-hydration.ts new file mode 100644 index 0000000..68d1a9d --- /dev/null +++ b/packages/ng-devtools/src/http-hydration.ts @@ -0,0 +1,65 @@ +export const HYDRATION_CODE = /\bNG05\d\d\b/; + +export interface HydrationMismatch { + component: string; + expected?: string; + actual?: string; +} + +export interface HydrationScan { + hydrated: number; + skipped: number; + mismatched: number; + mismatches: HydrationMismatch[]; +} + +const INFO_KEY = '__ngDebugHydrationInfo__'; +const MAX_NODES = 50_000; +const MAX_MISMATCHES = 20; +const MAX_DETAIL = 500; + +export function isHydrationMessage(text: string): boolean { + return HYDRATION_CODE.test(text); +} + +/** The APP_ID the server used, read from the `{appId}-state` TransferState script. */ +export function appIdOf(doc: Document): string { + const script = doc.querySelector('script[id$="-state"][type="application/json"]'); + return script?.id.replace(/-state$/, '') || 'ng'; +} + +export function hasStateScript(doc: Document): boolean { + const script = doc.getElementById(`${appIdOf(doc)}-state`); + return script?.tagName === 'SCRIPT'; +} + +const detail = (value: unknown) => + typeof value === 'string' && value ? value.slice(0, MAX_DETAIL) : undefined; + +/** Counts the hydration status Angular patches on DOM nodes in dev mode. */ +export function scanHydration(root: Node): HydrationScan { + const scan: HydrationScan = { hydrated: 0, skipped: 0, mismatched: 0, mismatches: [] }; + const doc = root.ownerDocument ?? (root as Document); + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ALL); + let seen = 0; + for (let node: Node | null = walker.currentNode; node && seen < MAX_NODES; seen++) { + const info = (node as unknown as Record)[INFO_KEY] as + { status?: unknown; expectedNodeDetails?: unknown; actualNodeDetails?: unknown } | undefined; + if (info && typeof info === 'object') { + if (info.status === 'hydrated') scan.hydrated++; + else if (info.status === 'skipped') scan.skipped++; + else if (info.status === 'mismatched') { + scan.mismatched++; + if (scan.mismatches.length < MAX_MISMATCHES) { + scan.mismatches.push({ + component: node.nodeName.toLowerCase(), + expected: detail(info.expectedNodeDetails), + actual: detail(info.actualNodeDetails), + }); + } + } + } + node = walker.nextNode(); + } + return scan; +} diff --git a/packages/ng-devtools/src/http-overlay.ts b/packages/ng-devtools/src/http-overlay.ts index 6e3138d..0e4c67a 100644 --- a/packages/ng-devtools/src/http-overlay.ts +++ b/packages/ng-devtools/src/http-overlay.ts @@ -1,4 +1,5 @@ -import { decodePayload } from './http-payload.ts'; +import { appIdOf, scanHydration } from './http-hydration.ts'; +import { decodePayload, type PayloadSummary } from './http-payload.ts'; import { httpRegistry, sanitizeRules, storeRules } from './http-rules.ts'; import type { HttpPage, HydrationStats } from './types.ts'; @@ -15,53 +16,82 @@ interface RpcScope { }; } -function hydrationStats(): HydrationStats { +type HydrationScan = ReturnType; + +export function createHydrationScanner(scan: () => HydrationScan = () => scanHydration(document)) { + let cached: HydrationScan | null = null; + let key = ''; + let scans = 0; + return (counters: Record | undefined): HydrationScan => { + const next = counters + ? `${counters['hydratedNodes']}:${counters['componentsSkippedHydration']}:${counters['deferBlocksWithIncrementalHydration']}` + : ''; + if (cached && scans >= 3 && next === key) return cached; + cached = scan(); + key = next; + scans++; + return cached; + }; +} + +function hydrationStats( + payload: PayloadSummary, + scanner: ReturnType, +): HydrationStats { const counters = (globalThis as { ngDevMode?: Record | boolean }).ngDevMode; const num = (key: string) => counters && typeof counters === 'object' && typeof counters[key] === 'number' ? (counters[key] as number) : undefined; const hydratedComponents = num('hydratedComponents'); + const scan = scanner(counters && typeof counters === 'object' ? counters : undefined); + const annotated = payload.entries.some((e) => e.key === '__nghData__'); + const warnings = httpRegistry().warnings; return { - enabled: !!document.querySelector('[ngh]') || (hydratedComponents ?? 0) > 0, + enabled: annotated || (hydratedComponents ?? 0) > 0 || scan.hydrated > 0, hydratedComponents, hydratedNodes: num('hydratedNodes'), componentsSkippedHydration: num('componentsSkippedHydration'), deferBlocksWithIncrementalHydration: num('deferBlocksWithIncrementalHydration'), + nodes: { hydrated: scan.hydrated, skipped: scan.skipped, mismatched: scan.mismatched }, + mismatches: scan.mismatches, skipHydrationHosts: [...document.querySelectorAll('[ngskiphydration]')] .slice(0, 50) .map((el) => el.tagName.toLowerCase()), - warnings: [...(httpRegistry().warnings ?? [])], + warnings: [...(warnings ?? [])], + warningsCaptured: Array.isArray(warnings), }; } -function appId(): string { - const script = document.querySelector('script[id$="-state"][type="application/json"]'); - return script?.id.replace(/-state$/, '') || 'ng'; -} - /** Reports payload, hydration and client calls, and keeps fault rules in sync. */ export function attachHttp(my: RpcScope, pageId: string) { - // The payload never changes after load, so decode it once. - const payload = decodePayload(document, appId()); + const payload = decodePayload(document, appIdOf(document)); + const initialUrl = location.pathname + location.search; + const scanner = createHydrationScanner(); + let payloadSent = false; let lastSent = ''; let lastSentAt = 0; const push = async () => { - const report: Omit = { + const report: Omit = { pageId, url: location.pathname + location.search, + initialUrl, title: document.title, - payload, - hydration: hydrationStats(), + hydration: hydrationStats(payload, scanner), calls: [...(httpRegistry().calls ?? [])], }; const body = JSON.stringify(report); - // Re-send unchanged reports every few pushes so the server keeps the page alive. - if (body === lastSent && Date.now() - lastSentAt < 8000) return; + if (payloadSent && body === lastSent && Date.now() - lastSentAt < 8000) return; lastSent = body; lastSentAt = Date.now(); - await my.rpc.call('push-http', report); + const withPayload = !payloadSent; + const answer = (await my.rpc.call( + 'push-http', + withPayload ? { ...report, payload } : report, + )) as { needPayload?: boolean } | null | undefined; + payloadSent = withPayload || payloadSent; + if (answer?.needPayload) payloadSent = false; }; my.rpc.register({ diff --git a/packages/ng-devtools/src/http-payload.ts b/packages/ng-devtools/src/http-payload.ts index 27b8ac7..2235e28 100644 --- a/packages/ng-devtools/src/http-payload.ts +++ b/packages/ng-devtools/src/http-payload.ts @@ -1,9 +1,13 @@ +import type { HydrationMismatch } from './http-hydration.ts'; import type { HydrationStats } from './types.ts'; +export type PayloadSource = 'http' | 'analog' | 'hydration'; + export interface PayloadEntry { key: string; /** Present when the entry is an HttpClient transfer-cache response. */ http?: { url?: string; status?: number; statusText?: string; responseType?: string }; + source?: PayloadSource; size: number; value: unknown; } @@ -48,10 +52,15 @@ export function sanitizePayload(input: unknown): PayloadSummary { const key = str(e.key, 500); if (key === undefined) continue; const h = e.http && typeof e.http === 'object' ? (e.http as Record) : null; + const source = + e.source === 'http' || e.source === 'analog' || e.source === 'hydration' + ? e.source + : undefined; entries.push({ key, size: num(e.size) ?? 0, value: clip(e.value), + ...(source && { source }), ...(h && { http: { url: str(h['url'], 2000), @@ -75,17 +84,42 @@ export function sanitizeHydration(input: unknown): HydrationStats | null { if (!input || typeof input !== 'object') return null; const h = input as { [K in keyof HydrationStats]?: unknown }; if (typeof h.enabled !== 'boolean') return null; + const n = + h.nodes && typeof h.nodes === 'object' ? (h.nodes as Record) : undefined; + const mismatches: HydrationMismatch[] = []; + for (const raw of Array.isArray(h.mismatches) ? h.mismatches.slice(0, 20) : []) { + if (!raw || typeof raw !== 'object') continue; + const m = raw as Record; + const component = str(m['component'], 200); + if (!component) continue; + mismatches.push({ + component, + expected: str(m['expected'], 500), + actual: str(m['actual'], 500), + }); + } return { enabled: h.enabled, hydratedComponents: num(h.hydratedComponents), hydratedNodes: num(h.hydratedNodes), componentsSkippedHydration: num(h.componentsSkippedHydration), deferBlocksWithIncrementalHydration: num(h.deferBlocksWithIncrementalHydration), + ...(n && { + nodes: { + hydrated: num(n['hydrated']) ?? 0, + skipped: num(n['skipped']) ?? 0, + mismatched: num(n['mismatched']) ?? 0, + }, + }), + mismatches, skipHydrationHosts: strings(h.skipHydrationHosts, 200), warnings: strings(h.warnings, 1000), + warningsCaptured: h.warningsCaptured === true, }; } +const HYDRATION_KEYS = new Set(['__nghData__', '__nghDeferData__']); + /** Reads the TransferState script (`
    + {{ row.node.fullPath }} @if (isActive(row.node)) { active @@ -107,10 +135,18 @@ interface MatchResult { @if (row.node.outlet) { outlet {{ row.node.outlet }} } + @if (row.source) { + declared in {{ row.source }} + } @if (row.node.redirectTo !== undefined) { - redirect → {{ row.node.redirectTo }} + redirect + {{ row.node.redirectTo }} } @else { {{ row.node.component ?? @@ -125,38 +161,51 @@ interface MatchResult { @for (resolver of row.node.resolvers ?? []; track resolver) { resolve {{ resolver }} } + @if (!guardList(row.node).length && !row.node.resolvers?.length) { + none + } + + @if (row.node.title) { + {{ row.node.title }} + } @else { + none + } {{ row.node.title ?? '' }} - @if (canNavigate(row.node)) { - @for (param of params(row.node); track param) { - +
    + @if (canNavigate(row.node)) { + @for (param of params(row.node); track param) { + + } + } - - } - @if (row.node.kind === 'lazy' && row.node.lazy === 'unloaded') { - - } + @if (row.node.kind === 'lazy' && row.node.lazy === 'unloaded') { + + } +