From 06bf62f7d25fd6f4d510c38bf3554672e81c8172 Mon Sep 17 00:00:00 2001 From: Kevin Dangoor Date: Sat, 25 Aug 2012 22:22:35 -0400 Subject: [PATCH 01/10] Improves quick open by search across the whole file path and sorting the results giving preference to the kinds of things the user will likely have in mind as they search (beginnings of path segments, sequences of characters, parts of the filename). --- src/search/QuickOpen.js | 108 +++++++++++++++++++++++++++++++--------- 1 file changed, 84 insertions(+), 24 deletions(-) diff --git a/src/search/QuickOpen.js b/src/search/QuickOpen.js index fbb76dee4e1..0f7fc9b6a2c 100644 --- a/src/search/QuickOpen.js +++ b/src/search/QuickOpen.js @@ -360,40 +360,100 @@ define(function (require, exports, module) { $(window.document).off("mousedown", this.handleDocumentClick); }; + var FILENAME_BOOST = 3; + var MATCH_BOOST = 5; + var PATH_SEGMENT_START_MATCH_BOOST = 5; + + /** + * Returns a file list that contains only the files that contain + * the characters in query. The search is case insensitive. + * + * The result is sorted to steer the user toward the likeliest files. + * Specifically, preference is given to: + * * matches within the filename (these are most specific) + * * matches at the beginning of a path segment (these likely come to mind) + * * sequential matches (people will likely enter chunks of the name) + */ function filterFileList(query) { + var queryChars = query.toLowerCase().split(""); var filteredList = $.map(fileList, function (fileInfo) { - // match query against filename only (not the full path) - var path = fileInfo.fullPath; - var filename = _filenameFromPath(path, true); - if (filename.toLowerCase().indexOf(query.toLowerCase()) !== -1) { - return path; - } else { + var path = fileInfo.fullPath.toLowerCase(); + + var score = 0; + var sequentialMatches = 0; + var segmentCounter = 0; + + // start at the end and work backward, because we give preference + // to matches in the filename segment + var pathCounter = path.length - 1; + var queryCounter = queryChars.length - 1; + while (pathCounter >= 0 && queryCounter >= 0) { + var curChar = path.charAt(pathCounter); + if (curChar === '/') { + // Beginning of path segment, apply boost for a matching + // string of characters, if there is one + if (sequentialMatches) { + score += sequentialMatches * sequentialMatches * PATH_SEGMENT_START_MATCH_BOOST; + } + + // if this is the filename segment, add the boost + if (segmentCounter === 0) { + score = score * FILENAME_BOOST; + } + segmentCounter++; + } + + if (queryChars[queryCounter] === curChar) { + // matched character, chaulk up another match + // and move both counters back a character + sequentialMatches++; + queryCounter--; + pathCounter--; + } else { + // character didn't match, apply sequential matches + // to score and keep looking + pathCounter--; + score += sequentialMatches * sequentialMatches * MATCH_BOOST; + sequentialMatches = 0; + } + } + + // if there are still query characters left, we don't + // have a match + if (queryCounter >= 0) { return null; } + + // now, we need to apply any score we've accumulated + // before we ran out of query characters + score += sequentialMatches * sequentialMatches * MATCH_BOOST; + + if (sequentialMatches && pathCounter >= 0) { + if (path.charAt(pathCounter) === '/') { + score += sequentialMatches * PATH_SEGMENT_START_MATCH_BOOST; + } + } + if (segmentCounter === 0) { + score = score * FILENAME_BOOST; + } + + // return score, fullPath tuple for sorting + // the extra Array is there because jQuery.map flattens Arrays + return [[score, fileInfo.fullPath]]; }).sort(function (a, b) { - a = a.toLowerCase(); - b = b.toLowerCase(); - //first, sort by filename without extension - var filenameA = _filenameFromPath(a, false); - var filenameB = _filenameFromPath(b, false); - if (filenameA < filenameB) { + // sorting by score, which is the first element + if (a[0] > b[0]) { return -1; - } else if (filenameA > filenameB) { + } else if (a[0] < b[0]) { return 1; } else { - // filename is the same, compare including extension - filenameA = _filenameFromPath(a, true); - filenameB = _filenameFromPath(b, true); - if (filenameA < filenameB) { - return -1; - } else if (filenameA > filenameB) { - return 1; - } else { - return 0; - } + return 0; } + }).map(function (item) { + // we want to end up with just the fullPath, now that we have a good ordering + return item[1]; }); - + return filteredList; } From 781e7aa3dc0e5b6c66d9fc8e5399bb3adeadb90d Mon Sep 17 00:00:00 2001 From: Kevin Dangoor Date: Fri, 28 Sep 2012 15:46:18 -0400 Subject: [PATCH 02/10] Improved QuickOpen matching. Matches query characters throughout the string with scoring the prefers the end of the string (filename). The matched characters are highlighted in the result. --- src/search/QuickOpen.js | 175 +++++++++++++++++++++++++++++++-------- src/styles/brackets.less | 5 ++ 2 files changed, 147 insertions(+), 33 deletions(-) diff --git a/src/search/QuickOpen.js b/src/search/QuickOpen.js index ea600f3009c..4dfbff2daf5 100644 --- a/src/search/QuickOpen.js +++ b/src/search/QuickOpen.js @@ -401,34 +401,138 @@ define(function (require, exports, module) { }; - /** - * Performs basic filtering of a string based on a filter query, and ranks how close the match - * is. Use basicMatchSort() to sort the filtered results taking this ranking into account. The - * label of the SearchResult is set to 'str'. - * @param {!string} str - * @param {!string} query - * @return {?SearchResult} - */ + var NAME_BOOST = 3; + var MATCH_BOOST = 5; + var PATH_SEGMENT_START_MATCH_BOOST = 5; + + /** + * Performs matching of a string based on a query, and scores + * the result based on specificity (assuming that the rightmost + * side of the input is the most specific) and how clustered the + * query characters are in the input string. The matching is + * case-insensitive + * + * If the query characters cannot be found in order (but not necessarily all together), + * undefined is returned. + * + * the returned SearchResult has a matchGoodness score that can be used + * for sorting. It also has a stringRanges array, each entry with + * "text" and "matched". If you string the "text" properties together, you will + * get the original str. Using the matched properties, you can highlight + * the string matches. + * + * Use basicMatchSort() to sort the filtered results taking this ranking + * label of the SearchResult is set to 'str'. + * @param {!string} str + * @param {!string} query + * @return {?SearchResult} + */ function stringMatch(str, query) { - // is it a match at all? - var matchIndex = str.toLowerCase().indexOf(query.toLowerCase()); - if (matchIndex !== -1) { - var searchResult = new SearchResult(str); + if (!query) { + return; + } + var lowerStr = str.toLowerCase(); + var queryChars = query.toLowerCase().split(""); + + var score = 0; + + // sequentialMatches is positive when we are stepping through matched + // characters and negative when stepping through unmatched characters + var sequentialMatches = 0; + var segmentCounter = 0; + var stringRanges = []; + + // start at the end and work backward, because we give preference + // to matches in the name (last) segment + var strCounter = lowerStr.length - 1; + var queryCounter = queryChars.length - 1; + while (strCounter >= 0 && queryCounter >= 0) { + var curChar = lowerStr.charAt(strCounter); + if (curChar === '/') { + // Beginning of segment, apply boost for a matching + // string of characters, if there is one + if (sequentialMatches > 0) { + score += sequentialMatches * sequentialMatches * PATH_SEGMENT_START_MATCH_BOOST; + } + + // if this is the name segment, add the boost + if (segmentCounter === 0) { + score = score * NAME_BOOST; + } + segmentCounter++; + } - // Rough heuristic to decide how good the match is: if query very closely matches str, - // rank it highly. Divides the search results into three broad buckets (0-2) - if (matchIndex === 0) { - if (str.length === query.length) { - searchResult.matchGoodness = 0; - } else { - searchResult.matchGoodness = 1; + if (queryChars[queryCounter] === curChar) { + // are we ending a string of unmatched characters? + if (sequentialMatches < 0) { + stringRanges.unshift({ + text: str.substr(strCounter + 1, -1 * sequentialMatches), + matched: false + }); + sequentialMatches = 0; } + + // matched character, chaulk up another match + // and move both counters back a character + sequentialMatches++; + queryCounter--; + strCounter--; } else { - searchResult.matchGoodness = 2; + // are we ending a string of matched characters? + if (sequentialMatches > 0) { + stringRanges.unshift({ + text: str.substr(strCounter + 1, sequentialMatches), + matched: true + }); + score += sequentialMatches * sequentialMatches * MATCH_BOOST; + sequentialMatches = 0; + } + // character didn't match, apply sequential matches + // to score and keep looking + strCounter--; + sequentialMatches--; } - - return searchResult; } + + // if there are still query characters left, we don't + // have a match + if (queryCounter >= 0) { + return undefined; + } + + if (sequentialMatches) { + stringRanges.unshift({ + text: str.substr(strCounter + 1, Math.abs(sequentialMatches)), + matched: sequentialMatches > 0 + }); + } + + if (strCounter > 0) { + stringRanges.unshift({ + text: str.substring(0, strCounter + 1), + matched: false + }); + } + + // now, we need to apply any score we've accumulated + // before we ran out of query characters + score += sequentialMatches * sequentialMatches * MATCH_BOOST; + + if (sequentialMatches && strCounter >= 0) { + if (lowerStr.charAt(strCounter) === '/') { + score += sequentialMatches * PATH_SEGMENT_START_MATCH_BOOST; + } + } + if (segmentCounter === 0) { + score = score * NAME_BOOST; + } + + // return score, fullPath tuple for sorting + // the extra Array is there because jQuery.map flattens Arrays + var result = new SearchResult(str); + result.matchGoodness = -1 * score; + result.stringRanges = stringRanges; + return result; } /** @@ -507,8 +611,9 @@ define(function (require, exports, module) { var filteredList = $.map(fileList, function (fileInfo) { // Is it a match at all? // match query against filename only (not the full path) - var searchResult = stringMatch(fileInfo.name, query); + var searchResult = stringMatch(ProjectManager.makeProjectRelativeIfPossible(fileInfo.fullPath), query); if (searchResult) { + searchResult.label = fileInfo.name; searchResult.fullPath = fileInfo.fullPath; searchResult.filenameWithoutExtension = _filenameFromPath(fileInfo.name, false); } @@ -569,16 +674,20 @@ define(function (require, exports, module) { // Use the filename formatter query = StringUtils.htmlEscape(query); var displayName = StringUtils.htmlEscape(item.label); - var displayPath = StringUtils.htmlEscape(ProjectManager.makeProjectRelativeIfPossible(item.fullPath)); - - if (query.length > 0) { - // make the user's query bold within the item's text - displayName = displayName.replace( - new RegExp(StringUtils.regexEscape(query), "gi"), - "$&" - ); - } - + + // put the path pieces together, highlighting the matched parts + // of the string + var displayPath = ""; + item.stringRanges.forEach(function (segment) { + if (segment.matched) { + displayPath += ''; + } + displayPath += StringUtils.htmlEscape(segment.text); + if (segment.matched) { + displayPath += ""; + } + }); + return "
  • " + displayName + "
    " + displayPath + "
  • "; } diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 66c05290ba1..5ec690a5cac 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -626,3 +626,8 @@ li.smart_autocomplete_highlight { background-color: #5fa3e0; } + +.quicksearch-match { + color: @bc-blue; + font-weight: bold; +} From 06a7999970826eacb974b3736f5e41e14c0c23d6 Mon Sep 17 00:00:00 2001 From: Kevin Dangoor Date: Sun, 14 Oct 2012 21:32:57 -0400 Subject: [PATCH 03/10] refactoring and improved comments based on feedback. Created addToStringRanges function in stringMatch to factor out the common behavior. Also fixed a bug where sequentialMatches was not squared in one instance. --- src/search/QuickOpen.js | 46 ++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/src/search/QuickOpen.js b/src/search/QuickOpen.js index 735ada0942f..c1fb0cb5b57 100644 --- a/src/search/QuickOpen.js +++ b/src/search/QuickOpen.js @@ -400,9 +400,14 @@ define(function (require, exports, module) { $(window.document).off("mousedown", this.handleDocumentMouseDown); }; - + // Multiplier used for matches within the most-specific part of the name (filename, for example) var NAME_BOOST = 3; + + // Multiplier for the number of sequential matched characters var MATCH_BOOST = 5; + + // Multiplier for sequential matched characters at the beginning + // of a delimited section (after a '/' in a path, for example) var PATH_SEGMENT_START_MATCH_BOOST = 5; /** @@ -415,7 +420,7 @@ define(function (require, exports, module) { * If the query characters cannot be found in order (but not necessarily all together), * undefined is returned. * - * the returned SearchResult has a matchGoodness score that can be used + * The returned SearchResult has a matchGoodness score that can be used * for sorting. It also has a stringRanges array, each entry with * "text" and "matched". If you string the "text" properties together, you will * get the original str. Using the matched properties, you can highlight @@ -440,14 +445,30 @@ define(function (require, exports, module) { // characters and negative when stepping through unmatched characters var sequentialMatches = 0; var segmentCounter = 0; - var stringRanges = []; // start at the end and work backward, because we give preference // to matches in the name (last) segment var strCounter = lowerStr.length - 1; var queryCounter = queryChars.length - 1; + + // stringRanges are used to keep track of which parts of + // the input str matched the query + var stringRanges = []; + + // addToStringRanges is used when we transition between matched and unmatched + // parts of the string. + function addToStringRanges(numberOfCharacters, matched) { + stringRanges.unshift({ + text: str.substr(strCounter + 1, numberOfCharacters), + matched: matched + }); + } + while (strCounter >= 0 && queryCounter >= 0) { var curChar = lowerStr.charAt(strCounter); + + // Ideally, this function will work with different delimiters used in + // different contexts. For now, this is used for paths delimited by '/'. if (curChar === '/') { // Beginning of segment, apply boost for a matching // string of characters, if there is one @@ -465,14 +486,11 @@ define(function (require, exports, module) { if (queryChars[queryCounter] === curChar) { // are we ending a string of unmatched characters? if (sequentialMatches < 0) { - stringRanges.unshift({ - text: str.substr(strCounter + 1, -1 * sequentialMatches), - matched: false - }); + addToStringRanges(-sequentialMatches, false); sequentialMatches = 0; } - // matched character, chaulk up another match + // matched character, chalk up another match // and move both counters back a character sequentialMatches++; queryCounter--; @@ -480,10 +498,7 @@ define(function (require, exports, module) { } else { // are we ending a string of matched characters? if (sequentialMatches > 0) { - stringRanges.unshift({ - text: str.substr(strCounter + 1, sequentialMatches), - matched: true - }); + addToStringRanges(sequentialMatches, true); score += sequentialMatches * sequentialMatches * MATCH_BOOST; sequentialMatches = 0; } @@ -501,10 +516,7 @@ define(function (require, exports, module) { } if (sequentialMatches) { - stringRanges.unshift({ - text: str.substr(strCounter + 1, Math.abs(sequentialMatches)), - matched: sequentialMatches > 0 - }); + addToStringRanges(Math.abs(sequentialMatches), sequentialMatches > 0); } if (strCounter > 0) { @@ -520,7 +532,7 @@ define(function (require, exports, module) { if (sequentialMatches && strCounter >= 0) { if (lowerStr.charAt(strCounter) === '/') { - score += sequentialMatches * PATH_SEGMENT_START_MATCH_BOOST; + score += sequentialMatches * sequentialMatches * PATH_SEGMENT_START_MATCH_BOOST; } } if (segmentCounter === 0) { From 5a44ea15cc54d4e713416ecb0a1e22923099ff57 Mon Sep 17 00:00:00 2001 From: Kevin Dangoor Date: Tue, 16 Oct 2012 21:48:50 -0400 Subject: [PATCH 04/10] More fixes based on feedback. * fixed quicksearch match highlighting (calls breakableUrl at the right time) * refactored the scoring to avoid duplication (and potential errors in calculation) * quicksearch matches are a dark gray color to make them more visible than just bold --- src/search/QuickOpen.js | 63 +++++++++++++++++++++++----------------- src/styles/brackets.less | 2 +- 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/search/QuickOpen.js b/src/search/QuickOpen.js index c1fb0cb5b57..d109ca8a79f 100644 --- a/src/search/QuickOpen.js +++ b/src/search/QuickOpen.js @@ -400,15 +400,25 @@ define(function (require, exports, module) { $(window.document).off("mousedown", this.handleDocumentMouseDown); }; - // Multiplier used for matches within the most-specific part of the name (filename, for example) - var NAME_BOOST = 3; + function _adjustScoreForSegment(segmentCounter, score) { + if (segmentCounter === 0) { + // Multiplier used for matches within the most-specific part of the name (filename, for example) + return score * 3; + } else { + return score; + } + } - // Multiplier for the number of sequential matched characters - var MATCH_BOOST = 5; + function _boostForMatches(sequentialMatches) { + // Multiplier for the number of sequential matched characters + return sequentialMatches * sequentialMatches * 5; + } - // Multiplier for sequential matched characters at the beginning - // of a delimited section (after a '/' in a path, for example) - var PATH_SEGMENT_START_MATCH_BOOST = 5; + function _boostForPathSegmentStart(sequentialMatches) { + // Multiplier for sequential matched characters at the beginning + // of a delimited section (after a '/' in a path, for example) + return sequentialMatches * sequentialMatches * 5; + } /** * Performs matching of a string based on a query, and scores @@ -473,13 +483,10 @@ define(function (require, exports, module) { // Beginning of segment, apply boost for a matching // string of characters, if there is one if (sequentialMatches > 0) { - score += sequentialMatches * sequentialMatches * PATH_SEGMENT_START_MATCH_BOOST; + score += _boostForPathSegmentStart(sequentialMatches); } - // if this is the name segment, add the boost - if (segmentCounter === 0) { - score = score * NAME_BOOST; - } + score = _adjustScoreForSegment(segmentCounter, score); segmentCounter++; } @@ -499,7 +506,7 @@ define(function (require, exports, module) { // are we ending a string of matched characters? if (sequentialMatches > 0) { addToStringRanges(sequentialMatches, true); - score += sequentialMatches * sequentialMatches * MATCH_BOOST; + score += _boostForMatches(sequentialMatches); sequentialMatches = 0; } // character didn't match, apply sequential matches @@ -528,19 +535,18 @@ define(function (require, exports, module) { // now, we need to apply any score we've accumulated // before we ran out of query characters - score += sequentialMatches * sequentialMatches * MATCH_BOOST; + score += _boostForMatches(sequentialMatches); if (sequentialMatches && strCounter >= 0) { if (lowerStr.charAt(strCounter) === '/') { - score += sequentialMatches * sequentialMatches * PATH_SEGMENT_START_MATCH_BOOST; + score += _boostForPathSegmentStart(sequentialMatches); } } - if (segmentCounter === 0) { - score = score * NAME_BOOST; - } + score = _adjustScoreForSegment(segmentCounter, score); - // return score, fullPath tuple for sorting - // the extra Array is there because jQuery.map flattens Arrays + // Produce a SearchResult that is augmented with matchGoodness + // (used for sorting) and stringRanges (used for highlighting + // matched areas of the string) var result = new SearchResult(str); result.matchGoodness = -1 * score; result.stringRanges = stringRanges; @@ -622,7 +628,7 @@ define(function (require, exports, module) { // for sorting & display var filteredList = $.map(fileList, function (fileInfo) { // Is it a match at all? - // match query against filename only (not the full path) + // match query against the full path (with gaps between query characters allowed) var searchResult = stringMatch(ProjectManager.makeProjectRelativeIfPossible(fileInfo.fullPath), query); if (searchResult) { searchResult.label = fileInfo.name; @@ -690,16 +696,21 @@ define(function (require, exports, module) { // put the path pieces together, highlighting the matched parts // of the string var displayPath = ""; + if (displayName.indexOf("QuickOpen.js") > -1) { + console.log("qo", item.stringRanges); + } item.stringRanges.forEach(function (segment) { - if (segment.matched) { - displayPath += ''; + if (displayName.indexOf("QuickOpen.js") > -1) { + console.log("quickopen:", segment); } - displayPath += StringUtils.htmlEscape(segment.text); if (segment.matched) { - displayPath += ""; + displayPath += ''; + } else { + displayPath += ''; } + displayPath += StringUtils.breakableUrl(StringUtils.htmlEscape(segment.text)); + displayPath += ''; }); - displayPath = StringUtils.breakableUrl(displayPath); return "
  • " + displayName + "
    " + displayPath + "
  • "; } diff --git a/src/styles/brackets.less b/src/styles/brackets.less index a48dc9bbbf7..8e2d59e6bdd 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -786,6 +786,6 @@ li.smart_autocomplete_highlight { } .quicksearch-match { - color: @bc-blue; font-weight: bold; + color: #555; } From 51cc0d54a2391129dcca7f77fedf277fee6f69c6 Mon Sep 17 00:00:00 2001 From: Kevin Dangoor Date: Fri, 19 Oct 2012 23:20:46 -0400 Subject: [PATCH 05/10] QuickOpen returns everything for an empty query. Also includes better comments for the scoring functions. --- src/search/QuickOpen.js | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/search/QuickOpen.js b/src/search/QuickOpen.js index d109ca8a79f..7449097ccd8 100644 --- a/src/search/QuickOpen.js +++ b/src/search/QuickOpen.js @@ -400,6 +400,14 @@ define(function (require, exports, module) { $(window.document).off("mousedown", this.handleDocumentMouseDown); }; + /** + * Helper functions for stringMatch score calculation. + */ + + /** + * The current scoring gives a boost for matches in the "most specific" (generally farthest right) + * segment of the string being tested against the query. + */ function _adjustScoreForSegment(segmentCounter, score) { if (segmentCounter === 0) { // Multiplier used for matches within the most-specific part of the name (filename, for example) @@ -409,11 +417,19 @@ define(function (require, exports, module) { } } + /** + * Additional points are added when multiple characters in the string + * being tested match against query characters. + */ function _boostForMatches(sequentialMatches) { // Multiplier for the number of sequential matched characters return sequentialMatches * sequentialMatches * 5; } + /** + * The score is boosted for matches that occur at the beginning + * of a segment of string that is being tested against the query. + */ function _boostForPathSegmentStart(sequentialMatches) { // Multiplier for sequential matched characters at the beginning // of a delimited section (after a '/' in a path, for example) @@ -443,9 +459,17 @@ define(function (require, exports, module) { * @return {?SearchResult} */ function stringMatch(str, query) { + var result; + + // No query? Short circuit the normal work done and just + // return a simple match. if (!query) { - return; + result = new SearchResult(str); + result.matchGoodness = 0; + result.stringRanges = []; + return result; } + var lowerStr = str.toLowerCase(); var queryChars = query.toLowerCase().split(""); @@ -547,7 +571,7 @@ define(function (require, exports, module) { // Produce a SearchResult that is augmented with matchGoodness // (used for sorting) and stringRanges (used for highlighting // matched areas of the string) - var result = new SearchResult(str); + result = new SearchResult(str); result.matchGoodness = -1 * score; result.stringRanges = stringRanges; return result; From 79c72b8080a9c40f7344233b0509430b0cc5c147 Mon Sep 17 00:00:00 2001 From: Kevin Dangoor Date: Sat, 20 Oct 2012 21:56:28 -0400 Subject: [PATCH 06/10] highlights the matched characters in the filename. also handles the case of empty query string better. --- src/search/QuickOpen.js | 84 ++++++++++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/src/search/QuickOpen.js b/src/search/QuickOpen.js index 7449097ccd8..2c23595c3d3 100644 --- a/src/search/QuickOpen.js +++ b/src/search/QuickOpen.js @@ -461,43 +461,58 @@ define(function (require, exports, module) { function stringMatch(str, query) { var result; + // start at the end and work backward, because we give preference + // to matches in the name (last) segment + var strCounter = str.length - 1; + + // stringRanges are used to keep track of which parts of + // the input str matched the query + var stringRanges = []; + + // segmentCounter tracks which "segment" (delimited section) of the + // str we are in so that we can treat certain (generally most-specific) segments + // specially. + var segmentCounter = 0; + + // Keeps track of the most specific segment that the current stringRange + // is associated with. + var rangeSegment = 0; + + // addToStringRanges is used when we transition between matched and unmatched + // parts of the string. + function addToStringRanges(numberOfCharacters, matched) { + var segment = rangeSegment; + rangeSegment = segmentCounter; + stringRanges.unshift({ + text: str.substr(strCounter + 1, numberOfCharacters), + matched: matched, + segment: segment + }); + } + // No query? Short circuit the normal work done and just // return a simple match. if (!query) { result = new SearchResult(str); result.matchGoodness = 0; - result.stringRanges = []; + strCounter = -1; + addToStringRanges(str.length, false); + result.stringRanges = stringRanges; return result; } var lowerStr = str.toLowerCase(); var queryChars = query.toLowerCase().split(""); + + // start at the end of the query + var queryCounter = queryChars.length - 1; var score = 0; // sequentialMatches is positive when we are stepping through matched // characters and negative when stepping through unmatched characters var sequentialMatches = 0; - var segmentCounter = 0; - - // start at the end and work backward, because we give preference - // to matches in the name (last) segment - var strCounter = lowerStr.length - 1; - var queryCounter = queryChars.length - 1; - - // stringRanges are used to keep track of which parts of - // the input str matched the query - var stringRanges = []; - // addToStringRanges is used when we transition between matched and unmatched - // parts of the string. - function addToStringRanges(numberOfCharacters, matched) { - stringRanges.unshift({ - text: str.substr(strCounter + 1, numberOfCharacters), - matched: matched - }); - } - while (strCounter >= 0 && queryCounter >= 0) { var curChar = lowerStr.charAt(strCounter); @@ -715,25 +730,32 @@ define(function (require, exports, module) { function _filenameResultsFormatter(item, query) { // Use the filename formatter query = StringUtils.htmlEscape(query); - var displayName = StringUtils.htmlEscape(item.label); - + // put the path pieces together, highlighting the matched parts // of the string + var displayName = ""; var displayPath = ""; - if (displayName.indexOf("QuickOpen.js") > -1) { - console.log("qo", item.stringRanges); - } - item.stringRanges.forEach(function (segment) { - if (displayName.indexOf("QuickOpen.js") > -1) { - console.log("quickopen:", segment); - } - if (segment.matched) { + + item.stringRanges.forEach(function (range) { + if (range.matched) { displayPath += ''; + displayName += ''; } else { displayPath += ''; + displayName += ''; } - displayPath += StringUtils.breakableUrl(StringUtils.htmlEscape(segment.text)); + displayPath += StringUtils.breakableUrl(StringUtils.htmlEscape(range.text)); displayPath += ''; + + if (range.segment === 0) { + var rightmostSlash = range.text.lastIndexOf('/'); + if (rightmostSlash > -1) { + displayName += StringUtils.htmlEscape(range.text.substring(rightmostSlash + 1)); + } else { + displayName += StringUtils.htmlEscape(range.text); + } + } + displayName += ''; }); return "
  • " + displayName + "
    " + displayPath + "
  • "; From dad5383b1d51e4e3ead3eea61e9478925615b576 Mon Sep 17 00:00:00 2001 From: Kevin Dangoor Date: Sat, 20 Oct 2012 22:56:38 -0400 Subject: [PATCH 07/10] Provides a boost for upper case characters. This change makes MixedCase and camelCase matches get a boost. --- src/search/QuickOpen.js | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/search/QuickOpen.js b/src/search/QuickOpen.js index 2c23595c3d3..1d9dc40e1ff 100644 --- a/src/search/QuickOpen.js +++ b/src/search/QuickOpen.js @@ -435,22 +435,30 @@ define(function (require, exports, module) { // of a delimited section (after a '/' in a path, for example) return sequentialMatches * sequentialMatches * 5; } + + /** + * Upper case characters are boosted to help match MixedCase strings better. + */ + function _boostForUpperCase(c) { + return c.toUpperCase() === c ? 50 : 0; + } /** * Performs matching of a string based on a query, and scores * the result based on specificity (assuming that the rightmost * side of the input is the most specific) and how clustered the * query characters are in the input string. The matching is - * case-insensitive + * case-insensitive, but case is taken into account in the scoring. * * If the query characters cannot be found in order (but not necessarily all together), * undefined is returned. * * The returned SearchResult has a matchGoodness score that can be used * for sorting. It also has a stringRanges array, each entry with - * "text" and "matched". If you string the "text" properties together, you will - * get the original str. Using the matched properties, you can highlight - * the string matches. + * "text", "matched" and "segment". If you string the "text" properties together, you will + * get the original str. Using the matched property, you can highlight + * the string matches. The segment property tells you the most specific segment + * covered by the range, though there may be more than one segment convered. * * Use basicMatchSort() to sort the filtered results taking this ranking * label of the SearchResult is set to 'str'. @@ -491,7 +499,7 @@ define(function (require, exports, module) { } // No query? Short circuit the normal work done and just - // return a simple match. + // return a simple match with a range that covers the whole string. if (!query) { result = new SearchResult(str); result.matchGoodness = 0; @@ -501,7 +509,6 @@ define(function (require, exports, module) { return result; } - var lowerStr = str.toLowerCase(); var queryChars = query.toLowerCase().split(""); // start at the end of the query @@ -514,7 +521,7 @@ define(function (require, exports, module) { var sequentialMatches = 0; while (strCounter >= 0 && queryCounter >= 0) { - var curChar = lowerStr.charAt(strCounter); + var curChar = str.charAt(strCounter); // Ideally, this function will work with different delimiters used in // different contexts. For now, this is used for paths delimited by '/'. @@ -529,7 +536,10 @@ define(function (require, exports, module) { segmentCounter++; } - if (queryChars[queryCounter] === curChar) { + if (queryChars[queryCounter] === curChar.toLowerCase()) { + + score += _boostForUpperCase(curChar); + // are we ending a string of unmatched characters? if (sequentialMatches < 0) { addToStringRanges(-sequentialMatches, false); @@ -568,7 +578,8 @@ define(function (require, exports, module) { if (strCounter > 0) { stringRanges.unshift({ text: str.substring(0, strCounter + 1), - matched: false + matched: false, + segment: rangeSegment }); } @@ -577,7 +588,7 @@ define(function (require, exports, module) { score += _boostForMatches(sequentialMatches); if (sequentialMatches && strCounter >= 0) { - if (lowerStr.charAt(strCounter) === '/') { + if (str.charAt(strCounter) === '/') { score += _boostForPathSegmentStart(sequentialMatches); } } From 79c219fc7ed22b7c369ca80590975328f8b14f84 Mon Sep 17 00:00:00 2001 From: Kevin Dangoor Date: Thu, 1 Nov 2012 21:21:48 -0400 Subject: [PATCH 08/10] Improve color for displayName in quicksearch window. per review comment --- src/search/QuickOpen.js | 4 ++-- src/styles/brackets.less | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/search/QuickOpen.js b/src/search/QuickOpen.js index 11b3b9cd928..67cd4d94379 100644 --- a/src/search/QuickOpen.js +++ b/src/search/QuickOpen.js @@ -743,8 +743,8 @@ define(function (require, exports, module) { item.stringRanges.forEach(function (range) { if (range.matched) { - displayPath += ''; - displayName += ''; + displayPath += ''; + displayName += ''; } else { displayPath += ''; displayName += ''; diff --git a/src/styles/brackets.less b/src/styles/brackets.less index eaf132577d0..6a9d5858a07 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -796,7 +796,11 @@ li.smart_autocomplete_highlight { cursor: pointer; } -.quicksearch-match { +.quicksearch-pathmatch { font-weight: bold; color: #555; } + +.quicksearch-namematch { + font-weight: bold; +} From 1f266d4a36dffcd6e6f01e6b11938a3b5993b827 Mon Sep 17 00:00:00 2001 From: Kevin Dangoor Date: Thu, 1 Nov 2012 21:25:03 -0400 Subject: [PATCH 09/10] Cleanup multiline comment spacing. --- src/search/QuickOpen.js | 50 ++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/search/QuickOpen.js b/src/search/QuickOpen.js index 67cd4d94379..0c346bb8893 100644 --- a/src/search/QuickOpen.js +++ b/src/search/QuickOpen.js @@ -25,15 +25,15 @@ /*global define, $, window, setTimeout */ /* -* 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 -*/ + * 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) { @@ -146,9 +146,9 @@ define(function (require, exports, module) { } /** - * QuickNavigateDialog class - * @constructor - */ + * QuickNavigateDialog class + * @constructor + */ function QuickNavigateDialog() { this.$searchField = undefined; // defined when showDialog() is called } @@ -395,13 +395,13 @@ define(function (require, exports, module) { }; /** - * Helper functions for stringMatch score calculation. - */ + * Helper functions for stringMatch score calculation. + */ /** - * The current scoring gives a boost for matches in the "most specific" (generally farthest right) - * segment of the string being tested against the query. - */ + * The current scoring gives a boost for matches in the "most specific" (generally farthest right) + * segment of the string being tested against the query. + */ function _adjustScoreForSegment(segmentCounter, score) { if (segmentCounter === 0) { // Multiplier used for matches within the most-specific part of the name (filename, for example) @@ -412,18 +412,18 @@ define(function (require, exports, module) { } /** - * Additional points are added when multiple characters in the string - * being tested match against query characters. - */ + * Additional points are added when multiple characters in the string + * being tested match against query characters. + */ function _boostForMatches(sequentialMatches) { // Multiplier for the number of sequential matched characters return sequentialMatches * sequentialMatches * 5; } /** - * The score is boosted for matches that occur at the beginning - * of a segment of string that is being tested against the query. - */ + * The score is boosted for matches that occur at the beginning + * of a segment of string that is being tested against the query. + */ function _boostForPathSegmentStart(sequentialMatches) { // Multiplier for sequential matched characters at the beginning // of a delimited section (after a '/' in a path, for example) @@ -810,8 +810,8 @@ define(function (require, exports, module) { }; /** - * Shows the search dialog and initializes the auto suggestion list with filenames from the current project - */ + * Shows the search dialog and initializes the auto suggestion list with filenames from the current project + */ QuickNavigateDialog.prototype.showDialog = function (prefix, initialString) { var that = this; From 6dd2cf2144ce61b59d750e38d645f82258cc18d7 Mon Sep 17 00:00:00 2001 From: Kevin Dangoor Date: Thu, 1 Nov 2012 21:34:09 -0400 Subject: [PATCH 10/10] revert accidental addition to brackets.less. .clickable-link was excised a few revisions back and my merge brought it back. --- src/styles/brackets.less | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 6a9d5858a07..9ffe0f42f36 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -792,10 +792,6 @@ li.smart_autocomplete_highlight { background-color: #e0f0fa; } -.clickable-link { - cursor: pointer; -} - .quicksearch-pathmatch { font-weight: bold; color: #555;