├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── extension ├── .DS_Store ├── devtools │ ├── 32709ce1000d77ae3c14fa968595e573.png │ ├── background.js │ ├── index.html │ ├── index.js │ ├── listener.js │ ├── panel.html │ └── public │ │ └── images │ │ ├── icon128.png │ │ ├── icon16.png │ │ └── svelte_slicer_logo_64x64.png └── manifest.json ├── package-lock.json ├── package.json ├── src ├── App.svelte ├── CollapsibleSection.svelte ├── Component.svelte ├── Diffs.svelte ├── FileStructure.svelte ├── StateChart.svelte ├── StateTree.svelte ├── Variable.svelte ├── global.css ├── main.js └── stores.js └── webpack.config.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | ecmaVersion: 2019, 4 | sourceType: 'module' 5 | }, 6 | env: { 7 | es6: true, 8 | browser: true 9 | }, 10 | plugins: [ 11 | 'svelte3' 12 | ], 13 | overrides: [ 14 | { 15 | files: ['*.svelte'], 16 | processor: 'svelte3/svelte3' 17 | } 18 | ], 19 | rules: { 20 | // ... 21 | }, 22 | settings: { 23 | // ... 24 | } 25 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | extension/devtools/build/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SvelteSlicer 2 | 3 | ![Svelte Slicer logo](extension/devtools/public/images/svelte_slicer_logo_64x64.png) 4 | 5 | # About The Project 6 | Svelte Slicer is an open-source Chrome Developer Tool for visualizing component and state changes in Svelte applications. Svelte Slicer allows users to capture, store and traverse detailed snapshots of application state to aid in debugging. 7 | 8 | # Key features include: 9 | - Visualization of component relationships 10 | - Moment-by-moment tracking of state variables 11 | - Snapshot diffing to identify specific state changes 12 | - Dynamic time travel through past state snapshots 13 | 14 | # Built With 15 | - [Svelte](https://svelte.dev/) 16 | - [D3.js](https://d3js.org/) 17 | - [Chrome Extension API’s](https://developer.chrome.com/docs/extensions/reference/) 18 | - [Webpack](https://webpack.js.org/) 19 | 20 | # Getting Started 21 | - Install Svelte Slicer from the Chrome Web Store 22 | - Run your Svelte application in development mode. 23 | - Open Chrome Developer Tools (Cmd + Option + I) & navigate to the “Slicer” panel 24 | 25 | # Using Svelte Slicer 26 | After opening the tool, you will see two panels. With each DOM update in your application, the panel on the left will populate a new snapshot with a *Data* and a *Jump* button. Clicking on the Data button for a particular snapshot will display in the right hand panel data about the application’s state at the moment the selected snapshot was captured. 27 | 28 | Snapshots that result from a specific user interaction are labeled with the component, event and event handler that triggered its state changes. Users can also use the *Filter* feature to identify specific snapshots based on their labels. 29 | 30 | Each snapshot can be explored in several ways. While in the *State* view, selecting the *Tree* button will display a list of all components with stateful variables that were part of the DOM when the snapshot was captured. Clicking on the name of a component will show it’s stateful variables and their values at the moment of snapshot capture. Clicking the Chart button will display a graphical visualization of the component relationships on the DOM for the selected snapshot. Clicking the *Diff* button will present a list of the specific components and variables that changed from the previous snapshot. 31 | 32 | The *Component* view displays the relationship between user-defined components in the file structure of the application. The *Tree*button displays this information as a collapsible tree, while the *Chart* button shows a hierarchical graphical representation. 33 | 34 | Using the *Jump* buttons allows the user to not only see the data for a chosen snapshot, but also to actually re-render their application as it was at the moment the snapshot was captured. After jumping, the user can choose to start a new timeline by continuing to interact with their application. This will result in new snapshots that build off application state at the last jump. 35 | 36 | Snapshots outside the timeline of the currently rendered snapshot are retained and can still be viewed and jumped to, but are washed out in the snapshot panel to indicate that they are not related to the current snapshot. Using the *Path* button will clear out these washed out snapshots, leaving only the current timeline in the panel. 37 | 38 | The *Previous* and *Forward* buttons also clear out unwanted snapshots, removing all snapshots respectively before or after the currently rendered one. 39 | 40 | # Contributing 41 | Found a bug or have suggestions for improvement? We would love to hear from you! 42 | 43 | Please open an issue to submit feedback or problems you come across. 44 | 45 | # Authors 46 | - Heather Barney - [LinkedIn](https://www.linkedin.com/in/heather-barney-81ab2834/) 47 | - Rachel Collins - [LinkedIn](https://www.linkedin.com/in/rachel-c-bb5b0346/) 48 | - Lynda Labranche - [LinkedIn](https://www.linkedin.com/in/lynda-labranche-854184146/) 49 | - Anchi Teng - [LinkedIn](https://www.linkedin.com/in/anchiteng/) 50 | 51 | # License 52 | This project is licensed under the MIT [License](https://github.com/oslabs-beta/svelte-sight/blob/master/LICENSE) - see the LICENSE file for details 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /extension/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/SvelteSlicer/fe8bcd53e83551cc27667ab7a3fa06b4610d4e0e/extension/.DS_Store -------------------------------------------------------------------------------- /extension/devtools/32709ce1000d77ae3c14fa968595e573.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/SvelteSlicer/fe8bcd53e83551cc27667ab7a3fa06b4610d4e0e/extension/devtools/32709ce1000d77ae3c14fa968595e573.png -------------------------------------------------------------------------------- /extension/devtools/background.js: -------------------------------------------------------------------------------- 1 | // background.js 2 | var connections = {}; 3 | 4 | chrome.runtime.onConnect.addListener(function (port) { 5 | 6 | var extensionListener = function (message, sender, sendResponse) { 7 | 8 | // The original connection event doesn't include the tab ID of the 9 | // DevTools page, so we need to send it explicitly. 10 | if (message.name == "init") { 11 | connections[message.tabId] = port; 12 | return; 13 | } 14 | 15 | if (message.name === "jumpState" || message.name === "clearSnapshots") { 16 | chrome.tabs.sendMessage(message.tabId, message); 17 | } 18 | } 19 | 20 | // Listen to messages sent from the DevTools page 21 | port.onMessage.addListener(extensionListener); 22 | 23 | port.onDisconnect.addListener(function(port) { 24 | port.onMessage.removeListener(extensionListener); 25 | 26 | var tabs = Object.keys(connections); 27 | for (var i=0, len=tabs.length; i < len; i++) { 28 | if (connections[tabs[i]] == port) { 29 | delete connections[tabs[i]] 30 | break; 31 | } 32 | } 33 | }); 34 | }); 35 | 36 | // Receive message from content script and relay to the devTools page for the 37 | // current tab 38 | chrome.runtime.onMessage.addListener(function(message, sender) { 39 | // Messages from content scripts should have sender.tab set 40 | if (sender.tab) { 41 | var tabId = sender.tab.id; 42 | if (tabId in connections) { 43 | connections[tabId].postMessage(message); 44 | } else { 45 | console.log("Tab not found in connection list."); 46 | } 47 | } else { 48 | console.log("sender.tab not defined."); 49 | } 50 | return true; 51 | }); -------------------------------------------------------------------------------- /extension/devtools/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /extension/devtools/index.js: -------------------------------------------------------------------------------- 1 | chrome.devtools.panels.create( 2 | "Slicer", 3 | "svelte_logo.png", 4 | "devtools/panel.html", 5 | function (panel) { 6 | panel.onShown.addListener(() => { 7 | chrome.devtools.inspectedWindow.reload( 8 | {injectedScript: 9 | ` 10 | const components = []; 11 | const deletedNodes = []; 12 | const insertedNodes = []; 13 | const listeners = {}; 14 | const nodes = new Map(); 15 | const componentCounts = {}; 16 | const componentObject = {}; 17 | let node_id = 0; 18 | let firstLoadSent = false; 19 | let stateHistory = []; 20 | const storeVariables = {}; 21 | let rebuildingDom = false; 22 | let snapshotLabel = "Init"; 23 | let jumpIndex; 24 | 25 | function setup(root) { 26 | root.addEventListener('SvelteRegisterComponent', svelteRegisterComponent); 27 | root.addEventListener('SvelteDOMInsert', svelteDOMInsert); 28 | root.addEventListener('SvelteDOMRemove', svelteDOMRemove); 29 | root.addEventListener('SvelteDOMAddEventListener', svelteDOMAddEventListener); 30 | } 31 | 32 | function svelteRegisterComponent (e) { 33 | const { component, tagName, options } = e.detail; 34 | // assign sequential instance value 35 | let instance = 0; 36 | if (componentCounts.hasOwnProperty(tagName)) { 37 | instance = ++componentCounts[tagName]; 38 | } 39 | componentCounts[tagName] = instance; 40 | const id = tagName + instance; 41 | 42 | componentObject[id] = {component, tagName}; 43 | 44 | data = { 45 | id, 46 | state: captureComponentState(component), 47 | tagName, 48 | instance, 49 | target: (options.target) ? options.target.nodeName + options.target.id : null 50 | } 51 | components.push(data); 52 | } 53 | 54 | function parseState(element, name = null) { 55 | if (element === null) { 56 | return { 57 | value: element, 58 | name 59 | }; 60 | } 61 | else if (typeof element === "function") { 62 | return { 63 | name, 64 | value: element.toString() 65 | }; 66 | } 67 | else if (typeof element === "object") { 68 | if (element.constructor) { 69 | if (element.constructor.name === "Object" || element.constructor.name === "Array") { 70 | const value = {}; 71 | for (let i in element) { 72 | value[i] = parseState(element[i], i); 73 | } 74 | return {value, name}; 75 | } 76 | else { 77 | return { 78 | name, 79 | value: '<' + element.constructor.name + '>' 80 | } 81 | } 82 | } 83 | else { 84 | return { 85 | name, 86 | value: "Unknown Object" 87 | } 88 | } 89 | } 90 | else { 91 | return { 92 | value: element, 93 | name 94 | }; 95 | } 96 | } 97 | 98 | function captureComponentState(component) { 99 | const captureStateFunc = component.$capture_state; 100 | let state = captureStateFunc ? captureStateFunc() : {}; 101 | // if capture_state produces an empty object, may need to use ctx instead (older version of Svelte) 102 | if (state && !Object.keys(state).length) { 103 | if (component.$$.ctx.constructor.name === "Object") { 104 | state = deepClone(component.$$.ctx); 105 | } 106 | } 107 | 108 | const parsedState = {}; 109 | for (let variable in state) { 110 | if (typeof state[variable] === "function") { 111 | delete state[variable]; 112 | } 113 | else if (state[variable] === null) { 114 | parsedState[variable] = parseState(state[variable], variable); 115 | } 116 | else if (typeof state[variable] === "object") { 117 | if (state[variable].constructor) { 118 | if (state[variable].constructor.name === "Object" || state[variable].constructor.name === "Array") { 119 | // check if variable is a store variable 120 | if (state[variable].hasOwnProperty('subscribe')) { 121 | // if a writable store, we need to store the instance 122 | if (state[variable].hasOwnProperty('set') && state[variable].hasOwnProperty('update')) { 123 | storeVariables[variable] = state[variable]; 124 | } 125 | delete state[variable]; 126 | } 127 | else { 128 | parsedState[variable] = parseState(state[variable], variable); 129 | } 130 | } 131 | else { 132 | parsedState[variable] = parseState(state[variable], variable) 133 | } 134 | } 135 | else { 136 | delete state[variable]; 137 | } 138 | } 139 | else { 140 | parsedState[variable] = parseState(state[variable], variable); 141 | } 142 | } 143 | return parsedState; 144 | } 145 | 146 | function svelteDOMRemove(e) { 147 | 148 | const { node } = e.detail; 149 | const nodeData = nodes.get(node); 150 | if (nodeData) { 151 | deletedNodes.push({ 152 | id: nodeData.id, 153 | component: nodeData.component 154 | }) 155 | } 156 | } 157 | 158 | function svelteDOMInsert(e) { 159 | 160 | const { node, target } = e.detail; 161 | if (node.__svelte_meta) { 162 | let id = nodes.get(node); 163 | if (!id) { 164 | id = node_id++; 165 | componentName = getComponentName(node.__svelte_meta.loc.file) 166 | nodes.set(node, {id, componentName}); 167 | } 168 | insertedNodes.push({ 169 | target: ((nodes.get(target)) ? nodes.get(target).id : target.nodeName + target.id), 170 | id, 171 | component: componentName, 172 | loc: node.__svelte_meta.loc.char 173 | }); 174 | } 175 | } 176 | 177 | function svelteDOMAddEventListener(e) { 178 | const { node, event } = e.detail; 179 | if (node.__svelte_meta) { 180 | if (!nodes.has(node)) { 181 | const nodeId = node_id++; 182 | const componentName = getComponentName(node.__svelte_meta.loc.file) 183 | nodes.set(node, {nodeId, componentName}); 184 | } 185 | const nodeData = nodes.get(node); 186 | const listenerId = nodeData.id + event; 187 | node.addEventListener(event, () => updateLabel(nodeData.id, event)); 188 | 189 | listeners[listenerId] = ({ 190 | node: nodeData.id, 191 | event, 192 | handlerName: e.detail.handler.name, 193 | component: nodeData.componentName, 194 | }) 195 | } 196 | } 197 | 198 | function getComponentName(file) { 199 | if (file.indexOf('/') === -1) { 200 | tagName = file.slice((file.lastIndexOf('\\\\') + 1), -7); 201 | } 202 | else { 203 | tagName = file.slice((file.lastIndexOf('/') + 1), -7); 204 | } 205 | return tagName; 206 | } 207 | 208 | const deepClone = (inObject) => { 209 | let outObject, value, key 210 | 211 | if (typeof inObject !== "object" || inObject === null) { 212 | return inObject // Return the value if inObject is not an object 213 | } 214 | 215 | if (inObject.constructor.name !== "Object" && inObject.constructor.name !== "Array") { 216 | return inObject // Return the value if inObject is not an object 217 | } 218 | 219 | // Create an array or object to hold the values 220 | outObject = Array.isArray(inObject) ? [] : {} 221 | 222 | for (key in inObject) { 223 | value = inObject[key] 224 | 225 | // Recursively (deep) copy for nested objects, including arrays 226 | outObject[key] = deepClone(value) 227 | } 228 | 229 | return outObject 230 | } 231 | 232 | function updateLabel(nodeId, event) { 233 | const listener = listeners[nodeId + event]; 234 | const { component, handlerName } = listener; 235 | snapshotLabel = component + ' - ' + event + " -> " + handlerName; 236 | rebuildingDom = false; 237 | } 238 | 239 | function rebuildDom(tree) { 240 | rebuildingDom = true; 241 | 242 | tree.forEach(componentFile => { 243 | for (let componentInstance in componentObject) { 244 | if (componentObject[componentInstance].tagName === componentFile) { 245 | if (stateHistory[jumpIndex].hasOwnProperty(componentInstance)) { 246 | const variables = stateHistory[jumpIndex][componentInstance]; 247 | for (let variable in variables) { 248 | if (variable[0] === '$') { 249 | updateStore(componentInstance, variable, variables[variable]); 250 | } 251 | else { 252 | injectState(componentInstance, variable, variables[variable]); 253 | } 254 | } 255 | } 256 | } 257 | } 258 | }) 259 | } 260 | 261 | function injectState(componentId, key, value) { 262 | const component = componentObject[componentId].component; 263 | component.$inject_state({ [key]: value }) 264 | } 265 | 266 | function updateStore(componentId, name, value) { 267 | const component = componentObject[componentId].component; 268 | const store = storeVariables[name.slice(1)]; 269 | store.set(value); 270 | } 271 | 272 | function clearSnapshots(index, path, clearType) { 273 | if (clearType === 'forward') { 274 | stateHistory = stateHistory.slice(0, index + 1); 275 | } 276 | else if (clearType === 'previous') { 277 | stateHistory = stateHistory.slice(index); 278 | } 279 | else if (clearType === 'path') { 280 | for (let i = stateHistory.length -1; i > 0 ; i--){ 281 | if (!path.includes(i)){ 282 | stateHistory.splice(i,1); 283 | } 284 | } 285 | } 286 | } 287 | 288 | setup(window.document); 289 | 290 | for (let i = 0; i < window.frames.length; i++) { 291 | const frame = window.frames[i] 292 | const root = frame.document 293 | setup(root) 294 | } 295 | 296 | // observe for changes to the DOM 297 | const observer = new MutationObserver(() => { 298 | if (!rebuildingDom){ 299 | const domChange = new CustomEvent('dom-changed'); 300 | window.document.dispatchEvent(domChange) 301 | } 302 | else { 303 | const rebuild = new CustomEvent('rebuild'); 304 | window.document.dispatchEvent(rebuild); 305 | } 306 | }); 307 | 308 | // capture initial DOM load as one snapshot 309 | window.onload = () => { 310 | // make sure that data is being sent 311 | if (components.length || insertedNodes.length || deletedNodes.length) { 312 | stateHistory.push(deepClone(captureRawAppState())); 313 | firstLoadSent = true; 314 | 315 | window.postMessage({ 316 | source: 'panel.js', 317 | type: 'firstLoad', 318 | data: { 319 | stateObject: captureParsedAppState(), 320 | components, 321 | insertedNodes, 322 | deletedNodes, 323 | snapshotLabel 324 | } 325 | }) 326 | 327 | // reset arrays 328 | components.splice(0, components.length); 329 | insertedNodes.splice(0, insertedNodes.length); 330 | deletedNodes.splice(0, deletedNodes.length); 331 | snapshotLabel = undefined; 332 | } 333 | 334 | // start MutationObserver 335 | observer.observe(window.document, {attributes: true, childList: true, subtree: true}); 336 | } 337 | 338 | function captureRawAppState() { 339 | const appState = {}; 340 | for (let component in componentObject) { 341 | const captureStateFunc = componentObject[component].component.$capture_state; 342 | let state = captureStateFunc ? captureStateFunc() : {}; 343 | // if state object is empty, may need to use ctx instead (older version of Svelte) 344 | if (state && !Object.keys(state).length) { 345 | if (componentObject[component].component.$$.ctx.constructor.name === "Object") { 346 | state = componentObject[component].component.$$.ctx; 347 | } 348 | } 349 | appState[component] = state; 350 | } 351 | return appState; 352 | } 353 | 354 | function captureParsedAppState() { 355 | const appState = {}; 356 | for (let component in componentObject) { 357 | appState[component] = captureComponentState(componentObject[component].component); 358 | } 359 | return appState; 360 | } 361 | 362 | // capture subsequent DOM changes to update snapshots 363 | window.document.addEventListener('dom-changed', (e) => { 364 | // only send message if something changed in SvelteDOM or stateObject 365 | if (components.length || insertedNodes.length || deletedNodes.length) { 366 | // check for deleted components 367 | for (let component in componentObject) { 368 | if (componentObject[component].component.$$.fragment === null) { 369 | delete componentObject[component]; 370 | } 371 | } 372 | const currentState = captureRawAppState(); 373 | stateHistory.push(deepClone(currentState)); 374 | let type; 375 | // make sure the first load has already been sent; if not, this is the first load 376 | if (!firstLoadSent) { 377 | type = "firstLoad"; 378 | firstLoadSent = true; 379 | } 380 | else type = "update"; 381 | 382 | window.postMessage({ 383 | source: 'panel.js', 384 | type, 385 | data: { 386 | stateObject: captureParsedAppState(), 387 | components, 388 | insertedNodes, 389 | deletedNodes, 390 | snapshotLabel 391 | } 392 | }); 393 | 394 | // reset arrays 395 | components.splice(0, components.length); 396 | insertedNodes.splice(0, insertedNodes.length); 397 | deletedNodes.splice(0, deletedNodes.length); 398 | snapshotLabel = undefined; 399 | } 400 | }); 401 | 402 | // clean up after jumps 403 | window.document.addEventListener('rebuild', (e) => { 404 | deletedComponents = []; 405 | for (let component in componentObject) { 406 | if (componentObject[component].component.$$.fragment === null) { 407 | delete componentObject[component]; 408 | deletedComponents.push(component); 409 | } 410 | } 411 | 412 | components.forEach(newComponent => { 413 | const { tagName, id } = newComponent; 414 | const component = componentObject[id].component; 415 | const captureStateFunc = component.$capture_state; 416 | let componentState = captureStateFunc ? captureStateFunc() : {}; 417 | if (componentState && !Object.keys(componentState).length) { 418 | if (component.$$.ctx.constructor.name === "Object") { 419 | componentState = deepClone(component.$$.ctx); 420 | } 421 | } 422 | 423 | const previousState = stateHistory[jumpIndex]; 424 | for (let componentId in previousState) { 425 | if (JSON.stringify(previousState[componentId]) === JSON.stringify(componentState) && !componentObject.hasOwnProperty(componentId)) { 426 | componentObject[componentId] = { 427 | component, 428 | tagName 429 | } 430 | newComponent.id = componentId; 431 | delete componentObject[id]; 432 | componentCounts[tagName]--; 433 | } 434 | } 435 | }) 436 | 437 | window.postMessage({ 438 | source: 'panel.js', 439 | type: 'rebuild', 440 | data: { 441 | stateObject: captureParsedAppState(), 442 | components, 443 | insertedNodes, 444 | deletedNodes, 445 | deletedComponents, 446 | snapshotLabel 447 | } 448 | }); 449 | 450 | components.splice(0, components.length); 451 | insertedNodes.splice(0, insertedNodes.length); 452 | deletedNodes.splice(0, deletedNodes.length); 453 | snapshotLabel = undefined; 454 | jumpIndex = undefined; 455 | }) 456 | 457 | // listen for devTool messages 458 | window.addEventListener('message', function () { 459 | // Only accept messages from the same frame 460 | if (event.source !== window) { 461 | return; 462 | } 463 | 464 | // Only accept messages that we know are ours 465 | if (typeof event.data !== 'object' || event.data === null || 466 | !event.data.source === 'panel.js') { 467 | return; 468 | } 469 | 470 | if (event.data.type === 'jumpState') { 471 | const { index, tree} = event.data; 472 | jumpIndex = index; 473 | rebuildDom(tree); 474 | } 475 | 476 | if (event.data.type === 'clearSnapshots') { 477 | const { index, path, clearType } = event.data; 478 | clearSnapshots(index, path, clearType); 479 | } 480 | }) 481 | ` 482 | } 483 | ); 484 | }) 485 | } 486 | ) -------------------------------------------------------------------------------- /extension/devtools/listener.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('message', function(event) { 2 | // Only accept messages from the same frame 3 | if (event.source !== window) { 4 | return; 5 | } 6 | 7 | // Only accept messages that we know are ours 8 | if (typeof event.data !== 'object' || event.data === null || 9 | !event.data.source === 'panel.js') { 10 | return; 11 | } 12 | 13 | const data = event.data; 14 | 15 | chrome.runtime.sendMessage(JSON.stringify(data)); 16 | }); 17 | 18 | chrome.runtime.onMessage.addListener(message => { 19 | if (message.name === "jumpState") { 20 | window.postMessage({ 21 | source: 'listener.js', 22 | type: 'jumpState', 23 | index: message.index, 24 | state: message.state, 25 | tree: message.tree 26 | }); 27 | } 28 | 29 | else if (message.name === "clearSnapshots") { 30 | window.postMessage({ 31 | source: 'listeneter.js', 32 | type: 'clearSnapshots', 33 | index: message.index, 34 | clearType: message.clearType, 35 | path: message.path 36 | }) 37 | } 38 | }); 39 | 40 | -------------------------------------------------------------------------------- /extension/devtools/panel.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Analysis 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /extension/devtools/public/images/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/SvelteSlicer/fe8bcd53e83551cc27667ab7a3fa06b4610d4e0e/extension/devtools/public/images/icon128.png -------------------------------------------------------------------------------- /extension/devtools/public/images/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/SvelteSlicer/fe8bcd53e83551cc27667ab7a3fa06b4610d4e0e/extension/devtools/public/images/icon16.png -------------------------------------------------------------------------------- /extension/devtools/public/images/svelte_slicer_logo_64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/SvelteSlicer/fe8bcd53e83551cc27667ab7a3fa06b4610d4e0e/extension/devtools/public/images/svelte_slicer_logo_64x64.png -------------------------------------------------------------------------------- /extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Svelte Slicer", 4 | "version": "1.0", 5 | "minimum_chrome_version": "10.0", 6 | "description": "Browser devtools extension for time traveling and visualizing Svelte applications.", 7 | 8 | "devtools_page": "devtools/index.html", 9 | "permissions": [ 10 | "activeTab", 11 | "scripting" 12 | ], 13 | "host_permissions": [ 14 | "http://*/*", 15 | "https://*/*" 16 | ], 17 | "background": { 18 | "service_worker": "devtools/background.js" 19 | }, 20 | "content_scripts": [ 21 | { 22 | "matches": [ 23 | "http://*/*", 24 | "https://*/*" 25 | ], 26 | "js": ["devtools/listener.js"], 27 | "run_at": "document_start" 28 | } 29 | ], 30 | "action": { 31 | "default_icon": { 32 | "16": "devtools/public/images/icon16.png", 33 | "128": "devtools/public/images/icon128.png" 34 | } 35 | }, 36 | "icons": { 37 | "16": "devtools/public/images/icon16.png", 38 | "128": "devtools/public/images/icon128.png" 39 | } 40 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-app", 3 | "version": "1.0.0", 4 | "devDependencies": { 5 | "cross-env": "^7.0.3", 6 | "css-loader": "^5.0.1", 7 | "eslint": "^8.8.0", 8 | "eslint-plugin-import": "^2.25.4", 9 | "eslint-plugin-svelte3": "^3.4.0", 10 | "file-loader": "^6.2.0", 11 | "mini-css-extract-plugin": "^1.3.4", 12 | "prettier": "^2.5.1", 13 | "prettier-plugin-svelte": "^2.6.0", 14 | "svelte": "^3.31.2", 15 | "svelte-loader": "^3.0.0", 16 | "webpack": "^5.16.0", 17 | "webpack-cli": "^4.4.0", 18 | "webpack-dev-server": "^3.11.2" 19 | }, 20 | "scripts": { 21 | "build": "cross-env NODE_ENV=production webpack", 22 | "dev": "webpack serve --content-base public", 23 | "format": "prettier --write '{public,src}/**/*.{css,html,js,svelte}'", 24 | "lint": "eslint . --ext .js,.svelte --fix" 25 | }, 26 | "dependencies": { 27 | "d3": "^7.2.1", 28 | "lodash": "^4.17.21" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/App.svelte: -------------------------------------------------------------------------------- 1 | 184 | 185 |
186 |
187 |
188 | logo 189 |

Svelte Slicer

190 |
191 |
192 |
filterEventHandler(e)} 194 | class="form" 195 | > 196 | 203 | 206 | 207 | 208 |
209 |
210 | {#if !filtered.length} 211 | {#each $snapshots as snapshot, i} 212 |
217 | Snapshot {i} 219 | {snapshot.label ? " : " + snapshot.label : ""} 221 |
222 | 227 | 230 |
231 |
232 | {/each} 233 | {:else if filtered.length} 234 | {#each filtered as snapshot} 235 |
242 | Snapshot {snapshot.index} 244 | {snapshot.snapshot.label 245 | ? " : " + snapshot.snapshot.label 246 | : ""} 248 |
249 | 254 | 258 |
259 |
260 | {/each} 261 | {/if} 262 |
263 | 273 |
274 | {#if View === "files"} 275 | 279 | 283 | {:else if View === "state"} 284 | 288 | 292 | 296 | {/if} 297 |
298 |
299 | {#if $snapshots.length} 300 | {#if View === "files" && Vis === "tree"} 301 | {#if Object.keys($fileTree).length} 302 | 303 | {:else} 304 |

File structure data unavailable

305 | {/if} 306 | {:else if View === "files" && Vis === "chart"} 307 | {#if Object.keys($fileTree).length} 308 | 309 | {:else} 310 |

File structure data unavailable

311 | {/if} 312 | {:else if View === "state"} 313 | {#if Vis === "tree"} 314 | 315 | {:else if Vis === "chart"} 316 | 317 | {:else if Vis === "diff"} 318 | 319 | {/if} 320 | {/if} 321 | {/if} 322 |
323 |
324 |
Clear Snapshots
325 |
326 |
327 | 331 | Remove all Snapshots prior to current view 334 |
335 |
336 | 339 | Remove Snapshots outside current view's timeline 342 |
343 |
344 | 347 | Remove all Snapshots after the current view 350 |
351 |
352 |
353 |
354 |
355 | 356 | 622 | -------------------------------------------------------------------------------- /src/CollapsibleSection.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |

9 | 24 |

25 | 26 | 29 |
30 | 31 | 56 | -------------------------------------------------------------------------------- /src/Component.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 |
17 |
18 |
    19 |
  • 20 | {#if children.length} 21 | 22 | {id} 24 | {#if expanded} 25 | {#each children as child} 26 | 27 | {/each} 28 | {/if} 29 | {:else} 30 | {id} 31 | {/if} 32 |
  • 33 |
34 |
35 |
36 | 37 | 63 | -------------------------------------------------------------------------------- /src/Diffs.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 |
12 |

Snapshot {I}: {snapshot.label}

13 | {#if newComponents.length} 14 |

New Components:

15 | {#each newComponents as component} 16 |

{component.component}

17 | {/each} 18 | {/if} 19 | {#if deletedComponents.length} 20 |

Deleted Components:

21 | {#each deletedComponents as component} 22 |

{component.component}

23 | {/each} 24 | {/if} 25 | {#if Object.keys(changedVariables).length} 26 |

Changed Variables:

27 | {#each Object.entries(changedVariables) as [componentName, component]} 28 |

{componentName}

29 |
    30 | {#each Object.entries(component) as [variableName, variable]} 31 | {#if variable.oldValue !== "" && variable.newValue !== ""} 32 |
  • 33 | {variableName}: 34 | {variable.oldValue} 35 | → {variable.newValue} 36 |
  • 37 | {:else if variable.oldValue === ""} 38 |
  • 39 | {variableName}: ' ' 40 | → {variable.newValue} 41 |
  • 42 | {:else if variable.newValue === ""} 43 |
  • 44 | {variableName}: 45 | {variable.oldValue} 46 | → ' ' 47 |
  • 48 | {/if} 49 | {/each} 50 |
51 | {/each} 52 | {/if} 53 |
54 |
55 | 56 | 111 | -------------------------------------------------------------------------------- /src/FileStructure.svelte: -------------------------------------------------------------------------------- 1 | 169 | 170 |
171 | -------------------------------------------------------------------------------- /src/StateChart.svelte: -------------------------------------------------------------------------------- 1 | 248 | 249 |
250 |

Snapshot {I}: {label}

251 | 252 |
253 |
254 | -------------------------------------------------------------------------------- /src/StateTree.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 | {#if data} 17 |

Snapshot {I}: {label}

18 | {#each componentList as componentName} 19 | {#if Object.keys(data[componentName].variables).length} 20 | 21 |
22 | {#each Object.keys(data[componentName].variables) as variable} 23 | {#if data[componentName].variables[variable].value !== undefined && data[componentName].variables[variable].value !== null} 24 | 25 | {/if} 26 | {/each} 27 |
28 |
29 | {/if} 30 | {/each} 31 | {/if} 32 |
33 | 34 | 36 | -------------------------------------------------------------------------------- /src/Variable.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | {#if typeof variable.value !== "object" || variable.value === null} 8 | {#if variable.value === ""} 9 |

{variable.name}: ' '

10 | {:else} 11 |

{variable.name}: {variable.value}

12 | {/if} 13 | {:else} 14 |
15 |

{variable.name}:

16 |
    17 | {#each Object.keys(variable.value) as nestedValue} 18 |
  • 19 | 20 |
  • 21 | {/each} 22 |
23 |
24 | {/if} 25 |
26 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Overpass:wght@100&display=swap"); 2 | 3 | body { 4 | margin: 0; 5 | background: rgb(83, 81, 81); 6 | margin: 0; 7 | height: 100%; 8 | font-size: 16px; 9 | color: whitesmoke; 10 | font-family: "Overpass", sans-serif; 11 | } 12 | 13 | h2 { 14 | font-weight: 700; 15 | font-size: 19px; 16 | margin: 2px; 17 | padding: 10px; 18 | } 19 | 20 | div { 21 | padding: 1em; 22 | } 23 | 24 | .panelDiv { 25 | /* border-top: 1px solid whitesmoke; */ 26 | text-align: center; 27 | } 28 | 29 | a { 30 | color: rgb(0, 100, 200); 31 | text-decoration: none; 32 | } 33 | 34 | ol li::marker { 35 | content: "▼"; 36 | } 37 | 38 | a:hover { 39 | text-decoration: underline; 40 | } 41 | 42 | a:visited { 43 | color: rgb(0, 80, 160); 44 | } 45 | 46 | label { 47 | display: block; 48 | } 49 | 50 | input, 51 | select, 52 | textarea { 53 | color: whitesmoke; 54 | background: #333; 55 | outline: none; 56 | margin: 2px; 57 | font-size: 16px; 58 | font-family: "Overpass", sans-serif; 59 | padding: 0.4em; 60 | border: none; 61 | border-radius: 2px; 62 | } 63 | 64 | button { 65 | color: whitesmoke; 66 | background: #333; 67 | outline: none; 68 | margin: 2px; 69 | font-size: 16px; 70 | font-weight: bold; 71 | font-family: "Overpass", sans-serif; 72 | padding: 5px; 73 | border: none; 74 | border-radius: 2px; 75 | } 76 | 77 | input { 78 | width: 150px; 79 | } 80 | 81 | input:disabled { 82 | color: #ccc; 83 | } 84 | 85 | button:disabled { 86 | color: #999; 87 | } 88 | 89 | button:not(:disabled):active { 90 | background-color: #ddd; 91 | } 92 | 93 | button:focus { 94 | border-color: #666; 95 | } 96 | 97 | main { 98 | margin: 0px; 99 | align-items: center; 100 | color: whitesmoke; 101 | /* background: rgb(83, 81, 81); */ 102 | } 103 | 104 | span { 105 | /* padding: 1em; */ 106 | font-size: 16px; 107 | } 108 | 109 | .xAxis path, 110 | .xAxis line { 111 | stroke: teal; 112 | shape-rendering: crispEdges; 113 | } 114 | 115 | .xAxis text { 116 | font-weight: bold; 117 | font-size: 14px; 118 | fill: teal; 119 | } 120 | 121 | .node circle { 122 | fill: #fff; 123 | stroke: steelblue; 124 | stroke-width: 3px; 125 | } 126 | 127 | .node text { 128 | font: 12px Overpass; 129 | } 130 | 131 | .link { 132 | fill: none; 133 | stroke: #ccc; 134 | stroke-width: 2px; 135 | } 136 | 137 | .test { 138 | content: "▼"; 139 | } 140 | 141 | .no-arrow { 142 | font-size: 16px; 143 | } 144 | 145 | ul.ulArrows { 146 | list-style-type: none; 147 | } 148 | 149 | ul.variableVal { 150 | font-size: 16px; 151 | list-style-type: none; 152 | } 153 | 154 | #variableName { 155 | /* font-size: 16px; */ 156 | list-style-type: none; 157 | } 158 | .arrow { 159 | cursor: pointer; 160 | font-weight: bold; 161 | display: inline-block; 162 | transition: transform 200ms; 163 | text-align: center; 164 | } 165 | 166 | .arrowDown { 167 | transform: rotate(90deg); 168 | } 169 | 170 | .search-button { 171 | color: rgb(162, 159, 159); 172 | background: transparent; 173 | border: none; 174 | outline: none; 175 | margin-right: -25px; 176 | } 177 | form.form button:hover { 178 | background: rgb(238, 137, 5); 179 | } 180 | 181 | .content { 182 | padding: 2px; 183 | font-size: 16px; 184 | font-weight: bold; 185 | border: none; 186 | /* transition: max-height 0.2s ease-out; */ 187 | } 188 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import "./global.css"; 2 | 3 | import App from "./App.svelte"; 4 | 5 | const app = new App({ 6 | target: document.body, 7 | }); 8 | 9 | export default app; 10 | -------------------------------------------------------------------------------- /src/stores.js: -------------------------------------------------------------------------------- 1 | import { writable, get } from "svelte/store"; 2 | import { compile } from "svelte/compiler"; 3 | import _ from "lodash"; 4 | 5 | export const snapshots = writable([]); 6 | export const fileTree = writable({}); 7 | export const flatFileTree = writable([]); 8 | export const backgroundPageConnection = writable( 9 | chrome.runtime.connect({ name: "panel" }) 10 | ); 11 | export const sharedAppView = writable(); 12 | 13 | // store updateable objects for current component state 14 | let componentData = {}; 15 | // store ALL nodes and listeners 16 | const nodes = {}; 17 | 18 | // store AST info for each file 19 | const astInfo = {}; 20 | const componentTree = {}; 21 | let parentComponent; 22 | let domParent; 23 | let rebuild = false; 24 | 25 | // set up background page Connection 26 | const connection = get(backgroundPageConnection); 27 | 28 | connection.postMessage({ 29 | name: "init", 30 | tabId: chrome.devtools.inspectedWindow.tabId, 31 | }); 32 | 33 | // Listen for SvelteDOM messages from content script 34 | chrome.runtime.onMessage.addListener((msg) => { 35 | const parsedMessage = JSON.parse(msg); 36 | 37 | const { data, type } = parsedMessage; 38 | 39 | if (type === "firstLoad") { 40 | snapshots.set([]); 41 | const snapshot = buildSnapshot(data); 42 | snapshots.update((array) => [...array, snapshot]); 43 | } else if (type === "update") { 44 | const newSnapshot = buildSnapshot(data); 45 | snapshots.update((array) => [...array, newSnapshot]); 46 | } else if (type === "rebuild") { 47 | rebuild = true; 48 | const newSnapshot = buildSnapshot(data); 49 | } 50 | }); 51 | 52 | // get and parse through the AST for additional variable info 53 | chrome.devtools.inspectedWindow.getResources((resources) => { 54 | const arrSvelteFiles = resources.filter((file) => 55 | file.url.includes(".svelte") 56 | ); 57 | arrSvelteFiles.forEach((svelteFile) => { 58 | svelteFile.getContent((source) => { 59 | if (source) { 60 | const { ast } = compile(source); 61 | const componentName = svelteFile.url.slice( 62 | svelteFile.url.lastIndexOf("/") + 1, 63 | svelteFile.url.lastIndexOf(".svelte") 64 | ); 65 | const components = {}; 66 | if (ast.instance) { 67 | const astVariables = ast.instance.content.body; 68 | astVariables.forEach((variable) => { 69 | const data = {}; 70 | if ( 71 | variable.type === "ImportDeclaration" && 72 | variable.source.value.includes(".svelte") 73 | ) { 74 | data.name = variable.specifiers[0].local.name; 75 | data.parent = componentName; 76 | components[data.name] = data; 77 | } 78 | }); 79 | } 80 | 81 | astInfo[componentName] = components; 82 | componentTree[componentName] = { 83 | id: componentName, 84 | children: [], 85 | }; 86 | } 87 | }); 88 | }); 89 | }); 90 | 91 | function buildSnapshot(data) { 92 | const { 93 | components, 94 | insertedNodes, 95 | deletedNodes, 96 | stateObject, 97 | snapshotLabel, 98 | } = data; 99 | const diff = { 100 | newComponents: [], 101 | deletedComponents: [], 102 | changedVariables: {}, 103 | }; 104 | 105 | // build nodes object 106 | insertedNodes.forEach((node) => { 107 | nodes[node.id] = { 108 | children: [], 109 | id: node.id, 110 | component: node.component, 111 | target: node.target, 112 | loc: node.loc, 113 | }; 114 | // add as a child to target node 115 | if (typeof node.target === "number") { 116 | nodes[node.target].children.push({ id: node.id }); 117 | } 118 | }); 119 | 120 | // build components and assign nodes and variables 121 | components.forEach((component) => { 122 | const { tagName, id, instance } = component; 123 | 124 | const data = { 125 | tagName, 126 | id, 127 | nodes: {}, 128 | variables: {}, 129 | active: true, 130 | instance, 131 | }; 132 | 133 | const targets = {}; 134 | // create object with all associated nodes 135 | const nodeLocations = {}; // store nodes by code location and parent to ensure they get assigned to correct component 136 | insertedNodes.forEach((node, i) => { 137 | // make sure node belongs to this component - component name should match, should be only node in component from that location OR if same location, must share a target 138 | if ( 139 | node.component === tagName && 140 | (!nodeLocations.hasOwnProperty(node.loc) || 141 | nodeLocations[node.loc] === node.target) 142 | ) { 143 | // update node component to include full component id with instance number 144 | nodes[node.id].component = id; 145 | // assign node by reference to component data 146 | data.nodes[node.id] = nodes[node.id]; 147 | // mark the node location as taken for this component and store target node 148 | nodeLocations[node.loc] = node.target; 149 | // remove node from insertedNodes array so it can't be assigned to another component 150 | delete insertedNodes[i]; 151 | // push node target and node to targetArray for later reference 152 | targets[node.target] = true; 153 | targets[node.id] = true; 154 | } 155 | }); 156 | 157 | // identify the top-level parent node for the component 158 | let parentNode; 159 | if (component.target) { 160 | parentNode = component.target; 161 | domParent = component.id; 162 | } 163 | parentNode = Math.min(...Object.keys(targets)); 164 | data.parentNode = parentNode; 165 | data.targets = targets; 166 | 167 | // assign variables to components using state object 168 | const variables = stateObject[id]; 169 | for (let variable in variables) { 170 | const varData = { 171 | name: variable, 172 | component: id, 173 | value: variables[variable].value, 174 | }; 175 | 176 | data.variables[varData.name] = varData; 177 | } 178 | 179 | diff.newComponents.push({ component: id, variables: data.variables }); 180 | 181 | componentData[id] = data; 182 | }); 183 | 184 | // assign any remaining inserted Nodes that didn't go to new components 185 | insertedNodes.forEach((node) => { 186 | // loop through components in case there are multiple instances 187 | for (let component in componentData) { 188 | if ( 189 | componentData[component].tagName === node.component && 190 | componentData[component].targets.hasOwnProperty(node.target) 191 | ) { 192 | // update node component to include full component id with instance number 193 | nodes[node.id].component = componentData[component].id; 194 | // assign node by reference to component data 195 | componentData[component].nodes[node.id] = nodes[node.id]; 196 | // store node target and node id for later reference 197 | componentData[component].targets[node.target] = true; 198 | componentData[component].targets[node.id] = true; 199 | } 200 | } 201 | }); 202 | 203 | // delete nodes and descendents 204 | deletedNodes.forEach((node) => { 205 | deleteNode(node.id); 206 | }); 207 | 208 | // if DOM was rebuilt by jumping, need to explicitly remove components 209 | if (rebuild) { 210 | data.deletedComponents.forEach((component) => { 211 | delete componentData[component]; 212 | }); 213 | } 214 | 215 | // determine and assign the DOM parent (can't happen until all components are built and have nodes assigned) 216 | for (let component in componentData) { 217 | const { parentNode } = componentData[component]; 218 | componentData[component].parent = nodes.hasOwnProperty(parentNode) 219 | ? nodes[parentNode].component 220 | : component === domParent 221 | ? null 222 | : domParent; 223 | componentData[component].children = []; 224 | } 225 | 226 | // assign children to components and determine if component is active in the DOM 227 | for (let i in componentData) { 228 | const component = componentData[i]; 229 | const parent = component.parent; 230 | if (parent && componentData.hasOwnProperty(parent)) { 231 | componentData[parent].children.push(component); 232 | } 233 | // if no current nodes, mark component as not active 234 | if (!Object.keys(component.nodes).length && component.id !== domParent) { 235 | if (component.active === true) { 236 | component.active = false; 237 | diff.deletedComponents.push({ 238 | component: component.id, 239 | variables: component.variables, 240 | }); 241 | } 242 | } else { 243 | component.active = true; 244 | } 245 | } 246 | 247 | // update state variables 248 | const currentIndex = get(sharedAppView); 249 | if (currentIndex >= 0) { 250 | const allStates = get(snapshots); 251 | const stateHistory = allStates[currentIndex].data; 252 | const storeDiff = {}; 253 | 254 | for (let [componentId, component] of Object.entries(componentData)) { 255 | const componentDiff = {}; 256 | 257 | if (stateObject.hasOwnProperty(componentId)) { 258 | for (let [varName, variable] of Object.entries( 259 | stateObject[componentId] 260 | )) { 261 | const { value } = variable; 262 | let data = {}; 263 | // if variable is in stateObject but not in componentData, set value in componentData to null 264 | if (!component.variables.hasOwnProperty(varName)) { 265 | component.variables[varName] = variable; 266 | } 267 | // if values are different, set value in componentData to value from stateObject 268 | else if (!_.isEqual(value, component.variables[varName].value)) { 269 | component.variables[varName].value = value; 270 | } 271 | 272 | if (stateHistory.hasOwnProperty(componentId)) { 273 | // if variable is in stateObject but not in stateHistory, old value is null 274 | if (!stateHistory[componentId].variables.hasOwnProperty(varName)) { 275 | data = { 276 | name: varName, 277 | oldValue: null, 278 | newValue: getDiffValue(value), 279 | }; 280 | } 281 | // if values are different in stateObject and stateHistory, set old and new values respetively 282 | else if ( 283 | !_.isEqual( 284 | stateHistory[componentId].variables[varName].value, 285 | value 286 | ) 287 | ) { 288 | data = { 289 | name: varName, 290 | oldValue: 291 | stateHistory[componentId].variables[varName].value !== undefined 292 | ? getDiffValue( 293 | stateHistory[componentId].variables[varName].value 294 | ) 295 | : "undefined", 296 | newValue: getDiffValue(value), 297 | }; 298 | } 299 | } 300 | 301 | // if there are diffs, add to component diff or store diff 302 | if (!_.isEmpty(data)) { 303 | if (varName[0] === "$") { 304 | storeDiff[varName] = data; 305 | } else { 306 | componentDiff[varName] = data; 307 | } 308 | } 309 | } 310 | } 311 | 312 | if (stateObject.hasOwnProperty(componentId)) { 313 | for (let [varName, variable] of Object.entries(component.variables)) { 314 | // if variable is in componentData but not in stateObject, new value in componentData is null 315 | if (!stateObject[componentId].hasOwnProperty(varName)) { 316 | variable.value = null; 317 | } 318 | } 319 | 320 | if (stateHistory.hasOwnProperty(componentId)) { 321 | for (let [varName, variable] of Object.entries( 322 | stateHistory[componentId].variables 323 | )) { 324 | // if variable is in stateHistory but not in stateObject, new value is null 325 | if (!stateObject[componentId].hasOwnProperty(varName)) { 326 | const data = { 327 | name: varName, 328 | oldValue: 329 | variable.value !== undefined 330 | ? getDiffValue(variable.value) 331 | : "undefined", 332 | newValue: null, 333 | }; 334 | if (varName[0] === "$") { 335 | storeDiff[varName] = data; 336 | } else { 337 | componentDiff[varName] = data; 338 | } 339 | } 340 | } 341 | } 342 | } 343 | 344 | if (!_.isEmpty(componentDiff)) { 345 | diff.changedVariables[componentId] = componentDiff; 346 | } 347 | } 348 | 349 | if (!_.isEmpty(storeDiff)) { 350 | diff.changedVariables["Store"] = storeDiff; 351 | } 352 | } 353 | 354 | let currentTree = get(fileTree); 355 | if (_.isEmpty(currentTree)) { 356 | // assign component children 357 | for (let file in astInfo) { 358 | for (let childFile in astInfo[file]) { 359 | componentTree[file].children.push(componentTree[childFile]); 360 | componentTree[childFile].parent = file; 361 | } 362 | } 363 | 364 | // determine top-level parent component 365 | for (let file in astInfo) { 366 | if (!componentTree[file].parent) { 367 | parentComponent = file; 368 | } 369 | } 370 | 371 | if (!_.isEmpty(componentTree)) { 372 | fileTree.set(componentTree[parentComponent]); 373 | } 374 | 375 | //create depth-first ordering of tree for state injections 376 | const flatTreeArray = []; 377 | 378 | // if AST came through, make flat file tree based on that; otherwise use componentData 379 | if (!_.isEmpty(componentTree)) { 380 | depthFirstTraverse(componentTree[parentComponent]); 381 | } else { 382 | depthFirstTraverse(componentData[domParent]); 383 | } 384 | 385 | function depthFirstTraverse(tree) { 386 | flatTreeArray.push(tree.tagName || tree.id); 387 | if (tree.children.length) { 388 | tree.children.forEach((child) => { 389 | depthFirstTraverse(child); 390 | }); 391 | } 392 | } 393 | 394 | flatFileTree.set(flatTreeArray); 395 | } 396 | 397 | const snapshot = { 398 | data: componentData, 399 | parent: domParent, 400 | label: snapshotLabel ? snapshotLabel : "Unlabeled Snapshot", 401 | diff, 402 | }; 403 | 404 | rebuild = false; 405 | 406 | const deepCloneSnapshot = JSON.parse(JSON.stringify(snapshot)); 407 | 408 | return deepCloneSnapshot; // deep clone to "freeze" state 409 | } 410 | 411 | // recursively delete node and all descendents 412 | function deleteNode(nodeId) { 413 | const { children, component, id } = nodes[nodeId]; 414 | if (children.length) { 415 | children.forEach((child) => { 416 | deleteNode(child.id); 417 | }); 418 | } 419 | delete componentData[component].nodes[id]; 420 | } 421 | 422 | function getDiffValue(value) { 423 | let text = ""; 424 | 425 | if (value === null || typeof value !== "object") { 426 | text += value; 427 | } else { 428 | text += "\n"; 429 | for (let i in value) { 430 | nested(value[i], 1); 431 | } 432 | } 433 | 434 | return text; 435 | 436 | function nested(obj, tabCount) { 437 | if (obj.value) { 438 | // add tabs based on the level in the recursion 439 | for (let i = 1; i <= tabCount; i++) { 440 | text = text + "\t"; 441 | } 442 | text = text + obj.name + ": "; 443 | if (typeof obj.value !== "object") { 444 | text = text + obj.value + "\n"; 445 | } else { 446 | tabCount++; 447 | text = text + "\n"; 448 | for (let val in obj.value) { 449 | nested(obj.value[val], tabCount); 450 | } 451 | } 452 | } 453 | } 454 | } 455 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 2 | const path = require('path'); 3 | 4 | const mode = process.env.NODE_ENV || 'development'; 5 | const prod = mode === 'production'; 6 | 7 | module.exports = { 8 | entry: { 9 | 'build/bundle': ['./src/main.js'] 10 | }, 11 | resolve: { 12 | alias: { 13 | svelte: path.dirname(require.resolve('svelte/package.json')) 14 | }, 15 | extensions: ['.mjs', '.js', '.svelte'], 16 | mainFields: ['svelte', 'browser', 'module', 'main'] 17 | }, 18 | output: { 19 | path: path.join(__dirname, '/extension/devtools'), 20 | filename: '[name].js', 21 | chunkFilename: '[name].[id].js' 22 | }, 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.svelte$/, 27 | use: { 28 | loader: 'svelte-loader', 29 | options: { 30 | compilerOptions: { 31 | dev: !prod 32 | }, 33 | emitCss: prod, 34 | hotReload: !prod 35 | } 36 | } 37 | }, 38 | { 39 | test: /\.css$/, 40 | use: [ 41 | MiniCssExtractPlugin.loader, 42 | 'css-loader' 43 | ] 44 | }, 45 | { 46 | // required to prevent errors from Svelte on Webpack 5+ 47 | test: /node_modules\/svelte\/.*\.mjs$/, 48 | resolve: { 49 | fullySpecified: false 50 | } 51 | }, 52 | { 53 | test: /\.(png|jpe?g|gif)$/i, 54 | use: [ 55 | { 56 | loader: 'file-loader', 57 | }, 58 | ], 59 | }, 60 | ] 61 | }, 62 | mode, 63 | plugins: [ 64 | new MiniCssExtractPlugin({ 65 | filename: '[name].css' 66 | }) 67 | ], 68 | devtool: prod ? false : 'source-map', 69 | devServer: { 70 | hot: true 71 | } 72 | }; 73 | --------------------------------------------------------------------------------