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

Commit 5354ea2

Browse files
Merge pull request #2156 from github/autocompletebox
Migrating and Updating AutoCompleteBox from *original* GitHub Desktop for Windows
2 parents 98a843b + 0b5badb commit 5354ea2

File tree

62 files changed

+5786
-120
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+5786
-120
lines changed
+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System;
2+
using GitHub.Extensions;
3+
using GitHub.Helpers;
4+
5+
namespace GitHub.Models
6+
{
7+
/// <summary>
8+
/// Represents a single auto completion suggestion (mentions, emojis, issues) in a generic format that can be
9+
/// easily cached.
10+
/// </summary>
11+
public class SuggestionItem
12+
{
13+
public SuggestionItem(string name, string description)
14+
{
15+
Guard.ArgumentNotEmptyString(name, "name");
16+
Guard.ArgumentNotEmptyString(description, "description");
17+
18+
Name = name;
19+
Description = description;
20+
}
21+
22+
public SuggestionItem(string name, string description, string imageUrl)
23+
{
24+
Guard.ArgumentNotEmptyString(name, "name");
25+
26+
Name = name;
27+
Description = description;
28+
ImageUrl = imageUrl;
29+
}
30+
31+
/// <summary>
32+
/// The name to display for this entry
33+
/// </summary>
34+
public string Name { get; set; }
35+
36+
/// <summary>
37+
/// Additional details about the entry
38+
/// </summary>
39+
public string Description { get; set; }
40+
41+
/// <summary>
42+
/// An image url for this entry
43+
/// </summary>
44+
public string ImageUrl { get; set; }
45+
46+
/// <summary>
47+
/// The date this suggestion was last modified according to the API.
48+
/// </summary>
49+
public DateTimeOffset? LastModifiedDate { get; set; }
50+
}
51+
}

src/GitHub.App/SampleData/CommentViewModelDesigner.cs

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Reactive;
44
using System.Threading.Tasks;
55
using GitHub.Models;
6+
using GitHub.Services;
67
using GitHub.ViewModels;
78
using ReactiveUI;
89

@@ -37,6 +38,7 @@ public CommentViewModelDesigner()
3738
public ReactiveCommand<Unit, Unit> CommitEdit { get; }
3839
public ReactiveCommand<Unit, Unit> OpenOnGitHub { get; } = ReactiveCommand.Create(() => { });
3940
public ReactiveCommand<Unit, Unit> Delete { get; }
41+
public IAutoCompleteAdvisor AutoCompleteAdvisor { get; }
4042

4143
public Task InitializeAsync(ICommentThreadViewModel thread, ActorModel currentUser, CommentModel comment, CommentEditState state)
4244
{

src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Reactive;
55
using System.Threading.Tasks;
66
using GitHub.Models;
7+
using GitHub.Services;
78
using GitHub.Validation;
89
using GitHub.ViewModels.GitHubPane;
910
using ReactiveUI;
@@ -53,6 +54,7 @@ public PullRequestCreationViewModelDesigner()
5354
public string PRTitle { get; set; }
5455

5556
public ReactivePropertyValidator TitleValidator { get; }
57+
public IAutoCompleteAdvisor AutoCompleteAdvisor { get; }
5658

5759
public ReactivePropertyValidator BranchValidator { get; }
5860

src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Reactive;
44
using System.Threading.Tasks;
55
using GitHub.Models;
6+
using GitHub.Services;
67
using GitHub.ViewModels.GitHubPane;
78
using ReactiveUI;
89

@@ -53,6 +54,7 @@ public PullRequestReviewAuthoringViewModelDesigner()
5354
public ReactiveCommand<Unit, Unit> Comment { get; }
5455
public ReactiveCommand<Unit, Unit> RequestChanges { get; }
5556
public ReactiveCommand<Unit, Unit> Cancel { get; }
57+
public IAutoCompleteAdvisor AutoCompleteAdvisor { get; }
5658

5759
public Task InitializeAsync(
5860
LocalRepositoryModel localRepository,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Collections.ObjectModel;
4+
using System.ComponentModel.Composition;
5+
using System.Diagnostics;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.Globalization;
8+
using System.Linq;
9+
using System.Reactive.Linq;
10+
using GitHub.Extensions;
11+
using GitHub.Logging;
12+
using GitHub.Models;
13+
using Serilog;
14+
15+
namespace GitHub.Services
16+
{
17+
[Export(typeof(IAutoCompleteAdvisor))]
18+
[PartCreationPolicy(CreationPolicy.Shared)]
19+
public class AutoCompleteAdvisor : IAutoCompleteAdvisor
20+
{
21+
const int SuggestionCount = 5; // The number of suggestions we'll provide. github.com does 5.
22+
23+
static readonly ILogger log = LogManager.ForContext<AutoCompleteAdvisor>();
24+
readonly Lazy<Dictionary<string, IAutoCompleteSource>> prefixSourceMap;
25+
26+
[ImportingConstructor]
27+
public AutoCompleteAdvisor([ImportMany(typeof(IAutoCompleteSource))]IEnumerable<IAutoCompleteSource> autocompleteSources)
28+
{
29+
prefixSourceMap = new Lazy<Dictionary<string, IAutoCompleteSource>>(
30+
() => autocompleteSources.ToDictionary(s => s.Prefix, s => s));
31+
}
32+
33+
public IObservable<AutoCompleteResult> GetAutoCompletionSuggestions(string text, int caretPosition)
34+
{
35+
Guard.ArgumentNotNull("text", text);
36+
37+
if (caretPosition < 0 || caretPosition > text.Length)
38+
{
39+
string error = String.Format(CultureInfo.InvariantCulture,
40+
"The CaretPosition '{0}', is not in the range of '0' and the text length '{1}' for the text '{2}'",
41+
caretPosition,
42+
text.Length,
43+
text);
44+
45+
// We need to be alerted when this happens because it should never happen.
46+
// But it apparently did happen in production.
47+
Debug.Fail(error);
48+
log.Error(error);
49+
return Observable.Empty<AutoCompleteResult>();
50+
}
51+
var tokenAndSource = PrefixSourceMap
52+
.Select(kvp => new {Source = kvp.Value, Token = ParseAutoCompletionToken(text, caretPosition, kvp.Key)})
53+
.FirstOrDefault(s => s.Token != null);
54+
55+
if (tokenAndSource == null)
56+
{
57+
return Observable.Return(AutoCompleteResult.Empty);
58+
}
59+
60+
return tokenAndSource.Source.GetSuggestions()
61+
.Select(suggestion => new
62+
{
63+
suggestion,
64+
rank = suggestion.GetSortRank(tokenAndSource.Token.SearchSearchPrefix)
65+
})
66+
.Where(suggestion => suggestion.rank > -1)
67+
.ToList()
68+
.Select(suggestions => suggestions
69+
.OrderByDescending(s => s.rank)
70+
.ThenBy(s => s.suggestion.Name)
71+
.Take(SuggestionCount)
72+
.Select(s => s.suggestion)
73+
.ToList())
74+
.Select(suggestions => new AutoCompleteResult(tokenAndSource.Token.Offset,
75+
new ReadOnlyCollection<AutoCompleteSuggestion>(suggestions)))
76+
.Catch<AutoCompleteResult, Exception>(e =>
77+
{
78+
log.Error(e, "Error Getting AutoCompleteResult");
79+
return Observable.Return(AutoCompleteResult.Empty);
80+
});
81+
}
82+
83+
[SuppressMessage("Microsoft.Usage", "CA2233:OperationsShouldNotOverflow", MessageId = "caretPosition-1"
84+
, Justification = "We ensure the argument is greater than -1 so it can't overflow")]
85+
public static AutoCompletionToken ParseAutoCompletionToken(string text, int caretPosition, string triggerPrefix)
86+
{
87+
Guard.ArgumentNotNull("text", text);
88+
Guard.ArgumentInRange(caretPosition, 0, text.Length, "caretPosition");
89+
if (caretPosition == 0 || text.Length == 0) return null;
90+
91+
// :th : 1
92+
//:th : 0
93+
//Hi :th : 3
94+
int beginningOfWord = text.LastIndexOfAny(new[] { ' ', '\n' }, caretPosition - 1) + 1;
95+
string word = text.Substring(beginningOfWord, caretPosition - beginningOfWord);
96+
if (!word.StartsWith(triggerPrefix, StringComparison.Ordinal)) return null;
97+
98+
return new AutoCompletionToken(word.Substring(1), beginningOfWord);
99+
}
100+
101+
Dictionary<string, IAutoCompleteSource> PrefixSourceMap { get { return prefixSourceMap.Value; } }
102+
}
103+
104+
public class AutoCompletionToken
105+
{
106+
public AutoCompletionToken(string searchPrefix, int offset)
107+
{
108+
Guard.ArgumentNotNull(searchPrefix, "searchPrefix");
109+
Guard.ArgumentNonNegative(offset, "offset");
110+
111+
SearchSearchPrefix = searchPrefix;
112+
Offset = offset;
113+
}
114+
115+
/// <summary>
116+
/// Used to filter the list of auto complete suggestions to what the user has typed in.
117+
/// </summary>
118+
public string SearchSearchPrefix { get; private set; }
119+
public int Offset { get; private set; }
120+
}
121+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using System;
2+
using GitHub.Models;
3+
4+
namespace GitHub.Services
5+
{
6+
public interface IAutoCompleteSource
7+
{
8+
IObservable<AutoCompleteSuggestion> GetSuggestions();
9+
10+
// The prefix used to trigger auto completion.
11+
string Prefix { get; }
12+
}
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.ComponentModel.Composition;
4+
using System.Linq;
5+
using System.Reactive.Linq;
6+
using GitHub.Api;
7+
using GitHub.Extensions;
8+
using GitHub.Models;
9+
using GitHub.Primitives;
10+
using Octokit.GraphQL;
11+
using Octokit.GraphQL.Model;
12+
using static Octokit.GraphQL.Variable;
13+
14+
namespace GitHub.Services
15+
{
16+
[Export(typeof(IAutoCompleteSource))]
17+
[PartCreationPolicy(CreationPolicy.Shared)]
18+
public class IssuesAutoCompleteSource : IAutoCompleteSource
19+
{
20+
readonly ITeamExplorerContext teamExplorerContext;
21+
readonly IGraphQLClientFactory graphqlFactory;
22+
ICompiledQuery<Page<SuggestionItem>> query;
23+
24+
[ImportingConstructor]
25+
public IssuesAutoCompleteSource(ITeamExplorerContext teamExplorerContext, IGraphQLClientFactory graphqlFactory)
26+
{
27+
Guard.ArgumentNotNull(teamExplorerContext, nameof(teamExplorerContext));
28+
Guard.ArgumentNotNull(graphqlFactory, nameof(graphqlFactory));
29+
30+
this.teamExplorerContext = teamExplorerContext;
31+
this.graphqlFactory = graphqlFactory;
32+
}
33+
34+
public IObservable<AutoCompleteSuggestion> GetSuggestions()
35+
{
36+
var localRepositoryModel = teamExplorerContext.ActiveRepository;
37+
38+
var hostAddress = HostAddress.Create(localRepositoryModel.CloneUrl.Host);
39+
var owner = localRepositoryModel.Owner;
40+
var name = localRepositoryModel.Name;
41+
42+
string filter;
43+
string after;
44+
45+
if (query == null)
46+
{
47+
query = new Query().Search(query: Var(nameof(filter)), SearchType.Issue, 100, after: Var(nameof(after)))
48+
.Select(item => new Page<SuggestionItem>
49+
{
50+
Items = item.Nodes.Select(searchResultItem =>
51+
searchResultItem.Switch<SuggestionItem>(selector => selector
52+
.Issue(i => new SuggestionItem("#" + i.Number, i.Title) { LastModifiedDate = i.LastEditedAt })
53+
.PullRequest(p => new SuggestionItem("#" + p.Number, p.Title) { LastModifiedDate = p.LastEditedAt }))
54+
).ToList(),
55+
EndCursor = item.PageInfo.EndCursor,
56+
HasNextPage = item.PageInfo.HasNextPage,
57+
TotalCount = item.IssueCount
58+
})
59+
.Compile();
60+
}
61+
62+
filter = $"repo:{owner}/{name}";
63+
64+
return Observable.FromAsync(async () =>
65+
{
66+
var results = new List<SuggestionItem>();
67+
68+
var variables = new Dictionary<string, object>
69+
{
70+
{nameof(filter), filter },
71+
};
72+
73+
var connection = await graphqlFactory.CreateConnection(hostAddress);
74+
var searchResults = await connection.Run(query, variables);
75+
76+
results.AddRange(searchResults.Items);
77+
78+
while (searchResults.HasNextPage)
79+
{
80+
variables[nameof(after)] = searchResults.EndCursor;
81+
searchResults = await connection.Run(query, variables);
82+
83+
results.AddRange(searchResults.Items);
84+
}
85+
86+
return results.Select(item => new IssueAutoCompleteSuggestion(item, Prefix));
87+
88+
}).SelectMany(observable => observable);
89+
}
90+
91+
class SearchResult
92+
{
93+
public SuggestionItem SuggestionItem { get; set; }
94+
}
95+
96+
public string Prefix
97+
{
98+
get { return "#"; }
99+
}
100+
101+
class IssueAutoCompleteSuggestion : AutoCompleteSuggestion
102+
{
103+
// Just needs to be some value before GitHub stored its first issue.
104+
static readonly DateTimeOffset lowerBound = new DateTimeOffset(2000, 1, 1, 12, 0, 0, TimeSpan.FromSeconds(0));
105+
106+
readonly SuggestionItem suggestion;
107+
public IssueAutoCompleteSuggestion(SuggestionItem suggestion, string prefix)
108+
: base(suggestion.Name, suggestion.Description, prefix)
109+
{
110+
this.suggestion = suggestion;
111+
}
112+
113+
public override int GetSortRank(string text)
114+
{
115+
// We need to override the sort rank behavior because when we display issues, we include the prefix
116+
// unlike mentions. So we need to account for that in how we do filtering.
117+
if (text.Length == 0)
118+
{
119+
return (int) ((suggestion.LastModifiedDate ?? lowerBound) - lowerBound).TotalSeconds;
120+
}
121+
// Name is always "#" followed by issue number.
122+
return Name.StartsWith("#" + text, StringComparison.OrdinalIgnoreCase)
123+
? 1
124+
: DescriptionWords.Any(word => word.StartsWith(text, StringComparison.OrdinalIgnoreCase))
125+
? 0
126+
: -1;
127+
}
128+
129+
// This is what gets "completed" when you tab.
130+
public override string ToString()
131+
{
132+
return Name;
133+
}
134+
}
135+
}
136+
}

0 commit comments

Comments
 (0)