From 13ecbb283f97a18ddd0bf53f90fd240376a0f76d Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Wed, 2 Jan 2019 12:07:00 -0500 Subject: [PATCH 01/44] Pulling the AutoCompleteBox from the original GitHub Desktop for Windows --- .../AutoCompleteBox/AutoCompleteBox.cs | 1586 +++++++++++++++++ .../AutoCompleteBoxAutomationPeer.cs | 300 ++++ .../AutoCompleteBox/AutoCompleteResult.cs | 18 + .../AutoCompleteBox/AutoCompleteSuggestion.cs | 98 + .../AutoCompleteTextInputExtensions.cs | 35 + .../AutoCompleteBox/IAutoCompleteAdvisor.cs | 9 + .../AutoCompleteBox/IAutoCompleteTextInput.cs | 28 + .../Controls/AutoCompleteBox/IPopupTarget.cs | 18 + .../AutoCompleteBox/ISelectionAdapter.cs | 73 + .../AutoCompleteBox/IUpdateVisualState.cs | 23 + .../AutoCompleteBox/InteractionHelper.cs | 158 ++ .../Controls/AutoCompleteBox/PopupHelper.cs | 280 +++ .../RichTextBoxAutoCompleteTextInput.cs | 200 +++ .../SelectorSelectionAdapter.cs | 354 ++++ .../TextBoxAutoCompleteTextInput.cs | 120 ++ .../Controls/AutoCompleteBox/VisualStates.cs | 409 +++++ .../AutoCompleteBox/VisualTreeExtensions.cs | 71 + .../AutoCompleteBox/WeakEventListener.cs | 84 + .../Controls/AutoCompleteBox/_README.md | 40 + src/GitHub.UI/GitHub.UI.csproj | 2 + src/GitHub.UI/Helpers/Dpi.cs | 65 + src/GitHub.UI/Helpers/DpiManager.cs | 21 + 22 files changed, 3992 insertions(+) create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBoxAutomationPeer.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteResult.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteSuggestion.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteTextInputExtensions.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteAdvisor.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteTextInput.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/IPopupTarget.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/ISelectionAdapter.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/IUpdateVisualState.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/InteractionHelper.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/PopupHelper.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/RichTextBoxAutoCompleteTextInput.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/SelectorSelectionAdapter.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/TextBoxAutoCompleteTextInput.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/VisualStates.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/VisualTreeExtensions.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/WeakEventListener.cs create mode 100644 src/GitHub.UI/Controls/AutoCompleteBox/_README.md create mode 100644 src/GitHub.UI/Helpers/Dpi.cs create mode 100644 src/GitHub.UI/Helpers/DpiManager.cs diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs new file mode 100644 index 0000000000..b0555d2e70 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs @@ -0,0 +1,1586 @@ +// (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.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 suggestions; // local cached copy of the items data. + + /// + // Gets or sets the observable collection that contains references to + // all of the items in the generated view of data that is provided to + /// the selection-style control adapter. + /// + private ObservableCollection view; + + /// + /// Gets or sets a value to ignore a number of pending change handlers. + /// The value is decremented after each use. This is used to reset the + /// value of properties without performing any of the actions in their + /// change handlers. + /// + /// The int is important as a value because the TextBox + /// TextChanged event does not immediately fire, and this will allow for + /// nested property changes to be ignored. + private int ignoreTextPropertyChange; + private bool ignorePropertyChange; // indicates whether to ignore calling pending change handlers. + private bool userCalledPopulate; // indicates whether the user initiated the current populate call. + private bool popupHasOpened; // A value indicating whether the popup has been opened at least once. + // Helper that provides all of the standard interaction functionality. Making it internal for subclass access. + internal InteractionHelper Interaction { get; set; } + // BindingEvaluator that provides updated string values from a single binding. + /// A weak event listener for the collection changed event. + private WeakEventListener collectionChangedWeakEventListener; + bool supportsShortcutOriginalValue; // Used to save whether the text input allows shortcuts or not. + readonly Subject populatingSubject = new Subject(); + readonly IDpiManager dpiManager; + + /// + /// Initializes a new instance of the + /// class. + /// + public AutoCompleteBox() : this(DpiManager.Instance) + { + } + + public AutoCompleteBox(IDpiManager dpiManager) + { + Guard.ArgumentNotNull(dpiManager, "dpiManager"); + + CompletionOffset = 0; + IsEnabledChanged += ControlIsEnabledChanged; + Interaction = new InteractionHelper(this); + + // Creating the view here ensures that View is always != null + ClearView(); + + Populating = populatingSubject; + + Populating + .SelectMany(_ => + { + var advisor = Advisor ?? EmptyAutoCompleteAdvisor.Instance; + return advisor.GetAutoCompletionSuggestions(Text, TextBox.CaretIndex); + }) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(result => + { + CompletionOffset = result.Offset; + ItemsSource = result.Suggestions; + PopulateComplete(); + }); + this.dpiManager = dpiManager; + } + + public IObservable Populating { get; private set; } + + public int CompletionOffset + { + get { return (int)GetValue(CompletionOffsetProperty); } + set { SetValue(CompletionOffsetProperty, value); } + } + + // Using a DependencyProperty as the backing store for CompletionOffset. This enables animation, styling, binding, etc... + public static readonly DependencyProperty CompletionOffsetProperty = + DependencyProperty.Register( + "CompletionOffset", + typeof(int), + typeof(AutoCompleteBox), + new PropertyMetadata(0)); + + public Point PopupPosition + { + get + { + var position = TextBox.GetPositionFromCharIndex(CompletionOffset); + var dpi = dpiManager.CurrentDpi; + double verticalOffset = 5.0 - TextBox.Margin.Bottom; + position.Offset(0, verticalOffset); // Vertically pad it. Yeah, Point is mutable. WTF? + return dpi.Scale(position); + } + } + + /// + /// Gets or sets the minimum delay, in milliseconds, after text is typed + /// in the text box before the + /// control + /// populates the list of possible matches in the drop-down. + /// + /// The minimum delay, in milliseconds, after text is typed in + /// the text box, but before the + /// populates + /// the list of possible matches in the drop-down. The default is 0. + /// The set value is less than 0. + public int MinimumPopulateDelay + { + get { return (int)GetValue(MinimumPopulateDelayProperty); } + set { SetValue(MinimumPopulateDelayProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty MinimumPopulateDelayProperty = + DependencyProperty.Register( + "MinimumPopulateDelay", + typeof(int), + typeof(AutoCompleteBox), + new PropertyMetadata(OnMinimumPopulateDelayPropertyChanged)); + + /// + /// MinimumPopulateDelayProperty property changed handler. Any current + /// dispatcher timer will be stopped. The timer will not be restarted + /// until the next TextUpdate call by the user. + /// + /// AutoCompleteTextBox that changed its + /// MinimumPopulateDelay. + /// Event arguments. + [SuppressMessage("Microsoft.Usage", "CA2208:InstantiateArgumentExceptionsCorrectly", + Justification = "The exception is most likely to be called through the CLR property setter.")] + private static void OnMinimumPopulateDelayPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var source = d as AutoCompleteBox; + + if (source == null) return; + + if (source.ignorePropertyChange) + { + source.ignorePropertyChange = false; + return; + } + + int newValue = (int)e.NewValue; + if (newValue < 0) + { + source.ignorePropertyChange = true; + d.SetValue(e.Property, e.OldValue); + + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, + "Invalid value '{0}' for MinimumPopulateDelay", newValue), "e"); + } + + // Resubscribe to TextBox changes with new delay. The easiest way is to just set the TextBox to itself. + var textBox = source.TextBox; + source.TextBox = null; + source.TextBox = textBox; + } + + /// + /// Gets or sets the used + /// to display each item in the drop-down portion of the control. + /// + /// The used to + /// display each item in the drop-down. The default is null. + /// + /// You use the ItemTemplate property to specify the visualization + /// of the data objects in the drop-down portion of the AutoCompleteBox + /// control. If your AutoCompleteBox is bound to a collection and you + /// do not provide specific display instructions by using a + /// DataTemplate, the resulting UI of each item is a string + /// representation of each object in the underlying collection. + /// + public DataTemplate ItemTemplate + { + get { return GetValue(ItemTemplateProperty) as DataTemplate; } + set { SetValue(ItemTemplateProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty ItemTemplateProperty = + DependencyProperty.Register( + "ItemTemplate", + typeof(DataTemplate), + typeof(AutoCompleteBox), + new PropertyMetadata(null)); + + /// + /// Gets or sets the that is + /// applied to the selection adapter contained in the drop-down portion + /// of the + /// control. + /// + /// The applied to the + /// selection adapter contained in the drop-down portion of the + /// control. + /// The default is null. + /// + /// The default selection adapter contained in the drop-down is a + /// ListBox control. + /// + public Style ItemContainerStyle + { + get { return GetValue(ItemContainerStyleProperty) as Style; } + set { SetValue(ItemContainerStyleProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty ItemContainerStyleProperty = + DependencyProperty.Register( + elementItemContainerStyle, + typeof(Style), + typeof(AutoCompleteBox), + new PropertyMetadata(null, null)); + + /// + /// Gets or sets the applied to + /// the text box portion of the + /// control. + /// + /// The applied to the text + /// box portion of the + /// control. + /// The default is null. + public Style TextBoxStyle + { + get { return GetValue(TextBoxStyleProperty) as Style; } + set { SetValue(TextBoxStyleProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty TextBoxStyleProperty = + DependencyProperty.Register( + elementTextBoxStyle, + typeof(Style), + typeof(AutoCompleteBox), + new PropertyMetadata(null)); + + /// + /// Gets or sets the maximum height of the drop-down portion of the + /// control. + /// + /// The maximum height of the drop-down portion of the + /// control. + /// The default is . + /// The specified value is less than 0. + public double MaxDropDownHeight + { + get { return (double)GetValue(MaxDropDownHeightProperty); } + set { SetValue(MaxDropDownHeightProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty MaxDropDownHeightProperty = + DependencyProperty.Register( + "MaxDropDownHeight", + typeof(double), + typeof(AutoCompleteBox), + new PropertyMetadata(double.PositiveInfinity, OnMaxDropDownHeightPropertyChanged)); + + /// + /// MaxDropDownHeightProperty property changed handler. + /// + /// AutoCompleteTextBox that changed its MaxDropDownHeight. + /// Event arguments. + [SuppressMessage("Microsoft.Usage", "CA2208:InstantiateArgumentExceptionsCorrectly" + , Justification = "The exception will be called through a CLR setter in most cases.")] + private static void OnMaxDropDownHeightPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var source = d as AutoCompleteBox; + if (source == null) return; + if (source.ignorePropertyChange) + { + source.ignorePropertyChange = false; + return; + } + + double newValue = (double)e.NewValue; + + // Revert to the old value if invalid (negative) + if (newValue < 0) + { + source.ignorePropertyChange = true; + source.SetValue(e.Property, e.OldValue); + + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, + "Invalid value '{0}' for MaxDropDownHeight", e.NewValue), "e"); + } + + source.OnMaxDropDownHeightChanged(newValue); + } + + /// + /// Gets or sets a value indicating whether the drop-down portion of + /// the control is open. + /// + /// + /// True if the drop-down is open; otherwise, false. The default is + /// false. + /// + public bool IsDropDownOpen + { + get { return (bool)GetValue(IsDropDownOpenProperty); } + set + { + HandleShortcutSupport(value); + SetValue(IsDropDownOpenProperty, value); + } + } + + void HandleShortcutSupport(bool value) + { + if (TextBox == null) + { + return; + } + + var shortcutContainer = TextBox.Control as IShortcutContainer; + if (shortcutContainer != null) + { + shortcutContainer.SupportsKeyboardShortcuts = !value && supportsShortcutOriginalValue; + } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty IsDropDownOpenProperty = + DependencyProperty.Register( + "IsDropDownOpen", + typeof(bool), + typeof(AutoCompleteBox), + new PropertyMetadata(false, OnIsDropDownOpenPropertyChanged)); + + /// + /// IsDropDownOpenProperty property changed handler. + /// + /// AutoCompleteTextBox that changed its IsDropDownOpen. + /// Event arguments. + private static void OnIsDropDownOpenPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var source = d as AutoCompleteBox; + + if (source == null) return; + + // Ignore the change if requested + if (source.ignorePropertyChange) + { + source.ignorePropertyChange = false; + return; + } + + bool oldValue = (bool)e.OldValue; + bool newValue = (bool)e.NewValue; + + if (!newValue) + { + source.ClosingDropDown(oldValue); + } + + source.UpdateVisualState(true); + } + + /// + /// Gets or sets a collection that is used to generate the items for the + /// drop-down portion of the + /// control. + /// + /// The collection that is used to generate the items of the + /// drop-down portion of the + /// control. + public IEnumerable ItemsSource + { + get { return GetValue(ItemsSourceProperty) as IEnumerable; } + set { SetValue(ItemsSourceProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty ItemsSourceProperty = + DependencyProperty.Register( + "ItemsSource", + typeof(IEnumerable), + typeof(AutoCompleteBox), + new PropertyMetadata(OnItemsSourcePropertyChanged)); + + /// + /// ItemsSourceProperty property changed handler. + /// + /// AutoCompleteBox that changed its ItemsSource. + /// Event arguments. + private static void OnItemsSourcePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var autoComplete = d as AutoCompleteBox; + if (autoComplete == null) return; + autoComplete.OnItemsSourceChanged((IEnumerable)e.OldValue, (IEnumerable)e.NewValue); + } + + /// + /// Gets or sets the selected item in the drop-down. + /// + /// The selected item in the drop-down. + /// + /// If the IsTextCompletionEnabled property is true and text typed by + /// the user matches an item in the ItemsSource collection, which is + /// then displayed in the text box, the SelectedItem property will be + /// a null reference. + /// + public object SelectedItem + { + get { return GetValue(SelectedItemProperty); } + set { SetValue(SelectedItemProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier the + /// + /// dependency property. + public static readonly DependencyProperty SelectedItemProperty = + DependencyProperty.Register( + "SelectedItem", + typeof(object), + typeof(AutoCompleteBox), + new PropertyMetadata()); + + private void CancelSuggestion() + { + Debug.Assert(TextBox != null, "TextBox is somehow null"); + Debug.Assert(Text != null, "Text is somehow null"); + + DismissDropDown(); + + Debug.Assert(0 == TextBox.SelectionLength, "SelectionLength is what I think it is"); + } + + private void ExpandSuggestion(string value) + { + Debug.Assert(value != null, "The string passed into ExpandSuggestion should not be null"); + Debug.Assert(TextBox != null, "TextBox is somehow null"); + Debug.Assert(Text != null, "Text is somehow null"); + + var newText = TextBox.GetExpandedText(value, CompletionOffset); + UpdateTextValue(newText); + + // New caret index should be one space after the inserted text. + int newCaretIndex = CompletionOffset + value.Length + 1; + TextBox.CaretIndex = newCaretIndex; + Debug.Assert(newCaretIndex == TextBox.SelectionStart, + String.Format(CultureInfo.InvariantCulture, + "SelectionStart '{0}' should be the same as newCaretIndex '{1}'", + TextBox.SelectionStart, newCaretIndex)); + Debug.Assert(0 == TextBox.SelectionLength, + String.Format(CultureInfo.InvariantCulture, + "SelectionLength should be 0 but is '{0}' is what I think it is", + TextBox.SelectionStart)); + } + + /// + /// Gets or sets the text in the text box portion of the + /// control. + /// + /// The text in the text box portion of the + /// control. + public string Text + { + get { return GetValue(TextProperty) as string; } + set { SetValue(TextProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty TextProperty = + DependencyProperty.Register( + "Text", + typeof(string), + typeof(AutoCompleteBox), + new PropertyMetadata(string.Empty, OnTextPropertyChanged)); + + /// + /// TextProperty property changed handler. + /// + /// AutoCompleteBox that changed its Text. + /// Event arguments. + private static void OnTextPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var source = d as AutoCompleteBox; + if (source == null) return; + + source.OnTextPropertyChanged((string) e.NewValue); + } + + /// + /// Gets or sets the drop down popup control. + /// + private PopupHelper DropDownPopup { get; set; } + + /// + /// The TextBox template part. + /// + private IAutoCompleteTextInput textInput; + + /// + /// The SelectionAdapter. + /// + private ISelectionAdapter adapter; + + /// + /// Gets or sets the Text template part. + /// + [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0")] + protected internal IAutoCompleteTextInput TextBox + { + get { return textInput; } + set { UpdateTextBox(value); } + } + + void UpdateTextBox(IAutoCompleteTextInput value) + { + // Detach existing handlers + if (textInput != null) + { + UnsubscribeToEvent("SelectionChanged"); + UnsubscribeToEvent("OnTextBoxTextChanged"); + } + + textInput = value; + + // Attach handlers + if (textInput != null) + { + var shortcutContainer = textInput.Control as IShortcutContainer; + if (shortcutContainer != null) + { + supportsShortcutOriginalValue = shortcutContainer.SupportsKeyboardShortcuts; + } + + SubscribeToEvent("OnTextBoxTextChanged", ObserveTextBoxChanges() + .Subscribe(shouldPopulate => + { + if (shouldPopulate) + { + PopulateDropDown(); + } + else + { + DismissDropDown(); + } + })); + + if (Text != null) + { + UpdateTextValue(Text); + } + } + } + + IObservable ObserveTextBoxChanges() + { + var distinctTextChanges = textInput + .TextChanged + .Select(_ => textInput.Text ?? "") + .DistinctUntilChanged(); + + if (MinimumPopulateDelay >= 0) + { + distinctTextChanges = distinctTextChanges + .Throttle(TimeSpan.FromMilliseconds(MinimumPopulateDelay), RxApp.MainThreadScheduler); + } + + return distinctTextChanges + .Select(text => { + bool userChangedTextBox = ignoreTextPropertyChange == 0; + if (ignoreTextPropertyChange > 0) ignoreTextPropertyChange--; + + return new { Text = text, ShouldPopulate = text.Length > 0 && userChangedTextBox }; + }) + .Do(textInfo => + { + userCalledPopulate = textInfo.ShouldPopulate; + UpdateAutoCompleteTextValue(textInfo.Text); + }) + .Select(textInfo => textInfo.ShouldPopulate); + } + + /// + /// Gets or sets the selection adapter used to populate the drop-down + /// with a list of selectable items. + /// + /// The selection adapter used to populate the drop-down with a + /// list of selectable items. + /// + /// You can use this property when you create an automation peer to + /// use with AutoCompleteBox or deriving from AutoCompleteBox to + /// create a custom control. + /// + [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0")] + protected internal ISelectionAdapter SelectionAdapter + { + get { return adapter; } + set + { + if (adapter != null) + { + adapter.SelectionChanged -= OnAdapterSelectionChanged; + adapter.Commit -= OnAdapterSelectionComplete; + adapter.Cancel -= OnAdapterSelectionCanceled; + adapter.ItemsSource = null; + } + + adapter = value; + + if (adapter != null) + { + adapter.SelectionChanged += OnAdapterSelectionChanged; + adapter.Commit += OnAdapterSelectionComplete; + adapter.Cancel += OnAdapterSelectionCanceled; + adapter.ItemsSource = view; + } + } + } + + /// + /// Provides suggestions based on what's been typed. + /// + public IAutoCompleteAdvisor Advisor + { + get; + set; + } + + /// + /// Builds the visual tree for the + /// control + /// when a new template is applied. + /// + public override void OnApplyTemplate() + { + if (TextBox != null) + { + UnsubscribeToEvent("PreviewKeyDown"); + } + + if (DropDownPopup != null) + { + DropDownPopup.Closed -= OnDropDownClosed; + DropDownPopup.FocusChanged -= OnDropDownFocusChanged; + DropDownPopup.UpdateVisualStates -= OnDropDownPopupUpdateVisualStates; + DropDownPopup.BeforeOnApplyTemplate(); + DropDownPopup = null; + } + + base.OnApplyTemplate(); + + // Set the template parts. Individual part setters remove and add + // any event handlers. + var popup = GetTemplateChild(elementPopup) as Popup; + if (popup != null) + { + DropDownPopup = new PopupHelper(this, popup) + { + MaxDropDownHeight = MaxDropDownHeight + }; + DropDownPopup.AfterOnApplyTemplate(); + DropDownPopup.Closed += OnDropDownClosed; + DropDownPopup.FocusChanged += OnDropDownFocusChanged; + DropDownPopup.UpdateVisualStates += OnDropDownPopupUpdateVisualStates; + } + SelectionAdapter = GetSelectionAdapterPart(); + // TODO: eliminate duplication between these two elements... + TextBox = InputElement; + + if (TextBox != null) + { + SubscribeToEvent("PreviewKeyDown", TextBox.PreviewKeyDown.Subscribe(OnTextBoxPreviewKeyDown)); + } + + Interaction.OnApplyTemplateBase(); + + // If the drop down property indicates that the popup is open, + // flip its value to invoke the changed handler. + if (IsDropDownOpen && DropDownPopup != null && !DropDownPopup.IsOpen) + { + OpeningDropDown(); + } + } + + /// + /// Allows the popup wrapper to fire visual state change events. + /// + /// The source object. + /// The event data. + private void OnDropDownPopupUpdateVisualStates(object sender, EventArgs e) + { + UpdateVisualState(true); + } + + /// + /// Allows the popup wrapper to fire the FocusChanged event. + /// + /// The source object. + /// The event data. + private void OnDropDownFocusChanged(object sender, EventArgs e) + { + FocusChanged(HasFocus()); + } + + /// + /// Begin closing the drop-down. + /// + /// The original value. + private void ClosingDropDown(bool oldValue) + { + bool delayedClosingVisual = false; + if (DropDownPopup != null) + { + delayedClosingVisual = DropDownPopup.UsesClosingVisualState; + } + + if (view == null || view.Count == 0) + { + delayedClosingVisual = false; + } + + // Immediately close the drop down window: + // When a popup closed visual state is present, the code path is + // slightly different and the actual call to CloseDropDown will + // be called only after the visual state's transition is done + RaiseExpandCollapseAutomationEvent(oldValue, false); + if (!delayedClosingVisual) + { + CloseDropDown(); + } + + UpdateVisualState(true); + } + + private void OpeningDropDown() + { + OpenDropDown(); + + UpdateVisualState(true); + } + + /// + /// Raise an expand/collapse event through the automation peer. + /// + /// The old value. + /// The new value. + private void RaiseExpandCollapseAutomationEvent(bool oldValue, bool newValue) + { + var peer = UIElementAutomationPeer.FromElement(this) as AutoCompleteBoxAutomationPeer; + if (peer != null) + { + peer.RaiseExpandCollapseAutomationEvent(oldValue, newValue); + } + } + + /// + /// Handles the PreviewKeyDown event on the TextBox for WPF. + /// + /// The event data. + private void OnTextBoxPreviewKeyDown(KeyEventArgs e) + { + OnKeyDown(e); + } + + /// + /// Connects to the DropDownPopup Closed event. + /// + /// The source object. + /// The event data. + private void OnDropDownClosed(object sender, EventArgs e) + { + // Force the drop down dependency property to be false. + if (IsDropDownOpen) + { + IsDropDownOpen = false; + } + } + + /// + /// Creates an + /// + /// + /// A + /// + /// for the + /// object. + protected override AutomationPeer OnCreateAutomationPeer() + { + return new AutoCompleteBoxAutomationPeer(this); + } + + /// + /// Handles the FocusChanged event. + /// + /// A value indicating whether the control + /// currently has the focus. + private void FocusChanged(bool hasFocus) + { + // The OnGotFocus & OnLostFocus are asynchronously and cannot + // reliably tell you that have the focus. All they do is let you + // know that the focus changed sometime in the past. To determine + // if you currently have the focus you need to do consult the + // FocusManager (see HasFocus()). + + if (!hasFocus) + { + IsDropDownOpen = false; + userCalledPopulate = false; + } + } + + /// + /// Determines whether the text box or drop-down portion of the + /// control has + /// focus. + /// + /// true to indicate the + /// has focus; + /// otherwise, false. + protected bool HasFocus() + { + var focused = + // For WPF, check if the element that has focus is within the control, as + // FocusManager.GetFocusedElement(this) will return null in such a case. + IsKeyboardFocusWithin ? Keyboard.FocusedElement as DependencyObject : FocusManager.GetFocusedElement(this) as DependencyObject; + + while (focused != null) + { + if (ReferenceEquals(focused, this)) + { + return true; + } + + // This helps deal with popups that may not be in the same + // visual tree + var parent = VisualTreeHelper.GetParent(focused); + if (parent == null) + { + // Try the logical parent. + var element = focused as FrameworkElement; + if (element != null) + { + parent = element.Parent; + } + } + focused = parent; + } + return false; + } + + /// + /// Provides handling for the + /// event. + /// + /// A + /// that contains the event data. + protected override void OnGotFocus(RoutedEventArgs e) + { + base.OnGotFocus(e); + FocusChanged(HasFocus()); + } + + /// + /// Handles change of keyboard focus, which is treated differently than control focus + /// + /// + protected override void OnIsKeyboardFocusWithinChanged(DependencyPropertyChangedEventArgs e) + { + base.OnIsKeyboardFocusWithinChanged(e); + FocusChanged((bool)e.NewValue); + } + + /// + /// Provides handling for the + /// event. + /// + /// A + /// that contains the event data. + protected override void OnLostFocus(RoutedEventArgs e) + { + base.OnLostFocus(e); + FocusChanged(HasFocus()); + } + + /// + /// Handle the change of the IsEnabled property. + /// + /// The source object. + /// The event data. + private void ControlIsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) + { + bool isEnabled = (bool)e.NewValue; + if (!isEnabled) + { + IsDropDownOpen = false; + } + } + + /// + /// Returns the + /// part, if + /// possible. + /// + /// + /// A object, + /// if possible. Otherwise, null. + /// + [SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", + Justification = "Following the GetTemplateChild pattern for the method.")] + protected virtual ISelectionAdapter GetSelectionAdapterPart() + { + var selector = GetTemplateChild(elementSelector) as Selector; + if (selector != null) + { + // Built in support for wrapping a Selector control + adapter = new SelectorSelectionAdapter(selector); + } + return adapter; + } + + /// + /// Populates the drop down + /// + private void PopulateDropDown() + { + populatingSubject.OnNext(Unit.Default); + } + + void DismissDropDown() + { + SelectedItem = null; + if (IsDropDownOpen) + { + IsDropDownOpen = false; + } + } + + /// + /// Converts the specified object to a string by using the + /// and + /// values + /// of the binding object specified by the + /// + /// property. + /// + /// The object to format as a string. + /// The string representation of the specified object. + /// + /// Override this method to provide a custom string conversion. + /// + protected virtual string FormatValue(object value) + { + return value == null ? string.Empty : value.ToString(); + } + + /// + /// Updates both the text box value and underlying text dependency + /// property value if and when they change. Automatically fires the + /// text changed events when there is a change. + /// + /// The new string value. + private void UpdateTextValue(string value) + { + UpdateAutoCompleteTextValue(value); + UpdateTextBoxValue(value); + } + + // Update the TextBox's Text dependency property + void UpdateTextBoxValue(string value) + { + var newValue = value ?? string.Empty; + + if (TextBox == null || TextBox.Text == newValue) + { + return; + } + + ignoreTextPropertyChange++; + TextBox.Text = newValue; + } + + void UpdateAutoCompleteTextValue(string value) + { + if (Text == value) return; + + ignoreTextPropertyChange++; + Text = value; + } + + /// + /// Handle the update of the text when the Text dependency property changes. + /// + /// The new text. + private void OnTextPropertyChanged(string newText) + { + // Only process this event if it is coming from someone outside + // setting the Text dependency property directly. + if (ignoreTextPropertyChange > 0) + { + ignoreTextPropertyChange--; + return; + } + + UpdateTextBoxValue(newText); + } + + /// + /// Notifies the + /// that the + /// + /// property has been set and the data can be filtered to provide + /// possible matches in the drop-down. + /// + /// + /// Call this method when you are providing custom population of + /// the drop-down portion of the AutoCompleteBox, to signal the control + /// that you are done with the population process. + /// Typically, you use PopulateComplete when the population process + /// is a long-running process and you want to cancel built-in filtering + /// of the ItemsSource items. In this case, you can handle the + /// Populated event and set PopulatingEventArgs.Cancel to true. + /// When the long-running process has completed you call + /// PopulateComplete to indicate the drop-down is populated. + /// + protected void PopulateComplete() + { + RefreshView(); + + if (SelectionAdapter != null && !Equals(SelectionAdapter.ItemsSource, view)) + { + SelectionAdapter.ItemsSource = view; + } + + bool isDropDownOpen = userCalledPopulate && (view.Count > 0); + if (isDropDownOpen != IsDropDownOpen) + { + ignorePropertyChange = true; + IsDropDownOpen = isDropDownOpen; + } + if (IsDropDownOpen) + { + OpeningDropDown(); + } + else + { + ClosingDropDown(true); + } + + // We always want to select the first suggestion after populating the drop down. + SelectFirstItem(); + } + + void SelectFirstItem() + { + if (!view.Any()) return; + + var newSelectedItem = view.First(); + SelectionAdapter.SelectedItem = newSelectedItem; + SelectedItem = newSelectedItem; + } + + + /// + /// A simple helper method to clear the view and ensure that a view + /// object is always present and not null. + /// + private void ClearView() + { + if (view == null) + { + view = new ObservableCollection(); + } + else + { + view.Clear(); + } + } + + /// + /// Walks through the items enumeration. Performance is not going to be perfect with the current implementation. + /// + private void RefreshView() + { + if (suggestions == null) + { + ClearView(); + return; + } + + int viewIndex = 0; + int viewCount = view.Count; + var items = suggestions; + foreach (var item in items) + { + if (viewCount > viewIndex && view[viewIndex] == item) + { + // Item is still in the view + viewIndex++; + } + else + { + // Insert the item + if (viewCount > viewIndex && view[viewIndex] != item) + { + // Replace item + // Unfortunately replacing via index throws a fatal + // exception: View[view_index] = item; + // Cost: O(n) vs O(1) + view.RemoveAt(viewIndex); + view.Insert(viewIndex, item); + viewIndex++; + } + else + { + // Add the item + if (viewIndex == viewCount) + { + // Constant time is preferred (Add). + view.Add(item); + } + else + { + view.Insert(viewIndex, item); + } + viewIndex++; + viewCount++; + } + } + } + } + + /// + /// Handle any change to the ItemsSource dependency property, update + /// the underlying ObservableCollection view, and set the selection + /// adapter's ItemsSource to the view if appropriate. + /// + /// The old enumerable reference. + /// The new enumerable reference. + private void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue) + { + // Remove handler for oldValue.CollectionChanged (if present) + var oldValueINotifyCollectionChanged = oldValue as INotifyCollectionChanged; + if (null != oldValueINotifyCollectionChanged && null != collectionChangedWeakEventListener) + { + collectionChangedWeakEventListener.Detach(); + collectionChangedWeakEventListener = null; + } + + // Add handler for newValue.CollectionChanged (if possible) + var newValueINotifyCollectionChanged = newValue as INotifyCollectionChanged; + if (null != newValueINotifyCollectionChanged) + { + collectionChangedWeakEventListener = new WeakEventListener(this) + { + OnEventAction = + (instance, source, eventArgs) => instance.ItemsSourceCollectionChanged(eventArgs), + OnDetachAction = + weakEventListener => + newValueINotifyCollectionChanged.CollectionChanged -= weakEventListener.OnEvent + }; + newValueINotifyCollectionChanged.CollectionChanged += collectionChangedWeakEventListener.OnEvent; + } + + // Store a local cached copy of the data + suggestions = newValue == null ? null : new List(newValue.Cast().ToList()); + + // Clear and set the view on the selection adapter + ClearView(); + if (SelectionAdapter != null && !Equals(SelectionAdapter.ItemsSource, view)) + { + SelectionAdapter.ItemsSource = view; + } + if (IsDropDownOpen) + { + RefreshView(); + } + } + + /// + /// Method that handles the ObservableCollection.CollectionChanged event for the ItemsSource property. + /// + /// The event data. + private void ItemsSourceCollectionChanged(NotifyCollectionChangedEventArgs e) + { + // Update the cache + if (e.Action == NotifyCollectionChangedAction.Remove && e.OldItems != null) + { + for (int index = 0; index < e.OldItems.Count; index++) + { + suggestions.RemoveAt(e.OldStartingIndex); + } + } + if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems != null && suggestions.Count >= e.NewStartingIndex) + { + for (int index = 0; index < e.NewItems.Count; index++) + { + suggestions.Insert(e.NewStartingIndex + index, e.NewItems[index]); + } + } + if (e.Action == NotifyCollectionChangedAction.Replace && e.NewItems != null && e.OldItems != null) + { + foreach (var t in e.NewItems) + { + suggestions[e.NewStartingIndex] = t; + } + } + + // Update the view + if (e.Action == NotifyCollectionChangedAction.Remove || e.Action == NotifyCollectionChangedAction.Replace) + { + if (e.OldItems != null) + { + foreach (var t in e.OldItems) + { + view.Remove(t); + } + } + } + + if (e.Action == NotifyCollectionChangedAction.Reset) + { + // Significant changes to the underlying data. + ClearView(); + if (ItemsSource != null) + { + suggestions = new List(ItemsSource.Cast().ToList()); + } + } + + // Refresh the observable collection used in the selection adapter. + RefreshView(); + } + + /// + /// Handles the SelectionChanged event of the selection adapter. + /// + /// The source object. + /// The selection changed event data. + private void OnAdapterSelectionChanged(object sender, SelectionChangedEventArgs e) + { + SelectedItem = adapter.SelectedItem; + } + + /// + /// Handles the Commit event on the selection adapter. + /// + /// The source object. + /// The event data. + private void OnAdapterSelectionComplete(object sender, RoutedEventArgs e) + { + IsDropDownOpen = false; + + var selectedItem = SelectedItem; + + // Completion will update the selected value + ExpandSuggestion(selectedItem == null ? string.Empty : selectedItem.ToString()); + + // This forces the textbox to get keyboard focus, in the case where + // another part of the control may have temporarily received focus. + if (TextBox != null) + { + // Because LOL WPF focus shit, we need to make sure don't lose the caret index when we give this focus. + int caretIndex = TextBox.CaretIndex; + TextBox.Focus(); + TextBox.CaretIndex = caretIndex; + } + else + { + Focus(); + } + } + + /// + /// Handles the Cancel event on the selection adapter. + /// + /// The source object. + /// The event data. + private void OnAdapterSelectionCanceled(object sender, RoutedEventArgs e) + { + IsDropDownOpen = false; + + CancelSuggestion(); + + // This forces the textbox to get keyboard focus, in the case where + // another part of the control may have temporarily received focus. + if (TextBox != null) + { + TextBox.Focus(); + } + else + { + Focus(); + } + } + + /// + /// Handles MaxDropDownHeightChanged by re-arranging and updating the + /// popup arrangement. + /// + /// The new value. + [SuppressMessage("Microsoft.Usage", "CA1801:ReviewUnusedParameters", MessageId = "newValue", + Justification = "This makes it easy to add validation or other changes in the future.")] + private void OnMaxDropDownHeightChanged(double newValue) + { + if (DropDownPopup != null) + { + DropDownPopup.MaxDropDownHeight = newValue; + } + UpdateVisualState(true); + } + + private void OpenDropDown() + { + if (DropDownPopup != null) + { + DropDownPopup.IsOpen = true; + } + popupHasOpened = true; + } + + private void CloseDropDown() + { + if (popupHasOpened) + { + if (SelectionAdapter != null) + { + SelectionAdapter.SelectedItem = null; + } + if (DropDownPopup != null) + { + DropDownPopup.IsOpen = false; + } + } + } + + /// + /// Provides handling for the + /// event. + /// + /// A + /// that contains the event data. + [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0")] + protected override void OnKeyDown(KeyEventArgs e) + { + if (e == null) + { + throw new ArgumentNullException("e"); + } + + base.OnKeyDown(e); + + if (e.Handled || !IsEnabled) + { + return; + } + + // The drop down is open, pass along the key event arguments to the + // selection adapter. If it isn't handled by the adapter's logic, + // then we handle some simple navigation scenarios for controlling + // the drop down. + if (IsDropDownOpen) + { + if (SelectionAdapter != null) + { + SelectionAdapter.HandleKeyDown(e); + if (e.Handled) + { + return; + } + } + + if (e.Key == Key.Escape) + { + OnAdapterSelectionCanceled(this, new RoutedEventArgs()); + e.Handled = true; + } + } + + // Standard drop down navigation + switch (e.Key) + { + case Key.F4: + IsDropDownOpen = !IsDropDownOpen; + e.Handled = true; + break; + + case Key.Enter: + if (IsDropDownOpen && SelectedItem != null) + { + OnAdapterSelectionComplete(this, new RoutedEventArgs()); + e.Handled = true; + } + + break; + } + } + + /// + /// Update the visual state of the control. + /// + /// + /// A value indicating whether to automatically generate transitions to + /// the new state, or instantly transition to the new state. + /// + void IUpdateVisualState.UpdateVisualState(bool useTransitions) + { + UpdateVisualState(useTransitions); + } + + /// + /// Update the current visual state of the button. + /// + /// + /// True to use transitions when updating the visual state, false to + /// snap directly to the new visual state. + /// + internal virtual void UpdateVisualState(bool useTransitions) + { + // Popup + VisualStateManager.GoToState(this, IsDropDownOpen ? VisualStates.StatePopupOpened : VisualStates.StatePopupClosed, useTransitions); + + // Handle the Common and Focused states + Interaction.UpdateVisualStateBase(useTransitions); + } + + private class EmptyAutoCompleteAdvisor : IAutoCompleteAdvisor + { + public static readonly IAutoCompleteAdvisor Instance = new EmptyAutoCompleteAdvisor(); + + private EmptyAutoCompleteAdvisor() + { + } + + public IObservable GetAutoCompletionSuggestions(string text, int caretPosition) + { + return Observable.Empty(); + } + } + + private void SubscribeToEvent(string eventName, IDisposable disposable) + { + eventSubscriptions[eventName] = disposable; + } + + private void UnsubscribeToEvent(string eventName) + { + IDisposable disposable; + if (eventSubscriptions.TryGetValue(eventName, out disposable)) + { + disposable.Dispose(); + } + } + + public IAutoCompleteTextInput InputElement + { + get { return (IAutoCompleteTextInput)GetValue(InputElementProperty); } + set { SetValue(InputElementProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty InputElementProperty = + DependencyProperty.Register( + "InputElement", + typeof(IAutoCompleteTextInput), + typeof(AutoCompleteBox)); + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBoxAutomationPeer.cs b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBoxAutomationPeer.cs new file mode 100644 index 0000000000..5ac3965c47 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBoxAutomationPeer.cs @@ -0,0 +1,300 @@ +// (c) Copyright Microsoft Corporation. +// 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.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Windows; +using System.Windows.Automation; +using System.Windows.Automation.Peers; +using System.Windows.Automation.Provider; + +namespace GitHub.UI +{ + /// + /// Exposes AutoCompleteBox types to UI Automation. + /// + /// Stable + public sealed class AutoCompleteBoxAutomationPeer : FrameworkElementAutomationPeer, IValueProvider, IExpandCollapseProvider, ISelectionProvider + { + /// + /// The name reported as the core class name. + /// + private const string autoCompleteBoxClassNameCore = "AutoCompleteBox"; + + /// + /// Gets the AutoCompleteBox that owns this + /// AutoCompleteBoxAutomationPeer. + /// + private AutoCompleteBox OwnerAutoCompleteBox + { + get { return (AutoCompleteBox)Owner; } + } + + /// + /// Gets a value indicating whether the UI automation provider allows + /// more than one child element to be selected concurrently. + /// + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + /// True if multiple selection is allowed; otherwise, false. + bool ISelectionProvider.CanSelectMultiple + { + get { return false; } + } + + /// + /// Gets a value indicating whether the UI automation provider + /// requires at least one child element to be selected. + /// + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + /// True if selection is required; otherwise, false. + bool ISelectionProvider.IsSelectionRequired + { + get { return false; } + } + + /// + /// Initializes a new instance of the AutoCompleteBoxAutomationPeer + /// class. + /// + /// + /// The AutoCompleteBox that is associated with this + /// AutoCompleteBoxAutomationPeer. + /// + public AutoCompleteBoxAutomationPeer(AutoCompleteBox owner) + : base(owner) + { + } + + /// + /// Gets the control type for the AutoCompleteBox that is associated + /// with this AutoCompleteBoxAutomationPeer. This method is called by + /// GetAutomationControlType. + /// + /// ComboBox AutomationControlType. + protected override AutomationControlType GetAutomationControlTypeCore() + { + return AutomationControlType.ComboBox; + } + + /// + /// Gets the name of the AutoCompleteBox that is associated with this + /// AutoCompleteBoxAutomationPeer. This method is called by + /// GetClassName. + /// + /// The name AutoCompleteBox. + protected override string GetClassNameCore() + { + return autoCompleteBoxClassNameCore; + } + + /// + /// Gets the control pattern for the AutoCompleteBox that is associated + /// with this AutoCompleteBoxAutomationPeer. + /// + /// The desired PatternInterface. + /// The desired AutomationPeer or null. + public override object GetPattern(PatternInterface patternInterface) + { + object iface = null; + var owner = OwnerAutoCompleteBox; + + if (patternInterface == PatternInterface.Value) + { + iface = this; + } + else if (patternInterface == PatternInterface.ExpandCollapse) + { + iface = this; + } + else if (owner.SelectionAdapter != null) + { + var peer = owner.SelectionAdapter.CreateAutomationPeer(); + if (peer != null) + { + iface = peer.GetPattern(patternInterface); + } + } + + return iface ?? base.GetPattern(patternInterface); + } + + /// + /// Blocking method that returns after the element has been expanded. + /// + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + void IExpandCollapseProvider.Expand() + { + if (!IsEnabled()) + { + throw new ElementNotEnabledException(); + } + + OwnerAutoCompleteBox.IsDropDownOpen = true; + } + + /// + /// Blocking method that returns after the element has been collapsed. + /// + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + void IExpandCollapseProvider.Collapse() + { + if (!IsEnabled()) + { + throw new ElementNotEnabledException(); + } + + OwnerAutoCompleteBox.IsDropDownOpen = false; + } + + /// + /// Gets an element's current Collapsed or Expanded state. + /// + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + ExpandCollapseState IExpandCollapseProvider.ExpandCollapseState + { + get + { + return OwnerAutoCompleteBox.IsDropDownOpen ? ExpandCollapseState.Expanded : ExpandCollapseState.Collapsed; + } + } + + /// + /// Raises the ExpandCollapse automation event. + /// + /// The old value. + /// The new value. + internal void RaiseExpandCollapseAutomationEvent(bool oldValue, bool newValue) + { + RaisePropertyChangedEvent( + ExpandCollapsePatternIdentifiers.ExpandCollapseStateProperty, + oldValue ? ExpandCollapseState.Expanded : ExpandCollapseState.Collapsed, + newValue ? ExpandCollapseState.Expanded : ExpandCollapseState.Collapsed); + } + + /// + /// Sets the value of a control. + /// + /// The value to set. The provider is responsible + /// for converting the value to the appropriate data type. + void IValueProvider.SetValue(string value) + { + OwnerAutoCompleteBox.Text = value; + } + + /// + /// Gets a value indicating whether the value of a control is + /// read-only. + /// + /// True if the value is read-only; false if it can be modified. + bool IValueProvider.IsReadOnly + { + get + { + return !OwnerAutoCompleteBox.IsEnabled; + } + } + + /// + /// Gets the value of the control. + /// + /// The value of the control. + string IValueProvider.Value + { + get + { + return OwnerAutoCompleteBox.Text ?? string.Empty; + } + } + + /// + /// Gets the collection of child elements of the AutoCompleteBox that + /// are associated with this AutoCompleteBoxAutomationPeer. This method + /// is called by GetChildren. + /// + /// + /// A collection of automation peer elements, or an empty collection + /// if there are no child elements. + /// + [SuppressMessage("Microsoft.Design", "CA1002:DoNotExposeGenericLists", Justification = "Required by automation")] + protected override List GetChildrenCore() + { + var children = new List(); + var owner = OwnerAutoCompleteBox; + + // TextBox part. + var textBox = owner.TextBox; + if (textBox != null) + { + var peer = CreatePeerForElement(textBox.Control); + if (peer != null) + { + children.Insert(0, peer); + } + } + + // Include SelectionAdapter's children. + if (owner.SelectionAdapter != null) + { + var selectionAdapterPeer = owner.SelectionAdapter.CreateAutomationPeer(); + if (selectionAdapterPeer != null) + { + var listChildren = selectionAdapterPeer.GetChildren(); + if (listChildren != null) + { + children.AddRange(listChildren); + } + } + } + + return children; + } + + /// + /// Retrieves a UI automation provider for each child element that is + /// selected. + /// + /// An array of UI automation providers. + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + IRawElementProviderSimple[] ISelectionProvider.GetSelection() + { + if (OwnerAutoCompleteBox.SelectionAdapter != null) + { + var selectedItem = OwnerAutoCompleteBox.SelectionAdapter.SelectedItem; + if (selectedItem != null) + { + var uie = selectedItem as UIElement; + if (uie != null) + { + var peer = CreatePeerForElement(uie); + if (peer != null) + { + return new[] { ProviderFromPeer(peer) }; + } + } + } + } + + return new IRawElementProviderSimple[] { }; + } + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteResult.cs b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteResult.cs new file mode 100644 index 0000000000..4397375158 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteResult.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace GitHub.UI +{ + 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.UI/Controls/AutoCompleteBox/AutoCompleteSuggestion.cs b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteSuggestion.cs new file mode 100644 index 0000000000..4ca400c1a8 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/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.UI +{ + 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.UI/Controls/AutoCompleteBox/AutoCompleteTextInputExtensions.cs b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteTextInputExtensions.cs new file mode 100644 index 0000000000..4815b5d045 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteTextInputExtensions.cs @@ -0,0 +1,35 @@ +using System; +using System.Diagnostics; +using GitHub.Extensions; +using GitHub.Helpers; + +namespace GitHub.UI.Controls.AutoCompleteBox +{ + public static class AutoCompleteTextInputExtensions + { + /// + /// Given a text input and the current value, returns the expected new text. + /// + /// + /// + /// + /// + public static string GetExpandedText(this IAutoCompleteTextInput textInput, string value, int completionOffset) + { + Guard.ArgumentNotNull(textInput, "textInput"); + Guard.ArgumentNotNull(value, "value"); + + int caretIndex = textInput.CaretIndex; + int afterIndex = Math.Max(caretIndex, textInput.SelectionLength + textInput.SelectionStart); + int offset = completionOffset; + + var currentText = textInput.Text ?? ""; // Playing it safe + Debug.Assert(offset <= currentText.Length, "The offset can't be larger than the current text length"); + Debug.Assert(afterIndex <= currentText.Length, "The afterIndex can't be larger than the current text length"); + var before = currentText.Substring(0, offset); + var after = currentText.Substring(afterIndex); + string prefix = before + value + " "; + return prefix + after; + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteAdvisor.cs b/src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteAdvisor.cs new file mode 100644 index 0000000000..5a2b945c54 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteAdvisor.cs @@ -0,0 +1,9 @@ +using System; + +namespace GitHub.UI +{ + public interface IAutoCompleteAdvisor + { + IObservable GetAutoCompletionSuggestions(string text, int caretPosition); + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteTextInput.cs b/src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteTextInput.cs new file mode 100644 index 0000000000..1128c232ff --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteTextInput.cs @@ -0,0 +1,28 @@ +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace GitHub.UI +{ + public interface IAutoCompleteTextInput : INotifyPropertyChanged + { + void Focus(); + [SuppressMessage("Microsoft.Naming", "CA1716:IdentifiersShouldNotMatchKeywords", MessageId = "Select", + Justification = "Matches the underlying control method name")] + void Select(int position, int length); + void SelectAll(); + int CaretIndex { get; set; } + int SelectionStart { get; } + int SelectionLength { get; } + string Text { get; set; } + IObservable PreviewKeyDown { get; } + IObservable SelectionChanged { get; } + IObservable TextChanged { get; } + UIElement Control { get; } + Point GetPositionFromCharIndex(int charIndex); + Thickness Margin { get; set; } + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/IPopupTarget.cs b/src/GitHub.UI/Controls/AutoCompleteBox/IPopupTarget.cs new file mode 100644 index 0000000000..250fa9bca9 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/IPopupTarget.cs @@ -0,0 +1,18 @@ + +using System.Windows; + +namespace GitHub.UI.Controls +{ + /// + /// Controls that implement this interface can specify where an associated popup should be located. + /// + /// + /// The PopupHelper is a generic class for managing Popups that align to the bottom of their associated control. + /// However, our AutoCompleteBox needs the Popup to align to where the completion is happening. Intellisense™ + /// controls behave in a similar fashion. We might find popups useful elsewhere. + /// + public interface IPopupTarget + { + Point PopupPosition { get; } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/ISelectionAdapter.cs b/src/GitHub.UI/Controls/AutoCompleteBox/ISelectionAdapter.cs new file mode 100644 index 0000000000..088514c69a --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/ISelectionAdapter.cs @@ -0,0 +1,73 @@ +// (c) Copyright Microsoft Corporation. +// 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.Collections; +using System.Windows; +using System.Windows.Automation.Peers; +using System.Windows.Controls; +using System.Windows.Input; + +namespace GitHub.UI +{ + /// + /// Defines an item collection, selection members, and key handling for the + /// selection adapter contained in the drop-down portion of an + /// control. + /// + /// Stable + public interface ISelectionAdapter + { + /// + /// Gets or sets the selected item. + /// + /// The currently selected item. + object SelectedItem { get; set; } + + /// + /// Occurs when the + /// + /// property value changes. + /// + event SelectionChangedEventHandler SelectionChanged; + + /// + /// Gets or sets a collection that is used to generate content for the + /// selection adapter. + /// + /// The collection that is used to generate content for the + /// selection adapter. + IEnumerable ItemsSource { get; set; } + + /// + /// Occurs when a selected item is not cancelled and is committed as the + /// selected item. + /// + event RoutedEventHandler Commit; + + /// + /// Occurs when a selection has been canceled. + /// + event RoutedEventHandler Cancel; + + /// + /// Provides handling for the + /// event that occurs + /// when a key is pressed while the drop-down portion of the + /// has focus. + /// + /// A + /// that contains data about the + /// event. + void HandleKeyDown(KeyEventArgs e); + + /// + /// Returns an automation peer for the selection adapter, for use by the + /// Silverlight automation infrastructure. + /// + /// An automation peer for the selection adapter, if one is + /// available; otherwise, null. + AutomationPeer CreateAutomationPeer(); + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/IUpdateVisualState.cs b/src/GitHub.UI/Controls/AutoCompleteBox/IUpdateVisualState.cs new file mode 100644 index 0000000000..abbecd5f85 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/IUpdateVisualState.cs @@ -0,0 +1,23 @@ +// (c) Copyright Microsoft Corporation. +// 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. + +namespace GitHub.UI +{ + /// + /// The IUpdateVisualState interface is used to provide the + /// InteractionHelper with access to the type's UpdateVisualState method. + /// + internal interface IUpdateVisualState + { + /// + /// Update the visual state of the control. + /// + /// + /// A value indicating whether to automatically generate transitions to + /// the new state, or instantly transition to the new state. + /// + void UpdateVisualState(bool useTransitions); + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/InteractionHelper.cs b/src/GitHub.UI/Controls/AutoCompleteBox/InteractionHelper.cs new file mode 100644 index 0000000000..33dbe73cb8 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/InteractionHelper.cs @@ -0,0 +1,158 @@ +// (c) Copyright Microsoft Corporation. +// 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.Diagnostics; +using System.Windows; +using System.Windows.Controls; + +namespace GitHub.UI +{ + /// + /// The InteractionHelper provides controls with support for all of the + /// common interactions like mouse movement, mouse clicks, key presses, + /// etc., and also incorporates proper event semantics when the control is + /// disabled. + /// + internal sealed class InteractionHelper + { + /// + /// Gets the control the InteractionHelper is targeting. + /// + public Control Control { get; private set; } + + /// + /// Gets a value indicating whether the control has focus. + /// + public bool IsFocused { get; private set; } + + /// + /// Gets a value indicating whether the mouse is over the control. + /// + public bool IsMouseOver { get; private set; } + + /// + /// Gets a value indicating whether the mouse button is pressed down + /// over the control. + /// + public bool IsPressed { get; private set; } + + /// + /// Reference used to call UpdateVisualState on the base class. + /// + private readonly IUpdateVisualState updateVisualState; + + /// + /// Initializes a new instance of the InteractionHelper class. + /// + /// Control receiving interaction. + public InteractionHelper(Control control) + { + Debug.Assert(control != null, "control should not be null!"); + Control = control; + updateVisualState = control as IUpdateVisualState; + + // Wire up the event handlers for events without a virtual override + control.Loaded += OnLoaded; + control.IsEnabledChanged += OnIsEnabledChanged; + } + + /// + /// Update the visual state of the control. + /// + /// + /// A value indicating whether to automatically generate transitions to + /// the new state, or instantly transition to the new state. + /// + /// + /// UpdateVisualState works differently than the rest of the injected + /// functionality. Most of the other events are overridden by the + /// calling class which calls Allow, does what it wants, and then calls + /// Base. UpdateVisualState is the opposite because a number of the + /// methods in InteractionHelper need to trigger it in the calling + /// class. We do this using the IUpdateVisualState internal interface. + /// + private void UpdateVisualState(bool useTransitions) + { + if (updateVisualState != null) + { + updateVisualState.UpdateVisualState(useTransitions); + } + } + + /// + /// Update the visual state of the control. + /// + /// + /// A value indicating whether to automatically generate transitions to + /// the new state, or instantly transition to the new state. + /// + public void UpdateVisualStateBase(bool useTransitions) + { + // Handle the Common states + if (!Control.IsEnabled) + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StateDisabled, VisualStates.StateNormal); + } + else if (IsPressed) + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StatePressed, VisualStates.StateMouseOver, VisualStates.StateNormal); + } + else if (IsMouseOver) + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StateMouseOver, VisualStates.StateNormal); + } + else + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StateNormal); + } + + // Handle the Focused states + if (IsFocused) + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StateFocused, VisualStates.StateUnfocused); + } + else + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StateUnfocused); + } + } + + /// + /// Handle the control's Loaded event. + /// + /// The control. + /// Event arguments. + private void OnLoaded(object sender, RoutedEventArgs e) + { + UpdateVisualState(false); + } + + /// + /// Handle changes to the control's IsEnabled property. + /// + /// The control. + /// Event arguments. + private void OnIsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) + { + bool enabled = (bool)e.NewValue; + if (!enabled) + { + IsPressed = false; + IsMouseOver = false; + IsFocused = false; + } + + UpdateVisualState(true); + } + + /// + /// Update the visual state of the control when its template is changed. + /// + public void OnApplyTemplateBase() + { + UpdateVisualState(false); + } + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/PopupHelper.cs b/src/GitHub.UI/Controls/AutoCompleteBox/PopupHelper.cs new file mode 100644 index 0000000000..7ba010c413 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/PopupHelper.cs @@ -0,0 +1,280 @@ +// (c) Copyright Microsoft Corporation. +// 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.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using GitHub.Extensions; +using GitHub.Helpers; +using GitHub.UI.Controls; + +namespace GitHub.UI +{ + /// + /// PopupHelper is a simple wrapper type that helps abstract platform + /// differences out of the Popup. + /// + internal class PopupHelper + { + /// + /// Gets a value indicating whether a visual popup state is being used + /// in the current template for the Closed state. Setting this value to + /// true will delay the actual setting of Popup.IsOpen to false until + /// after the visual state's transition for Closed is complete. + /// + public bool UsesClosingVisualState { get; private set; } + + /// + /// Gets or sets the parent control. + /// + private Control Parent { get; set; } + + /// + /// Gets or sets the maximum drop down height value. + /// + public double MaxDropDownHeight { get; set; } + + /// + /// Gets the Popup control instance. + /// + public Popup Popup { get; private set; } + + /// + /// Gets or sets a value indicating whether the actual Popup is open. + /// + public bool IsOpen + { + get { return Popup.IsOpen; } + set { Popup.IsOpen = value; } + } + + /// + /// Gets or sets the popup child framework element. Can be used if an + /// assumption is made on the child type. + /// + private FrameworkElement PopupChild { get; set; } + + /// + /// The Closed event is fired after the Popup closes. + /// + public event EventHandler Closed; + + /// + /// Fired when the popup children have a focus event change, allows the + /// parent control to update visual states or react to the focus state. + /// + public event EventHandler FocusChanged; + + /// + /// Fired when the popup children intercept an event that may indicate + /// the need for a visual state update by the parent control. + /// + public event EventHandler UpdateVisualStates; + + /// + /// Initializes a new instance of the PopupHelper class. + /// + /// The parent control. + public PopupHelper(Control parent) + { + Guard.ArgumentNotNull(parent, "parent"); + Parent = parent; + } + + /// + /// Initializes a new instance of the PopupHelper class. + /// + /// The parent control. + /// The Popup template part. + public PopupHelper(Control parent, Popup popup) + : this(parent) + { + Guard.ArgumentNotNull(parent, "parent"); + Guard.ArgumentNotNull(popup, "popup"); + + Popup = popup; + + var target = parent as IPopupTarget; + if (target != null) + { + popup.CustomPopupPlacementCallback += (size, targetSize, offset) => new[] + { + new CustomPopupPlacement(target.PopupPosition, PopupPrimaryAxis.Horizontal) + }; + } + } + + /// + /// Fires the Closed event. + /// + /// The event data. + private void OnClosed(EventArgs e) + { + var handler = Closed; + if (handler != null) + { + handler(this, e); + } + } + + /// + /// Actually closes the popup after the VSM state animation completes. + /// + /// Event source. + /// Event arguments. + private void OnPopupClosedStateChanged(object sender, VisualStateChangedEventArgs e) + { + // Delayed closing of the popup until now + if (e != null && e.NewState != null && e.NewState.Name == VisualStates.StatePopupClosed) + { + if (Popup != null) + { + Popup.IsOpen = false; + } + OnClosed(EventArgs.Empty); + } + } + + /// + /// Should be called by the parent control before the base + /// OnApplyTemplate method is called. + /// + public void BeforeOnApplyTemplate() + { + if (UsesClosingVisualState) + { + // Unhook the event handler for the popup closed visual state group. + // This code is used to enable visual state transitions before + // actually setting the underlying Popup.IsOpen property to false. + VisualStateGroup groupPopupClosed = VisualStates.TryGetVisualStateGroup(Parent, VisualStates.GroupPopup); + if (null != groupPopupClosed) + { + groupPopupClosed.CurrentStateChanged -= OnPopupClosedStateChanged; + UsesClosingVisualState = false; + } + } + + if (Popup != null) + { + Popup.Closed -= Popup_Closed; + } + } + + /// + /// Should be called by the parent control after the base + /// OnApplyTemplate method is called. + /// + public void AfterOnApplyTemplate() + { + if (Popup != null) + { + Popup.Closed += Popup_Closed; + } + + var groupPopupClosed = VisualStates.TryGetVisualStateGroup(Parent, VisualStates.GroupPopup); + if (null != groupPopupClosed) + { + groupPopupClosed.CurrentStateChanged += OnPopupClosedStateChanged; + UsesClosingVisualState = true; + } + + // TODO: Consider moving to the DropDownPopup setter + // TODO: Although in line with other implementations, what happens + // when the template is swapped out? + if (Popup != null) + { + PopupChild = Popup.Child as FrameworkElement; + + if (PopupChild != null) + { + PopupChild.MinWidth = 203; // TODO: Make this configurable. + PopupChild.GotFocus += PopupChild_GotFocus; + PopupChild.LostFocus += PopupChild_LostFocus; + PopupChild.MouseEnter += PopupChild_MouseEnter; + PopupChild.MouseLeave += PopupChild_MouseLeave; + } + } + } + + /// + /// Connected to the Popup Closed event and fires the Closed event. + /// + /// The source object. + /// The event data. + private void Popup_Closed(object sender, EventArgs e) + { + OnClosed(EventArgs.Empty); + } + + /// + /// Connected to several events that indicate that the FocusChanged + /// event should bubble up to the parent control. + /// + /// The event data. + private void OnFocusChanged(EventArgs e) + { + EventHandler handler = FocusChanged; + if (handler != null) + { + handler(this, e); + } + } + + /// + /// Fires the UpdateVisualStates event. + /// + /// The event data. + private void OnUpdateVisualStates(EventArgs e) + { + EventHandler handler = UpdateVisualStates; + if (handler != null) + { + handler(this, e); + } + } + + /// + /// The popup child has received focus. + /// + /// The source object. + /// The event data. + private void PopupChild_GotFocus(object sender, RoutedEventArgs e) + { + OnFocusChanged(EventArgs.Empty); + } + + /// + /// The popup child has lost focus. + /// + /// The source object. + /// The event data. + private void PopupChild_LostFocus(object sender, RoutedEventArgs e) + { + OnFocusChanged(EventArgs.Empty); + } + + /// + /// The popup child has had the mouse enter its bounds. + /// + /// The source object. + /// The event data. + private void PopupChild_MouseEnter(object sender, MouseEventArgs e) + { + OnUpdateVisualStates(EventArgs.Empty); + } + + /// + /// The mouse has left the popup child's bounds. + /// + /// The source object. + /// The event data. + private void PopupChild_MouseLeave(object sender, MouseEventArgs e) + { + OnUpdateVisualStates(EventArgs.Empty); + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/RichTextBoxAutoCompleteTextInput.cs b/src/GitHub.UI/Controls/AutoCompleteBox/RichTextBoxAutoCompleteTextInput.cs new file mode 100644 index 0000000000..c4ac5ce1be --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/RichTextBoxAutoCompleteTextInput.cs @@ -0,0 +1,200 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Reactive.Linq; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Markup; + +namespace GitHub.UI +{ + [ContentProperty("TextBox")] + public class RichTextBoxAutoCompleteTextInput : IAutoCompleteTextInput + { + private static readonly int newLineLength = Environment.NewLine.Length; + const int promptRichTextBoxCaretIndexAdjustments = 2; + RichTextBox textBox; + + public event PropertyChangedEventHandler PropertyChanged; + + TextPointer ContentStart + { + get { return textBox.Document.ContentStart; } + } + + TextPointer ContentEnd + { + get + { + // RichTextBox always appends a new line at the end. So we need to back that shit up. + return textBox.Document.ContentEnd.GetPositionAtOffset(-1 * newLineLength) + ?? textBox.Document.ContentEnd; + } + } + + public void Select(int position, int length) + { + var textRange = new TextRange(ContentStart, ContentEnd); + + if (textRange.Text.Length >= (position + length)) + { + var start = textRange.Start.GetPositionAtOffset(GetOffsetIndex(position), LogicalDirection.Forward); + var end = textRange.Start.GetPositionAtOffset(GetOffsetIndex(position + length), LogicalDirection.Backward); + if (start != null && end != null) + textBox.Selection.Select(start, end); + } + } + + public void SelectAll() + { + textBox.Selection.Select(ContentStart, ContentEnd); + } + + public int CaretIndex + { + get + { + var start = ContentStart; + var caret = textBox.CaretPosition; + var range = new TextRange(start, caret); + return range.Text.Length; + } + set + { + Select(value, 0); + Debug.Assert(value == CaretIndex, + String.Format(CultureInfo.InvariantCulture, + "I just set the caret index to '{0}' but it's '{1}'", value, CaretIndex)); + } + } + + public int SelectionStart + { + get + { + return new TextRange(ContentStart, textBox.Selection.Start).Text.Length; + } + } + + public int SelectionLength + { + get { return CaretIndex - SelectionStart; } + } + +#if DEBUG + [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope")] +#endif + public string Text + { + get + { + return new TextRange(ContentStart, ContentEnd).Text; + } + set + { + textBox.Document.Blocks.Clear(); + + if (!string.IsNullOrEmpty(value)) + { + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(value))) + { + var contents = new TextRange(ContentStart, ContentEnd); + contents.Load(stream, DataFormats.Text); + } + } + } + } + + public IObservable PreviewKeyDown + { + get; + private set; + } + + public IObservable SelectionChanged { get; private set; } + + public IObservable TextChanged { get; private set; } + + public UIElement Control { get { return textBox; } } + + public Point GetPositionFromCharIndex(int charIndex) + { + var offset = new TextRange(ContentStart, textBox.CaretPosition) + .Start + .GetPositionAtOffset(charIndex, LogicalDirection.Forward); + + return offset != null + ? offset.GetCharacterRect(LogicalDirection.Forward).BottomLeft + : new Point(0, 0); + } + + public Thickness Margin + { + get { return textBox.Margin; } + set { textBox.Margin = value; } + } + + public void Focus() + { + Keyboard.Focus(textBox); + } + + public RichTextBox TextBox + { + get + { + return textBox; + } + set + { + if (value != textBox) + { + textBox = value; + + PreviewKeyDown = Observable.FromEvent( + h => textBox.PreviewKeyDown += h, + h => textBox.PreviewKeyDown -= h); + + SelectionChanged = Observable.FromEvent( + h => textBox.SelectionChanged += h, + h => textBox.SelectionChanged -= h); + + TextChanged = Observable.FromEvent( + h => textBox.TextChanged += h, + h => textBox.TextChanged -= h); + + NotifyPropertyChanged("Control"); + } + + } + } + + // This is a fudge factor needed because of PromptRichTextBox. When commit messages are 51 characters or more, + // The PromptRichTextBox applies a styling that fucks up the CaretPosition by 2. :( + // This method helps us account for that. + int GetOffsetIndex(int selectionEnd) + { + if (textBox is PromptRichTextBox && selectionEnd >= PromptRichTextBox.BadCommitMessageLength) + { + return selectionEnd + promptRichTextBoxCaretIndexAdjustments; + } + return selectionEnd; + } + + private void NotifyPropertyChanged(String info) + { + var propertyChanged = PropertyChanged; + if (propertyChanged != null) + { + propertyChanged(this, new PropertyChangedEventArgs(info)); + } + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/SelectorSelectionAdapter.cs b/src/GitHub.UI/Controls/AutoCompleteBox/SelectorSelectionAdapter.cs new file mode 100644 index 0000000000..1d88f446f5 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/SelectorSelectionAdapter.cs @@ -0,0 +1,354 @@ +// (c) Copyright Microsoft Corporation. +// 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.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Windows; +using System.Windows.Automation.Peers; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; + +namespace GitHub.UI +{ + /// + /// Represents the selection adapter contained in the drop-down portion of + /// an control. + /// + /// Stable + public class SelectorSelectionAdapter : ISelectionAdapter + { + /// + /// The Selector instance. + /// + private Selector selector; + + /// + /// Gets or sets a value indicating whether the selection change event + /// should not be fired. + /// + private bool IgnoringSelectionChanged { get; set; } + + /// + /// Gets or sets the underlying control. + /// + /// The underlying control. + [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", + Justification = "We do validate the parameter. Code Analysis just doesn't see it.")] + public Selector SelectorControl + { + get { return selector; } + + set + { + if (selector != null) + { + selector.SelectionChanged -= OnSelectionChanged; + selector.MouseLeftButtonUp -= OnSelectorMouseLeftButtonUp; + } + + selector = value; + + if (selector != null) + { + selector.SelectionChanged += OnSelectionChanged; + selector.MouseLeftButtonUp += OnSelectorMouseLeftButtonUp; + } + } + } + + /// + /// Occurs when the property value changes. + /// + public event SelectionChangedEventHandler SelectionChanged; + + /// + /// Occurs when an item is selected and is committed to the underlying + /// control. + /// + public event RoutedEventHandler Commit; + + /// + /// Occurs when a selection is canceled before it is committed. + /// + public event RoutedEventHandler Cancel; + + /// + /// Initializes a new instance of the class. + /// + public SelectorSelectionAdapter() + { + } + + /// + /// Initializes a new instance of the class with the specified + /// + /// control. + /// + /// The + /// control + /// to wrap as a + /// . + public SelectorSelectionAdapter(Selector selector) + { + SelectorControl = selector; + } + + /// + /// Gets or sets the selected item of the selection adapter. + /// + /// The selected item of the underlying selection adapter. + public object SelectedItem + { + get + { + return SelectorControl == null ? null : SelectorControl.SelectedItem; + } + + set + { + IgnoringSelectionChanged = true; + if (SelectorControl != null) + { + SelectorControl.SelectedItem = value; + } + + // Attempt to reset the scroll viewer's position + if (value == null) + { + ResetScrollViewer(); + } + + IgnoringSelectionChanged = false; + } + } + + /// + /// Gets or sets a collection that is used to generate the content of + /// the selection adapter. + /// + /// The collection used to generate content for the selection + /// adapter. + public IEnumerable ItemsSource + { + get + { + return SelectorControl == null ? null : SelectorControl.ItemsSource; + } + set + { + if (SelectorControl != null) + { + SelectorControl.ItemsSource = value; + } + } + } + + /// + /// If the control contains a ScrollViewer, this will reset the viewer + /// to be scrolled to the top. + /// + private void ResetScrollViewer() + { + if (SelectorControl != null) + { + var sv = SelectorControl.GetLogicalChildrenBreadthFirst().OfType().FirstOrDefault(); + if (sv != null) + { + sv.ScrollToTop(); + } + } + } + + /// + /// Handles the mouse left button up event on the selector control. + /// + /// The source object. + /// The event data. + private void OnSelectorMouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + OnCommit(); + } + + /// + /// Handles the SelectionChanged event on the Selector control. + /// + /// The source object. + /// The selection changed event data. + private void OnSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (IgnoringSelectionChanged) + { + return; + } + + var handler = SelectionChanged; + if (handler != null) + { + handler(sender, e); + } + } + + /// + /// Increments the + /// + /// property of the underlying + /// + /// control. + /// + protected void SelectedIndexIncrement() + { + if (SelectorControl != null) + { + SelectorControl.SelectedIndex = + SelectorControl.SelectedIndex + 1 >= SelectorControl.Items.Count + ? SelectorControl.Items.Count - 1 + : SelectorControl.SelectedIndex + 1; + } + } + + /// + /// Decrements the + /// + /// property of the underlying + /// + /// control. + /// + protected void SelectedIndexDecrement() + { + if (SelectorControl != null) + { + int index = SelectorControl.SelectedIndex; + if (index >= 1) + { + SelectorControl.SelectedIndex--; + } + else + { + SelectorControl.SelectedIndex = 0; + } + } + } + + /// + /// Provides handling for the + /// event that occurs + /// when a key is pressed while the drop-down portion of the + /// has focus. + /// + /// A + /// that contains data about the + /// event. + public void HandleKeyDown(KeyEventArgs e) + { + switch (e.Key) + { + case Key.Enter: + case Key.Tab: + case Key.Right: + OnCommit(); + e.Handled = true; + break; + + case Key.Up: + SelectedIndexDecrement(); + e.Handled = true; + break; + + case Key.Down: + if ((ModifierKeys.Alt & Keyboard.Modifiers) == ModifierKeys.None) + { + SelectedIndexIncrement(); + e.Handled = true; + } + break; + + case Key.Escape: + OnCancel(); + e.Handled = true; + break; + } + } + + /// + /// Raises the + /// + /// event. + /// + protected virtual void OnCommit() + { + OnCommit(this, new RoutedEventArgs()); + } + + /// + /// Fires the Commit event. + /// + /// The source object. + /// The event data. + private void OnCommit(object sender, RoutedEventArgs e) + { + RoutedEventHandler handler = Commit; + if (handler != null) + { + handler(sender, e); + } + + AfterAdapterAction(); + } + + /// + /// Raises the + /// + /// event. + /// + protected virtual void OnCancel() + { + OnCancel(this, new RoutedEventArgs()); + } + + /// + /// Fires the Cancel event. + /// + /// The source object. + /// The event data. + private void OnCancel(object sender, RoutedEventArgs e) + { + var handler = Cancel; + if (handler != null) + { + handler(sender, e); + } + + AfterAdapterAction(); + } + + /// + /// Change the selection after the actions are complete. + /// + private void AfterAdapterAction() + { + IgnoringSelectionChanged = true; + if (SelectorControl != null) + { + SelectorControl.SelectedItem = null; + SelectorControl.SelectedIndex = -1; + } + IgnoringSelectionChanged = false; + } + + /// + /// Returns an automation peer for the underlying + /// + /// control, for use by the Silverlight automation infrastructure. + /// + /// An automation peer for use by the Silverlight automation + /// infrastructure. + public AutomationPeer CreateAutomationPeer() + { + return selector != null ? UIElementAutomationPeer.CreatePeerForElement(selector) : null; + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/TextBoxAutoCompleteTextInput.cs b/src/GitHub.UI/Controls/AutoCompleteBox/TextBoxAutoCompleteTextInput.cs new file mode 100644 index 0000000000..e6cff0d94e --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/TextBoxAutoCompleteTextInput.cs @@ -0,0 +1,120 @@ +using System; +using System.ComponentModel; +using System.Reactive.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Markup; +using ReactiveUI.Wpf; +using ReactiveUI; + +namespace GitHub.UI +{ + [ContentProperty("TextBox")] + public class TextBoxAutoCompleteTextInput : IAutoCompleteTextInput + { + TextBox textBox; + + public event PropertyChangedEventHandler PropertyChanged; + + public void Select(int position, int length) + { + textBox.Select(position, length); + } + + public void SelectAll() + { + textBox.SelectAll(); + } + + public int CaretIndex + { + get { return textBox.CaretIndex; } + set { textBox.CaretIndex = value; } + } + + public int SelectionStart + { + get { return textBox.SelectionStart; } + set { textBox.SelectionStart = value; } + } + + public int SelectionLength + { + get { return textBox.SelectionLength; } + } + + public string Text + { + get { return textBox.Text; } + set { textBox.Text = value; } + } + + public IObservable PreviewKeyDown + { + get; + private set; + } + + public IObservable SelectionChanged { get; private set; } + public IObservable TextChanged { get; private set; } + public UIElement Control { get { return textBox; } } + + public Point GetPositionFromCharIndex(int charIndex) + { + var position = textBox.GetRectFromCharacterIndex(charIndex).BottomLeft; + position.Offset(0, 10); // Vertically pad it. Yeah, Point is mutable. WTF? + return position; + } + + public void Focus() + { + Keyboard.Focus(textBox); + } + + public TextBox TextBox + { + get + { + return textBox; + } + set + { + if (value != textBox) + { + textBox = value; + + PreviewKeyDown = Observable.FromEvent( + h => textBox.PreviewKeyDown += h, + h => textBox.PreviewKeyDown -= h); + + SelectionChanged = Observable.FromEvent( + h => textBox.SelectionChanged += h, + h => textBox.SelectionChanged -= h); + + TextChanged = Observable.FromEvent( + h => textBox.TextChanged += h, + h => textBox.TextChanged -= h); + + NotifyPropertyChanged("Control"); + } + } + } + + public Thickness Margin + { + get { return textBox.Margin; } + set { textBox.Margin = value; } + } + + private void NotifyPropertyChanged(String info) + { + var propertyChanged = PropertyChanged; + if (propertyChanged != null) + { + propertyChanged(this, new PropertyChangedEventArgs(info)); + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/VisualStates.cs b/src/GitHub.UI/Controls/AutoCompleteBox/VisualStates.cs new file mode 100644 index 0000000000..6aa065fdaf --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/VisualStates.cs @@ -0,0 +1,409 @@ +// (c) Copyright Microsoft Corporation. +// 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.Diagnostics; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace GitHub.UI +{ + /// + /// Names and helpers for visual states in the controls. + /// + internal static class VisualStates + { + #region GroupCommon + /// + /// Common state group. + /// + public const string GroupCommon = "CommonStates"; + + /// + /// Normal state of the Common state group. + /// + public const string StateNormal = "Normal"; + + /// + /// Normal state of the Common state group. + /// + public const string StateReadOnly = "ReadOnly"; + + /// + /// MouseOver state of the Common state group. + /// + public const string StateMouseOver = "MouseOver"; + + /// + /// Pressed state of the Common state group. + /// + public const string StatePressed = "Pressed"; + + /// + /// Disabled state of the Common state group. + /// + public const string StateDisabled = "Disabled"; + #endregion GroupCommon + + #region GroupFocus + /// + /// Focus state group. + /// + public const string GroupFocus = "FocusStates"; + + /// + /// Unfocused state of the Focus state group. + /// + public const string StateUnfocused = "Unfocused"; + + /// + /// Focused state of the Focus state group. + /// + public const string StateFocused = "Focused"; + #endregion GroupFocus + + #region GroupSelection + /// + /// Selection state group. + /// + public const string GroupSelection = "SelectionStates"; + + /// + /// Selected state of the Selection state group. + /// + public const string StateSelected = "Selected"; + + /// + /// Unselected state of the Selection state group. + /// + public const string StateUnselected = "Unselected"; + + /// + /// Selected inactive state of the Selection state group. + /// + public const string StateSelectedInactive = "SelectedInactive"; + #endregion GroupSelection + + #region GroupExpansion + /// + /// Expansion state group. + /// + public const string GroupExpansion = "ExpansionStates"; + + /// + /// Expanded state of the Expansion state group. + /// + public const string StateExpanded = "Expanded"; + + /// + /// Collapsed state of the Expansion state group. + /// + public const string StateCollapsed = "Collapsed"; + #endregion GroupExpansion + + #region GroupPopup + /// + /// Popup state group. + /// + public const string GroupPopup = "PopupStates"; + + /// + /// Opened state of the Popup state group. + /// + public const string StatePopupOpened = "PopupOpened"; + + /// + /// Closed state of the Popup state group. + /// + public const string StatePopupClosed = "PopupClosed"; + #endregion + + #region GroupValidation + /// + /// ValidationStates state group. + /// + public const string GroupValidation = "ValidationStates"; + + /// + /// The valid state for the ValidationStates group. + /// + public const string StateValid = "Valid"; + + /// + /// Invalid, focused state for the ValidationStates group. + /// + public const string StateInvalidFocused = "InvalidFocused"; + + /// + /// Invalid, unfocused state for the ValidationStates group. + /// + public const string StateInvalidUnfocused = "InvalidUnfocused"; + #endregion + + #region GroupExpandDirection + /// + /// ExpandDirection state group. + /// + public const string GroupExpandDirection = "ExpandDirectionStates"; + + /// + /// Down expand direction state of ExpandDirection state group. + /// + public const string StateExpandDown = "ExpandDown"; + + /// + /// Up expand direction state of ExpandDirection state group. + /// + public const string StateExpandUp = "ExpandUp"; + + /// + /// Left expand direction state of ExpandDirection state group. + /// + public const string StateExpandLeft = "ExpandLeft"; + + /// + /// Right expand direction state of ExpandDirection state group. + /// + public const string StateExpandRight = "ExpandRight"; + #endregion + + #region GroupHasItems + /// + /// HasItems state group. + /// + public const string GroupHasItems = "HasItemsStates"; + + /// + /// HasItems state of the HasItems state group. + /// + public const string StateHasItems = "HasItems"; + + /// + /// NoItems state of the HasItems state group. + /// + public const string StateNoItems = "NoItems"; + #endregion GroupHasItems + + #region GroupIncrease + /// + /// Increment state group. + /// + public const string GroupIncrease = "IncreaseStates"; + + /// + /// State enabled for increment group. + /// + public const string StateIncreaseEnabled = "IncreaseEnabled"; + + /// + /// State disabled for increment group. + /// + public const string StateIncreaseDisabled = "IncreaseDisabled"; + #endregion GroupIncrease + + #region GroupDecrease + /// + /// Decrement state group. + /// + public const string GroupDecrease = "DecreaseStates"; + + /// + /// State enabled for decrement group. + /// + public const string StateDecreaseEnabled = "DecreaseEnabled"; + + /// + /// State disabled for decrement group. + /// + public const string StateDecreaseDisabled = "DecreaseDisabled"; + #endregion GroupDecrease + + #region GroupIteractionMode + /// + /// InteractionMode state group. + /// + public const string GroupInteractionMode = "InteractionModeStates"; + + /// + /// Edit of the DisplayMode state group. + /// + public const string StateEdit = "Edit"; + + /// + /// Display of the DisplayMode state group. + /// + public const string StateDisplay = "Display"; + #endregion GroupIteractionMode + + #region GroupLocked + /// + /// DisplayMode state group. + /// + public const string GroupLocked = "LockedStates"; + + /// + /// Edit of the DisplayMode state group. + /// + public const string StateLocked = "Locked"; + + /// + /// Display of the DisplayMode state group. + /// + public const string StateUnlocked = "Unlocked"; + #endregion GroupLocked + + #region GroupActive + /// + /// Active state. + /// + public const string StateActive = "Active"; + + /// + /// Inactive state. + /// + public const string StateInactive = "Inactive"; + + /// + /// Active state group. + /// + public const string GroupActive = "ActiveStates"; + #endregion GroupActive + + #region GroupWatermark + /// + /// Non-watermarked state. + /// + public const string StateUnwatermarked = "Unwatermarked"; + + /// + /// Watermarked state. + /// + public const string StateWatermarked = "Watermarked"; + + /// + /// Watermark state group. + /// + public const string GroupWatermark = "WatermarkStates"; + #endregion GroupWatermark + + #region GroupCalendarButtonFocus + /// + /// Unfocused state for Calendar Buttons. + /// + public const string StateCalendarButtonUnfocused = "CalendarButtonUnfocused"; + + /// + /// Focused state for Calendar Buttons. + /// + public const string StateCalendarButtonFocused = "CalendarButtonFocused"; + + /// + /// CalendarButtons Focus state group. + /// + public const string GroupCalendarButtonFocus = "CalendarButtonFocusStates"; + #endregion GroupCalendarButtonFocus + + #region GroupBusyStatus + /// + /// Busy state for BusyIndicator. + /// + public const string StateBusy = "Busy"; + + /// + /// Idle state for BusyIndicator. + /// + public const string StateIdle = "Idle"; + + /// + /// Busyness group name. + /// + public const string GroupBusyStatus = "BusyStatusStates"; + #endregion + + #region GroupVisibility + /// + /// Visible state name for BusyIndicator. + /// + public const string StateVisible = "Visible"; + + /// + /// Hidden state name for BusyIndicator. + /// + public const string StateHidden = "Hidden"; + + /// + /// BusyDisplay group. + /// + public const string GroupVisibility = "VisibilityStates"; + #endregion + + /// + /// Use VisualStateManager to change the visual state of the control. + /// + /// + /// Control whose visual state is being changed. + /// + /// + /// A value indicating whether to use transitions when updating the + /// visual state, or to snap directly to the new visual state. + /// + /// + /// Ordered list of state names and fallback states to transition into. + /// Only the first state to be found will be used. + /// + public static void GoToState(Control control, bool useTransitions, params string[] stateNames) + { + Debug.Assert(control != null, "control should not be null!"); + Debug.Assert(stateNames != null, "stateNames should not be null!"); + Debug.Assert(stateNames.Length > 0, "stateNames should not be empty!"); + + foreach (string name in stateNames) + { + if (VisualStateManager.GoToState(control, name, useTransitions)) + { + break; + } + } + } + + /// + /// Gets the implementation root of the Control. + /// + /// The DependencyObject. + /// + /// Implements Silverlight's corresponding internal property on Control. + /// + /// Returns the implementation root or null. + public static FrameworkElement GetImplementationRoot(DependencyObject dependencyObject) + { + Debug.Assert(dependencyObject != null, "DependencyObject should not be null."); + return (1 == VisualTreeHelper.GetChildrenCount(dependencyObject)) ? + VisualTreeHelper.GetChild(dependencyObject, 0) as FrameworkElement : + null; + } + + /// + /// This method tries to get the named VisualStateGroup for the + /// dependency object. The provided object's ImplementationRoot will be + /// looked up in this call. + /// + /// The dependency object. + /// The visual state group's name. + /// Returns null or the VisualStateGroup object. + public static VisualStateGroup TryGetVisualStateGroup(DependencyObject dependencyObject, string groupName) + { + var root = GetImplementationRoot(dependencyObject); + if (root == null) + { + return null; + } + + return VisualStateManager.GetVisualStateGroups(root) + .OfType() + .FirstOrDefault(group => string.CompareOrdinal(groupName, @group.Name) == 0); + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/VisualTreeExtensions.cs b/src/GitHub.UI/Controls/AutoCompleteBox/VisualTreeExtensions.cs new file mode 100644 index 0000000000..b6cc0966c5 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/VisualTreeExtensions.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Windows; +using System.Windows.Media; + +namespace GitHub.UI +{ + public static class VisualTreeExtensions + { + /// + /// Retrieves all the visual children of a framework element. + /// + /// The parent framework element. + /// The visual children of the framework element. + internal static IEnumerable GetVisualChildren(this DependencyObject parent) + { + Debug.Assert(parent != null, "The parent cannot be null."); + + int childCount = VisualTreeHelper.GetChildrenCount(parent); + for (int counter = 0; counter < childCount; counter++) + { + yield return VisualTreeHelper.GetChild(parent, counter); + } + } + + /// + /// Retrieves all the logical children of a framework element using a + /// breadth-first search. A visual element is assumed to be a logical + /// child of another visual element if they are in the same namescope. + /// For performance reasons this method manually manages the queue + /// instead of using recursion. + /// + /// + /// License for this method. + /// + /// (c) Copyright Microsoft Corporation. + /// 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. + /// + /// The parent framework element. + /// The logical children of the framework element. + internal static IEnumerable GetLogicalChildrenBreadthFirst(this FrameworkElement parent) + { + Debug.Assert(parent != null, "The parent cannot be null."); + + var queue = new Queue(parent.GetVisualChildren().OfType()); + + while (queue.Count > 0) + { + var element = queue.Dequeue(); + yield return element; + + foreach (var visualChild in element.GetVisualChildren().OfType()) + { + queue.Enqueue(visualChild); + } + } + } + + internal static Window GetActiveWindow(this Application application) + { + var windows = application.Windows; + if (windows.Count == 0) return null; + return windows.Count == 1 + ? windows[0] // Optimization. I think this is the common case for us. + : windows.Cast().FirstOrDefault(x => x.IsActive); + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/WeakEventListener.cs b/src/GitHub.UI/Controls/AutoCompleteBox/WeakEventListener.cs new file mode 100644 index 0000000000..f9a76e5581 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/WeakEventListener.cs @@ -0,0 +1,84 @@ +// (c) Copyright Microsoft Corporation. +// 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.Diagnostics.CodeAnalysis; + +namespace GitHub.UI +{ + /// + /// Implements a weak event listener that allows the owner to be garbage + /// collected if its only remaining link is an event handler. + /// + /// Type of instance listening for the event. + /// Type of source for the event. + /// Type of event arguments for the event. + [SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Used as link target in several projects.")] + internal class WeakEventListener where TInstance : class + { + /// + /// WeakReference to the instance listening for the event. + /// + private readonly WeakReference weakInstance; + + /// + /// Gets or sets the method to call when the event fires. + /// + public Action OnEventAction { get; set; } + + /// + /// Gets or sets the method to call when detaching from the event. + /// + public Action> OnDetachAction { get; set; } + + /// + /// Initializes a new instances of the WeakEventListener class. + /// + /// Instance subscribing to the event. + public WeakEventListener(TInstance instance) + { + if (null == instance) + { + throw new ArgumentNullException("instance"); + } + weakInstance = new WeakReference(instance); + } + + /// + /// Handler for the subscribed event calls OnEventAction to handle it. + /// + /// Event source. + /// Event arguments. + public void OnEvent(TSource source, TEventArgs eventArgs) + { + var target = (TInstance)weakInstance.Target; + if (null != target) + { + // Call registered action + if (null != OnEventAction) + { + OnEventAction(target, source, eventArgs); + } + } + else + { + // Detach from event + Detach(); + } + } + + /// + /// Detaches from the subscribed event. + /// + public void Detach() + { + if (null != OnDetachAction) + { + OnDetachAction(this); + OnDetachAction = null; + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/_README.md b/src/GitHub.UI/Controls/AutoCompleteBox/_README.md new file mode 100644 index 0000000000..c601dbfeda --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/_README.md @@ -0,0 +1,40 @@ +# WPF Toolkit + +This folder contains code copied and adapted from the [WPF Toolkit](http://wpf.codeplex.com/) project under the MS-PL +license. + +This contains the AutoCompleteBox code. + +# LICENSE + +Microsoft Public License (Ms-PL) + +This license governs use of the accompanying software. If you use the software, you accept this license. If you do not accept the license, do not use the software. + +1. Definitions + +The terms "reproduce," "reproduction," "derivative works," and "distribution" have the same meaning here as under U.S. copyright law. + +A "contribution" is the original software, or any additions or changes to the software. + +A "contributor" is any person that distributes its contribution under this license. + +"Licensed patents" are a contributor's patent claims that read directly on its contribution. + +2. Grant of Rights + +(A) Copyright Grant- Subject to the terms of this license, including the license conditions and limitations in section 3, each contributor grants you a non-exclusive, worldwide, royalty-free copyright license to reproduce its contribution, prepare derivative works of its contribution, and distribute its contribution or any derivative works that you create. + +(B) Patent Grant- Subject to the terms of this license, including the license conditions and limitations in section 3, each contributor grants you a non-exclusive, worldwide, royalty-free license under its licensed patents to make, have made, use, sell, offer for sale, import, and/or otherwise dispose of its contribution in the software or derivative works of the contribution in the software. + +3. Conditions and Limitations + +(A) No Trademark License- This license does not grant you rights to use any contributors' name, logo, or trademarks. + +(B) If you bring a patent claim against any contributor over patents that you claim are infringed by the software, your patent license from such contributor to the software ends automatically. + +(C) If you distribute any portion of the software, you must retain all copyright, patent, trademark, and attribution notices that are present in the software. + +(D) If you distribute any portion of the software in source code form, you may do so only under this license by including a complete copy of this license with your distribution. If you distribute any portion of the software in compiled or object code form, you may only do so under a license that complies with this license. + +(E) The software is licensed "as-is." You bear the risk of using it. The contributors give no express warranties, guarantees or conditions. You may have additional consumer rights under your local laws which this license cannot change. To the extent permitted under your local laws, the contributors exclude the implied warranties of merchantability, fitness for a particular purpose and non-infringement. \ No newline at end of file diff --git a/src/GitHub.UI/GitHub.UI.csproj b/src/GitHub.UI/GitHub.UI.csproj index e07adc819e..9829ed9dc8 100644 --- a/src/GitHub.UI/GitHub.UI.csproj +++ b/src/GitHub.UI/GitHub.UI.csproj @@ -12,6 +12,8 @@ + + diff --git a/src/GitHub.UI/Helpers/Dpi.cs b/src/GitHub.UI/Helpers/Dpi.cs new file mode 100644 index 0000000000..c95981bf21 --- /dev/null +++ b/src/GitHub.UI/Helpers/Dpi.cs @@ -0,0 +1,65 @@ +using System; +using System.Windows; + +namespace GitHub.UI +{ + public struct Dpi + { + const double defaultDpi = 96.0; + readonly double horizontal; + readonly double horizontalScale; + readonly double vertical; + readonly double verticalScale; + + public static readonly Dpi Default = new Dpi(defaultDpi, defaultDpi); + + public Dpi(double horizontal, double vertical) + { + this.horizontal = horizontal; + horizontalScale = horizontal / defaultDpi; + this.vertical = vertical; + verticalScale = vertical / defaultDpi; + } + + public double Horizontal { get { return horizontal; } } + public double HorizontalScale { get { return horizontalScale; } } + public double Vertical { get { return vertical; } } + public double VerticalScale { get { return verticalScale; } } + + public Point Scale(Point point) + { + return new Point(point.X * HorizontalScale, point.Y * VerticalScale); + } + + public override bool Equals(object obj) + { + return obj is Dpi && this == (Dpi)obj; + } + + public override int GetHashCode() + { + // Implementation from Jon Skeet: http://stackoverflow.com/questions/263400/what-is-the-best-algorithm-for-an-overridden-system-object-gethashcode + unchecked // Overflow is fine, just wrap + { + int hash = (int)2166136261; + hash = hash * 16777619 ^ Horizontal.GetHashCode(); + hash = hash * 16777619 ^ Vertical.GetHashCode(); + return hash; + } + } + + public static bool operator ==(Dpi x, Dpi y) + { + const double epsilon = 0.001; + + // Since we're comparing double's we need to use an epsilon because LOL floating point numbers. + return Math.Abs(x.Horizontal - y.Horizontal) < epsilon + && Math.Abs(x.Vertical - y.Vertical) < epsilon; + } + + public static bool operator !=(Dpi x, Dpi y) + { + return !(x == y); + } + } +} diff --git a/src/GitHub.UI/Helpers/DpiManager.cs b/src/GitHub.UI/Helpers/DpiManager.cs new file mode 100644 index 0000000000..092bdafd43 --- /dev/null +++ b/src/GitHub.UI/Helpers/DpiManager.cs @@ -0,0 +1,21 @@ +namespace GitHub.UI.Helpers +{ + public interface IDpiManager + { + Dpi CurrentDpi { get; set; } + } + + public class DpiManager : IDpiManager + { + static readonly DpiManager dpiManagerInstance = new DpiManager(); + + public static DpiManager Instance { get { return dpiManagerInstance; } } + + private DpiManager() + { + CurrentDpi = Dpi.Default; + } + + public Dpi CurrentDpi { get; set; } + } +} From 6d1dab1912babbdf8050b4504e39289daeabb7b7 Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Thu, 3 Jan 2019 10:06:36 -0500 Subject: [PATCH 02/44] Importing tests (set to not compile) --- .../Controls/AutoCompleteBoxTests.cs | 212 ++++++++++++++++++ .../Controls/AutoCompleteSuggestionTests.cs | 58 +++++ .../AutoCompleteTextInputExtensionsTests.cs | 25 +++ .../GitHub.UI.UnitTests.csproj | 11 + 4 files changed, 306 insertions(+) create mode 100644 test/GitHub.UI.UnitTests/Controls/AutoCompleteBoxTests.cs create mode 100644 test/GitHub.UI.UnitTests/Controls/AutoCompleteSuggestionTests.cs create mode 100644 test/GitHub.UI.UnitTests/Controls/AutoCompleteTextInputExtensionsTests.cs diff --git a/test/GitHub.UI.UnitTests/Controls/AutoCompleteBoxTests.cs b/test/GitHub.UI.UnitTests/Controls/AutoCompleteBoxTests.cs new file mode 100644 index 0000000000..ace2f2b467 --- /dev/null +++ b/test/GitHub.UI.UnitTests/Controls/AutoCompleteBoxTests.cs @@ -0,0 +1,212 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Reactive.Linq; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media.Imaging; +using GitHub.Tests.TestHelpers; +using GitHub.UI; +using GitHub.UI.Helpers; +using Moq; +using Xunit; + +public class AutoCompleteBoxTests +{ + public class TheItemsSourceProperty + { + [STAFact] + public void SelectsFirstItemWhenSetToNonEmptyCollection() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("aaaa", obs, ":", ":"), + new AutoCompleteSuggestion("bbbb", obs, ":", ":"), + new AutoCompleteSuggestion("ccc", obs, ":", ":") + }; + var result = new AutoCompleteResult(1, new ReadOnlyCollection(suggestions)); + var advisor = Mock.Of( + a => a.GetAutoCompletionSuggestions(Args.String, Args.Int32) == Observable.Return(result)); + var textBox = new TextBox(); + var autoCompleteBox = new AutoCompleteBox(Mock.Of()) + { + SelectionAdapter = new SelectorSelectionAdapter(new ListBox()), + Advisor = advisor, + TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } + }; + + textBox.Text = ":"; + + Assert.Equal("aaaa", ((AutoCompleteSuggestion) autoCompleteBox.SelectedItem).Name); + Assert.Equal(":", autoCompleteBox.Text); // It should not have expanded it yet + } + } + + public class TheIsDropDownOpenProperty + { + [STAFact] + public void IsTrueWhenTextBoxChangesWithPrefixedValue() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("aaaa", obs, ":", ":"), + new AutoCompleteSuggestion("bbbb", obs, ":", ":"), + new AutoCompleteSuggestion("ccc", obs, ":", ":") + }; + var result = new AutoCompleteResult(0, new ReadOnlyCollection(suggestions)); + var advisor = Mock.Of( + a => a.GetAutoCompletionSuggestions(Args.String, Args.Int32) == Observable.Return(result)); + var textBox = new TextBox(); + var autoCompleteBox = new AutoCompleteBox(Mock.Of()) + { + SelectionAdapter = new SelectorSelectionAdapter(new ListBox()), + Advisor = advisor, + TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } + }; + + textBox.Text = ":"; + + Assert.True(autoCompleteBox.IsDropDownOpen); + } + + [STAFact] + public void IsFalseAfterASuggestionIsSelected() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("aaaa", obs, ":", ":"), + new AutoCompleteSuggestion("bbbb", obs, ":", ":"), + new AutoCompleteSuggestion("ccc", obs, ":", ":") + }; + var result = new AutoCompleteResult(2, new ReadOnlyCollection(suggestions)); + var advisor = Mock.Of( + a => a.GetAutoCompletionSuggestions(Args.String, Args.Int32) == Observable.Return(result)); + var selectionAdapter = new TestSelectorSelectionAdapter(); + var textBox = new TextBox(); + var autoCompleteBox = new AutoCompleteBox(Mock.Of()) + { + SelectionAdapter = selectionAdapter, + Advisor = advisor, + TextBox = new TextBoxAutoCompleteTextInput {TextBox = textBox} + }; + textBox.Text = "A :a"; + textBox.CaretIndex = 4; + Assert.Equal(4, textBox.CaretIndex); + Assert.Equal(4, autoCompleteBox.TextBox.CaretIndex); + Assert.True(autoCompleteBox.IsDropDownOpen); + + selectionAdapter.DoCommit(); + + Assert.Equal("A :aaaa: ", textBox.Text); + Assert.False(autoCompleteBox.IsDropDownOpen); + } + + [STAFact] + public void IsFalseAfterASuggestionIsCancelled() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("aaaa", obs, ":", ":"), + new AutoCompleteSuggestion("bbbb", obs, ":", ":"), + new AutoCompleteSuggestion("ccc", obs, ":", ":") + }; + var result = new AutoCompleteResult(2, new ReadOnlyCollection(suggestions)); + var advisor = Mock.Of( + a => a.GetAutoCompletionSuggestions(Args.String, Args.Int32) == Observable.Return(result)); + var selectionAdapter = new TestSelectorSelectionAdapter(); + var textBox = new TextBox(); + var autoCompleteBox = new AutoCompleteBox(Mock.Of()) + { + SelectionAdapter = selectionAdapter, + Advisor = advisor, + TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } + }; + textBox.Text = "A :a"; + textBox.CaretIndex = 4; + Assert.Equal(4, textBox.CaretIndex); + Assert.Equal(4, autoCompleteBox.TextBox.CaretIndex); + Assert.True(autoCompleteBox.IsDropDownOpen); + + selectionAdapter.DoCancel(); + + Assert.Equal("A :a", textBox.Text); + Assert.False(autoCompleteBox.IsDropDownOpen); + } + + [STAFact] + public void HandlesKeyPressesToSelectAndCancelSelections() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("aaaa", obs, ":", ":"), + new AutoCompleteSuggestion("bbbb", obs, ":", ":"), + new AutoCompleteSuggestion("ccc", obs, ":", ":") + }; + var result = new AutoCompleteResult(2, new ReadOnlyCollection(suggestions)); + var advisor = Mock.Of( + a => a.GetAutoCompletionSuggestions(Args.String, Args.Int32) == Observable.Return(result)); + var selectionAdapter = new TestSelectorSelectionAdapter(); + var textBox = new TextBox(); + var autoCompleteBox = new AutoCompleteBox(Mock.Of()) + { + SelectionAdapter = selectionAdapter, + Advisor = advisor, + TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } + }; + textBox.Text = "A :a"; + textBox.CaretIndex = 4; + Assert.Equal(4, textBox.CaretIndex); + Assert.Equal(4, autoCompleteBox.TextBox.CaretIndex); + Assert.True(autoCompleteBox.IsDropDownOpen); + selectionAdapter.SelectorControl.SelectedIndex = 1; // Select the second item + + selectionAdapter.DoKeyDown(Key.Enter); + + Assert.Equal("A :bbbb: ", textBox.Text); + Assert.False(autoCompleteBox.IsDropDownOpen); + + textBox.Text = "A :bbbb: :"; + textBox.CaretIndex = 10; + + // Ensure we can re-open the dropdown + Assert.True(autoCompleteBox.IsDropDownOpen); + + selectionAdapter.DoKeyDown(Key.Escape); + Assert.False(autoCompleteBox.IsDropDownOpen); + Assert.Equal("A :bbbb: :", textBox.Text); + } + + class TestSelectorSelectionAdapter : SelectorSelectionAdapter + { + public TestSelectorSelectionAdapter() + : base(new ListBox()) + { + } + + public void DoCommit() + { + base.OnCommit(); + } + + public void DoCancel() + { + base.OnCancel(); + } + + public void DoKeyDown(Key key) + { + var keyEventArgs = FakeKeyEventArgs.Create(key, false); + HandleKeyDown(keyEventArgs); + } + } + } +} diff --git a/test/GitHub.UI.UnitTests/Controls/AutoCompleteSuggestionTests.cs b/test/GitHub.UI.UnitTests/Controls/AutoCompleteSuggestionTests.cs new file mode 100644 index 0000000000..66b3d28728 --- /dev/null +++ b/test/GitHub.UI.UnitTests/Controls/AutoCompleteSuggestionTests.cs @@ -0,0 +1,58 @@ +using System.Reactive.Linq; +using System.Windows.Media.Imaging; +using GitHub.UI; +using NUnit.Framework; +using Xunit; + +public class AutoCompleteSuggestionTests +{ + public class TheToStringMethod + { + [Theory] + [InlineData(":", ":", ":foo:")] + [InlineData("@", "", "@foo")] + [InlineData("#", "", "#foo")] + [InlineData("@", null, "@foo")] + public void ReturnsWordSurroundedByPrefixAndSuffix(string prefix, string suffix, string expected) + { + var obs = Observable.Return(new BitmapImage()); + var suggestion = new AutoCompleteSuggestion("foo", obs, prefix, suffix); + Assert.Equal(expected, suggestion.ToString()); + } + } + + public class TheGetSortRankMethod + { + [Theory] + [InlineData("pat", "full name", 1)] + [InlineData("yosemite", "pat name", 0)] + [InlineData("minnie", "full pat", 0)] + [InlineData("patrick", "full name", 1)] + [InlineData("groot", "patrick name", 0)] + [InlineData("driver", "danica patrick", 0)] + [InlineData("patricka", "pat name", 1)] + [InlineData("nomatch", "full name", -1)] + public void ReturnsCorrectScoreForSuggestions(string login, string name, int expectedRank) + { + var obs = Observable.Return(new BitmapImage()); + + var suggestion = new AutoCompleteSuggestion(login, name, obs, "@", ""); + + int rank = suggestion.GetSortRank("pat"); + + Assert.Equal(expectedRank, rank); + } + + [Fact] + public void ReturnsOneForEmptyString() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestion = new AutoCompleteSuggestion("joe", "namathe", obs, "@", ""); + + int rank = suggestion.GetSortRank(""); + + Assert.Equal(1, rank); + } + } +} diff --git a/test/GitHub.UI.UnitTests/Controls/AutoCompleteTextInputExtensionsTests.cs b/test/GitHub.UI.UnitTests/Controls/AutoCompleteTextInputExtensionsTests.cs new file mode 100644 index 0000000000..e53a250818 --- /dev/null +++ b/test/GitHub.UI.UnitTests/Controls/AutoCompleteTextInputExtensionsTests.cs @@ -0,0 +1,25 @@ +using GitHub.UI; +using GitHub.UI.Controls.AutoCompleteBox; +using Moq; +using Xunit; + +class AutoCompleteTextInputExtensionsTests +{ + public class TheGetExpandedTextMethod + { + [Theory] + [InlineData(":", 1, 0, ":apple: ")] + [InlineData(":a", 2, 0, ":apple: ")] + [InlineData(":ap", 3, 0, ":apple: ")] + [InlineData(":a", 1, 0, ":apple: a")] + [InlineData("Test :", 6, 5, "Test :apple: ")] + [InlineData("Test :ap", 8, 5, "Test :apple: ")] + [InlineData("Test :apother stuff", 8, 5, "Test :apple: other stuff")] + public void ReturnsExpandedText(string text, int caretIndex, int completionOffset, string expected) + { + var textInput = Mock.Of(t => t.CaretIndex == caretIndex && t.Text == text); + var expandedText = textInput.GetExpandedText(":apple:", completionOffset); + Assert.Equal(expected, expandedText); + } + } +} diff --git a/test/GitHub.UI.UnitTests/GitHub.UI.UnitTests.csproj b/test/GitHub.UI.UnitTests/GitHub.UI.UnitTests.csproj index a617e415be..b7796d7021 100644 --- a/test/GitHub.UI.UnitTests/GitHub.UI.UnitTests.csproj +++ b/test/GitHub.UI.UnitTests/GitHub.UI.UnitTests.csproj @@ -2,6 +2,11 @@ net46 + + + + + @@ -22,6 +27,12 @@ + + + + + + From 83cdda5157f9a1f38e3e10bed1eba8ab4ad2061a Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Thu, 3 Jan 2019 10:41:53 -0500 Subject: [PATCH 03/44] Adding AutoCompleteSuggestionView --- .../Views/AutoCompleteSuggestionView.xaml | 27 +++++++++++ .../Views/AutoCompleteSuggestionView.xaml.cs | 48 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml create mode 100644 src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml.cs diff --git a/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml b/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml new file mode 100644 index 0000000000..c7869572e6 --- /dev/null +++ b/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml.cs b/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml.cs new file mode 100644 index 0000000000..d0c52b33f7 --- /dev/null +++ b/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml.cs @@ -0,0 +1,48 @@ +using System.Reactive.Linq; +using System.Windows; +using GitHub.Extensions; +using GitHub.Extensions.Reactive; +using GitHub.UI; +using ReactiveUI; + +namespace GitHub.UI +{ + /// + /// Interaction logic for AutoCompleteSuggestionView.xaml + /// + public partial class AutoCompleteSuggestionView : IViewFor + { + public AutoCompleteSuggestionView() + { + InitializeComponent(); + this.WhenActivated(d => + { + d(this.OneWayBind(ViewModel, vm => vm.Name, v => v.name.Text)); + d(this.OneWayBind(ViewModel, vm => vm.Description, v => v.description.Text)); + + var imageObservable = this.WhenAnyObservable(v => v.ViewModel.Image); + d(imageObservable.WhereNotNull().BindTo(this, v => v.image.Source)); + d(imageObservable.Select(image => image != null).BindTo(this, v => v.image.Visibility)); + d(imageObservable.Select(image => image != null) + .Select(visible => new Thickness((visible ? 5 : 0), 0, 0, 0)) + .BindTo(this, v => v.suggestionText.Margin)); + }); + } + + public AutoCompleteSuggestion ViewModel + { + get { return (AutoCompleteSuggestion)GetValue(ViewModelProperty); } + set { SetValue(ViewModelProperty, value); } + } + + public static readonly DependencyProperty ViewModelProperty = DependencyProperty + .Register("ViewModel", typeof(AutoCompleteSuggestion), typeof(AutoCompleteSuggestionView), + new PropertyMetadata(null)); + + object IViewFor.ViewModel + { + get { return ViewModel; } + set { ViewModel = (AutoCompleteSuggestion)value; } + } + } +} From 446a08046fff59ddd4ff53094df4fe69faad29be Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Thu, 3 Jan 2019 10:56:24 -0500 Subject: [PATCH 04/44] Adding missing advisors --- src/GitHub.UI/Helpers/AutoCompleteAdvisor.cs | 119 ++++++++++++++++++ .../Helpers/EmojiAutoCompleteSource.cs | 41 ++++++ src/GitHub.UI/Helpers/IAutoCompleteSource.cs | 13 ++ .../Helpers/IssuesAutoCompleteSource.cs | 90 +++++++++++++ .../Helpers/MentionsAutoCompleteSource.cs | 75 +++++++++++ 5 files changed, 338 insertions(+) create mode 100644 src/GitHub.UI/Helpers/AutoCompleteAdvisor.cs create mode 100644 src/GitHub.UI/Helpers/EmojiAutoCompleteSource.cs create mode 100644 src/GitHub.UI/Helpers/IAutoCompleteSource.cs create mode 100644 src/GitHub.UI/Helpers/IssuesAutoCompleteSource.cs create mode 100644 src/GitHub.UI/Helpers/MentionsAutoCompleteSource.cs diff --git a/src/GitHub.UI/Helpers/AutoCompleteAdvisor.cs b/src/GitHub.UI/Helpers/AutoCompleteAdvisor.cs new file mode 100644 index 0000000000..1c06d0ac96 --- /dev/null +++ b/src/GitHub.UI/Helpers/AutoCompleteAdvisor.cs @@ -0,0 +1,119 @@ +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.UI; +using NLog; + +namespace GitHub.Helpers +{ + [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 Logger log = LogManager.GetCurrentClassLogger(); + readonly Lazy> prefixSourceMap; + + [ImportingConstructor] + public AutoCompleteAdvisor([ImportMany]IEnumerable autocompleteSources) + { + prefixSourceMap = new Lazy>( + () => autocompleteSources.ToDictionary(s => s.Prefix, s => s)); + } + + public IObservable GetAutoCompletionSuggestions(string text, int caretPosition) + { + Ensure.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.Info(e); + 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) + { + Ensure.ArgumentNotNull("text", text); + Ensure.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) + { + Ensure.ArgumentNotNull(searchPrefix, "searchPrefix"); + Ensure.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.UI/Helpers/EmojiAutoCompleteSource.cs b/src/GitHub.UI/Helpers/EmojiAutoCompleteSource.cs new file mode 100644 index 0000000000..2dbf7d0750 --- /dev/null +++ b/src/GitHub.UI/Helpers/EmojiAutoCompleteSource.cs @@ -0,0 +1,41 @@ +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive.Linq; +using System.Windows.Media.Imaging; +using GitHub.UI; + +namespace GitHub.Helpers +{ + [Export(typeof(IAutoCompleteSource))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class EmojiAutoCompleteSource : IAutoCompleteSource + { + readonly IEmojiCache emojiCache; + + [ImportingConstructor] + public EmojiAutoCompleteSource(IEmojiCache emojiCache) + { + Ensure.ArgumentNotNull(emojiCache, "emojiCache"); + + this.emojiCache = emojiCache; + } + + public IObservable GetSuggestions() + { + Func> resolveImage = uri => + Observable.Defer(() => + { + var resourcePath = "pack://application:,,,/GitHub;component/" + uri; + return Observable.Return(App.CreateBitmapImage(resourcePath)); + }); + + return emojiCache.GetEmojis() + .Where(emoji => !String.IsNullOrEmpty(emoji.Name)) // Just being extra cautious. + .Select(emoji => new AutoCompleteSuggestion(emoji.Name, resolveImage(emoji.IconKey), ":", ":")) + .ToObservable(); + } + + public string Prefix { get { return ":"; } } + } +} diff --git a/src/GitHub.UI/Helpers/IAutoCompleteSource.cs b/src/GitHub.UI/Helpers/IAutoCompleteSource.cs new file mode 100644 index 0000000000..2b7b80688e --- /dev/null +++ b/src/GitHub.UI/Helpers/IAutoCompleteSource.cs @@ -0,0 +1,13 @@ +using System; +using GitHub.UI; + +namespace GitHub.Helpers +{ + public interface IAutoCompleteSource + { + IObservable GetSuggestions(); + + // The prefix used to trigger auto completion. + string Prefix { get; } + } +} diff --git a/src/GitHub.UI/Helpers/IssuesAutoCompleteSource.cs b/src/GitHub.UI/Helpers/IssuesAutoCompleteSource.cs new file mode 100644 index 0000000000..1e67271309 --- /dev/null +++ b/src/GitHub.UI/Helpers/IssuesAutoCompleteSource.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive.Linq; +using GitHub.Cache; +using GitHub.Models; +using GitHub.UI; +using GitHub.ViewModels; + +namespace GitHub.Helpers +{ + [Export(typeof(IAutoCompleteSource))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class IssuesAutoCompleteSource : IAutoCompleteSource + { + readonly Lazy issuesCache; + readonly Lazy currentRepositoryState; + + [ImportingConstructor] + public IssuesAutoCompleteSource( + Lazy issuesCache, + Lazy currentRepositoryState) + { + Ensure.ArgumentNotNull(issuesCache, "issuesCache"); + Ensure.ArgumentNotNull(currentRepositoryState, "currentRepositoryState"); + + this.issuesCache = issuesCache; + this.currentRepositoryState = currentRepositoryState; + } + + public IObservable GetSuggestions() + { + if (CurrentRepository.RepositoryHost == null) + { + return Observable.Empty(); + } + + return IssuesCache.RetrieveSuggestions(CurrentRepository) + .Catch, Exception>(_ => Observable.Empty>()) + .SelectMany(x => x.ToObservable()) + .Where(suggestion => !String.IsNullOrEmpty(suggestion.Name)) // Just being extra cautious + .Select(suggestion => new IssueAutoCompleteSuggestion(suggestion, Prefix)); + } + + public string Prefix + { + get { return "#"; } + } + + IIssuesCache IssuesCache { get { return issuesCache.Value; } } + + IRepositoryModel CurrentRepository { get { return currentRepositoryState.Value.SelectedRepository; } } + + 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.UI/Helpers/MentionsAutoCompleteSource.cs b/src/GitHub.UI/Helpers/MentionsAutoCompleteSource.cs new file mode 100644 index 0000000000..a6402320a0 --- /dev/null +++ b/src/GitHub.UI/Helpers/MentionsAutoCompleteSource.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Reactive.Linq; +using System.Windows.Media.Imaging; +using GitHub.Cache; +using GitHub.Models; +using GitHub.UI; +using GitHub.ViewModels; + +namespace GitHub.Helpers +{ + /// + /// Supplies @mentions auto complete suggestions. + /// + [Export(typeof(IAutoCompleteSource))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class MentionsAutoCompleteSource : IAutoCompleteSource + { + readonly Lazy mentionsCache; + readonly Lazy currentRepositoryState; + readonly Lazy imageCache; + readonly IHostAvatarProvider hostAvatarProvider; + + [ImportingConstructor] + public MentionsAutoCompleteSource( + Lazy mentionsCache, + Lazy currentRepositoryState, + Lazy imageCache, + IHostAvatarProvider hostAvatarProvider) + { + Ensure.ArgumentNotNull(mentionsCache, "mentionsCache"); + Ensure.ArgumentNotNull(currentRepositoryState, "currentRepositoryState"); + Ensure.ArgumentNotNull(imageCache, "imageCache"); + Ensure.ArgumentNotNull(hostAvatarProvider, "hostAvatarProvider"); + + this.mentionsCache = mentionsCache; + this.currentRepositoryState = currentRepositoryState; + this.imageCache = imageCache; + this.hostAvatarProvider = hostAvatarProvider; + } + + public IObservable GetSuggestions() + { + if (CurrentRepository.RepositoryHost == null) + { + return Observable.Empty(); + } + + var avatarProviderKey = CurrentRepository.RepositoryHost.Address.WebUri.ToString(); + var avatarProvider = hostAvatarProvider.Get(avatarProviderKey); + + Func> resolveImage = uri => + Observable.Defer(() => ImageCache + .GetImage(uri) + .Catch(_ => Observable.Return(avatarProvider.DefaultUserBitmapImage)) + .StartWith(avatarProvider.DefaultUserBitmapImage)); + + return MentionsCache.RetrieveSuggestions(CurrentRepository) + .Catch, Exception>(_ => Observable.Empty>()) + .SelectMany(x => x.ToObservable()) + .Where(suggestion => !String.IsNullOrEmpty(suggestion.Name)) // Just being extra cautious + .Select(suggestion => + new AutoCompleteSuggestion(suggestion.Name, suggestion.Description, resolveImage(suggestion.IconKey), Prefix)); + } + + public string Prefix { get { return "@"; } } + + IImageCache ImageCache { get { return imageCache.Value; } } + + IMentionsCache MentionsCache { get { return mentionsCache.Value; } } + + IRepositoryModel CurrentRepository { get { return currentRepositoryState.Value.SelectedRepository; } } + } +} From c2c4a1bb4eb399df552ddc8aae66dec1c92db45d Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Thu, 3 Jan 2019 10:59:26 -0500 Subject: [PATCH 05/44] Adding more tests (not compiled) --- .../GitHub.UI.UnitTests.csproj | 17 +- .../Helpers/AutoCompleteAdvisorTests.cs | 207 ++++++++++++++++++ .../Helpers/AutoCompleteSourceTests.cs | 89 ++++++++ .../Helpers/IssuesAutoCompleteSourceTests.cs | 106 +++++++++ .../MentionsAutoCompleteSourceTests.cs | 13 ++ 5 files changed, 429 insertions(+), 3 deletions(-) create mode 100644 test/GitHub.UI.UnitTests/Helpers/AutoCompleteAdvisorTests.cs create mode 100644 test/GitHub.UI.UnitTests/Helpers/AutoCompleteSourceTests.cs create mode 100644 test/GitHub.UI.UnitTests/Helpers/IssuesAutoCompleteSourceTests.cs create mode 100644 test/GitHub.UI.UnitTests/Helpers/MentionsAutoCompleteSourceTests.cs diff --git a/test/GitHub.UI.UnitTests/GitHub.UI.UnitTests.csproj b/test/GitHub.UI.UnitTests/GitHub.UI.UnitTests.csproj index b7796d7021..450a540013 100644 --- a/test/GitHub.UI.UnitTests/GitHub.UI.UnitTests.csproj +++ b/test/GitHub.UI.UnitTests/GitHub.UI.UnitTests.csproj @@ -6,6 +6,10 @@ + + + + @@ -29,9 +33,16 @@ - - - + + + + + + + + + + diff --git a/test/GitHub.UI.UnitTests/Helpers/AutoCompleteAdvisorTests.cs b/test/GitHub.UI.UnitTests/Helpers/AutoCompleteAdvisorTests.cs new file mode 100644 index 0000000000..d0f37c8b3e --- /dev/null +++ b/test/GitHub.UI.UnitTests/Helpers/AutoCompleteAdvisorTests.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Linq; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using GitHub.Helpers; +using GitHub.UI; +using Moq; +using Xunit; + +public class AutoCompleteAdvisorTests +{ + public class TheParseAutoCompletionTokenMethod + { + [Theory] + [InlineData(":", 1, "", 0)] + [InlineData(":po", 3, "po", 0)] + [InlineData(":po", 2, "p", 0)] + [InlineData(":po or no :po", 2, "p", 0)] + [InlineData(":po or no :po yo", 13, "po", 10)] + [InlineData("This is :poo", 12, "poo", 8)] + [InlineData("This is :poo or is it", 12, "poo", 8)] + [InlineData("This is\r\n:poo or is it", 13, "poo", 9)] + [InlineData("This is :poo or is it :zap:", 12, "poo", 8)] + public void ParsesWordOffsetAndType( + string text, + int caretPosition, + string expectedPrefix, + int expectedOffset) + { + var token = AutoCompleteAdvisor.ParseAutoCompletionToken(text, caretPosition, ":"); + + Assert.Equal(expectedPrefix, token.SearchSearchPrefix); + Assert.Equal(expectedOffset, token.Offset); + } + + [Theory] + [InlineData("", 0)] + [InlineData("foo bar", 0)] + [InlineData("This has no special stuff", 5)] + [InlineData("This has a : but caret is after the space after it", 13)] + public void ReturnsNullForTextWithoutAnyTriggerCharactersMatchingCaretIndex(string text, int caretPosition) + { + Assert.Null(AutoCompleteAdvisor.ParseAutoCompletionToken(text, caretPosition, ":")); + } + + [Theory] + [InlineData("", 1)] + [InlineData("", -1)] + [InlineData("foo", 4)] + [InlineData("foo", -1)] + public void ThrowsExceptionWhenCaretIndexIsOutOfRangeOfText(string text, int caretIndex) + { + Assert.Throws( + () => AutoCompleteAdvisor.ParseAutoCompletionToken(text, caretIndex, ":")); + } + } + + public class TheGetAutoCompletionSuggestionsMethod + { + [Fact] + public async Task ReturnsResultsWhenOnlyTokenTyped() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("rainbow", obs, ":", ":"), + new AutoCompleteSuggestion("poop", obs, ":", ":"), + new AutoCompleteSuggestion("poop_scoop", obs, ":", ":") + }.ToObservable(); + var mentionsSource = Mock.Of(c => + c.GetSuggestions() == Observable.Empty() && c.Prefix == "@"); + var emojiSource = Mock.Of(c => + c.GetSuggestions() == suggestions && c.Prefix == ":"); + var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); + + var result = await advisor.GetAutoCompletionSuggestions(":", 1); + + Assert.Equal(0, result.Offset); + Assert.Equal(3, result.Suggestions.Count); + Assert.Equal("poop", result.Suggestions[0].Name); + Assert.Equal("poop_scoop", result.Suggestions[1].Name); + Assert.Equal("rainbow", result.Suggestions[2].Name); + } + + [Fact] + public async Task ReturnsResultsWithNameMatchingToken() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("rainbow", obs, ":", ":"), + new AutoCompleteSuggestion("poop", obs, ":", ":"), + new AutoCompleteSuggestion("poop_scoop", obs, ":", ":") + }.ToObservable(); + var mentionsSource = Mock.Of(c => + c.GetSuggestions() == Observable.Empty() && c.Prefix == "@"); + var emojiSource = Mock.Of(c => + c.GetSuggestions() == suggestions && c.Prefix == ":"); + var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); + + var result = await advisor.GetAutoCompletionSuggestions("this is :poo", 12); + + Assert.Equal(8, result.Offset); + Assert.Equal(2, result.Suggestions.Count); + Assert.Equal("poop", result.Suggestions[0].Name); + Assert.Equal("poop_scoop", result.Suggestions[1].Name); + } + + [Fact] + public async Task ReturnsResultsWithDescriptionMatchingToken() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("rainbow", "John Doe", obs, "@", ""), + new AutoCompleteSuggestion("poop", "Alice Bob", obs, "@", ""), + new AutoCompleteSuggestion("poop_scoop", obs, "@", ""), + new AutoCompleteSuggestion("loop", "Jimmy Alice Cooper", obs, "@", ""), + }.ToObservable(); + var mentionsSource = Mock.Of(c => + c.GetSuggestions() == suggestions && c.Prefix == "@"); + var emojiSource = Mock.Of(c => + c.GetSuggestions() == Observable.Empty() && c.Prefix == ":"); + var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); + + var result = await advisor.GetAutoCompletionSuggestions("this is @alice", 12); + + Assert.Equal(8, result.Offset); + Assert.Equal(2, result.Suggestions.Count); + Assert.Equal("loop", result.Suggestions[0].Name); + Assert.Equal("poop", result.Suggestions[1].Name); + } + + [Fact] + public async Task ReturnsMentionsInCorrectOrder() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + // We need to have more than 10 matches to ensure we grab the most appropriate top ten + new AutoCompleteSuggestion("zztop1", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop2", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop3", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop4", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop5", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop6", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop7", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop8", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop9", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop10", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("rainbowbright", "Jimmy Alice Cooper", obs, "@", ""), + new AutoCompleteSuggestion("apricot", "Bob Rainbow", obs, "@", ""), + new AutoCompleteSuggestion("rainbow", "John Doe", obs, "@", ""), + new AutoCompleteSuggestion("poop_scoop", obs, "@", ""), + new AutoCompleteSuggestion("zeke", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("bill", "RainbowBright Doe", obs, "@", "") + }.ToObservable(); + var mentionsSource = Mock.Of(c => + c.GetSuggestions() == suggestions && c.Prefix == "@"); + var emojiSource = Mock.Of(c => + c.GetSuggestions() == Observable.Empty() && c.Prefix == ":"); + var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); + + var result = await advisor.GetAutoCompletionSuggestions("this is @rainbow sucka", 16); + + Assert.Equal("rainbow", result.Suggestions[0].Name); + Assert.Equal("rainbowbright", result.Suggestions[1].Name); + Assert.Equal("apricot", result.Suggestions[2].Name); + Assert.Equal("bill", result.Suggestions[3].Name); // Bill and Zeke have the same name + Assert.Equal("zeke", result.Suggestions[4].Name); // but the secondary sort is by login + } + + [Theory] + [InlineData("", 0)] + [InlineData("Foo bar baz", 0)] + [InlineData("Foo bar baz", 3)] + public async Task ReturnsEmptyAutoCompleteResult(string text, int caretIndex) + { + var autoCompleteSource = Mock.Of( + c => c.GetSuggestions() == Observable.Empty() && c.Prefix == ":"); + + var advisor = new AutoCompleteAdvisor(new[] {autoCompleteSource}); + + var result = await advisor.GetAutoCompletionSuggestions(text, 0); + + Assert.Same(AutoCompleteResult.Empty, result); + } + + [Fact] + public async Task ReturnsEmptyAutoCompleteResultWhenSourceThrowsException() + { + var autoCompleteSource = Mock.Of( + c => c.GetSuggestions() == Observable.Throw(new Exception("FAIL!")) + && c.Prefix == "@"); + var advisor = new AutoCompleteAdvisor(new[] { autoCompleteSource }); + + var result = await advisor.GetAutoCompletionSuggestions("@", 1); + + Assert.Same(AutoCompleteResult.Empty, result); + } + } +} diff --git a/test/GitHub.UI.UnitTests/Helpers/AutoCompleteSourceTests.cs b/test/GitHub.UI.UnitTests/Helpers/AutoCompleteSourceTests.cs new file mode 100644 index 0000000000..f1a518c190 --- /dev/null +++ b/test/GitHub.UI.UnitTests/Helpers/AutoCompleteSourceTests.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Linq; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using GitHub; +using GitHub.Cache; +using GitHub.Helpers; +using GitHub.Models; +using GitHub.UI; +using GitHub.ViewModels; +using Moq; +using Xunit; + +/// +/// Tests common to and . +/// Run the actual concrete test classes. +/// +public abstract class AutoCompleteSourceTests + where TAutoCompleteSource : IAutoCompleteSource + where TCacheInterface : class, IAutoCompleteSourceCache +{ + [Fact] + public async Task LocalRepositoryDoesNotSupportAutoComplete() + { + var container = new TestContainer(); + var localRepository = Mock.Of(); + container.Setup(vm => vm.SelectedRepository).Returns(localRepository); + var source = container.Get(); + + var suggestions = await source.GetSuggestions().ToList(); + + Assert.Empty(suggestions); + } + + [Fact] + public async Task ReturnsEmptyWhenSourceCacheThrows() + { + var container = new TestContainer(); + var gitHubRemote = Mock.Of(x => x.Address == HostAddress.Create("https://github.com/")); + var repository = Mock.Of(x => x.RepositoryHost == gitHubRemote); + container.Setup(vm => vm.SelectedRepository).Returns(repository); + container.Setup(c => c.RetrieveSuggestions(Args.RepositoryModel)) + .Returns(Observable.Throw>(new Exception("Shit happened!"))); + var source = container.Get(); + + var suggestions = await source.GetSuggestions().ToList(); + + Assert.Empty(suggestions); + } + + [Fact] + public async Task ReturnsResultForGitHubRepository() + { + var container = new TestContainer(); + var expectedAvatar = Mock.Of(); + var gitHubRepository = CreateRepository("https://github.com"); + container.Setup(vm => vm.SelectedRepository).Returns(gitHubRepository); + container.Setup(c => c.GetImage(new Uri("https://githubusercontent.com/a/shiftkey.png"))) + .Returns(Observable.Return(expectedAvatar)); + + var suggestions = new[] + { + new SuggestionItem("shiftkey", "Nice guy", new Uri("https://githubusercontent.com/a/shiftkey.png")) + }; + container.Setup(c => c.RetrieveSuggestions(gitHubRepository)) + .Returns(Observable.Return(suggestions)); + var source = container.Get(); + + var retrieved = await source.GetSuggestions().ToList(); + + Assert.NotEmpty(retrieved); + Assert.Equal("shiftkey", retrieved[0].Name); + Assert.Equal("Nice guy", retrieved[0].Description); + await AssertAvatar(expectedAvatar, retrieved[0]); + } + + protected IRepositoryModel CreateRepository(string hostAddress) + { + var gitHubRemote = Mock.Of(x => x.Address == HostAddress.Create(hostAddress)); + return Mock.Of(x => x.RepositoryHost == gitHubRemote); + } + + protected virtual async Task AssertAvatar(BitmapSource expected, AutoCompleteSuggestion suggestion) + { + var avatar = await suggestion.Image; + Assert.Same(expected, avatar); + } +} diff --git a/test/GitHub.UI.UnitTests/Helpers/IssuesAutoCompleteSourceTests.cs b/test/GitHub.UI.UnitTests/Helpers/IssuesAutoCompleteSourceTests.cs new file mode 100644 index 0000000000..821daec048 --- /dev/null +++ b/test/GitHub.UI.UnitTests/Helpers/IssuesAutoCompleteSourceTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Linq; +using System.Reactive.Linq; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using GitHub.Cache; +using GitHub.Helpers; +using GitHub.Models; +using GitHub.UI; +using GitHub.ViewModels; +using Xunit; + +/// +/// Tests of the . Test implementations are in +/// +/// +/// +/// THIS CLASS SHOULD ONLY CONTAIN TESTS SPECIFIC TO that deviate from the +/// behavior in common with all implementations. +/// +public class IssuesAutoCompleteSourceTests : AutoCompleteSourceTests +{ + [Fact] + public async Task ReturnsIssuesSortedByLastModified() + { + var container = new TestContainer(); + var gitHubRepository = CreateRepository("https://github.com"); + container.Setup(vm => vm.SelectedRepository).Returns(gitHubRepository); + + var suggestions = new[] + { + new SuggestionItem("100", "We should do this") { LastModifiedDate = DateTimeOffset.UtcNow.AddDays(-1) }, + new SuggestionItem("101", "This shit is broken") { LastModifiedDate = DateTimeOffset.UtcNow }, + new SuggestionItem("102", "What even?") { LastModifiedDate = DateTimeOffset.UtcNow.AddHours(-1) } + }; + container.Setup(c => c.RetrieveSuggestions(gitHubRepository)) + .Returns(Observable.Return(suggestions)); + var source = container.Get(); + + var retrieved = await source.GetSuggestions().ToList(); + + Assert.NotEmpty(retrieved); + retrieved = retrieved.OrderByDescending(r => r.GetSortRank("")).ToList(); + Assert.Equal("101", retrieved[0].Name); + Assert.Equal("102", retrieved[1].Name); + Assert.Equal("100", retrieved[2].Name); + } + + [Fact] + public async Task ReturnsIssuesFilteredByIssueNumber() + { + var container = new TestContainer(); + var gitHubRepository = CreateRepository("https://github.com"); + container.Setup(vm => vm.SelectedRepository).Returns(gitHubRepository); + + var suggestions = new[] + { + new SuggestionItem("#200", "We should do this") { LastModifiedDate = DateTimeOffset.UtcNow.AddDays(-1) }, + new SuggestionItem("#101", "This shit is broken") { LastModifiedDate = DateTimeOffset.UtcNow }, + new SuggestionItem("#210", "What even?") { LastModifiedDate = DateTimeOffset.UtcNow.AddHours(-1) } + }; + container.Setup(c => c.RetrieveSuggestions(gitHubRepository)) + .Returns(Observable.Return(suggestions)); + var source = container.Get(); + + var retrieved = await source.GetSuggestions().ToList(); + + Assert.NotEmpty(retrieved); + retrieved = retrieved.OrderByDescending(r => r.GetSortRank("2")).ToList(); + Assert.Equal("#200", retrieved[0].Name); + Assert.Equal("#210", retrieved[1].Name); + Assert.Equal("#101", retrieved[2].Name); + } + + [Fact] + public async Task ReturnsIssuesFilteredByText() + { + var container = new TestContainer(); + var gitHubRepository = CreateRepository("https://github.com"); + container.Setup(vm => vm.SelectedRepository).Returns(gitHubRepository); + + var suggestions = new[] + { + new SuggestionItem("#200", "We should do this") { LastModifiedDate = DateTimeOffset.UtcNow.AddDays(-1) }, + new SuggestionItem("#101", "This shit is broken") { LastModifiedDate = DateTimeOffset.UtcNow }, + new SuggestionItem("#210", "What even?") { LastModifiedDate = DateTimeOffset.UtcNow.AddHours(-1) } + }; + container.Setup(c => c.RetrieveSuggestions(gitHubRepository)) + .Returns(Observable.Return(suggestions)); + var source = container.Get(); + + var retrieved = await source.GetSuggestions().ToList(); + + Assert.NotEmpty(retrieved); + retrieved = retrieved.OrderByDescending(r => r.GetSortRank("shit")).ToList(); + Assert.Equal("#101", retrieved[0].Name); + Assert.Equal("#200", retrieved[1].Name); + Assert.Equal("#210", retrieved[2].Name); + } + + protected override Task AssertAvatar(BitmapSource expected, AutoCompleteSuggestion suggestion) + { + // Issues do not have images associated with them so we'll just ignore the avatar when asserting it. + return Task.FromResult(0); + } +} diff --git a/test/GitHub.UI.UnitTests/Helpers/MentionsAutoCompleteSourceTests.cs b/test/GitHub.UI.UnitTests/Helpers/MentionsAutoCompleteSourceTests.cs new file mode 100644 index 0000000000..399f05c2b7 --- /dev/null +++ b/test/GitHub.UI.UnitTests/Helpers/MentionsAutoCompleteSourceTests.cs @@ -0,0 +1,13 @@ +using GitHub.Helpers; + +/// +/// Tests of the . Test implementations are in +/// +/// +/// +/// THIS CLASS SHOULD ONLY CONTAIN TESTS SPECIFIC TO that deviate from the +/// behavior in common with all implementations. +/// +public class MentionsAutoCompleteSourceTests : AutoCompleteSourceTests +{ +} From 9f83539c5dcb3765b7ac1d0b82c0a57d5a41759c Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Thu, 3 Jan 2019 11:28:12 -0500 Subject: [PATCH 06/44] Other items that need to be imported --- .../Caches/AutoCompleteSourceCache.cs | 147 ++++++++++++++++++ .../Caches/IAutoCompleteSourceCache.cs | 17 ++ src/GitHub.App/Caches/IIssuesCache.cs | 12 ++ src/GitHub.App/Caches/IMentionsCache.cs | 14 ++ src/GitHub.App/Caches/IssuesCache.cs | 80 ++++++++++ src/GitHub.App/Caches/MentionsCache.cs | 50 ++++++ src/GitHub.App/Models/SuggestionItem.cs | 64 ++++++++ .../PullRequestCreationViewModelDesigner.cs | 2 + .../PullRequestCreationViewModel.cs | 14 +- .../Models}/AutoCompleteResult.cs | 2 +- .../Models}/AutoCompleteSuggestion.cs | 2 +- .../Services}/IAutoCompleteAdvisor.cs | 3 +- .../IPullRequestCreationViewModel.cs | 2 + .../AutoCompleteBox/AutoCompleteBox.cs | 2 + .../Helpers/MentionsAutoCompleteSource.cs | 18 ++- 15 files changed, 414 insertions(+), 15 deletions(-) create mode 100644 src/GitHub.App/Caches/AutoCompleteSourceCache.cs create mode 100644 src/GitHub.App/Caches/IAutoCompleteSourceCache.cs create mode 100644 src/GitHub.App/Caches/IIssuesCache.cs create mode 100644 src/GitHub.App/Caches/IMentionsCache.cs create mode 100644 src/GitHub.App/Caches/IssuesCache.cs create mode 100644 src/GitHub.App/Caches/MentionsCache.cs create mode 100644 src/GitHub.App/Models/SuggestionItem.cs rename src/{GitHub.UI/Controls/AutoCompleteBox => GitHub.Exports.Reactive/Models}/AutoCompleteResult.cs (95%) rename src/{GitHub.UI/Controls/AutoCompleteBox => GitHub.Exports.Reactive/Models}/AutoCompleteSuggestion.cs (99%) rename src/{GitHub.UI/Controls/AutoCompleteBox => GitHub.Exports.Reactive/Services}/IAutoCompleteAdvisor.cs (79%) diff --git a/src/GitHub.App/Caches/AutoCompleteSourceCache.cs b/src/GitHub.App/Caches/AutoCompleteSourceCache.cs new file mode 100644 index 0000000000..543ceef1b7 --- /dev/null +++ b/src/GitHub.App/Caches/AutoCompleteSourceCache.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using GitHub.Api; +using GitHub.Extensions; +using GitHub.Helpers; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.Cache +{ + public abstract class AutoCompleteSourceCache : IAutoCompleteSourceCache + { + static readonly NLog.Logger log = NLog.LogManager.GetCurrentClassLogger(); + + readonly SerializedObservableProvider> serializedSuggestions; + readonly TimeSpan cacheDuration; + readonly TimeSpan maxCacheDuration; + + protected AutoCompleteSourceCache(TimeSpan cacheDuration, TimeSpan maxCacheDuration) + { + this.cacheDuration = cacheDuration; + this.maxCacheDuration = maxCacheDuration; + + serializedSuggestions = new SerializedObservableProvider>( + GetAndFetchCachedSourceItemsImpl); + } + + // We append this to the cache key to differentiate the various auto completion caches. + protected abstract string CacheSuffix { get; } + + /// + /// Retrieves suggestions from the cache for the specified repository. If not there, it makes an API + /// call to retrieve them. + /// + /// The repository that contains the suggestion items. + /// An observable containing a readonly list of auto complete suggestions + public IObservable> RetrieveSuggestions(IRepositoryModel repository) + { + Guard.ArgumentNotNull(repository, "repository"); + + return serializedSuggestions.Get(repository); + } + + /// + /// Calls the API to fetch mentionables, issues, or whatever. + /// + /// Existing items in the cache. Useful for incremental cache updates + /// The repository containing the items to fetch + /// The API client to use to make the request + protected abstract IObservable FetchSuggestionsSourceItems( + CachedData> existingCachedItems, + IRepositoryModel repository, + IApiClient apiClient); + + IObservable> GetAndFetchExistingCachedSourceItems( + IHostCache hostCache, + IRepositoryModel repository, + Func>, IObservable>> fetchFunc) + { + Debug.Assert(repository != null, "Repository cannot be null because we validated it at the callsite"); + Debug.Assert(hostCache != null, "HostCache cannot be null because we validated it at the callsite"); + + return GetOrFetchCachedItems(hostCache, repository, fetchFunc); + } + + IObservable> GetAndFetchCachedSourceItemsImpl(IRepositoryModel repository) + { + Debug.Assert(repository != null, "Repository cannot be null because we validated it at the callsite"); + + return Observable.Defer(() => + { + var hostCache = repository.RepositoryHost != null + ? repository.RepositoryHost.Cache + : null; + + if (hostCache == null) + { + return Observable.Empty>(); + } + + var ret = new ReplaySubject>(1); + + GetAndFetchExistingCachedSourceItems(hostCache, repository, GetCacheableSuggestions) + .Catch, Exception>(_ => Observable.Return(new List())) + .Multicast(ret) + .PermaRef(); + + // If GetAndFetchExistingCachedSourceItems finds that the cache item is stale it produces the + // stale value and then fetches and produces a fresh one. It can thus produce + // 1, 2 or no values (if cache miss and fetch fails). While I'd ideally want + // to expose that through this method so that the suggestions list in the UI get updated + // as soon as we have a fresh value this method has historically only produced + // one value so in an effort to reduce scope I'm keeping it that way. We + // unfortunately still need to maintain the subscription to GetAndRefresh though + // so that we don't cancel the refresh as soon as we get the stale object. + return ret.Take(1); + }); + } + + IObservable> GetCacheableSuggestions( + IRepositoryModel repository, + CachedData> existingCachedItems) + { + Debug.Assert(repository != null, "Repository cannot be null because we validated it at the callsite"); + + var apiClient = repository.RepositoryHost != null ? repository.RepositoryHost.ApiClient : null; + if (apiClient == null) + { + return Observable.Empty>(); + } + + // Our current serializer can't handle deserializing IReadOnlyList. That's why we need a concrete list + // here. + return FetchSuggestionsSourceItems(existingCachedItems, repository, apiClient) + .ToConcreteList(); + } + + IObservable> GetOrFetchCachedItems( + IHostCache hostCache, + IRepositoryModel repositoryModel, + Func>, IObservable>> fetchFunc) + where T : class + { + Ensure.ArgumentNotNull(repositoryModel, "repositoryModel"); + Ensure.ArgumentNotNull(fetchFunc, "fetchFunc"); + + string cacheKey = repositoryModel.NameWithOwner + ":" + CacheSuffix; + return hostCache.LocalMachine.GetCachedValueThenFetchForNextTime>( + cacheKey, + cacheData => fetchFunc(repositoryModel, cacheData), + cacheDuration, + maxCacheDuration) + .Catch, Exception>(ex => + { + log.Info(String.Format(CultureInfo.InvariantCulture, + "Exception occurred attempting to get a cached value and then fetch '{0}'", cacheKey), ex); + return Observable.Return(new List()); + }) + .Select(result => result ?? new List()); + } + } +} diff --git a/src/GitHub.App/Caches/IAutoCompleteSourceCache.cs b/src/GitHub.App/Caches/IAutoCompleteSourceCache.cs new file mode 100644 index 0000000000..ad50aa00ef --- /dev/null +++ b/src/GitHub.App/Caches/IAutoCompleteSourceCache.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using GitHub.Models; + +namespace GitHub.Cache +{ + public interface IAutoCompleteSourceCache + { + /// + /// Retrieves suggestions from the cache for the specified repository. If not there, it makes an API + /// call to retrieve them. + /// + /// The repository that contains the users + /// An observable containing a readonly list of issue suggestions + IObservable> RetrieveSuggestions(IRepositoryModel repositoryModel); + } +} diff --git a/src/GitHub.App/Caches/IIssuesCache.cs b/src/GitHub.App/Caches/IIssuesCache.cs new file mode 100644 index 0000000000..d99bcc64e9 --- /dev/null +++ b/src/GitHub.App/Caches/IIssuesCache.cs @@ -0,0 +1,12 @@ +using System.Diagnostics.CodeAnalysis; +namespace GitHub.Cache +{ + /// + /// Used to cache and supply #issues in the autocomplete control. + /// + [SuppressMessage("Microsoft.Design", "CA1040:AvoidEmptyInterfaces", + Justification = "Yeah, it's empty, but it makes it easy to import the correct one.")] + public interface IIssuesCache : IAutoCompleteSourceCache + { + } +} \ No newline at end of file diff --git a/src/GitHub.App/Caches/IMentionsCache.cs b/src/GitHub.App/Caches/IMentionsCache.cs new file mode 100644 index 0000000000..b0ef8a0391 --- /dev/null +++ b/src/GitHub.App/Caches/IMentionsCache.cs @@ -0,0 +1,14 @@ +using System.Diagnostics.CodeAnalysis; +using GitHub.Cache; + +namespace GitHub.Helpers +{ + /// + /// Used to cache and supply @mentions in the autocomplete control. + /// + [SuppressMessage("Microsoft.Design", "CA1040:AvoidEmptyInterfaces", + Justification = "Yeah, it's empty, but it makes it easy to import the correct one.")] + public interface IMentionsCache : IAutoCompleteSourceCache + { + } +} \ No newline at end of file diff --git a/src/GitHub.App/Caches/IssuesCache.cs b/src/GitHub.App/Caches/IssuesCache.cs new file mode 100644 index 0000000000..96dd3407e9 --- /dev/null +++ b/src/GitHub.App/Caches/IssuesCache.cs @@ -0,0 +1,80 @@ +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.ViewModels; +using Octokit; + +namespace GitHub.Cache +{ + [Export(typeof(IIssuesCache))] + [Export(typeof(IAutoCompleteSourceCache))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class IssuesCache : AutoCompleteSourceCache, IIssuesCache + { + // 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)); + + [ImportingConstructor] + public IssuesCache() : base(TimeSpan.FromSeconds(10), TimeSpan.FromDays(7)) + { + } + + protected override string CacheSuffix + { + get { return "issues"; } + } + + protected override IObservable FetchSuggestionsSourceItems( + CachedData> existingCachedItems, + IRepositoryModel repository, + IApiClient apiClient) + { + var data = (existingCachedItems.Data ?? new List()) + .Where(item => !String.IsNullOrEmpty(item.Name)) // Helps handle cache corruption + .ToList(); + + if (data.IsEmpty()) + { + return apiClient.GetIssuesForRepository(repository.Owner, repository.Name) + .Select(ConvertToSuggestionItem); + } + + // Update cache with changes + var since = data.Max(issue => issue.LastModifiedDate ?? lowerBound).ToUniversalTime(); + var existingIssues = data.ToDictionary(i => i.Name, i => i); + return apiClient.GetIssuesChangedSince(repository.Owner, repository.Name, since) + .WhereNotNull() + .Do(issue => + { + var suggestionItem = ConvertToSuggestionItem(issue); + // Remove closed ones. + if (issue.State == ItemState.Closed) + { + existingIssues.Remove(suggestionItem.Name); + } + else + { + // Adds new ones (this is basically a noop for existing ones) + existingIssues[suggestionItem.Name] = suggestionItem; + } + }) + .ToList() // We always want to return existing issues. + .SelectMany(_ => existingIssues.Values.ToObservable()); + } + + static SuggestionItem ConvertToSuggestionItem(Issue issue) + { + return new SuggestionItem("#" + issue.Number, issue.Title) + { + // Just in case CreatedAt isn't set, we'll use UTCNow. + LastModifiedDate = issue.UpdatedAt + ?? (issue.CreatedAt == DateTimeOffset.MinValue ? DateTimeOffset.UtcNow : issue.CreatedAt) + }; + } + } +} diff --git a/src/GitHub.App/Caches/MentionsCache.cs b/src/GitHub.App/Caches/MentionsCache.cs new file mode 100644 index 0000000000..1c2872bae6 --- /dev/null +++ b/src/GitHub.App/Caches/MentionsCache.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Reactive.Linq; +using GitHub.Api; +using GitHub.Helpers; +using GitHub.Models; +using Octokit; + +namespace GitHub.Cache +{ + /// + /// Used to cache and supply @mentions in the autocomplete control. + /// + [Export(typeof(IMentionsCache))] + [Export(typeof(IAutoCompleteSourceCache))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class MentionsCache : AutoCompleteSourceCache, IMentionsCache + { + public MentionsCache() : base(TimeSpan.FromHours(12), TimeSpan.FromDays(7)) + { + } + + protected override string CacheSuffix + { + get { return "mentions"; } + } + + protected override IObservable FetchSuggestionsSourceItems( + CachedData> existingCachedItems, + IRepositoryModel repository, + IApiClient apiClient) + { + return apiClient.GetMentionables(repository.Owner, repository.Name) + .Select(ConvertToSuggestionItem); + } + + static SuggestionItem ConvertToSuggestionItem(AccountMention sourceItem) + { + return new SuggestionItem(sourceItem.Login, sourceItem.Name ?? "(unknown)", GetUrlSafe(sourceItem.AvatarUrl)); + } + + static Uri GetUrlSafe(string url) + { + Uri uri; + Uri.TryCreate(url, UriKind.Absolute, out uri); + return uri; + } + } +} diff --git a/src/GitHub.App/Models/SuggestionItem.cs b/src/GitHub.App/Models/SuggestionItem.cs new file mode 100644 index 0000000000..0b8bd59644 --- /dev/null +++ b/src/GitHub.App/Models/SuggestionItem.cs @@ -0,0 +1,64 @@ +using System; +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() // So this can be deserialized from cache + { + } + + public SuggestionItem(string name, Uri iconCacheKey) + { + Ensure.ArgumentNotNullOrEmptyString(name, "name"); + Ensure.ArgumentNotNull(iconCacheKey, "iconCacheKey"); + + Name = name; + IconKey = iconCacheKey; + } + + public SuggestionItem(string name, string description) + { + Ensure.ArgumentNotNullOrEmptyString(name, "name"); + Ensure.ArgumentNotNullOrEmptyString(description, "description"); + + Name = name; + Description = description; + } + + public SuggestionItem(string name, string description, Uri iconCacheKey) + { + Ensure.ArgumentNotNullOrEmptyString(name, "name"); + Ensure.ArgumentNotNull(iconCacheKey, "iconCacheKey"); + + Name = name; + Description = description; + IconKey = iconCacheKey; + } + + /// + /// The name to display for this entry + /// + public string Name { get; set; } + + /// + /// Additional details about the entry + /// + public string Description { get; set; } + + /// + /// A key to lookup when displaying the icon for this entry + /// + public Uri IconKey { 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/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/ViewModels/GitHubPane/PullRequestCreationViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs index 89f8e37c50..5450fb970e 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) @@ -334,8 +339,9 @@ protected string GetDraftKey() SourceBranch.Name); } - public RemoteRepositoryModel GitHubRepository { get { return githubRepository?.Value; } } - bool IsExecuting { get { return isExecuting.Value; } } + public RemoteRepositoryModel GitHubRepository => githubRepository?.Value; + bool IsExecuting => isExecuting.Value; + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } bool initialized; bool Initialized diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteResult.cs b/src/GitHub.Exports.Reactive/Models/AutoCompleteResult.cs similarity index 95% rename from src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteResult.cs rename to src/GitHub.Exports.Reactive/Models/AutoCompleteResult.cs index 4397375158..c9461025f9 100644 --- a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteResult.cs +++ b/src/GitHub.Exports.Reactive/Models/AutoCompleteResult.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace GitHub.UI +namespace GitHub.Models { public class AutoCompleteResult { diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteSuggestion.cs b/src/GitHub.Exports.Reactive/Models/AutoCompleteSuggestion.cs similarity index 99% rename from src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteSuggestion.cs rename to src/GitHub.Exports.Reactive/Models/AutoCompleteSuggestion.cs index 4ca400c1a8..e00e67274e 100644 --- a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteSuggestion.cs +++ b/src/GitHub.Exports.Reactive/Models/AutoCompleteSuggestion.cs @@ -7,7 +7,7 @@ using GitHub.Helpers; using ReactiveUI; -namespace GitHub.UI +namespace GitHub.Models { public class AutoCompleteSuggestion { diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteAdvisor.cs b/src/GitHub.Exports.Reactive/Services/IAutoCompleteAdvisor.cs similarity index 79% rename from src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteAdvisor.cs rename to src/GitHub.Exports.Reactive/Services/IAutoCompleteAdvisor.cs index 5a2b945c54..b071be1bff 100644 --- a/src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteAdvisor.cs +++ b/src/GitHub.Exports.Reactive/Services/IAutoCompleteAdvisor.cs @@ -1,6 +1,7 @@ using System; +using GitHub.Models; -namespace GitHub.UI +namespace GitHub.Services { public interface IAutoCompleteAdvisor { 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.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs index b0555d2e70..7c6075caa0 100644 --- a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs +++ b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs @@ -25,6 +25,8 @@ 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; diff --git a/src/GitHub.UI/Helpers/MentionsAutoCompleteSource.cs b/src/GitHub.UI/Helpers/MentionsAutoCompleteSource.cs index a6402320a0..c1453759ce 100644 --- a/src/GitHub.UI/Helpers/MentionsAutoCompleteSource.cs +++ b/src/GitHub.UI/Helpers/MentionsAutoCompleteSource.cs @@ -4,7 +4,10 @@ using System.Reactive.Linq; using System.Windows.Media.Imaging; using GitHub.Cache; +using GitHub.Caches; +using GitHub.Extensions; using GitHub.Models; +using GitHub.Services; using GitHub.UI; using GitHub.ViewModels; @@ -20,19 +23,18 @@ public class MentionsAutoCompleteSource : IAutoCompleteSource readonly Lazy mentionsCache; readonly Lazy currentRepositoryState; readonly Lazy imageCache; - readonly IHostAvatarProvider hostAvatarProvider; + readonly IAvatarProvider hostAvatarProvider; [ImportingConstructor] public MentionsAutoCompleteSource( Lazy mentionsCache, - Lazy currentRepositoryState, Lazy imageCache, - IHostAvatarProvider hostAvatarProvider) + IAvatarProvider hostAvatarProvider) { - Ensure.ArgumentNotNull(mentionsCache, "mentionsCache"); - Ensure.ArgumentNotNull(currentRepositoryState, "currentRepositoryState"); - Ensure.ArgumentNotNull(imageCache, "imageCache"); - Ensure.ArgumentNotNull(hostAvatarProvider, "hostAvatarProvider"); + Guard.ArgumentNotNull(mentionsCache, "mentionsCache"); + Guard.ArgumentNotNull(currentRepositoryState, "currentRepositoryState"); + Guard.ArgumentNotNull(imageCache, "imageCache"); + Guard.ArgumentNotNull(hostAvatarProvider, "hostAvatarProvider"); this.mentionsCache = mentionsCache; this.currentRepositoryState = currentRepositoryState; @@ -70,6 +72,6 @@ public IObservable GetSuggestions() IMentionsCache MentionsCache { get { return mentionsCache.Value; } } - IRepositoryModel CurrentRepository { get { return currentRepositoryState.Value.SelectedRepository; } } + RepositoryModel CurrentRepository { get { return currentRepositoryState.Value.SelectedRepository; } } } } From c3fa22d739d44bf22f2d8ea1f401b5824dcf49ff Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Fri, 4 Jan 2019 10:46:42 -0500 Subject: [PATCH 07/44] Changing services to use GraphQL library --- .../Caches/AutoCompleteSourceCache.cs | 147 ------------------ .../Caches/IAutoCompleteSourceCache.cs | 17 -- src/GitHub.App/Caches/IIssuesCache.cs | 12 -- src/GitHub.App/Caches/IMentionsCache.cs | 14 -- src/GitHub.App/Caches/IssuesCache.cs | 80 ---------- src/GitHub.App/Caches/MentionsCache.cs | 50 ------ src/GitHub.App/Models/SuggestionItem.cs | 13 +- .../Services}/AutoCompleteAdvisor.cs | 21 +-- .../Services}/IAutoCompleteSource.cs | 4 +- .../Services}/IssuesAutoCompleteSource.cs | 51 +++--- .../Services/MentionsAutoCompleteSource.cs | 75 +++++++++ .../Helpers/EmojiAutoCompleteSource.cs | 41 ----- .../Helpers/MentionsAutoCompleteSource.cs | 77 --------- .../Views/AutoCompleteSuggestionView.xaml.cs | 1 + 14 files changed, 123 insertions(+), 480 deletions(-) delete mode 100644 src/GitHub.App/Caches/AutoCompleteSourceCache.cs delete mode 100644 src/GitHub.App/Caches/IAutoCompleteSourceCache.cs delete mode 100644 src/GitHub.App/Caches/IIssuesCache.cs delete mode 100644 src/GitHub.App/Caches/IMentionsCache.cs delete mode 100644 src/GitHub.App/Caches/IssuesCache.cs delete mode 100644 src/GitHub.App/Caches/MentionsCache.cs rename src/{GitHub.UI/Helpers => GitHub.App/Services}/AutoCompleteAdvisor.cs (88%) rename src/{GitHub.UI/Helpers => GitHub.App/Services}/IAutoCompleteSource.cs (75%) rename src/{GitHub.UI/Helpers => GitHub.App/Services}/IssuesAutoCompleteSource.cs (58%) create mode 100644 src/GitHub.App/Services/MentionsAutoCompleteSource.cs delete mode 100644 src/GitHub.UI/Helpers/EmojiAutoCompleteSource.cs delete mode 100644 src/GitHub.UI/Helpers/MentionsAutoCompleteSource.cs diff --git a/src/GitHub.App/Caches/AutoCompleteSourceCache.cs b/src/GitHub.App/Caches/AutoCompleteSourceCache.cs deleted file mode 100644 index 543ceef1b7..0000000000 --- a/src/GitHub.App/Caches/AutoCompleteSourceCache.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using GitHub.Api; -using GitHub.Extensions; -using GitHub.Helpers; -using GitHub.Models; -using GitHub.Services; -using ReactiveUI; - -namespace GitHub.Cache -{ - public abstract class AutoCompleteSourceCache : IAutoCompleteSourceCache - { - static readonly NLog.Logger log = NLog.LogManager.GetCurrentClassLogger(); - - readonly SerializedObservableProvider> serializedSuggestions; - readonly TimeSpan cacheDuration; - readonly TimeSpan maxCacheDuration; - - protected AutoCompleteSourceCache(TimeSpan cacheDuration, TimeSpan maxCacheDuration) - { - this.cacheDuration = cacheDuration; - this.maxCacheDuration = maxCacheDuration; - - serializedSuggestions = new SerializedObservableProvider>( - GetAndFetchCachedSourceItemsImpl); - } - - // We append this to the cache key to differentiate the various auto completion caches. - protected abstract string CacheSuffix { get; } - - /// - /// Retrieves suggestions from the cache for the specified repository. If not there, it makes an API - /// call to retrieve them. - /// - /// The repository that contains the suggestion items. - /// An observable containing a readonly list of auto complete suggestions - public IObservable> RetrieveSuggestions(IRepositoryModel repository) - { - Guard.ArgumentNotNull(repository, "repository"); - - return serializedSuggestions.Get(repository); - } - - /// - /// Calls the API to fetch mentionables, issues, or whatever. - /// - /// Existing items in the cache. Useful for incremental cache updates - /// The repository containing the items to fetch - /// The API client to use to make the request - protected abstract IObservable FetchSuggestionsSourceItems( - CachedData> existingCachedItems, - IRepositoryModel repository, - IApiClient apiClient); - - IObservable> GetAndFetchExistingCachedSourceItems( - IHostCache hostCache, - IRepositoryModel repository, - Func>, IObservable>> fetchFunc) - { - Debug.Assert(repository != null, "Repository cannot be null because we validated it at the callsite"); - Debug.Assert(hostCache != null, "HostCache cannot be null because we validated it at the callsite"); - - return GetOrFetchCachedItems(hostCache, repository, fetchFunc); - } - - IObservable> GetAndFetchCachedSourceItemsImpl(IRepositoryModel repository) - { - Debug.Assert(repository != null, "Repository cannot be null because we validated it at the callsite"); - - return Observable.Defer(() => - { - var hostCache = repository.RepositoryHost != null - ? repository.RepositoryHost.Cache - : null; - - if (hostCache == null) - { - return Observable.Empty>(); - } - - var ret = new ReplaySubject>(1); - - GetAndFetchExistingCachedSourceItems(hostCache, repository, GetCacheableSuggestions) - .Catch, Exception>(_ => Observable.Return(new List())) - .Multicast(ret) - .PermaRef(); - - // If GetAndFetchExistingCachedSourceItems finds that the cache item is stale it produces the - // stale value and then fetches and produces a fresh one. It can thus produce - // 1, 2 or no values (if cache miss and fetch fails). While I'd ideally want - // to expose that through this method so that the suggestions list in the UI get updated - // as soon as we have a fresh value this method has historically only produced - // one value so in an effort to reduce scope I'm keeping it that way. We - // unfortunately still need to maintain the subscription to GetAndRefresh though - // so that we don't cancel the refresh as soon as we get the stale object. - return ret.Take(1); - }); - } - - IObservable> GetCacheableSuggestions( - IRepositoryModel repository, - CachedData> existingCachedItems) - { - Debug.Assert(repository != null, "Repository cannot be null because we validated it at the callsite"); - - var apiClient = repository.RepositoryHost != null ? repository.RepositoryHost.ApiClient : null; - if (apiClient == null) - { - return Observable.Empty>(); - } - - // Our current serializer can't handle deserializing IReadOnlyList. That's why we need a concrete list - // here. - return FetchSuggestionsSourceItems(existingCachedItems, repository, apiClient) - .ToConcreteList(); - } - - IObservable> GetOrFetchCachedItems( - IHostCache hostCache, - IRepositoryModel repositoryModel, - Func>, IObservable>> fetchFunc) - where T : class - { - Ensure.ArgumentNotNull(repositoryModel, "repositoryModel"); - Ensure.ArgumentNotNull(fetchFunc, "fetchFunc"); - - string cacheKey = repositoryModel.NameWithOwner + ":" + CacheSuffix; - return hostCache.LocalMachine.GetCachedValueThenFetchForNextTime>( - cacheKey, - cacheData => fetchFunc(repositoryModel, cacheData), - cacheDuration, - maxCacheDuration) - .Catch, Exception>(ex => - { - log.Info(String.Format(CultureInfo.InvariantCulture, - "Exception occurred attempting to get a cached value and then fetch '{0}'", cacheKey), ex); - return Observable.Return(new List()); - }) - .Select(result => result ?? new List()); - } - } -} diff --git a/src/GitHub.App/Caches/IAutoCompleteSourceCache.cs b/src/GitHub.App/Caches/IAutoCompleteSourceCache.cs deleted file mode 100644 index ad50aa00ef..0000000000 --- a/src/GitHub.App/Caches/IAutoCompleteSourceCache.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using System.Collections.Generic; -using GitHub.Models; - -namespace GitHub.Cache -{ - public interface IAutoCompleteSourceCache - { - /// - /// Retrieves suggestions from the cache for the specified repository. If not there, it makes an API - /// call to retrieve them. - /// - /// The repository that contains the users - /// An observable containing a readonly list of issue suggestions - IObservable> RetrieveSuggestions(IRepositoryModel repositoryModel); - } -} diff --git a/src/GitHub.App/Caches/IIssuesCache.cs b/src/GitHub.App/Caches/IIssuesCache.cs deleted file mode 100644 index d99bcc64e9..0000000000 --- a/src/GitHub.App/Caches/IIssuesCache.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -namespace GitHub.Cache -{ - /// - /// Used to cache and supply #issues in the autocomplete control. - /// - [SuppressMessage("Microsoft.Design", "CA1040:AvoidEmptyInterfaces", - Justification = "Yeah, it's empty, but it makes it easy to import the correct one.")] - public interface IIssuesCache : IAutoCompleteSourceCache - { - } -} \ No newline at end of file diff --git a/src/GitHub.App/Caches/IMentionsCache.cs b/src/GitHub.App/Caches/IMentionsCache.cs deleted file mode 100644 index b0ef8a0391..0000000000 --- a/src/GitHub.App/Caches/IMentionsCache.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using GitHub.Cache; - -namespace GitHub.Helpers -{ - /// - /// Used to cache and supply @mentions in the autocomplete control. - /// - [SuppressMessage("Microsoft.Design", "CA1040:AvoidEmptyInterfaces", - Justification = "Yeah, it's empty, but it makes it easy to import the correct one.")] - public interface IMentionsCache : IAutoCompleteSourceCache - { - } -} \ No newline at end of file diff --git a/src/GitHub.App/Caches/IssuesCache.cs b/src/GitHub.App/Caches/IssuesCache.cs deleted file mode 100644 index 96dd3407e9..0000000000 --- a/src/GitHub.App/Caches/IssuesCache.cs +++ /dev/null @@ -1,80 +0,0 @@ -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.ViewModels; -using Octokit; - -namespace GitHub.Cache -{ - [Export(typeof(IIssuesCache))] - [Export(typeof(IAutoCompleteSourceCache))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class IssuesCache : AutoCompleteSourceCache, IIssuesCache - { - // 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)); - - [ImportingConstructor] - public IssuesCache() : base(TimeSpan.FromSeconds(10), TimeSpan.FromDays(7)) - { - } - - protected override string CacheSuffix - { - get { return "issues"; } - } - - protected override IObservable FetchSuggestionsSourceItems( - CachedData> existingCachedItems, - IRepositoryModel repository, - IApiClient apiClient) - { - var data = (existingCachedItems.Data ?? new List()) - .Where(item => !String.IsNullOrEmpty(item.Name)) // Helps handle cache corruption - .ToList(); - - if (data.IsEmpty()) - { - return apiClient.GetIssuesForRepository(repository.Owner, repository.Name) - .Select(ConvertToSuggestionItem); - } - - // Update cache with changes - var since = data.Max(issue => issue.LastModifiedDate ?? lowerBound).ToUniversalTime(); - var existingIssues = data.ToDictionary(i => i.Name, i => i); - return apiClient.GetIssuesChangedSince(repository.Owner, repository.Name, since) - .WhereNotNull() - .Do(issue => - { - var suggestionItem = ConvertToSuggestionItem(issue); - // Remove closed ones. - if (issue.State == ItemState.Closed) - { - existingIssues.Remove(suggestionItem.Name); - } - else - { - // Adds new ones (this is basically a noop for existing ones) - existingIssues[suggestionItem.Name] = suggestionItem; - } - }) - .ToList() // We always want to return existing issues. - .SelectMany(_ => existingIssues.Values.ToObservable()); - } - - static SuggestionItem ConvertToSuggestionItem(Issue issue) - { - return new SuggestionItem("#" + issue.Number, issue.Title) - { - // Just in case CreatedAt isn't set, we'll use UTCNow. - LastModifiedDate = issue.UpdatedAt - ?? (issue.CreatedAt == DateTimeOffset.MinValue ? DateTimeOffset.UtcNow : issue.CreatedAt) - }; - } - } -} diff --git a/src/GitHub.App/Caches/MentionsCache.cs b/src/GitHub.App/Caches/MentionsCache.cs deleted file mode 100644 index 1c2872bae6..0000000000 --- a/src/GitHub.App/Caches/MentionsCache.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.Reactive.Linq; -using GitHub.Api; -using GitHub.Helpers; -using GitHub.Models; -using Octokit; - -namespace GitHub.Cache -{ - /// - /// Used to cache and supply @mentions in the autocomplete control. - /// - [Export(typeof(IMentionsCache))] - [Export(typeof(IAutoCompleteSourceCache))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class MentionsCache : AutoCompleteSourceCache, IMentionsCache - { - public MentionsCache() : base(TimeSpan.FromHours(12), TimeSpan.FromDays(7)) - { - } - - protected override string CacheSuffix - { - get { return "mentions"; } - } - - protected override IObservable FetchSuggestionsSourceItems( - CachedData> existingCachedItems, - IRepositoryModel repository, - IApiClient apiClient) - { - return apiClient.GetMentionables(repository.Owner, repository.Name) - .Select(ConvertToSuggestionItem); - } - - static SuggestionItem ConvertToSuggestionItem(AccountMention sourceItem) - { - return new SuggestionItem(sourceItem.Login, sourceItem.Name ?? "(unknown)", GetUrlSafe(sourceItem.AvatarUrl)); - } - - static Uri GetUrlSafe(string url) - { - Uri uri; - Uri.TryCreate(url, UriKind.Absolute, out uri); - return uri; - } - } -} diff --git a/src/GitHub.App/Models/SuggestionItem.cs b/src/GitHub.App/Models/SuggestionItem.cs index 0b8bd59644..17bdfa2525 100644 --- a/src/GitHub.App/Models/SuggestionItem.cs +++ b/src/GitHub.App/Models/SuggestionItem.cs @@ -1,4 +1,5 @@ using System; +using GitHub.Extensions; using GitHub.Helpers; namespace GitHub.Models @@ -15,8 +16,8 @@ public SuggestionItem() // So this can be deserialized from cache public SuggestionItem(string name, Uri iconCacheKey) { - Ensure.ArgumentNotNullOrEmptyString(name, "name"); - Ensure.ArgumentNotNull(iconCacheKey, "iconCacheKey"); + Guard.ArgumentNotEmptyString(name, "name"); + Guard.ArgumentNotNull(iconCacheKey, "iconCacheKey"); Name = name; IconKey = iconCacheKey; @@ -24,8 +25,8 @@ public SuggestionItem(string name, Uri iconCacheKey) public SuggestionItem(string name, string description) { - Ensure.ArgumentNotNullOrEmptyString(name, "name"); - Ensure.ArgumentNotNullOrEmptyString(description, "description"); + Guard.ArgumentNotEmptyString(name, "name"); + Guard.ArgumentNotEmptyString(description, "description"); Name = name; Description = description; @@ -33,8 +34,8 @@ public SuggestionItem(string name, string description) public SuggestionItem(string name, string description, Uri iconCacheKey) { - Ensure.ArgumentNotNullOrEmptyString(name, "name"); - Ensure.ArgumentNotNull(iconCacheKey, "iconCacheKey"); + Guard.ArgumentNotEmptyString(name, "name"); + Guard.ArgumentNotNull(iconCacheKey, "iconCacheKey"); Name = name; Description = description; diff --git a/src/GitHub.UI/Helpers/AutoCompleteAdvisor.cs b/src/GitHub.App/Services/AutoCompleteAdvisor.cs similarity index 88% rename from src/GitHub.UI/Helpers/AutoCompleteAdvisor.cs rename to src/GitHub.App/Services/AutoCompleteAdvisor.cs index 1c06d0ac96..031392d581 100644 --- a/src/GitHub.UI/Helpers/AutoCompleteAdvisor.cs +++ b/src/GitHub.App/Services/AutoCompleteAdvisor.cs @@ -7,10 +7,13 @@ using System.Globalization; using System.Linq; using System.Reactive.Linq; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; using GitHub.UI; -using NLog; +using Serilog; -namespace GitHub.Helpers +namespace GitHub.Services { [Export(typeof(IAutoCompleteAdvisor))] [PartCreationPolicy(CreationPolicy.Shared)] @@ -18,7 +21,7 @@ public class AutoCompleteAdvisor : IAutoCompleteAdvisor { const int SuggestionCount = 5; // The number of suggestions we'll provide. github.com does 5. - static readonly Logger log = LogManager.GetCurrentClassLogger(); + static readonly ILogger log = LogManager.ForContext(); readonly Lazy> prefixSourceMap; [ImportingConstructor] @@ -30,7 +33,7 @@ public AutoCompleteAdvisor([ImportMany]IEnumerable autocomp public IObservable GetAutoCompletionSuggestions(string text, int caretPosition) { - Ensure.ArgumentNotNull("text", text); + Guard.ArgumentNotNull("text", text); if (caretPosition < 0 || caretPosition > text.Length) { @@ -73,7 +76,7 @@ public IObservable GetAutoCompletionSuggestions(string text, new ReadOnlyCollection(suggestions))) .Catch(e => { - log.Info(e); + log.Error(e, "Error Getting AutoCompleteResult"); return Observable.Return(AutoCompleteResult.Empty); }); } @@ -82,8 +85,8 @@ public IObservable GetAutoCompletionSuggestions(string text, , Justification = "We ensure the argument is greater than -1 so it can't overflow")] public static AutoCompletionToken ParseAutoCompletionToken(string text, int caretPosition, string triggerPrefix) { - Ensure.ArgumentNotNull("text", text); - Ensure.ArgumentInRange(caretPosition, 0, text.Length, "caretPosition"); + Guard.ArgumentNotNull("text", text); + Guard.ArgumentInRange(caretPosition, 0, text.Length, "caretPosition"); if (caretPosition == 0 || text.Length == 0) return null; // :th : 1 @@ -103,8 +106,8 @@ public class AutoCompletionToken { public AutoCompletionToken(string searchPrefix, int offset) { - Ensure.ArgumentNotNull(searchPrefix, "searchPrefix"); - Ensure.ArgumentNonNegative(offset, "offset"); + Guard.ArgumentNotNull(searchPrefix, "searchPrefix"); + Guard.ArgumentNonNegative(offset, "offset"); SearchSearchPrefix = searchPrefix; Offset = offset; diff --git a/src/GitHub.UI/Helpers/IAutoCompleteSource.cs b/src/GitHub.App/Services/IAutoCompleteSource.cs similarity index 75% rename from src/GitHub.UI/Helpers/IAutoCompleteSource.cs rename to src/GitHub.App/Services/IAutoCompleteSource.cs index 2b7b80688e..8d54799c6e 100644 --- a/src/GitHub.UI/Helpers/IAutoCompleteSource.cs +++ b/src/GitHub.App/Services/IAutoCompleteSource.cs @@ -1,7 +1,9 @@ using System; +using System.Threading.Tasks; +using GitHub.Models; using GitHub.UI; -namespace GitHub.Helpers +namespace GitHub.Services { public interface IAutoCompleteSource { diff --git a/src/GitHub.UI/Helpers/IssuesAutoCompleteSource.cs b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs similarity index 58% rename from src/GitHub.UI/Helpers/IssuesAutoCompleteSource.cs rename to src/GitHub.App/Services/IssuesAutoCompleteSource.cs index 1e67271309..41c311d79b 100644 --- a/src/GitHub.UI/Helpers/IssuesAutoCompleteSource.cs +++ b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs @@ -3,44 +3,47 @@ using System.ComponentModel.Composition; using System.Linq; using System.Reactive.Linq; -using GitHub.Cache; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.Extensions; using GitHub.Models; -using GitHub.UI; -using GitHub.ViewModels; +using GitHub.Primitives; +using Octokit.GraphQL; -namespace GitHub.Helpers +namespace GitHub.Services { [Export(typeof(IAutoCompleteSource))] [PartCreationPolicy(CreationPolicy.Shared)] public class IssuesAutoCompleteSource : IAutoCompleteSource { - readonly Lazy issuesCache; - readonly Lazy currentRepositoryState; + readonly LocalRepositoryModel localRepositoryModel; + readonly IGraphQLClientFactory graphqlFactory; [ImportingConstructor] - public IssuesAutoCompleteSource( - Lazy issuesCache, - Lazy currentRepositoryState) + public IssuesAutoCompleteSource(LocalRepositoryModel localRepositoryModel, IGraphQLClientFactory graphqlFactory) { - Ensure.ArgumentNotNull(issuesCache, "issuesCache"); - Ensure.ArgumentNotNull(currentRepositoryState, "currentRepositoryState"); + Guard.ArgumentNotNull(localRepositoryModel, nameof(localRepositoryModel)); + Guard.ArgumentNotNull(graphqlFactory, nameof(graphqlFactory)); - this.issuesCache = issuesCache; - this.currentRepositoryState = currentRepositoryState; + this.localRepositoryModel = localRepositoryModel; + this.graphqlFactory = graphqlFactory; } public IObservable GetSuggestions() { - if (CurrentRepository.RepositoryHost == null) - { - return Observable.Empty(); - } + var query = new Query().Repository(owner: localRepositoryModel.Owner, name: localRepositoryModel.Name) + .Select(repository => + repository.Issues(null, null, null, null, null, null, null) + .AllPages() + .Select(issue => new SuggestionItem("#" + issue.Number, issue.Title)) + .ToList()); - return IssuesCache.RetrieveSuggestions(CurrentRepository) - .Catch, Exception>(_ => Observable.Empty>()) - .SelectMany(x => x.ToObservable()) - .Where(suggestion => !String.IsNullOrEmpty(suggestion.Name)) // Just being extra cautious - .Select(suggestion => new IssueAutoCompleteSuggestion(suggestion, Prefix)); + return Observable.FromAsync(async () => + { + var connection = await graphqlFactory.CreateConnection(HostAddress.Create(localRepositoryModel.CloneUrl.Host)); + var suggestions = await connection.Run(query); + return suggestions.Select(suggestion => new IssueAutoCompleteSuggestion(suggestion, Prefix)); + }).SelectMany(enumerable => enumerable); } public string Prefix @@ -48,10 +51,6 @@ public string Prefix get { return "#"; } } - IIssuesCache IssuesCache { get { return issuesCache.Value; } } - - IRepositoryModel CurrentRepository { get { return currentRepositoryState.Value.SelectedRepository; } } - class IssueAutoCompleteSuggestion : AutoCompleteSuggestion { // Just needs to be some value before GitHub stored its first issue. diff --git a/src/GitHub.App/Services/MentionsAutoCompleteSource.cs b/src/GitHub.App/Services/MentionsAutoCompleteSource.cs new file mode 100644 index 0000000000..2a95c39741 --- /dev/null +++ b/src/GitHub.App/Services/MentionsAutoCompleteSource.cs @@ -0,0 +1,75 @@ +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive.Linq; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using GitHub.Api; +using GitHub.Caches; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Primitives; +using Octokit.GraphQL; + +namespace GitHub.Services +{ + /// + /// Supplies @mentions auto complete suggestions. + /// + [Export(typeof(IAutoCompleteSource))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class MentionsAutoCompleteSource : IAutoCompleteSource + { + readonly LocalRepositoryModel localRepositoryModel; + readonly IGraphQLClientFactory graphqlFactory; + readonly IAvatarProvider avatarProvider; + + [ImportingConstructor] + public MentionsAutoCompleteSource(LocalRepositoryModel localRepositoryModel, + IGraphQLClientFactory graphqlFactory, + IAvatarProvider avatarProvider) + { + Guard.ArgumentNotNull(localRepositoryModel, nameof(localRepositoryModel)); + Guard.ArgumentNotNull(graphqlFactory, nameof(graphqlFactory)); + Guard.ArgumentNotNull(avatarProvider, nameof(avatarProvider)); + + this.localRepositoryModel = localRepositoryModel; + this.graphqlFactory = graphqlFactory; + this.avatarProvider = avatarProvider; + } + + public IObservable GetSuggestions() + { + var query = new Query().Repository(owner: localRepositoryModel.Owner, name: localRepositoryModel.Name) + .Select(repository => + repository.MentionableUsers(null, null, null, null) + .AllPages() + .Select(sourceItem => + new SuggestionItem(sourceItem.Login, + sourceItem.Name ?? "(unknown)", + GetUrlSafe(sourceItem.AvatarUrl(null)))) + .ToList()); + + return Observable.FromAsync(async () => + { + var connection = await graphqlFactory.CreateConnection(HostAddress.Create(localRepositoryModel.CloneUrl.Host)); + var suggestions = await connection.Run(query); + return suggestions.Select(suggestion => new AutoCompleteSuggestion(suggestion.Name, + suggestion.Description, + ResolveImage(suggestion.IconKey.ToString()), + Prefix)); + }).SelectMany(enumerable => enumerable); + } + + private IObservable ResolveImage(string uri) => avatarProvider.GetAvatar(uri); + + public string Prefix => "@"; + + static Uri GetUrlSafe(string url) + { + Uri uri; + Uri.TryCreate(url, UriKind.Absolute, out uri); + return uri; + } + } +} diff --git a/src/GitHub.UI/Helpers/EmojiAutoCompleteSource.cs b/src/GitHub.UI/Helpers/EmojiAutoCompleteSource.cs deleted file mode 100644 index 2dbf7d0750..0000000000 --- a/src/GitHub.UI/Helpers/EmojiAutoCompleteSource.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Linq; -using System.Reactive.Linq; -using System.Windows.Media.Imaging; -using GitHub.UI; - -namespace GitHub.Helpers -{ - [Export(typeof(IAutoCompleteSource))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class EmojiAutoCompleteSource : IAutoCompleteSource - { - readonly IEmojiCache emojiCache; - - [ImportingConstructor] - public EmojiAutoCompleteSource(IEmojiCache emojiCache) - { - Ensure.ArgumentNotNull(emojiCache, "emojiCache"); - - this.emojiCache = emojiCache; - } - - public IObservable GetSuggestions() - { - Func> resolveImage = uri => - Observable.Defer(() => - { - var resourcePath = "pack://application:,,,/GitHub;component/" + uri; - return Observable.Return(App.CreateBitmapImage(resourcePath)); - }); - - return emojiCache.GetEmojis() - .Where(emoji => !String.IsNullOrEmpty(emoji.Name)) // Just being extra cautious. - .Select(emoji => new AutoCompleteSuggestion(emoji.Name, resolveImage(emoji.IconKey), ":", ":")) - .ToObservable(); - } - - public string Prefix { get { return ":"; } } - } -} diff --git a/src/GitHub.UI/Helpers/MentionsAutoCompleteSource.cs b/src/GitHub.UI/Helpers/MentionsAutoCompleteSource.cs deleted file mode 100644 index c1453759ce..0000000000 --- a/src/GitHub.UI/Helpers/MentionsAutoCompleteSource.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.Reactive.Linq; -using System.Windows.Media.Imaging; -using GitHub.Cache; -using GitHub.Caches; -using GitHub.Extensions; -using GitHub.Models; -using GitHub.Services; -using GitHub.UI; -using GitHub.ViewModels; - -namespace GitHub.Helpers -{ - /// - /// Supplies @mentions auto complete suggestions. - /// - [Export(typeof(IAutoCompleteSource))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class MentionsAutoCompleteSource : IAutoCompleteSource - { - readonly Lazy mentionsCache; - readonly Lazy currentRepositoryState; - readonly Lazy imageCache; - readonly IAvatarProvider hostAvatarProvider; - - [ImportingConstructor] - public MentionsAutoCompleteSource( - Lazy mentionsCache, - Lazy imageCache, - IAvatarProvider hostAvatarProvider) - { - Guard.ArgumentNotNull(mentionsCache, "mentionsCache"); - Guard.ArgumentNotNull(currentRepositoryState, "currentRepositoryState"); - Guard.ArgumentNotNull(imageCache, "imageCache"); - Guard.ArgumentNotNull(hostAvatarProvider, "hostAvatarProvider"); - - this.mentionsCache = mentionsCache; - this.currentRepositoryState = currentRepositoryState; - this.imageCache = imageCache; - this.hostAvatarProvider = hostAvatarProvider; - } - - public IObservable GetSuggestions() - { - if (CurrentRepository.RepositoryHost == null) - { - return Observable.Empty(); - } - - var avatarProviderKey = CurrentRepository.RepositoryHost.Address.WebUri.ToString(); - var avatarProvider = hostAvatarProvider.Get(avatarProviderKey); - - Func> resolveImage = uri => - Observable.Defer(() => ImageCache - .GetImage(uri) - .Catch(_ => Observable.Return(avatarProvider.DefaultUserBitmapImage)) - .StartWith(avatarProvider.DefaultUserBitmapImage)); - - return MentionsCache.RetrieveSuggestions(CurrentRepository) - .Catch, Exception>(_ => Observable.Empty>()) - .SelectMany(x => x.ToObservable()) - .Where(suggestion => !String.IsNullOrEmpty(suggestion.Name)) // Just being extra cautious - .Select(suggestion => - new AutoCompleteSuggestion(suggestion.Name, suggestion.Description, resolveImage(suggestion.IconKey), Prefix)); - } - - public string Prefix { get { return "@"; } } - - IImageCache ImageCache { get { return imageCache.Value; } } - - IMentionsCache MentionsCache { get { return mentionsCache.Value; } } - - RepositoryModel CurrentRepository { get { return currentRepositoryState.Value.SelectedRepository; } } - } -} diff --git a/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml.cs b/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml.cs index d0c52b33f7..d76f4fb316 100644 --- a/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml.cs +++ b/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml.cs @@ -2,6 +2,7 @@ using System.Windows; using GitHub.Extensions; using GitHub.Extensions.Reactive; +using GitHub.Models; using GitHub.UI; using ReactiveUI; From 9e4d7f963de1410aec2c62c4383a1a8f5675feb7 Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Fri, 4 Jan 2019 10:47:51 -0500 Subject: [PATCH 08/44] Starting to implement autocomplete functionality --- .../GitHubPane/PullRequestCreationView.xaml | 41 +++++++++++++------ .../PullRequestCreationViewModelTests.cs | 21 ++++++---- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCreationView.xaml b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCreationView.xaml index ba748d1eef..e3d1c4b224 100644 --- a/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCreationView.xaml +++ b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCreationView.xaml @@ -149,18 +149,35 @@ SpellCheck.IsEnabled="True" AutomationProperties.AutomationId="{x:Static ghfvs:AutomationIDs.PullRequestCreationTitleTextBox}"/> - - + + + + + + + + + + + + + (); var api = Substitute.For(); var ms = Substitute.For(); + var autoCompleteAdvisor = Substitute.For(); connection.HostAddress.Returns(HostAddress.Create("https://github.com")); @@ -121,7 +123,8 @@ static TestData PrepareTestData( NotificationService = notifications, Connection = connection, ApiClient = api, - ModelService = ms + ModelService = ms, + AutoCompleteAdvisor = autoCompleteAdvisor }; } @@ -147,7 +150,7 @@ public async Task TargetBranchDisplayNameIncludesRepoOwnerWhenForkAsync() var prservice = new PullRequestService(data.GitClient, data.GitService, Substitute.For(), Substitute.For(), data.ServiceProvider.GetOperatingSystem(), Substitute.For()); prservice.GetPullRequestTemplate(data.ActiveRepo).Returns(Observable.Empty()); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - Substitute.For(), data.GitService); + Substitute.For(), data.GitService, data.AutoCompleteAdvisor); await vm.InitializeAsync(data.ActiveRepo, data.Connection); Assert.That("octokit/master", Is.EqualTo(vm.TargetBranch.DisplayName)); } @@ -183,7 +186,7 @@ public async Task CreatingPRsAsync( var prservice = new PullRequestService(data.GitClient, data.GitService, Substitute.For(), Substitute.For(), data.ServiceProvider.GetOperatingSystem(), Substitute.For()); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - Substitute.For(), data.GitService); + Substitute.For(), data.GitService, data.AutoCompleteAdvisor); await vm.InitializeAsync(data.ActiveRepo, data.Connection); // the TargetBranch property gets set to whatever the repo default is (we assume master here), @@ -226,7 +229,7 @@ public async Task TemplateIsUsedIfPresentAsync() prservice.GetPullRequestTemplate(data.ActiveRepo).Returns(Observable.Return("Test PR template")); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - Substitute.For(), data.GitService); + Substitute.For(), data.GitService, data.AutoCompleteAdvisor); await vm.InitializeAsync(data.ActiveRepo, data.Connection); Assert.That("Test PR template", Is.EqualTo(vm.Description)); @@ -246,7 +249,7 @@ public async Task LoadsDraft() var prservice = Substitute.For(); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - draftStore, data.GitService); + draftStore, data.GitService, data.AutoCompleteAdvisor); await vm.InitializeAsync(data.ActiveRepo, data.Connection); Assert.That(vm.PRTitle, Is.EqualTo("This is a Title.")); @@ -261,7 +264,7 @@ public async Task UpdatesDraftWhenDescriptionChanges() var draftStore = Substitute.For(); var prservice = Substitute.For(); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - draftStore, data.GitService, scheduler); + draftStore, data.GitService, data.AutoCompleteAdvisor, scheduler); await vm.InitializeAsync(data.ActiveRepo, data.Connection); vm.Description = "Body changed."; @@ -284,7 +287,7 @@ public async Task UpdatesDraftWhenTitleChanges() var draftStore = Substitute.For(); var prservice = Substitute.For(); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - draftStore, data.GitService, scheduler); + draftStore, data.GitService, data.AutoCompleteAdvisor, scheduler); await vm.InitializeAsync(data.ActiveRepo, data.Connection); vm.PRTitle = "Title changed."; @@ -307,7 +310,7 @@ public async Task DeletesDraftWhenPullRequestSubmitted() var draftStore = Substitute.For(); var prservice = Substitute.For(); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, draftStore, - data.GitService, scheduler); + data.GitService, data.AutoCompleteAdvisor, scheduler); await vm.InitializeAsync(data.ActiveRepo, data.Connection); await vm.CreatePullRequest.Execute(); @@ -323,7 +326,7 @@ public async Task DeletesDraftWhenCanceled() var draftStore = Substitute.For(); var prservice = Substitute.For(); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, draftStore, - data.GitService, scheduler); + data.GitService, data.AutoCompleteAdvisor, scheduler); await vm.InitializeAsync(data.ActiveRepo, data.Connection); await vm.Cancel.Execute(); From 6e830236f18c25fd6c9f45e238a53102d180db64 Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Fri, 4 Jan 2019 10:55:11 -0500 Subject: [PATCH 09/44] Moving some files --- .../Services}/EmojiAutoCompleteSource.cs | 2 +- .../Helpers => GitHub.App/Services}/IAutoCompleteSource.cs | 3 ++- .../Services}/IssuesAutoCompleteSource.cs | 2 +- .../Services}/MentionsAutoCompleteSource.cs | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) rename src/{GitHub.UI/Helpers => GitHub.App/Services}/EmojiAutoCompleteSource.cs (98%) rename src/{GitHub.UI/Helpers => GitHub.App/Services}/IAutoCompleteSource.cs (83%) rename src/{GitHub.UI/Helpers => GitHub.App/Services}/IssuesAutoCompleteSource.cs (99%) rename src/{GitHub.UI/Helpers => GitHub.App/Services}/MentionsAutoCompleteSource.cs (98%) diff --git a/src/GitHub.UI/Helpers/EmojiAutoCompleteSource.cs b/src/GitHub.App/Services/EmojiAutoCompleteSource.cs similarity index 98% rename from src/GitHub.UI/Helpers/EmojiAutoCompleteSource.cs rename to src/GitHub.App/Services/EmojiAutoCompleteSource.cs index 2dbf7d0750..73c9f2780c 100644 --- a/src/GitHub.UI/Helpers/EmojiAutoCompleteSource.cs +++ b/src/GitHub.App/Services/EmojiAutoCompleteSource.cs @@ -5,7 +5,7 @@ using System.Windows.Media.Imaging; using GitHub.UI; -namespace GitHub.Helpers +namespace GitHub.Services { [Export(typeof(IAutoCompleteSource))] [PartCreationPolicy(CreationPolicy.Shared)] diff --git a/src/GitHub.UI/Helpers/IAutoCompleteSource.cs b/src/GitHub.App/Services/IAutoCompleteSource.cs similarity index 83% rename from src/GitHub.UI/Helpers/IAutoCompleteSource.cs rename to src/GitHub.App/Services/IAutoCompleteSource.cs index 2b7b80688e..0aea7bf1a7 100644 --- a/src/GitHub.UI/Helpers/IAutoCompleteSource.cs +++ b/src/GitHub.App/Services/IAutoCompleteSource.cs @@ -1,7 +1,8 @@ using System; +using GitHub.Models; using GitHub.UI; -namespace GitHub.Helpers +namespace GitHub.Services { public interface IAutoCompleteSource { diff --git a/src/GitHub.UI/Helpers/IssuesAutoCompleteSource.cs b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs similarity index 99% rename from src/GitHub.UI/Helpers/IssuesAutoCompleteSource.cs rename to src/GitHub.App/Services/IssuesAutoCompleteSource.cs index 1e67271309..1ceca2a1d2 100644 --- a/src/GitHub.UI/Helpers/IssuesAutoCompleteSource.cs +++ b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs @@ -8,7 +8,7 @@ using GitHub.UI; using GitHub.ViewModels; -namespace GitHub.Helpers +namespace GitHub.Services { [Export(typeof(IAutoCompleteSource))] [PartCreationPolicy(CreationPolicy.Shared)] diff --git a/src/GitHub.UI/Helpers/MentionsAutoCompleteSource.cs b/src/GitHub.App/Services/MentionsAutoCompleteSource.cs similarity index 98% rename from src/GitHub.UI/Helpers/MentionsAutoCompleteSource.cs rename to src/GitHub.App/Services/MentionsAutoCompleteSource.cs index c1453759ce..e90c0b0d9b 100644 --- a/src/GitHub.UI/Helpers/MentionsAutoCompleteSource.cs +++ b/src/GitHub.App/Services/MentionsAutoCompleteSource.cs @@ -3,15 +3,15 @@ using System.ComponentModel.Composition; using System.Reactive.Linq; using System.Windows.Media.Imaging; -using GitHub.Cache; using GitHub.Caches; using GitHub.Extensions; +using GitHub.Helpers; using GitHub.Models; using GitHub.Services; using GitHub.UI; using GitHub.ViewModels; -namespace GitHub.Helpers +namespace GitHub.Services { /// /// Supplies @mentions auto complete suggestions. From fcd503d66df5f2b42c603fa9beab451a13f3c1d1 Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Fri, 4 Jan 2019 10:56:40 -0500 Subject: [PATCH 10/44] Moving files --- .../Helpers => GitHub.App/Services}/AutoCompleteAdvisor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/{GitHub.UI/Helpers => GitHub.App/Services}/AutoCompleteAdvisor.cs (99%) diff --git a/src/GitHub.UI/Helpers/AutoCompleteAdvisor.cs b/src/GitHub.App/Services/AutoCompleteAdvisor.cs similarity index 99% rename from src/GitHub.UI/Helpers/AutoCompleteAdvisor.cs rename to src/GitHub.App/Services/AutoCompleteAdvisor.cs index 1c06d0ac96..c1d610526c 100644 --- a/src/GitHub.UI/Helpers/AutoCompleteAdvisor.cs +++ b/src/GitHub.App/Services/AutoCompleteAdvisor.cs @@ -10,7 +10,7 @@ using GitHub.UI; using NLog; -namespace GitHub.Helpers +namespace GitHub.Services { [Export(typeof(IAutoCompleteAdvisor))] [PartCreationPolicy(CreationPolicy.Shared)] From 653cc79c6cb098417f69b608af19edea82aaf4a0 Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Fri, 4 Jan 2019 10:58:36 -0500 Subject: [PATCH 11/44] Undoing changes to PullRequestCreationViewModel --- .../GitHubPane/PullRequestCreationViewModel.cs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs index 5450fb970e..89f8e37c50 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs @@ -18,7 +18,6 @@ using GitHub.Models.Drafts; using GitHub.Primitives; using GitHub.Services; -using GitHub.UI; using GitHub.Validation; using Octokit; using ReactiveUI; @@ -52,9 +51,8 @@ public PullRequestCreationViewModel( IPullRequestService service, INotificationService notifications, IMessageDraftStore draftStore, - IGitService gitService, - IAutoCompleteAdvisor autoCompleteAdvisor) - : this(modelServiceFactory, service, notifications, draftStore, gitService, autoCompleteAdvisor, DefaultScheduler.Instance) + IGitService gitService) + : this(modelServiceFactory, service, notifications, draftStore, gitService, DefaultScheduler.Instance) { } @@ -64,7 +62,6 @@ public PullRequestCreationViewModel( INotificationService notifications, IMessageDraftStore draftStore, IGitService gitService, - IAutoCompleteAdvisor autoCompleteAdvisor, IScheduler timerScheduler) { Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory)); @@ -72,14 +69,12 @@ 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) @@ -339,9 +334,8 @@ protected string GetDraftKey() SourceBranch.Name); } - public RemoteRepositoryModel GitHubRepository => githubRepository?.Value; - bool IsExecuting => isExecuting.Value; - public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + public RemoteRepositoryModel GitHubRepository { get { return githubRepository?.Value; } } + bool IsExecuting { get { return isExecuting.Value; } } bool initialized; bool Initialized From 8906c54b4faf2a10d15cf782881d5617f42f98e3 Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Fri, 4 Jan 2019 10:59:41 -0500 Subject: [PATCH 12/44] Revert "Undoing changes to PullRequestCreationViewModel" This reverts commit 653cc79c6cb098417f69b608af19edea82aaf4a0. --- .../GitHubPane/PullRequestCreationViewModel.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs index 89f8e37c50..5450fb970e 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) @@ -334,8 +339,9 @@ protected string GetDraftKey() SourceBranch.Name); } - public RemoteRepositoryModel GitHubRepository { get { return githubRepository?.Value; } } - bool IsExecuting { get { return isExecuting.Value; } } + public RemoteRepositoryModel GitHubRepository => githubRepository?.Value; + bool IsExecuting => isExecuting.Value; + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } bool initialized; bool Initialized From bfa18c619ec6a02b9689b6b31a0c40672a7324c0 Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Fri, 4 Jan 2019 11:09:03 -0500 Subject: [PATCH 13/44] Undoing some changes --- .../ViewModels/GitHubPane/PullRequestCreationViewModel.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs index 5450fb970e..bd25c85c52 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs @@ -339,8 +339,8 @@ protected string GetDraftKey() SourceBranch.Name); } - public RemoteRepositoryModel GitHubRepository => githubRepository?.Value; - bool IsExecuting => isExecuting.Value; + public RemoteRepositoryModel GitHubRepository { get { return githubRepository?.Value; } } + bool IsExecuting { get { return isExecuting.Value; } } public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } bool initialized; From 2c947487771d7038b02013b342785462b9269626 Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Sat, 5 Jan 2019 10:43:14 -0500 Subject: [PATCH 14/44] Trying to display the control correctly --- .../GitHubPane/PullRequestCreationView.xaml | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCreationView.xaml b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCreationView.xaml index e3d1c4b224..9b1c35db1f 100644 --- a/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCreationView.xaml +++ b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCreationView.xaml @@ -149,13 +149,24 @@ SpellCheck.IsEnabled="True" AutomationProperties.AutomationId="{x:Static ghfvs:AutomationIDs.PullRequestCreationTitleTextBox}"/> + + + Grid.Row="3" + MinHeight="100" + Margin="10,5" + Advisor="{Binding AutoCompleteAdvisor}" + > @@ -163,17 +174,13 @@ - + SpellCheck.IsEnabled="True"/> From 027b32254c08caa1544ade927dbc13c6286bd694 Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Sat, 5 Jan 2019 10:43:40 -0500 Subject: [PATCH 15/44] Exposing Advisor as a dependency property --- .../AutoCompleteBox/AutoCompleteBox.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs index 7c6075caa0..c96b73c2ee 100644 --- a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs +++ b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs @@ -740,6 +740,34 @@ public IAutoCompleteAdvisor Advisor set; } + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty AdvisorProperty = + DependencyProperty.Register( + "Advisor", + typeof(IAutoCompleteAdvisor), + typeof(AutoCompleteBox), + new PropertyMetadata(null, OnAdvisorPropertyChanged)); + + /// + /// AdvisorProperty property changed handler. + /// + /// AutoCompleteBox that changed its Advisor. + /// Event arguments. + private static void OnAdvisorPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var source = d as AutoCompleteBox; + if (source == null) return; + + source.Advisor = (IAutoCompleteAdvisor)e.NewValue; + } + /// /// Builds the visual tree for the /// control From 4779ac1d052ef3d37dfd51ea2c2e88fdc2b3a8c4 Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Thu, 24 Jan 2019 10:29:03 -0500 Subject: [PATCH 16/44] Add missing control styles --- src/GitHub.UI/Assets/Controls.xaml | 1 + .../Assets/Controls/AutoCompleteBox.xaml | 261 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml diff --git a/src/GitHub.UI/Assets/Controls.xaml b/src/GitHub.UI/Assets/Controls.xaml index 4e0abcc18b..105afa4c6b 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..1a1a950e6b --- /dev/null +++ b/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 9d2ee84e80dd415819719bd1db6c79d7a92b2be5 Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Thu, 24 Jan 2019 11:06:34 -0500 Subject: [PATCH 17/44] Including missing themes --- .../Assets/Controls/AutoCompleteBox.xaml | 20 +++---- .../Styles/ThemeBlue.xaml | 52 +++++++++++++++++++ .../Styles/ThemeDark.xaml | 52 +++++++++++++++++++ .../Styles/ThemeLight.xaml | 52 +++++++++++++++++++ 4 files changed, 166 insertions(+), 10 deletions(-) diff --git a/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml b/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml index 1a1a950e6b..2283b4b9a1 100644 --- a/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml +++ b/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml @@ -65,11 +65,11 @@ - - + + - + @@ -81,23 +81,23 @@ - + - - + + - - + + @@ -113,8 +113,8 @@ - - - - - + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + Submit review diff --git a/src/GitHub.VisualStudio.UI/Views/PullRequestReviewCommentView.xaml b/src/GitHub.VisualStudio.UI/Views/PullRequestReviewCommentView.xaml index 41597a44dc..eab0a78381 100644 --- a/src/GitHub.VisualStudio.UI/Views/PullRequestReviewCommentView.xaml +++ b/src/GitHub.VisualStudio.UI/Views/PullRequestReviewCommentView.xaml @@ -161,39 +161,51 @@ - - - - - - - + + + + + + + + + (); + autoCompleteAdvisor = autoCompleteAdvisor ?? Substitute.For(); - return new IssueishCommentViewModel(commentService); + return new IssueishCommentViewModel(commentService, autoCompleteAdvisor); } } } diff --git a/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentThreadViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentThreadViewModelTests.cs index 72e2c88536..2420120dea 100644 --- a/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentThreadViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentThreadViewModelTests.cs @@ -172,8 +172,9 @@ static IViewViewModelFactory CreateFactory() { var result = Substitute.For(); var commentService = Substitute.For(); + var autoCompleteAdvisor = Substitute.For(); result.CreateViewModel().Returns(_ => - new PullRequestReviewCommentViewModel(commentService)); + new PullRequestReviewCommentViewModel(commentService, autoCompleteAdvisor)); return result; } diff --git a/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs index 838d6fc8ca..d6d2fe8043 100644 --- a/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs @@ -54,7 +54,8 @@ public async Task CanBeExecutedForPlaceholders() var thread = CreateThread(); var currentUser = Substitute.For(); var commentService = Substitute.For(); - var target = new PullRequestReviewCommentViewModel(commentService); + var autoCompleteAdvisor = Substitute.For(); + var target = new PullRequestReviewCommentViewModel(commentService, autoCompleteAdvisor); await target.InitializeAsPlaceholderAsync(session, thread, false, false); @@ -98,7 +99,8 @@ public async Task CannotBeExecutedForPlaceholders() var thread = CreateThread(); var currentUser = Substitute.For(); var commentService = Substitute.For(); - var target = new PullRequestReviewCommentViewModel(commentService); + var autoCompleteAdvisor = Substitute.For(); + var target = new PullRequestReviewCommentViewModel(commentService, autoCompleteAdvisor); await target.InitializeAsPlaceholderAsync(session, thread, false, false); @@ -218,16 +220,18 @@ static async Task CreateTarget( ICommentThreadViewModel thread = null, ActorModel currentUser = null, PullRequestReviewModel review = null, - PullRequestReviewCommentModel comment = null) + PullRequestReviewCommentModel comment = null, + IAutoCompleteAdvisor autoCompleteAdvisor = null) { session = session ?? CreateSession(); commentService = commentService ?? Substitute.For(); + autoCompleteAdvisor = autoCompleteAdvisor ?? Substitute.For(); thread = thread ?? CreateThread(); currentUser = currentUser ?? new ActorModel { Login = "CurrentUser" }; comment = comment ?? new PullRequestReviewCommentModel(); review = review ?? CreateReview(PullRequestReviewState.Approved, comment); - var result = new PullRequestReviewCommentViewModel(commentService); + var result = new PullRequestReviewCommentViewModel(commentService, autoCompleteAdvisor); await result.InitializeAsync(session, thread, review, comment, CommentEditState.None); return result; } diff --git a/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs b/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs index bbdf4668c2..5e9ac069e6 100644 --- a/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs +++ b/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs @@ -272,8 +272,9 @@ static IViewViewModelFactory CreateFactory() var draftStore = Substitute.For(); var commentService = Substitute.For(); var result = Substitute.For(); + var autoCompleteAdvisor = Substitute.For(); result.CreateViewModel().Returns(_ => - new PullRequestReviewCommentViewModel(commentService)); + new PullRequestReviewCommentViewModel(commentService, autoCompleteAdvisor)); result.CreateViewModel().Returns(_ => new PullRequestReviewCommentThreadViewModel(draftStore, result)); return result; From 72a82e5f91ec58e5045c67c2397bd81e1b3e2ebf Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Mon, 8 Apr 2019 20:38:06 -0400 Subject: [PATCH 41/44] Fixing tests --- .../ViewModels/CommentThreadViewModelTests.cs | 2 +- .../ViewModels/CommentViewModelTests.cs | 7 +++++-- .../GitHubPane/PullRequestReviewAuthoringViewModelTests.cs | 6 +++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/test/GitHub.App.UnitTests/ViewModels/CommentThreadViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/CommentThreadViewModelTests.cs index 41744b5780..562522d252 100644 --- a/test/GitHub.App.UnitTests/ViewModels/CommentThreadViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/CommentThreadViewModelTests.cs @@ -113,7 +113,7 @@ public async Task AddPlaceholder(bool isEditing) class TestComment : CommentViewModel { public TestComment() - : base(Substitute.For()) + : base(Substitute.For(), Substitute.For()) { } diff --git a/test/GitHub.App.UnitTests/ViewModels/CommentViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/CommentViewModelTests.cs index 6fa4e42632..786719d517 100644 --- a/test/GitHub.App.UnitTests/ViewModels/CommentViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/CommentViewModelTests.cs @@ -65,11 +65,14 @@ await target.InitializeAsync( } CommentViewModel CreateTarget( - ICommentService commentService = null) + ICommentService commentService = null, + IAutoCompleteAdvisor autoCompleteAdvisor = null + ) { commentService = commentService ?? Substitute.For(); + autoCompleteAdvisor = autoCompleteAdvisor ?? Substitute.For(); - return new CommentViewModel(commentService); + return new CommentViewModel(commentService, autoCompleteAdvisor); } } } diff --git a/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModelTests.cs index 42c876cb35..f2d4d2925a 100644 --- a/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModelTests.cs @@ -487,12 +487,15 @@ static PullRequestReviewAuthoringViewModel CreateTarget( IPullRequestSessionManager sessionManager = null, IMessageDraftStore draftStore = null, IPullRequestFilesViewModel files = null, - IScheduler timerScheduler = null) + IScheduler timerScheduler = null, + IAutoCompleteAdvisor autoCompleteAdvisor = null + ) { editorService = editorService ?? Substitute.For(); sessionManager = sessionManager ?? CreateSessionManager(); draftStore = draftStore ?? Substitute.For(); files = files ?? Substitute.For(); + autoCompleteAdvisor = autoCompleteAdvisor ?? Substitute.For(); timerScheduler = timerScheduler ?? DefaultScheduler.Instance; return new PullRequestReviewAuthoringViewModel( @@ -501,6 +504,7 @@ static PullRequestReviewAuthoringViewModel CreateTarget( sessionManager, draftStore, files, + autoCompleteAdvisor, timerScheduler); } From 8a6737c9e207851fdcc77ae1635312adf2bb49e3 Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Tue, 9 Apr 2019 14:06:23 -0400 Subject: [PATCH 42/44] Using the GraphQL Search for Issues and Pull Requests --- .../Services/IssuesAutoCompleteSource.cs | 67 +++++++++++++------ submodules/octokit.graphql.net | 2 +- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/GitHub.App/Services/IssuesAutoCompleteSource.cs b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs index bc05f4469e..4889879841 100644 --- a/src/GitHub.App/Services/IssuesAutoCompleteSource.cs +++ b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs @@ -8,6 +8,7 @@ using GitHub.Models; using GitHub.Primitives; using Octokit.GraphQL; +using Octokit.GraphQL.Model; using static Octokit.GraphQL.Variable; namespace GitHub.Services @@ -18,7 +19,7 @@ public class IssuesAutoCompleteSource : IAutoCompleteSource { readonly ITeamExplorerContext teamExplorerContext; readonly IGraphQLClientFactory graphqlFactory; - ICompiledQuery> query; + ICompiledQuery> query; [ImportingConstructor] public IssuesAutoCompleteSource(ITeamExplorerContext teamExplorerContext, IGraphQLClientFactory graphqlFactory) @@ -38,32 +39,58 @@ public IObservable GetSuggestions() var owner = localRepositoryModel.Owner; var name = localRepositoryModel.Name; + string filter; + string after; + if (query == null) { - query = new Query().Repository(owner: Var(nameof(owner)), name: Var(nameof(name))) - .Select(repository => - repository.Issues(null, null, null, null, null, null, null) - .AllPages() - .Select(issue => new SuggestionItem("#" + issue.Number, issue.Title) - { - LastModifiedDate = issue.LastEditedAt - }) - .ToList()) - .Compile(); + 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(); } - var variables = new Dictionary - { - {nameof(owner), owner }, - {nameof(name), name }, - }; + filter = $"repo:{owner}/{name} is:open"; 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) { - var connection = await graphqlFactory.CreateConnection(hostAddress); - var suggestions = await connection.Run(query, variables); - return suggestions.Select(suggestion => new IssueAutoCompleteSuggestion(suggestion, Prefix)); - }).SelectMany(enumerable => enumerable); + 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 diff --git a/submodules/octokit.graphql.net b/submodules/octokit.graphql.net index ff20b6e9de..f12917ae36 160000 --- a/submodules/octokit.graphql.net +++ b/submodules/octokit.graphql.net @@ -1 +1 @@ -Subproject commit ff20b6e9de3d016112de8787ec8ade080214db2b +Subproject commit f12917ae365cc98b4f4a48b1aaefc6dc8c2593aa From 3995d0d841c6553c77f5d45531790b61d71937d6 Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Wed, 10 Apr 2019 07:13:19 -0400 Subject: [PATCH 43/44] Reverting submodule update --- submodules/octokit.graphql.net | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/octokit.graphql.net b/submodules/octokit.graphql.net index f12917ae36..ff20b6e9de 160000 --- a/submodules/octokit.graphql.net +++ b/submodules/octokit.graphql.net @@ -1 +1 @@ -Subproject commit f12917ae365cc98b4f4a48b1aaefc6dc8c2593aa +Subproject commit ff20b6e9de3d016112de8787ec8ade080214db2b From f1b538a4be4c146e3f41294cfe1da0bfd730582f Mon Sep 17 00:00:00 2001 From: Stanley Goldman Date: Wed, 10 Apr 2019 12:23:40 -0400 Subject: [PATCH 44/44] Allowing all issues closed or open --- src/GitHub.App/Services/IssuesAutoCompleteSource.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GitHub.App/Services/IssuesAutoCompleteSource.cs b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs index 4889879841..d2c64e671c 100644 --- a/src/GitHub.App/Services/IssuesAutoCompleteSource.cs +++ b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs @@ -59,7 +59,7 @@ public IObservable GetSuggestions() .Compile(); } - filter = $"repo:{owner}/{name} is:open"; + filter = $"repo:{owner}/{name}"; return Observable.FromAsync(async () => {