script-panel.mjs 11.8 KB
Newer Older
1 2 3
// Copyright 2020 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
4
import {App} from '../index.mjs'
5

6
import {SelectionEvent, SelectRelatedEvent, ToolTipEvent} from './events.mjs';
7
import {arrayEquals, CollapsableElement, CSSColor, defer, delay, DOM, formatBytes, gradientStopsFromGroups, groupBy, LazyTable} from './helper.mjs';
8

9 10 11 12
// A source mapping proxy for source maps that don't have CORS headers.
// TODO(leszeks): Make this configurable.
const sourceMapFetchPrefix = 'http://localhost:8080/';

13
DOM.defineCustomElement('view/script-panel',
14
                        (templateText) =>
15
                            class SourcePanel extends CollapsableElement {
16
  _selectedSourcePositions = [];
17
  _sourcePositionsToMarkNodesPromise = defer();
18 19
  _scripts = [];
  _script;
20

21 22
  showToolTipEntriesHandler = this.handleShowToolTipEntries.bind(this);

23 24 25 26
  constructor() {
    super(templateText);
    this.scriptDropdown.addEventListener(
        'change', e => this._handleSelectScript(e));
27 28
    this.$('#selectedRelatedButton').onclick =
        this._handleSelectRelated.bind(this);
29
  }
30

31 32 33
  get script() {
    return this.$('#script');
  }
34

35 36 37
  get scriptNode() {
    return this.$('.scriptNode');
  }
38

39 40 41
  set script(script) {
    if (this._script === script) return;
    this._script = script;
42 43
    script.ensureSourceMapCalculated(sourceMapFetchPrefix);
    this._sourcePositionsToMarkNodesPromise = defer();
44 45
    this._selectedSourcePositions =
        this._selectedSourcePositions.filter(each => each.script === script);
46 47 48 49 50
    this.requestUpdate();
  }

  set focusedSourcePositions(sourcePositions) {
    this.selectedSourcePositions = sourcePositions;
51
  }
52

53
  set selectedSourcePositions(sourcePositions) {
54 55 56 57 58 59 60 61
    if (arrayEquals(this._selectedSourcePositions, sourcePositions)) {
      this._focusSelectedMarkers(0);
    } else {
      this._selectedSourcePositions = sourcePositions;
      // TODO: highlight multiple scripts
      this.script = sourcePositions[0]?.script;
      this._focusSelectedMarkers(100);
    }
62
  }
63

64
  set scripts(scripts) {
65 66 67
    this._scripts = scripts;
    this._initializeScriptDropdown();
  }
68

69 70 71
  get scriptDropdown() {
    return this.$('#script-dropdown');
  }
72

73 74 75 76 77
  _update() {
    this._renderSourcePanel();
    this._updateScriptDropdownSelection();
  }

78
  _initializeScriptDropdown() {
79
    this._scripts.sort((a, b) => a.name?.localeCompare(b.name) ?? 0);
80 81 82 83 84 85 86 87 88 89
    let select = this.scriptDropdown;
    select.options.length = 0;
    for (const script of this._scripts) {
      const option = document.createElement('option');
      const size = formatBytes(script.source.length);
      option.text = `${script.name} (id=${script.id} size=${size})`;
      option.script = script;
      select.add(option);
    }
  }
90

91 92 93 94
  _updateScriptDropdownSelection() {
    this.scriptDropdown.selectedIndex =
        this._script ? this._scripts.indexOf(this._script) : -1;
  }
95

96 97
  async _renderSourcePanel() {
    let scriptNode;
98 99
    const script = this._script;
    if (script) {
100
      await delay(1);
101
      if (script != this._script) return;
102
      const builder = new LineBuilder(this, this._script);
103 104 105 106
      scriptNode = await builder.createScriptNode(this._script.startLine);
      if (script != this._script) return;
      this._sourcePositionsToMarkNodesPromise.resolve(
          builder.sourcePositionToMarkers);
107
    } else {
108
      scriptNode = DOM.div();
109
      this._selectedMarkNodes = undefined;
110
      this._sourcePositionsToMarkNodesPromise.resolve(new Map());
111 112 113 114
    }
    const oldScriptNode = this.script.childNodes[1];
    this.script.replaceChild(scriptNode, oldScriptNode);
  }
115

116 117
  async _focusSelectedMarkers(delay_ms) {
    if (delay_ms) await delay(delay_ms);
118 119
    const sourcePositionsToMarkNodes =
        await this._sourcePositionsToMarkNodesPromise;
120
    // Remove all marked nodes.
121
    for (let markNode of sourcePositionsToMarkNodes.values()) {
122 123 124
      markNode.className = '';
    }
    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 133
    const sourcePosition = this._selectedSourcePositions.find(
        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 141 142 143 144
  _handleSelectScript(e) {
    const option =
        this.scriptDropdown.options[this.scriptDropdown.selectedIndex];
    this.script = option.script;
  }
145

146 147 148 149 150
  _handleSelectRelated(e) {
    if (!this._script) return;
    this.dispatchEvent(new SelectRelatedEvent(this._script));
  }

151 152 153 154 155
  setSelectedSourcePositionInternal(sourcePosition) {
    this._selectedSourcePositions = [sourcePosition];
    console.assert(sourcePosition.script === this._script);
  }

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 164
    const sourcePosition = e.target.sourcePosition;
    const entries = sourcePosition.entries;
165 166 167
    const toolTipContent = DOM.div();
    toolTipContent.appendChild(
        new ToolTipTableBuilder(this, entries).tableNode);
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199

    let sourceMapContent;
    switch (this._script.sourceMapState) {
      case 'loaded': {
        const originalPosition = sourcePosition.originalPosition;
        if (originalPosition.source === null) {
          sourceMapContent =
              DOM.element('i', {textContent: 'no source mapping for location'});
        } else {
          sourceMapContent = DOM.element('a', {
            href: `${originalPosition.source}`,
            target: '_blank',
            textContent: `${originalPosition.source}:${originalPosition.line}:${
                originalPosition.column}`
          });
        }
        break;
      }
      case 'loading':
        sourceMapContent =
            DOM.element('i', {textContent: 'source map still loading...'});
        break;
      case 'failed':
        sourceMapContent =
            DOM.element('i', {textContent: 'source map failed to load'});
        break;
      case 'none':
        sourceMapContent = DOM.element('i', {textContent: 'no source map'});
        break;
      default:
        break;
    }
200
    toolTipContent.appendChild(sourceMapContent);
201
    this.dispatchEvent(new ToolTipEvent(toolTipContent, e.target));
202
  }
203 204 205 206 207 208 209 210 211

  handleShowToolTipEntries(event) {
    let entries = event.currentTarget.data;
    const sourcePosition = entries[0].sourcePosition;
    // Add a source position entry so the current position stays focused.
    this.setSelectedSourcePositionInternal(sourcePosition);
    entries = entries.concat(this._selectedSourcePositions);
    this.dispatchEvent(new SelectionEvent(entries));
  }
212
});
213

214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243
class ToolTipTableBuilder {
  constructor(scriptPanel, entries) {
    this._scriptPanel = scriptPanel;
    this.tableNode = DOM.table();
    const tr = DOM.tr();
    tr.appendChild(DOM.td('Type'));
    tr.appendChild(DOM.td('Subtype'));
    tr.appendChild(DOM.td('Count'));
    this.tableNode.appendChild(document.createElement('thead')).appendChild(tr);
    groupBy(entries, each => each.constructor, true).forEach(group => {
      this.addRow(group.key.name, 'all', entries, false)
      groupBy(group.entries, each => each.type, true).forEach(group => {
        this.addRow('', group.key, group.entries, false)
      })
    })
  }

  addRow(name, subtypeName, entries) {
    const tr = DOM.tr();
    tr.appendChild(DOM.td(name));
    tr.appendChild(DOM.td(subtypeName));
    tr.appendChild(DOM.td(entries.length));
    const button =
        DOM.button('Show', this._scriptPanel.showToolTipEntriesHandler);
    button.data = entries;
    tr.appendChild(DOM.td(button));
    this.tableNode.appendChild(tr);
  }
}

244
class SourcePositionIterator {
245 246
  _entries;
  _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 255
      yield this._current();
      this._next();
256 257 258
    }
  }

259
  _findStart(lineIndex) {
260
    while (!this._done() && this._current().line < lineIndex) {
261 262 263 264
      this._next();
    }
  }

265 266
  _current() {
    return this._entries[this._index];
267 268
  }

269
  _done() {
270
    return this._index >= this._entries.length;
271 272
  }

273 274
  _next() {
    this._index++;
275 276 277
  }
}

278
function* lineIterator(source, startLine) {
279
  let current = 0;
280
  let line = startLine;
281 282
  while (current < source.length) {
    const next = source.indexOf('\n', current);
283 284 285 286 287 288 289 290 291
    if (next === -1) break;
    yield [line, source.substring(current, next)];
    line++;
    current = next + 1;
  }
  if (current < source.length) yield [line, source.substring(current)];
}

class LineBuilder {
292 293 294 295 296 297 298 299 300 301 302 303
  static _colorMap = (function() {
    const map = new Map();
    let i = 0;
    for (let type of App.allEventTypes) {
      map.set(type, CSSColor.at(i++));
    }
    return map;
  })();
  static get colorMap() {
    return this._colorMap;
  }

304 305
  _script;
  _clickHandler;
306
  _mouseoverHandler;
307
  _sourcePositionToMarkers = new Map();
308

309
  constructor(panel, script) {
310 311
    this._script = script;
    this._clickHandler = panel.handleSourcePositionClick.bind(panel);
312
    this._mouseoverHandler = panel.handleSourcePositionMouseOver.bind(panel);
313
  }
314

315 316
  get sourcePositionToMarkers() {
    return this._sourcePositionToMarkers;
317 318
  }

319
  async createScriptNode(startLine) {
320
    const scriptNode = DOM.div('scriptNode');
321 322 323 324 325 326 327 328 329

    // TODO: sort on script finalization.
    this._script.sourcePositions.sort((a, b) => {
      if (a.line === b.line) return a.column - b.column;
      return a.line - b.line;
    });

    const sourcePositionsIterator =
        new SourcePositionIterator(this._script.sourcePositions);
330
    scriptNode.style.counterReset = `sourceLineCounter ${startLine - 1}`;
331 332
    for (let [lineIndex, line] of lineIterator(
             this._script.source, startLine)) {
333 334
      scriptNode.appendChild(
          this._createLineNode(sourcePositionsIterator, lineIndex, line));
335
    }
336 337 338 339
    if (this._script.sourcePositions.length !=
        this._sourcePositionToMarkers.size) {
      console.error('Not all SourcePositions were processed.');
    }
340 341 342
    return scriptNode;
  }

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 349
      lineNode.appendChild(document.createTextNode(
          line.substring(columnIndex, nextColumnIndex)));
350 351 352
      columnIndex = nextColumnIndex;

      lineNode.appendChild(
353
          this._createMarkerNode(line[columnIndex], sourcePosition));
354 355 356
      columnIndex++;
    }
    lineNode.appendChild(
357
        document.createTextNode(line.substring(columnIndex) + '\n'));
358 359 360
    return lineNode;
  }

361
  _createMarkerNode(text, sourcePosition) {
362
    const marker = document.createElement('mark');
363
    this._sourcePositionToMarkers.set(sourcePosition, marker);
364 365
    marker.textContent = text;
    marker.sourcePosition = sourcePosition;
366
    marker.onclick = this._clickHandler;
367
    marker.onmouseover = this._mouseoverHandler;
368 369

    const entries = sourcePosition.entries;
370 371 372 373 374 375 376 377
    const groups = groupBy(entries, entry => entry.constructor);
    if (groups.length > 1) {
      const stops = gradientStopsFromGroups(
          entries.length, '%', groups, type => LineBuilder.colorMap.get(type));
      marker.style.backgroundImage = `linear-gradient(0deg,${stops.join(',')})`
    } else {
      marker.style.backgroundColor = LineBuilder.colorMap.get(groups[0].key)
    }
378

379 380
    return marker;
  }
381
}