Merge feat/repo-import-polish: remember folders, search, compact rows, no auto-select

This commit is contained in:
mika kuns
2026-05-29 16:39:06 +02:00
10 changed files with 182 additions and 26 deletions

View File

@@ -31,6 +31,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => 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 });
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddRepoImportFolders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "repo_import_folders",
table: "app_settings");
}
}
}

View File

@@ -54,6 +54,10 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")

View File

@@ -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; }
}

View File

@@ -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<List<string>> 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<string>();
try { return JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>(); }
catch (JsonException) { return new List<string>(); }
}
public async Task SetRepoImportFoldersAsync(IEnumerable<string> 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);
}
}

View File

@@ -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;
}

View File

@@ -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<ClaudeDoDbContext> _dbFactory;
private readonly HashSet<string> _existingDirs = new(StringComparer.OrdinalIgnoreCase);
private readonly List<string> _folders = new();
public ObservableCollection<RepoImportItemViewModel> 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<ClaudeDoDbContext> 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<string> folders)
public async Task AddFoldersAsync(IEnumerable<string> folders)
{
var added = new List<string>();
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<string> folders)
{
var current = new HashSet<string>(
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<RepoImportItemViewModel> BuildCandidates(
@@ -69,17 +95,61 @@ 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,
// New repos start unchecked (no auto-select); already-added rows show ticked + disabled.
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))

View File

@@ -26,30 +26,40 @@
</Grid>
</Border>
<!-- Add folder row -->
<Border Grid.Row="1" Padding="16,12,16,4">
<Button Content="Add folder…" Click="AddFolderClicked" HorizontalAlignment="Left"/>
</Border>
<!-- Toolbar: search + folder actions -->
<StackPanel Grid.Row="1" Spacing="8" Margin="16,12,16,6">
<TextBox Text="{Binding SearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="Search repos…"/>
<Grid ColumnDefinitions="Auto,*,Auto">
<Button Grid.Column="0" Content="Add folder…" Click="AddFolderClicked"/>
<Button Grid.Column="2" Content="Forget folders"
Command="{Binding ForgetFoldersCommand}"
IsVisible="{Binding HasFolders}"/>
</Grid>
</StackPanel>
<!-- Repo checklist -->
<ScrollViewer Grid.Row="2" Padding="16,4,16,8">
<ScrollViewer Grid.Row="2" Padding="16,2,16,8">
<ItemsControl ItemsSource="{Binding Repos}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:RepoImportItemViewModel">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,4">
<CheckBox Grid.Column="0"
<Grid ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,1"
IsVisible="{Binding IsVisible}">
<CheckBox Grid.Column="0" MinWidth="0"
IsChecked="{Binding IsChecked, Mode=TwoWay}"
IsEnabled="{Binding CanToggle}"
VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Margin="6,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource TextBrush}" FontSize="13"/>
<TextBlock Text="{Binding FullPath}" Foreground="{DynamicResource TextFaintBrush}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="(already added)"
Foreground="{DynamicResource TextFaintBrush}" FontSize="11"
VerticalAlignment="Center"
<TextBlock Grid.Column="1" Text="{Binding Name}"
Foreground="{DynamicResource TextBrush}" FontSize="12"
VerticalAlignment="Center" Margin="4,0,0,0"/>
<TextBlock Grid.Column="2" Text="{Binding FullPath}"
Foreground="{DynamicResource TextFaintBrush}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" Margin="8,0,0,0"/>
<TextBlock Grid.Column="3" Text="(already added)"
Foreground="{DynamicResource TextFaintBrush}" FontSize="10"
VerticalAlignment="Center" Margin="8,0,0,0"
IsVisible="{Binding AlreadyAdded}"/>
</Grid>
</DataTemplate>

View File

@@ -25,6 +25,6 @@ public partial class RepoImportModalView : Window
});
if (folders.Count == 0) return;
vm.AddFolders(folders.Select(f => f.Path.LocalPath));
await vm.AddFoldersAsync(folders.Select(f => f.Path.LocalPath));
}
}

View File

@@ -6,7 +6,7 @@ namespace ClaudeDo.Ui.Tests;
public sealed class RepoImportCandidatesTests
{
[Fact]
public void BuildCandidates_NewRepo_IsCheckedAndNotAlreadyAdded()
public void BuildCandidates_NewRepo_IsUncheckedAndNotAlreadyAdded()
{
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
var current = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -15,7 +15,7 @@ public sealed class RepoImportCandidatesTests
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
Assert.Single(items);
Assert.True(items[0].IsChecked);
Assert.False(items[0].IsChecked); // new repos are not auto-selected
Assert.False(items[0].AlreadyAdded);
Assert.Equal("repo-a", items[0].Name);
Assert.Equal(@"C:\src\repo-a", items[0].FullPath);