@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add a SublimeText-style repository typeahead

Summary:
Allows you to quickly search for files within a repository. Roughly:

- We build a big tree of everything and ship it to the client.
- The client implements a bunch of Sublime-ish magic to find paths.

Test Plan: {F154007}

Reviewers: chad, btrahan

Reviewed By: btrahan

Subscribers: epriestley, zeeg

Differential Revision: https://secure.phabricator.com/D9087

+485 -9
+4
src/__phutil_library_map__.php
··· 51 51 'AphrontFormTextWithSubmitControl' => 'view/form/control/AphrontFormTextWithSubmitControl.php', 52 52 'AphrontFormToggleButtonsControl' => 'view/form/control/AphrontFormToggleButtonsControl.php', 53 53 'AphrontFormTokenizerControl' => 'view/form/control/AphrontFormTokenizerControl.php', 54 + 'AphrontFormTypeaheadControl' => 'view/form/control/AphrontFormTypeaheadControl.php', 54 55 'AphrontFormView' => 'view/form/AphrontFormView.php', 55 56 'AphrontGlyphBarView' => 'view/widget/bars/AphrontGlyphBarView.php', 56 57 'AphrontHTMLResponse' => 'aphront/response/AphrontHTMLResponse.php', ··· 524 525 'DiffusionPathIDQuery' => 'applications/diffusion/query/pathid/DiffusionPathIDQuery.php', 525 526 'DiffusionPathQuery' => 'applications/diffusion/query/DiffusionPathQuery.php', 526 527 'DiffusionPathQueryTestCase' => 'applications/diffusion/query/pathid/__tests__/DiffusionPathQueryTestCase.php', 528 + 'DiffusionPathTreeController' => 'applications/diffusion/controller/DiffusionPathTreeController.php', 527 529 'DiffusionPathValidateController' => 'applications/diffusion/controller/DiffusionPathValidateController.php', 528 530 'DiffusionPushEventViewController' => 'applications/diffusion/controller/DiffusionPushEventViewController.php', 529 531 'DiffusionPushLogController' => 'applications/diffusion/controller/DiffusionPushLogController.php', ··· 2704 2706 'AphrontFormTextWithSubmitControl' => 'AphrontFormControl', 2705 2707 'AphrontFormToggleButtonsControl' => 'AphrontFormControl', 2706 2708 'AphrontFormTokenizerControl' => 'AphrontFormControl', 2709 + 'AphrontFormTypeaheadControl' => 'AphrontFormControl', 2707 2710 'AphrontFormView' => 'AphrontView', 2708 2711 'AphrontGlyphBarView' => 'AphrontBarView', 2709 2712 'AphrontHTMLResponse' => 'AphrontResponse', ··· 3164 3167 'DiffusionMirrorEditController' => 'DiffusionController', 3165 3168 'DiffusionPathCompleteController' => 'DiffusionController', 3166 3169 'DiffusionPathQueryTestCase' => 'PhabricatorTestCase', 3170 + 'DiffusionPathTreeController' => 'DiffusionController', 3167 3171 'DiffusionPathValidateController' => 'DiffusionController', 3168 3172 'DiffusionPushEventViewController' => 'DiffusionPushLogController', 3169 3173 'DiffusionPushLogController' => 'DiffusionController',
+1
src/applications/diffusion/application/PhabricatorApplicationDiffusion.php
··· 85 85 'hosting/' => 'DiffusionRepositoryEditHostingController', 86 86 '(?P<serve>serve)/' => 'DiffusionRepositoryEditHostingController', 87 87 ), 88 + 'pathtree/(?P<dblob>.*)' => 'DiffusionPathTreeController', 88 89 'mirror/' => array( 89 90 'edit/(?:(?P<id>\d+)/)?' => 'DiffusionMirrorEditController', 90 91 'delete/(?P<id>\d+)/' => 'DiffusionMirrorDeleteController',
+19 -6
src/applications/diffusion/conduit/ConduitAPI_diffusion_querypaths_Method.php
··· 15 15 return array( 16 16 'path' => 'required string', 17 17 'commit' => 'required string', 18 - 'pattern' => 'required string', 18 + 'pattern' => 'optional string', 19 19 'limit' => 'optional int', 20 20 'offset' => 'optional int', 21 21 ); ··· 39 39 'ls-tree --name-only -r -z %s -- %s', 40 40 $commit, 41 41 $path); 42 + 42 43 43 44 $lines = id(new LinesOfALargeExecFuture($future))->setDelimiter("\0"); 44 45 return $this->filterResults($lines, $request); ··· 65 66 $lines[] = $path; 66 67 } 67 68 } 69 + 68 70 return $this->filterResults($lines, $request); 69 71 } 70 72 71 73 protected function filterResults($lines, ConduitAPIRequest $request) { 72 74 $pattern = $request->getValue('pattern'); 73 - $limit = $request->getValue('limit'); 74 - $offset = $request->getValue('offset'); 75 + $limit = (int)$request->getValue('limit'); 76 + $offset = (int)$request->getValue('offset'); 77 + 78 + if (strlen($pattern)) { 79 + $pattern = '/'.preg_quote($pattern, '/').'/'; 80 + } 75 81 76 82 $results = array(); 83 + $count = 0; 77 84 foreach ($lines as $line) { 78 - if (preg_match('#'.str_replace('#', '\#', $pattern).'#', $line)) { 79 - $results[] = $line; 80 - if (count($results) >= $offset + $limit) { 85 + if (!$pattern || preg_match($pattern, $line)) { 86 + if ($count >= $offset) { 87 + $results[] = $line; 88 + } 89 + 90 + $count++; 91 + 92 + if ($limit && ($count >= ($offset + $limit))) { 81 93 break; 82 94 } 83 95 } 84 96 } 97 + 85 98 return $results; 86 99 } 87 100 }
+36
src/applications/diffusion/controller/DiffusionPathTreeController.php
··· 1 + <?php 2 + 3 + final class DiffusionPathTreeController extends DiffusionController { 4 + 5 + public function processRequest() { 6 + $drequest = $this->getDiffusionRequest(); 7 + 8 + if (!$drequest->getRepository()->canUsePathTree()) { 9 + return new Aphront404Response(); 10 + } 11 + 12 + $paths = $this->callConduitWithDiffusionRequest( 13 + 'diffusion.querypaths', 14 + array( 15 + 'path' => $drequest->getPath(), 16 + 'commit' => $drequest->getCommit(), 17 + )); 18 + 19 + $tree = array(); 20 + foreach ($paths as $path) { 21 + $parts = preg_split('((?<=/))', $path); 22 + $cursor = &$tree; 23 + foreach ($parts as $part) { 24 + if (!is_array($cursor)) { 25 + $cursor = array(); 26 + } 27 + if (!isset($cursor[$part])) { 28 + $cursor[$part] = 1; 29 + } 30 + $cursor = &$cursor[$part]; 31 + } 32 + } 33 + 34 + return id(new AphrontAjaxResponse())->setContent(array('tree' => $tree)); 35 + } 36 + }
+30 -3
src/applications/diffusion/controller/DiffusionRepositoryController.php
··· 366 366 $button->setTag('a'); 367 367 $button->setIcon($icon); 368 368 $button->setHref($drequest->generateURI( 369 - array( 370 - 'action' => 'tags', 371 - ))); 369 + array( 370 + 'action' => 'tags', 371 + ))); 372 372 373 373 $header->addActionLink($button); 374 374 ··· 529 529 530 530 $header->addActionLink($button); 531 531 $browse_panel->setHeader($header); 532 + 533 + if ($repository->canUsePathTree()) { 534 + Javelin::initBehavior( 535 + 'diffusion-locate-file', 536 + array( 537 + 'controlID' => 'locate-control', 538 + 'inputID' => 'locate-input', 539 + 'browseBaseURI' => (string)$drequest->generateURI( 540 + array( 541 + 'action' => 'browse', 542 + )), 543 + 'uri' => (string)$drequest->generateURI( 544 + array( 545 + 'action' => 'pathtree', 546 + )), 547 + )); 548 + 549 + $form = id(new AphrontFormView()) 550 + ->setUser($viewer) 551 + ->appendChild( 552 + id(new AphrontFormTypeaheadControl()) 553 + ->setHardpointID('locate-control') 554 + ->setID('locate-input') 555 + ->setLabel(pht('Locate File'))); 556 + $browse_panel->appendChild($form->buildLayoutView()); 557 + } 558 + 532 559 $browse_panel->appendChild($browse_table); 533 560 534 561 return $browse_panel;
+1
src/applications/diffusion/request/DiffusionRequest.php
··· 520 520 case 'tags': 521 521 case 'branches': 522 522 case 'lint': 523 + case 'pathtree': 523 524 $uri = "/diffusion/{$callsign}{$action}/{$path}{$commit}{$line}"; 524 525 break; 525 526 case 'branch':
+4
src/applications/repository/storage/PhabricatorRepository.php
··· 1191 1191 return Filesystem::isDescendant($this->getLocalPath(), $default_path); 1192 1192 } 1193 1193 1194 + public function canUsePathTree() { 1195 + return !$this->isSVN(); 1196 + } 1197 + 1194 1198 public function canMirror() { 1195 1199 if ($this->isGit() || $this->isHg()) { 1196 1200 return true;
+39
src/view/form/control/AphrontFormTypeaheadControl.php
··· 1 + <?php 2 + 3 + final class AphrontFormTypeaheadControl extends AphrontFormControl { 4 + 5 + private $hardpointID; 6 + 7 + public function setHardpointID($hardpoint_id) { 8 + $this->hardpointID = $hardpoint_id; 9 + return $this; 10 + } 11 + 12 + public function getHardpointID() { 13 + return $this->hardpointID; 14 + } 15 + 16 + protected function getCustomControlClass() { 17 + return 'aphront-form-control-typeahead'; 18 + } 19 + 20 + protected function renderInput() { 21 + return javelin_tag( 22 + 'div', 23 + array( 24 + 'style' => 'position: relative;', 25 + 'id' => $this->getHardpointID(), 26 + ), 27 + javelin_tag( 28 + 'input', 29 + array( 30 + 'type' => 'text', 31 + 'name' => $this->getName(), 32 + 'value' => $this->getValue(), 33 + 'disabled' => $this->getDisabled() ? 'disabled' : null, 34 + 'autocomplete' => 'off', 35 + 'id' => $this->getID(), 36 + ))); 37 + } 38 + 39 + }
+8
webroot/rsrc/css/aphront/table-view.css
··· 268 268 } 269 269 270 270 .phui-object-box .aphront-table-view { 271 + border-style: solid; 272 + border-width: 1px 0 0 0; 273 + border-color: {$lightblueborder}; 274 + } 275 + 276 + /* When a table immediately follows a header, remove the top border. */ 277 + .phui-object-box .phui-header-shell + 278 + .aphront-table-wrap .aphront-table-view { 271 279 border: none; 272 280 }
+19
webroot/rsrc/css/aphront/typeahead.css
··· 18 18 margin: -1px 1% 0; 19 19 } 20 20 21 + .aphront-form-control-typeahead div.jx-typeahead-results { 22 + width: 100%; 23 + margin: 0; 24 + box-sizing: border-box; 25 + } 26 + 21 27 div.jx-typeahead-results a.jx-result { 22 28 color: #333; 23 29 display: block; ··· 51 57 div.jx-tokenizer-container-focused.jx-typeahead-waiting { 52 58 border-color: {$lightblueborder}; 53 59 } 60 + 61 + div.jx-typeahead-results a.diffusion-locate-file { 62 + padding: 4px 8px; 63 + color: {$darkgreytext} 64 + } 65 + 66 + .diffusion-locate-file strong { 67 + color: {$blue}; 68 + } 69 + 70 + .diffusion-locate-file .phui-icon-view { 71 + padding-right: 8px; 72 + }
+4
webroot/rsrc/externals/javelin/lib/control/typeahead/source/TypeaheadPreloadedSource.js
··· 53 53 this.matchResults(this.lastValue); 54 54 } 55 55 this.ready = true; 56 + }, 57 + 58 + setReady: function(ready) { 59 + this.ready = ready; 56 60 } 57 61 } 58 62 });
+289
webroot/rsrc/js/application/diffusion/DiffusionLocateFileSource.js
··· 1 + /** 2 + * @provides javelin-diffusion-locate-file-source 3 + * @requires javelin-install 4 + * javelin-dom 5 + * javelin-typeahead-preloaded-source 6 + * javelin-util 7 + * @javelin 8 + */ 9 + 10 + JX.install('DiffusionLocateFileSource', { 11 + 12 + extend: 'TypeaheadPreloadedSource', 13 + 14 + construct: function(uri) { 15 + JX.TypeaheadPreloadedSource.call(this, uri); 16 + this.cache = {}; 17 + }, 18 + 19 + members: { 20 + tree: null, 21 + limit: 20, 22 + cache: null, 23 + 24 + ondata: function(results) { 25 + this.tree = results.tree; 26 + this.setReady(true); 27 + }, 28 + 29 + 30 + /** 31 + * Match a query and show results in the typeahead. 32 + */ 33 + matchResults: function(value, partial) { 34 + // For now, just pretend spaces don't exist. 35 + var search = value.toLowerCase(); 36 + search = search.replace(" ", ""); 37 + 38 + var paths = this.findResults(search); 39 + 40 + var nodes = []; 41 + for (var ii = 0; ii < paths.length; ii++) { 42 + var path = paths[ii]; 43 + var name = []; 44 + name.push(path.path.substr(0, path.pos)); 45 + name.push( 46 + JX.$N('strong', {}, path.path.substr(path.pos, path.score))); 47 + 48 + var pos = path.score; 49 + var lower = path.path.toLowerCase(); 50 + for (var jj = path.pos + path.score; jj < path.path.length; jj++) { 51 + if (lower.charAt(jj) == search.charAt(pos)) { 52 + pos++; 53 + name.push(JX.$N('strong', {}, path.path.charAt(jj))); 54 + if (pos == search.length) { 55 + break; 56 + } 57 + } else { 58 + name.push(path.path.charAt(jj)); 59 + } 60 + } 61 + 62 + if (jj < path.path.length - 1 ) { 63 + name.push(path.path.substr(jj + 1)); 64 + } 65 + 66 + var attr = { 67 + className: 'visual-only phui-icon-view phui-font-fa fa-file' 68 + }; 69 + var icon = JX.$N('span', attr, ''); 70 + 71 + nodes.push( 72 + JX.$N( 73 + 'a', 74 + { 75 + sigil: 'typeahead-result', 76 + className: 'jx-result diffusion-locate-file', 77 + ref: path.path 78 + }, 79 + [icon, name])); 80 + } 81 + 82 + this.invoke('resultsready', nodes, value); 83 + if (!partial) { 84 + this.invoke('complete'); 85 + } 86 + }, 87 + 88 + 89 + /** 90 + * Find the results matching a query. 91 + */ 92 + findResults: function(search) { 93 + if (!search.length) { 94 + return []; 95 + } 96 + 97 + // We know that the results for "abc" are always a subset of the results 98 + // for "a" and "ab" -- and there's a good chance we already computed 99 + // those result sets. Find the longest cached result which is a prefix 100 + // of the search query. 101 + var best = 0; 102 + var start = this.tree; 103 + for (var k in this.cache) { 104 + if ((k.length <= search.length) && 105 + (k.length > best) && 106 + (search.substr(0, k.length) == k)) { 107 + best = k.length; 108 + start = this.cache[k]; 109 + } 110 + } 111 + 112 + var matches; 113 + if (start === null) { 114 + matches = null; 115 + } else { 116 + matches = this.matchTree(start, search, 0); 117 + } 118 + 119 + // Save this tree in cache; throw the cache away after a few minutes. 120 + if (!(search in this.cache)) { 121 + this.cache[search] = matches; 122 + setTimeout( 123 + JX.bind(this, function() { delete this.cache[search]; }), 124 + 1000 * 60 * 5); 125 + } 126 + 127 + if (!matches) { 128 + return []; 129 + } 130 + 131 + var paths = []; 132 + this.buildPaths(matches, paths, '', search, []); 133 + 134 + paths.sort( 135 + function(u, v) { 136 + if (u.score != v.score) { 137 + return (v.score - u.score); 138 + } 139 + 140 + if (u.pos != v.pos) { 141 + return (u.pos - v.pos); 142 + } 143 + 144 + return ((u.path > v.path) ? 1 : -1); 145 + }); 146 + 147 + var num = Math.min(paths.length, this.limit); 148 + var results = []; 149 + for (var ii = 0; ii < num; ii++) { 150 + results.push(paths[ii]); 151 + } 152 + 153 + return results; 154 + }, 155 + 156 + 157 + /** 158 + * Select the subtree that matches a query. 159 + */ 160 + matchTree: function(tree, value, pos) { 161 + var matches = null; 162 + var count = 0; 163 + for (var k in tree) { 164 + var p = pos; 165 + 166 + if (p != value.length) { 167 + p = this.matchString(k, value, pos); 168 + } 169 + 170 + var result; 171 + if (p == value.length) { 172 + result = tree[k]; 173 + } else { 174 + if (tree == 1) { 175 + continue; 176 + } else { 177 + result = this.matchTree(tree[k], value, p); 178 + if (!result) { 179 + continue; 180 + } 181 + } 182 + } 183 + 184 + if (!matches) { 185 + matches = {}; 186 + } 187 + matches[k] = result; 188 + } 189 + 190 + return matches; 191 + }, 192 + 193 + 194 + /** 195 + * Look for the needle in a string, returning how much of it was found. 196 + */ 197 + matchString: function(haystack, needle, pos) { 198 + var str = haystack.toLowerCase(); 199 + var len = str.length; 200 + for (var ii = 0; ii < len; ii++) { 201 + if (str.charAt(ii) == needle.charAt(pos)) { 202 + pos++; 203 + if (pos == needle.length) { 204 + break; 205 + } 206 + } 207 + } 208 + return pos; 209 + }, 210 + 211 + 212 + /** 213 + * Flatten a tree into paths. 214 + */ 215 + buildPaths: function(matches, paths, prefix, search) { 216 + var first = search.charAt(0); 217 + 218 + for (var k in matches) { 219 + if (matches[k] == 1) { 220 + var path = prefix + k; 221 + var lower = path.toLowerCase(); 222 + 223 + var best = 0; 224 + var pos = 0; 225 + for (var jj = 0; jj < lower.length; jj++) { 226 + if (lower.charAt(jj) != first) { 227 + continue; 228 + } 229 + 230 + var score = this.scoreMatch(lower, jj, search); 231 + if (score == -1) { 232 + break; 233 + } 234 + 235 + if (score > best) { 236 + best = score; 237 + pos = jj; 238 + if (best == search.length) { 239 + break; 240 + } 241 + } 242 + } 243 + 244 + paths.push({ 245 + path: path, 246 + score: best, 247 + pos: pos 248 + }); 249 + 250 + } else { 251 + this.buildPaths(matches[k], paths, prefix + k, search); 252 + } 253 + } 254 + }, 255 + 256 + 257 + /** 258 + * Score a matching string by finding the longest prefix of the search 259 + * query it contains continguously. 260 + */ 261 + scoreMatch: function(haystack, haypos, search) { 262 + var pos = 0; 263 + for (var ii = haypos; ii < haystack.length; ii++) { 264 + if (haystack.charAt(ii) == search.charAt(pos)) { 265 + pos++; 266 + if (pos == search.length) { 267 + return pos; 268 + } 269 + } else { 270 + ii++; 271 + break; 272 + } 273 + } 274 + 275 + var rem = pos; 276 + for (/* keep going */; ii < haystack.length; ii++) { 277 + if (haystack.charAt(ii) == search.charAt(rem)) { 278 + rem++; 279 + if (rem == search.length) { 280 + return pos; 281 + } 282 + } 283 + } 284 + 285 + return -1; 286 + } 287 + 288 + } 289 + });
+31
webroot/rsrc/js/application/diffusion/behavior-locate-file.js
··· 1 + /** 2 + * @provides javelin-behavior-diffusion-locate-file 3 + * @requires javelin-behavior 4 + * javelin-diffusion-locate-file-source 5 + * javelin-dom 6 + * javelin-typeahead 7 + * javelin-uri 8 + */ 9 + 10 + JX.behavior('diffusion-locate-file', function(config) { 11 + var control = JX.$(config.controlID); 12 + var input = JX.$(config.inputID); 13 + 14 + var datasource = new JX.DiffusionLocateFileSource(config.uri); 15 + 16 + var typeahead = new JX.Typeahead(control, input); 17 + typeahead.setDatasource(datasource); 18 + 19 + typeahead.listen('choose', function(r) { 20 + JX.$U(config.browseBaseURI + r.ref).go(); 21 + }); 22 + 23 + var started = false; 24 + JX.DOM.listen(input, 'click', null, function() { 25 + if (!started) { 26 + started = true; 27 + typeahead.start(); 28 + } 29 + }); 30 + 31 + });