diff --git a/README.md b/README.md index 1a00c674..2852ce0f 100644 --- a/README.md +++ b/README.md @@ -52,17 +52,18 @@ How to define and use tags is described in the [Tags](docs/Tags.md) documentatio ## Search It is possible to filter or search for repositories using the search box at the top of RepoM. +RepoM also supports quick filters, which let you save searches, re-use tag filters, and toggle built-in filters such as favorites and active repositories. See the [Search](docs/search.md) for more information. ## Ordering and Filtering -The order of the repositories shown in RepoM is customizable. The default is alphabetical on the repository name. Read more about ordering [here](docs/_old/Ordering.md). +The order of the repositories shown in RepoM is customizable. The default is alphabetical on the repository name. Read more in the [Ordering documentation](docs/_old/Ordering.md). The repositories shown in RepoM are filtered using the search box in RepoM. But the default set can also be configured using different presets. ## Global configuration -When RepoM starts for the first time, a configuration file wil be created. Most of the properties can be adjusted using the UI but, at this moment, one property must be altered manually. Read more over [here](docs/_old/Settings.md). +When RepoM starts for the first time, a configuration file wil be created. Most of the properties can be adjusted using the UI but, at this moment, one property must be altered manually. Read more in the [Settings documentation](docs/_old/Settings.md). ## Multi configuration diff --git a/README.source.md b/README.source.md index c561ff25..862a504d 100644 --- a/README.source.md +++ b/README.source.md @@ -52,17 +52,18 @@ How to define and use tags is described in the [Tags](docs/Tags.md) documentatio ## Search It is possible to filter or search for repositories using the search box at the top of RepoM. +RepoM also supports quick filters, which let you save searches, re-use tag filters, and toggle built-in filters such as favorites and active repositories. See the [Search](docs/search.md) for more information. ## Ordering and Filtering -The order of the repositories shown in RepoM is customizable. The default is alphabetical on the repository name. Read more about ordering [here](docs/_old/Ordering.md). +The order of the repositories shown in RepoM is customizable. The default is alphabetical on the repository name. Read more in the [Ordering documentation](docs/_old/Ordering.md). The repositories shown in RepoM are filtered using the search box in RepoM. But the default set can also be configured using different presets. ## Global configuration -When RepoM starts for the first time, a configuration file wil be created. Most of the properties can be adjusted using the UI but, at this moment, one property must be altered manually. Read more over [here](docs/_old/Settings.md). +When RepoM starts for the first time, a configuration file wil be created. Most of the properties can be adjusted using the UI but, at this moment, one property must be altered manually. Read more in the [Settings documentation](docs/_old/Settings.md). ## Multi configuration diff --git a/docs/Tags.md b/docs/Tags.md index 3a821acc..50dc4e3d 100644 --- a/docs/Tags.md +++ b/docs/Tags.md @@ -7,7 +7,7 @@ Tags can be set to repositories and are used for searching, filtering, and order Tags can be defined in the `TagsV2.yaml` file located in `%APPDATA%\RepoM\` folder. The yaml consists of two parts. First an optional context section, and second the tags section. -The context is the same context you use defining the action menu's. It allows you to add or define methods and variables. More information over the context can be found [here](Context.md) +The context is the same context you use defining the action menus. It allows you to add or define methods and variables. See [Context](Context.md) for more information. The 'tags' section of the yaml is an array of tag specifications. The property `tag` is a string and represents the name of the tag. The optional property `when` is a predicate condition when to apply the tag. The predicate is evaluated using Scriban. @@ -68,7 +68,9 @@ tags: First, after applying tags, you noticat that they become visible in the UI. Although this is nice, it doesn't do anything for you at the moment. -The power of defining tags is currently the possiblility to search by a tag. For this to work, you need to have selected the 'Lucene query parser'. +The power of defining tags is currently the possibility to search by a tag. For this to work, you need to have selected the 'Lucene query parser'. + +Tags also integrate with quick filters in the UI. Clicking a tag on a repository row creates or activates a quick filter for that tag, so you can keep using that tag-based filter without retyping it. ### Search @@ -84,8 +86,11 @@ For now, all regular text is also matched against tags. ### Filter -TODO -See filtering. +Tags can also be used through quick filters. + +- Clicking a visible tag in the repository list creates or activates a quick filter for that tag. +- That quick filter can then be toggled on, off, or inverse like any other quick filter. +- Multiple tag-based quick filters can be combined with other quick filters and with the search box. ### Ordering diff --git a/docs/search.md b/docs/search.md index 7b77beff..96675588 100644 --- a/docs/search.md +++ b/docs/search.md @@ -1,31 +1,114 @@ # Search -After opening RepoM, you can search for respositories using the searchbox at the top. +After opening RepoM, you can narrow down the repository list in two ways: -By default, this box enabled you to search for repositories by simply matching repository names (i.e., the default query parser is very limited). +- by typing in the search box at the top, +- by enabling one or more quick filters below the search box. -It is possible to use a different query parser enabling the use of complex queries. +The search box is good for ad-hoc filtering. Quick filters are useful when you want to keep a search around and re-use it with a single click. + +## Search box + +By default, the search box uses the currently selected query parser. + +The default parser is intentionally simple and is mainly suited for free-text matching on repository names and tags. If you need field-based queries such as `tag:work` or `is:favorite`, select a more capable parser such as the Lucene query parser. + +The current search text is always combined with the active quick filters. In practice this means that the visible repository list must match both the search box and the enabled quick filters. + +## Quick filters + +Quick filters are persistent filter chips shown directly below the search box. A quick filter stores a parsed query and lets you enable, disable, invert, rename, and re-order that query without having to type it again. + +### What quick filters can do + +- Save a search query and re-use it later. +- Turn a repository tag into a reusable filter. +- Quickly switch common filters on and off. +- Invert a filter so matching repositories are excluded instead of included. +- Combine multiple quick filters to build a narrower result set. +- Keep custom labels and tooltips for filters you use often. + +### How to create quick filters + +There are three ways to get a quick filter: + +1. Type a query in the search box and click the pin button. +2. Click a tag on a repository item to create or activate a `tag:` filter. +3. Use the built-in quick filters that are always available. + +When you save a search, RepoM parses the text with the currently selected query parser and stores the resulting query. Saving the same query again does not create duplicates; RepoM activates the existing quick filter instead. + +### Built-in quick filters + +RepoM currently includes two built-in quick filters: + +- `Favorites`: matches repositories that are marked as favorite. Internally this is the same as `is:favorite`. +- `Active`: matches repositories with monitoring enabled. Internally this is the same as `is:active`. + +Built-in quick filters are always present. They cannot be renamed, deleted, or re-ordered. + +### Toggle behavior + +Clicking a quick filter cycles through three states: + +1. Off: the quick filter is ignored. +2. On: only repositories matching the filter are included. +3. Inverse: repositories matching the filter are excluded. + +This makes quick filters useful for both inclusion and exclusion scenarios. For example, a quick filter based on `tag:archived` can be inverted to hide archived repositories. + +### Combining behavior + +All active quick filters are combined using `AND`. + +Examples: + +- `Favorites` + `tag:work` shows only favorite work repositories. +- `Active` + `branch:main` shows only monitored repositories on the `main` branch. +- Inverted `tag:archived` + `tag:work` shows work repositories except archived ones. + +The search box is also combined with the quick filters using `AND`. So if the search box contains `github` and you enable a quick filter for `tag:work`, RepoM only shows repositories that match both. + +### Managing custom quick filters + +Custom quick filters support a small context popup that lets you: + +- change the label shown on the quick filter chip, +- add or change a tooltip, +- delete the quick filter. + +Custom quick filters can also be re-ordered by dragging them. This is useful when you have a small set of frequently used filters and want them in a predictable order. + +Custom quick filters are stored in `%APPDATA%\RepoM\quickfilters.json`. + +### Practical examples + +- Save `tag:work` as a quick filter if you often switch to work repositories. +- Save `is:favorite` if you prefer using a textual query instead of the built-in `Favorites` filter. +- Save `tag:github AND branch:main` when you often work on GitHub repositories on the main branch. +- Click the `private` tag on a repository row to create or activate a quick filter for that tag. ## Query Parser -The query parser is responsible for parsing a query string into a tree representation of this query. The leaves are so called query terms, and which can be combined with boolean operators. +The query parser is responsible for parsing a query string into a query tree. The leaves are query terms, which can be combined with boolean operators. -The default query parser takes the query string and creates a single 'FreeText' which will be evaluted using the corresponding matcher. +The active query parser is used in two places: -A suffisticated query parser can create for instance multiple terms combined using an 'OR' operator, so only one term has to match. +- while typing in the search box, +- when saving the current search as a quick filter. -Currently, the following query parsers are available: +The default query parser takes the query string and creates a single `FreeText` query which is evaluated using the corresponding matcher. -- Default (legacy, all text will be matched using FreeTextTerm matcher) -- LuceneQueryParser (provided using `LuceneQueryParser` plugin, which enables a suffisticated query syntax based on Lucene) +A more sophisticated query parser can create multiple terms combined using operators such as `AND`, `OR`, and `NOT`. -### Terms and Operators +Currently, the following query parsers are available: -sdf +- Default (legacy, all text will be matched using FreeTextTerm matcher) +- LuceneQueryParser (provided using `LuceneQueryParser` plugin, which enables a sophisticated query syntax based on Lucene) ### Query matchers -Terms are matched using query matchers. A list of query matchers can be found over [here](querymatchers.md). +Terms are matched using query matchers. See [Query matchers](querymatchers.md) for the available matchers. ## Terms @@ -33,17 +116,13 @@ There are multiple terms which express intent. It is up to the different handler ### FreeText -Free text without a term. The free text matcher currently matches the free text against the repository name or it's tags. - -### SimpleTerm - -sdf +Free text without a term. The free text matcher currently matches the free text against the repository name or its tags. ### StartsWithTerm The start with term has a term and a value. -For example, the term `StartsWithTerm(term: 'branch', value: 'ma')` will be evaluated by a specific IQueryMatcher which checks if the +For example, the term `StartsWithTerm(term: 'branch', value: 'ma')` can be evaluated by a matcher that checks whether the branch starts with `ma`. - - @@ -199,7 +224,8 @@ - @@ -227,7 +253,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - + - + /// Interaction logic for MainWindow.xaml @@ -83,21 +75,18 @@ private enum AcrylicBehavior private readonly IRepositoryMonitoringEvents _monitoringEvents; private readonly ITranslationService _translationService; private readonly IFileSystem _fileSystem; - private readonly ActionExecutor _executor; private readonly IRepositoryFilteringManager _repositoryFilteringManager; private readonly ILogger _logger; - private readonly IUserMenuActionMenuFactory _userMenuActionFactory; private readonly IAppDataPathProvider _appDataPathProvider; private readonly IRepositoryComparerManager _repositoryComparerManager; private readonly IThreadDispatcher _threadDispatcher; private readonly IAppSettingsService _appSettingsService; private readonly IModuleManager _moduleManager; + private readonly QuickFilterBarViewModel _quickFilterBarViewModel; + private readonly RepositoryListViewModel _repositoryListViewModel; private ReadOnlyObservableCollection _repositories = null!; private readonly CompositeDisposable _disposables = new(); private bool _isScanning; - private readonly object _separator = new(); - private readonly object _singleItem = new(); - private readonly object _menuItem = new(); public MainWindow( RepositoryMonitorService monitorService, @@ -114,6 +103,8 @@ public MainWindow( IRepositoryComparerManager repositoryComparerManager, IThreadDispatcher threadDispatcher, IRepositoryFilteringManager repositoryFilteringManager, + IQuickFilterService quickFilterService, + IEnumerable namedQueryParsers, IModuleManager moduleManager, ILogger logger, IUserMenuActionMenuFactory userMenuActionFactory) @@ -124,19 +115,24 @@ public MainWindow( _monitoringService = monitoringService ?? throw new ArgumentNullException(nameof(monitoringService)); _monitoringEvents = monitoringEvents ?? throw new ArgumentNullException(nameof(monitoringEvents)); _repositoryFilteringManager = repositoryFilteringManager ?? throw new ArgumentNullException(nameof(repositoryFilteringManager)); + ArgumentNullException.ThrowIfNull(quickFilterService); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _userMenuActionFactory = userMenuActionFactory ?? throw new ArgumentNullException(nameof(userMenuActionFactory)); + ArgumentNullException.ThrowIfNull(userMenuActionFactory); _translationService = translationService ?? throw new ArgumentNullException(nameof(translationService)); _repositoryIgnoreStore = repositoryIgnoreStore ?? throw new ArgumentNullException(nameof(repositoryIgnoreStore)); _appDataPathProvider = appDataPathProvider ?? throw new ArgumentNullException(nameof(appDataPathProvider)); _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); - _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + ArgumentNullException.ThrowIfNull(executor); _repositoryComparerManager = repositoryComparerManager ?? throw new ArgumentNullException(nameof(repositoryComparerManager)); _threadDispatcher = threadDispatcher ?? throw new ArgumentNullException(nameof(threadDispatcher)); _appSettingsService = appSettingsService ?? throw new ArgumentNullException(nameof(appSettingsService)); _moduleManager = moduleManager ?? throw new ArgumentNullException(nameof(moduleManager)); + _quickFilterBarViewModel = new QuickFilterBarViewModel(quickFilterService, repositoryFilteringManager, namedQueryParsers, logger); + _repositoryListViewModel = new RepositoryListViewModel(_monitorService, executor, userMenuActionFactory, _logger, _quickFilterBarViewModel.AddTagCommand); InitializeComponent(); + quickFilterBar.Initialize(_quickFilterBarViewModel); + repositoryList.Initialize(_repositoryListViewModel); SetAcrylicWindowStyle(this, AcrylicWindowStyle.None); @@ -278,6 +274,9 @@ public void SetReady() var queryParsersViewModel = new QueryParsersViewModel(_repositoryFilteringManager, _threadDispatcher); var filterViewModel = new FiltersViewModel(_repositoryFilteringManager, _threadDispatcher); var pluginsViewModel = new PluginCollectionViewModel(_moduleManager); + var quickFilterCommands = new MainWindowQuickFilterCommands( + _quickFilterBarViewModel.SaveSearchTextCommand, + _quickFilterBarViewModel.AddTagCommand); DataContext = new MainWindowViewModel( _appSettingsService, @@ -285,7 +284,8 @@ public void SetReady() queryParsersViewModel, filterViewModel, pluginsViewModel, - new HelpViewModel(_translationService)); + new HelpViewModel(_translationService), + quickFilterCommands); SettingsMenu.DataContext = DataContext; // this is out of the visual tree var uiScheduler = new SynchronizationContextScheduler(SynchronizationContext.Current!); @@ -300,9 +300,30 @@ public void SetReady() _filterTextSubject = new BehaviorSubject(string.Empty); _disposables.Add(_filterTextSubject); - var filterObservable = _repositoryFilteringManager.CreateFilterObservable( + var quickFilterChanged = Observable.FromEventPattern( + h => _quickFilterBarViewModel.FilterStateChanged += h, + h => _quickFilterBarViewModel.FilterStateChanged -= h) + .Select(_ => System.Reactive.Unit.Default) + .StartWith(System.Reactive.Unit.Default); + + var baseFilterObservable = _repositoryFilteringManager.CreateFilterObservable( _filterTextSubject.Throttle(TimeSpan.FromMilliseconds(200)).DistinctUntilChanged()); + // Combine the existing filter with quick filters + var filterObservable = baseFilterObservable + .CombineLatest(quickFilterChanged, (basePredicate, _) => + { + var quickQuery = _quickFilterBarViewModel.GetCombinedActiveQuery(); + if (quickQuery == null) + { + return basePredicate; + } + + var matcher = Bootstrapper.Container.GetInstance(); + return (RepositoryViewModel vm) => + basePredicate(vm) && matcher.Matches(vm.Repository, quickQuery); + }); + var bindSubscription = _store.Connect() .TransformWithInlineUpdate( info => new RepositoryViewModel(info, _favoriteService, _monitoringService, _monitoringEvents), @@ -315,7 +336,7 @@ public void SetReady() .Subscribe(); _disposables.Add(bindSubscription); - lstRepositories.ItemsSource = _repositories; + _repositoryListViewModel.ItemsSource = _repositories; // Track whether we have any repositories at all (unfiltered count) var countSubscription = _store.Connect() @@ -345,348 +366,6 @@ public void ShowAndActivate() }); } - private async void LstRepositories_MouseDoubleClick(object? sender, MouseButtonEventArgs e) - { - // prevent doubleclicks from scrollbars and other non-data areas - if (e.OriginalSource is not (Grid or TextBlock)) - { - return; - } - - try - { - await InvokeActionOnCurrentRepositoryAsync().ConfigureAwait(false); - } - catch (Exception exception) - { - _logger.LogError(exception, "Could not invoke action on current repository."); - } - } - - private async void LstRepositories_ContextMenuOpening(object? sender, ContextMenuEventArgs e) - { - if (sender == null) - { - e.Handled = true; - return; - } - - WpfContextMenu ctxMenu = ((FrameworkElement)e.Source).ContextMenu!; - var lstRepositoriesContextMenuOpening = await LstRepositoriesContextMenuOpeningWrapperAsync(ctxMenu).ConfigureAwait(true); - if (!lstRepositoriesContextMenuOpening) - { - e.Handled = true; - } - } - - private async Task LstRepositoriesContextMenuOpeningWrapperAsync(WpfContextMenu ctxMenu) - { - try - { - return await LstRepositoriesContextMenuOpeningAsync(ctxMenu).ConfigureAwait(true); - } - catch (Exception e) - { - _logger.LogError(e, "Could not create menu."); - - ctxMenu.Items.Clear(); - ctxMenu.Items.Add(new AcrylicMenuItem - { - Header = "Error", - IsEnabled = false, - }); - ctxMenu.Items.Add(new AcrylicMenuItem - { - Header = e.Message, - IsEnabled = false, - }); - - return false; - } - } - - private async Task LstRepositoriesContextMenuOpeningAsync(WpfContextMenu ctxMenu) - { - if (lstRepositories.SelectedItem is not RepositoryViewModel vm) - { - return false; - } - - // Auto-activate monitoring when the context menu is opened. - vm.EnableMonitoring(); - - // The context menu is built from repo state, so ensure we have the latest - // branch/ref information before generating menu actions. - RepositoryInfo? updatedInfo = await _monitorService.RefreshRepositoryAsync(vm.Path, CancellationToken.None).ConfigureAwait(false); - IRepository repositoryForMenu = updatedInfo != null ? new RepositoryInfoAdapter(updatedInfo) : vm.Repository; - - int AddItemMenuAndSeparator(int count) - { - ctxMenu.Items.Add(new AcrylicMenuItem - { - Header = string.Empty, - Visibility = Visibility.Collapsed, - Tag = _singleItem, - IsEnabled = default, - }); - ctxMenu.Items.Add(new AcrylicMenuItem - { - Header = string.Empty, - Visibility = Visibility.Collapsed, - Items = { new Separator(), }, - IsEnabled = default, - Tag = _menuItem, - }); - ctxMenu.Items.Add(new Separator - { - Visibility = Visibility.Collapsed, - Tag = _separator, - }); - - return count + 3; - } - - // Phase 1: Collect all actions off the UI thread to avoid per-item - // dispatcher marshaling and intermediate layout passes. - var actions = new List(); - await foreach (UserInterfaceRepositoryActionBase action in _userMenuActionFactory.CreateMenuAsync(repositoryForMenu).ConfigureAwait(false)) - { - actions.Add(action); - } - - // Phase 2: Marshal back to UI thread once and apply all changes - // in a single synchronous batch — no intermediate layout passes. - await Dispatcher.InvokeAsync(() => ApplyMenuActions(ctxMenu, actions, vm, AddItemMenuAndSeparator)); - - return true; - } - - private void ApplyMenuActions( - WpfContextMenu ctxMenu, - List actions, - RepositoryViewModel vm, - Func addItemMenuAndSeparator) - { - var index = -1; - var lastVisibleSeparator = false; - var ctxMenuItemsCount = ctxMenu.Items.Count; - - foreach (UserInterfaceRepositoryActionBase action in actions) - { - index++; - - if (action is UserInterfaceSeparatorRepositoryAction) - { - lastVisibleSeparator = ApplySeparator(ctxMenu, ref index, ref ctxMenuItemsCount, lastVisibleSeparator, addItemMenuAndSeparator); - } - else if (action is UserInterfaceRepositoryAction uira) - { - lastVisibleSeparator = false; - ApplyMenuItem(ctxMenu, ref index, ref ctxMenuItemsCount, uira, vm, addItemMenuAndSeparator); - } - } - - if (!lastVisibleSeparator) - { - index++; - } - - CollapseItemsFrom(ctxMenu, index, ctxMenuItemsCount); - } - - private static bool ApplySeparator( - WpfContextMenu ctxMenu, - ref int index, - ref int ctxMenuItemsCount, - bool lastVisibleSeparator, - Func addItemMenuAndSeparator) - { - SkipAndCollapse(ctxMenu, ref index, ctxMenuItemsCount, item => item is AcrylicMenuItem); - - if (ctxMenuItemsCount <= index) - { - ctxMenuItemsCount = addItemMenuAndSeparator(ctxMenuItemsCount); - index += 2; - } - - if (ctxMenu.Items[index] is Separator s) - { - s.Visibility = lastVisibleSeparator ? Visibility.Collapsed : Visibility.Visible; - } - - return true; - } - - private void ApplyMenuItem( - WpfContextMenu ctxMenu, - ref int index, - ref int ctxMenuItemsCount, - UserInterfaceRepositoryAction uira, - RepositoryViewModel vm, - Func addItemMenuAndSeparator) - { - var hasSubItems = HasSubItems(uira); - var skipTag = hasSubItems ? _singleItem : _menuItem; - - SkipAndCollapse(ctxMenu, ref index, ctxMenuItemsCount, item => item is Separator || (item is AcrylicMenuItem ami && ami.Tag == skipTag)); - - if (ctxMenuItemsCount <= index) - { - ctxMenuItemsCount = addItemMenuAndSeparator(ctxMenuItemsCount); - index += hasSubItems ? 1 : 0; - } - - var acrylicMenuItem = (AcrylicMenuItem)ctxMenu.Items[index]!; - if (acrylicMenuItem.Visibility != Visibility.Visible) - { - acrylicMenuItem.Visibility = Visibility.Visible; - } - - acrylicMenuItem.SetHeader(uira.Name); - acrylicMenuItem.SetEnabled(uira.CanExecute); - - if (hasSubItems) - { - SetSubMenu(acrylicMenuItem, uira); - } - else - { - SetClick(acrylicMenuItem, uira, vm); - } - } - - private static void SkipAndCollapse(WpfContextMenu ctxMenu, ref int index, int itemsCount, Func shouldSkip) - { - while (itemsCount > index && shouldSkip(ctxMenu.Items[index]!)) - { - var ctrl = (Control)ctxMenu.Items[index]!; - if (ctrl.Visibility != Visibility.Collapsed) - { - ctrl.Visibility = Visibility.Collapsed; - } - - index++; - } - } - - private static void CollapseItemsFrom(WpfContextMenu ctxMenu, int startIndex, int itemsCount) - { - for (var i = startIndex; i < itemsCount; i++) - { - var ctrl = (Control)ctxMenu.Items[i]!; - if (ctrl.Visibility != Visibility.Collapsed) - { - ctrl.Visibility = Visibility.Collapsed; - } - } - } - - private void SetClick(AcrylicMenuItem acrylicMenuItem, UserInterfaceRepositoryAction action, RepositoryViewModel? affectedViews) - { - void ClickAction(object clickSender, object clickArgs) - { - // run actions in the UI async to not block it - if (action.ExecutionCausesSynchronizing) - { - Task.Run(() => SetVmSynchronizing(affectedViews, true)) - .ContinueWith(t => _executor.Execute(action.Repository, action.RepositoryCommand)) - .ContinueWith(t => SetVmSynchronizing(affectedViews, false)); - } - else - { - Task.Run(() => _executor.Execute(action.Repository, action.RepositoryCommand)); - } - } - - if (action.RepositoryCommand is null or NullRepositoryCommand) - { - acrylicMenuItem.ClearClick(); - } - else - { - acrylicMenuItem.SetClick(new RoutedEventHandler((Action)ClickAction)); - } - } - - private async void LstRepositories_KeyDown(object? sender, KeyEventArgs e) - { - if (e.Key is Key.Return or Key.Enter) - { - try - { - await InvokeActionOnCurrentRepositoryAsync().ConfigureAwait(false); - } - catch (Exception exception) - { - Console.WriteLine(exception); - } - - return; - } - - if (e.Key is Key.Left or Key.Right) - { - if (sender == null) - { - e.Handled = true; - return; - } - - // try open context menu. - WpfContextMenu? ctxMenu = ((FrameworkElement)e.Source).ContextMenu; - if (ctxMenu == null) - { - e.Handled = true; - return; - } - - var lstRepositoriesContextMenuOpening = await LstRepositoriesContextMenuOpeningWrapperAsync(ctxMenu).ConfigureAwait(true); - if (lstRepositoriesContextMenuOpening) - { - ctxMenu.Placement = PlacementMode.Left; - ctxMenu.PlacementTarget = (UIElement)e.OriginalSource; - ctxMenu.IsOpen = true; - } - } - } - - private async Task InvokeActionOnCurrentRepositoryAsync() - { - if (lstRepositories.SelectedItem is not RepositoryViewModel selectedView) - { - return; - } - - if (!selectedView.WasFound) - { - return; - } - - var skip = 0; - if (Keyboard.IsKeyDown(Key.LeftAlt) || Keyboard.IsKeyDown(Key.LeftCtrl)) - { - skip = 1; - } - - UserInterfaceRepositoryActionBase uiRepositoryAction = await _userMenuActionFactory - .CreateMenuAsync(selectedView.Repository) - .Skip(skip) - .FirstAsync() - .ConfigureAwait(false); - - if (uiRepositoryAction is not UserInterfaceRepositoryAction action) - { - return; - } - - if (action.RepositoryCommand is NullRepositoryCommand) - { - return; - } - - _executor.Execute(action.Repository, action.RepositoryCommand); - } - private void HelpButton_Click(object sender, RoutedEventArgs e) { transitionerMain.SelectedIndex = transitionerMain.SelectedIndex == 0 ? 1 : 0; @@ -700,24 +379,6 @@ private void MenuButton_Click(object sender, RoutedEventArgs e) } } - private void MonitoringToggle_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) - { - if (sender is FrameworkElement fe && fe.DataContext is RepositoryViewModel vm) - { - vm.ToggleMonitoring(); - e.Handled = true; - } - } - - private void FavoriteToggle_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) - { - if (sender is FrameworkElement fe && fe.DataContext is RepositoryViewModel vm) - { - vm.ToggleFavorite(); - e.Handled = true; - } - } - private void ScanButton_Click(object sender, RoutedEventArgs e) { if (_isScanning) @@ -748,13 +409,15 @@ private void CustomizeContextMenu_Click(object sender, RoutedEventArgs e) { var directoryName = _appDataPathProvider.AppDataPath; - if (_fileSystem.Directory.Exists(directoryName)) + if (!_fileSystem.Directory.Exists(directoryName)) { - Process.Start(new ProcessStartInfo(directoryName) - { - UseShellExecute = true, - }); + return; } + + Process.Start(new ProcessStartInfo(directoryName) + { + UseShellExecute = true, + }); } private void UpdateButton_Click(object sender, RoutedEventArgs e) @@ -828,107 +491,6 @@ private void ShowUpdateIfAvailable() parent.ColumnDefinitions[Grid.GetColumn(UpdateButton)].Width = App.AvailableUpdate == null ? new GridLength(0) : GridLength.Auto; } - private Control? CreateMenuItemAsync(UserInterfaceRepositoryActionBase action, RepositoryViewModel? affectedViews = null) - { - if (action is UserInterfaceSeparatorRepositoryAction) - { - return new Separator(); - } - - if (action is not UserInterfaceRepositoryAction repositoryAction) - { - // throw?? - return null; - } - - var item = new AcrylicMenuItem - { - Header = repositoryAction.Name, - IsEnabled = repositoryAction.CanExecute, - }; - SetClick(item, repositoryAction, affectedViews); - SetSubMenu(item, repositoryAction); - return item; - } - - private static bool HasSubItems(UserInterfaceRepositoryAction repositoryAction) - { - if (repositoryAction is DeferredSubActionsUserInterfaceRepositoryAction) - { - return true; - } - - return repositoryAction.SubActions != null; - } - - private void SetSubMenu(AcrylicMenuItem item, UserInterfaceRepositoryAction repositoryAction) - { - if (repositoryAction is DeferredSubActionsUserInterfaceRepositoryAction deferredRepositoryAction) - { - EnsureTemplateSeparator(item); - item.LoadData(deferredRepositoryAction); - item.SetSubMenuOpened(async (_, _) => - { - item.ClearSubMenuOpened(); - var data = await item.DataTask; - item.ClearItems(); - PopulateSubMenuItems(item, data); - item.ClearData(); - }); - } - else if (repositoryAction.SubActions != null) - { - EnsureTemplateSeparator(item); - item.SetSubMenuOpened((_, _) => - { - item.ClearSubMenuOpened(); - item.ClearItems(); - PopulateSubMenuItems(item, repositoryAction.SubActions); - }); - } - } - - private static void EnsureTemplateSeparator(AcrylicMenuItem item) - { - if (item.Items.Count == 0) - { - item.Items.Add(new Separator()); - } - } - - private void PopulateSubMenuItems(AcrylicMenuItem item, IEnumerable subActions) - { - foreach (UserInterfaceRepositoryActionBase subAction in subActions) - { - Control? controlItem = CreateMenuItemAsync(subAction); - if (controlItem == null) - { - continue; - } - - if (controlItem is not Separator) - { - item.Items.Add(controlItem); - continue; - } - - if (item.Items.Count > 0 && item.Items[^1] is not Separator) - { - item.Items.Add(controlItem); - } - } - - if (item.Items.Count > 0 && item.Items[^1] is Separator) - { - item.Items.RemoveAt(item.Items.Count - 1); - } - } - - private static void SetVmSynchronizing(RepositoryViewModel? affectedVm, bool synchronizing) - { - affectedVm?.IsSynchronizing = synchronizing; - } - private void ShowScanningState(bool isScanning) { _logger.LogInformation("UI scan state changed: IsScanning = {IsScanning}", isScanning); @@ -951,7 +513,7 @@ protected override void OnKeyDown(KeyEventArgs e) if (e.Key == Key.Down && txtFilter.IsFocused) { - lstRepositories.Focus(); + repositoryList.FocusList(); } // show/hide the titlebar to move the window for screenshots, for example @@ -978,15 +540,7 @@ private void OnTxtFilterTextChanged(object? sender, TextChangedEventArgs e) private void TxtFilter_Finish(object sender, EventArgs e) { - lstRepositories.Focus(); - if (lstRepositories.Items.Count <= 0) - { - return; - } - - lstRepositories.SelectedIndex = 0; - var item = (ListBoxItem)lstRepositories.ItemContainerGenerator.ContainerFromIndex(0); - item?.Focus(); + repositoryList.FocusFirstItem(); } public bool IsShown => Visibility == Visibility.Visible && IsActive && (!_useOffScreenHide || Left > -99000); diff --git a/src/RepoM.App/NotifyIconResources.xaml b/src/RepoM.App/NotifyIconResources.xaml index 6e1b1e5a..7d829ac8 100644 --- a/src/RepoM.App/NotifyIconResources.xaml +++ b/src/RepoM.App/NotifyIconResources.xaml @@ -1,15 +1,20 @@  + xmlns:app="clr-namespace:RepoM.App" + mc:Ignorable="d"> - + @@ -32,6 +37,7 @@ IconSource="/App.ico" LeftClickCommand="{Binding OpenCommand}" NoLeftClickDelay="True" + d:DataContext="{d:DesignInstance app:NotifyIconViewModel}" ContextMenu="{StaticResource SysTrayMenu}"> diff --git a/src/RepoM.App/ViewModels/MainWindowQuickFilterCommands.cs b/src/RepoM.App/ViewModels/MainWindowQuickFilterCommands.cs new file mode 100644 index 00000000..ee21df8d --- /dev/null +++ b/src/RepoM.App/ViewModels/MainWindowQuickFilterCommands.cs @@ -0,0 +1,17 @@ +namespace RepoM.App.ViewModels; + +using System; +using System.Windows.Input; + +internal sealed class MainWindowQuickFilterCommands +{ + public MainWindowQuickFilterCommands(ICommand saveQuickFilterCommand, ICommand addQuickFilterTagCommand) + { + SaveQuickFilterCommand = saveQuickFilterCommand ?? throw new ArgumentNullException(nameof(saveQuickFilterCommand)); + AddQuickFilterTagCommand = addQuickFilterTagCommand ?? throw new ArgumentNullException(nameof(addQuickFilterTagCommand)); + } + + public ICommand SaveQuickFilterCommand { get; } + + public ICommand AddQuickFilterTagCommand { get; } +} \ No newline at end of file diff --git a/src/RepoM.App/ViewModels/MainWindowViewModel.cs b/src/RepoM.App/ViewModels/MainWindowViewModel.cs index 3810d06c..1a6fdfcf 100644 --- a/src/RepoM.App/ViewModels/MainWindowViewModel.cs +++ b/src/RepoM.App/ViewModels/MainWindowViewModel.cs @@ -2,22 +2,51 @@ namespace RepoM.App.ViewModels; using System; using System.ComponentModel; +using System.Windows.Input; using JetBrains.Annotations; using RepoM.Api.Common; using RepoM.Api.Git.AutoFetch; public class MainWindowViewModel : INotifyPropertyChanged { + private static readonly PropertyChangedEventArgs _autoFetchModeChangedArgs = new(nameof(AutoFetchMode)); + private static readonly PropertyChangedEventArgs _autoFetchOffChangedArgs = new(nameof(AutoFetchOff)); + private static readonly PropertyChangedEventArgs _autoFetchDiscretelyChangedArgs = new(nameof(AutoFetchDiscretely)); + private static readonly PropertyChangedEventArgs _autoFetchAdequateChangedArgs = new(nameof(AutoFetchAdequate)); + private static readonly PropertyChangedEventArgs _autoFetchAggressiveChangedArgs = new(nameof(AutoFetchAggressive)); + private readonly IAppSettingsService _appSettingsService; + private static readonly ICommand _noOpCommand = new NoOpCommand(); + private static readonly MainWindowQuickFilterCommands _noOpQuickFilterCommands = new(_noOpCommand, _noOpCommand); + public event PropertyChangedEventHandler? PropertyChanged; - public MainWindowViewModel( + internal MainWindowViewModel( IAppSettingsService appSettingsService, OrderingsViewModel orderingsViewModel, QueryParsersViewModel queryParsersViewModel, FiltersViewModel filtersViewModel, PluginCollectionViewModel pluginsViewModel, HelpViewModel helpViewModel) + : this( + appSettingsService, + orderingsViewModel, + queryParsersViewModel, + filtersViewModel, + pluginsViewModel, + helpViewModel, + _noOpQuickFilterCommands) + { + } + + internal MainWindowViewModel( + IAppSettingsService appSettingsService, + OrderingsViewModel orderingsViewModel, + QueryParsersViewModel queryParsersViewModel, + FiltersViewModel filtersViewModel, + PluginCollectionViewModel pluginsViewModel, + HelpViewModel helpViewModel, + MainWindowQuickFilterCommands quickFilterCommands) { _appSettingsService = appSettingsService ?? throw new ArgumentNullException(nameof(appSettingsService)); Orderings = orderingsViewModel ?? throw new ArgumentNullException(nameof(orderingsViewModel)); @@ -25,6 +54,9 @@ public MainWindowViewModel( Filters = filtersViewModel ?? throw new ArgumentNullException(nameof(filtersViewModel)); Plugins = pluginsViewModel ?? throw new ArgumentNullException(nameof(pluginsViewModel)); Help = helpViewModel ?? throw new ArgumentNullException(nameof(helpViewModel)); + ArgumentNullException.ThrowIfNull(quickFilterCommands); + SaveQuickFilterCommand = quickFilterCommands.SaveQuickFilterCommand; + AddQuickFilterTagCommand = quickFilterCommands.AddQuickFilterTagCommand; } private AutoFetchMode AutoFetchMode @@ -34,11 +66,11 @@ private AutoFetchMode AutoFetchMode { _appSettingsService.AutoFetchMode = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoFetchMode))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoFetchOff))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoFetchDiscretely))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoFetchAdequate))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoFetchAggressive))); + PropertyChanged?.Invoke(this, _autoFetchModeChangedArgs); + PropertyChanged?.Invoke(this, _autoFetchOffChangedArgs); + PropertyChanged?.Invoke(this, _autoFetchDiscretelyChangedArgs); + PropertyChanged?.Invoke(this, _autoFetchAdequateChangedArgs); + PropertyChanged?.Invoke(this, _autoFetchAggressiveChangedArgs); } } @@ -52,6 +84,10 @@ private AutoFetchMode AutoFetchMode public HelpViewModel Help { [UsedImplicitly] get; } + public ICommand SaveQuickFilterCommand { [UsedImplicitly] get; } + + public ICommand AddQuickFilterTagCommand { [UsedImplicitly] get; } + public bool AutoFetchOff { get => AutoFetchMode == AutoFetchMode.Off; @@ -97,4 +133,17 @@ public bool PruneOnFetch get => _appSettingsService.PruneOnFetch; set => _appSettingsService.PruneOnFetch = value; } + + private sealed class NoOpCommand : ICommand + { + public event EventHandler? CanExecuteChanged + { + add => _ = value; + remove => _ = value; + } + + public bool CanExecute(object? parameter) => true; + + public void Execute(object? parameter) => _ = parameter; + } } \ No newline at end of file diff --git a/src/RepoM.App/ViewModels/QuickFilterBarViewModel.cs b/src/RepoM.App/ViewModels/QuickFilterBarViewModel.cs new file mode 100644 index 00000000..ce3f0f16 --- /dev/null +++ b/src/RepoM.App/ViewModels/QuickFilterBarViewModel.cs @@ -0,0 +1,215 @@ +namespace RepoM.App.ViewModels; + +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Windows.Input; +using Microsoft.Extensions.Logging; +using RepoM.Api.QuickFilter; +using RepoM.App.RepositoryFiltering; +using RepoM.Core.Plugin.RepositoryFiltering; +using RepoM.Core.Plugin.RepositoryFiltering.Clause; +using RepoM.Core.Plugin.RepositoryFiltering.Clause.Terms; + +public sealed class QuickFilterBarViewModel : INotifyPropertyChanged +{ + private static readonly PropertyChangedEventArgs _combineModeChangedArgs = new(nameof(CombineMode)); + private static readonly PropertyChangedEventArgs _combineModeLabelChangedArgs = new(nameof(CombineModeLabel)); + private static readonly PropertyChangedEventArgs _combineModeToolTipChangedArgs = new(nameof(CombineModeToolTip)); + private static readonly PropertyChangedEventArgs _hasItemsChangedArgs = new(nameof(HasItems)); + + private readonly IQuickFilterService _service; + private readonly IRepositoryFilteringManager _repositoryFilteringManager; + private readonly INamedQueryParser[] _queryParsers; + private readonly ILogger _logger; + + public QuickFilterBarViewModel( + IQuickFilterService service, + IRepositoryFilteringManager repositoryFilteringManager, + System.Collections.Generic.IEnumerable queryParsers, + ILogger logger) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + _repositoryFilteringManager = repositoryFilteringManager ?? throw new ArgumentNullException(nameof(repositoryFilteringManager)); + _queryParsers = queryParsers?.ToArray() ?? throw new ArgumentNullException(nameof(queryParsers)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + if (_queryParsers.Length == 0) + { + throw new ArgumentException("At least one query parser must be available.", nameof(queryParsers)); + } + + Items = new ObservableCollection( + _service.GetAll().Select(model => new QuickFilterViewModel(model, _service))); + + AddTagCommand = new RelayCommand(parameter => + { + if (parameter is string tag) + { + AddFromTag(tag); + } + }); + + SaveSearchTextCommand = new RelayCommand(parameter => + { + if (parameter is string searchText) + { + SaveFromSearchText(searchText); + } + }); + + ToggleCombineModeCommand = new RelayCommand(_ => ToggleCombineMode()); + + _service.Changed += OnServiceChanged; + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public event EventHandler? FilterStateChanged; + + public ObservableCollection Items { get; } + + public bool HasItems => Items.Count > 0; + + public ICommand AddTagCommand { get; } + + public ICommand SaveSearchTextCommand { get; } + + public ICommand ToggleCombineModeCommand { get; } + + public QuickFilterCombineMode CombineMode + { + get => _service.CombineMode; + set + { + if (_service.CombineMode == value) + { + return; + } + + _service.CombineMode = value; + PropertyChanged?.Invoke(this, _combineModeChangedArgs); + PropertyChanged?.Invoke(this, _combineModeLabelChangedArgs); + PropertyChanged?.Invoke(this, _combineModeToolTipChangedArgs); + FilterStateChanged?.Invoke(this, EventArgs.Empty); + } + } + + public string CombineModeLabel => _service.CombineMode == QuickFilterCombineMode.And ? "AND" : "OR"; + + public string CombineModeToolTip => _service.CombineMode == QuickFilterCombineMode.And + ? "Filters combined with AND (all must match). Click to switch to OR." + : "Filters combined with OR (any must match). Click to switch to AND."; + + public IQuery? GetCombinedActiveQuery() + { + var activeQueries = _service.GetAll() + .Where(filter => filter.IsActive) + .Select(filter => filter.IsInverse ? (IQuery)new NotQuery(filter.Query) : filter.Query) + .ToArray(); + + return activeQueries.Length switch + { + 0 => null, + 1 => activeQueries[0], + _ => _service.CombineMode == QuickFilterCombineMode.Or + ? new OrQuery(activeQueries) + : new AndQuery(activeQueries), + }; + } + + public void ToggleCombineMode() + { + CombineMode = _service.CombineMode == QuickFilterCombineMode.And + ? QuickFilterCombineMode.Or + : QuickFilterCombineMode.And; + } + + public void AddFromTag(string tag) + { + var query = new SimpleTerm("tag", tag); + var existing = _service.FindByQuery(query); + if (existing != null) + { + _service.SetActive(existing.Id, true); + } + else + { + _service.Add(tag, query); + } + } + + public void AddFromSearchQuery(string label, IQuery query) + { + var existing = _service.FindByQuery(query); + if (existing != null) + { + _service.SetActive(existing.Id, true); + } + else + { + _service.Add(label, query); + } + } + + public void Remove(Guid id) + { + _service.Remove(id); + } + + private void SaveFromSearchText(string searchText) + { + searchText = searchText?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(searchText)) + { + return; + } + + try + { + var selectedKey = _repositoryFilteringManager.SelectedQueryParserKey; + IQueryParser? activeParser = _queryParsers.FirstOrDefault(parser => parser.Name == selectedKey); + activeParser ??= _queryParsers[0]; + + var query = activeParser.Parse(searchText); + AddFromSearchQuery(searchText, query); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not save search as quick filter: invalid query."); + } + } + + private void OnServiceChanged(object? sender, EventArgs e) + { + Items.Clear(); + foreach (var model in _service.GetAll()) + { + Items.Add(new QuickFilterViewModel(model, _service)); + } + + PropertyChanged?.Invoke(this, _hasItemsChangedArgs); + FilterStateChanged?.Invoke(this, EventArgs.Empty); + } + + private sealed class RelayCommand : ICommand + { + private readonly Action _execute; + + public RelayCommand(Action execute) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + } + + public event EventHandler? CanExecuteChanged + { + add => _ = value; + remove => _ = value; + } + + public bool CanExecute(object? parameter) => true; + + public void Execute(object? parameter) => _execute(parameter); + } +} \ No newline at end of file diff --git a/src/RepoM.App/ViewModels/QuickFilterViewModel.cs b/src/RepoM.App/ViewModels/QuickFilterViewModel.cs new file mode 100644 index 00000000..7e7b003b --- /dev/null +++ b/src/RepoM.App/ViewModels/QuickFilterViewModel.cs @@ -0,0 +1,169 @@ +namespace RepoM.App.ViewModels; + +using System; +using System.ComponentModel; +using System.Windows.Input; +using RepoM.Api.QuickFilter; + +public sealed class QuickFilterViewModel : INotifyPropertyChanged +{ + private const string FavoriteLabel = "\u2605"; + + private static readonly PropertyChangedEventArgs _isActiveChangedArgs = new(nameof(IsActive)); + private static readonly PropertyChangedEventArgs _isInverseChangedArgs = new(nameof(IsInverse)); + private static readonly PropertyChangedEventArgs _orderChangedArgs = new(nameof(Order)); + private static readonly PropertyChangedEventArgs _labelChangedArgs = new(nameof(Label)); + private static readonly PropertyChangedEventArgs _displayLabelChangedArgs = new(nameof(DisplayLabel)); + private static readonly PropertyChangedEventArgs _toolTipChangedArgs = new(nameof(ToolTip)); + private static readonly PropertyChangedEventArgs _rawToolTipChangedArgs = new(nameof(RawToolTip)); + private static readonly PropertyChangedEventArgs _hasToolTipChangedArgs = new(nameof(HasToolTip)); + + private readonly QuickFilterModel _model; + private readonly IQuickFilterService _service; + + public QuickFilterViewModel(QuickFilterModel model, IQuickFilterService service) + { + _model = model ?? throw new ArgumentNullException(nameof(model)); + _service = service ?? throw new ArgumentNullException(nameof(service)); + + ToggleCommand = new RelayCommand(_ => Toggle()); + RemoveCommand = new RelayCommand(_ => _service.Remove(_model.Id)); + EditLabelCommand = new RelayCommand(parameter => _ = parameter); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public Guid Id => _model.Id; + + public string Label => _model.Label; + + public string DisplayLabel => _model.Label.Length > 10 ? _model.Label[..10] + "..." : _model.Label; + + public string ToolTip + { + get + { + if (_model.IsBuiltIn) + { + return _model.Label == FavoriteLabel ? "Favorites" : "Active"; + } + + return string.IsNullOrEmpty(_model.ToolTip) ? _model.Label : _model.ToolTip; + } + } + + public string RawToolTip => _model.IsBuiltIn ? string.Empty : _model.ToolTip; + + public bool HasToolTip => !string.IsNullOrEmpty(ToolTip); + + public bool IsBuiltIn => _model.IsBuiltIn; + + public bool IsActive + { + get => _model.IsActive; + set + { + if (_model.IsActive == value) + { + return; + } + + _service.SetActive(_model.Id, value); + PropertyChanged?.Invoke(this, _isActiveChangedArgs); + } + } + + public bool IsInverse + { + get => _model.IsInverse; + set + { + if (_model.IsInverse == value) + { + return; + } + + _service.SetInverse(_model.Id, value); + PropertyChanged?.Invoke(this, _isInverseChangedArgs); + } + } + + public int Order + { + get => _model.Order; + set + { + if (_model.Order == value) + { + return; + } + + _service.UpdateOrder(_model.Id, value); + PropertyChanged?.Invoke(this, _orderChangedArgs); + } + } + + public ICommand ToggleCommand { get; } + + public ICommand RemoveCommand { get; } + + public ICommand EditLabelCommand { get; } + + public void Toggle() + { + if (!IsActive) + { + // off → on + IsInverse = false; + IsActive = true; + } + else if (!IsInverse) + { + // on → inverse + IsInverse = true; + } + else + { + // inverse → off + IsInverse = false; + IsActive = false; + } + } + + public void UpdateLabel(string newLabel) + { + _service.UpdateLabel(_model.Id, newLabel); + PropertyChanged?.Invoke(this, _labelChangedArgs); + PropertyChanged?.Invoke(this, _displayLabelChangedArgs); + PropertyChanged?.Invoke(this, _toolTipChangedArgs); + PropertyChanged?.Invoke(this, _hasToolTipChangedArgs); + } + + public void UpdateToolTip(string newToolTip) + { + _service.UpdateToolTip(_model.Id, newToolTip); + PropertyChanged?.Invoke(this, _toolTipChangedArgs); + PropertyChanged?.Invoke(this, _rawToolTipChangedArgs); + PropertyChanged?.Invoke(this, _hasToolTipChangedArgs); + } + + private sealed class RelayCommand : ICommand + { + private readonly Action _execute; + + public RelayCommand(Action execute) + { + _execute = execute; + } + + public event EventHandler? CanExecuteChanged + { + add => _ = value; + remove => _ = value; + } + + public bool CanExecute(object? parameter) => true; + + public void Execute(object? parameter) => _execute(parameter); + } +} diff --git a/src/RepoM.App/ViewModels/RepositoryListViewModel.cs b/src/RepoM.App/ViewModels/RepositoryListViewModel.cs new file mode 100644 index 00000000..f8a2215d --- /dev/null +++ b/src/RepoM.App/ViewModels/RepositoryListViewModel.cs @@ -0,0 +1,293 @@ +namespace RepoM.App.ViewModels; + +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; +using Microsoft.Extensions.Logging; +using RepoM.ActionMenu.Interface.UserInterface; +using RepoM.Api.Git; +using RepoM.App.RepositoryActions; +using RepoM.App.Services; +using RepoM.Core.Plugin.Repository; +using RepoM.Core.Plugin.RepositoryActions.Commands; +using RepoM.Core.Repositories; +using RepoM.Core.Repositories.Adapters; +using RepoM.Core.Repositories.Model; + +public sealed class RepositoryListViewModel : INotifyPropertyChanged +{ + private readonly RepositoryMonitorService _monitorService; + private readonly ActionExecutor _executor; + private readonly IUserMenuActionMenuFactory _userMenuActionMenuFactory; + private readonly ILogger _logger; + private IEnumerable? _itemsSource; + private RepositoryViewModel? _selectedRepository; + + public RepositoryListViewModel( + RepositoryMonitorService monitorService, + ActionExecutor executor, + IUserMenuActionMenuFactory userMenuActionMenuFactory, + ILogger logger, + ICommand addQuickFilterTagCommand) + { + _monitorService = monitorService ?? throw new ArgumentNullException(nameof(monitorService)); + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + _userMenuActionMenuFactory = userMenuActionMenuFactory ?? throw new ArgumentNullException(nameof(userMenuActionMenuFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + AddQuickFilterTagCommand = addQuickFilterTagCommand ?? throw new ArgumentNullException(nameof(addQuickFilterTagCommand)); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public IEnumerable? ItemsSource + { + get => _itemsSource; + set + { + if (ReferenceEquals(_itemsSource, value)) + { + return; + } + + _itemsSource = value; + OnPropertyChanged(); + } + } + + public RepositoryViewModel? SelectedRepository + { + get => _selectedRepository; + set + { + if (ReferenceEquals(_selectedRepository, value)) + { + return; + } + + _selectedRepository = value; + OnPropertyChanged(); + } + } + + public ICommand AddQuickFilterTagCommand { get; } + + internal async Task> CreateContextMenuEntriesAsync(CancellationToken cancellationToken) + { + if (SelectedRepository is not RepositoryViewModel selectedRepository) + { + return []; + } + + selectedRepository.EnableMonitoring(); + + RepositoryInfo? updatedInfo = await _monitorService.RefreshRepositoryAsync(selectedRepository.Path, cancellationToken).ConfigureAwait(false); + IRepository repositoryForMenu = updatedInfo != null ? new RepositoryInfoAdapter(updatedInfo) : selectedRepository.Repository; + + var entries = new List(); + await foreach (UserInterfaceRepositoryActionBase action in _userMenuActionMenuFactory.CreateMenuAsync(repositoryForMenu).ConfigureAwait(false)) + { + IRepositoryMenuEntryViewModel? entry = CreateMenuEntry(action, selectedRepository); + if (entry != null) + { + entries.Add(entry); + } + } + + return entries; + } + + internal async Task InvokeDefaultActionOnSelectionAsync() + { + if (SelectedRepository is not RepositoryViewModel selectedRepository || !selectedRepository.WasFound) + { + return; + } + + var skip = 0; + if (Keyboard.IsKeyDown(Key.LeftAlt) || Keyboard.IsKeyDown(Key.LeftCtrl)) + { + skip = 1; + } + + UserInterfaceRepositoryActionBase uiRepositoryAction = await _userMenuActionMenuFactory + .CreateMenuAsync(selectedRepository.Repository) + .Skip(skip) + .FirstAsync() + .ConfigureAwait(false); + + if (uiRepositoryAction is not UserInterfaceRepositoryAction action || action.RepositoryCommand is NullRepositoryCommand) + { + return; + } + + ExecuteRepositoryAction(action, selectedRepository); + } + + internal void LogContextMenuError(Exception exception) + { + _logger.LogError(exception, "Could not create menu."); + } + + internal void LogInvokeActionError(Exception exception) + { + _logger.LogError(exception, "Could not invoke action on current repository."); + } + + private IRepositoryMenuEntryViewModel? CreateMenuEntry(UserInterfaceRepositoryActionBase action, RepositoryViewModel selectedRepository) + { + if (action is UserInterfaceSeparatorRepositoryAction) + { + return RepositoryMenuSeparatorViewModel.Instance; + } + + if (action is not UserInterfaceRepositoryAction repositoryAction) + { + return null; + } + + return new RepositoryMenuItemViewModel( + repositoryAction.Name, + repositoryAction.CanExecute, + CreateExecuteAction(repositoryAction, selectedRepository), + CreateSubMenuLoader(repositoryAction, selectedRepository)); + } + + private Action? CreateExecuteAction(UserInterfaceRepositoryAction action, RepositoryViewModel selectedRepository) + { + if (action.RepositoryCommand is null or NullRepositoryCommand) + { + return null; + } + + return () => ExecuteRepositoryAction(action, selectedRepository); + } + + private Func>>? CreateSubMenuLoader( + UserInterfaceRepositoryAction repositoryAction, + RepositoryViewModel selectedRepository) + { + if (repositoryAction is DeferredSubActionsUserInterfaceRepositoryAction deferredRepositoryAction) + { + return async () => + { + UserInterfaceRepositoryActionBase[] deferredSubActions = await deferredRepositoryAction.GetAsync().ConfigureAwait(false); + return CreateMenuEntries(deferredSubActions, selectedRepository); + }; + } + + if (repositoryAction.SubActions == null) + { + return null; + } + + return () => Task.FromResult>(CreateMenuEntries(repositoryAction.SubActions, selectedRepository)); + } + + private List CreateMenuEntries( + IEnumerable actions, + RepositoryViewModel selectedRepository) + { + var entries = new List(); + foreach (UserInterfaceRepositoryActionBase action in actions) + { + IRepositoryMenuEntryViewModel? entry = CreateMenuEntry(action, selectedRepository); + if (entry != null) + { + entries.Add(entry); + } + } + + return entries; + } + + private void ExecuteRepositoryAction(UserInterfaceRepositoryAction action, RepositoryViewModel? selectedRepository) + { + Task.Run(() => + { + try + { + if (action.ExecutionCausesSynchronizing) + { + SetVmSynchronizing(selectedRepository, true); + } + + _executor.Execute(action.Repository, action.RepositoryCommand); + } + catch (Exception exception) + { + _logger.LogError(exception, "Could not execute repository action {ActionName}.", action.Name); + } + finally + { + if (action.ExecutionCausesSynchronizing) + { + SetVmSynchronizing(selectedRepository, false); + } + } + }); + } + + private static void SetVmSynchronizing(RepositoryViewModel? selectedRepository, bool synchronizing) + { + if (selectedRepository != null) + { + selectedRepository.IsSynchronizing = synchronizing; + } + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} + +internal interface IRepositoryMenuEntryViewModel; + +internal sealed class RepositoryMenuSeparatorViewModel : IRepositoryMenuEntryViewModel +{ + public static RepositoryMenuSeparatorViewModel Instance { get; } = new(); + + private RepositoryMenuSeparatorViewModel() + { + } +} + +internal sealed class RepositoryMenuItemViewModel : IRepositoryMenuEntryViewModel +{ + private readonly Action? _execute; + private readonly Func>>? _loadChildrenAsync; + + public RepositoryMenuItemViewModel( + string header, + bool isEnabled, + Action? execute, + Func>>? loadChildrenAsync) + { + Header = header ?? throw new ArgumentNullException(nameof(header)); + IsEnabled = isEnabled; + _execute = execute; + _loadChildrenAsync = loadChildrenAsync; + } + + public string Header { get; } + + public bool IsEnabled { get; } + + public bool HasSubItems => _loadChildrenAsync != null; + + public void Execute() + { + _execute?.Invoke(); + } + + public Task> LoadChildrenAsync() + { + return _loadChildrenAsync?.Invoke() ?? Task.FromResult>([]); + } +} \ No newline at end of file diff --git a/tests/RepoM.Api.Tests/QuickFilter/QueryJsonConverterTests.cs b/tests/RepoM.Api.Tests/QuickFilter/QueryJsonConverterTests.cs new file mode 100644 index 00000000..e661cda1 --- /dev/null +++ b/tests/RepoM.Api.Tests/QuickFilter/QueryJsonConverterTests.cs @@ -0,0 +1,80 @@ +namespace RepoM.Api.Tests.QuickFilter; + +using System; +using AwesomeAssertions; +using Newtonsoft.Json; +using RepoM.Api.QuickFilter; +using RepoM.Core.Plugin.RepositoryFiltering.Clause; +using RepoM.Core.Plugin.RepositoryFiltering.Clause.Terms; +using Xunit; + +public class QueryJsonConverterTests +{ + private static readonly JsonSerializerSettings SerializerSettings = new() + { + Converters = { new QueryJsonConverter(), }, + }; + + [Fact] + public void SerializeAndDeserialize_ShouldRoundTripComplexQuery() + { + // arrange + IQuery query = new AndQuery( + new SimpleTerm("tag", "work"), + new OrQuery(new FreeText("github"), TrueQuery.Instance), + new NotQuery(new StartsWithTerm("branch", "ma"))); + + // act + var json = JsonConvert.SerializeObject(query, SerializerSettings); + var result = JsonConvert.DeserializeObject(json, SerializerSettings); + + // assert + result.Should().BeOfType(); + var and = (AndQuery)result!; + and.Items.Should().HaveCount(3); + + and.Items[0].Should().BeOfType(); + ((SimpleTerm)and.Items[0]).Term.Should().Be("tag"); + ((SimpleTerm)and.Items[0]).Value.Should().Be("work"); + + and.Items[1].Should().BeOfType(); + var or = (OrQuery)and.Items[1]; + or.Items.Should().HaveCount(2); + or.Items[0].Should().BeOfType(); + ((FreeText)or.Items[0]).Value.Should().Be("github"); + or.Items[1].Should().BeSameAs(TrueQuery.Instance); + + and.Items[2].Should().BeOfType(); + var not = (NotQuery)and.Items[2]; + not.Item.Should().BeOfType(); + ((StartsWithTerm)not.Item).Term.Should().Be("branch"); + ((StartsWithTerm)not.Item).Value.Should().Be("ma"); + } + + [Fact] + public void Serialize_ShouldWriteNull_WhenQueryIsNull() + { + // arrange + IQuery? query = null; + + // act + var json = JsonConvert.SerializeObject(query, SerializerSettings); + + // assert + json.Should().Be("null"); + } + + [Fact] + public void Deserialize_ShouldThrow_WhenTypeIsUnknown() + { + // arrange + const string json = "{\"type\":\"unknown\"}"; + + // act + Action act = () => JsonConvert.DeserializeObject(json, SerializerSettings); + + // assert + act.Should().ThrowExactly() + .WithMessage("*Unknown query type 'unknown'.*"); + } +} \ No newline at end of file diff --git a/tests/RepoM.Api.Tests/QuickFilter/QuickFilterModelTests.cs b/tests/RepoM.Api.Tests/QuickFilter/QuickFilterModelTests.cs new file mode 100644 index 00000000..8eb944c1 --- /dev/null +++ b/tests/RepoM.Api.Tests/QuickFilter/QuickFilterModelTests.cs @@ -0,0 +1,50 @@ +namespace RepoM.Api.Tests.QuickFilter; + +using AwesomeAssertions; +using Newtonsoft.Json; +using RepoM.Api.QuickFilter; +using RepoM.Core.Plugin.RepositoryFiltering.Clause.Terms; +using Xunit; + +public class QuickFilterModelTests +{ + [Fact] + public void Ctor_ShouldInitializeDefaults() + { + // arrange + + // act + var sut = new QuickFilterModel(); + + // assert + sut.Label.Should().BeEmpty(); + sut.Query.Should().BeSameAs(RepoM.Core.Plugin.RepositoryFiltering.Clause.TrueQuery.Instance); + sut.IsActive.Should().BeFalse(); + sut.IsInverse.Should().BeFalse(); + sut.Order.Should().Be(0); + sut.ToolTip.Should().BeEmpty(); + sut.IsBuiltIn.Should().BeFalse(); + } + + [Fact] + public void Serialize_ShouldIgnoreIsBuiltIn() + { + // arrange + var sut = new QuickFilterModel + { + Label = "Work", + Query = new SimpleTerm("tag", "work"), + IsBuiltIn = true, + }; + + // act + var json = JsonConvert.SerializeObject(sut, new JsonSerializerSettings + { + Converters = { new QueryJsonConverter(), }, + }); + + // assert + json.Should().Contain("\"Label\":\"Work\""); + json.Should().NotContain("IsBuiltIn"); + } +} \ No newline at end of file diff --git a/tests/RepoM.Api.Tests/QuickFilter/QuickFilterServiceTests.cs b/tests/RepoM.Api.Tests/QuickFilter/QuickFilterServiceTests.cs new file mode 100644 index 00000000..c79a52ae --- /dev/null +++ b/tests/RepoM.Api.Tests/QuickFilter/QuickFilterServiceTests.cs @@ -0,0 +1,425 @@ +namespace RepoM.Api.Tests.QuickFilter; + +using System; +using System.Collections.Generic; +using System.IO.Abstractions.TestingHelpers; +using System.Linq; +using AwesomeAssertions; +using FakeItEasy; +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using RepoM.Api.QuickFilter; +using RepoM.Core.Plugin.Common; +using RepoM.Core.Plugin.RepositoryFiltering.Clause; +using RepoM.Core.Plugin.RepositoryFiltering.Clause.Terms; +using Xunit; + +public class QuickFilterServiceTests +{ + private const string APP_DATA_PATH = @"C:\AppData\RepoM"; + + [Fact] + public void Ctor_ShouldThrow_WhenArgumentNull() + { + // arrange + var pathProvider = A.Fake(); + var fileSystem = new MockFileSystem(); + + // act + Action act1 = () => _ = new QuickFilterService(pathProvider, fileSystem, null!); + Action act2 = () => _ = new QuickFilterService(pathProvider, null!, NullLogger.Instance); + Action act3 = () => _ = new QuickFilterService(null!, fileSystem, NullLogger.Instance); + + // assert + act1.Should().ThrowExactly(); + act2.Should().ThrowExactly(); + act3.Should().ThrowExactly(); + } + + [Fact] + public void GetAll_ShouldContainBuiltInFilters_WhenNoFileExists() + { + // arrange + var sut = CreateSut(); + + // act + IReadOnlyList result = sut.GetAll(); + + // assert + result.Should().HaveCount(2); + result[0].IsBuiltIn.Should().BeTrue(); + result[0].Label.Should().Be("\u2605"); + result[0].Query.ToString().Should().Be("is:favorite"); + result[1].IsBuiltIn.Should().BeTrue(); + result[1].Query.ToString().Should().Be("is:active"); + } + + [Fact] + public void Add_ShouldPersistUserFilterOnly_AndRaiseChanged() + { + // arrange + var fileSystem = new MockFileSystem(); + var sut = CreateSut(fileSystem: fileSystem); + var changedCount = 0; + sut.Changed += (_, _) => changedCount++; + + // act + QuickFilterModel result = sut.Add("Work", new SimpleTerm("tag", "work")); + + // assert + result.Label.Should().Be("Work"); + result.IsActive.Should().BeTrue(); + sut.GetAll().Should().HaveCount(3); + changedCount.Should().Be(1); + + fileSystem.FileExists($"{APP_DATA_PATH}\\quickfilters.json").Should().BeTrue(); + var json = fileSystem.File.ReadAllText($"{APP_DATA_PATH}\\quickfilters.json"); + var envelope = JObject.Parse(json); + var persisted = envelope["Filters"]!.ToObject>(JsonSerializer.Create(new JsonSerializerSettings + { + Converters = { new QueryJsonConverter(), }, + })); + persisted.Should().NotBeNull(); + var persistedFilter = persisted!.Should().ContainSingle().Which; + persistedFilter.Label.Should().Be("Work"); + persistedFilter.Query.ToString().Should().Be("tag:work"); + } + + [Fact] + public void Remove_ShouldIgnoreBuiltInFilter() + { + // arrange + var sut = CreateSut(); + var builtInId = sut.GetAll()[0].Id; + + // act + sut.Remove(builtInId); + + // assert + sut.GetAll().Should().HaveCount(2); + } + + [Fact] + public void UpdateLabel_ShouldIgnoreBuiltInFilter() + { + // arrange + var sut = CreateSut(); + var builtIn = sut.GetAll()[0]; + + // act + sut.UpdateLabel(builtIn.Id, "Changed"); + + // assert + sut.GetAll()[0].Label.Should().Be(builtIn.Label); + } + + [Fact] + public void FindByQuery_ShouldMatchCaseInsensitiveQueryString() + { + // arrange + var sut = CreateSut(); + QuickFilterModel added = sut.Add("Work", new SimpleTerm("tag", "work")); + + // act + QuickFilterModel? result = sut.FindByQuery(new SimpleTerm("TAG", "WORK")); + + // assert + result.Should().NotBeNull(); + result!.Id.Should().Be(added.Id); + } + + [Fact] + public void FindByQuery_ShouldReturnNull_WhenNoMatch() + { + // arrange + var sut = CreateSut(); + sut.Add("Work", new SimpleTerm("tag", "work")); + + // act + QuickFilterModel? result = sut.FindByQuery(new SimpleTerm("tag", "personal")); + + // assert + result.Should().BeNull(); + } + + [Fact] + public void Remove_ShouldRemoveUserFilter_AndRaiseChanged() + { + // arrange + var sut = CreateSut(); + QuickFilterModel added = sut.Add("Work", new SimpleTerm("tag", "work")); + var changedCount = 0; + sut.Changed += (_, _) => changedCount++; + + // act + sut.Remove(added.Id); + + // assert + sut.GetAll().Should().HaveCount(2); // only built-in remain + changedCount.Should().Be(1); + } + + [Fact] + public void Remove_ShouldDoNothing_WhenIdDoesNotExist() + { + // arrange + var sut = CreateSut(); + var changedCount = 0; + sut.Changed += (_, _) => changedCount++; + + // act + sut.Remove(Guid.NewGuid()); + + // assert + sut.GetAll().Should().HaveCount(2); + changedCount.Should().Be(0); + } + + [Fact] + public void SetActive_ShouldUpdateFilter_AndPersist() + { + // arrange + var fileSystem = new MockFileSystem(); + var sut = CreateSut(fileSystem: fileSystem); + QuickFilterModel added = sut.Add("Work", new SimpleTerm("tag", "work")); + added.IsActive.Should().BeTrue(); + + // act + sut.SetActive(added.Id, false); + + // assert + sut.GetAll().First(f => f.Id == added.Id).IsActive.Should().BeFalse(); + } + + [Fact] + public void SetActive_ShouldDoNothing_WhenValueIsSame() + { + // arrange + var sut = CreateSut(); + QuickFilterModel added = sut.Add("Work", new SimpleTerm("tag", "work")); + var changedCount = 0; + sut.Changed += (_, _) => changedCount++; + + // act + sut.SetActive(added.Id, true); // already true + + // assert + changedCount.Should().Be(0); + } + + [Fact] + public void SetInverse_ShouldUpdateFilter_AndRaiseChanged() + { + // arrange + var sut = CreateSut(); + QuickFilterModel added = sut.Add("Work", new SimpleTerm("tag", "work")); + var changedCount = 0; + sut.Changed += (_, _) => changedCount++; + + // act + sut.SetInverse(added.Id, true); + + // assert + sut.GetAll().First(f => f.Id == added.Id).IsInverse.Should().BeTrue(); + changedCount.Should().Be(1); + } + + [Fact] + public void SetInverse_ShouldDoNothing_WhenValueIsSame() + { + // arrange + var sut = CreateSut(); + QuickFilterModel added = sut.Add("Work", new SimpleTerm("tag", "work")); + var changedCount = 0; + sut.Changed += (_, _) => changedCount++; + + // act + sut.SetInverse(added.Id, false); // already false + + // assert + changedCount.Should().Be(0); + } + + [Fact] + public void UpdateLabel_ShouldUpdateUserFilter_AndRaiseChanged() + { + // arrange + var sut = CreateSut(); + QuickFilterModel added = sut.Add("Work", new SimpleTerm("tag", "work")); + var changedCount = 0; + sut.Changed += (_, _) => changedCount++; + + // act + sut.UpdateLabel(added.Id, "Personal"); + + // assert + sut.GetAll().First(f => f.Id == added.Id).Label.Should().Be("Personal"); + changedCount.Should().Be(1); + } + + [Fact] + public void UpdateToolTip_ShouldUpdateUserFilter_AndRaiseChanged() + { + // arrange + var sut = CreateSut(); + QuickFilterModel added = sut.Add("Work", new SimpleTerm("tag", "work")); + var changedCount = 0; + sut.Changed += (_, _) => changedCount++; + + // act + sut.UpdateToolTip(added.Id, "Work repositories"); + + // assert + sut.GetAll().First(f => f.Id == added.Id).ToolTip.Should().Be("Work repositories"); + changedCount.Should().Be(1); + } + + [Fact] + public void UpdateToolTip_ShouldIgnoreBuiltInFilter() + { + // arrange + var sut = CreateSut(); + var builtIn = sut.GetAll()[0]; + var changedCount = 0; + sut.Changed += (_, _) => changedCount++; + + // act + sut.UpdateToolTip(builtIn.Id, "Changed"); + + // assert + changedCount.Should().Be(0); + } + + [Fact] + public void UpdateOrder_ShouldUpdateUserFilter_AndRaiseChanged() + { + // arrange + var sut = CreateSut(); + QuickFilterModel added = sut.Add("Work", new SimpleTerm("tag", "work")); + var changedCount = 0; + sut.Changed += (_, _) => changedCount++; + + // act + sut.UpdateOrder(added.Id, 42); + + // assert + sut.GetAll().First(f => f.Id == added.Id).Order.Should().Be(42); + changedCount.Should().Be(1); + } + + [Fact] + public void UpdateOrder_ShouldIgnoreBuiltInFilter() + { + // arrange + var sut = CreateSut(); + var builtIn = sut.GetAll()[0]; + var changedCount = 0; + sut.Changed += (_, _) => changedCount++; + + // act + sut.UpdateOrder(builtIn.Id, 99); + + // assert + changedCount.Should().Be(0); + } + + [Fact] + public void GetAll_ShouldReturnOrderedByOrder() + { + // arrange + var sut = CreateSut(); + var a = sut.Add("A", new SimpleTerm("tag", "a")); + var b = sut.Add("B", new SimpleTerm("tag", "b")); + sut.UpdateOrder(b.Id, -100); + + // act + var result = sut.GetAll(); + + // assert - built-in (order -2, -1) come first, then B (-100 reordered), then A + // Actually, the built-in filters have order -2 and -1, B is -100, so B should be first + var userFilters = result.Where(f => !f.IsBuiltIn).ToList(); + userFilters[0].Label.Should().Be("B"); + userFilters[1].Label.Should().Be("A"); + } + + [Fact] + public void Ctor_ShouldLoadExistingFilters_FromFile() + { + // arrange + var fileSystem = new MockFileSystem(); + var filters = new List + { + new() + { + Id = Guid.NewGuid(), + Label = "Persisted", + Query = new SimpleTerm("tag", "persisted"), + Order = 5, + }, + }; + var envelope = new { Filters = filters, CombineMode = "And" }; + var json = JsonConvert.SerializeObject(envelope, new JsonSerializerSettings { Converters = { new QueryJsonConverter(), }, }); + fileSystem.AddFile($"{APP_DATA_PATH}\\quickfilters.json", new MockFileData(json)); + + // act + var sut = CreateSut(fileSystem: fileSystem); + + // assert + sut.GetAll().Should().HaveCount(3); // 2 built-in + 1 persisted + sut.GetAll().Should().Contain(f => f.Label == "Persisted"); + } + + [Fact] + public void CombineMode_ShouldDefaultToAnd() + { + // arrange & act + var sut = CreateSut(); + + // assert + sut.CombineMode.Should().Be(QuickFilterCombineMode.And); + } + + [Fact] + public void CombineMode_Set_ShouldPersistAndRaiseChanged() + { + // arrange + var fileSystem = new MockFileSystem(); + var sut = CreateSut(fileSystem: fileSystem); + var changedCount = 0; + sut.Changed += (_, _) => changedCount++; + + // act + sut.CombineMode = QuickFilterCombineMode.Or; + + // assert + sut.CombineMode.Should().Be(QuickFilterCombineMode.Or); + changedCount.Should().Be(1); + + // Verify it was persisted by reloading + var sut2 = CreateSut(fileSystem: fileSystem); + sut2.CombineMode.Should().Be(QuickFilterCombineMode.Or); + } + + [Fact] + public void CombineMode_SetSameValue_ShouldNotRaiseChanged() + { + // arrange + var sut = CreateSut(); + var changedCount = 0; + sut.Changed += (_, _) => changedCount++; + + // act + sut.CombineMode = QuickFilterCombineMode.And; // same as default + + // assert + changedCount.Should().Be(0); + } + + private static QuickFilterService CreateSut(MockFileSystem? fileSystem = null) + { + var appDataPathProvider = A.Fake(); + A.CallTo(() => appDataPathProvider.AppDataPath).Returns(APP_DATA_PATH); + return new QuickFilterService(appDataPathProvider, fileSystem ?? new MockFileSystem(), NullLogger.Instance); + } +} \ No newline at end of file diff --git a/tests/RepoM.App.Tests/ViewModels/MainWindowViewModelTests.cs b/tests/RepoM.App.Tests/ViewModels/MainWindowViewModelTests.cs index 34078c31..4b4f3d4e 100644 --- a/tests/RepoM.App.Tests/ViewModels/MainWindowViewModelTests.cs +++ b/tests/RepoM.App.Tests/ViewModels/MainWindowViewModelTests.cs @@ -4,11 +4,15 @@ namespace RepoM.App.Tests.ViewModels; using RepoM.Api.Common; using RepoM.App.Plugins; using System; +using System.Collections.Generic; using FakeItEasy; using RepoM.App.RepositoryFiltering; using RepoM.App.ViewModels; using Xunit; using RepoM.App.RepositoryOrdering; +using RepoM.Api.Git.AutoFetch; +using System.Diagnostics.CodeAnalysis; +using System.Windows.Input; public class MainWindowViewModelTests { @@ -18,6 +22,8 @@ public class MainWindowViewModelTests private readonly FiltersViewModel _filtersViewModel; private readonly PluginCollectionViewModel _pluginsViewModel; private readonly HelpViewModel _helpViewModel; + private readonly ICommand _saveQuickFilterCommand; + private readonly ICommand _addQuickFilterTagCommand; public MainWindowViewModelTests() { @@ -32,6 +38,8 @@ public MainWindowViewModelTests() _filtersViewModel = new FiltersViewModel(repositoryFilterManager, threadDispatcher); _pluginsViewModel = new PluginCollectionViewModel(moduleManager); _helpViewModel = new HelpViewModel(A.Fake()); + _saveQuickFilterCommand = A.Dummy(); + _addQuickFilterTagCommand = A.Dummy(); } [Fact] @@ -70,4 +78,90 @@ public void Ctor_ShouldInitializeProperties() sut.Filters.Should().BeSameAs(_filtersViewModel); sut.Plugins.Should().BeSameAs(_pluginsViewModel); } + + [Fact] + public void Ctor_WithCommands_ShouldInitializeCommandProperties() + { + // arrange + + // act + var sut = new MainWindowViewModel( + _appSettingsService, + _orderingsViewModel, + _queryParsersViewModel, + _filtersViewModel, + _pluginsViewModel, + _helpViewModel, + new MainWindowQuickFilterCommands(_saveQuickFilterCommand, _addQuickFilterTagCommand)); + + // assert + sut.SaveQuickFilterCommand.Should().BeSameAs(_saveQuickFilterCommand); + sut.AddQuickFilterTagCommand.Should().BeSameAs(_addQuickFilterTagCommand); + sut.Help.Should().BeSameAs(_helpViewModel); + } + + [Fact] + public void AutoFetchAdequate_ShouldSetUnderlyingAutoFetchMode_AndRaisePropertyChanged() + { + // arrange + var propertyNames = new List(); + var appSettingsService = new TestAppSettingsService { AutoFetchMode = AutoFetchMode.Off }; + var sut = new MainWindowViewModel(appSettingsService, _orderingsViewModel, _queryParsersViewModel, _filtersViewModel, _pluginsViewModel, _helpViewModel); + sut.PropertyChanged += (_, e) => propertyNames.Add(e.PropertyName); + + // act + sut.AutoFetchAdequate = true; + + // assert + appSettingsService.AutoFetchMode.Should().Be(AutoFetchMode.Adequate); + sut.AutoFetchAdequate.Should().BeTrue(); + propertyNames.Should().Contain(nameof(sut.AutoFetchAdequate)); + propertyNames.Should().Contain(nameof(sut.AutoFetchOff)); + } + + [Fact] + public void PruneOnFetch_ShouldReadAndWriteThroughAppSettingsService() + { + // arrange + var appSettingsService = new TestAppSettingsService(); + var sut = new MainWindowViewModel(appSettingsService, _orderingsViewModel, _queryParsersViewModel, _filtersViewModel, _pluginsViewModel, _helpViewModel); + + // act + sut.PruneOnFetch = true; + + // assert + sut.PruneOnFetch.Should().BeTrue(); + appSettingsService.PruneOnFetch.Should().BeTrue(); + } + + private sealed class TestAppSettingsService : IAppSettingsService + { + public AutoFetchMode AutoFetchMode { get; set; } + + public bool PruneOnFetch { get; set; } + + public void UpdateMenuSize(string resolution, MenuSize size) + { + } + + public bool TryGetMenuSize(string resolution, [NotNullWhen(true)] out MenuSize? size) + { + size = null; + return false; + } + + public List ReposRootDirectories { get; set; } = []; + + public string SortKey { get; set; } = string.Empty; + + public string QueryParserKey { get; set; } = string.Empty; + + public string SelectedFilter { get; set; } = string.Empty; + + public List Plugins { get; set; } = []; + + public void RegisterInvalidationHandler(Action handler) + { + } + } } \ No newline at end of file diff --git a/tests/RepoM.App.Tests/ViewModels/QuickFilterBarViewModelTests.cs b/tests/RepoM.App.Tests/ViewModels/QuickFilterBarViewModelTests.cs new file mode 100644 index 00000000..6ee13427 --- /dev/null +++ b/tests/RepoM.App.Tests/ViewModels/QuickFilterBarViewModelTests.cs @@ -0,0 +1,450 @@ +namespace RepoM.App.Tests.ViewModels; + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using AwesomeAssertions; +using FakeItEasy; +using Microsoft.Extensions.Logging.Abstractions; +using RepoM.Api.QuickFilter; +using RepoM.App.RepositoryFiltering; +using RepoM.App.ViewModels; +using RepoM.Core.Plugin.RepositoryFiltering; +using RepoM.Core.Plugin.RepositoryFiltering.Clause; +using RepoM.Core.Plugin.RepositoryFiltering.Clause.Terms; +using Xunit; + +public class QuickFilterBarViewModelTests +{ + [Fact] + public void Ctor_ShouldThrow_WhenArgumentNull() + { + // arrange + var service = new TestQuickFilterService(); + var repositoryFilteringManager = A.Fake(); + var queryParsers = new[] { new TestNamedQueryParser("Default", text => new FreeText(text)), }; + + // act + Action act1 = () => _ = new QuickFilterBarViewModel(null!, repositoryFilteringManager, queryParsers, NullLogger.Instance); + Action act2 = () => _ = new QuickFilterBarViewModel(service, null!, queryParsers, NullLogger.Instance); + Action act3 = () => _ = new QuickFilterBarViewModel(service, repositoryFilteringManager, null!, NullLogger.Instance); + Action act4 = () => _ = new QuickFilterBarViewModel(service, repositoryFilteringManager, queryParsers, null!); + + // assert + act1.Should().ThrowExactly(); + act2.Should().ThrowExactly(); + act3.Should().ThrowExactly(); + act4.Should().ThrowExactly(); + } + + [Fact] + public void Ctor_ShouldThrow_WhenNoQueryParsersAvailable() + { + // arrange + var service = new TestQuickFilterService(); + var repositoryFilteringManager = A.Fake(); + + // act + Action act = () => _ = new QuickFilterBarViewModel(service, repositoryFilteringManager, [], NullLogger.Instance); + + // assert + act.Should().ThrowExactly() + .WithMessage("At least one query parser must be available.*"); + } + + [Fact] + public void AddFromTag_ShouldActivateExistingFilter_WhenQueryAlreadyExists() + { + // arrange + var service = new TestQuickFilterService( + [ + new QuickFilterModel + { + Id = Guid.NewGuid(), + Label = "work", + Query = new SimpleTerm("tag", "work"), + IsActive = false, + }, + ]); + var sut = CreateSut(service); + + // act + sut.AddFromTag("work"); + + // assert + service.SetActiveCalls.Should().ContainSingle(); + service.AddCalls.Should().BeEmpty(); + service.Filters[0].IsActive.Should().BeTrue(); + } + + [Fact] + public void SaveSearchTextCommand_ShouldParseAndAddUsingSelectedParser() + { + // arrange + var service = new TestQuickFilterService(); + var selectedParser = new TestNamedQueryParser("Lucene", text => new SimpleTerm("branch", text)); + var defaultParser = new TestNamedQueryParser("Default", text => new FreeText(text)); + var repositoryFilteringManager = A.Fake(); + A.CallTo(() => repositoryFilteringManager.SelectedQueryParserKey).Returns("Lucene"); + var sut = new QuickFilterBarViewModel(service, repositoryFilteringManager, [defaultParser, selectedParser], NullLogger.Instance); + + // act + sut.SaveSearchTextCommand.Execute(" main "); + + // assert + service.AddCalls.Should().ContainSingle(); + service.AddCalls[0].label.Should().Be("main"); + service.AddCalls[0].query.Should().BeOfType(); + ((SimpleTerm)service.AddCalls[0].query).Term.Should().Be("branch"); + ((SimpleTerm)service.AddCalls[0].query).Value.Should().Be("main"); + } + + [Fact] + public void GetCombinedActiveQuery_ShouldCombineActiveAndInverseQueries() + { + // arrange + var service = new TestQuickFilterService( + [ + new QuickFilterModel { Id = Guid.NewGuid(), Label = "work", Query = new SimpleTerm("tag", "work"), IsActive = true }, + new QuickFilterModel { Id = Guid.NewGuid(), Label = "archived", Query = new SimpleTerm("tag", "archived"), IsActive = true, IsInverse = true }, + new QuickFilterModel { Id = Guid.NewGuid(), Label = "ignored", Query = new SimpleTerm("tag", "ignored"), IsActive = false }, + ]); + var sut = CreateSut(service); + + // act + IQuery? result = sut.GetCombinedActiveQuery(); + + // assert + result.Should().BeOfType(); + result!.ToString().Should().Be("And(tag:work, Not(tag:archived))"); + } + + [Fact] + public void GetCombinedActiveQuery_ShouldUseOrQuery_WhenCombineModeIsOr() + { + // arrange + var service = new TestQuickFilterService( + [ + new QuickFilterModel { Id = Guid.NewGuid(), Label = "work", Query = new SimpleTerm("tag", "work"), IsActive = true }, + new QuickFilterModel { Id = Guid.NewGuid(), Label = "personal", Query = new SimpleTerm("tag", "personal"), IsActive = true }, + ]); + var sut = CreateSut(service); + sut.CombineMode = QuickFilterCombineMode.Or; + + // act + IQuery? result = sut.GetCombinedActiveQuery(); + + // assert + result.Should().BeOfType(); + result!.ToString().Should().Be("Or(tag:work, tag:personal)"); + } + + [Fact] + public void ToggleCombineMode_ShouldSwitchBetweenAndAndOr() + { + // arrange + var sut = CreateSut(new TestQuickFilterService()); + + // act & assert + sut.CombineMode.Should().Be(QuickFilterCombineMode.And); + sut.CombineModeLabel.Should().Be("AND"); + + sut.ToggleCombineMode(); + sut.CombineMode.Should().Be(QuickFilterCombineMode.Or); + sut.CombineModeLabel.Should().Be("OR"); + + sut.ToggleCombineMode(); + sut.CombineMode.Should().Be(QuickFilterCombineMode.And); + sut.CombineModeLabel.Should().Be("AND"); + } + + [Fact] + public void ToggleCombineMode_ShouldRaiseFilterStateChanged() + { + // arrange + var sut = CreateSut(new TestQuickFilterService()); + var filterStateChangedCount = 0; + var propertyNames = new List(); + sut.FilterStateChanged += (_, _) => filterStateChangedCount++; + sut.PropertyChanged += (_, e) => propertyNames.Add(e.PropertyName); + + // act + sut.ToggleCombineMode(); + + // assert + filterStateChangedCount.Should().Be(1); + propertyNames.Should().Contain(nameof(QuickFilterBarViewModel.CombineMode)); + propertyNames.Should().Contain(nameof(QuickFilterBarViewModel.CombineModeLabel)); + propertyNames.Should().Contain(nameof(QuickFilterBarViewModel.CombineModeToolTip)); + } + + [Fact] + public void CombineMode_ShouldDefaultToAnd() + { + // arrange & act + var sut = CreateSut(new TestQuickFilterService()); + + // assert + sut.CombineMode.Should().Be(QuickFilterCombineMode.And); + sut.CombineModeLabel.Should().Be("AND"); + } + + [Fact] + public void CombineMode_SetSameValue_ShouldNotRaiseEvents() + { + // arrange + var sut = CreateSut(new TestQuickFilterService()); + var filterStateChangedCount = 0; + var propertyChangedCount = 0; + sut.FilterStateChanged += (_, _) => filterStateChangedCount++; + sut.PropertyChanged += (_, _) => propertyChangedCount++; + + // act + sut.CombineMode = QuickFilterCombineMode.And; // same as default + + // assert + filterStateChangedCount.Should().Be(0); + propertyChangedCount.Should().Be(0); + } + + [Fact] + public void CombineModeToolTip_ShouldDescribeAndMode_WhenAnd() + { + // arrange + var sut = CreateSut(new TestQuickFilterService()); + + // act & assert + sut.CombineModeToolTip.Should().Contain("AND"); + sut.CombineModeToolTip.Should().Contain("all must match"); + } + + [Fact] + public void CombineModeToolTip_ShouldDescribeOrMode_WhenOr() + { + // arrange + var sut = CreateSut(new TestQuickFilterService()); + sut.CombineMode = QuickFilterCombineMode.Or; + + // act & assert + sut.CombineModeToolTip.Should().Contain("OR"); + sut.CombineModeToolTip.Should().Contain("any must match"); + } + + [Fact] + public void GetCombinedActiveQuery_ShouldReturnNull_WhenNoActiveFilters_RegardlessOfMode() + { + // arrange + var service = new TestQuickFilterService( + [ + new QuickFilterModel { Id = Guid.NewGuid(), Label = "work", Query = new SimpleTerm("tag", "work"), IsActive = false }, + ]); + var sut = CreateSut(service); + + // act & assert (AND) + sut.GetCombinedActiveQuery().Should().BeNull(); + + // act & assert (OR) + sut.CombineMode = QuickFilterCombineMode.Or; + sut.GetCombinedActiveQuery().Should().BeNull(); + } + + [Fact] + public void GetCombinedActiveQuery_SingleActive_ShouldReturnQueryDirectly_RegardlessOfMode() + { + // arrange + var service = new TestQuickFilterService( + [ + new QuickFilterModel { Id = Guid.NewGuid(), Label = "work", Query = new SimpleTerm("tag", "work"), IsActive = true }, + ]); + var sut = CreateSut(service); + + // act & assert (AND mode - single filter returns raw query, not wrapped in AndQuery) + var resultAnd = sut.GetCombinedActiveQuery(); + resultAnd.Should().BeOfType(); + + // act & assert (OR mode - single filter returns raw query, not wrapped in OrQuery) + sut.CombineMode = QuickFilterCombineMode.Or; + var resultOr = sut.GetCombinedActiveQuery(); + resultOr.Should().BeOfType(); + } + + [Fact] + public void GetCombinedActiveQuery_OrMode_ShouldWrapInverseFiltersWithNotQuery() + { + // arrange + var service = new TestQuickFilterService( + [ + new QuickFilterModel { Id = Guid.NewGuid(), Label = "work", Query = new SimpleTerm("tag", "work"), IsActive = true }, + new QuickFilterModel { Id = Guid.NewGuid(), Label = "archived", Query = new SimpleTerm("tag", "archived"), IsActive = true, IsInverse = true }, + ]); + var sut = CreateSut(service); + sut.CombineMode = QuickFilterCombineMode.Or; + + // act + IQuery? result = sut.GetCombinedActiveQuery(); + + // assert + result.Should().BeOfType(); + result!.ToString().Should().Be("Or(tag:work, Not(tag:archived))"); + } + + [Fact] + public void ToggleCombineModeCommand_ShouldToggleMode() + { + // arrange + var sut = CreateSut(new TestQuickFilterService()); + sut.CombineMode.Should().Be(QuickFilterCombineMode.And); + + // act + sut.ToggleCombineModeCommand.Execute(null); + + // assert + sut.CombineMode.Should().Be(QuickFilterCombineMode.Or); + } + + [Fact] + public void ServiceChanged_ShouldRefreshItems_AndRaiseNotifications() + { + // arrange + var service = new TestQuickFilterService(); + var sut = CreateSut(service); + var propertyNames = new List(); + var filterStateChangedCount = 0; + sut.PropertyChanged += (_, e) => propertyNames.Add(e.PropertyName); + sut.FilterStateChanged += (_, _) => filterStateChangedCount++; + + // act + service.Filters.Add(new QuickFilterModel { Id = Guid.NewGuid(), Label = "Work", Query = new SimpleTerm("tag", "work") }); + service.RaiseChanged(); + + // assert + sut.Items.Should().ContainSingle(); + sut.HasItems.Should().BeTrue(); + propertyNames.Should().Contain(nameof(QuickFilterBarViewModel.HasItems)); + filterStateChangedCount.Should().Be(1); + } + + private static QuickFilterBarViewModel CreateSut(TestQuickFilterService service) + { + var repositoryFilteringManager = A.Fake(); + A.CallTo(() => repositoryFilteringManager.SelectedQueryParserKey).Returns("Default"); + return new QuickFilterBarViewModel( + service, + repositoryFilteringManager, + [new TestNamedQueryParser("Default", text => new FreeText(text))], + NullLogger.Instance); + } + + private sealed class TestQuickFilterService : IQuickFilterService + { + public TestQuickFilterService(IEnumerable? initialFilters = null) + { + if (initialFilters != null) + { + Filters.AddRange(initialFilters); + } + } + + public List Filters { get; } = []; + + public List<(string label, IQuery query)> AddCalls { get; } = []; + + public List<(Guid id, bool isActive)> SetActiveCalls { get; } = []; + + public QuickFilterCombineMode CombineMode { get; set; } + + public event EventHandler? Changed; + + public IReadOnlyList GetAll() => Filters.ToArray(); + + public QuickFilterModel Add(string label, IQuery query) + { + AddCalls.Add((label, query)); + var model = new QuickFilterModel + { + Id = Guid.NewGuid(), + Label = label, + Query = query, + IsActive = true, + }; + Filters.Add(model); + return model; + } + + public void Remove(Guid id) + { + Filters.RemoveAll(x => x.Id == id); + } + + public void SetActive(Guid id, bool isActive) + { + SetActiveCalls.Add((id, isActive)); + var filter = Filters.Find(x => x.Id == id); + if (filter != null) + { + filter.IsActive = isActive; + } + } + + public void SetInverse(Guid id, bool isInverse) + { + var filter = Filters.Find(x => x.Id == id); + if (filter != null) + { + filter.IsInverse = isInverse; + } + } + + public void UpdateLabel(Guid id, string newLabel) + { + var filter = Filters.Find(x => x.Id == id); + if (filter != null) + { + filter.Label = newLabel; + } + } + + public void UpdateToolTip(Guid id, string newToolTip) + { + var filter = Filters.Find(x => x.Id == id); + if (filter != null) + { + filter.ToolTip = newToolTip; + } + } + + public void UpdateOrder(Guid id, int newOrder) + { + var filter = Filters.Find(x => x.Id == id); + if (filter != null) + { + filter.Order = newOrder; + } + } + + public QuickFilterModel? FindByQuery(IQuery query) + { + return Filters.Find(x => string.Equals(x.Query.ToString(), query.ToString(), StringComparison.OrdinalIgnoreCase)); + } + + public void RaiseChanged() + { + Changed?.Invoke(this, EventArgs.Empty); + } + } + + private sealed class TestNamedQueryParser : INamedQueryParser + { + private readonly Func _parse; + + public TestNamedQueryParser(string name, Func parse) + { + Name = name; + _parse = parse; + } + + public string Name { get; } + + public IQuery Parse(string text) => _parse(text); + } +} \ No newline at end of file diff --git a/tests/RepoM.App.Tests/ViewModels/QuickFilterViewModelTests.cs b/tests/RepoM.App.Tests/ViewModels/QuickFilterViewModelTests.cs new file mode 100644 index 00000000..925d52fb --- /dev/null +++ b/tests/RepoM.App.Tests/ViewModels/QuickFilterViewModelTests.cs @@ -0,0 +1,372 @@ +namespace RepoM.App.Tests.ViewModels; + +using System; +using System.Collections.Generic; +using AwesomeAssertions; +using FakeItEasy; +using RepoM.Api.QuickFilter; +using RepoM.App.ViewModels; +using RepoM.Core.Plugin.RepositoryFiltering.Clause.Terms; +using Xunit; + +public class QuickFilterViewModelTests +{ + private static readonly Guid TestId = Guid.NewGuid(); + + [Fact] + public void Ctor_ShouldThrow_WhenArgumentNull() + { + // arrange + var model = CreateModel(); + var service = A.Fake(); + + // act + Action act1 = () => _ = new QuickFilterViewModel(null!, service); + Action act2 = () => _ = new QuickFilterViewModel(model, null!); + + // assert + act1.Should().ThrowExactly(); + act2.Should().ThrowExactly(); + } + + [Fact] + public void Properties_ShouldReflectModel() + { + // arrange & act + var model = CreateModel(label: "WorkTag", toolTip: "My tooltip", isBuiltIn: false, order: 5); + var sut = CreateSut(model); + + // assert + sut.Id.Should().Be(model.Id); + sut.Label.Should().Be("WorkTag"); + sut.IsBuiltIn.Should().BeFalse(); + sut.Order.Should().Be(5); + sut.RawToolTip.Should().Be("My tooltip"); + sut.ToolTip.Should().Be("My tooltip"); + sut.HasToolTip.Should().BeTrue(); + } + + [Fact] + public void DisplayLabel_ShouldTruncateLongLabels() + { + // arrange + var model = CreateModel(label: "VeryLongLabelText"); + var sut = CreateSut(model); + + // act & assert + sut.DisplayLabel.Should().Be("VeryLongLa..."); + } + + [Fact] + public void DisplayLabel_ShouldNotTruncateShortLabels() + { + // arrange + var model = CreateModel(label: "Short"); + var sut = CreateSut(model); + + // act & assert + sut.DisplayLabel.Should().Be("Short"); + } + + [Fact] + public void DisplayLabel_ShouldNotTruncateExactly10CharLabels() + { + // arrange + var model = CreateModel(label: "1234567890"); + var sut = CreateSut(model); + + // act & assert + sut.DisplayLabel.Should().Be("1234567890"); + } + + [Fact] + public void ToolTip_ShouldReturnFavorites_ForBuiltInStarFilter() + { + // arrange + var model = CreateModel(label: "\u2605", isBuiltIn: true); + var sut = CreateSut(model); + + // act & assert + sut.ToolTip.Should().Be("Favorites"); + } + + [Fact] + public void ToolTip_ShouldReturnActive_ForBuiltInNonStarFilter() + { + // arrange + var model = CreateModel(label: "\uD83D\uDC41", isBuiltIn: true); + var sut = CreateSut(model); + + // act & assert + sut.ToolTip.Should().Be("Active"); + } + + [Fact] + public void ToolTip_ShouldFallBackToLabel_WhenToolTipIsEmpty() + { + // arrange + var model = CreateModel(label: "Work", toolTip: ""); + var sut = CreateSut(model); + + // act & assert + sut.ToolTip.Should().Be("Work"); + } + + [Fact] + public void RawToolTip_ShouldReturnEmpty_ForBuiltIn() + { + // arrange + var model = CreateModel(isBuiltIn: true); + var sut = CreateSut(model); + + // act & assert + sut.RawToolTip.Should().BeEmpty(); + } + + [Fact] + public void IsActive_Set_ShouldCallService_AndRaisePropertyChanged() + { + // arrange + var service = A.Fake(); + var model = CreateModel(isActive: false); + var sut = CreateSut(model, service); + var propertyNames = new List(); + sut.PropertyChanged += (_, e) => propertyNames.Add(e.PropertyName); + + // act + sut.IsActive = true; + + // assert + A.CallTo(() => service.SetActive(model.Id, true)).MustHaveHappenedOnceExactly(); + propertyNames.Should().Contain(nameof(QuickFilterViewModel.IsActive)); + } + + [Fact] + public void IsActive_SetSameValue_ShouldNotCallService() + { + // arrange + var service = A.Fake(); + var model = CreateModel(isActive: true); + var sut = CreateSut(model, service); + var propertyChangedCount = 0; + sut.PropertyChanged += (_, _) => propertyChangedCount++; + + // act + sut.IsActive = true; + + // assert + A.CallTo(() => service.SetActive(A._, A._)).MustNotHaveHappened(); + propertyChangedCount.Should().Be(0); + } + + [Fact] + public void IsInverse_Set_ShouldCallService_AndRaisePropertyChanged() + { + // arrange + var service = A.Fake(); + var model = CreateModel(isInverse: false); + var sut = CreateSut(model, service); + var propertyNames = new List(); + sut.PropertyChanged += (_, e) => propertyNames.Add(e.PropertyName); + + // act + sut.IsInverse = true; + + // assert + A.CallTo(() => service.SetInverse(model.Id, true)).MustHaveHappenedOnceExactly(); + propertyNames.Should().Contain(nameof(QuickFilterViewModel.IsInverse)); + } + + [Fact] + public void IsInverse_SetSameValue_ShouldNotCallService() + { + // arrange + var service = A.Fake(); + var model = CreateModel(isInverse: false); + var sut = CreateSut(model, service); + + // act + sut.IsInverse = false; + + // assert + A.CallTo(() => service.SetInverse(A._, A._)).MustNotHaveHappened(); + } + + [Fact] + public void Order_Set_ShouldCallService_AndRaisePropertyChanged() + { + // arrange + var service = A.Fake(); + var model = CreateModel(order: 0); + var sut = CreateSut(model, service); + var propertyNames = new List(); + sut.PropertyChanged += (_, e) => propertyNames.Add(e.PropertyName); + + // act + sut.Order = 5; + + // assert + A.CallTo(() => service.UpdateOrder(model.Id, 5)).MustHaveHappenedOnceExactly(); + propertyNames.Should().Contain(nameof(QuickFilterViewModel.Order)); + } + + [Fact] + public void Order_SetSameValue_ShouldNotCallService() + { + // arrange + var service = A.Fake(); + var model = CreateModel(order: 3); + var sut = CreateSut(model, service); + + // act + sut.Order = 3; + + // assert + A.CallTo(() => service.UpdateOrder(A._, A._)).MustNotHaveHappened(); + } + + [Fact] + public void Toggle_FromOff_ShouldActivate() + { + // arrange + var service = A.Fake(); + var model = CreateModel(isActive: false, isInverse: false); + var sut = CreateSut(model, service); + + // act + sut.Toggle(); + + // assert + A.CallTo(() => service.SetActive(model.Id, true)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void Toggle_FromActiveNotInverse_ShouldSetInverse() + { + // arrange + var service = A.Fake(); + var model = CreateModel(isActive: true, isInverse: false); + var sut = CreateSut(model, service); + + // act + sut.Toggle(); + + // assert + A.CallTo(() => service.SetInverse(model.Id, true)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void Toggle_FromActiveInverse_ShouldDeactivate() + { + // arrange + var service = A.Fake(); + var model = CreateModel(isActive: true, isInverse: true); + var sut = CreateSut(model, service); + + // act + sut.Toggle(); + + // assert + A.CallTo(() => service.SetInverse(model.Id, false)).MustHaveHappenedOnceExactly(); + A.CallTo(() => service.SetActive(model.Id, false)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void UpdateLabel_ShouldCallService_AndRaisePropertyChanged() + { + // arrange + var service = A.Fake(); + var model = CreateModel(label: "Old"); + var sut = CreateSut(model, service); + var propertyNames = new List(); + sut.PropertyChanged += (_, e) => propertyNames.Add(e.PropertyName); + + // act + sut.UpdateLabel("New"); + + // assert + A.CallTo(() => service.UpdateLabel(model.Id, "New")).MustHaveHappenedOnceExactly(); + propertyNames.Should().Contain(nameof(QuickFilterViewModel.Label)); + propertyNames.Should().Contain(nameof(QuickFilterViewModel.DisplayLabel)); + propertyNames.Should().Contain(nameof(QuickFilterViewModel.ToolTip)); + propertyNames.Should().Contain(nameof(QuickFilterViewModel.HasToolTip)); + } + + [Fact] + public void UpdateToolTip_ShouldCallService_AndRaisePropertyChanged() + { + // arrange + var service = A.Fake(); + var model = CreateModel(); + var sut = CreateSut(model, service); + var propertyNames = new List(); + sut.PropertyChanged += (_, e) => propertyNames.Add(e.PropertyName); + + // act + sut.UpdateToolTip("New tooltip"); + + // assert + A.CallTo(() => service.UpdateToolTip(model.Id, "New tooltip")).MustHaveHappenedOnceExactly(); + propertyNames.Should().Contain(nameof(QuickFilterViewModel.ToolTip)); + propertyNames.Should().Contain(nameof(QuickFilterViewModel.RawToolTip)); + propertyNames.Should().Contain(nameof(QuickFilterViewModel.HasToolTip)); + } + + [Fact] + public void ToggleCommand_ShouldInvokeToggle() + { + // arrange + var service = A.Fake(); + var model = CreateModel(isActive: false); + var sut = CreateSut(model, service); + + // act + sut.ToggleCommand.Execute(null); + + // assert + A.CallTo(() => service.SetActive(model.Id, true)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void RemoveCommand_ShouldCallServiceRemove() + { + // arrange + var service = A.Fake(); + var model = CreateModel(); + var sut = CreateSut(model, service); + + // act + sut.RemoveCommand.Execute(null); + + // assert + A.CallTo(() => service.Remove(model.Id)).MustHaveHappenedOnceExactly(); + } + + private static QuickFilterViewModel CreateSut(QuickFilterModel? model = null, IQuickFilterService? service = null) + { + return new QuickFilterViewModel( + model ?? CreateModel(), + service ?? A.Fake()); + } + + private static QuickFilterModel CreateModel( + string label = "Test", + string toolTip = "", + bool isBuiltIn = false, + bool isActive = false, + bool isInverse = false, + int order = 0) + { + return new QuickFilterModel + { + Id = TestId, + Label = label, + Query = new SimpleTerm("tag", "test"), + IsActive = isActive, + IsInverse = isInverse, + IsBuiltIn = isBuiltIn, + ToolTip = toolTip, + Order = order, + }; + } +} diff --git a/tests/RepoM.App.Tests/ViewModels/RepositoryListViewModelTests.cs b/tests/RepoM.App.Tests/ViewModels/RepositoryListViewModelTests.cs new file mode 100644 index 00000000..a4b06cc4 --- /dev/null +++ b/tests/RepoM.App.Tests/ViewModels/RepositoryListViewModelTests.cs @@ -0,0 +1,428 @@ +namespace RepoM.App.Tests.ViewModels; + +using System; +using System.Collections.Generic; +using System.IO.Abstractions.TestingHelpers; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using DynamicData.Kernel; +using FakeItEasy; +using Microsoft.Extensions.Logging.Abstractions; +using RepoM.ActionMenu.Interface.ActionMenuFactory; +using RepoM.ActionMenu.Interface.UserInterface; +using RepoM.Api.Git; +using RepoM.App.RepositoryActions; +using RepoM.App.Services; +using RepoM.App.ViewModels; +using RepoM.Core.Plugin.Repository; +using RepoM.Core.Plugin.RepositoryActions; +using RepoM.Core.Repositories; +using RepoM.Core.Repositories.Favorite; +using RepoM.Core.Repositories.Model; +using RepoM.Core.Repositories.Monitoring; +using RepoM.Core.Repositories.Reading; +using RepoM.Core.Repositories.Scanning; +using RepoM.Core.Repositories.Store; +using RepoM.Core.Repositories.Watching; +using SimpleInjector; +using Xunit; + +public class RepositoryListViewModelTests +{ + [Fact] + public void Ctor_ShouldThrow_WhenArgumentNull() + { + // arrange + var monitorService = CreateMonitorService(); + var executor = CreateActionExecutor(_ => { }); + var menuFactory = A.Fake(); + var command = A.Fake(); + + // act + Action act1 = () => _ = new RepositoryListViewModel(null!, executor, menuFactory, NullLogger.Instance, command); + Action act2 = () => _ = new RepositoryListViewModel(monitorService, null!, menuFactory, NullLogger.Instance, command); + Action act3 = () => _ = new RepositoryListViewModel(monitorService, executor, null!, NullLogger.Instance, command); + Action act4 = () => _ = new RepositoryListViewModel(monitorService, executor, menuFactory, null!, command); + Action act5 = () => _ = new RepositoryListViewModel(monitorService, executor, menuFactory, NullLogger.Instance, null!); + + // assert + act1.Should().ThrowExactly(); + act2.Should().ThrowExactly(); + act3.Should().ThrowExactly(); + act4.Should().ThrowExactly(); + act5.Should().ThrowExactly(); + } + + [Fact] + public void SettingProperties_ShouldRaisePropertyChanged() + { + // arrange + var sut = CreateSut(); + var propertyNames = new List(); + sut.PropertyChanged += (_, e) => propertyNames.Add(e.PropertyName); + var items = new[] { "a", "b" }; + var repository = CreateRepositoryViewModel(); + + // act + sut.ItemsSource = items; + sut.SelectedRepository = repository; + + // assert + propertyNames.Should().Contain(nameof(RepositoryListViewModel.ItemsSource)); + propertyNames.Should().Contain(nameof(RepositoryListViewModel.SelectedRepository)); + sut.ItemsSource.Should().BeSameAs(items); + sut.SelectedRepository.Should().BeSameAs(repository); + } + + [Fact] + public async Task CreateContextMenuEntriesAsync_ShouldReturnEmpty_WhenNothingSelected() + { + // arrange + var sut = CreateSut(); + + // act + var result = await sut.CreateContextMenuEntriesAsync(default); + + // assert + result.Should().BeEmpty(); + } + + [Fact] + public async Task CreateContextMenuEntriesAsync_ShouldCreateEntries_ForRegularAndDeferredActions() + { + // arrange + var repository = CreateRepositoryViewModel(); + var updatedInfo = CreateRepositoryInfo(currentBranch: "develop"); + var monitorService = CreateMonitorService(updatedInfo); + var menuFactory = A.Fake(); + var context = A.Fake(); + A.CallTo(() => context.Clone()).Returns(context); + + var regularAction = new UserInterfaceRepositoryAction("Open", repository.Repository) + { + RepositoryCommand = new TestRepositoryCommand(), + }; + + var deferredAction = new DeferredSubActionsUserInterfaceRepositoryAction( + "More", + repository.Repository, + context, + _ => Task.FromResult( + [ + new UserInterfaceRepositoryAction("SubAction", repository.Repository) + { + RepositoryCommand = new TestRepositoryCommand(), + }, + ])); + + A.CallTo(() => menuFactory.CreateMenuAsync(A._)).Returns(GetMenuActions(repository.Repository, + [ + new UserInterfaceSeparatorRepositoryAction(repository.Repository), + regularAction, + deferredAction, + ])); + + var sut = CreateSut(monitorService: monitorService, menuFactory: menuFactory); + sut.SelectedRepository = repository; + + // act + var result = await sut.CreateContextMenuEntriesAsync(default); + + // assert + result.Should().HaveCount(3); + result[0].Should().BeSameAs(RepositoryMenuSeparatorViewModel.Instance); + + result[1].Should().BeOfType(); + var regularEntry = (RepositoryMenuItemViewModel)result[1]; + regularEntry.Header.Should().Be("Open"); + regularEntry.IsEnabled.Should().BeTrue(); + regularEntry.HasSubItems.Should().BeFalse(); + + result[2].Should().BeOfType(); + var deferredEntry = (RepositoryMenuItemViewModel)result[2]; + deferredEntry.HasSubItems.Should().BeTrue(); + IReadOnlyList childEntries = await deferredEntry.LoadChildrenAsync(); + childEntries.Should().ContainSingle(); + childEntries[0].Should().BeOfType(); + ((RepositoryMenuItemViewModel)childEntries[0]).Header.Should().Be("SubAction"); + + A.CallTo(() => A.Fake().EnableMonitoring(A._)).MustNotHaveHappened(); + } + + [Fact] + public async Task CreateContextMenuEntriesAsync_ShouldEnableMonitoring_ForSelectedRepository() + { + // arrange + var monitoringService = A.Fake(); + var repository = CreateRepositoryViewModel(monitoringService: monitoringService); + var menuFactory = A.Fake(); + A.CallTo(() => menuFactory.CreateMenuAsync(A._)) + .Returns(GetMenuActions(repository.Repository, [new UserInterfaceRepositoryAction("Open", repository.Repository)])); + var sut = CreateSut(menuFactory: menuFactory); + sut.SelectedRepository = repository; + + // act + _ = await sut.CreateContextMenuEntriesAsync(default); + + // assert + A.CallTo(() => monitoringService.EnableMonitoring(repository.Repository.SafePath)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task InvokeDefaultActionOnSelectionAsync_ShouldNotCreateMenu_WhenRepositoryWasNotFound() + { + // arrange + var repository = CreateRepositoryViewModel(info: CreateRepositoryInfo(wasFound: false)); + var menuFactory = A.Fake(); + var sut = CreateSut(menuFactory: menuFactory); + sut.SelectedRepository = repository; + + // act + await sut.InvokeDefaultActionOnSelectionAsync(); + + // assert + A.CallTo(() => menuFactory.CreateMenuAsync(A._)).MustNotHaveHappened(); + } + + [Fact] + public async Task InvokeDefaultActionOnSelectionAsync_ShouldDoNothing_WhenNoSelectedRepository() + { + // arrange + var menuFactory = A.Fake(); + var sut = CreateSut(menuFactory: menuFactory); + + // act + await sut.InvokeDefaultActionOnSelectionAsync(); + + // assert + A.CallTo(() => menuFactory.CreateMenuAsync(A._)).MustNotHaveHappened(); + } + + [Fact] + public void SettingItemsSource_SamReference_ShouldNotRaisePropertyChanged() + { + // arrange + var items = new[] { "a", "b" }; + var sut = CreateSut(); + sut.ItemsSource = items; + var propertyChangedCount = 0; + sut.PropertyChanged += (_, _) => propertyChangedCount++; + + // act + sut.ItemsSource = items; + + // assert + propertyChangedCount.Should().Be(0); + } + + [Fact] + public void SettingSelectedRepository_SameReference_ShouldNotRaisePropertyChanged() + { + // arrange + var repository = CreateRepositoryViewModel(); + var sut = CreateSut(); + sut.SelectedRepository = repository; + var propertyChangedCount = 0; + sut.PropertyChanged += (_, _) => propertyChangedCount++; + + // act + sut.SelectedRepository = repository; + + // assert + propertyChangedCount.Should().Be(0); + } + + [Fact] + public void SettingSelectedRepository_ToNull_ShouldRaisePropertyChanged() + { + // arrange + var repository = CreateRepositoryViewModel(); + var sut = CreateSut(); + sut.SelectedRepository = repository; + var propertyNames = new List(); + sut.PropertyChanged += (_, e) => propertyNames.Add(e.PropertyName); + + // act + sut.SelectedRepository = null; + + // assert + propertyNames.Should().Contain(nameof(RepositoryListViewModel.SelectedRepository)); + sut.SelectedRepository.Should().BeNull(); + } + + [Fact] + public async Task CreateContextMenuEntriesAsync_ShouldSkipUnknownActionTypes() + { + // arrange + var repository = CreateRepositoryViewModel(); + var menuFactory = A.Fake(); + A.CallTo(() => menuFactory.CreateMenuAsync(A._)) + .Returns(GetMenuActions(repository.Repository, + [ + new UserInterfaceRepositoryAction("Open", repository.Repository) { RepositoryCommand = new TestRepositoryCommand() }, + new UserInterfaceSeparatorRepositoryAction(repository.Repository), + ])); + var sut = CreateSut(menuFactory: menuFactory); + sut.SelectedRepository = repository; + + // act + var result = await sut.CreateContextMenuEntriesAsync(default); + + // assert + result.Should().HaveCount(2); + result[0].Should().BeOfType(); + result[1].Should().BeSameAs(RepositoryMenuSeparatorViewModel.Instance); + } + + [Fact] + public async Task CreateContextMenuEntriesAsync_ShouldMapDisabledAction() + { + // arrange + var repository = CreateRepositoryViewModel(); + var menuFactory = A.Fake(); + var disabledAction = new UserInterfaceRepositoryAction("Disabled", repository.Repository) + { + CanExecute = false, + RepositoryCommand = new TestRepositoryCommand(), + }; + A.CallTo(() => menuFactory.CreateMenuAsync(A._)) + .Returns(GetMenuActions(repository.Repository, [disabledAction])); + var sut = CreateSut(menuFactory: menuFactory); + sut.SelectedRepository = repository; + + // act + var result = await sut.CreateContextMenuEntriesAsync(default); + + // assert + result.Should().ContainSingle(); + var entry = (RepositoryMenuItemViewModel)result[0]; + entry.IsEnabled.Should().BeFalse(); + entry.Header.Should().Be("Disabled"); + } + + [Fact] + public void AddQuickFilterTagCommand_ShouldBeExposed() + { + // arrange + var command = A.Fake(); + var sut = new RepositoryListViewModel( + CreateMonitorService(), + CreateActionExecutor(_ => { }), + A.Fake(), + NullLogger.Instance, + command); + + // act & assert + sut.AddQuickFilterTagCommand.Should().BeSameAs(command); + } + + private static RepositoryListViewModel CreateSut( + RepositoryMonitorService? monitorService = null, + IUserMenuActionMenuFactory? menuFactory = null, + ActionExecutor? executor = null) + { + return new RepositoryListViewModel( + monitorService ?? CreateMonitorService(), + executor ?? CreateActionExecutor(_ => { }), + menuFactory ?? A.Fake(), + NullLogger.Instance, + A.Dummy()); + } + + private static RepositoryMonitorService CreateMonitorService(RepositoryInfo? updatedInfo = null) + { + var scanner = A.Fake(); + var watcher = A.Fake(); + var reader = A.Fake(); + var store = new RepositoryStore(); + var fileSystem = new MockFileSystem(); + var monitoringState = A.Fake(); + var monitoringEvents = A.Fake(); + + if (updatedInfo != null) + { + A.CallTo(() => reader.ReadAsync(updatedInfo.Path, default)).Returns(updatedInfo); + } + + return new RepositoryMonitorService( + scanner, + watcher, + reader, + store, + fileSystem, + () => [], + monitoringState, + monitoringEvents, + NullLogger.Instance); + } + + private static ActionExecutor CreateActionExecutor(Action onExecute) + { + var container = new Container(); + container.RegisterInstance>(new TestRepositoryCommandExecutor(onExecute)); + return new ActionExecutor(container, NullLogger.Instance); + } + + private static RepositoryViewModel CreateRepositoryViewModel( + RepositoryInfo? info = null, + IFavoriteService? favoriteService = null, + IRepositoryMonitoringService? monitoringService = null, + IRepositoryMonitoringEvents? monitoringEvents = null) + { + return new RepositoryViewModel( + info ?? CreateRepositoryInfo(), + favoriteService ?? A.Fake(), + monitoringService ?? A.Fake(), + monitoringEvents ?? A.Fake()); + } + + private static RepositoryInfo CreateRepositoryInfo(string currentBranch = "main", bool wasFound = true) + { + return new RepositoryInfo + { + Path = @"C:\Repos\RepoM", + SafePath = "C:/Repos/RepoM", + Name = "RepoM", + CurrentBranch = currentBranch, + Branches = [currentBranch], + LocalBranches = [currentBranch], + Tags = ["work"], + LastUpdated = DateTimeOffset.UtcNow.AddMinutes(-10), + LastSeen = DateTimeOffset.UtcNow.AddMinutes(-10), + WasFound = wasFound, + }; + } + + private static async IAsyncEnumerable GetMenuActions(IRepository repository, IEnumerable actions) + { + _ = repository; + foreach (UserInterfaceRepositoryActionBase action in actions) + { + yield return action; + await Task.Yield(); + } + } + + private sealed class TestRepositoryCommand : IRepositoryCommand + { + public string Name { get; } = nameof(TestRepositoryCommand); + } + + private sealed class TestRepositoryCommandExecutor : ICommandExecutor + { + private readonly Action _onExecute; + + public TestRepositoryCommandExecutor(Action onExecute) + { + _onExecute = onExecute; + } + + public void Execute(IRepository repository, TestRepositoryCommand action) + { + _ = repository; + _onExecute(action); + } + } +} \ No newline at end of file