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:
parent
82102cd95a
commit
436f0563e8
13 changed files with 485 additions and 9 deletions
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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;
|
||||
|
|
39
src/view/form/control/AphrontFormTypeaheadControl.php
Normal file
39
src/view/form/control/AphrontFormTypeaheadControl.php
Normal 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(),
|
||||
)));
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -53,6 +53,10 @@ JX.install('TypeaheadPreloadedSource', {
|
|||
this.matchResults(this.lastValue);
|
||||
}
|
||||
this.ready = true;
|
||||
},
|
||||
|
||||
setReady: function(ready) {
|
||||
this.ready = ready;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
Loading…
Reference in a new issue