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