├── Support ├── help.mdown ├── assets │ ├── progress_wheel.gif │ ├── _file.html.erb │ ├── gotofile.html.erb │ ├── gotofile.css │ └── gotofile.js └── lib │ ├── go_to_file.rb │ ├── insertPath.applescript │ ├── insertRelPath.applescript │ ├── file_finder.rb │ └── fuzzy_file_finder.rb ├── LICENSE ├── Commands ├── Go to File.tmCommand └── Help.tmCommand ├── info.plist └── README.markdown /Support/help.mdown: -------------------------------------------------------------------------------- 1 | ../README.markdown -------------------------------------------------------------------------------- /Support/assets/progress_wheel.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subtleGradient/gotofile.tmbundle/master/Support/assets/progress_wheel.gif -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This project and source codes referring to it are under the 2 | Creative Commons Attribution-NonCommercial-ShareAlike 1.0 3 | license. For more information, please visit 4 | http://creativecommons.org/licenses/by-nc-sa/1.0/ 5 | 6 | NOTE: Support/lib/fuzzy_file_finder.rb has its own licence, please see http://github.com/jamis/fuzzy_file_finder. 7 | -------------------------------------------------------------------------------- /Support/assets/_file.html.erb: -------------------------------------------------------------------------------- 1 |
GoToFile could not determine a base location to start searching. Please open a project or save this file.
' 19 | exit 20 | end 21 | 22 | search_string = ARGV[0].gsub(/(\\( ))|(([^\\]) )/, '\2') 23 | 24 | if search_string.nil? || search_string.empty? 25 | print 'Please enter a search
' 26 | exit 27 | end 28 | 29 | 30 | if search_string =~ /\\(?! )/ 31 | TM_FUZZYFINDER_REVERSEPATHMODE = true 32 | search_string = search_string.split(/\\(?! )/).reverse.join("/") 33 | else 34 | search_string = search_string.split(/\\(?! )/).reverse.join("/") if TM_FUZZYFINDER_REVERSEPATHMODE 35 | end 36 | 37 | asset_path = ENV['TM_BUNDLE_SUPPORT'] + '/assets' 38 | 39 | 40 | # counter for outputted files 41 | cnt = 0 42 | 43 | template_path = asset_path + '/_file.html.erb' 44 | template = ERB.new(File.read(template_path)) 45 | 46 | begin 47 | FuzzyFileFinder.new(project_path, TM_FUZZYFINDER_CEILING, TM_FUZZYFINDER_IGNORE, FuzzyFileFinder::HtmlCharacterRun).find(search_string).sort{|b,a| a[:score] <=> b[:score] }.each do |p| 48 | sc = (p[:score].to_f * 100).to_i 49 | sc = sc > 100 ? 100 : sc 50 | hpath = p[:highlighted_path] 51 | hpath = hpath.split(%r{/(?!span)}).reverse.join("<") if TM_FUZZYFINDER_REVERSEPATHMODE 52 | puts template.result(binding) 53 | 54 | cnt += 1 55 | if cnt > MAX_OUTPUT 56 | puts %(… more than #{MAX_OUTPUT} files found.
) 57 | break 58 | end 59 | end 60 | rescue FuzzyFileFinder::TooManyEntries 61 | puts %(The root directory ‘#{project_path.gsub(/^#{ENV['HOME']}/, '~')}’ contains more than #{TM_FUZZYFINDER_CEILING} files. To increase the number of files parsed set up a TextMate shell variable TM_FUZZYFINDER_CEILING accordingly.
#{$!}
) 64 | end 65 | 66 | puts %(nothing found
) if cnt == 0 and !search_string.nil? and !search_string.empty? 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /Support/assets/gotofile.css: -------------------------------------------------------------------------------- 1 | /* CSS Styles for GoToFile TextMate Bundle */ 2 | 3 | /* Window Styling */ 4 | html, body 5 | { 6 | margin: 0; 7 | padding: 0; 8 | font-family: "Lucida Grande", "Trebuchet MS", Verdana, sans-serif; 9 | } 10 | 11 | /* Header */ 12 | #head 13 | { 14 | background-color: #666; 15 | background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABQCAYAAADvCdDvAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAOhJREFUeNrs3UuKAkEURUELyi+KioXoWt27qYtwcMA40Bu4wUsoJz2NMV4rZZq/f2sztEDeZmiBDDMAERAgAgJEQIDIh6ELkQuRCwEiT5YLkQsBIk+WC5ELkQsBIk+WCxEQT5ZcCBABERAgAgJEvkNciIAAERABASIgQAQEiID8H4jfslyIgAARECACAkS+Q1yIgMiT5UIExJOlnzaNMZ5maIE8zNACuZuhBbKYoQVyM0ML5GqGFsjFDC2QsxlaICcztECOZmiBHMzQAtmboQWyM0MLZGuGFsjGDC0Q/5w4BjKbodNHgAEA3LJ02F62uMsAAAAASUVORK5CYII=); 16 | border-bottom: 1px solid #666; 17 | height: 40px; 18 | position: fixed; 19 | top: 0px; 20 | width: 100%; 21 | z-index: 5; 22 | } 23 | 24 | #head input#search 25 | { 26 | margin: 11px 5% 0 5%; 27 | width: 90%; 28 | -webkit-appearance: searchfield; 29 | } 30 | 31 | /* Search Results */ 32 | #result 33 | { 34 | margin-top: 41px; 35 | } 36 | 37 | .file 38 | { 39 | padding: 4px; 40 | } 41 | 42 | .file:nth-child(odd) 43 | { 44 | background: #ECF3FE; 45 | } 46 | 47 | .file:hover 48 | { 49 | background: #DAE8FE; 50 | } 51 | 52 | .file:nth-child(odd):hover 53 | { 54 | background: #C9DDFE; 55 | } 56 | 57 | .file .score_wrapper 58 | { 59 | background: #fff; 60 | border: 1px solid #999; 61 | float: left; 62 | height: 6px; 63 | margin-top: 2px; 64 | padding: 1px; 65 | width: 15%; 66 | } 67 | 68 | 69 | .file .score 70 | { 71 | background-color: #1553AA; 72 | height: 100%; 73 | width: 1%; 74 | } 75 | 76 | .mylink 77 | { 78 | color: #111; 79 | cursor: pointer; 80 | font-size: 8.5pt; 81 | overflow: hidden; 82 | padding-left: 2%; 83 | width: 75%; 84 | white-space: nowrap; 85 | display: block; 86 | } 87 | 88 | .fuzzyff_match 89 | { 90 | background-color: #ffa; 91 | } 92 | 93 | /* Current Selection */ 94 | .selected, .file.selected:nth-child(odd), .file.selected:hover 95 | { 96 | background-color: #1553AA; 97 | background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADVJREFUeNpi/P//fwQDjQELEP8btYQUS/4OG0tGg2vwWfJnNLhGg2s0uEYjfjS4hrMlAAEGAHZHG24TN7qTAAAAAElFTkSuQmCC); 98 | } 99 | 100 | .selected .mylink 101 | { 102 | color: #fff; 103 | } 104 | 105 | .selected .fuzzyff_match 106 | { 107 | background: transparent; 108 | text-decoration: underline; 109 | } 110 | 111 | /* Notices */ 112 | .notice 113 | { 114 | background-color: #fc0; 115 | background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADVJREFUeNpi/P//fwQDjQELEP8btYQUS/4OG0tGg2vwWfJnNLhGg2s0uEYjfjS4hrMlAAEGAHZHG24TN7qTAAAAAElFTkSuQmCC); 116 | border-bottom: 1px solid #fc0; 117 | font-size: 9pt; 118 | font-weight: bold; 119 | padding: 1% 2%; 120 | } 121 | 122 | /* Error Message */ 123 | .error 124 | { 125 | background-color: #fc0; 126 | background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADVJREFUeNpi/P//fwQDjQELEP8btYQUS/4OG0tGg2vwWfJnNLhGg2s0uEYjfjS4hrMlAAEGAHZHG24TN7qTAAAAAElFTkSuQmCC); 127 | border-bottom: 1px solid #fc0; 128 | font-size: 9pt; 129 | font-weight: bold; 130 | color: #ff0000; 131 | padding: 1% 2%; 132 | } 133 | 134 | /*Progress spinning wheel*/ 135 | #progress { 136 | position: absolute; 137 | top: 0; 138 | right: 0; 139 | bottom: 0; 140 | left: 0; 141 | width: 10%; 142 | height: 10%; 143 | margin: auto; 144 | z-index: 8; 145 | } 146 | .progress_image { 147 | width: 56px; 148 | } 149 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # Outline # 2 | 3 | “GoToFile” imitates TextMate’s “Go to File…” ⌘T functionality [see here](http://manual.macromates.com/en/working_with_multiple_files#moving_between_files_with_grace). In addition it is possible to narrow down the list of files by considering (parts of) the file path using '/' as a delimiter. 4 | 5 | Furthermore it is not only possible to open the selected file in TextMate but also to: 6 | 7 | * open the selected file in the default application (by using “open”) 8 | * activate QuickLook 9 | * insert the relative path from the current file (that file which had the focus while invoking “GoToFile”) to the selected one 10 | * insert the absolute path. 11 | 12 | The list of found files is sorted by the score which is calculated inside of Jamis Buck's “Fuzzy File Finder” routine. If the letters typed in match word prefixes (as _mf_ does in _my\_file.txt_), the match is usually prioritised. The maximum number of outputted files is set to 100 (can be changed in “FileFinder.rb” line 4). 13 | 14 | To ignore certain files add a TextMate shell variable called `TM_FUZZYFINDER_IGNORE`, in it put the file globs separated by commas. For example: '\*.pyc,\*.zip,\*.gz,\*.bz,\*.tar,\*.jpg,\*.png,\*.gif,\*.avi,\*.wmv,\*.ogg,\*.mp3,\*.mov'. 15 | 16 | As default there is a limit of 10.000 files in the tree structure. To increase/decrease that maximum number add the TextMate shell variable `TM_FUZZYFINDER_CEILING` and set it accordingly. 17 | 18 | This GUI makes usage of makes usage of Jamis Buck's [“Fuzzy File Finder”](http://github.com/jamis/fuzzy_file_finder) and was inspired by Amiel Martin's [“FuzzyFileFinder”](http://github.com/amiel/gotofile.tmbundle/tree/amiels_original) bundle which a few code fragments are taken from. 19 | 20 | # Installation # 21 | 22 | * by using “GetBundles” 23 | 24 | * by using “git”: 25 | 26 |cd ~/Library/Application\ Support/TextMate/Bundles/ 27 | git clone git://github.com/amiel/gotofile.tmbundle.git GoToFile.tmbundle 28 |29 | 30 | * by downloading this [zip archive](http://github.com/amiel/gotofile.tmbundle/zipball/master), decompressing it, renaming it to “GoToFile.tmbundle”, and finally double-clicking at it 31 | 32 | # Usage # 33 | 34 | invokes “GoToFile”. The root directory will be taken from `$TM_PROJECT_DIRECTORY` || `$TM_DIRECTORY` || current directory. “GoToFile” won't work on unsaved documents. There is a mouse-over event to display the entire file path. 35 | 36 | 37 | ## Input Field ## 38 | 39 | Type characters in order to narrow down the list of files. The dialogue will be updated while typing. To search only in certain folders type for instance: `s/rb` or `s/li/mm` etc. If the letters match word prefixes (as _mf_ does in _my\_file.txt_), the match is usually prioritized. 40 | 41 | If you would like to search for the file and then narrow down to subdirectory, you can type `rb\s` or `mm\li\s`. 42 | 43 | Normally spaces are ignored. If one wants to look for a space one has to escape the space: `\␣` 44 | 45 | ## Shortcuts ## 46 | 47 | * or opens the file in TextMate 48 | * or opens the file with the default application 49 | * or inserts the relative file path 50 | * or inserts the absolute file path 51 | * toggles the QuickLook mode (Leopard only) 52 | * adds an (escaped) space character in the search query 53 | * and resp. and navigates through the list of files 54 | * sets the focus to the input field 55 | * or closes the “GoToFile” window 56 | 57 | # Official Git Repos # 58 | 59 | Can be found here: http://github.com/amiel/gotofile.tmbundle 60 | 61 | # ToDo / wish list # 62 | 63 | * window should close when a file is opened (maybe configurable as it seems that some people like that it stays open) 64 | * take out duplicate files 65 | * clean up javascript (incorporate [sizzle](http://sizzlejs.com/) or jquery) 66 | 67 | ***also check out the todo list on the [github wiki](http://wiki.github.com/amiel/gotofile.tmbundle/todo)*** 68 | 69 | # Contributions # 70 | 71 | ***Date: Feb 26 2009*** 72 |
73 | - Jamis Buck — fuzzy_file_finder library - jamis@37signals.com 74 |75 |
76 | - Amiel Martin amiel.martin@gmail.com 77 | - Hans-Jörg Bibiko bibiko@eva.mpg.de 78 | - Eric Doughty-Papassideris github:ddlsmurf 79 | - Travis Jeffery t.jeffery@utoronto.ca 80 | - Eric O'Connell github:drd 81 | - Nathan Carnes github:nathancarnes 82 |83 | -------------------------------------------------------------------------------- /Support/assets/gotofile.js: -------------------------------------------------------------------------------- 1 | /* 2 | * the following will be assigned by the ruby file 3 | * * bundle_support 4 | * * path_to_ruby 5 | */ 6 | 7 | var myCommand = null; 8 | var actpath = ""; 9 | var oldterm; 10 | var outStr = ""; 11 | var startTimer; 12 | var progressTimer; 13 | var term; 14 | var ft; 15 | var current_file=null; 16 | var current_ql_command=null; 17 | var current_ql_command_id=0; 18 | 19 | function init() { 20 | document.getElementById("search").focus(); 21 | startSearch(""); 22 | } 23 | 24 | function startSearch(t) { 25 | term = t; 26 | window.clearTimeout(startTimer); 27 | if (myCommand) myCommand.cancel(); 28 | startTimer = window.setTimeout("startSearchTimed()", 0); 29 | } 30 | function startProgressWheel() { 31 | window.clearTimeout(progressTimer); 32 | progressTimer = window.setTimeout("showProgressWheel()", 500); 33 | } 34 | function showProgressWheel() { 35 | document.getElementById("progress").innerHTML = "
";
36 | }
37 | function stopProgressWheel() {
38 | window.clearTimeout(progressTimer);
39 | document.getElementById("progress").innerHTML = "";
40 | }
41 | function startSearchTimed() {
42 | TextMate.isBusy = false;
43 | outStr = "";
44 | startProgressWheel();
45 | setSelection(null);
46 | var cmd = "'" + path_to_ruby + "' '" + bundle_support + "/lib/file_finder.rb' '" + term + "'";
47 | myCommand = TextMate.system(cmd, function(task) {});
48 | myCommand.onreadoutput = output;
49 | }
50 | function output(str) {
51 | outStr += str;
52 | stopProgressWheel();
53 | document.getElementById("result").innerHTML = outStr;
54 | if (current_file == null)
55 | changeSelect(1);
56 | }
57 |
58 | function setFile(path) {
59 | actpath = path;
60 | }
61 |
62 | function gotofile() {
63 | if (actpath != "") {
64 | TextMate.system("file -b '" + actpath + "' | grep text && mate '" + actpath + "' &", null);
65 | }
66 | }
67 | function insertPath() {
68 | if (actpath != "") {
69 | cmd = "osascript '" + bundle_support + "/lib/insertPath.applescript' '" + actpath + "' &";
70 | TextMate.system(cmd, null);
71 | }
72 | }
73 | function insertRelPath() {
74 | if (actpath != "") {
75 | cmd = "osascript '" + bundle_support + "/lib/insertRelPath.applescript' '" + actpath + "' &";
76 | TextMate.system(cmd, null);
77 | }
78 | }
79 | function openFile() {
80 | if (actpath != "") {
81 | TextMate.system("open '" + actpath + "' &", null);
82 | }
83 | }
84 | function cancel_quicklook() {
85 | var closed_quicklook = current_ql_command != null;
86 | if (current_ql_command)
87 | current_ql_command.cancel();
88 | current_ql_command = null;
89 | return closed_quicklook;
90 | }
91 | function quicklook() {
92 | if (!current_file) return;
93 | var display_id = current_ql_command_id + 1;
94 | if (current_ql_command)
95 | cancel_quicklook();
96 | else {
97 | current_ql_command_id = display_id;
98 | current_ql_command = TextMate.system("qlmanage -p '" + actpath + "'", function(task) {
99 | if (display_id == current_ql_command_id)
100 | current_ql_command = null;
101 | });
102 | }
103 | }
104 |
105 | function myClick(path) {
106 | wkey = event.keyCode;
107 | actpath = path;
108 | if (event.shiftKey && event.altKey) insertPath();
109 | else if (event.shiftKey) insertRelPath();
110 | else if (event.altKey) openFile();
111 | else gotofile();
112 | }
113 |
114 | function getItemTopOffset(item) {
115 | var parent = item;
116 | var top = item.offsetTop;
117 | while(parent = parent.offsetParent)
118 | top += parent.offsetTop;
119 | return top;
120 | }
121 | function scrollToItem(item) {
122 | var itemPos = getItemTopOffset(item.parentNode);
123 | var headHeight = document.getElementById('head').offsetHeight;
124 | if (itemPos < document.body.scrollTop + headHeight) {
125 | document.body.scrollTop = itemPos - headHeight - 2;
126 | } else if ((itemPos + item.parentNode.offsetHeight >= document.body.clientHeight + document.body.scrollTop)) {
127 | document.body.scrollTop = itemPos;
128 | }
129 | }
130 |
131 | /* stolen from jquery */
132 | function grep( elems, callback, inv ) {
133 | var ret = [];
134 |
135 | // Go through the array, only saving the items
136 | // that pass the validator function
137 | for ( var i = 0, length = elems.length; i < length; i++ )
138 | if ( !inv != !callback( elems[ i ], i ) )
139 | ret.push( elems[ i ] );
140 |
141 | return ret;
142 | }
143 |
144 | function addClass(elem, className) {
145 | alert(elem.className);
146 | elem.className += (elem.className ? " " : "") + className;
147 | }
148 |
149 | function removeClass(elem, className) {
150 | elem.className = grep(elem.className.split(/\s+/), function(name){
151 | return name == className;
152 | }, true).join(" ");
153 | }
154 |
155 | function setSelection(item) {
156 | if (item == current_file) return;
157 | var bReopenQuickLook = cancel_quicklook();
158 | if (current_file) {
159 | removeClass(current_file.parentNode, "selected");
160 | }
161 | current_file = item;
162 | if (current_file) {
163 | addClass(current_file.parentNode, "selected");
164 | setFile(current_file.value);
165 | scrollToItem(current_file);
166 | if (bReopenQuickLook)
167 | quicklook();
168 | }
169 | }
170 |
171 | function changeSelect(y) {
172 | var oItems = document.getElementById('result').getElementsByTagName("input");
173 | var iCurIndex = -1;
174 | if (current_file)
175 | for (var i=0; i < oItems.length; i++) {
176 | if (oItems[i] == current_file) {
177 | iCurIndex = i;
178 | break;
179 | }
180 | };
181 | iCurIndex += y;
182 | if (iCurIndex >= oItems.length) iCurIndex = 0;
183 | if (iCurIndex < 0) iCurIndex = oItems.length - 1;
184 | if (iCurIndex >= 0 && iCurIndex < oItems.length) {
185 | setSelection(oItems[iCurIndex]);
186 | }
187 | else {
188 | setSelection(null);
189 | };
190 | }
191 |
192 | function insertEscapedSpace() {
193 | var searchBox = document.getElementById('search');
194 | var query = searchBox.value;
195 | var selStart = searchBox.selectionStart;
196 | searchBox.value = query.substr(0, selStart) + "\\ " + query.substr(searchBox.selectionEnd);
197 | searchBox.selectionStart = selStart + 2;
198 | searchBox.selectionEnd = searchBox.selectionStart;
199 | startSearch(searchBox.value);
200 | }
201 | document.onkeydown = function keyPress(event) {
202 | if (typeof event == "undefined") event = window.event;
203 | wkey = event.keyCode;
204 | if (wkey == 32 || wkey == 27) {
205 | event.stopPropagation();
206 | event.preventDefault();
207 | } else if (wkey == 38 || wkey == 40 || wkey == 33 || wkey == 34 || wkey == 9) {
208 | event.stopPropagation();
209 | event.preventDefault();
210 | var iDiff = 1;
211 | if (wkey == 33)
212 | iDiff = -10;
213 | else if (wkey == 34)
214 | iDiff = 10;
215 | else if (wkey == 38 || (wkey == 9 && event.shiftKey))
216 | iDiff = -1;
217 |
218 | changeSelect(iDiff);
219 | } else if (wkey == 13) {
220 | event.stopPropagation();
221 | event.preventDefault();
222 | }
223 | };
224 | document.onkeyup = function keyPress(event) {
225 | if (typeof event == "undefined") event = window.event;
226 | wkey = event.keyCode;
227 | if (wkey == 38 || wkey == 40 || wkey == 33 || wkey == 34 || wkey == 9) {
228 | event.stopPropagation();
229 | event.preventDefault();
230 | }
231 | if (wkey == 27) {
232 | if (myCommand) myCommand.cancel();
233 | window.clearTimeout(progressTimer);
234 | window.clearTimeout(startTimer);
235 | stopProgressWheel();
236 | if (document.getElementById('search').value == "")
237 | window.close();
238 | else
239 | document.getElementById('search').value = "";
240 | event.stopPropagation();
241 | event.preventDefault();
242 | }
243 | if (wkey == 32) {
244 | if (event.altKey)
245 | insertEscapedSpace();
246 | else
247 | quicklook();
248 | event.stopPropagation();
249 | event.preventDefault();
250 | }
251 | if (wkey == 13) {
252 | if (event.shiftKey && event.altKey) insertPath();
253 | else if (event.shiftKey) insertRelPath();
254 | else if (event.altKey) openFile();
255 | else gotofile();
256 | event.stopPropagation();
257 | event.preventDefault();
258 | }
259 | };
--------------------------------------------------------------------------------
/Support/lib/fuzzy_file_finder.rb:
--------------------------------------------------------------------------------
1 | #--
2 | # ==================================================================
3 | # Author: Jamis Buck (jamis@jamisbuck.org)
4 | # Date: 2008-10-09
5 | #
6 | # This file is in the public domain. Usage, modification, and
7 | # redistribution of this file are unrestricted.
8 | # ==================================================================
9 | #++
10 |
11 | # The "fuzzy" file finder provides a way for searching a directory
12 | # tree with only a partial name. This is similar to the "cmd-T"
13 | # feature in TextMate (http://macromates.com).
14 | #
15 | # Usage:
16 | #
17 | # finder = FuzzyFileFinder.new
18 | # finder.search("app/blogcon") do |match|
19 | # puts match[:highlighted_path]
20 | # end
21 | #
22 | # In the above example, all files matching "app/blogcon" will be
23 | # yielded to the block. The given pattern is reduced to a regular
24 | # expression internally, so that any file that contains those
25 | # characters in that order (even if there are other characters
26 | # in between) will match.
27 | #
28 | # In other words, "app/blogcon" would match any of the following
29 | # (parenthesized strings indicate how the match was made):
30 | #
31 | # * (app)/controllers/(blog)_(con)troller.rb
32 | # * lib/c(ap)_(p)ool/(bl)ue_(o)r_(g)reen_(co)loratio(n)
33 | # * test/(app)/(blog)_(con)troller_test.rb
34 | #
35 | # And so forth.
36 |
37 | require 'CGI'
38 |
39 | class FuzzyFileFinder
40 | module Version
41 | MAJOR = 1
42 | MINOR = 0
43 | TINY = 4
44 | STRING = [MAJOR, MINOR, TINY].join(".")
45 | end
46 |
47 | # This is the exception that is raised if you try to scan a
48 | # directory tree with too many entries. By default, a ceiling of
49 | # 10,000 entries is enforced, but you can change that number via
50 | # the +ceiling+ parameter to FuzzyFileFinder.new.
51 | class TooManyEntries < RuntimeError; end
52 |
53 | # Used internally to represent a run of characters within a
54 | # match. This is used to build the highlighted version of
55 | # a file name.
56 | class CharacterRun < Struct.new(:string, :inside) #:nodoc:
57 | def to_s
58 | if inside
59 | "(#{string})"
60 | else
61 | string
62 | end
63 | end
64 | end
65 |
66 | # Just like CharacterRun except outputs HTML.
67 | class HtmlCharacterRun < Struct.new(:string, :inside) #:nodoc:
68 | def to_s
69 | if inside
70 | "#{CGI.escapeHTML(string)}"
71 | else
72 | CGI.escapeHTML(string)
73 | end
74 | end
75 | end
76 |
77 | # Used internally to represent a file within the directory tree.
78 | class FileSystemEntry #:nodoc:
79 | attr_reader :parent
80 | attr_reader :name
81 |
82 | def initialize(parent, name)
83 | @parent = parent
84 | @name = name
85 | end
86 |
87 | def path
88 | File.join(parent.name, name)
89 | end
90 | end
91 |
92 | # Used internally to represent a subdirectory within the directory
93 | # tree.
94 | class Directory #:nodoc:
95 | attr_reader :name
96 |
97 | def initialize(name, is_root=false)
98 | @name = name
99 | @is_root = is_root
100 | end
101 |
102 | def root?
103 | is_root
104 | end
105 | end
106 |
107 | # The roots directory trees to search.
108 | attr_reader :roots
109 |
110 | # The list of files beneath all +roots+
111 | attr_reader :files
112 |
113 | # The maximum number of files beneath all +roots+
114 | attr_reader :ceiling
115 |
116 | # The prefix shared by all +roots+.
117 | attr_reader :shared_prefix
118 |
119 | # The list of glob patterns to ignore.
120 | attr_reader :ignores
121 |
122 | # The class used to output the highlighted text in the desired format.
123 | attr_reader :highlighted_match_class
124 |
125 | # Initializes a new FuzzyFileFinder. This will scan the
126 | # given +directories+, using +ceiling+ as the maximum number
127 | # of entries to scan. If there are more than +ceiling+ entries
128 | # a TooManyEntries exception will be raised.
129 | def initialize(directories=['.'], ceiling=10_000, ignores=nil, highlighter=CharacterRun)
130 | directories = Array(directories)
131 | directories << "." if directories.empty?
132 |
133 | # expand any paths with ~
134 | root_dirnames = directories.map { |d| File.expand_path(d) }.select { |d| File.directory?(d) }.uniq
135 |
136 | @roots = root_dirnames.map { |d| Directory.new(d, true) }
137 | @shared_prefix = determine_shared_prefix
138 | @shared_prefix_re = Regexp.new("^#{Regexp.escape(shared_prefix)}" + (shared_prefix.empty? ? "" : "/"))
139 |
140 | @files = []
141 | @ceiling = ceiling
142 |
143 | @ignores = Array(ignores)
144 |
145 | @highlighted_match_class = highlighter
146 |
147 | rescan!
148 | end
149 |
150 | # Rescans the subtree. If the directory contents every change,
151 | # you'll need to call this to force the finder to be aware of
152 | # the changes.
153 | def rescan!
154 | @files.clear
155 | roots.each { |root| follow_tree(root) }
156 | end
157 |
158 | # Takes the given +pattern+ (which must be a string) and searches
159 | # all files beneath +root+, yielding each match.
160 | #
161 | # +pattern+ is interpreted thus:
162 | #
163 | # * "foo" : look for any file with the characters 'f', 'o', and 'o'
164 | # in its basename (discounting directory names). The characters
165 | # must be in that order.
166 | # * "foo/bar" : look for any file with the characters 'b', 'a',
167 | # and 'r' in its basename (discounting directory names). Also,
168 | # any successful match must also have at least one directory
169 | # element matching the characters 'f', 'o', and 'o' (in that
170 | # order.
171 | # * "foo/bar/baz" : same as "foo/bar", but matching two
172 | # directory elements in addition to a file name of "baz".
173 | #
174 | # Each yielded match will be a hash containing the following keys:
175 | #
176 | # * :path refers to the full path to the file
177 | # * :directory refers to the directory of the file
178 | # * :name refers to the name of the file (without directory)
179 | # * :highlighted_directory refers to the directory of the file with
180 | # matches highlighted in parentheses.
181 | # * :highlighted_name refers to the name of the file with matches
182 | # highlighted in parentheses
183 | # * :highlighted_path refers to the full path of the file with
184 | # matches highlighted in parentheses
185 | # * :abbr refers to an abbreviated form of :highlighted_path, where
186 | # path segments without matches are compressed to just their first
187 | # character.
188 | # * :score refers to a value between 0 and 1 indicating how closely
189 | # the file matches the given pattern. A score of 1 means the
190 | # pattern matches the file exactly.
191 | def search(pattern, &block)
192 | #pattern.gsub!(" ", "")
193 | path_parts = pattern.split("/")
194 | path_parts.push "" if pattern[-1,1] == "/"
195 |
196 | file_name_part = path_parts.pop || ""
197 |
198 | if path_parts.any?
199 | path_regex_raw = "^(.*?)" + path_parts.map { |part| make_pattern(part) }.join("(.*?/.*?)") + "(.*?)$"
200 | path_regex = Regexp.new(path_regex_raw, Regexp::IGNORECASE)
201 | end
202 |
203 | file_regex_raw = "^(.*?)" << make_pattern(file_name_part) << "(.*)$"
204 | file_regex = Regexp.new(file_regex_raw, Regexp::IGNORECASE)
205 |
206 | path_matches = {}
207 | files.each do |file|
208 | path_match = match_path(file.parent, path_matches, path_regex, path_parts.length)
209 | next if path_match[:missed]
210 |
211 | match_file(file, file_regex, path_match, &block)
212 | end
213 | end
214 |
215 | # Takes the given +pattern+ (which must be a string, formatted as
216 | # described in #search), and returns up to +max+ matches in an
217 | # Array. If +max+ is nil, all matches will be returned.
218 | def find(pattern, max=nil)
219 | results = []
220 | search(pattern) do |match|
221 | results << match
222 | break if max && results.length >= max
223 | end
224 | return results
225 | end
226 |
227 | # Displays the finder object in a sane, non-explosive manner.
228 | def inspect #:nodoc:
229 | "#<%s:0x%x roots=%s, files=%d>" % [self.class.name, object_id, roots.map { |r| r.name.inspect }.join(", "), files.length]
230 | end
231 |
232 | private
233 |
234 | # Recursively scans +directory+ and all files and subdirectories
235 | # beneath it, depth-first.
236 | def follow_tree(directory)
237 | Dir.entries(directory.name).each do |entry|
238 | next if entry[0,1] == "."
239 | raise TooManyEntries if files.length > ceiling
240 |
241 | full = File.join(directory.name, entry)
242 |
243 | if File.directory?(full)
244 | follow_tree(Directory.new(full))
245 | elsif !ignore?(full.sub(@shared_prefix_re, ""))
246 | files.push(FileSystemEntry.new(directory, entry))
247 | end
248 | end
249 | end
250 |
251 | # Returns +true+ if the given name matches any of the ignore
252 | # patterns.
253 | def ignore?(name)
254 | ignores.any? { |pattern| File.fnmatch(pattern, name) }
255 | end
256 |
257 | # Takes the given pattern string "foo" and converts it to a new
258 | # string "(f)([^/]*?)(o)([^/]*?)(o)" that can be used to create
259 | # a regular expression.
260 | def make_pattern(pattern)
261 | pattern = pattern.split(//)
262 | pattern << "" if pattern.empty?
263 |
264 | pattern.inject("") do |regex, character|
265 | regex << "([^/]*?)" if regex.length > 0
266 | regex << "(" << Regexp.escape(character) << ")"
267 | end
268 | end
269 |
270 | # Given a MatchData object +match+ and a number of "inside"
271 | # segments to support, compute both the match score and the
272 | # highlighted match string. The "inside segments" refers to how
273 | # many patterns were matched in this one match. For a file name,
274 | # this will always be one. For directories, it will be one for
275 | # each directory segment in the original pattern.
276 | def build_match_result(match, inside_segments)
277 | runs = []
278 | inside_chars = total_chars = 0
279 | is_word_prefixes = inside_segments == 1
280 | match.captures.each_with_index do |capture, index|
281 | if capture.length > 0
282 | # odd-numbered captures are matches inside the pattern.
283 | # even-numbered captures are matches between the pattern's elements.
284 | inside = index % 2 != 0
285 |
286 | total_chars += capture.gsub(%r(/), "").length # ignore '/' delimiters
287 | inside_chars += capture.length if inside
288 |
289 | if runs.last && runs.last.inside == inside
290 | runs.last.string << capture
291 | else
292 | runs << @highlighted_match_class.new(capture, inside)
293 | end
294 |
295 | if !inside && is_word_prefixes && index != match.captures.length - 1
296 | if capture.match(/[A-Za-z]$/i) #if this inbetween item finishes with a letter, the next is not an initial letter
297 | is_word_prefixes = false
298 | end
299 | end
300 | end
301 | end
302 |
303 | # Determine the score of this match.
304 | # 1. fewer "inside runs" (runs corresponding to the original pattern)
305 | # is better.
306 | # 2. better coverage of the actual path name is better
307 |
308 | inside_runs = runs.select { |r| r.inside }
309 | run_ratio = inside_runs.length.zero? ? 1 : inside_segments / inside_runs.length.to_f
310 |
311 | char_ratio = total_chars.zero? ? 1 : inside_chars.to_f / total_chars
312 |
313 | score = run_ratio * char_ratio
314 |
315 | return { :score => score, :result => runs.join, :is_word_start_match => is_word_prefixes }
316 | end
317 |
318 | # Match the given path against the regex, caching the result in +path_matches+.
319 | # If +path+ is already cached in the path_matches cache, just return the cached
320 | # value.
321 | def match_path(path, path_matches, path_regex, path_segments)
322 | return path_matches[path] if path_matches.key?(path)
323 |
324 | name_with_slash = path.name + "/" # add a trailing slash for matching the prefix
325 | matchable_name = name_with_slash.sub(@shared_prefix_re, "")
326 | matchable_name.chop! # kill the trailing slash
327 |
328 | if path_regex
329 | match = matchable_name.match(path_regex)
330 |
331 | path_matches[path] =
332 | match && build_match_result(match, path_segments) ||
333 | { :score => 1, :result => matchable_name, :missed => true }
334 | else
335 | path_matches[path] = { :score => 1, :result => matchable_name }
336 | end
337 | end
338 |
339 | # Match +file+ against +file_regex+. If it matches, yield the match
340 | # metadata to the block.
341 | def match_file(file, file_regex, path_match, &block)
342 | if file_match = file.name.match(file_regex)
343 | match_result = build_match_result(file_match, 1)
344 | full_match_result = path_match[:result].empty? ? match_result[:result] : File.join(path_match[:result], match_result[:result])
345 | shortened_path = path_match[:result].gsub(/[^\/]+/) { |m| m.index("(") ? m : m[0,1] }
346 | abbr = shortened_path.empty? ? match_result[:result] : File.join(shortened_path, match_result[:result])
347 | plain_score = (path_match[:score] * match_result[:score]) / 2.0
348 |
349 | result = { :path => file.path,
350 | :abbr => abbr,
351 | :directory => file.parent.name,
352 | :name => file.name,
353 | :highlighted_directory => path_match[:result],
354 | :highlighted_name => match_result[:result],
355 | :highlighted_path => full_match_result,
356 | :score => (match_result[:is_word_start_match] ? 0.5 : 0) + plain_score }
357 | yield result
358 | end
359 | end
360 |
361 | def determine_shared_prefix
362 | # the common case: if there is only a single root, then the entire
363 | # name of the root is the shared prefix.
364 | return roots.first.name if roots.length == 1
365 |
366 | split_roots = roots.map { |root| root.name.split(%r{/}) }
367 | segments = split_roots.map { |root| root.length }.max
368 | master = split_roots.pop
369 |
370 | segments.times do |segment|
371 | if !split_roots.all? { |root| root[segment] == master[segment] }
372 | return master[0,segment].join("/")
373 | end
374 | end
375 |
376 | # shouldn't ever get here, since we uniq the root list before
377 | # calling this method, but if we do, somehow...
378 | return roots.first.name
379 | end
380 | end
381 |
--------------------------------------------------------------------------------
";
36 | }
37 | function stopProgressWheel() {
38 | window.clearTimeout(progressTimer);
39 | document.getElementById("progress").innerHTML = "";
40 | }
41 | function startSearchTimed() {
42 | TextMate.isBusy = false;
43 | outStr = "";
44 | startProgressWheel();
45 | setSelection(null);
46 | var cmd = "'" + path_to_ruby + "' '" + bundle_support + "/lib/file_finder.rb' '" + term + "'";
47 | myCommand = TextMate.system(cmd, function(task) {});
48 | myCommand.onreadoutput = output;
49 | }
50 | function output(str) {
51 | outStr += str;
52 | stopProgressWheel();
53 | document.getElementById("result").innerHTML = outStr;
54 | if (current_file == null)
55 | changeSelect(1);
56 | }
57 |
58 | function setFile(path) {
59 | actpath = path;
60 | }
61 |
62 | function gotofile() {
63 | if (actpath != "") {
64 | TextMate.system("file -b '" + actpath + "' | grep text && mate '" + actpath + "' &", null);
65 | }
66 | }
67 | function insertPath() {
68 | if (actpath != "") {
69 | cmd = "osascript '" + bundle_support + "/lib/insertPath.applescript' '" + actpath + "' &";
70 | TextMate.system(cmd, null);
71 | }
72 | }
73 | function insertRelPath() {
74 | if (actpath != "") {
75 | cmd = "osascript '" + bundle_support + "/lib/insertRelPath.applescript' '" + actpath + "' &";
76 | TextMate.system(cmd, null);
77 | }
78 | }
79 | function openFile() {
80 | if (actpath != "") {
81 | TextMate.system("open '" + actpath + "' &", null);
82 | }
83 | }
84 | function cancel_quicklook() {
85 | var closed_quicklook = current_ql_command != null;
86 | if (current_ql_command)
87 | current_ql_command.cancel();
88 | current_ql_command = null;
89 | return closed_quicklook;
90 | }
91 | function quicklook() {
92 | if (!current_file) return;
93 | var display_id = current_ql_command_id + 1;
94 | if (current_ql_command)
95 | cancel_quicklook();
96 | else {
97 | current_ql_command_id = display_id;
98 | current_ql_command = TextMate.system("qlmanage -p '" + actpath + "'", function(task) {
99 | if (display_id == current_ql_command_id)
100 | current_ql_command = null;
101 | });
102 | }
103 | }
104 |
105 | function myClick(path) {
106 | wkey = event.keyCode;
107 | actpath = path;
108 | if (event.shiftKey && event.altKey) insertPath();
109 | else if (event.shiftKey) insertRelPath();
110 | else if (event.altKey) openFile();
111 | else gotofile();
112 | }
113 |
114 | function getItemTopOffset(item) {
115 | var parent = item;
116 | var top = item.offsetTop;
117 | while(parent = parent.offsetParent)
118 | top += parent.offsetTop;
119 | return top;
120 | }
121 | function scrollToItem(item) {
122 | var itemPos = getItemTopOffset(item.parentNode);
123 | var headHeight = document.getElementById('head').offsetHeight;
124 | if (itemPos < document.body.scrollTop + headHeight) {
125 | document.body.scrollTop = itemPos - headHeight - 2;
126 | } else if ((itemPos + item.parentNode.offsetHeight >= document.body.clientHeight + document.body.scrollTop)) {
127 | document.body.scrollTop = itemPos;
128 | }
129 | }
130 |
131 | /* stolen from jquery */
132 | function grep( elems, callback, inv ) {
133 | var ret = [];
134 |
135 | // Go through the array, only saving the items
136 | // that pass the validator function
137 | for ( var i = 0, length = elems.length; i < length; i++ )
138 | if ( !inv != !callback( elems[ i ], i ) )
139 | ret.push( elems[ i ] );
140 |
141 | return ret;
142 | }
143 |
144 | function addClass(elem, className) {
145 | alert(elem.className);
146 | elem.className += (elem.className ? " " : "") + className;
147 | }
148 |
149 | function removeClass(elem, className) {
150 | elem.className = grep(elem.className.split(/\s+/), function(name){
151 | return name == className;
152 | }, true).join(" ");
153 | }
154 |
155 | function setSelection(item) {
156 | if (item == current_file) return;
157 | var bReopenQuickLook = cancel_quicklook();
158 | if (current_file) {
159 | removeClass(current_file.parentNode, "selected");
160 | }
161 | current_file = item;
162 | if (current_file) {
163 | addClass(current_file.parentNode, "selected");
164 | setFile(current_file.value);
165 | scrollToItem(current_file);
166 | if (bReopenQuickLook)
167 | quicklook();
168 | }
169 | }
170 |
171 | function changeSelect(y) {
172 | var oItems = document.getElementById('result').getElementsByTagName("input");
173 | var iCurIndex = -1;
174 | if (current_file)
175 | for (var i=0; i < oItems.length; i++) {
176 | if (oItems[i] == current_file) {
177 | iCurIndex = i;
178 | break;
179 | }
180 | };
181 | iCurIndex += y;
182 | if (iCurIndex >= oItems.length) iCurIndex = 0;
183 | if (iCurIndex < 0) iCurIndex = oItems.length - 1;
184 | if (iCurIndex >= 0 && iCurIndex < oItems.length) {
185 | setSelection(oItems[iCurIndex]);
186 | }
187 | else {
188 | setSelection(null);
189 | };
190 | }
191 |
192 | function insertEscapedSpace() {
193 | var searchBox = document.getElementById('search');
194 | var query = searchBox.value;
195 | var selStart = searchBox.selectionStart;
196 | searchBox.value = query.substr(0, selStart) + "\\ " + query.substr(searchBox.selectionEnd);
197 | searchBox.selectionStart = selStart + 2;
198 | searchBox.selectionEnd = searchBox.selectionStart;
199 | startSearch(searchBox.value);
200 | }
201 | document.onkeydown = function keyPress(event) {
202 | if (typeof event == "undefined") event = window.event;
203 | wkey = event.keyCode;
204 | if (wkey == 32 || wkey == 27) {
205 | event.stopPropagation();
206 | event.preventDefault();
207 | } else if (wkey == 38 || wkey == 40 || wkey == 33 || wkey == 34 || wkey == 9) {
208 | event.stopPropagation();
209 | event.preventDefault();
210 | var iDiff = 1;
211 | if (wkey == 33)
212 | iDiff = -10;
213 | else if (wkey == 34)
214 | iDiff = 10;
215 | else if (wkey == 38 || (wkey == 9 && event.shiftKey))
216 | iDiff = -1;
217 |
218 | changeSelect(iDiff);
219 | } else if (wkey == 13) {
220 | event.stopPropagation();
221 | event.preventDefault();
222 | }
223 | };
224 | document.onkeyup = function keyPress(event) {
225 | if (typeof event == "undefined") event = window.event;
226 | wkey = event.keyCode;
227 | if (wkey == 38 || wkey == 40 || wkey == 33 || wkey == 34 || wkey == 9) {
228 | event.stopPropagation();
229 | event.preventDefault();
230 | }
231 | if (wkey == 27) {
232 | if (myCommand) myCommand.cancel();
233 | window.clearTimeout(progressTimer);
234 | window.clearTimeout(startTimer);
235 | stopProgressWheel();
236 | if (document.getElementById('search').value == "")
237 | window.close();
238 | else
239 | document.getElementById('search').value = "";
240 | event.stopPropagation();
241 | event.preventDefault();
242 | }
243 | if (wkey == 32) {
244 | if (event.altKey)
245 | insertEscapedSpace();
246 | else
247 | quicklook();
248 | event.stopPropagation();
249 | event.preventDefault();
250 | }
251 | if (wkey == 13) {
252 | if (event.shiftKey && event.altKey) insertPath();
253 | else if (event.shiftKey) insertRelPath();
254 | else if (event.altKey) openFile();
255 | else gotofile();
256 | event.stopPropagation();
257 | event.preventDefault();
258 | }
259 | };
--------------------------------------------------------------------------------
/Support/lib/fuzzy_file_finder.rb:
--------------------------------------------------------------------------------
1 | #--
2 | # ==================================================================
3 | # Author: Jamis Buck (jamis@jamisbuck.org)
4 | # Date: 2008-10-09
5 | #
6 | # This file is in the public domain. Usage, modification, and
7 | # redistribution of this file are unrestricted.
8 | # ==================================================================
9 | #++
10 |
11 | # The "fuzzy" file finder provides a way for searching a directory
12 | # tree with only a partial name. This is similar to the "cmd-T"
13 | # feature in TextMate (http://macromates.com).
14 | #
15 | # Usage:
16 | #
17 | # finder = FuzzyFileFinder.new
18 | # finder.search("app/blogcon") do |match|
19 | # puts match[:highlighted_path]
20 | # end
21 | #
22 | # In the above example, all files matching "app/blogcon" will be
23 | # yielded to the block. The given pattern is reduced to a regular
24 | # expression internally, so that any file that contains those
25 | # characters in that order (even if there are other characters
26 | # in between) will match.
27 | #
28 | # In other words, "app/blogcon" would match any of the following
29 | # (parenthesized strings indicate how the match was made):
30 | #
31 | # * (app)/controllers/(blog)_(con)troller.rb
32 | # * lib/c(ap)_(p)ool/(bl)ue_(o)r_(g)reen_(co)loratio(n)
33 | # * test/(app)/(blog)_(con)troller_test.rb
34 | #
35 | # And so forth.
36 |
37 | require 'CGI'
38 |
39 | class FuzzyFileFinder
40 | module Version
41 | MAJOR = 1
42 | MINOR = 0
43 | TINY = 4
44 | STRING = [MAJOR, MINOR, TINY].join(".")
45 | end
46 |
47 | # This is the exception that is raised if you try to scan a
48 | # directory tree with too many entries. By default, a ceiling of
49 | # 10,000 entries is enforced, but you can change that number via
50 | # the +ceiling+ parameter to FuzzyFileFinder.new.
51 | class TooManyEntries < RuntimeError; end
52 |
53 | # Used internally to represent a run of characters within a
54 | # match. This is used to build the highlighted version of
55 | # a file name.
56 | class CharacterRun < Struct.new(:string, :inside) #:nodoc:
57 | def to_s
58 | if inside
59 | "(#{string})"
60 | else
61 | string
62 | end
63 | end
64 | end
65 |
66 | # Just like CharacterRun except outputs HTML.
67 | class HtmlCharacterRun < Struct.new(:string, :inside) #:nodoc:
68 | def to_s
69 | if inside
70 | "#{CGI.escapeHTML(string)}"
71 | else
72 | CGI.escapeHTML(string)
73 | end
74 | end
75 | end
76 |
77 | # Used internally to represent a file within the directory tree.
78 | class FileSystemEntry #:nodoc:
79 | attr_reader :parent
80 | attr_reader :name
81 |
82 | def initialize(parent, name)
83 | @parent = parent
84 | @name = name
85 | end
86 |
87 | def path
88 | File.join(parent.name, name)
89 | end
90 | end
91 |
92 | # Used internally to represent a subdirectory within the directory
93 | # tree.
94 | class Directory #:nodoc:
95 | attr_reader :name
96 |
97 | def initialize(name, is_root=false)
98 | @name = name
99 | @is_root = is_root
100 | end
101 |
102 | def root?
103 | is_root
104 | end
105 | end
106 |
107 | # The roots directory trees to search.
108 | attr_reader :roots
109 |
110 | # The list of files beneath all +roots+
111 | attr_reader :files
112 |
113 | # The maximum number of files beneath all +roots+
114 | attr_reader :ceiling
115 |
116 | # The prefix shared by all +roots+.
117 | attr_reader :shared_prefix
118 |
119 | # The list of glob patterns to ignore.
120 | attr_reader :ignores
121 |
122 | # The class used to output the highlighted text in the desired format.
123 | attr_reader :highlighted_match_class
124 |
125 | # Initializes a new FuzzyFileFinder. This will scan the
126 | # given +directories+, using +ceiling+ as the maximum number
127 | # of entries to scan. If there are more than +ceiling+ entries
128 | # a TooManyEntries exception will be raised.
129 | def initialize(directories=['.'], ceiling=10_000, ignores=nil, highlighter=CharacterRun)
130 | directories = Array(directories)
131 | directories << "." if directories.empty?
132 |
133 | # expand any paths with ~
134 | root_dirnames = directories.map { |d| File.expand_path(d) }.select { |d| File.directory?(d) }.uniq
135 |
136 | @roots = root_dirnames.map { |d| Directory.new(d, true) }
137 | @shared_prefix = determine_shared_prefix
138 | @shared_prefix_re = Regexp.new("^#{Regexp.escape(shared_prefix)}" + (shared_prefix.empty? ? "" : "/"))
139 |
140 | @files = []
141 | @ceiling = ceiling
142 |
143 | @ignores = Array(ignores)
144 |
145 | @highlighted_match_class = highlighter
146 |
147 | rescan!
148 | end
149 |
150 | # Rescans the subtree. If the directory contents every change,
151 | # you'll need to call this to force the finder to be aware of
152 | # the changes.
153 | def rescan!
154 | @files.clear
155 | roots.each { |root| follow_tree(root) }
156 | end
157 |
158 | # Takes the given +pattern+ (which must be a string) and searches
159 | # all files beneath +root+, yielding each match.
160 | #
161 | # +pattern+ is interpreted thus:
162 | #
163 | # * "foo" : look for any file with the characters 'f', 'o', and 'o'
164 | # in its basename (discounting directory names). The characters
165 | # must be in that order.
166 | # * "foo/bar" : look for any file with the characters 'b', 'a',
167 | # and 'r' in its basename (discounting directory names). Also,
168 | # any successful match must also have at least one directory
169 | # element matching the characters 'f', 'o', and 'o' (in that
170 | # order.
171 | # * "foo/bar/baz" : same as "foo/bar", but matching two
172 | # directory elements in addition to a file name of "baz".
173 | #
174 | # Each yielded match will be a hash containing the following keys:
175 | #
176 | # * :path refers to the full path to the file
177 | # * :directory refers to the directory of the file
178 | # * :name refers to the name of the file (without directory)
179 | # * :highlighted_directory refers to the directory of the file with
180 | # matches highlighted in parentheses.
181 | # * :highlighted_name refers to the name of the file with matches
182 | # highlighted in parentheses
183 | # * :highlighted_path refers to the full path of the file with
184 | # matches highlighted in parentheses
185 | # * :abbr refers to an abbreviated form of :highlighted_path, where
186 | # path segments without matches are compressed to just their first
187 | # character.
188 | # * :score refers to a value between 0 and 1 indicating how closely
189 | # the file matches the given pattern. A score of 1 means the
190 | # pattern matches the file exactly.
191 | def search(pattern, &block)
192 | #pattern.gsub!(" ", "")
193 | path_parts = pattern.split("/")
194 | path_parts.push "" if pattern[-1,1] == "/"
195 |
196 | file_name_part = path_parts.pop || ""
197 |
198 | if path_parts.any?
199 | path_regex_raw = "^(.*?)" + path_parts.map { |part| make_pattern(part) }.join("(.*?/.*?)") + "(.*?)$"
200 | path_regex = Regexp.new(path_regex_raw, Regexp::IGNORECASE)
201 | end
202 |
203 | file_regex_raw = "^(.*?)" << make_pattern(file_name_part) << "(.*)$"
204 | file_regex = Regexp.new(file_regex_raw, Regexp::IGNORECASE)
205 |
206 | path_matches = {}
207 | files.each do |file|
208 | path_match = match_path(file.parent, path_matches, path_regex, path_parts.length)
209 | next if path_match[:missed]
210 |
211 | match_file(file, file_regex, path_match, &block)
212 | end
213 | end
214 |
215 | # Takes the given +pattern+ (which must be a string, formatted as
216 | # described in #search), and returns up to +max+ matches in an
217 | # Array. If +max+ is nil, all matches will be returned.
218 | def find(pattern, max=nil)
219 | results = []
220 | search(pattern) do |match|
221 | results << match
222 | break if max && results.length >= max
223 | end
224 | return results
225 | end
226 |
227 | # Displays the finder object in a sane, non-explosive manner.
228 | def inspect #:nodoc:
229 | "#<%s:0x%x roots=%s, files=%d>" % [self.class.name, object_id, roots.map { |r| r.name.inspect }.join(", "), files.length]
230 | end
231 |
232 | private
233 |
234 | # Recursively scans +directory+ and all files and subdirectories
235 | # beneath it, depth-first.
236 | def follow_tree(directory)
237 | Dir.entries(directory.name).each do |entry|
238 | next if entry[0,1] == "."
239 | raise TooManyEntries if files.length > ceiling
240 |
241 | full = File.join(directory.name, entry)
242 |
243 | if File.directory?(full)
244 | follow_tree(Directory.new(full))
245 | elsif !ignore?(full.sub(@shared_prefix_re, ""))
246 | files.push(FileSystemEntry.new(directory, entry))
247 | end
248 | end
249 | end
250 |
251 | # Returns +true+ if the given name matches any of the ignore
252 | # patterns.
253 | def ignore?(name)
254 | ignores.any? { |pattern| File.fnmatch(pattern, name) }
255 | end
256 |
257 | # Takes the given pattern string "foo" and converts it to a new
258 | # string "(f)([^/]*?)(o)([^/]*?)(o)" that can be used to create
259 | # a regular expression.
260 | def make_pattern(pattern)
261 | pattern = pattern.split(//)
262 | pattern << "" if pattern.empty?
263 |
264 | pattern.inject("") do |regex, character|
265 | regex << "([^/]*?)" if regex.length > 0
266 | regex << "(" << Regexp.escape(character) << ")"
267 | end
268 | end
269 |
270 | # Given a MatchData object +match+ and a number of "inside"
271 | # segments to support, compute both the match score and the
272 | # highlighted match string. The "inside segments" refers to how
273 | # many patterns were matched in this one match. For a file name,
274 | # this will always be one. For directories, it will be one for
275 | # each directory segment in the original pattern.
276 | def build_match_result(match, inside_segments)
277 | runs = []
278 | inside_chars = total_chars = 0
279 | is_word_prefixes = inside_segments == 1
280 | match.captures.each_with_index do |capture, index|
281 | if capture.length > 0
282 | # odd-numbered captures are matches inside the pattern.
283 | # even-numbered captures are matches between the pattern's elements.
284 | inside = index % 2 != 0
285 |
286 | total_chars += capture.gsub(%r(/), "").length # ignore '/' delimiters
287 | inside_chars += capture.length if inside
288 |
289 | if runs.last && runs.last.inside == inside
290 | runs.last.string << capture
291 | else
292 | runs << @highlighted_match_class.new(capture, inside)
293 | end
294 |
295 | if !inside && is_word_prefixes && index != match.captures.length - 1
296 | if capture.match(/[A-Za-z]$/i) #if this inbetween item finishes with a letter, the next is not an initial letter
297 | is_word_prefixes = false
298 | end
299 | end
300 | end
301 | end
302 |
303 | # Determine the score of this match.
304 | # 1. fewer "inside runs" (runs corresponding to the original pattern)
305 | # is better.
306 | # 2. better coverage of the actual path name is better
307 |
308 | inside_runs = runs.select { |r| r.inside }
309 | run_ratio = inside_runs.length.zero? ? 1 : inside_segments / inside_runs.length.to_f
310 |
311 | char_ratio = total_chars.zero? ? 1 : inside_chars.to_f / total_chars
312 |
313 | score = run_ratio * char_ratio
314 |
315 | return { :score => score, :result => runs.join, :is_word_start_match => is_word_prefixes }
316 | end
317 |
318 | # Match the given path against the regex, caching the result in +path_matches+.
319 | # If +path+ is already cached in the path_matches cache, just return the cached
320 | # value.
321 | def match_path(path, path_matches, path_regex, path_segments)
322 | return path_matches[path] if path_matches.key?(path)
323 |
324 | name_with_slash = path.name + "/" # add a trailing slash for matching the prefix
325 | matchable_name = name_with_slash.sub(@shared_prefix_re, "")
326 | matchable_name.chop! # kill the trailing slash
327 |
328 | if path_regex
329 | match = matchable_name.match(path_regex)
330 |
331 | path_matches[path] =
332 | match && build_match_result(match, path_segments) ||
333 | { :score => 1, :result => matchable_name, :missed => true }
334 | else
335 | path_matches[path] = { :score => 1, :result => matchable_name }
336 | end
337 | end
338 |
339 | # Match +file+ against +file_regex+. If it matches, yield the match
340 | # metadata to the block.
341 | def match_file(file, file_regex, path_match, &block)
342 | if file_match = file.name.match(file_regex)
343 | match_result = build_match_result(file_match, 1)
344 | full_match_result = path_match[:result].empty? ? match_result[:result] : File.join(path_match[:result], match_result[:result])
345 | shortened_path = path_match[:result].gsub(/[^\/]+/) { |m| m.index("(") ? m : m[0,1] }
346 | abbr = shortened_path.empty? ? match_result[:result] : File.join(shortened_path, match_result[:result])
347 | plain_score = (path_match[:score] * match_result[:score]) / 2.0
348 |
349 | result = { :path => file.path,
350 | :abbr => abbr,
351 | :directory => file.parent.name,
352 | :name => file.name,
353 | :highlighted_directory => path_match[:result],
354 | :highlighted_name => match_result[:result],
355 | :highlighted_path => full_match_result,
356 | :score => (match_result[:is_word_start_match] ? 0.5 : 0) + plain_score }
357 | yield result
358 | end
359 | end
360 |
361 | def determine_shared_prefix
362 | # the common case: if there is only a single root, then the entire
363 | # name of the root is the shared prefix.
364 | return roots.first.name if roots.length == 1
365 |
366 | split_roots = roots.map { |root| root.name.split(%r{/}) }
367 | segments = split_roots.map { |root| root.length }.max
368 | master = split_roots.pop
369 |
370 | segments.times do |segment|
371 | if !split_roots.all? { |root| root[segment] == master[segment] }
372 | return master[0,segment].join("/")
373 | end
374 | end
375 |
376 | # shouldn't ever get here, since we uniq the root list before
377 | # calling this method, but if we do, somehow...
378 | return roots.first.name
379 | end
380 | end
381 |
--------------------------------------------------------------------------------