blob: 969f6bcaabbbe901ce738c76949856159e17603d [file] [log] [blame]
// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
// for details. 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:convert';
import 'dart:html';
import 'package:nnbd_migration/instrumentation.dart';
import 'package:nnbd_migration/src/front_end/migration_info.dart';
import 'package:nnbd_migration/src/front_end/web/edit_details.dart';
import 'package:nnbd_migration/src/front_end/web/file_details.dart';
import 'package:nnbd_migration/src/front_end/web/navigation_tree.dart';
import 'package:path/path.dart' as _p;
import 'highlight_js.dart';
// TODO(devoncarew): Fix the issue where we can't load source maps.
// TODO(devoncarew): Include a favicon.
void main() {
document.addEventListener('DOMContentLoaded', (event) {
var path = window.location.pathname;
var offset = getOffset(window.location.href);
var lineNumber = getLine(window.location.href);
loadNavigationTree();
if (path != '/' && path != rootPath) {
// TODO(srawlins): replaceState?
loadFile(path, offset, lineNumber, true, callback: () {
pushState(path, offset, lineNumber);
});
}
final applyMigrationButton = document.querySelector('.apply-migration');
applyMigrationButton.onClick.listen((event) {
if (window.confirm(
"This will apply the changes you've previewed to your working "
'directory. It is recommended you commit any changes you made before '
'doing this.')) {
var navigationTreeJson = [
for (var entity in navigationTree) entity.toJson()
];
doPost('/apply-migration', {'navigationTree': navigationTreeJson})
.then((xhr) {
document.body.classes
..remove('proposed')
..add('applied');
}).catchError((e, st) {
handleError('Could not apply migration', e, st);
});
}
});
final rerunMigrationButton = document.querySelector('.rerun-migration');
rerunMigrationButton.onClick.listen((event) async {
try {
document.body.classes..add('rerunning');
var response = await doPost('/rerun-migration');
if (response['success'] as bool) {
window.location.reload();
} else {
handleRerunFailure(response['errors'] as List<Object>);
}
} catch (e, st) {
handleError('Failed to rerun migration', e, st);
} finally {
document.body.classes.remove('rerunning');
}
});
final reportProblemButton = document.querySelector('.report-problem');
reportProblemButton.onClick.listen((_) {
window.open(getGitHubProblemUri().toString(), 'report-problem');
});
document.querySelector('.popup-pane .close').onClick.listen(
(_) => document.querySelector('.popup-pane').style.display = 'none');
migrateUnitStatusIcon.onClick.listen((MouseEvent event) {
var unitPath = unitName.innerText;
var unitNavItem = document
.querySelector('.nav-panel [data-name*="$unitPath"]')
.parentNode as Element;
var statusIcon = unitNavItem.querySelector('.status-icon');
var entity = navigationTree.find(unitPath);
if (entity is NavigationTreeFileNode) {
toggleFileMigrationStatus(entity);
updateIconsForNode(statusIcon, entity);
updateParentIcons(unitNavItem, entity);
}
});
});
window.addEventListener('popstate', (event) {
var path = window.location.pathname;
var offset = getOffset(window.location.href);
var lineNumber = getLine(window.location.href);
if (path.length > 1) {
loadFile(path, offset, lineNumber, false);
} else {
// Blank out the page, for the index screen.
writeCodeAndRegions(path, FileDetails.empty(), true);
updatePage('&nbsp;', null);
}
});
}
/// Returns the "authToken" query parameter value of the current location.
// TODO(srawlins): This feels a little fragile, as the user can accidentally
// change/remove this text, and break their session. Normally auth tokens are
// stored in cookies, but there is no authentication step during which the
// server would attach such a token to cookies. We could do a little step where
// the first request to the server with the token is considered
// "authentication", and we subsequently store the token in cookies thereafter.
final String authToken =
Uri.parse(window.location.href).queryParameters['authToken'];
final Element editListElement =
document.querySelector('.edit-list .panel-content');
final Element editPanel = document.querySelector('.edit-panel .panel-content');
final Element footerPanel = document.querySelector('footer');
final Element headerPanel = document.querySelector('header');
final Element unitName = document.querySelector('#unit-name');
final Element migrateUnitStatusIconLabel =
document.querySelector('#migrate-unit-status-icon-label');
final Element migrateUnitStatusIcon =
document.querySelector('#migrate-unit-status-icon');
String get rootPath => querySelector('.root').text.trim();
String get sdkVersion => document.getElementById('sdk-version').text;
/*late final*/ List<NavigationTreeNode> navigationTree;
void addArrowClickHandler(Element arrow) {
var childList = (arrow.parentNode as Element).querySelector(':scope > ul');
// Animating height from "auto" to "0" is not supported by CSS [1], so all we
// have are hacks. The `* 2` allows for events in which the list grows in
// height when resized, with additional text wrapping.
// [1] https://css-tricks.com/using-css-transitions-auto-dimensions/
childList.style.maxHeight = '${childList.offsetHeight * 2}px';
arrow.onClick.listen((MouseEvent event) {
if (!childList.classes.contains('collapsed')) {
childList.classes.add('collapsed');
arrow.classes.add('collapsed');
} else {
childList.classes.remove('collapsed');
arrow.classes.remove('collapsed');
}
});
}
void addClickHandlers(String selector, bool clearEditDetails) {
var parentElement = document.querySelector(selector);
// Add navigation handlers for navigation links in the source code.
List<Element> navLinks = parentElement.querySelectorAll('.nav-link');
navLinks.forEach((link) {
link.onClick.listen((event) {
var tableElement = document.querySelector('table[data-path]');
var parentPath = tableElement.dataset['path'];
handleNavLinkClick(event, clearEditDetails, relativeTo: parentPath);
});
});
List<Element> regions = parentElement.querySelectorAll('.region');
if (regions.isNotEmpty) {
var table = parentElement.querySelector('table[data-path]');
var path = table.dataset['path'];
regions.forEach((Element anchor) {
anchor.onClick.listen((event) {
var offset = int.parse(anchor.dataset['offset']);
var line = int.parse(anchor.dataset['line']);
loadAndPopulateEditDetails(path, offset, line);
});
});
}
List<Element> addHintLinks = parentElement.querySelectorAll('.add-hint-link');
addHintLinks.forEach((link) {
link.onClick.listen(handleAddHintLinkClick);
});
}
/// Creates an icon using a `<span>` element and the Material Icons font.
Element createIcon([String name = '']) {
return document.createElement('span')
..classes.add('material-icons')
..innerText = name;
}
/// Perform a GET request on the path, return the json decoded response.
///
/// Returns a T so that the various json objects can be requested (lists, maps,
/// etc.).
Future<T> doGet<T>(String path,
{Map<String, String> queryParameters = const {}}) =>
doRequest(HttpRequest()
..open('GET', pathWithQueryParameters(path, queryParameters), async: true)
..setRequestHeader('Content-Type', 'application/json; charset=UTF-8'));
/// Perform a POST request on the path, return the JSON-decoded response.
Future<Map<String, Object>> doPost(String path, [Object body]) => doRequest(
HttpRequest()
..open('POST', pathWithQueryParameters(path, {}), async: true)
..setRequestHeader('Content-Type', 'application/json; charset=UTF-8'),
body);
/// Execute the [HttpRequest], handle its error codes, and return or throw the
/// response.
///
/// This is preferable over helper methods on [HttpRequest] because they ignore
/// the response body on a non-200 code. We want to get that response body in
/// that case, though, because it may be an error response from the server with
/// useful debugging information (stack trace etc).
Future<T> doRequest<T>(HttpRequest xhr, [Object body]) async {
var completer = Completer<HttpRequest>();
xhr.onLoad.listen((e) {
completer.complete(xhr);
});
xhr.onError.listen(completer.completeError);
xhr.send(body == null ? null : jsonEncode(body));
try {
await completer.future;
} catch (e, st) {
// Handle refused connection and make it user-presentable.
throw AsyncError('Error reaching migration preview server.', st);
}
final json = jsonDecode(xhr.responseText);
if (xhr.status == 200) {
// Request OK.
return json as T;
} else {
throw json;
}
}
/// Returns the URL of the "new issue" form for the SDK repository,
/// pre-populating the title, some labels, using [description], [exception], and
/// [stackTrace] in the body.
Uri getGitHubErrorUri(
String description, Object exception, Object stackTrace) =>
Uri.https('github.com', 'dart-lang/sdk/issues/new', {
'title': 'Customer-reported issue with NNBD migration tool: $description',
'labels': 'area-analyzer,analyzer-nnbd-migration,type-bug',
'body': '''
$description
Error: $exception
Please fill in the following:
**Name of package being migrated (if public)**:
**What I was doing when this issue occurred**:
**Is it possible to work around this issue**:
**Has this issue happened before, and if so, how often**:
**Dart SDK version**: $sdkVersion
**Additional details**:
Thanks for filing!
Stacktrace: _auto populated by migration preview tool._
```
$stackTrace
```
''',
});
/// Returns the URL of the "new issue" form for the SDK repository,
/// pre-populating some labels and a body template.
Uri getGitHubProblemUri() =>
Uri.https('github.com', 'dart-lang/sdk/issues/new', {
'title': 'Customer-reported issue with NNBD migration tool',
'labels': 'area-analyzer,analyzer-nnbd-migration,type-bug',
'body': '''
#### Steps to reproduce
#### What did you expect to happen?
#### What actually happened?
_Screenshots are appreciated_
**Dart SDK version**: $sdkVersion
Thanks for filing!
''',
});
int getLine(String location) {
var str = Uri.parse(location).queryParameters['line'];
return str == null ? null : int.tryParse(str);
}
int getOffset(String location) {
var str = Uri.parse(location).queryParameters['offset'];
return str == null ? null : int.tryParse(str);
}
void handleAddHintLinkClick(MouseEvent event) async {
var path = (event.currentTarget as Element).getAttribute('href');
// Don't navigate on link click.
event.preventDefault();
try {
var previousScrollPosition = _getCurrentScrollPosition();
// Directing the server to produce an edit; request it, then do work with
// the response.
await doPost(path);
await loadFile(window.location.pathname, null, null, false);
_scrollContentTo(previousScrollPosition);
} catch (e, st) {
handleError('Could not add/remove hint', e, st);
}
}
void handleError(String header, Object exception, Object stackTrace) {
String subheader;
if (exception is Map<String, Object> &&
exception['success'] == false &&
exception.containsKey('exception') &&
exception.containsKey('stackTrace')) {
subheader = exception['exception'] as String;
stackTrace = exception['stackTrace'];
} else {
subheader = exception.toString();
}
final popupPane = document.querySelector('.popup-pane');
popupPane.querySelector('h2').innerText = header;
popupPane.querySelector('p').innerText = subheader;
popupPane.querySelector('pre').innerText = stackTrace.toString();
var bottom = popupPane.querySelector('a.bottom') as AnchorElement;
bottom
..href = getGitHubErrorUri(header, subheader, stackTrace).toString()
..style.display = 'initial';
popupPane..style.display = 'initial';
logError('$header: $exception', stackTrace);
}
void handleNavLinkClick(
MouseEvent event,
bool clearEditDetails, {
String relativeTo,
}) {
Element target = event.currentTarget as Element;
event.preventDefault();
var location = target.getAttribute('href');
var path = _stripQuery(location);
var offset = getOffset(location);
var lineNumber = getLine(location);
if (offset != null) {
navigate(path, offset, lineNumber, clearEditDetails, callback: () {
pushState(path, offset, lineNumber);
});
} else {
navigate(path, null, null, clearEditDetails, callback: () {
pushState(path, null, null);
});
}
}
void handleRerunFailure(List<Object> errors) {
final popupPane = document.querySelector('.popup-pane');
popupPane.querySelector('h2').innerText = 'Failed to rerun from sources';
popupPane.querySelector('p').innerText =
'Sources contain static analysis errors:';
popupPane.querySelector('pre').innerText = errors.cast<Map>().map((error) {
return '${error['severity']} - ${error['message']} '
'at ${error['location']} - (${error['code']})';
}).join('\n');
popupPane.querySelector('a.bottom').style.display = 'none';
popupPane.style.display = 'initial';
// TODO(srawlins): I think we should lock down the entire web UI, except for
// the "Rerun from source" button.
}
void highlightAllCode() {
document.querySelectorAll('.code').forEach((Element block) {
hljs.highlightBlock(block);
});
}
/// Loads the explanation for [region], into the ".panel-content" div.
void loadAndPopulateEditDetails(String path, int offset, int line) async {
try {
final responseJson = await doGet<Map<String, Object>>(path,
queryParameters: {'region': 'region', 'offset': '$offset'});
var response = EditDetails.fromJson(responseJson);
populateEditDetails(response);
pushState(path, offset, line);
addClickHandlers('.edit-panel .panel-content', false);
} catch (e, st) {
handleError('Could not load edit details', e, st);
}
}
/// Loads the file at [path] from the server, optionally scrolling [offset] into
/// view.
Future<void> loadFile(
String path,
int offset,
int line,
bool clearEditDetails, {
VoidCallback callback,
}) async {
// Handle the case where we're requesting a directory.
if (!path.endsWith('.dart')) {
writeCodeAndRegions(path, FileDetails.empty(), clearEditDetails);
updatePage(path);
if (callback != null) {
callback();
}
return;
}
try {
// Navigating to another file; request it, then do work with the response.
final response = await doGet<Map<String, Object>>(path,
queryParameters: {'inline': 'true'});
writeCodeAndRegions(path, FileDetails.fromJson(response), clearEditDetails);
maybeScrollToAndHighlight(offset, line);
var filePathPart = _stripQuery(path);
updatePage(filePathPart, offset);
if (callback != null) {
callback();
}
} catch (e, st) {
handleError('Could not load dart file $path', e, st);
}
}
/// Load the navigation tree into the ".nav-tree" div.
void loadNavigationTree() async {
var path = '/_preview/navigationTree.json';
// Request the navigation tree, then do work with the response.
try {
final response = await doGet<List<Object>>(path);
var navTree = document.querySelector('.nav-tree');
navTree.innerHtml = '';
navigationTree = NavigationTreeNode.listFromJson(response);
writeNavigationSubtree(navTree, navigationTree,
enablePartialMigration: true);
} catch (e, st) {
handleError('Could not load navigation tree', e, st);
}
}
void logError(Object e, Object st) {
window.console.error('$e');
window.console.error('$st');
}
/// Scroll an element into view if it is not visible.
void maybeScrollIntoView(Element element) {
var rect = element.getBoundingClientRect();
// A line of text in the code view is 14px high. Including it here means we
// only choose to _not_ scroll a line of code into view if the entire line is
// visible.
var lineHeight = 14;
var visibleCeiling = headerPanel.offsetHeight + lineHeight;
var visibleFloor =
window.innerHeight - (footerPanel.offsetHeight + lineHeight);
if (rect.bottom > visibleFloor) {
element.scrollIntoView();
} else if (rect.top < visibleCeiling) {
element.scrollIntoView();
}
}
/// Scrolls target with id [offset] into view if it is not currently in view.
///
/// Falls back to [lineNumber] if a target with id "o$offset" does not exist.
///
/// Also adds the "target" class, highlighting the target, and the "highlight"
/// class to the entire line on which the target lies.
///
/// If [offset] is null, instead scrolls to the top of the file.
void maybeScrollToAndHighlight(int offset, int lineNumber) {
Element target;
Element line;
if (offset != null) {
target = document.getElementById('o$offset');
line = document.querySelector('.line-$lineNumber');
if (target != null) {
maybeScrollIntoView(target);
target.classes.add('target');
} else if (line != null) {
// If the target doesn't exist, but the line does, scroll that into view
// instead.
maybeScrollIntoView(line.parent);
}
if (line != null) {
(line.parentNode as Element).classes.add('highlight');
}
} else {
// If no offset is given, this is likely a navigation link, and we need to
// scroll back to the top of the page.
var lines = document.querySelectorAll('.line-no');
if (lines.isEmpty) {
// I don't see how this could happen, but return anyhow.
return;
}
maybeScrollIntoView(lines.first);
}
}
/// Navigate to [path] and optionally scroll [offset] into view.
///
/// If [callback] is present, it will be called after the server response has
/// been processed, and the content has been updated on the page.
void navigate(
String path,
int offset,
int lineNumber,
bool clearEditDetails, {
VoidCallback callback,
}) {
var currentOffset = getOffset(window.location.href);
var currentLineNumber = getLine(window.location.href);
removeHighlight(currentOffset, currentLineNumber);
if (path == window.location.pathname) {
// Navigating to same file; just scroll into view.
maybeScrollToAndHighlight(offset, lineNumber);
if (callback != null) {
callback();
}
} else {
loadFile(path, offset, lineNumber, clearEditDetails, callback: callback);
}
}
/// Returns [path], which may include query parameters, with a new path which
/// adds (or replaces) parameters from [queryParameters].
///
/// Additionally, the "authToken" parameter will be added with the authToken
/// found in the current location.
String pathWithQueryParameters(
String path, Map<String, String> queryParameters) {
var uri = Uri.parse(path);
var mergedQueryParameters = {
...uri.queryParameters,
...queryParameters,
'authToken': authToken
};
return uri.replace(queryParameters: mergedQueryParameters).toString();
}
String pluralize(int count, String single, {String multiple}) {
return count == 1 ? single : (multiple ?? '${single}s');
}
void populateEditDetails([EditDetails response]) {
// Clear out any current edit details.
editPanel.innerHtml = '';
if (response == null) {
Element p = ParagraphElement()
..text = 'See details about a proposed edit.'
..classes = ['placeholder'];
editPanel.append(p);
p.scrollIntoView();
return;
}
var fileDisplayPath = response.displayPath;
var parentDirectory = _p.dirname(fileDisplayPath);
// 'Changed ... at foo.dart:12.'
var explanationMessage = response.explanation;
var relPath = _p.relative(fileDisplayPath, from: rootPath);
var line = response.line;
Element explanation = document.createElement('p');
editPanel.append(explanation);
explanation
..appendText('$explanationMessage at ')
..append(AnchorElement(
href: pathWithQueryParameters(
response.uriPath, {'line': line.toString()}))
..appendText('$relPath:$line.'));
explanation.scrollIntoView();
_populateEditTraces(response, editPanel, parentDirectory);
_populateEditLinks(response, editPanel);
}
/// Write the contents of the Edit List, from JSON data [editListData].
void populateProposedEdits(
String path, Map<String, List<EditListItem>> edits, bool clearEditDetails) {
editListElement.innerHtml = '';
var editCount = edits.length;
if (editCount == 0) {
Element p = document.createElement('p');
editListElement.append(p);
p.append(Text('No proposed edits'));
} else {
for (var entry in edits.entries) {
Element p = document.createElement('p');
editListElement.append(p);
p.append(Text('${entry.key}:'));
Element list = document.createElement('ul');
editListElement.append(list);
for (var edit in entry.value) {
Element item = document.createElement('li');
list.append(item);
item.classes.add('edit');
AnchorElement anchor = AnchorElement();
item.append(anchor);
anchor.classes.add('edit-link');
var offset = edit.offset;
anchor.dataset['offset'] = '$offset';
var line = edit.line;
anchor.dataset['line'] = '$line';
anchor.append(Text('line $line'));
anchor.setAttribute(
'href',
pathWithQueryParameters(window.location.pathname, {
'line': '$line',
'offset': '$offset',
}));
anchor.onClick.listen((MouseEvent event) {
event.preventDefault();
navigate(window.location.pathname, offset, line, true, callback: () {
pushState(window.location.pathname, offset, line);
});
loadAndPopulateEditDetails(path, offset, line);
});
item.append(Text(': ${edit.explanation}'));
}
}
}
if (clearEditDetails) {
populateEditDetails();
}
}
void pushState(String path, int offset, int line) {
var uri = Uri.parse('${window.location.origin}$path');
var params = {
if (offset != null) 'offset': '$offset',
if (line != null) 'line': '$line',
'authToken': authToken,
};
uri = uri.replace(queryParameters: params);
window.history.pushState({}, '', uri.toString());
}
/// If [path] lies within [root], return the relative path of [path] from [root].
/// Otherwise, return [path].
String relativePath(String path) {
var root = querySelector('.root').text + '/';
if (path.startsWith(root)) {
return path.substring(root.length);
} else {
return path;
}
}
/// Remove highlighting from [offset].
void removeHighlight(int offset, int lineNumber) {
if (offset != null) {
var anchor = document.getElementById('o$offset');
if (anchor != null) {
anchor.classes.remove('target');
}
}
if (lineNumber != null) {
var line = document.querySelector('.line-$lineNumber');
if (line != null) {
line.parent.classes.remove('highlight');
}
}
}
void toggleDirectoryMigrationStatus(NavigationTreeDirectoryNode entity) {
switch (entity.migrationStatus) {
case UnitMigrationStatus.alreadyMigrated:
// This tree cannot be toggled.
break;
case UnitMigrationStatus.migrating:
// At least one child file is 'migrating' (some may be 'already
// migrated'). Toggle all 'migrating' children to opt out.
entity.toggleChildrenToOptOut();
break;
case UnitMigrationStatus.optingOut:
// At least one child file is 'opting out' (some may be 'already
// migrated'). Toggle all 'migrating' children to migrate.
entity.toggleChildrenToMigrate();
break;
case UnitMigrationStatus.indeterminate:
// At least one child file is 'migrating' and at least one child file is
// 'opting out' (some may be 'already migrated'). Toggle all 'migrating'
// children to migrate.
entity.toggleChildrenToMigrate();
}
}
void toggleFileMigrationStatus(NavigationTreeFileNode entity) {
switch (entity.migrationStatus) {
case UnitMigrationStatus.alreadyMigrated:
// This file cannot be toggled.
break;
case UnitMigrationStatus.migrating:
entity.migrationStatus = UnitMigrationStatus.optingOut;
break;
case UnitMigrationStatus.optingOut:
entity.migrationStatus = UnitMigrationStatus.migrating;
break;
case UnitMigrationStatus.indeterminate:
throw StateError('File ${entity.path} should not have '
'indeterminate migration status');
}
}
/// Updates the navigation [icon] and current file icon according to the current
/// migration status of [entity].
void updateIconsForNode(Element icon, NavigationTreeNode entity) {
updateIconForStatus(icon, entity.migrationStatus);
// Update the status at the top of the file view if [entity] represents the
// current file.
var unitPath = unitName.innerText;
if (entity.path == unitPath) {
updateIconForStatus(migrateUnitStatusIcon, entity.migrationStatus);
}
}
/// Updates [icon] according to [status].
void updateIconForStatus(Element icon, UnitMigrationStatus status) {
switch (status) {
case UnitMigrationStatus.alreadyMigrated:
icon.innerText = 'check_box';
icon.classes.add('already-migrated');
icon.setAttribute('title', 'Already migrated');
break;
case UnitMigrationStatus.migrating:
icon.innerText = 'check_box';
icon.classes.remove('opted-out');
icon.classes.add('migrating');
icon.setAttribute('title', 'Migrating to null safety');
break;
case UnitMigrationStatus.optingOut:
icon.innerText = 'check_box_outline_blank';
icon.classes.remove('migrating');
icon.classes.add('opted-out');
icon.setAttribute('title', 'Opting out of null safety');
break;
default:
icon.innerText = 'indeterminate_check_box';
icon.classes.remove('migrating');
// 'opted-out' is the same style as 'indeterminate'.
icon.classes.add('opted-out');
icon.setAttribute(
'title', "Mixed statuses of 'migrating' and 'opting out'");
break;
}
}
/// Update the heading and navigation links.
///
/// Call this after updating page content on a navigation.
void updatePage(String path, [int offset]) {
path = relativePath(path);
// Update page heading.
unitName.text = path;
// Update navigation styles.
document.querySelectorAll('.nav-panel .nav-link').forEach((Element link) {
var name = link.dataset['name'];
if (name == path) {
link.classes.add('selected-file');
} else {
link.classes.remove('selected-file');
}
});
migrateUnitStatusIconLabel.classes.add('visible');
}
/// Updates the parent icons of [entity] with list item [element] in the
/// navigation tree.
void updateParentIcons(Element element, NavigationTreeNode entity) {
var parent = entity.parent;
if (parent != null) {
var parentElement = (element.parentNode as Element).parentNode as Element;
var statusIcon = parentElement.querySelector(':scope > .status-icon');
updateIconsForNode(statusIcon, parent);
updateParentIcons(parentElement, parent);
}
}
/// Updates subtree icons for the children [entity] with list item [element].
void updateSubtreeIcons(Element element, NavigationTreeDirectoryNode entity) {
for (var child in entity.subtree) {
var childNode = element.querySelector('[data-name*="${child.path}"]');
if (child is NavigationTreeDirectoryNode) {
updateSubtreeIcons(childNode, child);
var childIcon = childNode.querySelector(':scope > .status-icon');
updateIconsForNode(childIcon, entity);
} else {
var childIcon = (childNode.parentNode as Element)
.querySelector(':scope > .status-icon');
updateIconsForNode(childIcon, child);
}
}
}
/// Load data from [data] into the .code and the .regions divs.
void writeCodeAndRegions(String path, FileDetails data, bool clearEditDetails) {
var regionsElement = document.querySelector('.regions');
var codeElement = document.querySelector('.code');
_PermissiveNodeValidator.setInnerHtml(regionsElement, data.regions);
_PermissiveNodeValidator.setInnerHtml(codeElement, data.navigationContent);
populateProposedEdits(path, data.edits, clearEditDetails);
// highlightAllCode is remarkably slow (about 4 seconds to handle a 300k file
// on a Pixelbook), so skip it for large files.
if (data.sourceCode.length < 200000) {
highlightAllCode();
}
addClickHandlers('.code', true);
addClickHandlers('.regions', true);
}
void writeNavigationSubtree(
Element parentElement, List<NavigationTreeNode> tree,
{bool enablePartialMigration = false}) {
Element ul = document.createElement('ul');
parentElement.append(ul);
for (var entity in tree) {
Element li = document.createElement('li');
ul.append(li);
if (entity is NavigationTreeDirectoryNode) {
li.classes.add('dir');
li.dataset['name'] = entity.path;
Element arrow = document.createElement('span');
li.append(arrow);
arrow.classes.add('arrow');
arrow.innerHtml = '&#x25BC;';
var folderIcon = createIcon('folder_open');
li.append(folderIcon);
li.append(Text(entity.name));
writeNavigationSubtree(li, entity.subtree,
enablePartialMigration: enablePartialMigration);
if (enablePartialMigration) {
var statusIcon = createIcon('indeterminate_check_box')
..classes.add('status-icon');
updateIconsForNode(statusIcon, entity);
statusIcon.onClick.listen((MouseEvent event) {
toggleDirectoryMigrationStatus(entity);
updateSubtreeIcons(li, entity);
updateIconsForNode(statusIcon, entity);
updateParentIcons(li, entity);
});
li.insertBefore(statusIcon, folderIcon);
}
addArrowClickHandler(arrow);
} else if (entity is NavigationTreeFileNode) {
if (enablePartialMigration) {
var statusIcon = createIcon()..classes.add('status-icon');
updateIconsForNode(statusIcon, entity);
statusIcon.onClick.listen((MouseEvent event) {
toggleFileMigrationStatus(entity);
updateIconsForNode(statusIcon, entity);
updateParentIcons(li, entity);
});
li.append(statusIcon);
}
li.append(createIcon('insert_drive_file'));
Element a = document.createElement('a');
li.append(a);
a.classes.add('nav-link');
a.dataset['name'] = entity.path;
a.setAttribute('href', pathWithQueryParameters(entity.href, {}));
a.append(Text(entity.name));
a.onClick.listen((MouseEvent event) => handleNavLinkClick(event, true));
var editCount = entity.editCount;
if (editCount > 0) {
Element editsBadge = document.createElement('span');
li.append(editsBadge);
editsBadge.classes.add('edit-count');
editsBadge.setAttribute(
'title', '$editCount ${pluralize(editCount, 'proposed edit')}');
editsBadge.append(Text(editCount.toString()));
}
}
}
}
void _addHintAction(HintAction hintAction, Node drawer, TargetLink link) {
drawer.append(ButtonElement()
..onClick.listen((event) async {
try {
var previousScrollPosition = _getCurrentScrollPosition();
await doPost(
pathWithQueryParameters('/apply-hint', {}), hintAction.toJson());
var path = _stripQuery(link.href);
await loadFile(path, null, link.line, false);
document.body.classes.add('needs-rerun');
_scrollContentTo(previousScrollPosition);
} catch (e, st) {
handleError('Could not apply hint', e, st);
}
})
..appendText(hintAction.kind.description));
}
AnchorElement _aElementForLink(TargetLink link) {
var targetLine = link.line;
AnchorElement a = AnchorElement();
a.append(Text('${link.path}:$targetLine'));
a.setAttribute('href', link.href);
a.classes.add('nav-link');
return a;
}
int _getCurrentScrollPosition() => document.querySelector('.content').scrollTop;
void _populateEditLinks(EditDetails response, Element editPanel) {
if (response.edits == null) {
return;
}
var subheading = editPanel.append(document.createElement('p'));
subheading.append(document.createElement('span')
..classes = ['type-description']
..append(Text('Actions')));
subheading.append(Text(':'));
Element editParagraph = document.createElement('p');
editPanel.append(editParagraph);
for (var edit in response.edits) {
Element a = document.createElement('a');
editParagraph.append(a);
a.append(Text(edit.description));
a.setAttribute('href', edit.href);
a.classes = ['add-hint-link', 'before-apply', 'button'];
}
}
void _populateEditTraces(
EditDetails response, Element editPanel, String parentDirectory) {
for (var trace in response.traces) {
var traceParagraph =
editPanel.append(document.createElement('p')..classes = ['trace']);
traceParagraph.append(document.createElement('span')
..classes = ['type-description']
..append(Text(trace.description)));
traceParagraph.append(Text(':'));
var ul = traceParagraph
.append(document.createElement('ul')..classes = ['trace']);
for (var entry in trace.entries) {
Element li = document.createElement('li');
ul.append(li);
li.append(document.createElement('span')
..classes = ['function']
..appendTextWithBreaks(entry.function ?? 'unknown'));
var link = entry.link;
if (link != null) {
li.append(Text(' ('));
li.append(_aElementForLink(link));
li.append(Text(')'));
}
li.append(Text(': '));
li.appendTextWithBreaks(entry.description ?? 'unknown');
if (entry.hintActions.isNotEmpty) {
var drawer = li.append(
document.createElement('p')..classes = ['drawer', 'before-apply']);
for (final hintAction in entry.hintActions) {
_addHintAction(hintAction, drawer, link);
}
}
}
}
}
void _scrollContentTo(int top) =>
document.querySelector('.content').scrollTop = top;
String _stripQuery(String path) =>
path.contains('?') ? path.substring(0, path.indexOf('?')) : path;
class _PermissiveNodeValidator implements NodeValidator {
static _PermissiveNodeValidator instance = _PermissiveNodeValidator();
@override
bool allowsAttribute(Element element, String attributeName, String value) {
return true;
}
@override
bool allowsElement(Element element) {
return true;
}
static void setInnerHtml(Element element, String html) {
element.setInnerHtml(html, validator: instance);
}
}
/// An extension on Element that fits into cascades.
extension on Element {
/// Append [text] to this, inserting a word break before each '.' character.
void appendTextWithBreaks(String text) {
var textParts = text.split('.');
append(Text(textParts.first));
for (var substring in textParts.skip(1)) {
// Replace the '.' with a zero-width space and a '.'.
appendHtml('&#8203;.');
append(Text(substring));
}
}
}
extension on List<NavigationTreeNode> {
/// Finds the node with path equal to [path], recursively, or `null`.
NavigationTreeNode find(String path) {
for (var node in this) {
if (node is NavigationTreeDirectoryNode) {
var foundInSubtree = node.subtree.find(path);
if (foundInSubtree != null) return foundInSubtree;
} else {
assert(node is NavigationTreeFileNode);
if (node.path == path) return node;
}
}
return null;
}
}