diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index ab8975c..0000000 --- a/.eslintignore +++ /dev/null @@ -1,5 +0,0 @@ -# Third party -**/node_modules - -# Build products -coverage/ diff --git a/docs/ru/tests/01-components.md b/docs/ru/tests/01-components.md new file mode 100644 index 0000000..48dbcc6 --- /dev/null +++ b/docs/ru/tests/01-components.md @@ -0,0 +1,19 @@ +# Тесты страницы "Components" + +## 1. Выбор компонента на странице + +### Кейс 1.1 + +1. Открыть приложение V4Fire +2. Открыть devtools +3. Открыть вкладку `v4fire` в devtools +4. Кликнуть на иконку прицела и выбрать компонент на странице -> Данные компонента отобразились в панели + +### Кейс 1.2 + +1. Открыть приложение V4Fire +2. Открыть devtools +3. Открыть вкладку `v4fire` в devtools +4. Сменить вкладку в браузере +5. Вернуться на вкладку с приложением V4Fire +6. Кликнуть на иконку прицела и выбрать компонент на странице -> Данные компонента отобразились в панели \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 42a91d2..7d75ef0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,7 +24,24 @@ const copyrightTemplate = [ ' ' ]; +const ignore = [ + '**/src/**/@(i-|b-|p-|g-|v-)*/index.js', + '**/src/**/test/**/*.js', + + '**/assets/**', + '**/src/assets/**', + + '**/tmp/**', + '**/src/entries/tmp/**', + + '**/docs/**', + '**/dist/**', + '**/node_modules/**' +]; + base.forEach((item) => { + item.ignores = ignore; + if (item.plugins) { item.plugins['header'] = headerPlugin; } diff --git a/packages/devtools-backend/src/index.ts b/packages/devtools-backend/src/index.ts index e758937..18d0247 100644 --- a/packages/devtools-backend/src/index.ts +++ b/packages/devtools-backend/src/index.ts @@ -7,3 +7,5 @@ */ export * from './serialize'; +export * from './search'; +export * from './ui'; diff --git a/packages/devtools-backend/src/search/README.md b/packages/devtools-backend/src/search/README.md new file mode 100644 index 0000000..e8a13d6 --- /dev/null +++ b/packages/devtools-backend/src/search/README.md @@ -0,0 +1,5 @@ +# Search + +These modules provide search API: + +- find component node diff --git a/packages/devtools-backend/src/search/find-component-node.ts b/packages/devtools-backend/src/search/find-component-node.ts new file mode 100644 index 0000000..a9b2fc4 --- /dev/null +++ b/packages/devtools-backend/src/search/find-component-node.ts @@ -0,0 +1,28 @@ +/*! + * V4Fire DevTools + * https://github.com/V4Fire/DevTools + * + * Released under the MIT license + * https://github.com/V4Fire/DevTools/blob/main/LICENSE + */ + +/** + * Find component DOM node + * + * @param id - component id + * @param name - component name + */ +export default function findComponentNode(id: string, name?: string): T | null { + let node = Array.prototype.find.call( + document.querySelectorAll(`.i-block-helper.${id}`), + (node) => node.component?.componentId === id + ); + + if (node == null && name != null) { + // Maybe it's a functional component + const nodes = document.querySelectorAll(`.i-block-helper.${name}`); + node = Array.prototype.find.call(nodes, (node) => node.component?.componentId === id); + } + + return node; +} diff --git a/packages/devtools-backend/src/search/index.ts b/packages/devtools-backend/src/search/index.ts new file mode 100644 index 0000000..fa1d3e5 --- /dev/null +++ b/packages/devtools-backend/src/search/index.ts @@ -0,0 +1,8 @@ +/*! + * V4Fire DevTools + * https://github.com/V4Fire/DevTools + * + * Released under the MIT license + * https://github.com/V4Fire/DevTools/blob/main/LICENSE + */ +export { default as findComponentNode } from './find-component-node'; diff --git a/packages/devtools-backend/src/serialize/index.ts b/packages/devtools-backend/src/serialize/index.ts index d89b2dc..76f7653 100644 --- a/packages/devtools-backend/src/serialize/index.ts +++ b/packages/devtools-backend/src/serialize/index.ts @@ -37,7 +37,7 @@ export function getType(value: T): string { */ export function serialize( value: T, - isRestrictedKey?: (key: string) => boolean + isRestrictedKey?: (key: string, value?: unknown) => boolean ): string { let refCounter = 1; @@ -49,7 +49,7 @@ export function serialize( return JSON.stringify(value, expandedStringify); function expandedStringify(this: any, key: string, value: unknown): unknown { - if (isRestrictedKey?.(key)) { + if (isRestrictedKey?.(key, value)) { return '[Restricted]'; } diff --git a/packages/devtools-backend/src/ui/README.md b/packages/devtools-backend/src/ui/README.md new file mode 100644 index 0000000..fc127cc --- /dev/null +++ b/packages/devtools-backend/src/ui/README.md @@ -0,0 +1,5 @@ +# UI + +These modules provide API to modify the UI of inspected window: + +- componentHighlight - displays an overlay over the component diff --git a/packages/devtools-backend/src/ui/component-highlight.ts b/packages/devtools-backend/src/ui/component-highlight.ts new file mode 100644 index 0000000..039ac7e --- /dev/null +++ b/packages/devtools-backend/src/ui/component-highlight.ts @@ -0,0 +1,118 @@ +/*! + * V4Fire DevTools + * https://github.com/V4Fire/DevTools + * + * Released under the MIT license + * https://github.com/V4Fire/DevTools/blob/main/LICENSE + */ + +import findComponentNode from '../search/find-component-node'; + +interface HideOptions { + animate?: boolean; + delay?: number; +} + +const + highlightNodeId = 'v4fire-devtools-highlight', + highlightAnimationDuration = 300; + +class ComponentHighlight { + /** + * Highlight animation timeout + */ + #animationTimeout: any; + + /** + * Show highlight for the component + * + * @param componentId + * @param componentName + */ + show(componentId: string, componentName: string): void { + const node = findComponentNode(componentId, componentName); + + if (node == null) { + return; + } + + clearTimeout(this.#animationTimeout); + + const highlightNode = getOrCreateHighlightNode(); + + const {width, height, top, left} = node.getBoundingClientRect(); + highlightNode.style.width = `${width}px`; + highlightNode.style.height = `${height}px`; + highlightNode.style.top = `${(globalThis.scrollY + top)}px`; + highlightNode.style.left = `${left}px`; + highlightNode.style.opacity = '1'; + highlightNode.style.display = 'block'; + } + + /** + * Hide component's highlight + * @param animateOrOptions + */ + hide(animateOrOptions?: boolean | HideOptions): void { + let animate = false; + let delay: number | null = null; + + if (typeof animateOrOptions === 'boolean') { + animate = animateOrOptions; + + } else if (typeof animateOrOptions === 'object') { + ({animate = false, delay = null} = animateOrOptions); + } + + const node = document.getElementById(highlightNodeId); + + if (node == null) { + return; + } + + if (animate) { + clearTimeout(this.#animationTimeout); + + const end = () => { + this.#animationTimeout = setTimeout(() => { + node.style.display = 'none'; + }, highlightAnimationDuration); + }; + + const start = () => { + node.style.opacity = '0'; + + end(); + }; + + if (delay != null) { + this.#animationTimeout = setTimeout(start, delay); + } else { + start(); + } + + } else { + node.style.display = 'none'; + } + } +} + +export const componentHighlight = new ComponentHighlight(); + +function getOrCreateHighlightNode(): HTMLElement { + let highlightNode = document.getElementById(highlightNodeId); + + if (highlightNode == null) { + highlightNode = document.createElement('div'); + highlightNode.id = highlightNodeId; + highlightNode.style.position = 'absolute'; + highlightNode.style.display = 'none'; + highlightNode.style.backgroundColor = 'rgba(250, 0, 250, 0.3)'; + highlightNode.style.zIndex = '9999'; + highlightNode.style.transition = `opacity ${highlightAnimationDuration}ms ease`; + highlightNode.style.pointerEvents = 'none'; + document.body.appendChild(highlightNode); + } + + return highlightNode; +} diff --git a/packages/devtools-backend/src/ui/component-locate.ts b/packages/devtools-backend/src/ui/component-locate.ts new file mode 100644 index 0000000..21d4ed2 --- /dev/null +++ b/packages/devtools-backend/src/ui/component-locate.ts @@ -0,0 +1,99 @@ +/*! + * V4Fire DevTools + * https://github.com/V4Fire/DevTools + * + * Released under the MIT license + * https://github.com/V4Fire/DevTools/blob/main/LICENSE + */ + +import { componentHighlight } from './component-highlight'; + +interface ComponentInterface { + componentId: string; + componentName: string; +} + +class ComponentLocate { + #mouseoverListener: ((e: MouseEvent) => void) | null = null; + + #clickListener: ((e: MouseEvent) => void) | null = null; + + enable() { + this.disable(); + + let component: ComponentInterface | null = null; + + this.#mouseoverListener = (e: MouseEvent) => { + component = e.target instanceof Element ? findComponent(e.target) : null; + + if (component != null) { + const {componentId, componentName} = component; + componentHighlight.show(componentId, componentName); + + } else { + componentHighlight.hide(); + } + }; + + this.#clickListener = (e) => { + e.preventDefault(); + e.stopPropagation(); + + this.disable(); + + if (component != null) { + componentHighlight.hide({animate: true}); + + const {componentId, componentName} = component; + + // TODO: create bridge + globalThis.postMessage({ + source: 'v4fire-devtools-bridge', + payload: { + event: 'select-component', + payload: {componentId, componentName} + } + }, '*'); + } + }; + + document.addEventListener('mouseover', this.#mouseoverListener); + document.addEventListener('click', this.#clickListener, {capture: true}); + } + + disable() { + if (this.#mouseoverListener != null) { + document.removeEventListener('mouseover', this.#mouseoverListener); + this.#mouseoverListener = null; + } + + if (this.#clickListener != null) { + document.removeEventListener('click', this.#clickListener, {capture: true}); + this.#clickListener = null; + } + } +} + +export const componentLocate = new ComponentLocate(); + +function findComponent(target: Element | null): ComponentInterface | null { + let component: ComponentInterface | null = null; + + while (component == null && target != null) { + const node: Element & {component?: ComponentInterface} | null = target.closest('.i-block-helper'); + + if (node == null) { + break; + } + + if (node.component != null) { + ({component} = node); + + } else { + // If `component` property is missing - search higher in hierarchy + target = node.parentNode; + } + } + + return component; +} diff --git a/packages/devtools-backend/src/ui/index.ts b/packages/devtools-backend/src/ui/index.ts new file mode 100644 index 0000000..f0f697e --- /dev/null +++ b/packages/devtools-backend/src/ui/index.ts @@ -0,0 +1,9 @@ +/*! + * V4Fire DevTools + * https://github.com/V4Fire/DevTools + * + * Released under the MIT license + * https://github.com/V4Fire/DevTools/blob/main/LICENSE + */ +export { componentHighlight } from './component-highlight'; +export { componentLocate } from './component-locate'; diff --git a/packages/devtools-core/src/assets/svg/circle-dashed.svg b/packages/devtools-core/src/assets/svg/circle-dashed.svg new file mode 100644 index 0000000..0a07b66 --- /dev/null +++ b/packages/devtools-core/src/assets/svg/circle-dashed.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/devtools-core/src/assets/svg/circle.svg b/packages/devtools-core/src/assets/svg/circle.svg new file mode 100644 index 0000000..64a57b4 --- /dev/null +++ b/packages/devtools-core/src/assets/svg/circle.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/devtools-core/src/assets/svg/crosshair.svg b/packages/devtools-core/src/assets/svg/crosshair.svg new file mode 100644 index 0000000..011c425 --- /dev/null +++ b/packages/devtools-core/src/assets/svg/crosshair.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/devtools-core/src/assets/svg/inspect.svg b/packages/devtools-core/src/assets/svg/inspect.svg new file mode 100644 index 0000000..dded026 --- /dev/null +++ b/packages/devtools-core/src/assets/svg/inspect.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/devtools-core/src/assets/svg/reload.svg b/packages/devtools-core/src/assets/svg/reload.svg new file mode 100644 index 0000000..04c3a9e --- /dev/null +++ b/packages/devtools-core/src/assets/svg/reload.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/devtools-core/src/components/base/b-tree/b-tree_theme_pretty.styl b/packages/devtools-core/src/components/base/b-tree/b-tree_theme_pretty.styl new file mode 100644 index 0000000..4ee0194 --- /dev/null +++ b/packages/devtools-core/src/components/base/b-tree/b-tree_theme_pretty.styl @@ -0,0 +1,26 @@ +b-tree_theme_pretty extends b-tree + /theme &__item-wrapper + display flex + + /theme &__marker + width 16px + + /theme &__fold + text-align center + + /theme &__fold:before + content "" + display inline-block + + border-top 4px solid transparent + border-bottom 4px solid transparent + border-left 6px solid rgba(100, 100, 100, 0.5) + + transition transform 0.1s ease + transform rotate(90deg) + + /theme &__node_folded_true &__fold:before + transform rotate(0) + + /theme &__node_folded_true &__children + display none diff --git a/packages/devtools-core/src/components/widgets/b-header/b-header.ss b/packages/devtools-core/src/components/widgets/b-header/b-header.ss index 1931396..a252793 100644 --- a/packages/devtools-core/src/components/widgets/b-header/b-header.ss +++ b/packages/devtools-core/src/components/widgets/b-header/b-header.ss @@ -4,6 +4,16 @@ - template index() extends ['i-block'].index - block body + < .&__actions + < b-icon-button & + :icon = 'reload' | + @click = onReload | + :hint = 'Reload tree' | + :hintPos = 'bottom-right' + . + + < b-components-actions v-if = r.activePage === 'components' + < .&__tabs < b-button.&__tab @click = r.router.push('components') Components diff --git a/packages/devtools-core/src/components/widgets/b-header/b-header.styl b/packages/devtools-core/src/components/widgets/b-header/b-header.styl index cf7dfd6..c15d2f1 100644 --- a/packages/devtools-core/src/components/widgets/b-header/b-header.styl +++ b/packages/devtools-core/src/components/widgets/b-header/b-header.styl @@ -1,8 +1,15 @@ @import "components/super/i-block/i-block.styl" b-header extends i-block + flex-row flex-start center + line-height 1 border-bottom 1px solid black + &__actions + flex-row flex-start center + gap 8px + padding 8px + &__tabs flex-row flex-start diff --git a/packages/devtools-core/src/components/widgets/b-header/b-header.ts b/packages/devtools-core/src/components/widgets/b-header/b-header.ts index ba6d648..ae6c9d9 100644 --- a/packages/devtools-core/src/components/widgets/b-header/b-header.ts +++ b/packages/devtools-core/src/components/widgets/b-header/b-header.ts @@ -16,4 +16,10 @@ export * from 'components/super/i-block/i-block'; @component() export default class bHeader extends iBlock { + /** + * Reloads the window + */ + onReload(): void { + globalThis.location.reload(); + } } diff --git a/packages/devtools-core/src/components/widgets/b-header/index.js b/packages/devtools-core/src/components/widgets/b-header/index.js index 011fe59..a7ed14e 100644 --- a/packages/devtools-core/src/components/widgets/b-header/index.js +++ b/packages/devtools-core/src/components/widgets/b-header/index.js @@ -11,5 +11,7 @@ package('b-header') .extends('i-block') .dependencies( - 'b-button' + 'b-button', + 'b-icon-button', + 'b-components-actions' ) diff --git a/packages/devtools-core/src/features/components/b-components-actions/b-components-actions.ss b/packages/devtools-core/src/features/components/b-components-actions/b-components-actions.ss new file mode 100644 index 0000000..c60d5ac --- /dev/null +++ b/packages/devtools-core/src/features/components/b-components-actions/b-components-actions.ss @@ -0,0 +1,23 @@ +- namespace [%fileName%] + +- include 'components/super/i-block'|b as placeholder + +- template index() extends ['i-block'].index + - block body + < b-icon-button & + :icon = 'crosshair' | + @click = enableLocateComponent | + :hint = 'Select component in the page' | + :hintPos = 'bottom-right' + . + + < b-window & + ref = modal | + @close = disableLocateComponent + . + < template #body + < b.&__modal-body + Click on a component on the page to select it + < template #controls + < b-button @click = disableLocateComponent + Cancel diff --git a/packages/devtools-core/src/features/components/b-components-actions/b-components-actions.styl b/packages/devtools-core/src/features/components/b-components-actions/b-components-actions.styl new file mode 100644 index 0000000..3d6fc20 --- /dev/null +++ b/packages/devtools-core/src/features/components/b-components-actions/b-components-actions.styl @@ -0,0 +1,9 @@ +@import "components/super/i-block/i-block.styl" + +b-components-actions extends i-block + display flex + line-height 1 + + &__modal-body + display block + padding 16px diff --git a/packages/devtools-core/src/features/components/b-components-actions/b-components-actions.ts b/packages/devtools-core/src/features/components/b-components-actions/b-components-actions.ts new file mode 100644 index 0000000..f14299f --- /dev/null +++ b/packages/devtools-core/src/features/components/b-components-actions/b-components-actions.ts @@ -0,0 +1,36 @@ +/*! + * V4Fire DevTools + * https://github.com/V4Fire/DevTools + * + * Released under the MIT license + * https://github.com/V4Fire/DevTools/blob/main/LICENSE + */ + +import iBlock, { component, watch } from 'components/super/i-block/i-block'; +import type bWindow from 'components/base/b-window/b-window'; + +@component() +export default class bComponentsActions extends iBlock { + override readonly $refs!: iBlock['$refs'] & { + modal?: bWindow; + }; + + /** + * Enables component search via DOM + */ + enableLocateComponent(): void { + this.$refs.modal?.open().catch(stderr); + } + + /** + * Disables component search via DOM + */ + disableLocateComponent(): void { + this.$refs.modal?.close().catch(stderr); + } + + @watch('rootEmitter:bridge.select-component') + protected closeModal(): void { + this.$refs.modal?.close().catch(stderr); + } +} diff --git a/packages/devtools-core/src/features/components/b-components-actions/index.js b/packages/devtools-core/src/features/components/b-components-actions/index.js new file mode 100644 index 0000000..c450c52 --- /dev/null +++ b/packages/devtools-core/src/features/components/b-components-actions/index.js @@ -0,0 +1,17 @@ +/*! + * V4Fire DevTools + * https://github.com/V4Fire/DevTools + * + * Released under the MIT license + * https://github.com/V4Fire/DevTools/blob/main/LICENSE + */ + +'use strict'; + +package('b-components-actions') + .extends('i-block') + .dependencies( + 'b-icon-button', + 'b-button', + 'b-window' + ); diff --git a/packages/devtools-core/src/features/components/b-components-panel/b-components-panel.ss b/packages/devtools-core/src/features/components/b-components-panel/b-components-panel.ss index 47868f9..07e017e 100644 --- a/packages/devtools-core/src/features/components/b-components-panel/b-components-panel.ss +++ b/packages/devtools-core/src/features/components/b-components-panel/b-components-panel.ss @@ -8,17 +8,28 @@ < b {{ componentData.componentName.camelize(false) }} - < b-checkbox & - :label = (showEmpty ? "hide" : "show") + " empty" | - :checked = showEmpty | - @change = showEmptyChange - . + < .&__header-actions + < b-icon-button & + :hint = "Inspect DOM" | + :hintPos = 'bottom-left' | + :icon = 'inspect' | + @click = onInspect + . + + < b-icon-button & + :hint = (showEmpty ? "Hide" : "Show") + " empty" | + :hintPos = 'bottom-left' | + :icon = (showEmpty ? 'circle' : 'circle-dashed') | + @click = onShowEmptyChange + . < .&__body < b-tree & + ref = tree | :items = items | :item = 'b-components-panel-item' | - :theme = 'demo' | + :theme = 'pretty' | :cancelable = true | - :lazyRender = true + :lazyRender = true | + :renderFilter = createTreeRenderFilter() . diff --git a/packages/devtools-core/src/features/components/b-components-panel/b-components-panel.styl b/packages/devtools-core/src/features/components/b-components-panel/b-components-panel.styl index 661ac7b..5ccd219 100644 --- a/packages/devtools-core/src/features/components/b-components-panel/b-components-panel.styl +++ b/packages/devtools-core/src/features/components/b-components-panel/b-components-panel.styl @@ -4,11 +4,19 @@ b-components-panel extends i-block &__header display flex justify-content space-between + align-items center padding 8px border-bottom solid 1px black + &-actions + display flex + gap 4px + line-height 1 + &__body - height 88vh overflow auto - padding 8px + padding 4px 0 word-break break-word + + .b-tree__node_level_0 + padding 4px 8px 8px 4px diff --git a/packages/devtools-core/src/features/components/b-components-panel/b-components-panel.ts b/packages/devtools-core/src/features/components/b-components-panel/b-components-panel.ts index 190529a..b7194b7 100644 --- a/packages/devtools-core/src/features/components/b-components-panel/b-components-panel.ts +++ b/packages/devtools-core/src/features/components/b-components-panel/b-components-panel.ts @@ -6,16 +6,24 @@ * https://github.com/V4Fire/DevTools/blob/main/LICENSE */ +import symbolGenerator from 'core/symbol'; import iBlock, { component, prop, field, computed } from 'components/super/i-block/i-block'; +import type bTree from 'components/base/b-tree/b-tree'; +import type { RenderFilter } from 'components/base/b-tree/b-tree'; import type { Item, ComponentData } from 'features/components/b-components-panel/interface'; import { createItems } from 'features/components/b-components-panel/modules/helpers'; export * from 'features/components/b-components-panel/interface'; +const $$ = symbolGenerator(); + @component() export default class bComponentsPanel extends iBlock { + override readonly $refs!: iBlock['$refs'] & { + tree?: bTree; + }; /** * Component's data @@ -61,13 +69,59 @@ export default class bComponentsPanel extends iBlock { } } + /** + * Returns render filter for `bTree`, which delays rendering of children + * for folded items + */ + createTreeRenderFilter(): RenderFilter { + const unfolded = new Set(); + const resolvers = new Map void>(); + + this.waitRef('tree') + .then((tree) => this.async.on( + tree.unsafe.top.selfEmitter, + 'fold', + (_ctx: unknown, _el: Element, item: Item, folded: boolean) => { + if (folded) { + unfolded.delete(item.value); + + } else { + unfolded.add(item.value); + + resolvers.get(item.value)?.(true); + resolvers.delete(item.value); + } + }, + {label: $$.foldChange} + )) + .catch(stderr); + + return (ctx, el, i) => { + if (ctx.level === 0 && i < ctx.renderChunks) { + return true; + } + + if (!ctx.folded || unfolded.has(el.parentValue)) { + return true; + } + + return new Promise((resolve) => { + resolvers.set(el.parentValue, resolve); + }); + }; + } + /** * Update show empty - * - * @param _ - * @param checked */ - protected showEmptyChange(_: unknown, checked?: boolean): void { - this.showEmpty = Boolean(checked); + protected onShowEmptyChange(): void { + this.showEmpty = !this.showEmpty; + } + + /** + * Inspect component's node + */ + protected onInspect(): void { + // TODO: use inspected app } } diff --git a/packages/devtools-core/src/features/components/b-components-panel/interface.ts b/packages/devtools-core/src/features/components/b-components-panel/interface.ts index 57f5069..b85939f 100644 --- a/packages/devtools-core/src/features/components/b-components-panel/interface.ts +++ b/packages/devtools-core/src/features/components/b-components-panel/interface.ts @@ -17,6 +17,11 @@ export interface Item extends Super { } export type ComponentData = { + /** + * Component's id + */ + componentId: string; + /** * Component's values for props, fields, etc. */ diff --git a/packages/devtools-core/src/features/components/b-components-tree/b-components-tree.ss b/packages/devtools-core/src/features/components/b-components-tree/b-components-tree.ss index 320e09c..132f14a 100644 --- a/packages/devtools-core/src/features/components/b-components-tree/b-components-tree.ss +++ b/packages/devtools-core/src/features/components/b-components-tree/b-components-tree.ss @@ -22,7 +22,7 @@ :item = 'b-components-tree-item' | :itemProps = itemProps | :folded = false | - :theme = 'demo' | + :theme = 'pretty' | :cancelable = false . diff --git a/packages/devtools-core/src/features/components/b-components-tree/b-components-tree.styl b/packages/devtools-core/src/features/components/b-components-tree/b-components-tree.styl index 0a142eb..4682e8b 100644 --- a/packages/devtools-core/src/features/components/b-components-tree/b-components-tree.styl +++ b/packages/devtools-core/src/features/components/b-components-tree/b-components-tree.styl @@ -3,10 +3,12 @@ b-components-tree extends i-block &__wrapper position relative - // FIXME fill remaining height - height 88vh overflow auto + .b-tree__node_active_true + > .b-tree__item-wrapper + background-color rgba(100, 100, 100, 0.1) + &__input width 100% padding 8px 16px diff --git a/packages/devtools-core/src/features/components/b-components-tree/b-components-tree.ts b/packages/devtools-core/src/features/components/b-components-tree/b-components-tree.ts index a0c2c81..7529764 100644 --- a/packages/devtools-core/src/features/components/b-components-tree/b-components-tree.ts +++ b/packages/devtools-core/src/features/components/b-components-tree/b-components-tree.ts @@ -78,14 +78,7 @@ class bComponentsTree extends iBlock implements iSearch { tree.setActive(value); } - // It's ugly but we need to scroll to this element - const el = tree.unsafe.findItemElement(value); - - if (el != null) { - const {clientHeight = 0} = el.querySelector(`.${tree.unsafe.block!.getFullElementName('item-wrapper')}`) ?? {}; - - this.search.scrollContainerToElement(wrapper, el, clientHeight); - } + this.scrollToItem(value); } /** @@ -98,6 +91,9 @@ class bComponentsTree extends iBlock implements iSearch { ['children', 'folded', 'componentName', 'parentValue'] ); + props['@mouseenter'] = this.onItemMouseEnter.bind(this, item); + props['@mouseleave'] = this.onItemMouseLeave.bind(this, item); + return props; } @@ -142,6 +138,23 @@ class bComponentsTree extends iBlock implements iSearch { } } + /** + * Find item by it's value and scroll to it + * @param value + */ + protected scrollToItem(value: string): void { + const {tree, wrapper} = this.$refs; + + // It's ugly but we need to scroll to this element + const el = tree!.unsafe.findItemElement(value); + + if (el != null) { + const {clientHeight = 0} = el.querySelector(`.${tree!.unsafe.block!.getFullElementName('item-wrapper')}`) ?? {}; + + this.search.scrollContainerToElement(wrapper!, el, clientHeight); + } + } + /** * * @param _ @@ -151,6 +164,41 @@ class bComponentsTree extends iBlock implements iSearch { protected onTreeChange(_: unknown, componentId: string): void { this.emit('change', componentId); } + + /** + * Watch for `select-component` event from root + * + * @param _ + * @param payload + */ + // FIXME: watch r.bridge:select-component + @watch('rootEmitter:bridge.select-component') + protected onSelectComponent(_: unknown, payload: any): void { + const {componentId} = payload; + if (this.$refs.tree?.setActive(componentId)) { + this.scrollToItem(componentId); + } + } + + /** + * Handle item mouseenter event + * + * @param item + * @param event + */ + protected onItemMouseEnter(item: Item, event: MouseEvent): void { + // TODO: use engine to highlight the component node + } + + /** + * Handle item mouseleave event + * + * @param item + * @param event + */ + protected onItemMouseLeave(item: Item, event: MouseEvent): void { + // TODO: use engine to remove highlight from the component node + } } export default bComponentsTree; diff --git a/packages/devtools-core/src/pages/p-components/p-components.styl b/packages/devtools-core/src/pages/p-components/p-components.styl index 5f58f8b..f23ac0b 100644 --- a/packages/devtools-core/src/pages/p-components/p-components.styl +++ b/packages/devtools-core/src/pages/p-components/p-components.styl @@ -5,6 +5,11 @@ p-components extends i-dynamic-page &__content display flex + > * + display flex + flex-direction column + height calc(100vh - var(--header-height, 0)) + > :first-child flex 0 0 60% border-right solid 1px black diff --git a/packages/devtools-core/src/pages/p-components/p-components.ts b/packages/devtools-core/src/pages/p-components/p-components.ts index 94d3f66..dfa6b97 100644 --- a/packages/devtools-core/src/pages/p-components/p-components.ts +++ b/packages/devtools-core/src/pages/p-components/p-components.ts @@ -54,14 +54,14 @@ export default class pComponents extends iDynamicPage { this.selectedComponentId = componentId; this.selectedComponentData = null; - const load = this.async.debounce(async () => { + const load = this.async.throttle(async () => { try { await this.loadSelectedComponentData(); } catch (error) { // TODO: show alert stderr(error); } - }, 300, {label: $$.loadSelectedComponentMeta}); + }, 1000, {label: $$.loadSelectedComponentMeta}); load(); } diff --git a/packages/devtools-core/src/pages/p-root/p-root.ss b/packages/devtools-core/src/pages/p-root/p-root.ss index 012b9a0..9f3d3cb 100644 --- a/packages/devtools-core/src/pages/p-root/p-root.ss +++ b/packages/devtools-core/src/pages/p-root/p-root.ss @@ -17,8 +17,6 @@ {{ placeholder }} < template v-else - block header - < b-header + < b-header ref = header - block page - < b-dynamic-page.&__page & - ref = page - . + < b-dynamic-page.&__page ref = page diff --git a/packages/devtools-core/src/pages/p-root/p-root.ts b/packages/devtools-core/src/pages/p-root/p-root.ts index 8f6a3b8..f65f0d4 100644 --- a/packages/devtools-core/src/pages/p-root/p-root.ts +++ b/packages/devtools-core/src/pages/p-root/p-root.ts @@ -5,10 +5,11 @@ * Released under the MIT license * https://github.com/V4Fire/DevTools/blob/main/LICENSE */ -import iStaticPage, { component, system, field } from 'components/super/i-static-page/i-static-page'; +import iStaticPage, { component, system, field, hook } from 'components/super/i-static-page/i-static-page'; import createRouter from 'core/router/engines/in-memory'; +import type bHeader from 'components/widgets/b-header/b-header'; import type bDynamicPage from 'components/base/b-dynamic-page/b-dynamic-page'; export * from 'components/super/i-static-page/i-static-page'; @@ -16,6 +17,7 @@ export * from 'components/super/i-static-page/i-static-page'; @component({root: true}) export default class pRoot extends iStaticPage { override readonly $refs!: iStaticPage['$refs'] & { + header?: bHeader; page?: bDynamicPage; }; @@ -30,4 +32,14 @@ export default class pRoot extends iStaticPage { */ @system() routerEngine: typeof createRouter = createRouter; + + /** + * Sets `--header-height` css variable + */ + @hook('mounted') + async init(): Promise { + const header = await this.waitRef('header'); + + (this.$el).style.setProperty('--header-height', `${header.$el?.clientHeight ?? 0}px`); + } } diff --git a/packages/devtools-extension/components-lock.json b/packages/devtools-extension/components-lock.json index dd2ce0f..749b10e 100644 --- a/packages/devtools-extension/components-lock.json +++ b/packages/devtools-extension/components-lock.json @@ -1,5 +1,5 @@ { - "hash": "9a9ab7af9b3e01398691a5504d10670718f30c05120c98a22533eaf56fe2daeb", + "hash": "7c0fc3586bfa5c89c9ec9aa033da0ec91fc13c9afba1c020b37f858c5d51936f", "data": { "%data": "%data:Map", "%data:Map": [ @@ -111,6 +111,46 @@ "etpl": null } ], + [ + "b-components-actions", + { + "index": "node_modules/@v4fire/devtools-core/src/features/components/b-components-actions/index.js", + "declaration": { + "name": "b-components-actions", + "parent": "i-block", + "dependencies": [ + "b-icon-button", + "b-button", + "b-window" + ], + "libs": [] + }, + "name": "b-components-actions", + "parent": "i-block", + "dependencies": [ + "b-icon-button", + "b-button", + "b-window" + ], + "libs": [], + "resolvedLibs": { + "%data": "%data:Set", + "%data:Set": [] + }, + "resolvedOwnLibs": { + "%data": "%data:Set", + "%data:Set": [] + }, + "type": "block", + "mixin": false, + "logic": "src/features/components/b-components-actions/b-components-actions.ts", + "styles": [ + "node_modules/@v4fire/devtools-core/src/features/components/b-components-actions/b-components-actions.styl" + ], + "tpl": "node_modules/@v4fire/devtools-core/src/features/components/b-components-actions/b-components-actions.ss", + "etpl": null + } + ], [ "b-components-panel", { @@ -143,7 +183,7 @@ }, "type": "block", "mixin": false, - "logic": "node_modules/@v4fire/devtools-core/src/features/components/b-components-panel/b-components-panel.ts", + "logic": "src/features/components/b-components-panel/b-components-panel.ts", "styles": [ "node_modules/@v4fire/devtools-core/src/features/components/b-components-panel/b-components-panel.styl" ], @@ -213,7 +253,7 @@ }, "type": "block", "mixin": false, - "logic": "node_modules/@v4fire/devtools-core/src/features/components/b-components-tree/b-components-tree.ts", + "logic": "src/features/components/b-components-tree/b-components-tree.ts", "styles": [ "node_modules/@v4fire/devtools-core/src/features/components/b-components-tree/b-components-tree.styl" ], @@ -749,14 +789,18 @@ "name": "b-header", "parent": "i-block", "dependencies": [ - "b-button" + "b-button", + "b-icon-button", + "b-components-actions" ], "libs": [] }, "name": "b-header", "parent": "i-block", "dependencies": [ - "b-button" + "b-button", + "b-icon-button", + "b-components-actions" ], "libs": [], "resolvedLibs": { @@ -2652,7 +2696,8 @@ "components/traits/i-control-list/i-control-list", "core/browser", "core/cookies", - "core/html" + "core/html", + "models/modules/session" ] }, "name": "p-v4-components-demo", @@ -2693,7 +2738,8 @@ "components/traits/i-control-list/i-control-list", "core/browser", "core/cookies", - "core/html" + "core/html", + "models/modules/session" ], "resolvedLibs": { "%data": "%data:Set", @@ -2707,7 +2753,8 @@ "core/cookies", "core/html", "core/router/engines/browser-history", - "core/router/engines/in-memory" + "core/router/engines/in-memory", + "models/modules/session" ] }, "resolvedOwnLibs": { @@ -2722,7 +2769,8 @@ "core/cookies", "core/html", "core/router/engines/browser-history", - "core/router/engines/in-memory" + "core/router/engines/in-memory", + "models/modules/session" ] }, "type": "page", diff --git a/packages/devtools-extension/src/features/components/b-components-actions/b-components-actions.ts b/packages/devtools-extension/src/features/components/b-components-actions/b-components-actions.ts new file mode 100644 index 0000000..0f5da54 --- /dev/null +++ b/packages/devtools-extension/src/features/components/b-components-actions/b-components-actions.ts @@ -0,0 +1,34 @@ +/*! + * V4Fire DevTools + * https://github.com/V4Fire/DevTools + * + * Released under the MIT license + * https://github.com/V4Fire/DevTools/blob/main/LICENSE + */ + +import { component } from 'components/super/i-block/i-block'; +import Super from '@super/features/components/b-components-actions/b-components-actions'; + +import { devtoolsEval } from 'core/browser-api'; + +@component() +export default class bComponentsActions extends Super { + override enableLocateComponent(): void { + super.enableLocateComponent(); + + // TODO: use InspectedApp interface + devtoolsEval(() => globalThis.__V4FIRE_DEVTOOLS_BACKEND__.componentLocate.enable()) + .catch(stderr); + } + + override disableLocateComponent(): void { + super.disableLocateComponent(); + + // TODO: use InspectedApp interface + devtoolsEval(() => { + globalThis.__V4FIRE_DEVTOOLS_BACKEND__.componentHighlight.hide(); + globalThis.__V4FIRE_DEVTOOLS_BACKEND__.componentLocate.disable(); + }) + .catch(stderr); + } +} diff --git a/packages/devtools-extension/src/features/components/b-components-panel/b-components-panel.ts b/packages/devtools-extension/src/features/components/b-components-panel/b-components-panel.ts new file mode 100644 index 0000000..f3b0421 --- /dev/null +++ b/packages/devtools-extension/src/features/components/b-components-panel/b-components-panel.ts @@ -0,0 +1,43 @@ +/*! + * V4Fire DevTools + * https://github.com/V4Fire/DevTools + * + * Released under the MIT license + * https://github.com/V4Fire/DevTools/blob/main/LICENSE + */ +import { devtoolsEval } from 'core/browser-api'; + +import { component } from 'components/super/i-block/i-block'; + +import Super from '@super/features/components/b-components-panel/b-components-panel'; + +@component() +export default class bComponentsPanel extends Super { + protected override onInspect(): void { + const {componentId, componentName} = this.componentData; + + devtoolsEval(evalInspect, [componentId, componentName]) + .catch(stderr); + } +} + +function evalInspect(componentId: string, componentName: string): void { + // eslint-disable-next-line @typescript-eslint/method-signature-style + const {inspect} = <{inspect?: (el: Element) => void} & Global>globalThis; + + if (typeof inspect !== 'function') { + // eslint-disable-next-line no-alert + alert('Browser doesn\'t provide inspect util'); + return; + } + + const node = globalThis.__V4FIRE_DEVTOOLS_BACKEND__.findComponentNode(componentId, componentName); + + if (node != null) { + inspect(node); + + } else { + // eslint-disable-next-line no-alert + alert('Component\'s node not found'); + } +} diff --git a/packages/devtools-extension/src/features/components/b-components-tree/b-components-tree.ts b/packages/devtools-extension/src/features/components/b-components-tree/b-components-tree.ts new file mode 100644 index 0000000..e2cf257 --- /dev/null +++ b/packages/devtools-extension/src/features/components/b-components-tree/b-components-tree.ts @@ -0,0 +1,78 @@ +/*! + * V4Fire DevTools + * https://github.com/V4Fire/DevTools + * + * Released under the MIT license + * https://github.com/V4Fire/DevTools/blob/main/LICENSE + */ + +import { devtoolsEval } from 'core/browser-api'; + +import { component, hook, system } from 'components/super/i-block/i-block'; + +import Super, { Item } from '@super/features/components/b-components-tree/b-components-tree'; + +export * from '@super/features/components/b-components-tree/b-components-tree'; + +@component() +export default class bComponentsTree extends Super { + + /** + * Id of highlighted component + */ + @system() + highlightedComponentId: string | null = null; + + @hook('mounted') + init(): void { + this.async.on(this.selfEmitter, 'change', (_: unknown, componentId: string) => { + const item = >this.$refs.tree?.getItemByValue(componentId); + + if (item != null) { + devtoolsEval( + evalHighlightActive, + [this.highlightedComponentId !== componentId, componentId, item.componentName] + ) + .catch(stderr); + } + }); + } + + /** + * Show highlight for the component on mouseenter + * + * @param item + */ + protected override onItemMouseEnter(item: Item): void { + this.highlightedComponentId = item.value; + devtoolsEval(evalShowComponentHighlight, [item.value, item.componentName]).catch(stderr); + } + + /** + * Hide highlight for the component + */ + protected override onItemMouseLeave(): void { + devtoolsEval(evalHideComponentHighlight).catch(stderr); + } +} + +function evalHighlightActive(autoHide: boolean, ...args: [string, string]): void { + const backend = globalThis.__V4FIRE_DEVTOOLS_BACKEND__; + const node = backend.findComponentNode(...args); + // @ts-expect-error Non-standard API + node?.scrollIntoViewIfNeeded(false); + + backend.componentHighlight.show(...args); + + if (autoHide) { + backend.componentHighlight.hide({delay: 1500, animate: true}); + } +} + +function evalShowComponentHighlight(...args: [string, string]): void { + globalThis.__V4FIRE_DEVTOOLS_BACKEND__.componentHighlight.show(...args); +} + +function evalHideComponentHighlight(): void { + globalThis.__V4FIRE_DEVTOOLS_BACKEND__.componentHighlight.hide(); +} diff --git a/packages/devtools-extension/src/pages/p-components/p-components.ts b/packages/devtools-extension/src/pages/p-components/p-components.ts index b94029f..ec50f7a 100644 --- a/packages/devtools-extension/src/pages/p-components/p-components.ts +++ b/packages/devtools-extension/src/pages/p-components/p-components.ts @@ -109,7 +109,14 @@ function evalComponentsTree(): Item[] { if (parentId != null) { if (!map.has(parentId)) { buffer.push(() => { - map.get(parentId).children.push(descriptor); + const item = map.get(parentId); + + if (item != null) { + item.children.push(descriptor); + + } else { + stderr(`Missing parent, component: ${component.componentName}, parent id: ${parentId}`); + } }); } else { @@ -131,20 +138,11 @@ function evalComponentMeta(value: string, name?: string): Nullable { 'r', 'self', 'unsafe', - 'window', - 'document', - 'console', 'router', 'LANG_PACKS' ]); - let node = document.querySelector(`.i-block-helper.${value}`); - - if (node == null && name != null) { - // Maybe it's a functional component - const nodes = document.querySelectorAll(`.i-block-helper.${name}`); - node = Array.prototype.find.call(nodes, (node) => node.component?.componentId === value); - } + const node = globalThis.__V4FIRE_DEVTOOLS_BACKEND__.findComponentNode(value, name); if (node == null) { return null; @@ -176,7 +174,10 @@ function evalComponentMeta(value: string, name?: string): Nullable { parent = parent.parentMeta; } - const result = {componentName, props, fields, computedFields, systemFields, hierarchy, values}; + const result = {componentId: value, componentName, props, fields, computedFields, systemFields, hierarchy, values}; - return globalThis.__V4FIRE_DEVTOOLS_BACKEND__.serialize(result, (key) => key.startsWith('$') || restricted.has(key)); + return globalThis.__V4FIRE_DEVTOOLS_BACKEND__.serialize( + result, + (key, value) => key.startsWith('$') || restricted.has(key) || value === globalThis || value === document || value === console + ); } diff --git a/packages/devtools-extension/src/pages/p-devtools/init.ts b/packages/devtools-extension/src/pages/p-devtools/init.ts index b1253ee..439fdcf 100644 --- a/packages/devtools-extension/src/pages/p-devtools/init.ts +++ b/packages/devtools-extension/src/pages/p-devtools/init.ts @@ -7,11 +7,13 @@ */ import Async from '@v4fire/core/core/async'; -import { browserAPI } from 'core/browser-api'; +import { browserAPI, devtoolsEval } from 'core/browser-api'; import type pRoot from 'pages/p-root/p-root'; import { CouldNotFindV4FireOnThePageError, detectV4Fire } from 'pages/p-devtools/modules/detect-v4fire'; +// TODO: refactor + const $a = new Async(); /** @@ -69,11 +71,18 @@ function mountDevToolsWhenV4FireHasLoaded() { ); // Inject backend only when the v4fire is mounted - injectBackend(browserAPI.devtools.inspectedWindow.tabId); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (browserAPI.devtools.inspectedWindow.tabId != null) { + injectBackend(browserAPI.devtools.inspectedWindow.tabId); + } if (shouldUpdateRoot) { - setRootPlaceholder(null); - shouldUpdateRoot = false; + devtoolsEval(() => new Promise((resolve) => globalThis.requestIdleCallback(resolve))) + .then(() => { + setRootPlaceholder(null); + shouldUpdateRoot = false; + }) + .catch(stderr); } }; @@ -128,6 +137,9 @@ function connectDevToolsPort() { name: String(tabId) }); + // TODO: create bridge + port.onMessage.addListener(listenDevtoolsMessage); + // This port may be disconnected by Chrome at some point, this callback // will be executed only if this port was disconnected from the other end // so, when we call `port.disconnect()` from this script, @@ -147,6 +159,7 @@ function performFullCleanup() { root = null; try { + port?.onMessage.removeListener(listenDevtoolsMessage); port?.disconnect(); } catch (error) { // eslint-disable-next-line no-console @@ -167,3 +180,19 @@ function injectBackend(tabId: number): void { .catch(stderr); } +function listenDevtoolsMessage(message: {event: string; payload: any}): void { + if (root == null) { + return; + } + + const {component} = (<{component: pRoot} & Element>root); + + switch (message.event) { + case 'select-component': + component.selfEmitter.emit(`bridge.${message.event}`, message.payload); + break; + + default: + // Do nothing + } +} diff --git a/packages/devtools-extension/src/pages/p-devtools/modules/detect-v4fire/error.ts b/packages/devtools-extension/src/pages/p-devtools/modules/detect-v4fire/error.ts index 28b58b7..2a0a441 100644 --- a/packages/devtools-extension/src/pages/p-devtools/modules/detect-v4fire/error.ts +++ b/packages/devtools-extension/src/pages/p-devtools/modules/detect-v4fire/error.ts @@ -14,6 +14,6 @@ export default class CouldNotFindV4FireOnThePageError extends Error { Error.captureStackTrace(this, CouldNotFindV4FireOnThePageError); } - this.name = 'CouldNotFindReactOnThePageError'; + this.name = 'CouldNotFindV4FireOnThePageError'; } } diff --git a/packages/devtools-extension/src/scripts/proxy.ts b/packages/devtools-extension/src/scripts/proxy.ts index f7df6e6..eed22b9 100644 --- a/packages/devtools-extension/src/scripts/proxy.ts +++ b/packages/devtools-extension/src/scripts/proxy.ts @@ -10,8 +10,14 @@ // @see: https://chromium.googlesource.com/chromium/src/+/master/third_party/blink/renderer/bindings/core/v8/V8BindingDesign.md#world // It can forward messages from content scripts running in MAIN world to the extension's service worker. +let port: chrome.runtime.Port | null = null; + globalThis.addEventListener('pageshow', () => { globalThis.addEventListener('message', handleMessageFromPage); + + if (port == null) { + connectPort(); + } }); globalThis.addEventListener('pagehide', () => { @@ -25,6 +31,12 @@ function handleMessageFromPage(event: MessageEvent) { } switch (event.data.source) { + // This is a message from a bridge (initialized by a devtools backend) + case 'v4fire-devtools-bridge': + port?.postMessage(event.data.payload); + break; + + // This is a message from a detect script case 'v4fire-devtools-detect': { const {source, payload} = event.data; void chrome.runtime.sendMessage({source, payload}); @@ -36,3 +48,31 @@ function handleMessageFromPage(event: MessageEvent) { // Do nothing } } + +function handleMessageFromDevtools(message: any) { + globalThis.postMessage( + { + source: 'v4fire-devtools-content-script', + payload: message + }, + '*' + ); +} + +function handleDisconnect() { + port = null; + + // Try to reconnect + connectPort(); +} + +// Creates port from application page to the V4Fire DevTools' service worker +// Which then connects it with devtools port +function connectPort() { + port = chrome.runtime.connect({ + name: 'proxy' + }); + + port.onMessage.addListener(handleMessageFromDevtools); + port.onDisconnect.addListener(handleDisconnect); +} diff --git a/packages/devtools-extension/src/sw/browser/browser-tab-manager.ts b/packages/devtools-extension/src/sw/browser/browser-tab-manager.ts deleted file mode 100644 index 2d1910b..0000000 --- a/packages/devtools-extension/src/sw/browser/browser-tab-manager.ts +++ /dev/null @@ -1,83 +0,0 @@ -/*! - * V4Fire DevTools - * https://github.com/V4Fire/DevTools - * - * Released under the MIT license - * https://github.com/V4Fire/DevTools/blob/main/LICENSE - */ - -import { browserAPI } from 'core/browser-api'; - -interface Tab { - /** - * Port of the devtools page (V4Fire tab in the browser devtools) - */ - devtoolsPort: chrome.runtime.Port | null; -} - -/** - * This class manages communication between the browser tabs and the devtools page - * by forwarding messages from and to their ports - */ -export default class BrowserTabManager { - /** - * All managed tabs by the extension service worker - */ - tabs: Map = new Map(); - - /** - * Register a new tab - * @param tabId - */ - register(tabId: number): Tab { - if (!this.tabs.has(tabId)) { - this.tabs.set(tabId, {devtoolsPort: null}); - } - - return this.tabs.get(tabId)!; - } - - /** - * Unregister a tab - * @param tabId - */ - unregister(tabId: number): void { - const tab = this.tabs.get(tabId); - - if (tab != null) { - this.cleanup(tab); - } - } - - /** - * Cleanup tab connections - * @param tab - */ - cleanup(tab: Tab): void { - // Perform cleanups: drop all connections, etc. - tab.devtoolsPort?.disconnect(); - } - - /** - * Listen for incoming connections from devtools or the active tab - */ - listen(): void { - browserAPI.runtime.onConnect.addListener((port) => { - // Connection from devtools page - if (/^\d+$/.test(port.name)) { - // DevTools page port doesn't have tab id specified because its sender is the extension - // so the tab id is encoded as the name of the port - const - tabId = Number(port.name), - tab = this.register(tabId); - - // eslint-disable-next-line no-console - console.log('New connection from devtools, tabId:', tabId); - - tab.devtoolsPort = port; - } - - // TODO: Handle connection from content scripts - }); - } -} diff --git a/packages/devtools-extension/src/sw/browser/tab-manager/helpers/connect-tab-ports.ts b/packages/devtools-extension/src/sw/browser/tab-manager/helpers/connect-tab-ports.ts new file mode 100644 index 0000000..58778b0 --- /dev/null +++ b/packages/devtools-extension/src/sw/browser/tab-manager/helpers/connect-tab-ports.ts @@ -0,0 +1,63 @@ +/*! + * V4Fire DevTools + * https://github.com/V4Fire/DevTools + * + * Released under the MIT license + * https://github.com/V4Fire/DevTools/blob/main/LICENSE + */ + +import type { Tab } from 'sw/browser/tab-manager/interface'; + +/** + * Enables message proxy between devtools and proxy port + * + * @param tab + * @throws {Error} - in case ports are already connected + */ +export default function connectTabPorts(tab: Tab): void { + const devtoolsPort = tab.ports.devtools; + const proxyPort = tab.ports.proxy; + + if (devtoolsPort == null || proxyPort == null) { + return; + } + + if (tab.disconnect != null) { + throw new Error( + `Attempted to connect already connected ports for tab with id ${tab.id}` + ); + } + + const + devtoolsPortMessageListener = createPortMessageListener(proxyPort), + proxyPortMessageListener = createPortMessageListener(devtoolsPort); + + const disconnectListener = () => { + devtoolsPort.onMessage.removeListener(devtoolsPortMessageListener); + proxyPort.onMessage.removeListener(proxyPortMessageListener); + + // We handle disconnect() calls manually, based on each specific case + // No need to disconnect other port here + tab.disconnect = null; + }; + + function createPortMessageListener(portOut: chrome.runtime.Port) { + return (message: unknown) => { + try { + portOut.postMessage(message); + } catch (e) { + stderr(new Error(`Broken pipe ${tab.id}`, {cause: e})); + + disconnectListener(); + } + }; + } + + tab.disconnect = disconnectListener; + + devtoolsPort.onMessage.addListener(devtoolsPortMessageListener); + proxyPort.onMessage.addListener(proxyPortMessageListener); + + devtoolsPort.onDisconnect.addListener(disconnectListener); + proxyPort.onDisconnect.addListener(disconnectListener); +} diff --git a/packages/devtools-extension/src/sw/browser/tab-manager/helpers/handle-port-disconnect.ts b/packages/devtools-extension/src/sw/browser/tab-manager/helpers/handle-port-disconnect.ts new file mode 100644 index 0000000..15cc5e9 --- /dev/null +++ b/packages/devtools-extension/src/sw/browser/tab-manager/helpers/handle-port-disconnect.ts @@ -0,0 +1,31 @@ +/*! + * V4Fire DevTools + * https://github.com/V4Fire/DevTools + * + * Released under the MIT license + * https://github.com/V4Fire/DevTools/blob/main/LICENSE + */ + +import type { Tab } from 'sw/browser/tab-manager/interface'; + +/** + * Adds listener for port's disconnect event + * + * @param tab + * @param portKey + */ +export default function handlePortDisconnect( + tab: Tab, + portKey: keyof Tab['ports'] +): void { + const port = tab.ports[portKey]; + + // In case proxy port was disconnected from the other end, from content script + // This can happen if content script was detached, when user does in-tab navigation + // This listener should never be called when we call port.disconnect() from this service worker + port?.onDisconnect.addListener(() => { + tab.disconnect?.(); + + tab.ports[portKey] = null; + }); +} diff --git a/packages/devtools-extension/src/sw/browser/tab-manager/helpers/index.ts b/packages/devtools-extension/src/sw/browser/tab-manager/helpers/index.ts new file mode 100644 index 0000000..0440a35 --- /dev/null +++ b/packages/devtools-extension/src/sw/browser/tab-manager/helpers/index.ts @@ -0,0 +1,11 @@ +/*! + * V4Fire DevTools + * https://github.com/V4Fire/DevTools + * + * Released under the MIT license + * https://github.com/V4Fire/DevTools/blob/main/LICENSE + */ + +export { default as handlePortDisconnect } from 'sw/browser/tab-manager/helpers/handle-port-disconnect'; +export { default as connectTabPorts } from 'sw/browser/tab-manager/helpers/connect-tab-ports'; + diff --git a/packages/devtools-extension/src/sw/browser/tab-manager/index.ts b/packages/devtools-extension/src/sw/browser/tab-manager/index.ts new file mode 100644 index 0000000..eaeb43a --- /dev/null +++ b/packages/devtools-extension/src/sw/browser/tab-manager/index.ts @@ -0,0 +1,119 @@ +/*! + * V4Fire DevTools + * https://github.com/V4Fire/DevTools + * + * Released under the MIT license + * https://github.com/V4Fire/DevTools/blob/main/LICENSE + */ + +import { browserAPI } from 'core/browser-api'; + +import type { Tab } from 'sw/browser/tab-manager/interface'; + +import { + + handlePortDisconnect, + connectTabPorts + +} from 'sw/browser/tab-manager/helpers'; + +/** + * This class manages communication between the browser tabs and the devtools page + * by forwarding messages from and to their ports + */ +export default class BrowserTabManager { + /** + * All managed tabs by the extension service worker + */ + tabs: Map = new Map(); + + /** + * Register a new tab + * @param tabId + */ + register(tabId: number): Tab { + if (!this.tabs.has(tabId)) { + this.tabs.set(tabId, { + id: tabId, + ports: { + devtools: null, + proxy: null + }, + disconnect: null + }); + } + + return this.tabs.get(tabId)!; + } + + /** + * Unregister a tab + * @param tabId + */ + unregister(tabId: number): void { + const tab = this.tabs.get(tabId); + + if (tab != null) { + this.cleanup(tab); + } + } + + /** + * Cleanup tab connections + * @param tab + */ + cleanup(tab: Tab): void { + // Perform cleanups: drop all connections, etc. + tab.ports.devtools?.disconnect(); + tab.ports.proxy?.disconnect(); + } + + /** + * Listen for incoming connections from devtools or the active tab + */ + listen(): void { + browserAPI.runtime.onConnect.addListener((port) => { + let tab: Tab | null = null; + + // Connection from devtools page + if (/^\d+$/.test(port.name)) { + // DevTools page port doesn't have tab id specified + // because it's sender is the extension so the tab id is encoded as the name of the port + const tabId = Number(port.name); + + tab = this.register(tabId); + + // eslint-disable-next-line no-console + console.log('New connection from devtools, tabId:', tabId); + + tab.ports.devtools = port; + handlePortDisconnect(tab, 'devtools'); + + // Connection from inspected window. + // Tab might not be present for restricted pages in Firefox. + } else if (port.name === 'proxy' && port.sender?.tab?.id != null) { + const tabId = port.sender.tab.id; + + tab = this.register(tabId); + + // eslint-disable-next-line no-console + console.log('New connection from inspected window, tabId:', tabId); + + if (tab.ports.proxy != null) { + + // eslint-disable-next-line no-console + console.log('Reset previous proxy connection'); + tab.disconnect?.(); + tab.ports.proxy.disconnect(); + } + + tab.ports.proxy = port; + handlePortDisconnect(tab, 'proxy'); + } + + if (tab != null) { + connectTabPorts(tab); + } + }); + } +} diff --git a/packages/devtools-extension/src/sw/browser/tab-manager/interface.ts b/packages/devtools-extension/src/sw/browser/tab-manager/interface.ts new file mode 100644 index 0000000..192c5a4 --- /dev/null +++ b/packages/devtools-extension/src/sw/browser/tab-manager/interface.ts @@ -0,0 +1,31 @@ +/*! + * V4Fire DevTools + * https://github.com/V4Fire/DevTools + * + * Released under the MIT license + * https://github.com/V4Fire/DevTools/blob/main/LICENSE + */ + +export interface Tab { + /** + * Id of the tab + */ + id: number; + + ports: { + /** + * Port of the devtools page (V4Fire tab in the browser devtools) + */ + devtools: chrome.runtime.Port | null; + + /** + * Port of the proxy which is injected into the inspected window + */ + proxy: chrome.runtime.Port | null; + }; + + /** + * Disconnect callback + */ + disconnect: (() => void) | null; +} diff --git a/packages/devtools-extension/src/sw/index.ts b/packages/devtools-extension/src/sw/index.ts index 333de0c..cb692fb 100644 --- a/packages/devtools-extension/src/sw/index.ts +++ b/packages/devtools-extension/src/sw/index.ts @@ -8,7 +8,7 @@ import 'sw/init/inject-content-scripts'; -import BrowserTabManager from 'sw/browser/browser-tab-manager'; +import BrowserTabManager from 'sw/browser/tab-manager'; import RuntimeMessageHandler from 'sw/browser/runtime-message-handler'; const diff --git a/yarn.lock b/yarn.lock index 40c9d96..1a3d15b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2710,8 +2710,8 @@ __metadata: linkType: hard "@v4fire/client@git+https://github.com/V4Fire/Client.git#v4": - version: 4.0.0-beta.37 - resolution: "@v4fire/client@https://github.com/V4Fire/Client.git#commit=1b46a272dfe5592fcdc5696622330fdb3912caf5" + version: 4.0.0-beta.45 + resolution: "@v4fire/client@https://github.com/V4Fire/Client.git#commit=3e73bf79b4e8b9a3c3e183fc44fc38679f295582" dependencies: "@babel/core": 7.17.5 "@babel/helpers": 7.17.7 @@ -2778,6 +2778,7 @@ __metadata: path-to-regexp: 3.2.0 portfinder: 1.0.28 postcss: 8.4.6 + postcss-discard-comments: 6.0.0 postcss-loader: 6.2.1 raw-loader: 4.0.2 requestidlecallback: 0.3.0 @@ -2795,6 +2796,7 @@ __metadata: svgo-loader: 3.0.0 svgo-sync: 0.5.1 terser-webpack-plugin: 5.3.1 + to-string-loader: ^1.2.0 ts-loader: 9.2.6 tslib: 2.4.1 typescript: 4.6.2 @@ -2907,6 +2909,8 @@ __metadata: optional: true postcss: optional: true + postcss-discard-comments: + optional: true postcss-loader: optional: true raw-loader: @@ -2935,6 +2939,8 @@ __metadata: optional: true terser-webpack-plugin: optional: true + to-string-loader: + optional: true ts-loader: optional: true typescript: @@ -2949,7 +2955,7 @@ __metadata: optional: true webpack-cli: optional: true - checksum: ba699367d38b9f7bcd6661eb982a9452fa14f565e323f3b884d191f72d490844b2a1ad755974fdc3cacf4b0974b56a5a22ddfd394ef336d3e0c64d7b943aaf5a + checksum: 5801744827a0c3d60c11e75d4bbc18312feff82a07d44d5087e3141b04b7903efa553c464c4e5f957b1b200873b2d9284f57296b4f5b5ea37486dcd8547e3a96 languageName: node linkType: hard @@ -12123,7 +12129,7 @@ __metadata: languageName: node linkType: hard -"loader-utils@npm:^1.0.3, loader-utils@npm:^1.1.0": +"loader-utils@npm:^1.0.0, loader-utils@npm:^1.0.3, loader-utils@npm:^1.1.0": version: 1.4.2 resolution: "loader-utils@npm:1.4.2" dependencies: @@ -14549,6 +14555,15 @@ __metadata: languageName: node linkType: hard +"postcss-discard-comments@npm:6.0.0": + version: 6.0.0 + resolution: "postcss-discard-comments@npm:6.0.0" + peerDependencies: + postcss: ^8.2.15 + checksum: 9be073707b5ef781c616ddd32ffd98faf14bf8b40027f341d5a4fb7989fa7b017087ad54146a370fe38295b1f2568b9f5522f4e4c1a1d09fe0e01abd9f5ae00d + languageName: node + linkType: hard + "postcss-discard-comments@npm:^5.1.2": version: 5.1.2 resolution: "postcss-discard-comments@npm:5.1.2" @@ -17776,6 +17791,15 @@ __metadata: languageName: node linkType: hard +"to-string-loader@npm:^1.2.0": + version: 1.2.0 + resolution: "to-string-loader@npm:1.2.0" + dependencies: + loader-utils: ^1.0.0 + checksum: 738d51379aab962c843b0764335b0a1f89f42402b18c1a75d1e2653ef938702a7a6f132cfe7fb888cd14ca2e9a76ed779f9be34ea0a257c500d3f8edde8a1140 + languageName: node + linkType: hard + "to-through@npm:^2.0.0": version: 2.0.0 resolution: "to-through@npm:2.0.0"