Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions app/src/__tests__/full-panel-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fullPanelHref } from '../full-panel';

const connection = vi.hoisted(() => ({ resolve: (_client: unknown) => {} }));

vi.mock('devframe/client', () => ({
connectDevframe: () =>
new Promise((resolve) => {
connection.resolve = resolve;
}),
}));

const { App } = await import('../app');

const BASE = '/__devframes/pangular/';
const capacitorPage = {
pageId: 'cap1',
url: '/',
title: 'Trips',
reportedAt: 5_000,
info: {
platform: 'android',
native: true,
bridge: true,
webCalls: false,
env: { serverUrl: '', debug: false, logging: false, capacitorHttp: true, userAgent: 'UA' },
plugins: [],
},
calls: {},
listeners: [],
dropped: 0,
};

function fakeClient(capacitorPages: Record<string, unknown>) {
const rpc = {
call: () => new Promise(() => {}),
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve(
name === 'capacitor'
? { value: () => ({ pages: capacitorPages }), on: () => () => {} }
: { value: () => null, on: () => () => {} },
),
};
return {
connectionMeta: { configs: {} },
scope: () => ({ rpc }),
events: { on: () => () => {} },
} as unknown as DevframeRpcClient;
}

/** The hub's client context on the parent window, which switches docks like the rail does. */
function fakeHub(selectedId: string) {
const listeners = new Map<string, () => void>();
const docks = {
selectedId,
getStateById: (id: string) => ({
events: {
on: (_event: 'entry:activated', listener: () => void) => {
listeners.set(id, listener);
return () => listeners.delete(id);
},
},
}),
switchEntry: vi.fn(async (id?: string | null) => {
docks.selectedId = id ?? '';
listeners.get(id ?? '')?.();
return true;
}),
};
const parent = { __DEVFRAME_HUB_CLIENT_CONTEXT__: { docks } };
vi.spyOn(window, 'parent', 'get').mockReturnValue(parent as unknown as Window);
return { docks, activate: (id: string) => docks.switchEntry(id) };
}

async function settle(fixture: ComponentFixture<unknown>) {
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
}

async function open(path: string, capacitorPages: Record<string, unknown> = {}) {
history.replaceState(null, '', path);
const fixture = TestBed.createComponent(App);
await settle(fixture);
connection.resolve(fakeClient(capacitorPages));
await settle(fixture);
return { fixture, el: fixture.nativeElement as HTMLElement };
}

const link = (el: HTMLElement) => el.querySelector<HTMLAnchorElement>('header a.full-panel');

afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
history.replaceState(null, '', '/');
});

describe('Open full panel link', () => {
it('links a hub view to the full panel on the tab with the same content', async () => {
const { el } = await open(`${BASE}?view=capacitor`, { cap1: capacitorPage });
const anchor = link(el);
expect(anchor?.textContent?.trim()).toBe('Open full panel');
expect(anchor?.getAttribute('href')).toBe(`${BASE}#tab=capacitor`);
});

it('keeps other parameters and links the Angular view to its current tab', async () => {
const { el } = await open(`${BASE}?view=angular&theme=light`);
expect(link(el)?.getAttribute('href')).toBe(`${BASE}?theme=light#tab=dashboard`);
});

it('links a view with nothing to show to the Dashboard, not to a remembered tab', async () => {
const { el } = await open(`${BASE}?view=angular-native`);
expect(el.textContent).toContain('No Angular Native app is connected');
expect(link(el)?.getAttribute('href')).toBe(`${BASE}#tab=dashboard`);
});

it('links the Capacitor view to the Dashboard while no app reports', async () => {
const { el } = await open(`${BASE}?view=capacitor`);
expect(el.textContent).toContain('Inspect Capacitor apps');
expect(link(el)?.getAttribute('href')).toBe(`${BASE}#tab=dashboard`);
});

it('is not shown on the full panel', async () => {
const { el } = await open(BASE);
expect(link(el)).toBeNull();
});

it('switches the hub to the All tools dock and shows the full panel in place', async () => {
const hub = fakeHub('pangular:capacitor');
const { fixture, el } = await open(`${BASE}?view=capacitor`, { cap1: capacitorPage });
expect(el.querySelector('h1')?.textContent?.trim()).toBe('Capacitor');

link(el)!.click();
await settle(fixture);
expect(hub.docks.switchEntry).toHaveBeenCalledWith('pangular:all');
expect(el.querySelector('h1')?.textContent?.trim()).toBe('Pangular Inspector');
expect(el.querySelector('nav button.active')?.textContent).toContain('Capacitor');
expect(location.pathname + location.search + location.hash).toBe(`${BASE}#tab=capacitor`);
expect(link(el)).toBeNull();
expect(document.activeElement?.textContent).toContain('Capacitor');
});

it('lets the browser follow the link outside the hub', async () => {
const { el } = await open(`${BASE}?view=capacitor`);
let prevented: boolean | undefined;
const record = (event: Event) => {
prevented = event.defaultPrevented;
// jsdom can't navigate, so stop it after the panel had its turn.
event.preventDefault();
};
document.addEventListener('click', record);
link(el)!.click();
document.removeEventListener('click', record);
expect(prevented).toBe(false);
});
});

describe('Hub rail and the full panel', () => {
it('moves between the full panel and a view as the rail selection changes', async () => {
const hub = fakeHub('pangular:all');
const { fixture, el } = await open(BASE);
expect(el.querySelector('h1')?.textContent?.trim()).toBe('Pangular Inspector');

await hub.activate('pangular:ngrx');
await settle(fixture);
expect(el.querySelector('h1')?.textContent?.trim()).toBe('NgRx Store');
expect(new URLSearchParams(location.search).get('view')).toBe('ngrx');

await hub.activate('pangular:all');
await settle(fixture);
expect(el.querySelector('h1')?.textContent?.trim()).toBe('Pangular Inspector');
expect(new URLSearchParams(location.search).get('view')).toBeNull();
});
});

describe('fullPanelHref', () => {
it('drops the view and sets or clears the tab', () => {
expect(fullPanelHref(`http://localhost${BASE}?view=ngrx#tab=store`, 'store')).toBe(
`${BASE}#tab=store`,
);
expect(fullPanelHref(`http://localhost${BASE}?view=nativescript#tab=x`)).toBe(BASE);
});
});
102 changes: 98 additions & 4 deletions app/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ import { capacitorPages } from './pages/capacitor-types';
import { TabIcon } from './pages/tab-icon';
import { styleHubRail } from './hub-rail-style';
import { ThemeService } from './theme.service';
import { followHubDocks, selectHubDock } from './hub-dock-sync';
import { followHubDocks, inHub, selectHubDock } from './hub-dock-sync';
import { fullPanelHref, isPlainClick } from './full-panel';
import { panelConfig, tabEnabled } from './devtools-config';
import { hostPageId, scopeToPage } from './page-id';
import { angularNativePage, type PlatformPage } from './native-page';
Expand All @@ -53,6 +54,10 @@ const HUB_VIEWS = [

type View = (typeof HUB_VIEWS)[number];

/** The hub dock that opens this panel without a view, with every tab. */
const ALL_TOOLS = 'all';
const HUB_DOCKS = [ALL_TOOLS, ...HUB_VIEWS] as const;

const VIEW_TITLE: Record<View, string> = {
angular: 'Angular',
ngrx: 'NgRx Store',
Expand Down Expand Up @@ -370,6 +375,17 @@ function readView(): View | null {
}
}
</nav>
@if (fullPanelLink(); as link) {
<a class="full-panel" [href]="link.href" (click)="openFullPanel($event, link.tab)">
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</svg>
Open full panel
</a>
}
<span
class="status"
[class.connected]="connected()"
Expand Down Expand Up @@ -642,6 +658,42 @@ function readView(): View | null {
border-color 0.2s var(--ease),
background-color 0.2s var(--ease);
}
.full-panel {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 6px;
height: 28px;
margin-left: auto;
padding: 0 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface);
color: var(--text);
font-size: 12px;
font-weight: 600;
line-height: 1;
text-decoration: none;
white-space: nowrap;
transition:
background-color 0.16s var(--ease),
color 0.16s var(--ease);
svg {
fill: none;
stroke: currentColor;
stroke-width: 2;
}
&:hover {
background: var(--surface-2);
color: var(--text-strong);
}
&:focus-visible {
@include m.focus-ring(2px);
}
}
.full-panel + .status {
margin-left: 0;
}
.dot {
position: relative;
display: grid;
Expand Down Expand Up @@ -843,6 +895,15 @@ export class App implements OnInit, OnDestroy {
return tabs.filter((t) => t.id === 'dashboard' || t.id === tab);
});

/** The link from a hub view to the full panel, on the tab that shows the same content. */
protected readonly fullPanelLink = computed(() => {
const view = this.view();
// The extension has no hub and no views to leave.
if (!view || location.protocol === 'chrome-extension:') return null;
const tab = this.comingSoon() ? 'dashboard' : (VIEW_TAB[view] ?? this.tab());
return { tab, href: fullPanelHref(location.href, tab) };
});

tab = linkedSignal<Tab>(() => {
const view = this.view();
return (view && (VIEW_TAB[view] ?? VIEW_TABS[view]?.[0])) || 'dashboard';
Expand Down Expand Up @@ -924,9 +985,11 @@ export class App implements OnInit, OnDestroy {
}

ngOnInit() {
if (this.view()) {
this.stopFollowing = followHubDocks(HUB_VIEWS, (view) => this.showView(view));
}
// The views and the full panel share one hub frame, so whichever loaded
// first follows the rail.
this.stopFollowing = followHubDocks(HUB_DOCKS, (dock) =>
dock === ALL_TOOLS ? this.showFullPanel() : this.showView(dock),
);
const restored = initialTab(
location.hash,
storedTab(this.tabScope()),
Expand Down Expand Up @@ -1006,6 +1069,32 @@ export class App implements OnInit, OnDestroy {
history.replaceState(history.state, '', url);
}

/** Leaves the hub view for the full panel in place, on `target` when it has that tab. */
private showFullPanel(target?: Tab) {
if (!this.view()) return;
scopeToPage(null);
this.view.set(null);
const url = new URL(location.href);
url.searchParams.delete('view');
url.hash = target ? `tab=${target}` : '';
history.replaceState(history.state, '', url);
const wanted = target ?? storedTab(this.tabScope());
const available = this.availableTabs().map((t) => t.id);
const restored = initialTab(url.hash, wanted, available);
if (restored) this.setTab(restored);
// The tab exists once a Capacitor app reports; watchCapacitor opens it then.
else if (wanted === 'capacitor') this.restoreCapacitor = true;
}

openFullPanel(event: MouseEvent, tab: Tab | undefined) {
// Outside the hub, and for a new tab or window, the link navigates.
if (!isPlainClick(event) || !inHub()) return;
event.preventDefault();
this.showFullPanel(tab);
this.focusCurrentTab();
void selectHubDock(ALL_TOOLS);
}

formFocus = signal<{ id: string } | null>(null);
// Belongs to the Components tab that received it, so leaving the tab drops it.
readonly componentFocus = linkedSignal<Tab, ComponentFocus | null>({
Expand Down Expand Up @@ -1123,6 +1212,11 @@ export class App implements OnInit, OnDestroy {
const active = document.activeElement;
const main = this.main()?.nativeElement;
if (active && active !== document.body && !main?.contains(active)) return;
this.focusCurrentTab();
}

/** Moves focus to the selected tab, or the content, once the next render settles. */
private focusCurrentTab() {
afterNextRender(
() => {
const now = document.activeElement;
Expand Down
16 changes: 16 additions & 0 deletions app/src/full-panel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* The full panel's address for a panel opened on one hub view: the same page
* without `view`, deep linked to `tab` when there is one. Relative, so it
* stays inside the frame and on the same origin.
*/
export function fullPanelHref(current: string, tab?: string): string {
const url = new URL(current);
url.searchParams.delete('view');
url.hash = tab ? `tab=${tab}` : '';
return url.pathname + url.search + url.hash;
}

/** A plain left click, which the panel may handle itself; others open a new tab or window. */
export function isPlainClick(event: MouseEvent): boolean {
return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
}
5 changes: 5 additions & 0 deletions app/src/hub-dock-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export function followHubDocks<T extends string>(
return () => offs.forEach((off) => off());
}

/** Whether the panel runs inside the hub, which can switch its docks. */
export function inHub(): boolean {
return !!hubContext();
}

export async function selectHubDock(view: string): Promise<boolean> {
const ctx = hubContext();
if (!ctx) return false;
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/getting-started/angular-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ The **Angular Native** view shows only the tabs an app on a device fills: **Comp

The panel without `?view` also shows the app, in the same tabs as a browser page.

The overlay marks its component reports with `platform: 'angular-native'`, and the view follows the newest app that sends them. It keeps the app it shows while that app reports, and the app keeps its page id when it reconnects. With no app connected, the view says **No Angular Native app is connected** and links to this page. The view reads the component reports, so turning off the `components` inspector hides the dock and leaves the view empty.
The overlay marks its component reports with `platform: 'angular-native'`, and the view follows the newest app that sends them. It keeps the app it shows while that app reports, and the app keeps its page id when it reconnects. With no app connected, the view says **No Angular Native app is connected** and links to this page. In the hub, the dock's tooltip in the rail then reads **Angular Native: no app connected**. The view reads the component reports, so turning off the `components` inspector hides the dock and leaves the view empty.

`list-pages` gives each page's platform, so an agent can tell an Angular Native app from a browser tab. See [Agent tools](../agents/tools.md#list-pages).

Expand Down
Loading
Loading