DevTools Test Page
26 |This page is used for testing the Hotwire Native DevTools.
27 |├── .github └── workflows │ ├── cypress.yml │ └── publish.yml ├── .gitignore ├── .prettierrc.json ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── android └── DevToolsComponent.kt ├── cypress.config.js ├── cypress ├── e2e │ ├── bottom-sheet.cy.js │ ├── console-logs.cy.js │ └── floating-bubble.cy.js ├── fixtures │ └── index.html └── support │ ├── commands.js │ └── e2e.js ├── ios └── DevToolsComponent.swift ├── package-lock.json ├── package.json ├── src ├── DevTools.js ├── assets │ ├── DevToolsStyling.css.js │ └── icons.js ├── components │ ├── BottomSheet.js │ └── FloatingBubble.js ├── index.d.ts ├── index.js ├── lib │ ├── DevToolsState.js │ ├── DiagnosticsChecker.js │ └── NativeBridge.js └── utils │ ├── settings.js │ └── utils.js └── vite.config.js /.github/workflows/cypress.yml: -------------------------------------------------------------------------------- 1 | name: End-To-End tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | cypress-run: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 18 20 | 21 | - name: Install dependencies 22 | run: npm ci 23 | 24 | - name: Run E2E tests 25 | run: npm run test:e2e 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | id-token: write 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: "20.x" 16 | registry-url: "https://registry.npmjs.org" 17 | always-auth: true 18 | - run: npm ci 19 | - run: npm run build 20 | - run: npm publish --provenance --access public 21 | env: 22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "printWidth": 220, 4 | "semi": false 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "search.exclude": { 5 | "**/dist": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Leon Vogt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
This page is used for testing the Hotwire Native DevTools.
27 |${JSON.stringify(arg, null, 2)}` 173 | } catch { 174 | return `
${arg}` 175 | } 176 | } 177 | // Escape HTML in string values 178 | const stringValue = arg.toString() 179 | return stringValue.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'") 180 | }) 181 | .join(" ") 182 | 183 | // Ignore messages from the dev tools itself 184 | // Otherwise we could end up in an infinite loop 185 | if (message.includes("hotwire-native-dev-tools") || message.includes("HotwireDevTools")) return 186 | 187 | this.state.addConsoleLog(type, message) 188 | if (type === "error") { 189 | this.bubble.animateErrorBorder() 190 | } 191 | } 192 | 193 | // Fetch the current stack from the native side 194 | // The debounce on this function is intentionally high, 195 | // to ensure the native side has enough time to set the ViewController / Fragment titles. 196 | // With a lower debounce, the view controller / fragment title would often be empty. 197 | fetchNativeStack = debounce(() => { 198 | this.nativeBridge.send("currentStackInfo", {}, (message) => { 199 | this.state.setSupportsNativeStack(true) 200 | this.state.setNativeStack(message.data.stack) 201 | }) 202 | }, 1000) 203 | 204 | refetchNativeStack() { 205 | this.nativeBridge.send("currentStackInfo", {}, (message) => { 206 | this.state.setNativeStack(message.data.stack) 207 | }) 208 | } 209 | 210 | injectCSSToShadowRoot = async () => { 211 | if (this.shadowRoot.querySelector("style")) return 212 | 213 | const style = document.createElement("style") 214 | style.textContent = cssContent() 215 | this.shadowRoot.appendChild(style) 216 | } 217 | 218 | addEventListeners() { 219 | if (this.hasEventListeners) return 220 | 221 | // Capture uncaught errors and unhandled promise rejections 222 | window.addEventListener("error", (event) => { 223 | const { message, filename, lineno, colno } = event 224 | const formattedMessage = `${message} at ${filename}:${lineno}:${colno}` 225 | this.interceptedConsoleMessage("error", [formattedMessage]) 226 | }) 227 | window.addEventListener("unhandledrejection", (event) => { 228 | this.interceptedConsoleMessage("error", [event.reason?.message]) 229 | }) 230 | 231 | // Observe screen size or orientation changes to reposition the bubble 232 | window.addEventListener( 233 | "resize", 234 | () => { 235 | this.bubble.render() 236 | }, 237 | { passive: true } 238 | ) 239 | 240 | // Listen for localStorage changes triggered by devtools in another (native) tab. 241 | // This keeps the devtools UI in sync across tabs. 242 | window.addEventListener("storage", (event) => { 243 | if (event.key === "hotwire-native-dev-tools") { 244 | this.state.updateLocalStorageSettings() 245 | this.bubble.render() 246 | this.bottomSheet.update(this.state.state) 247 | this.bottomSheet.applySettingsFromStorage() 248 | } 249 | }) 250 | 251 | this.hasEventListeners = true 252 | } 253 | 254 | listenForTurboEvents() { 255 | if (this.eventsRegistered) return 256 | 257 | const turboEvents = [ 258 | "turbo:click", 259 | "turbo:before-visit", 260 | "turbo:visit", 261 | "turbo:before-cache", 262 | "turbo:before-render", 263 | "turbo:render", 264 | "turbo:load", 265 | "turbo:morph", 266 | "turbo:before-morph-element", 267 | "turbo:before-morph-attribute", 268 | "turbo:morph-element", 269 | "turbo:submit-start", 270 | "turbo:submit-end", 271 | "turbo:before-frame-render", 272 | "turbo:frame-render", 273 | "turbo:frame-load", 274 | "turbo:frame-missing", 275 | "turbo:before-stream-render", 276 | "turbo:before-fetch-request", 277 | "turbo:before-fetch-response", 278 | "turbo:before-prefetch", 279 | "turbo:fetch-request-error", 280 | ] 281 | 282 | turboEvents.forEach((eventName) => { 283 | window.addEventListener( 284 | eventName, 285 | (event) => { 286 | this.state.addEventLog(eventName) 287 | }, 288 | { passive: true } 289 | ) 290 | }) 291 | 292 | this.eventsRegistered = true 293 | } 294 | 295 | startBridgeComponentObserver() { 296 | if (this.bridgeComponentObserver) return 297 | 298 | this.bridgeComponentObserver = new MutationObserver((mutationsList) => { 299 | for (const mutation of mutationsList) { 300 | if (mutation.type === "attributes" && mutation.attributeName === "data-bridge-components") { 301 | this.updateSupportedBridgeComponents() 302 | } 303 | } 304 | }) 305 | 306 | this.bridgeComponentObserver.observe(document.documentElement, { 307 | attributes: true, 308 | attributeFilter: ["data-bridge-components"], 309 | }) 310 | } 311 | 312 | updateSupportedBridgeComponents() { 313 | this.state.setSupportedBridgeComponents(this.nativeBridge.getSupportedComponents().sort()) 314 | } 315 | 316 | getCSSProperty(propertyName) { 317 | const rootStyles = getComputedStyle(this.shadowContainer) 318 | return rootStyles.getPropertyValue(propertyName).trim() 319 | } 320 | 321 | setCSSProperty(propertyName, value) { 322 | this.shadowContainer.style.setProperty(propertyName, value) 323 | } 324 | 325 | get shadowContainer() { 326 | const existingShadowContainer = document.getElementById("hotwire-native-dev-tools-shadow-container") 327 | if (existingShadowContainer) { 328 | return existingShadowContainer 329 | } 330 | const shadowContainer = document.createElement("div") 331 | shadowContainer.id = "hotwire-native-dev-tools-shadow-container" 332 | shadowContainer.setAttribute("data-native-prevent-pull-to-refresh", "") 333 | document.body.appendChild(shadowContainer) 334 | return shadowContainer 335 | } 336 | 337 | get currentTime() { 338 | return new Date().toLocaleTimeString() 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /src/assets/DevToolsStyling.css.js: -------------------------------------------------------------------------------- 1 | // Ideally, we would use a dedicated CSS file, but I dind't find a good way to load the styles from a dedicated CSS file. 2 | // So we just use a function that returns the CSS content as a string. 3 | // For better syntax highlighting, you can set the language to CSS in the editor for this file. 4 | export const cssContent = () => { 5 | return ` 6 | :host { 7 | all: initial; 8 | font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol" !important; 9 | --font-size: 16px; 10 | font-size: var(--font-size) !important; 11 | } 12 | 13 | * { 14 | box-sizing: border-box; 15 | } 16 | 17 | p, span, h1, h2, h3, h4, h5, h6, div, a, button, input, label { 18 | font-size: inherit; 19 | } 20 | 21 | 22 | a { 23 | color: white; 24 | } 25 | 26 | h1, h2, h3, h4, h5, h6 { 27 | margin: 0; 28 | } 29 | 30 | button, label, .toggle-label { 31 | user-select: none; 32 | -webkit-user-select: none; 33 | -webkit-tap-highlight-color: transparent; 34 | } 35 | 36 | input { 37 | display: block; 38 | padding: 3px; 39 | box-sizing: border-box; 40 | border: 1px solid #ccc; 41 | border-radius: 4px; 42 | } 43 | 44 | input:focus { 45 | outline: none; 46 | } 47 | 48 | .btn-icon { 49 | background-color: transparent; 50 | border: none; 51 | color: white; 52 | display: flex; 53 | align-items: center; 54 | justify-content: center; 55 | padding: 0.5em; 56 | height: 100%; 57 | } 58 | 59 | .btn-icon svg { 60 | width: 1rem; 61 | height: 1rem; 62 | fill: white; 63 | } 64 | 65 | /* Dropdown */ 66 | .dropdown-content { 67 | display: none; 68 | position: absolute; 69 | z-index: 1000; 70 | background: white; 71 | border: 1px solid #ddd; 72 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 73 | min-width: 200px; 74 | max-width: 300px; 75 | opacity: 0; 76 | transform: scale(0.9); 77 | transition: opacity 0.2s, transform 0.2s; 78 | user-select: none; 79 | -webkit-user-select: none; 80 | } 81 | 82 | .dropdown-content.dropdown-open { 83 | display: block; 84 | opacity: 1; 85 | transform: scale(1); 86 | pointer-events: auto; 87 | } 88 | 89 | .dropdown-content > * { 90 | padding: 12px; 91 | } 92 | 93 | .dropdown-content button, 94 | .dropdown-content label { 95 | color: black; 96 | width: 100%; 97 | margin: 0; 98 | border: none; 99 | display: flex; 100 | align-items: center; 101 | } 102 | 103 | .dropdown-content button:not(:first-child) { 104 | border-top: 1px solid #cecdcd; 105 | } 106 | 107 | .settings-dropdown { 108 | right: 0; 109 | top: 2rem; 110 | } 111 | 112 | /* Floating bubble */ 113 | #floating-bubble { 114 | display: flex; 115 | background-color: hsl(0deg 0% 0% / 60%); 116 | border-radius: 50%; 117 | touch-action: none; 118 | user-select: none; 119 | z-index: 10000000; 120 | position: fixed; 121 | top: 10px; 122 | left: 10px; 123 | 124 | /* Remove tap highlight on iOS */ 125 | -webkit-user-select: none; 126 | -webkit-tap-highlight-color: transparent; 127 | 128 | /* Keep width, height, and border in sync with bubbleSize in FloatingBubble.js */ 129 | width: 4.75rem; 130 | height: 4.75rem; 131 | border: 0.3rem solid rgba(136, 136, 136, 0.5); 132 | } 133 | 134 | #floating-bubble svg { 135 | transform: scale(0.6); 136 | fill: #b1b1b1; 137 | } 138 | 139 | #floating-bubble .animation-container { 140 | position: absolute; 141 | top: 0; 142 | left: 0; 143 | width: 100%; 144 | height: 100%; 145 | pointer-events: none; 146 | z-index: 1; 147 | } 148 | 149 | #floating-bubble .error-border { 150 | position: absolute; 151 | top: -30px; 152 | left: -30px; 153 | width: calc(100% + 60px); 154 | height: calc(100% + 60px); 155 | border-radius: 50%; 156 | } 157 | 158 | #floating-bubble .error-border circle { 159 | transform-origin: center; 160 | transform: rotate(-90deg); 161 | } 162 | 163 | #floating-bubble .error-border circle.animate { 164 | animation: error-border-progress 0.8s ease-out forwards; 165 | } 166 | 167 | #floating-bubble .animation-container.fade-out { 168 | animation: fade-out 0.4s ease-out forwards; 169 | } 170 | 171 | /* 172 | The "stroke-dasharray" defines the start of the animation 173 | The value is calculated by the formula: 2 * Math.PI * radius 174 | In this case: 2 * Math.PI * 90 = 565 175 | */ 176 | @keyframes error-border-progress { 177 | from { 178 | stroke-dashoffset: 565; 179 | } 180 | to { 181 | stroke-dashoffset: 0; 182 | } 183 | } 184 | 185 | @keyframes fade-out { 186 | from { 187 | opacity: 1; 188 | } 189 | to { 190 | opacity: 0; 191 | } 192 | } 193 | 194 | /* Bottom Sheet */ 195 | .bottom-sheet { 196 | position: fixed; 197 | bottom: 0; 198 | left: 0; 199 | width: 100%; 200 | max-height: 100%; 201 | display: flex; 202 | opacity: 0; 203 | pointer-events: none; 204 | align-items: center; 205 | flex-direction: column; 206 | justify-content: flex-end; 207 | transition: 0.1s linear; 208 | z-index: 10000001; 209 | } 210 | 211 | .bottom-sheet .sheet-overlay.active { 212 | position: fixed; 213 | top: 0; 214 | left: 0; 215 | z-index: -1; 216 | width: 100%; 217 | height: 100%; 218 | opacity: 0.2; 219 | background: #000; 220 | } 221 | 222 | .bottom-sheet .content { 223 | display: flex; 224 | flex-direction: column; 225 | width: 100%; 226 | height: 40vh; 227 | position: relative; 228 | color: white; 229 | transform: translateY(100%); 230 | border-radius: 12px 12px 0 0; 231 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.03); 232 | transition: 0.3s ease; 233 | overflow-y: hidden; 234 | } 235 | 236 | .bottom-sheet .log-entry { 237 | border-bottom: 1px solid #6c6c6c; 238 | white-space: collapse; 239 | } 240 | 241 | .bottom-sheet .log-entry-icon svg { 242 | width: 1rem; 243 | fill: white; 244 | } 245 | 246 | .bottom-sheet.show { 247 | opacity: 1; 248 | pointer-events: auto; 249 | } 250 | 251 | .bottom-sheet.show .content { 252 | transform: translateY(0%); 253 | } 254 | 255 | .bottom-sheet.dragging .content { 256 | transition: none; 257 | } 258 | .bottom-sheet.fullscreen .content { 259 | border-radius: 0; 260 | overflow-y: hidden; 261 | } 262 | 263 | .bottom-sheet .log-entry-message.warn { 264 | color: #f39c12; 265 | } 266 | 267 | .bottom-sheet .log-entry-message.error { 268 | color: #ED4E4C; 269 | } 270 | 271 | .bottom-sheet .tab-action-bars { 272 | /* Fixes a 1px gap that can appear between .tab-action-bar and .tablist on Android devices */ 273 | margin-top: -1px; 274 | } 275 | 276 | .bottom-sheet .tab-action-bar { 277 | display: none; 278 | justify-content: space-between; 279 | background-color: rgb(49, 54, 63); 280 | padding: 0.5rem; 281 | padding-right: 1rem; 282 | padding-left: 1rem; 283 | } 284 | 285 | .bottom-sheet .tab-action-bar.active { 286 | display: flex; 287 | } 288 | 289 | .bottom-sheet .tab-action-bar button:active svg { 290 | fill: #6c6c6c; 291 | } 292 | 293 | .bottom-sheet .btn-clear-tab, 294 | .bottom-sheet .btn-reload-tab { 295 | margin-left: auto; 296 | } 297 | 298 | /* Bottom Sheet Tabs */ 299 | .tablist { 300 | display: flex; 301 | overflow: hidden; 302 | background-color: #EEEEEE; 303 | } 304 | 305 | .tablist .tablink { 306 | color: black; 307 | background-color: inherit; 308 | width: 100%; 309 | border: none; 310 | outline: none; 311 | padding: 14px 16px; 312 | margin: 0; 313 | font-size: 0.8em; 314 | } 315 | 316 | .tablist .tablink.active { 317 | background-color: #31363f; 318 | color: white; 319 | } 320 | 321 | .tablist .tablink-settings { 322 | background-color: inherit; 323 | } 324 | 325 | .tab-contents { 326 | height: 100%; 327 | overflow: scroll; 328 | /* Fixes a 1px gap that can appear between .tab-action-bar and .tab-contents on Android devices */ 329 | margin-top: -1px; 330 | } 331 | 332 | .outer-tab-content { 333 | display: none; 334 | border-top: none; 335 | height: 100%; 336 | overflow: scroll; 337 | background-color: hsl(0deg 0% 0% / 80%); 338 | backdrop-filter: blur(30px) saturate(250%); 339 | -webkit-backdrop-filter: blur(30px) saturate(250%); 340 | padding-bottom: 7em; 341 | } 342 | .outer-tab-content.active { 343 | display: block; 344 | } 345 | .inner-tab-content { 346 | padding: 1rem; 347 | overflow-x: auto; 348 | white-space: nowrap; 349 | } 350 | .single-tab-content .inner-tab-content { 351 | white-space: normal; 352 | } 353 | 354 | .info-card { 355 | border-radius: 5px; 356 | background: hsl(0deg 0% 0% / 20%); 357 | padding: 1em; 358 | margin-bottom: 1em; 359 | } 360 | 361 | .info-card-title { 362 | font-size: 1em; 363 | font-weight: 700; 364 | margin-bottom: 1em; 365 | display: flex; 366 | justify-content: space-between; 367 | } 368 | 369 | .info-card-hint { 370 | font-size: 0.8em; 371 | } 372 | 373 | .tab-empty-content { 374 | display: flex; 375 | justify-content: center; 376 | flex-direction: column; 377 | align-items: center; 378 | padding: 1em; 379 | } 380 | 381 | .bottom-sheet .tablink-dropdown { 382 | background: inherit; 383 | border: none; 384 | outline: none; 385 | display: flex; 386 | align-items: center; 387 | justify-content: center; 388 | padding: 0.5em; 389 | width: 2rem; 390 | } 391 | 392 | .bottom-sheet .tablink-dropdown svg { 393 | width: 1rem; 394 | height: 1rem; 395 | fill: #121212; 396 | } 397 | 398 | .bottom-sheet .tablink-dropdown:active { 399 | background-color: #31363f; 400 | } 401 | .bottom-sheet .tablink-dropdown:active svg { 402 | fill: white; 403 | } 404 | 405 | /* Bottom Sheet Stack Visualization */ 406 | .bottom-sheet .viewstack-card { 407 | border: 1px solid #ddd; 408 | border-radius: 8px; 409 | padding: 10px; 410 | margin: 10px 0; 411 | background: white; 412 | color: black; 413 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 414 | overflow: auto; 415 | } 416 | 417 | .bottom-sheet .viewstack-card.current-view { 418 | border: 2px solid #f1f208; 419 | } 420 | 421 | .bottom-sheet .tab-container { 422 | background: #EEEEEE; 423 | } 424 | 425 | .bottom-sheet .main-view { 426 | border-color: #4e6080; 427 | background: #31363F; 428 | } 429 | 430 | .bottom-sheet .hotwire-view { 431 | border-color: #6db1b5; 432 | background: #76ABAE; 433 | } 434 | 435 | .bottom-sheet .child-container { 436 | margin-left: 30px; 437 | position: relative; 438 | } 439 | 440 | .bottom-sheet .child-container::before { 441 | content: ""; 442 | position: absolute; 443 | left: -15px; 444 | top: 0; 445 | bottom: 0; 446 | width: 2px; 447 | background: #ddd; 448 | } 449 | 450 | .bottom-sheet .view-title { 451 | display: flex; 452 | align-items: center; 453 | gap: 0.5em; 454 | 455 | font-weight: bold; 456 | color: white; 457 | margin-bottom: 5px; 458 | } 459 | 460 | .bottom-sheet .view-title-details { 461 | color: #efefef; 462 | font-size: 0.6em; 463 | } 464 | 465 | .bottom-sheet .tab-container .view-title-details { 466 | color: #6c6c6c; 467 | } 468 | 469 | .bottom-sheet .view-url { 470 | color: #000000; 471 | font-size: 0.9em; 472 | margin-top: 5px; 473 | word-break: break-all; 474 | } 475 | 476 | .bottom-sheet .non-identified-view { 477 | background: #EEEEEE; 478 | } 479 | .bottom-sheet .non-identified-view .view-title-details, 480 | .bottom-sheet .non-identified-view .view-title { 481 | color: #6c6c6c; 482 | } 483 | 484 | .bottom-sheet .viewstack-card pre { 485 | font-size: 0.8em; 486 | } 487 | 488 | /* Bottom Sheet Bridge Components */ 489 | .bottom-sheet .bridge-components-collapse-btn { 490 | background: none; 491 | border: none; 492 | color: white; 493 | width: 100%; 494 | text-align: left; 495 | border-bottom: 1px solid #eee; 496 | padding: 0.5em 0em; 497 | font-size: 0.9em; 498 | } 499 | 500 | .tab-content-bridge-components { 501 | display: grid; 502 | grid-template-columns: 1fr 1fr; 503 | gap: 10px; 504 | padding: 0.5em 0em; 505 | } 506 | 507 | .tab-content-bridge-components .bridge-component { 508 | position: relative; 509 | padding-left: 15px; 510 | } 511 | 512 | .tab-content-bridge-components .bridge-component::before { 513 | content: "•"; 514 | color: #eee; 515 | font-size: 1.5em; 516 | position: absolute; 517 | left: 0; 518 | top: 50%; 519 | transform: translateY(-50%); 520 | } 521 | .tab-content-bridge-components .bridge-component.connected::before { 522 | color: #5cff00 523 | } 524 | 525 | /* Collapsibles */ 526 | .collapse-target { 527 | display: none; 528 | } 529 | 530 | .collapse-target.active { 531 | display: block; 532 | } 533 | 534 | .collapse:not(.no-chevron):after { 535 | content: '\\25BC'; 536 | font-size: 13px; 537 | color: #777; 538 | float: right; 539 | margin-left: 5px; 540 | } 541 | 542 | .collapse:not(.no-chevron).active:after { 543 | content: "\\25B2"; 544 | } 545 | 546 | /* Custom checkbox toggles */ 547 | .toggle { 548 | display: inline-block; 549 | user-select: none; 550 | } 551 | 552 | .toggle-switch { 553 | display: inline-block; 554 | background: #ccc; 555 | border-radius: 16px; 556 | width: 29px; 557 | height: 16px; 558 | position: relative; 559 | vertical-align: middle; 560 | transition: background 0.15s; 561 | } 562 | .toggle-switch:before, 563 | .toggle-switch:after { 564 | content: ""; 565 | } 566 | .toggle-switch:before { 567 | display: block; 568 | background: linear-gradient(to bottom, #fff 0%, #eee 100%); 569 | border-radius: 50%; 570 | width: 12px; 571 | height: 12px; 572 | position: absolute; 573 | top: 2px; 574 | left: 2px; 575 | transition: left 0.15s; 576 | } 577 | .toggle-checkbox:checked + .toggle-switch { 578 | background: #56c080; 579 | } 580 | .toggle-checkbox:checked + .toggle-switch:before { 581 | left: 15px; 582 | } 583 | 584 | .toggle-checkbox { 585 | position: absolute; 586 | visibility: hidden; 587 | } 588 | 589 | .toggle-label { 590 | position: relative; 591 | margin-left: 3px; 592 | top: 2px; 593 | } 594 | 595 | /* Utility classes */ 596 | .d-none { 597 | display: none; 598 | } 599 | 600 | .text-center { 601 | text-align: center; 602 | } 603 | 604 | .text-ellipsis { 605 | text-overflow: ellipsis; 606 | white-space: nowrap; 607 | overflow: hidden; 608 | } 609 | 610 | .break-word { 611 | word-break: break-word; 612 | } 613 | 614 | .d-flex { 615 | display: flex; 616 | } 617 | 618 | .flex-column { 619 | flex-direction: column; 620 | } 621 | 622 | .justify-content-between { 623 | justify-content: space-between; 624 | } 625 | 626 | .justify-content-end { 627 | justify-content: flex-end; 628 | } 629 | 630 | .align-items-center { 631 | align-items: center; 632 | } 633 | 634 | .flex-grow-1 { 635 | flex-grow: 1; 636 | } 637 | 638 | .border-bottom { 639 | border-bottom: 1px solid #c5c1c1; 640 | } 641 | 642 | .no-wrap { 643 | overflow: hidden; 644 | white-space: nowrap; 645 | } 646 | 647 | .white-space-collapse { 648 | white-space: collapse; 649 | } 650 | 651 | .overflow-auto { 652 | overflow: auto; 653 | } 654 | 655 | .m-0 { 656 | margin: 0; 657 | } 658 | 659 | .mt-1 { 660 | margin-top: 0.25rem; 661 | } 662 | 663 | .mt-2 { 664 | margin-top: 0.5rem; 665 | } 666 | 667 | .mt-4 { 668 | margin-top: 1.5rem; 669 | } 670 | 671 | .ms-1 { 672 | margin-left: 0.25rem; 673 | } 674 | 675 | .mb-2 { 676 | margin-bottom: 0.5rem; 677 | } 678 | 679 | .mb-3 { 680 | margin-bottom: 1rem; 681 | } 682 | 683 | .mb-4 { 684 | margin-bottom: 1.5rem; 685 | } 686 | 687 | .gap-1 { 688 | gap: 0.25rem; 689 | } 690 | 691 | .gap-3 { 692 | gap: 1rem; 693 | } 694 | 695 | .pb-2 { 696 | padding-bottom: 0.5rem; 697 | } 698 | 699 | .pt-2 { 700 | padding-top: 0.5rem; 701 | } 702 | 703 | .w-100 { 704 | width: 100%; 705 | } 706 | 707 | .w-80 { 708 | width: 80%; 709 | } 710 | ` 711 | } 712 | -------------------------------------------------------------------------------- /src/assets/icons.js: -------------------------------------------------------------------------------- 1 | export const hotwireIcon = ` 2 | 9 | ` 10 | 11 | export const arrowUp = ` 12 | 16 | ` 17 | 18 | export const arrowDown = ` 19 | 23 | ` 24 | 25 | export const arrowLeft = ` 26 | 30 | ` 31 | 32 | export const trash = ` 33 | 37 | ` 38 | 39 | export const rotate = ` 40 | 44 | ` 45 | 46 | export const questionMark = ` 47 | 51 | ` 52 | 53 | export const search = ` 54 | 58 | ` 59 | 60 | export const filter = ` 61 | 65 | ` 66 | 67 | export const threeDotsVertical = ` 68 | 72 | ` 73 | -------------------------------------------------------------------------------- /src/components/BottomSheet.js: -------------------------------------------------------------------------------- 1 | import * as Icons from "../assets/icons" 2 | import { platform, formattedPlatform, getMetaContent } from "../utils/utils" 3 | import { saveSettings, getSettings, getConsoleFilterLevels, saveConsoleFilterLevels } from "../utils/settings" 4 | 5 | // WARNING: Be careful when console logging in this file, as it can cause an infinite loop 6 | // When you need to debug, use the `log` helper function like this: 7 | // this.log("message") 8 | // or turn off the console proxy in DevTools.js 9 | 10 | export default class BottomSheet { 11 | constructor(devTools) { 12 | this.devTools = devTools 13 | this.state = devTools.state.state 14 | this.sheetHeight = parseInt(getSettings("bottomSheetHeight")) || 55 15 | } 16 | 17 | render() { 18 | this.createBottomSheet() 19 | this.sheetContent = this.bottomSheet.querySelector(".content") 20 | this.sheetOverlay = this.bottomSheet.querySelector(".sheet-overlay") 21 | this.addEventListeners() 22 | } 23 | 24 | // Called when the in-memory state changes, 25 | // such as when a new console or bridge log is captured. 26 | update(newState) { 27 | this.state = newState 28 | this.checkNativeFeatures() 29 | this.renderConsoleLogs() 30 | this.renderBridgeComponents() 31 | this.renderBridgeLogs() 32 | this.renderEvents() 33 | this.renderNativeStack() 34 | this.scrollToLatestLog(this.state.activeTab) 35 | this.state.shouldScrollToLatestLog = true 36 | } 37 | 38 | // Called when another native tab of the mobile app 39 | // updates devtools-related local storage. 40 | applySettingsFromStorage() { 41 | const settings = [ 42 | { key: "bottomSheetHeight", setter: (value) => this.updateSheetHeight(value) }, 43 | { key: "activeTab", setter: (value) => this.updateTabView(value) }, 44 | { key: "fontSize", setter: (value) => this.updateFontSize(value) }, 45 | { key: "errorAnimationEnabled", setter: (value) => this.updateErrorAnimation(value) }, 46 | { key: "autoOpen", setter: (value) => this.updateAutoOpen(value) }, 47 | ] 48 | settings.forEach(({ key, setter }) => { 49 | const storedValue = getSettings(key) 50 | if (storedValue !== undefined) setter(storedValue) 51 | }) 52 | 53 | const storedConsoleFilterLevels = getConsoleFilterLevels() 54 | if (storedConsoleFilterLevels) { 55 | this.updateConsoleFilter(storedConsoleFilterLevels) 56 | } 57 | } 58 | 59 | createBottomSheet() { 60 | const existingBottomSheet = this.devTools.shadowRoot?.querySelector(".bottom-sheet") 61 | if (existingBottomSheet) { 62 | this.bottomSheet = existingBottomSheet 63 | return 64 | } 65 | 66 | const activeTab = this.state.activeTab 67 | const consoleFilterLevels = getConsoleFilterLevels() 68 | const consoleSearch = this.state.consoleSearch 69 | this.bottomSheet = document.createElement("div") 70 | this.bottomSheet.classList.add("bottom-sheet") 71 | this.bottomSheet.innerHTML = ` 72 | 73 |
This list shows all the bridge components that the ${formattedPlatform()} app supports. Components that are active on this page are marked with a green dot.
163 |Bridge components are automatically detected when they are registered in the native code. If your component is not on the list, make sure it is registered correctly.
165 | ${this.registerBridgeComponentExample()} 166 |For more information, check out the documentation:
167 | ${this.registerBridgeComponentHelpURL()} 168 |turbo-cache-control:${getMetaContent("turbo-cache-control") || "-"}
turbo-refresh-method:${getMetaContent("turbo-refresh-method") || "-"}
turbo-visit-control:${getMetaContent("turbo-visit-control") || "-"}
${pathConfigurationPropertiesJson}` : "" 390 | 391 | const childrenHTML = view.children?.length 392 | ? `
442 | Hotwire.registerBridgeComponents( 443 | BridgeComponentFactory("my-component", ::MyComponent) 444 | ) 445 |446 | ` 447 | case "ios": 448 | return ` 449 |
450 | Hotwire.registerBridgeComponents([ 451 | MyComponent.self 452 | ]) 453 |` 454 | default: 455 | return "" 456 | } 457 | } 458 | 459 | registerBridgeComponentHelpURL() { 460 | switch (platform()) { 461 | case "android": 462 | return "https://native.hotwired.dev/android/bridge-components" 463 | case "ios": 464 | return "https://native.hotwired.dev/ios/bridge-components" 465 | default: 466 | return "https://native.hotwired.dev" 467 | } 468 | } 469 | 470 | checkNativeFeatures() { 471 | if (this.state.supportsNativeStackView) { 472 | this.bottomSheet.querySelector(".tablink[data-tab-id='tab-native-stack']").classList.remove("d-none") 473 | } 474 | } 475 | 476 | addEventListeners() { 477 | if (this.bottomSheet.hasEventListeners) return 478 | 479 | // Click outside to close 480 | this.sheetOverlay.addEventListener("click", () => { 481 | this.hideBottomSheet() 482 | this.switchToMultiTabSheet() 483 | }) 484 | 485 | // Tab Click 486 | this.bottomSheet.querySelector(".tablist").addEventListener("click", (event) => this.handleTabClick(event)) 487 | 488 | // Action Buttons 489 | this.bottomSheet.querySelector(".btn-clear-console-logs").addEventListener("click", () => { 490 | this.devTools.state.clearConsoleLogs() 491 | this.renderConsoleLogs() 492 | }) 493 | this.bottomSheet.querySelector(".btn-clear-bridge-logs").addEventListener("click", () => { 494 | this.devTools.state.clearBridgeLogs() 495 | this.renderBridgeLogs() 496 | }) 497 | this.bottomSheet.querySelector(".btn-clear-events").addEventListener("click", () => { 498 | this.devTools.state.clearEventLogs() 499 | this.renderEvents() 500 | }) 501 | this.bottomSheet.querySelector(".btn-reload-stack").addEventListener("click", () => { 502 | this.bottomSheet.querySelector(".native-stack-wrapper").style.opacity = 0.5 503 | this.devTools.refetchNativeStack() 504 | }) 505 | 506 | // Switch to Single Tab Buttons 507 | this.bottomSheet.querySelectorAll(".btn-switch-to-single-tab-sheet").forEach((button) => { 508 | button.addEventListener("click", (event) => { 509 | const singleTabId = event.target.closest("[data-tab-id]").dataset.tabId 510 | if (!singleTabId) return 511 | this.switchToSingleTabSheet(singleTabId) 512 | }) 513 | }) 514 | 515 | // Close Single Tab Buttons 516 | this.bottomSheet.querySelectorAll(".btn-close-single-mode").forEach((button) => { 517 | button.addEventListener("click", () => { 518 | this.switchToMultiTabSheet() 519 | }) 520 | }) 521 | 522 | // Dragging 523 | this.bottomSheet.querySelector(".top-part").addEventListener("touchstart", this.dragStart.bind(this), { passive: true }) 524 | this.bottomSheet.addEventListener("touchmove", this.dragging.bind(this), { passive: true }) 525 | this.bottomSheet.addEventListener("touchend", this.dragStop.bind(this), { passive: true }) 526 | 527 | // Filters 528 | this.bottomSheet.querySelector(".console-filter-levels").addEventListener("click", ({ target }) => { 529 | const checkbox = target.closest("input[type='checkbox']") 530 | if (!checkbox) return 531 | 532 | const filterType = checkbox.dataset.consoleFilter 533 | const isActive = checkbox.checked 534 | 535 | saveConsoleFilterLevels(filterType, isActive) 536 | this.renderConsoleLogs() 537 | }) 538 | 539 | this.bottomSheet.querySelector(".btn-search-console").addEventListener("click", () => { 540 | const searchInput = this.bottomSheet.querySelector(".console-search") 541 | searchInput.classList.toggle("d-none") 542 | searchInput.querySelector("input").focus() 543 | }) 544 | 545 | this.bottomSheet.querySelector(".console-search-input").addEventListener("input", (event) => { 546 | this.devTools.state.setConsoleSearchValue(event.target.value.toLowerCase()) 547 | this.renderConsoleLogs() 548 | }) 549 | 550 | // Settings 551 | this.bottomSheet.querySelector("#bottom-sheet-height-setting").addEventListener("change", (event) => { 552 | const value = event.target.value 553 | this.sheetHeight = parseInt(value) 554 | saveSettings("bottomSheetHeight", value) 555 | this.updateSheetHeight(value) 556 | }) 557 | 558 | this.bottomSheet.querySelector("#console-error-animation-setting").addEventListener("change", (event) => { 559 | saveSettings("errorAnimationEnabled", event.target.checked) 560 | }) 561 | 562 | this.bottomSheet.querySelector("#font-size-setting").addEventListener("change", (event) => { 563 | let value = event.target.value 564 | value = Math.max(8, Math.min(24, value)) 565 | saveSettings("fontSize", value) 566 | this.devTools.setCSSProperty("--font-size", `${value}px`) 567 | }) 568 | 569 | this.bottomSheet.querySelector("#auto-open-setting").addEventListener("change", (event) => { 570 | saveSettings("autoOpen", event.target.checked) 571 | }) 572 | 573 | this.bottomSheet.querySelector("#scroll-to-latest-log-setting").addEventListener("change", (event) => { 574 | saveSettings("scrollToLatestLog", event.target.checked) 575 | }) 576 | 577 | this.bottomSheet.querySelector(".pin-bottom-sheet").addEventListener("click", () => { 578 | const isPinned = getSettings("bottomSheetPinned") === true 579 | saveSettings("bottomSheetPinned", !isPinned) 580 | this.sheetOverlay.classList.toggle("active") 581 | this.bottomSheet.querySelector(".settings-dropdown").classList.remove("dropdown-open") 582 | }) 583 | 584 | this.bottomSheet.addEventListener("click", (event) => { 585 | // Handle collapsible elements 586 | const collapsible = event.target.closest(".collapse") 587 | if (collapsible && this.bottomSheet.contains(collapsible)) { 588 | const targetId = collapsible.getAttribute("data-collapse-target") 589 | const targetElement = this.bottomSheet.querySelector(`#${targetId}`) 590 | if (targetElement) { 591 | const isActive = collapsible.classList.toggle("active") 592 | targetElement.classList.toggle("active", isActive) 593 | } 594 | return 595 | } 596 | 597 | // Handle dropdown triggers 598 | const trigger = event.target.closest(".dropdown-trigger") 599 | if (trigger) { 600 | event.preventDefault() 601 | this.toggleDropdown(trigger) 602 | return 603 | } 604 | 605 | // Close dropdowns when clicking outside 606 | const openDropdowns = this.bottomSheet.querySelectorAll(".dropdown-content.dropdown-open") 607 | openDropdowns.forEach((dropdown) => { 608 | const dropdownContainer = dropdown.closest(".dropdown") 609 | if (!dropdownContainer.contains(event.target)) { 610 | dropdown.classList.remove("dropdown-open") 611 | } 612 | }) 613 | }) 614 | 615 | this.bottomSheet.hasEventListeners = true 616 | } 617 | 618 | updateConsoleFilter(consoleFilterLevels) { 619 | this.bottomSheet.querySelectorAll(".console-filter-levels input[type='checkbox']").forEach((checkbox) => { 620 | const filterType = checkbox.dataset.consoleFilter 621 | const isActive = consoleFilterLevels[filterType] 622 | checkbox.checked = isActive 623 | }) 624 | } 625 | 626 | toggleDropdown(triggerElement) { 627 | const dropdownContent = triggerElement.nextElementSibling || triggerElement.closest(".dropdown").querySelector(".dropdown-content") 628 | // Close other dropdowns first 629 | this.bottomSheet.querySelectorAll(".dropdown-content.dropdown-open").forEach((el) => { 630 | if (el !== dropdownContent) { 631 | el.classList.remove("dropdown-open") 632 | } 633 | }) 634 | dropdownContent.classList.toggle("dropdown-open") 635 | } 636 | 637 | handleTabClick = (event) => { 638 | const clickedTab = event.target.closest(".tablink") 639 | if (!clickedTab) return 640 | 641 | const tabId = clickedTab.dataset.tabId 642 | this.devTools.state.setActiveTab(tabId) 643 | this.updateTabView(tabId) 644 | } 645 | 646 | updateTabView(tabId) { 647 | // Hide all Tabs 648 | this.devTools.shadowRoot.querySelectorAll(".tablink, .outer-tab-content").forEach((tab) => tab.classList.remove("active")) 649 | 650 | // Hide all Action Bars 651 | this.devTools.shadowRoot.querySelectorAll(".tab-action-bar").forEach((tab) => tab.classList.remove("active")) 652 | 653 | // Show the clicked tab 654 | this.devTools.shadowRoot.querySelector(`[data-tab-id="${tabId}"]`).classList.add("active") 655 | this.devTools.shadowRoot.getElementById(tabId).classList.add("active") 656 | 657 | // Show the action bar for the clicked tab 658 | this.devTools.shadowRoot.querySelector(`.tab-action-bar.${tabId}`).classList.add("active") 659 | 660 | // Scroll to the latest log in the clicked tab 661 | if (this.state.shouldScrollToLatestLog) { 662 | this.scrollToLatestLog(tabId) 663 | 664 | // Reset the flag to avoid scrolling on every tab switch without new logs 665 | this.state.shouldScrollToLatestLog = false 666 | } 667 | } 668 | 669 | scrollToLatestLog(tabId) { 670 | if (getSettings("scrollToLatestLog") != true) return 671 | 672 | requestAnimationFrame(() => { 673 | const tabContainer = this.devTools.shadowRoot.getElementById(tabId) 674 | const latestLog = tabContainer?.querySelector(".log-entry:last-child") 675 | latestLog?.scrollIntoView({ behavior: "instant", block: "center" }) 676 | }) 677 | } 678 | 679 | showBottomSheet() { 680 | if (this.bottomSheet.classList.contains("show")) return 681 | this.bottomSheet.classList.add("show") 682 | this.originalOverflow = document.body.style.overflow 683 | document.body.style.overflow = "hidden" 684 | this.updateSheetHeight(this.sheetHeight) 685 | } 686 | 687 | hideBottomSheet() { 688 | this.bottomSheet.classList.remove("show") 689 | document.body.style.overflow = this.originalOverflow 690 | } 691 | 692 | updateSheetHeight(height) { 693 | this.sheetContent.style.height = `${height}vh` 694 | this.bottomSheet.classList.toggle("fullscreen", height === 100) 695 | } 696 | 697 | dragStart(event) { 698 | this.isDragging = true 699 | this.startY = event.pageY || event.touches?.[0].pageY 700 | this.startHeight = parseInt(this.sheetContent.style.height) 701 | this.bottomSheet.classList.add("dragging") 702 | } 703 | 704 | dragging(event) { 705 | if (!this.isDragging) return 706 | const delta = this.startY - (event.pageY || event.touches?.[0].pageY) 707 | const newHeight = this.startHeight + (delta / window.innerHeight) * 100 708 | this.updateSheetHeight(newHeight) 709 | } 710 | 711 | dragStop() { 712 | this.isDragging = false 713 | this.bottomSheet.classList.remove("dragging") 714 | const draggingThreshold = 10 // Defines how much the user needs to drag to trigger the hide/show 715 | const currentHeight = parseInt(this.sheetContent.style.height) 716 | 717 | const minThreshold = Math.max(0, this.sheetHeight - draggingThreshold) 718 | const maxThreshold = Math.min(100, this.sheetHeight + draggingThreshold) 719 | 720 | if (currentHeight < minThreshold) { 721 | this.hideBottomSheet() 722 | } else if (currentHeight > maxThreshold) { 723 | this.updateSheetHeight(100) 724 | } else { 725 | this.updateSheetHeight(this.sheetHeight) 726 | } 727 | } 728 | 729 | updateFontSize(value) { 730 | this.devTools.setCSSProperty("--font-size", `${value}px`) 731 | this.bottomSheet.querySelector("#font-size-setting").value = value 732 | } 733 | 734 | updateErrorAnimation(value) { 735 | this.bottomSheet.querySelector("#console-error-animation-setting").checked = value 736 | } 737 | 738 | updateAutoOpen(value) { 739 | this.bottomSheet.querySelector("#auto-open-setting").checked = value 740 | } 741 | 742 | // Helper function to log messages, without causing a rerender of the bottom sheet 743 | // (Messages with a `HotwireDevTools` prefix will not be logged in the bottom sheet) 744 | log(message) { 745 | console.log(`HotwireDevTools: ${message}`) 746 | } 747 | 748 | // Get all the `static component = "..."` from the bridge components 749 | get bridgeComponentIdentifiers() { 750 | return window.Stimulus?.controllers.map((controller) => controller.component).filter((component) => component !== undefined) || [] 751 | } 752 | 753 | get currentUrl() { 754 | return window.location.href 755 | } 756 | } 757 | -------------------------------------------------------------------------------- /src/components/FloatingBubble.js: -------------------------------------------------------------------------------- 1 | import { getSettings, saveSettings } from "../utils/settings" 2 | import { debounce } from "../utils/utils" 3 | import { hotwireIcon } from "../assets/icons" 4 | 5 | export default class FloatingBubble { 6 | constructor(devTools) { 7 | this.devTools = devTools 8 | this.bubbleSize = 4.75 * 16 + 0.3 * 16 // 4.75rem + 0.3rem border 9 | this.minVisible = this.bubbleSize * 0.5 // Keep 50% of the bubble visible at all times 10 | this.currentlyDragging = false 11 | } 12 | 13 | render = debounce(() => { 14 | this.setPosition() 15 | this.createDragItem() 16 | this.setTranslate(this.initialX, this.initialY, this.dragItem) 17 | this.addEventListeners() 18 | }, 50) 19 | 20 | setPosition() { 21 | this.settingKey = window.innerWidth < window.innerHeight ? "bubblePosPortrait" : "bubblePosLandscape" 22 | 23 | // Get stored position or use default (bottom right corner) 24 | const defaultPos = { x: window.innerWidth - 100, y: window.innerHeight - 100 } 25 | const { x: startX, y: startY } = getSettings(this.settingKey) || defaultPos 26 | 27 | this.currentX = this.initialX = this.xOffset = startX 28 | this.currentY = this.initialY = this.yOffset = startY 29 | } 30 | 31 | createDragItem() { 32 | const existingBubble = this.devTools.shadowRoot?.getElementById("floating-bubble") 33 | if (existingBubble) { 34 | this.dragItem = existingBubble 35 | return 36 | } 37 | 38 | this.dragItem = document.createElement("div") 39 | this.dragItem.id = "floating-bubble" 40 | this.dragItem.innerHTML = hotwireIcon 41 | this.devTools.shadowRoot.appendChild(this.dragItem) 42 | } 43 | 44 | addEventListeners() { 45 | if (this.dragItem.hasEventListeners) return 46 | this.dragItem.addEventListener("click", this.click.bind(this), { passive: true }) 47 | this.dragItem.addEventListener("touchstart", this.dragStart.bind(this), { passive: true }) 48 | this.dragItem.addEventListener("touchend", this.dragEnd.bind(this), { passive: true }) 49 | this.dragItem.addEventListener("touchmove", this.drag.bind(this), { passive: true }) 50 | this.dragItem.hasEventListeners = true 51 | } 52 | 53 | click(event) { 54 | if (this.clickCallback) { 55 | this.clickCallback(event) 56 | } 57 | } 58 | 59 | animateErrorBorder = debounce(() => { 60 | if (!this.dragItem) return 61 | if (getSettings("errorAnimationEnabled") === false) return 62 | 63 | let errorBorder = this.dragItem.querySelector(".error-border") 64 | let circleElement = this.dragItem.querySelector(".error-border circle") 65 | 66 | if (errorBorder) { 67 | errorBorder.remove() 68 | } 69 | 70 | const animationContainer = document.createElement("div") 71 | animationContainer.className = "animation-container" 72 | animationContainer.innerHTML = ` 73 | 84 | ` 85 | 86 | this.dragItem.appendChild(animationContainer) 87 | circleElement = this.dragItem.querySelector(".error-border circle") 88 | circleElement.classList.add("animate") 89 | 90 | setTimeout(() => { 91 | animationContainer.classList.add("fade-out") 92 | }, 1300) // Start fade-out after animation completes 93 | 94 | setTimeout(() => { 95 | if (animationContainer && animationContainer.parentNode) { 96 | animationContainer.remove() 97 | } 98 | }, 1800) // Remove after fade-out completes 99 | }, 100) 100 | 101 | onClick(callback) { 102 | this.clickCallback = callback 103 | } 104 | 105 | dragStart(event) { 106 | if (!event.target.closest("#floating-bubble")) return 107 | this.currentlyDragging = true 108 | 109 | this.initialX = event.touches[0].clientX - this.xOffset 110 | this.initialY = event.touches[0].clientY - this.yOffset 111 | } 112 | 113 | dragEnd() { 114 | this.initialX = this.currentX 115 | this.initialY = this.currentY 116 | this.currentlyDragging = false 117 | 118 | saveSettings(this.settingKey, { x: this.currentX, y: this.currentY }) 119 | } 120 | 121 | drag(event) { 122 | if (!this.currentlyDragging) return 123 | 124 | const touch = event.touches[0] 125 | const deltaX = touch.clientX - this.initialX 126 | const deltaY = touch.clientY - this.initialY 127 | 128 | // Constrain movement within screen bounds 129 | this.currentX = Math.max(-this.bubbleSize + this.minVisible, Math.min(deltaX, window.innerWidth - this.minVisible)) 130 | this.currentY = Math.max(-this.bubbleSize + this.minVisible, Math.min(deltaY, window.innerHeight - this.minVisible)) 131 | 132 | this.xOffset = this.currentX 133 | this.yOffset = this.currentY 134 | 135 | if (!this.animationFrame) { 136 | this.animationFrame = requestAnimationFrame(() => { 137 | this.setTranslate(this.currentX, this.currentY, this.dragItem) 138 | this.animationFrame = null 139 | }) 140 | } 141 | } 142 | 143 | setTranslate(xPos, yPos, element) { 144 | element.style.transform = `translate3d(${xPos}px, ${yPos}px, 0)` 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | export interface DevToolsOptions { 2 | enabled?: boolean 3 | reset?: boolean 4 | } 5 | 6 | export function setupDevTools(options?: DevToolsOptions): void 7 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import DevTools from "./DevTools" 2 | 3 | const setupDevTools = (options = {}) => { 4 | const devTools = new DevTools(options) 5 | if (!devTools.options.enabled) return 6 | 7 | devTools.setup() 8 | 9 | document.addEventListener( 10 | "turbo:load", 11 | () => { 12 | devTools.setup() 13 | }, 14 | { passive: true } 15 | ) 16 | } 17 | 18 | export { setupDevTools } 19 | -------------------------------------------------------------------------------- /src/lib/DevToolsState.js: -------------------------------------------------------------------------------- 1 | import { getSettings, saveSettings } from "../utils/settings" 2 | 3 | export default class DevToolsState { 4 | constructor() { 5 | this.state = { 6 | consoleLogs: [], 7 | bridgeLogs: [], 8 | eventLogs: [], 9 | nativeStack: [], 10 | supportedBridgeComponents: [], 11 | bridgeIsConnected: false, 12 | supportsNativeStackView: false, 13 | consoleSearch: "", 14 | activeTab: this.storedActiveTab, 15 | } 16 | this.listeners = [] 17 | } 18 | 19 | subscribe(listener) { 20 | this.listeners.push(listener) 21 | } 22 | 23 | notify() { 24 | this.listeners.forEach((listener) => listener(this.state)) 25 | } 26 | 27 | addConsoleLog(type, message) { 28 | const log = { type, message, time: this.currentTime } 29 | this.state.consoleLogs.push(log) 30 | this.notify() 31 | } 32 | 33 | addBridgeLog(direction, componentName, eventName, eventArgs) { 34 | const log = { direction, componentName, eventName, eventArgs, time: this.currentTime } 35 | this.state.bridgeLogs.push(log) 36 | this.notify() 37 | } 38 | 39 | addEventLog(eventName) { 40 | const event = { eventName, time: this.currentTime } 41 | this.state.eventLogs.push(event) 42 | this.notify() 43 | } 44 | 45 | setNativeStack(stack) { 46 | this.state.nativeStack = stack 47 | this.notify() 48 | } 49 | 50 | setSupportsNativeStack(supports) { 51 | this.state.supportsNativeStackView = supports 52 | this.notify() 53 | } 54 | 55 | setBridgeIsConnected(isConnected) { 56 | this.state.bridgeIsConnected = isConnected 57 | this.notify() 58 | } 59 | 60 | setSupportedBridgeComponents(components) { 61 | this.state.supportedBridgeComponents = components 62 | this.notify() 63 | } 64 | 65 | clearConsoleLogs() { 66 | this.state.consoleLogs = [] 67 | this.notify() 68 | } 69 | 70 | clearBridgeLogs() { 71 | this.state.bridgeLogs = [] 72 | this.notify() 73 | } 74 | 75 | clearEventLogs() { 76 | this.state.eventLogs = [] 77 | this.notify() 78 | } 79 | 80 | setActiveTab(tab) { 81 | this.state.activeTab = tab 82 | saveSettings("activeTab", tab) 83 | } 84 | 85 | setConsoleSearchValue(value) { 86 | this.state.consoleSearch = value 87 | } 88 | 89 | updateLocalStorageSettings() { 90 | this.state.activeTab = this.storedActiveTab 91 | } 92 | 93 | get currentTime() { 94 | return new Date().toLocaleTimeString() 95 | } 96 | 97 | get storedActiveTab() { 98 | return getSettings("activeTab") || "tab-bridge-components" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/lib/DiagnosticsChecker.js: -------------------------------------------------------------------------------- 1 | export default class DiagnosticsChecker { 2 | constructor() { 3 | this.printedWarnings = [] 4 | } 5 | 6 | printWarning = (message, once = true, ...extraArgs) => { 7 | if (once && this.printedWarnings.includes(message)) return 8 | 9 | console.warn(`DevTools: ${message}`, ...extraArgs) 10 | this.printedWarnings.push(message) 11 | } 12 | 13 | checkForWarnings = () => { 14 | this.#checkForTurboDrive() 15 | this.#checkForDuplicatedTurboFrames() 16 | this.#checkTurboPermanentElements() 17 | } 18 | 19 | #checkForTurboDrive = () => { 20 | if (!window.Turbo) { 21 | // Since it's possible that the DevTools are loaded before Turbo, we need to wait a bit to check if Turbo is loaded 22 | setTimeout(() => { 23 | if (!window.Turbo) { 24 | this.printWarning("Turbo is not detected. Hotwire Native will not work correctly without Turbo") 25 | } 26 | }, 1000) 27 | } else if (window.Turbo?.session.drive === false) { 28 | setTimeout(() => { 29 | if (window.Turbo?.session.drive === false) { 30 | this.printWarning("Turbo Drive is disabled. Hotwire Native will not work correctly without Turbo Drive") 31 | } 32 | }, 1000) 33 | } 34 | } 35 | 36 | #checkForDuplicatedTurboFrames = () => { 37 | const turboFramesIds = this.turboFrameIds 38 | const duplicatedIds = turboFramesIds.filter((id, index) => turboFramesIds.indexOf(id) !== index) 39 | 40 | duplicatedIds.forEach((id) => { 41 | 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.`) 42 | }) 43 | } 44 | 45 | #checkTurboPermanentElements = () => { 46 | const turboPermanentElements = document.querySelectorAll("[data-turbo-permanent]") 47 | if (turboPermanentElements.length === 0) return 48 | 49 | turboPermanentElements.forEach((element) => { 50 | const id = element.id 51 | if (id === "") { 52 | const message = `Turbo Permanent Element detected without an ID. Turbo Permanent Elements must have a unique ID to work correctly.` 53 | this.printWarning(message, true, element) 54 | } 55 | 56 | const idIsDuplicated = id && document.querySelectorAll(`#${id}`).length > 1 57 | if (idIsDuplicated) { 58 | const message = `Turbo Permanent Element with ID '${id}' doesn't have a unique ID. Turbo Permanent Elements must have a unique ID to work correctly.` 59 | this.printWarning(message, true, element) 60 | } 61 | }) 62 | } 63 | 64 | get turboFrameIds() { 65 | return Array.from(document.querySelectorAll("turbo-frame")).map((turboFrame) => turboFrame.id) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/lib/NativeBridge.js: -------------------------------------------------------------------------------- 1 | /* 2 | Similar to the `BridgeComponent` class from the Hotwire Native Bridge, 3 | but without requiring a bridge component HTML element or Stimulus controller. 4 | 5 | Originally from: 37signals LLC 6 | https://github.com/hotwired/hotwire-native-bridge 7 | */ 8 | export default class NativeBridge { 9 | bridgeIsConnected() { 10 | return !!(window.HotwireNative?.web || window.Strada?.web) 11 | } 12 | 13 | // Send a message to the native side 14 | send(event, data = {}, callback = null) { 15 | if (!this.bridgeIsConnected()) { 16 | return Promise.reject("Bridge is not connected") 17 | } 18 | 19 | const messageData = { 20 | ...data, 21 | metadata: { 22 | url: window.location.href, 23 | }, 24 | } 25 | 26 | return this.bridge.send({ 27 | component: "dev-tools", 28 | event, 29 | data: messageData, 30 | callback, 31 | }) 32 | } 33 | 34 | isComponentSupported(component) { 35 | if (!this.bridgeIsConnected()) { 36 | return false 37 | } 38 | return this.bridge.supportsComponent(component) 39 | } 40 | 41 | getSupportedComponents() { 42 | return document.documentElement.dataset.bridgeComponents?.split(" ") || [] 43 | } 44 | 45 | get bridge() { 46 | return window.HotwireNative?.web || window.Strada?.web 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/settings.js: -------------------------------------------------------------------------------- 1 | export const getSettings = (key) => { 2 | let settings = JSON.parse(localStorage.getItem("hotwire-native-dev-tools") || "{}") 3 | return settings[key] 4 | } 5 | 6 | export const saveSettings = (key, value) => { 7 | let settings = JSON.parse(localStorage.getItem("hotwire-native-dev-tools") || "{}") 8 | settings[key] = value 9 | 10 | localStorage.setItem("hotwire-native-dev-tools", JSON.stringify(settings)) 11 | } 12 | 13 | export const resetSettings = () => { 14 | localStorage.removeItem("hotwire-native-dev-tools") 15 | } 16 | 17 | export const getConsoleFilterLevels = () => { 18 | const consoleFilterLevels = getSettings("consoleFilterLevels") || { 19 | warn: true, 20 | error: true, 21 | debug: true, 22 | info: true, 23 | log: true, 24 | } 25 | 26 | return consoleFilterLevels 27 | } 28 | 29 | export const saveConsoleFilterLevels = (key, value) => { 30 | const consoleFilterLevels = getConsoleFilterLevels() 31 | consoleFilterLevels[key] = value 32 | saveSettings("consoleFilterLevels", consoleFilterLevels) 33 | return consoleFilterLevels 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/utils.js: -------------------------------------------------------------------------------- 1 | export const debounce = (fn, delay) => { 2 | let timeoutId = null 3 | 4 | return (...args) => { 5 | const callback = () => fn.apply(this, args) 6 | clearTimeout(timeoutId) 7 | timeoutId = setTimeout(callback, delay) 8 | } 9 | } 10 | 11 | const { userAgent } = window.navigator 12 | export const isIosApp = /iOS/.test(userAgent) 13 | export const isAndroidApp = /Android/.test(userAgent) 14 | 15 | export const platform = () => { 16 | if (isIosApp) { 17 | return "ios" 18 | } else if (isAndroidApp) { 19 | return "android" 20 | } 21 | return "unknown" 22 | } 23 | 24 | export const formattedPlatform = () => { 25 | switch (platform()) { 26 | case "android": 27 | return "Android" 28 | case "ios": 29 | return "iOS" 30 | default: 31 | return "