blob: ac98ee3bdffdb8adbc4634e5670a9c9754f30ab3 [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:html' as html;
import 'package:vm_service/vm_service.dart';
import '../debugger/debugger_state.dart';
import '../ui/custom.dart';
import '../ui/elements.dart';
enum ListDirection {
pageUp,
pageDown,
home,
end,
}
/// Keycode definitions.
const int DOM_VK_RETURN = 13;
const int DOM_VK_ESCAPE = 27;
const int DOM_VK_PAGE_UP = 33;
const int DOM_VK_PAGE_DOWN = 34;
const int DOM_VK_END = 35;
const int DOM_VK_HOME = 36;
const int DOM_VK_UP = 38;
const int DOM_VK_DOWN = 40;
typedef URIDescriber = String Function(String uri);
class PopupView extends CoreElement {
PopupView(this._scriptsView, this._sourceArea, this._sourcePathDiv,
this._popupTextfield)
: super('div', classes: 'open-popup');
final CoreElement _sourceArea; // Top div area container of _sourcePathDiv
final CoreElement _sourcePathDiv; // Text name of the source
final CoreElement _popupTextfield; // Textfield to show while popup is active.
CoreElement get popupTextfield => _popupTextfield;
final ScriptsView _scriptsView;
String _oldSourceNameTextColor;
bool _poppedUp = false;
bool get isPoppedUp => _poppedUp;
void showPopup() {
_poppedUp = true;
add(_scriptsView);
final int sourceAreaWidth = _sourceArea.element.clientWidth;
_scriptsView.element.element.style
..height = '${element.getBoundingClientRect().height - 2}px'
..width = '${sourceAreaWidth / 2}px';
final html.Rectangle r = _sourceArea.element.getBoundingClientRect();
final int nameHeight = _sourcePathDiv.element.clientHeight;
int leftPosition = 20; // Default left position.
final List<html.Node> elems =
html.document.getElementsByClassName('CodeMirror-gutters');
if (elems.length == 2) {
final html.Element firstGutter = elems[0].firstChild as html.Element;
if (firstGutter.style.display != 'none') {
// Compute total gutter width (parent has both BP and line # gutters).
// Offset left a little (5px) so it's not aligned to the gutter's edge
// (hard to visualize).
leftPosition = firstGutter.parent.getBoundingClientRect().width + 5;
}
}
// Set the sourcePathDiv text color to the background color (hide filename).
final String bgColor =
_sourcePathDiv.element.getComputedStyle().backgroundColor;
_oldSourceNameTextColor = _sourcePathDiv.element.getComputedStyle().color;
_sourcePathDiv.element.style.color = bgColor;
_popupTextfield.element.style
..height = '${nameHeight}px'
..minHeight = '${nameHeight}px'
..maxHeight = '${nameHeight}px'
..width = _scriptsView.element.element.style.width
..top = '${r.top}px'
..left = '${r.left + leftPosition}px'
..display = 'inline';
element.style
..top = '${r.top + nameHeight}px'
..left = '${r.left + leftPosition}px'
..display = 'inline';
}
void hidePopup() {
// Restore the text color to what it was (so the title can be seen).
_sourcePathDiv.element.style.color = _oldSourceNameTextColor;
element.style.display = 'none'; // Hide ScriptsView
// Hide textfield and reset it's value.
final html.InputElement inputElement = _popupTextfield.element;
inputElement.value = '';
inputElement.style.display = 'none';
_poppedUp = false;
}
ScriptsView get scriptsView => _scriptsView;
}
class ScriptsView implements CoreElementView {
ScriptsView(URIDescriber uriDescriber) {
_items = SelectableList<ScriptRef>()
..flex()
..clazz('debugger-items-list');
_items.setRenderer((ScriptRef scriptRef) {
final String uri = scriptRef.uri;
final String name = uriDescriber(uri);
// Case insensitive matching.
final matchingName = name.toLowerCase();
CoreElement element;
if (_matcherRendering != null && _matcherRendering.active) {
// InputElement's need to fetch the value not text/textContent property.
// The value and text are different, all nodes have a text. It the text
// content of the node itself along with its descendants. However, input
// elements have a value property - its the input data of the input
// element. Input elements may have a text/textContent but it is always
// empty because they are void elements.
final html.InputElement inputElement =
_matcherRendering._textfield.element as html.InputElement;
// Case insensitive matching.
final String matchPart = inputElement.value.toLowerCase();
// Compute the matched characters to be bolded.
final int startIndex = matchingName.lastIndexOf(matchPart);
final String firstPart = name.substring(0, startIndex);
final int endBoldIndex = startIndex + matchPart.length;
final String boldPart = name.substring(startIndex, endBoldIndex);
final String endPart = name.substring(endBoldIndex);
// Construct the HTML with the bold tag and ensure that the HTML
// constructed is safe from attacks e.g., XSS, etc.
final String safeElement = html.Element.html(
'<div>$firstPart<strong class="strong-match">$boldPart</strong>$endPart</div>')
.innerHtml;
element = li(html: safeElement, c: 'list-item');
} else {
element = li(text: name, c: 'list-item');
}
element.tooltip = uri;
return element;
});
}
ScriptsMatcher _matcherRendering;
ScriptsMatcher get matcher => _matcherRendering;
void setMatcher(ScriptsMatcher _matcher) {
_matcherRendering = _matcher;
}
void reset() {
_highlightRef = null;
}
void scrollAndHighlight(
int row,
int topPosition, {
bool top = false,
bool bottom = false,
}) {
// TODO(terry): this fixed a RangeError, but investigate why this method is
// called when the list is empty.
if (items.isEmpty) return;
// Highlight this row.
_highlightRef = items[row];
final CoreElement newElement = _items.renderer(_highlightRef);
_items.setReplace(row, _highlightRef);
if (topPosition != -1) element.scrollTop = topPosition;
newElement?.scrollIntoView(top: top, bottom: bottom);
}
/// Returns the row number of item to make visible.
int page(ListDirection direction, [int startRow = 0]) {
final int listHeight = element.element.clientHeight;
final int itemHeight = _items.element.children[0].clientHeight;
final int itemsVis = (listHeight / itemHeight).truncate() - 1;
int childToScrollTo;
switch (direction) {
case ListDirection.pageDown:
int itemIndex = startRow + itemsVis;
if (itemIndex > _items.items.length - 1) {
itemIndex = _items.items.length - 1;
}
childToScrollTo = itemIndex;
final int scrollPosition = startRow > 0 ? startRow * itemHeight : 0;
scrollAndHighlight(childToScrollTo, scrollPosition, top: true);
break;
case ListDirection.pageUp:
int itemIndex = startRow - itemsVis;
if (itemIndex < 0) itemIndex = 0;
childToScrollTo = itemIndex;
final int scrollPosition =
childToScrollTo > 0 ? childToScrollTo * itemHeight : 0;
scrollAndHighlight(childToScrollTo, scrollPosition, top: true);
break;
case ListDirection.home:
childToScrollTo = 0;
scrollAndHighlight(childToScrollTo, childToScrollTo);
break;
case ListDirection.end:
childToScrollTo = _items.items.length - 1;
final int scrollPosition =
childToScrollTo > 0 ? (childToScrollTo - itemsVis) * itemHeight : 0;
scrollAndHighlight(childToScrollTo, scrollPosition);
break;
}
return childToScrollTo;
}
SelectableList<ScriptRef> _items;
ScriptRef _highlightRef;
String rootLib;
List<ScriptRef> get items => _items.items;
@override
CoreElement get element => _items;
bool get itemsHadClicked => _items.hadClicked;
Stream<html.KeyboardEvent> get onKeyDown {
return _items.onKeyDown;
}
Stream<ScriptRef> get onSelectionChanged {
return _items.onSelectionChanged;
}
Stream<void> get onScriptsChanged {
return _items.onItemsChanged;
}
void showScripts(
List<ScriptRef> scripts,
String rootLib,
String commonPrefix, {
bool selectRootScript = false,
ScriptRef selectScriptRef,
}) {
this.rootLib = rootLib;
scripts.sort((ScriptRef ref1, ScriptRef ref2) {
String uri1 = ref1.uri;
String uri2 = ref2.uri;
uri1 = _convertDartInternalUris(uri1);
uri2 = _convertDartInternalUris(uri2);
if (commonPrefix != null) {
if (uri1.startsWith(commonPrefix) && !uri2.startsWith(commonPrefix)) {
return -1;
} else if (!uri1.startsWith(commonPrefix) &&
uri2.startsWith(commonPrefix)) {
return 1;
}
}
if (uri1.startsWith('dart:') && !uri2.startsWith('dart:')) {
return 1;
} else if (!uri1.startsWith('dart:') && uri2.startsWith('dart:')) {
return -1;
}
return uri1.compareTo(uri2);
});
ScriptRef selection;
if (selectRootScript) {
selection = scripts.firstWhere((script) => script.uri == rootLib,
orElse: () => null);
} else if (selectScriptRef != null) {
selection = selectScriptRef;
}
_items.setItems(scripts,
selection: selection, scrollSelectionIntoView: true);
}
String _convertDartInternalUris(String uri) {
if (uri.startsWith('dart:_')) {
return uri.replaceAll('dart:_', 'dart:');
} else {
return uri;
}
}
void clearScripts() => _items.clearItems();
}
class ScriptsMatcher {
ScriptsMatcher(this._debuggerState);
ScriptsView _scriptsView;
CoreElement _textfield;
final DebuggerState _debuggerState;
ScriptRef _originalScriptRef;
int _originalScrollTop;
Map<String, List<ScriptRef>> matchingState = {};
String _lastMatchingChars;
String get lastMatchingChars => _lastMatchingChars;
// Current Row via matching and navigation (up/down ARROW, up/down PAGE, HOME
// and END.
int _selectRow = -1;
StreamSubscription _keyEventSubscription;
bool get active => _keyEventSubscription != null;
Function _finishCallback;
void finish() {
if (_finishCallback != null) _finishCallback();
}
void start(
ScriptRef revertScriptRef, ScriptsView scriptView, CoreElement textfield,
[Function finishCallback]) {
_scriptsView = scriptView;
_textfield = textfield;
if (finishCallback != null) _finishCallback = finishCallback;
_startMatching(revertScriptRef, true);
// Start handling user's keystrokes to show matching list of files.
_keyEventSubscription ??=
_textfield.onKeyDown.listen((html.KeyboardEvent e) {
switch (e.keyCode) {
case DOM_VK_RETURN:
reset();
_scriptsView.reset();
finish();
e.preventDefault();
break;
case DOM_VK_ESCAPE:
cancel();
break;
case DOM_VK_PAGE_UP:
_selectRow = _scriptsView.page(ListDirection.pageUp, _selectRow);
e.preventDefault();
break;
case DOM_VK_PAGE_DOWN:
_selectRow = _scriptsView.page(ListDirection.pageDown, _selectRow);
e.preventDefault();
break;
case DOM_VK_END:
_selectRow = _scriptsView.page(ListDirection.end);
e.preventDefault();
break;
case DOM_VK_HOME:
_selectRow = _scriptsView.page(ListDirection.home);
e.preventDefault();
break;
case DOM_VK_UP:
// Set selection one item up.
if (_selectRow > 0) {
_selectRow -= 1;
_scriptsView.scrollAndHighlight(_selectRow, -1);
}
e.preventDefault();
break;
case DOM_VK_DOWN:
// Set selection one item down.
if (_selectRow < _scriptsView.items.length - 1) {
_selectRow += 1;
_scriptsView.scrollAndHighlight(_selectRow, -1);
}
e.preventDefault();
break;
}
});
}
void cancel() {
revert();
finish();
}
void selectFirstItem() {
_selectRow = 0;
_scriptsView.scrollAndHighlight(_selectRow, -1);
}
// Finished matching - throw away all matching states.
void reset() {
ScriptRef selectedScriptRef;
if (_scriptsView._items.hadClicked) {
// Matcher was active but user clicked. So remember the item clicked on -
// is the currently selected.
selectedScriptRef = _scriptsView._items.selectedItem();
} else {
// Use the ScriptRef we've highlighted from match navigation.
selectedScriptRef = _scriptsView._highlightRef;
}
if (_keyEventSubscription != null) {
// No more event routing until user has clicked again in the textfield.
_keyEventSubscription.cancel();
_keyEventSubscription = null;
}
// Remember the whole set of ScriptRefs
final List<ScriptRef> originalRefs = matchingState[''];
_scriptsView.showScripts(
originalRefs,
_debuggerState.rootLib.uri,
_debuggerState.commonScriptPrefix,
selectScriptRef: selectedScriptRef,
);
// Lose all other intermediate matches - we're done.
matchingState.clear();
matchingState.putIfAbsent('', () => originalRefs);
(_textfield.element as html.InputElement).value = '';
_scriptsView._highlightRef = null;
}
int rowPosition(int row) {
final int itemHeight = _scriptsView._items.element.children[0].clientHeight;
return row * itemHeight;
}
/// Revert list and selection back to before the matcher (first click in the
/// textfield).
void revert() {
reset();
_scriptsView.showScripts(
matchingState[''],
_debuggerState.rootLib.uri,
_debuggerState.commonScriptPrefix,
selectScriptRef: _originalScriptRef,
);
if (_originalScriptRef != null) {
if (_scriptsView._items.selectedItem() != null) {
_scriptsView.element.scrollTop = _originalScrollTop;
}
}
}
void _startMatching(ScriptRef originalScriptRef, [bool initialize = false]) {
_originalScriptRef = originalScriptRef;
_originalScrollTop = _scriptsView.element.scrollTop;
final html.InputElement element = _textfield.element;
if (initialize || element.value.isEmpty) {
// Save all the scripts.
matchingState.putIfAbsent('', () => _scriptsView.items);
}
}
/// Show the list of files matching the set of keystrokes typed.
void displayMatchingScripts(String charsToMatch) {
String previousMatch = '';
final charsMatchLen = charsToMatch.length;
if (charsMatchLen > 0) {
previousMatch = charsToMatch.substring(0, charsMatchLen - 1);
}
List<ScriptRef> lastMatchingRefs = matchingState[previousMatch];
lastMatchingRefs ??= matchingState[''];
final List<ScriptRef> matchingRefs = lastMatchingRefs
.where((ScriptRef ref) =>
_debuggerState
.getShortScriptName(ref.uri)
// Case insensitive matching.
.toLowerCase()
.lastIndexOf('${charsToMatch.toLowerCase()}') >=
0)
.toList();
matchingState.putIfAbsent(charsToMatch, () => matchingRefs);
_scriptsView.clearScripts();
_scriptsView.showScripts(
matchingRefs,
_debuggerState.rootLib.uri,
_debuggerState.commonScriptPrefix,
);
selectFirstItem();
_scriptsView._items.scrollTop = 0;
_lastMatchingChars = charsToMatch;
}
}