This repository was archived by the owner on Sep 6, 2021. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 7.5k
Expand file tree
/
Copy pathQuickOpen.js
More file actions
980 lines (859 loc) · 42.8 KB
/
QuickOpen.js
File metadata and controls
980 lines (859 loc) · 42.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
/*
* Copyright (c) 2012 Adobe Systems Incorporated. All rights reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*
*/
/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */
/*global define, $, window, setTimeout */
/*unittests: QuickOpen*/
/*
* Displays an auto suggest pop-up list of files to allow the user to quickly navigate to a file and lines
* within a file.
* Uses FileIndexManger to supply the file list.
*
* TODO (issue 333) - currently jquery smart auto complete is used for the pop-up list. While it mostly works
* it has several issues, so it should be replace with an alternative. Issues:
* - the pop-up position logic has flaws that require CSS workarounds
* - the pop-up properties cannot be modified once the object is constructed
*/
define(function (require, exports, module) {
"use strict";
var DocumentManager = require("document/DocumentManager"),
EditorManager = require("editor/EditorManager"),
MainViewManager = require("view/MainViewManager"),
MainViewFactory = require("view/MainViewFactory"),
CommandManager = require("command/CommandManager"),
Strings = require("strings"),
StringUtils = require("utils/StringUtils"),
Commands = require("command/Commands"),
ProjectManager = require("project/ProjectManager"),
LanguageManager = require("language/LanguageManager"),
KeyEvent = require("utils/KeyEvent"),
ModalBar = require("widgets/ModalBar").ModalBar,
StringMatch = require("utils/StringMatch"),
ViewUtils = require("utils/ViewUtils");
/**
* The regular expression to check the cursor position
* @const {RegExp}
*/
var CURSOR_POS_EXP = new RegExp(":([^,]+)?(,(.+)?)?");
/**
* List of plugins
* @type {Array.<QuickOpenPlugin>}
*/
var plugins = [];
/**
* Current plugin
* @type {QuickOpenPlugin}
*/
var currentPlugin = null;
/**
* List of files
* @type {Array.<FileInfo>}
*/
var fileList;
/**
* File list promise
* @type {$.Promise}
*/
var fileListPromise;
/**
* The currently open quick open dialog.
* @type {Dialog}
*/
var _curDialog;
/**
* Defines API for new QuickOpen plug-ins
*/
function QuickOpenPlugin(name, languageIds, done, search, match, itemFocus, itemSelect, resultsFormatter, matcherOptions, label) {
this.name = name;
this.languageIds = languageIds;
this.done = done;
this.search = search;
this.match = match;
this.itemFocus = itemFocus;
this.itemSelect = itemSelect;
this.resultsFormatter = resultsFormatter;
this.matcherOptions = matcherOptions;
this.label = label;
}
/**
* Creates and registers a new QuickOpenPlugin
*
* @param { name: string,
* languageIds: Array.<string>,
* done: ?function(),
* search: function(string, !StringMatch.StringMatcher):Array.<SearchResult|string>,
* match: function(string):boolean,
* itemFocus: ?function(?SearchResult|string),
* itemSelect: function(?SearchResult|string),
* resultsFormatter: ?function(SearchResult|string, string):string,
* matcherOptions: ?Object,
* label: ?string
* } pluginDef
*
* Parameter Documentation:
*
* name - plug-in name, **must be unique**
* languageIds - language Ids array. Example: ["javascript", "css", "html"]. To allow any language, pass []. Required.
* done - called when quick open is complete. Plug-in should clear its internal state. Optional.
* search - takes a query string and a StringMatcher (the use of which is optional but can speed up your searches) and returns an array of strings that match the query. Required.
* match - takes a query string and returns true if this plug-in wants to provide
* results for this query. Required.
* itemFocus - performs an action when a result has been highlighted (via arrow keys, mouseover, etc.).
* The highlighted search result item (as returned by search()) is passed as an argument. Optional.
* itemSelect - performs an action when a result is chosen.
* The selected search result item (as returned by search()) is passed as an argument. Required.
* resultsFormatter - takes a query string and an item string and returns
* a <LI> item to insert into the displayed search results. Optional.
* matcherOptions - options to pass along to the StringMatcher (see StringMatch.StringMatcher
* for available options). Optional.
* label - if provided, the label to show before the query field. Optional.
*
* If itemFocus() makes changes to the current document or cursor/scroll position and then the user
* cancels Quick Open (via Esc), those changes are automatically reverted.
*/
function addQuickOpenPlugin(pluginDef) {
// Backwards compatibility (for now) for old fileTypes field, if newer languageIds not specified
if (pluginDef.fileTypes && !pluginDef.languageIds) {
console.warn("Using fileTypes for QuickOpen plugins is deprecated. Use languageIds instead.");
pluginDef.languageIds = pluginDef.fileTypes.map(function (extension) {
return LanguageManager.getLanguageForPath("file." + extension).getId();
});
delete pluginDef.fileTypes;
}
plugins.push(new QuickOpenPlugin(
pluginDef.name,
pluginDef.languageIds,
pluginDef.done,
pluginDef.search,
pluginDef.match,
pluginDef.itemFocus,
pluginDef.itemSelect,
pluginDef.resultsFormatter,
pluginDef.matcherOptions,
pluginDef.label
));
}
/**
* QuickNavigateDialog class
* @constructor
*/
function QuickNavigateDialog() {
this.$searchField = undefined; // defined when showDialog() is called
// Bind event handlers
this._handleItemSelect = this._handleItemSelect.bind(this);
this._handleItemFocus = this._handleItemFocus.bind(this);
this._handleKeyUp = this._handleKeyUp.bind(this);
this._handleResultsReady = this._handleResultsReady.bind(this);
this._handleShowResults = this._handleShowResults.bind(this);
this._handleBlur = this._handleBlur.bind(this);
this._handleDocumentMouseDown = this._handleDocumentMouseDown.bind(this);
// Bind callbacks from smart-autocomplete
this._filterCallback = this._filterCallback.bind(this);
this._resultsFormatterCallback = this._resultsFormatterCallback.bind(this);
// StringMatchers that cache in-progress query data.
this._filenameMatcher = new StringMatch.StringMatcher({
segmentedSearch: true
});
this._matchers = {};
}
/**
* True if the dialog is currently open. Note that this is set to false immediately
* when the dialog starts closing; it doesn't wait for the ModalBar animation to finish.
* @type {boolean}
*/
QuickNavigateDialog.prototype.isOpen = false;
/**
* @private
* Handles caching of filename search information for the lifetime of a
* QuickNavigateDialog (a single search until the dialog is dismissed)
*
* @type {StringMatch.StringMatcher}
*/
QuickNavigateDialog.prototype._filenameMatcher = null;
/**
* @private
* StringMatcher caches for each QuickOpen plugin that keep track of search
* information for the lifetime of a QuickNavigateDialog (a single search
* until the dialog is dismissed)
*
* @type {Object.<string, StringMatch.StringMatcher>}
*/
QuickNavigateDialog.prototype._matchers = null;
/**
* @private
* If the dialog is closing, this will contain a deferred that is resolved
* when it's done closing.
* @type {$.Deferred}
*/
QuickNavigateDialog.prototype._closeDeferred = null;
/**
* @private
* Remembers the current document that was displayed when showDialog() was called.
* TODO: in the future, if focusing an item can switch documents, need to restore this on Escape.
* @type {?string} full path
*/
QuickNavigateDialog.prototype._origDocPath = null;
/**
* @private
* Remembers the selection state in origDocPath that was present when showDialog() was called. Focusing on an
* item can change the selection; we restore this original selection if the user presses Escape. Null if
* no document was open when Quick Open was invoked.
* @type {?Array.<{{start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed:boolean}}>}
*/
QuickNavigateDialog.prototype._origSelections = null;
/**
* @private
* Remembers the scroll position in origDocPath when showDialog() was called (see origSelection above).
* @type {?{x:number, y:number}}
*/
QuickNavigateDialog.prototype._origScrollPos = null;
function _filenameFromPath(path, includeExtension) {
var end;
if (includeExtension) {
end = path.length;
} else {
end = path.lastIndexOf(".");
if (end === -1) {
end = path.length;
}
}
return path.slice(path.lastIndexOf("/") + 1, end);
}
/**
* Attempts to extract a line number from the query where the line number
* is followed by a colon. Callers should explicitly test result with isNaN()
*
* @param {string} query string to extract line number from
* @return {{query: string, local: boolean, line: number, ch: number}} An object with
* the extracted line and column numbers, and two additional fields: query with the original position
* string and local indicating if the cursor position should be applied to the current file.
* Or null if the query is invalid
*/
function extractCursorPos(query) {
var regInfo = query.match(CURSOR_POS_EXP);
if (query.length <= 1 || !regInfo ||
(regInfo[1] && isNaN(regInfo[1])) ||
(regInfo[3] && isNaN(regInfo[3]))) {
return null;
}
return {
query: regInfo[0],
local: query.indexOf(":") === 0,
line: regInfo[1] - 1 || 0,
ch: regInfo[3] - 1 || 0
};
}
/** Returns the last return value of _filterCallback(), which Smart Autocomplete helpfully caches */
function getLastFilterResult() {
var cachedResult = $("input#quickOpenSearch").data("smart-autocomplete").rawResults;
return cachedResult || [];
}
/**
* Converts from list item DOM node to search provider list object
* @param {jQueryObject} domItem
* @return {SearchResult|string} value returned from search()
*/
function domItemToSearchResult(domItem) {
if (!domItem) {
return null;
}
// Smart Autocomplete uses this assumption internally: index of DOM node in results list container
// exactly matches index of search result in list returned by _filterCallback()
var index = $(domItem).index();
var lastFilterResult = getLastFilterResult();
return lastFilterResult[index];
}
/**
* Navigates to the appropriate file and file location given the selected item
* and closes the dialog.
*
* Note, if selectedItem is null quick search should inspect $searchField for text
* that may have not matched anything in in the list, but may have information
* for carrying out an action (e.g. go to line).
*/
QuickNavigateDialog.prototype._handleItemSelect = function (e, selectedDOMItem) {
// This is a work-around to select first item when a selection event occurs
// (usually from pressing the enter key) and no item is selected in the list.
// This is a work-around since Smart auto complete doesn't select the first item
if (!selectedDOMItem) {
selectedDOMItem = $(".smart_autocomplete_container > li:first-child").get(0);
}
var selectedItem = domItemToSearchResult(selectedDOMItem),
doClose = true,
self = this,
query = this.$searchField.val(),
cursorPos = extractCursorPos(query);
// Delegate to current plugin
if (currentPlugin) {
currentPlugin.itemSelect(selectedItem);
} else {
// Navigate to file and line number
var fullPath = selectedItem && selectedItem.fullPath;
if (fullPath) {
// This case is tricky. We want to switch editors, so we need to deal with
// resizing/rescrolling the current editor first. But we don't actually want
// to start the animation of the ModalBar until afterward (otherwise it glitches
// because it gets starved of cycles during the creation of the new editor).
// So we call `prepareClose()` first, and finish the close later.
doClose = false;
this.modalBar.prepareClose();
CommandManager.execute(Commands.CMD_ADD_TO_WORKINGSET_AND_OPEN, {fullPath: fullPath})
.done(function () {
if (cursorPos) {
var editor = EditorManager.getCurrentFullEditor();
editor.setCursorPos(cursorPos.line, cursorPos.ch, true);
}
})
.always(function () {
self.close();
});
} else if (cursorPos) {
EditorManager.getCurrentFullEditor().setCursorPos(cursorPos.line, cursorPos.ch, true);
}
}
if (doClose) {
this.close(null, null, true);
MainViewManager.focusActivePane();
}
};
/**
* Opens the file specified by selected item if there is no current plug-in, otherwise defers handling
* to the currentPlugin
*/
QuickNavigateDialog.prototype._handleItemFocus = function (e, selectedDOMItem) {
var selectedItem = domItemToSearchResult(selectedDOMItem);
if (currentPlugin && currentPlugin.itemFocus) {
currentPlugin.itemFocus(selectedItem);
}
// TODO: Disable opening files on focus for now since this causes focus related bugs between
// the editor and the search field.
// Also, see related code in _handleItemFocus
/*
else {
var fullPath = selectedItem.fullPath;
if (fullPath) {
CommandManager.execute(Commands.FILE_OPEN, {fullPath: fullPath, focusEditor: false});
}
}
*/
};
/**
* Called before Smart Autocomplete processes the key, but after the DOM textfield ($searchField) updates its value.
* After this, Smart Autocomplete doesn't call _handleFilter() & re-render the list until a setTimeout(0) later.
*/
QuickNavigateDialog.prototype._handleKeyUp = function (e) {
// Cancel the search on Esc key, and finish the search on Enter key
if (e.keyCode === KeyEvent.DOM_VK_RETURN || e.keyCode === KeyEvent.DOM_VK_ESCAPE) {
// Smart Autocomplete also handles Enter; but it does so without a timeout, which causes #1855.
// Since our listener was added first (see showDialog()), we can steal the Enter event and block
// Smart Autocomplete from buggily acting on it.
e.stopImmediatePropagation();
e.preventDefault();
// Process on a timeout since letter keys are handled that way and we don't want to get ahead
// of processing letters that were typed before the Enter key. The ideal order of events is:
// letter keydown/keyup, letter key processed async, enter keydown/keyup, enter key processed async
// However, we might get 'enter keyup' before 'letter key processed async'. The letter key's
// timeout will always run before ours since it was registered first.
var self = this;
setTimeout(function () {
if (e.keyCode === KeyEvent.DOM_VK_ESCAPE) {
// Restore original selection / scroll pos
self.close(self._origScrollPos, self._origSelections, true);
} else if (e.keyCode === KeyEvent.DOM_VK_RETURN) {
self._handleItemSelect(null, $(".smart_autocomplete_highlight").get(0)); // calls close() too
}
}, 0);
}
};
/**
* Checks if the given query string is a line number query that is either empty (the number hasn't been typed yet)
* or is a valid line number within the visible range of the current full editor.
* @param {string} query The query to check.
* @return {boolean} true if the given query is a valid line number query.
*/
QuickNavigateDialog.prototype._isValidLineNumberQuery = function (query) {
// Empty query returns NaN from extractLineNumber, but we want to treat it as valid for UI purposes.
if (query === ":") {
return true;
}
var cursorPos = extractCursorPos(query),
editor = EditorManager.getCurrentFullEditor();
// We could just use 0 and lineCount() here, but in future we might want this logic to work for inline editors as well.
return (cursorPos && editor && cursorPos.line >= editor.getFirstVisibleLine() && cursorPos.line <= editor.getLastVisibleLine());
};
/**
* Called synchronously after _handleFilter(), but before the cached "last result" is updated and before the DOM
* list items are re-rendered. Both happen synchronously just after we return. Called even when results is empty.
*/
QuickNavigateDialog.prototype._handleResultsReady = function (e, results) {
// Give visual clue when there are no results (unless we're in "Go To Line" mode, where there
// are never results, or we're in file search mode and waiting for the index to get rebuilt)
var hasNoResults = (results.length === 0 && (fileList || currentPlugin) && !this._isValidLineNumberQuery(this.$searchField.val()));
ViewUtils.toggleClass(this.$searchField, "no-results", hasNoResults);
};
/**
* Called synchronously after all other processing is done (_handleFilter(), updating cached "last result" and
* re-rendering DOM list items). NOT called if the last filter action had 0 results.
*/
QuickNavigateDialog.prototype._handleShowResults = function (e, results) {
// Scroll to top result (unless some other item has been highlighted by user)
if ($(".smart_autocomplete_highlight").length === 0) {
this._handleItemFocus(null, $(".smart_autocomplete_container > li:first-child").get(0));
}
};
/**
* Closes the search dialog and notifies all quick open plugins that
* searching is done.
* @param {{x: number, y: number}=} scrollPos If specified, scroll to the given
* position when closing the ModalBar.
* @param Array.<{{start:{line:number, ch:number}, end:{line:number, ch:number}, primary:boolean, reversed:boolean}}>
* selections If specified, restore the given selections when closing the ModalBar.
* @param {boolean=} keepScrollPos Adjust scroll pos to keep current position when modal bar closes.
* Useful for Go to Line case where doc is scrolled, but don't know actual scroll pos to set so let modal bar figure it out.
* @return {$.Promise} Resolved when the search bar is entirely closed.
*/
QuickNavigateDialog.prototype.close = function (scrollPos, selections, keepScrollPos) {
if (!this.isOpen) {
return this._closeDeferred.promise();
}
this.isOpen = false;
// We can't just return the ModalBar's `close()` promise because we need to do it on a
// setTimeout, so we create our own Deferred and cache it so we can return it if multiple
// callers happen to call `close()`.
this._closeDeferred = new $.Deferred();
var i;
for (i = 0; i < plugins.length; i++) {
var plugin = plugins[i];
if (plugin.done) {
plugin.done();
}
}
// Make sure Smart Autocomplete knows its popup is getting closed (in cases where there's no
// editor to give focus to below, it won't notice otherwise).
this.$searchField.trigger("lostFocus");
// Closing the dialog is a little tricky (see #1384): some Smart Autocomplete code may run later (e.g.
// (because it's a later handler of the event that just triggered close()), and that code expects to
// find metadata that it stuffed onto the DOM node earlier. But $.remove() strips that metadata.
// So we wait until after this call chain is complete before actually closing the dialog.
var self = this;
setTimeout(function () {
self.modalBar.close(!!keepScrollPos).done(function () {
self._closeDeferred.resolve();
});
// Note that we deliberately reset the scroll position synchronously on return from
// `ModalBar.close()` (before the animation completes).
// See description of `restoreScrollPos` in `ModalBar.close()`.
var editor = EditorManager.getCurrentFullEditor();
if (selections) {
editor.setSelections(selections);
}
if (scrollPos) {
editor.setScrollPos(scrollPos.x, scrollPos.y);
}
}, 0);
$(".smart_autocomplete_container").remove();
$(window.document).off("mousedown", this._handleDocumentMouseDown);
return this._closeDeferred.promise();
};
/**
* Returns true if the query string doesn't match the query text field. This can happen when _handleFilter()
* runs slow (either synchronously or async as in searchFileList()). Several key events queue up before filtering
* is done, and each sets a timeout. After all the key events are handled, we wind up with a queue of timeouts
* waiting to run, once per key event. All but the last one reflect a stale value of the text field.
* @param {string} query
* @return {boolean}
*/
function queryIsStale(query) {
var currentQuery = $("input#quickOpenSearch").val();
return currentQuery !== query;
}
function searchFileList(query, matcher) {
// The file index may still be loading asynchronously - if so, can't return a result yet
if (!fileList) {
// Smart Autocomplete allows us to return a Promise instead...
var asyncResult = new $.Deferred();
fileListPromise.done(function () {
// ...but it's not very robust. If a previous Promise is obsoleted by the query string changing, it
// keeps listening to it anyway. So the last Promise to resolve "wins" the UI update even if it's for
// a stale query. Guard from that by checking that filter text hasn't changed while we were waiting:
if (!queryIsStale(query)) {
// We're still the current query. Synchronously re-run the search call and resolve with its results
asyncResult.resolve(searchFileList(query, matcher));
} else {
asyncResult.reject();
}
});
return asyncResult.promise();
}
var cursorPos = extractCursorPos(query);
if (cursorPos && !cursorPos.local && cursorPos.query !== "") {
query = query.replace(cursorPos.query, "");
}
// First pass: filter based on search string; convert to SearchResults containing extra info
// for sorting & display
var filteredList = $.map(fileList, function (fileInfo) {
// Is it a match at all?
// match query against the full path (with gaps between query characters allowed)
var searchResult;
searchResult = matcher.match(ProjectManager.makeProjectRelativeIfPossible(fileInfo.fullPath), query);
if (searchResult) {
searchResult.label = fileInfo.name;
searchResult.fullPath = fileInfo.fullPath;
searchResult.filenameWithoutExtension = _filenameFromPath(fileInfo.name, false);
}
return searchResult;
});
// Sort by "match goodness" tier first, then within each tier sort alphabetically - first by filename
// sans extension, (so that "abc.js" comes before "abc-d.js"), then by filename, and finally (for
// identically-named files) by full path
StringMatch.multiFieldSort(filteredList, { matchGoodness: 0, filenameWithoutExtension: 1, label: 2, fullPath: 3 });
return filteredList;
}
/**
* Handles changes to the current query in the search field.
* @param {string} query The new query.
* @return {Array} The filtered list of results.
*/
QuickNavigateDialog.prototype._filterCallback = function (query) {
// If previous filter calls ran slow, we may have accumulated several query change events in the meantime.
// Only respond to the one that's current. Note that this only works because we're called on a timeout after
// the key event; checking DURING the key event itself would never yield a future value for the input field.
if (queryIsStale(query)) {
return getLastFilterResult();
}
// "Go to line" mode is special-cased
var cursorPos = extractCursorPos(query);
if (cursorPos && cursorPos.local) {
var from = {line: cursorPos.line, ch: cursorPos.ch},
to = {line: cursorPos.line};
EditorManager.getCurrentFullEditor().setSelection(from, to, true);
}
// Try to invoke a search plugin
var curDoc = DocumentManager.getCurrentDocument(), languageId;
if (curDoc) {
languageId = curDoc.getLanguage().getId();
}
var i;
for (i = 0; i < plugins.length; i++) {
var plugin = plugins[i];
var languageIdMatch = plugin.languageIds.length === 0 || plugin.languageIds.indexOf(languageId) !== -1;
if (languageIdMatch && plugin.match && plugin.match(query)) {
currentPlugin = plugin;
// Look up the StringMatcher for this plugin.
var matcher = this._matchers[currentPlugin.name];
if (!matcher) {
matcher = new StringMatch.StringMatcher(plugin.matcherOptions);
this._matchers[currentPlugin.name] = matcher;
}
this._updateDialogLabel(plugin, query);
return plugin.search(query, matcher);
}
}
// Reflect current search mode in UI
this._updateDialogLabel(null, query);
// No matching plugin: use default file search mode
currentPlugin = null;
return searchFileList(query, this._filenameMatcher);
};
/**
* Formats item's label as properly escaped HTML text, highlighting sections that match 'query'.
* If item is a SearchResult generated by stringMatch(), uses its metadata about which string ranges
* matched; else formats the label with no highlighting.
* @param {!string|SearchResult} item
* @param {?string} matchClass CSS class for highlighting matched text
* @param {?function(boolean, string):string} rangeFilter
* @return {!string} bolded, HTML-escaped result
*/
function highlightMatch(item, matchClass, rangeFilter) {
var label = item.label || item;
matchClass = matchClass || "quicksearch-namematch";
var stringRanges = item.stringRanges;
if (!stringRanges) {
// If result didn't come from stringMatch(), highlight nothing
stringRanges = [{
text: label,
matched: false,
includesLastSegment: true
}];
}
var displayName = "";
if (item.scoreDebug) {
var sd = item.scoreDebug;
displayName += '<span title="sp:' + sd.special + ', m:' + sd.match +
', ls:' + sd.lastSegment + ', b:' + sd.beginning +
', ld:' + sd.lengthDeduction + ', c:' + sd.consecutive + ', nsos: ' +
sd.notStartingOnSpecial + '">(' + item.matchGoodness + ') </span>';
}
// Put the path pieces together, highlighting the matched parts
stringRanges.forEach(function (range) {
if (range.matched) {
displayName += "<span class='" + matchClass + "'>";
}
var rangeText = rangeFilter ? rangeFilter(range.includesLastSegment, range.text) : range.text;
displayName += StringUtils.breakableUrl(rangeText);
if (range.matched) {
displayName += "</span>";
}
});
return displayName;
}
function defaultResultsFormatter(item, query) {
query = query.slice(query.indexOf("@") + 1, query.length);
var displayName = highlightMatch(item);
return "<li>" + displayName + "</li>";
}
function _filenameResultsFormatter(item, query) {
// For main label, we just want filename: drop most of the string
function fileNameFilter(includesLastSegment, rangeText) {
if (includesLastSegment) {
var rightmostSlash = rangeText.lastIndexOf('/');
return rangeText.substring(rightmostSlash + 1); // safe even if rightmostSlash is -1
} else {
return "";
}
}
var displayName = highlightMatch(item, null, fileNameFilter);
var displayPath = highlightMatch(item, "quicksearch-pathmatch");
return "<li>" + displayName + "<br /><span class='quick-open-path'>" + displayPath + "</span></li>";
}
/**
* Formats the entry for the given item to be displayed in the dropdown.
* @param {Object} item The item to be displayed.
* @return {string} The HTML to be displayed.
*/
QuickNavigateDialog.prototype._resultsFormatterCallback = function (item) {
var query = this.$searchField.val();
var formatter;
if (currentPlugin) {
// Plugins use their own formatter or the default formatter
formatter = currentPlugin.resultsFormatter || defaultResultsFormatter;
} else {
// No plugin: default file search mode uses a special formatter
formatter = _filenameResultsFormatter;
}
return formatter(item, query);
};
/**
* Sets the value in the search field, updating the current mode and label based on the
* given prefix.
* @param {string} prefix The prefix that determines which mode we're in: must be empty (for file search),
* "@" for go to definition, or ":" for go to line.
* @param {string} initialString The query string to search for (without the prefix).
*/
QuickNavigateDialog.prototype.setSearchFieldValue = function (prefix, initialString) {
prefix = prefix || "";
initialString = initialString || "";
initialString = prefix + initialString;
var $field = this.$searchField;
$field.val(initialString);
$field.get(0).setSelectionRange(prefix.length, initialString.length);
// Kick smart-autocomplete to update (it only listens for keyboard events)
// (due to #1855, this will only pop up results list; it won't auto-"focus" the first result)
$field.trigger("keyIn", [initialString]);
};
/**
* Sets the dialog label based on the current plugin (if any) and the current query.
* @param {Object} plugin The current Quick Open plugin, or none if there is none.
* @param {string} query The user's current query.
*/
QuickNavigateDialog.prototype._updateDialogLabel = function (plugin, query) {
var dialogLabel = "";
if (plugin && plugin.label) {
dialogLabel = plugin.label;
} else {
var prefix = (query.length > 0 ? query.charAt(0) : "");
// Update the dialog label based on the current prefix.
switch (prefix) {
case ":":
dialogLabel = Strings.CMD_GOTO_LINE + "\u2026";
break;
case "@":
dialogLabel = Strings.CMD_GOTO_DEFINITION + "\u2026";
break;
default:
dialogLabel = "";
break;
}
}
$(".find-dialog-label", this.dialog).text(dialogLabel);
};
/**
* Close the dialog when the user clicks outside of it. Smart-autocomplete listens for this and automatically closes its popup,
* but we want to close the whole search "dialog." (And we can't just piggyback on the popup closing event, since there are cases
* where the popup closes that we want the dialog to remain open (e.g. deleting search term via backspace).
* @param {!Event} e
*/
QuickNavigateDialog.prototype._handleDocumentMouseDown = function (e) {
if (this.modalBar.getRoot().find(e.target).length === 0 && $(".smart_autocomplete_container").find(e.target).length === 0) {
// User clicked in page, so ignore original scroll pos/selection to use new scroll pos/selection.
this.close(null, null, true);
} else {
// Allow clicks in the search field to propagate. Clicks in the menu should be
// blocked to prevent focus from leaving the search field.
if ($("input#quickOpenSearch").get(0) !== e.target) {
e.preventDefault();
e.stopPropagation();
}
}
};
/**
* Close the dialog when it loses focus.
*/
QuickNavigateDialog.prototype._handleBlur = function (e) {
var origScrollPos, origSelections,
curDoc = DocumentManager.getCurrentDocument();
// If doc hasn't changed, restore scroll pos/selections
if (curDoc && this._origDocPath === curDoc.file.fullPath) {
origScrollPos = this._origScrollPos;
origSelections = this._origSelections;
}
this.close(origScrollPos, origSelections, true);
};
/**
* Shows the search dialog and initializes the auto suggestion list with filenames from the current project
*/
QuickNavigateDialog.prototype.showDialog = function (prefix, initialString) {
if (this.isOpen) {
return;
}
this.isOpen = true;
// Global listener to hide search bar & popup
$(window.document).on("mousedown", this._handleDocumentMouseDown);
// Record current document & cursor pos so we can restore it if search is canceled
// We record scroll pos *before* modal bar is opened since we're going to restore it *after* it's closed
var curDoc = DocumentManager.getCurrentDocument();
this._origDocPath = curDoc ? curDoc.file.fullPath : null;
if (curDoc) {
this._origSelections = EditorManager.getCurrentFullEditor().getSelections();
this._origScrollPos = EditorManager.getCurrentFullEditor().getScrollPos();
} else {
this._origSelections = null;
this._origScrollPos = null;
}
// Show the search bar ("dialog")
var dialogHTML = "<div align='right'><input type='text' autocomplete='off' id='quickOpenSearch' placeholder='" + Strings.CMD_QUICK_OPEN + "\u2026' style='width: 30em'><span class='find-dialog-label'></span></div>";
this.modalBar = new ModalBar(dialogHTML, false);
this.$searchField = $("input#quickOpenSearch");
// The various listeners registered below fire in this order:
// keydown, (async gap), keyup, (async gap), filter, resultsReady, showResults/noResults
// The later events *always* come after the keydown & keyup (they're triggered on a timeout from keyup). But
// because of the async gaps, a keydown for the *next* key typed might come *before* they run:
// keydown, (async gap), keyup, (async gap), keydown #2, (async gap), filter, resultsReady, showResults/noResults
// The staleness check in _filterCallback() and the forced async wait in _handleKeyUp() are due to this.
this.$searchField.bind({
resultsReady: this._handleResultsReady,
showResults: this._handleShowResults,
itemSelect: this._handleItemSelect,
itemFocus: this._handleItemFocus,
keyup: this._handleKeyUp, // it's important we register this BEFORE calling smartAutoComplete(); see handler for details
blur: this._handleBlur // can't use lostFocus since smart autocomplete fires it immediately in response to the shortcut's keyup
});
this.$searchField.smartAutoComplete({
source: [],
maxResults: 20,
minCharLimit: 0,
autocompleteFocused: true,
forceSelect: false,
typeAhead: false, // won't work right now because smart auto complete
// using internal raw results instead of filtered results for matching
filter: this._filterCallback,
resultFormatter: this._resultsFormatterCallback
});
this.setSearchFieldValue(prefix, initialString);
// Return files that are non-binary, or binary files that have a custom viewer
function _filter(file) {
return !LanguageManager.getLanguageForPath(file.fullPath).isBinary() ||
MainViewFactory.findSuitableFactoryForPath(file.fullPath);
}
// Start fetching the file list, which will be needed the first time the user enters
// an un-prefixed query. If file index caches are out of date, this list might take
// some time to asynchronously build. See searchFileList() for how this is handled.
fileListPromise = ProjectManager.getAllFiles(_filter, true)
.done(function (files) {
fileList = files;
fileListPromise = null;
this._filenameMatcher.reset();
}.bind(this));
};
function getCurrentEditorSelectedText() {
var currentEditor = EditorManager.getActiveEditor();
return (currentEditor && currentEditor.getSelectedText()) || "";
}
/**
* Opens the Quick Open bar prepopulated with the given prefix (to select a mode) and optionally
* with the given query text too. Updates text field contents if Quick Open already open.
* @param {?string} prefix
* @param {?string} initialString
*/
function beginSearch(prefix, initialString) {
function createDialog() {
_curDialog = new QuickNavigateDialog();
_curDialog.showDialog(prefix, initialString);
}
if (_curDialog) {
if (_curDialog.isOpen) {
// Just start a search using the existing dialog.
_curDialog.setSearchFieldValue(prefix, initialString);
} else {
// The dialog is already closing. Wait till it's done closing,
// then open a new dialog. (Calling close() again returns the
// promise for the deferred that was already kicked off when it
// started closing.)
_curDialog.close().done(createDialog);
}
} else {
createDialog();
}
}
function doFileSearch() {
beginSearch("", getCurrentEditorSelectedText());
}
function doGotoLine() {
// TODO: Brackets doesn't support disabled menu items right now, when it does goto line and
// goto definition should be disabled when there is not a current document
if (DocumentManager.getCurrentDocument()) {
beginSearch(":", "");
}
}
// TODO: should provide a way for QuickOpenJSSymbol to create this function as a plug-in
function doDefinitionSearch() {
if (DocumentManager.getCurrentDocument()) {
beginSearch("@", getCurrentEditorSelectedText());
}
}
// Listen for a change of project to invalidate our file list
$(ProjectManager).on("projectOpen", function () {
fileList = null;
});
// TODO: allow QuickOpenJS to register it's own commands and key bindings
CommandManager.register(Strings.CMD_QUICK_OPEN, Commands.NAVIGATE_QUICK_OPEN, doFileSearch);
CommandManager.register(Strings.CMD_GOTO_DEFINITION, Commands.NAVIGATE_GOTO_DEFINITION, doDefinitionSearch);
CommandManager.register(Strings.CMD_GOTO_LINE, Commands.NAVIGATE_GOTO_LINE, doGotoLine);
exports.beginSearch = beginSearch;
exports.addQuickOpenPlugin = addQuickOpenPlugin;
exports.highlightMatch = highlightMatch;
// accessing these from this module will ultimately be deprecated
exports.stringMatch = StringMatch.stringMatch;
exports.SearchResult = StringMatch.SearchResult;
exports.basicMatchSort = StringMatch.basicMatchSort;
exports.multiFieldSort = StringMatch.multiFieldSort;
});