From 6d0973c67c2223343df89b9cb91334377210c723 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 29 May 2026 16:29:22 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(ui):=20repo-import=20modal=20=E2=80=94?= =?UTF-8?q?=20remember=20folders,=20search,=20compact=20rows,=20no=20auto-?= =?UTF-8?q?select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppSettingsEntityConfiguration.cs | 3 + .../20260529142614_AddRepoImportFolders.cs | 35 ++++++++ .../ClaudeDoDbContextModelSnapshot.cs | 4 + src/ClaudeDo.Data/Models/AppSettingsEntity.cs | 3 + .../Repositories/AppSettingsRepository.cs | 28 +++++++ .../Modals/RepoImportItemViewModel.cs | 3 + .../Modals/RepoImportModalViewModel.cs | 83 +++++++++++++++++-- .../Views/Modals/RepoImportModalView.axaml | 42 ++++++---- .../Views/Modals/RepoImportModalView.axaml.cs | 2 +- .../RepoImportCandidatesTests.cs | 4 +- 10 files changed, 181 insertions(+), 26 deletions(-) create mode 100644 src/ClaudeDo.Data/Migrations/20260529142614_AddRepoImportFolders.cs diff --git a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs index 44defb4..42caac8 100644 --- a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs @@ -31,6 +31,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration s.WorktreeAutoCleanupDays) .HasColumnName("worktree_auto_cleanup_days").IsRequired().HasDefaultValue(7); + builder.Property(s => s.RepoImportFolders) + .HasColumnName("repo_import_folders"); + builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }); } } diff --git a/src/ClaudeDo.Data/Migrations/20260529142614_AddRepoImportFolders.cs b/src/ClaudeDo.Data/Migrations/20260529142614_AddRepoImportFolders.cs new file mode 100644 index 0000000..537291c --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260529142614_AddRepoImportFolders.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddRepoImportFolders : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "repo_import_folders", + table: "app_settings", + type: "TEXT", + nullable: true); + + migrationBuilder.UpdateData( + table: "app_settings", + keyColumn: "id", + keyValue: 1, + column: "repo_import_folders", + value: null); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "repo_import_folders", + table: "app_settings"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 333d8c5..814f265 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -54,6 +54,10 @@ namespace ClaudeDo.Data.Migrations .HasDefaultValue("bypassPermissions") .HasColumnName("default_permission_mode"); + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + b.Property("WorktreeAutoCleanupDays") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") diff --git a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs index dc92908..6587692 100644 --- a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs +++ b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs @@ -15,4 +15,7 @@ public sealed class AppSettingsEntity public string? CentralWorktreeRoot { get; set; } public bool WorktreeAutoCleanupEnabled { get; set; } public int WorktreeAutoCleanupDays { get; set; } = 7; + + // JSON array of parent folders remembered by the repo-import modal. + public string? RepoImportFolders { get; set; } } diff --git a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs index e425585..b7500d0 100644 --- a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs +++ b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using ClaudeDo.Data.Models; using Microsoft.EntityFrameworkCore; @@ -45,4 +46,31 @@ public sealed class AppSettingsRepository await _context.SaveChangesAsync(ct); } + + public async Task> GetRepoImportFoldersAsync(CancellationToken ct = default) + { + var json = await _context.AppSettings.AsNoTracking() + .Where(s => s.Id == AppSettingsEntity.SingletonId) + .Select(s => s.RepoImportFolders) + .FirstOrDefaultAsync(ct); + + if (string.IsNullOrWhiteSpace(json)) return new List(); + try { return JsonSerializer.Deserialize>(json) ?? new List(); } + catch (JsonException) { return new List(); } + } + + public async Task SetRepoImportFoldersAsync(IEnumerable folders, CancellationToken ct = default) + { + var list = folders.ToList(); + var row = await _context.AppSettings + .FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct); + if (row is null) + { + row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }; + _context.AppSettings.Add(row); + } + + row.RepoImportFolders = list.Count == 0 ? null : JsonSerializer.Serialize(list); + await _context.SaveChangesAsync(ct); + } } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs index 10773a1..3793660 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs @@ -12,4 +12,7 @@ public sealed partial class RepoImportItemViewModel : ViewModelBase public bool CanToggle => !AlreadyAdded; [ObservableProperty] private bool _isChecked; + + // Driven by the search filter; the row collapses when it doesn't match. + [ObservableProperty] private bool _isVisible = true; } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs index 16827dc..1debc21 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs @@ -4,6 +4,7 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Services; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.EntityFrameworkCore; @@ -13,14 +14,18 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase { private readonly IDbContextFactory _dbFactory; private readonly HashSet _existingDirs = new(StringComparer.OrdinalIgnoreCase); + private readonly List _folders = new(); public ObservableCollection Repos { get; } = new(); public Action? CloseAction { get; set; } + [ObservableProperty] private string _searchText = ""; + public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded); public bool CanCreate => CreateCount > 0; public string CreateButtonText => $"Create {CreateCount} list(s)"; + public bool HasFolders => _folders.Count > 0; public RepoImportModalViewModel(IDbContextFactory dbFactory) { @@ -29,8 +34,9 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase public async Task LoadAsync(CancellationToken ct = default) { - Repos.Clear(); + ClearRepos(); _existingDirs.Clear(); + _folders.Clear(); await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var lists = new ListRepository(ctx); @@ -39,25 +45,45 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase if (!string.IsNullOrWhiteSpace(l.WorkingDir)) _existingDirs.Add(l.WorkingDir!); } + + var settings = new AppSettingsRepository(ctx); + foreach (var f in await settings.GetRepoImportFoldersAsync(ct)) + AddFolderToSet(f); + + ScanAndAdd(_folders); + OnPropertyChanged(nameof(HasFolders)); NotifyCreateState(); } - public void AddFolders(IEnumerable folders) + public async Task AddFoldersAsync(IEnumerable folders) + { + var added = new List(); + foreach (var f in folders) + if (AddFolderToSet(f)) added.Add(f); + + if (added.Count == 0) return; + + ScanAndAdd(added); + OnPropertyChanged(nameof(HasFolders)); + NotifyCreateState(); + await SaveFoldersAsync(); + } + + private void ScanAndAdd(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)) + foreach (var item in BuildCandidates(RepoScanner.Scan(folder), current, _existingDirs)) { item.PropertyChanged += OnItemChanged; Repos.Add(item); current.Add(item.FullPath); } } - NotifyCreateState(); + ApplyFilter(); } public static List BuildCandidates( @@ -69,17 +95,60 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase foreach (var c in found) { if (currentPaths.Contains(c.FullPath)) continue; + var alreadyAdded = existingDirs.Contains(c.FullPath); items.Add(new RepoImportItemViewModel { Name = c.Name, FullPath = c.FullPath, - AlreadyAdded = existingDirs.Contains(c.FullPath), - IsChecked = true, + AlreadyAdded = alreadyAdded, + IsChecked = alreadyAdded, }); } return items; } + [RelayCommand] + private async Task ForgetFoldersAsync() + { + _folders.Clear(); + ClearRepos(); + OnPropertyChanged(nameof(HasFolders)); + NotifyCreateState(); + await SaveFoldersAsync(); + } + + partial void OnSearchTextChanged(string value) => ApplyFilter(); + + private void ApplyFilter() + { + var q = SearchText?.Trim() ?? ""; + foreach (var r in Repos) + r.IsVisible = q.Length == 0 + || r.Name.Contains(q, StringComparison.OrdinalIgnoreCase) + || r.FullPath.Contains(q, StringComparison.OrdinalIgnoreCase); + } + + private bool AddFolderToSet(string folder) + { + if (string.IsNullOrWhiteSpace(folder)) return false; + if (_folders.Any(f => string.Equals(f, folder, StringComparison.OrdinalIgnoreCase))) + return false; + _folders.Add(folder); + return true; + } + + private async Task SaveFoldersAsync() + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + await new AppSettingsRepository(ctx).SetRepoImportFoldersAsync(_folders); + } + + private void ClearRepos() + { + foreach (var r in Repos) r.PropertyChanged -= OnItemChanged; + Repos.Clear(); + } + private void OnItemChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(RepoImportItemViewModel.IsChecked)) diff --git a/src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml index 7a31047..f28290b 100644 --- a/src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml @@ -26,30 +26,40 @@ - - -