├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github ├── release.yml ├── screenshot.jpg ├── stimulus_devtools_logo.svg └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc.json ├── .vscode └── extensions.json ├── LICENSE ├── README.md ├── components.json ├── devtools-background.html ├── devtools.html ├── package-lock.json ├── package.json ├── popup.html ├── public └── images │ ├── icon-128.png │ ├── icon-16.png │ ├── icon-32.png │ ├── icon-48.png │ ├── popup_tab_dark.jpg │ └── popup_tab_light.jpg ├── scripts └── utils.ts ├── src ├── bridge │ └── index.ts ├── client │ ├── Inspector.ts │ ├── Observer.ts │ └── index.ts ├── components │ ├── StimulusDevToolsLogo.vue │ ├── core │ │ ├── CodeBlock.vue │ │ ├── CopyButton.vue │ │ ├── SplitPane.vue │ │ ├── ValueType.vue │ │ ├── code │ │ │ └── CodeInline.vue │ │ ├── tree │ │ │ ├── Tree.vue │ │ │ ├── TreeAction.vue │ │ │ └── TreeItem.vue │ │ └── value-tree │ │ │ ├── ValueTree.vue │ │ │ ├── ValueTreeWrapper.vue │ │ │ ├── form │ │ │ ├── ValueTreeArrayForm.vue │ │ │ └── ValueTreeObjectForm.vue │ │ │ └── type │ │ │ ├── ValueTreeArray.vue │ │ │ ├── ValueTreeBoolean.vue │ │ │ ├── ValueTreeNull.vue │ │ │ ├── ValueTreeNumber.vue │ │ │ ├── ValueTreeObject.vue │ │ │ └── ValueTreeString.vue │ ├── stimulus │ │ ├── StimulusControllers.vue │ │ ├── StimulusUnavailable.vue │ │ ├── definition │ │ │ ├── StimulusControllerDefinitionDetails.vue │ │ │ ├── StimulusControllerDefinitions.vue │ │ │ └── StimulusControllerDefinitionsRow.vue │ │ ├── instance │ │ │ └── StimulusControllerInstancesRow.vue │ │ └── members │ │ │ ├── classes │ │ │ ├── StimulusControllerClasses.vue │ │ │ └── StimulusControllerClassesRow.vue │ │ │ ├── outlets │ │ │ ├── StimulusControllerOutlets.vue │ │ │ └── StimulusControllerOutletsRow.vue │ │ │ ├── targets │ │ │ ├── StimulusControllerTargets.vue │ │ │ └── StimulusControllerTargetsRow.vue │ │ │ └── values │ │ │ ├── StimulusControllerValues.vue │ │ │ └── StimulusControllerValuesRow.vue │ └── ui │ │ ├── accordion │ │ ├── Accordion.vue │ │ ├── AccordionContent.vue │ │ ├── AccordionItem.vue │ │ ├── AccordionTrigger.vue │ │ └── index.ts │ │ ├── alert │ │ ├── Alert.vue │ │ ├── AlertDescription.vue │ │ ├── AlertTitle.vue │ │ └── index.ts │ │ ├── button │ │ ├── Button.vue │ │ └── index.ts │ │ ├── checkbox │ │ ├── Checkbox.vue │ │ └── index.ts │ │ ├── popover │ │ ├── Popover.vue │ │ ├── PopoverContent.vue │ │ ├── PopoverTrigger.vue │ │ └── index.ts │ │ └── scroll-area │ │ ├── ScrollArea.vue │ │ ├── ScrollBar.vue │ │ └── index.ts ├── composables │ ├── stimulus │ │ ├── useControllerDefinition.ts │ │ ├── useControllerDefinitions.ts │ │ ├── useControllerInstanceClasses.ts │ │ ├── useControllerInstanceOutlets.ts │ │ ├── useControllerInstanceTargets.ts │ │ └── useControllerInstanceValues.ts │ ├── useBridge.ts │ ├── useChromeStorage.ts │ ├── useCodeHighlighter.ts │ ├── useCopyButton.ts │ └── useStimulusDetector.ts ├── detector │ └── index.ts ├── devtools │ ├── DevTools.vue │ ├── background.ts │ ├── highlighter.ts │ ├── index.ts │ └── style.css ├── enum │ └── index.ts ├── global.d.ts ├── lib │ └── utils.ts ├── manifest.ts ├── types │ ├── index.ts │ └── stimulus.ts ├── utils │ ├── client.ts │ ├── dom.ts │ └── index.ts └── vite-env.d.ts ├── tailwind.config.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.client.ts └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,ts,jsx,tsx,vue}] 2 | tab_width = 2 3 | indent_size = 2 4 | indent_style = space 5 | 6 | [*.{json,yml,yaml}] 7 | tab_width = 2 8 | indent_size = 2 9 | indent_style = space 10 | 11 | [*.{css,scss}] 12 | tab_width = 2 13 | indent_size = 2 14 | indent_style = space -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | vendor 3 | var 4 | build 5 | dist 6 | public 7 | 8 | webpack.config.js 9 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es2022": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:vue/recommended", 10 | "@vue/typescript/recommended", 11 | "prettier" 12 | ], 13 | "plugins": [ 14 | "@typescript-eslint", 15 | "prettier" 16 | ], 17 | "parser": "vue-eslint-parser", 18 | "parserOptions": { 19 | "parser": "@typescript-eslint/parser" 20 | }, 21 | "rules": { 22 | "prettier/prettier": "error", 23 | "no-console": ["error", { "allow": ["warn", "error"] }], 24 | // Typescript 25 | "@typescript-eslint/no-explicit-any": "off", 26 | "@typescript-eslint/ban-ts-comment": "off", 27 | // Vue 28 | "vue/multi-word-component-names": "off", 29 | "vue/no-multiple-template-root": "off", 30 | "vue/no-v-html": "off", 31 | "vue/no-v-model-argument": "off" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | authors: 6 | - octocat 7 | categories: 8 | - title: 🚀 Enhancements 9 | labels: 10 | - enhancement 11 | - title: 👾 Fixes 12 | labels: 13 | - bug 14 | - title: 🏠 Chore 15 | labels: 16 | - chore 17 | - title: 🤖 Dependencies 18 | labels: 19 | - dependencies 20 | - title: 📖 Documentation 21 | labels: 22 | - documentation 23 | -------------------------------------------------------------------------------- /.github/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinsimonklein/stimulus-devtools/fc9e62e0b9f4d167c48cd1c2417ddafbb67708a3/.github/screenshot.jpg -------------------------------------------------------------------------------- /.github/stimulus_devtools_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Use Node.js 20 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 20.x 19 | cache: 'npm' 20 | - name: Install assets 21 | run: npm ci 22 | - name: Build 23 | run: npm run build --if-present 24 | lint: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Use Node.js 20 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: 20.x 33 | cache: 'npm' 34 | - name: Install assets 35 | run: npm ci 36 | - name: Lint code 37 | run: npm run lint 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | build 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | *.zip 28 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "arrowParens": "avoid", 6 | "printWidth": 120, 7 | "plugins": ["prettier-plugin-tailwindcss"] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Robin Simonklein 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | Stimulus DevTools 5 | 6 |

7 |
8 | 9 |

A developer tool for inspecting and debugging Stimulus applications.

10 | 11 |

12 | 13 | Chrome Web Store Version 14 | 15 | 16 | CI 17 | 18 |

19 | 20 | 21 | 22 | ## Overview 23 | 24 | The [Stimulus DevTools Chrome Extension](https://chromewebstore.google.com/detail/stimulus-devtools/ljofhbgbmcnggnnomninmadlnicbojbh) is designed to simplify the process of debugging [Stimulus](https://stimulus.hotwired.dev/) on a web page. It provides a user-friendly interface for inspecting values, targets, outlets, and classes of Stimulus controllers, all directly from Chrome's DevTools. 25 | 26 | *Inspired by the amazing [Vue DevTools](https://github.com/vuejs/devtools) and [Nuxt DevTools](https://github.com/nuxt/devtools).* 27 | 28 | ![Screenshot](.github/screenshot.jpg) 29 | 30 | ## Features 31 | 32 | - ✨ **Controllers List:** Get a quick view of all Stimulus controllers present on the current page. 33 | - 🔍 **Property Inspection:** Inspect values, targets, outlets, and classes associated with each controller. 34 | - ✏️ **Real-time Modification:** Change controller's values on-the-fly and observe immediate updates. 35 | 36 | ## Usage 37 | 38 | ### Install the Extension 39 | 40 | Download and install the Stimulus DevTools extension [from the Chrome Web Store](https://chromewebstore.google.com/detail/stimulus-devtools/ljofhbgbmcnggnnomninmadlnicbojbh). 41 | 42 | ### Open Stimulus DevTools 43 | 44 | 1. Navigate to your web page where Stimulus controllers are used. 45 | 2. Open Chrome DevTools by right-clicking on the page, selecting "Inspect", or using the keyboard shortcut (Ctrl+Shift+I on Windows/Linux or Cmd+Option+I on macOS). 46 | 3. In Chrome DevTools, go to the "Stimulus" tab. 47 | 48 | ### Enable Stimulus DevTools on your project 49 | 50 | Ensure that the Stimulus application is added to `window.Stimulus` in your project. This is necessary for the extension to detect and display the Stimulus controllers properly. 51 | 52 | For example : 53 | ```javascript 54 | // src/application.js 55 | import { Application } from "@hotwired/stimulus" 56 | 57 | import HelloController from "./controllers/hello_controller" 58 | import ClipboardController from "./controllers/clipboard_controller" 59 | 60 | window.Stimulus = Application.start() // <- Here 61 | Stimulus.register("hello", HelloController) 62 | Stimulus.register("clipboard", ClipboardController) 63 | ``` 64 | 65 | #### TypeScript 66 | 67 | If you are using TypeScript in your project, it may throw an error when accessing `window.Stimulus`. To fix that, add this `stimulus.d.ts` file : 68 | 69 | ```typescript 70 | import type { Application } from '@hotwired/stimulus'; 71 | 72 | declare global { 73 | interface Window { 74 | Stimulus: Application; 75 | } 76 | } 77 | ``` 78 | 79 | ## Sponsors 80 | 81 |

82 | 83 | Mezcalito - Agence Digitale à Grenoble depuis 2006 84 | 85 |

86 | 87 | ## License 88 | 89 | This project is licensed under the [MIT License](LICENSE). 90 | 91 | --- 92 | 93 | > [robinsimonklein.com](https://robinsimonklein.com)  ·  94 | > GitHub [@robinsimonklein](https://github.com/robinsimonklein)  ·  95 | > 𝕏 [@rsimonklein](https://twitter.com/rsimonklein) 96 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://shadcn-vue.com/schema.json", 3 | "style": "default", 4 | "typescript": true, 5 | "tailwind": { 6 | "config": "tailwind.config.ts", 7 | "css": "src/assets/style/style.css", 8 | "baseColor": "zinc", 9 | "cssVariables": true 10 | }, 11 | "framework": "vite", 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /devtools-background.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /devtools.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + Vue + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stimulus-devtools", 3 | "displayName": "Stimulus DevTools", 4 | "description": "A developer tool for inspecting and debugging Stimulus applications.", 5 | "keywords": [ 6 | "stimulus", 7 | "devtools", 8 | "stimulusjs", 9 | "chrome extension" 10 | ], 11 | "author": { 12 | "name": "Robin Simonklein", 13 | "email": "contact@robinsimonklein.com", 14 | "url": "robinsimonklein.com" 15 | }, 16 | "license": "MIT", 17 | "homepage": "https://github.com/robinsimonklein/stimulus-devtools", 18 | "bugs": { 19 | "url": "https://github.com/robinsimonklein/stimulus-devtools/issues" 20 | }, 21 | "private": true, 22 | "version": "0.4.2", 23 | "type": "module", 24 | "scripts": { 25 | "dev": "npm run clear && NODE_ENV=development run-p dev:*", 26 | "dev:main": "npm run build:main -- --mode development", 27 | "dev:client": "npm run build:client -- --mode development", 28 | "build": "NODE_ENV=production run-s clear build:main build:client", 29 | "build:main": "vite build --config vite.config.ts", 30 | "build:client": "vite build --config vite.config.client.ts", 31 | "pack": "rimraf extension.zip && jszip-cli add dist/* -o ./extension.zip", 32 | "start": "web-ext run --source-dir ./dist --target=chromium", 33 | "clear": "rimraf --glob ./dist", 34 | "lint": "eslint --ext .js,.ts,.vue ./src" 35 | }, 36 | "dependencies": { 37 | "@vueuse/core": "^10.9.0", 38 | "class-variance-authority": "^0.7.0", 39 | "clsx": "^2.1.0", 40 | "lucide-vue-next": "^0.352.0", 41 | "radix-vue": "^1.5.1", 42 | "tailwind-merge": "^2.2.1", 43 | "tailwindcss-animate": "^1.0.7", 44 | "vue": "^3.4.19" 45 | }, 46 | "devDependencies": { 47 | "@ffflorian/jszip-cli": "^3.6.2", 48 | "@hotwired/stimulus": "^3.2.2", 49 | "@types/chrome": "^0.0.263", 50 | "@types/node": "^20.11.25", 51 | "@typescript-eslint/eslint-plugin": "^7.1.1", 52 | "@typescript-eslint/parser": "^7.1.1", 53 | "@vitejs/plugin-vue": "^5.0.4", 54 | "@vue/eslint-config-typescript": "^13.0.0", 55 | "autoprefixer": "^10.4.18", 56 | "eslint": "^8.57.0", 57 | "eslint-config-prettier": "^9.1.0", 58 | "eslint-plugin-prettier": "^5.1.3", 59 | "eslint-plugin-vue": "^9.22.0", 60 | "npm-run-all": "^4.1.5", 61 | "prettier": "^3.2.5", 62 | "prettier-plugin-tailwindcss": "^0.5.12", 63 | "rimraf": "^5.0.5", 64 | "shiki": "^1.2.0", 65 | "tailwindcss": "^3.4.1", 66 | "typescript": "^5.2.2", 67 | "vite": "^5.4.14", 68 | "vue-tsc": "^1.8.27", 69 | "web-ext": "^8.2.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Stimulus DevTools 5 | 6 | 46 | 47 | 48 | 53 | 54 | -------------------------------------------------------------------------------- /public/images/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinsimonklein/stimulus-devtools/fc9e62e0b9f4d167c48cd1c2417ddafbb67708a3/public/images/icon-128.png -------------------------------------------------------------------------------- /public/images/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinsimonklein/stimulus-devtools/fc9e62e0b9f4d167c48cd1c2417ddafbb67708a3/public/images/icon-16.png -------------------------------------------------------------------------------- /public/images/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinsimonklein/stimulus-devtools/fc9e62e0b9f4d167c48cd1c2417ddafbb67708a3/public/images/icon-32.png -------------------------------------------------------------------------------- /public/images/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinsimonklein/stimulus-devtools/fc9e62e0b9f4d167c48cd1c2417ddafbb67708a3/public/images/icon-48.png -------------------------------------------------------------------------------- /public/images/popup_tab_dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinsimonklein/stimulus-devtools/fc9e62e0b9f4d167c48cd1c2417ddafbb67708a3/public/images/popup_tab_dark.jpg -------------------------------------------------------------------------------- /public/images/popup_tab_light.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinsimonklein/stimulus-devtools/fc9e62e0b9f4d167c48cd1c2417ddafbb67708a3/public/images/popup_tab_light.jpg -------------------------------------------------------------------------------- /scripts/utils.ts: -------------------------------------------------------------------------------- 1 | export const isDev = process.env.NODE_ENV !== 'production'; 2 | -------------------------------------------------------------------------------- /src/bridge/index.ts: -------------------------------------------------------------------------------- 1 | function injectClient() { 2 | // Inject client script in page 3 | const script = document.createElement('script'); 4 | script.src = chrome.runtime.getURL('assets/client.js'); 5 | (document.head || document.body || document.documentElement).appendChild(script); 6 | } 7 | 8 | chrome.runtime.onMessage.addListener(message => { 9 | window.postMessage(message); 10 | }); 11 | 12 | window.addEventListener( 13 | 'message', 14 | async event => { 15 | try { 16 | if (event.data.type === 'stimulus-devtools:event' && event.data.name === 'stimulus-devtools:detected') { 17 | injectClient(); 18 | return; 19 | } 20 | 21 | // Transmit events to devtools 22 | if (event.data.type === 'stimulus-devtools:event') { 23 | await chrome.runtime.sendMessage(event.data); 24 | return; 25 | } 26 | } catch (e) { 27 | // Ignore errors 28 | } 29 | }, 30 | false, 31 | ); 32 | -------------------------------------------------------------------------------- /src/client/Inspector.ts: -------------------------------------------------------------------------------- 1 | const zIndex = '2147483645'; 2 | 3 | const titleTemplate: string = `__title__`; 4 | const sizeTemplate: string = `__width__x__height__`; 5 | 6 | function createHighlightBox(target: HTMLElement, title?: string) { 7 | const targetBoundingClientRect = target.getBoundingClientRect(); 8 | 9 | const highlightBox = document.createElement('div'); 10 | highlightBox.classList.add('stimulus-devtools-highlight'); 11 | highlightBox.style.position = 'fixed'; 12 | highlightBox.style.zIndex = zIndex; 13 | highlightBox.style.top = `${targetBoundingClientRect.top}px`; 14 | highlightBox.style.left = `${targetBoundingClientRect.left}px`; 15 | highlightBox.style.width = `${targetBoundingClientRect.width}px`; 16 | highlightBox.style.height = `${targetBoundingClientRect.height}px`; 17 | highlightBox.style.backgroundColor = 'rgba(119, 232, 185, 0.5)'; 18 | highlightBox.style.borderColor = 'rgba(119, 232, 185, 1)'; 19 | highlightBox.style.borderWidth = '2px'; 20 | highlightBox.style.borderStyle = 'dashed'; 21 | 22 | if (title) { 23 | const titleHeight = 24; 24 | const arrowHeight = 8; 25 | 26 | const position = targetBoundingClientRect.top > titleHeight + arrowHeight ? 'top' : 'bottom'; 27 | const overflow = 28 | position === 'top' 29 | ? targetBoundingClientRect.top > window.innerHeight 30 | : targetBoundingClientRect.top + targetBoundingClientRect.height < 0; 31 | 32 | const highlightBoxTitle = document.createElement('div'); 33 | highlightBoxTitle.innerHTML = 34 | titleTemplate.replaceAll('__title__', title) + 35 | sizeTemplate 36 | .replaceAll('__width__', parseFloat(targetBoundingClientRect.width.toFixed(2)).toString()) 37 | .replaceAll('__height__', parseFloat(targetBoundingClientRect.height.toFixed(2)).toString()); 38 | 39 | highlightBoxTitle.style.display = 'inline-flex'; 40 | highlightBoxTitle.style.position = overflow ? 'fixed' : 'absolute'; 41 | highlightBoxTitle.style.zIndex = zIndex; 42 | highlightBoxTitle.style.fontFamily = 'ui-sans-serif, system-ui, sans-serif'; 43 | highlightBoxTitle.style.left = overflow ? `${Math.max(targetBoundingClientRect.left, 0)}px` : '0'; 44 | highlightBoxTitle.style.height = `${titleHeight}px`; 45 | highlightBoxTitle.style.alignItems = 'center'; 46 | highlightBoxTitle.style.columnGap = '4px'; 47 | highlightBoxTitle.style.padding = '0 8px'; 48 | highlightBoxTitle.style.borderRadius = '4px'; 49 | highlightBoxTitle.style.backgroundColor = '#fff'; 50 | highlightBoxTitle.style.boxShadow = '0px 0px 4px 0px rgba(0,0,0,0.2)'; 51 | 52 | position === 'top' 53 | ? (highlightBoxTitle.style.bottom = overflow ? `${arrowHeight}px` : `calc(100% + 1px + ${arrowHeight}px)`) 54 | : (highlightBoxTitle.style.top = overflow ? `${arrowHeight}px` : `calc(100% + 1px + ${arrowHeight}px)`); 55 | 56 | highlightBox.appendChild(highlightBoxTitle); 57 | 58 | const highlightBoxArrow = document.createElement('span'); 59 | highlightBoxArrow.style.display = 'inline-block'; 60 | highlightBoxArrow.style.position = 'absolute'; 61 | highlightBoxArrow.style.left = '4px'; 62 | highlightBoxArrow.style.zIndex = '+1'; 63 | highlightBoxArrow.style.width = `${arrowHeight}px`; 64 | highlightBoxArrow.style.height = `${arrowHeight}px`; 65 | highlightBoxArrow.style.rotate = '45deg'; 66 | highlightBoxArrow.style.backgroundColor = '#fff'; 67 | highlightBoxArrow.style.boxShadow = 68 | position === 'top' ? '2px 2px 4px 0px rgba(0,0,0,0.1)' : '-2px -2px 4px 0px rgba(0,0,0,0.1)'; 69 | 70 | position === 'top' 71 | ? (highlightBoxArrow.style.bottom = `${arrowHeight / -2}px`) 72 | : (highlightBoxArrow.style.top = `${arrowHeight / -2}px`); 73 | 74 | highlightBoxTitle.appendChild(highlightBoxArrow); 75 | } 76 | 77 | return highlightBox; 78 | } 79 | 80 | export class Inspector { 81 | highlightElements(args: { elements: { selector: string; title?: string }[] }) { 82 | const { elements } = args; 83 | if (!elements?.length) return; 84 | 85 | elements.forEach(element => { 86 | const highlightedElement = document.querySelector(element.selector) as HTMLElement; 87 | if (!highlightedElement) return; 88 | 89 | const highlightBox = createHighlightBox(highlightedElement, element.title); 90 | 91 | document.body.appendChild(highlightBox); 92 | }); 93 | } 94 | 95 | stopHighlightElements() { 96 | const highlightBoxes = document.querySelectorAll('.stimulus-devtools-highlight'); 97 | highlightBoxes.forEach(highlightBoxe => { 98 | highlightBoxe.remove(); 99 | }); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/client/Observer.ts: -------------------------------------------------------------------------------- 1 | import type { Controller } from '@hotwired/stimulus'; 2 | import { 3 | ParsedStimulusControllerDefinition, 4 | ParsedStimulusControllerInstance, 5 | StimulusControllerDefinition, 6 | StimulusControllerInstance, 7 | StimulusControllerTarget, 8 | StimulusControllerValue, 9 | } from '../types/stimulus.ts'; 10 | import { getControllerFromInstance, getControllerKeys, sendEvent } from '@/utils/client.ts'; 11 | import { getElementSelectorString } from '@/utils/dom.ts'; 12 | import type { ValueController } from '@hotwired/stimulus/dist/types/tests/controllers/value_controller'; 13 | import { MessageEventName } from '@/enum'; 14 | 15 | type ControllerWithClasses = Controller & Record; 16 | 17 | export class Observer { 18 | controllerInstances: StimulusControllerInstance[] = []; 19 | lazyControllerIdentifiers = new Set(); 20 | 21 | controllersObserver?: MutationObserver; 22 | controllerValuesObserver?: MutationObserver; 23 | controllerTargetsObserver?: MutationObserver; 24 | controllerOutletsObserver?: MutationObserver; 25 | controllerClassesObserver?: MutationObserver; 26 | 27 | controllerInstancesLastIndex = new Map(); 28 | controllerTargetElementsLastIndex = new Map(); 29 | controllerOutletElementsLastIndex = new Map(); 30 | 31 | observedControllerValuesInstanceUid?: string; 32 | observedControllerTargetsInstanceUid?: string; 33 | observedControllerTargetsAttribute?: string; 34 | observedControllerOutletsInstanceUid?: string; 35 | observedControllerClassesInstanceUid?: string; 36 | 37 | constructor() { 38 | this.controllersObserver = new MutationObserver(this.onControllersObservation.bind(this)); 39 | this.controllersObserver.observe(document.body, { 40 | childList: true, 41 | subtree: true, 42 | }); 43 | 44 | this.controllerValuesObserver = new MutationObserver(this.onControllerValuesObservation.bind(this)); 45 | this.controllerTargetsObserver = new MutationObserver(this.onControllerTargetsObservation.bind(this)); 46 | this.controllerOutletsObserver = new MutationObserver(this.onControllerOutletsObservation.bind(this)); 47 | this.controllerClassesObserver = new MutationObserver(this.onControllerClassesObservation.bind(this)); 48 | 49 | this.updateControllers(); 50 | } 51 | 52 | // Getters 53 | 54 | get controllerDefinitions(): StimulusControllerDefinition[] { 55 | if (!this.controllerInstances.length) return []; 56 | 57 | const controllerDefinitions: StimulusControllerDefinition[] = []; 58 | 59 | this.controllerInstances.forEach(instance => { 60 | const matchingDefinition = controllerDefinitions.find( 61 | definition => definition.identifier === instance.identifier, 62 | ); 63 | 64 | if (matchingDefinition) { 65 | matchingDefinition.instances.push(instance); 66 | } else { 67 | controllerDefinitions.push({ 68 | identifier: instance.identifier, 69 | instances: [instance], 70 | }); 71 | } 72 | }); 73 | 74 | controllerDefinitions.forEach(definition => { 75 | definition.isLazyController = this.lazyControllerIdentifiers.has(definition.identifier); 76 | }); 77 | 78 | return controllerDefinitions; 79 | } 80 | 81 | get parsedControllerDefinitions(): ParsedStimulusControllerDefinition[] { 82 | return this.controllerDefinitions.map(definition => ({ 83 | ...definition, 84 | instances: definition.instances.map(instance => this.parseControllerInstance(instance)), 85 | })); 86 | } 87 | 88 | // Parsers 89 | 90 | parseControllerInstance(instance: StimulusControllerInstance): ParsedStimulusControllerInstance { 91 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 92 | const { element: _, ...parsedInstance } = instance; 93 | return parsedInstance; 94 | } 95 | 96 | // Actions 97 | 98 | updateControllers() { 99 | if (!window.Stimulus) { 100 | sendEvent(MessageEventName.Undetected); 101 | return; 102 | } 103 | 104 | this.controllerInstances = 105 | window.Stimulus?.controllers.map(controller => { 106 | const isLazyController = !!(controller as Controller & { __stimulusLazyController: unknown })[ 107 | '__stimulusLazyController' 108 | ]; 109 | if (isLazyController) this.lazyControllerIdentifiers.add(controller.identifier); 110 | 111 | // Create uid and ensure to track controller instances elements 112 | const uidAttribute = `sd-${controller.identifier}-uid`; 113 | const lastIndexKey = controller.identifier; 114 | 115 | let index = 0; 116 | 117 | const elementUid = controller.element.getAttribute(uidAttribute); 118 | const elementIndex = elementUid?.split('-').pop(); 119 | const lastIndex = this.controllerInstancesLastIndex.get(lastIndexKey); 120 | 121 | if (elementIndex) { 122 | index = parseInt(elementIndex); 123 | if (!lastIndex || lastIndex < index) this.controllerInstancesLastIndex.set(lastIndexKey, index); 124 | } else { 125 | if (typeof lastIndex === 'number') index = lastIndex + 1; 126 | this.controllerInstancesLastIndex.set(lastIndexKey, index); 127 | } 128 | 129 | const uid = `${controller.identifier}-${index.toString()}`; 130 | if (!elementIndex) controller.element.setAttribute(uidAttribute, uid); 131 | 132 | return { 133 | uid, 134 | uidSelector: `[${uidAttribute}="${uid}"]`, 135 | identifier: controller.identifier, 136 | element: controller.element, 137 | elementSelector: getElementSelectorString(controller.element), 138 | } as StimulusControllerInstance; 139 | }) || []; 140 | 141 | sendEvent(MessageEventName.ControllersUpdated, { 142 | controllerDefinitions: this.parsedControllerDefinitions, 143 | }); 144 | } 145 | 146 | updateInstanceValues(args: unknown) { 147 | const uid = (args as { uid: StimulusControllerInstance['uid'] }).uid; 148 | if (!uid) return; 149 | 150 | if (!window.Stimulus) return; 151 | 152 | const instance = this.controllerInstances.find(controllerInstance => controllerInstance.uid === uid); 153 | if (!instance) return; 154 | 155 | const controller = getControllerFromInstance(instance); 156 | if (!controller) return; 157 | 158 | const valueDescriptorMap = (controller as ValueController).valueDescriptorMap; 159 | 160 | const values = Object.values(valueDescriptorMap).map(valueDescriptor => { 161 | const name = valueDescriptor.name.slice(0, -5); // Remove "Value" at the end 162 | return { 163 | name, 164 | type: valueDescriptor.type, 165 | key: valueDescriptor.key, 166 | defaultValue: valueDescriptor.defaultValue, 167 | currentValue: (controller as Controller & Record)[valueDescriptor.name], 168 | htmlAttribute: `data-${controller.identifier}-${valueDescriptor.key}`, 169 | jsSingular: `this.${name}Value`, 170 | jsPlural: `this.${name}Values`, 171 | jsExistential: `this.has${name[0].toUpperCase() + name.slice(1)}Value`, 172 | } as StimulusControllerValue; 173 | }); 174 | 175 | // Start or restart observer 176 | if (this.observedControllerValuesInstanceUid !== uid) { 177 | this.observedControllerValuesInstanceUid = uid; 178 | this.controllerValuesObserver?.disconnect(); 179 | this.controllerValuesObserver?.observe(controller.element, { 180 | attributes: true, 181 | }); 182 | } 183 | 184 | sendEvent(MessageEventName.InstanceValuesUpdated, { uid, values }); 185 | } 186 | 187 | updateInstanceTargets(args: unknown) { 188 | const uid = (args as { uid: StimulusControllerInstance['uid'] }).uid; 189 | if (!uid) return; 190 | 191 | if (!window.Stimulus) return; 192 | 193 | const instance = this.controllerInstances.find(controllerInstance => controllerInstance.uid === uid); 194 | if (!instance) return; 195 | 196 | const controller = getControllerFromInstance(instance); 197 | if (!controller) return; 198 | 199 | const controllerKeys = getControllerKeys(controller); 200 | 201 | const targetNames = controllerKeys 202 | .filter(k => k.endsWith('Target') && !k.startsWith('has')) 203 | .map(k => k.slice(0, -6)); // Remove "Target" at the end 204 | 205 | const targets = targetNames.map(targetName => { 206 | const elements = (controller as Controller & Record)[`${targetName}Targets`] as Element[]; 207 | 208 | const targetElements = elements.map(element => { 209 | // Create uid and ensure to track target elements 210 | const uidAttribute = `sd-${controller.identifier}-t-${targetName.toLowerCase()}-uid`; 211 | const lastIndexKey = `${controller.identifier}-${targetName}`; 212 | 213 | let index = 0; 214 | 215 | const elementUid = element.getAttribute(uidAttribute); 216 | const elementIndex = elementUid?.split('-').pop(); 217 | const lastIndex = this.controllerTargetElementsLastIndex.get(lastIndexKey); 218 | 219 | if (elementIndex) { 220 | index = parseInt(elementIndex); 221 | if (!lastIndex || lastIndex < index) this.controllerTargetElementsLastIndex.set(lastIndexKey, index); 222 | } else { 223 | if (typeof lastIndex === 'number') index = lastIndex + 1; 224 | this.controllerTargetElementsLastIndex.set(lastIndexKey, index); 225 | } 226 | 227 | const uid = `${targetName}-${index}`; 228 | if (!elementUid) element.setAttribute(uidAttribute, uid); 229 | 230 | return { 231 | uid, 232 | uidSelector: `[${uidAttribute}="${uid}"]`, 233 | elementSelector: getElementSelectorString(element), 234 | }; 235 | }); 236 | 237 | return { 238 | name: targetName, 239 | elements: targetElements, 240 | htmlAttribute: `${controller.context.schema.targetAttributeForScope(controller.identifier)}="${targetName}"`, 241 | jsSingular: `this.${targetName}Target`, 242 | jsPlural: `this.${targetName}Targets`, 243 | jsExistential: `this.has${targetName[0].toUpperCase() + targetName.slice(1)}Target`, 244 | } as StimulusControllerTarget; 245 | }); 246 | 247 | // Start or restart observer 248 | if (this.observedControllerTargetsInstanceUid !== uid) { 249 | this.observedControllerTargetsInstanceUid = uid; 250 | this.observedControllerTargetsAttribute = window.Stimulus.schema.targetAttributeForScope(controller.identifier); 251 | this.controllerTargetsObserver?.disconnect(); 252 | this.controllerTargetsObserver?.observe(controller.element, { 253 | childList: true, 254 | subtree: true, 255 | }); 256 | } 257 | 258 | sendEvent(MessageEventName.InstanceTargetsUpdated, { uid, targets }); 259 | } 260 | 261 | updateInstanceOutlets(args: unknown) { 262 | const uid = (args as { uid: StimulusControllerInstance['uid'] }).uid; 263 | if (!uid) return; 264 | 265 | if (!window.Stimulus) return; 266 | 267 | const instance = this.controllerInstances.find(controllerInstance => controllerInstance.uid === uid); 268 | if (!instance) return; 269 | 270 | const controller = getControllerFromInstance(instance); 271 | if (!controller) return; 272 | 273 | const outletNames = controller.context['outletObserver']?.outletDefinitions as string[] | undefined; 274 | // Ignore if no outlet (Stimulus < v3.2) 275 | if (!outletNames) return; 276 | 277 | const outlets = outletNames.map(outletName => { 278 | const outletReferences = controller.context['outletObserver'].outletsByName 279 | .getValuesForKey(outletName) 280 | .map((outletController: Controller) => { 281 | // Create uid and ensure to track outlet elements 282 | const uidAttribute = `sd-${controller.identifier}-o-${outletName}-uid`; 283 | const lastIndexKey = `${controller.identifier}-${outletName}`; 284 | 285 | let index = 0; 286 | 287 | const elementUid = outletController.element.getAttribute(uidAttribute); 288 | const elementIndex = elementUid?.split('-').pop(); 289 | const lastIndex = this.controllerOutletElementsLastIndex.get(lastIndexKey); 290 | 291 | if (elementIndex) { 292 | index = parseInt(elementIndex); 293 | if (!lastIndex || lastIndex < index) this.controllerOutletElementsLastIndex.set(lastIndexKey, index); 294 | } else { 295 | if (typeof lastIndex === 'number') index = lastIndex + 1; 296 | this.controllerOutletElementsLastIndex.set(lastIndexKey, index); 297 | } 298 | 299 | const uid = `${outletName}-${index}`; 300 | if (!elementUid) outletController.element.setAttribute(uidAttribute, uid); 301 | 302 | return { 303 | uid, 304 | uidSelector: `[${uidAttribute}="${uid}"]`, 305 | identifier: outletController.identifier, 306 | elementSelector: getElementSelectorString(controller.element), 307 | }; 308 | }); 309 | 310 | const propertyName = outletName 311 | .split('-') 312 | .filter(s => s.length) 313 | .map((s, i) => (i > 0 ? s[0].toUpperCase() + s.slice(1) : s)) 314 | .join(''); 315 | 316 | return { 317 | name: outletName, 318 | selector: controller.outlets.getSelectorForOutletName(outletName), 319 | references: outletReferences, 320 | htmlAttribute: `${controller.context.schema.outletAttributeForScope(controller.identifier, outletName)}=""`, 321 | jsSingular: `this.${propertyName}Outlet`, 322 | jsPlural: `this.${propertyName}Outlets`, 323 | jsExistential: `this.has${propertyName[0].toUpperCase() + propertyName.slice(1)}Outlet`, 324 | jsElementSingular: `this.${propertyName}OutletElement`, 325 | jsElementPlural: `this.${propertyName}OutletElements`, 326 | }; 327 | }); 328 | 329 | // Start or restart observer 330 | if (this.observedControllerOutletsInstanceUid !== uid) { 331 | this.observedControllerOutletsInstanceUid = uid; 332 | this.controllerOutletsObserver?.disconnect(); 333 | this.controllerOutletsObserver?.observe(document.documentElement, { 334 | childList: true, 335 | subtree: true, 336 | }); 337 | } 338 | 339 | sendEvent(MessageEventName.InstanceOutletsUpdated, { uid, outlets }); 340 | } 341 | 342 | updateInstanceClasses(args: unknown) { 343 | const uid = (args as { uid: StimulusControllerInstance['uid'] }).uid; 344 | if (!uid) return; 345 | 346 | if (!window.Stimulus) return; 347 | 348 | const instance = this.controllerInstances.find(controllerInstance => controllerInstance.uid === uid); 349 | if (!instance) return; 350 | 351 | const controller = getControllerFromInstance(instance); 352 | if (!controller) return; 353 | 354 | // Retrieve controller's prototype members 355 | const controllerKeys = getControllerKeys(controller); 356 | 357 | const classesNames = controllerKeys 358 | .filter(k => k.endsWith('Class') && !k.startsWith('has')) 359 | .map(k => k.slice(0, -5)); // Remove "Class" at the end 360 | 361 | const classes = classesNames.map(name => ({ 362 | name: name, 363 | classNames: (controller as ControllerWithClasses)[`${name}Classes`], 364 | htmlAttribute: controller.classes.getAttributeName(name), 365 | jsSingular: `this.${name}Class`, 366 | jsPlural: `this.${name}Classes`, 367 | jsExistential: `this.has${name[0].toUpperCase() + name.slice(1)}Class`, 368 | test: controller.classes.getAll(name), 369 | })); 370 | 371 | // Start or restart observer 372 | if (this.observedControllerClassesInstanceUid !== uid) { 373 | this.observedControllerClassesInstanceUid = uid; 374 | this.controllerClassesObserver?.disconnect(); 375 | this.controllerClassesObserver?.observe(controller.element, { 376 | attributes: true, 377 | }); 378 | } 379 | 380 | sendEvent(MessageEventName.InstanceClassesUpdated, { uid, classes }); 381 | } 382 | 383 | updateValue(args: any) { 384 | const { value, key, identifier, uidSelector } = args as { 385 | value: any; 386 | key: string; 387 | identifier?: string; 388 | uidSelector?: string; 389 | }; 390 | 391 | if (!key || !identifier || !uidSelector) return; 392 | const controllerElement = document.querySelector(uidSelector); 393 | if (!controllerElement) return; 394 | 395 | const controller = window.Stimulus?.getControllerForElementAndIdentifier(controllerElement, identifier); 396 | if (!controller) return; 397 | 398 | // @ts-ignore 399 | controller[key] = value; 400 | } 401 | 402 | // Observations 403 | 404 | onControllersObservation(mutationsList: MutationRecord[]) { 405 | let shouldUpdate = false; 406 | 407 | for (const mutation of mutationsList) { 408 | if (mutation.type !== 'childList') continue; 409 | 410 | for (const addedNode of mutation.addedNodes) { 411 | if ((addedNode as HTMLElement).classList?.contains('stimulus-devtools-highlight')) continue; 412 | shouldUpdate = true; 413 | } 414 | 415 | for (const addedNode of mutation.removedNodes) { 416 | if ((addedNode as HTMLElement).classList?.contains('stimulus-devtools-highlight')) continue; 417 | shouldUpdate = true; 418 | } 419 | } 420 | 421 | if (shouldUpdate) this.updateControllers(); 422 | } 423 | 424 | onControllerValuesObservation(mutationsList: MutationRecord[]) { 425 | let shouldUpdate = false; 426 | for (const mutation of mutationsList) { 427 | if (mutation.attributeName?.startsWith('data-') && mutation.attributeName?.endsWith('-value')) { 428 | shouldUpdate = true; 429 | } 430 | } 431 | 432 | if (shouldUpdate) this.updateInstanceValues({ uid: this.observedControllerValuesInstanceUid }); 433 | } 434 | 435 | onControllerTargetsObservation(mutationsList: MutationRecord[]) { 436 | let shouldUpdate = false; 437 | for (const mutation of mutationsList) { 438 | if (mutation.type !== 'childList') continue; 439 | 440 | for (const addedNode of mutation.addedNodes) { 441 | if ((addedNode as HTMLElement).classList?.contains('stimulus-devtools-highlight')) continue; 442 | shouldUpdate = true; 443 | } 444 | 445 | for (const addedNode of mutation.removedNodes) { 446 | if ((addedNode as HTMLElement).classList?.contains('stimulus-devtools-highlight')) continue; 447 | shouldUpdate = true; 448 | } 449 | } 450 | 451 | if (shouldUpdate) this.updateInstanceTargets({ uid: this.observedControllerTargetsInstanceUid }); 452 | } 453 | 454 | onControllerOutletsObservation(mutationsList: MutationRecord[]) { 455 | let shouldUpdate = false; 456 | 457 | for (const mutation of mutationsList) { 458 | if (mutation.type !== 'childList') continue; 459 | 460 | for (const addedNode of mutation.addedNodes) { 461 | if ((addedNode as HTMLElement).classList?.contains('stimulus-devtools-highlight')) continue; 462 | shouldUpdate = true; 463 | } 464 | 465 | for (const addedNode of mutation.removedNodes) { 466 | if ((addedNode as HTMLElement).classList?.contains('stimulus-devtools-highlight')) continue; 467 | shouldUpdate = true; 468 | } 469 | } 470 | 471 | if (shouldUpdate) this.updateInstanceOutlets({ uid: this.observedControllerOutletsInstanceUid }); 472 | } 473 | 474 | onControllerClassesObservation(mutationsList: MutationRecord[]) { 475 | let shouldUpdate = false; 476 | for (const mutation of mutationsList) { 477 | if (mutation.attributeName?.startsWith('data-') && mutation.attributeName?.endsWith('-class')) { 478 | shouldUpdate = true; 479 | } 480 | } 481 | 482 | if (shouldUpdate) this.updateInstanceClasses({ uid: this.observedControllerValuesInstanceUid }); 483 | } 484 | } 485 | -------------------------------------------------------------------------------- /src/client/index.ts: -------------------------------------------------------------------------------- 1 | import { Observer } from '@/client/Observer.ts'; 2 | import { Inspector } from '@/client/Inspector.ts'; 3 | import { MessageType } from '@/enum'; 4 | 5 | const observer = new Observer(); 6 | const inspector = new Inspector(); 7 | 8 | window.addEventListener('message', e => { 9 | const message = e.data; 10 | if (!message) return; 11 | 12 | if (message.type !== MessageType.Action) return; 13 | if (!observer) return; 14 | 15 | const actionName = message.name as string; 16 | 17 | // @ts-ignore 18 | if (observer[actionName] && typeof observer[actionName] === 'function') observer[actionName](message.args); 19 | // @ts-ignore 20 | if (inspector[actionName] && typeof inspector[actionName] === 'function') inspector[actionName](message.args); 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/StimulusDevToolsLogo.vue: -------------------------------------------------------------------------------- 1 | 20 | -------------------------------------------------------------------------------- /src/components/core/CodeBlock.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 26 | 27 | 32 | -------------------------------------------------------------------------------- /src/components/core/CopyButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 19 | -------------------------------------------------------------------------------- /src/components/core/SplitPane.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 164 | -------------------------------------------------------------------------------- /src/components/core/ValueType.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 56 | -------------------------------------------------------------------------------- /src/components/core/code/CodeInline.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | 21 | 30 | -------------------------------------------------------------------------------- /src/components/core/tree/Tree.vue: -------------------------------------------------------------------------------- 1 | 20 | 30 | -------------------------------------------------------------------------------- /src/components/core/tree/TreeAction.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/core/tree/TreeItem.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 27 | -------------------------------------------------------------------------------- /src/components/core/value-tree/ValueTree.vue: -------------------------------------------------------------------------------- 1 | 81 | 82 | 120 | -------------------------------------------------------------------------------- /src/components/core/value-tree/ValueTreeWrapper.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 86 | -------------------------------------------------------------------------------- /src/components/core/value-tree/form/ValueTreeArrayForm.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 109 | -------------------------------------------------------------------------------- /src/components/core/value-tree/form/ValueTreeObjectForm.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 158 | -------------------------------------------------------------------------------- /src/components/core/value-tree/type/ValueTreeArray.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 84 | -------------------------------------------------------------------------------- /src/components/core/value-tree/type/ValueTreeBoolean.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 40 | -------------------------------------------------------------------------------- /src/components/core/value-tree/type/ValueTreeNull.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 36 | -------------------------------------------------------------------------------- /src/components/core/value-tree/type/ValueTreeNumber.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 153 | -------------------------------------------------------------------------------- /src/components/core/value-tree/type/ValueTreeObject.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 78 | -------------------------------------------------------------------------------- /src/components/core/value-tree/type/ValueTreeString.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 127 | -------------------------------------------------------------------------------- /src/components/stimulus/StimulusControllers.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 43 | -------------------------------------------------------------------------------- /src/components/stimulus/StimulusUnavailable.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 18 | -------------------------------------------------------------------------------- /src/components/stimulus/definition/StimulusControllerDefinitionDetails.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 156 | -------------------------------------------------------------------------------- /src/components/stimulus/definition/StimulusControllerDefinitions.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 75 | -------------------------------------------------------------------------------- /src/components/stimulus/definition/StimulusControllerDefinitionsRow.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 60 | -------------------------------------------------------------------------------- /src/components/stimulus/instance/StimulusControllerInstancesRow.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 46 | -------------------------------------------------------------------------------- /src/components/stimulus/members/classes/StimulusControllerClasses.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 25 | -------------------------------------------------------------------------------- /src/components/stimulus/members/classes/StimulusControllerClassesRow.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 86 | -------------------------------------------------------------------------------- /src/components/stimulus/members/outlets/StimulusControllerOutlets.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 25 | -------------------------------------------------------------------------------- /src/components/stimulus/members/outlets/StimulusControllerOutletsRow.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | 118 | -------------------------------------------------------------------------------- /src/components/stimulus/members/targets/StimulusControllerTargets.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 25 | -------------------------------------------------------------------------------- /src/components/stimulus/members/targets/StimulusControllerTargetsRow.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 98 | -------------------------------------------------------------------------------- /src/components/stimulus/members/values/StimulusControllerValues.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 27 | -------------------------------------------------------------------------------- /src/components/stimulus/members/values/StimulusControllerValuesRow.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 82 | -------------------------------------------------------------------------------- /src/components/ui/accordion/Accordion.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/components/ui/accordion/AccordionContent.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 27 | -------------------------------------------------------------------------------- /src/components/ui/accordion/AccordionItem.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 24 | -------------------------------------------------------------------------------- /src/components/ui/accordion/AccordionTrigger.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 36 | -------------------------------------------------------------------------------- /src/components/ui/accordion/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Accordion } from './Accordion.vue'; 2 | export { default as AccordionContent } from './AccordionContent.vue'; 3 | export { default as AccordionItem } from './AccordionItem.vue'; 4 | export { default as AccordionTrigger } from './AccordionTrigger.vue'; 5 | -------------------------------------------------------------------------------- /src/components/ui/alert/Alert.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /src/components/ui/alert/AlertDescription.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /src/components/ui/alert/AlertTitle.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /src/components/ui/alert/index.ts: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from 'class-variance-authority'; 2 | 3 | export { default as Alert } from './Alert.vue'; 4 | export { default as AlertTitle } from './AlertTitle.vue'; 5 | export { default as AlertDescription } from './AlertDescription.vue'; 6 | 7 | export const alertVariants = cva( 8 | 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-background text-foreground', 13 | warning: 'border-warning/50 text-warning dark:border-warning [&>svg]:text-warning', 14 | destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', 15 | }, 16 | }, 17 | defaultVariants: { 18 | variant: 'default', 19 | }, 20 | }, 21 | ); 22 | 23 | export type AlertVariants = VariantProps; 24 | -------------------------------------------------------------------------------- /src/components/ui/button/Button.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 28 | -------------------------------------------------------------------------------- /src/components/ui/button/index.ts: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from 'class-variance-authority'; 2 | 3 | export { default as Button } from './Button.vue'; 4 | 5 | export const buttonVariants = cva( 6 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 7 | { 8 | variants: { 9 | variant: { 10 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 11 | destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 12 | outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', 13 | secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 14 | ghost: 'hover:bg-accent hover:text-accent-foreground', 15 | link: 'text-primary underline-offset-4 hover:underline', 16 | }, 17 | size: { 18 | default: 'h-10 px-4 py-2', 19 | sm: 'h-9 rounded-md px-3', 20 | lg: 'h-11 rounded-md px-8', 21 | link: 'px-0 py-0', 22 | icon: 'h-10 w-10', 23 | 'icon-sm': 'h-6 w-6', 24 | }, 25 | }, 26 | defaultVariants: { 27 | variant: 'default', 28 | size: 'default', 29 | }, 30 | }, 31 | ); 32 | 33 | export type ButtonVariants = VariantProps; 34 | -------------------------------------------------------------------------------- /src/components/ui/checkbox/Checkbox.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 39 | -------------------------------------------------------------------------------- /src/components/ui/checkbox/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Checkbox } from './Checkbox.vue'; 2 | -------------------------------------------------------------------------------- /src/components/ui/popover/Popover.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /src/components/ui/popover/PopoverContent.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 49 | -------------------------------------------------------------------------------- /src/components/ui/popover/PopoverTrigger.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/ui/popover/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Popover } from './Popover.vue'; 2 | export { default as PopoverTrigger } from './PopoverTrigger.vue'; 3 | export { default as PopoverContent } from './PopoverContent.vue'; 4 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area/ScrollArea.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 27 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area/ScrollBar.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 35 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ScrollArea } from './ScrollArea.vue'; 2 | export { default as ScrollBar } from './ScrollBar.vue'; 3 | -------------------------------------------------------------------------------- /src/composables/stimulus/useControllerDefinition.ts: -------------------------------------------------------------------------------- 1 | import { computed, Ref } from 'vue'; 2 | import { useControllerDefinitions } from './useControllerDefinitions.ts'; 3 | import { StimulusControllerDefinition } from '@/types/stimulus.ts'; 4 | 5 | export const useControllerDefinition = (identifier: Ref) => { 6 | const { definitions } = useControllerDefinitions(); 7 | 8 | const definition = computed(() => definitions.value.find(d => d.identifier === identifier.value)); 9 | 10 | return { definition }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/composables/stimulus/useControllerDefinitions.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref, shallowRef, watch } from 'vue'; 2 | import type { Controller } from '@hotwired/stimulus'; 3 | import { ParsedStimulusControllerDefinition } from '@/types/stimulus.ts'; 4 | import { Action, MessageEventName, MessageType } from '@/enum'; 5 | import { useBridge } from '@/composables/useBridge.ts'; 6 | 7 | const definitions = shallowRef([]); 8 | const selectedDefinitionIdentifier = ref(null); 9 | 10 | const { executeAction } = useBridge(); 11 | 12 | chrome.runtime.onMessage.addListener(async message => { 13 | if (message.type === MessageType.Event && message.name === MessageEventName.Detected) { 14 | await executeAction(Action.UpdateControllers); 15 | } 16 | 17 | if (message.type === MessageType.Event && message.name === MessageEventName.ControllersUpdated) { 18 | definitions.value = (message.data.controllerDefinitions || []).sort( 19 | (a: ParsedStimulusControllerDefinition, b: ParsedStimulusControllerDefinition) => 20 | a.identifier < b.identifier ? -1 : 1, 21 | ); 22 | } 23 | }); 24 | 25 | watch(definitions, updatedDefinitions => { 26 | window.dispatchEvent( 27 | new CustomEvent('stimulus-devtools:controllers:updated', { 28 | detail: { controllerDefinitions: updatedDefinitions }, 29 | }), 30 | ); 31 | }); 32 | 33 | export const useControllerDefinitions = () => { 34 | const selectedDefinition = computed(() => 35 | definitions.value?.find(definition => definition.identifier === selectedDefinitionIdentifier.value), 36 | ); 37 | 38 | const selectDefinition = (controllerIdentifier: Controller['identifier']) => { 39 | selectedDefinitionIdentifier.value = controllerIdentifier; 40 | }; 41 | 42 | const refresh = async () => { 43 | await executeAction(Action.UpdateControllers); 44 | }; 45 | 46 | return { definitions, selectedDefinition, refresh, selectDefinition }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/composables/stimulus/useControllerInstanceClasses.ts: -------------------------------------------------------------------------------- 1 | import { onBeforeMount, onBeforeUnmount, Ref, shallowRef, watch } from 'vue'; 2 | import { ParsedStimulusControllerInstance, StimulusControllerClass } from '@/types/stimulus.ts'; 3 | import { StimulusDevToolsMessage } from '@/types'; 4 | import { Action, MessageEventName, MessageType } from '@/enum'; 5 | import { useBridge } from '@/composables/useBridge.ts'; 6 | 7 | export const useControllerInstanceClasses = (instance: Ref) => { 8 | const { executeAction } = useBridge(); 9 | 10 | const classes = shallowRef([]); 11 | 12 | const updateClassesFromMessage = async (message: StimulusDevToolsMessage) => { 13 | if (message.type === MessageType.Event && message.name === MessageEventName.InstanceClassesUpdated) { 14 | // TODO: check if instance has same id ? 15 | if (message.data?.classes) classes.value = message.data.classes as StimulusControllerClass[]; 16 | } 17 | }; 18 | 19 | const reset = async () => { 20 | await executeAction(Action.UpdateInstanceClasses, { uid: instance.value.uid }); 21 | }; 22 | 23 | watch(instance, reset); 24 | 25 | onBeforeMount(async () => { 26 | await reset(); 27 | 28 | chrome.runtime.onMessage.addListener(updateClassesFromMessage); 29 | }); 30 | 31 | onBeforeUnmount(async () => { 32 | chrome.runtime.onMessage.removeListener(updateClassesFromMessage); 33 | }); 34 | 35 | return { classes }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/composables/stimulus/useControllerInstanceOutlets.ts: -------------------------------------------------------------------------------- 1 | import { onBeforeMount, onBeforeUnmount, Ref, shallowRef, watch } from 'vue'; 2 | import { ParsedStimulusControllerInstance, StimulusControllerOutlet } from '@/types/stimulus.ts'; 3 | import { StimulusDevToolsMessage } from '@/types'; 4 | import { Action, MessageEventName, MessageType } from '@/enum'; 5 | import { useBridge } from '@/composables/useBridge.ts'; 6 | 7 | export const useControllerInstanceOutlets = (instance: Ref) => { 8 | const { executeAction } = useBridge(); 9 | 10 | const outlets = shallowRef([]); 11 | 12 | const updateOutletsFromMessage = async (message: StimulusDevToolsMessage) => { 13 | if (message.type === MessageType.Event && message.name === MessageEventName.InstanceOutletsUpdated) { 14 | // TODO: check if instance has same id ? 15 | if (message.data?.outlets) outlets.value = message.data.outlets as StimulusControllerOutlet[]; 16 | } 17 | }; 18 | 19 | const reset = async () => { 20 | await executeAction(Action.UpdateInstanceOutlets, { uid: instance.value.uid }); 21 | }; 22 | 23 | watch(instance, reset); 24 | 25 | onBeforeMount(async () => { 26 | await reset(); 27 | 28 | chrome.runtime.onMessage.addListener(updateOutletsFromMessage); 29 | }); 30 | 31 | onBeforeUnmount(async () => { 32 | chrome.runtime.onMessage.removeListener(updateOutletsFromMessage); 33 | }); 34 | 35 | return { outlets }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/composables/stimulus/useControllerInstanceTargets.ts: -------------------------------------------------------------------------------- 1 | import { onBeforeMount, onBeforeUnmount, Ref, shallowRef, watch } from 'vue'; 2 | import { ParsedStimulusControllerInstance, StimulusControllerTarget } from '@/types/stimulus.ts'; 3 | import { StimulusDevToolsMessage } from '@/types'; 4 | import { Action, MessageEventName, MessageType } from '@/enum'; 5 | import { useBridge } from '@/composables/useBridge.ts'; 6 | 7 | export const useControllerInstanceTargets = (instance: Ref) => { 8 | const { executeAction } = useBridge(); 9 | 10 | const targets = shallowRef([]); 11 | 12 | const updateTargetsFromMessage = async (message: StimulusDevToolsMessage) => { 13 | if (message.type === MessageType.Event && message.name === MessageEventName.InstanceTargetsUpdated) { 14 | // TODO: check if instance has same id ? 15 | if (message.data?.targets) targets.value = message.data.targets as StimulusControllerTarget[]; 16 | } 17 | }; 18 | 19 | const reset = async () => { 20 | await executeAction(Action.UpdateInstanceTargets, { uid: instance.value.uid }); 21 | }; 22 | 23 | watch(instance, reset); 24 | 25 | onBeforeMount(async () => { 26 | await reset(); 27 | 28 | chrome.runtime.onMessage.addListener(updateTargetsFromMessage); 29 | }); 30 | 31 | onBeforeUnmount(async () => { 32 | chrome.runtime.onMessage.removeListener(updateTargetsFromMessage); 33 | }); 34 | 35 | return { targets }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/composables/stimulus/useControllerInstanceValues.ts: -------------------------------------------------------------------------------- 1 | import { onBeforeMount, onBeforeUnmount, Ref, shallowRef, watch } from 'vue'; 2 | import { ParsedStimulusControllerInstance, StimulusControllerValue } from '@/types/stimulus.ts'; 3 | import { StimulusDevToolsMessage } from '@/types'; 4 | import { Action, MessageEventName, MessageType } from '@/enum'; 5 | import { useBridge } from '@/composables/useBridge.ts'; 6 | 7 | export const useControllerInstanceValues = (instance: Ref) => { 8 | const { executeAction } = useBridge(); 9 | 10 | const values = shallowRef([]); 11 | 12 | const updateValuesFromMessage = async (message: StimulusDevToolsMessage) => { 13 | if (message.type === MessageType.Event && message.name === MessageEventName.InstanceValuesUpdated) { 14 | // TODO: check if instance has same id ? 15 | if (message.data?.values) values.value = message.data.values as StimulusControllerValue[]; 16 | } 17 | }; 18 | 19 | const reset = async () => { 20 | await executeAction(Action.UpdateInstanceValues, { uid: instance.value.uid }); 21 | }; 22 | 23 | watch(instance, reset); 24 | 25 | onBeforeMount(async () => { 26 | await reset(); 27 | 28 | chrome.runtime.onMessage.addListener(updateValuesFromMessage); 29 | }); 30 | 31 | onBeforeUnmount(async () => { 32 | chrome.runtime.onMessage.removeListener(updateValuesFromMessage); 33 | }); 34 | 35 | return { values }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/composables/useBridge.ts: -------------------------------------------------------------------------------- 1 | import { Action, MessageType } from '@/enum'; 2 | 3 | async function sendMessage(message: unknown) { 4 | try { 5 | await chrome.tabs?.sendMessage(chrome.devtools.inspectedWindow.tabId, message); 6 | } catch (error) { 7 | console.error(error); 8 | } 9 | } 10 | 11 | async function executeAction(name: Action, args?: Record) { 12 | await sendMessage({ type: MessageType.Action, name, args }); 13 | } 14 | 15 | export const useBridge = () => { 16 | return { executeAction }; 17 | }; 18 | -------------------------------------------------------------------------------- /src/composables/useChromeStorage.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | import { watchDebounced } from '@vueuse/core'; 3 | 4 | export const useChromeStorage = (key: string, defaultValue?: Record) => { 5 | const value = ref>({ ...defaultValue }); 6 | 7 | chrome.storage.local.get(key).then(storageValue => { 8 | value.value = { ...value.value, ...storageValue[key] }; 9 | }); 10 | 11 | watchDebounced(value, async newValue => { 12 | await chrome.storage.local.set({ [key]: newValue }); 13 | }); 14 | 15 | return value; 16 | }; 17 | -------------------------------------------------------------------------------- /src/composables/useCodeHighlighter.ts: -------------------------------------------------------------------------------- 1 | import { computed, MaybeRef, unref } from 'vue'; 2 | import { highlighter } from '@/devtools/highlighter.ts'; 3 | import { usePreferredColorScheme } from '@vueuse/core'; 4 | 5 | export const useCodeHighlighter = () => { 6 | const preferredColor = usePreferredColorScheme(); 7 | 8 | const theme = computed(() => (preferredColor.value === 'dark' ? 'github-dark' : 'github-light')); 9 | 10 | const codeToHtml = (code: MaybeRef, lang: MaybeRef) => { 11 | return computed(() => highlighter.codeToHtml(unref(code), { lang: unref(lang), theme: theme.value })); 12 | }; 13 | 14 | return { codeToHtml }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/composables/useCopyButton.ts: -------------------------------------------------------------------------------- 1 | import { useClipboard } from '@vueuse/core'; 2 | import { ref } from 'vue'; 3 | 4 | export const useCopyButton = () => { 5 | const copied = ref(false); 6 | const copiedTimeout = ref | null>(null); 7 | 8 | const { copy } = useClipboard(); 9 | 10 | const copyText = async (text: string) => { 11 | if (text) await copy(text); 12 | if (copiedTimeout.value) clearTimeout(copiedTimeout.value); 13 | 14 | copied.value = true; 15 | 16 | copiedTimeout.value = setTimeout(() => { 17 | copied.value = false; 18 | }, 1000); 19 | }; 20 | 21 | return { copied, copy: copyText }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/composables/useStimulusDetector.ts: -------------------------------------------------------------------------------- 1 | import { onBeforeMount, onBeforeUnmount, ref } from 'vue'; 2 | import { StimulusDevToolsMessage } from '@/types'; 3 | import { MessageEventName, MessageType } from '@/enum'; 4 | 5 | const stimulusDetected = ref(false); 6 | 7 | function checkIfHasStimulus() { 8 | chrome.devtools.inspectedWindow.eval('!!(window.__STIMULUS_DEVTOOLS_DETECTED__)', hasStimulus => { 9 | stimulusDetected.value = !!hasStimulus; 10 | }); 11 | } 12 | 13 | function onRuntimeMessage(message: StimulusDevToolsMessage) { 14 | if (message.type === MessageType.Event && message.name === MessageEventName.Undetected) { 15 | checkIfHasStimulus(); 16 | } 17 | } 18 | 19 | export const useStimulusDetector = () => { 20 | onBeforeMount(() => { 21 | if (!stimulusDetected.value) checkIfHasStimulus(); 22 | chrome.runtime.onMessage.addListener(onRuntimeMessage); 23 | }); 24 | 25 | onBeforeUnmount(() => { 26 | chrome.runtime.onMessage.removeListener(onRuntimeMessage); 27 | }); 28 | 29 | return { stimulusDetected, checkIfHasStimulus }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/detector/index.ts: -------------------------------------------------------------------------------- 1 | (function () { 2 | function detect() { 3 | const detector = { 4 | delay: 1000, 5 | retry: 10, 6 | }; 7 | 8 | function runDetect() { 9 | const stimulusDetected = !!window['Stimulus']; 10 | 11 | if (stimulusDetected) { 12 | window.postMessage( 13 | { 14 | type: 'stimulus-devtools:event', 15 | name: 'stimulus-devtools:detected', 16 | }, 17 | '*', 18 | ); 19 | window['__STIMULUS_DEVTOOLS_DETECTED__'] = true; 20 | return; 21 | } 22 | 23 | if (detector.retry < 8) { 24 | window.postMessage( 25 | { 26 | type: 'stimulus-devtools:event', 27 | name: 'stimulus-devtools:undetected', 28 | }, 29 | '*', 30 | ); 31 | if (window['__STIMULUS_DEVTOOLS_DETECTED__']) delete window['__STIMULUS_DEVTOOLS_DETECTED__']; 32 | } 33 | 34 | if (detector.retry > 0) { 35 | detector.retry--; 36 | setTimeout(() => { 37 | runDetect(); 38 | }, detector.delay); 39 | detector.delay *= 1.5; 40 | } 41 | } 42 | 43 | setTimeout(() => { 44 | runDetect(); 45 | }, 100); 46 | } 47 | 48 | if (document instanceof HTMLDocument) { 49 | document.addEventListener('DOMContentLoaded', detect); 50 | document.addEventListener('visibilitychange', () => { 51 | if (document.visibilityState === 'visible') detect(); 52 | }); 53 | } 54 | })(); 55 | -------------------------------------------------------------------------------- /src/devtools/DevTools.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 29 | -------------------------------------------------------------------------------- /src/devtools/background.ts: -------------------------------------------------------------------------------- 1 | chrome.devtools.panels.create('Stimulus', '', '../../devtools.html'); 2 | -------------------------------------------------------------------------------- /src/devtools/highlighter.ts: -------------------------------------------------------------------------------- 1 | import type { HighlighterCore } from 'shiki'; 2 | import { getHighlighterCore } from 'shiki/core'; 3 | import getWasm from 'shiki/wasm'; 4 | 5 | // Themes 6 | import themeGithubLight from 'shiki/themes/github-light.mjs'; 7 | import themeGithubDark from 'shiki/themes/github-dark.mjs'; 8 | 9 | // Langs 10 | import langCss from 'shiki/langs/css.mjs'; 11 | import langJavascript from 'shiki/langs/javascript.mjs'; 12 | 13 | export let highlighter: HighlighterCore; 14 | 15 | export async function initHighlighter() { 16 | highlighter = await getHighlighterCore({ 17 | themes: [themeGithubLight, themeGithubDark], 18 | langs: [langCss, langJavascript], 19 | loadWasm: getWasm, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/devtools/index.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import '@/devtools/style.css'; 3 | import DevTools from './DevTools.vue'; 4 | import { initHighlighter } from '@/devtools/highlighter.ts'; 5 | 6 | initHighlighter().then(() => { 7 | createApp(DevTools).mount('#app'); 8 | }); 9 | -------------------------------------------------------------------------------- /src/devtools/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root, 7 | :host { 8 | --background: 0 0% 100%; 9 | --foreground: 240 10% 3.9%; 10 | 11 | --muted: 240 4.8% 95.9%; 12 | --muted-foreground: 240 3.8% 46.1%; 13 | 14 | --popover: 0 0% 100%; 15 | --popover-foreground: 240 10% 3.9%; 16 | 17 | --card: 0 0% 100%; 18 | --card-foreground: 240 10% 3.9%; 19 | 20 | --border: 240 5.9% 90%; 21 | --input: 240 5.9% 90%; 22 | 23 | --stimulus: 155 71% 69%; 24 | --stimulus-foreground: 0 0% 0%; 25 | 26 | --primary: 240 5.9% 10%; 27 | --primary-foreground: 0 0% 98%; 28 | 29 | --secondary: 240 4.8% 95.9%; 30 | --secondary-foreground: 240 5.9% 10%; 31 | 32 | --accent: 240 4.8% 95.9%; 33 | --accent-foreground: 240 5.9% 10%; 34 | 35 | --warning: 38 92% 50%; 36 | --warning-foreground: 48 96% 89%; 37 | 38 | --destructive: 0 84.2% 60.2%; 39 | --destructive-foreground: 0 0% 98%; 40 | 41 | --code-blue: 212 100% 39%; 42 | --code-navy: 212 94% 20%; 43 | --code-orange: 24 92% 46%; 44 | --code-red: 354 66% 54%; 45 | --code-purple: 261 51% 51%; 46 | --code-green: 134 60% 33%; 47 | 48 | --ring: 240 10% 3.9%; 49 | 50 | --radius: 0.5rem; 51 | } 52 | 53 | .dark { 54 | --background: 240 10% 3.9%; 55 | --foreground: 0 0% 98%; 56 | 57 | --muted: 240 3.7% 15.9%; 58 | --muted-foreground: 240 5% 64.9%; 59 | 60 | --popover: 240 10% 3.9%; 61 | --popover-foreground: 0 0% 98%; 62 | 63 | --card: 240 10% 3.9%; 64 | --card-foreground: 0 0% 98%; 65 | 66 | --border: 240 3.7% 15.9%; 67 | --input: 240 3.7% 15.9%; 68 | 69 | --primary: 0 0% 98%; 70 | --primary-foreground: 240 5.9% 10%; 71 | 72 | --secondary: 240 3.7% 15.9%; 73 | --secondary-foreground: 0 0% 98%; 74 | 75 | --accent: 240 3.7% 15.9%; 76 | --accent-foreground: 0 0% 98%; 77 | 78 | --warning: 48 96% 89%; 79 | --warning-foreground: 38 92% 50%; 80 | 81 | --destructive: 0 62.8% 30.6%; 82 | --destructive-foreground: 0 0% 98%; 83 | 84 | --code-blue: 212 100% 39%; 85 | --code-navy: 212 100% 81%; 86 | --code-orange: 25 100% 72%; 87 | --code-red: 354 92% 72%; 88 | --code-purple: 261 76% 76%; 89 | --code-green: 135 68% 72%; 90 | 91 | --ring: 240 4.9% 83.9%; 92 | } 93 | } 94 | 95 | @layer base { 96 | * { 97 | @apply border-border; 98 | } 99 | body { 100 | @apply bg-background text-foreground; 101 | } 102 | } -------------------------------------------------------------------------------- /src/enum/index.ts: -------------------------------------------------------------------------------- 1 | const prefix = 'stimulus-devtools'; 2 | 3 | export enum MessageType { 4 | Event = `${prefix}:event`, 5 | Action = `${prefix}:action`, 6 | } 7 | 8 | export enum MessageEventName { 9 | Detected = `${prefix}:detected`, 10 | Undetected = `${prefix}:undetected`, 11 | 12 | ControllersUpdated = `${prefix}:controllers:updated`, 13 | InstanceValuesUpdated = `${prefix}:instance:values:updated`, 14 | InstanceTargetsUpdated = `${prefix}:instance:targets:updated`, 15 | InstanceOutletsUpdated = `${prefix}:instance:outlets:updated`, 16 | InstanceClassesUpdated = `${prefix}:instance:classes:updated`, 17 | } 18 | 19 | export enum Action { 20 | UpdateControllers = 'updateControllers', 21 | UpdateInstanceValues = 'updateInstanceValues', 22 | UpdateInstanceTargets = 'updateInstanceTargets', 23 | UpdateInstanceOutlets = 'updateInstanceOutlets', 24 | UpdateInstanceClasses = 'updateInstanceClasses', 25 | UpdateValue = 'updateValue', 26 | 27 | HighlightElements = 'highlightElements', 28 | StopHighlightElements = 'stopHighlightElements', 29 | } 30 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | import type { Application } from '@hotwired/stimulus'; 2 | 3 | declare global { 4 | interface Window { 5 | Stimulus?: Application; 6 | __STIMULUS_DEVTOOLS_DETECTED__?: boolean; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/manifest.ts: -------------------------------------------------------------------------------- 1 | import packageData from '../package.json'; 2 | 3 | const manifest: Record = { 4 | manifest_version: 3, 5 | name: packageData.displayName || packageData.name, 6 | version: packageData.version, 7 | description: packageData.description, 8 | icons: { 9 | '16': 'images/icon-16.png', 10 | '32': 'images/icon-32.png', 11 | '48': 'images/icon-48.png', 12 | '128': 'images/icon-128.png', 13 | }, 14 | action: { 15 | default_icons: { 16 | '16': 'images/icon-16.png', 17 | '32': 'images/icon-32.png', 18 | '48': 'images/icon-48.png', 19 | '128': 'images/icon-128.png', 20 | }, 21 | default_title: 'Stimulus DevTools', 22 | default_popup: 'popup.html', 23 | }, 24 | devtools_page: 'devtools-background.html', 25 | content_scripts: [ 26 | { 27 | matches: [''], 28 | js: ['assets/bridge.js'], 29 | run_at: 'document_start', 30 | }, 31 | { 32 | matches: [''], 33 | js: ['assets/detector.js'], 34 | run_at: 'document_start', 35 | world: 'MAIN', 36 | }, 37 | ], 38 | web_accessible_resources: [{ resources: ['assets/client.js'], matches: [''] }], 39 | content_security_policy: { 40 | extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';", 41 | }, 42 | host_permissions: [''], 43 | permissions: ['storage'], 44 | }; 45 | 46 | export default JSON.stringify(manifest); 47 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { MessageType } from '@/enum'; 2 | 3 | export type StimulusDevToolsMessage = { 4 | type: MessageType; 5 | name: string; 6 | args?: Record; 7 | data?: Record; 8 | }; 9 | -------------------------------------------------------------------------------- /src/types/stimulus.ts: -------------------------------------------------------------------------------- 1 | import type { Controller } from '@hotwired/stimulus'; 2 | import type { ValueDescriptor } from '@hotwired/stimulus/dist/types/core/value_properties'; 3 | 4 | export type StimulusControllerDefinition = { 5 | identifier: Controller['identifier']; 6 | instances: StimulusControllerInstance[]; 7 | isLazyController?: boolean; 8 | }; 9 | 10 | export type ParsedStimulusControllerDefinition = { 11 | identifier: Controller['identifier']; 12 | instances: ParsedStimulusControllerInstance[]; 13 | isLazyController?: boolean; 14 | }; 15 | 16 | export type StimulusControllerInstance = { 17 | uid: string; 18 | uidSelector: string; 19 | identifier: Controller['identifier']; 20 | element: Controller['element']; 21 | elementSelector: string; 22 | isLazyController?: boolean; 23 | }; 24 | 25 | export type ParsedStimulusControllerInstance = Pick< 26 | StimulusControllerInstance, 27 | 'uid' | 'uidSelector' | 'identifier' | 'elementSelector' | 'isLazyController' 28 | >; 29 | 30 | export interface StimulusControllerMember { 31 | name: string; 32 | htmlAttribute: string; 33 | jsSingular: string; 34 | jsPlural: string; 35 | jsExistential: string; 36 | } 37 | 38 | export interface StimulusControllerValue extends StimulusControllerMember, ValueDescriptor { 39 | currentValue: unknown; 40 | } 41 | 42 | export interface StimulusControllerTarget extends StimulusControllerMember { 43 | elements: StimulusControllerTargetElement[]; 44 | } 45 | 46 | export type StimulusControllerTargetElement = { 47 | uid: string; 48 | uidSelector: string; 49 | elementSelector: string; 50 | }; 51 | 52 | export interface StimulusControllerOutlet extends StimulusControllerMember { 53 | selector: string | null; 54 | references: StimulusControllerOutletReference[]; 55 | 56 | jsElementSingular: string; 57 | jsElementPlural: string; 58 | } 59 | 60 | export type StimulusControllerOutletReference = { 61 | uid: string; 62 | uidSelector: string; 63 | identifier: Controller['identifier']; 64 | }; 65 | 66 | export interface StimulusControllerClass extends StimulusControllerMember { 67 | classNames: string[]; 68 | } 69 | -------------------------------------------------------------------------------- /src/utils/client.ts: -------------------------------------------------------------------------------- 1 | import { StimulusControllerInstance } from '@/types/stimulus.ts'; 2 | import type { Controller } from '@hotwired/stimulus'; 3 | import { MessageType } from '@/enum'; 4 | 5 | export const sendEvent = (name: string, data?: Record) => { 6 | window.postMessage({ 7 | type: MessageType.Event, 8 | name, 9 | data, 10 | }); 11 | }; 12 | 13 | export const getControllerFromInstance = (instance: StimulusControllerInstance) => { 14 | if (!window.Stimulus) return null; 15 | if (!instance) return null; 16 | 17 | return window.Stimulus.getControllerForElementAndIdentifier(instance.element, instance.identifier); 18 | }; 19 | 20 | export const getControllerKeys = (controller: Controller) => { 21 | // Retrieve controller's prototype members 22 | const controllerPrototypeMembers = Object.getOwnPropertyDescriptors(Object.getPrototypeOf(controller)); 23 | return Object.keys(controllerPrototypeMembers); 24 | }; 25 | -------------------------------------------------------------------------------- /src/utils/dom.ts: -------------------------------------------------------------------------------- 1 | export const getElementSelectorString = (element: Element) => { 2 | if (!element) return ''; 3 | let str = element.localName; 4 | if (element.id) str += '#' + element.id; 5 | if (element.classList.length) { 6 | element.classList.forEach(c => { 7 | str += '.' + c; 8 | }); 9 | } 10 | return str; 11 | }; 12 | 13 | export const placeCursorAtEnd = (element: HTMLElement) => { 14 | if (!element.childNodes?.length) return; 15 | 16 | const range = document.createRange(); 17 | const sel = window.getSelection(); 18 | range.setStart(element.childNodes[0], element.innerText?.length); 19 | range.collapse(true); 20 | sel?.removeAllRanges(); 21 | sel?.addRange(range); 22 | }; 23 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export const inspectElement = (selector: string) => { 2 | chrome.devtools.inspectedWindow.eval(`inspect(document.querySelector('${selector}'))`); 3 | }; 4 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import animate from 'tailwindcss-animate'; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | darkMode: ['class'], 6 | safelist: ['dark'], 7 | 8 | content: [ 9 | './pages/**/*.{ts,tsx,vue}', 10 | './components/**/*.{ts,tsx,vue}', 11 | './app/**/*.{ts,tsx,vue}', 12 | './src/**/*.{ts,tsx,vue}', 13 | ], 14 | 15 | theme: { 16 | container: { 17 | center: true, 18 | padding: '2rem', 19 | screens: { 20 | '2xl': '1400px', 21 | }, 22 | }, 23 | extend: { 24 | colors: { 25 | border: 'hsl(var(--border))', 26 | input: 'hsl(var(--input))', 27 | ring: 'hsl(var(--ring))', 28 | background: 'hsl(var(--background))', 29 | foreground: 'hsl(var(--foreground))', 30 | stimulus: { 31 | DEFAULT: 'hsl(var(--stimulus))', 32 | foreground: 'hsl(var(--stimulus-foreground))', 33 | }, 34 | primary: { 35 | DEFAULT: 'hsl(var(--primary))', 36 | foreground: 'hsl(var(--primary-foreground))', 37 | }, 38 | secondary: { 39 | DEFAULT: 'hsl(var(--secondary))', 40 | foreground: 'hsl(var(--secondary-foreground))', 41 | }, 42 | warning: { 43 | DEFAULT: 'hsl(var(--warning))', 44 | foreground: 'hsl(var(--warning-foreground))', 45 | }, 46 | destructive: { 47 | DEFAULT: 'hsl(var(--destructive))', 48 | foreground: 'hsl(var(--destructive-foreground))', 49 | }, 50 | muted: { 51 | DEFAULT: 'hsl(var(--muted))', 52 | foreground: 'hsl(var(--muted-foreground))', 53 | }, 54 | accent: { 55 | DEFAULT: 'hsl(var(--accent))', 56 | foreground: 'hsl(var(--accent-foreground))', 57 | }, 58 | // Code 59 | 'code-blue': 'hsl(var(--code-blue))', 60 | 'code-blue-secondary': 'hsl(var(--code-blue-secondary))', 61 | 'code-navy': 'hsl(var(--code-navy))', 62 | 'code-orange': 'hsl(var(--code-orange))', 63 | 'code-red': 'hsl(var(--code-red))', 64 | 'code-purple': 'hsl(var(--code-purple))', 65 | 'code-green': 'hsl(var(--code-green))', 66 | popover: { 67 | DEFAULT: 'hsl(var(--popover))', 68 | foreground: 'hsl(var(--popover-foreground))', 69 | }, 70 | card: { 71 | DEFAULT: 'hsl(var(--card))', 72 | foreground: 'hsl(var(--card-foreground))', 73 | }, 74 | }, 75 | borderRadius: { 76 | xl: 'calc(var(--radius) + 4px)', 77 | lg: 'var(--radius)', 78 | md: 'calc(var(--radius) - 2px)', 79 | sm: 'calc(var(--radius) - 4px)', 80 | }, 81 | keyframes: { 82 | pin: { 83 | from: { transform: 'scale(0)' }, 84 | to: { transform: 'scale(1)' }, 85 | }, 86 | 'accordion-down': { 87 | from: { height: 0 }, 88 | to: { height: 'var(--radix-accordion-content-height)' }, 89 | }, 90 | 'accordion-up': { 91 | from: { height: 'var(--radix-accordion-content-height)' }, 92 | to: { height: 0 }, 93 | }, 94 | 'collapsible-down': { 95 | from: { height: 0 }, 96 | to: { height: 'var(--radix-collapsible-content-height)' }, 97 | }, 98 | 'collapsible-up': { 99 | from: { height: 'var(--radix-collapsible-content-height)' }, 100 | to: { height: 0 }, 101 | }, 102 | }, 103 | animation: { 104 | pin: 'pin 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)', 105 | 'accordion-down': 'accordion-down 0.2s ease-out', 106 | 'accordion-up': 'accordion-up 0.2s ease-out', 107 | 'collapsible-down': 'collapsible-down 0.2s ease-in-out', 108 | 'collapsible-up': 'collapsible-up 0.2s ease-in-out', 109 | }, 110 | }, 111 | }, 112 | plugins: [animate], 113 | }; 114 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2021", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | }, 12 | 13 | /* Bundler mode */ 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "preserve", 20 | 21 | /* Linting */ 22 | "strict": true, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "noFallthroughCasesInSwitch": true 26 | }, 27 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], 28 | "references": [{ "path": "./tsconfig.node.json" }] 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts", "vite.config.client.ts", "src/manifest.ts", "scripts/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.client.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { resolve } from 'node:path'; 3 | import { isDev } from './scripts/utils'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | resolve: { 8 | alias: { 9 | '@': resolve(__dirname, './src'), 10 | }, 11 | }, 12 | publicDir: false, 13 | build: { 14 | watch: isDev ? {} : undefined, 15 | outDir: 'dist', 16 | emptyOutDir: false, 17 | target: ['esnext'], 18 | lib: { 19 | entry: resolve(__dirname, 'src/client/index.ts'), 20 | fileName: 'client', 21 | name: 'StimulusDevToolsClient', 22 | formats: ['iife'], 23 | }, 24 | rollupOptions: { 25 | output: { 26 | entryFileNames: 'assets/client.js', 27 | extend: true, 28 | }, 29 | }, 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { resolve } from 'node:path'; 3 | import vue from '@vitejs/plugin-vue'; 4 | import tailwind from 'tailwindcss'; 5 | import autoprefixer from 'autoprefixer'; 6 | import manifest from './src/manifest'; 7 | import { isDev } from './scripts/utils'; 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | css: { 12 | postcss: { 13 | plugins: [tailwind(), autoprefixer()], 14 | }, 15 | }, 16 | resolve: { 17 | alias: { 18 | '@': resolve(__dirname, './src'), 19 | }, 20 | }, 21 | plugins: [ 22 | vue(), 23 | { 24 | name: 'manifest', 25 | generateBundle() { 26 | this.emitFile({ 27 | type: 'asset', 28 | fileName: 'manifest.json', 29 | source: manifest, 30 | }); 31 | }, 32 | }, 33 | ], 34 | build: { 35 | watch: isDev ? {} : undefined, 36 | outDir: 'dist', 37 | emptyOutDir: false, 38 | chunkSizeWarningLimit: 2 * 1000, 39 | rollupOptions: { 40 | input: { 41 | popup: 'popup.html', 42 | devtools: 'devtools.html', 43 | 'devtools-background': 'devtools-background.html', 44 | bridge: 'src/bridge/index.ts', 45 | detector: 'src/detector/index.ts', 46 | }, 47 | output: { 48 | entryFileNames: 'assets/[name].js', 49 | }, 50 | }, 51 | }, 52 | }); 53 | --------------------------------------------------------------------------------