The regular expression to check the cursor position
var CURSOR_POS_EXP = new RegExp(":([^,]+)?(,(.+)?)?");
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
function addQuickOpenPlugin(pluginDef) {
plugins.push(new QuickOpenPlugin(
pluginDef.name,
pluginDef.languageIds,
pluginDef.done,
pluginDef.search,
pluginDef.match,
pluginDef.itemFocus,
pluginDef.itemSelect,
pluginDef.resultsFormatter,
pluginDef.matcherOptions,
pluginDef.label
));
}
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.
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(":", "");
}
}
function doDefinitionSearch() {
if (DocumentManager.getCurrentDocument()) {
beginSearch("@", getCurrentEditorSelectedText());
}
}
// Listen for a change of project to invalidate our file list
ProjectManager.on("projectOpen", function () {
fileList = null;
});
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;
// Convenience exports for functions that most QuickOpen plugins would need.
exports.stringMatch = StringMatch.stringMatch;
exports.SearchResult = StringMatch.SearchResult;
exports.basicMatchSort = StringMatch.basicMatchSort;
exports.multiFieldSort = StringMatch.multiFieldSort;
});
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()
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[0] === ":",
line: regInfo[1] - 1 || 0,
ch: regInfo[3] - 1 || 0
};
}
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.
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 + ', upper: ' + sd.upper + '">(' + 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>";
}
QuickNavigateDialog class
function QuickNavigateDialog() {
this.$searchField = undefined; // defined when showDialog() is called
// ModalBar event handlers & callbacks
this._handleCloseBar = this._handleCloseBar.bind(this);
// QuickSearchField callbacks
this._handleItemSelect = this._handleItemSelect.bind(this);
this._handleItemHighlight = this._handleItemHighlight.bind(this);
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 = {};
}
QuickNavigateDialog.prototype._filenameMatcher = null;
QuickNavigateDialog.prototype._matchers = null;
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);
}
Handles changes to the current query in the search field.
QuickNavigateDialog.prototype._filterCallback = function (query) {
// Re-evaluate which plugin is active each time query string changes
currentPlugin = null;
// "Go to line" mode is special-cased
var cursorPos = extractCursorPos(query);
if (cursorPos && cursorPos.local) {
// Bare Go to Line (no filename search) - can validate & jump to it now, without waiting for Enter/commit
var editor = EditorManager.getCurrentFullEditor();
// Validate (could just use 0 and lineCount() here, but in future might want this to work for inline editors too)
if (cursorPos && editor && cursorPos.line >= editor.getFirstVisibleLine() && cursorPos.line <= editor.getLastVisibleLine()) {
var from = {line: cursorPos.line, ch: cursorPos.ch},
to = {line: cursorPos.line};
EditorManager.getCurrentFullEditor().setSelection(from, to, true);
return { error: null }; // no error even though no results listed
} else {
return []; // red error highlight: line number out of range, or no editor open
}
}
if (query === ":") { // treat blank ":" query as valid, but no-op
return { error: null };
}
// 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(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
return searchFileList(query, this._filenameMatcher);
};
Opens the file specified by selected item if there is no current plug-in, otherwise defers handling to the currentPlugin
QuickNavigateDialog.prototype._handleItemHighlight = function (selectedItem, query, explicit) {
if (currentPlugin && currentPlugin.itemFocus) {
currentPlugin.itemFocus(selectedItem, query, explicit);
}
};
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 the list, but may have information for carrying out an action (e.g. go to line).
QuickNavigateDialog.prototype._handleItemSelect = function (selectedItem, query) {
var doClose = true,
self = this;
// Delegate to current plugin
if (currentPlugin) {
currentPlugin.itemSelect(selectedItem, query);
} else {
// Extract line/col number, if any
var cursorPos = extractCursorPos(query);
// Navigate to file and line number
var fullPath = selectedItem && selectedItem.fullPath;
if (fullPath) {
// We need to fix up the current editor's scroll pos before switching to the next one. But if
// we run the full close() now, ModalBar's animate-out won't be smooth (gets starved of cycles
// during creation of the new editor). So we call prepareClose() to do *only* the scroll pos
// fixup, and let the full close() be triggered later when the new editor takes focus.
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 () {
// Ensure we finish closing even if file failed to open, or file was already current (in
// either case, we may not get a blur to trigger ModalBar to auto-close)
self.close();
});
} else if (cursorPos) {
EditorManager.getCurrentFullEditor().setCursorPos(cursorPos.line, cursorPos.ch, true);
}
}
if (doClose) {
this.close();
MainViewManager.focusActivePane();
}
};
Formats the entry for the given item to be displayed in the dropdown.
QuickNavigateDialog.prototype._resultsFormatterCallback = function (item, query) {
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 dialog label based on the current plugin (if any) and the 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);
};
Closes the search bar; if search bar is already closing, returns the Promise that is tracking the existing close activity.
QuickNavigateDialog.prototype.close = function () {
if (!this.isOpen) {
return this.closePromise;
}
this.modalBar.close(); // triggers _handleCloseBar(), setting closePromise
return this.closePromise;
};
QuickNavigateDialog.prototype._handleCloseBar = function (event, reason, modalBarClosePromise) {
console.assert(!this.closePromise);
this.closePromise = modalBarClosePromise;
this.isOpen = false;
var i;
for (i = 0; i < plugins.length; i++) {
var plugin = plugins[i];
if (plugin.done) {
plugin.done();
}
}
// Close popup & ensure we ignore any still-pending result promises
this.searchField.destroy();
// Restore original selection / scroll pos if closed via Escape
if (reason === ModalBar.CLOSE_ESCAPE) {
// We can reset the scroll position synchronously on ModalBar's "close" event (before close animation
// completes) since ModalBar has already resized the editor and done its own scroll adjustment before
// this event fired - so anything we set here will override the pos that was (re)set by ModalBar.
var editor = EditorManager.getCurrentFullEditor();
if (this._origSelections) {
editor.setSelections(this._origSelections);
}
if (this._origScrollPos) {
editor.setScrollPos(this._origScrollPos.x, this._origScrollPos.y);
}
}
};
function _doSearchFileList(query, matcher) {
// Strip off line/col number suffix so it doesn't interfere with filename search
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;
}
function searchFileList(query, matcher) {
// The file index may still be loading asynchronously - if so, can't return a result yet
if (!fileList) {
var asyncResult = new $.Deferred();
fileListPromise.done(function () {
// Synchronously re-run the search call and resolve with its results
asyncResult.resolve(_doSearchFileList(query, matcher));
});
return asyncResult.promise();
} else {
return _doSearchFileList(query, matcher);
}
}
Sets the value in the search field, updating the current mode and label based on the given prefix.
QuickNavigateDialog.prototype.setSearchFieldValue = function (prefix, initialString) {
prefix = prefix || "";
initialString = initialString || "";
initialString = prefix + initialString;
this.searchField.setText(initialString);
// Select just the text after the prefix
this.$searchField[0].setSelectionRange(prefix.length, initialString.length);
};
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;
// 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
var searchBarHTML = "<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(searchBarHTML, true);
this.modalBar.on("close", this._handleCloseBar);
this.$searchField = $("input#quickOpenSearch");
this.searchField = new QuickSearchField(this.$searchField, {
maxResults: 20,
firstHighlightIndex: 0,
verticalAdjust: this.modalBar.getRoot().outerHeight(),
resultProvider: this._filterCallback,
formatter: this._resultsFormatterCallback,
onCommit: this._handleItemSelect,
onHighlight: this._handleItemHighlight
});
// 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 prefetching 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, forcing searchFileList() to wait. In the
// meantime we show our old, stale fileList (unless the user has switched projects and we cleared it).
fileListPromise = ProjectManager.getAllFiles(_filter, true)
.done(function (files) {
fileList = files;
fileListPromise = null;
this._filenameMatcher.reset();
}.bind(this));
// Prepopulated query
this.$searchField.focus();
this.setSearchFieldValue(prefix, initialString);
};
function getCurrentEditorSelectedText() {
var currentEditor = EditorManager.getActiveEditor();
return (currentEditor && currentEditor.getSelectedText()) || "";
}