diff --git a/devtools/projects/ng-devtools/src/lib/application-operations/BUILD.bazel b/devtools/projects/ng-devtools/src/lib/application-operations/BUILD.bazel index 12c199aa5a3..c9a1c69bc73 100644 --- a/devtools/projects/ng-devtools/src/lib/application-operations/BUILD.bazel +++ b/devtools/projects/ng-devtools/src/lib/application-operations/BUILD.bazel @@ -6,6 +6,7 @@ ts_library( name = "application-operations", srcs = ["index.ts"], deps = [ + "//devtools/projects/ng-devtools/src/lib/application-environment", "//devtools/projects/protocol", "@npm//@types", ], diff --git a/devtools/projects/ng-devtools/src/lib/application-operations/index.ts b/devtools/projects/ng-devtools/src/lib/application-operations/index.ts index 88fd2b8ec78..456f49582c9 100644 --- a/devtools/projects/ng-devtools/src/lib/application-operations/index.ts +++ b/devtools/projects/ng-devtools/src/lib/application-operations/index.ts @@ -6,10 +6,11 @@ * found in the LICENSE file at https://angular.dev/license */ +import {Frame} from '../application-environment'; import {DirectivePosition, ElementPosition} from 'protocol'; export abstract class ApplicationOperations { - abstract viewSource(position: ElementPosition, directiveIndex?: number, target?: URL): void; - abstract selectDomElement(position: ElementPosition, target?: URL): void; - abstract inspect(directivePosition: DirectivePosition, objectPath: string[], target?: URL): void; + abstract viewSource(position: ElementPosition, target: Frame, directiveIndex?: number): void; + abstract selectDomElement(position: ElementPosition, target: Frame): void; + abstract inspect(directivePosition: DirectivePosition, objectPath: string[], target: Frame): void; } diff --git a/devtools/projects/ng-devtools/src/lib/application-services/frame_manager.ts b/devtools/projects/ng-devtools/src/lib/application-services/frame_manager.ts index 7eb81cdfbba..61b62498da4 100644 --- a/devtools/projects/ng-devtools/src/lib/application-services/frame_manager.ts +++ b/devtools/projects/ng-devtools/src/lib/application-services/frame_manager.ts @@ -30,6 +30,14 @@ export class FrameManager { return this._frames().get(selectedFrameId) ?? null; }); + readonly topLevelFrameIsActive = computed(() => { + return this._selectedFrameId() === TOP_LEVEL_FRAME_ID; + }); + + readonly activeFrameHasUniqueUrl = computed(() => { + return this.frameHasUniqueUrl(this.selectedFrame()); + }); + static initialize(inspectedWindowTabIdTestOnly?: number | null) { const manager = new FrameManager(); manager.initialize(inspectedWindowTabIdTestOnly); @@ -104,7 +112,7 @@ export class FrameManager { this._messageBus.emit('enableFrameConnection', [frame.id, this._inspectedWindowTabId]); } - frameHasUniqueUrl(frame: Frame | null): boolean { + private frameHasUniqueUrl(frame: Frame | null): boolean { if (frame === null) { return false; } diff --git a/devtools/projects/ng-devtools/src/lib/application-services/frame_manager_spec.ts b/devtools/projects/ng-devtools/src/lib/application-services/frame_manager_spec.ts index 62d4954ce28..e60a020f5fc 100644 --- a/devtools/projects/ng-devtools/src/lib/application-services/frame_manager_spec.ts +++ b/devtools/projects/ng-devtools/src/lib/application-services/frame_manager_spec.ts @@ -169,14 +169,14 @@ describe('FrameManager', () => { contentScriptConnected(topLevelFrameId, 'name', 'https://angular.dev/'); contentScriptConnected(otherFrameId, 'name2', 'https://angular.dev/'); expect(frameManager.selectedFrame()?.url.toString()).toBe('https://angular.dev/'); - expect(frameManager.frameHasUniqueUrl(frameManager.selectedFrame()!)).toBe(false); + expect(frameManager.activeFrameHasUniqueUrl()).toBe(false); }); it('frameHasUniqueUrl should return true when only one frame has a given url', () => { contentScriptConnected(topLevelFrameId, 'name', 'https://angular.dev/'); contentScriptConnected(otherFrameId, 'name', 'https://angular.dev/overview'); expect(frameManager.selectedFrame()?.url.toString()).toBe('https://angular.dev/'); - expect(frameManager.frameHasUniqueUrl(frameManager.selectedFrame()!)).toBe(true); + expect(frameManager.activeFrameHasUniqueUrl()).toBe(true); }); it('frameHasUniqueUrl should not consider url fragments as part of the url comparison', () => { @@ -189,10 +189,10 @@ describe('FrameManager', () => { expect(frameManager.selectedFrame()?.url.toString()).toBe( 'https://angular.dev/guide/components', ); - expect(frameManager.frameHasUniqueUrl(frameManager.selectedFrame()!)).toBe(false); + expect(frameManager.activeFrameHasUniqueUrl()).toBe(false); }); it('frameHasUniqueUrl should return false when frame is null', () => { - expect(frameManager.frameHasUniqueUrl(null)).toBe(false); + expect(frameManager.activeFrameHasUniqueUrl()).toBe(false); }); }); diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts index c5d2711a01d..9bf292aaecd 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts @@ -46,6 +46,8 @@ import {PropertyTabComponent} from './property-tab/property-tab.component'; import {SplitAreaDirective} from '../../vendor/angular-split/lib/component/splitArea.directive'; import {MatSlideToggle} from '@angular/material/slide-toggle'; import {FormsModule} from '@angular/forms'; +import {Platform} from '@angular/cdk/platform'; +import {MatSnackBarModule, MatSnackBar} from '@angular/material/snack-bar'; const sameDirectives = (a: IndexedNode, b: IndexedNode) => { if ((a.component && !b.component) || (!a.component && b.component)) { @@ -81,6 +83,7 @@ const sameDirectives = (a: IndexedNode, b: IndexedNode) => { PropertyTabComponent, MatSlideToggle, FormsModule, + MatSnackBarModule, ], }) export class DirectiveExplorerComponent { @@ -108,6 +111,10 @@ export class DirectiveExplorerComponent { private readonly _propResolver = inject(ElementPropertyResolver); private readonly _frameManager = inject(FrameManager); + private readonly platform = inject(Platform); + + private readonly snackBar = inject(MatSnackBar); + constructor() { afterRenderEffect((cleanup) => { const splitElement = this.splitElementRef().nativeElement; @@ -134,6 +141,10 @@ export class DirectiveExplorerComponent { this.refresh(); } + private isNonTopLevelFirefoxFrame() { + return this.platform.FIREFOX && !this._frameManager.topLevelFrameIsActive(); + } + handleNodeSelection(node: IndexedNode | null): void { if (node) { // We want to guarantee that we're not reusing any of the previous properties. @@ -192,36 +203,42 @@ export class DirectiveExplorerComponent { ); const selectedFrame = this._frameManager.selectedFrame(); - if (!this._frameManager.frameHasUniqueUrl(selectedFrame)) { - this._messageBus.emit('log', [ - { - level: 'warn', - message: `The currently inspected frame does not have a unique url on this page. Cannot view source.`, - }, - ]); + if (!this._frameManager.activeFrameHasUniqueUrl()) { + const error = `The currently inspected frame does not have a unique url on this page. Cannot view source.`; + this.snackBar.open(error, 'Dismiss', {duration: 5000, horizontalPosition: 'left'}); + this._messageBus.emit('log', [{level: 'warn', message: error}]); return; } - this._appOperations.viewSource( - selectedEl.position, - directiveIndex !== -1 ? directiveIndex : undefined, - new URL(selectedFrame!.url), - ); + if (this.isNonTopLevelFirefoxFrame()) { + const error = `Viewing source is not supported in Firefox when the inspected frame is not the top-level frame.`; + this.snackBar.open(error, 'Dismiss', {duration: 5000, horizontalPosition: 'left'}); + this._messageBus.emit('log', [{level: 'warn', message: error}]); + } else { + this._appOperations.viewSource( + selectedEl.position, + selectedFrame!, + directiveIndex !== -1 ? directiveIndex : undefined, + ); + } } handleSelectDomElement(node: IndexedNode): void { const selectedFrame = this._frameManager.selectedFrame(); - if (!this._frameManager.frameHasUniqueUrl(selectedFrame)) { - this._messageBus.emit('log', [ - { - level: 'warn', - message: `The currently inspected frame does not have a unique url on this page. Cannot select DOM element.`, - }, - ]); + if (!this._frameManager.activeFrameHasUniqueUrl()) { + const error = `The currently inspected frame does not have a unique url on this page. Cannot select DOM element.`; + this.snackBar.open(error, 'Dismiss', {duration: 5000, horizontalPosition: 'left'}); + this._messageBus.emit('log', [{level: 'warn', message: error}]); return; } - this._appOperations.selectDomElement(node.position, new URL(selectedFrame!.url)); + if (this.isNonTopLevelFirefoxFrame()) { + const error = `Inspecting a component's DOM element is not supported in Firefox when the inspected frame is not the top-level frame.`; + this.snackBar.open(error, 'Dismiss', {duration: 5000, horizontalPosition: 'left'}); + this._messageBus.emit('log', [{level: 'warn', message: error}]); + } else { + this._appOperations.selectDomElement(node.position, selectedFrame!); + } } highlight(node: FlatNode): void { @@ -291,17 +308,21 @@ export class DirectiveExplorerComponent { const objectPath = constructPathOfKeysToPropertyValue(node.prop); const selectedFrame = this._frameManager.selectedFrame(); - if (!this._frameManager.frameHasUniqueUrl(selectedFrame)) { - this._messageBus.emit('log', [ - { - level: 'warn', - message: `The currently inspected frame does not have a unique url on this page. Cannot inspect object.`, - }, - ]); + + if (!this._frameManager.activeFrameHasUniqueUrl()) { + const error = `The currently inspected frame does not have a unique url on this page. Cannot inspect object.`; + this.snackBar.open(error, 'Dismiss', {duration: 5000, horizontalPosition: 'left'}); + this._messageBus.emit('log', [{level: 'warn', message: error}]); return; } - this._appOperations.inspect(directivePosition, objectPath, new URL(selectedFrame!.url)); + if (this.isNonTopLevelFirefoxFrame()) { + const error = `Inspecting object is not supported in Firefox when the inspected frame is not the top-level frame.`; + this.snackBar.open(error, 'Dismiss', {duration: 5000, horizontalPosition: 'left'}); + this._messageBus.emit('log', [{level: 'warn', message: error}]); + } else { + this._appOperations.inspect(directivePosition, objectPath, selectedFrame!); + } } hightlightHydrationNodes() { diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.spec.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.spec.ts index 3a2ca28c319..5f7699f0b11 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.spec.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.spec.ts @@ -260,8 +260,8 @@ describe('DirectiveExplorerComponent', () => { expect(messageBusMock.emit).toHaveBeenCalledWith('enableFrameConnection', [0, 123]); expect(applicationOperationsSpy.viewSource).toHaveBeenCalledWith( [0], // current selected element position + {name: 'test1', id: 0, url: new URL('http://localhost:4200/url')}, 0, // directive index - new URL('http://localhost:4200/url'), // selected frame url ); }); }); @@ -298,7 +298,7 @@ describe('DirectiveExplorerComponent', () => { expect(messageBusMock.emit).toHaveBeenCalledWith('enableFrameConnection', [0, 123]); expect(applicationOperationsSpy.selectDomElement).toHaveBeenCalledWith( [0], // current selected element position - new URL('http://localhost:4200/url'), // selected frame url + {name: 'test1', id: 0, url: new URL('http://localhost:4200/url')}, ); }); }); @@ -355,11 +355,11 @@ describe('DirectiveExplorerComponent', () => { expect(applicationOperationsSpy.inspect).toHaveBeenCalledTimes(1); expect(messageBusMock.emit).toHaveBeenCalledWith('enableFrameConnection', [0, 123]); - expect(applicationOperationsSpy.inspect).toHaveBeenCalledWith( - directivePosition, - ['foo'], - new URL('http://localhost:4200/url'), // selected frame url - ); + expect(applicationOperationsSpy.inspect).toHaveBeenCalledWith(directivePosition, ['foo'], { + name: 'test1', + id: 0, + url: new URL('http://localhost:4200/url'), + }); }); }); }); diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.html b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.html index e366e9b816d..7089e76b275 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.html +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.html @@ -1,6 +1,10 @@ {{ directive() }} - diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.scss b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.scss index 05818ee3936..2295849876f 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.scss +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.scss @@ -30,6 +30,11 @@ button { &:active { opacity: 1; } + + &:disabled { + cursor: not-allowed; + opacity: 1; + } } :host-context(.dark-theme) { diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.ts index abaf5f70287..6f4b99c6fcd 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.ts @@ -6,10 +6,12 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Component, input, output} from '@angular/core'; +import {Component, computed, inject, input, output} from '@angular/core'; import {MatIcon} from '@angular/material/icon'; import {MatTooltip} from '@angular/material/tooltip'; import {MatToolbar} from '@angular/material/toolbar'; +import {Platform} from '@angular/cdk/platform'; +import {FrameManager} from '../../../../application-services/frame_manager'; @Component({ selector: 'ng-property-view-header', @@ -21,6 +23,15 @@ export class PropertyViewHeaderComponent { readonly directive = input.required(); readonly viewSource = output(); + private readonly frameManager = inject(FrameManager); + private readonly platform = inject(Platform); + + readonly disableViewSourceButton = computed(() => { + const isTopLevelFrame = this.frameManager.topLevelFrameIsActive(); + const frameHasUniqueUrl = this.frameManager.activeFrameHasUniqueUrl(); + return (this.platform.FIREFOX && !isTopLevelFrame) || !frameHasUniqueUrl; + }); + // output that emits directive handleViewSource(event: MouseEvent): void { event.stopPropagation(); diff --git a/devtools/projects/shell-browser/src/app/BUILD.bazel b/devtools/projects/shell-browser/src/app/BUILD.bazel index a3974c0533f..1797548f9df 100644 --- a/devtools/projects/shell-browser/src/app/BUILD.bazel +++ b/devtools/projects/shell-browser/src/app/BUILD.bazel @@ -35,6 +35,7 @@ ng_module( "//packages/core", "//packages/platform-browser", "//packages/platform-browser/animations", + "@npm//@angular/cdk", "@npm//@angular/material", "@npm//rxjs", ], @@ -85,6 +86,7 @@ ts_library( "//devtools/projects/ng-devtools", "//devtools/projects/protocol", "//packages/core", + "@npm//@angular/cdk", "@npm//@types", ], ) diff --git a/devtools/projects/shell-browser/src/app/app.config.ts b/devtools/projects/shell-browser/src/app/app.config.ts index d5c37fda24c..03331a0aaf6 100644 --- a/devtools/projects/shell-browser/src/app/app.config.ts +++ b/devtools/projects/shell-browser/src/app/app.config.ts @@ -15,6 +15,7 @@ import {ChromeApplicationOperations} from './chrome-application-operations'; import {ZoneAwareChromeMessageBus} from './zone-aware-chrome-message-bus'; import {Events, MessageBus, PriorityAwareMessageBus} from 'protocol'; import {FrameManager} from '../../../ng-devtools/src/lib/application-services/frame_manager'; +import {Platform} from '@angular/cdk/platform'; export const appConfig: ApplicationConfig = { providers: [ @@ -23,6 +24,7 @@ export const appConfig: ApplicationConfig = { { provide: ApplicationOperations, useClass: ChromeApplicationOperations, + deps: [Platform], }, { provide: ApplicationEnvironment, diff --git a/devtools/projects/shell-browser/src/app/chrome-application-operations.ts b/devtools/projects/shell-browser/src/app/chrome-application-operations.ts index 17a9cbb48ec..8398094fb56 100644 --- a/devtools/projects/shell-browser/src/app/chrome-application-operations.ts +++ b/devtools/projects/shell-browser/src/app/chrome-application-operations.ts @@ -8,25 +8,44 @@ /// -import {ApplicationOperations} from 'ng-devtools'; +import {Platform} from '@angular/cdk/platform'; +import {inject} from '@angular/core'; +import {ApplicationOperations, Frame, TOP_LEVEL_FRAME_ID} from 'ng-devtools'; import {DirectivePosition, ElementPosition} from 'protocol'; -function runInInspectedWindow(script: string, frameURL?: URL): void { - chrome.devtools.inspectedWindow.eval(script, {frameURL: frameURL?.toString?.()}); -} - export class ChromeApplicationOperations extends ApplicationOperations { - override viewSource(position: ElementPosition, directiveIndex?: number, target?: URL): void { + platform = inject(Platform); + + private runInInspectedWindow(script: string, target: Frame) { + if (this.platform.FIREFOX && target.id !== TOP_LEVEL_FRAME_ID) { + console.error( + '[Angular DevTools]: This browser does not support targeting a specific frame for eval by URL.', + ); + return; + } else if (this.platform.FIREFOX) { + chrome.devtools.inspectedWindow.eval(script); + return; + } + + const frameURL = target.url; + chrome.devtools.inspectedWindow.eval(script, {frameURL: frameURL?.toString?.()}); + } + + override viewSource(position: ElementPosition, target: Frame, directiveIndex?: number): void { const viewSource = `inspect(inspectedApplication.findConstructorByPosition('${position}', ${directiveIndex}))`; - runInInspectedWindow(viewSource, target); + this.runInInspectedWindow(viewSource, target); } - override selectDomElement(position: ElementPosition, target?: URL): void { + override selectDomElement(position: ElementPosition, target: Frame): void { const selectDomElement = `inspect(inspectedApplication.findDomElementByPosition('${position}'))`; - runInInspectedWindow(selectDomElement, target); + this.runInInspectedWindow(selectDomElement, target); } - override inspect(directivePosition: DirectivePosition, objectPath: string[], target?: URL): void { + override inspect( + directivePosition: DirectivePosition, + objectPath: string[], + target: Frame, + ): void { const args = { directivePosition, objectPath, @@ -34,6 +53,6 @@ export class ChromeApplicationOperations extends ApplicationOperations { const inspect = `inspect(inspectedApplication.findPropertyByPosition('${JSON.stringify( args, )}'))`; - runInInspectedWindow(inspect, target); + this.runInInspectedWindow(inspect, target); } }