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
4 changes: 4 additions & 0 deletions public/styles/dev_tool_panel.css
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,10 @@ svg.icon-muted {
}
}

.d-table-row {
display: table-row;
}

/* Highlight.js GitHub Theme */
pre code.hljs {
display: block;
Expand Down
9 changes: 9 additions & 0 deletions src/browser_panel/State.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ let turboFrames = $state([])
let turboCables = $state([])
let turboStreams = $state([])
let turboEvents = $state([])
let stimulusData = $state([])

export function setTurboFrames(frames, url) {
turboFrames = frames
Expand All @@ -41,6 +42,14 @@ export function getTurboCables() {
return turboCables
}

export function setStimulusData(data, url) {
stimulusData = data
}

export function getStimulusData() {
return stimulusData
}

export function addTurboEvent(event) {
const exists = turboEvents.some((e) => e.uuid === event.uuid)
if (exists) return
Expand Down
6 changes: 5 additions & 1 deletion src/browser_panel/messaging.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { PANEL_TO_BACKEND_MESSAGES, BACKEND_TO_PANEL_MESSAGES, PORT_IDENTIFIERS, HOTWIRE_DEV_TOOLS_PANEL_SOURCE } from "$lib/constants"
import { setTurboFrames, setTurboCables, addTurboStream, addTurboEvent } from "./State.svelte.js"
import { setTurboFrames, setTurboCables, setStimulusData, addTurboStream, addTurboEvent } from "./State.svelte.js"

function setPort(port) {
if (!window.__HotwireDevTools) {
Expand All @@ -22,6 +22,10 @@ export const handleBackendToPanelMessage = (message, port) => {
setTurboCables(message.turboCables, message.url)
setPort(port)
break
case BACKEND_TO_PANEL_MESSAGES.SET_STIMULUS_CONTROLLERS:
setStimulusData(message.stimulusData, message.url)
setPort(port)
break
case BACKEND_TO_PANEL_MESSAGES.TURBO_STREAM_RECEIVED:
addTurboStream(message.turboStream)
setPort(port)
Expand Down
37 changes: 35 additions & 2 deletions src/browser_panel/page/backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { addHighlightOverlayToElements, removeHighlightOverlay } from "$utils/hi
import { debounce, generateUUID, getElementPath, getElementFromIndexPath, stringifyHTMLElementTag, stringifyHTMLElementTagShallow, safeStringifyEventDetail } from "$utils/utils"
import TurboFrameObserver from "./turbo_frame_observer.js"
import TurboCableObserver from "./turbo_cable_observer.js"
import StimulusObserver from "./stimulus_observer.js"
import ElementObserver from "./element_observer.js"

// This is the backend script which interacts with the page's DOM.
Expand All @@ -14,6 +15,7 @@ function init() {
this.observers = {
turboFrame: new TurboFrameObserver(this),
turboCable: new TurboCableObserver(this),
stimulus: new StimulusObserver(this),
}

this.elementObserver = new ElementObserver(document, this)
Expand Down Expand Up @@ -44,11 +46,11 @@ function init() {

// ElementObserver delegate methods
matchElement(element) {
return this.observers.turboFrame.matchElement(element) || this.observers.turboCable.matchElement(element)
return this.observers.turboFrame.matchElement(element) || this.observers.turboCable.matchElement(element) || this.observers.stimulus.matchElement(element)
}

matchElementsInTree(tree) {
return [...this.observers.turboFrame.matchElementsInTree(tree), ...this.observers.turboCable.matchElementsInTree(tree)]
return [...this.observers.turboFrame.matchElementsInTree(tree), ...this.observers.turboCable.matchElementsInTree(tree), ...this.observers.stimulus.matchElementsInTree(tree)]
}

elementMatched(element) {
Expand All @@ -59,6 +61,10 @@ function init() {
if (this.observers.turboCable.matchElement(element)) {
this.observers.turboCable.elementMatched(element)
}

if (this.observers.stimulus.matchElement(element)) {
this.observers.stimulus.elementMatched(element)
}
}

elementUnmatched(element) {
Expand All @@ -69,6 +75,10 @@ function init() {
if (this.observers.turboCable.matchElement(element)) {
this.observers.turboCable.elementUnmatched(element)
}

if (this.observers.stimulus.matchElement(element)) {
this.observers.stimulus.elementUnmatched(element)
}
}

elementAttributeChanged(element, attributeName, oldValue) {
Expand All @@ -79,6 +89,10 @@ function init() {
if (this.observers.turboCable.matchElement(element)) {
this.observers.turboCable.elementAttributeChanged(element, attributeName, oldValue)
}

if (this.observers.stimulus.matchElement(element)) {
this.observers.stimulus.elementAttributeChanged(element, attributeName, oldValue)
}
}

// TurboFrameObserver delegate methods
Expand All @@ -103,6 +117,17 @@ function init() {
this.sendTurboCableData()
}

// Stimulus delegate methods
stimulusControllerConnected(element) {
this.sendStimulusData()
}
stimulusControllerDisonnected(element) {
this.sendStimulusData()
}
stimulusControllerChanged(element, attributeName, oldValue, newValue) {
this.sendStimulusData()
}

sendTurboFrames = debounce(() => {
this._postMessage({
frames: this.observers.turboFrame.getFrameData(),
Expand Down Expand Up @@ -161,6 +186,14 @@ function init() {
})
}

sendStimulusData = debounce(() => {
this._postMessage({
stimulusData: this.observers.stimulus.getStimulusData(),
url: btoa(window.location.href),
type: BACKEND_TO_PANEL_MESSAGES.SET_STIMULUS_CONTROLLERS,
})
}, 10)

handleIncomingTurboStream = (event) => {
const turboStream = event.target
const turboStreamContent = turboStream.outerHTML
Expand Down
166 changes: 166 additions & 0 deletions src/browser_panel/page/stimulus_observer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// ;[
// {
// controllers: [
// {
// identifier: "my-controller",
// selector: "[data-controller~='my-controller']",
// values: [{ name: "auto-start", value: "true" }],
// class: "my-controller",
// },
// ],
// targets: [{ name: "item", selector: "[data-my-controller-target='item']" }],
// },
// ]

import { ensureUUIDOnElement, getUUIDFromElement, stringifyHTMLElementTag, getElementPath } from "$utils/utils.js"

export default class StimulusObserver {
constructor(delegate) {
this.delegate = delegate
this.controllerElements = new Map() // UUID -> [controller data]
}

matchElement(element) {
if (element.dataset?.controller !== undefined) return true

return false
// return this.elementHasStimulusAttributes(element)
}

matchElementsInTree(tree) {
const match = this.matchElement(tree) ? [tree] : []
const matches = Array.from(tree.querySelectorAll("*")).filter((el) => this.matchElement(el))
return match.concat(matches)
}

elementMatched(element) {
const identifiers = (element.dataset.controller || "")
.split(" ")
.map((id) => id.trim())
.filter((id) => id.length > 0)

const uuid = ensureUUIDOnElement(element)
if (!this.controllerElements.has(uuid)) {
this.controllerElements.set(uuid, [])
}
identifiers.forEach((identifier) => {
const controllerData = this.buildStimulusElementData(element, identifier)
this.controllerElements.get(uuid).push(controllerData)
})
this.delegate.stimulusControllerConnected(element)
}

elementUnmatched(element) {
const uuid = getUUIDFromElement(element)

if (this.controllerElements.has(uuid)) {
this.controllerElements.delete(uuid)
this.delegate.stimulusControllerDisonnected(element)
}
}

elementAttributeChanged(element, attributeName, oldValue) {
if (this.matchElement(element)) {
const uuid = getUUIDFromElement(element)
if (this.controllerElements.has(uuid)) {
const newValue = element.getAttribute(attributeName)
this.controllerElements.get(uuid).forEach((controllerData) => {
if (newValue === null) {
delete controllerData.attributes[attributeName]
} else {
controllerData.attributes[attributeName] = newValue
}

controllerData.serializedTag = stringifyHTMLElementTag(element)
})

this.delegate.stimulusControllerChanged(element, attributeName, oldValue, newValue)
}
}
}

buildStimulusElementData(element, identifier) {
const controller = window.Stimulus.getControllerForElementAndIdentifier(element, identifier)
return {
id: element.id,
uuid: getUUIDFromElement(element),
identifier: identifier,
serializedTag: stringifyHTMLElementTag(element),
attributes: Array.from(element.attributes).reduce((map, attr) => {
map[attr.name] = attr.value
return map
}, {}),
values: this.buildControllerValues(controller),
targets: this.buildControllerTargets(controller),
children: [],
element,
}
}

buildControllerValues(controller) {
return Object.values(controller.valueDescriptorMap).map((descriptor) => {
return {
key: descriptor.key,
name: descriptor.name,
type: descriptor.type,
defaultValue: descriptor.defaultValue,
value: controller[descriptor.name],
}
})
}

buildControllerTargets(controller) {
const keys = Object.keys(Object.getOwnPropertyDescriptors(Object.getPrototypeOf(controller)))
return keys.filter((key) => key.endsWith("Target") && !key.startsWith("has"))
}

getStimulusData() {
const buildStimulusTree = () => {
const root = []
this.controllerElements.forEach((controllersData) => {
controllersData.forEach((controllerData) => {
controllerData.children = []
})
})

this.controllerElements.forEach((controllersData) => {
const controllerData = controllersData[0]
const element = controllerData.element
const parentElement = element.parentElement?.closest("[data-controller]")

if (parentElement) {
const parentUUID = getUUIDFromElement(parentElement)
if (parentUUID && this.controllerElements.has(parentUUID)) {
this.controllerElements.get(parentUUID).forEach((parentControllerData) => {
parentControllerData.children.push(controllerData)
})
} else {
// Parent exists but not in our tracking => add as root
root.push(controllerData)
}
} else {
// No parent frame => this is a root frame
root.push(controllerData)
}
})

return root
}

// Remove DOM elements before sending
const stripDOMElements = (data) => {
const { element, children, ...cleanData } = data
const strippedChildren = children.map((child) => stripDOMElements(child))
return { ...cleanData, children: strippedChildren }
}

const controllerTree = buildStimulusTree()
return controllerTree.map((element) => stripDOMElements(element))
}

elementHasStimulusAttributes(element) {
return Array.from(element.attributes).some((attr) => {
return attr.name.startsWith("data-") && (attr.name.endsWith("-target") || attr.name.endsWith("-value") || attr.name.endsWith("-action") || attr.name.endsWith("-outlet") || attr.name.endsWith("-class"))
})
}
}
3 changes: 2 additions & 1 deletion src/browser_panel/panel/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import { getDevtoolInstance, setDevtoolInstance } from "$lib/devtool.js"
import { handleResize } from "../theme.svelte.js"
import { connection } from "../State.svelte.js"
import StimulusTab from "./tabs/StimulusTab.svelte"
import TurboTab from "./tabs/TurboTab.svelte"
import LogsTab from "./tabs/LogsTab.svelte"

Expand Down Expand Up @@ -79,7 +80,7 @@
</div>

<div id="stimulus-tab" class="tabcontent">
<h2>Stimulus</h2>
<StimulusTab />
</div>

<div id="native-tab" class="tabcontent">
Expand Down
9 changes: 1 addition & 8 deletions src/browser_panel/panel/tabs/LogsTab.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@
onmouseenter={() => addHighlightOverlayByPath(event.targetElementPath)}
onmouseleave={() => hideHighlightOverlay()}
>
<div class="turbo-event-entry-wrapper">
<div class="d-table-row">
<div class="turbo-event-first-column">
<strong>{event.eventName}</strong>
</div>
Expand Down Expand Up @@ -281,9 +281,6 @@
</Splitpanes>

<style>
.turbo-event-entry-wrapper {
display: table-row;
}
.turbo-event-first-column {
display: table-cell;
width: 60%;
Expand All @@ -300,8 +297,4 @@
.turbo-events-table {
table-layout: fixed;
}

.turbo-cable-icon {
padding: var(--sl-spacing-3x-small);
}
</style>
Loading