1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-28 16:30:59 +01:00

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
This commit is contained in:
epriestley 2014-05-13 14:08:21 -07:00
parent 82102cd95a
commit 436f0563e8
13 changed files with 485 additions and 9 deletions

View file

@ -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',

View file

@ -85,6 +85,7 @@ final class PhabricatorApplicationDiffusion extends PhabricatorApplication {
'hosting/' => 'DiffusionRepositoryEditHostingController',
'(?P<serve>serve)/' => 'DiffusionRepositoryEditHostingController',
),
'pathtree/(?P<dblob>.*)' => 'DiffusionPathTreeController',
'mirror/' => array(
'edit/(?:(?P<id>\d+)/)?' => 'DiffusionMirrorEditController',
'delete/(?P<id>\d+)/' => 'DiffusionMirrorDeleteController',

View file

@ -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;
}
}

View file

@ -0,0 +1,36 @@
<?php
final class DiffusionPathTreeController extends DiffusionController {
public function processRequest() {
$drequest = $this->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));
}
}

View file

@ -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;

View file

@ -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':

View file

@ -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;

View file

@ -0,0 +1,39 @@
<?php
final class AphrontFormTypeaheadControl extends AphrontFormControl {
private $hardpointID;
public function setHardpointID($hardpoint_id) {
$this->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(),
)));
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -53,6 +53,10 @@ JX.install('TypeaheadPreloadedSource', {
this.matchResults(this.lastValue);
}
this.ready = true;
},
setReady: function(ready) {
this.ready = ready;
}
}
});

View file

@ -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;
}
}
});

View file

@ -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();
}
});
});