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