From 50b1589b23a80c01ed6eccea9b55ec23936179e6 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 29 May 2026 15:39:43 +0200 Subject: [PATCH] feat(ui): add RepoImportModalViewModel with candidate merge logic --- .../Modals/RepoImportModalViewModel.cs | 121 ++++++++++++++++++ .../RepoImportCandidatesTests.cs | 48 +++++++ 2 files changed, 169 insertions(+) create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs create mode 100644 tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs new file mode 100644 index 0000000..16827dc --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs @@ -0,0 +1,121 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Ui.Services; +using CommunityToolkit.Mvvm.Input; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Ui.ViewModels.Modals; + +public sealed partial class RepoImportModalViewModel : ViewModelBase +{ + private readonly IDbContextFactory _dbFactory; + private readonly HashSet _existingDirs = new(StringComparer.OrdinalIgnoreCase); + + public ObservableCollection Repos { get; } = new(); + + public Action? CloseAction { get; set; } + + public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded); + public bool CanCreate => CreateCount > 0; + public string CreateButtonText => $"Create {CreateCount} list(s)"; + + public RepoImportModalViewModel(IDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task LoadAsync(CancellationToken ct = default) + { + Repos.Clear(); + _existingDirs.Clear(); + + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var lists = new ListRepository(ctx); + foreach (var l in await lists.GetAllAsync(ct)) + { + if (!string.IsNullOrWhiteSpace(l.WorkingDir)) + _existingDirs.Add(l.WorkingDir!); + } + NotifyCreateState(); + } + + public void AddFolders(IEnumerable folders) + { + var current = new HashSet( + Repos.Select(r => r.FullPath), StringComparer.OrdinalIgnoreCase); + + foreach (var folder in folders) + { + var found = RepoScanner.Scan(folder); + foreach (var item in BuildCandidates(found, current, _existingDirs)) + { + item.PropertyChanged += OnItemChanged; + Repos.Add(item); + current.Add(item.FullPath); + } + } + NotifyCreateState(); + } + + public static List BuildCandidates( + IEnumerable found, + IReadOnlySet currentPaths, + IReadOnlySet existingDirs) + { + var items = new List(); + foreach (var c in found) + { + if (currentPaths.Contains(c.FullPath)) continue; + items.Add(new RepoImportItemViewModel + { + Name = c.Name, + FullPath = c.FullPath, + AlreadyAdded = existingDirs.Contains(c.FullPath), + IsChecked = true, + }); + } + return items; + } + + private void OnItemChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(RepoImportItemViewModel.IsChecked)) + NotifyCreateState(); + } + + private void NotifyCreateState() + { + OnPropertyChanged(nameof(CreateCount)); + OnPropertyChanged(nameof(CanCreate)); + OnPropertyChanged(nameof(CreateButtonText)); + } + + [RelayCommand] + private async Task CreateAsync() + { + var toCreate = Repos.Where(r => r.IsChecked && !r.AlreadyAdded).ToList(); + if (toCreate.Count > 0) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + var lists = new ListRepository(ctx); + foreach (var r in toCreate) + { + await lists.AddAsync(new ListEntity + { + Id = Guid.NewGuid().ToString("N"), + Name = r.Name, + WorkingDir = r.FullPath, + DefaultCommitType = CommitTypeRegistry.DefaultType, + CreatedAt = DateTime.UtcNow, + }); + } + } + CloseAction?.Invoke(); + } + + [RelayCommand] + private void Cancel() => CloseAction?.Invoke(); +} diff --git a/tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs b/tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs new file mode 100644 index 0000000..0567c9a --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs @@ -0,0 +1,48 @@ +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Modals; + +namespace ClaudeDo.Ui.Tests; + +public sealed class RepoImportCandidatesTests +{ + [Fact] + public void BuildCandidates_NewRepo_IsCheckedAndNotAlreadyAdded() + { + var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") }; + var current = new HashSet(StringComparer.OrdinalIgnoreCase); + var existing = new HashSet(StringComparer.OrdinalIgnoreCase); + + var items = RepoImportModalViewModel.BuildCandidates(found, current, existing); + + Assert.Single(items); + Assert.True(items[0].IsChecked); + Assert.False(items[0].AlreadyAdded); + Assert.Equal("repo-a", items[0].Name); + } + + [Fact] + public void BuildCandidates_ExistingWorkingDir_IsMarkedAlreadyAdded() + { + var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") }; + var current = new HashSet(StringComparer.OrdinalIgnoreCase); + var existing = new HashSet(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase); + + var items = RepoImportModalViewModel.BuildCandidates(found, current, existing); + + Assert.Single(items); + Assert.True(items[0].AlreadyAdded); + Assert.True(items[0].IsChecked); // already-added rows render ticked + } + + [Fact] + public void BuildCandidates_SkipsPathsAlreadyShown() + { + var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") }; + var current = new HashSet(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase); + var existing = new HashSet(StringComparer.OrdinalIgnoreCase); + + var items = RepoImportModalViewModel.BuildCandidates(found, current, existing); + + Assert.Empty(items); + } +}