├── .github └── workflows │ └── lint.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .tool-versions ├── .vscode └── settings.json ├── MIT-LICENSE ├── README.md ├── build.js ├── manifest.template.json ├── package-lock.json ├── package.json ├── privacy_policy.md ├── public ├── images │ ├── icon-128-wo.png │ ├── icon-128.png │ ├── icon-16-wo.png │ ├── icon-16.png │ ├── icon-32-wo.png │ ├── icon-32.png │ ├── icon-48-wo.png │ └── icon-48.png ├── popup.html └── styles │ ├── hotwire_dev_tools_content.css │ ├── hotwire_dev_tools_detail_panel.css │ └── hotwire_dev_tools_popup.css ├── src ├── components │ └── detail_panel.js ├── content.js ├── inject_script.js ├── lib │ ├── devtool.js │ ├── diagnostics_checker.js │ └── monitoring_events.js ├── popup.js └── utils │ ├── dom_scanner.js │ ├── highlight.js │ ├── icons.js │ ├── turbo_utils.js │ └── utils.js └── xcode ├── HotwireDevTools Extension ├── HotwireDevTools_Extension.entitlements ├── Info.plist └── SafariWebExtensionHandler.swift ├── HotwireDevTools.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── HotwireDevTools.xcscheme └── HotwireDevTools ├── AppDelegate.swift ├── Assets.xcassets ├── AccentColor.colorset │ └── Contents.json ├── AppIcon.appiconset │ ├── Contents.json │ ├── mac-icon-128@1x.png │ ├── mac-icon-128@2x.png │ ├── mac-icon-16@1x.png │ ├── mac-icon-16@2x.png │ ├── mac-icon-256@1x.png │ ├── mac-icon-256@2x.png │ ├── mac-icon-32@1x.png │ ├── mac-icon-32@2x.png │ ├── mac-icon-512@1x.png │ └── mac-icon-512@2x.png ├── Contents.json └── LargeIcon.imageset │ └── Contents.json ├── Base.lproj ├── Main.html └── Main.storyboard ├── HotwireDevTools.entitlements ├── Info.plist ├── Resources ├── Icon.png ├── Script.js └── Style.css └── ViewController.swift /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | manifest.template.json 2 | xcode/HotwireDevTools/*/** 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "printWidth": 220, 4 | "semi": false 5 | } 6 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 20.5.1 2 | -------------------------------------------------------------------------------- /.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 | } 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hotwire Dev Tools 2 | 3 | Hotwire Dev Tools is a browser extension with the goal of helping developers inspect their Turbo and Stimulus applications. 4 | 5 | **Turbo features**: 6 | 7 | - Highlight Turbo Frames 8 | - Monitor incoming Turbo Streams 9 | - Display Turbo context information (Turbo Drive enabled, morphing enabled, ...) 10 | - Log all Turbo related events 11 | - Log warning when a Turbo Frame ID is not unique 12 | - Log warning when an element has `data-turbo-permanent` but no ID or a non-unique ID 13 | - Highlight Turbo Frame changes 14 | 15 | **Stimulus features**: 16 | 17 | - Highlight Stimulus controllers 18 | - List all Stimulus controllers on the page 19 | - Log warning when a `data-controller` doesn't match any registered controller 20 | - Log warning when a Stimulus target is not nested within the corresponding controller 21 | 22 | ## Installation 23 | 24 | The extension can be installed at: 25 | 26 | - [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/hotwire-dev-tools/) 27 | - [Chrome Web Store](https://chromewebstore.google.com/detail/hotwire-dev-tools/phdobjkbablgffmmgnjbmfbbofnbkajc) 28 | - [App Store for Safari](https://apps.apple.com/ch/app/hotwire-dev-tools/id6503706225) 29 | 30 | ## Usage 31 | 32 | Once installed, click on the extension icon (or press Alt+Shift+S) to open the Dev Tools options. 33 | From there you can enable/disable the features you want to use. 34 | _Note: On Firefox you may need to select "Always allow on example.com" to enable the extension on your site._ 35 | 36 | ## Development 37 | 38 | - Fork the project locally 39 | - `npm install` 40 | - `npm run dev` - to build the extension and watch for changes 41 | - `npm run build` - to bundle the extension into static files for production 42 | - `npm run format` - to format changes with Prettier 43 | 44 | > [!NOTE] 45 | > 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`. 46 | 47 | ### Test on Chrome 48 | 49 | 1. Open Chrome and navigate to `chrome://extensions/` 50 | 2. Enable Developer mode 51 | 3. Click on `Load unpacked` and select the `public` folder (make sure to build the extension first) 52 | 53 | ### Test on Firefox 54 | 55 | The easiest way is to make use of the [web-ext](https://extensionworkshop.com/documentation/develop/getting-started-with-web-ext/) tool: 56 | 57 | ```bash 58 | npm install --global web-ext 59 | 60 | cd public 61 | web-ext run 62 | ``` 63 | 64 | That will open a new Firefox instance with the extension installed and hot reloading enabled. 65 | 66 | ### Test on Safari 67 | 68 | First [configure Safari to run unsigned extensions](https://developer.apple.com/documentation/safariservices/safari_web_extensions/running_your_safari_web_extension#3744467): 69 | 70 | 1. Choose Safari > Settings 71 | 2. Select the Advanced tab 72 | 3. Check the "Show features for web developers" box 73 | 4. Select the Developer tab. 74 | 5. Check the Allow unsigned extensions box. 75 | 76 | This may depend on the version of macOS and Safari you are using. 77 | So if you can't find the settings, you may need to search for the specific version you are using. 78 | 79 | Then you can load the extension by following these steps: 80 | 81 | 1. Open Xcode 82 | 2. Choose "Open Existing Project" 83 | 3. Select the [xcode/HotwireDevTools.xcodeproj](./xcode/HotwireDevTools.xcodeproj) workspace (blue icon) 84 | 4. Build the project (you may need to select a team in the project settings -> Signing & Capabilities) 85 | 5. Open Safari > Settings > Extensions and enable the Hotwire Dev Tools extension 86 | 87 | ## Contributing 88 | 89 | Bug reports and pull requests are welcome on GitHub at https://github.com/leonvogt/hotwire-dev-tools. 90 | 91 | ### Coding Standards 92 | 93 | This project uses Prettier to format the code and ensure a consistent style. 94 | 95 | Please run `npm run format` prior to submitting pull requests. 96 | 97 | --- 98 | 99 | 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) 🙌 100 | -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | const esbuild = require("esbuild") 2 | const fs = require("fs-extra") 3 | const path = require("path") 4 | const mustache = require("mustache") 5 | 6 | const nodeEnv = process.env.NODE_ENV 7 | const browser = process.argv[3] || "chrome" 8 | const production = nodeEnv === "production" 9 | const outputPath = path.join(__dirname, "public", "manifest.json") 10 | const templatePath = path.join(__dirname, "manifest.template.json") 11 | 12 | const browserSpecificSettings = { 13 | chrome: { 14 | useOutlineIcons: true, 15 | browser_specific_settings: false, 16 | }, 17 | firefox: { 18 | useOutlineIcons: true, 19 | browser_specific_settings: true, 20 | }, 21 | safari: { 22 | useOutlineIcons: false, 23 | browser_specific_settings: false, 24 | }, 25 | } 26 | 27 | const outputFileNames = { 28 | "content.js": "hotwire_dev_tools_content.js", 29 | "popup.js": "hotwire_dev_tools_popup.js", 30 | "inject_script.js": "hotwire_dev_tools_inject_script.js", 31 | } 32 | 33 | const esbuildConfig = { 34 | entryPoints: ["./src/content.js", "./src/popup.js", "./src/inject_script.js"], 35 | bundle: true, 36 | minify: production, 37 | sourcemap: !production, 38 | target: ["chrome88", "firefox109", "safari15"], 39 | outdir: "./public/dist", 40 | define: { 41 | "process.env.NODE_ENV": `"${nodeEnv}"`, 42 | }, 43 | metafile: true, 44 | plugins: [ 45 | { 46 | name: "rename-output-files", 47 | setup(build) { 48 | build.onEnd(async (result) => { 49 | if (result.metafile) { 50 | const outputFiles = Object.keys(result.metafile.outputs) 51 | for (const outputFile of outputFiles) { 52 | const originalName = path.basename(outputFile) 53 | const newName = outputFileNames[originalName] 54 | 55 | if (newName) { 56 | const newPath = path.join(path.dirname(outputFile), newName) 57 | await fs.rename(outputFile, newPath) 58 | console.log(`Renamed ${originalName} to ${newName}`) 59 | } 60 | } 61 | } 62 | }) 63 | }, 64 | }, 65 | ], 66 | } 67 | 68 | async function generateManifest() { 69 | try { 70 | const template = await fs.readFile(templatePath, "utf8") 71 | const output = mustache.render(template, browserSpecificSettings[browser]) 72 | await fs.writeFile(outputPath, output) 73 | console.log(`Generated manifest.json for ${browser}`) 74 | } catch (err) { 75 | console.error("Error generating manifest file:", err) 76 | } 77 | } 78 | 79 | const buildAndWatch = async () => { 80 | const context = await esbuild.context({ ...esbuildConfig, logLevel: "info" }) 81 | context.watch() 82 | } 83 | 84 | async function buildProject() { 85 | await generateManifest() 86 | 87 | if (process.argv.includes("--watch")) { 88 | buildAndWatch() 89 | } else { 90 | esbuild.build(esbuildConfig) 91 | } 92 | } 93 | 94 | buildProject() 95 | -------------------------------------------------------------------------------- /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 | {{#useOutlineIcons}} 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 | {{/useOutlineIcons}} 13 | {{^useOutlineIcons}} 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 | {{/useOutlineIcons}} 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 | "permissions": ["storage", "activeTab"], 28 | "action": { 29 | "default_title": "Click or press Alt+Shift+S to launch Dev Tools", 30 | "default_popup": "popup.html" 31 | }, 32 | "web_accessible_resources": [ 33 | { 34 | "resources": ["dist/hotwire_dev_tools_inject_script.js", "styles/hotwire_dev_tools_detail_panel.css"], 35 | "matches": [""] 36 | } 37 | ], 38 | {{#browser_specific_settings}} 39 | "browser_specific_settings": { 40 | "gecko": { 41 | "id": "hotwire_dev_tools@browser_extension" 42 | } 43 | }, 44 | {{/browser_specific_settings}} 45 | "commands": { 46 | "_execute_action": { 47 | "suggested_key": { 48 | "default": "Alt+Shift+S" 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hotwire-dev-tools", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "highlight.js": "^11.11.1" 9 | }, 10 | "devDependencies": { 11 | "esbuild": "^0.25.4", 12 | "fs-extra": "^11.3.0", 13 | "mustache": "^4.2.0", 14 | "prettier": "^3.5.3" 15 | } 16 | }, 17 | "node_modules/@esbuild/aix-ppc64": { 18 | "version": "0.25.4", 19 | "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", 20 | "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", 21 | "cpu": [ 22 | "ppc64" 23 | ], 24 | "dev": true, 25 | "license": "MIT", 26 | "optional": true, 27 | "os": [ 28 | "aix" 29 | ], 30 | "engines": { 31 | "node": ">=18" 32 | } 33 | }, 34 | "node_modules/@esbuild/android-arm": { 35 | "version": "0.25.4", 36 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", 37 | "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", 38 | "cpu": [ 39 | "arm" 40 | ], 41 | "dev": true, 42 | "license": "MIT", 43 | "optional": true, 44 | "os": [ 45 | "android" 46 | ], 47 | "engines": { 48 | "node": ">=18" 49 | } 50 | }, 51 | "node_modules/@esbuild/android-arm64": { 52 | "version": "0.25.4", 53 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", 54 | "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", 55 | "cpu": [ 56 | "arm64" 57 | ], 58 | "dev": true, 59 | "license": "MIT", 60 | "optional": true, 61 | "os": [ 62 | "android" 63 | ], 64 | "engines": { 65 | "node": ">=18" 66 | } 67 | }, 68 | "node_modules/@esbuild/android-x64": { 69 | "version": "0.25.4", 70 | "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", 71 | "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", 72 | "cpu": [ 73 | "x64" 74 | ], 75 | "dev": true, 76 | "license": "MIT", 77 | "optional": true, 78 | "os": [ 79 | "android" 80 | ], 81 | "engines": { 82 | "node": ">=18" 83 | } 84 | }, 85 | "node_modules/@esbuild/darwin-arm64": { 86 | "version": "0.25.4", 87 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", 88 | "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", 89 | "cpu": [ 90 | "arm64" 91 | ], 92 | "dev": true, 93 | "license": "MIT", 94 | "optional": true, 95 | "os": [ 96 | "darwin" 97 | ], 98 | "engines": { 99 | "node": ">=18" 100 | } 101 | }, 102 | "node_modules/@esbuild/darwin-x64": { 103 | "version": "0.25.4", 104 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", 105 | "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", 106 | "cpu": [ 107 | "x64" 108 | ], 109 | "dev": true, 110 | "license": "MIT", 111 | "optional": true, 112 | "os": [ 113 | "darwin" 114 | ], 115 | "engines": { 116 | "node": ">=18" 117 | } 118 | }, 119 | "node_modules/@esbuild/freebsd-arm64": { 120 | "version": "0.25.4", 121 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", 122 | "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", 123 | "cpu": [ 124 | "arm64" 125 | ], 126 | "dev": true, 127 | "license": "MIT", 128 | "optional": true, 129 | "os": [ 130 | "freebsd" 131 | ], 132 | "engines": { 133 | "node": ">=18" 134 | } 135 | }, 136 | "node_modules/@esbuild/freebsd-x64": { 137 | "version": "0.25.4", 138 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", 139 | "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", 140 | "cpu": [ 141 | "x64" 142 | ], 143 | "dev": true, 144 | "license": "MIT", 145 | "optional": true, 146 | "os": [ 147 | "freebsd" 148 | ], 149 | "engines": { 150 | "node": ">=18" 151 | } 152 | }, 153 | "node_modules/@esbuild/linux-arm": { 154 | "version": "0.25.4", 155 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", 156 | "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", 157 | "cpu": [ 158 | "arm" 159 | ], 160 | "dev": true, 161 | "license": "MIT", 162 | "optional": true, 163 | "os": [ 164 | "linux" 165 | ], 166 | "engines": { 167 | "node": ">=18" 168 | } 169 | }, 170 | "node_modules/@esbuild/linux-arm64": { 171 | "version": "0.25.4", 172 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", 173 | "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", 174 | "cpu": [ 175 | "arm64" 176 | ], 177 | "dev": true, 178 | "license": "MIT", 179 | "optional": true, 180 | "os": [ 181 | "linux" 182 | ], 183 | "engines": { 184 | "node": ">=18" 185 | } 186 | }, 187 | "node_modules/@esbuild/linux-ia32": { 188 | "version": "0.25.4", 189 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", 190 | "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", 191 | "cpu": [ 192 | "ia32" 193 | ], 194 | "dev": true, 195 | "license": "MIT", 196 | "optional": true, 197 | "os": [ 198 | "linux" 199 | ], 200 | "engines": { 201 | "node": ">=18" 202 | } 203 | }, 204 | "node_modules/@esbuild/linux-loong64": { 205 | "version": "0.25.4", 206 | "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", 207 | "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", 208 | "cpu": [ 209 | "loong64" 210 | ], 211 | "dev": true, 212 | "license": "MIT", 213 | "optional": true, 214 | "os": [ 215 | "linux" 216 | ], 217 | "engines": { 218 | "node": ">=18" 219 | } 220 | }, 221 | "node_modules/@esbuild/linux-mips64el": { 222 | "version": "0.25.4", 223 | "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", 224 | "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", 225 | "cpu": [ 226 | "mips64el" 227 | ], 228 | "dev": true, 229 | "license": "MIT", 230 | "optional": true, 231 | "os": [ 232 | "linux" 233 | ], 234 | "engines": { 235 | "node": ">=18" 236 | } 237 | }, 238 | "node_modules/@esbuild/linux-ppc64": { 239 | "version": "0.25.4", 240 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", 241 | "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", 242 | "cpu": [ 243 | "ppc64" 244 | ], 245 | "dev": true, 246 | "license": "MIT", 247 | "optional": true, 248 | "os": [ 249 | "linux" 250 | ], 251 | "engines": { 252 | "node": ">=18" 253 | } 254 | }, 255 | "node_modules/@esbuild/linux-riscv64": { 256 | "version": "0.25.4", 257 | "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", 258 | "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", 259 | "cpu": [ 260 | "riscv64" 261 | ], 262 | "dev": true, 263 | "license": "MIT", 264 | "optional": true, 265 | "os": [ 266 | "linux" 267 | ], 268 | "engines": { 269 | "node": ">=18" 270 | } 271 | }, 272 | "node_modules/@esbuild/linux-s390x": { 273 | "version": "0.25.4", 274 | "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", 275 | "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", 276 | "cpu": [ 277 | "s390x" 278 | ], 279 | "dev": true, 280 | "license": "MIT", 281 | "optional": true, 282 | "os": [ 283 | "linux" 284 | ], 285 | "engines": { 286 | "node": ">=18" 287 | } 288 | }, 289 | "node_modules/@esbuild/linux-x64": { 290 | "version": "0.25.4", 291 | "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", 292 | "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", 293 | "cpu": [ 294 | "x64" 295 | ], 296 | "dev": true, 297 | "license": "MIT", 298 | "optional": true, 299 | "os": [ 300 | "linux" 301 | ], 302 | "engines": { 303 | "node": ">=18" 304 | } 305 | }, 306 | "node_modules/@esbuild/netbsd-arm64": { 307 | "version": "0.25.4", 308 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", 309 | "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", 310 | "cpu": [ 311 | "arm64" 312 | ], 313 | "dev": true, 314 | "license": "MIT", 315 | "optional": true, 316 | "os": [ 317 | "netbsd" 318 | ], 319 | "engines": { 320 | "node": ">=18" 321 | } 322 | }, 323 | "node_modules/@esbuild/netbsd-x64": { 324 | "version": "0.25.4", 325 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", 326 | "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", 327 | "cpu": [ 328 | "x64" 329 | ], 330 | "dev": true, 331 | "license": "MIT", 332 | "optional": true, 333 | "os": [ 334 | "netbsd" 335 | ], 336 | "engines": { 337 | "node": ">=18" 338 | } 339 | }, 340 | "node_modules/@esbuild/openbsd-arm64": { 341 | "version": "0.25.4", 342 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", 343 | "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", 344 | "cpu": [ 345 | "arm64" 346 | ], 347 | "dev": true, 348 | "license": "MIT", 349 | "optional": true, 350 | "os": [ 351 | "openbsd" 352 | ], 353 | "engines": { 354 | "node": ">=18" 355 | } 356 | }, 357 | "node_modules/@esbuild/openbsd-x64": { 358 | "version": "0.25.4", 359 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", 360 | "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", 361 | "cpu": [ 362 | "x64" 363 | ], 364 | "dev": true, 365 | "license": "MIT", 366 | "optional": true, 367 | "os": [ 368 | "openbsd" 369 | ], 370 | "engines": { 371 | "node": ">=18" 372 | } 373 | }, 374 | "node_modules/@esbuild/sunos-x64": { 375 | "version": "0.25.4", 376 | "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", 377 | "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", 378 | "cpu": [ 379 | "x64" 380 | ], 381 | "dev": true, 382 | "license": "MIT", 383 | "optional": true, 384 | "os": [ 385 | "sunos" 386 | ], 387 | "engines": { 388 | "node": ">=18" 389 | } 390 | }, 391 | "node_modules/@esbuild/win32-arm64": { 392 | "version": "0.25.4", 393 | "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", 394 | "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", 395 | "cpu": [ 396 | "arm64" 397 | ], 398 | "dev": true, 399 | "license": "MIT", 400 | "optional": true, 401 | "os": [ 402 | "win32" 403 | ], 404 | "engines": { 405 | "node": ">=18" 406 | } 407 | }, 408 | "node_modules/@esbuild/win32-ia32": { 409 | "version": "0.25.4", 410 | "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", 411 | "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", 412 | "cpu": [ 413 | "ia32" 414 | ], 415 | "dev": true, 416 | "license": "MIT", 417 | "optional": true, 418 | "os": [ 419 | "win32" 420 | ], 421 | "engines": { 422 | "node": ">=18" 423 | } 424 | }, 425 | "node_modules/@esbuild/win32-x64": { 426 | "version": "0.25.4", 427 | "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", 428 | "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", 429 | "cpu": [ 430 | "x64" 431 | ], 432 | "dev": true, 433 | "license": "MIT", 434 | "optional": true, 435 | "os": [ 436 | "win32" 437 | ], 438 | "engines": { 439 | "node": ">=18" 440 | } 441 | }, 442 | "node_modules/esbuild": { 443 | "version": "0.25.4", 444 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", 445 | "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", 446 | "dev": true, 447 | "hasInstallScript": true, 448 | "license": "MIT", 449 | "bin": { 450 | "esbuild": "bin/esbuild" 451 | }, 452 | "engines": { 453 | "node": ">=18" 454 | }, 455 | "optionalDependencies": { 456 | "@esbuild/aix-ppc64": "0.25.4", 457 | "@esbuild/android-arm": "0.25.4", 458 | "@esbuild/android-arm64": "0.25.4", 459 | "@esbuild/android-x64": "0.25.4", 460 | "@esbuild/darwin-arm64": "0.25.4", 461 | "@esbuild/darwin-x64": "0.25.4", 462 | "@esbuild/freebsd-arm64": "0.25.4", 463 | "@esbuild/freebsd-x64": "0.25.4", 464 | "@esbuild/linux-arm": "0.25.4", 465 | "@esbuild/linux-arm64": "0.25.4", 466 | "@esbuild/linux-ia32": "0.25.4", 467 | "@esbuild/linux-loong64": "0.25.4", 468 | "@esbuild/linux-mips64el": "0.25.4", 469 | "@esbuild/linux-ppc64": "0.25.4", 470 | "@esbuild/linux-riscv64": "0.25.4", 471 | "@esbuild/linux-s390x": "0.25.4", 472 | "@esbuild/linux-x64": "0.25.4", 473 | "@esbuild/netbsd-arm64": "0.25.4", 474 | "@esbuild/netbsd-x64": "0.25.4", 475 | "@esbuild/openbsd-arm64": "0.25.4", 476 | "@esbuild/openbsd-x64": "0.25.4", 477 | "@esbuild/sunos-x64": "0.25.4", 478 | "@esbuild/win32-arm64": "0.25.4", 479 | "@esbuild/win32-ia32": "0.25.4", 480 | "@esbuild/win32-x64": "0.25.4" 481 | } 482 | }, 483 | "node_modules/fs-extra": { 484 | "version": "11.3.0", 485 | "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", 486 | "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", 487 | "dev": true, 488 | "license": "MIT", 489 | "dependencies": { 490 | "graceful-fs": "^4.2.0", 491 | "jsonfile": "^6.0.1", 492 | "universalify": "^2.0.0" 493 | }, 494 | "engines": { 495 | "node": ">=14.14" 496 | } 497 | }, 498 | "node_modules/graceful-fs": { 499 | "version": "4.2.11", 500 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", 501 | "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", 502 | "dev": true 503 | }, 504 | "node_modules/highlight.js": { 505 | "version": "11.11.1", 506 | "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", 507 | "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", 508 | "engines": { 509 | "node": ">=12.0.0" 510 | } 511 | }, 512 | "node_modules/jsonfile": { 513 | "version": "6.1.0", 514 | "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", 515 | "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", 516 | "dev": true, 517 | "dependencies": { 518 | "universalify": "^2.0.0" 519 | }, 520 | "optionalDependencies": { 521 | "graceful-fs": "^4.1.6" 522 | } 523 | }, 524 | "node_modules/mustache": { 525 | "version": "4.2.0", 526 | "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", 527 | "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", 528 | "dev": true, 529 | "bin": { 530 | "mustache": "bin/mustache" 531 | } 532 | }, 533 | "node_modules/prettier": { 534 | "version": "3.5.3", 535 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", 536 | "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", 537 | "dev": true, 538 | "bin": { 539 | "prettier": "bin/prettier.cjs" 540 | }, 541 | "engines": { 542 | "node": ">=14" 543 | }, 544 | "funding": { 545 | "url": "https://github.com/prettier/prettier?sponsor=1" 546 | } 547 | }, 548 | "node_modules/universalify": { 549 | "version": "2.0.1", 550 | "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", 551 | "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", 552 | "dev": true, 553 | "engines": { 554 | "node": ">= 10.0.0" 555 | } 556 | } 557 | } 558 | } 559 | -------------------------------------------------------------------------------- /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 | "highlight.js": "^11.11.1" 10 | }, 11 | "devDependencies": { 12 | "esbuild": "^0.25.4", 13 | "fs-extra": "^11.3.0", 14 | "mustache": "^4.2.0", 15 | "prettier": "^3.5.3" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/images/icon-128-wo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/public/images/icon-128-wo.png -------------------------------------------------------------------------------- /public/images/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/public/images/icon-128.png -------------------------------------------------------------------------------- /public/images/icon-16-wo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/public/images/icon-16-wo.png -------------------------------------------------------------------------------- /public/images/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/public/images/icon-16.png -------------------------------------------------------------------------------- /public/images/icon-32-wo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/public/images/icon-32-wo.png -------------------------------------------------------------------------------- /public/images/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/public/images/icon-32.png -------------------------------------------------------------------------------- /public/images/icon-48-wo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/public/images/icon-48-wo.png -------------------------------------------------------------------------------- /public/images/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/public/images/icon-48.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/components/detail_panel.js: -------------------------------------------------------------------------------- 1 | import { getMetaContent, debounce } from "../utils/utils" 2 | import { turboStreamTargetElements } from "../utils/turbo_utils" 3 | import { addHighlightOverlayToElements, removeHighlightOverlay } from "../utils/highlight" 4 | import DOMScanner from "../utils/dom_scanner" 5 | import * as Icons from "../utils/icons" 6 | 7 | import hljs from "highlight.js/lib/core" 8 | import xml from "highlight.js/lib/languages/xml" 9 | hljs.registerLanguage("xml", xml) 10 | 11 | const STIMULUS_TAB_ID = "hotwire-dev-tools-stimulus-tab" 12 | const TURBO_FRAME_TAB_ID = "hotwire-dev-tools-turbo-frame-tab" 13 | const TURBO_STREAM_TAB_ID = "hotwire-dev-tools-turbo-stream-tab" 14 | const INFO_TAB_ID = "hotwire-dev-tools-info-tab" 15 | 16 | export default class DetailPanel { 17 | constructor(devTool) { 18 | this.devTool = devTool 19 | this.shadowRoot = this.shadowContainer.attachShadow({ mode: "open" }) 20 | } 21 | 22 | render = debounce(async () => { 23 | await this.injectCSSToShadowRoot() 24 | this.createOrUpdateDetailPanel() 25 | 26 | this.listenForTabNavigation() 27 | this.listenForToggleCollapse() 28 | this.listenForStimulusControllerHover() 29 | this.listenForTurboFrameHover() 30 | this.listenForTurboStreamInteractions() 31 | }, 150) 32 | 33 | dispose() { 34 | this.shadowRoot.innerHTML = "" 35 | } 36 | 37 | injectCSSToShadowRoot = async () => { 38 | if (this.shadowRoot.querySelector("style")) return 39 | 40 | const style = document.createElement("style") 41 | style.textContent = await this.devTool.detailPanelCSS() 42 | this.shadowRoot.appendChild(style) 43 | } 44 | 45 | createOrUpdateDetailPanel() { 46 | const container = this.detailPanelContainer 47 | container.innerHTML = this.html 48 | this.shadowRoot.appendChild(container) 49 | this.toggleDetailPanelVisibility() 50 | } 51 | 52 | listenForTabNavigation() { 53 | const tablist = this.shadowRoot.querySelector(".hotwire-dev-tools-tablist") 54 | tablist.addEventListener("click", this.#handleClickTab) 55 | } 56 | 57 | listenForToggleCollapse() { 58 | this.shadowRoot.querySelector(".hotwire-dev-tools-collapse-button").addEventListener("click", this.#handleClickToggleCollapse) 59 | } 60 | 61 | addTurboStreamToDetailPanel = (event) => { 62 | if (!this.devTool.options.detailPanel.show || !this.isTabEnabled(TURBO_STREAM_TAB_ID)) return 63 | 64 | const turboStream = event.target 65 | const action = turboStream.getAttribute("action") 66 | const target = turboStream.getAttribute("target") 67 | const targets = turboStream.getAttribute("targets") 68 | const targetSelector = target ? `#${target}` : targets 69 | const targetElements = turboStreamTargetElements(turboStream) 70 | const time = new Date().toLocaleTimeString() 71 | 72 | const entry = document.createElement("div") 73 | entry.classList.add("hotwire-dev-tools-entry", "flex-column", "turbo-stream") 74 | if (targetSelector) { 75 | entry.dataset.targetSelector = targetSelector 76 | } 77 | 78 | const turboStreamContent = hljs.highlight(turboStream.outerHTML, { language: "html" }).value 79 | entry.innerHTML = ` 80 |
81 | ${time} 82 |
83 |
84 | ${action} 85 | ${targetSelector || ""} 86 |
87 |
88 |
${turboStreamContent}
89 |
90 | ` 91 | 92 | const streamTab = this.shadowRoot.getElementById(TURBO_STREAM_TAB_ID) 93 | streamTab.prepend(entry) 94 | streamTab.querySelector(".hotwire-dev-tools-no-entry")?.remove() 95 | 96 | if ((target || targets) && (targetElements || []).length === 0) { 97 | entry.classList.add("hotwire-dev-tools-entry-warning") 98 | entry.title = "Target not found" 99 | } 100 | 101 | this.listenForTurboStreamInteractions() 102 | this.addTabEffect(TURBO_STREAM_TAB_ID) 103 | } 104 | 105 | addTabEffect = debounce((tabId) => { 106 | const tab = this.shadowRoot.querySelector(`[data-tab-id="${tabId}"]`) 107 | if (!tab || tab.classList.contains("active")) return 108 | const animationClass = "animate__animated animate__headShake" 109 | tab.classList.add(...animationClass.split(" ")) 110 | setTimeout(() => tab.classList.remove(...animationClass.split(" ")), 10000) 111 | }, 150) 112 | 113 | toggleDetailPanelVisibility = (hide = this.devTool.options.detailPanel.collapsed) => { 114 | const detailPanelContainer = this.shadowRoot.getElementById("hotwire-dev-tools-detail-panel-container") 115 | 116 | if (hide) { 117 | this.shadowRoot.querySelector(".collapse-icon").style.display = "none" 118 | this.shadowRoot.querySelector(".expand-icon").style.display = "contents" 119 | 120 | detailPanelContainer.classList.add("collapsed") 121 | } else { 122 | this.shadowRoot.querySelector(".collapse-icon").style.display = "contents" 123 | this.shadowRoot.querySelector(".expand-icon").style.display = "none" 124 | 125 | detailPanelContainer.classList.remove("collapsed") 126 | } 127 | } 128 | 129 | listenForStimulusControllerHover = () => { 130 | this.shadowRoot.querySelectorAll(`#${STIMULUS_TAB_ID} .hotwire-dev-tools-entry`).forEach((entry) => { 131 | entry.addEventListener("mouseenter", this.#handleMouseEnterStimulusController) 132 | entry.addEventListener("mouseleave", this.#handleMouseLeaveStimulusController) 133 | }) 134 | } 135 | 136 | listenForTurboFrameHover = () => { 137 | this.shadowRoot.querySelectorAll(`#${TURBO_FRAME_TAB_ID} .hotwire-dev-tools-entry`).forEach((entry) => { 138 | entry.addEventListener("mouseenter", this.#handleMouseEnterTurboFrame) 139 | entry.addEventListener("mouseleave", this.#handleMouseLeaveTurboFrame) 140 | }) 141 | } 142 | 143 | listenForTurboStreamInteractions = () => { 144 | this.shadowRoot.querySelectorAll(`#${TURBO_STREAM_TAB_ID} .hotwire-dev-tools-entry`).forEach((entry) => { 145 | entry.addEventListener("click", this.#handleClickTurboStream) 146 | entry.addEventListener("mouseenter", this.#handleMouseEnterTurboStream) 147 | entry.addEventListener("mouseleave", this.#handleMouseLeaveTurboStream) 148 | }) 149 | } 150 | 151 | isTabEnabled = (tabId) => { 152 | return this.tabs.map((tab) => tab.id).includes(tabId) 153 | } 154 | 155 | #handleClickTab = (event) => { 156 | this.shadowRoot.querySelectorAll(".hotwire-dev-tools-tablink, .hotwire-dev-tools-tab-content").forEach((tab) => { 157 | tab.classList.remove("active") 158 | }) 159 | 160 | const clickedTab = event.target.closest(".hotwire-dev-tools-tablink") 161 | const desiredTabContent = this.shadowRoot.getElementById(clickedTab.dataset.tabId) 162 | 163 | clickedTab.classList.add("active") 164 | desiredTabContent.classList.add("active") 165 | 166 | const options = this.devTool.options 167 | options.detailPanel.currentTab = clickedTab.dataset.tabId 168 | options.detailPanel.collapsed = false 169 | 170 | this.devTool.saveOptions(options) 171 | this.toggleDetailPanelVisibility() 172 | } 173 | 174 | #handleClickToggleCollapse = () => { 175 | const options = this.devTool.options 176 | options.detailPanel.collapsed = !options.detailPanel.collapsed 177 | 178 | this.devTool.saveOptions(options) 179 | this.toggleDetailPanelVisibility() 180 | } 181 | 182 | #handleMouseEnterTurboStream = (event) => { 183 | const selector = event.currentTarget.dataset.targetSelector 184 | const elements = document.querySelectorAll(selector) 185 | addHighlightOverlayToElements(elements, this.devTool.options.turbo.highlightFramesOutlineColor) 186 | } 187 | 188 | #handleMouseLeaveTurboStream = () => { 189 | removeHighlightOverlay() 190 | } 191 | 192 | #handleClickTurboStream = (event) => { 193 | const entryDetails = event.target.closest(".hotwire-dev-tools-entry").querySelector(".hotwire-dev-tools-entry-details") 194 | const wasCollapsed = entryDetails.classList.contains("d-none") 195 | 196 | this.shadowRoot.querySelectorAll(".hotwire-dev-tools-entry-details").forEach((entryDetails) => { 197 | entryDetails.classList.add("d-none") 198 | }) 199 | 200 | if (wasCollapsed) { 201 | entryDetails.classList.remove("d-none") 202 | } 203 | entryDetails.closest(".hotwire-dev-tools-entry").scrollIntoView({ behavior: "smooth" }) 204 | } 205 | 206 | #handleMouseEnterStimulusController = (event) => { 207 | const controllerId = event.currentTarget.getAttribute("data-stimulus-controller-id") 208 | const elements = document.querySelectorAll(`[data-controller="${controllerId}"]`) 209 | addHighlightOverlayToElements(elements, this.devTool.options.stimulus.highlightControllersOutlineColor) 210 | } 211 | 212 | #handleMouseLeaveStimulusController = () => { 213 | removeHighlightOverlay() 214 | } 215 | 216 | #handleMouseEnterTurboFrame = (event) => { 217 | const frameId = event.currentTarget.getAttribute("data-turbo-frame-id") 218 | const elements = document.querySelectorAll(`turbo-frame#${frameId}`) 219 | addHighlightOverlayToElements(elements, this.devTool.options.turbo.highlightFramesOutlineColor) 220 | } 221 | 222 | #handleMouseLeaveTurboFrame = () => { 223 | removeHighlightOverlay() 224 | } 225 | 226 | get panelHeader() { 227 | return ` 228 |
229 |
230 | ${this.tabs.map((tab) => ``).join("")} 231 |
232 | 236 |
237 | ` 238 | } 239 | 240 | get stimulusTabContent() { 241 | const groupedControllers = DOMScanner.groupedStimulusControllerElements 242 | const sortedControllerIds = Object.keys(groupedControllers).sort() 243 | 244 | if (sortedControllerIds.length === 0) { 245 | return ` 246 |
247 | No Stimulus controllers found on this page 248 | We'll keep looking 249 |
250 | ` 251 | } 252 | 253 | const entries = [] 254 | const detectedRegistedStimulusControllers = this.devTool.registeredStimulusControllers.length > 0 255 | sortedControllerIds.forEach((stimulusControllerId) => { 256 | let entryAttributes = { class: "hotwire-dev-tools-entry", "data-stimulus-controller-id": stimulusControllerId } 257 | 258 | const controllerNotRegistered = detectedRegistedStimulusControllers && !this.devTool.registeredStimulusControllers.includes(stimulusControllerId) 259 | if (controllerNotRegistered) { 260 | entryAttributes.class += " hotwire-dev-tools-entry-warning" 261 | entryAttributes.title = "Controller not registered" 262 | } 263 | 264 | const stimulusControllerElements = groupedControllers[stimulusControllerId] 265 | entries.push(` 266 |
`${key}="${value}"`) 268 | .join(" ")}> 269 | ${stimulusControllerId}${stimulusControllerElements.length} 270 |
271 | `) 272 | }) 273 | 274 | return entries.join("") 275 | } 276 | 277 | get turboFrameTabContent() { 278 | const frames = Array.from(DOMScanner.turboFrameElements) 279 | if (frames.length === 0) { 280 | return ` 281 |
282 | No Turbo Frames found on this page 283 | We'll keep looking 284 |
285 | ` 286 | } 287 | 288 | const entries = [] 289 | frames.forEach((frame) => { 290 | const nonUniqueFrameId = frames.filter((f) => f.id === frame.id).length > 1 291 | let className = "hotwire-dev-tools-entry" 292 | let title = "" 293 | if (nonUniqueFrameId) { 294 | className += " hotwire-dev-tools-entry-warning" 295 | title = "Multiple frames with the same id" 296 | } 297 | entries.push(` 298 |
299 | ${frame.id} 300 | ${frame.hasAttribute("src") ? `${Icons.clock}` : ""} 301 |
302 | `) 303 | }) 304 | return entries.join("") 305 | } 306 | 307 | get turboSteamTabContent() { 308 | const streamTabEntries = Array.from(this.shadowRoot.querySelectorAll(`#${TURBO_STREAM_TAB_ID} .hotwire-dev-tools-entry`)) 309 | if (streamTabEntries.length > 0) { 310 | return streamTabEntries.map((entry) => entry.outerHTML).join("") 311 | } 312 | 313 | return ` 314 |
315 | No Turbo Streams seen yet 316 | We'll keep looking 317 |
318 | ` 319 | } 320 | 321 | get infoTabContent() { 322 | return ` 323 |
324 |
325 |
326 | Turbo Frames: 327 | ${DOMScanner.turboFrameElements.length} 328 |
329 | ${ 330 | typeof this.devTool.turboDetails.turboDriveEnabled === "boolean" 331 | ? ` 332 |
333 | Turbo Drive: 334 | ${this.devTool.turboDetails.turboDriveEnabled ? "On" : "Off"} 335 |
336 | ` 337 | : ` 338 |
339 | Turbo Drive: 340 | - 341 |
342 | ` 343 | } 344 | ${ 345 | getMetaContent("turbo-prefetch") === "false" 346 | ? ` 347 |
348 | Link Prefetch: 349 | Off 350 |
351 | ` 352 | : "" 353 | } 354 |
355 | Refresh Control: 356 | ${getMetaContent("turbo-refresh-method") || "-"} 357 |
358 |
359 | Visit Control: 360 | ${getMetaContent("turbo-visit-control") || "-"} 361 |
362 |
363 | Cache Control: 364 | ${getMetaContent("turbo-cache-control") || "-"} 365 |
366 |
367 | 368 |
369 |
370 | Stimulus Controllers: 371 | ${DOMScanner.stimulusControllerElements.length} 372 |
373 |
374 |
375 | ` 376 | } 377 | 378 | get detailPanelContainer() { 379 | const existingContainer = this.shadowRoot.getElementById("hotwire-dev-tools-detail-panel-container") 380 | if (existingContainer) { 381 | return existingContainer 382 | } 383 | const container = document.createElement("div") 384 | container.id = "hotwire-dev-tools-detail-panel-container" 385 | return container 386 | } 387 | 388 | get shadowContainer() { 389 | const existingShadowContainer = DOMScanner.shadowContainer 390 | if (existingShadowContainer) { 391 | return existingShadowContainer 392 | } 393 | const shadowContainer = document.createElement("div") 394 | shadowContainer.id = DOMScanner.SHADOW_CONTAINER_ID 395 | document.body.appendChild(shadowContainer) 396 | return shadowContainer 397 | } 398 | 399 | get html() { 400 | return ` 401 | ${this.panelHeader} 402 | ${this.tabs.map((tab) => `
${tab.content}
`).join("")} 403 | ` 404 | } 405 | 406 | get tabs() { 407 | const { showStimulusTab, showTurboFrameTab, showTurboStreamTab } = this.devTool.options.detailPanel 408 | const enabledTabs = [] 409 | if (showStimulusTab) { 410 | enabledTabs.push({ id: STIMULUS_TAB_ID, label: "Stimulus", content: this.stimulusTabContent }) 411 | } 412 | if (showTurboFrameTab) { 413 | enabledTabs.push({ id: TURBO_FRAME_TAB_ID, label: "Frames", content: this.turboFrameTabContent }) 414 | } 415 | if (showTurboStreamTab) { 416 | enabledTabs.push({ id: TURBO_STREAM_TAB_ID, label: "Streams", content: this.turboSteamTabContent }) 417 | } 418 | if (enabledTabs.length > 0) { 419 | enabledTabs.push({ id: INFO_TAB_ID, label: Icons.info, content: this.infoTabContent }) 420 | } 421 | 422 | return enabledTabs 423 | } 424 | 425 | get currentTab() { 426 | const options = this.devTool.options 427 | 428 | const storedCurrentTab = options.detailPanel.currentTab 429 | if (this.isTabEnabled(storedCurrentTab)) return storedCurrentTab 430 | 431 | const newCurrentTab = this.tabs[0].id 432 | options.detailPanel.currentTab = newCurrentTab 433 | this.devTool.saveOptions(options) 434 | return newCurrentTab 435 | } 436 | } 437 | -------------------------------------------------------------------------------- /src/content.js: -------------------------------------------------------------------------------- 1 | import { debounce } from "./utils/utils" 2 | import { turboStreamTargetElements } from "./utils/turbo_utils" 3 | import { addHighlightOverlayToElements, removeHighlightOverlay } from "./utils/highlight" 4 | import { MONITORING_EVENTS } from "./lib/monitoring_events" 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 | const LOCATION_ORIGIN = window.location.origin 12 | const devTool = new Devtool(LOCATION_ORIGIN) 13 | const detailPanel = new DetailPanel(devTool) 14 | const diagnosticsChecker = new DiagnosticsChecker(devTool) 15 | 16 | const highlightTurboFrames = () => { 17 | const badgeClass = "hotwire-dev-tools-turbo-frame-info-badge" 18 | const badgeContainerClass = "hotwire-dev-tools-turbo-frame-info-badge-container" 19 | 20 | if (!devTool.options.turbo.highlightFrames) { 21 | document.body.classList.remove("hotwire-dev-tools-highlight-turbo-frames") 22 | DOMScanner.turboFrameElements.forEach((frame) => { 23 | frame.style.outline = "" 24 | frame.querySelector(`.${badgeContainerClass}`)?.remove() 25 | }) 26 | DOMScanner.turboFrameOverlayElements.forEach((overlay) => overlay.remove()) 27 | return 28 | } 29 | 30 | const { highlightFramesOutlineWidth, highlightFramesOutlineStyle, highlightFramesOutlineColor, highlightFramesBlacklist, highlightFramesWithOverlay, ignoreEmptyFrames } = devTool.options.turbo 31 | 32 | if (!highlightFramesWithOverlay) { 33 | document.body.classList.add("hotwire-dev-tools-highlight-turbo-frames") 34 | } 35 | 36 | let blacklistedFrames = [] 37 | if (highlightFramesBlacklist) { 38 | try { 39 | blacklistedFrames = Array.from(document.querySelectorAll(highlightFramesBlacklist)) 40 | } catch (error) { 41 | console.warn("Hotwire Dev Tools: Invalid Turbo Frame ignore selector:", highlightFramesBlacklist) 42 | } 43 | } 44 | 45 | const addBadge = (element, frameId) => { 46 | const existingBadge = element.querySelector(`.${badgeClass}`) 47 | if (existingBadge) { 48 | existingBadge.style.backgroundColor = highlightFramesOutlineColor 49 | } else { 50 | const badgeContainer = document.createElement("div") 51 | badgeContainer.classList.add(badgeContainerClass) 52 | badgeContainer.dataset.turboTemporary = true 53 | 54 | const badgeContent = document.createElement("span") 55 | badgeContent.textContent = `ʘ #${frameId}` 56 | badgeContent.classList.add(badgeClass) 57 | badgeContent.dataset.turboId = frameId 58 | badgeContent.style.backgroundColor = highlightFramesOutlineColor 59 | badgeContent.addEventListener("click", handleTurboFrameBadgeClick) 60 | badgeContent.addEventListener("animationend", handleTurboFrameBadgeAnimationEnd) 61 | 62 | badgeContainer.appendChild(badgeContent) 63 | element.insertAdjacentElement("afterbegin", badgeContainer) 64 | } 65 | } 66 | 67 | const windowScrollY = window.scrollY 68 | const windowScrollX = window.scrollX 69 | DOMScanner.turboFrameElements.forEach((frame) => { 70 | const frameId = frame.id 71 | const isEmpty = frame.innerHTML.trim() === "" 72 | const shouldIgnore = isEmpty && ignoreEmptyFrames 73 | if (blacklistedFrames.includes(frame) || shouldIgnore) { 74 | frame.style.outline = "" 75 | document.getElementById(`hotwire-dev-tools-highlight-overlay-${frameId}`)?.remove() 76 | return 77 | } 78 | 79 | if (highlightFramesWithOverlay) { 80 | const rect = frame.getBoundingClientRect() 81 | let overlay = document.getElementById(`hotwire-dev-tools-highlight-overlay-${frameId}`) 82 | if (!overlay) { 83 | overlay = document.createElement("div") 84 | overlay.id = `hotwire-dev-tools-highlight-overlay-${frameId}` 85 | overlay.className = DOMScanner.TURBO_FRAME_OVERLAY_CLASS_NAME 86 | } 87 | 88 | Object.assign(overlay.style, { 89 | top: `${rect.top + windowScrollY}px`, 90 | left: `${rect.left + windowScrollX}px`, 91 | width: `${rect.width}px`, 92 | height: `${rect.height}px`, 93 | outlineStyle: highlightFramesOutlineStyle, 94 | outlineWidth: highlightFramesOutlineWidth, 95 | outlineColor: highlightFramesOutlineColor, 96 | }) 97 | 98 | if (!overlay.parentNode) { 99 | document.body.appendChild(overlay) 100 | } 101 | addBadge(overlay, frameId) 102 | } else { 103 | Object.assign(frame.style, { 104 | outlineStyle: highlightFramesOutlineStyle, 105 | outlineWidth: highlightFramesOutlineWidth, 106 | outlineColor: highlightFramesOutlineColor, 107 | }) 108 | addBadge(frame, frameId) 109 | } 110 | }) 111 | } 112 | 113 | const highlightStimulusControllers = () => { 114 | const controllers = DOMScanner.stimulusControllerElements 115 | if (!devTool.options.stimulus.highlightControllers) { 116 | controllers.forEach((controller) => (controller.style.outline = "")) 117 | return 118 | } 119 | 120 | const { highlightControllersOutlineWidth, highlightControllersOutlineStyle, highlightControllersOutlineColor, highlightControllersBlacklist } = devTool.options.stimulus 121 | let blacklistedControllers = [] 122 | if (highlightControllersBlacklist) { 123 | try { 124 | blacklistedControllers = Array.from(document.querySelectorAll(highlightControllersBlacklist)) 125 | } catch (error) { 126 | console.warn("Hotwire Dev Tools: Invalid Stimulus controller ignore selector:", highlightControllersBlacklist) 127 | } 128 | } 129 | 130 | controllers.forEach((controller) => { 131 | if (blacklistedControllers.includes(controller)) { 132 | controller.style.outline = "" 133 | return 134 | } 135 | controller.style.outlineStyle = highlightControllersOutlineStyle 136 | controller.style.outlineWidth = highlightControllersOutlineWidth 137 | controller.style.outlineColor = highlightControllersOutlineColor 138 | }) 139 | } 140 | 141 | const injectCustomScript = () => { 142 | const existingScript = document.getElementById("hotwire-dev-tools-inject-script") 143 | if (existingScript) return 144 | 145 | const script = document.createElement("script") 146 | script.src = chrome.runtime.getURL("dist/hotwire_dev_tools_inject_script.js") 147 | script.id = "hotwire-dev-tools-inject-script" 148 | document.documentElement.appendChild(script) 149 | } 150 | 151 | const consoleLogTurboStream = (event) => { 152 | if (!devTool.options.turbo.consoleLogTurboStreams) return 153 | 154 | const turboStream = event.target 155 | const targetElements = turboStreamTargetElements(turboStream) 156 | const target = turboStream.getAttribute("target") 157 | const targets = turboStream.getAttribute("targets") 158 | 159 | let message = `Hotwire Dev Tools: Turbo Stream received` 160 | 161 | const targetsNotFoundInTheDOM = (target || targets) && (targetElements || []).length === 0 162 | if (targetsNotFoundInTheDOM) { 163 | message += ` - Target ${target ? "element" : "elements"} not found!` 164 | console.warn(message, turboStream) 165 | return 166 | } 167 | 168 | console.log(message, turboStream) 169 | } 170 | 171 | const checkForWarnings = debounce(() => { 172 | if (devTool.options.logWarnings) { 173 | diagnosticsChecker.checkForWarnings() 174 | } 175 | }, 150) 176 | 177 | const handleTurboFrameBadgeClick = (event) => { 178 | navigator.clipboard.writeText(event.target.dataset.turboId).then(() => { 179 | event.target.classList.add("copied") 180 | }) 181 | } 182 | 183 | const handleTurboFrameBadgeAnimationEnd = (event) => { 184 | event.target.classList.remove("copied") 185 | } 186 | 187 | const handleIncomingTurboStream = (event) => { 188 | detailPanel.addTurboStreamToDetailPanel(event) 189 | consoleLogTurboStream(event) 190 | } 191 | 192 | const handleWindowMessage = (event) => { 193 | if (event.origin !== LOCATION_ORIGIN) return 194 | if (event.data.source !== "inject") return 195 | 196 | switch (event.data.message) { 197 | case "stimulusController": 198 | if (event.data.registeredControllers) { 199 | devTool.registeredStimulusControllers = event.data.registeredControllers 200 | renderDetailPanel() 201 | checkForWarnings() 202 | } 203 | break 204 | case "turboDetails": 205 | devTool.turboDetails = event.data.details 206 | renderDetailPanel() 207 | break 208 | } 209 | } 210 | 211 | const handleTurboBeforeCache = (event) => { 212 | DOMScanner.turboFrameOverlayElements.forEach((element) => { 213 | element.remove() 214 | }) 215 | } 216 | 217 | const handleMonitoredEvent = (eventName, event) => { 218 | if (!devTool.options.monitor.events?.includes(eventName)) return 219 | 220 | let message = `Hotwire Dev Tools: ${eventName}` 221 | const target = event.target 222 | if (target?.id) message += ` #${target.id}` 223 | 224 | console.groupCollapsed(message) 225 | console.log(event) 226 | console.groupEnd() 227 | } 228 | 229 | const handleTurboFrameRender = (event) => { 230 | if (!devTool.options.turbo.highlightFramesChanges) return 231 | 232 | const turboFrame = event.target 233 | const overlayClassName = `${DOMScanner.TURBO_FRAME_OVERLAY_CLASS_NAME}-${turboFrame.id}` 234 | const color = devTool.options.turbo.highlightFramesOutlineColor 235 | addHighlightOverlayToElements([turboFrame], color, overlayClassName, "0.1") 236 | 237 | setTimeout(() => { 238 | removeHighlightOverlay(`.${overlayClassName}`) 239 | }, 350) 240 | } 241 | 242 | const renderDetailPanel = () => { 243 | if (!devTool.shouldRenderDetailPanel()) { 244 | detailPanel.dispose() 245 | return 246 | } 247 | 248 | detailPanel.render() 249 | } 250 | 251 | const listenForEvents = () => { 252 | MONITORING_EVENTS.forEach((eventName) => { 253 | window.addEventListener(eventName, (event) => { 254 | // For some unknown reason, we can't use the event itself in Safari, without loosing custom properties, like event.detail. 255 | // The only hacky workaround that seems to work is to use a setTimeout with some delay. (Issue#73) 256 | setTimeout(() => { 257 | handleMonitoredEvent(eventName, event) 258 | }, 100) 259 | }) 260 | }) 261 | } 262 | 263 | const init = async () => { 264 | await devTool.setOptions() 265 | 266 | injectCustomScript() 267 | highlightTurboFrames() 268 | highlightStimulusControllers() 269 | renderDetailPanel() 270 | checkForWarnings() 271 | } 272 | 273 | const events = ["turbolinks:load", "turbo:load", "turbo:frame-load", "hotwire-dev-tools:options-changed"] 274 | events.forEach((event) => document.addEventListener(event, init, { passive: true })) 275 | document.addEventListener("turbo:before-stream-render", handleIncomingTurboStream, { passive: true }) 276 | 277 | // When Turbo Drive renders a new page, we wanna copy over the existing detail panel - shadow container - to the new page, 278 | // so we can keep the detail panel open, without flickering, when navigating between pages. 279 | // (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) 280 | window.addEventListener("turbo:before-render", (event) => { 281 | event.target.appendChild(DOMScanner.shadowContainer) 282 | }) 283 | 284 | // Chance to clean up any DOM modifications made by this extension before Turbo caches the page 285 | window.addEventListener("turbo:before-cache", handleTurboBeforeCache) 286 | 287 | // Listen for potential message from the injected script 288 | window.addEventListener("message", handleWindowMessage) 289 | 290 | // Listen for window resize events 291 | window.addEventListener("resize", highlightTurboFrames) 292 | 293 | // Listen for specific Turbo events 294 | window.addEventListener("turbo:frame-render", handleTurboFrameRender) 295 | 296 | // Listen for option changes made in the popup 297 | chrome.storage.onChanged.addListener((changes, area) => { 298 | if (changes.options?.newValue || changes[LOCATION_ORIGIN]?.newValue) { 299 | document.dispatchEvent(new CustomEvent("hotwire-dev-tools:options-changed")) 300 | } 301 | }) 302 | 303 | // On pages without Turbo, there doesn't seem to be an event that informs us when the page has fully loaded. 304 | // Therefore, we call init as soon as this content.js file is loaded. 305 | init() 306 | listenForEvents() 307 | -------------------------------------------------------------------------------- /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 | 5 | const sendRegisteredControllers = () => { 6 | const registeredControllers = window.Stimulus?.router.modulesByIdentifier.keys() 7 | window.postMessage( 8 | { 9 | source: "inject", 10 | message: "stimulusController", 11 | registeredControllers: Array.from(registeredControllers || []), 12 | }, 13 | window.location.origin, 14 | ) 15 | } 16 | 17 | const sendTurboDetails = () => { 18 | window.postMessage( 19 | { 20 | source: "inject", 21 | message: "turboDetails", 22 | details: { turboDriveEnabled: window.Turbo?.session.drive }, 23 | }, 24 | window.location.origin, 25 | ) 26 | } 27 | 28 | const sendWindowDetails = () => { 29 | sendRegisteredControllers() 30 | sendTurboDetails() 31 | } 32 | 33 | const events = ["DOMContentLoaded", "turbolinks:load", "turbo:load"] 34 | events.forEach((event) => document.addEventListener(event, sendWindowDetails, { passive: true })) 35 | sendWindowDetails() 36 | -------------------------------------------------------------------------------- /src/lib/devtool.js: -------------------------------------------------------------------------------- 1 | import { loadCSS } from "../utils/utils" 2 | 3 | export default class Devtool { 4 | constructor(origin = null) { 5 | this.options = this.defaultOptions 6 | this.registeredStimulusControllers = [] 7 | this.turboDetails = {} 8 | 9 | this.origin = origin 10 | this.detailPanelCSSContent = null 11 | 12 | this.setOptions() 13 | } 14 | 15 | setOptions = async () => { 16 | this.options = await this.getOptions() 17 | } 18 | 19 | getOptions = async () => { 20 | const globalOptions = await this.globalUserOptions() 21 | const originOptions = await this.originOptions() 22 | 23 | let options = originOptions || globalOptions || this.defaultOptions 24 | options = this.addMissingDefaultOptions(options) 25 | return options 26 | } 27 | 28 | globalUserOptions = async () => { 29 | const options = await chrome.storage.sync.get("options") 30 | return options?.options 31 | } 32 | 33 | originOptions = async () => { 34 | const pageOptions = await chrome.storage.sync.get(this.origin) 35 | return pageOptions[this.origin]?.options 36 | } 37 | 38 | saveOptions = async (options, saveToOriginStore = null) => { 39 | const newOptions = { ...this.options, ...options } 40 | let dataToStore = newOptions 41 | let key = "options" 42 | 43 | if (saveToOriginStore === null) { 44 | saveToOriginStore = await this.originOptionsExist() 45 | } 46 | 47 | if (saveToOriginStore) { 48 | dataToStore = this.origin ? { options: newOptions } : newOptions 49 | key = this.origin || "options" 50 | } 51 | 52 | chrome.storage.sync.set({ [key]: dataToStore }, () => { 53 | const error = chrome.runtime.lastError 54 | if (error) { 55 | if (error.message.includes("MAX_WRITE_OPERATIONS_PER_MINUTE")) { 56 | 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.") 57 | } else { 58 | console.error("Hotwire Dev Tools: Error while saving options:", error) 59 | } 60 | return 61 | } 62 | 63 | // Options were saved successfully 64 | this.options = newOptions 65 | }) 66 | } 67 | 68 | removeOptionsForOrigin = async () => { 69 | await chrome.storage.sync.remove(this.origin) 70 | } 71 | 72 | originOptionsExist = async () => { 73 | const options = await this.originOptions() 74 | return !!options 75 | } 76 | 77 | detailPanelCSS = async () => { 78 | if (this.detailPanelCSSContent) return this.detailPanelCSSContent 79 | 80 | this.detailPanelCSSContent = await loadCSS(chrome.runtime.getURL("styles/hotwire_dev_tools_detail_panel.css")) 81 | return this.detailPanelCSSContent 82 | } 83 | 84 | shouldRenderDetailPanel = () => { 85 | const { show, showStimulusTab, showTurboFrameTab, showTurboStreamTab } = this.options.detailPanel 86 | return show && (showStimulusTab || showTurboFrameTab || showTurboStreamTab) 87 | } 88 | 89 | addMissingDefaultOptions = (options) => { 90 | if (options.addOptionsForVersion === this.version) return options 91 | 92 | const defaultOptions = this.defaultOptions 93 | for (const key in defaultOptions) { 94 | if (options[key] === undefined) { 95 | options[key] = defaultOptions[key] 96 | } 97 | } 98 | 99 | options.addOptionsForVersion = this.version 100 | return options 101 | } 102 | 103 | get version() { 104 | return chrome.runtime.getManifest().version 105 | } 106 | 107 | get isFirefox() { 108 | return navigator.userAgent.toLowerCase().indexOf("firefox") > -1 109 | } 110 | 111 | get defaultOptions() { 112 | return { 113 | turbo: { 114 | highlightFrames: false, 115 | highlightFramesOutlineWidth: "2px", 116 | highlightFramesOutlineStyle: "dashed", 117 | highlightFramesOutlineColor: "#5cd8e5", 118 | highlightFramesBlacklist: "", 119 | highlightFramesWithOverlay: false, 120 | highlightFramesChanges: false, 121 | ignoreEmptyFrames: false, 122 | consoleLogTurboStreams: false, 123 | }, 124 | stimulus: { 125 | highlightControllers: false, 126 | highlightControllersOutlineWidth: "2px", 127 | highlightControllersOutlineStyle: "dashed", 128 | highlightControllersOutlineColor: "#77e8b9", 129 | highlightControllersBlacklist: "", 130 | }, 131 | detailPanel: { 132 | show: false, 133 | showStimulusTab: true, 134 | showTurboFrameTab: true, 135 | showTurboStreamTab: true, 136 | collapsed: false, 137 | currentTab: "hotwire-dev-tools-stimulus-tab", 138 | }, 139 | monitor: { 140 | events: [], 141 | }, 142 | logWarnings: true, 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /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/lib/monitoring_events.js: -------------------------------------------------------------------------------- 1 | export const MONITORING_EVENTS = [ 2 | "turbo:click", 3 | "turbo:before-visit", 4 | "turbo:visit", 5 | "turbo:before-cache", 6 | "turbo:before-render", 7 | "turbo:render", 8 | "turbo:load", 9 | "turbo:morph", 10 | "turbo:before-morph-element", 11 | "turbo:before-morph-attribute", 12 | "turbo:morph-element", 13 | "turbo:submit-start", 14 | "turbo:submit-end", 15 | "turbo:before-frame-render", 16 | "turbo:frame-render", 17 | "turbo:frame-load", 18 | "turbo:frame-missing", 19 | "turbo:before-stream-render", 20 | "turbo:before-fetch-request", 21 | "turbo:before-fetch-response", 22 | "turbo:before-prefetch", 23 | "turbo:fetch-request-error", 24 | ] 25 | 26 | export const MONITORING_EVENT_GROUPS = { 27 | Document: ["turbo:click", "turbo:before-visit", "turbo:visit", "turbo:before-cache", "turbo:before-render", "turbo:render", "turbo:load"], 28 | "Page Refreshes": ["turbo:morph", "turbo:before-morph-element", "turbo:before-morph-attribute", "turbo:morph-element"], 29 | Forms: ["turbo:submit-start", "turbo:submit-end"], 30 | Frames: ["turbo:before-frame-render", "turbo:frame-render", "turbo:frame-load", "turbo:frame-missing"], 31 | Streams: ["turbo:before-stream-render"], 32 | "HTTP Requests": ["turbo:before-fetch-request", "turbo:before-fetch-response", "turbo:before-prefetch", "turbo:fetch-request-error"], 33 | } 34 | -------------------------------------------------------------------------------- /src/popup.js: -------------------------------------------------------------------------------- 1 | import Devtool from "./lib/devtool" 2 | import { MONITORING_EVENTS, MONITORING_EVENT_GROUPS } from "./lib/monitoring_events" 3 | 4 | const devTool = new Devtool() 5 | 6 | const versionString = document.getElementById("version-string") 7 | 8 | const pageSpecificOptions = document.getElementById("page-specific-options") 9 | 10 | const turboHighlightFrames = document.getElementById("turbo-highlight-frames") 11 | const turboHighlightFramesOutlineWidth = document.getElementById("turbo-highlight-frames-outline-width") 12 | const turboHighlightFramesOutlineStyle = document.getElementById("turbo-highlight-frames-outline-style") 13 | const turboHighlightFramesOutlineColor = document.getElementById("turbo-highlight-frames-outline-color") 14 | const turboHighlightFramesBlacklist = document.getElementById("turbo-highlight-frames-blacklist") 15 | const turboHighlightFramesToggles = document.querySelectorAll(".turbo-highlight-frames-toggle-element") 16 | const turboHighlightFramesIgnoreEmpty = document.getElementById("turbo-highlight-frames-ignore-empty") 17 | const turboHighlightFramesWithOverlay = document.getElementById("turbo-highlight-frames-with-overlay") 18 | const turbohighlightFramesChanges = document.getElementById("turbo-highlight-frames-changes") 19 | const turboConsoleLogTurboStreams = document.getElementById("turbo-console-log-turbo-streams") 20 | 21 | const stimulusHighlightControllers = document.getElementById("stimulus-highlight-controllers") 22 | const stimulusHighlightControllersOutlineWidth = document.getElementById("stimulus-highlight-controllers-outline-width") 23 | const stimulusHighlightControllersOutlineStyle = document.getElementById("stimulus-highlight-controllers-outline-style") 24 | const stimulusHighlightControllersOutlineColor = document.getElementById("stimulus-highlight-controllers-outline-color") 25 | const stimulusHighlightControllersBlacklist = document.getElementById("stimulus-highlight-controllers-blacklist") 26 | const stimulusHighlightControllersToggles = document.querySelectorAll(".stimulus-highlight-controllers-toggle-element") 27 | 28 | const detailPanelShow = document.getElementById("detail-panel-show") 29 | const detailPanelShowStimulusTab = document.getElementById("detail-panel-show-stimulus-tab") 30 | const detailPanelShowTurboFrameTab = document.getElementById("detail-panel-show-turbo-frame-tab") 31 | const detailPanelShowTurboStreamTab = document.getElementById("detail-panel-show-turbo-stream-tab") 32 | const detailPanelToggles = document.querySelectorAll(".detail-panel-toggle-element") 33 | 34 | const monitorEvents = document.getElementById("monitor-events") 35 | const monitorEventsToggles = document.querySelectorAll(".monitor-events-toggle-element") 36 | const monitorEventsCheckboxContainer = document.querySelector(".monitor-events-checkbox-container") 37 | const monitorEventsSelectAll = document.getElementById("monitor-events-select-all") 38 | 39 | const logWarning = document.getElementById("log-warnings") 40 | 41 | const toggleInputs = (toggleElements, show) => { 42 | toggleElements.forEach((element) => { 43 | element.classList.toggle("d-none", !show) 44 | }) 45 | } 46 | 47 | // When the popup is opened, the CSS transitions are disabled to prevent flickering 48 | // After initializing the form, we enable the transition effects again 49 | const enableCSSTransitions = () => { 50 | setTimeout(() => { 51 | document.body.classList.remove("no-transitions") 52 | }, 100) 53 | } 54 | 55 | const saveOptions = async (options) => { 56 | devTool.saveOptions(options, pageSpecificOptions.checked) 57 | } 58 | 59 | const showFirefoxColorPreview = () => { 60 | if (!devTool.isFirefox) return 61 | 62 | document.querySelectorAll(".color-preview").forEach((preview) => { 63 | preview.classList.remove("d-none") 64 | preview.style.backgroundColor = preview.nextElementSibling.value 65 | }) 66 | } 67 | 68 | const initializeForm = async (options) => { 69 | const originOptionsExist = await devTool.originOptionsExist() 70 | pageSpecificOptions.checked = originOptionsExist 71 | 72 | turboHighlightFrames.checked = options.turbo.highlightFrames 73 | turboConsoleLogTurboStreams.checked = options.turbo.consoleLogTurboStreams 74 | turboHighlightFramesWithOverlay.checked = options.turbo.highlightFramesWithOverlay 75 | turboHighlightFramesIgnoreEmpty.checked = options.turbo.ignoreEmptyFrames 76 | turbohighlightFramesChanges.checked = options.turbo.highlightFramesChanges 77 | turboHighlightFramesOutlineColor.value = options.turbo.highlightFramesOutlineColor 78 | turboHighlightFramesOutlineStyle.value = options.turbo.highlightFramesOutlineStyle 79 | turboHighlightFramesOutlineWidth.value = options.turbo.highlightFramesOutlineWidth 80 | turboHighlightFramesBlacklist.value = options.turbo.highlightFramesBlacklist 81 | 82 | stimulusHighlightControllers.checked = options.stimulus.highlightControllers 83 | stimulusHighlightControllersOutlineColor.value = options.stimulus.highlightControllersOutlineColor 84 | stimulusHighlightControllersOutlineStyle.value = options.stimulus.highlightControllersOutlineStyle 85 | stimulusHighlightControllersOutlineWidth.value = options.stimulus.highlightControllersOutlineWidth 86 | stimulusHighlightControllersBlacklist.value = options.stimulus.highlightControllersBlacklist 87 | 88 | detailPanelShow.checked = options.detailPanel.show 89 | detailPanelShowStimulusTab.checked = options.detailPanel.showStimulusTab 90 | detailPanelShowTurboFrameTab.checked = options.detailPanel.showTurboFrameTab 91 | detailPanelShowTurboStreamTab.checked = options.detailPanel.showTurboStreamTab 92 | 93 | monitorEvents.checked = options.monitor.events.length > 0 94 | 95 | logWarning.checked = options.logWarnings 96 | 97 | if (devTool.isFirefox) { 98 | // In Firefox the color picker inside an extension popup doesn't really work (See https://github.com/leonvogt/hotwire-dev-tools/issues/20) 99 | // Workaround: Change the input type to text so the user can input the color manually 100 | turboHighlightFramesOutlineColor.type = "text" 101 | stimulusHighlightControllersOutlineColor.type = "text" 102 | 103 | turboHighlightFramesOutlineColor.value = options.turbo.highlightFramesOutlineColor 104 | stimulusHighlightControllersOutlineColor.value = options.stimulus.highlightControllersOutlineColor 105 | 106 | showFirefoxColorPreview() 107 | } 108 | 109 | const activeEvents = Array.from(options.monitor?.events || []) 110 | 111 | Object.entries(MONITORING_EVENT_GROUPS).forEach(([groupName, events]) => { 112 | const groupContainer = document.createElement("div") 113 | groupContainer.classList.add("monitor-events-group") 114 | 115 | const groupTitle = document.createElement("strong") 116 | groupTitle.textContent = groupName 117 | groupTitle.classList.add("monitor-events-group-title") 118 | groupContainer.appendChild(groupTitle) 119 | 120 | events.forEach((event) => { 121 | const wrapper = document.createElement("div") 122 | const checkbox = document.createElement("input") 123 | checkbox.type = "checkbox" 124 | checkbox.id = `monitor-${event}` 125 | checkbox.value = event 126 | checkbox.checked = activeEvents.includes(event) 127 | 128 | const label = document.createElement("label") 129 | label.htmlFor = `monitor-${event}` 130 | label.textContent = event 131 | 132 | wrapper.appendChild(checkbox) 133 | wrapper.appendChild(label) 134 | groupContainer.appendChild(wrapper) 135 | }) 136 | 137 | monitorEventsCheckboxContainer.appendChild(groupContainer) 138 | }) 139 | 140 | toggleInputs(turboHighlightFramesToggles, options.turbo.highlightFrames) 141 | toggleInputs(stimulusHighlightControllersToggles, options.stimulus.highlightControllers) 142 | toggleInputs(detailPanelToggles, options.detailPanel.show) 143 | toggleInputs(monitorEventsToggles, monitorEvents.checked) 144 | enableCSSTransitions() 145 | } 146 | 147 | const maybeHideDetailPanel = (options) => { 148 | const { showStimulusTab, showTurboFrameTab, showTurboStreamTab } = options.detailPanel 149 | const showDetailPanel = showStimulusTab || showTurboFrameTab || showTurboStreamTab 150 | 151 | if (!showDetailPanel) { 152 | detailPanelShow.checked = false 153 | toggleInputs(detailPanelToggles, false) 154 | } 155 | } 156 | 157 | const setupEventListeners = (options) => { 158 | pageSpecificOptions.addEventListener("change", async (event) => { 159 | if (!event.target.checked) { 160 | await devTool.removeOptionsForOrigin() 161 | 162 | // Reset the form to the global options 163 | await devTool.setOptions() 164 | initializeForm(devTool.options) 165 | } 166 | }) 167 | 168 | turboHighlightFrames.addEventListener("change", (event) => { 169 | const checked = event.target.checked 170 | options.turbo.highlightFrames = checked 171 | toggleInputs(turboHighlightFramesToggles, checked) 172 | saveOptions(options) 173 | }) 174 | 175 | turboHighlightFramesOutlineStyle.addEventListener("change", (event) => { 176 | options.turbo.highlightFramesOutlineStyle = event.target.value 177 | saveOptions(options) 178 | }) 179 | 180 | turboHighlightFramesOutlineWidth.addEventListener("change", (event) => { 181 | options.turbo.highlightFramesOutlineWidth = event.target.value 182 | saveOptions(options) 183 | }) 184 | 185 | turboHighlightFramesOutlineColor.addEventListener(devTool.isFirefox ? "input" : "change", (event) => { 186 | options.turbo.highlightFramesOutlineColor = event.target.value 187 | 188 | showFirefoxColorPreview() 189 | saveOptions(options) 190 | }) 191 | 192 | turboHighlightFramesBlacklist.addEventListener("input", (event) => { 193 | options.turbo.highlightFramesBlacklist = event.target.value 194 | saveOptions(options) 195 | }) 196 | 197 | turboHighlightFramesIgnoreEmpty.addEventListener("change", (event) => { 198 | options.turbo.ignoreEmptyFrames = event.target.checked 199 | saveOptions(options) 200 | }) 201 | 202 | turboHighlightFramesWithOverlay.addEventListener("change", (event) => { 203 | options.turbo.highlightFramesWithOverlay = event.target.checked 204 | saveOptions(options) 205 | }) 206 | 207 | turbohighlightFramesChanges.addEventListener("change", (event) => { 208 | options.turbo.highlightFramesChanges = event.target.checked 209 | saveOptions(options) 210 | }) 211 | 212 | turboConsoleLogTurboStreams.addEventListener("change", (event) => { 213 | options.turbo.consoleLogTurboStreams = event.target.checked 214 | saveOptions(options) 215 | }) 216 | 217 | stimulusHighlightControllers.addEventListener("change", (event) => { 218 | const checked = event.target.checked 219 | options.stimulus.highlightControllers = checked 220 | toggleInputs(stimulusHighlightControllersToggles, checked) 221 | saveOptions(options) 222 | }) 223 | 224 | stimulusHighlightControllersOutlineStyle.addEventListener("change", (event) => { 225 | options.stimulus.highlightControllersOutlineStyle = event.target.value 226 | saveOptions(options) 227 | }) 228 | 229 | stimulusHighlightControllersOutlineWidth.addEventListener("change", (event) => { 230 | options.stimulus.highlightControllersOutlineWidth = event.target.value 231 | saveOptions(options) 232 | }) 233 | 234 | stimulusHighlightControllersOutlineColor.addEventListener(devTool.isFirefox ? "input" : "change", (event) => { 235 | options.stimulus.highlightControllersOutlineColor = event.target.value 236 | 237 | showFirefoxColorPreview() 238 | saveOptions(options) 239 | }) 240 | 241 | stimulusHighlightControllersBlacklist.addEventListener("input", (event) => { 242 | options.stimulus.highlightControllersBlacklist = event.target.value 243 | saveOptions(options) 244 | }) 245 | 246 | detailPanelShow.addEventListener("change", (event) => { 247 | const showDetailPanel = event.target.checked 248 | options.detailPanel.show = showDetailPanel 249 | options.detailPanel.collapsed = false 250 | toggleInputs(detailPanelToggles, showDetailPanel) 251 | 252 | const anyTabActive = detailPanelShowStimulusTab.checked || detailPanelShowTurboFrameTab.checked || detailPanelShowTurboStreamTab.checked 253 | if (showDetailPanel && !anyTabActive) { 254 | // Enable all tabs by default 255 | detailPanelShowStimulusTab.checked = true 256 | detailPanelShowTurboFrameTab.checked = true 257 | detailPanelShowTurboStreamTab.checked = true 258 | 259 | options.detailPanel.showStimulusTab = true 260 | options.detailPanel.showTurboFrameTab = true 261 | options.detailPanel.showTurboStreamTab = true 262 | } 263 | 264 | saveOptions(options) 265 | }) 266 | 267 | detailPanelShowStimulusTab.addEventListener("change", (event) => { 268 | options.detailPanel.showStimulusTab = event.target.checked 269 | saveOptions(options) 270 | maybeHideDetailPanel(options) 271 | }) 272 | 273 | detailPanelShowTurboFrameTab.addEventListener("change", (event) => { 274 | options.detailPanel.showTurboFrameTab = event.target.checked 275 | saveOptions(options) 276 | maybeHideDetailPanel(options) 277 | }) 278 | 279 | detailPanelShowTurboStreamTab.addEventListener("change", (event) => { 280 | options.detailPanel.showTurboStreamTab = event.target.checked 281 | saveOptions(options) 282 | maybeHideDetailPanel(options) 283 | }) 284 | 285 | monitorEvents.addEventListener("change", (event) => { 286 | const checked = event.target.checked 287 | toggleInputs(monitorEventsToggles, checked) 288 | if (checked) { 289 | const allCheckboxes = document.querySelectorAll(".monitor-events-checkbox-container input[type='checkbox']") 290 | allCheckboxes.forEach((checkbox) => (checkbox.checked = true)) 291 | options.monitor.events = MONITORING_EVENTS 292 | } else { 293 | options.monitor.events = [] 294 | } 295 | saveOptions(options) 296 | 297 | // Scoll the first event group into view 298 | document.querySelector(".monitor-events-group-title").scrollIntoView({ behavior: "smooth", block: "center" }) 299 | }) 300 | 301 | monitorEventsCheckboxContainer.addEventListener("change", (event) => { 302 | const checkbox = event.target 303 | const eventValue = checkbox.value 304 | 305 | if (checkbox.checked) { 306 | options.monitor.events.push(eventValue) 307 | } else { 308 | options.monitor.events = options.monitor.events.filter((event) => event !== eventValue) 309 | } 310 | 311 | saveOptions(options) 312 | }) 313 | 314 | monitorEventsCheckboxContainer.addEventListener("click", (event) => { 315 | const element = event.target 316 | if (element.classList.contains("monitor-events-group-title")) { 317 | const checkboxes = element.parentElement.querySelectorAll("input[type='checkbox']") 318 | const allChecked = Array.from(checkboxes).every((checkbox) => checkbox.checked) 319 | 320 | checkboxes.forEach((checkbox) => { 321 | checkbox.checked = !allChecked 322 | const eventValue = checkbox.value 323 | if (checkbox.checked) { 324 | options.monitor.events.push(eventValue) 325 | } else { 326 | options.monitor.events = options.monitor.events.filter((event) => event !== eventValue) 327 | } 328 | }) 329 | 330 | saveOptions(options) 331 | } 332 | }) 333 | 334 | monitorEventsSelectAll.addEventListener("click", (event) => { 335 | event.preventDefault() 336 | const allCheckboxes = document.querySelectorAll(".monitor-events-checkbox-container input[type='checkbox']") 337 | const allChecked = Array.from(allCheckboxes).every((checkbox) => checkbox.checked) 338 | 339 | allCheckboxes.forEach((checkbox) => { 340 | checkbox.checked = !allChecked 341 | }) 342 | 343 | options.monitor.events = allChecked ? [] : MONITORING_EVENTS 344 | saveOptions(options) 345 | }) 346 | 347 | logWarning.addEventListener("change", (event) => { 348 | options.logWarnings = event.target.checked 349 | saveOptions(options) 350 | }) 351 | } 352 | 353 | const getCurrentTabOrigin = async () => { 354 | return new Promise((resolve, reject) => { 355 | chrome.tabs.query({ currentWindow: true, active: true }, function (tabs) { 356 | if (chrome.runtime.lastError) { 357 | return reject(chrome.runtime.lastError) 358 | } 359 | 360 | if (tabs.length === 0) { 361 | return reject(new Error("No active tab found")) 362 | } 363 | 364 | if (!tabs[0].url) { 365 | return reject(new Error("Active tab has no URL")) 366 | } 367 | 368 | const origin = new URL(tabs[0].url).origin 369 | resolve(origin) 370 | }) 371 | }) 372 | } 373 | 374 | ;(async () => { 375 | try { 376 | const origin = await getCurrentTabOrigin() 377 | devTool.origin = origin 378 | } catch (error) { 379 | // If we can't get the origin, we just work with the global user options 380 | document.querySelector(".page-specific-options-wrapper").remove() 381 | } 382 | 383 | versionString.textContent = devTool.version 384 | 385 | const options = await devTool.getOptions() 386 | initializeForm(options) 387 | setupEventListeners(options) 388 | })() 389 | -------------------------------------------------------------------------------- /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 = (elements, color = "#77e8b9", overlayClassName = "hotwire-dev-tools-highlight-overlay", opacity = "0.2") => { 2 | elements.forEach((element) => { 3 | const rect = element.getBoundingClientRect() 4 | createOverlay(rect, color, overlayClassName, opacity) 5 | }) 6 | } 7 | 8 | export const createOverlay = (rect, color, overlayClassName, opacity) => { 9 | const overlay = document.createElement("div") 10 | overlay.className = overlayClassName 11 | overlay.style.position = "absolute" 12 | overlay.style.zIndex = 9999999 13 | overlay.style.opacity = opacity 14 | overlay.style.top = `${rect.top + window.scrollY}px` 15 | overlay.style.left = `${rect.left + window.scrollX}px` 16 | overlay.style.width = `${rect.width}px` 17 | overlay.style.height = `${rect.height}px` 18 | overlay.style.backgroundColor = color 19 | document.body.appendChild(overlay) 20 | } 21 | 22 | export const removeHighlightOverlay = (selector = ".hotwire-dev-tools-highlight-overlay") => { 23 | const overlays = document.querySelectorAll(selector) 24 | overlays.forEach((overlay) => overlay.remove()) 25 | } 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 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 | -------------------------------------------------------------------------------- /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.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 356FE36E2C0DF1F10001527B /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 356FE36D2C0DF1F10001527B /* AppDelegate.swift */; }; 11 | 356FE3722C0DF1F10001527B /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 356FE3712C0DF1F10001527B /* Base */; }; 12 | 356FE3742C0DF1F10001527B /* Icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 356FE3732C0DF1F10001527B /* Icon.png */; }; 13 | 356FE3762C0DF1F10001527B /* Style.css in Resources */ = {isa = PBXBuildFile; fileRef = 356FE3752C0DF1F10001527B /* Style.css */; }; 14 | 356FE3782C0DF1F10001527B /* Script.js in Resources */ = {isa = PBXBuildFile; fileRef = 356FE3772C0DF1F10001527B /* Script.js */; }; 15 | 356FE37A2C0DF1F10001527B /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 356FE3792C0DF1F10001527B /* ViewController.swift */; }; 16 | 356FE37D2C0DF1F10001527B /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 356FE37C2C0DF1F10001527B /* Base */; }; 17 | 356FE37F2C0DF1F20001527B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 356FE37E2C0DF1F20001527B /* Assets.xcassets */; }; 18 | 356FE3882C0DF1F30001527B /* HotwireDevTools Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 356FE3872C0DF1F30001527B /* HotwireDevTools Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 19 | 356FE38D2C0DF1F30001527B /* SafariWebExtensionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 356FE38C2C0DF1F30001527B /* SafariWebExtensionHandler.swift */; }; 20 | 356FE3A02C0DF1F30001527B /* dist in Resources */ = {isa = PBXBuildFile; fileRef = 356FE39A2C0DF1F30001527B /* dist */; }; 21 | 356FE3A12C0DF1F30001527B /* images in Resources */ = {isa = PBXBuildFile; fileRef = 356FE39B2C0DF1F30001527B /* images */; }; 22 | 356FE3A22C0DF1F30001527B /* popup.html in Resources */ = {isa = PBXBuildFile; fileRef = 356FE39C2C0DF1F30001527B /* popup.html */; }; 23 | 356FE3A32C0DF1F30001527B /* styles in Resources */ = {isa = PBXBuildFile; fileRef = 356FE39D2C0DF1F30001527B /* styles */; }; 24 | 356FE3A52C0DF1F30001527B /* manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = 356FE39F2C0DF1F30001527B /* manifest.json */; }; 25 | /* End PBXBuildFile section */ 26 | 27 | /* Begin PBXContainerItemProxy section */ 28 | 356FE3892C0DF1F30001527B /* PBXContainerItemProxy */ = { 29 | isa = PBXContainerItemProxy; 30 | containerPortal = 356FE3622C0DF1F10001527B /* Project object */; 31 | proxyType = 1; 32 | remoteGlobalIDString = 356FE3862C0DF1F30001527B; 33 | remoteInfo = "HotwireDevTools Extension"; 34 | }; 35 | /* End PBXContainerItemProxy section */ 36 | 37 | /* Begin PBXCopyFilesBuildPhase section */ 38 | 356FE3952C0DF1F30001527B /* Embed Foundation Extensions */ = { 39 | isa = PBXCopyFilesBuildPhase; 40 | buildActionMask = 2147483647; 41 | dstPath = ""; 42 | dstSubfolderSpec = 13; 43 | files = ( 44 | 356FE3882C0DF1F30001527B /* HotwireDevTools Extension.appex in Embed Foundation Extensions */, 45 | ); 46 | name = "Embed Foundation Extensions"; 47 | runOnlyForDeploymentPostprocessing = 0; 48 | }; 49 | /* End PBXCopyFilesBuildPhase section */ 50 | 51 | /* Begin PBXFileReference section */ 52 | 356FE36A2C0DF1F10001527B /* HotwireDevTools.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HotwireDevTools.app; sourceTree = BUILT_PRODUCTS_DIR; }; 53 | 356FE36D2C0DF1F10001527B /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 54 | 356FE3712C0DF1F10001527B /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = Base; path = ../Base.lproj/Main.html; sourceTree = ""; }; 55 | 356FE3732C0DF1F10001527B /* Icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = Icon.png; sourceTree = ""; }; 56 | 356FE3752C0DF1F10001527B /* Style.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = Style.css; sourceTree = ""; }; 57 | 356FE3772C0DF1F10001527B /* Script.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = Script.js; sourceTree = ""; }; 58 | 356FE3792C0DF1F10001527B /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 59 | 356FE37C2C0DF1F10001527B /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 60 | 356FE37E2C0DF1F20001527B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 61 | 356FE3802C0DF1F20001527B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 62 | 356FE3812C0DF1F20001527B /* HotwireDevTools.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HotwireDevTools.entitlements; sourceTree = ""; }; 63 | 356FE3822C0DF1F20001527B /* HotwireDevTools.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HotwireDevTools.entitlements; sourceTree = ""; }; 64 | 356FE3872C0DF1F30001527B /* HotwireDevTools Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "HotwireDevTools Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 65 | 356FE38C2C0DF1F30001527B /* SafariWebExtensionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariWebExtensionHandler.swift; sourceTree = ""; }; 66 | 356FE38E2C0DF1F30001527B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 67 | 356FE38F2C0DF1F30001527B /* HotwireDevTools_Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HotwireDevTools_Extension.entitlements; sourceTree = ""; }; 68 | 356FE39A2C0DF1F30001527B /* dist */ = {isa = PBXFileReference; lastKnownFileType = folder; name = dist; path = ../../public/dist; sourceTree = ""; }; 69 | 356FE39B2C0DF1F30001527B /* images */ = {isa = PBXFileReference; lastKnownFileType = folder; name = images; path = ../../public/images; sourceTree = ""; }; 70 | 356FE39C2C0DF1F30001527B /* popup.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = popup.html; path = ../../public/popup.html; sourceTree = ""; }; 71 | 356FE39D2C0DF1F30001527B /* styles */ = {isa = PBXFileReference; lastKnownFileType = folder; name = styles; path = ../../public/styles; sourceTree = ""; }; 72 | 356FE39F2C0DF1F30001527B /* manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = manifest.json; path = ../../public/manifest.json; sourceTree = ""; }; 73 | /* End PBXFileReference section */ 74 | 75 | /* Begin PBXFrameworksBuildPhase section */ 76 | 356FE3672C0DF1F10001527B /* Frameworks */ = { 77 | isa = PBXFrameworksBuildPhase; 78 | buildActionMask = 2147483647; 79 | files = ( 80 | ); 81 | runOnlyForDeploymentPostprocessing = 0; 82 | }; 83 | 356FE3842C0DF1F30001527B /* Frameworks */ = { 84 | isa = PBXFrameworksBuildPhase; 85 | buildActionMask = 2147483647; 86 | files = ( 87 | ); 88 | runOnlyForDeploymentPostprocessing = 0; 89 | }; 90 | /* End PBXFrameworksBuildPhase section */ 91 | 92 | /* Begin PBXGroup section */ 93 | 356FE3612C0DF1F10001527B = { 94 | isa = PBXGroup; 95 | children = ( 96 | 356FE36C2C0DF1F10001527B /* HotwireDevTools */, 97 | 356FE38B2C0DF1F30001527B /* HotwireDevTools Extension */, 98 | 356FE36B2C0DF1F10001527B /* Products */, 99 | ); 100 | sourceTree = ""; 101 | }; 102 | 356FE36B2C0DF1F10001527B /* Products */ = { 103 | isa = PBXGroup; 104 | children = ( 105 | 356FE36A2C0DF1F10001527B /* HotwireDevTools.app */, 106 | 356FE3872C0DF1F30001527B /* HotwireDevTools Extension.appex */, 107 | ); 108 | name = Products; 109 | sourceTree = ""; 110 | }; 111 | 356FE36C2C0DF1F10001527B /* HotwireDevTools */ = { 112 | isa = PBXGroup; 113 | children = ( 114 | 356FE36D2C0DF1F10001527B /* AppDelegate.swift */, 115 | 356FE3792C0DF1F10001527B /* ViewController.swift */, 116 | 356FE37B2C0DF1F10001527B /* Main.storyboard */, 117 | 356FE37E2C0DF1F20001527B /* Assets.xcassets */, 118 | 356FE3802C0DF1F20001527B /* Info.plist */, 119 | 356FE3812C0DF1F20001527B /* HotwireDevTools.entitlements */, 120 | 356FE3822C0DF1F20001527B /* HotwireDevTools.entitlements */, 121 | 356FE36F2C0DF1F10001527B /* Resources */, 122 | ); 123 | path = HotwireDevTools; 124 | sourceTree = ""; 125 | }; 126 | 356FE36F2C0DF1F10001527B /* Resources */ = { 127 | isa = PBXGroup; 128 | children = ( 129 | 356FE3702C0DF1F10001527B /* Main.html */, 130 | 356FE3732C0DF1F10001527B /* Icon.png */, 131 | 356FE3752C0DF1F10001527B /* Style.css */, 132 | 356FE3772C0DF1F10001527B /* Script.js */, 133 | ); 134 | path = Resources; 135 | sourceTree = ""; 136 | }; 137 | 356FE38B2C0DF1F30001527B /* HotwireDevTools Extension */ = { 138 | isa = PBXGroup; 139 | children = ( 140 | 356FE3992C0DF1F30001527B /* Resources */, 141 | 356FE38C2C0DF1F30001527B /* SafariWebExtensionHandler.swift */, 142 | 356FE38E2C0DF1F30001527B /* Info.plist */, 143 | 356FE38F2C0DF1F30001527B /* HotwireDevTools_Extension.entitlements */, 144 | ); 145 | path = "HotwireDevTools Extension"; 146 | sourceTree = ""; 147 | }; 148 | 356FE3992C0DF1F30001527B /* Resources */ = { 149 | isa = PBXGroup; 150 | children = ( 151 | 356FE39A2C0DF1F30001527B /* dist */, 152 | 356FE39B2C0DF1F30001527B /* images */, 153 | 356FE39C2C0DF1F30001527B /* popup.html */, 154 | 356FE39D2C0DF1F30001527B /* styles */, 155 | 356FE39F2C0DF1F30001527B /* manifest.json */, 156 | ); 157 | name = Resources; 158 | path = "HotwireDevTools Extension"; 159 | sourceTree = SOURCE_ROOT; 160 | }; 161 | /* End PBXGroup section */ 162 | 163 | /* Begin PBXNativeTarget section */ 164 | 356FE3692C0DF1F10001527B /* HotwireDevTools */ = { 165 | isa = PBXNativeTarget; 166 | buildConfigurationList = 356FE3962C0DF1F30001527B /* Build configuration list for PBXNativeTarget "HotwireDevTools" */; 167 | buildPhases = ( 168 | 356FE3662C0DF1F10001527B /* Sources */, 169 | 356FE3672C0DF1F10001527B /* Frameworks */, 170 | 356FE3682C0DF1F10001527B /* Resources */, 171 | 356FE3952C0DF1F30001527B /* Embed Foundation Extensions */, 172 | ); 173 | buildRules = ( 174 | ); 175 | dependencies = ( 176 | 356FE38A2C0DF1F30001527B /* PBXTargetDependency */, 177 | ); 178 | name = HotwireDevTools; 179 | productName = HotwireDevTools; 180 | productReference = 356FE36A2C0DF1F10001527B /* HotwireDevTools.app */; 181 | productType = "com.apple.product-type.application"; 182 | }; 183 | 356FE3862C0DF1F30001527B /* HotwireDevTools Extension */ = { 184 | isa = PBXNativeTarget; 185 | buildConfigurationList = 356FE3922C0DF1F30001527B /* Build configuration list for PBXNativeTarget "HotwireDevTools Extension" */; 186 | buildPhases = ( 187 | 356FE3832C0DF1F30001527B /* Sources */, 188 | 356FE3842C0DF1F30001527B /* Frameworks */, 189 | 356FE3852C0DF1F30001527B /* Resources */, 190 | ); 191 | buildRules = ( 192 | ); 193 | dependencies = ( 194 | ); 195 | name = "HotwireDevTools Extension"; 196 | productName = "HotwireDevTools Extension"; 197 | productReference = 356FE3872C0DF1F30001527B /* HotwireDevTools Extension.appex */; 198 | productType = "com.apple.product-type.app-extension"; 199 | }; 200 | /* End PBXNativeTarget section */ 201 | 202 | /* Begin PBXProject section */ 203 | 356FE3622C0DF1F10001527B /* Project object */ = { 204 | isa = PBXProject; 205 | attributes = { 206 | BuildIndependentTargetsInParallel = 1; 207 | LastSwiftUpdateCheck = 1540; 208 | LastUpgradeCheck = 1540; 209 | TargetAttributes = { 210 | 356FE3692C0DF1F10001527B = { 211 | CreatedOnToolsVersion = 15.4; 212 | }; 213 | 356FE3862C0DF1F30001527B = { 214 | CreatedOnToolsVersion = 15.4; 215 | }; 216 | }; 217 | }; 218 | buildConfigurationList = 356FE3652C0DF1F10001527B /* Build configuration list for PBXProject "HotwireDevTools" */; 219 | compatibilityVersion = "Xcode 14.0"; 220 | developmentRegion = en; 221 | hasScannedForEncodings = 0; 222 | knownRegions = ( 223 | en, 224 | Base, 225 | ); 226 | mainGroup = 356FE3612C0DF1F10001527B; 227 | productRefGroup = 356FE36B2C0DF1F10001527B /* Products */; 228 | projectDirPath = ""; 229 | projectRoot = ""; 230 | targets = ( 231 | 356FE3692C0DF1F10001527B /* HotwireDevTools */, 232 | 356FE3862C0DF1F30001527B /* HotwireDevTools Extension */, 233 | ); 234 | }; 235 | /* End PBXProject section */ 236 | 237 | /* Begin PBXResourcesBuildPhase section */ 238 | 356FE3682C0DF1F10001527B /* Resources */ = { 239 | isa = PBXResourcesBuildPhase; 240 | buildActionMask = 2147483647; 241 | files = ( 242 | 356FE3742C0DF1F10001527B /* Icon.png in Resources */, 243 | 356FE3782C0DF1F10001527B /* Script.js in Resources */, 244 | 356FE3722C0DF1F10001527B /* Base in Resources */, 245 | 356FE3762C0DF1F10001527B /* Style.css in Resources */, 246 | 356FE37F2C0DF1F20001527B /* Assets.xcassets in Resources */, 247 | 356FE37D2C0DF1F10001527B /* Base in Resources */, 248 | ); 249 | runOnlyForDeploymentPostprocessing = 0; 250 | }; 251 | 356FE3852C0DF1F30001527B /* Resources */ = { 252 | isa = PBXResourcesBuildPhase; 253 | buildActionMask = 2147483647; 254 | files = ( 255 | 356FE3A12C0DF1F30001527B /* images in Resources */, 256 | 356FE3A22C0DF1F30001527B /* popup.html in Resources */, 257 | 356FE3A32C0DF1F30001527B /* styles in Resources */, 258 | 356FE3A52C0DF1F30001527B /* manifest.json in Resources */, 259 | 356FE3A02C0DF1F30001527B /* dist in Resources */, 260 | ); 261 | runOnlyForDeploymentPostprocessing = 0; 262 | }; 263 | /* End PBXResourcesBuildPhase section */ 264 | 265 | /* Begin PBXSourcesBuildPhase section */ 266 | 356FE3662C0DF1F10001527B /* Sources */ = { 267 | isa = PBXSourcesBuildPhase; 268 | buildActionMask = 2147483647; 269 | files = ( 270 | 356FE37A2C0DF1F10001527B /* ViewController.swift in Sources */, 271 | 356FE36E2C0DF1F10001527B /* AppDelegate.swift in Sources */, 272 | ); 273 | runOnlyForDeploymentPostprocessing = 0; 274 | }; 275 | 356FE3832C0DF1F30001527B /* Sources */ = { 276 | isa = PBXSourcesBuildPhase; 277 | buildActionMask = 2147483647; 278 | files = ( 279 | 356FE38D2C0DF1F30001527B /* SafariWebExtensionHandler.swift in Sources */, 280 | ); 281 | runOnlyForDeploymentPostprocessing = 0; 282 | }; 283 | /* End PBXSourcesBuildPhase section */ 284 | 285 | /* Begin PBXTargetDependency section */ 286 | 356FE38A2C0DF1F30001527B /* PBXTargetDependency */ = { 287 | isa = PBXTargetDependency; 288 | target = 356FE3862C0DF1F30001527B /* HotwireDevTools Extension */; 289 | targetProxy = 356FE3892C0DF1F30001527B /* PBXContainerItemProxy */; 290 | }; 291 | /* End PBXTargetDependency section */ 292 | 293 | /* Begin PBXVariantGroup section */ 294 | 356FE3702C0DF1F10001527B /* Main.html */ = { 295 | isa = PBXVariantGroup; 296 | children = ( 297 | 356FE3712C0DF1F10001527B /* Base */, 298 | ); 299 | name = Main.html; 300 | sourceTree = ""; 301 | }; 302 | 356FE37B2C0DF1F10001527B /* Main.storyboard */ = { 303 | isa = PBXVariantGroup; 304 | children = ( 305 | 356FE37C2C0DF1F10001527B /* Base */, 306 | ); 307 | name = Main.storyboard; 308 | sourceTree = ""; 309 | }; 310 | /* End PBXVariantGroup section */ 311 | 312 | /* Begin XCBuildConfiguration section */ 313 | 356FE3902C0DF1F30001527B /* Debug */ = { 314 | isa = XCBuildConfiguration; 315 | buildSettings = { 316 | ALWAYS_SEARCH_USER_PATHS = NO; 317 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 318 | CLANG_ANALYZER_NONNULL = YES; 319 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 320 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 321 | CLANG_ENABLE_MODULES = YES; 322 | CLANG_ENABLE_OBJC_ARC = YES; 323 | CLANG_ENABLE_OBJC_WEAK = YES; 324 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 325 | CLANG_WARN_BOOL_CONVERSION = YES; 326 | CLANG_WARN_COMMA = YES; 327 | CLANG_WARN_CONSTANT_CONVERSION = YES; 328 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 329 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 330 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 331 | CLANG_WARN_EMPTY_BODY = YES; 332 | CLANG_WARN_ENUM_CONVERSION = YES; 333 | CLANG_WARN_INFINITE_RECURSION = YES; 334 | CLANG_WARN_INT_CONVERSION = YES; 335 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 336 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 337 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 338 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 339 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 340 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 341 | CLANG_WARN_STRICT_PROTOTYPES = YES; 342 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 343 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 344 | CLANG_WARN_UNREACHABLE_CODE = YES; 345 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 346 | COPY_PHASE_STRIP = NO; 347 | DEBUG_INFORMATION_FORMAT = dwarf; 348 | ENABLE_STRICT_OBJC_MSGSEND = YES; 349 | ENABLE_TESTABILITY = YES; 350 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 351 | GCC_C_LANGUAGE_STANDARD = gnu17; 352 | GCC_DYNAMIC_NO_PIC = NO; 353 | GCC_NO_COMMON_BLOCKS = YES; 354 | GCC_OPTIMIZATION_LEVEL = 0; 355 | GCC_PREPROCESSOR_DEFINITIONS = ( 356 | "DEBUG=1", 357 | "$(inherited)", 358 | ); 359 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 360 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 361 | GCC_WARN_UNDECLARED_SELECTOR = YES; 362 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 363 | GCC_WARN_UNUSED_FUNCTION = YES; 364 | GCC_WARN_UNUSED_VARIABLE = YES; 365 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 366 | MACOSX_DEPLOYMENT_TARGET = 14.2; 367 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 368 | MTL_FAST_MATH = YES; 369 | ONLY_ACTIVE_ARCH = YES; 370 | SDKROOT = macosx; 371 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 372 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 373 | }; 374 | name = Debug; 375 | }; 376 | 356FE3912C0DF1F30001527B /* Release */ = { 377 | isa = XCBuildConfiguration; 378 | buildSettings = { 379 | ALWAYS_SEARCH_USER_PATHS = NO; 380 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 381 | CLANG_ANALYZER_NONNULL = YES; 382 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 383 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 384 | CLANG_ENABLE_MODULES = YES; 385 | CLANG_ENABLE_OBJC_ARC = YES; 386 | CLANG_ENABLE_OBJC_WEAK = YES; 387 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 388 | CLANG_WARN_BOOL_CONVERSION = YES; 389 | CLANG_WARN_COMMA = YES; 390 | CLANG_WARN_CONSTANT_CONVERSION = YES; 391 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 392 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 393 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 394 | CLANG_WARN_EMPTY_BODY = YES; 395 | CLANG_WARN_ENUM_CONVERSION = YES; 396 | CLANG_WARN_INFINITE_RECURSION = YES; 397 | CLANG_WARN_INT_CONVERSION = YES; 398 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 399 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 400 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 401 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 402 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 403 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 404 | CLANG_WARN_STRICT_PROTOTYPES = YES; 405 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 406 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 407 | CLANG_WARN_UNREACHABLE_CODE = YES; 408 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 409 | COPY_PHASE_STRIP = NO; 410 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 411 | ENABLE_NS_ASSERTIONS = NO; 412 | ENABLE_STRICT_OBJC_MSGSEND = YES; 413 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 414 | GCC_C_LANGUAGE_STANDARD = gnu17; 415 | GCC_NO_COMMON_BLOCKS = YES; 416 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 417 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 418 | GCC_WARN_UNDECLARED_SELECTOR = YES; 419 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 420 | GCC_WARN_UNUSED_FUNCTION = YES; 421 | GCC_WARN_UNUSED_VARIABLE = YES; 422 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 423 | MACOSX_DEPLOYMENT_TARGET = 14.2; 424 | MTL_ENABLE_DEBUG_INFO = NO; 425 | MTL_FAST_MATH = YES; 426 | SDKROOT = macosx; 427 | SWIFT_COMPILATION_MODE = wholemodule; 428 | }; 429 | name = Release; 430 | }; 431 | 356FE3932C0DF1F30001527B /* Debug */ = { 432 | isa = XCBuildConfiguration; 433 | buildSettings = { 434 | CODE_SIGN_ENTITLEMENTS = "HotwireDevTools Extension/HotwireDevTools_Extension.entitlements"; 435 | CODE_SIGN_STYLE = Automatic; 436 | CURRENT_PROJECT_VERSION = 1; 437 | DEVELOPMENT_TEAM = M28LQ2QM5W; 438 | ENABLE_HARDENED_RUNTIME = YES; 439 | GENERATE_INFOPLIST_FILE = YES; 440 | INFOPLIST_FILE = "HotwireDevTools Extension/Info.plist"; 441 | INFOPLIST_KEY_CFBundleDisplayName = "HotwireDevTools Extension"; 442 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 443 | LD_RUNPATH_SEARCH_PATHS = ( 444 | "$(inherited)", 445 | "@executable_path/../Frameworks", 446 | "@executable_path/../../../../Frameworks", 447 | ); 448 | MACOSX_DEPLOYMENT_TARGET = 10.14; 449 | MARKETING_VERSION = 0.1.2; 450 | OTHER_LDFLAGS = ( 451 | "-framework", 452 | SafariServices, 453 | ); 454 | PRODUCT_BUNDLE_IDENTIFIER = hotwire.dev.tools.extension; 455 | PRODUCT_NAME = "$(TARGET_NAME)"; 456 | SKIP_INSTALL = YES; 457 | SWIFT_EMIT_LOC_STRINGS = YES; 458 | SWIFT_VERSION = 5.0; 459 | }; 460 | name = Debug; 461 | }; 462 | 356FE3942C0DF1F30001527B /* Release */ = { 463 | isa = XCBuildConfiguration; 464 | buildSettings = { 465 | CODE_SIGN_ENTITLEMENTS = "HotwireDevTools Extension/HotwireDevTools_Extension.entitlements"; 466 | CODE_SIGN_STYLE = Automatic; 467 | CURRENT_PROJECT_VERSION = 1; 468 | DEVELOPMENT_TEAM = M28LQ2QM5W; 469 | ENABLE_HARDENED_RUNTIME = YES; 470 | GENERATE_INFOPLIST_FILE = YES; 471 | INFOPLIST_FILE = "HotwireDevTools Extension/Info.plist"; 472 | INFOPLIST_KEY_CFBundleDisplayName = "HotwireDevTools Extension"; 473 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 474 | LD_RUNPATH_SEARCH_PATHS = ( 475 | "$(inherited)", 476 | "@executable_path/../Frameworks", 477 | "@executable_path/../../../../Frameworks", 478 | ); 479 | MACOSX_DEPLOYMENT_TARGET = 10.14; 480 | MARKETING_VERSION = 0.1.2; 481 | OTHER_LDFLAGS = ( 482 | "-framework", 483 | SafariServices, 484 | ); 485 | PRODUCT_BUNDLE_IDENTIFIER = hotwire.dev.tools.extension; 486 | PRODUCT_NAME = "$(TARGET_NAME)"; 487 | SKIP_INSTALL = YES; 488 | SWIFT_EMIT_LOC_STRINGS = YES; 489 | SWIFT_VERSION = 5.0; 490 | }; 491 | name = Release; 492 | }; 493 | 356FE3972C0DF1F30001527B /* Debug */ = { 494 | isa = XCBuildConfiguration; 495 | buildSettings = { 496 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 497 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 498 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 499 | CODE_SIGN_ENTITLEMENTS = HotwireDevTools/HotwireDevTools.entitlements; 500 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; 501 | CODE_SIGN_STYLE = Automatic; 502 | COMBINE_HIDPI_IMAGES = YES; 503 | CURRENT_PROJECT_VERSION = 12; 504 | DEVELOPMENT_TEAM = M28LQ2QM5W; 505 | ENABLE_HARDENED_RUNTIME = YES; 506 | GENERATE_INFOPLIST_FILE = YES; 507 | INFOPLIST_FILE = HotwireDevTools/Info.plist; 508 | INFOPLIST_KEY_CFBundleDisplayName = HotwireDevTools; 509 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; 510 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 511 | INFOPLIST_KEY_NSMainStoryboardFile = Main; 512 | INFOPLIST_KEY_NSPrincipalClass = NSApplication; 513 | LD_RUNPATH_SEARCH_PATHS = ( 514 | "$(inherited)", 515 | "@executable_path/../Frameworks", 516 | ); 517 | MACOSX_DEPLOYMENT_TARGET = 10.14; 518 | MARKETING_VERSION = 0.3.3; 519 | OTHER_LDFLAGS = ( 520 | "-framework", 521 | SafariServices, 522 | "-framework", 523 | WebKit, 524 | ); 525 | PRODUCT_BUNDLE_IDENTIFIER = hotwire.dev.tools; 526 | PRODUCT_NAME = "$(TARGET_NAME)"; 527 | SWIFT_EMIT_LOC_STRINGS = YES; 528 | SWIFT_VERSION = 5.0; 529 | }; 530 | name = Debug; 531 | }; 532 | 356FE3982C0DF1F30001527B /* Release */ = { 533 | isa = XCBuildConfiguration; 534 | buildSettings = { 535 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 536 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 537 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 538 | CODE_SIGN_ENTITLEMENTS = HotwireDevTools/HotwireDevTools.entitlements; 539 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; 540 | CODE_SIGN_STYLE = Automatic; 541 | COMBINE_HIDPI_IMAGES = YES; 542 | CURRENT_PROJECT_VERSION = 12; 543 | DEVELOPMENT_TEAM = M28LQ2QM5W; 544 | ENABLE_HARDENED_RUNTIME = YES; 545 | GENERATE_INFOPLIST_FILE = YES; 546 | INFOPLIST_FILE = HotwireDevTools/Info.plist; 547 | INFOPLIST_KEY_CFBundleDisplayName = HotwireDevTools; 548 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; 549 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 550 | INFOPLIST_KEY_NSMainStoryboardFile = Main; 551 | INFOPLIST_KEY_NSPrincipalClass = NSApplication; 552 | LD_RUNPATH_SEARCH_PATHS = ( 553 | "$(inherited)", 554 | "@executable_path/../Frameworks", 555 | ); 556 | MACOSX_DEPLOYMENT_TARGET = 10.14; 557 | MARKETING_VERSION = 0.3.3; 558 | OTHER_LDFLAGS = ( 559 | "-framework", 560 | SafariServices, 561 | "-framework", 562 | WebKit, 563 | ); 564 | PRODUCT_BUNDLE_IDENTIFIER = hotwire.dev.tools; 565 | PRODUCT_NAME = "$(TARGET_NAME)"; 566 | SWIFT_EMIT_LOC_STRINGS = YES; 567 | SWIFT_VERSION = 5.0; 568 | }; 569 | name = Release; 570 | }; 571 | /* End XCBuildConfiguration section */ 572 | 573 | /* Begin XCConfigurationList section */ 574 | 356FE3652C0DF1F10001527B /* Build configuration list for PBXProject "HotwireDevTools" */ = { 575 | isa = XCConfigurationList; 576 | buildConfigurations = ( 577 | 356FE3902C0DF1F30001527B /* Debug */, 578 | 356FE3912C0DF1F30001527B /* Release */, 579 | ); 580 | defaultConfigurationIsVisible = 0; 581 | defaultConfigurationName = Release; 582 | }; 583 | 356FE3922C0DF1F30001527B /* Build configuration list for PBXNativeTarget "HotwireDevTools Extension" */ = { 584 | isa = XCConfigurationList; 585 | buildConfigurations = ( 586 | 356FE3932C0DF1F30001527B /* Debug */, 587 | 356FE3942C0DF1F30001527B /* Release */, 588 | ); 589 | defaultConfigurationIsVisible = 0; 590 | defaultConfigurationName = Release; 591 | }; 592 | 356FE3962C0DF1F30001527B /* Build configuration list for PBXNativeTarget "HotwireDevTools" */ = { 593 | isa = XCConfigurationList; 594 | buildConfigurations = ( 595 | 356FE3972C0DF1F30001527B /* Debug */, 596 | 356FE3982C0DF1F30001527B /* Release */, 597 | ); 598 | defaultConfigurationIsVisible = 0; 599 | defaultConfigurationName = Release; 600 | }; 601 | /* End XCConfigurationList section */ 602 | }; 603 | rootObject = 356FE3622C0DF1F10001527B /* Project object */; 604 | } 605 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-128@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/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/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-128@2x.png -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-16@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/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/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-16@2x.png -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-256@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/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/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-256@2x.png -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-32@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/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/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-32@2x.png -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-512@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/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/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/xcode/HotwireDevTools/Assets.xcassets/AppIcon.appiconset/mac-icon-512@2x.png -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SFSafariWebExtensionConverterVersion 6 | 15.4 7 | 8 | 9 | -------------------------------------------------------------------------------- /xcode/HotwireDevTools/Resources/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leonvogt/hotwire-dev-tools/00e1beef8d9b2c715ebd91a5ec5867c77b9c1c5a/xcode/HotwireDevTools/Resources/Icon.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | --------------------------------------------------------------------------------