From 137b21fc6b322146bb97315e69d7da551d371f6a Mon Sep 17 00:00:00 2001 From: Coen van den Munckhof Date: Fri, 3 Apr 2026 22:03:53 +0200 Subject: [PATCH 1/9] add new filtering, new concept of quick filter --- src/RepoM.Api/Bootstrapper.cs | 7 + .../QuickFilter/IQuickFilterService.cs | 28 + .../QuickFilter/QueryJsonConverter.cs | 167 ++++++ src/RepoM.Api/QuickFilter/QuickFilterModel.cs | 25 + .../QuickFilter/QuickFilterService.cs | 226 ++++++++ src/RepoM.App/Bootstrapper.cs | 1 + src/RepoM.App/Controls/QuickFilterBar.xaml | 260 +++++++++ src/RepoM.App/Controls/QuickFilterBar.xaml.cs | 446 +++++++++++++++ .../Controls/RepositoryComponent.xaml | 203 +++++++ .../Controls/RepositoryComponent.xaml.cs | 32 ++ src/RepoM.App/Controls/RepositoryList.xaml | 39 ++ src/RepoM.App/Controls/RepositoryList.xaml.cs | 233 ++++++++ src/RepoM.App/MainWindow.xaml | 215 ++----- src/RepoM.App/MainWindow.xaml.cs | 531 ++---------------- src/RepoM.App/NotifyIconResources.xaml | 10 +- .../ViewModels/MainWindowViewModel.cs | 46 +- .../ViewModels/QuickFilterBarViewModel.cs | 173 ++++++ .../ViewModels/QuickFilterViewModel.cs | 149 +++++ .../ViewModels/RepositoryListViewModel.cs | 293 ++++++++++ 19 files changed, 2425 insertions(+), 659 deletions(-) create mode 100644 src/RepoM.Api/QuickFilter/IQuickFilterService.cs create mode 100644 src/RepoM.Api/QuickFilter/QueryJsonConverter.cs create mode 100644 src/RepoM.Api/QuickFilter/QuickFilterModel.cs create mode 100644 src/RepoM.Api/QuickFilter/QuickFilterService.cs create mode 100644 src/RepoM.App/Controls/QuickFilterBar.xaml create mode 100644 src/RepoM.App/Controls/QuickFilterBar.xaml.cs create mode 100644 src/RepoM.App/Controls/RepositoryComponent.xaml create mode 100644 src/RepoM.App/Controls/RepositoryComponent.xaml.cs create mode 100644 src/RepoM.App/Controls/RepositoryList.xaml create mode 100644 src/RepoM.App/Controls/RepositoryList.xaml.cs create mode 100644 src/RepoM.App/ViewModels/QuickFilterBarViewModel.cs create mode 100644 src/RepoM.App/ViewModels/QuickFilterViewModel.cs create mode 100644 src/RepoM.App/ViewModels/RepositoryListViewModel.cs diff --git a/src/RepoM.Api/Bootstrapper.cs b/src/RepoM.Api/Bootstrapper.cs index 62450cc1..000e178e 100644 --- a/src/RepoM.Api/Bootstrapper.cs +++ b/src/RepoM.Api/Bootstrapper.cs @@ -11,6 +11,7 @@ namespace RepoM.Api; using System.Threading.Tasks; using System; using RepoM.Api.Plugins.SimpleInjector; +using RepoM.Api.QuickFilter; using RepoM.Core.Plugin; using RepoM.Core.Plugin.Common; using RepoM.Core.Plugin.RepositoryOrdering.Configuration; @@ -48,6 +49,12 @@ public static void RegisterRepositoryComparerConfigurationsTypes(Container conta } } + public static void RegisterQuickFilterServices(Container container) + { + ArgumentNullException.ThrowIfNull(container); + container.Register(Lifestyle.Singleton); + } + public async Task LoadAndRegisterPluginsAsync(Container container, string baseDirectory) { _ = container ?? throw new ArgumentNullException(nameof(container)); diff --git a/src/RepoM.Api/QuickFilter/IQuickFilterService.cs b/src/RepoM.Api/QuickFilter/IQuickFilterService.cs new file mode 100644 index 00000000..e24515c8 --- /dev/null +++ b/src/RepoM.Api/QuickFilter/IQuickFilterService.cs @@ -0,0 +1,28 @@ +namespace RepoM.Api.QuickFilter; + +using System; +using System.Collections.Generic; +using RepoM.Core.Plugin.RepositoryFiltering.Clause; + +public interface IQuickFilterService +{ + IReadOnlyList GetAll(); + + QuickFilterModel Add(string label, IQuery query); + + void Remove(Guid id); + + void SetActive(Guid id, bool isActive); + + void SetInverse(Guid id, bool isInverse); + + void UpdateLabel(Guid id, string newLabel); + + void UpdateToolTip(Guid id, string newToolTip); + + void UpdateOrder(Guid id, int newOrder); + + QuickFilterModel? FindByQuery(IQuery query); + + event EventHandler? Changed; +} \ No newline at end of file diff --git a/src/RepoM.Api/QuickFilter/QueryJsonConverter.cs b/src/RepoM.Api/QuickFilter/QueryJsonConverter.cs new file mode 100644 index 00000000..8c46da2c --- /dev/null +++ b/src/RepoM.Api/QuickFilter/QueryJsonConverter.cs @@ -0,0 +1,167 @@ +namespace RepoM.Api.QuickFilter; + +using System; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using RepoM.Core.Plugin.RepositoryFiltering.Clause; +using RepoM.Core.Plugin.RepositoryFiltering.Clause.Terms; + +internal sealed class QueryJsonConverter : JsonConverter +{ + public override IQuery? ReadJson(JsonReader reader, Type objectType, IQuery? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + var token = JToken.Load(reader); + return ReadQuery(token); + } + + public override void WriteJson(JsonWriter writer, IQuery? value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + return; + } + + WriteQuery(writer, value); + } + + private static IQuery ReadQuery(JToken token) + { + if (token.Type != JTokenType.Object) + { + throw new JsonSerializationException("Expected JSON object for IQuery."); + } + + var obj = (JObject)token; + var type = obj.Value("type") ?? throw new JsonSerializationException("Missing 'type' property."); + + return type switch + { + "true" => TrueQuery.Instance, + "false" => FalseQuery.Instance, + "and" => ReadAndQuery(obj), + "or" => ReadOrQuery(obj), + "not" => ReadNotQuery(obj), + "freetext" => new FreeText(obj.Value("value") ?? string.Empty), + "simpleterm" => new SimpleTerm( + obj.Value("term") ?? string.Empty, + obj.Value("value") ?? string.Empty), + "startswithterm" => new StartsWithTerm( + obj.Value("term") ?? string.Empty, + obj.Value("value") ?? string.Empty), + _ => throw new JsonSerializationException($"Unknown query type '{type}'."), + }; + } + + private static AndQuery ReadAndQuery(JObject obj) + { + var items = obj["items"] as JArray ?? []; + return new AndQuery(items.Select(ReadQuery).ToArray()); + } + + private static OrQuery ReadOrQuery(JObject obj) + { + var items = obj["items"] as JArray ?? []; + return new OrQuery(items.Select(ReadQuery).ToArray()); + } + + private static NotQuery ReadNotQuery(JObject obj) + { + var item = obj["item"] ?? throw new JsonSerializationException("Missing 'item' in NotQuery."); + return new NotQuery(ReadQuery(item)); + } + + private static void WriteQuery(JsonWriter writer, IQuery query) + { + switch (query) + { + case TrueQuery: + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue("true"); + writer.WriteEndObject(); + break; + + case FalseQuery: + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue("false"); + writer.WriteEndObject(); + break; + + case AndQuery and: + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue("and"); + writer.WritePropertyName("items"); + writer.WriteStartArray(); + foreach (IQuery item in and.Items) + { + WriteQuery(writer, item); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + break; + + case OrQuery or: + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue("or"); + writer.WritePropertyName("items"); + writer.WriteStartArray(); + foreach (IQuery item in or.Items) + { + WriteQuery(writer, item); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + break; + + case NotQuery not: + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue("not"); + writer.WritePropertyName("item"); + WriteQuery(writer, not.Item); + writer.WriteEndObject(); + break; + + case FreeText freeText: + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue("freetext"); + writer.WritePropertyName("value"); + writer.WriteValue(freeText.Value); + writer.WriteEndObject(); + break; + + case SimpleTerm simpleTerm: + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue("simpleterm"); + writer.WritePropertyName("term"); + writer.WriteValue(simpleTerm.Term); + writer.WritePropertyName("value"); + writer.WriteValue(simpleTerm.Value); + writer.WriteEndObject(); + break; + + case StartsWithTerm startsWithTerm: + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue("startswithterm"); + writer.WritePropertyName("term"); + writer.WriteValue(startsWithTerm.Term); + writer.WritePropertyName("value"); + writer.WriteValue(startsWithTerm.Value); + writer.WriteEndObject(); + break; + + default: + throw new JsonSerializationException($"Unknown IQuery type '{query.GetType().Name}'."); + } + } +} \ No newline at end of file diff --git a/src/RepoM.Api/QuickFilter/QuickFilterModel.cs b/src/RepoM.Api/QuickFilter/QuickFilterModel.cs new file mode 100644 index 00000000..f5225a4c --- /dev/null +++ b/src/RepoM.Api/QuickFilter/QuickFilterModel.cs @@ -0,0 +1,25 @@ +namespace RepoM.Api.QuickFilter; + +using System; +using Newtonsoft.Json; +using RepoM.Core.Plugin.RepositoryFiltering.Clause; + +public sealed class QuickFilterModel +{ + public Guid Id { get; set; } + + public string Label { get; set; } = string.Empty; + + public IQuery Query { get; set; } = TrueQuery.Instance; + + public bool IsActive { get; set; } + + public bool IsInverse { get; set; } + + public int Order { get; set; } + + public string ToolTip { get; set; } = string.Empty; + + [JsonIgnore] + public bool IsBuiltIn { get; set; } +} \ No newline at end of file diff --git a/src/RepoM.Api/QuickFilter/QuickFilterService.cs b/src/RepoM.Api/QuickFilter/QuickFilterService.cs new file mode 100644 index 00000000..2b2f880d --- /dev/null +++ b/src/RepoM.Api/QuickFilter/QuickFilterService.cs @@ -0,0 +1,226 @@ +namespace RepoM.Api.QuickFilter; + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using RepoM.Core.Plugin.Common; +using RepoM.Core.Plugin.RepositoryFiltering.Clause; +using RepoM.Core.Plugin.RepositoryFiltering.Clause.Terms; + +internal sealed class QuickFilterService : IQuickFilterService +{ + private readonly IFileSystem _fileSystem; + private readonly ILogger _logger; + private readonly string _filePath; + private readonly List _filters; + + private static readonly JsonSerializerSettings _jsonSettings = new() + { + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Ignore, + Converters = { new QueryJsonConverter(), }, + }; + + private static readonly Guid _builtInFavoriteId = new("00000000-0000-0000-0000-000000000001"); + private static readonly Guid _builtInActiveId = new("00000000-0000-0000-0000-000000000002"); + + public QuickFilterService(IAppDataPathProvider appDataPathProvider, IFileSystem fileSystem, ILogger logger) + { + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + ArgumentNullException.ThrowIfNull(appDataPathProvider); + + _filePath = Path.Combine(appDataPathProvider.AppDataPath, "quickfilters.json"); + _filters = Load(); + EnsureBuiltInFilters(); + } + + public event EventHandler? Changed; + + public IReadOnlyList GetAll() + { + return _filters.OrderBy(f => f.Order).ToList(); + } + + public QuickFilterModel Add(string label, IQuery query) + { + ArgumentNullException.ThrowIfNull(query); + + var model = new QuickFilterModel + { + Id = Guid.NewGuid(), + Label = label ?? string.Empty, + Query = query, + IsActive = true, + Order = _filters.Count > 0 ? _filters.Max(f => f.Order) + 1 : 0, + }; + + _filters.Add(model); + Save(); + Changed?.Invoke(this, EventArgs.Empty); + return model; + } + + public void Remove(Guid id) + { + var index = _filters.FindIndex(f => f.Id == id); + if (index < 0) + { + return; + } + + if (_filters[index].IsBuiltIn) + { + return; + } + + _filters.RemoveAt(index); + Save(); + Changed?.Invoke(this, EventArgs.Empty); + } + + public void SetActive(Guid id, bool isActive) + { + var filter = _filters.Find(f => f.Id == id); + if (filter == null || filter.IsActive == isActive) + { + return; + } + + filter.IsActive = isActive; + Save(); + Changed?.Invoke(this, EventArgs.Empty); + } + + public void SetInverse(Guid id, bool isInverse) + { + var filter = _filters.Find(f => f.Id == id); + if (filter == null || filter.IsInverse == isInverse) + { + return; + } + + filter.IsInverse = isInverse; + Save(); + Changed?.Invoke(this, EventArgs.Empty); + } + + public void UpdateLabel(Guid id, string newLabel) + { + var filter = _filters.Find(f => f.Id == id); + if (filter == null || filter.IsBuiltIn) + { + return; + } + + filter.Label = newLabel ?? string.Empty; + Save(); + Changed?.Invoke(this, EventArgs.Empty); + } + + public void UpdateToolTip(Guid id, string newToolTip) + { + var filter = _filters.Find(f => f.Id == id); + if (filter == null || filter.IsBuiltIn) + { + return; + } + + filter.ToolTip = newToolTip ?? string.Empty; + Save(); + Changed?.Invoke(this, EventArgs.Empty); + } + + public void UpdateOrder(Guid id, int newOrder) + { + var filter = _filters.Find(f => f.Id == id); + if (filter == null || filter.IsBuiltIn) + { + return; + } + + filter.Order = newOrder; + Save(); + Changed?.Invoke(this, EventArgs.Empty); + } + + public QuickFilterModel? FindByQuery(IQuery query) + { + var queryString = query.ToString(); + return _filters.Find(f => string.Equals(f.Query.ToString(), queryString, StringComparison.OrdinalIgnoreCase)); + } + + private List Load() + { + if (!_fileSystem.File.Exists(_filePath)) + { + return []; + } + + try + { + var json = _fileSystem.File.ReadAllText(_filePath); + var result = JsonConvert.DeserializeObject>(json, _jsonSettings); + return result ?? []; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not load quick filters from '{File}'.", _filePath); + } + + return []; + } + + private void Save() + { + try + { + IDirectoryInfo? directoryInfo = _fileSystem.Directory.GetParent(_filePath); + if (directoryInfo != null && !_fileSystem.Directory.Exists(directoryInfo.FullName)) + { + _fileSystem.Directory.CreateDirectory(directoryInfo.FullName); + } + + var userFilters = _filters.Where(f => !f.IsBuiltIn).OrderBy(f => f.Order).ToList(); + var json = JsonConvert.SerializeObject(userFilters, _jsonSettings); + _fileSystem.File.WriteAllText(_filePath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not save quick filters to '{File}'.", _filePath); + } + } + + private void EnsureBuiltInFilters() + { + if (_filters.All(f => f.Id != _builtInFavoriteId)) + { + _filters.Add(new QuickFilterModel + { + Id = _builtInFavoriteId, + Label = "\u2605", + Query = new SimpleTerm("is", "favorite"), + IsActive = false, + Order = -2, + IsBuiltIn = true, + }); + } + + if (_filters.All(f => f.Id != _builtInActiveId)) + { + _filters.Add(new QuickFilterModel + { + Id = _builtInActiveId, + Label = "\uD83D\uDC41", + Query = new SimpleTerm("is", "active"), + IsActive = false, + Order = -1, + IsBuiltIn = true, + }); + } + } +} \ No newline at end of file diff --git a/src/RepoM.App/Bootstrapper.cs b/src/RepoM.App/Bootstrapper.cs index 52a02218..e6f9aa4e 100644 --- a/src/RepoM.App/Bootstrapper.cs +++ b/src/RepoM.App/Bootstrapper.cs @@ -93,6 +93,7 @@ public static void RegisterServices(IFileSystem fileSystem, IAppDataPathProvider Container.Register(Lifestyle.Singleton); Container.Register(Lifestyle.Singleton); + CoreBootstrapper.RegisterQuickFilterServices(Container); Container.Collection.Append(Lifestyle.Singleton); Container.Collection.Append(Lifestyle.Singleton); diff --git a/src/RepoM.App/Controls/QuickFilterBar.xaml b/src/RepoM.App/Controls/QuickFilterBar.xaml new file mode 100644 index 00000000..f4e8ee94 --- /dev/null +++ b/src/RepoM.App/Controls/QuickFilterBar.xaml @@ -0,0 +1,260 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +