Merge feat/repo-import-polish: remember folders, search, compact rows, no auto-select
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user