diff --git a/src/GitHub.App/Models/SuggestionItem.cs b/src/GitHub.App/Models/SuggestionItem.cs
new file mode 100644
index 0000000000..713e0357e9
--- /dev/null
+++ b/src/GitHub.App/Models/SuggestionItem.cs
@@ -0,0 +1,51 @@
+using System;
+using GitHub.Extensions;
+using GitHub.Helpers;
+
+namespace GitHub.Models
+{
+ ///
+ /// Represents a single auto completion suggestion (mentions, emojis, issues) in a generic format that can be
+ /// easily cached.
+ ///
+ 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;
+ }
+
+ ///
+ /// The name to display for this entry
+ ///
+ public string Name { get; set; }
+
+ ///
+ /// Additional details about the entry
+ ///
+ public string Description { get; set; }
+
+ ///
+ /// An image url for this entry
+ ///
+ public string ImageUrl { get; set; }
+
+ ///
+ /// The date this suggestion was last modified according to the API.
+ ///
+ public DateTimeOffset? LastModifiedDate { get; set; }
+ }
+}
diff --git a/src/GitHub.App/SampleData/CommentViewModelDesigner.cs b/src/GitHub.App/SampleData/CommentViewModelDesigner.cs
index b060088de9..132fe03568 100644
--- a/src/GitHub.App/SampleData/CommentViewModelDesigner.cs
+++ b/src/GitHub.App/SampleData/CommentViewModelDesigner.cs
@@ -3,6 +3,7 @@
using System.Reactive;
using System.Threading.Tasks;
using GitHub.Models;
+using GitHub.Services;
using GitHub.ViewModels;
using ReactiveUI;
@@ -37,6 +38,7 @@ public CommentViewModelDesigner()
public ReactiveCommand CommitEdit { get; }
public ReactiveCommand OpenOnGitHub { get; } = ReactiveCommand.Create(() => { });
public ReactiveCommand Delete { get; }
+ public IAutoCompleteAdvisor AutoCompleteAdvisor { get; }
public Task InitializeAsync(ICommentThreadViewModel thread, ActorModel currentUser, CommentModel comment, CommentEditState state)
{
diff --git a/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs
index 32922438d8..77edc7ab89 100644
--- a/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs
+++ b/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs
@@ -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;
@@ -53,6 +54,7 @@ public PullRequestCreationViewModelDesigner()
public string PRTitle { get; set; }
public ReactivePropertyValidator TitleValidator { get; }
+ public IAutoCompleteAdvisor AutoCompleteAdvisor { get; }
public ReactivePropertyValidator BranchValidator { get; }
diff --git a/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs
index cfc8eb23e4..9d0c118cbb 100644
--- a/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs
+++ b/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs
@@ -3,6 +3,7 @@
using System.Reactive;
using System.Threading.Tasks;
using GitHub.Models;
+using GitHub.Services;
using GitHub.ViewModels.GitHubPane;
using ReactiveUI;
@@ -53,6 +54,7 @@ public PullRequestReviewAuthoringViewModelDesigner()
public ReactiveCommand Comment { get; }
public ReactiveCommand RequestChanges { get; }
public ReactiveCommand Cancel { get; }
+ public IAutoCompleteAdvisor AutoCompleteAdvisor { get; }
public Task InitializeAsync(
LocalRepositoryModel localRepository,
diff --git a/src/GitHub.App/Services/AutoCompleteAdvisor.cs b/src/GitHub.App/Services/AutoCompleteAdvisor.cs
new file mode 100644
index 0000000000..569a87b526
--- /dev/null
+++ b/src/GitHub.App/Services/AutoCompleteAdvisor.cs
@@ -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();
+ readonly Lazy> prefixSourceMap;
+
+ [ImportingConstructor]
+ public AutoCompleteAdvisor([ImportMany(typeof(IAutoCompleteSource))]IEnumerable autocompleteSources)
+ {
+ prefixSourceMap = new Lazy>(
+ () => autocompleteSources.ToDictionary(s => s.Prefix, s => s));
+ }
+
+ public IObservable 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();
+ }
+ 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(suggestions)))
+ .Catch(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 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;
+ }
+
+ ///
+ /// Used to filter the list of auto complete suggestions to what the user has typed in.
+ ///
+ public string SearchSearchPrefix { get; private set; }
+ public int Offset { get; private set; }
+ }
+}
diff --git a/src/GitHub.App/Services/IAutoCompleteSource.cs b/src/GitHub.App/Services/IAutoCompleteSource.cs
new file mode 100644
index 0000000000..09b77c4cc3
--- /dev/null
+++ b/src/GitHub.App/Services/IAutoCompleteSource.cs
@@ -0,0 +1,13 @@
+using System;
+using GitHub.Models;
+
+namespace GitHub.Services
+{
+ public interface IAutoCompleteSource
+ {
+ IObservable GetSuggestions();
+
+ // The prefix used to trigger auto completion.
+ string Prefix { get; }
+ }
+}
diff --git a/src/GitHub.App/Services/IssuesAutoCompleteSource.cs b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs
new file mode 100644
index 0000000000..d2c64e671c
--- /dev/null
+++ b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs
@@ -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> 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 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
+ {
+ Items = item.Nodes.Select(searchResultItem =>
+ searchResultItem.Switch(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();
+
+ var variables = new Dictionary
+ {
+ {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;
+ }
+ }
+ }
+}
diff --git a/src/GitHub.App/Services/MentionsAutoCompleteSource.cs b/src/GitHub.App/Services/MentionsAutoCompleteSource.cs
new file mode 100644
index 0000000000..68fc002292
--- /dev/null
+++ b/src/GitHub.App/Services/MentionsAutoCompleteSource.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.Composition;
+using System.Linq;
+using System.Reactive.Linq;
+using System.Windows.Media.Imaging;
+using GitHub.Api;
+using GitHub.Extensions;
+using GitHub.Models;
+using GitHub.Primitives;
+using Octokit.GraphQL;
+using static Octokit.GraphQL.Variable;
+
+namespace GitHub.Services
+{
+ ///
+ /// Supplies @mentions auto complete suggestions.
+ ///
+ [Export(typeof(IAutoCompleteSource))]
+ [PartCreationPolicy(CreationPolicy.Shared)]
+ public class MentionsAutoCompleteSource : IAutoCompleteSource
+ {
+ const string DefaultAvatar = "pack://application:,,,/GitHub.App;component/Images/default_user_avatar.png";
+
+ readonly ITeamExplorerContext teamExplorerContext;
+ readonly IGraphQLClientFactory graphqlFactory;
+ readonly IAvatarProvider avatarProvider;
+ ICompiledQuery> query;
+
+ [ImportingConstructor]
+ public MentionsAutoCompleteSource(
+ ITeamExplorerContext teamExplorerContext,
+ IGraphQLClientFactory graphqlFactory,
+ IAvatarProvider avatarProvider)
+ {
+ Guard.ArgumentNotNull(teamExplorerContext, nameof(teamExplorerContext));
+ Guard.ArgumentNotNull(graphqlFactory, nameof(graphqlFactory));
+ Guard.ArgumentNotNull(avatarProvider, nameof(avatarProvider));
+
+ this.teamExplorerContext = teamExplorerContext;
+ this.graphqlFactory = graphqlFactory;
+ this.avatarProvider = avatarProvider;
+ }
+
+ public IObservable GetSuggestions()
+ {
+ var localRepositoryModel = teamExplorerContext.ActiveRepository;
+
+ var hostAddress = HostAddress.Create(localRepositoryModel.CloneUrl.Host);
+ var owner = localRepositoryModel.Owner;
+ var name = localRepositoryModel.Name;
+
+ if (query == null)
+ {
+ query = new Query().Repository(owner: Var(nameof(owner)), name: Var(nameof(name)))
+ .Select(repository =>
+ repository.MentionableUsers(null, null, null, null)
+ .AllPages()
+ .Select(sourceItem =>
+ new SuggestionItem(sourceItem.Login,
+ sourceItem.Name ?? "(unknown)",
+ sourceItem.AvatarUrl(null)))
+ .ToList())
+ .Compile();
+ }
+
+ var variables = new Dictionary
+ {
+ {nameof(owner), owner },
+ {nameof(name), name },
+ };
+
+ return Observable.FromAsync(async () =>
+ {
+ var connection = await graphqlFactory.CreateConnection(hostAddress);
+ var suggestions = await connection.Run(query, variables);
+ return suggestions.Select(suggestion => new AutoCompleteSuggestion(suggestion.Name,
+ suggestion.Description,
+ ResolveImage(suggestion),
+ Prefix));
+ }).SelectMany(enumerable => enumerable);
+ }
+
+ IObservable ResolveImage(SuggestionItem uri)
+ {
+ if (uri.ImageUrl != null)
+ {
+ return avatarProvider.GetAvatar(uri.ImageUrl);
+ }
+
+ return Observable.Return(AvatarProvider.CreateBitmapImage(DefaultAvatar));
+ }
+
+ public string Prefix => "@";
+ }
+}
diff --git a/src/GitHub.App/ViewModels/CommentViewModel.cs b/src/GitHub.App/ViewModels/CommentViewModel.cs
index b8a640742b..0a750f08a4 100644
--- a/src/GitHub.App/ViewModels/CommentViewModel.cs
+++ b/src/GitHub.App/ViewModels/CommentViewModel.cs
@@ -41,11 +41,14 @@ public class CommentViewModel : ViewModelBase, ICommentViewModel
/// Initializes a new instance of the class.
///
/// The comment service.
+ /// The auto complete advisor.
[ImportingConstructor]
- public CommentViewModel(ICommentService commentService)
+ public CommentViewModel(ICommentService commentService, IAutoCompleteAdvisor autoCompleteAdvisor)
{
Guard.ArgumentNotNull(commentService, nameof(commentService));
+ Guard.ArgumentNotNull(autoCompleteAdvisor, nameof(autoCompleteAdvisor));
+ AutoCompleteAdvisor = autoCompleteAdvisor;
this.commentService = commentService;
var canDeleteObservable = this.WhenAnyValue(
@@ -190,6 +193,9 @@ public ICommentThreadViewModel Thread
///
public ReactiveCommand Delete { get; }
+ ///
+ public IAutoCompleteAdvisor AutoCompleteAdvisor { get; }
+
///
public Task InitializeAsync(
ICommentThreadViewModel thread,
diff --git a/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs b/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs
index 184406eb1a..44db887114 100644
--- a/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs
+++ b/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs
@@ -23,9 +23,10 @@ public sealed class IssueishCommentViewModel : CommentViewModel, IIssueishCommen
/// Initializes a new instance of the class.
///
/// The comment service.
+ ///
[ImportingConstructor]
- public IssueishCommentViewModel(ICommentService commentService)
- : base(commentService)
+ public IssueishCommentViewModel(ICommentService commentService, IAutoCompleteAdvisor autoCompleteAdvisor)
+ : base(commentService, autoCompleteAdvisor)
{
CloseOrReopen = ReactiveCommand.CreateFromTask(
DoCloseOrReopen,
diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs
index 89f8e37c50..bd25c85c52 100644
--- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs
+++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs
@@ -18,6 +18,7 @@
using GitHub.Models.Drafts;
using GitHub.Primitives;
using GitHub.Services;
+using GitHub.UI;
using GitHub.Validation;
using Octokit;
using ReactiveUI;
@@ -51,8 +52,9 @@ public PullRequestCreationViewModel(
IPullRequestService service,
INotificationService notifications,
IMessageDraftStore draftStore,
- IGitService gitService)
- : this(modelServiceFactory, service, notifications, draftStore, gitService, DefaultScheduler.Instance)
+ IGitService gitService,
+ IAutoCompleteAdvisor autoCompleteAdvisor)
+ : this(modelServiceFactory, service, notifications, draftStore, gitService, autoCompleteAdvisor, DefaultScheduler.Instance)
{
}
@@ -62,6 +64,7 @@ public PullRequestCreationViewModel(
INotificationService notifications,
IMessageDraftStore draftStore,
IGitService gitService,
+ IAutoCompleteAdvisor autoCompleteAdvisor,
IScheduler timerScheduler)
{
Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory));
@@ -69,12 +72,14 @@ public PullRequestCreationViewModel(
Guard.ArgumentNotNull(notifications, nameof(notifications));
Guard.ArgumentNotNull(draftStore, nameof(draftStore));
Guard.ArgumentNotNull(gitService, nameof(gitService));
+ Guard.ArgumentNotNull(autoCompleteAdvisor, nameof(autoCompleteAdvisor));
Guard.ArgumentNotNull(timerScheduler, nameof(timerScheduler));
this.service = service;
this.modelServiceFactory = modelServiceFactory;
this.draftStore = draftStore;
this.gitService = gitService;
+ this.AutoCompleteAdvisor = autoCompleteAdvisor;
this.timerScheduler = timerScheduler;
this.WhenAnyValue(x => x.Branches)
@@ -336,6 +341,7 @@ protected string GetDraftKey()
public RemoteRepositoryModel GitHubRepository { get { return githubRepository?.Value; } }
bool IsExecuting { get { return isExecuting.Value; } }
+ public IAutoCompleteAdvisor AutoCompleteAdvisor { get; }
bool initialized;
bool Initialized
diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs
index c263af4585..82ff54359c 100644
--- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs
+++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs
@@ -45,8 +45,9 @@ public PullRequestReviewAuthoringViewModel(
IPullRequestEditorService editorService,
IPullRequestSessionManager sessionManager,
IMessageDraftStore draftStore,
- IPullRequestFilesViewModel files)
- : this(pullRequestService, editorService, sessionManager,draftStore, files, DefaultScheduler.Instance)
+ IPullRequestFilesViewModel files,
+ IAutoCompleteAdvisor autoCompleteAdvisor)
+ : this(pullRequestService, editorService, sessionManager,draftStore, files, autoCompleteAdvisor, DefaultScheduler.Instance)
{
}
@@ -56,12 +57,14 @@ public PullRequestReviewAuthoringViewModel(
IPullRequestSessionManager sessionManager,
IMessageDraftStore draftStore,
IPullRequestFilesViewModel files,
+ IAutoCompleteAdvisor autoCompleteAdvisor,
IScheduler timerScheduler)
{
Guard.ArgumentNotNull(editorService, nameof(editorService));
Guard.ArgumentNotNull(sessionManager, nameof(sessionManager));
Guard.ArgumentNotNull(draftStore, nameof(draftStore));
Guard.ArgumentNotNull(files, nameof(files));
+ Guard.ArgumentNotNull(autoCompleteAdvisor, nameof(autoCompleteAdvisor));
Guard.ArgumentNotNull(timerScheduler, nameof(timerScheduler));
this.pullRequestService = pullRequestService;
@@ -77,6 +80,7 @@ public PullRequestReviewAuthoringViewModel(
.ToProperty(this, x => x.CanApproveRequestChanges);
Files = files;
+ AutoCompleteAdvisor = autoCompleteAdvisor;
var hasBodyOrComments = this.WhenAnyValue(
x => x.Body,
@@ -118,6 +122,9 @@ public PullRequestDetailModel PullRequestModel
///
public IPullRequestFilesViewModel Files { get; }
+ ///
+ public IAutoCompleteAdvisor AutoCompleteAdvisor { get; }
+
///
public string Body
{
diff --git a/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs b/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs
index 1d47330c88..4a2c1a8e97 100644
--- a/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs
+++ b/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs
@@ -25,10 +25,12 @@ public class PullRequestReviewCommentViewModel : CommentViewModel, IPullRequestR
///
/// Initializes a new instance of the class.
///
- /// The comment service
+ /// The comment service.
+ /// The auto complete advisor.
[ImportingConstructor]
- public PullRequestReviewCommentViewModel(ICommentService commentService)
- : base(commentService)
+ public PullRequestReviewCommentViewModel(ICommentService commentService,
+ IAutoCompleteAdvisor autoCompleteAdvisor)
+ : base(commentService, autoCompleteAdvisor)
{
canStartReview = this.WhenAnyValue(
x => x.IsPending,
diff --git a/src/GitHub.Exports.Reactive/Models/AutoCompleteResult.cs b/src/GitHub.Exports.Reactive/Models/AutoCompleteResult.cs
new file mode 100644
index 0000000000..c9461025f9
--- /dev/null
+++ b/src/GitHub.Exports.Reactive/Models/AutoCompleteResult.cs
@@ -0,0 +1,18 @@
+using System.Collections.Generic;
+
+namespace GitHub.Models
+{
+ public class AutoCompleteResult
+ {
+ public static AutoCompleteResult Empty = new AutoCompleteResult(0, new AutoCompleteSuggestion[] {});
+
+ public AutoCompleteResult(int offset, IReadOnlyList suggestions)
+ {
+ Offset = offset;
+ Suggestions = suggestions;
+ }
+
+ public int Offset { get; private set; }
+ public IReadOnlyList Suggestions { get; private set; }
+ }
+}
diff --git a/src/GitHub.Exports.Reactive/Models/AutoCompleteSuggestion.cs b/src/GitHub.Exports.Reactive/Models/AutoCompleteSuggestion.cs
new file mode 100644
index 0000000000..e00e67274e
--- /dev/null
+++ b/src/GitHub.Exports.Reactive/Models/AutoCompleteSuggestion.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reactive.Linq;
+using System.Windows.Media.Imaging;
+using GitHub.Extensions;
+using GitHub.Helpers;
+using ReactiveUI;
+
+namespace GitHub.Models
+{
+ public class AutoCompleteSuggestion
+ {
+ readonly string prefix;
+ readonly string suffix;
+ readonly string[] descriptionWords;
+
+ public AutoCompleteSuggestion(string name, string description, string prefix)
+ : this(name, description, Observable.Return(null), prefix)
+ {
+ }
+
+ public AutoCompleteSuggestion(string name, string description, IObservable image, string prefix)
+ : this(name, description, image, prefix, null)
+ {
+ }
+
+ public AutoCompleteSuggestion(string name, IObservable image, string prefix, string suffix)
+ : this(name, null, image, prefix, suffix)
+ {
+ }
+
+ public AutoCompleteSuggestion(string name, string description, IObservable image, string prefix, string suffix)
+ {
+ Guard.ArgumentNotEmptyString(name, "name");
+ Guard.ArgumentNotEmptyString(prefix, "prefix"); // Suggestions have to have a triggering prefix.
+ Guard.ArgumentNotNull(image, "image");
+
+ Name = name;
+ Description = description;
+ if (image != null)
+ {
+ image = image.ObserveOn(RxApp.MainThreadScheduler);
+ }
+ Image = image;
+
+ this.prefix = prefix;
+ this.suffix = suffix;
+
+ // This is pretty naive, but since the Description is currently limited to a user's FullName,
+ // This is fine. When we add #issue completion, we may need to fancy this up a bit.
+ descriptionWords = (description ?? String.Empty)
+ .Split(new[] {' '}, StringSplitOptions.RemoveEmptyEntries);
+ }
+
+ ///
+ /// The name to display in the autocomplete list box. This should not have the "@" or ":" characters around it.
+ ///
+ public string Name { get; private set; }
+
+ public string Description { get; private set; }
+
+ public IObservable Image { get; private set; }
+
+ protected IReadOnlyCollection DescriptionWords { get { return descriptionWords; } }
+
+ // What gets autocompleted.
+ public override string ToString()
+ {
+ return prefix + Name + suffix;
+ }
+
+ ///
+ /// Used to determine if the suggestion matches the text and if so, how it should be sorted. The larger the
+ /// rank, the higher it sorts.
+ ///
+ ///
+ /// For mentions we sort suggestions in the following order:
+ ///
+ /// 1. Login starts with text
+ /// 2. Component of Name starts with text (split name by spaces, then match each word)
+ ///
+ /// Non matches return -1. The secondary sort is by Login ascending.
+ ///
+ /// The suggestion text to match
+ /// -1 for non-match and the sort order described in the remarks for matches
+ public virtual int GetSortRank(string text)
+ {
+ Guard.ArgumentNotNull(text, "text");
+
+ return Name.StartsWith(text, StringComparison.OrdinalIgnoreCase)
+ ? 1
+ : descriptionWords.Any(word => word.StartsWith(text, StringComparison.OrdinalIgnoreCase))
+ ? 0
+ : -1;
+ }
+ }
+}
diff --git a/src/GitHub.Exports.Reactive/Services/IAutoCompleteAdvisor.cs b/src/GitHub.Exports.Reactive/Services/IAutoCompleteAdvisor.cs
new file mode 100644
index 0000000000..b071be1bff
--- /dev/null
+++ b/src/GitHub.Exports.Reactive/Services/IAutoCompleteAdvisor.cs
@@ -0,0 +1,10 @@
+using System;
+using GitHub.Models;
+
+namespace GitHub.Services
+{
+ public interface IAutoCompleteAdvisor
+ {
+ IObservable GetAutoCompletionSuggestions(string text, int caretPosition);
+ }
+}
diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs
index 444c5bd191..fb201f3ab7 100644
--- a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs
+++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs
@@ -4,6 +4,7 @@
using ReactiveUI;
using System.Threading.Tasks;
using System.Reactive;
+using GitHub.Services;
namespace GitHub.ViewModels.GitHubPane
{
@@ -16,6 +17,7 @@ public interface IPullRequestCreationViewModel : IPanePageViewModel
ReactiveCommand Cancel { get; }
string PRTitle { get; set; }
ReactivePropertyValidator TitleValidator { get; }
+ IAutoCompleteAdvisor AutoCompleteAdvisor { get; }
Task InitializeAsync(LocalRepositoryModel repository, IConnection connection);
}
diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs
index e0be2242d9..6d6137050a 100644
--- a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs
+++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs
@@ -3,6 +3,7 @@
using System.Reactive;
using System.Threading.Tasks;
using GitHub.Models;
+using GitHub.Services;
using ReactiveUI;
namespace GitHub.ViewModels.GitHubPane
@@ -87,6 +88,11 @@ public interface IPullRequestReviewAuthoringViewModel : IPanePageViewModel, IDis
///
ReactiveCommand Cancel { get; }
+ ///
+ /// Provides an AutoCompleteAdvisor.
+ ///
+ IAutoCompleteAdvisor AutoCompleteAdvisor { get; }
+
///
/// Initializes the view model for creating a new review.
///
diff --git a/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs
index ce35d8a3ff..be6d97e13b 100644
--- a/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs
+++ b/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs
@@ -1,5 +1,6 @@
using System;
using System.Reactive;
+using GitHub.Services;
using ReactiveUI;
namespace GitHub.ViewModels
@@ -119,5 +120,10 @@ public interface ICommentViewModel : IViewModel
/// Deletes a comment.
///
ReactiveCommand Delete { get; }
+
+ ///
+ /// Provides an AutoCompleteAdvisor.
+ ///
+ IAutoCompleteAdvisor AutoCompleteAdvisor { get; }
}
}
\ No newline at end of file
diff --git a/src/GitHub.Extensions/ReflectionExtensions.cs b/src/GitHub.Extensions/ReflectionExtensions.cs
index e69c65f1d5..df10dba2f4 100644
--- a/src/GitHub.Extensions/ReflectionExtensions.cs
+++ b/src/GitHub.Extensions/ReflectionExtensions.cs
@@ -4,6 +4,7 @@
using System.Globalization;
using System.Linq;
using System.Reflection;
+using System.Runtime.Serialization;
namespace GitHub.Extensions
{
@@ -53,5 +54,20 @@ public static string GetCustomAttributeValue(this Assembly assembly, string p
var value = propertyInfo.GetValue(attribute, null);
return value.ToString();
}
+
+ public static T CreateUninitialized()
+ {
+ // WARNING: THIS METHOD IS PURE EVIL!
+ // Only use this in cases where T is sealed and has an internal ctor and
+ // you're SURE the API you're passing it into won't do anything interesting with it.
+ // Even then, consider refactoring.
+ return (T)FormatterServices.GetUninitializedObject(typeof(T));
+ }
+
+ public static void Invoke(object obj, string methodName, params object[] parameters)
+ {
+ var method = obj.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
+ method.Invoke(obj, parameters);
+ }
}
}
diff --git a/src/GitHub.UI/Assets/Controls.xaml b/src/GitHub.UI/Assets/Controls.xaml
index dccf59c145..0f7c550f0f 100644
--- a/src/GitHub.UI/Assets/Controls.xaml
+++ b/src/GitHub.UI/Assets/Controls.xaml
@@ -6,6 +6,7 @@
mc:Ignorable="d">
+
diff --git a/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml b/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml
new file mode 100644
index 0000000000..6d10b00ade
--- /dev/null
+++ b/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml
@@ -0,0 +1,252 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs
new file mode 100644
index 0000000000..013c3e40e9
--- /dev/null
+++ b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs
@@ -0,0 +1,1616 @@
+// (c) Copyright Microsoft Corporation.
+// (c) Copyright GitHub, Inc.
+// This source is subject to the Microsoft Public License (Ms-PL).
+// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details.
+// All other rights reserved.
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Linq;
+using System.Reactive;
+using System.Reactive.Linq;
+using System.Reactive.Subjects;
+using System.Windows;
+using System.Windows.Automation.Peers;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Input;
+using System.Windows.Markup;
+using System.Windows.Media;
+using GitHub.Extensions;
+using GitHub.Helpers;
+using GitHub.Models;
+using GitHub.Services;
+using GitHub.UI.Controls;
+using GitHub.UI.Controls.AutoCompleteBox;
+using GitHub.UI.Helpers;
+using ReactiveUI;
+using Control = System.Windows.Controls.Control;
+using KeyEventArgs = System.Windows.Input.KeyEventArgs;
+
+namespace GitHub.UI
+{
+ ///
+ /// Represents a control that provides a text box for user input and a
+ /// drop-down that contains possible matches based on the input in the text
+ /// box.
+ ///
+ [SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable",
+ Justification = "It's a control. It'll be disposed when the app shuts down.")]
+ [SuppressMessage("Microsoft.Maintainability", "CA1506:AvoidExcessiveClassCoupling",
+ Justification = "Large implementation keeps the components contained.")]
+ [ContentProperty("ItemsSource")]
+ public class AutoCompleteBox : Control, IUpdateVisualState, IPopupTarget
+ {
+ private const string elementSelector = "Selector";
+ private const string elementPopup = "Popup";
+ private const string elementTextBoxStyle = "TextBoxStyle";
+ private const string elementItemContainerStyle = "ItemContainerStyle";
+
+ private readonly IDictionary eventSubscriptions = new Dictionary();
+ private List