Skip to content
This repository has been archived by the owner on Jun 21, 2023. It is now read-only.

Commit

Permalink
Merge pull request #2156 from github/autocompletebox
Browse files Browse the repository at this point in the history
Migrating and Updating AutoCompleteBox from *original* GitHub Desktop for Windows
  • Loading branch information
StanleyGoldman authored Apr 11, 2019
2 parents 98a843b + 0b5badb commit 5354ea2
Show file tree
Hide file tree
Showing 62 changed files with 5,786 additions and 120 deletions.
51 changes: 51 additions & 0 deletions src/GitHub.App/Models/SuggestionItem.cs
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; }
}
}
2 changes: 2 additions & 0 deletions src/GitHub.App/SampleData/CommentViewModelDesigner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Reactive;
using System.Threading.Tasks;
using GitHub.Models;
using GitHub.Services;
using GitHub.ViewModels;
using ReactiveUI;

Expand Down Expand Up @@ -37,6 +38,7 @@ public CommentViewModelDesigner()
public ReactiveCommand<Unit, Unit> CommitEdit { get; }
public ReactiveCommand<Unit, Unit> OpenOnGitHub { get; } = ReactiveCommand.Create(() => { });
public ReactiveCommand<Unit, Unit> Delete { get; }
public IAutoCompleteAdvisor AutoCompleteAdvisor { get; }

public Task InitializeAsync(ICommentThreadViewModel thread, ActorModel currentUser, CommentModel comment, CommentEditState state)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Reactive;
using System.Threading.Tasks;
using GitHub.Models;
using GitHub.Services;
using GitHub.Validation;
using GitHub.ViewModels.GitHubPane;
using ReactiveUI;
Expand Down Expand Up @@ -53,6 +54,7 @@ public PullRequestCreationViewModelDesigner()
public string PRTitle { get; set; }

public ReactivePropertyValidator TitleValidator { get; }
public IAutoCompleteAdvisor AutoCompleteAdvisor { get; }

public ReactivePropertyValidator BranchValidator { get; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Reactive;
using System.Threading.Tasks;
using GitHub.Models;
using GitHub.Services;
using GitHub.ViewModels.GitHubPane;
using ReactiveUI;

Expand Down Expand Up @@ -53,6 +54,7 @@ public PullRequestReviewAuthoringViewModelDesigner()
public ReactiveCommand<Unit, Unit> Comment { get; }
public ReactiveCommand<Unit, Unit> RequestChanges { get; }
public ReactiveCommand<Unit, Unit> Cancel { get; }
public IAutoCompleteAdvisor AutoCompleteAdvisor { get; }

public Task InitializeAsync(
LocalRepositoryModel localRepository,
Expand Down
121 changes: 121 additions & 0 deletions src/GitHub.App/Services/AutoCompleteAdvisor.cs
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; }
}
}
13 changes: 13 additions & 0 deletions src/GitHub.App/Services/IAutoCompleteSource.cs
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; }
}
}
136 changes: 136 additions & 0 deletions src/GitHub.App/Services/IssuesAutoCompleteSource.cs
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;
}
}
}
}
Loading

0 comments on commit 5354ea2

Please sign in to comment.