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