diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index b56e98d381..5818ad6671 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -51,6 +51,7 @@ phutil_register_library_map(array( 'AphrontFormTextWithSubmitControl' => 'view/form/control/AphrontFormTextWithSubmitControl.php', 'AphrontFormToggleButtonsControl' => 'view/form/control/AphrontFormToggleButtonsControl.php', 'AphrontFormTokenizerControl' => 'view/form/control/AphrontFormTokenizerControl.php', + 'AphrontFormTypeaheadControl' => 'view/form/control/AphrontFormTypeaheadControl.php', 'AphrontFormView' => 'view/form/AphrontFormView.php', 'AphrontGlyphBarView' => 'view/widget/bars/AphrontGlyphBarView.php', 'AphrontHTMLResponse' => 'aphront/response/AphrontHTMLResponse.php', @@ -524,6 +525,7 @@ phutil_register_library_map(array( 'DiffusionPathIDQuery' => 'applications/diffusion/query/pathid/DiffusionPathIDQuery.php', 'DiffusionPathQuery' => 'applications/diffusion/query/DiffusionPathQuery.php', 'DiffusionPathQueryTestCase' => 'applications/diffusion/query/pathid/__tests__/DiffusionPathQueryTestCase.php', + 'DiffusionPathTreeController' => 'applications/diffusion/controller/DiffusionPathTreeController.php', 'DiffusionPathValidateController' => 'applications/diffusion/controller/DiffusionPathValidateController.php', 'DiffusionPushEventViewController' => 'applications/diffusion/controller/DiffusionPushEventViewController.php', 'DiffusionPushLogController' => 'applications/diffusion/controller/DiffusionPushLogController.php', @@ -2704,6 +2706,7 @@ phutil_register_library_map(array( 'AphrontFormTextWithSubmitControl' => 'AphrontFormControl', 'AphrontFormToggleButtonsControl' => 'AphrontFormControl', 'AphrontFormTokenizerControl' => 'AphrontFormControl', + 'AphrontFormTypeaheadControl' => 'AphrontFormControl', 'AphrontFormView' => 'AphrontView', 'AphrontGlyphBarView' => 'AphrontBarView', 'AphrontHTMLResponse' => 'AphrontResponse', @@ -3164,6 +3167,7 @@ phutil_register_library_map(array( 'DiffusionMirrorEditController' => 'DiffusionController', 'DiffusionPathCompleteController' => 'DiffusionController', 'DiffusionPathQueryTestCase' => 'PhabricatorTestCase', + 'DiffusionPathTreeController' => 'DiffusionController', 'DiffusionPathValidateController' => 'DiffusionController', 'DiffusionPushEventViewController' => 'DiffusionPushLogController', 'DiffusionPushLogController' => 'DiffusionController', diff --git a/src/applications/diffusion/application/PhabricatorApplicationDiffusion.php b/src/applications/diffusion/application/PhabricatorApplicationDiffusion.php index 1d6ff40df5..e22087af67 100644 --- a/src/applications/diffusion/application/PhabricatorApplicationDiffusion.php +++ b/src/applications/diffusion/application/PhabricatorApplicationDiffusion.php @@ -85,6 +85,7 @@ final class PhabricatorApplicationDiffusion extends PhabricatorApplication { 'hosting/' => 'DiffusionRepositoryEditHostingController', '(?Pserve)/' => 'DiffusionRepositoryEditHostingController', ), + 'pathtree/(?P.*)' => 'DiffusionPathTreeController', 'mirror/' => array( 'edit/(?:(?P\d+)/)?' => 'DiffusionMirrorEditController', 'delete/(?P\d+)/' => 'DiffusionMirrorDeleteController', diff --git a/src/applications/diffusion/conduit/ConduitAPI_diffusion_querypaths_Method.php b/src/applications/diffusion/conduit/ConduitAPI_diffusion_querypaths_Method.php index 43169aea5c..c40dfec5e4 100644 --- a/src/applications/diffusion/conduit/ConduitAPI_diffusion_querypaths_Method.php +++ b/src/applications/diffusion/conduit/ConduitAPI_diffusion_querypaths_Method.php @@ -15,7 +15,7 @@ final class ConduitAPI_diffusion_querypaths_Method return array( 'path' => 'required string', 'commit' => 'required string', - 'pattern' => 'required string', + 'pattern' => 'optional string', 'limit' => 'optional int', 'offset' => 'optional int', ); @@ -40,6 +40,7 @@ final class ConduitAPI_diffusion_querypaths_Method $commit, $path); + $lines = id(new LinesOfALargeExecFuture($future))->setDelimiter("\0"); return $this->filterResults($lines, $request); } @@ -65,23 +66,35 @@ final class ConduitAPI_diffusion_querypaths_Method $lines[] = $path; } } + return $this->filterResults($lines, $request); } protected function filterResults($lines, ConduitAPIRequest $request) { $pattern = $request->getValue('pattern'); - $limit = $request->getValue('limit'); - $offset = $request->getValue('offset'); + $limit = (int)$request->getValue('limit'); + $offset = (int)$request->getValue('offset'); + + if (strlen($pattern)) { + $pattern = '/'.preg_quote($pattern, '/').'/'; + } $results = array(); + $count = 0; foreach ($lines as $line) { - if (preg_match('#'.str_replace('#', '\#', $pattern).'#', $line)) { - $results[] = $line; - if (count($results) >= $offset + $limit) { + if (!$pattern || preg_match($pattern, $line)) { + if ($count >= $offset) { + $results[] = $line; + } + + $count++; + + if ($limit && ($count >= ($offset + $limit))) { break; } } } + return $results; } } diff --git a/src/applications/diffusion/controller/DiffusionPathTreeController.php b/src/applications/diffusion/controller/DiffusionPathTreeController.php new file mode 100644 index 0000000000..a2e9feb9a5 --- /dev/null +++ b/src/applications/diffusion/controller/DiffusionPathTreeController.php @@ -0,0 +1,36 @@ +getDiffusionRequest(); + + if (!$drequest->getRepository()->canUsePathTree()) { + return new Aphront404Response(); + } + + $paths = $this->callConduitWithDiffusionRequest( + 'diffusion.querypaths', + array( + 'path' => $drequest->getPath(), + 'commit' => $drequest->getCommit(), + )); + + $tree = array(); + foreach ($paths as $path) { + $parts = preg_split('((?<=/))', $path); + $cursor = &$tree; + foreach ($parts as $part) { + if (!is_array($cursor)) { + $cursor = array(); + } + if (!isset($cursor[$part])) { + $cursor[$part] = 1; + } + $cursor = &$cursor[$part]; + } + } + + return id(new AphrontAjaxResponse())->setContent(array('tree' => $tree)); + } +} diff --git a/src/applications/diffusion/controller/DiffusionRepositoryController.php b/src/applications/diffusion/controller/DiffusionRepositoryController.php index 7a2f6fc7a6..2149b8ef49 100644 --- a/src/applications/diffusion/controller/DiffusionRepositoryController.php +++ b/src/applications/diffusion/controller/DiffusionRepositoryController.php @@ -366,9 +366,9 @@ final class DiffusionRepositoryController extends DiffusionController { $button->setTag('a'); $button->setIcon($icon); $button->setHref($drequest->generateURI( - array( - 'action' => 'tags', - ))); + array( + 'action' => 'tags', + ))); $header->addActionLink($button); @@ -529,6 +529,33 @@ final class DiffusionRepositoryController extends DiffusionController { $header->addActionLink($button); $browse_panel->setHeader($header); + + if ($repository->canUsePathTree()) { + Javelin::initBehavior( + 'diffusion-locate-file', + array( + 'controlID' => 'locate-control', + 'inputID' => 'locate-input', + 'browseBaseURI' => (string)$drequest->generateURI( + array( + 'action' => 'browse', + )), + 'uri' => (string)$drequest->generateURI( + array( + 'action' => 'pathtree', + )), + )); + + $form = id(new AphrontFormView()) + ->setUser($viewer) + ->appendChild( + id(new AphrontFormTypeaheadControl()) + ->setHardpointID('locate-control') + ->setID('locate-input') + ->setLabel(pht('Locate File'))); + $browse_panel->appendChild($form->buildLayoutView()); + } + $browse_panel->appendChild($browse_table); return $browse_panel; diff --git a/src/applications/diffusion/request/DiffusionRequest.php b/src/applications/diffusion/request/DiffusionRequest.php index eebc3ce92d..9bf439d815 100644 --- a/src/applications/diffusion/request/DiffusionRequest.php +++ b/src/applications/diffusion/request/DiffusionRequest.php @@ -520,6 +520,7 @@ abstract class DiffusionRequest { case 'tags': case 'branches': case 'lint': + case 'pathtree': $uri = "/diffusion/{$callsign}{$action}/{$path}{$commit}{$line}"; break; case 'branch': diff --git a/src/applications/repository/storage/PhabricatorRepository.php b/src/applications/repository/storage/PhabricatorRepository.php index fbaf058901..632e1785a8 100644 --- a/src/applications/repository/storage/PhabricatorRepository.php +++ b/src/applications/repository/storage/PhabricatorRepository.php @@ -1191,6 +1191,10 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO return Filesystem::isDescendant($this->getLocalPath(), $default_path); } + public function canUsePathTree() { + return !$this->isSVN(); + } + public function canMirror() { if ($this->isGit() || $this->isHg()) { return true; diff --git a/src/view/form/control/AphrontFormTypeaheadControl.php b/src/view/form/control/AphrontFormTypeaheadControl.php new file mode 100644 index 0000000000..e4bee7c1ca --- /dev/null +++ b/src/view/form/control/AphrontFormTypeaheadControl.php @@ -0,0 +1,39 @@ +hardpointID = $hardpoint_id; + return $this; + } + + public function getHardpointID() { + return $this->hardpointID; + } + + protected function getCustomControlClass() { + return 'aphront-form-control-typeahead'; + } + + protected function renderInput() { + return javelin_tag( + 'div', + array( + 'style' => 'position: relative;', + 'id' => $this->getHardpointID(), + ), + javelin_tag( + 'input', + array( + 'type' => 'text', + 'name' => $this->getName(), + 'value' => $this->getValue(), + 'disabled' => $this->getDisabled() ? 'disabled' : null, + 'autocomplete' => 'off', + 'id' => $this->getID(), + ))); + } + +} diff --git a/webroot/rsrc/css/aphront/table-view.css b/webroot/rsrc/css/aphront/table-view.css index 7c03ce7a08..3c8b4934eb 100644 --- a/webroot/rsrc/css/aphront/table-view.css +++ b/webroot/rsrc/css/aphront/table-view.css @@ -268,5 +268,13 @@ span.single-display-line-content { } .phui-object-box .aphront-table-view { + border-style: solid; + border-width: 1px 0 0 0; + border-color: {$lightblueborder}; +} + +/* When a table immediately follows a header, remove the top border. */ +.phui-object-box .phui-header-shell + + .aphront-table-wrap .aphront-table-view { border: none; } diff --git a/webroot/rsrc/css/aphront/typeahead.css b/webroot/rsrc/css/aphront/typeahead.css index ecf1297dde..923e2b3fa5 100644 --- a/webroot/rsrc/css/aphront/typeahead.css +++ b/webroot/rsrc/css/aphront/typeahead.css @@ -18,6 +18,12 @@ div.jx-typeahead-results { margin: -1px 1% 0; } +.aphront-form-control-typeahead div.jx-typeahead-results { + width: 100%; + margin: 0; + box-sizing: border-box; +} + div.jx-typeahead-results a.jx-result { color: #333; display: block; @@ -51,3 +57,16 @@ input.jx-typeahead-placeholder { div.jx-tokenizer-container-focused.jx-typeahead-waiting { border-color: {$lightblueborder}; } + +div.jx-typeahead-results a.diffusion-locate-file { + padding: 4px 8px; + color: {$darkgreytext} +} + +.diffusion-locate-file strong { + color: {$blue}; +} + +.diffusion-locate-file .phui-icon-view { + padding-right: 8px; +} diff --git a/webroot/rsrc/externals/javelin/lib/control/typeahead/source/TypeaheadPreloadedSource.js b/webroot/rsrc/externals/javelin/lib/control/typeahead/source/TypeaheadPreloadedSource.js index d0bd4672d9..6324bf8070 100644 --- a/webroot/rsrc/externals/javelin/lib/control/typeahead/source/TypeaheadPreloadedSource.js +++ b/webroot/rsrc/externals/javelin/lib/control/typeahead/source/TypeaheadPreloadedSource.js @@ -53,6 +53,10 @@ JX.install('TypeaheadPreloadedSource', { this.matchResults(this.lastValue); } this.ready = true; + }, + + setReady: function(ready) { + this.ready = ready; } } }); diff --git a/webroot/rsrc/js/application/diffusion/DiffusionLocateFileSource.js b/webroot/rsrc/js/application/diffusion/DiffusionLocateFileSource.js new file mode 100644 index 0000000000..5b75bc71de --- /dev/null +++ b/webroot/rsrc/js/application/diffusion/DiffusionLocateFileSource.js @@ -0,0 +1,289 @@ +/** + * @provides javelin-diffusion-locate-file-source + * @requires javelin-install + * javelin-dom + * javelin-typeahead-preloaded-source + * javelin-util + * @javelin + */ + +JX.install('DiffusionLocateFileSource', { + + extend: 'TypeaheadPreloadedSource', + + construct: function(uri) { + JX.TypeaheadPreloadedSource.call(this, uri); + this.cache = {}; + }, + + members: { + tree: null, + limit: 20, + cache: null, + + ondata: function(results) { + this.tree = results.tree; + this.setReady(true); + }, + + + /** + * Match a query and show results in the typeahead. + */ + matchResults: function(value, partial) { + // For now, just pretend spaces don't exist. + var search = value.toLowerCase(); + search = search.replace(" ", ""); + + var paths = this.findResults(search); + + var nodes = []; + for (var ii = 0; ii < paths.length; ii++) { + var path = paths[ii]; + var name = []; + name.push(path.path.substr(0, path.pos)); + name.push( + JX.$N('strong', {}, path.path.substr(path.pos, path.score))); + + var pos = path.score; + var lower = path.path.toLowerCase(); + for (var jj = path.pos + path.score; jj < path.path.length; jj++) { + if (lower.charAt(jj) == search.charAt(pos)) { + pos++; + name.push(JX.$N('strong', {}, path.path.charAt(jj))); + if (pos == search.length) { + break; + } + } else { + name.push(path.path.charAt(jj)); + } + } + + if (jj < path.path.length - 1 ) { + name.push(path.path.substr(jj + 1)); + } + + var attr = { + className: 'visual-only phui-icon-view phui-font-fa fa-file' + }; + var icon = JX.$N('span', attr, ''); + + nodes.push( + JX.$N( + 'a', + { + sigil: 'typeahead-result', + className: 'jx-result diffusion-locate-file', + ref: path.path + }, + [icon, name])); + } + + this.invoke('resultsready', nodes, value); + if (!partial) { + this.invoke('complete'); + } + }, + + + /** + * Find the results matching a query. + */ + findResults: function(search) { + if (!search.length) { + return []; + } + + // We know that the results for "abc" are always a subset of the results + // for "a" and "ab" -- and there's a good chance we already computed + // those result sets. Find the longest cached result which is a prefix + // of the search query. + var best = 0; + var start = this.tree; + for (var k in this.cache) { + if ((k.length <= search.length) && + (k.length > best) && + (search.substr(0, k.length) == k)) { + best = k.length; + start = this.cache[k]; + } + } + + var matches; + if (start === null) { + matches = null; + } else { + matches = this.matchTree(start, search, 0); + } + + // Save this tree in cache; throw the cache away after a few minutes. + if (!(search in this.cache)) { + this.cache[search] = matches; + setTimeout( + JX.bind(this, function() { delete this.cache[search]; }), + 1000 * 60 * 5); + } + + if (!matches) { + return []; + } + + var paths = []; + this.buildPaths(matches, paths, '', search, []); + + paths.sort( + function(u, v) { + if (u.score != v.score) { + return (v.score - u.score); + } + + if (u.pos != v.pos) { + return (u.pos - v.pos); + } + + return ((u.path > v.path) ? 1 : -1); + }); + + var num = Math.min(paths.length, this.limit); + var results = []; + for (var ii = 0; ii < num; ii++) { + results.push(paths[ii]); + } + + return results; + }, + + + /** + * Select the subtree that matches a query. + */ + matchTree: function(tree, value, pos) { + var matches = null; + var count = 0; + for (var k in tree) { + var p = pos; + + if (p != value.length) { + p = this.matchString(k, value, pos); + } + + var result; + if (p == value.length) { + result = tree[k]; + } else { + if (tree == 1) { + continue; + } else { + result = this.matchTree(tree[k], value, p); + if (!result) { + continue; + } + } + } + + if (!matches) { + matches = {}; + } + matches[k] = result; + } + + return matches; + }, + + + /** + * Look for the needle in a string, returning how much of it was found. + */ + matchString: function(haystack, needle, pos) { + var str = haystack.toLowerCase(); + var len = str.length; + for (var ii = 0; ii < len; ii++) { + if (str.charAt(ii) == needle.charAt(pos)) { + pos++; + if (pos == needle.length) { + break; + } + } + } + return pos; + }, + + + /** + * Flatten a tree into paths. + */ + buildPaths: function(matches, paths, prefix, search) { + var first = search.charAt(0); + + for (var k in matches) { + if (matches[k] == 1) { + var path = prefix + k; + var lower = path.toLowerCase(); + + var best = 0; + var pos = 0; + for (var jj = 0; jj < lower.length; jj++) { + if (lower.charAt(jj) != first) { + continue; + } + + var score = this.scoreMatch(lower, jj, search); + if (score == -1) { + break; + } + + if (score > best) { + best = score; + pos = jj; + if (best == search.length) { + break; + } + } + } + + paths.push({ + path: path, + score: best, + pos: pos + }); + + } else { + this.buildPaths(matches[k], paths, prefix + k, search); + } + } + }, + + + /** + * Score a matching string by finding the longest prefix of the search + * query it contains continguously. + */ + scoreMatch: function(haystack, haypos, search) { + var pos = 0; + for (var ii = haypos; ii < haystack.length; ii++) { + if (haystack.charAt(ii) == search.charAt(pos)) { + pos++; + if (pos == search.length) { + return pos; + } + } else { + ii++; + break; + } + } + + var rem = pos; + for (/* keep going */; ii < haystack.length; ii++) { + if (haystack.charAt(ii) == search.charAt(rem)) { + rem++; + if (rem == search.length) { + return pos; + } + } + } + + return -1; + } + + } +}); diff --git a/webroot/rsrc/js/application/diffusion/behavior-locate-file.js b/webroot/rsrc/js/application/diffusion/behavior-locate-file.js new file mode 100644 index 0000000000..c8817428e6 --- /dev/null +++ b/webroot/rsrc/js/application/diffusion/behavior-locate-file.js @@ -0,0 +1,31 @@ +/** + * @provides javelin-behavior-diffusion-locate-file + * @requires javelin-behavior + * javelin-diffusion-locate-file-source + * javelin-dom + * javelin-typeahead + * javelin-uri + */ + +JX.behavior('diffusion-locate-file', function(config) { + var control = JX.$(config.controlID); + var input = JX.$(config.inputID); + + var datasource = new JX.DiffusionLocateFileSource(config.uri); + + var typeahead = new JX.Typeahead(control, input); + typeahead.setDatasource(datasource); + + typeahead.listen('choose', function(r) { + JX.$U(config.browseBaseURI + r.ref).go(); + }); + + var started = false; + JX.DOM.listen(input, 'click', null, function() { + if (!started) { + started = true; + typeahead.start(); + } + }); + +});