├── .tool-versions ├── .prettierignore ├── .prettierrc.json ├── public ├── images │ ├── icon-16.png │ ├── icon-32.png │ ├── icon-48.png │ ├── icon-128.png │ ├── icon-16-wo.png │ ├── icon-32-wo.png │ ├── icon-48-wo.png │ └── icon-128-wo.png ├── fonts │ ├── AirbnbCereal_W_Bd.otf │ ├── AirbnbCereal_W_Bk.otf │ ├── AirbnbCereal_W_Blk.otf │ ├── AirbnbCereal_W_Lt.otf │ ├── AirbnbCereal_W_Md.otf │ └── AirbnbCereal_W_XBd.otf ├── register.html ├── icons │ └── inspect.svg ├── panel.html ├── styles │ ├── hotwire_dev_tools_content.css │ ├── panel │ │ ├── code_highlight.css │ │ ├── utilities.css │ │ └── main.css │ ├── hotwire_dev_tools_popup.css │ └── hotwire_dev_tools_detail_panel.css └── popup.html ├── src ├── browser_panel │ ├── panel │ │ ├── register.js │ │ ├── App.svelte │ │ ├── panel.js │ │ └── tabs │ │ │ └── LogsTab.svelte │ ├── HTMLRenderer.svelte │ ├── theme.svelte.js │ ├── proxy.js │ ├── page │ │ ├── turbo_cable_observer.js │ │ ├── element_observer.js │ │ ├── turbo_frame_observer.js │ │ ├── turbo_attribute_elements_observer.js │ │ └── stimulus_observer.js │ ├── State.svelte.js │ └── messaging.js ├── uikit │ ├── IconButton.svelte │ └── webawesome.svelte.js ├── components │ ├── InspectButton.svelte │ ├── Stimulus │ │ ├── ClassTreeItem.svelte │ │ ├── OutletTreeItem.svelte │ │ ├── TargetTreeItem.svelte │ │ ├── ValueEditor.svelte │ │ ├── ActionTreeItem.svelte │ │ ├── NestedValue.svelte │ │ └── ValueTreeItem.svelte │ ├── ScrollIntoViewButton.svelte │ ├── StripedHtmlTag.svelte │ └── CopyButton.svelte ├── utils │ ├── turbo_utils.js │ ├── dom_scanner.js │ ├── highlight.js │ ├── collapsible.js │ ├── icons.js │ └── utils.js ├── inject_script.js ├── lib │ ├── constants.js │ ├── diagnostics_checker.js │ └── devtool.js ├── background.js └── content.js ├── xcode ├── HotwireDevTools │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── mac-icon-16@1x.png │ │ │ ├── mac-icon-16@2x.png │ │ │ ├── mac-icon-32@1x.png │ │ │ ├── mac-icon-32@2x.png │ │ │ ├── mac-icon-128@1x.png │ │ │ ├── mac-icon-128@2x.png │ │ │ ├── mac-icon-256@1x.png │ │ │ ├── mac-icon-256@2x.png │ │ │ ├── mac-icon-512@1x.png │ │ │ ├── mac-icon-512@2x.png │ │ │ └── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── LargeIcon.imageset │ │ │ └── Contents.json │ ├── Resources │ │ ├── Icon.png │ │ ├── Style.css │ │ └── Script.js │ ├── Info.plist │ ├── HotwireDevTools.entitlements │ ├── AppDelegate.swift │ ├── Base.lproj │ │ ├── Main.html │ │ └── Main.storyboard │ └── ViewController.swift ├── HotwireDevTools.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── HotwireDevTools.xcscheme └── HotwireDevTools Extension │ ├── HotwireDevTools_Extension.entitlements │ ├── Info.plist │ └── SafariWebExtensionHandler.swift ├── privacy_policy.md ├── .gitignore ├── jsconfig.json ├── .vscode └── settings.json ├── .github ├── workflows │ └── lint.yml └── copilot-instructions.md ├── package.json ├── MIT-LICENSE ├── manifest.template.json ├── ARCHITECTURE.md └── README.md /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 24.2.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | manifest.template.json 2 | xcode/HotwireDevTools/*/** 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "printWidth": 220, 4 | "semi": false 5 | } 6 | -------------------------------------------------------------------------------- /public/images/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/public/images/icon-16.png -------------------------------------------------------------------------------- /public/images/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/public/images/icon-32.png -------------------------------------------------------------------------------- /public/images/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/public/images/icon-48.png -------------------------------------------------------------------------------- /public/images/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/public/images/icon-128.png -------------------------------------------------------------------------------- /public/images/icon-16-wo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/public/images/icon-16-wo.png -------------------------------------------------------------------------------- /public/images/icon-32-wo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/public/images/icon-32-wo.png -------------------------------------------------------------------------------- /public/images/icon-48-wo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/public/images/icon-48-wo.png -------------------------------------------------------------------------------- /src/browser_panel/panel/register.js: -------------------------------------------------------------------------------- 1 | chrome.devtools.panels.create("Hotwire", "/images/icon-128.png", "panel.html") 2 | -------------------------------------------------------------------------------- /public/images/icon-128-wo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/public/images/icon-128-wo.png -------------------------------------------------------------------------------- /public/fonts/AirbnbCereal_W_Bd.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/public/fonts/AirbnbCereal_W_Bd.otf -------------------------------------------------------------------------------- /public/fonts/AirbnbCereal_W_Bk.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/public/fonts/AirbnbCereal_W_Bk.otf -------------------------------------------------------------------------------- /public/fonts/AirbnbCereal_W_Blk.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/public/fonts/AirbnbCereal_W_Blk.otf -------------------------------------------------------------------------------- /public/fonts/AirbnbCereal_W_Lt.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/public/fonts/AirbnbCereal_W_Lt.otf -------------------------------------------------------------------------------- /public/fonts/AirbnbCereal_W_Md.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/public/fonts/AirbnbCereal_W_Md.otf -------------------------------------------------------------------------------- /public/fonts/AirbnbCereal_W_XBd.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/public/fonts/AirbnbCereal_W_XBd.otf -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Resources/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/xcode/HotwireDevTools/Resources/Icon.png -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-16@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-16@1x.png -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-16@2x.png -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-32@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-32@1x.png -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-32@2x.png -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-128@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-128@1x.png -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-128@2x.png -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-256@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-256@1x.png -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-256@2x.png -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-512@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-512@1x.png -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/HEAD/xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-512@2x.png -------------------------------------------------------------------------------- /src/uikit/IconButton.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/register.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /privacy_policy.md: -------------------------------------------------------------------------------- 1 | Hotwire Dev Tools does not send anything about your page or browsing session anywhere. 2 | All configured options are stored locally in the browser using official browser storage API's. 3 | No requests are made to outside services. 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generic 2 | *.DS_Store 3 | node_modules/ 4 | 5 | # Build files 6 | /public/dist 7 | /public/manifest.json 8 | 9 | # Release files 10 | public.zip 11 | public.crx 12 | public.pem 13 | 14 | # Xcode 15 | xcuserdata/ 16 | 17 | # jetbrains IDE 18 | .idea/ 19 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SFSafariWebExtensionConverterVersion 6 | 15.4 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/browser_panel/HTMLRenderer.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | {@html hljs.highlight(htmlString, { language: "html" }).value} 10 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "$src/*": ["src/*"], 6 | "$uikit/*": ["src/uikit/*"], 7 | "$components/*": ["src/components/*"], 8 | "$utils/*": ["src/utils/*"], 9 | "$lib/*": ["src/lib/*"] 10 | } 11 | }, 12 | "include": ["src/**/*"] 13 | } 14 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.watcherExclude": { 3 | "**/public/dist/*": true 4 | }, 5 | "search.exclude": { 6 | "**/public/dist/*": true 7 | }, 8 | "editor.formatOnSave": true, 9 | "editor.defaultFormatter": "esbenp.prettier-vscode", 10 | "[svelte]": { 11 | "editor.defaultFormatter": "svelte.svelte-vscode" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools Extension/HotwireDevTools_Extension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/LargeIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "idiom" : "universal", 13 | "scale" : "3x" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools/HotwireDevTools.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | prettier: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v3 11 | 12 | - name: Setup Node 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: 20 16 | 17 | - name: npm install 18 | run: npm install 19 | 20 | - name: Run prettier 21 | run: npm run lint 22 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools Extension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionPointIdentifier 8 | com.apple.Safari.web-extension 9 | NSExtensionPrincipalClass 10 | $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/InspectButton.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | inspectElement(selector)}> 14 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // HotwireDevTools 4 | // 5 | // Created by Leon on 03.06.2024. 6 | // 7 | 8 | import Cocoa 9 | 10 | @main 11 | class AppDelegate: NSObject, NSApplicationDelegate { 12 | 13 | func applicationDidFinishLaunching(_ notification: Notification) { 14 | // Override point for customization after application launch. 15 | } 16 | 17 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 18 | return true 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "build": "NODE_ENV=production node build.js --no-watch", 4 | "dev": "NODE_ENV=development node build.js --watch", 5 | "lint": "npx prettier --check .", 6 | "format": "npx prettier --write ." 7 | }, 8 | "dependencies": { 9 | "@awesome.me/webawesome": "^3.0.0", 10 | "@number-flow/svelte": "^0.3.9", 11 | "esbuild": "^0.25.4", 12 | "esbuild-svelte": "^0.9.3", 13 | "fs-extra": "^11.3.0", 14 | "highlight.js": "^11.11.1", 15 | "mustache": "^4.2.0", 16 | "prettier": "^3.5.3", 17 | "svelte": "^5.33.18", 18 | "svelte-splitpanes": "^8.0.9" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /public/icons/inspect.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/panel.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/components/Stimulus/ClassTreeItem.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | {klass.key} 10 | {#if klass.classes.length === 0} 11 | (0) 12 | {:else} 13 | {#each klass.classes as value} 14 | 15 |
16 | {value} 17 | 18 |
19 |
20 | {/each} 21 | {/if} 22 |
23 |
24 | 25 | 30 | -------------------------------------------------------------------------------- /src/components/Stimulus/OutletTreeItem.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | {outlet.key} 11 | {#if outlet.elements.length === 0} 12 | (no outlets) 13 | {:else} 14 | {#each outlet.elements as element} 15 | 16 |
17 | 18 | 19 |
20 |
21 | {/each} 22 | {/if} 23 |
24 |
25 | -------------------------------------------------------------------------------- /src/utils/turbo_utils.js: -------------------------------------------------------------------------------- 1 | export const turboStreamTargetElements = (turboStream) => { 2 | const target = turboStream.getAttribute("target") 3 | const targets = turboStream.getAttribute("targets") 4 | 5 | if (target) { 6 | return targetElementsById(target) 7 | } else if (targets) { 8 | return targetElementsByQuery(targets) 9 | } else { 10 | ;[] 11 | } 12 | } 13 | 14 | export const targetElementsById = (target) => { 15 | const element = document.getElementById(target) 16 | 17 | if (element !== null) { 18 | return [element] 19 | } else { 20 | return [] 21 | } 22 | } 23 | 24 | export const targetElementsByQuery = (targets) => { 25 | const elements = document.querySelectorAll(targets) 26 | 27 | if (elements.length !== 0) { 28 | return Array.prototype.slice.call(elements) 29 | } else { 30 | return [] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Resources/Style.css: -------------------------------------------------------------------------------- 1 | * { 2 | -webkit-user-select: none; 3 | -webkit-user-drag: none; 4 | cursor: default; 5 | } 6 | 7 | :root { 8 | color-scheme: light dark; 9 | 10 | --spacing: 20px; 11 | } 12 | 13 | html { 14 | height: 100%; 15 | } 16 | 17 | body { 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | flex-direction: column; 22 | 23 | gap: var(--spacing); 24 | margin: 0 calc(var(--spacing) * 2); 25 | height: 100%; 26 | 27 | font: -apple-system-short-body; 28 | text-align: center; 29 | } 30 | 31 | body:not(.state-on, .state-off) :is(.state-on, .state-off) { 32 | display: none; 33 | } 34 | 35 | body.state-on :is(.state-off, .state-unknown) { 36 | display: none; 37 | } 38 | 39 | body.state-off :is(.state-on, .state-unknown) { 40 | display: none; 41 | } 42 | 43 | button { 44 | font-size: 1em; 45 | } 46 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Base.lproj/Main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | HotwireDevTools Icon 14 |

You can turn on HotwireDevTools’s extension in Safari Extensions preferences.

15 |

HotwireDevTools’s extension is currently on. You can turn it off in Safari Extensions preferences.

16 |

HotwireDevTools’s extension is currently off. You can turn it on in Safari Extensions preferences.

17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/browser_panel/theme.svelte.js: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store" 2 | import { MediaQuery } from "svelte/reactivity" 3 | import { debounce } from "$utils/utils" 4 | 5 | const breakpoints = { 6 | md: 640, 7 | lg: 960, 8 | } 9 | const large = new MediaQuery(`(min-width: ${breakpoints.lg}px)`) 10 | const medium = new MediaQuery(`(min-width: ${breakpoints.md}px)`) 11 | 12 | const detectOrientation = () => { 13 | return medium.current ? "landscape" : "portrait" 14 | } 15 | 16 | const detectHorizontalPanes = () => { 17 | return detectOrientation() === "portrait" 18 | } 19 | 20 | const detectBreakpoint = () => { 21 | if (large.current) return "lg" 22 | if (medium.current) return "md" 23 | return "sm" 24 | } 25 | 26 | export const orientation = writable(detectOrientation()) 27 | export const breakpoint = writable(detectBreakpoint()) 28 | export const horizontalPanes = writable(detectHorizontalPanes()) 29 | 30 | export const handleResize = debounce(() => { 31 | orientation.set(detectOrientation()) 32 | breakpoint.set(detectBreakpoint()) 33 | horizontalPanes.set(detectHorizontalPanes()) 34 | }, 100) 35 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Leon Vogt 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/components/Stimulus/TargetTreeItem.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | {target.key} 13 | {#if target.elements.length === 0} 14 | (no targets) 15 | {:else} 16 | {#each target.elements as element} 17 | addHighlightOverlay(selectorByUUID(element.uuid))} onmouseleave={() => hideHighlightOverlay()}> 18 |
19 | 20 | 21 |
22 |
23 | {/each} 24 | {/if} 25 |
26 |
27 | -------------------------------------------------------------------------------- /src/components/ScrollIntoViewButton.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 | Scroll into view 31 | { 37 | scrollAndHighlight() 38 | }} 39 | > 40 | -------------------------------------------------------------------------------- /public/styles/hotwire_dev_tools_content.css: -------------------------------------------------------------------------------- 1 | body.hotwire-dev-tools-highlight-turbo-frames { 2 | & turbo-frame { 3 | display: block; 4 | border-radius: 5px; 5 | } 6 | } 7 | 8 | .hotwire-dev-tools-highlight-overlay-turbo-frame { 9 | position: absolute; 10 | pointer-events: none; 11 | border-radius: 5px; 12 | } 13 | 14 | .hotwire-dev-tools-turbo-frame-info-badge-container { 15 | position: relative; 16 | pointer-events: all; 17 | } 18 | 19 | .hotwire-dev-tools-turbo-frame-info-badge { 20 | position: absolute; 21 | z-index: 1000; 22 | top: -20px; 23 | height: 20px; 24 | color: #fff; 25 | padding: 0 5px; 26 | border-radius: 5px; 27 | font-size: 12px; 28 | font-weight: bold; 29 | cursor: pointer; 30 | white-space: nowrap; 31 | overflow: hidden; 32 | width: 18px; 33 | opacity: 0.1; 34 | transition: 35 | opacity 0.3s, 36 | width 0.3s; 37 | } 38 | 39 | .hotwire-dev-tools-turbo-frame-info-badge:hover { 40 | opacity: 1; 41 | width: fit-content; 42 | } 43 | 44 | .hotwire-dev-tools-turbo-frame-info-badge.copied { 45 | animation: turboFrameScaleEffect 0.3s ease-in-out; 46 | } 47 | 48 | @keyframes turboFrameScaleEffect { 49 | 0% { 50 | transform: scale(1); 51 | } 52 | 50% { 53 | transform: scale(1.1); 54 | } 55 | 100% { 56 | transform: scale(1); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Resources/Script.js: -------------------------------------------------------------------------------- 1 | function show(enabled, useSettingsInsteadOfPreferences) { 2 | if (useSettingsInsteadOfPreferences) { 3 | document.getElementsByClassName('state-on')[0].innerText = "HotwireDevTools’s extension is currently on. You can turn it off in the Extensions section of Safari Settings."; 4 | document.getElementsByClassName('state-off')[0].innerText = "HotwireDevTools’s extension is currently off. You can turn it on in the Extensions section of Safari Settings."; 5 | document.getElementsByClassName('state-unknown')[0].innerText = "You can turn on HotwireDevTools’s extension in the Extensions section of Safari Settings."; 6 | document.getElementsByClassName('open-preferences')[0].innerText = "Quit and Open Safari Settings…"; 7 | } 8 | 9 | if (typeof enabled === "boolean") { 10 | document.body.classList.toggle(`state-on`, enabled); 11 | document.body.classList.toggle(`state-off`, !enabled); 12 | } else { 13 | document.body.classList.remove(`state-on`); 14 | document.body.classList.remove(`state-off`); 15 | } 16 | } 17 | 18 | function openPreferences() { 19 | webkit.messageHandlers.controller.postMessage("open-preferences"); 20 | } 21 | 22 | document.querySelector("button.open-preferences").addEventListener("click", openPreferences); 23 | -------------------------------------------------------------------------------- /src/components/Stimulus/ValueEditor.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 | {#if isEditing} 27 |
28 | (editValue = e.target.value)}> 29 | 30 | 31 | 32 |
33 | 34 | {:else if type === "boolean"} 35 | onSave(e.target.checked)}> 36 | {:else} 37 | {value} 38 | 39 | {/if} 40 | -------------------------------------------------------------------------------- /src/components/StripedHtmlTag.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | {element.tagName.toLowerCase()} 7 | {#if element.attributes.id} 8 | #{element.attributes.id} 9 | {:else if element.attributes.class} 10 | {#each element.attributes.class.split(" ") as className} 11 | .{className} 12 | {/each} 13 | {/if} 14 | {#each Object.entries(additionalAttributes) as [key, value]} 15 | 16 | {key}= 17 | "{value}" 18 | 19 | {/each} 20 | 21 | 22 | 52 | -------------------------------------------------------------------------------- /src/uikit/webawesome.svelte.js: -------------------------------------------------------------------------------- 1 | import "@awesome.me/webawesome/dist/styles/webawesome.css" 2 | import "@awesome.me/webawesome/dist/styles/themes/default.css" 3 | import "@awesome.me/webawesome/dist/styles/color/palettes/shoelace.css" 4 | 5 | import "@awesome.me/webawesome/dist/components/button/button.js" 6 | import "@awesome.me/webawesome/dist/components/select/select.js" 7 | import "@awesome.me/webawesome/dist/components/callout/callout.js" 8 | import "@awesome.me/webawesome/dist/components/dropdown/dropdown.js" 9 | import "@awesome.me/webawesome/dist/components/skeleton/skeleton.js" 10 | import "@awesome.me/webawesome/dist/components/tooltip/tooltip.js" 11 | import "@awesome.me/webawesome/dist/components/spinner/spinner.js" 12 | import "@awesome.me/webawesome/dist/components/badge/badge.js" 13 | import "@awesome.me/webawesome/dist/components/tree/tree.js" 14 | import "@awesome.me/webawesome/dist/components/input/input.js" 15 | import "@awesome.me/webawesome/dist/components/switch/switch.js" 16 | import "@awesome.me/webawesome/dist/components/tab-group/tab-group.js" 17 | 18 | // Set WebAwesome base path to point to the correct location of the WebAwesome icons 19 | import { setBasePath, registerIconLibrary } from "@awesome.me/webawesome/dist/webawesome.js" 20 | setBasePath("/dist") 21 | 22 | // Register our custom icons 23 | registerIconLibrary("custom", { 24 | resolver: (name) => { 25 | return `/icons/${name}.svg` 26 | }, 27 | mutator: (svg) => svg.setAttribute("fill", "currentColor"), 28 | }) 29 | -------------------------------------------------------------------------------- /src/components/CopyButton.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 25 | 26 | 57 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools Extension/SafariWebExtensionHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SafariWebExtensionHandler.swift 3 | // HotwireDevTools Extension 4 | // 5 | // Created by Leon on 03.06.2024. 6 | // 7 | 8 | import SafariServices 9 | import os.log 10 | 11 | class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { 12 | 13 | func beginRequest(with context: NSExtensionContext) { 14 | let request = context.inputItems.first as? NSExtensionItem 15 | 16 | let profile: UUID? 17 | if #available(iOS 17.0, macOS 14.0, *) { 18 | profile = request?.userInfo?[SFExtensionProfileKey] as? UUID 19 | } else { 20 | profile = request?.userInfo?["profile"] as? UUID 21 | } 22 | 23 | let message: Any? 24 | if #available(iOS 15.0, macOS 11.0, *) { 25 | message = request?.userInfo?[SFExtensionMessageKey] 26 | } else { 27 | message = request?.userInfo?["message"] 28 | } 29 | 30 | os_log(.default, "Received message from browser.runtime.sendNativeMessage: %@ (profile: %@)", String(describing: message), profile?.uuidString ?? "none") 31 | 32 | let response = NSExtensionItem() 33 | if #available(iOS 15.0, macOS 11.0, *) { 34 | response.userInfo = [ SFExtensionMessageKey: [ "echo": message ] ] 35 | } else { 36 | response.userInfo = [ "message": [ "echo": message ] ] 37 | } 38 | 39 | context.completeRequest(returningItems: [ response ], completionHandler: nil) 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "mac-icon-16@1x.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "mac-icon-16@2x.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "mac-icon-32@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "mac-icon-32@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "mac-icon-128@1x.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "size" : "128x128", 35 | "idiom" : "mac", 36 | "filename" : "mac-icon-128@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "256x256", 41 | "idiom" : "mac", 42 | "filename" : "mac-icon-256@1x.png", 43 | "scale" : "1x" 44 | }, 45 | { 46 | "size" : "256x256", 47 | "idiom" : "mac", 48 | "filename" : "mac-icon-256@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "512x512", 53 | "idiom" : "mac", 54 | "filename" : "mac-icon-512@1x.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "512x512", 59 | "idiom" : "mac", 60 | "filename" : "mac-icon-512@2x.png", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } -------------------------------------------------------------------------------- /src/components/Stimulus/ActionTreeItem.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
addHighlightOverlay(selectorByUUID(action.element.uuid))} onmouseleave={() => hideHighlightOverlay()}> 11 |
12 |
13 |
14 | 15 | {#if action.keyFilter} 16 | {`${action.eventName}.${action.keyFilter}`} 17 | {:else} 18 | {action.eventName} 19 | {/if} 20 | 21 | 22 | {action.methodName}() 23 |
24 |
25 | 26 |
27 | {#if action.hasParams} 28 |
29 | Params: 30 | {#each Object.entries(action.params) as [key, value]} 31 | {key}="{value}" 32 | {/each} 33 |
34 | {/if} 35 |
36 |
37 | 38 |
39 |
40 |
41 | -------------------------------------------------------------------------------- /src/inject_script.js: -------------------------------------------------------------------------------- 1 | // This script can be injected into the active page by the content script 2 | // The purpose is to access the page's `window` object, which is inaccessible from the content script 3 | // We gather details about the current page and sends them back to the content script via `window.postMessage` 4 | class HotwireDevToolsInjectScript { 5 | init = () => { 6 | this.sendWindowDetails() 7 | this.addEventListeners() 8 | } 9 | 10 | sendRegisteredControllers = () => { 11 | const registeredControllers = window.Stimulus?.router.modulesByIdentifier.keys() 12 | window.postMessage( 13 | { 14 | source: "inject", 15 | message: "stimulusController", 16 | registeredControllers: Array.from(registeredControllers || []), 17 | }, 18 | window.location.origin, 19 | ) 20 | } 21 | 22 | sendTurboDetails = () => { 23 | window.postMessage( 24 | { 25 | source: "inject", 26 | message: "turboDetails", 27 | details: { turboDriveEnabled: window.Turbo?.session.drive }, 28 | }, 29 | window.location.origin, 30 | ) 31 | } 32 | 33 | sendWindowDetails = () => { 34 | this.sendRegisteredControllers() 35 | this.sendTurboDetails() 36 | } 37 | 38 | addEventListeners() { 39 | const events = ["DOMContentLoaded", "turbolinks:load", "turbo:load"] 40 | events.forEach((event) => document.addEventListener(event, this.sendWindowDetails, { passive: true })) 41 | } 42 | } 43 | 44 | if (window.HotwireDevToolsInjectScript) { 45 | // If the inject script is already loaded, we don't need to reinitialize it 46 | } else { 47 | window.HotwireDevToolsInjectScript = new HotwireDevToolsInjectScript() 48 | window.HotwireDevToolsInjectScript.init() 49 | } 50 | -------------------------------------------------------------------------------- /public/styles/panel/code_highlight.css: -------------------------------------------------------------------------------- 1 | /* Highlight.js GitHub Theme */ 2 | pre code.hljs { 3 | display: block; 4 | overflow-x: auto; 5 | padding: 1em; 6 | } 7 | code.hljs { 8 | padding: 3px 5px; 9 | } /*! 10 | Theme: GitHub 11 | Description: Light theme as seen on github.com 12 | Author: github.com 13 | Maintainer: @Hirse 14 | Updated: 2021-05-15 15 | 16 | Outdated base version: https://github.com/primer/github-syntax-light 17 | Current colors taken from GitHub's CSS 18 | */ 19 | .hljs { 20 | color: #24292e; 21 | background: #fff; 22 | } 23 | .hljs-doctag, 24 | .hljs-keyword, 25 | .hljs-meta .hljs-keyword, 26 | .hljs-template-tag, 27 | .hljs-template-variable, 28 | .hljs-type, 29 | .hljs-variable.language_ { 30 | color: #d73a49; 31 | } 32 | .hljs-title, 33 | .hljs-title.class_, 34 | .hljs-title.class_.inherited__, 35 | .hljs-title.function_ { 36 | color: #6f42c1; 37 | } 38 | .hljs-attr, 39 | .hljs-attribute, 40 | .hljs-literal, 41 | .hljs-meta, 42 | .hljs-number, 43 | .hljs-operator, 44 | .hljs-selector-attr, 45 | .hljs-selector-class, 46 | .hljs-selector-id, 47 | .hljs-variable { 48 | color: #005cc5; 49 | } 50 | .hljs-meta .hljs-string, 51 | .hljs-regexp, 52 | .hljs-string { 53 | color: #032f62; 54 | } 55 | .hljs-built_in, 56 | .hljs-symbol { 57 | color: #e36209; 58 | } 59 | .hljs-code, 60 | .hljs-comment, 61 | .hljs-formula { 62 | color: #6a737d; 63 | } 64 | .hljs-name, 65 | .hljs-quote, 66 | .hljs-selector-pseudo, 67 | .hljs-selector-tag { 68 | color: #22863a; 69 | } 70 | .hljs-subst { 71 | color: #24292e; 72 | } 73 | .hljs-section { 74 | color: #005cc5; 75 | font-weight: 700; 76 | } 77 | .hljs-bullet { 78 | color: #735c0f; 79 | } 80 | .hljs-emphasis { 81 | color: #24292e; 82 | font-style: italic; 83 | } 84 | .hljs-strong { 85 | color: #24292e; 86 | font-weight: 700; 87 | } 88 | .hljs-addition { 89 | color: #22863a; 90 | background-color: #f0fff4; 91 | } 92 | .hljs-deletion { 93 | color: #b31d28; 94 | background-color: #ffeef0; 95 | } 96 | -------------------------------------------------------------------------------- /src/utils/dom_scanner.js: -------------------------------------------------------------------------------- 1 | export default class DOMScanner { 2 | static SHADOW_CONTAINER_ID = "hotwire-dev-tools-shadow-container" 3 | static TURBO_FRAME_OVERLAY_CLASS_NAME = "hotwire-dev-tools-highlight-overlay-turbo-frame" 4 | 5 | // Turbo 6 | static get turboFrameElements() { 7 | return document.querySelectorAll("turbo-frame") 8 | } 9 | 10 | static get turboFrameIds() { 11 | return Array.from(this.turboFrameElements).map((turboFrame) => turboFrame.id) 12 | } 13 | 14 | static get turboPermanentElements() { 15 | return document.querySelectorAll("[data-turbo-permanent]") 16 | } 17 | 18 | // Stimulus 19 | static get stimulusControllerElements() { 20 | return document.querySelectorAll("[data-controller]") 21 | } 22 | 23 | static get stimulusControllerIdentifiers() { 24 | return Array.from(this.stimulusControllerElements) 25 | .map((element) => element.dataset.controller.split(" ")) 26 | .flat() 27 | } 28 | 29 | static get uniqueStimulusControllerIdentifiers() { 30 | return [...new Set(this.stimulusControllerIdentifiers)] 31 | } 32 | 33 | static get groupedStimulusControllerElements() { 34 | const groupedElements = {} 35 | this.stimulusControllerElements.forEach((element) => { 36 | element.dataset.controller 37 | .split(" ") 38 | .filter((stimulusControllerId) => stimulusControllerId.trim() !== "") 39 | .forEach((stimulusControllerId) => { 40 | if (!groupedElements[stimulusControllerId]) { 41 | groupedElements[stimulusControllerId] = [] 42 | } 43 | groupedElements[stimulusControllerId].push(element) 44 | }) 45 | }) 46 | 47 | return groupedElements 48 | } 49 | 50 | // Dev Tools 51 | static get shadowContainer() { 52 | return document.getElementById(this.SHADOW_CONTAINER_ID) 53 | } 54 | 55 | static get turboFrameOverlayElements() { 56 | return document.querySelectorAll(`.${this.TURBO_FRAME_OVERLAY_CLASS_NAME}`) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/utils/highlight.js: -------------------------------------------------------------------------------- 1 | export const addHighlightOverlayToElements = (elementsOrSelector, color = "#007aff", overlayClassName = "hotwire-dev-tools-highlight-overlay", opacity = "0.2") => { 2 | removeHighlightOverlay() 3 | let elements = [] 4 | 5 | if (typeof elementsOrSelector === "string") { 6 | elements = Array.from(document.querySelectorAll(elementsOrSelector)) 7 | } else if (Array.isArray(elementsOrSelector) || elementsOrSelector instanceof NodeList || elementsOrSelector instanceof HTMLCollection) { 8 | elements = elementsOrSelector 9 | } else if (elementsOrSelector instanceof Element) { 10 | elements = [elementsOrSelector] 11 | } 12 | 13 | elements.forEach((element) => { 14 | const rect = element.getBoundingClientRect() 15 | 16 | // If the element is inside a dialog, use the dialog as the container 17 | // If we don't place the highlight inside the dialog, it will be clipped by the dialog 18 | const container = element.closest("dialog") || document.body 19 | createOverlay(rect, color, overlayClassName, opacity, container) 20 | }) 21 | } 22 | 23 | export const createOverlay = (rect, color, overlayClassName, opacity, container = document.body) => { 24 | const overlay = document.createElement("div") 25 | overlay.className = overlayClassName 26 | overlay.style.position = "absolute" 27 | overlay.style.zIndex = 2147483647 // Highest possible z-index 28 | overlay.style.opacity = opacity 29 | overlay.style.top = `${rect.top + window.scrollY}px` 30 | overlay.style.left = `${rect.left + window.scrollX}px` 31 | overlay.style.width = `${rect.width}px` 32 | overlay.style.height = `${rect.height}px` 33 | overlay.style.backgroundColor = color 34 | overlay.style.pointerEvents = "none" 35 | container.appendChild(overlay) 36 | } 37 | 38 | export const removeHighlightOverlay = (selector = ".hotwire-dev-tools-highlight-overlay") => { 39 | const overlays = document.querySelectorAll(selector) 40 | overlays.forEach((overlay) => overlay.remove()) 41 | } 42 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // HotwireDevTools 4 | // 5 | // Created by Leon on 03.06.2024. 6 | // 7 | 8 | import Cocoa 9 | import SafariServices 10 | import WebKit 11 | 12 | let extensionBundleIdentifier = "hotwire.dev.tools.extension" 13 | 14 | class ViewController: NSViewController, WKNavigationDelegate, WKScriptMessageHandler { 15 | 16 | @IBOutlet var webView: WKWebView! 17 | 18 | override func viewDidLoad() { 19 | super.viewDidLoad() 20 | 21 | self.webView.navigationDelegate = self 22 | 23 | self.webView.configuration.userContentController.add(self, name: "controller") 24 | 25 | self.webView.loadFileURL(Bundle.main.url(forResource: "Main", withExtension: "html")!, allowingReadAccessTo: Bundle.main.resourceURL!) 26 | } 27 | 28 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 29 | SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extensionBundleIdentifier) { (state, error) in 30 | guard let state = state, error == nil else { 31 | // Insert code to inform the user that something went wrong. 32 | return 33 | } 34 | 35 | DispatchQueue.main.async { 36 | if #available(macOS 13, *) { 37 | webView.evaluateJavaScript("show(\(state.isEnabled), true)") 38 | } else { 39 | webView.evaluateJavaScript("show(\(state.isEnabled), false)") 40 | } 41 | } 42 | } 43 | } 44 | 45 | func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { 46 | if (message.body as! String != "open-preferences") { 47 | return; 48 | } 49 | 50 | SFSafariApplication.showPreferencesForExtension(withIdentifier: extensionBundleIdentifier) { error in 51 | DispatchQueue.main.async { 52 | NSApplication.shared.terminate(nil) 53 | } 54 | } 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/browser_panel/proxy.js: -------------------------------------------------------------------------------- 1 | // This is a content-script that is injected only when the devtools are 2 | // activated. Because it is not injected using eval, it has full privilege 3 | // to the chrome runtime API. It serves as a proxy between the injected 4 | // backend and the DevTool panel. 5 | import { HOTWIRE_DEV_TOOLS_BACKEND_SOURCE, HOTWIRE_DEV_TOOLS_PROXY_SOURCE, PANEL_TO_BACKEND_MESSAGES, PORT_IDENTIFIERS } from "$lib/constants" 6 | 7 | function proxy() { 8 | const proxyPort = chrome.runtime.connect({ 9 | name: PORT_IDENTIFIERS.PROXY, 10 | }) 11 | 12 | proxyPort.onMessage.addListener(sendMessageToBackend) 13 | window.addEventListener("message", sendMessageToDevtools) 14 | proxyPort.onDisconnect.addListener(handleDisconnect) 15 | 16 | handshakeWithBackend() 17 | 18 | function handshakeWithBackend() { 19 | sendMessageToBackend(PANEL_TO_BACKEND_MESSAGES.INIT) 20 | 21 | // It can happen, that the proxy gets loaded before the backend script is injected into the page. 22 | // For that case, we will try to send the INIT message multiple times. 23 | // The backend script stop listening for the INIT message after the first one gets received. 24 | const MAX_ATTEMPTS = 10 25 | const INTERVAL_MS = 100 26 | let attempts = 0 27 | 28 | const intervalId = setInterval(() => { 29 | if (attempts++ >= MAX_ATTEMPTS) { 30 | clearInterval(intervalId) 31 | return 32 | } 33 | sendMessageToBackend(PANEL_TO_BACKEND_MESSAGES.INIT) 34 | }, INTERVAL_MS) 35 | } 36 | 37 | function sendMessageToBackend(payload) { 38 | window.postMessage( 39 | { 40 | source: HOTWIRE_DEV_TOOLS_PROXY_SOURCE, 41 | payload: payload, 42 | }, 43 | "*", 44 | ) 45 | } 46 | 47 | function sendMessageToDevtools(e) { 48 | if (e.data && e.data.source === HOTWIRE_DEV_TOOLS_BACKEND_SOURCE) { 49 | proxyPort.postMessage(e.data.payload) 50 | } 51 | } 52 | 53 | function handleDisconnect() { 54 | proxyPort.onMessage.removeListener(sendMessageToBackend) 55 | window.removeEventListener("message", sendMessageToDevtools) 56 | sendMessageToBackend(PANEL_TO_BACKEND_MESSAGES.SHUTDOWN) 57 | } 58 | } 59 | 60 | proxy() 61 | -------------------------------------------------------------------------------- /manifest.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Hotwire Dev Tools", 4 | "version": "0.3.3", 5 | "description": "Dev Tools for Turbo and Stimulus", 6 | "icons": { 7 | {{#use_outline_icons}} 8 | "16": "images/icon-16-wo.png", 9 | "32": "images/icon-32-wo.png", 10 | "48": "images/icon-48-wo.png", 11 | "128": "images/icon-128-wo.png" 12 | {{/use_outline_icons}} 13 | {{^use_outline_icons}} 14 | "16": "images/icon-16.png", 15 | "32": "images/icon-32.png", 16 | "48": "images/icon-48.png", 17 | "128": "images/icon-128.png" 18 | {{/use_outline_icons}} 19 | }, 20 | "content_scripts": [ 21 | { 22 | "matches": [""], 23 | "js": ["dist/hotwire_dev_tools_content.js"], 24 | "css": ["styles/hotwire_dev_tools_content.css"] 25 | } 26 | ], 27 | "host_permissions": ["http://*/*", "https://*/*", "file:///*"], 28 | "devtools_page": "register.html", 29 | "content_security_policy": { 30 | "extension_pages": "script-src 'self'; object-src" 31 | }, 32 | {{#needs_service_worker}} 33 | "background": { 34 | "service_worker": "dist/background.js", 35 | "type": "module" 36 | }, 37 | {{/needs_service_worker}} 38 | {{^needs_service_worker}} 39 | "background": { 40 | "scripts": ["dist/background.js"] 41 | }, 42 | {{/needs_service_worker}} 43 | "permissions": ["storage", "activeTab", "scripting"], 44 | "action": { 45 | "default_title": "Click or press Alt+Shift+S to launch Dev Tools", 46 | "default_popup": "popup.html" 47 | }, 48 | "web_accessible_resources": [ 49 | { 50 | "resources": [ 51 | "dist/hotwire_dev_tools_inject_script.js", 52 | "styles/hotwire_dev_tools_detail_panel.css", 53 | "panel.html", 54 | "register.html", 55 | "dist/browser_panel/page/backend.js" 56 | ], 57 | "matches": [""] 58 | } 59 | ], 60 | {{#needs_browser_specific_settings}} 61 | "browser_specific_settings": { 62 | "gecko": { 63 | "id": "hotwire_dev_tools@browser_extension" 64 | } 65 | }, 66 | {{/needs_browser_specific_settings}} 67 | "commands": { 68 | "_execute_action": { 69 | "suggested_key": { 70 | "default": "Alt+Shift+S" 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/components/Stimulus/NestedValue.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | {#if isObject(data)} 13 | {#each Object.entries(data) as [key, value]} 14 | 15 | {#if isPrimitive(value)} 16 |
17 | {key}: 18 | onEdit([...path, key])} 22 | onSave={(newVal) => onSave([...path, key], newVal)} 23 | onCancel={() => onCancel([...path, key])} 24 | /> 25 |
26 | {:else} 27 | {key}: 28 | {#if isArray(value)} 29 | {value.length} 30 | {/if} 31 | 32 | {/if} 33 |
34 | {/each} 35 | {:else if isArray(data)} 36 | {#each data as item, i} 37 | 38 | {#if isPrimitive(item)} 39 |
40 | {i}: 41 | onEdit([...path, i])} 45 | onSave={(newVal) => onSave([...path, i], newVal)} 46 | onCancel={() => onCancel([...path, i])} 47 | /> 48 |
49 | {:else} 50 | {i}: 51 | {#if isArray(item)} 52 | Array [{item.length}] 53 | {/if} 54 | 55 | {/if} 56 |
57 | {/each} 58 | {/if} 59 | -------------------------------------------------------------------------------- /src/browser_panel/page/turbo_cable_observer.js: -------------------------------------------------------------------------------- 1 | import { ensureUUIDOnElement, getUUIDFromElement, serializeAttributes } from "$utils/utils.js" 2 | 3 | // The TurboCableObserver class is responsible for observing `` elements, 4 | // which are used in Turbo Streams to manage WebSocket connections. 5 | export default class TurboCableObserver { 6 | constructor(delegate) { 7 | this.delegate = delegate 8 | this.streamSources = new Map() // UUID -> Turbo Cable Stream Source data 9 | } 10 | 11 | matchElement(element) { 12 | return element.tagName?.toLowerCase() === "turbo-cable-stream-source" 13 | } 14 | 15 | matchElementsInTree(tree) { 16 | const match = this.matchElement(tree) ? [tree] : [] 17 | const matches = Array.from(tree.querySelectorAll("turbo-cable-stream-source")) 18 | return match.concat(matches) 19 | } 20 | 21 | elementMatched(element) { 22 | const uuid = ensureUUIDOnElement(element) 23 | 24 | if (!this.streamSources.has(uuid)) { 25 | const turboCableData = this.buildTurboCableData(element) 26 | this.streamSources.set(uuid, turboCableData) 27 | this.delegate.turboCableChanged() 28 | } 29 | } 30 | 31 | elementUnmatched(element) { 32 | const uuid = getUUIDFromElement(element) 33 | 34 | if (this.streamSources.has(uuid)) { 35 | this.streamSources.delete(uuid) 36 | this.delegate.turboCableChanged() 37 | } 38 | } 39 | 40 | elementAttributeChanged(element, attributeName, oldValue) { 41 | if (this.matchElement(element)) { 42 | const uuid = getUUIDFromElement(element) 43 | if (this.streamSources.has(uuid)) { 44 | const turboCableData = this.streamSources.get(uuid) 45 | const newValue = element.getAttribute(attributeName) 46 | 47 | if (newValue === null) { 48 | delete turboCableData.attributes[attributeName] 49 | } else { 50 | turboCableData.attributes[attributeName] = newValue 51 | } 52 | turboCableData.connected = element.hasAttribute("connected") 53 | 54 | this.delegate.turboCableChanged() 55 | } 56 | } 57 | } 58 | 59 | buildTurboCableData(element) { 60 | return { 61 | connected: element.hasAttribute("connected"), 62 | attributes: serializeAttributes(element), 63 | } 64 | } 65 | 66 | getTurboCableData() { 67 | return Array.from(this.streamSources.values()) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/constants.js: -------------------------------------------------------------------------------- 1 | export const HOTWIRE_DEV_TOOLS_PROXY_SOURCE = "hotwire-dev-tools-proxy" 2 | export const HOTWIRE_DEV_TOOLS_PANEL_SOURCE = "hotwire-dev-tools-panel" 3 | export const HOTWIRE_DEV_TOOLS_BACKEND_SOURCE = "hotwire-dev-tools-backend" 4 | 5 | export const PORT_IDENTIFIERS = { 6 | PROXY: "proxy", 7 | INSPECTOR_PREFIX: "INSPECTOR_", 8 | } 9 | 10 | export const BACKEND_TO_PANEL_MESSAGES = { 11 | SET_TURBO_FRAMES: "set-turbo-frames", 12 | SET_TURBO_CABLES: "set-turbo-cables", 13 | SET_STIMULUS_DATA: "set-stimulus-data", 14 | SET_REGISTERED_STIMULUS_IDENTIFIERS: "set-registered-stimulus-identifiers", 15 | SET_TURBO_PERMANENT_ELEMENTS: "set-turbo-permanent-elements", 16 | SET_TURBO_TEMPORARY_ELEMENTS: "set-turbo-temporary-elements", 17 | SET_TURBO_CONFIG: "set-turbo-config", 18 | TURBO_STREAM_RECEIVED: "turbo-stream-received", 19 | TURBO_EVENT_RECEIVED: "turbo-event-received", 20 | HEALTH_CHECK_RESPONSE: "health-check-response", 21 | } 22 | 23 | export const PANEL_TO_BACKEND_MESSAGES = { 24 | // Triggered by the Proxy 25 | INIT: "init", 26 | SHUTDOWN: "shutdown", 27 | 28 | // Triggered by the Panel itself 29 | HEALTH_CHECK: "healt-check", 30 | HIGHLIGHT_ELEMENT: "highlight-element", 31 | HIDE_HIGHLIGHTING: "hide-highlighting", 32 | REFRESH_TURBO_FRAME: "refresh-turbo-frame", 33 | REFRESH_ALL_STATE: "refresh-all-state", 34 | SCROLL_AND_HIGHLIGHT: "scroll-and-highlight", 35 | UPDATE_DATA_ATTRIBUTE: "update-data-attribute", 36 | } 37 | 38 | export const TURBO_EVENTS = [ 39 | "turbo:click", 40 | "turbo:before-visit", 41 | "turbo:visit", 42 | "turbo:before-cache", 43 | "turbo:before-render", 44 | "turbo:render", 45 | "turbo:load", 46 | "turbo:morph", 47 | "turbo:before-morph-element", 48 | "turbo:before-morph-attribute", 49 | "turbo:morph-element", 50 | "turbo:submit-start", 51 | "turbo:submit-end", 52 | "turbo:before-frame-render", 53 | "turbo:frame-render", 54 | "turbo:frame-load", 55 | "turbo:frame-missing", 56 | "turbo:before-stream-render", 57 | "turbo:before-fetch-request", 58 | "turbo:before-fetch-response", 59 | "turbo:before-prefetch", 60 | "turbo:fetch-request-error", 61 | ] 62 | 63 | export const TURBO_EVENTS_GROUPED = { 64 | Document: ["turbo:click", "turbo:before-visit", "turbo:visit", "turbo:before-cache", "turbo:before-render", "turbo:render", "turbo:load"], 65 | "Page Refreshes": ["turbo:morph", "turbo:before-morph-element", "turbo:before-morph-attribute", "turbo:morph-element"], 66 | Forms: ["turbo:submit-start", "turbo:submit-end"], 67 | Frames: ["turbo:before-frame-render", "turbo:frame-render", "turbo:frame-load", "turbo:frame-missing"], 68 | Streams: ["turbo:before-stream-render"], 69 | "HTTP Requests": ["turbo:before-fetch-request", "turbo:before-fetch-response", "turbo:before-prefetch", "turbo:fetch-request-error"], 70 | } 71 | -------------------------------------------------------------------------------- /src/utils/collapsible.js: -------------------------------------------------------------------------------- 1 | export const collapseEntryRows = (uuid, event, collapsibles, stickyParents) => { 2 | event.stopPropagation() 3 | 4 | const isCurrentlyCollapsed = collapsibles[uuid] || false 5 | const frameElement = event.target.closest(".entry-row") 6 | const containerElement = frameElement.nextElementSibling 7 | 8 | if (containerElement && containerElement.classList.contains("children-container")) { 9 | if (isCurrentlyCollapsed) { 10 | // Expanding 11 | containerElement.classList.remove("collapsed") 12 | containerElement.style.height = "0px" 13 | containerElement.offsetHeight 14 | 15 | const targetHeight = containerElement.scrollHeight 16 | containerElement.style.height = `${targetHeight}px` 17 | setTimeout(() => { 18 | containerElement.style.height = "" 19 | if (stickyParents) { 20 | toggleStickyParent(uuid, frameElement, true, stickyParents) 21 | } 22 | }, 300) // Match the transition duration in CSS 23 | } else { 24 | // Collapsing 25 | const startHeight = containerElement.scrollHeight 26 | containerElement.style.height = `${startHeight}px` 27 | containerElement.offsetHeight 28 | 29 | containerElement.style.height = "0px" 30 | if (stickyParents) { 31 | toggleStickyParent(uuid, frameElement, false, stickyParents) 32 | } 33 | setTimeout(() => { 34 | containerElement.classList.add("collapsed") 35 | }, 300) // Match the transition duration 36 | } 37 | } 38 | 39 | collapsibles[uuid] = !isCurrentlyCollapsed 40 | } 41 | 42 | export const toggleStickyParent = (uuid, frameElement, makeSticky, stickyParents) => { 43 | if (makeSticky) { 44 | frameElement.classList.add("sticky-parent") 45 | stickyParents[uuid] = frameElement 46 | } else { 47 | frameElement.classList.remove("sticky-parent") 48 | delete stickyParents[uuid] 49 | } 50 | } 51 | 52 | export const checkStickyVisibility = (scrollableList, stickyParents, collapsibles) => { 53 | if (!scrollableList) return 54 | 55 | Object.entries(stickyParents).forEach(([uuid, frameElement]) => { 56 | const containerElement = frameElement.nextElementSibling 57 | if (!containerElement || !containerElement.classList.contains("children-container")) return 58 | 59 | const isCurrentlyCollapsed = collapsibles[uuid] || false 60 | if (isCurrentlyCollapsed) { 61 | frameElement.classList.remove("sticky-parent") 62 | return 63 | } 64 | 65 | const children = Array.from(containerElement.querySelectorAll(".entry-row")) 66 | const isAnyChildVisible = children.some((child) => { 67 | const rect = child.getBoundingClientRect() 68 | const parentRect = scrollableList.getBoundingClientRect() 69 | return rect.bottom > parentRect.top && rect.top < parentRect.bottom 70 | }) 71 | 72 | if (isAnyChildVisible) { 73 | frameElement.classList.add("sticky-parent") 74 | } else { 75 | frameElement.classList.remove("sticky-parent") 76 | } 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Hotwire DevTools Architecture 2 | 3 | This document outlines the architecture and communication flow of the extension. 4 | 5 | Currently, the extension is two parted: 6 | 7 | 1. **Popup / Content Script**: 8 | This is the legacy part of the extension. In the popup you can set the features you want to enable. 9 | The content script gets injected by the browser into the page and runs the selected features. 10 | 11 | 2. **DevTools Panel**: 12 | This is the new part of the extension. It provides a panel in the browser's DevTools. 13 | 14 | We might still use the popup in the future, to provide a fast way to enable/disable features. 15 | But there are currently duplicated parts of code and state between the popup and the DevTools panel. 16 | The goal is to align both parts and use a single source of truth for the state and features. 17 | 18 | ## Devtool Panel 19 | 20 | ![image](https://github.com/user-attachments/assets/7cb63fb0-08ee-4854-9a3a-c68a8df5f910) 21 | 22 | ### Communication Flow 23 | 24 | ``` 25 | ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ 26 | │ │ │ │ │ │ │ │ 27 | │ panel.js │◄────►│ background.js │◄────►│ proxy.js │◄────►│ backend.js │ 28 | │ │ port │ │ port │ │window│ │ 29 | └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ 30 | ``` 31 | 32 | **Step-by-Step** 33 | 34 | - The panel injects the backend script into the inspected page using `injectScript()` 35 | - The panel connects to the background script via chrome.runtime.connect 36 | - The background script detects this connection (using the inspector port name) 37 | - The background script injects a proxy script 38 | - The proxy creates a connection back to the background script 39 | - The background script establishes a two-way communication pipe between the panel and proxy 40 | 41 | **Messages are sent in this path:** 42 | 43 | - Panel → Background → Proxy → Backend (page) 44 | - Backend (page) → Proxy → Background → Panel 45 | 46 | **Component Responsibilities** 47 | 48 | 1. **DevTools Panel**: The user interface shown in browser DevTools. 49 | 50 | - Built with Svelte 51 | - Communicates with background script via Chrome extension ports 52 | - Renders and updates based on events from the inspected page 53 | 54 | 2. **Background Script**: Central coordination script that runs persistently. 55 | 56 | - Maintains connections between DevTools panel and proxy 57 | - Routes messages between components 58 | - Handles tab and lifecycle events 59 | 60 | 3. **Proxy**: Content script injected into the page context. 61 | 62 | - Bridges the extension world and page world 63 | - Translates port-based communication to window.postMessage 64 | - Has access to DOM but not page JavaScript context 65 | 66 | 4. **Backend**: Script injected directly into the page's JavaScript context. 67 | - Hooks into Hotwire's internal APIs 68 | - Captures events and state from Turbo and Stimulus 69 | - Sends data back to the DevTools panel via the proxy 70 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools.xcodeproj/xcshareddata/xcschemes/HotwireDevTools.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /src/browser_panel/State.svelte.js: -------------------------------------------------------------------------------- 1 | export function createConnectionState() { 2 | let connectedToBackend = $state(false) 3 | let isPermanentlyDisconnected = $state(false) 4 | 5 | return { 6 | get isPermanentlyDisconnected() { 7 | return isPermanentlyDisconnected 8 | }, 9 | set isPermanentlyDisconnected(value) { 10 | isPermanentlyDisconnected = value 11 | }, 12 | 13 | get connectedToBackend() { 14 | return connectedToBackend 15 | }, 16 | set connectedToBackend(value) { 17 | connectedToBackend = value 18 | }, 19 | } 20 | } 21 | export const connection = createConnectionState() 22 | 23 | let turboFrames = $state([]) 24 | let turboCables = $state([]) 25 | let turboStreams = $state([]) 26 | let turboEvents = $state([]) 27 | let stimulusData = $state([]) 28 | let registeredStimulusIdentifiers = $state([]) 29 | let turboPermanentElements = $state([]) 30 | let turboTemporaryElements = $state([]) 31 | let turboConfig = $state({}) 32 | 33 | export function setTurboFrames(frames, url) { 34 | turboFrames = frames 35 | } 36 | 37 | export function getTurboFrames() { 38 | return turboFrames 39 | } 40 | 41 | export function setTurboCables(cables, url) { 42 | turboCables = cables 43 | } 44 | 45 | export function getTurboCables() { 46 | return turboCables 47 | } 48 | 49 | export function setStimulusData(data, url) { 50 | stimulusData = data 51 | } 52 | 53 | export function getStimulusData() { 54 | return stimulusData 55 | } 56 | 57 | export function setRegisteredStimulusIdentifiers(identifiers, url) { 58 | registeredStimulusIdentifiers = identifiers 59 | } 60 | 61 | export function getRegisteredStimulusIdentifiers() { 62 | return registeredStimulusIdentifiers 63 | } 64 | 65 | export function setTurboPermanentElements(elements, url) { 66 | turboPermanentElements = elements 67 | } 68 | 69 | export function getTurboPermanentElements() { 70 | return turboPermanentElements 71 | } 72 | 73 | export function setTurboTemporaryElements(elements, url) { 74 | turboTemporaryElements = elements 75 | } 76 | 77 | export function getTurboTemporaryElements() { 78 | return turboTemporaryElements 79 | } 80 | 81 | export function setTurboConfig(config, url) { 82 | turboConfig = config 83 | } 84 | 85 | export function getTurboConfig() { 86 | return turboConfig 87 | } 88 | 89 | export function addTurboEvent(event) { 90 | const exists = turboEvents.some((e) => e.uuid === event.uuid) 91 | if (exists) return 92 | turboEvents = [...turboEvents, event] 93 | } 94 | 95 | export function getTurboEvents() { 96 | return turboEvents 97 | } 98 | 99 | export function clearTurboEvents() { 100 | turboEvents = [] 101 | } 102 | 103 | export function addTurboStream(turboStream) { 104 | const exists = turboStreams.some((stream) => stream.uuid === turboStream.uuid) 105 | if (exists) return 106 | 107 | turboStreams = [ 108 | ...turboStreams, 109 | { 110 | uuid: turboStream.uuid, 111 | time: turboStream.time, 112 | action: turboStream.action, 113 | target: turboStream.target, 114 | targets: turboStream.targets, 115 | targetSelector: turboStream.targetSelector, 116 | turboStreamContent: turboStream.turboStreamContent, 117 | }, 118 | ] 119 | } 120 | 121 | export function getTurboStreams() { 122 | return turboStreams 123 | } 124 | 125 | export function clearTurboStreams() { 126 | turboStreams = [] 127 | } 128 | 129 | export default { 130 | setTurboFrames, 131 | getTurboFrames, 132 | addTurboStream, 133 | getTurboStreams, 134 | clearTurboStreams, 135 | } 136 | -------------------------------------------------------------------------------- /src/browser_panel/page/element_observer.js: -------------------------------------------------------------------------------- 1 | export default class ElementObserver { 2 | constructor(element, delegate) { 3 | this.element = element 4 | this.delegate = delegate 5 | this.started = false 6 | this.elements = new Set() 7 | 8 | this.mutationObserver = new MutationObserver((mutations) => this.processMutations(mutations)) 9 | 10 | this.mutationObserverInit = { 11 | attributes: true, 12 | childList: true, 13 | subtree: true, 14 | attributeOldValue: true, 15 | } 16 | } 17 | 18 | start() { 19 | if (!this.started) { 20 | this.started = true 21 | this.mutationObserver.observe(this.element, this.mutationObserverInit) 22 | this.refresh() 23 | } 24 | } 25 | 26 | stop() { 27 | if (this.started) { 28 | this.mutationObserver.takeRecords() 29 | this.mutationObserver.disconnect() 30 | this.started = false 31 | } 32 | } 33 | 34 | refresh() { 35 | if (this.started) { 36 | const elements = this.matchElementsInTree() 37 | for (const element of elements) { 38 | this.matchElement(element) 39 | } 40 | } 41 | } 42 | 43 | matchElementsInTree(tree = this.element) { 44 | return this.delegate.matchElementsInTree(tree) 45 | } 46 | 47 | matchElement(element) { 48 | if (!this.elements.has(element) && this.delegate.matchElement(element)) { 49 | this.elements.add(element) 50 | if (this.delegate.elementMatched) { 51 | this.delegate.elementMatched(element) 52 | } 53 | } 54 | } 55 | 56 | processMutations(mutations) { 57 | if (this.started) { 58 | for (const mutation of mutations) { 59 | this.processMutation(mutation) 60 | } 61 | } 62 | } 63 | 64 | processMutation(mutation) { 65 | if (mutation.type === "childList") { 66 | this.processRemovedNodes(mutation.removedNodes) 67 | this.processAddedNodes(mutation.addedNodes) 68 | } else if (mutation.type === "attributes") { 69 | this.processAttributeChange(mutation.target, mutation.attributeName, mutation.oldValue) 70 | } 71 | } 72 | 73 | processRemovedNodes(nodes) { 74 | for (const node of Array.from(nodes)) { 75 | const element = this.elementFromNode(node) 76 | if (element) { 77 | this.processTree(element, this.removeElement) 78 | } 79 | } 80 | } 81 | 82 | processAddedNodes(nodes) { 83 | for (const node of Array.from(nodes)) { 84 | const element = this.elementFromNode(node) 85 | if (element) { 86 | this.processTree(element, this.matchElement) 87 | } 88 | } 89 | } 90 | 91 | processAttributeChange(node, attributeName, oldValue) { 92 | if (attributeName == "data-hotwire-dev-tools-id") return 93 | 94 | const element = this.elementFromNode(node) 95 | if (element && this.elements.has(element)) { 96 | if (this.delegate.elementAttributeChanged) { 97 | this.delegate.elementAttributeChanged(element, attributeName, oldValue) 98 | } 99 | } 100 | } 101 | 102 | elementFromNode(node) { 103 | if (node.nodeType === Node.ELEMENT_NODE) { 104 | return node 105 | } 106 | } 107 | 108 | processTree(tree, processor) { 109 | for (const element of this.matchElementsInTree(tree)) { 110 | processor.call(this, element) 111 | } 112 | } 113 | 114 | removeElement(element) { 115 | if (this.elements.has(element)) { 116 | this.elements.delete(element) 117 | if (this.delegate.elementUnmatched) { 118 | this.delegate.elementUnmatched(element) 119 | } 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # Hotwire Dev Tools - Copilot Instructions 2 | 3 | ## Project Overview 4 | 5 | Browser extension (Chrome/Firefox/Safari) for debugging Hotwire (Turbo + Stimulus) applications. Built with **Svelte 5** using runes (`$state`, `$props`, `$derived`) and **esbuild**. 6 | 7 | ## Architecture - Four-Part Communication Flow 8 | 9 | ``` 10 | Panel (UI) ←→ Background ←→ Proxy ←→ Backend (page context) 11 | ``` 12 | 13 | - **Panel** ([src/browser_panel/panel/](src/browser_panel/panel/)): Svelte UI in DevTools, communicates via Chrome ports 14 | - **Background** ([src/background.js](src/background.js)): Routes messages between panel and proxy, handles tab lifecycle 15 | - **Proxy** ([src/browser_panel/proxy.js](src/browser_panel/proxy.js)): Content script bridging extension world ↔ page world via `window.postMessage` 16 | - **Backend** ([src/browser_panel/page/backend.js](src/browser_panel/page/backend.js)): Injected into page context, hooks into Hotwire APIs 17 | 18 | Messages flow: `Panel → Background → Proxy → Backend` and back. See [ARCHITECTURE.md](ARCHITECTURE.md) for diagrams. 19 | 20 | ## Key Development Commands 21 | 22 | ```bash 23 | npm run dev # Build + watch mode (Chrome by default) 24 | npm run build # Production build for Chrome 25 | npm run build firefox # Build for Firefox 26 | npm run build safari # Build for Safari 27 | npm run format # Run Prettier 28 | ``` 29 | 30 | Load extension from `public/` folder after building. 31 | 32 | ## Code Conventions 33 | 34 | ### Import Aliases (configured in [build.js](build.js)) 35 | 36 | ```javascript 37 | import { ... } from "$src/..." // src/ 38 | import { ... } from "$uikit/..." // src/uikit/ 39 | import { ... } from "$components/..." // src/components/ 40 | import { ... } from "$utils/..." // src/utils/ 41 | import { ... } from "$lib/..." // src/lib/ 42 | ``` 43 | 44 | ### Svelte 5 Runes Pattern 45 | 46 | State uses Svelte 5 runes syntax. See [State.svelte.js](src/browser_panel/State.svelte.js): 47 | 48 | ```javascript 49 | let turboFrames = $state([]) // Reactive state 50 | export function getTurboFrames() { 51 | return turboFrames 52 | } 53 | export function setTurboFrames(frames) { 54 | turboFrames = frames 55 | } 56 | ``` 57 | 58 | Components use `$props()` and `$state()`: 59 | 60 | ```svelte 61 | let { value, onSave } = $props() 62 | let editValue = $state(value) 63 | ``` 64 | 65 | ### Message Constants 66 | 67 | All message types are defined in [src/lib/constants.js](src/lib/constants.js): 68 | 69 | - `BACKEND_TO_PANEL_MESSAGES`: Backend → Panel data updates 70 | - `PANEL_TO_BACKEND_MESSAGES`: Panel → Backend commands 71 | - `PORT_IDENTIFIERS`: Port naming for Chrome runtime 72 | 73 | ### Observer Pattern for DOM Monitoring 74 | 75 | Backend uses observers ([src/browser_panel/page/](src/browser_panel/page/)) to watch DOM changes: 76 | 77 | - `TurboFrameObserver`, `StimulusObserver`, `TurboCableObserver`, etc. 78 | - Observers implement `matchElement()`, `elementMatched()`, `elementUnmatched()` 79 | 80 | ## Browser-Specific Build 81 | 82 | Build system uses Mustache templating for [manifest.template.json](manifest.template.json): 83 | 84 | - `__IS_CHROME__`, `__IS_FIREFOX__`, `__IS_SAFARI__` flags available at build time 85 | - Safari uses background scripts; Chrome uses service workers 86 | 87 | ## UI Components 88 | 89 | - Uses **Web Awesome** (`@awesome.me/webawesome`) for UI components (``, ``, etc.) 90 | - Custom components in [src/uikit/](src/uikit/) and [src/components/](src/components/) 91 | 92 | ## Legacy vs DevTools Panel 93 | 94 | Two coexisting systems (consolidation in progress): 95 | 96 | 1. **Popup/Content Script** ([src/popup.js](src/popup.js), [src/content.js](src/content.js)): Legacy feature toggles 97 | 2. **DevTools Panel**: New primary interface - focus development here 98 | -------------------------------------------------------------------------------- /src/lib/diagnostics_checker.js: -------------------------------------------------------------------------------- 1 | import DOMScanner from "$utils/dom_scanner" 2 | 3 | export default class DiagnosticsChecker { 4 | constructor(devTool) { 5 | this.devTool = devTool 6 | this.printedWarnings = [] 7 | this.logger = console 8 | } 9 | 10 | printWarning = (message, once = true, ...extraArgs) => { 11 | if (once && this.printedWarnings.includes(message)) return 12 | 13 | this.logger.warn(`Hotwire Dev Tools: ${message}`, ...extraArgs) 14 | this.printedWarnings.push(message) 15 | } 16 | 17 | checkForWarnings = () => { 18 | this._checkForDuplicatedTurboFrames() 19 | this._checkForNonRegisteredStimulusControllers() 20 | this._checkTurboPermanentElements() 21 | this._checkStimulusTargetsNesting() 22 | } 23 | 24 | _checkForDuplicatedTurboFrames = () => { 25 | const turboFramesIds = DOMScanner.turboFrameIds 26 | const duplicatedIds = turboFramesIds.filter((id, index) => turboFramesIds.indexOf(id) !== index) 27 | 28 | duplicatedIds.forEach((id) => { 29 | this.printWarning(`Multiple Turbo Frames with the same ID '${id}' detected. This can cause unexpected behavior. Ensure that each Turbo Frame has a unique ID.`) 30 | }) 31 | } 32 | 33 | _checkForNonRegisteredStimulusControllers = () => { 34 | const registeredStimulusControllers = this.devTool.registeredStimulusControllers 35 | if (registeredStimulusControllers.length === 0) return 36 | 37 | DOMScanner.uniqueStimulusControllerIdentifiers.forEach((controllerId) => { 38 | // Bridge components are only registered in the Mobile app, 39 | // so we don't want to show warnings for them in the web app. 40 | // Ideally, we'd verify whether a controller is truly a bridge component, 41 | // but since we have limited insight into the Stimulus application, 42 | // we just use a simple prefix check. 43 | const isBridgeComponent = controllerId.startsWith("native--") || controllerId.startsWith("bridge--") 44 | 45 | const controllerRegistered = registeredStimulusControllers.includes(controllerId) 46 | if (!controllerRegistered && !isBridgeComponent) { 47 | this.printWarning(`The Stimulus controller '${controllerId}' does not appear to be registered. Learn more about registering Stimulus controllers here: https://stimulus.hotwired.dev/handbook/installing.`) 48 | } 49 | }) 50 | } 51 | 52 | _checkStimulusTargetsNesting = () => { 53 | DOMScanner.uniqueStimulusControllerIdentifiers.forEach((controllerId) => { 54 | const dataSelector = `data-${controllerId}-target` 55 | const targetElements = document.querySelectorAll(`[${dataSelector}`) 56 | targetElements.forEach((element) => { 57 | const parent = element.closest(`[data-controller~="${controllerId}"]`) 58 | if (!parent) { 59 | const targetName = element.getAttribute(`${dataSelector}`) 60 | this.printWarning(`The Stimulus target '${targetName}' is not inside the Stimulus controller '${controllerId}'`, true, element) 61 | } 62 | }) 63 | }) 64 | } 65 | 66 | _checkTurboPermanentElements = () => { 67 | const turboPermanentElements = DOMScanner.turboPermanentElements 68 | if (turboPermanentElements.length === 0) return 69 | 70 | turboPermanentElements.forEach((element) => { 71 | const id = element.id 72 | if (id === "") { 73 | const message = `Hotwire Dev Tools: Turbo Permanent Element detected without an ID. Turbo Permanent Elements must have a unique ID to work correctly.` 74 | this.printWarning(message, true, element) 75 | } 76 | 77 | const idIsDuplicated = id && document.querySelectorAll(`#${id}`).length > 1 78 | if (idIsDuplicated) { 79 | const message = `Hotwire Dev Tools: Turbo Permanent Element with ID '${id}' doesn't have a unique ID. Turbo Permanent Elements must have a unique ID to work correctly.` 80 | this.printWarning(message, true, element) 81 | } 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/browser_panel/page/turbo_frame_observer.js: -------------------------------------------------------------------------------- 1 | import { ensureUUIDOnElement, getUUIDFromElement, serializeAttributes } from "$utils/utils.js" 2 | 3 | export default class TurboFrameObserver { 4 | constructor(delegate) { 5 | this.delegate = delegate 6 | this.frames = new Map() // UUID -> frame data 7 | } 8 | 9 | matchElement(element) { 10 | return element.tagName?.toLowerCase() === "turbo-frame" 11 | } 12 | 13 | matchElementsInTree(tree) { 14 | const match = this.matchElement(tree) ? [tree] : [] 15 | const matches = Array.from(tree.querySelectorAll("turbo-frame")) 16 | return match.concat(matches) 17 | } 18 | 19 | elementMatched(element) { 20 | const uuid = ensureUUIDOnElement(element) 21 | 22 | if (!this.frames.has(uuid)) { 23 | const frameData = this.buildFrameData(element) 24 | this.frames.set(uuid, frameData) 25 | this.delegate.turboFramesChanged() 26 | } 27 | } 28 | 29 | elementUnmatched(element) { 30 | const uuid = getUUIDFromElement(element) 31 | 32 | if (this.frames.has(uuid)) { 33 | this.frames.delete(uuid) 34 | this.delegate.turboFramesChanged() 35 | } 36 | } 37 | 38 | elementAttributeChanged(element, attributeName, oldValue) { 39 | if (this.matchElement(element)) { 40 | const uuid = getUUIDFromElement(element) 41 | if (this.frames.has(uuid)) { 42 | const frameData = this.frames.get(uuid) 43 | const newValue = element.getAttribute(attributeName) 44 | 45 | if (newValue === null) { 46 | delete frameData.attributes[attributeName] 47 | } else { 48 | frameData.attributes[attributeName] = newValue 49 | } 50 | this.delegate.turboFramesChanged() 51 | } 52 | } 53 | } 54 | 55 | buildFrameData(element) { 56 | return { 57 | id: element.id, 58 | uuid: getUUIDFromElement(element), 59 | attributes: serializeAttributes(element), 60 | hasUniqueId: document.querySelectorAll(`turbo-frame[id='${element.id}']`).length === 1, 61 | tagName: element.tagName.toLowerCase(), 62 | referenceElements: Array.from(document.querySelectorAll(`[data-turbo-frame='${element.id}']`)).map((element) => { 63 | return { 64 | uuid: ensureUUIDOnElement(element), 65 | attributes: serializeAttributes(element), 66 | tagName: element.tagName.toLowerCase(), 67 | } 68 | }), 69 | children: [], 70 | element, 71 | } 72 | } 73 | 74 | getFrameData() { 75 | const buildFrameTree = () => { 76 | const rootFrames = [] 77 | this.frames.forEach((frameData) => { 78 | frameData.children = [] 79 | }) 80 | 81 | this.frames.forEach((frameData) => { 82 | const element = frameData.element 83 | const parentElement = element.parentElement?.closest("turbo-frame") 84 | 85 | if (parentElement) { 86 | const parentUUID = getUUIDFromElement(parentElement) 87 | if (parentUUID && this.frames.has(parentUUID)) { 88 | this.frames.get(parentUUID).children.push(frameData) 89 | } else { 90 | // Parent exists but not in our tracking => add as root 91 | rootFrames.push(frameData) 92 | } 93 | } else { 94 | // No parent frame => this is a root frame 95 | rootFrames.push(frameData) 96 | } 97 | }) 98 | 99 | return rootFrames 100 | } 101 | 102 | // Remove DOM elements before sending 103 | const stripDOMElements = (frameData) => { 104 | const { element, children, ...cleanData } = frameData 105 | const strippedChildren = children.map((child) => stripDOMElements(child)) 106 | return { ...cleanData, children: strippedChildren } 107 | } 108 | 109 | const frameTree = buildFrameTree() 110 | return frameTree.map((frame) => stripDOMElements(frame)) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/browser_panel/page/turbo_attribute_elements_observer.js: -------------------------------------------------------------------------------- 1 | import { ensureUUIDOnElement, getUUIDFromElement, serializeAttributes } from "$utils/utils.js" 2 | 3 | export default class TurboAttributeElementsObserver { 4 | constructor(delegate) { 5 | this.delegate = delegate 6 | this.permanentElements = new Map() // UUID -> element data 7 | this.temporaryElements = new Map() // UUID -> element data 8 | } 9 | 10 | matchElement(element) { 11 | return element && element.hasAttribute && (element.hasAttribute("data-turbo-permanent") || element.hasAttribute("data-turbo-temporary")) 12 | } 13 | 14 | matchElementsInTree(tree) { 15 | if (!tree || !tree.querySelectorAll) return [] 16 | 17 | const match = this.matchElement(tree) ? [tree] : [] 18 | const permanentMatches = Array.from(tree.querySelectorAll("[data-turbo-permanent]")) 19 | const temporaryMatches = Array.from(tree.querySelectorAll("[data-turbo-temporary]")) 20 | return match.concat(permanentMatches, temporaryMatches) 21 | } 22 | 23 | elementMatched(element) { 24 | if (!element || !element.hasAttribute) return 25 | 26 | const uuid = ensureUUIDOnElement(element) 27 | const isPermanent = element.hasAttribute("data-turbo-permanent") 28 | const isTemporary = element.hasAttribute("data-turbo-temporary") 29 | 30 | if (isPermanent) { 31 | if (!this.permanentElements.has(uuid)) { 32 | const elementData = this.buildElementData(element, "permanent") 33 | this.permanentElements.set(uuid, elementData) 34 | this.delegate.turboPermanentElementsChanged() 35 | } 36 | } 37 | 38 | if (isTemporary) { 39 | if (!this.temporaryElements.has(uuid)) { 40 | const elementData = this.buildElementData(element, "temporary") 41 | this.temporaryElements.set(uuid, elementData) 42 | this.delegate.turboTemporaryElementsChanged() 43 | } 44 | } 45 | } 46 | 47 | elementUnmatched(element) { 48 | const uuid = getUUIDFromElement(element) 49 | 50 | if (uuid) { 51 | let changed = false 52 | 53 | if (this.permanentElements.has(uuid)) { 54 | this.permanentElements.delete(uuid) 55 | changed = true 56 | } 57 | 58 | if (this.temporaryElements.has(uuid)) { 59 | this.temporaryElements.delete(uuid) 60 | changed = true 61 | } 62 | 63 | if (changed) { 64 | this.delegate.turboPermanentElementsChanged() 65 | this.delegate.turboTemporaryElementsChanged() 66 | } 67 | } 68 | } 69 | 70 | elementAttributeChanged(element, attributeName, oldValue) { 71 | if (!element || !element.hasAttribute) return 72 | 73 | if (attributeName === "data-turbo-permanent" || attributeName === "data-turbo-temporary") { 74 | const uuid = getUUIDFromElement(element) 75 | 76 | if (uuid) { 77 | // Remove from both maps first 78 | const wasPermanent = this.permanentElements.has(uuid) 79 | const wasTemporary = this.temporaryElements.has(uuid) 80 | 81 | this.permanentElements.delete(uuid) 82 | this.temporaryElements.delete(uuid) 83 | 84 | // Re-add if element still matches 85 | if (this.matchElement(element)) { 86 | this.elementMatched(element) 87 | } else if (wasPermanent || wasTemporary) { 88 | this.delegate.turboPermanentElementsChanged() 89 | this.delegate.turboTemporaryElementsChanged() 90 | } 91 | } 92 | } 93 | } 94 | 95 | buildElementData(element, type) { 96 | return { 97 | type: type, 98 | id: element.id || null, 99 | uuid: getUUIDFromElement(element), 100 | attributes: serializeAttributes(element), 101 | tagName: element.tagName.toLowerCase(), 102 | } 103 | } 104 | 105 | getPermanentElementsData() { 106 | return Array.from(this.permanentElements.values()) 107 | } 108 | 109 | getTemporaryElementsData() { 110 | return Array.from(this.temporaryElements.values()) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | // This background script is running all the time and checks for connections, 2 | // from the browser devtools panel and the backend. 3 | // It sets up a two-way communication channel between the devtools panel and the backend script. 4 | 5 | import { isDevToolPanel, devToolPanelNameToTabId } from "./browser_panel/messaging" 6 | import { PORT_IDENTIFIERS } from "$lib/constants" 7 | 8 | let ports = {} 9 | 10 | const initPortsForTab = (tabId) => { 11 | if (!ports[tabId]) { 12 | ports[tabId] = { 13 | devtools: undefined, 14 | backend: undefined, 15 | } 16 | } 17 | } 18 | const resetPortsForTab = (tabId) => { 19 | ports[tabId] = { 20 | devtools: undefined, 21 | backend: undefined, 22 | } 23 | } 24 | 25 | const getActiveTab = async () => { 26 | try { 27 | const tabs = await chrome.tabs.query({ active: true, currentWindow: true }) 28 | return tabs[0]?.id 29 | } catch (error) { 30 | console.warn("Could not get active tab:", error) 31 | return null 32 | } 33 | } 34 | 35 | chrome.runtime.onConnect.addListener(async (port) => { 36 | let tabId 37 | 38 | if (isDevToolPanel(port)) { 39 | // When the browser devtools panel is opened, it creates a connection 40 | // and sends a port with the name "inspector_". 41 | tabId = devToolPanelNameToTabId(port.name) 42 | 43 | // Safari seems to not provide a valid tabId in some cases and just sends -1. 44 | // In these cases, we will just use the active tab 45 | if (tabId === -1) { 46 | const activeTabId = await getActiveTab() 47 | if (activeTabId) { 48 | tabId = activeTabId 49 | console.log(`Using active tab ID ${tabId} for Safari`) 50 | } else { 51 | console.warn("Could not determine active tab ID, skipping script injection") 52 | return 53 | } 54 | } 55 | 56 | try { 57 | await chrome.scripting.executeScript({ 58 | target: { tabId: tabId }, 59 | files: ["dist/browser_panel/proxy.js"], 60 | }) 61 | } catch (error) { 62 | console.error(`Failed to inject script for tabId ${tabId}:`, error) 63 | } 64 | 65 | initPortsForTab(tabId) 66 | ports[tabId].devtools = port 67 | } else { 68 | tabId = port.sender?.tab?.id 69 | if (port.name !== PORT_IDENTIFIERS.PROXY) { 70 | console.warn("Received onConnect from ", port.name, " not initialising a devtools <-> backend, tabId: ", tabId) 71 | return 72 | } 73 | 74 | if (tabId) { 75 | // This connection is coming from backend.js 76 | initPortsForTab(tabId) 77 | ports[tabId].backend = port 78 | } else { 79 | console.warn("Sender not defined, not initialising port ", port.name) 80 | } 81 | } 82 | 83 | // If both devtools and backend ports are set for the tab, start double piping 84 | if (tabId && ports[tabId].devtools && ports[tabId].backend) { 85 | doublePipe(tabId, ports[tabId].devtools, ports[tabId].backend) 86 | } 87 | return 88 | }) 89 | 90 | // For each tab, 2-way forward messages, devtools <-> backend. 91 | function doublePipe(tabId, devtools, backend) { 92 | console.log(devtools.name, backend.name) 93 | devtools.onMessage.addListener(lOne) 94 | function lOne(message) { 95 | if (message.event === "log") { 96 | return console.log(`tab ${tabId}`, message.payload) 97 | } 98 | console.log("devtools -> backend", message) 99 | backend.postMessage(message) 100 | } 101 | 102 | backend.onMessage.addListener(lTwo) 103 | function lTwo(message) { 104 | if (message.event === "log") { 105 | return console.log(`tab ${tabId}`, message.payload) 106 | } 107 | console.log(`${tabId} backend -> devtools`, message) 108 | devtools.postMessage(message) 109 | } 110 | 111 | function shutdown() { 112 | console.log(`tab ${tabId} disconnected.`) 113 | devtools.onMessage.removeListener(lOne) 114 | backend.onMessage.removeListener(lTwo) 115 | devtools.disconnect() 116 | backend.disconnect() 117 | resetPortsForTab(tabId) 118 | } 119 | 120 | devtools.onDisconnect.addListener(shutdown) 121 | backend.onDisconnect.addListener(shutdown) 122 | 123 | console.log(`tab ${tabId} connected.`) 124 | } 125 | -------------------------------------------------------------------------------- /public/styles/hotwire_dev_tools_popup.css: -------------------------------------------------------------------------------- 1 | body { 2 | width: 20rem; 3 | background-color: #eee; 4 | font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol" !important; 5 | font-size: 16px; 6 | color: black; 7 | user-select: none; 8 | -webkit-user-select: none; 9 | --text-muted-color: #212529bf; 10 | --btn-secondary-color: #555555; 11 | } 12 | 13 | p, 14 | label { 15 | font: 16 | 1rem "Fira Sans", 17 | sans-serif; 18 | } 19 | 20 | input { 21 | margin: 0.4rem; 22 | } 23 | 24 | input[type="text"] { 25 | width: 100%; 26 | padding: 7px; 27 | box-sizing: border-box; 28 | border: 1px solid #ccc; 29 | border-radius: 4px; 30 | } 31 | 32 | form { 33 | display: flex; 34 | flex-direction: column; 35 | } 36 | 37 | fieldset { 38 | margin: 1rem 0; 39 | border: 1px solid #ccc; 40 | border-radius: 4px; 41 | } 42 | 43 | fieldset legend { 44 | color: var(--text-muted-color); 45 | font-size: 12px; 46 | } 47 | 48 | button { 49 | background-color: var(--btn-secondary-color); 50 | border: none; 51 | color: white; 52 | padding: 5px 10px; 53 | text-align: center; 54 | text-decoration: none; 55 | display: inline-block; 56 | transition-duration: 0.4s; 57 | cursor: pointer; 58 | } 59 | 60 | button:hover { 61 | box-shadow: 62 | 0 3px 4px 0 rgba(0, 0, 0, 0.24), 63 | 0 4px 12px 0 rgba(0, 0, 0, 0.19); 64 | } 65 | 66 | .title { 67 | font-size: 1.5rem; 68 | font-weight: bold; 69 | text-align: center; 70 | margin-top: 0; 71 | margin-bottom: 0.5em; 72 | } 73 | 74 | .version { 75 | font-size: 0.8rem; 76 | font-weight: normal; 77 | color: #868686; 78 | } 79 | 80 | .highlight-frames-wrapper, 81 | .highlight-controllers-wrapper, 82 | .detail-panel-options-wrapper { 83 | display: flex; 84 | flex-direction: column; 85 | padding: 0.5em; 86 | margin-left: 2em; 87 | } 88 | 89 | .highlight-controllers-wrapper, 90 | .highlight-frames-wrapper { 91 | gap: 5px; 92 | } 93 | 94 | .detail-panel-options-wrapper input[type="checkbox"] { 95 | margin-left: 0; 96 | margin-bottom: 0; 97 | } 98 | 99 | .highlight-options-wrapper { 100 | display: flex; 101 | align-items: center; 102 | gap: 0.5em; 103 | justify-content: space-between; 104 | height: 2em; 105 | 106 | & select { 107 | height: 2em; 108 | } 109 | } 110 | 111 | .detail-panel-options-control { 112 | margin-left: 2.5em; 113 | margin-top: 0.5em; 114 | } 115 | 116 | .page-specific-options-wrapper span { 117 | color: var(--text-muted-color); 118 | } 119 | 120 | .monitor-events-group { 121 | margin-bottom: 1em; 122 | } 123 | 124 | .monitor-events-group-title { 125 | cursor: pointer; 126 | } 127 | 128 | /* Custom checkbox toggles */ 129 | .toggle { 130 | cursor: pointer; 131 | display: inline-block; 132 | margin-bottom: 1px; 133 | } 134 | 135 | .toggle-switch { 136 | display: inline-block; 137 | background: #ccc; 138 | border-radius: 16px; 139 | width: 29px; 140 | height: 16px; 141 | position: relative; 142 | vertical-align: middle; 143 | transition: background 0.15s; 144 | } 145 | .toggle-switch:before, 146 | .toggle-switch:after { 147 | content: ""; 148 | } 149 | .toggle-switch:before { 150 | display: block; 151 | background: linear-gradient(to bottom, #fff 0%, #eee 100%); 152 | border-radius: 50%; 153 | width: 12px; 154 | height: 12px; 155 | position: absolute; 156 | top: 2px; 157 | left: 2px; 158 | transition: left 0.15s; 159 | } 160 | .toggle-checkbox:checked + .toggle-switch { 161 | background: #56c080; 162 | } 163 | .toggle-checkbox:checked + .toggle-switch:before { 164 | left: 15px; 165 | } 166 | 167 | .toggle-checkbox { 168 | position: absolute; 169 | visibility: hidden; 170 | } 171 | 172 | .toggle-label { 173 | position: relative; 174 | margin-left: 3px; 175 | top: 2px; 176 | } 177 | 178 | body.no-transitions { 179 | & .toggle-switch, 180 | & .toggle-switch:before { 181 | transition: none; 182 | } 183 | } 184 | 185 | /* Weird firefox color preview fix - fixme */ 186 | .color-preview { 187 | width: 40px; 188 | height: 10px; 189 | margin-right: -7px; 190 | } 191 | 192 | /* Utility classes */ 193 | .d-none { 194 | display: none; 195 | } 196 | 197 | .d-flex { 198 | display: flex; 199 | } 200 | 201 | .justify-content-center { 202 | justify-content: center; 203 | } 204 | 205 | .justify-content-end { 206 | justify-content: flex-end; 207 | } 208 | 209 | .align-items-center { 210 | align-items: center; 211 | } 212 | 213 | .m-0 { 214 | margin: 0; 215 | } 216 | 217 | .ms-0 { 218 | margin-left: 0; 219 | } 220 | -------------------------------------------------------------------------------- /src/browser_panel/messaging.js: -------------------------------------------------------------------------------- 1 | import { PANEL_TO_BACKEND_MESSAGES, BACKEND_TO_PANEL_MESSAGES, PORT_IDENTIFIERS, HOTWIRE_DEV_TOOLS_PANEL_SOURCE } from "$lib/constants" 2 | import { setTurboFrames, setTurboCables, setStimulusData, setRegisteredStimulusIdentifiers, setTurboPermanentElements, setTurboTemporaryElements, setTurboConfig, addTurboStream, addTurboEvent } from "./State.svelte.js" 3 | 4 | function setPort(port) { 5 | if (!window.__HotwireDevTools) { 6 | window.__HotwireDevTools = {} 7 | } 8 | window.__HotwireDevTools.port = port 9 | } 10 | 11 | // Backend -> Panel messages 12 | // Here we receive messages from the backend script that runs in the page context, 13 | // and save them into the global state for the panel to use. 14 | // The panel will then automatically re-render the components based on the new state. 15 | export const handleBackendToPanelMessage = (message, port) => { 16 | switch (message.type) { 17 | case BACKEND_TO_PANEL_MESSAGES.SET_TURBO_FRAMES: 18 | setTurboFrames(message.frames, message.url) 19 | setPort(port) 20 | break 21 | case BACKEND_TO_PANEL_MESSAGES.SET_TURBO_CABLES: 22 | setTurboCables(message.turboCables, message.url) 23 | setPort(port) 24 | break 25 | case BACKEND_TO_PANEL_MESSAGES.SET_STIMULUS_DATA: 26 | setStimulusData(message.stimulusData, message.url) 27 | setPort(port) 28 | break 29 | case BACKEND_TO_PANEL_MESSAGES.SET_REGISTERED_STIMULUS_IDENTIFIERS: 30 | setRegisteredStimulusIdentifiers(message.identifiers, message.url) 31 | setPort(port) 32 | break 33 | case BACKEND_TO_PANEL_MESSAGES.SET_TURBO_PERMANENT_ELEMENTS: 34 | setTurboPermanentElements(message.turboPermanentElements, message.url) 35 | setPort(port) 36 | break 37 | case BACKEND_TO_PANEL_MESSAGES.SET_TURBO_TEMPORARY_ELEMENTS: 38 | setTurboTemporaryElements(message.turboTemporaryElements, message.url) 39 | setPort(port) 40 | break 41 | case BACKEND_TO_PANEL_MESSAGES.SET_TURBO_CONFIG: 42 | setTurboConfig(message.turboConfig, message.url) 43 | setPort(port) 44 | break 45 | case BACKEND_TO_PANEL_MESSAGES.TURBO_STREAM_RECEIVED: 46 | addTurboStream(message.turboStream) 47 | setPort(port) 48 | break 49 | case BACKEND_TO_PANEL_MESSAGES.TURBO_EVENT_RECEIVED: 50 | addTurboEvent(message.turboEvent) 51 | setPort(port) 52 | break 53 | case BACKEND_TO_PANEL_MESSAGES.HEALTH_CHECK_RESPONSE: 54 | setPort(port) 55 | break 56 | default: 57 | console.warn(`Unknown message type from backend: ${message.type}`) 58 | } 59 | } 60 | 61 | // Panel -> Backend messages 62 | export const panelPostMessage = (message) => { 63 | if (window.__HotwireDevTools?.port) { 64 | window.__HotwireDevTools.port.postMessage(message) 65 | } else { 66 | console.warn(`Unable to post message from panel, message: ${JSON.stringify(message)}`) 67 | } 68 | } 69 | 70 | export const isDevToolPanel = (port) => { 71 | return port.name.startsWith(PORT_IDENTIFIERS.INSPECTOR_PREFIX) 72 | } 73 | 74 | export const devToolPanelName = (tabId) => { 75 | return PORT_IDENTIFIERS.INSPECTOR_PREFIX + tabId 76 | } 77 | 78 | export const devToolPanelNameToTabId = (portName) => { 79 | return Number(portName.replace(PORT_IDENTIFIERS.INSPECTOR_PREFIX, "")) 80 | } 81 | 82 | // Common messages from the panel to the backend 83 | export const addHighlightOverlay = (selector) => { 84 | panelPostMessage({ 85 | action: PANEL_TO_BACKEND_MESSAGES.HIGHLIGHT_ELEMENT, 86 | source: HOTWIRE_DEV_TOOLS_PANEL_SOURCE, 87 | selector: selector, 88 | }) 89 | } 90 | 91 | export const addHighlightOverlayByPath = (elementPath) => { 92 | panelPostMessage({ 93 | action: PANEL_TO_BACKEND_MESSAGES.HIGHLIGHT_ELEMENT, 94 | source: HOTWIRE_DEV_TOOLS_PANEL_SOURCE, 95 | elementPath: elementPath, 96 | }) 97 | } 98 | 99 | export const hideHighlightOverlay = () => { 100 | panelPostMessage({ 101 | action: PANEL_TO_BACKEND_MESSAGES.HIDE_HIGHLIGHTING, 102 | source: HOTWIRE_DEV_TOOLS_PANEL_SOURCE, 103 | }) 104 | } 105 | 106 | export const updateDataAttribute = (selector, key, value) => { 107 | panelPostMessage({ 108 | action: PANEL_TO_BACKEND_MESSAGES.UPDATE_DATA_ATTRIBUTE, 109 | source: HOTWIRE_DEV_TOOLS_PANEL_SOURCE, 110 | selector: selector, 111 | key: key, 112 | value: value, 113 | }) 114 | } 115 | 116 | export const refreshAllState = () => { 117 | panelPostMessage({ 118 | action: PANEL_TO_BACKEND_MESSAGES.REFRESH_ALL_STATE, 119 | source: HOTWIRE_DEV_TOOLS_PANEL_SOURCE, 120 | }) 121 | } 122 | -------------------------------------------------------------------------------- /src/components/Stimulus/ValueTreeItem.svelte: -------------------------------------------------------------------------------- 1 | 79 | 80 |
81 | 82 | 83 | {#if isComplex(valueObject.value)} 84 | {valueObject.name} 85 | {#if isArray(valueObject.value)} 86 | Array ({valueObject.value.length}) 87 | {/if} 88 | 89 | {:else} 90 |
91 | {valueObject.name}: 92 | handleEdit(["root"])} 97 | onSave={(newVal) => handleSave(["root"], newVal)} 98 | onCancel={() => handleCancel(["root"])} 99 | /> 100 |
101 | {/if} 102 | 103 | 104 | 105 |
106 |
107 | 108 | 109 |
110 |
Type: {valueObject.type}
111 |
112 | {dataAttribute} 113 | 114 |
115 |
116 | {`this.${valueObject.name}Value`} 117 | 118 |
119 |
120 | {`this.has${capitalizeFirstChar(valueObject)}Value`} 121 | 122 |
123 |
124 |
125 |
126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Hotwire Dev Tools 4 | 5 | **Hotwire Dev Tools is a browser extension designed to help developers inspect their Turbo and Stimulus applications.** 6 | 7 | screenshot 8 | 9 | [![Firefox](https://img.shields.io/badge/Firefox-FF7139?style=for-the-badge&logo=Firefox-Browser&logoColor=white)](https://addons.mozilla.org/en-US/firefox/addon/hotwire-dev-tools/) 10 | [![Google Chrome](https://img.shields.io/badge/Google%20Chrome-4285F4?style=for-the-badge&logo=GoogleChrome&logoColor=white)](https://chromewebstore.google.com/detail/hotwire-dev-tools/phdobjkbablgffmmgnjbmfbbofnbkajc) 11 | [![Safari](https://img.shields.io/badge/Safari-000000?style=for-the-badge&logo=Safari&logoColor=white)](https://apps.apple.com/ch/app/hotwire-dev-tools/id6503706225) 12 | 13 |
14 | 15 | ## Features 16 | 17 | **Turbo**: 18 | 19 | - Highlight Turbo Frames 20 | - Monitor incoming Turbo Streams 21 | - Display Turbo context information (Turbo Drive enabled, morphing enabled, ...) 22 | - Log all Turbo related events 23 | - Log warning when a Turbo Frame ID is not unique 24 | - Log warning when an element has `data-turbo-permanent` but no ID or a non-unique ID 25 | - Highlight Turbo Frame changes 26 | 27 | **Stimulus**: 28 | 29 | - Highlight Stimulus controllers 30 | - List all Stimulus controllers on the page 31 | - Log warning when a `data-controller` doesn't match any registered controller 32 | - Log warning when a Stimulus target is not nested within the corresponding controller 33 | 34 | ## Installation 35 | 36 | The extension can be installed at: 37 | 38 | - [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/hotwire-dev-tools/) 39 | - [Chrome Web Store](https://chromewebstore.google.com/detail/hotwire-dev-tools/phdobjkbablgffmmgnjbmfbbofnbkajc) 40 | - [App Store for Safari](https://apps.apple.com/ch/app/hotwire-dev-tools/id6503706225) 41 | 42 | ## Usage 43 | 44 | Once installed, click the extension icon (or press Alt+Shift+S) to open the DevTools options. 45 | From there, you can enable/disable the features you want to use. 46 | 47 | > [!NOTE] 48 | > On Firefox you may need to select "Always allow on example.com" to enable the extension on your site 49 | 50 | ## Development 51 | 52 | - Fork the project locally 53 | - `npm install` 54 | - `npm run dev` - to build the extension and watch for changes 55 | - `npm run build` - to bundle the extension into static files for production 56 | - `npm run format` - to format changes with Prettier 57 | 58 | > [!NOTE] 59 | > By default, the extension will be built for Chrome. To build for Firefox or Safari just add `firefox` or `safari` as an argument to the build command: `npm run build firefox` or `npm run build safari`. 60 | 61 | ### Test on Chrome 62 | 63 | 1. Open Chrome and navigate to `chrome://extensions/` 64 | 2. Enable Developer mode 65 | 3. Click on `Load unpacked` and select the `public` folder (make sure to build the extension first) 66 | 67 | ### Test on Firefox 68 | 69 | The easiest way is to make use of the [web-ext](https://extensionworkshop.com/documentation/develop/getting-started-with-web-ext/) tool: 70 | 71 | ```bash 72 | npm install --global web-ext 73 | 74 | cd public 75 | web-ext run 76 | ``` 77 | 78 | That will open a new Firefox instance with the extension installed and hot reloading enabled. 79 | 80 | ### Test on Safari 81 | 82 | First [configure Safari to run unsigned extensions](https://developer.apple.com/documentation/safariservices/safari_web_extensions/running_your_safari_web_extension#3744467): 83 | 84 | 1. Choose Safari > Settings 85 | 2. Select the Advanced tab 86 | 3. Check the "Show features for web developers" box 87 | 4. Select the Developer tab. 88 | 5. Check the Allow unsigned extensions box. 89 | 90 | This may depend on the version of macOS and Safari you are using. 91 | So if you can't find the settings, you may need to search for the specific version you are using. 92 | 93 | Then you can load the extension by following these steps: 94 | 95 | 1. Open Xcode 96 | 2. Choose "Open Existing Project" 97 | 3. Select the [xcode/HotwireDevTools.xcodeproj](./xcode/HotwireDevTools.xcodeproj) workspace (blue icon) 98 | 4. Build the project (you may need to select a team in the project settings -> Signing & Capabilities) 99 | 5. Open Safari > Settings > Extensions and enable the Hotwire Dev Tools extension 100 | 101 | ## Contributing 102 | 103 | Bug reports and pull requests are welcome on GitHub at https://github.com/leonvogt/hotwire-dev-tools. 104 | 105 | ### Coding Standards 106 | 107 | This project uses Prettier to format the code and ensure a consistent style. 108 | 109 | Please run `npm run format` prior to submitting pull requests. 110 | 111 | --- 112 | 113 | This Dev Tool were inspired by [turbo-devtool](https://github.com/lcampanari/turbo-devtools) and [turbo_boost-devtools](https://github.com/hopsoft/turbo_boost-devtools) 🙌 114 | -------------------------------------------------------------------------------- /src/lib/devtool.js: -------------------------------------------------------------------------------- 1 | import { loadCSS } from "$utils/utils" 2 | import { getContext, setContext } from "svelte" 3 | 4 | export default class Devtool { 5 | constructor(origin = null) { 6 | this.options = this.defaultOptions 7 | this.registeredStimulusControllers = [] 8 | this.turboDetails = {} 9 | 10 | this.origin = origin 11 | this.detailPanelCSSContent = null 12 | } 13 | 14 | setOptions = async () => { 15 | this.options = await this.getOptions() 16 | } 17 | 18 | getOptions = async () => { 19 | const globalOptions = await this.globalUserOptions() 20 | const originOptions = await this.originOptions() 21 | 22 | let options = originOptions || globalOptions || this.defaultOptions 23 | options = this.addMissingDefaultOptions(options) 24 | return options 25 | } 26 | 27 | globalUserOptions = async () => { 28 | const options = await chrome.storage.sync.get("options") 29 | return options?.options 30 | } 31 | 32 | originOptions = async () => { 33 | const pageOptions = await chrome.storage.sync.get(this.origin) 34 | return pageOptions[this.origin]?.options 35 | } 36 | 37 | saveOptions = async (options, saveToOriginStore = null) => { 38 | const newOptions = { ...this.options, ...options } 39 | let dataToStore = newOptions 40 | let key = "options" 41 | 42 | if (saveToOriginStore === null) { 43 | saveToOriginStore = await this.originOptionsExist() 44 | } 45 | 46 | if (saveToOriginStore) { 47 | dataToStore = this.origin ? { options: newOptions } : newOptions 48 | key = this.origin || "options" 49 | } 50 | 51 | chrome.storage.sync.set({ [key]: dataToStore }, () => { 52 | const error = chrome.runtime.lastError 53 | if (error) { 54 | if (error.message.includes("MAX_WRITE_OPERATIONS_PER_MINUTE")) { 55 | console.error("Hotwire Dev Tools: Whoops! We are sorry but you've reached the maximum number of options changes allowed per minute. Please try again later.") 56 | } else { 57 | console.error("Hotwire Dev Tools: Error while saving options:", error) 58 | } 59 | return 60 | } 61 | 62 | // Options were saved successfully 63 | this.options = newOptions 64 | }) 65 | } 66 | 67 | removeOptionsForOrigin = async () => { 68 | await chrome.storage.sync.remove(this.origin) 69 | } 70 | 71 | originOptionsExist = async () => { 72 | const options = await this.originOptions() 73 | return !!options 74 | } 75 | 76 | detailPanelCSS = async () => { 77 | if (this.detailPanelCSSContent) return this.detailPanelCSSContent 78 | 79 | this.detailPanelCSSContent = await loadCSS(chrome.runtime.getURL("styles/hotwire_dev_tools_detail_panel.css")) 80 | return this.detailPanelCSSContent 81 | } 82 | 83 | shouldRenderDetailPanel = () => { 84 | const { show, showStimulusTab, showTurboFrameTab, showTurboStreamTab } = this.options.detailPanel 85 | return show && (showStimulusTab || showTurboFrameTab || showTurboStreamTab) 86 | } 87 | 88 | addMissingDefaultOptions = (options) => { 89 | if (options.addOptionsForVersion === this.version) return options 90 | 91 | const defaultOptions = this.defaultOptions 92 | for (const key in defaultOptions) { 93 | if (options[key] === undefined) { 94 | options[key] = defaultOptions[key] 95 | } 96 | } 97 | 98 | options.addOptionsForVersion = this.version 99 | return options 100 | } 101 | 102 | get version() { 103 | return chrome.runtime.getManifest().version 104 | } 105 | 106 | get isFirefox() { 107 | return navigator.userAgent.toLowerCase().indexOf("firefox") > -1 108 | } 109 | 110 | get defaultOptions() { 111 | return { 112 | turbo: { 113 | highlightFrames: false, 114 | highlightFramesOutlineWidth: "2px", 115 | highlightFramesOutlineStyle: "dashed", 116 | highlightFramesOutlineColor: "#5cd8e5", 117 | highlightFramesBlacklist: "", 118 | highlightFramesWithOverlay: false, 119 | highlightFramesChanges: false, 120 | ignoreEmptyFrames: false, 121 | consoleLogTurboStreams: false, 122 | }, 123 | stimulus: { 124 | highlightControllers: false, 125 | highlightControllersOutlineWidth: "2px", 126 | highlightControllersOutlineStyle: "dashed", 127 | highlightControllersOutlineColor: "#77e8b9", 128 | highlightControllersBlacklist: "", 129 | }, 130 | detailPanel: { 131 | show: false, 132 | showStimulusTab: true, 133 | showTurboFrameTab: true, 134 | showTurboStreamTab: true, 135 | collapsed: false, 136 | currentTab: "hotwire-dev-tools-stimulus-tab", 137 | }, 138 | monitor: { 139 | events: [], 140 | }, 141 | logWarnings: true, 142 | } 143 | } 144 | } 145 | 146 | const DEFAULT_KEY = "$_devtool_state" 147 | 148 | export const getDevtoolInstance = (key = DEFAULT_KEY) => { 149 | return getContext(key) 150 | } 151 | 152 | export const setDevtoolInstance = (key = DEFAULT_KEY) => { 153 | const devtoolInstance = new Devtool() 154 | return setContext(key, devtoolInstance) 155 | } 156 | -------------------------------------------------------------------------------- /src/utils/icons.js: -------------------------------------------------------------------------------- 1 | export const info = ` 2 | 3 | 4 | 5 | 6 | ` 7 | 8 | export const arrowUp = ` 9 | 10 | 11 | 12 | 13 | ` 14 | 15 | export const xmark = ` 16 | 17 | 18 | 19 | 20 | ` 21 | 22 | export const clock = ` 23 | 24 | 25 | 26 | 27 | ` 28 | 29 | export const inspectElement = ` 30 | 31 | 32 | 33 | 34 | ` 35 | 36 | export const refresh = ` 37 | 38 | 39 | 40 | 41 | ` 42 | 43 | export const copy = ` 44 | 45 | 46 | 47 | 48 | ` 49 | 50 | export const trash = ` 51 | 52 | 53 | 54 | 55 | ` 56 | 57 | export const check = ` 58 | 59 | 60 | 61 | 62 | ` 63 | -------------------------------------------------------------------------------- /src/browser_panel/panel/App.svelte: -------------------------------------------------------------------------------- 1 | 53 | 54 | {#if connection.isPermanentlyDisconnected} 55 | 56 | 57 | Connection Timeout
58 | Unable to connect to the current page. Try closing and reopening the inspection panel to resolve the issue. 59 |
60 | If this issue persists, consider reporting it on GitHub: 61 |
62 | https://github.com/leonvogt/hotwire-dev-tools/issues/new 63 |
64 |
65 | {:else if devToolOptionsLoaded} 66 |
67 |
68 | 82 | 83 |
84 | 85 |
86 | 87 |
88 | 89 |
90 | 91 |
92 |

Native

93 |
94 | 95 |
96 | 97 |
98 |
99 |
100 | {:else} 101 |
102 |
103 |
Setting up Hotwire DevTool...
104 |

105 |
106 |
107 |
Hmm... this is taking a bit longer than expected.
108 |
Check the browser console for any errors.
109 | {#if __IS_CHROME__} 110 |
Right-click anywhere on this panel → Inspect → Console
111 | {/if} 112 | 113 | {#if __IS_FIREFOX__} 114 |
If there isn't something showing up, you can also try to inspect the extension itself by:
115 |
116 |
117 | Open the "Debug Add-ons" page in Firefox: about:debugging#/runtime/this-firefox 118 |
119 |
Then clicking on the "Inspect" button next to the Hotwire DevTool. There you should see the console output.
120 | {/if} 121 | 122 | {#if __IS_SAFARI__} 123 |
124 | On Safari, you might need to enable the inspection option first by: 125 |
126 | Safari Web Inspector Setting (Gear Icon on the top right) → Experimental → Allow Inspecting Web Inspector 127 |
128 | {/if} 129 | 130 |
131 | Found something weird? Please open an issue on GitHub: 132 |
133 | https://github.com/leonvogt/hotwire-dev-tools/issues/new 134 |
135 |
136 |
137 | {/if} 138 | -------------------------------------------------------------------------------- /src/browser_panel/panel/panel.js: -------------------------------------------------------------------------------- 1 | // Entry point for the DevTools panel. 2 | // Initializes the Svelte app, injects the backend script into the inspected page, 3 | // and establishes a connection to the background.js for communication. 4 | 5 | import App from "./App.svelte" 6 | import { mount } from "svelte" 7 | import { connection } from "../State.svelte.js" 8 | 9 | import { panelPostMessage, handleBackendToPanelMessage, devToolPanelName } from "../messaging" 10 | import { HOTWIRE_DEV_TOOLS_PANEL_SOURCE, PANEL_TO_BACKEND_MESSAGES } from "$lib/constants" 11 | 12 | // Mount Svelte app 13 | document.body.classList.toggle("dark", chrome.devtools.panels.themeName === "dark") 14 | export default mount(App, { target: document.querySelector("#app") }) 15 | 16 | let lastBackendMessageAt 17 | let currentPort = null 18 | let isConnecting = false 19 | let connectionAttempts = 0 20 | let healthCheckInterval = null 21 | let backendCheckInterval = null 22 | const maxConnectionAttempts = 10 23 | 24 | async function connect() { 25 | if (isConnecting) return 26 | 27 | if (connectionAttempts >= maxConnectionAttempts) { 28 | console.error(`Max connection attempts (${maxConnectionAttempts}) reached. Giving up.`) 29 | connection.isPermanentlyDisconnected = true 30 | clearIntervals() 31 | return 32 | } 33 | 34 | isConnecting = true 35 | connectionAttempts++ 36 | 37 | try { 38 | await injectBackendScript() 39 | currentPort = createConnection() 40 | console.log(`Connected successfully (attempt ${connectionAttempts})`) 41 | connection.connectedToBackend = true 42 | connectionAttempts = 0 43 | if (__IS_SAFARI__) { 44 | // Health checks are only needed for Safari because it doesn't trigger the `port.onDisconnect` event consistently. 45 | // More: https://github.com/leonvogt/hotwire-dev-tools/pull/123 46 | startHealthCheck() 47 | } 48 | } catch (error) { 49 | console.warn(`Connection failed (attempt ${connectionAttempts}/${maxConnectionAttempts}):`, error) 50 | connection.connectedToBackend = false 51 | 52 | if (connectionAttempts < maxConnectionAttempts) { 53 | setTimeout(() => { 54 | isConnecting = false 55 | connect() 56 | }, 500) 57 | } else { 58 | console.error(`Failed to connect after ${maxConnectionAttempts} attempts`) 59 | } 60 | isConnecting = false 61 | return 62 | } 63 | 64 | isConnecting = false 65 | } 66 | 67 | function createConnection() { 68 | const port = chrome.runtime.connect({ 69 | name: devToolPanelName(chrome.devtools.inspectedWindow.tabId), 70 | }) 71 | 72 | port.onDisconnect.addListener(() => { 73 | cleanup() 74 | clearIntervals() 75 | connection.connectedToBackend = false 76 | }) 77 | 78 | port.onMessage.addListener((message) => { 79 | lastBackendMessageAt = Date.now() 80 | handleBackendToPanelMessage(message, port) 81 | }) 82 | 83 | return port 84 | } 85 | 86 | function injectBackendScript() { 87 | const scriptId = "hotwire-dev-tools-backend-script" 88 | const scriptURL = chrome.runtime.getURL("/dist/browser_panel/page/backend.js") 89 | 90 | const injectionScript = ` 91 | (function() { 92 | if (document.getElementById('${scriptId}')) return 'already-injected'; 93 | 94 | const script = document.createElement('script'); 95 | script.src = "${scriptURL}"; 96 | script.id = "${scriptId}"; 97 | script.async = true; 98 | 99 | (document.head || document.documentElement).appendChild(script); 100 | return 'injected'; 101 | })() 102 | ` 103 | 104 | return new Promise((resolve, reject) => { 105 | chrome.devtools.inspectedWindow.eval(injectionScript, (result, error) => { 106 | if (error) { 107 | reject(new Error(`Injection failed: ${error.description || error.message}`)) 108 | } else { 109 | resolve() 110 | } 111 | }) 112 | }) 113 | } 114 | 115 | function reconnect() { 116 | if (connectionAttempts >= maxConnectionAttempts) { 117 | console.error("Reconnect - Max reconnection attempts reached. Stopping retries.") 118 | clearIntervals() 119 | connection.isPermanentlyDisconnected = true 120 | return 121 | } 122 | 123 | connectionAttempts++ 124 | setTimeout(connect, 200) 125 | } 126 | 127 | function setupReconnectionHandlers() { 128 | chrome.devtools.network.onNavigated.addListener(() => { 129 | console.log("Page navigated, reconnecting...") 130 | connection.connectedToBackend = false 131 | cleanup() 132 | reconnect() 133 | }) 134 | 135 | if (__IS_CHROME__) { 136 | chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { 137 | if (tabId === chrome.devtools.inspectedWindow.tabId && changeInfo.status === "complete" && !currentPort) { 138 | console.log("Tab reloaded, reconnecting...") 139 | connection.connectedToBackend = false 140 | cleanup() 141 | reconnect() 142 | } 143 | }) 144 | } 145 | } 146 | 147 | function startHealthCheck() { 148 | if (!currentPort) { 149 | console.warn("HealthCheck - Cannot start without an established connection.") 150 | return 151 | } 152 | 153 | clearIntervals() 154 | 155 | // Send every 0.5 seconds a health check message to the backend. 156 | healthCheckInterval = setInterval(() => { 157 | panelPostMessage({ 158 | action: PANEL_TO_BACKEND_MESSAGES.HEALTH_CHECK, 159 | source: HOTWIRE_DEV_TOOLS_PANEL_SOURCE, 160 | }) 161 | }, 500) 162 | 163 | // Check every second (plus a small buffer) if the backend is still responsive. 164 | backendCheckInterval = setInterval(() => { 165 | if (Date.now() - lastBackendMessageAt > 1100) { 166 | console.log("HealthCheck - Backend unresponsive, reconnecting...") 167 | connection.connectedToBackend = false 168 | reconnect() 169 | } 170 | }, 1100) 171 | } 172 | 173 | function clearIntervals() { 174 | clearInterval(healthCheckInterval) 175 | clearInterval(backendCheckInterval) 176 | } 177 | 178 | function cleanup() { 179 | connectionAttempts = 0 180 | isConnecting = false 181 | 182 | if (currentPort) { 183 | currentPort.disconnect() 184 | currentPort = null 185 | } 186 | } 187 | 188 | setupReconnectionHandlers() 189 | connect() 190 | -------------------------------------------------------------------------------- /public/styles/panel/utilities.css: -------------------------------------------------------------------------------- 1 | .d-flex { 2 | display: flex; 3 | } 4 | .justify-content-center { 5 | justify-content: center; 6 | } 7 | .justify-content-between { 8 | justify-content: space-between; 9 | } 10 | .justify-content-end { 11 | justify-content: flex-end; 12 | } 13 | .align-items-center { 14 | align-items: center; 15 | } 16 | .align-items-top { 17 | align-items: flex-start; 18 | } 19 | .flex-column { 20 | flex-direction: column; 21 | } 22 | .flex-center { 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | } 27 | 28 | .cursor-pointer { 29 | cursor: pointer; 30 | } 31 | 32 | .d-none { 33 | display: none; 34 | } 35 | 36 | .button-as-link { 37 | border: unset; 38 | background-color: unset; 39 | color: inherit; 40 | } 41 | 42 | .scrollable-list { 43 | overflow-y: auto; 44 | max-height: 100%; 45 | scrollbar-width: thin; 46 | } 47 | 48 | .h-100 { 49 | height: 100%; 50 | } 51 | 52 | .h-100vh { 53 | height: 100vh; 54 | } 55 | 56 | .h-inherit { 57 | height: inherit; 58 | } 59 | 60 | .visibility-hidden { 61 | visibility: hidden; 62 | } 63 | 64 | .w-100 { 65 | width: 100%; 66 | } 67 | 68 | .max-w-100 { 69 | max-width: 100%; 70 | } 71 | 72 | .overflow-hidden { 73 | overflow: hidden; 74 | } 75 | .overflow-x-hidden { 76 | overflow-x: hidden; 77 | } 78 | .overflow-x-auto { 79 | overflow-x: auto; 80 | } 81 | .white-space-nowrap { 82 | white-space: nowrap; 83 | } 84 | 85 | .scrollbar-none { 86 | scrollbar-width: none; 87 | } 88 | 89 | .text-align-center { 90 | text-align: center; 91 | } 92 | 93 | .text-align-right { 94 | text-align: right; 95 | } 96 | 97 | .text-muted { 98 | color: var(--wa-color-text-quiet); 99 | } 100 | 101 | .p-0 { 102 | padding: 0; 103 | } 104 | .p-1 { 105 | padding: 0.25rem; 106 | } 107 | .p-2 { 108 | padding: 0.5rem; 109 | } 110 | .p-3 { 111 | padding: 1rem; 112 | } 113 | .p-4 { 114 | padding: 1.5rem; 115 | } 116 | .p-5 { 117 | padding: 3rem; 118 | } 119 | .ps-0 { 120 | padding-left: 0; 121 | } 122 | .ps-1 { 123 | padding-left: 0.25rem; 124 | } 125 | .ps-2 { 126 | padding-left: 0.5rem; 127 | } 128 | .ps-3 { 129 | padding-left: 1rem; 130 | } 131 | .ps-4 { 132 | padding-left: 1.5rem; 133 | } 134 | .ps-5 { 135 | padding-left: 3rem; 136 | } 137 | .pe-0 { 138 | padding-right: 0; 139 | } 140 | .pe-1 { 141 | padding-right: 0.25rem; 142 | } 143 | .pe-2 { 144 | padding-right: 0.5rem; 145 | } 146 | .pe-3 { 147 | padding-right: 1rem; 148 | } 149 | .pe-4 { 150 | padding-right: 1.5rem; 151 | } 152 | .pe-5 { 153 | padding-right: 3rem; 154 | } 155 | .pt-0 { 156 | padding-top: 0; 157 | } 158 | .pb-0 { 159 | padding-bottom: 0; 160 | } 161 | .py-0 { 162 | padding-top: 0; 163 | padding-bottom: 0; 164 | } 165 | .py-1 { 166 | padding-top: 0.25rem; 167 | padding-bottom: 0.25rem; 168 | } 169 | .py-2 { 170 | padding-top: 0.5rem; 171 | padding-bottom: 0.5rem; 172 | } 173 | .py-3 { 174 | padding-top: 1rem; 175 | padding-bottom: 1rem; 176 | } 177 | .py-4 { 178 | padding-top: 1.5rem; 179 | padding-bottom: 1.5rem; 180 | } 181 | .py-5 { 182 | padding-top: 3rem; 183 | padding-bottom: 3rem; 184 | } 185 | 186 | .mt-1 { 187 | margin-top: 0.25rem; 188 | } 189 | .mt-2 { 190 | margin-top: 0.5rem; 191 | } 192 | .mt-3 { 193 | margin-top: 1rem; 194 | } 195 | .mt-4 { 196 | margin-top: 1.5rem; 197 | } 198 | 199 | .mt-5 { 200 | margin-top: 3rem; 201 | } 202 | .mb-1 { 203 | margin-bottom: 0.25rem; 204 | } 205 | .mb-2 { 206 | margin-bottom: 0.5rem; 207 | } 208 | .mb-3 { 209 | margin-bottom: 1rem; 210 | } 211 | .mb-4 { 212 | margin-bottom: 1.5rem; 213 | } 214 | .mb-5 { 215 | margin-bottom: 3rem; 216 | } 217 | 218 | .ms-1 { 219 | margin-left: 0.25rem; 220 | } 221 | .ms-2 { 222 | margin-left: 0.5rem; 223 | } 224 | .ms-3 { 225 | margin-left: 1rem; 226 | } 227 | .me-1 { 228 | margin-right: 0.25rem; 229 | } 230 | .me-2 { 231 | margin-right: 0.5rem; 232 | } 233 | .me-3 { 234 | margin-right: 1rem; 235 | } 236 | 237 | .m-0 { 238 | margin: 0; 239 | } 240 | .m-1 { 241 | margin: 0.25rem; 242 | } 243 | .m-2 { 244 | margin: 0.5rem; 245 | } 246 | .m-3 { 247 | margin: 1rem; 248 | } 249 | .m-4 { 250 | margin: 1.5rem; 251 | } 252 | .m-5 { 253 | margin: 3rem; 254 | } 255 | .gap-0 { 256 | gap: 0; 257 | } 258 | .gap-1 { 259 | gap: 0.25rem; 260 | } 261 | .gap-2 { 262 | gap: 0.5rem; 263 | } 264 | .gap-3 { 265 | gap: 1rem; 266 | } 267 | .gap-4 { 268 | gap: 1.5rem; 269 | } 270 | .gap-5 { 271 | gap: 3rem; 272 | } 273 | 274 | .position-relative { 275 | position: relative; 276 | } 277 | 278 | .position-absolute { 279 | position: absolute; 280 | } 281 | 282 | .end-0 { 283 | right: 0; 284 | } 285 | 286 | .fs-100 { 287 | font-size: var(--fs-100); 288 | } 289 | .fs-200 { 290 | font-size: var(--fs-200); 291 | } 292 | .fs-300 { 293 | font-size: var(--fs-300); 294 | } 295 | .fs-400 { 296 | font-size: var(--fs-400); 297 | } 298 | .fs-500 { 299 | font-size: var(--fs-500); 300 | } 301 | .fs-600 { 302 | font-size: var(--fs-600); 303 | } 304 | .fs-700 { 305 | font-size: var(--fs-700); 306 | } 307 | .fs-800 { 308 | font-size: var(--fs-800); 309 | } 310 | .fs-900 { 311 | font-size: var(--fs-900); 312 | } 313 | 314 | .code-keyword { 315 | background: var(--wa-color-surface-lowered); 316 | width: fit-content; 317 | border-radius: 3px; 318 | padding: 2px 5px; 319 | font-weight: 600; 320 | } 321 | 322 | .scrollable-y { 323 | overflow-y: auto; 324 | scrollbar-width: thin; 325 | } 326 | 327 | .show-in-200ms { 328 | animation: showIn 0.3s ease forwards; 329 | animation-delay: 0.2s; 330 | opacity: 0; 331 | visibility: hidden; 332 | } 333 | 334 | .show-in-1000ms { 335 | animation: showIn 0.3s ease forwards; 336 | animation-delay: 1s; 337 | opacity: 0; 338 | visibility: hidden; 339 | } 340 | 341 | @keyframes showIn { 342 | to { 343 | opacity: 1; 344 | visibility: visible; 345 | } 346 | } 347 | 348 | .d-table-row { 349 | display: table-row; 350 | } 351 | 352 | .code-key { 353 | color: var(--wa-color-blue-60); 354 | } 355 | .code-value { 356 | color: var(--wa-color-purple-50); 357 | } 358 | 359 | .error-text-underline { 360 | text-decoration: underline wavy #f14c4c; 361 | text-decoration-thickness: 1px; 362 | text-underline-offset: 2px; 363 | } 364 | -------------------------------------------------------------------------------- /src/utils/utils.js: -------------------------------------------------------------------------------- 1 | export const getMetaElement = (name) => { 2 | return document.querySelector(`meta[name="${name}"]`) 3 | } 4 | 5 | export const getMetaContent = (name) => { 6 | const element = getMetaElement(name) 7 | return element && element.content 8 | } 9 | 10 | export const debounce = (fn, delay) => { 11 | let timeoutId = null 12 | 13 | return (...args) => { 14 | const callback = () => fn.apply(this, args) 15 | clearTimeout(timeoutId) 16 | timeoutId = setTimeout(callback, delay) 17 | } 18 | } 19 | 20 | export const loadCSS = async (url) => { 21 | return fetch(url) 22 | .then((response) => response.text()) 23 | .then((css) => css) 24 | .catch((error) => console.error("Hotwire Dev Tools: Error loading CSS", error)) 25 | } 26 | 27 | export const inspectElement = (selector) => { 28 | chrome.devtools.inspectedWindow.eval(`inspect(document.querySelector('${selector}'))`) 29 | } 30 | 31 | export const stringifyHTMLElementTag = (element, createClosingTag = true) => { 32 | if (!(element instanceof Element)) { 33 | throw new Error("Expected an Element") 34 | } 35 | 36 | const attributes = Array.from(element.attributes) 37 | .filter(({ name }) => name !== "data-hotwire-dev-tools-id") 38 | .map((attr) => `${attr.name}="${attr.value}"`) 39 | .join(" ") 40 | 41 | const tagName = element.tagName.toLowerCase() 42 | let string = `<${tagName}${attributes ? " " + attributes : ""}>` 43 | if (createClosingTag) { 44 | string += `` 45 | } 46 | 47 | return string 48 | } 49 | 50 | export const stringifyHTMLElementTagShallow = (element) => { 51 | if (!(element instanceof Element)) { 52 | throw new Error("Expected an Element") 53 | } 54 | const tagName = element.tagName.toLowerCase() 55 | const id = element.id ? ` id="${element.id}"` : "" 56 | return `<${tagName}${id} ...>` 57 | } 58 | 59 | export const generateUUID = () => { 60 | // 8 Chars should be enough for our use case 61 | // Orignal UUIDs could make the inspection tab quite noisy 62 | return Date.now().toString(36) + Math.random().toString(36).slice(2, 5) 63 | } 64 | 65 | // Copy to clipboard is bit tricky from a devtool panel context. 66 | // Currently, we're using the common trick of creating a textarea and using the `document.execCommand("copy")` method, which is deprecated. 67 | // Problem: When calling `navigator.clipboard.writeText` from: 68 | // - a devtools panel context, it throws the error: "The Clipboard API has been blocked because of a permissions policy applied to the current document." 69 | // - a content script context, it throws the error: "Failed to execute 'writeText' on 'Clipboard': Document is not focused" 70 | // In the future, we should look into how we can improve this. 71 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard 72 | export const copyToClipboard = (value) => { 73 | const textarea = document.createElement("textarea") 74 | textarea.value = value != null ? value : "" 75 | textarea.style.position = "absolute" 76 | textarea.style.opacity = "0" 77 | document.body.appendChild(textarea) 78 | textarea.select() 79 | document.execCommand("copy") 80 | textarea.remove() 81 | } 82 | 83 | export const handleKeyboardNavigation = (event, collection, currentIndex) => { 84 | let newIndex = currentIndex 85 | 86 | switch (event.key) { 87 | case "ArrowDown": 88 | event.preventDefault() 89 | newIndex = currentIndex < collection.length - 1 ? currentIndex + 1 : 0 90 | break 91 | case "ArrowUp": 92 | event.preventDefault() 93 | newIndex = currentIndex > 0 ? currentIndex - 1 : collection.length - 1 94 | break 95 | case "Home": 96 | event.preventDefault() 97 | newIndex = 0 98 | break 99 | case "End": 100 | event.preventDefault() 101 | newIndex = collection.length - 1 102 | break 103 | case "Enter": 104 | event.preventDefault() 105 | newIndex = currentIndex 106 | break 107 | default: 108 | return 109 | } 110 | 111 | return newIndex 112 | } 113 | 114 | export const getUUIDFromElement = (element) => { 115 | const uuid = element.getAttribute("data-hotwire-dev-tools-id") 116 | return uuid || null 117 | } 118 | 119 | export const setUUIDToElement = (element) => { 120 | const uuid = generateUUID() 121 | element.setAttribute("data-hotwire-dev-tools-id", uuid) 122 | return uuid 123 | } 124 | 125 | export const ensureUUIDOnElement = (element) => { 126 | let uuid = getUUIDFromElement(element) 127 | if (!uuid) { 128 | uuid = setUUIDToElement(element) 129 | } 130 | return uuid 131 | } 132 | 133 | export const getElementPath = (element) => { 134 | const path = [] 135 | while (element && element.parentElement) { 136 | const siblings = Array.from(element.parentElement.children) 137 | const index = siblings.indexOf(element) 138 | path.unshift(index) 139 | element = element.parentElement 140 | } 141 | return path 142 | } 143 | 144 | export const getElementFromIndexPath = (path) => { 145 | let element = document.documentElement 146 | for (const index of path) { 147 | element = element.children[index] 148 | if (!element) return null 149 | } 150 | return element 151 | } 152 | 153 | export const safeStringifyEventDetail = (detail) => { 154 | const seen = new WeakSet() 155 | return JSON.parse( 156 | JSON.stringify(detail, (key, value) => { 157 | if (typeof value === "object" && value !== null) { 158 | if (seen.has(value)) { 159 | return "[Circular Reference]" 160 | } 161 | seen.add(value) 162 | } 163 | // Remove problematic DOM elements and functions 164 | if (value instanceof HTMLElement || typeof value === "function") { 165 | return "[Object]" 166 | } 167 | return value 168 | }), 169 | ) 170 | } 171 | 172 | export const capitalizeFirstChar = (str) => { 173 | if (typeof str !== "string" || str === undefined || str.length === 0) { 174 | return str 175 | } 176 | return str.charAt(0).toUpperCase() + str.slice(1) 177 | } 178 | 179 | export const selectorByUUID = (uuid) => { 180 | return `[data-hotwire-dev-tools-id="${uuid}"]` 181 | } 182 | 183 | export const serializeAttributes = (element) => { 184 | return Array.from(element.attributes).reduce((map, attr) => { 185 | map[attr.name] = attr.value 186 | return map 187 | }, {}) 188 | } 189 | -------------------------------------------------------------------------------- /src/browser_panel/page/stimulus_observer.js: -------------------------------------------------------------------------------- 1 | import { ensureUUIDOnElement, getUUIDFromElement, capitalizeFirstChar, serializeAttributes } from "$utils/utils.js" 2 | 3 | export default class StimulusObserver { 4 | constructor(delegate) { 5 | this.delegate = delegate 6 | this.controllerElements = new Map() // UUID -> [controller data] 7 | } 8 | 9 | matchElement(element) { 10 | const controllerValue = element.dataset?.controller 11 | if (controllerValue === undefined) return false 12 | const identifiers = controllerValue 13 | .split(" ") 14 | .map((id) => id.trim()) 15 | .filter((id) => id.length > 0) 16 | return identifiers.length > 0 17 | } 18 | 19 | matchElementsInTree(tree) { 20 | const match = this.matchElement(tree) ? [tree] : [] 21 | const matches = Array.from(tree.querySelectorAll("*")).filter((el) => this.matchElement(el)) 22 | return match.concat(matches) 23 | } 24 | 25 | elementMatched(element) { 26 | const identifiers = (element.dataset.controller || "") 27 | .split(" ") 28 | .map((id) => id.trim()) 29 | .filter((id) => id.length > 0) 30 | 31 | const uuid = ensureUUIDOnElement(element) 32 | if (!this.controllerElements.has(uuid)) { 33 | this.controllerElements.set(uuid, []) 34 | } 35 | identifiers.forEach((identifier) => { 36 | const controllerData = this.buildStimulusElementData(element, identifier) 37 | this.controllerElements.get(uuid).push(controllerData) 38 | }) 39 | this.delegate.stimulusDataChanged() 40 | } 41 | 42 | elementUnmatched(element) { 43 | const uuid = getUUIDFromElement(element) 44 | 45 | if (this.controllerElements.has(uuid)) { 46 | this.controllerElements.delete(uuid) 47 | this.delegate.stimulusDataChanged() 48 | } 49 | } 50 | 51 | elementAttributeChanged(element, attributeName, oldValue) { 52 | if (this.matchElement(element)) { 53 | const uuid = getUUIDFromElement(element) 54 | if (this.controllerElements.has(uuid)) { 55 | const newControllerElementData = this.controllerElements.get(uuid).map((controllerData) => { 56 | return this.buildStimulusElementData(element, controllerData.identifier) 57 | }) 58 | this.controllerElements.set(uuid, newControllerElementData) 59 | this.delegate.stimulusDataChanged() 60 | } 61 | } 62 | } 63 | 64 | buildStimulusElementData(element, identifier) { 65 | const controller = window.Stimulus?.getControllerForElementAndIdentifier(element, identifier) 66 | return { 67 | id: element.id, 68 | uuid: getUUIDFromElement(element), 69 | identifier: identifier, 70 | attributes: serializeAttributes(element), 71 | tagName: element.tagName.toLowerCase(), 72 | values: this.buildControllerValues(controller), 73 | targets: this.buildControllerTargets(controller), 74 | outlets: this.buildControllerOutlets(controller), 75 | classes: this.buildControllerClasses(controller), 76 | actions: this.buildControllerActions(controller), 77 | element, 78 | } 79 | } 80 | 81 | buildControllerValues(controller) { 82 | if (!controller || !controller.valueDescriptorMap) return [] 83 | 84 | return Object.values(controller.valueDescriptorMap).map((descriptor) => { 85 | return { 86 | key: descriptor.key, 87 | name: descriptor.name, 88 | type: descriptor.type, 89 | defaultValue: descriptor.defaultValue, 90 | value: controller[descriptor.name], 91 | } 92 | }) 93 | } 94 | 95 | buildControllerTargets(controller) { 96 | if (!controller) return [] 97 | 98 | const keys = Object.keys(Object.getOwnPropertyDescriptors(Object.getPrototypeOf(controller))) 99 | const targetKeys = keys.filter((key) => key.endsWith("Target") && !key.startsWith("has")) 100 | return targetKeys.map((targetKey) => { 101 | const targets = controller[`has${capitalizeFirstChar(targetKey)}`] ? controller[`${targetKey}s`] : [] 102 | return { 103 | name: targetKey, 104 | key: targetKey.replace("Target", ""), 105 | elements: Array.from(targets).map((target) => { 106 | return { 107 | id: target.id, 108 | uuid: ensureUUIDOnElement(target), 109 | attributes: serializeAttributes(target), 110 | tagName: target.tagName.toLowerCase(), 111 | } 112 | }), 113 | } 114 | }) 115 | } 116 | 117 | buildControllerOutlets(controller) { 118 | if (!controller) return [] 119 | 120 | const keys = Object.keys(Object.getOwnPropertyDescriptors(Object.getPrototypeOf(controller))) 121 | const outletKeys = keys.filter((key) => key.endsWith("Outlet") && !key.startsWith("has")) 122 | return outletKeys.map((outletKey) => { 123 | const outlets = controller[`has${capitalizeFirstChar(outletKey)}`] ? controller[`${outletKey}s`] : [] 124 | const key = outletKey.replace("Outlet", "") 125 | return { 126 | name: outletKey, 127 | key: key, 128 | selector: controller.outlets.getSelectorForOutletName(key), 129 | elements: Array.from(outlets).map((outlet) => { 130 | return { 131 | id: outlet.id, 132 | uuid: ensureUUIDOnElement(outlet.element), 133 | attributes: serializeAttributes(outlet.element), 134 | tagName: outlet.element.tagName.toLowerCase(), 135 | } 136 | }), 137 | } 138 | }) 139 | } 140 | 141 | buildControllerClasses(controller) { 142 | if (!controller) return [] 143 | 144 | const keys = Object.keys(Object.getOwnPropertyDescriptors(Object.getPrototypeOf(controller))) 145 | const classKeys = keys.filter((key) => key.endsWith("Class") && !key.startsWith("has")) 146 | return classKeys.map((classKey) => { 147 | const classes = controller[`has${capitalizeFirstChar(classKey)}`] ? controller[`${classKey}es`] : [] 148 | const key = classKey.replace("Class", "") 149 | return { 150 | name: classKey, 151 | key: key, 152 | classes: Array.from(classes), 153 | } 154 | }) 155 | } 156 | 157 | buildControllerActions(controller) { 158 | if (!controller?.context?.bindingObserver) return [] 159 | 160 | return controller.context.bindingObserver.bindings.map((binding) => { 161 | const action = binding.action 162 | return { 163 | descriptor: action.toString(), 164 | eventName: action.eventName, 165 | methodName: action.methodName, 166 | element: { 167 | id: action.element.id || null, 168 | attributes: serializeAttributes(action.element), 169 | tagName: action.element.tagName.toLowerCase(), 170 | classes: Array.from(action.element.classList), 171 | uuid: ensureUUIDOnElement(action.element), 172 | }, 173 | keyFilter: action.keyFilter || null, 174 | eventTarget: action.eventTargetName || "element", 175 | params: action.params, 176 | hasParams: Object.keys(action.params).length > 0, 177 | } 178 | }) 179 | } 180 | 181 | getStimulusData() { 182 | const allControllers = [] 183 | 184 | this.controllerElements.forEach((controllersData) => { 185 | controllersData.forEach((controllerData) => { 186 | allControllers.push(controllerData) 187 | }) 188 | }) 189 | 190 | // Remove DOM elements before sending 191 | const stripDOMElements = (data) => { 192 | const { element, ...cleanData } = data 193 | return cleanData 194 | } 195 | 196 | return allControllers.map((controller) => stripDOMElements(controller)) 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /public/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
v0.0.0
13 | 14 |
15 |

Hotwire Dev Tools

16 |
17 | 18 |
19 | 24 |
25 | 26 |
27 | Turbo 28 |
29 | 34 | 35 |
36 |
37 | 49 | 50 | 60 | 61 |
62 | 63 |
64 | 65 |
66 | 67 | 68 |
69 | 70 |
71 | 72 | 73 |
74 | 75 | 76 |
77 | 78 | 83 |
84 |
85 | 86 |
87 | Stimulus 88 |
89 | 94 | 95 |
96 |
97 | 103 | 104 | 114 | 115 |
116 | 117 |
118 | 119 | 120 |
121 |
122 |
123 | 124 |
125 | Detail Panel 126 |
127 | 132 | 133 |
134 |
135 | 136 | 137 |
138 |
139 | 140 | 141 |
142 |
143 | 144 | 145 |
146 |
147 |
148 |
149 | 150 |
151 | Console Log 152 |
153 | 158 | 159 | 164 | 165 | 170 |
171 |
172 | 173 |
174 |
175 |
176 |
177 |
178 | 179 | 180 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /public/styles/panel/main.css: -------------------------------------------------------------------------------- 1 | /* Importing fonts */ 2 | @font-face { 3 | font-family: "AirbnbCereal"; 4 | src: url("../../fonts/AirbnbCereal_W_Bd.otf") format("opentype"); 5 | font-weight: bold; 6 | } 7 | 8 | @font-face { 9 | font-family: "AirbnbCereal"; 10 | src: url("../../fonts/AirbnbCereal_W_Bk.otf") format("opentype"); 11 | font-weight: 300; 12 | } 13 | 14 | @font-face { 15 | font-family: "AirbnbCereal"; 16 | src: url("../../fonts/AirbnbCereal_W_Blk.otf") format("opentype"); 17 | font-weight: 900; 18 | } 19 | 20 | @font-face { 21 | font-family: "AirbnbCereal"; 22 | src: url("../../fonts/AirbnbCereal_W_Lt.otf") format("opentype"); 23 | font-weight: 200; 24 | } 25 | 26 | @font-face { 27 | font-family: "AirbnbCereal"; 28 | src: url("../../fonts/AirbnbCereal_W_Md.otf") format("opentype"); 29 | font-weight: 500; 30 | } 31 | 32 | @font-face { 33 | font-family: "AirbnbCereal"; 34 | src: url("../../fonts/AirbnbCereal_W_XBd.otf") format("opentype"); 35 | font-weight: 800; 36 | } 37 | 38 | /* General */ 39 | :root { 40 | --color-tag: #80a3e6; 41 | --color-tag-id: #ef9364; 42 | --color-tag-class: #ef9364; 43 | --color-tag-attribute-name: #d44fba; 44 | 45 | --fs-100: 0.625rem; 46 | --fs-200: 0.75rem; 47 | --fs-300: 0.875rem; 48 | --fs-400: 1rem; 49 | --fs-500: 1.125rem; 50 | --fs-600: 1.25rem; 51 | --fs-700: 1.5rem; 52 | --fs-800: 1.875rem; 53 | --fs-900: 2.25rem; 54 | 55 | --wa-border-width-scale: 1; 56 | --wa-space-scale: 0.625; 57 | } 58 | 59 | body { 60 | margin: 0; 61 | font-family: 62 | "AirbnbCereal", 63 | -apple-system, 64 | BlinkMacSystemFont, 65 | Roboto, 66 | Helvetica Neue, 67 | sans-serif; 68 | } 69 | 70 | .navbar { 71 | border: 0.125rem solid var(--wa-color-surface-border); 72 | width: 7.5rem; 73 | display: flex; 74 | flex-direction: column; 75 | 76 | button { 77 | background-color: inherit; 78 | color: var(--wa-color-neutral-on-quiet); 79 | justify-content: start; 80 | border-radius: 0; 81 | border: none; 82 | outline: none; 83 | height: 4rem; 84 | } 85 | 86 | button.active { 87 | color: var(--wa-color-brand-on-quiet); 88 | border-inline-start: solid 0.125rem var(--wa-color-brand-fill-loud); 89 | } 90 | 91 | /* Theme toggle button styling */ 92 | .theme-toggle-btn { 93 | margin-top: auto; 94 | cursor: pointer; 95 | transition: background-color 0.2s ease; 96 | } 97 | 98 | .theme-toggle-btn:hover { 99 | background-color: var(--wa-color-surface-hover) !important; 100 | } 101 | 102 | .theme-toggle-btn wa-icon { 103 | margin: auto; 104 | } 105 | } 106 | .portrait .navbar { 107 | flex-direction: row; 108 | width: 100%; 109 | position: sticky; 110 | top: 0; 111 | } 112 | 113 | .tab-content { 114 | display: none; 115 | width: 100%; 116 | height: 100vh; 117 | overflow: auto; 118 | 119 | &.active { 120 | display: block; 121 | } 122 | } 123 | 124 | #container { 125 | width: 100%; 126 | display: flex; 127 | flex-direction: row; 128 | overflow: hidden; 129 | } 130 | .portrait #container { 131 | flex-direction: column; 132 | height: 100vh; 133 | } 134 | 135 | .splitpanes.default-theme .splitpanes__pane { 136 | background-color: transparent !important; 137 | padding: 0 !important; 138 | } 139 | 140 | .splitpanes.default-theme .splitpanes__splitter { 141 | background-color: var(--wa-color-surface-border) !important; 142 | border: none !important; 143 | } 144 | 145 | .splitpanes.default-theme .splitpanes__splitter::before, 146 | .splitpanes.default-theme .splitpanes__splitter::after { 147 | background-color: var(--wa-color-text-quiet) !important; 148 | } 149 | 150 | .btn-icon { 151 | background-color: transparent; 152 | border: none; 153 | color: white; 154 | display: flex; 155 | align-items: center; 156 | justify-content: center; 157 | padding: 0.5em; 158 | height: 100%; 159 | } 160 | 161 | .btn-icon svg { 162 | width: 1rem; 163 | height: 1rem; 164 | } 165 | 166 | button.action-icon svg { 167 | fill: var(--wa-color-text-normal); 168 | } 169 | 170 | button.action-icon:active svg { 171 | fill: var(--wa-color-shadow); 172 | } 173 | 174 | .no-entry-hint { 175 | display: flex; 176 | justify-content: center; 177 | flex-direction: column; 178 | align-items: center; 179 | color: var(--wa-color-text-quiet); 180 | padding: 1em; 181 | height: 100%; 182 | } 183 | 184 | .pane-container { 185 | display: flex; 186 | flex-direction: column; 187 | height: 100%; 188 | } 189 | 190 | .pane-header { 191 | border-bottom: 3px solid var(--wa-color-surface-border); 192 | margin-bottom: 0.5em; 193 | height: 2.25rem; 194 | position: relative; 195 | } 196 | .pane-header wa-badge { 197 | padding: 0.15em 0.5em; 198 | } 199 | 200 | .pane-header-title { 201 | text-align: center; 202 | margin: 0; 203 | } 204 | 205 | .pane-scrollable-list { 206 | overflow-y: auto; 207 | max-height: 100%; 208 | scrollbar-width: thin; 209 | flex: 1; 210 | } 211 | 212 | .pane-scrollable-list .entry-row { 213 | padding-right: 12px; /* To avoid content being hidden under scrollbar */ 214 | } 215 | 216 | .entry-row { 217 | border-bottom: 1px solid var(--wa-color-surface-border); 218 | user-select: none; 219 | padding: 0.25rem; 220 | 221 | --indent-size: 1rem; 222 | --minimum-base-padding: 0.5rem; 223 | padding-left: calc(var(--depth, 0) * var(--indent-size) + var(--minimum-base-padding)); 224 | } 225 | .entry-row.leaf-node { 226 | --indent-size: 1.5rem; 227 | } 228 | 229 | .entry-row--table-layout { 230 | display: table; 231 | width: 100%; 232 | table-layout: fixed; 233 | white-space: nowrap; 234 | } 235 | 236 | .entry-row.sticky-parent { 237 | position: sticky; 238 | top: 0; 239 | z-index: 1; 240 | background-color: var(--wa-color-surface-raised); 241 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 242 | } 243 | 244 | .entry-row:hover { 245 | background-color: var(--wa-color-brand-fill-quiet); 246 | } 247 | .entry-row.selected { 248 | background-color: var(--wa-color-brand-fill-normal); 249 | border-left: 3px solid var(--wa-color-brand-fill-loud); 250 | } 251 | 252 | .entry-row .btn-hoverable { 253 | opacity: 0; 254 | pointer-events: none; 255 | } 256 | 257 | .entry-row:hover .btn-hoverable { 258 | opacity: 1; 259 | pointer-events: auto; 260 | } 261 | 262 | .entry-row:focus-visible { 263 | outline: none; 264 | } 265 | 266 | .html-preview { 267 | display: flex; 268 | max-width: 100%; 269 | overflow-y: auto; 270 | mask-type: alpha; 271 | -webkit-mask-image: linear-gradient(to right, black 12px, black calc(100% - 25px), transparent calc(100% - 8px), transparent calc(100% - 8px) 100%); 272 | mask-image: linear-gradient(to right, black 12px, black calc(100% - 25px), transparent calc(100% - 8px), transparent calc(100% - 8px) 100%); 273 | padding-bottom: 3rem; 274 | padding-right: 25px; 275 | margin-bottom: 1em; 276 | scrollbar-width: none; 277 | } 278 | 279 | .pane-section-heading { 280 | font-size: 0.9em; 281 | padding: 0.2em 0.2em; 282 | background-color: #333842; 283 | color: #ccc; 284 | font-weight: 600; 285 | margin-bottom: 0.25em; 286 | } 287 | .pane-scrollable-list .pane-section-heading:not(:first-child) { 288 | margin-top: 0.5em; 289 | } 290 | 291 | .table td { 292 | border-bottom: 1px solid var(--wa-color-surface-border); 293 | table-layout: fixed; 294 | } 295 | .table:focus-visible { 296 | outline: none; 297 | } 298 | .table.table-sm td { 299 | padding: 0.5rem; 300 | } 301 | 302 | .full-pane { 303 | display: flex; 304 | flex-direction: column; 305 | gap: 1em; 306 | height: 100%; 307 | padding: 1rem; 308 | box-sizing: border-box; 309 | } 310 | 311 | .card { 312 | display: flex; 313 | flex-direction: column; 314 | } 315 | .card-body { 316 | height: 100%; 317 | } 318 | 319 | .collapse-icon { 320 | font-size: var(--fs-400); 321 | transition: transform 0.3s ease; 322 | transform-origin: center center; 323 | } 324 | 325 | .collapse-icon.rotated { 326 | transform: rotate(-90deg); 327 | } 328 | .children-container { 329 | overflow: hidden; 330 | transition: height 0.3s ease-out; 331 | } 332 | 333 | .children-container.collapsed { 334 | display: block; 335 | } 336 | 337 | /* Web Awesome Overrides */ 338 | .wa-dark { 339 | --wa-color-surface-default: #282c34; 340 | } 341 | .small-icon-button::part(base) { 342 | padding: 0; 343 | margin: 0; 344 | height: 100%; 345 | width: 100%; 346 | } 347 | 348 | .small-icon-button:not(.disabled):not(.loading)::part(base):hover { 349 | background-color: transparent !important; 350 | } 351 | 352 | .count-badge.count-badge--small::part(base) { 353 | font-size: var(--fs-100); 354 | } 355 | 356 | .collapse-icon::part(base) { 357 | padding: 0.25rem; 358 | } 359 | 360 | /* Remove default selection styles from wa-tree-item */ 361 | .stimulus-detail-pane wa-tree-item[selected]::part(item) { 362 | background-color: var(--wa-color-surface-default); 363 | border-inline-start-color: var(--wa-color-surface-default); 364 | } 365 | 366 | wa-tree-item.w-100::part(label) { 367 | width: 100%; 368 | } 369 | 370 | wa-tooltip .copy-icon:not(.copied) { 371 | color: var(--wa-color-surface-raised) !important; 372 | } 373 | wa-input[size="extra-small"] { 374 | --wa-form-control-height: 100%; 375 | } 376 | -------------------------------------------------------------------------------- /public/styles/hotwire_dev_tools_detail_panel.css: -------------------------------------------------------------------------------- 1 | :host { 2 | all: initial; 3 | font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol" !important; 4 | font-size: 16px !important; 5 | color: black !important; 6 | --hotwire-dev-tools-muted-color: rgba(33, 37, 41, 0.749); 7 | --animate-duration: 1s; 8 | } 9 | 10 | sup { 11 | font-size: 0.75em; 12 | line-height: 0; 13 | } 14 | 15 | #hotwire-dev-tools-detail-panel-container { 16 | position: fixed; 17 | bottom: 0em; 18 | right: 0em; 19 | z-index: 10000000; 20 | width: clamp(20em, 30em, 100vw); 21 | background: white; 22 | 23 | & button { 24 | cursor: pointer; 25 | } 26 | 27 | .hotwire-dev-tools-detail-panel-header { 28 | height: 2.5em; 29 | background: #29292e; 30 | color: #e7e9f5; 31 | display: flex; 32 | 33 | & svg { 34 | height: 50%; 35 | } 36 | 37 | & path { 38 | fill: white; 39 | } 40 | } 41 | 42 | /* Tabs */ 43 | .hotwire-dev-tools-tablist { 44 | display: flex; 45 | justify-content: space-between; 46 | align-items: center; 47 | height: 100%; 48 | width: 100%; 49 | 50 | & button { 51 | background-color: inherit; 52 | font-size: 1em; 53 | border: none; 54 | outline: none; 55 | width: 100%; 56 | height: 100%; 57 | color: #dddddd; 58 | } 59 | } 60 | 61 | .hotwire-dev-tools-tablink.active[data-tab-id="hotwire-dev-tools-turbo-frame-tab"], 62 | .hotwire-dev-tools-tablink.active[data-tab-id="hotwire-dev-tools-turbo-stream-tab"] { 63 | color: #5cd8e5; 64 | } 65 | 66 | .hotwire-dev-tools-tablink.active[data-tab-id="hotwire-dev-tools-stimulus-tab"] { 67 | color: #77e8b9; 68 | } 69 | 70 | .hotwire-dev-tools-tablink.active[data-tab-id="hotwire-dev-tools-info-tab"] path { 71 | fill: #ff9b40; 72 | } 73 | 74 | .hotwire-dev-tools-tablink:has(svg) { 75 | width: fit-content !important; 76 | padding-left: 1em; 77 | padding-right: 1em; 78 | } 79 | 80 | .hotwire-dev-tools-tab-content { 81 | display: none; 82 | 83 | &.active { 84 | display: block; 85 | } 86 | } 87 | 88 | .hotwire-dev-tools-collapse-button { 89 | background: #808080; 90 | color: white; 91 | border: none; 92 | outline: none; 93 | padding-right: 0.5em; 94 | padding-left: 0.5em; 95 | width: 2em; 96 | } 97 | 98 | .hotwire-dev-tools-collapse-button:hover { 99 | color: black; 100 | } 101 | 102 | & .hotwire-dev-tools-tab-content { 103 | max-height: 10em; 104 | overflow-y: auto; 105 | overscroll-behavior: contain; 106 | } 107 | 108 | & .hotwire-dev-tools-entry { 109 | display: flex; 110 | justify-content: space-between; 111 | padding: 0.5em; 112 | cursor: default; 113 | color: black; 114 | } 115 | 116 | & .hotwire-dev-tools-entry.hotwire-dev-tools-entry-warning { 117 | color: #ff0000; 118 | } 119 | 120 | & .hotwire-dev-tools-entry sup { 121 | font-weight: 200; 122 | } 123 | 124 | & .hotwire-dev-tools-entry svg { 125 | height: 1em; 126 | } 127 | 128 | & .hotwire-dev-tools-entry.turbo-stream { 129 | cursor: pointer; 130 | } 131 | 132 | & .hotwire-dev-tools-entry:hover { 133 | background: #ccc; 134 | } 135 | 136 | & .hotwire-dev-tools-entry-details { 137 | padding: 0.5em; 138 | font-size: 0.9em; 139 | overflow-x: auto; 140 | color: black; 141 | } 142 | 143 | & .hotwire-dev-tools-entry-details pre, 144 | & .hotwire-dev-tools-entry-details code { 145 | white-space: pre-wrap; 146 | } 147 | 148 | & .hotwire-dev-tools-no-entry { 149 | display: flex; 150 | justify-content: center; 151 | flex-direction: column; 152 | align-items: center; 153 | color: var(--hotwire-dev-tools-muted-color); 154 | padding: 1em; 155 | } 156 | 157 | & .hotwire-dev-tools-entry-time { 158 | text-align: right; 159 | color: var(--hotwire-dev-tools-muted-color); 160 | } 161 | 162 | & .hotwire-dev-tools-entry-content { 163 | display: flex; 164 | justify-content: space-between; 165 | gap: 1em; 166 | } 167 | 168 | &.collapsed { 169 | height: 8px; 170 | transition: height 0.25s ease-out; 171 | } 172 | 173 | &.collapsed:hover { 174 | height: 2.5em; 175 | } 176 | } 177 | 178 | .info-tab-content { 179 | display: flex; 180 | justify-content: space-between; 181 | padding: 0.5em; 182 | 183 | .info-tab-content-stimulus, 184 | .info-tab-content-turbo { 185 | min-width: 45%; 186 | display: flex; 187 | flex-direction: column; 188 | gap: 0.5em; 189 | } 190 | 191 | & .info-title { 192 | font-size: 1.2em; 193 | } 194 | 195 | & .info-title { 196 | font-size: 1.1em; 197 | } 198 | 199 | & .info-tab-content-wrapper { 200 | justify-content: space-between; 201 | font-family: monospace; 202 | unicode-bidi: isolate; 203 | white-space: nowrap; 204 | font-size: 0.8em; 205 | display: flex; 206 | margin: 0; 207 | } 208 | } 209 | 210 | #hotwire-dev-tools-detail-panel-container:not(.collapsed) { 211 | button.hotwire-dev-tools-tablink:not(.active):hover { 212 | color: #777; 213 | } 214 | } 215 | 216 | .hotwire-dev-tools-detail-panel-header, 217 | .hotwire-dev-tools-tablink:first-child { 218 | border-top-left-radius: 10px; 219 | } 220 | 221 | #hotwire-dev-tools-detail-panel-container { 222 | border-top-left-radius: 15px; 223 | } 224 | 225 | #hotwire-dev-tools-detail-panel-container.collapsed { 226 | background: #29292e; 227 | } 228 | 229 | .text-ellipsis { 230 | white-space: nowrap; 231 | text-overflow: ellipsis; 232 | overflow: hidden; 233 | } 234 | 235 | .flex-column { 236 | display: flex; 237 | flex-direction: column; 238 | } 239 | 240 | .d-none { 241 | display: none; 242 | } 243 | 244 | /* Animations copied from animate.css ❤️ (https://animate.style/) */ 245 | @-webkit-keyframes headShake { 246 | 0% { 247 | -webkit-transform: translateX(0); 248 | transform: translateX(0); 249 | } 250 | 251 | 6.5% { 252 | -webkit-transform: translateX(-6px) rotateY(-9deg); 253 | transform: translateX(-6px) rotateY(-9deg); 254 | } 255 | 256 | 18.5% { 257 | -webkit-transform: translateX(5px) rotateY(7deg); 258 | transform: translateX(5px) rotateY(7deg); 259 | } 260 | 261 | 31.5% { 262 | -webkit-transform: translateX(-3px) rotateY(-5deg); 263 | transform: translateX(-3px) rotateY(-5deg); 264 | } 265 | 266 | 43.5% { 267 | -webkit-transform: translateX(2px) rotateY(3deg); 268 | transform: translateX(2px) rotateY(3deg); 269 | } 270 | 271 | 50% { 272 | -webkit-transform: translateX(0); 273 | transform: translateX(0); 274 | } 275 | } 276 | @keyframes headShake { 277 | 0% { 278 | -webkit-transform: translateX(0); 279 | transform: translateX(0); 280 | } 281 | 282 | 6.5% { 283 | -webkit-transform: translateX(-6px) rotateY(-9deg); 284 | transform: translateX(-6px) rotateY(-9deg); 285 | } 286 | 287 | 18.5% { 288 | -webkit-transform: translateX(5px) rotateY(7deg); 289 | transform: translateX(5px) rotateY(7deg); 290 | } 291 | 292 | 31.5% { 293 | -webkit-transform: translateX(-3px) rotateY(-5deg); 294 | transform: translateX(-3px) rotateY(-5deg); 295 | } 296 | 297 | 43.5% { 298 | -webkit-transform: translateX(2px) rotateY(3deg); 299 | transform: translateX(2px) rotateY(3deg); 300 | } 301 | 302 | 50% { 303 | -webkit-transform: translateX(0); 304 | transform: translateX(0); 305 | } 306 | } 307 | .animate__animated { 308 | -webkit-animation-duration: 1s; 309 | animation-duration: 1s; 310 | -webkit-animation-duration: var(--animate-duration); 311 | animation-duration: var(--animate-duration); 312 | -webkit-animation-fill-mode: both; 313 | animation-fill-mode: both; 314 | } 315 | .animate__headShake { 316 | -webkit-animation-timing-function: ease-in-out; 317 | animation-timing-function: ease-in-out; 318 | -webkit-animation-name: headShake; 319 | animation-name: headShake; 320 | } 321 | 322 | /* Highlight.js GitHub Theme */ 323 | pre code.hljs { 324 | display: block; 325 | overflow-x: auto; 326 | padding: 1em; 327 | } 328 | code.hljs { 329 | padding: 3px 5px; 330 | } /*! 331 | Theme: GitHub 332 | Description: Light theme as seen on github.com 333 | Author: github.com 334 | Maintainer: @Hirse 335 | Updated: 2021-05-15 336 | 337 | Outdated base version: https://github.com/primer/github-syntax-light 338 | Current colors taken from GitHub's CSS 339 | */ 340 | .hljs { 341 | color: #24292e; 342 | background: #fff; 343 | } 344 | .hljs-doctag, 345 | .hljs-keyword, 346 | .hljs-meta .hljs-keyword, 347 | .hljs-template-tag, 348 | .hljs-template-variable, 349 | .hljs-type, 350 | .hljs-variable.language_ { 351 | color: #d73a49; 352 | } 353 | .hljs-title, 354 | .hljs-title.class_, 355 | .hljs-title.class_.inherited__, 356 | .hljs-title.function_ { 357 | color: #6f42c1; 358 | } 359 | .hljs-attr, 360 | .hljs-attribute, 361 | .hljs-literal, 362 | .hljs-meta, 363 | .hljs-number, 364 | .hljs-operator, 365 | .hljs-selector-attr, 366 | .hljs-selector-class, 367 | .hljs-selector-id, 368 | .hljs-variable { 369 | color: #005cc5; 370 | } 371 | .hljs-meta .hljs-string, 372 | .hljs-regexp, 373 | .hljs-string { 374 | color: #032f62; 375 | } 376 | .hljs-built_in, 377 | .hljs-symbol { 378 | color: #e36209; 379 | } 380 | .hljs-code, 381 | .hljs-comment, 382 | .hljs-formula { 383 | color: #6a737d; 384 | } 385 | .hljs-name, 386 | .hljs-quote, 387 | .hljs-selector-pseudo, 388 | .hljs-selector-tag { 389 | color: #22863a; 390 | } 391 | .hljs-subst { 392 | color: #24292e; 393 | } 394 | .hljs-section { 395 | color: #005cc5; 396 | font-weight: 700; 397 | } 398 | .hljs-bullet { 399 | color: #735c0f; 400 | } 401 | .hljs-emphasis { 402 | color: #24292e; 403 | font-style: italic; 404 | } 405 | .hljs-strong { 406 | color: #24292e; 407 | font-weight: 700; 408 | } 409 | .hljs-addition { 410 | color: #22863a; 411 | background-color: #f0fff4; 412 | } 413 | .hljs-deletion { 414 | color: #b31d28; 415 | background-color: #ffeef0; 416 | } 417 | 418 | @media print { 419 | #hotwire-dev-tools-detail-panel-container { 420 | display: none !important; 421 | } 422 | } 423 | -------------------------------------------------------------------------------- /src/browser_panel/panel/tabs/LogsTab.svelte: -------------------------------------------------------------------------------- 1 | 116 | 117 | 118 | 119 |
120 |
121 |

Events

122 |
123 | 124 | 125 | 126 | 127 | {#each Object.entries(TURBO_EVENTS_GROUPED) as [groupName, events]} 128 | handleFilterGroupToggle(groupName)} onclick={() => handleFilterGroupToggle(groupName)}> 129 | {groupName} 130 | 131 | 132 | {#each events as event} 133 | handleFilterToggle(e)} 139 | onclick={(e) => handleFilterToggle(e)} 140 | value={event} 141 | checked={turboEventsFilter.includes(event)}>{event} 143 | {/each} 144 | {/each} 145 | 146 | {#if turboEvents.length > 0} 147 | 148 | {/if} 149 |
150 |
151 | 152 | {#if turboEvents.length > 0} 153 |
154 | {#each turboEvents as event (event.uuid)} 155 |
setSelectedTurboEvent(event)} 163 | onkeydown={handleEventListKeyboardNavigation} 164 | onmouseenter={() => addHighlightOverlayByPath(event.targetElementPath)} 165 | onmouseleave={() => hideHighlightOverlay()} 166 | > 167 |
168 |
169 | {event.eventName} 170 |
171 | 172 |
173 |
{event.time}
174 |
175 |
176 |
177 |
178 | {/each} 179 |
180 | {:else} 181 |
182 | No Turbo Events spotted yet 183 | We'll keep looking 184 |
185 | {/if} 186 |
187 |
188 | 189 | 190 | {#snippet valueViewer(value, key = null)} 191 | {#if typeof value === "object" && value !== null} 192 | {#if Array.isArray(value)} 193 | {#each value as item, i} 194 |

195 | {i}: {@render valueViewer(item)} 196 |

197 | {/each} 198 | {:else} 199 | {#each Object.entries(value) as [k, v]} 200 |

201 | {k}: {@render valueViewer(v, k)} 202 |

203 | {/each} 204 | {/if} 205 | {:else if EVENT_TIMESTAMP_KEYS.includes(key)} 206 | {new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })} 207 | {:else} 208 | {String(value)} 209 | {/if} 210 | {/snippet} 211 | 212 |
213 | {#if selected.type === SELECTABLE_TYPES.TURBO_EVENT && selected.uuid} 214 |
215 |

216 | Event 217 |
{selected.turboEvent.eventName}
218 |

219 |
220 | 221 |
222 | {#if selected.turboEvent.eventName === "turbo:before-stream-render"} 223 |
224 | 225 |
226 |
227 |
228 |
229 | {/if} 230 | 231 | 232 | 233 | {#if selected.turboEvent.action} 234 | 235 | 236 | 237 | 238 | {/if} 239 | 240 | {#if selected.turboEvent.targetElementPath} 241 | 242 | 243 | 251 | 252 | {:else} 253 | 254 | 255 | 256 | 257 | {/if} 258 | 259 | {#if selected.turboEvent.details} 260 | {#each Object.entries(selected.turboEvent.details) as [key, value]} 261 | 262 | 263 | 264 | 265 | {/each} 266 | {/if} 267 | 268 |
action
{selected.turboEvent.action}
Target 244 |
245 | 246 |
247 | 248 |
249 |
250 |
Target
{key}
{@render valueViewer(value)}
269 |
270 | {:else} 271 |
272 |
273 | Nothing selected 274 | Select a Turbo Event to see its details 275 |
276 | {/if} 277 |
278 |
279 |
280 | 281 | 299 | -------------------------------------------------------------------------------- /src/content.js: -------------------------------------------------------------------------------- 1 | import { debounce } from "$utils/utils.js" 2 | import { turboStreamTargetElements } from "$utils/turbo_utils" 3 | import { addHighlightOverlayToElements, removeHighlightOverlay } from "$utils/highlight" 4 | import { TURBO_EVENTS } from "$lib/constants" 5 | 6 | import Devtool from "$lib/devtool" 7 | import DetailPanel from "$components/detail_panel" 8 | import DOMScanner from "$utils/dom_scanner" 9 | import DiagnosticsChecker from "$lib/diagnostics_checker" 10 | 11 | // Load scripts in the real world context, where they have the same window context as the page 12 | const loadScriptInRealWorld = (path) => { 13 | return new Promise((resolve, reject) => { 14 | const script = document.createElement("script") 15 | script.src = chrome.runtime.getURL(path) 16 | script.type = "module" 17 | 18 | script.addEventListener("error", (err) => reject(err)) 19 | script.addEventListener("load", () => resolve(undefined)) 20 | 21 | const mount = document.head || document.documentElement 22 | mount.appendChild(script) 23 | script.parentNode.removeChild(script) 24 | }) 25 | } 26 | loadScriptInRealWorld("dist/hotwire_dev_tools_inject_script.js") 27 | 28 | const LOCATION_ORIGIN = window.location.origin 29 | const devTool = new Devtool(LOCATION_ORIGIN) 30 | const detailPanel = new DetailPanel(devTool) 31 | const diagnosticsChecker = new DiagnosticsChecker(devTool) 32 | 33 | const highlightTurboFrames = () => { 34 | const badgeClass = "hotwire-dev-tools-turbo-frame-info-badge" 35 | const badgeContainerClass = "hotwire-dev-tools-turbo-frame-info-badge-container" 36 | 37 | if (!devTool.options.turbo.highlightFrames) { 38 | document.body.classList.remove("hotwire-dev-tools-highlight-turbo-frames") 39 | DOMScanner.turboFrameElements.forEach((frame) => { 40 | frame.style.outline = "" 41 | frame.querySelector(`.${badgeContainerClass}`)?.remove() 42 | }) 43 | DOMScanner.turboFrameOverlayElements.forEach((overlay) => overlay.remove()) 44 | return 45 | } 46 | 47 | const { highlightFramesOutlineWidth, highlightFramesOutlineStyle, highlightFramesOutlineColor, highlightFramesBlacklist, highlightFramesWithOverlay, ignoreEmptyFrames } = devTool.options.turbo 48 | 49 | if (!highlightFramesWithOverlay) { 50 | document.body.classList.add("hotwire-dev-tools-highlight-turbo-frames") 51 | } 52 | 53 | let blacklistedFrames = [] 54 | if (highlightFramesBlacklist) { 55 | try { 56 | blacklistedFrames = Array.from(document.querySelectorAll(highlightFramesBlacklist)) 57 | } catch (error) { 58 | console.warn("Hotwire Dev Tools: Invalid Turbo Frame ignore selector:", highlightFramesBlacklist) 59 | } 60 | } 61 | 62 | const addBadge = (element, frameId) => { 63 | const existingBadge = element.querySelector(`.${badgeClass}`) 64 | if (existingBadge) { 65 | existingBadge.style.backgroundColor = highlightFramesOutlineColor 66 | } else { 67 | const badgeContainer = document.createElement("div") 68 | badgeContainer.classList.add(badgeContainerClass) 69 | badgeContainer.dataset.turboTemporary = true 70 | 71 | const badgeContent = document.createElement("span") 72 | badgeContent.textContent = `ʘ #${frameId}` 73 | badgeContent.classList.add(badgeClass) 74 | badgeContent.dataset.turboId = frameId 75 | badgeContent.style.backgroundColor = highlightFramesOutlineColor 76 | badgeContent.addEventListener("click", handleTurboFrameBadgeClick) 77 | badgeContent.addEventListener("animationend", handleTurboFrameBadgeAnimationEnd) 78 | 79 | badgeContainer.appendChild(badgeContent) 80 | element.insertAdjacentElement("afterbegin", badgeContainer) 81 | } 82 | } 83 | 84 | const windowScrollY = window.scrollY 85 | const windowScrollX = window.scrollX 86 | DOMScanner.turboFrameElements.forEach((frame) => { 87 | const frameId = frame.id 88 | const isEmpty = frame.innerHTML.trim() === "" 89 | const shouldIgnore = isEmpty && ignoreEmptyFrames 90 | if (blacklistedFrames.includes(frame) || shouldIgnore) { 91 | frame.style.outline = "" 92 | document.getElementById(`hotwire-dev-tools-highlight-overlay-${frameId}`)?.remove() 93 | return 94 | } 95 | 96 | if (highlightFramesWithOverlay) { 97 | const rect = frame.getBoundingClientRect() 98 | let overlay = document.getElementById(`hotwire-dev-tools-highlight-overlay-${frameId}`) 99 | if (!overlay) { 100 | overlay = document.createElement("div") 101 | overlay.id = `hotwire-dev-tools-highlight-overlay-${frameId}` 102 | overlay.className = DOMScanner.TURBO_FRAME_OVERLAY_CLASS_NAME 103 | } 104 | 105 | Object.assign(overlay.style, { 106 | top: `${rect.top + windowScrollY}px`, 107 | left: `${rect.left + windowScrollX}px`, 108 | width: `${rect.width}px`, 109 | height: `${rect.height}px`, 110 | outlineStyle: highlightFramesOutlineStyle, 111 | outlineWidth: highlightFramesOutlineWidth, 112 | outlineColor: highlightFramesOutlineColor, 113 | }) 114 | 115 | if (!overlay.parentNode) { 116 | document.body.appendChild(overlay) 117 | } 118 | addBadge(overlay, frameId) 119 | } else { 120 | Object.assign(frame.style, { 121 | outlineStyle: highlightFramesOutlineStyle, 122 | outlineWidth: highlightFramesOutlineWidth, 123 | outlineColor: highlightFramesOutlineColor, 124 | }) 125 | addBadge(frame, frameId) 126 | } 127 | }) 128 | } 129 | 130 | const highlightStimulusControllers = () => { 131 | const controllers = DOMScanner.stimulusControllerElements 132 | if (!devTool.options.stimulus.highlightControllers) { 133 | controllers.forEach((controller) => (controller.style.outline = "")) 134 | return 135 | } 136 | 137 | const { highlightControllersOutlineWidth, highlightControllersOutlineStyle, highlightControllersOutlineColor, highlightControllersBlacklist } = devTool.options.stimulus 138 | let blacklistedControllers = [] 139 | if (highlightControllersBlacklist) { 140 | try { 141 | blacklistedControllers = Array.from(document.querySelectorAll(highlightControllersBlacklist)) 142 | } catch (error) { 143 | console.warn("Hotwire Dev Tools: Invalid Stimulus controller ignore selector:", highlightControllersBlacklist) 144 | } 145 | } 146 | 147 | controllers.forEach((controller) => { 148 | if (blacklistedControllers.includes(controller)) { 149 | controller.style.outline = "" 150 | return 151 | } 152 | controller.style.outlineStyle = highlightControllersOutlineStyle 153 | controller.style.outlineWidth = highlightControllersOutlineWidth 154 | controller.style.outlineColor = highlightControllersOutlineColor 155 | }) 156 | } 157 | 158 | const consoleLogTurboStream = (event) => { 159 | if (!devTool.options.turbo.consoleLogTurboStreams) return 160 | 161 | const turboStream = event.target 162 | const targetElements = turboStreamTargetElements(turboStream) 163 | const target = turboStream.getAttribute("target") 164 | const targets = turboStream.getAttribute("targets") 165 | 166 | let message = `Hotwire Dev Tools: Turbo Stream received` 167 | 168 | const targetsNotFoundInTheDOM = (target || targets) && (targetElements || []).length === 0 169 | if (targetsNotFoundInTheDOM) { 170 | message += ` - Target ${target ? "element" : "elements"} not found!` 171 | console.warn(message, turboStream) 172 | return 173 | } 174 | 175 | console.log(message, turboStream) 176 | } 177 | 178 | const checkForWarnings = debounce(() => { 179 | if (devTool.options.logWarnings) { 180 | diagnosticsChecker.checkForWarnings() 181 | } 182 | }, 150) 183 | 184 | const handleTurboFrameBadgeClick = (event) => { 185 | navigator.clipboard.writeText(event.target.dataset.turboId).then(() => { 186 | event.target.classList.add("copied") 187 | }) 188 | } 189 | 190 | const handleTurboFrameBadgeAnimationEnd = (event) => { 191 | event.target.classList.remove("copied") 192 | } 193 | 194 | const handleIncomingTurboStream = (event) => { 195 | detailPanel.addTurboStreamToDetailPanel(event) 196 | consoleLogTurboStream(event) 197 | } 198 | 199 | const handleWindowMessage = (event) => { 200 | if (event.origin !== LOCATION_ORIGIN) return 201 | if (event.data.source !== "inject") return 202 | 203 | switch (event.data.message) { 204 | case "stimulusController": 205 | if (event.data.registeredControllers) { 206 | devTool.registeredStimulusControllers = event.data.registeredControllers 207 | renderDetailPanel() 208 | checkForWarnings() 209 | } 210 | break 211 | case "turboDetails": 212 | devTool.turboDetails = event.data.details 213 | renderDetailPanel() 214 | break 215 | } 216 | } 217 | 218 | const handleTurboBeforeCache = (event) => { 219 | DOMScanner.turboFrameOverlayElements.forEach((element) => { 220 | element.remove() 221 | }) 222 | } 223 | 224 | const handleMonitoredEvent = (eventName, event) => { 225 | if (!devTool.options.monitor.events?.includes(eventName)) return 226 | 227 | let message = `Hotwire Dev Tools: ${eventName}` 228 | const target = event.target 229 | if (target?.id) message += ` #${target.id}` 230 | 231 | console.groupCollapsed(message) 232 | console.log(event) 233 | console.groupEnd() 234 | } 235 | 236 | const handleTurboFrameRender = (event) => { 237 | if (!devTool.options.turbo.highlightFramesChanges) return 238 | 239 | const turboFrame = event.target 240 | const overlayClassName = `${DOMScanner.TURBO_FRAME_OVERLAY_CLASS_NAME}-${turboFrame.id}` 241 | const color = devTool.options.turbo.highlightFramesOutlineColor 242 | addHighlightOverlayToElements([turboFrame], color, overlayClassName, "0.1") 243 | 244 | setTimeout(() => { 245 | removeHighlightOverlay(`.${overlayClassName}`) 246 | }, 350) 247 | } 248 | 249 | const renderDetailPanel = () => { 250 | if (!devTool.shouldRenderDetailPanel()) { 251 | detailPanel.dispose() 252 | return 253 | } 254 | 255 | detailPanel.render() 256 | } 257 | 258 | const listenForEvents = () => { 259 | TURBO_EVENTS.forEach((eventName) => { 260 | window.addEventListener(eventName, (event) => { 261 | // For some unknown reason, we can't use the event itself in Safari, without loosing custom properties, like event.detail. 262 | // The only hacky workaround that seems to work is to use a setTimeout with some delay. (Issue#73) 263 | setTimeout(() => { 264 | handleMonitoredEvent(eventName, event) 265 | }, 100) 266 | }) 267 | }) 268 | } 269 | 270 | const init = async () => { 271 | await devTool.setOptions() 272 | 273 | highlightTurboFrames() 274 | highlightStimulusControllers() 275 | renderDetailPanel() 276 | checkForWarnings() 277 | } 278 | 279 | const events = ["turbolinks:load", "turbo:load", "turbo:frame-load", "hotwire-dev-tools:options-changed"] 280 | events.forEach((event) => document.addEventListener(event, init, { passive: true })) 281 | document.addEventListener("turbo:before-stream-render", handleIncomingTurboStream, { passive: true }) 282 | 283 | // When Turbo Drive renders a new page, we wanna copy over the existing detail panel - shadow container - to the new page, 284 | // so we can keep the detail panel open, without flickering, when navigating between pages. 285 | // (The normal data-turbo-permanent way doesn't work for this, because the new page won't have the detail panel in the DOM yet) 286 | window.addEventListener("turbo:before-render", (event) => { 287 | event.target.appendChild(DOMScanner.shadowContainer) 288 | }) 289 | 290 | // Chance to clean up any DOM modifications made by this extension before Turbo caches the page 291 | window.addEventListener("turbo:before-cache", handleTurboBeforeCache) 292 | 293 | // Listen for potential message from the injected script 294 | window.addEventListener("message", handleWindowMessage) 295 | 296 | // Listen for window resize events 297 | window.addEventListener("resize", highlightTurboFrames) 298 | 299 | // Listen for specific Turbo events 300 | window.addEventListener("turbo:frame-render", handleTurboFrameRender) 301 | 302 | // Listen for option changes made in the popup 303 | chrome.storage.onChanged.addListener((changes, area) => { 304 | if (changes.options?.newValue || changes[LOCATION_ORIGIN]?.newValue) { 305 | document.dispatchEvent(new CustomEvent("hotwire-dev-tools:options-changed")) 306 | } 307 | }) 308 | 309 | // On pages without Turbo, there doesn't seem to be an event that informs us when the page has fully loaded. 310 | // Therefore, we call init as soon as this content.js file is loaded. 311 | init() 312 | listenForEvents() 313 | --------------------------------------------------------------------------------