| // Copyright (c) 2014, 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. |
| |
| library trydart.interaction_manager; |
| |
| import 'dart:html'; |
| |
| import 'dart:convert' show |
| JSON; |
| |
| import 'dart:math' show |
| max, |
| min; |
| |
| import 'dart:async' show |
| Completer, |
| Future, |
| Timer; |
| |
| import 'dart:collection' show |
| Queue; |
| |
| import 'package:compiler/src/scanner/string_scanner.dart' show |
| StringScanner; |
| |
| import 'package:compiler/src/tokens/token.dart' show |
| BeginGroupToken, |
| ErrorToken, |
| Token, |
| UnmatchedToken, |
| UnterminatedToken; |
| |
| import 'package:compiler/src/tokens/token_constants.dart' show |
| EOF_TOKEN, |
| STRING_INTERPOLATION_IDENTIFIER_TOKEN, |
| STRING_INTERPOLATION_TOKEN, |
| STRING_TOKEN; |
| |
| import 'package:compiler/src/io/source_file.dart' show |
| StringSourceFile; |
| |
| import 'package:compiler/src/string_validator.dart' show |
| StringValidator; |
| |
| import 'package:compiler/src/tree/tree.dart' show |
| StringQuoting; |
| |
| import 'compilation.dart' show |
| currentSource, |
| startCompilation; |
| |
| import 'ui.dart' show |
| currentTheme, |
| hackDiv, |
| mainEditorPane, |
| observer, |
| outputDiv, |
| outputFrame, |
| statusDiv; |
| |
| import 'decoration.dart' show |
| CodeCompletionDecoration, |
| Decoration, |
| DiagnosticDecoration, |
| error, |
| info, |
| warning; |
| |
| import 'html_to_text.dart' show |
| htmlToText; |
| |
| import 'compilation_unit.dart' show |
| CompilationUnit; |
| |
| import 'selection.dart' show |
| TrySelection, |
| isCollapsed; |
| |
| import 'editor.dart' as editor; |
| |
| import 'mock.dart' as mock; |
| |
| import 'settings.dart' as settings; |
| |
| import 'shadow_root.dart' show |
| getShadowRoot, |
| getText, |
| setShadowRoot, |
| containsNode; |
| |
| import 'iframe_error_handler.dart' show |
| ErrorMessage; |
| |
| const String TRY_DART_NEW_DEFECT = |
| 'https://code.google.com/p/dart/issues/entry' |
| '?template=Try+Dart+Internal+Error'; |
| |
| /// How frequently [InteractionManager.onHeartbeat] is called. |
| const Duration HEARTBEAT_INTERVAL = const Duration(milliseconds: 50); |
| |
| /// Determines how frequently "project" files are saved. The time is measured |
| /// from the time of last modification. |
| const Duration SAVE_INTERVAL = const Duration(seconds: 5); |
| |
| /// Determines how frequently the compiler is invoked. The time is measured |
| /// from the time of last modification. |
| const Duration COMPILE_INTERVAL = const Duration(seconds: 1); |
| |
| /// Determines how frequently the compiler is invoked in "live" mode. The time |
| /// is measured from the time of last modification. |
| const Duration LIVE_COMPILE_INTERVAL = const Duration(seconds: 0); |
| |
| /// Determines if a compilation is slow. The time is measured from the last |
| /// compilation started. If a compilation is slow, progress information is |
| /// displayed to the user, but the console is untouched if the compilation |
| /// finished quickly. The purpose is to reduce flicker in the UI. |
| const Duration SLOW_COMPILE = const Duration(seconds: 1); |
| |
| const int TAB_WIDTH = 2; |
| |
| /** |
| * UI interaction manager for the entire application. |
| */ |
| abstract class InteractionManager { |
| // Design note: All UI interactions go through one instance of this |
| // class. This is by design. |
| // |
| // Simplicity in UI is in the eye of the beholder, not the implementor. Great |
| // 'natural UI' is usually achieved with substantial implementation |
| // complexity that doesn't modularize well and has nasty complicated state |
| // dependencies. |
| // |
| // In rare cases, some UI components can be independent of this state |
| // machine. For example, animation and auto-save loops. |
| |
| // Implementation note: The state machine is actually implemented by |
| // [InteractionContext], this class represents public event handlers. |
| |
| factory InteractionManager() => new InteractionContext(); |
| |
| InteractionManager.internal(); |
| |
| // TODO(ahe): Remove this. |
| Set<AnchorElement> get oldDiagnostics; |
| |
| void onInput(Event event); |
| |
| // TODO(ahe): Rename to onKeyDown (as it is called in response to keydown |
| // event). |
| void onKeyUp(KeyboardEvent event); |
| |
| void onMutation(List<MutationRecord> mutations, MutationObserver observer); |
| |
| void onSelectionChange(Event event); |
| |
| /// Called when the content of a CompilationUnit changed. |
| void onCompilationUnitChanged(CompilationUnit unit); |
| |
| Future<List<String>> projectFileNames(); |
| |
| /// Called when the user selected a new project file. |
| void onProjectFileSelected(String projectFile); |
| |
| /// Called when notified about a project file changed (on the server). |
| void onProjectFileFsEvent(MessageEvent e); |
| |
| /// Called every [HEARTBEAT_INTERVAL]. |
| void onHeartbeat(Timer timer); |
| |
| /// Called by [:window.onMessage.listen:]. |
| void onWindowMessage(MessageEvent event); |
| |
| void onCompilationFailed(String firstError); |
| |
| void onCompilationDone(); |
| |
| /// Called when a compilation is starting, but just before sending the |
| /// initiating message to the compiler isolate. |
| void compilationStarting(); |
| |
| // TODO(ahe): Remove this from InteractionManager, but not from InitialState. |
| void consolePrintLine(line); |
| |
| /// Called just before running a freshly compiled program. |
| void aboutToRun(); |
| |
| /// Called when an error occurs when running user code in an iframe. |
| void onIframeError(ErrorMessage message); |
| |
| void verboseCompilerMessage(String message); |
| |
| /// Called if the compiler crashes. |
| void onCompilerCrash(data); |
| |
| /// Called if an internal error is detected. |
| void onInternalError(message); |
| } |
| |
| /** |
| * State machine for UI interactions. |
| */ |
| class InteractionContext extends InteractionManager { |
| InteractionState state; |
| |
| final Map<String, CompilationUnit> projectFiles = <String, CompilationUnit>{}; |
| |
| final Set<CompilationUnit> modifiedUnits = new Set<CompilationUnit>(); |
| |
| final Queue<CompilationUnit> unitsToSave = new Queue<CompilationUnit>(); |
| |
| /// Tracks time since last modification of a "project" file. |
| final Stopwatch saveTimer = new Stopwatch(); |
| |
| /// Tracks time since last modification. |
| final Stopwatch compileTimer = new Stopwatch(); |
| |
| /// Tracks elapsed time of current compilation. |
| final Stopwatch elapsedCompilationTime = new Stopwatch(); |
| |
| CompilationUnit currentCompilationUnit = |
| // TODO(ahe): Don't use a fake unit. |
| new CompilationUnit('fake', ''); |
| |
| Timer heartbeat; |
| |
| Completer<String> completeSaveOperation; |
| |
| bool shouldClearConsole = false; |
| |
| Element compilerConsole; |
| |
| bool isFirstCompile = true; |
| |
| final Set<AnchorElement> oldDiagnostics = new Set<AnchorElement>(); |
| |
| final Duration compileInterval = settings.live.value |
| ? LIVE_COMPILE_INTERVAL |
| : COMPILE_INTERVAL; |
| |
| InteractionContext() |
| : super.internal() { |
| state = new InitialState(this); |
| heartbeat = new Timer.periodic(HEARTBEAT_INTERVAL, onHeartbeat); |
| } |
| |
| void onInput(Event event) => state.onInput(event); |
| |
| void onKeyUp(KeyboardEvent event) => state.onKeyUp(event); |
| |
| void onMutation(List<MutationRecord> mutations, MutationObserver observer) { |
| workAroundFirefoxBug(); |
| try { |
| try { |
| return state.onMutation(mutations, observer); |
| } finally { |
| // Discard any mutations during the observer, as these can lead to |
| // infinite loop. |
| observer.takeRecords(); |
| } |
| } catch (error, stackTrace) { |
| try { |
| editor.isMalformedInput = true; |
| state.onInternalError( |
| '\nError and stack trace:\n$error\n$stackTrace\n'); |
| } catch (e) { |
| // Double faults ignored. |
| } |
| rethrow; |
| } |
| } |
| |
| void onSelectionChange(Event event) => state.onSelectionChange(event); |
| |
| void onCompilationUnitChanged(CompilationUnit unit) { |
| return state.onCompilationUnitChanged(unit); |
| } |
| |
| Future<List<String>> projectFileNames() => state.projectFileNames(); |
| |
| void onProjectFileSelected(String projectFile) { |
| return state.onProjectFileSelected(projectFile); |
| } |
| |
| void onProjectFileFsEvent(MessageEvent e) { |
| return state.onProjectFileFsEvent(e); |
| } |
| |
| void onHeartbeat(Timer timer) => state.onHeartbeat(timer); |
| |
| void onWindowMessage(MessageEvent event) => state.onWindowMessage(event); |
| |
| void onCompilationFailed(String firstError) { |
| return state.onCompilationFailed(firstError); |
| } |
| |
| void onCompilationDone() => state.onCompilationDone(); |
| |
| void compilationStarting() => state.compilationStarting(); |
| |
| void consolePrintLine(line) => state.consolePrintLine(line); |
| |
| void aboutToRun() => state.aboutToRun(); |
| |
| void onIframeError(ErrorMessage message) => state.onIframeError(message); |
| |
| void verboseCompilerMessage(String message) { |
| return state.verboseCompilerMessage(message); |
| } |
| |
| void onCompilerCrash(data) => state.onCompilerCrash(data); |
| |
| void onInternalError(message) => state.onInternalError(message); |
| } |
| |
| abstract class InteractionState implements InteractionManager { |
| InteractionContext get context; |
| |
| // TODO(ahe): Remove this. |
| Set<AnchorElement> get oldDiagnostics { |
| throw 'Use context.oldDiagnostics instead'; |
| } |
| |
| void set state(InteractionState newState); |
| |
| void onStateChanged(InteractionState previous) { |
| } |
| |
| void transitionToInitialState() { |
| state = new InitialState(context); |
| } |
| } |
| |
| class InitialState extends InteractionState { |
| final InteractionContext context; |
| bool requestCodeCompletion = false; |
| |
| InitialState(this.context); |
| |
| void set state(InteractionState state) { |
| InteractionState previous = context.state; |
| if (previous != state) { |
| context.state = state; |
| state.onStateChanged(previous); |
| } |
| } |
| |
| void onInput(Event event) { |
| state = new PendingInputState(context); |
| } |
| |
| void onKeyUp(KeyboardEvent event) { |
| if (computeHasModifier(event)) { |
| onModifiedKeyUp(event); |
| } else { |
| onUnmodifiedKeyUp(event); |
| } |
| } |
| |
| void onModifiedKeyUp(KeyboardEvent event) { |
| if (event.getModifierState("Shift")) return onShiftedKeyUp(event); |
| switch (event.keyCode) { |
| case KeyCode.S: |
| // Disable Ctrl-S, Cmd-S, etc. We have observed users hitting these |
| // keys often when using Try Dart and getting frustrated. |
| event.preventDefault(); |
| // TODO(ahe): Consider starting a compilation. |
| break; |
| } |
| } |
| |
| void onShiftedKeyUp(KeyboardEvent event) { |
| switch (event.keyCode) { |
| case KeyCode.TAB: |
| event.preventDefault(); |
| break; |
| } |
| } |
| |
| void onUnmodifiedKeyUp(KeyboardEvent event) { |
| switch (event.keyCode) { |
| case KeyCode.ENTER: { |
| Selection selection = window.getSelection(); |
| if (isCollapsed(selection)) { |
| event.preventDefault(); |
| Node node = selection.anchorNode; |
| if (node is Text) { |
| Text text = node; |
| int offset = selection.anchorOffset; |
| // If at end-of-file, insert an extra newline. The the extra |
| // newline ensures that the next line isn't empty. At least Chrome |
| // behaves as if "\n" is just a single line. "\nc" (where c is any |
| // character) is two lines, according to Chrome. |
| String newline = isAtEndOfFile(text, offset) ? '\n\n' : '\n'; |
| text.insertData(offset, newline); |
| selection.collapse(text, offset + 1); |
| } else if (node is Element) { |
| node.appendText('\n\n'); |
| selection.collapse(node.firstChild, 1); |
| } else { |
| window.console |
| ..error('Unexpected node') |
| ..dir(node); |
| } |
| } |
| break; |
| } |
| case KeyCode.TAB: { |
| Selection selection = window.getSelection(); |
| if (isCollapsed(selection)) { |
| event.preventDefault(); |
| Text text = new Text(' ' * TAB_WIDTH); |
| selection.getRangeAt(0).insertNode(text); |
| selection.collapse(text, TAB_WIDTH); |
| } |
| break; |
| } |
| } |
| |
| // This is a hack to get Safari (iOS) to send mutation events on |
| // contenteditable. |
| // TODO(ahe): Move to onInput? |
| var newDiv = new DivElement(); |
| hackDiv.replaceWith(newDiv); |
| hackDiv = newDiv; |
| } |
| |
| void onMutation(List<MutationRecord> mutations, MutationObserver observer) { |
| removeCodeCompletion(); |
| |
| Selection selection = window.getSelection(); |
| TrySelection trySelection = new TrySelection(mainEditorPane, selection); |
| |
| Set<Node> normalizedNodes = new Set<Node>(); |
| for (MutationRecord record in mutations) { |
| normalizeMutationRecord(record, trySelection, normalizedNodes); |
| } |
| |
| if (normalizedNodes.length == 1) { |
| Node node = normalizedNodes.single; |
| if (node is Element && node.classes.contains('lineNumber')) { |
| print('Single line change: ${node.outerHtml}'); |
| |
| updateHighlighting(node, selection, trySelection, mainEditorPane); |
| return; |
| } |
| } |
| |
| updateHighlighting(mainEditorPane, selection, trySelection); |
| } |
| |
| void updateHighlighting( |
| Element node, |
| Selection selection, |
| TrySelection trySelection, |
| [Element root]) { |
| String state = ''; |
| String currentText = getText(node); |
| if (root != null) { |
| // Single line change. |
| trySelection = trySelection.copyWithRoot(node); |
| Element previousLine = node.previousElementSibling; |
| if (previousLine != null) { |
| state = previousLine.getAttribute('dart-state'); |
| } |
| |
| node.parentNode.insertAllBefore( |
| createHighlightedNodes(trySelection, currentText, state), |
| node); |
| node.remove(); |
| } else { |
| root = node; |
| editor.seenIdentifiers = new Set<String>.from(mock.identifiers); |
| |
| // Fail safe: new [nodes] are computed before clearing old nodes. |
| List<Node> nodes = |
| createHighlightedNodes(trySelection, currentText, state); |
| |
| node.nodes |
| ..clear() |
| ..addAll(nodes); |
| } |
| |
| if (containsNode(mainEditorPane, trySelection.anchorNode)) { |
| // Sometimes the anchor node is removed by the above call. This has |
| // only been observed in Firefox, and is hard to reproduce. |
| trySelection.adjust(selection); |
| } |
| |
| // TODO(ahe): We know almost exactly what has changed. It could be |
| // more efficient to only communicate what changed. |
| context.currentCompilationUnit.content = getText(root); |
| |
| // Discard highlighting mutations. |
| observer.takeRecords(); |
| } |
| |
| List<Node> createHighlightedNodes( |
| TrySelection trySelection, |
| String currentText, |
| String state) { |
| trySelection.updateText(currentText); |
| |
| editor.isMalformedInput = false; |
| int offset = 0; |
| List<Node> nodes = <Node>[]; |
| |
| for (String line in splitLines(currentText)) { |
| List<Node> lineNodes = <Node>[]; |
| state = |
| tokenizeAndHighlight(line, state, offset, trySelection, lineNodes); |
| offset += line.length; |
| nodes.add(makeLine(lineNodes, state)); |
| } |
| |
| return nodes; |
| } |
| |
| void onSelectionChange(Event event) { |
| } |
| |
| void onStateChanged(InteractionState previous) { |
| super.onStateChanged(previous); |
| context.compileTimer |
| ..start() |
| ..reset(); |
| } |
| |
| void onCompilationUnitChanged(CompilationUnit unit) { |
| if (unit == context.currentCompilationUnit) { |
| currentSource = unit.content; |
| if (context.projectFiles.containsKey(unit.name)) { |
| postProjectFileUpdate(unit); |
| } |
| context.compileTimer.start(); |
| } else { |
| print("Unexpected change to compilation unit '${unit.name}'."); |
| } |
| } |
| |
| void postProjectFileUpdate(CompilationUnit unit) { |
| context.modifiedUnits.add(unit); |
| context.saveTimer.start(); |
| } |
| |
| Future<List<String>> projectFileNames() { |
| return getString('project?list').then((String response) { |
| WebSocket socket = new WebSocket('ws://127.0.0.1:9090/ws/watch'); |
| socket.onMessage.listen(context.onProjectFileFsEvent); |
| return new List<String>.from(JSON.decode(response)); |
| }); |
| } |
| |
| void onProjectFileSelected(String projectFile) { |
| // Disable editing whilst fetching data. |
| mainEditorPane.contentEditable = 'false'; |
| |
| CompilationUnit unit = context.projectFiles[projectFile]; |
| Future<CompilationUnit> future; |
| if (unit != null) { |
| // This project file had been fetched already. |
| future = new Future<CompilationUnit>.value(unit); |
| |
| // TODO(ahe): Probably better to fetch the sources again. |
| } else { |
| // This project file has to be fetched. |
| future = getString('project/$projectFile').then((String text) { |
| CompilationUnit unit = context.projectFiles[projectFile]; |
| if (unit == null) { |
| // Only create a new unit if the value hadn't arrived already. |
| unit = new CompilationUnit(projectFile, text); |
| context.projectFiles[projectFile] = unit; |
| } else { |
| // TODO(ahe): Probably better to overwrite sources. Create a new |
| // unit? |
| // The server should push updates to the client. |
| } |
| return unit; |
| }); |
| } |
| future.then((CompilationUnit unit) { |
| mainEditorPane |
| ..contentEditable = 'true' |
| ..nodes.clear(); |
| observer.takeRecords(); // Discard mutations. |
| |
| transitionToInitialState(); |
| context.currentCompilationUnit = unit; |
| |
| // Install the code, which will trigger a call to onMutation. |
| mainEditorPane.appendText(unit.content); |
| }); |
| } |
| |
| void transitionToInitialState() {} |
| |
| void onProjectFileFsEvent(MessageEvent e) { |
| Map map = JSON.decode(e.data); |
| List modified = map['modify']; |
| if (modified == null) return; |
| for (String name in modified) { |
| Completer completer = context.completeSaveOperation; |
| if (completer != null && !completer.isCompleted) { |
| completer.complete(name); |
| } else { |
| onUnexpectedServerModification(name); |
| } |
| } |
| } |
| |
| void onUnexpectedServerModification(String name) { |
| if (context.currentCompilationUnit.name == name) { |
| mainEditorPane.contentEditable = 'false'; |
| statusDiv.text = 'Modified on disk'; |
| } |
| } |
| |
| void onHeartbeat(Timer timer) { |
| if (context.unitsToSave.isEmpty && |
| context.saveTimer.elapsed > SAVE_INTERVAL) { |
| context.saveTimer |
| ..stop() |
| ..reset(); |
| context.unitsToSave.addAll(context.modifiedUnits); |
| context.modifiedUnits.clear(); |
| saveUnits(); |
| } |
| if (!settings.compilationPaused && |
| context.compileTimer.elapsed > context.compileInterval) { |
| if (startCompilation()) { |
| context.compileTimer |
| ..stop() |
| ..reset(); |
| } |
| } |
| |
| if (context.elapsedCompilationTime.elapsed > SLOW_COMPILE) { |
| if (context.compilerConsole.parent == null) { |
| outputDiv.append(context.compilerConsole); |
| } |
| } |
| } |
| |
| void saveUnits() { |
| if (context.unitsToSave.isEmpty) return; |
| CompilationUnit unit = context.unitsToSave.removeFirst(); |
| onError(ProgressEvent event) { |
| HttpRequest request = event.target; |
| statusDiv.text = "Couldn't save '${unit.name}': ${request.responseText}"; |
| context.completeSaveOperation.complete(unit.name); |
| } |
| new HttpRequest() |
| ..open("POST", "/project/${unit.name}") |
| ..onError.listen(onError) |
| ..send(unit.content); |
| void setupCompleter() { |
| context.completeSaveOperation = new Completer<String>.sync(); |
| context.completeSaveOperation.future.then((String name) { |
| if (name == unit.name) { |
| print("Saved source of '$name'"); |
| saveUnits(); |
| } else { |
| setupCompleter(); |
| } |
| }); |
| } |
| setupCompleter(); |
| } |
| |
| void onWindowMessage(MessageEvent event) { |
| if (event.source is! WindowBase || event.source == window) { |
| return onBadMessage(event); |
| } |
| if (event.data is List) { |
| List message = event.data; |
| if (message.length > 0) { |
| switch (message[0]) { |
| case 'scrollHeight': |
| return onScrollHeightMessage(message[1]); |
| } |
| } |
| return onBadMessage(event); |
| } else { |
| return consolePrintLine(event.data); |
| } |
| } |
| |
| /// Called when an iframe is modified. |
| void onScrollHeightMessage(int scrollHeight) { |
| window.console.log('scrollHeight = $scrollHeight'); |
| if (scrollHeight > 8) { |
| outputFrame.style |
| ..height = '${scrollHeight}px' |
| ..visibility = '' |
| ..position = ''; |
| while (outputFrame.nextNode is IFrameElement) { |
| outputFrame.nextNode.remove(); |
| } |
| } |
| } |
| |
| void onBadMessage(MessageEvent event) { |
| window.console |
| ..groupCollapsed('Bad message') |
| ..dir(event) |
| ..log(event.source.runtimeType) |
| ..groupEnd(); |
| } |
| |
| void consolePrintLine(line) { |
| if (context.shouldClearConsole) { |
| context.shouldClearConsole = false; |
| outputDiv.nodes.clear(); |
| } |
| if (window.parent != window) { |
| // Test support. |
| // TODO(ahe): Use '/' instead of '*' when Firefox is upgraded to version |
| // 30 across build bots. Support for '/' was added in version 29, and we |
| // support the two most recent versions. |
| window.parent.postMessage('$line\n', '*'); |
| } |
| outputDiv.appendText('$line\n'); |
| } |
| |
| void onCompilationFailed(String firstError) { |
| if (firstError == null) { |
| consolePrintLine('Compilation failed.'); |
| } else { |
| consolePrintLine('Compilation failed: $firstError'); |
| } |
| } |
| |
| void onCompilationDone() { |
| context.isFirstCompile = false; |
| context.elapsedCompilationTime.stop(); |
| Duration compilationDuration = context.elapsedCompilationTime.elapsed; |
| context.elapsedCompilationTime.reset(); |
| print('Compilation took $compilationDuration.'); |
| if (context.compilerConsole.parent != null) { |
| context.compilerConsole.remove(); |
| } |
| for (AnchorElement diagnostic in context.oldDiagnostics) { |
| if (diagnostic.parent != null) { |
| // Problem fixed, remove the diagnostic. |
| diagnostic.replaceWith(new Text(getText(diagnostic))); |
| } |
| } |
| context.oldDiagnostics.clear(); |
| observer.takeRecords(); // Discard mutations. |
| } |
| |
| void compilationStarting() { |
| var progress = new SpanElement() |
| ..appendHtml('<i class="icon-spinner icon-spin"></i>') |
| ..appendText(' Compiling Dart program.'); |
| if (settings.verboseCompiler) { |
| progress.appendText('..'); |
| } |
| context.compilerConsole = new SpanElement() |
| ..append(progress) |
| ..appendText('\n'); |
| context.shouldClearConsole = true; |
| context.elapsedCompilationTime |
| ..start() |
| ..reset(); |
| if (context.isFirstCompile) { |
| outputDiv.append(context.compilerConsole); |
| } |
| var diagnostics = mainEditorPane.querySelectorAll('a.diagnostic'); |
| context.oldDiagnostics |
| ..clear() |
| ..addAll(diagnostics); |
| } |
| |
| void aboutToRun() { |
| context.shouldClearConsole = true; |
| } |
| |
| void onIframeError(ErrorMessage message) { |
| // TODO(ahe): Consider replacing object URLs with something like <a |
| // href='...'>out.js</a>. |
| // TODO(ahe): Use source maps to translate stack traces. |
| consolePrintLine(message); |
| } |
| |
| void verboseCompilerMessage(String message) { |
| if (settings.verboseCompiler) { |
| context.compilerConsole.appendText('$message\n'); |
| } else { |
| if (isCompilerStageMarker(message)) { |
| Element progress = context.compilerConsole.firstChild; |
| progress.appendText('.'); |
| } |
| } |
| } |
| |
| void onCompilerCrash(data) { |
| onInternalError('Error and stack trace:\n$data'); |
| } |
| |
| void onInternalError(message) { |
| outputDiv |
| ..nodes.clear() |
| ..append(new HeadingElement.h1()..appendText('Internal Error')) |
| ..appendText('We would appreciate if you take a moment to report ' |
| 'this at ') |
| ..append( |
| new AnchorElement(href: TRY_DART_NEW_DEFECT) |
| ..target = '_blank' |
| ..appendText(TRY_DART_NEW_DEFECT)) |
| ..appendText('$message'); |
| if (window.parent != window) { |
| // Test support. |
| // TODO(ahe): Use '/' instead of '*' when Firefox is upgraded to version |
| // 30 across build bots. Support for '/' was added in version 29, and we |
| // support the two most recent versions. |
| window.parent.postMessage('$message\n', '*'); |
| } |
| } |
| } |
| |
| Future<String> getString(uri) { |
| return new Future<String>.sync(() => HttpRequest.getString('$uri')); |
| } |
| |
| class PendingInputState extends InitialState { |
| PendingInputState(InteractionContext context) |
| : super(context); |
| |
| void onInput(Event event) { |
| // Do nothing. |
| } |
| |
| void onMutation(List<MutationRecord> mutations, MutationObserver observer) { |
| super.onMutation(mutations, observer); |
| |
| InteractionState nextState = new InitialState(context); |
| if (settings.enableCodeCompletion.value) { |
| Element parent = editor.getElementAtSelection(); |
| Element ui; |
| if (parent != null) { |
| ui = parent.querySelector('.dart-code-completion'); |
| if (ui != null) { |
| nextState = new CodeCompletionState(context, parent, ui); |
| } |
| } |
| } |
| state = nextState; |
| } |
| } |
| |
| class CodeCompletionState extends InitialState { |
| final Element activeCompletion; |
| final Element ui; |
| int minWidth = 0; |
| DivElement staticResults; |
| SpanElement inline; |
| DivElement serverResults; |
| String inlineSuggestion; |
| |
| CodeCompletionState(InteractionContext context, |
| this.activeCompletion, |
| this.ui) |
| : super(context); |
| |
| void onInput(Event event) { |
| // Do nothing. |
| } |
| |
| void onModifiedKeyUp(KeyboardEvent event) { |
| // TODO(ahe): Handle DOWN (jump to server results). |
| } |
| |
| void onUnmodifiedKeyUp(KeyboardEvent event) { |
| switch (event.keyCode) { |
| case KeyCode.DOWN: |
| return moveDown(event); |
| |
| case KeyCode.UP: |
| return moveUp(event); |
| |
| case KeyCode.ESC: |
| event.preventDefault(); |
| return endCompletion(); |
| |
| case KeyCode.TAB: |
| case KeyCode.RIGHT: |
| case KeyCode.ENTER: |
| event.preventDefault(); |
| return endCompletion(acceptSuggestion: true); |
| |
| case KeyCode.SPACE: |
| return endCompletion(); |
| } |
| } |
| |
| void moveDown(Event event) { |
| event.preventDefault(); |
| move(1); |
| } |
| |
| void moveUp(Event event) { |
| event.preventDefault(); |
| move(-1); |
| } |
| |
| void move(int direction) { |
| Element element = editor.moveActive(direction, ui); |
| if (element == null) return; |
| var text = activeCompletion.firstChild; |
| String prefix = ""; |
| if (text is Text) prefix = text.data.trim(); |
| updateInlineSuggestion(prefix, element.text); |
| } |
| |
| void endCompletion({bool acceptSuggestion: false}) { |
| if (acceptSuggestion) { |
| suggestionAccepted(); |
| } |
| activeCompletion.classes.remove('active'); |
| mainEditorPane.querySelectorAll('.hazed-suggestion') |
| .forEach((e) => e.remove()); |
| // The above changes create mutation records. This implicitly fire mutation |
| // events that result in saving the source code in local storage. |
| // TODO(ahe): Consider making this more explicit. |
| state = new InitialState(context); |
| } |
| |
| void suggestionAccepted() { |
| if (inlineSuggestion != null) { |
| Text text = new Text(inlineSuggestion); |
| activeCompletion.replaceWith(text); |
| window.getSelection().collapse(text, inlineSuggestion.length); |
| } |
| } |
| |
| void onMutation(List<MutationRecord> mutations, MutationObserver observer) { |
| for (MutationRecord record in mutations) { |
| if (!activeCompletion.contains(record.target)) { |
| endCompletion(); |
| return super.onMutation(mutations, observer); |
| } |
| } |
| |
| var text = activeCompletion.firstChild; |
| if (text is! Text) return endCompletion(); |
| updateSuggestions(text.data.trim()); |
| } |
| |
| void onStateChanged(InteractionState previous) { |
| super.onStateChanged(previous); |
| displayCodeCompletion(); |
| } |
| |
| void displayCodeCompletion() { |
| Selection selection = window.getSelection(); |
| if (selection.anchorNode is! Text) { |
| return endCompletion(); |
| } |
| Text text = selection.anchorNode; |
| if (!activeCompletion.contains(text)) { |
| return endCompletion(); |
| } |
| |
| int anchorOffset = selection.anchorOffset; |
| |
| String prefix = text.data.substring(0, anchorOffset).trim(); |
| if (prefix.isEmpty) { |
| return endCompletion(); |
| } |
| |
| num height = activeCompletion.getBoundingClientRect().height; |
| activeCompletion.classes.add('active'); |
| Node root = getShadowRoot(ui); |
| |
| inline = new SpanElement() |
| ..classes.add('hazed-suggestion'); |
| Text rest = text.splitText(anchorOffset); |
| text.parentNode.insertBefore(inline, text.nextNode); |
| activeCompletion.parentNode.insertBefore( |
| rest, activeCompletion.nextNode); |
| |
| staticResults = new DivElement() |
| ..classes.addAll(['dart-static', 'dart-limited-height']); |
| serverResults = new DivElement() |
| ..style.display = 'none' |
| ..classes.add('dart-server'); |
| root.nodes.addAll([staticResults, serverResults]); |
| ui.style.top = '${height}px'; |
| |
| staticResults.nodes.add(buildCompletionEntry(prefix)); |
| |
| updateSuggestions(prefix); |
| } |
| |
| void updateInlineSuggestion(String prefix, String suggestion) { |
| inlineSuggestion = suggestion; |
| |
| minWidth = max(minWidth, activeCompletion.getBoundingClientRect().width); |
| |
| activeCompletion.style |
| ..display = 'inline-block' |
| ..minWidth = '${minWidth}px'; |
| |
| setShadowRoot(inline, suggestion.substring(prefix.length)); |
| inline.style.display = ''; |
| |
| observer.takeRecords(); // Discard mutations. |
| } |
| |
| void updateSuggestions(String prefix) { |
| if (prefix.isEmpty) { |
| return endCompletion(); |
| } |
| |
| Token first = tokenize(prefix); |
| for (Token token = first; token.kind != EOF_TOKEN; token = token.next) { |
| String tokenInfo = token.info.value; |
| if (token != first || |
| tokenInfo != 'identifier' && |
| tokenInfo != 'keyword') { |
| return endCompletion(); |
| } |
| } |
| |
| var borderHeight = 2; // 1 pixel border top & bottom. |
| num height = ui.getBoundingClientRect().height - borderHeight; |
| ui.style.minHeight = '${height}px'; |
| |
| minWidth = |
| max(minWidth, activeCompletion.getBoundingClientRect().width); |
| |
| staticResults.nodes.clear(); |
| serverResults.nodes.clear(); |
| |
| if (inlineSuggestion != null && inlineSuggestion.startsWith(prefix)) { |
| setShadowRoot(inline, inlineSuggestion.substring(prefix.length)); |
| } |
| |
| List<String> results = editor.seenIdentifiers.where( |
| (String identifier) { |
| return identifier != prefix && identifier.startsWith(prefix); |
| }).toList(growable: false); |
| results.sort(); |
| if (results.isEmpty) results = <String>[prefix]; |
| |
| results.forEach((String completion) { |
| staticResults.nodes.add(buildCompletionEntry(completion)); |
| }); |
| |
| if (settings.enableDartMind) { |
| // TODO(ahe): Move this code to its own function or class. |
| String encodedArg0 = Uri.encodeComponent('"$prefix"'); |
| String mindQuery = |
| 'http://dart-mind.appspot.com/rpc' |
| '?action=GetExportingPubCompletions' |
| '&arg0=$encodedArg0'; |
| try { |
| var serverWatch = new Stopwatch()..start(); |
| HttpRequest.getString(mindQuery).then((String responseText) { |
| serverWatch.stop(); |
| List<String> serverSuggestions = JSON.decode(responseText); |
| if (!serverSuggestions.isEmpty) { |
| updateInlineSuggestion(prefix, serverSuggestions.first); |
| } |
| var root = getShadowRoot(ui); |
| for (int i = 1; i < serverSuggestions.length; i++) { |
| String completion = serverSuggestions[i]; |
| DivElement where = staticResults; |
| int index = results.indexOf(completion); |
| if (index != -1) { |
| List<Element> entries = root.querySelectorAll( |
| '.dart-static>.dart-entry'); |
| entries[index].classes.add('doubleplusgood'); |
| } else { |
| if (results.length > 3) { |
| serverResults.style.display = 'block'; |
| where = serverResults; |
| } |
| Element entry = buildCompletionEntry(completion); |
| entry.classes.add('doubleplusgood'); |
| where.nodes.add(entry); |
| } |
| } |
| serverResults.appendHtml( |
| '<div>${serverWatch.elapsedMilliseconds}ms</div>'); |
| // Discard mutations. |
| observer.takeRecords(); |
| }).catchError((error, stack) { |
| window.console.dir(error); |
| window.console.error('$stack'); |
| }); |
| } catch (error, stack) { |
| window.console.dir(error); |
| window.console.error('$stack'); |
| } |
| } |
| // Discard mutations. |
| observer.takeRecords(); |
| } |
| |
| Element buildCompletionEntry(String completion) { |
| return new DivElement() |
| ..classes.add('dart-entry') |
| ..appendText(completion); |
| } |
| |
| void transitionToInitialState() { |
| endCompletion(); |
| } |
| } |
| |
| Token tokenize(String text) { |
| var file = new StringSourceFile.fromName('', text); |
| return new StringScanner(file, includeComments: true).tokenize(); |
| } |
| |
| bool computeHasModifier(KeyboardEvent event) { |
| return |
| event.getModifierState("Alt") || |
| event.getModifierState("AltGraph") || |
| event.getModifierState("CapsLock") || |
| event.getModifierState("Control") || |
| event.getModifierState("Fn") || |
| event.getModifierState("Meta") || |
| event.getModifierState("NumLock") || |
| event.getModifierState("ScrollLock") || |
| event.getModifierState("Scroll") || |
| event.getModifierState("Win") || |
| event.getModifierState("Shift") || |
| event.getModifierState("SymbolLock") || |
| event.getModifierState("OS"); |
| } |
| |
| String tokenizeAndHighlight(String line, |
| String state, |
| int start, |
| TrySelection trySelection, |
| List<Node> nodes) { |
| String newState = ''; |
| int offset = state.length; |
| int adjustedStart = start - state.length; |
| |
| // + offset + charOffset + globalOffset + (charOffset + charCount) |
| // v v v v |
| // do identifier_abcdefghijklmnopqrst |
| for (Token token = tokenize('$state$line'); |
| token.kind != EOF_TOKEN; |
| token = token.next) { |
| int charOffset = token.charOffset; |
| int charCount = token.charCount; |
| |
| Token tokenToDecorate = token; |
| if (token is UnterminatedToken && isUnterminatedMultiLineToken(token)) { |
| newState += '${token.start}'; |
| continue; // This might not be an error. |
| } else { |
| Token follow = token.next; |
| if (token is BeginGroupToken && token.endGroup != null) { |
| follow = token.endGroup.next; |
| } |
| if (token.kind == STRING_TOKEN) { |
| follow = followString(follow); |
| if (follow is UnmatchedToken) { |
| if ('${follow.begin.value}' == r'${') { |
| newState += '${extractQuote(token.value)}'; |
| } |
| } |
| } |
| if (follow is ErrorToken && follow.charOffset == token.charOffset) { |
| if (follow is UnmatchedToken) { |
| newState += '${follow.begin.value}'; |
| } else { |
| tokenToDecorate = follow; |
| } |
| } |
| } |
| |
| if (charOffset < offset) { |
| // Happens for scanner errors, or for the [state] prefix. |
| continue; |
| } |
| |
| Decoration decoration; |
| if (charOffset - state.length == line.length - 1 && line.endsWith('\n')) { |
| // Don't add decorations to trailing newline. |
| decoration = null; |
| } else { |
| decoration = editor.getDecoration(tokenToDecorate); |
| } |
| |
| if (decoration == null) continue; |
| |
| // Add a node for text before current token. |
| trySelection.addNodeFromSubstring( |
| adjustedStart + offset, adjustedStart + charOffset, nodes); |
| |
| // Add a node for current token. |
| trySelection.addNodeFromSubstring( |
| adjustedStart + charOffset, |
| adjustedStart + charOffset + charCount, nodes, decoration); |
| |
| offset = charOffset + charCount; |
| } |
| |
| // Add a node for anything after the last (decorated) token. |
| trySelection.addNodeFromSubstring( |
| adjustedStart + offset, start + line.length, nodes); |
| |
| return newState; |
| } |
| |
| bool isUnterminatedMultiLineToken(UnterminatedToken token) { |
| return |
| token.start == '/*' || |
| token.start == "'''" || |
| token.start == '"""' || |
| token.start == "r'''" || |
| token.start == 'r"""'; |
| } |
| |
| void normalizeMutationRecord(MutationRecord record, |
| TrySelection selection, |
| Set<Node> normalizedNodes) { |
| for (Node node in record.addedNodes) { |
| if (node.parentNode == null) continue; |
| normalizedNodes.add(findLine(node)); |
| if (node is Text) continue; |
| StringBuffer buffer = new StringBuffer(); |
| int selectionOffset = htmlToText(node, buffer, selection); |
| Text newNode = new Text('$buffer'); |
| node.replaceWith(newNode); |
| if (selectionOffset != -1) { |
| selection.anchorNode = newNode; |
| selection.anchorOffset = selectionOffset; |
| } |
| } |
| if (!record.removedNodes.isEmpty) { |
| var first = record.removedNodes.first; |
| var line = findLine(record.target); |
| |
| if (first is Text && line.nextNode != null) { |
| normalizedNodes.add(line.nextNode); |
| } |
| normalizedNodes.add(line); |
| } |
| if (record.type == "characterData" && record.target.parentNode != null) { |
| // At least Firefox sends a "characterData" record whose target is the |
| // deleted text node. It also sends a record where "removedNodes" isn't |
| // empty whose target is the parent (which we are interested in). |
| normalizedNodes.add(findLine(record.target)); |
| } |
| } |
| |
| // Finds the line of [node] (a parent node with CSS class 'lineNumber'). |
| // If no such parent exists, return mainEditorPane if it is a parent. |
| // Otherwise return [node]. |
| Node findLine(Node node) { |
| for (Node n = node; n != null; n = n.parentNode) { |
| if (n is Element && n.classes.contains('lineNumber')) return n; |
| if (n == mainEditorPane) return n; |
| } |
| return node; |
| } |
| |
| Element makeLine(List<Node> lineNodes, String state) { |
| return new SpanElement() |
| ..setAttribute('dart-state', state) |
| ..nodes.addAll(lineNodes) |
| ..classes.add('lineNumber'); |
| } |
| |
| bool isAtEndOfFile(Text text, int offset) { |
| Node line = findLine(text); |
| return |
| line.nextNode == null && |
| text.parentNode.nextNode == null && |
| offset == text.length; |
| } |
| |
| List<String> splitLines(String text) { |
| return text.split(new RegExp('^', multiLine: true)); |
| } |
| |
| void removeCodeCompletion() { |
| List<Node> highlighting = |
| mainEditorPane.querySelectorAll('.dart-code-completion'); |
| for (Element element in highlighting) { |
| element.remove(); |
| } |
| } |
| |
| bool isCompilerStageMarker(String message) { |
| return |
| message.startsWith('Package root is ') || |
| message.startsWith('Compiling ') || |
| message == "Resolving..." || |
| message.startsWith('Resolved ') || |
| message == "Inferring types..." || |
| message == "Compiling..." || |
| message.startsWith('Compiled '); |
| } |
| |
| void workAroundFirefoxBug() { |
| Selection selection = window.getSelection(); |
| if (!isCollapsed(selection)) return; |
| Node node = selection.anchorNode; |
| int offset = selection.anchorOffset; |
| if (node is Element && offset != 0) { |
| // In some cases, Firefox reports the wrong anchorOffset (always seems to |
| // be 6) when anchorNode is an Element. Moving the cursor back and forth |
| // adjusts the anchorOffset. |
| // Safari can also reach this code, but the offset isn't wrong, just |
| // inconsistent. After moving the cursor back and forth, Safari will make |
| // the offset relative to a text node. |
| if (settings.hasSelectionModify.value) { |
| // IE doesn't support selection.modify, but it's okay since the code |
| // above is for Firefox, IE doesn't have problems with anchorOffset. |
| selection |
| ..modify('move', 'backward', 'character') |
| ..modify('move', 'forward', 'character'); |
| print('Selection adjusted $node@$offset -> ' |
| '${selection.anchorNode}@${selection.anchorOffset}.'); |
| } |
| } |
| } |
| |
| /// Compute the token following a string. Compare to parseSingleLiteralString |
| /// in parser.dart. |
| Token followString(Token token) { |
| // TODO(ahe): I should be able to get rid of this if I change the scanner to |
| // create BeginGroupToken for strings. |
| int kind = token.kind; |
| while (kind != EOF_TOKEN) { |
| if (kind == STRING_INTERPOLATION_TOKEN) { |
| // Looking at ${expression}. |
| BeginGroupToken begin = token; |
| token = begin.endGroup.next; |
| } else if (kind == STRING_INTERPOLATION_IDENTIFIER_TOKEN) { |
| // Looking at $identifier. |
| token = token.next.next; |
| } else { |
| return token; |
| } |
| kind = token.kind; |
| if (kind != STRING_TOKEN) return token; |
| token = token.next; |
| kind = token.kind; |
| } |
| return token; |
| } |
| |
| String extractQuote(String string) { |
| StringQuoting q = StringValidator.quotingFromString(string); |
| return (q.raw ? 'r' : '') + (q.quoteChar * q.leftQuoteLength); |
| } |