1// Copyright 2020 the V8 project authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4import {App} from '../index.mjs' 5 6import {SelectionEvent, SelectRelatedEvent, ToolTipEvent} from './events.mjs'; 7import {arrayEquals, CollapsableElement, CSSColor, defer, delay, DOM, formatBytes, gradientStopsFromGroups, groupBy, LazyTable} from './helper.mjs'; 8 9// A source mapping proxy for source maps that don't have CORS headers. 10// TODO(leszeks): Make this configurable. 11const sourceMapFetchPrefix = 'http://localhost:8080/'; 12 13DOM.defineCustomElement('view/script-panel', 14 (templateText) => 15 class SourcePanel extends CollapsableElement { 16 _selectedSourcePositions = []; 17 _sourcePositionsToMarkNodesPromise = defer(); 18 _scripts = []; 19 _script; 20 21 showToolTipEntriesHandler = this.handleShowToolTipEntries.bind(this); 22 23 constructor() { 24 super(templateText); 25 this.scriptDropdown.addEventListener( 26 'change', e => this._handleSelectScript(e)); 27 this.$('#selectedRelatedButton').onclick = 28 this._handleSelectRelated.bind(this); 29 } 30 31 get script() { 32 return this.$('#script'); 33 } 34 35 get scriptNode() { 36 return this.$('.scriptNode'); 37 } 38 39 set script(script) { 40 if (this._script === script) return; 41 this._script = script; 42 script.ensureSourceMapCalculated(sourceMapFetchPrefix); 43 this._sourcePositionsToMarkNodesPromise = defer(); 44 this._selectedSourcePositions = 45 this._selectedSourcePositions.filter(each => each.script === script); 46 this.requestUpdate(); 47 } 48 49 set focusedSourcePositions(sourcePositions) { 50 this.selectedSourcePositions = sourcePositions; 51 } 52 53 set selectedSourcePositions(sourcePositions) { 54 if (arrayEquals(this._selectedSourcePositions, sourcePositions)) { 55 this._focusSelectedMarkers(0); 56 } else { 57 this._selectedSourcePositions = sourcePositions; 58 // TODO: highlight multiple scripts 59 this.script = sourcePositions[0]?.script; 60 this._focusSelectedMarkers(100); 61 } 62 } 63 64 set scripts(scripts) { 65 this._scripts = scripts; 66 this._initializeScriptDropdown(); 67 } 68 69 get scriptDropdown() { 70 return this.$('#script-dropdown'); 71 } 72 73 _update() { 74 this._renderSourcePanel(); 75 this._updateScriptDropdownSelection(); 76 } 77 78 _initializeScriptDropdown() { 79 this._scripts.sort((a, b) => a.name?.localeCompare(b.name) ?? 0); 80 let select = this.scriptDropdown; 81 select.options.length = 0; 82 for (const script of this._scripts) { 83 const option = document.createElement('option'); 84 const size = formatBytes(script.source.length); 85 option.text = `${script.name} (id=${script.id} size=${size})`; 86 option.script = script; 87 select.add(option); 88 } 89 } 90 91 _updateScriptDropdownSelection() { 92 this.scriptDropdown.selectedIndex = 93 this._script ? this._scripts.indexOf(this._script) : -1; 94 } 95 96 async _renderSourcePanel() { 97 let scriptNode; 98 const script = this._script; 99 if (script) { 100 await delay(1); 101 if (script != this._script) return; 102 const builder = new LineBuilder(this, this._script); 103 scriptNode = await builder.createScriptNode(this._script.startLine); 104 if (script != this._script) return; 105 this._sourcePositionsToMarkNodesPromise.resolve( 106 builder.sourcePositionToMarkers); 107 } else { 108 scriptNode = DOM.div(); 109 this._selectedMarkNodes = undefined; 110 this._sourcePositionsToMarkNodesPromise.resolve(new Map()); 111 } 112 const oldScriptNode = this.script.childNodes[1]; 113 this.script.replaceChild(scriptNode, oldScriptNode); 114 } 115 116 async _focusSelectedMarkers(delay_ms) { 117 if (delay_ms) await delay(delay_ms); 118 const sourcePositionsToMarkNodes = 119 await this._sourcePositionsToMarkNodesPromise; 120 // Remove all marked nodes. 121 for (let markNode of sourcePositionsToMarkNodes.values()) { 122 markNode.className = ''; 123 } 124 for (let sourcePosition of this._selectedSourcePositions) { 125 if (sourcePosition.script !== this._script) continue; 126 sourcePositionsToMarkNodes.get(sourcePosition).className = 'marked'; 127 } 128 this._scrollToFirstSourcePosition(sourcePositionsToMarkNodes) 129 } 130 131 _scrollToFirstSourcePosition(sourcePositionsToMarkNodes) { 132 const sourcePosition = this._selectedSourcePositions.find( 133 each => each.script === this._script); 134 if (!sourcePosition) return; 135 const markNode = sourcePositionsToMarkNodes.get(sourcePosition); 136 markNode.scrollIntoView( 137 {behavior: 'smooth', block: 'center', inline: 'center'}); 138 } 139 140 _handleSelectScript(e) { 141 const option = 142 this.scriptDropdown.options[this.scriptDropdown.selectedIndex]; 143 this.script = option.script; 144 } 145 146 _handleSelectRelated(e) { 147 if (!this._script) return; 148 this.dispatchEvent(new SelectRelatedEvent(this._script)); 149 } 150 151 setSelectedSourcePositionInternal(sourcePosition) { 152 this._selectedSourcePositions = [sourcePosition]; 153 console.assert(sourcePosition.script === this._script); 154 } 155 156 handleSourcePositionClick(e) { 157 const sourcePosition = e.target.sourcePosition; 158 this.setSelectedSourcePositionInternal(sourcePosition); 159 this.dispatchEvent(new SelectRelatedEvent(sourcePosition)); 160 } 161 162 handleSourcePositionMouseOver(e) { 163 const sourcePosition = e.target.sourcePosition; 164 const entries = sourcePosition.entries; 165 const toolTipContent = DOM.div(); 166 toolTipContent.appendChild( 167 new ToolTipTableBuilder(this, entries).tableNode); 168 169 let sourceMapContent; 170 switch (this._script.sourceMapState) { 171 case 'loaded': { 172 const originalPosition = sourcePosition.originalPosition; 173 if (originalPosition.source === null) { 174 sourceMapContent = 175 DOM.element('i', {textContent: 'no source mapping for location'}); 176 } else { 177 sourceMapContent = DOM.element('a', { 178 href: `${originalPosition.source}`, 179 target: '_blank', 180 textContent: `${originalPosition.source}:${originalPosition.line}:${ 181 originalPosition.column}` 182 }); 183 } 184 break; 185 } 186 case 'loading': 187 sourceMapContent = 188 DOM.element('i', {textContent: 'source map still loading...'}); 189 break; 190 case 'failed': 191 sourceMapContent = 192 DOM.element('i', {textContent: 'source map failed to load'}); 193 break; 194 case 'none': 195 sourceMapContent = DOM.element('i', {textContent: 'no source map'}); 196 break; 197 default: 198 break; 199 } 200 toolTipContent.appendChild(sourceMapContent); 201 this.dispatchEvent(new ToolTipEvent(toolTipContent, e.target)); 202 } 203 204 handleShowToolTipEntries(event) { 205 let entries = event.currentTarget.data; 206 const sourcePosition = entries[0].sourcePosition; 207 // Add a source position entry so the current position stays focused. 208 this.setSelectedSourcePositionInternal(sourcePosition); 209 entries = entries.concat(this._selectedSourcePositions); 210 this.dispatchEvent(new SelectionEvent(entries)); 211 } 212}); 213 214class ToolTipTableBuilder { 215 constructor(scriptPanel, entries) { 216 this._scriptPanel = scriptPanel; 217 this.tableNode = DOM.table(); 218 const tr = DOM.tr(); 219 tr.appendChild(DOM.td('Type')); 220 tr.appendChild(DOM.td('Subtype')); 221 tr.appendChild(DOM.td('Count')); 222 this.tableNode.appendChild(document.createElement('thead')).appendChild(tr); 223 groupBy(entries, each => each.constructor, true).forEach(group => { 224 this.addRow(group.key.name, 'all', entries, false) 225 groupBy(group.entries, each => each.type, true).forEach(group => { 226 this.addRow('', group.key, group.entries, false) 227 }) 228 }) 229 } 230 231 addRow(name, subtypeName, entries) { 232 const tr = DOM.tr(); 233 tr.appendChild(DOM.td(name)); 234 tr.appendChild(DOM.td(subtypeName)); 235 tr.appendChild(DOM.td(entries.length)); 236 const button = 237 DOM.button('Show', this._scriptPanel.showToolTipEntriesHandler); 238 button.data = entries; 239 tr.appendChild(DOM.td(button)); 240 this.tableNode.appendChild(tr); 241 } 242} 243 244class SourcePositionIterator { 245 _entries; 246 _index = 0; 247 constructor(sourcePositions) { 248 this._entries = sourcePositions; 249 } 250 251 * forLine(lineIndex) { 252 this._findStart(lineIndex); 253 while (!this._done() && this._current().line === lineIndex) { 254 yield this._current(); 255 this._next(); 256 } 257 } 258 259 _findStart(lineIndex) { 260 while (!this._done() && this._current().line < lineIndex) { 261 this._next(); 262 } 263 } 264 265 _current() { 266 return this._entries[this._index]; 267 } 268 269 _done() { 270 return this._index >= this._entries.length; 271 } 272 273 _next() { 274 this._index++; 275 } 276} 277 278function* lineIterator(source, startLine) { 279 let current = 0; 280 let line = startLine; 281 while (current < source.length) { 282 const next = source.indexOf('\n', current); 283 if (next === -1) break; 284 yield [line, source.substring(current, next)]; 285 line++; 286 current = next + 1; 287 } 288 if (current < source.length) yield [line, source.substring(current)]; 289} 290 291class LineBuilder { 292 static _colorMap = (function() { 293 const map = new Map(); 294 let i = 0; 295 for (let type of App.allEventTypes) { 296 map.set(type, CSSColor.at(i++)); 297 } 298 return map; 299 })(); 300 static get colorMap() { 301 return this._colorMap; 302 } 303 304 _script; 305 _clickHandler; 306 _mouseoverHandler; 307 _sourcePositionToMarkers = new Map(); 308 309 constructor(panel, script) { 310 this._script = script; 311 this._clickHandler = panel.handleSourcePositionClick.bind(panel); 312 this._mouseoverHandler = panel.handleSourcePositionMouseOver.bind(panel); 313 } 314 315 get sourcePositionToMarkers() { 316 return this._sourcePositionToMarkers; 317 } 318 319 async createScriptNode(startLine) { 320 const scriptNode = DOM.div('scriptNode'); 321 322 // TODO: sort on script finalization. 323 this._script.sourcePositions.sort((a, b) => { 324 if (a.line === b.line) return a.column - b.column; 325 return a.line - b.line; 326 }); 327 328 const sourcePositionsIterator = 329 new SourcePositionIterator(this._script.sourcePositions); 330 scriptNode.style.counterReset = `sourceLineCounter ${startLine - 1}`; 331 for (let [lineIndex, line] of lineIterator( 332 this._script.source, startLine)) { 333 scriptNode.appendChild( 334 this._createLineNode(sourcePositionsIterator, lineIndex, line)); 335 } 336 if (this._script.sourcePositions.length != 337 this._sourcePositionToMarkers.size) { 338 console.error('Not all SourcePositions were processed.'); 339 } 340 return scriptNode; 341 } 342 343 _createLineNode(sourcePositionsIterator, lineIndex, line) { 344 const lineNode = DOM.span(); 345 let columnIndex = 0; 346 for (const sourcePosition of sourcePositionsIterator.forLine(lineIndex)) { 347 const nextColumnIndex = sourcePosition.column - 1; 348 lineNode.appendChild(document.createTextNode( 349 line.substring(columnIndex, nextColumnIndex))); 350 columnIndex = nextColumnIndex; 351 352 lineNode.appendChild( 353 this._createMarkerNode(line[columnIndex], sourcePosition)); 354 columnIndex++; 355 } 356 lineNode.appendChild( 357 document.createTextNode(line.substring(columnIndex) + '\n')); 358 return lineNode; 359 } 360 361 _createMarkerNode(text, sourcePosition) { 362 const marker = document.createElement('mark'); 363 this._sourcePositionToMarkers.set(sourcePosition, marker); 364 marker.textContent = text; 365 marker.sourcePosition = sourcePosition; 366 marker.onclick = this._clickHandler; 367 marker.onmouseover = this._mouseoverHandler; 368 369 const entries = sourcePosition.entries; 370 const groups = groupBy(entries, entry => entry.constructor); 371 if (groups.length > 1) { 372 const stops = gradientStopsFromGroups( 373 entries.length, '%', groups, type => LineBuilder.colorMap.get(type)); 374 marker.style.backgroundImage = `linear-gradient(0deg,${stops.join(',')})` 375 } else { 376 marker.style.backgroundColor = LineBuilder.colorMap.get(groups[0].key) 377 } 378 379 return marker; 380 } 381} 382