Web Inspector: Global search sometimes returns duplicate results for a resource
[WebKit-https.git] / Source / WebInspectorUI / UserInterface / Views / SearchSidebarPanel.js
1 /*
2  * Copyright (C) 2013, 2015 Apple 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
6  * are met:
7  * 1. Redistributions of source code must retain the above copyright
8  *    notice, this list of conditions and the following disclaimer.
9  * 2. Redistributions in binary form must reproduce the above copyright
10  *    notice, this list of conditions and the following disclaimer in the
11  *    documentation and/or other materials provided with the distribution.
12  *
13  * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
14  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
15  * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
16  * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
17  * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18  * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
19  * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20  * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21  * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
22  * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
23  * THE POSSIBILITY OF SUCH DAMAGE.
24  */
25
26 WI.SearchSidebarPanel = class SearchSidebarPanel extends WI.NavigationSidebarPanel
27 {
28     constructor()
29     {
30         super("search", WI.UIString("Search"), true, true);
31
32         var searchElement = document.createElement("div");
33         searchElement.classList.add("search-bar");
34         this.element.appendChild(searchElement);
35
36         this._inputElement = document.createElement("input");
37         this._inputElement.type = "search";
38         this._inputElement.spellcheck = false;
39         this._inputElement.addEventListener("search", this._searchFieldChanged.bind(this));
40         this._inputElement.addEventListener("input", this._searchFieldInput.bind(this));
41         this._inputElement.setAttribute("results", 5);
42         this._inputElement.setAttribute("autosave", "inspector-search-autosave");
43         this._inputElement.setAttribute("placeholder", WI.UIString("Search Resource Content"));
44         searchElement.appendChild(this._inputElement);
45
46         this._searchQuerySetting = new WI.Setting("search-sidebar-query", "");
47         this._inputElement.value = this._searchQuerySetting.value;
48
49         WI.Frame.addEventListener(WI.Frame.Event.MainResourceDidChange, this._mainResourceDidChange, this);
50
51         this.contentTreeOutline.addEventListener(WI.TreeOutline.Event.SelectionDidChange, this._treeSelectionDidChange, this);
52     }
53
54     // Public
55
56     closed()
57     {
58         super.closed();
59
60         WI.Frame.removeEventListener(null, null, this);
61     }
62
63     focusSearchField(performSearch)
64     {
65         if (!this.parentSidebar)
66             return;
67
68         this.parentSidebar.selectedSidebarPanel = this;
69         this.parentSidebar.collapsed = false;
70
71         this._inputElement.select();
72
73         if (performSearch)
74             this.performSearch(this._inputElement.value);
75     }
76
77     performSearch(searchQuery)
78     {
79         // Before performing a new search, clear the old search.
80         this.contentTreeOutline.removeChildren();
81         this.contentBrowser.contentViewContainer.closeAllContentViews();
82
83         this._inputElement.value = searchQuery;
84         this._searchQuerySetting.value = searchQuery;
85
86         this.hideEmptyContentPlaceholder();
87
88         this.element.classList.remove("changed");
89         if (this._changedBanner)
90             this._changedBanner.remove();
91
92         searchQuery = searchQuery.trim();
93         if (!searchQuery.length)
94             return;
95
96         // FIXME: Provide UI to toggle regex and case sensitive searches.
97         var isCaseSensitive = false;
98         var isRegex = false;
99
100         var updateEmptyContentPlaceholderTimeout = null;
101
102         function createTreeElementForMatchObject(matchObject, parentTreeElement)
103         {
104             let matchTreeElement = new WI.SearchResultTreeElement(matchObject);
105             matchTreeElement.addEventListener(WI.TreeElement.Event.DoubleClick, this._treeElementDoubleClick, this);
106
107             parentTreeElement.appendChild(matchTreeElement);
108
109             if (!this.contentTreeOutline.selectedTreeElement)
110                 matchTreeElement.revealAndSelect(false, true);
111         }
112
113         function updateEmptyContentPlaceholderSoon()
114         {
115             if (updateEmptyContentPlaceholderTimeout)
116                 return;
117             updateEmptyContentPlaceholderTimeout = setTimeout(updateEmptyContentPlaceholder.bind(this), 100);
118         }
119
120         function updateEmptyContentPlaceholder()
121         {
122             if (updateEmptyContentPlaceholderTimeout) {
123                 clearTimeout(updateEmptyContentPlaceholderTimeout);
124                 updateEmptyContentPlaceholderTimeout = null;
125             }
126
127             this.updateEmptyContentPlaceholder(WI.UIString("No Search Results"));
128         }
129
130         function forEachMatch(searchQuery, lineContent, callback)
131         {
132             var lineMatch;
133             var searchRegex = new RegExp(searchQuery.escapeForRegExp(), "gi");
134             while ((searchRegex.lastIndex < lineContent.length) && (lineMatch = searchRegex.exec(lineContent)))
135                 callback(lineMatch, searchRegex.lastIndex);
136         }
137
138         function resourcesCallback(error, result)
139         {
140             updateEmptyContentPlaceholderSoon.call(this);
141
142             if (error)
143                 return;
144
145             function resourceCallback(frameId, url, error, resourceMatches)
146             {
147                 updateEmptyContentPlaceholderSoon.call(this);
148
149                 if (error || !resourceMatches || !resourceMatches.length)
150                     return;
151
152                 var frame = WI.frameResourceManager.frameForIdentifier(frameId);
153                 if (!frame)
154                     return;
155
156                 var resource = frame.url === url ? frame.mainResource : frame.resourceForURL(url);
157                 if (!resource)
158                     return;
159
160                 var resourceTreeElement = this._searchTreeElementForResource(resource);
161
162                 for (var i = 0; i < resourceMatches.length; ++i) {
163                     var match = resourceMatches[i];
164                     forEachMatch(searchQuery, match.lineContent, (lineMatch, lastIndex) => {
165                         var matchObject = new WI.SourceCodeSearchMatchObject(resource, match.lineContent, searchQuery, new WI.TextRange(match.lineNumber, lineMatch.index, match.lineNumber, lastIndex));
166                         createTreeElementForMatchObject.call(this, matchObject, resourceTreeElement);
167                     });
168                 }
169
170                 updateEmptyContentPlaceholder.call(this);
171             }
172
173             let preventDuplicates = new Set;
174
175             for (let i = 0; i < result.length; ++i) {
176                 let searchResult = result[i];
177                 if (!searchResult.url || !searchResult.frameId)
178                     continue;
179
180                 // FIXME: Backend sometimes searches files twice.
181                 // <https://webkit.org/b/188287> Web Inspector: [Backend] Page.searchInResources sometimes returns duplicate results for a resource
182                 // Note we will still want this to fix legacy backends.
183                 let key = searchResult.frameId + ":" + searchResult.url;
184                 if (preventDuplicates.has(key))
185                     continue;
186                 preventDuplicates.add(key);
187
188                 // COMPATIBILITY (iOS 9): Page.searchInResources did not have the optional requestId parameter.
189                 PageAgent.searchInResource(searchResult.frameId, searchResult.url, searchQuery, isCaseSensitive, isRegex, searchResult.requestId, resourceCallback.bind(this, searchResult.frameId, searchResult.url));
190             }
191
192             let promises = [
193                 WI.Frame.awaitEvent(WI.Frame.Event.ResourceWasAdded),
194                 WI.Target.awaitEvent(WI.Target.Event.ResourceAdded)
195             ];
196             Promise.race(promises).then(this._contentChanged.bind(this));
197         }
198
199         function searchScripts(scriptsToSearch)
200         {
201             updateEmptyContentPlaceholderSoon.call(this);
202
203             if (!scriptsToSearch.length)
204                 return;
205
206             function scriptCallback(script, error, scriptMatches)
207             {
208                 updateEmptyContentPlaceholderSoon.call(this);
209
210                 if (error || !scriptMatches || !scriptMatches.length)
211                     return;
212
213                 var scriptTreeElement = this._searchTreeElementForScript(script);
214
215                 for (var i = 0; i < scriptMatches.length; ++i) {
216                     var match = scriptMatches[i];
217                     forEachMatch(searchQuery, match.lineContent, (lineMatch, lastIndex) => {
218                         var matchObject = new WI.SourceCodeSearchMatchObject(script, match.lineContent, searchQuery, new WI.TextRange(match.lineNumber, lineMatch.index, match.lineNumber, lastIndex));
219                         createTreeElementForMatchObject.call(this, matchObject, scriptTreeElement);
220                     });
221                 }
222
223                 updateEmptyContentPlaceholder.call(this);
224             }
225
226             for (let script of scriptsToSearch)
227                 script.target.DebuggerAgent.searchInContent(script.id, searchQuery, isCaseSensitive, isRegex, scriptCallback.bind(this, script));
228         }
229
230         function domCallback(error, searchId, resultsCount)
231         {
232             updateEmptyContentPlaceholderSoon.call(this);
233
234             if (error || !resultsCount)
235                 return;
236
237             console.assert(searchId);
238
239             this._domSearchIdentifier = searchId;
240
241             function domSearchResults(error, nodeIds)
242             {
243                 updateEmptyContentPlaceholderSoon.call(this);
244
245                 if (error)
246                     return;
247
248                 for (var i = 0; i < nodeIds.length; ++i) {
249                     // If someone started a new search, then return early and stop showing seach results from the old query.
250                     if (this._domSearchIdentifier !== searchId)
251                         return;
252
253                     var domNode = WI.domTreeManager.nodeForId(nodeIds[i]);
254                     if (!domNode || !domNode.ownerDocument)
255                         continue;
256
257                     // We do not display the document node when the search query is "/". We don't have anything to display in the content view for it.
258                     if (domNode.nodeType() === Node.DOCUMENT_NODE)
259                         continue;
260
261                     // FIXME: This should use a frame to do resourceForURL, but DOMAgent does not provide a frameId.
262                     var resource = WI.frameResourceManager.resourceForURL(domNode.ownerDocument.documentURL);
263                     if (!resource)
264                         continue;
265
266                     var resourceTreeElement = this._searchTreeElementForResource(resource);
267                     var domNodeTitle = WI.DOMSearchMatchObject.titleForDOMNode(domNode);
268
269                     // Textual matches.
270                     var didFindTextualMatch = false;
271                     forEachMatch(searchQuery, domNodeTitle, (lineMatch, lastIndex) => {
272                         var matchObject = new WI.DOMSearchMatchObject(resource, domNode, domNodeTitle, searchQuery, new WI.TextRange(0, lineMatch.index, 0, lastIndex));
273                         createTreeElementForMatchObject.call(this, matchObject, resourceTreeElement);
274                         didFindTextualMatch = true;
275                     });
276
277                     // Non-textual matches are CSS Selector or XPath matches. In such cases, display the node entirely highlighted.
278                     if (!didFindTextualMatch) {
279                         var matchObject = new WI.DOMSearchMatchObject(resource, domNode, domNodeTitle, domNodeTitle, new WI.TextRange(0, 0, 0, domNodeTitle.length));
280                         createTreeElementForMatchObject.call(this, matchObject, resourceTreeElement);
281                     }
282
283                     updateEmptyContentPlaceholder.call(this);
284                 }
285             }
286
287             DOMAgent.getSearchResults(searchId, 0, resultsCount, domSearchResults.bind(this));
288         }
289
290         if (window.DOMAgent)
291             WI.domTreeManager.ensureDocument();
292
293         if (window.PageAgent)
294             PageAgent.searchInResources(searchQuery, isCaseSensitive, isRegex, resourcesCallback.bind(this));
295
296         setTimeout(searchScripts.bind(this, WI.debuggerManager.searchableScripts), 0);
297
298         if (window.DOMAgent) {
299             if (this._domSearchIdentifier) {
300                 DOMAgent.discardSearchResults(this._domSearchIdentifier);
301                 this._domSearchIdentifier = undefined;
302             }
303
304             DOMAgent.performSearch(searchQuery, domCallback.bind(this));
305         }
306
307         // FIXME: Resource search should work in JSContext inspection.
308         // <https://webkit.org/b/131252> Web Inspector: JSContext inspection Resource search does not work
309         if (!window.DOMAgent && !window.PageAgent)
310             updateEmptyContentPlaceholderSoon.call(this);
311     }
312
313     // Private
314
315     _searchFieldChanged(event)
316     {
317         this.performSearch(event.target.value);
318     }
319
320     _searchFieldInput(event)
321     {
322         // If the search field is cleared, immediately clear the search results tree outline.
323         if (!event.target.value.length)
324             this.performSearch("");
325     }
326
327     _searchTreeElementForResource(resource)
328     {
329         var resourceTreeElement = this.contentTreeOutline.getCachedTreeElement(resource);
330         if (!resourceTreeElement) {
331             resourceTreeElement = new WI.ResourceTreeElement(resource);
332             resourceTreeElement.hasChildren = true;
333             resourceTreeElement.expand();
334
335             this.contentTreeOutline.appendChild(resourceTreeElement);
336         }
337
338         return resourceTreeElement;
339     }
340
341     _searchTreeElementForScript(script)
342     {
343         var scriptTreeElement = this.contentTreeOutline.getCachedTreeElement(script);
344         if (!scriptTreeElement) {
345             scriptTreeElement = new WI.ScriptTreeElement(script);
346             scriptTreeElement.hasChildren = true;
347             scriptTreeElement.expand();
348
349             this.contentTreeOutline.appendChild(scriptTreeElement);
350         }
351
352         return scriptTreeElement;
353     }
354
355     _mainResourceDidChange(event)
356     {
357         if (!event.target.isMainFrame())
358             return;
359
360         if (this._delayedSearchTimeout) {
361             clearTimeout(this._delayedSearchTimeout);
362             this._delayedSearchTimeout = undefined;
363         }
364
365         this.contentTreeOutline.removeChildren();
366         this.contentBrowser.contentViewContainer.closeAllContentViews();
367
368         if (this.visible) {
369             const performSearch = true;
370             this.focusSearchField(performSearch);
371         }
372     }
373
374     _treeSelectionDidChange(event)
375     {
376         if (!this.selected)
377             return;
378
379         let treeElement = event.data.selectedElement;
380         if (!treeElement || treeElement instanceof WI.FolderTreeElement)
381             return;
382
383         const options = {
384             ignoreNetworkTab: true,
385         };
386
387         if (treeElement instanceof WI.ResourceTreeElement || treeElement instanceof WI.ScriptTreeElement) {
388             const cookie = null;
389             WI.showRepresentedObject(treeElement.representedObject, cookie, options);
390             return;
391         }
392
393         console.assert(treeElement instanceof WI.SearchResultTreeElement);
394         if (!(treeElement instanceof WI.SearchResultTreeElement))
395             return;
396
397         if (treeElement.representedObject instanceof WI.DOMSearchMatchObject)
398             WI.showMainFrameDOMTree(treeElement.representedObject.domNode);
399         else if (treeElement.representedObject instanceof WI.SourceCodeSearchMatchObject)
400             WI.showOriginalOrFormattedSourceCodeTextRange(treeElement.representedObject.sourceCodeTextRange, options);
401     }
402
403     _treeElementDoubleClick(event)
404     {
405         let treeElement = event.target;
406         if (!treeElement)
407             return;
408
409         if (treeElement.representedObject instanceof WI.DOMSearchMatchObject) {
410             WI.showMainFrameDOMTree(treeElement.representedObject.domNode, {
411                 ignoreSearchTab: true,
412             });
413         } else if (treeElement.representedObject instanceof WI.SourceCodeSearchMatchObject) {
414             WI.showOriginalOrFormattedSourceCodeTextRange(treeElement.representedObject.sourceCodeTextRange, {
415                 ignoreNetworkTab: true,
416                 ignoreSearchTab: true,
417             });
418         }
419     }
420
421     _contentChanged(event)
422     {
423         this.element.classList.add("changed");
424
425         if (!this._changedBanner) {
426             this._changedBanner = document.createElement("div");
427             this._changedBanner.classList.add("banner");
428             this._changedBanner.append(WI.UIString("The page's content has changed"), document.createElement("br"));
429
430             let performSearchLink = this._changedBanner.appendChild(document.createElement("a"));
431             performSearchLink.textContent = WI.UIString("Search Again");
432             performSearchLink.addEventListener("click", () => {
433                 const performSearch = true;
434                 this.focusSearchField(performSearch);
435             });
436         }
437
438         this.element.appendChild(this._changedBanner);
439     }
440 };