diff --git a/src/vs/base/common/filters.ts b/src/vs/base/common/filters.ts index d5bec85ebafeece68f090d59c783086c8319eb10..1f4df3e671cafd242f5b33af4ccafc130273431d 100644 --- a/src/vs/base/common/filters.ts +++ b/src/vs/base/common/filters.ts @@ -7,6 +7,7 @@ import strings = require('vs/base/common/strings'); import { LRUCache } from 'vs/base/common/map'; import { CharCode } from 'vs/base/common/charCode'; +import { ltrim } from 'vs/base/common/strings'; export interface IFilter { // Returns null if word doesn't match. @@ -341,6 +342,108 @@ export function matchesFuzzy(word: string, wordToMatchAgainst: string, enableSep return enableSeparateSubstringMatching ? fuzzySeparateFilter(word, wordToMatchAgainst) : fuzzyContiguousFilter(word, wordToMatchAgainst); } +const octiconStartMarker = '$('; + +export function matchesFuzzyOcticonAware(word: string, wordToMatchAgainst: string, enableSeparateSubstringMatching = false): IMatch[] { + + // Return early if there are no octicon markers in the word to match against + const firstOcticonIndex = wordToMatchAgainst.indexOf(octiconStartMarker); + if (firstOcticonIndex === -1) { + return matchesFuzzy(word, wordToMatchAgainst, enableSeparateSubstringMatching); + } + + const octiconOffsets: number[] = []; + + let wordToMatchAgainstWithoutOcticons: string = ''; + + function appendChars(chars: string) { + if (chars) { + wordToMatchAgainstWithoutOcticons += chars; + + for (let i = 0; i < chars.length; i++) { + octiconOffsets.push(octiconsOffset); // make sure to fill in octicon offsets + } + } + } + + let currentOcticonStart = -1; + let currentOcticonValue: string = ''; + let octiconsOffset = 0; + + let char: string; + let nextChar: string; + + let offset = firstOcticonIndex; + const length = wordToMatchAgainst.length; + + // Append all characters until the first octicon + appendChars(wordToMatchAgainst.substr(0, firstOcticonIndex)); + + // example: $(file-symlink-file) my cool $(other-octicon) entry + while (offset < length) { + char = wordToMatchAgainst[offset]; + nextChar = wordToMatchAgainst[offset + 1]; + + // beginning of octicon: some value $( <-- + if (char === octiconStartMarker[0] && nextChar === octiconStartMarker[1]) { + currentOcticonStart = offset; + + // if we had a previous potential octicon value without + // the closing ')', it was actually not an octicon and + // so we have to add it to the actual value + appendChars(currentOcticonValue); + + currentOcticonValue = octiconStartMarker; + + offset++; // jump over '(' + } + + // end of octicon: some value $(some-octicon) <-- + else if (char === ')' && currentOcticonStart !== -1) { + const currentOcticonLength = offset - currentOcticonStart + 1; // +1 to include the closing ')' + octiconsOffset += currentOcticonLength; + currentOcticonStart = -1; + currentOcticonValue = ''; + } + + // within octicon + else if (currentOcticonStart !== -1) { + currentOcticonValue += char; + } + + // any value outside of octicons + else { + appendChars(char); + } + + offset++; + } + + // if we had a previous potential octicon value without + // the closing ')', it was actually not an octicon and + // so we have to add it to the actual value + appendChars(currentOcticonValue); + + // Trim the word to match against because it could have leading + // whitespace now if the word started with an octicon + const wordToMatchAgainstWithoutOcticonsTrimmed = ltrim(wordToMatchAgainstWithoutOcticons, ' '); + const leadingWhitespaceOffset = wordToMatchAgainstWithoutOcticons.length - wordToMatchAgainstWithoutOcticonsTrimmed.length; + + // match on value without octicons + const matches = matchesFuzzy(word, wordToMatchAgainstWithoutOcticonsTrimmed, enableSeparateSubstringMatching); + + // Map matches back to offsets with octicons and trimming + if (matches) { + for (let i = 0; i < matches.length; i++) { + const octiconOffset = octiconOffsets[matches[i].start] /* octicon offsets at index */ + leadingWhitespaceOffset /* overall leading whitespace offset */; + matches[i].start += octiconOffset; + matches[i].end += octiconOffset; + } + } + + return matches; +} + export function skipScore(pattern: string, word: string, patternMaxWhitespaceIgnore?: number): [number, number[]] { pattern = pattern.toLowerCase(); word = word.toLowerCase(); diff --git a/src/vs/base/test/common/filters.test.ts b/src/vs/base/test/common/filters.test.ts index bc330457fa8d1bb021218037c9d75981e2ba8b39..d8a52cbb2ef5eedff5d4b63512ab1be4975f2b64 100644 --- a/src/vs/base/test/common/filters.test.ts +++ b/src/vs/base/test/common/filters.test.ts @@ -5,7 +5,7 @@ 'use strict'; import * as assert from 'assert'; -import { IFilter, or, matchesPrefix, matchesStrictPrefix, matchesCamelCase, matchesSubString, matchesContiguousSubString, matchesWords, fuzzyScore, IMatch, fuzzyScoreGraceful, fuzzyScoreGracefulAggressive } from 'vs/base/common/filters'; +import { IFilter, or, matchesPrefix, matchesStrictPrefix, matchesCamelCase, matchesSubString, matchesContiguousSubString, matchesWords, fuzzyScore, IMatch, fuzzyScoreGraceful, fuzzyScoreGracefulAggressive, matchesFuzzy, matchesFuzzyOcticonAware } from 'vs/base/common/filters'; function filterOk(filter: IFilter, word: string, wordToMatchAgainst: string, highlights?: { start: number; end: number; }[]) { let r = filter(word, wordToMatchAgainst); @@ -442,4 +442,53 @@ suite('Filters', () => { assertMatches('cno', 'co_new', '^c^o_^new', fuzzyScoreGraceful); assertMatches('cno', 'co_new', '^c^o_^new', fuzzyScoreGracefulAggressive); }); + + test('matchesFuzzzyOcticonAware', function () { + + // Camel Case + + filterOk(matchesFuzzy, 'ccr', 'CamelCaseRocks', [ + { start: 0, end: 1 }, + { start: 5, end: 6 }, + { start: 9, end: 10 } + ]); + + filterOk(matchesFuzzyOcticonAware, 'ccr', '$(octicon)CamelCaseRocks$(octicon)', [ + { start: 10, end: 11 }, + { start: 15, end: 16 }, + { start: 19, end: 20 } + ]); + + filterOk(matchesFuzzyOcticonAware, 'ccr', '$(octicon) CamelCaseRocks $(octicon)', [ + { start: 11, end: 12 }, + { start: 16, end: 17 }, + { start: 20, end: 21 } + ]); + + filterOk(matchesFuzzyOcticonAware, 'iut', '$(octicon) Indent $(octico) Using $(octic) Tpaces', [ + { start: 11, end: 12 }, + { start: 28, end: 29 }, + { start: 43, end: 44 }, + ]); + + // Prefix + + filterOk(matchesFuzzy, 'using', 'Indent Using Spaces', [ + { start: 7, end: 12 } + ]); + + filterOk(matchesFuzzyOcticonAware, 'using', '$(octicon) Indent Using Spaces', [ + { start: 18, end: 23 }, + ]); + + // Broken Octicon + + filterOk(matchesFuzzyOcticonAware, 'octicon', 'This $(octicon Indent Using Spaces', [ + { start: 7, end: 14 }, + ]); + + filterOk(matchesFuzzyOcticonAware, 'indent', 'This $octicon Indent Using Spaces', [ + { start: 14, end: 20 }, + ]); + }); }); diff --git a/src/vs/workbench/browser/parts/quickopen/quickOpenController.ts b/src/vs/workbench/browser/parts/quickopen/quickOpenController.ts index 917777faff1e04b606e5e83125accdda2d0c7441..e910e1ad7f5532f9c8e95354339e4395310cf61a 100644 --- a/src/vs/workbench/browser/parts/quickopen/quickOpenController.ts +++ b/src/vs/workbench/browser/parts/quickopen/quickOpenController.ts @@ -11,7 +11,6 @@ import nls = require('vs/nls'); import * as browser from 'vs/base/browser/browser'; import { Dimension, withElementById } from 'vs/base/browser/builder'; import strings = require('vs/base/common/strings'); -import filters = require('vs/base/common/filters'); import DOM = require('vs/base/browser/dom'); import URI from 'vs/base/common/uri'; import * as resources from 'vs/base/common/resources'; @@ -55,6 +54,7 @@ import { FileKind, IFileService } from 'vs/platform/files/common/files'; import { scoreItem, ScorerCache, compareItemsByScore, prepareQuery } from 'vs/base/parts/quickopen/common/quickOpenScorer'; import { getBaseLabel } from 'vs/base/common/labels'; import { WorkbenchTree } from 'vs/platform/list/browser/listService'; +import { matchesFuzzyOcticonAware } from 'vs/base/common/filters'; const HELP_PREFIX = '?'; @@ -431,12 +431,12 @@ export class QuickOpenController extends Component implements IQuickOpenService }); } - // Filter by value + // Filter by value (since we support octicons, use octicon aware fuzzy matching) else { entries.forEach(entry => { - const labelHighlights = filters.matchesFuzzy(value, entry.getLabel()); - const descriptionHighlights = options.matchOnDescription && filters.matchesFuzzy(value, entry.getDescription()); - const detailHighlights = options.matchOnDetail && entry.getDetail() && filters.matchesFuzzy(value, entry.getDetail()); + const labelHighlights = matchesFuzzyOcticonAware(value, entry.getLabel()); + const descriptionHighlights = options.matchOnDescription && matchesFuzzyOcticonAware(value, entry.getDescription()); + const detailHighlights = options.matchOnDetail && entry.getDetail() && matchesFuzzyOcticonAware(value, entry.getDetail()); if (entry.shouldAlwaysShow() || labelHighlights || descriptionHighlights || detailHighlights) { entry.setHighlights(labelHighlights, descriptionHighlights, detailHighlights);