Web Inspector: add Ace editor experiment
[WebKit-https.git] / Source / WebCore / inspector / front-end / SourceFrame.js
1 /*
2  * Copyright (C) 2011 Google Inc. All rights reserved.
3  *
4  * Redistribution and use in source and binary forms, with or without
5  * modification, are permitted provided that the following conditions are
6  * met:
7  *
8  *     * Redistributions of source code must retain the above copyright
9  * notice, this list of conditions and the following disclaimer.
10  *     * Redistributions in binary form must reproduce the above
11  * copyright notice, this list of conditions and the following disclaimer
12  * in the documentation and/or other materials provided with the
13  * distribution.
14  *     * Neither the name of Google Inc. nor the names of its
15  * contributors may be used to endorse or promote products derived from
16  * this software without specific prior written permission.
17  *
18  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19  * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20  * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21  * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22  * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23  * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24  * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25  * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26  * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27  * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29  */
30
31 /**
32  * @extends {WebInspector.View}
33  * @constructor
34  * @param {WebInspector.ContentProvider} contentProvider
35  */
36 WebInspector.SourceFrame = function(contentProvider)
37 {
38     WebInspector.View.call(this);
39     this.element.addStyleClass("script-view");
40
41     this._url = contentProvider.contentURL();
42     this._contentProvider = contentProvider;
43
44     var textEditorDelegate = new WebInspector.TextEditorDelegateForSourceFrame(this);
45
46     if (WebInspector.experimentsSettings.codemirror.isEnabled()) {
47         loadScript("CodeMirrorTextEditor.js");
48         this._textEditor = new WebInspector.CodeMirrorTextEditor(this._url, textEditorDelegate);
49     } else if (WebInspector.experimentsSettings.aceTextEditor.isEnabled()) {
50         loadScript("AceTextEditor.js");
51         this._textEditor = new WebInspector.AceTextEditor(this._url, textEditorDelegate);
52     } else
53         this._textEditor = new WebInspector.DefaultTextEditor(this._url, textEditorDelegate);
54
55     this._currentSearchResultIndex = -1;
56     this._searchResults = [];
57
58     this._messages = [];
59     this._rowMessages = {};
60     this._messageBubbles = {};
61
62     this._textEditor.setReadOnly(!this.canEditSource());
63
64     this._shortcuts = {};
65     this._shortcuts[WebInspector.KeyboardShortcut.makeKey("s", WebInspector.KeyboardShortcut.Modifiers.CtrlOrMeta)] = this._commitEditing.bind(this);
66     this.element.addEventListener("keydown", this._handleKeyDown.bind(this), false);
67
68     this._sourcePositionElement = document.createElement("div");
69     this._sourcePositionElement.className = "source-frame-cursor-position";
70 }
71
72 /**
73  * @param {string} query
74  * @param {string=} modifiers
75  */
76 WebInspector.SourceFrame.createSearchRegex = function(query, modifiers)
77 {
78     var regex;
79     modifiers = modifiers || "";
80
81     // First try creating regex if user knows the / / hint.
82     try {
83         if (/^\/.*\/$/.test(query))
84             regex = new RegExp(query.substring(1, query.length - 1), modifiers);
85     } catch (e) {
86         // Silent catch.
87     }
88
89     // Otherwise just do case-insensitive search.
90     if (!regex)
91         regex = createPlainTextSearchRegex(query, "i" + modifiers);
92
93     return regex;
94 }
95
96 WebInspector.SourceFrame.Events = {
97     ScrollChanged: "ScrollChanged",
98     SelectionChanged: "SelectionChanged"
99 }
100
101 WebInspector.SourceFrame.prototype = {
102     wasShown: function()
103     {
104         this._ensureContentLoaded();
105         this._textEditor.show(this.element);
106         this._wasShownOrLoaded();
107     },
108
109     willHide: function()
110     {
111         WebInspector.View.prototype.willHide.call(this);
112
113         this._clearLineHighlight();
114         this._clearLineToReveal();
115     },
116
117     /**
118      * @return {?Element}
119      */
120     statusBarText: function()
121     {
122         return this._sourcePositionElement;
123     },
124
125     /**
126      * @return {Array.<Element>}
127      */
128     statusBarItems: function()
129     {
130         return [];
131     },
132
133     defaultFocusedElement: function()
134     {
135         return this._textEditor.defaultFocusedElement();
136     },
137
138     get loaded()
139     {
140         return this._loaded;
141     },
142
143     hasContent: function()
144     {
145         return true;
146     },
147
148     get textEditor()
149     {
150         return this._textEditor;
151     },
152
153     _ensureContentLoaded: function()
154     {
155         if (!this._contentRequested) {
156             this._contentRequested = true;
157             this._contentProvider.requestContent(this.setContent.bind(this));
158         }
159     },
160
161     addMessage: function(msg)
162     {
163         this._messages.push(msg);
164         if (this.loaded)
165             this.addMessageToSource(msg.line - 1, msg);
166     },
167
168     clearMessages: function()
169     {
170         for (var line in this._messageBubbles) {
171             var bubble = this._messageBubbles[line];
172             bubble.parentNode.removeChild(bubble);
173         }
174
175         this._messages = [];
176         this._rowMessages = {};
177         this._messageBubbles = {};
178
179         this._textEditor.doResize();
180     },
181
182     /**
183      * @param {number} line
184      */
185     canHighlightLine: function(line)
186     {
187         return true;
188     },
189
190     /**
191      * @param {number} line
192      */
193     highlightLine: function(line)
194     {
195         this._clearLineToReveal();
196         this._clearLineToScrollTo();
197         this._lineToHighlight = line;
198         this._innerHighlightLineIfNeeded();
199         this._textEditor.setSelection(WebInspector.TextRange.createFromLocation(line, 0));
200     },
201
202     _innerHighlightLineIfNeeded: function()
203     {
204         if (typeof this._lineToHighlight === "number") {
205             if (this.loaded && this._textEditor.isShowing()) {
206                 this._textEditor.highlightLine(this._lineToHighlight);
207                 delete this._lineToHighlight
208             }
209         }
210     },
211
212     _clearLineHighlight: function()
213     {
214         this._textEditor.clearLineHighlight();
215         delete this._lineToHighlight;
216     },
217
218     /**
219      * @param {number} line
220      */
221     revealLine: function(line)
222     {
223         this._clearLineHighlight();
224         this._clearLineToScrollTo();
225         this._lineToReveal = line;
226         this._innerRevealLineIfNeeded();
227     },
228
229     _innerRevealLineIfNeeded: function()
230     {
231         if (typeof this._lineToReveal === "number") {
232             if (this.loaded && this._textEditor.isShowing()) {
233                 this._textEditor.revealLine(this._lineToReveal);
234                 delete this._lineToReveal
235             }
236         }
237     },
238
239     _clearLineToReveal: function()
240     {
241         delete this._lineToReveal;
242     },
243
244     /**
245      * @param {number} line
246      */
247     scrollToLine: function(line)
248     {
249         this._clearLineHighlight();
250         this._clearLineToReveal();
251         this._lineToScrollTo = line;
252         this._innerScrollToLineIfNeeded();
253     },
254
255     _innerScrollToLineIfNeeded: function()
256     {
257         if (typeof this._lineToScrollTo === "number") {
258             if (this.loaded && this._textEditor.isShowing()) {
259                 this._textEditor.scrollToLine(this._lineToScrollTo);
260                 delete this._lineToScrollTo
261             }
262         }
263     },
264
265     _clearLineToScrollTo: function()
266     {
267         delete this._lineToScrollTo;
268     },
269
270     /**
271      * @param {WebInspector.TextRange} textRange
272      */
273     setSelection: function(textRange)
274     {
275         this._selectionToSet = textRange;
276         this._innerSetSelectionIfNeeded();
277     },
278
279     _innerSetSelectionIfNeeded: function()
280     {
281         if (this._selectionToSet && this.loaded && this._textEditor.isShowing()) {
282             this._textEditor.setSelection(this._selectionToSet);
283             delete this._selectionToSet;
284         }
285     },
286
287     _wasShownOrLoaded: function()
288     {
289         this._innerHighlightLineIfNeeded();
290         this._innerRevealLineIfNeeded();
291         this._innerScrollToLineIfNeeded();
292         this._innerSetSelectionIfNeeded();
293     },
294
295     onTextChanged: function(oldRange, newRange)
296     {
297         if (!this._isReplacing)
298             WebInspector.searchController.cancelSearch();
299         this.clearMessages();
300     },
301
302     /**
303      * @param {?string} content
304      * @param {boolean} contentEncoded
305      * @param {string} mimeType
306      */
307     setContent: function(content, contentEncoded, mimeType)
308     {
309         this._textEditor.mimeType = mimeType;
310
311         if (!this._loaded) {
312             this._loaded = true;
313             this._textEditor.setText(content || "");
314         } else
315             this._textEditor.editRange(this._textEditor.range(), content || "");
316
317         this._textEditor.beginUpdates();
318
319         this._setTextEditorDecorations();
320
321         this._wasShownOrLoaded();
322
323         if (this._delayedFindSearchMatches) {
324             this._delayedFindSearchMatches();
325             delete this._delayedFindSearchMatches;
326         }
327
328         this.onTextEditorContentLoaded();
329
330         this._textEditor.endUpdates();
331     },
332
333     onTextEditorContentLoaded: function() {},
334
335     _setTextEditorDecorations: function()
336     {
337         this._rowMessages = {};
338         this._messageBubbles = {};
339
340         this._textEditor.beginUpdates();
341
342         this._addExistingMessagesToSource();
343
344         this._textEditor.doResize();
345
346         this._textEditor.endUpdates();
347     },
348
349     /**
350      * @param {string} query
351      * @param {function(WebInspector.View, number)} callback
352      */
353     performSearch: function(query, callback)
354     {
355         // Call searchCanceled since it will reset everything we need before doing a new search.
356         this.searchCanceled();
357
358         function doFindSearchMatches(query)
359         {
360             this._currentSearchResultIndex = -1;
361             this._searchResults = [];
362
363             var regex = WebInspector.SourceFrame.createSearchRegex(query);
364             this._searchResults = this._collectRegexMatches(regex);
365             var shiftToIndex = 0;
366             var selection = this._textEditor.lastSelection();
367             for (var i = 0; selection && i < this._searchResults.length; ++i) {
368                 if (this._searchResults[i].compareTo(selection) >= 0) {
369                     shiftToIndex = i;
370                     break;
371                 }
372             }
373
374             if (shiftToIndex)
375                 this._searchResults = this._searchResults.rotate(shiftToIndex);
376
377             callback(this, this._searchResults.length);
378         }
379
380         if (this.loaded)
381             doFindSearchMatches.call(this, query);
382         else
383             this._delayedFindSearchMatches = doFindSearchMatches.bind(this, query);
384
385         this._ensureContentLoaded();
386     },
387
388     searchCanceled: function()
389     {
390         delete this._delayedFindSearchMatches;
391         if (!this.loaded)
392             return;
393
394         this._currentSearchResultIndex = -1;
395         this._searchResults = [];
396         this._textEditor.markAndRevealRange(null);
397     },
398
399     hasSearchResults: function()
400     {
401         return this._searchResults.length > 0;
402     },
403
404     jumpToFirstSearchResult: function()
405     {
406         this.jumpToSearchResult(0);
407     },
408
409     jumpToLastSearchResult: function()
410     {
411         this.jumpToSearchResult(this._searchResults.length - 1);
412     },
413
414     jumpToNextSearchResult: function()
415     {
416         this.jumpToSearchResult(this._currentSearchResultIndex + 1);
417     },
418
419     jumpToPreviousSearchResult: function()
420     {
421         this.jumpToSearchResult(this._currentSearchResultIndex - 1);
422     },
423
424     showingFirstSearchResult: function()
425     {
426         return this._searchResults.length &&  this._currentSearchResultIndex === 0;
427     },
428
429     showingLastSearchResult: function()
430     {
431         return this._searchResults.length && this._currentSearchResultIndex === (this._searchResults.length - 1);
432     },
433
434     get currentSearchResultIndex()
435     {
436         return this._currentSearchResultIndex;
437     },
438
439     jumpToSearchResult: function(index)
440     {
441         if (!this.loaded || !this._searchResults.length)
442             return;
443         this._currentSearchResultIndex = (index + this._searchResults.length) % this._searchResults.length;
444         this._textEditor.markAndRevealRange(this._searchResults[this._currentSearchResultIndex]);
445     },
446
447     /**
448      * @param {string} text
449      */
450     replaceSearchMatchWith: function(text)
451     {
452         var range = this._searchResults[this._currentSearchResultIndex];
453         if (!range)
454             return;
455         this._textEditor.markAndRevealRange(null);
456
457         this._isReplacing = true;
458         var newRange = this._textEditor.editRange(range, text);
459         delete this._isReplacing;
460
461         this._textEditor.setSelection(newRange.collapseToEnd());
462     },
463
464     /**
465      * @param {string} query
466      * @param {string} replacement
467      */
468     replaceAllWith: function(query, replacement)
469     {
470         this._textEditor.markAndRevealRange(null);
471
472         var text = this._textEditor.text();
473         var range = this._textEditor.range();
474         text = text.replace(WebInspector.SourceFrame.createSearchRegex(query, "g"), replacement);
475
476         this._isReplacing = true;
477         this._textEditor.editRange(range, text);
478         delete this._isReplacing;
479     },
480
481     _collectRegexMatches: function(regexObject)
482     {
483         var ranges = [];
484         for (var i = 0; i < this._textEditor.linesCount; ++i) {
485             var line = this._textEditor.line(i);
486             var offset = 0;
487             do {
488                 var match = regexObject.exec(line);
489                 if (match) {
490                     if (match[0].length)
491                         ranges.push(new WebInspector.TextRange(i, offset + match.index, i, offset + match.index + match[0].length));
492                     offset += match.index + 1;
493                     line = line.substring(match.index + 1);
494                 }
495             } while (match && line);
496         }
497         return ranges;
498     },
499
500     _addExistingMessagesToSource: function()
501     {
502         var length = this._messages.length;
503         for (var i = 0; i < length; ++i)
504             this.addMessageToSource(this._messages[i].line - 1, this._messages[i]);
505     },
506
507     addMessageToSource: function(lineNumber, msg)
508     {
509         if (lineNumber >= this._textEditor.linesCount)
510             lineNumber = this._textEditor.linesCount - 1;
511         if (lineNumber < 0)
512             lineNumber = 0;
513
514         var messageBubbleElement = this._messageBubbles[lineNumber];
515         if (!messageBubbleElement || messageBubbleElement.nodeType !== Node.ELEMENT_NODE || !messageBubbleElement.hasStyleClass("webkit-html-message-bubble")) {
516             messageBubbleElement = document.createElement("div");
517             messageBubbleElement.className = "webkit-html-message-bubble";
518             this._messageBubbles[lineNumber] = messageBubbleElement;
519             this._textEditor.addDecoration(lineNumber, messageBubbleElement);
520         }
521
522         var rowMessages = this._rowMessages[lineNumber];
523         if (!rowMessages) {
524             rowMessages = [];
525             this._rowMessages[lineNumber] = rowMessages;
526         }
527
528         for (var i = 0; i < rowMessages.length; ++i) {
529             if (rowMessages[i].consoleMessage.isEqual(msg)) {
530                 rowMessages[i].repeatCount = msg.totalRepeatCount;
531                 this._updateMessageRepeatCount(rowMessages[i]);
532                 return;
533             }
534         }
535
536         var rowMessage = { consoleMessage: msg };
537         rowMessages.push(rowMessage);
538
539         var imageURL;
540         switch (msg.level) {
541             case WebInspector.ConsoleMessage.MessageLevel.Error:
542                 messageBubbleElement.addStyleClass("webkit-html-error-message");
543                 imageURL = "Images/errorIcon.png";
544                 break;
545             case WebInspector.ConsoleMessage.MessageLevel.Warning:
546                 messageBubbleElement.addStyleClass("webkit-html-warning-message");
547                 imageURL = "Images/warningIcon.png";
548                 break;
549         }
550
551         var messageLineElement = document.createElement("div");
552         messageLineElement.className = "webkit-html-message-line";
553         messageBubbleElement.appendChild(messageLineElement);
554
555         // Create the image element in the Inspector's document so we can use relative image URLs.
556         var image = document.createElement("img");
557         image.src = imageURL;
558         image.className = "webkit-html-message-icon";
559         messageLineElement.appendChild(image);
560         messageLineElement.appendChild(document.createTextNode(msg.message));
561
562         rowMessage.element = messageLineElement;
563         rowMessage.repeatCount = msg.totalRepeatCount;
564         this._updateMessageRepeatCount(rowMessage);
565     },
566
567     _updateMessageRepeatCount: function(rowMessage)
568     {
569         if (rowMessage.repeatCount < 2)
570             return;
571
572         if (!rowMessage.repeatCountElement) {
573             var repeatCountElement = document.createElement("span");
574             rowMessage.element.appendChild(repeatCountElement);
575             rowMessage.repeatCountElement = repeatCountElement;
576         }
577
578         rowMessage.repeatCountElement.textContent = WebInspector.UIString(" (repeated %d times)", rowMessage.repeatCount);
579     },
580
581     removeMessageFromSource: function(lineNumber, msg)
582     {
583         if (lineNumber >= this._textEditor.linesCount)
584             lineNumber = this._textEditor.linesCount - 1;
585         if (lineNumber < 0)
586             lineNumber = 0;
587
588         var rowMessages = this._rowMessages[lineNumber];
589         for (var i = 0; rowMessages && i < rowMessages.length; ++i) {
590             var rowMessage = rowMessages[i];
591             if (rowMessage.consoleMessage !== msg)
592                 continue;
593
594             var messageLineElement = rowMessage.element;
595             var messageBubbleElement = messageLineElement.parentElement;
596             messageBubbleElement.removeChild(messageLineElement);
597             rowMessages.remove(rowMessage);
598             if (!rowMessages.length)
599                 delete this._rowMessages[lineNumber];
600             if (!messageBubbleElement.childElementCount) {
601                 this._textEditor.removeDecoration(lineNumber, messageBubbleElement);
602                 delete this._messageBubbles[lineNumber];
603             }
604             break;
605         }
606     },
607
608     populateLineGutterContextMenu: function(contextMenu, lineNumber)
609     {
610     },
611
612     populateTextAreaContextMenu: function(contextMenu, lineNumber)
613     {
614     },
615
616     inheritScrollPositions: function(sourceFrame)
617     {
618         this._textEditor.inheritScrollPositions(sourceFrame._textEditor);
619     },
620
621     /**
622      * @return {boolean}
623      */
624     canEditSource: function()
625     {
626         return false;
627     },
628
629     /**
630      * @param {string} text 
631      */
632     commitEditing: function(text)
633     {
634     },
635
636     /**
637      * @param {WebInspector.TextRange} textRange
638      */
639     selectionChanged: function(textRange)
640     {
641         this._updateSourcePosition(textRange);
642         this.dispatchEventToListeners(WebInspector.SourceFrame.Events.SelectionChanged, textRange);
643     },
644
645     /**
646      * @param {WebInspector.TextRange} textRange
647      */
648     _updateSourcePosition: function(textRange)
649     {
650         if (!textRange)
651             return;
652
653         if (textRange.isEmpty()) {
654             this._sourcePositionElement.textContent = WebInspector.UIString("Line %d, Column %d", textRange.endLine + 1, textRange.endColumn + 1);
655             return;
656         }
657         textRange = textRange.normalize();
658
659         var selectedText = this._textEditor.copyRange(textRange);
660         if (textRange.startLine === textRange.endLine)
661             this._sourcePositionElement.textContent = WebInspector.UIString("%d characters selected", selectedText.length);
662         else
663             this._sourcePositionElement.textContent = WebInspector.UIString("%d lines, %d characters selected", textRange.endLine - textRange.startLine + 1, selectedText.length);
664     },
665
666     /**
667      * @param {number} lineNumber
668      */
669     scrollChanged: function(lineNumber)
670     {
671         this.dispatchEventToListeners(WebInspector.SourceFrame.Events.ScrollChanged, lineNumber);
672     },
673
674     _handleKeyDown: function(e)
675     {
676         var shortcutKey = WebInspector.KeyboardShortcut.makeKeyFromEvent(e);
677         var handler = this._shortcuts[shortcutKey];
678         if (handler && handler())
679             e.consume(true);
680     },
681
682     _commitEditing: function()
683     {
684         if (this._textEditor.readOnly())
685             return false;
686
687         var content = this._textEditor.text();
688         this.commitEditing(content);
689         return true;
690     },
691
692     __proto__: WebInspector.View.prototype
693 }
694
695
696 /**
697  * @implements {WebInspector.TextEditorDelegate}
698  * @constructor
699  */
700 WebInspector.TextEditorDelegateForSourceFrame = function(sourceFrame)
701 {
702     this._sourceFrame = sourceFrame;
703 }
704
705 WebInspector.TextEditorDelegateForSourceFrame.prototype = {
706     onTextChanged: function(oldRange, newRange)
707     {
708         this._sourceFrame.onTextChanged(oldRange, newRange);
709     },
710
711     /**
712      * @param {WebInspector.TextRange} textRange
713      */
714     selectionChanged: function(textRange)
715     {
716         this._sourceFrame.selectionChanged(textRange);
717     },
718
719     /**
720      * @param {number} lineNumber
721      */
722     scrollChanged: function(lineNumber)
723     {
724         this._sourceFrame.scrollChanged(lineNumber);
725     },
726
727     populateLineGutterContextMenu: function(contextMenu, lineNumber)
728     {
729         this._sourceFrame.populateLineGutterContextMenu(contextMenu, lineNumber);
730     },
731
732     populateTextAreaContextMenu: function(contextMenu, lineNumber)
733     {
734         this._sourceFrame.populateTextAreaContextMenu(contextMenu, lineNumber);
735     },
736
737     /**
738      * @param {string} hrefValue
739      * @param {boolean} isExternal
740      * @return {Element}
741      */
742     createLink: function(hrefValue, isExternal)
743     {
744         var targetLocation = WebInspector.ParsedURL.completeURL(this._sourceFrame._url, hrefValue);
745         return WebInspector.linkifyURLAsNode(targetLocation || hrefValue, hrefValue, undefined, isExternal);
746     },
747
748     __proto__: WebInspector.TextEditorDelegate.prototype
749 }