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)
|
builder.Property(s => s.WorktreeAutoCleanupDays)
|
||||||
.HasColumnName("worktree_auto_cleanup_days").IsRequired().HasDefaultValue(7);
|
.HasColumnName("worktree_auto_cleanup_days").IsRequired().HasDefaultValue(7);
|
||||||
|
|
||||||
|
builder.Property(s => s.RepoImportFolders)
|
||||||
|
.HasColumnName("repo_import_folders");
|
||||||
|
|
||||||
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
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")
|
.HasDefaultValue("bypassPermissions")
|
||||||
.HasColumnName("default_permission_mode");
|
.HasColumnName("default_permission_mode");
|
||||||
|
|
||||||
|
b.Property<string>("RepoImportFolders")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("repo_import_folders");
|
||||||
|
|
||||||
b.Property<int>("WorktreeAutoCleanupDays")
|
b.Property<int>("WorktreeAutoCleanupDays")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("INTEGER")
|
.HasColumnType("INTEGER")
|
||||||
|
|||||||
@@ -15,4 +15,7 @@ public sealed class AppSettingsEntity
|
|||||||
public string? CentralWorktreeRoot { get; set; }
|
public string? CentralWorktreeRoot { get; set; }
|
||||||
public bool WorktreeAutoCleanupEnabled { get; set; }
|
public bool WorktreeAutoCleanupEnabled { get; set; }
|
||||||
public int WorktreeAutoCleanupDays { get; set; } = 7;
|
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 ClaudeDo.Data.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -45,4 +46,31 @@ public sealed class AppSettingsRepository
|
|||||||
|
|
||||||
await _context.SaveChangesAsync(ct);
|
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;
|
public bool CanToggle => !AlreadyAdded;
|
||||||
|
|
||||||
[ObservableProperty] private bool _isChecked;
|
[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.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -13,14 +14,18 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly HashSet<string> _existingDirs = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _existingDirs = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly List<string> _folders = new();
|
||||||
|
|
||||||
public ObservableCollection<RepoImportItemViewModel> Repos { get; } = new();
|
public ObservableCollection<RepoImportItemViewModel> Repos { get; } = new();
|
||||||
|
|
||||||
public Action? CloseAction { get; set; }
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty] private string _searchText = "";
|
||||||
|
|
||||||
public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded);
|
public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded);
|
||||||
public bool CanCreate => CreateCount > 0;
|
public bool CanCreate => CreateCount > 0;
|
||||||
public string CreateButtonText => $"Create {CreateCount} list(s)";
|
public string CreateButtonText => $"Create {CreateCount} list(s)";
|
||||||
|
public bool HasFolders => _folders.Count > 0;
|
||||||
|
|
||||||
public RepoImportModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
public RepoImportModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||||
{
|
{
|
||||||
@@ -29,8 +34,9 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
public async Task LoadAsync(CancellationToken ct = default)
|
public async Task LoadAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Repos.Clear();
|
ClearRepos();
|
||||||
_existingDirs.Clear();
|
_existingDirs.Clear();
|
||||||
|
_folders.Clear();
|
||||||
|
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var lists = new ListRepository(ctx);
|
var lists = new ListRepository(ctx);
|
||||||
@@ -39,25 +45,45 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase
|
|||||||
if (!string.IsNullOrWhiteSpace(l.WorkingDir))
|
if (!string.IsNullOrWhiteSpace(l.WorkingDir))
|
||||||
_existingDirs.Add(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();
|
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>(
|
var current = new HashSet<string>(
|
||||||
Repos.Select(r => r.FullPath), StringComparer.OrdinalIgnoreCase);
|
Repos.Select(r => r.FullPath), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var folder in folders)
|
foreach (var folder in folders)
|
||||||
{
|
{
|
||||||
var found = RepoScanner.Scan(folder);
|
foreach (var item in BuildCandidates(RepoScanner.Scan(folder), current, _existingDirs))
|
||||||
foreach (var item in BuildCandidates(found, current, _existingDirs))
|
|
||||||
{
|
{
|
||||||
item.PropertyChanged += OnItemChanged;
|
item.PropertyChanged += OnItemChanged;
|
||||||
Repos.Add(item);
|
Repos.Add(item);
|
||||||
current.Add(item.FullPath);
|
current.Add(item.FullPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NotifyCreateState();
|
ApplyFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<RepoImportItemViewModel> BuildCandidates(
|
public static List<RepoImportItemViewModel> BuildCandidates(
|
||||||
@@ -69,17 +95,61 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase
|
|||||||
foreach (var c in found)
|
foreach (var c in found)
|
||||||
{
|
{
|
||||||
if (currentPaths.Contains(c.FullPath)) continue;
|
if (currentPaths.Contains(c.FullPath)) continue;
|
||||||
|
var alreadyAdded = existingDirs.Contains(c.FullPath);
|
||||||
items.Add(new RepoImportItemViewModel
|
items.Add(new RepoImportItemViewModel
|
||||||
{
|
{
|
||||||
Name = c.Name,
|
Name = c.Name,
|
||||||
FullPath = c.FullPath,
|
FullPath = c.FullPath,
|
||||||
AlreadyAdded = existingDirs.Contains(c.FullPath),
|
AlreadyAdded = alreadyAdded,
|
||||||
IsChecked = true,
|
// New repos start unchecked (no auto-select); already-added rows show ticked + disabled.
|
||||||
|
IsChecked = alreadyAdded,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return items;
|
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)
|
private void OnItemChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.PropertyName == nameof(RepoImportItemViewModel.IsChecked))
|
if (e.PropertyName == nameof(RepoImportItemViewModel.IsChecked))
|
||||||
|
|||||||
@@ -26,30 +26,40 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Add folder row -->
|
<!-- Toolbar: search + folder actions -->
|
||||||
<Border Grid.Row="1" Padding="16,12,16,4">
|
<StackPanel Grid.Row="1" Spacing="8" Margin="16,12,16,6">
|
||||||
<Button Content="Add folder…" Click="AddFolderClicked" HorizontalAlignment="Left"/>
|
<TextBox Text="{Binding SearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||||
</Border>
|
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 -->
|
<!-- 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 ItemsSource="{Binding Repos}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate DataType="vm:RepoImportItemViewModel">
|
<DataTemplate DataType="vm:RepoImportItemViewModel">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,4">
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,1"
|
||||||
<CheckBox Grid.Column="0"
|
IsVisible="{Binding IsVisible}">
|
||||||
|
<CheckBox Grid.Column="0" MinWidth="0"
|
||||||
IsChecked="{Binding IsChecked, Mode=TwoWay}"
|
IsChecked="{Binding IsChecked, Mode=TwoWay}"
|
||||||
IsEnabled="{Binding CanToggle}"
|
IsEnabled="{Binding CanToggle}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
<StackPanel Grid.Column="1" Margin="6,0" VerticalAlignment="Center">
|
<TextBlock Grid.Column="1" Text="{Binding Name}"
|
||||||
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource TextBrush}" FontSize="13"/>
|
Foreground="{DynamicResource TextBrush}" FontSize="12"
|
||||||
<TextBlock Text="{Binding FullPath}" Foreground="{DynamicResource TextFaintBrush}"
|
VerticalAlignment="Center" Margin="4,0,0,0"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding FullPath}"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"
|
||||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"
|
||||||
</StackPanel>
|
VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||||
<TextBlock Grid.Column="2" Text="(already added)"
|
<TextBlock Grid.Column="3" Text="(already added)"
|
||||||
Foreground="{DynamicResource TextFaintBrush}" FontSize="11"
|
Foreground="{DynamicResource TextFaintBrush}" FontSize="10"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center" Margin="8,0,0,0"
|
||||||
IsVisible="{Binding AlreadyAdded}"/>
|
IsVisible="{Binding AlreadyAdded}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|||||||
@@ -25,6 +25,6 @@ public partial class RepoImportModalView : Window
|
|||||||
});
|
});
|
||||||
if (folders.Count == 0) return;
|
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
|
public sealed class RepoImportCandidatesTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildCandidates_NewRepo_IsCheckedAndNotAlreadyAdded()
|
public void BuildCandidates_NewRepo_IsUncheckedAndNotAlreadyAdded()
|
||||||
{
|
{
|
||||||
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
|
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
|
||||||
var current = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var current = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -15,7 +15,7 @@ public sealed class RepoImportCandidatesTests
|
|||||||
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
|
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
|
||||||
|
|
||||||
Assert.Single(items);
|
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.False(items[0].AlreadyAdded);
|
||||||
Assert.Equal("repo-a", items[0].Name);
|
Assert.Equal("repo-a", items[0].Name);
|
||||||
Assert.Equal(@"C:\src\repo-a", items[0].FullPath);
|
Assert.Equal(@"C:\src\repo-a", items[0].FullPath);
|
||||||
|
|||||||
Reference in New Issue
Block a user