Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(191)

Side by Side Diff: runtime/observatory/lib/src/elements/debugger.dart

Issue 866663003: Add a working command line debugger to Observatory. (Closed) Base URL: https://dart.googlecode.com/svn/branches/bleeding_edge/dart
Patch Set: Created 5 years, 10 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
OLDNEW
1 // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file 1 // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file
2 // for details. All rights reserved. Use of this source code is governed by a 2 // for details. All rights reserved. Use of this source code is governed by a
3 // BSD-style license that can be found in the LICENSE file. 3 // BSD-style license that can be found in the LICENSE file.
4 4
5 library debugger_page_element; 5 library debugger_page_element;
6 6
7 import 'dart:async';
7 import 'dart:html'; 8 import 'dart:html';
8 import 'observatory_element.dart'; 9 import 'observatory_element.dart';
10 import 'package:observatory/cli.dart';
9 import 'package:observatory/service.dart'; 11 import 'package:observatory/service.dart';
10 import 'package:polymer/polymer.dart'; 12 import 'package:polymer/polymer.dart';
11 13
14 // TODO(turnidge): Move Debugger, DebuggerCommand to their own lib.
15 abstract class DebuggerCommand extends Command {
16 Debugger debugger;
17
18 DebuggerCommand(this.debugger, name, children)
19 : super(name, children);
20 }
21
22 class HelpCommand extends DebuggerCommand {
23 HelpCommand(Debugger debugger) : super(debugger, 'help', []);
24
25 Future run(List<String> args) {
26 var con = debugger.console;
27 con.printLine('List of commands:');
28 con.newline();
29
30 // TODO(turnidge): Build a real help system.
31 List completions = debugger.cmd.completeCommand('');
32 completions = completions.map((s )=> s.trimRight()).toList();
33 completions.sort();
34 con.printLine(completions.toString());
35 con.newline();
36 con.printLine("Command prefixes are accepted (e.g. 'h' for 'help')");
37 con.printLine("Hit [TAB] to complete a command (try 'i[TAB][TAB]')");
38 con.printLine("Hit [ENTER] to repeat the last command");
39
40 return new Future.value(null);
41 }
42 }
43
44 class PauseCommand extends DebuggerCommand {
45 PauseCommand(Debugger debugger) : super(debugger, 'pause', []);
46
47 Future run(List<String> args) {
48 if (!debugger.isolatePaused()) {
49 return debugger.isolate.pause();
50 } else {
51 debugger.console.printLine('The program is already paused');
52 return new Future.value(null);
53 }
54 }
55 }
56
57 class ContinueCommand extends DebuggerCommand {
58 ContinueCommand(Debugger debugger) : super(debugger, 'continue', []);
59
60 Future run(List<String> args) {
61 if (debugger.isolatePaused()) {
62 return debugger.isolate.resume().then((_) {
63 debugger.warnOutOfDate();
64 });
65 } else {
66 debugger.console.printLine('The program must be paused');
67 return new Future.value(null);
68 }
69 }
70 }
71
72 class NextCommand extends DebuggerCommand {
73 NextCommand(Debugger debugger) : super(debugger, 'next', []);
74
75 Future run(List<String> args) {
76 if (debugger.isolatePaused()) {
77 var event = debugger.isolate.pauseEvent;
78 if (event.eventType == 'IsolateCreated') {
79 debugger.console.printLine("Type 'continue' to start the isolate");
80 return new Future.value(null);
81 }
82 if (event.eventType == 'IsolateShutdown') {
83 debugger.console.printLine("Type 'continue' to exit the isolate");
84 return new Future.value(null);
85 }
86 return debugger.isolate.stepOver();
87 } else {
88 debugger.console.printLine('The program is already running');
89 return new Future.value(null);
90 }
91 }
92 }
93
94 class StepCommand extends DebuggerCommand {
95 StepCommand(Debugger debugger) : super(debugger, 'step', []);
96
97 Future run(List<String> args) {
98 if (debugger.isolatePaused()) {
99 var event = debugger.isolate.pauseEvent;
100 if (event.eventType == 'IsolateCreated') {
101 debugger.console.printLine("Type 'continue' to start the isolate");
102 return new Future.value(null);
103 }
104 if (event.eventType == 'IsolateShutdown') {
105 debugger.console.printLine("Type 'continue' to exit the isolate");
106 return new Future.value(null);
107 }
108 return debugger.isolate.stepInto();
109 } else {
110 debugger.console.printLine('The program is already running');
111 return new Future.value(null);
112 }
113 }
114 }
115
116 class FinishCommand extends DebuggerCommand {
117 FinishCommand(Debugger debugger) : super(debugger, 'finish', []);
118
119 Future run(List<String> args) {
120 if (debugger.isolatePaused()) {
121 return debugger.isolate.stepOut();
122 } else {
123 debugger.console.printLine('The program is already running');
124 return new Future.value(null);
125 }
126 }
127 }
128
129 // TODO(turnidge): Add argument completion.
130 class DeleteCommand extends DebuggerCommand {
131 DeleteCommand(Debugger debugger) : super(debugger, 'delete', []);
132
133 Future run(List<String> args) {
134 if (args.length < 1) {
135 debugger.console.printLine('delete expects one or more arguments');
136 return new Future.value(null);
137 }
138 List toDelete = [];
139 for (var arg in args) {
140 int id = int.parse(arg);
141 var bpt = null;
142 for (var candidate in debugger.isolate.breakpoints) {
143 if (candidate['breakpointNumber'] == id) {
144 bpt = candidate;
145 break;
146 }
147 }
148 if (bpt == null) {
149 debugger.console.printLine("Invalid breakpoint id '${id}'");
150 return new Future.value(null);
151 }
152 toDelete.add(bpt);
153 }
154 List pending = [];
155 for (var bpt in toDelete) {
156 pending.add(debugger.isolate.removeBreakpoint(bpt).then((_) {
157 var id = bpt['breakpointNumber'];
158 debugger.console.printLine("Removed breakpoint $id");
159 }));
160 }
161 return Future.wait(pending);
162 }
163 }
164
165 class InfoBreakpointsCommand extends DebuggerCommand {
166 InfoBreakpointsCommand(Debugger debugger)
167 : super(debugger, 'breakpoints', []);
168
169 Future run(List<String> args) {
170 return debugger.isolate.reloadBreakpoints().then((_) {
171 if (debugger.isolate.breakpoints.isEmpty) {
172 debugger.console.printLine('No breakpoints');
173 }
174 for (var bpt in debugger.isolate.breakpoints) {
175 var bpId = bpt['breakpointNumber'];
176 var script = bpt['location']['script'];
177 var tokenPos = bpt['location']['tokenPos'];
178 var line = script.tokenToLine(tokenPos);
179 var col = script.tokenToCol(tokenPos);
180 debugger.console.printLine(
181 'Breakpoint ${bpId} at ${script.name}:${line}:${col}');
182 }
183 });
184 }
185 }
186
187 class InfoIsolatesCommand extends DebuggerCommand {
188 InfoIsolatesCommand(Debugger debugger) : super(debugger, 'isolates', []);
189
190 Future run(List<String> args) {
191 for (var isolate in debugger.isolate.vm.isolates) {
192 debugger.console.printLine(
193 "Isolate ${isolate.id} '${isolate.name}'");
194 }
195 return new Future.value(null);
196 }
197 }
198
199 class InfoCommand extends DebuggerCommand {
200 InfoCommand(Debugger debugger) : super(debugger, 'info', [
201 new InfoBreakpointsCommand(debugger),
202 new InfoIsolatesCommand(debugger),
203 ]);
204
205 Future run(List<String> args) {
206 debugger.console.printLine("Invalid info command");
207 return new Future.value(null);
208 }
209 }
210
211 class RefreshCoverageCommand extends DebuggerCommand {
212 RefreshCoverageCommand(Debugger debugger) : super(debugger, 'coverage', []);
213
214 Future run(List<String> args) {
215 Set<Script> scripts = debugger.stackElement.activeScripts();
216 List pending = [];
217 for (var script in scripts) {
218 pending.add(script.refreshCoverage().then((_) {
219 debugger.console.printLine('Refreshed coverage for ${script.name}');
220 }));
221 }
222 return Future.wait(pending);
223 }
224 }
225
226 class RefreshCommand extends DebuggerCommand {
227 RefreshCommand(Debugger debugger) : super(debugger, 'refresh', [
228 new RefreshCoverageCommand(debugger),
229 ]);
230
231 Future run(List<String> args) {
232 return debugger.refreshStack();
233 }
234 }
235
236 // Tracks the state for an isolate debugging session.
237 class Debugger {
238 RootCommand cmd;
239 DebuggerConsoleElement console;
240 DebuggerStackElement stackElement;
241 ServiceMap stack;
242
243 Debugger() {
244 cmd = new RootCommand([
245 new HelpCommand(this),
246 new PauseCommand(this),
247 new ContinueCommand(this),
248 new NextCommand(this),
249 new StepCommand(this),
250 new FinishCommand(this),
251 new DeleteCommand(this),
252 new InfoCommand(this),
253 new RefreshCommand(this),
254 ]);
255 }
256
257 void set isolate(Isolate iso) {
258 _isolate = iso;
259 if (_isolate != null) {
260 _isolate.reload().then((_) {
261 _isolate.vm.events.stream.listen(_onEvent);
262 _refreshStack(isolate.pauseEvent).then((_) {
263 reportStatus();
264 });
265 });
266 }
267 }
268 Isolate get isolate => _isolate;
269 Isolate _isolate;
270
271 void init() {
272 console.newline();
273 console.printBold("Type 'h' for help");
274 }
275
276 Future refreshStack() {
277 return _refreshStack(isolate.pauseEvent).then((_) {
278 reportStatus();
279 });
280 }
281
282 bool isolatePaused() {
283 // TODO(turnidge): Stop relying on the isolate to track the last
284 // pause event. Since we listen to events directly in the
285 // debugger, this could introduce a race.
286 return isolate.pauseEvent != null;
287 }
288
289 void warnOutOfDate() {
290 // Wait a bit, then tell the user that the stack may be out of date.
291 new Timer(const Duration(seconds:2), () {
292 if (!isolatePaused()) {
293 stackElement.isSampled = true;
294 }
295 });
296 }
297
298 Future<ServiceMap> _refreshStack(ServiceEvent pauseEvent) {
299 return isolate.getStack().then((result) {
300 stack = result;
301 // TODO(turnidge): Replace only the changed part of the stack to
302 // reduce flicker.
303 // stackElement.stack = stack;
304 stackElement.updateStack(stack, pauseEvent);
305 });
306 }
307
308 void reportStatus() {
309 if (_isolate.idle) {
310 console.printLine('Isolate is idle');
311 } else if (_isolate.running) {
312 console.printLine("Isolate is running (type 'pause' to interrupt)");
313 } else if (_isolate.pauseEvent != null) {
314 _reportPause(_isolate.pauseEvent);
315 } else {
316 console.printLine('Isolate is in unknown state');
317 }
318 }
319
320 void _reportPause(ServiceEvent event) {
321 if (event.eventType == 'IsolateCreated') {
322 console.printLine(
323 "Paused at isolate start (type 'continue' to start the isolate')");
324 } else if (event.eventType == 'IsolateShutdown') {
325 console.printLine(
326 "Paused at isolate exit (type 'continue' to exit the isolate')");
327 }
328 if (stack['frames'].length > 0) {
329 var frame = stack['frames'][0];
330 var script = frame['script'];
331 script.load().then((_) {
332 var line = script.tokenToLine(frame['tokenPos']);
333 var col = script.tokenToCol(frame['tokenPos']);
334 if (event.breakpoint != null) {
335 var bpId = event.breakpoint['breakpointNumber'];
336 console.printLine('Breakpoint ${bpId} at ${script.name}:${line}:${col} ');
337 } else if (event.exception != null) {
338 // TODO(turnidge): Test this.
339 console.printLine(
340 'Exception ${event.exception} at ${script.name}:${line}:${col}');
341 } else {
342 console.printLine('Paused at ${script.name}:${line}:${col}');
343 }
344 });
345 }
346 }
347
348 void _onEvent(ServiceEvent event) {
349 if (event.owner != isolate) {
350 return;
351 }
352 switch(event.eventType) {
353 case 'IsolateShutdown':
354 console.printLine('Isolate shutdown');
355 isolate = null;
356 break;
357
358 case 'BreakpointReached':
359 case 'IsolateInterrupted':
360 case 'ExceptionThrown':
361 _refreshStack(event).then((_) {
362 _reportPause(event);
363 });
364 break;
365
366 case 'IsolateResumed':
367 console.printLine('Continuing...');
368 break;
369
370 case '_Graph':
371 case 'BreakpointResolved':
372 case 'IsolateCreated':
373 case 'GC':
374 // Ignore these events for now.
375 break;
376
377 default:
378 console.printLine('Unrecognized event: $event');
379 break;
380 }
381 }
382
383 String complete(String line) {
384 List<String> completions = cmd.completeCommand(line);
385 if (completions.length == 0) {
386 // No completions. Leave the line alone.
387 return line;
388 } else if (completions.length == 1) {
389 // Unambiguous completion.
390 return completions[0];
391 } else {
392 // Ambigous completion.
393 completions = completions.map((s )=> s.trimRight()).toList();
394 completions.sort();
395 console.printBold(completions.toString());
396
397 // TODO(turnidge): Complete to common prefix of all completions.
398 return line;
399 }
400 }
401
402 // TODO(turnidge): Implement real command line history.
403 String lastCommand;
404 bool busy = false;
405
406 Future run(String command) {
407 assert(!busy);
408 busy = true;
409 if (command == '') {
410 command = lastCommand;
411 }
412 lastCommand = command;
413 console.printBold('\$ $command');
414 return cmd.runCommand(command).then((_) {
415 busy = false;
416 }).catchError((e) {
417 console.printLine('ERROR $e');
418 });
419 }
420 }
421
12 @CustomTag('debugger-page') 422 @CustomTag('debugger-page')
13 class DebuggerPageElement extends ObservatoryElement { 423 class DebuggerPageElement extends ObservatoryElement {
14 @published Isolate isolate; 424 @published Isolate isolate;
15 @published bool showConsole = false; 425
426 isolateChanged(oldValue) {
427 if (isolate != null) {
428 debugger.isolate = isolate;
429 }
430 }
431 Debugger debugger = new Debugger();
16 432
17 DebuggerPageElement.created() : super.created(); 433 DebuggerPageElement.created() : super.created();
18 434
19 @override 435 @override
20 void attached() { 436 void attached() {
21 super.attached(); 437 super.attached();
22 438
23 // TODO(turnidge): Get these values from the DOM. 439 var navbarDiv = $['navbarDiv'];
24 // TODO(turnidge): splitterHeight is 0 until I implement it. 440 var stackDiv = $['stackDiv'];
25 const int navbarHeight = 56; 441 var splitterDiv = $['splitterDiv'];
26 const int splitterHeight = 0; 442 var cmdDiv = $['commandDiv'];
27 const int cmdHeight = 22; 443 var consoleDiv = $['consoleDiv'];
28 444
29 var stack = $['stack']; 445 int navbarHeight = navbarDiv.clientHeight;
446 int splitterHeight = splitterDiv.clientHeight;
447 int cmdHeight = cmdDiv.clientHeight;
448
30 int windowHeight = window.innerHeight; 449 int windowHeight = window.innerHeight;
31 int available = windowHeight - (navbarHeight + splitterHeight); 450 int fixedHeight = navbarHeight + splitterHeight + cmdHeight;
32 int stackHeight = available ~/ 1.3; 451 int available = windowHeight - fixedHeight;
33 if (showConsole) { 452 int stackHeight = available ~/ 1.6;
34 stack.style.setProperty('height', '${stackHeight}px'); 453 stackDiv.style.setProperty('height', '${stackHeight}px');
35 } else { 454
36 stack.style.setProperty('height', '${available}px'); 455 // Wire the debugger object to the stack, console, and command line.
37 } 456 var stackElement = $['stackElement'];
457 debugger.stackElement = stackElement;
458 stackElement.debugger = debugger;
459 debugger.console = $['console'];
460 $['commandline'].debugger = debugger;
461 debugger.init();
38 } 462 }
463
39 } 464 }
40 465
41 @CustomTag('debugger-stack') 466 @CustomTag('debugger-stack')
42 class DebuggerStackElement extends ObservatoryElement { 467 class DebuggerStackElement extends ObservatoryElement {
43 @published Isolate isolate; 468 @published Isolate isolate;
44 @published ServiceMap stack; 469 @observable bool hasStack = false;
45 @published int activeFrame = 0; 470 @observable bool isSampled = false;
471 Debugger debugger = null;
46 472
47 isolateChanged(oldValue) { 473 _addFrame(List frameList, ObservableMap frameInfo, bool expand) {
48 isolate.getStack().then((result) { 474 DebuggerFrameElement frameElement = new Element.tag('debugger-frame');
49 stack = result; 475 frameElement.expand = expand;
50 }); 476 frameElement.frame = frameInfo;
477
478 var li = new LIElement();
479 li.classes.add('list-group-item');
480 li.children.insert(0, frameElement);
481
482 frameList.insert(0, li);
483 }
484
485 void updateStack(ServiceMap newStack, ServiceEvent pauseEvent) {
486 List frameElements = $['frameList'].children;
487 List newFrames = newStack['frames'];
488
489 // Remove any frames whose functions don't match, starting from
490 // bottom of stack.
491 int oldPos = frameElements.length - 1;
492 int newPos = newFrames.length - 1;
493 while (oldPos >= 0 && newPos >= 0) {
494 if (!frameElements[oldPos].children[0].matchFrame(newFrames[newPos])) {
495 // The rest of the frame elements no longer match. Remove them.
496 for (int i = 0; i <= oldPos; i++) {
497 // NOTE(turnidge): removeRange is missing, sadly.
498 frameElements.removeAt(0);
499 }
500 break;
501 }
502 oldPos--;
503 newPos--;
504 }
505
506 // Remove any extra frames.
507 if (frameElements.length > newFrames.length) {
508 // Remove old frames from the top of stack.
509 int removeCount = frameElements.length - newFrames.length;
510 for (int i = 0; i < removeCount; i++) {
511 frameElements.removeAt(0);
512 }
513 }
514
515 // Add any new frames.
516 int newCount = 0;
517 if (frameElements.length < newFrames.length) {
518 // Add new frames to the top of stack.
519 newCount = newFrames.length - frameElements.length;
520 for (int i = newCount-1; i >= 0; i--) {
521 _addFrame(frameElements, newFrames[i], i == 0);
522 }
523 }
524 assert(frameElements.length == newFrames.length);
525
526 if (frameElements.isNotEmpty) {
527 frameElements[0].children[0].expand = true;
528 for (int i = newCount; i < frameElements.length; i++) {
529 frameElements[i].children[0].updateFrame(newFrames[i]);
530 }
531 }
532
533 isSampled = pauseEvent == null;
534 hasStack = frameElements.isNotEmpty;
535 }
536
537 Set<Script> activeScripts() {
538 var s = new Set<Script>();
539 List frameElements = $['frameList'].children;
540 for (var frameElement in frameElements) {
541 s.add(frameElement.children[0].script);
542 }
543 return s;
544 }
545
546 doPauseIsolate(_) {
547 if (debugger != null) {
548 return debugger.isolate.pause();
549 } else {
550 return new Future.value(null);
551 }
552 }
553
554 doRefreshStack(_) {
555 if (debugger != null) {
556 return debugger.refreshStack();
557 } else {
558 return new Future.value(null);
559 }
51 } 560 }
52 561
53 DebuggerStackElement.created() : super.created(); 562 DebuggerStackElement.created() : super.created();
54 } 563 }
55 564
56 @CustomTag('debugger-frame') 565 @CustomTag('debugger-frame')
57 class DebuggerFrameElement extends ObservatoryElement { 566 class DebuggerFrameElement extends ObservatoryElement {
58 @published ObservableMap frame; 567 @published ObservableMap frame;
59 568
60 // When true, the frame will start out expanded. 569 // When true, the frame will start out expanded.
61 @published bool expand = false; 570 @published bool expand = false;
62 571
63 @observable String scriptHeight; 572 @observable String scriptHeight;
64 @observable bool expanded = false; 573 @observable bool expanded = false;
65 @observable bool busy = false; 574 @observable bool busy = false;
66 575
67 DebuggerFrameElement.created() : super.created(); 576 DebuggerFrameElement.created() : super.created();
68 577
578 bool matchFrame(ObservableMap newFrame) {
579 return newFrame['function'].id == frame['function'].id;
580 }
581
582 void updateFrame(ObservableMap newFrame) {
583 assert(matchFrame(newFrame));
584 frame['depth'] = newFrame['depth'];
585 frame['tokenPos'] = newFrame['tokenPos'];
586 frame['vars'] = newFrame['vars'];
587 }
588
589 Script get script => frame['script'];
590
69 @override 591 @override
70 void attached() { 592 void attached() {
71 super.attached(); 593 super.attached();
72 int windowHeight = window.innerHeight; 594 int windowHeight = window.innerHeight;
73 scriptHeight = '${windowHeight ~/ 1.6}px'; 595 scriptHeight = '${windowHeight ~/ 1.6}px';
74 } 596 }
75 597
76 void expandChanged(oldValue) { 598 void expandChanged(oldValue) {
77 if (expand != expanded) { 599 if (expand != expanded) {
78 toggleExpand(null, null, null); 600 toggleExpand(null, null, null);
(...skipping 16 matching lines...) Expand all
95 busy = false; 617 busy = false;
96 }); 618 });
97 } 619 }
98 } 620 }
99 621
100 @CustomTag('debugger-console') 622 @CustomTag('debugger-console')
101 class DebuggerConsoleElement extends ObservatoryElement { 623 class DebuggerConsoleElement extends ObservatoryElement {
102 @published Isolate isolate; 624 @published Isolate isolate;
103 625
104 DebuggerConsoleElement.created() : super.created(); 626 DebuggerConsoleElement.created() : super.created();
627
628 void printLine(String line) {
629 var div = new DivElement();
630 div.classes.add('normal');
631 div.appendText(line);
632 $['consoleText'].children.add(div);
633 div.scrollIntoView();
634 }
635
636 void printBold(String line) {
637 var div = new DivElement();
638 div.classes.add('bold');
639 div.appendText(line);
640 $['consoleText'].children.add(div);
641 div.scrollIntoView();
642 }
643
644 void newline() {
645 var br = new BRElement();
646 $['consoleText'].children.add(br);
647 br.scrollIntoView();
648 }
105 } 649 }
106 650
107 @CustomTag('debugger-input') 651 @CustomTag('debugger-input')
108 class DebuggerInputElement extends ObservatoryElement { 652 class DebuggerInputElement extends ObservatoryElement {
109 @published Isolate isolate; 653 @published Isolate isolate;
110 @published String text = ''; 654 @published String text = '';
655 @observable Debugger debugger;
111 656
112 @override 657 @override
113 void ready() { 658 void ready() {
114 super.ready(); 659 super.ready();
115 var textBox = $['textBox']; 660 var textBox = $['textBox'];
116 textBox.select(); 661 textBox.select();
117 textBox.onKeyDown.listen((KeyboardEvent e) { 662 textBox.onKeyDown.listen((KeyboardEvent e) {
118 switch (e.keyCode) { 663 switch (e.keyCode) {
119 case KeyCode.TAB: 664 case KeyCode.TAB:
120 e.preventDefault(); 665 e.preventDefault();
121 textBox.setRangeText('TAB'); 666 int cursorPos = textBox.selectionStart;
122 textBox.setSelectionRange(textBox.selectionStart + 3, 667 var completion = debugger.complete(text.substring(0, cursorPos));
123 textBox.selectionStart + 3); 668 text = completion + text.substring(cursorPos);
669 // TODO(turnidge): Move the cursor to the end of the
670 // completion, rather than the end of the string.
124 break; 671 break;
125 case KeyCode.ENTER: 672 case KeyCode.ENTER:
126 print('Debugger command (not implemented): $text'); 673 if (!debugger.busy) {
127 text = ''; 674 debugger.run(text);
675 text = '';
676 }
128 break; 677 break;
129 } 678 }
130 }); 679 });
131 } 680 }
132 681
133 DebuggerInputElement.created() : super.created(); 682 DebuggerInputElement.created() : super.created();
134 } 683 }
135 684
OLDNEW
« no previous file with comments | « runtime/observatory/lib/src/app/application.dart ('k') | runtime/observatory/lib/src/elements/debugger.html » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698