This repository has been archived by the owner on Jun 21, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2156 from github/autocompletebox
Migrating and Updating AutoCompleteBox from *original* GitHub Desktop for Windows
- Loading branch information
Showing
62 changed files
with
5,786 additions
and
120 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
using System; | ||
using GitHub.Extensions; | ||
using GitHub.Helpers; | ||
|
||
namespace GitHub.Models | ||
{ | ||
/// <summary> | ||
/// Represents a single auto completion suggestion (mentions, emojis, issues) in a generic format that can be | ||
/// easily cached. | ||
/// </summary> | ||
public class SuggestionItem | ||
{ | ||
public SuggestionItem(string name, string description) | ||
{ | ||
Guard.ArgumentNotEmptyString(name, "name"); | ||
Guard.ArgumentNotEmptyString(description, "description"); | ||
|
||
Name = name; | ||
Description = description; | ||
} | ||
|
||
public SuggestionItem(string name, string description, string imageUrl) | ||
{ | ||
Guard.ArgumentNotEmptyString(name, "name"); | ||
|
||
Name = name; | ||
Description = description; | ||
ImageUrl = imageUrl; | ||
} | ||
|
||
/// <summary> | ||
/// The name to display for this entry | ||
/// </summary> | ||
public string Name { get; set; } | ||
|
||
/// <summary> | ||
/// Additional details about the entry | ||
/// </summary> | ||
public string Description { get; set; } | ||
|
||
/// <summary> | ||
/// An image url for this entry | ||
/// </summary> | ||
public string ImageUrl { get; set; } | ||
|
||
/// <summary> | ||
/// The date this suggestion was last modified according to the API. | ||
/// </summary> | ||
public DateTimeOffset? LastModifiedDate { get; set; } | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Collections.ObjectModel; | ||
using System.ComponentModel.Composition; | ||
using System.Diagnostics; | ||
using System.Diagnostics.CodeAnalysis; | ||
using System.Globalization; | ||
using System.Linq; | ||
using System.Reactive.Linq; | ||
using GitHub.Extensions; | ||
using GitHub.Logging; | ||
using GitHub.Models; | ||
using Serilog; | ||
|
||
namespace GitHub.Services | ||
{ | ||
[Export(typeof(IAutoCompleteAdvisor))] | ||
[PartCreationPolicy(CreationPolicy.Shared)] | ||
public class AutoCompleteAdvisor : IAutoCompleteAdvisor | ||
{ | ||
const int SuggestionCount = 5; // The number of suggestions we'll provide. github.com does 5. | ||
|
||
static readonly ILogger log = LogManager.ForContext<AutoCompleteAdvisor>(); | ||
readonly Lazy<Dictionary<string, IAutoCompleteSource>> prefixSourceMap; | ||
|
||
[ImportingConstructor] | ||
public AutoCompleteAdvisor([ImportMany(typeof(IAutoCompleteSource))]IEnumerable<IAutoCompleteSource> autocompleteSources) | ||
{ | ||
prefixSourceMap = new Lazy<Dictionary<string, IAutoCompleteSource>>( | ||
() => autocompleteSources.ToDictionary(s => s.Prefix, s => s)); | ||
} | ||
|
||
public IObservable<AutoCompleteResult> GetAutoCompletionSuggestions(string text, int caretPosition) | ||
{ | ||
Guard.ArgumentNotNull("text", text); | ||
|
||
if (caretPosition < 0 || caretPosition > text.Length) | ||
{ | ||
string error = String.Format(CultureInfo.InvariantCulture, | ||
"The CaretPosition '{0}', is not in the range of '0' and the text length '{1}' for the text '{2}'", | ||
caretPosition, | ||
text.Length, | ||
text); | ||
|
||
// We need to be alerted when this happens because it should never happen. | ||
// But it apparently did happen in production. | ||
Debug.Fail(error); | ||
log.Error(error); | ||
return Observable.Empty<AutoCompleteResult>(); | ||
} | ||
var tokenAndSource = PrefixSourceMap | ||
.Select(kvp => new {Source = kvp.Value, Token = ParseAutoCompletionToken(text, caretPosition, kvp.Key)}) | ||
.FirstOrDefault(s => s.Token != null); | ||
|
||
if (tokenAndSource == null) | ||
{ | ||
return Observable.Return(AutoCompleteResult.Empty); | ||
} | ||
|
||
return tokenAndSource.Source.GetSuggestions() | ||
.Select(suggestion => new | ||
{ | ||
suggestion, | ||
rank = suggestion.GetSortRank(tokenAndSource.Token.SearchSearchPrefix) | ||
}) | ||
.Where(suggestion => suggestion.rank > -1) | ||
.ToList() | ||
.Select(suggestions => suggestions | ||
.OrderByDescending(s => s.rank) | ||
.ThenBy(s => s.suggestion.Name) | ||
.Take(SuggestionCount) | ||
.Select(s => s.suggestion) | ||
.ToList()) | ||
.Select(suggestions => new AutoCompleteResult(tokenAndSource.Token.Offset, | ||
new ReadOnlyCollection<AutoCompleteSuggestion>(suggestions))) | ||
.Catch<AutoCompleteResult, Exception>(e => | ||
{ | ||
log.Error(e, "Error Getting AutoCompleteResult"); | ||
return Observable.Return(AutoCompleteResult.Empty); | ||
}); | ||
} | ||
|
||
[SuppressMessage("Microsoft.Usage", "CA2233:OperationsShouldNotOverflow", MessageId = "caretPosition-1" | ||
, Justification = "We ensure the argument is greater than -1 so it can't overflow")] | ||
public static AutoCompletionToken ParseAutoCompletionToken(string text, int caretPosition, string triggerPrefix) | ||
{ | ||
Guard.ArgumentNotNull("text", text); | ||
Guard.ArgumentInRange(caretPosition, 0, text.Length, "caretPosition"); | ||
if (caretPosition == 0 || text.Length == 0) return null; | ||
|
||
// :th : 1 | ||
//:th : 0 | ||
//Hi :th : 3 | ||
int beginningOfWord = text.LastIndexOfAny(new[] { ' ', '\n' }, caretPosition - 1) + 1; | ||
string word = text.Substring(beginningOfWord, caretPosition - beginningOfWord); | ||
if (!word.StartsWith(triggerPrefix, StringComparison.Ordinal)) return null; | ||
|
||
return new AutoCompletionToken(word.Substring(1), beginningOfWord); | ||
} | ||
|
||
Dictionary<string, IAutoCompleteSource> PrefixSourceMap { get { return prefixSourceMap.Value; } } | ||
} | ||
|
||
public class AutoCompletionToken | ||
{ | ||
public AutoCompletionToken(string searchPrefix, int offset) | ||
{ | ||
Guard.ArgumentNotNull(searchPrefix, "searchPrefix"); | ||
Guard.ArgumentNonNegative(offset, "offset"); | ||
|
||
SearchSearchPrefix = searchPrefix; | ||
Offset = offset; | ||
} | ||
|
||
/// <summary> | ||
/// Used to filter the list of auto complete suggestions to what the user has typed in. | ||
/// </summary> | ||
public string SearchSearchPrefix { get; private set; } | ||
public int Offset { get; private set; } | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
using System; | ||
using GitHub.Models; | ||
|
||
namespace GitHub.Services | ||
{ | ||
public interface IAutoCompleteSource | ||
{ | ||
IObservable<AutoCompleteSuggestion> GetSuggestions(); | ||
|
||
// The prefix used to trigger auto completion. | ||
string Prefix { get; } | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.ComponentModel.Composition; | ||
using System.Linq; | ||
using System.Reactive.Linq; | ||
using GitHub.Api; | ||
using GitHub.Extensions; | ||
using GitHub.Models; | ||
using GitHub.Primitives; | ||
using Octokit.GraphQL; | ||
using Octokit.GraphQL.Model; | ||
using static Octokit.GraphQL.Variable; | ||
|
||
namespace GitHub.Services | ||
{ | ||
[Export(typeof(IAutoCompleteSource))] | ||
[PartCreationPolicy(CreationPolicy.Shared)] | ||
public class IssuesAutoCompleteSource : IAutoCompleteSource | ||
{ | ||
readonly ITeamExplorerContext teamExplorerContext; | ||
readonly IGraphQLClientFactory graphqlFactory; | ||
ICompiledQuery<Page<SuggestionItem>> query; | ||
|
||
[ImportingConstructor] | ||
public IssuesAutoCompleteSource(ITeamExplorerContext teamExplorerContext, IGraphQLClientFactory graphqlFactory) | ||
{ | ||
Guard.ArgumentNotNull(teamExplorerContext, nameof(teamExplorerContext)); | ||
Guard.ArgumentNotNull(graphqlFactory, nameof(graphqlFactory)); | ||
|
||
this.teamExplorerContext = teamExplorerContext; | ||
this.graphqlFactory = graphqlFactory; | ||
} | ||
|
||
public IObservable<AutoCompleteSuggestion> GetSuggestions() | ||
{ | ||
var localRepositoryModel = teamExplorerContext.ActiveRepository; | ||
|
||
var hostAddress = HostAddress.Create(localRepositoryModel.CloneUrl.Host); | ||
var owner = localRepositoryModel.Owner; | ||
var name = localRepositoryModel.Name; | ||
|
||
string filter; | ||
string after; | ||
|
||
if (query == null) | ||
{ | ||
query = new Query().Search(query: Var(nameof(filter)), SearchType.Issue, 100, after: Var(nameof(after))) | ||
.Select(item => new Page<SuggestionItem> | ||
{ | ||
Items = item.Nodes.Select(searchResultItem => | ||
searchResultItem.Switch<SuggestionItem>(selector => selector | ||
.Issue(i => new SuggestionItem("#" + i.Number, i.Title) { LastModifiedDate = i.LastEditedAt }) | ||
.PullRequest(p => new SuggestionItem("#" + p.Number, p.Title) { LastModifiedDate = p.LastEditedAt })) | ||
).ToList(), | ||
EndCursor = item.PageInfo.EndCursor, | ||
HasNextPage = item.PageInfo.HasNextPage, | ||
TotalCount = item.IssueCount | ||
}) | ||
.Compile(); | ||
} | ||
|
||
filter = $"repo:{owner}/{name}"; | ||
|
||
return Observable.FromAsync(async () => | ||
{ | ||
var results = new List<SuggestionItem>(); | ||
|
||
var variables = new Dictionary<string, object> | ||
{ | ||
{nameof(filter), filter }, | ||
}; | ||
|
||
var connection = await graphqlFactory.CreateConnection(hostAddress); | ||
var searchResults = await connection.Run(query, variables); | ||
|
||
results.AddRange(searchResults.Items); | ||
|
||
while (searchResults.HasNextPage) | ||
{ | ||
variables[nameof(after)] = searchResults.EndCursor; | ||
searchResults = await connection.Run(query, variables); | ||
|
||
results.AddRange(searchResults.Items); | ||
} | ||
|
||
return results.Select(item => new IssueAutoCompleteSuggestion(item, Prefix)); | ||
|
||
}).SelectMany(observable => observable); | ||
} | ||
|
||
class SearchResult | ||
{ | ||
public SuggestionItem SuggestionItem { get; set; } | ||
} | ||
|
||
public string Prefix | ||
{ | ||
get { return "#"; } | ||
} | ||
|
||
class IssueAutoCompleteSuggestion : AutoCompleteSuggestion | ||
{ | ||
// Just needs to be some value before GitHub stored its first issue. | ||
static readonly DateTimeOffset lowerBound = new DateTimeOffset(2000, 1, 1, 12, 0, 0, TimeSpan.FromSeconds(0)); | ||
|
||
readonly SuggestionItem suggestion; | ||
public IssueAutoCompleteSuggestion(SuggestionItem suggestion, string prefix) | ||
: base(suggestion.Name, suggestion.Description, prefix) | ||
{ | ||
this.suggestion = suggestion; | ||
} | ||
|
||
public override int GetSortRank(string text) | ||
{ | ||
// We need to override the sort rank behavior because when we display issues, we include the prefix | ||
// unlike mentions. So we need to account for that in how we do filtering. | ||
if (text.Length == 0) | ||
{ | ||
return (int) ((suggestion.LastModifiedDate ?? lowerBound) - lowerBound).TotalSeconds; | ||
} | ||
// Name is always "#" followed by issue number. | ||
return Name.StartsWith("#" + text, StringComparison.OrdinalIgnoreCase) | ||
? 1 | ||
: DescriptionWords.Any(word => word.StartsWith(text, StringComparison.OrdinalIgnoreCase)) | ||
? 0 | ||
: -1; | ||
} | ||
|
||
// This is what gets "completed" when you tab. | ||
public override string ToString() | ||
{ | ||
return Name; | ||
} | ||
} | ||
} | ||
} |
Oops, something went wrong.