30 KiB
Repo Import List Helper Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a helper that scans parent folders for git repos and bulk-creates lists (with WorkingDir pre-filled) for the repos the user ticks.
Architecture: A pure RepoScanner finds git repos under a parent folder. A RepoImportModalViewModel loads existing lists' working dirs, merges scanned candidates into a checklist (marking already-added repos), and creates ListEntity rows for ticked-new repos via ListRepository. RepoImportModalView hosts the checklist and a folder picker. Two entry points open the modal: a Help-menu item (handled by IslandsShellViewModel) and a folder button in the Lists island (handled by ListsIslandViewModel). Each entry point reloads the Lists island after the modal closes.
Tech Stack: .NET 8, Avalonia 12, CommunityToolkit.Mvvm source generators, EF Core (SQLite), xUnit.
File Structure
Create:
src/ClaudeDo.Ui/Services/RepoScanner.cs— pure filesystem scan;RepoCandidaterecord +RepoScanner.Scan.src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs— one checklist row.src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs— modal VM (load, merge, create) + staticBuildCandidates.src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml(+.axaml.cs) — modal window + folder picker.tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs— scanner unit tests.tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs— merge/dedupe/already-added unit tests.
Modify:
src/ClaudeDo.App/Program.cs— registerRepoImportModalViewModel(transient) + aFunc<RepoImportModalViewModel>; pass the Func intoIslandsShellViewModel.src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs—ShowRepoImportModalFunc +OpenRepoImportCommand.src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml— folder button beside+ New list.src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs— wireShowRepoImportModal.src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs—ShowRepoImportModalFunc +OpenRepoImportCommand; injectFunc<RepoImportModalViewModel>.src/ClaudeDo.Ui/Views/MainWindow.axaml— Help-menu itemAdd repos as lists….src/ClaudeDo.Ui/Views/MainWindow.axaml.cs— wireShowRepoImportModal.src/ClaudeDo.Ui/CLAUDE.md— document the new modal + entry points.
Task 1: RepoScanner
Files:
-
Create:
src/ClaudeDo.Ui/Services/RepoScanner.cs -
Test:
tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs -
Step 1: Write the failing tests
Create tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs:
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.Tests;
public sealed class RepoScannerTests : IDisposable
{
private readonly string _root =
Path.Combine(Path.GetTempPath(), "repo-scan-" + Guid.NewGuid().ToString("N"));
public RepoScannerTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch { }
}
private string MakeDir(string name)
{
var p = Path.Combine(_root, name);
Directory.CreateDirectory(p);
return p;
}
[Fact]
public void Scan_ReturnsSubfoldersWithGitDirectory()
{
var repo = MakeDir("repo-a");
Directory.CreateDirectory(Path.Combine(repo, ".git"));
var result = RepoScanner.Scan(_root);
Assert.Single(result);
Assert.Equal("repo-a", result[0].Name);
Assert.Equal(repo, result[0].FullPath);
}
[Fact]
public void Scan_TreatsDotGitFileAsRepo()
{
var repo = MakeDir("worktree-repo");
File.WriteAllText(Path.Combine(repo, ".git"), "gitdir: ../somewhere");
var result = RepoScanner.Scan(_root);
Assert.Single(result);
Assert.Equal("worktree-repo", result[0].Name);
}
[Fact]
public void Scan_IgnoresPlainFolders()
{
MakeDir("not-a-repo");
var result = RepoScanner.Scan(_root);
Assert.Empty(result);
}
[Fact]
public void Scan_IsNotRecursive()
{
var nested = MakeDir(Path.Combine("outer", "inner"));
Directory.CreateDirectory(Path.Combine(nested, ".git"));
// outer itself has no .git
var result = RepoScanner.Scan(_root);
Assert.Empty(result);
}
[Fact]
public void Scan_ReturnsEmptyForMissingFolder()
{
var result = RepoScanner.Scan(Path.Combine(_root, "does-not-exist"));
Assert.Empty(result);
}
}
- Step 2: Run tests to verify they fail
Run: dotnet test tests/ClaudeDo.Ui.Tests --filter RepoScannerTests
Expected: FAIL — RepoScanner / RepoCandidate do not exist (compile error).
- Step 3: Implement RepoScanner
Create src/ClaudeDo.Ui/Services/RepoScanner.cs:
namespace ClaudeDo.Ui.Services;
public sealed record RepoCandidate(string Name, string FullPath);
public static class RepoScanner
{
public static IReadOnlyList<RepoCandidate> Scan(string parentFolder)
{
if (string.IsNullOrWhiteSpace(parentFolder) || !Directory.Exists(parentFolder))
return Array.Empty<RepoCandidate>();
var result = new List<RepoCandidate>();
IEnumerable<string> subdirs;
try { subdirs = Directory.EnumerateDirectories(parentFolder); }
catch { return Array.Empty<RepoCandidate>(); }
foreach (var dir in subdirs)
{
var gitPath = Path.Combine(dir, ".git");
if (Directory.Exists(gitPath) || File.Exists(gitPath))
result.Add(new RepoCandidate(Path.GetFileName(dir), dir));
}
return result;
}
}
- Step 4: Run tests to verify they pass
Run: dotnet test tests/ClaudeDo.Ui.Tests --filter RepoScannerTests
Expected: PASS (5 tests).
- Step 5: Commit
git add src/ClaudeDo.Ui/Services/RepoScanner.cs tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs
git commit -m "feat(ui): add RepoScanner for git repo discovery"
Task 2: RepoImportItemViewModel
Files:
- Create:
src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs
No dedicated test (trivial display VM; covered indirectly by Task 3).
- Step 1: Implement the item VM
Create src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs:
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class RepoImportItemViewModel : ViewModelBase
{
public string Name { get; init; } = "";
public string FullPath { get; init; } = "";
// True when a list already points at this path. Such rows are shown ticked + disabled.
public bool AlreadyAdded { get; init; }
public bool CanToggle => !AlreadyAdded;
[ObservableProperty] private bool _isChecked;
}
- Step 2: Build to verify it compiles
Run: dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
Expected: Build succeeded.
- Step 3: Commit
git add src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs
git commit -m "feat(ui): add RepoImportItemViewModel"
Task 3: RepoImportModalViewModel
Files:
- Create:
src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs - Test:
tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs
The pure BuildCandidates static method is the tested seam (dedupe + already-added marking). LoadAsync/CreateAsync touch the DB and are verified manually.
- Step 1: Write the failing tests
Create tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs:
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<string>(StringComparer.OrdinalIgnoreCase);
var existing = new HashSet<string>(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<string>(StringComparer.OrdinalIgnoreCase);
var existing = new HashSet<string>(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<string>(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase);
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
Assert.Empty(items);
}
}
- Step 2: Run tests to verify they fail
Run: dotnet test tests/ClaudeDo.Ui.Tests --filter RepoImportCandidatesTests
Expected: FAIL — RepoImportModalViewModel does not exist (compile error).
- Step 3: Implement the modal VM
Create src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs:
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<ClaudeDoDbContext> _dbFactory;
private readonly HashSet<string> _existingDirs = new(StringComparer.OrdinalIgnoreCase);
public ObservableCollection<RepoImportItemViewModel> 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<ClaudeDoDbContext> 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<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))
{
item.PropertyChanged += OnItemChanged;
Repos.Add(item);
current.Add(item.FullPath);
}
}
NotifyCreateState();
}
public static List<RepoImportItemViewModel> BuildCandidates(
IEnumerable<RepoCandidate> found,
IReadOnlySet<string> currentPaths,
IReadOnlySet<string> existingDirs)
{
var items = new List<RepoImportItemViewModel>();
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();
}
- Step 4: Run tests to verify they pass
Run: dotnet test tests/ClaudeDo.Ui.Tests --filter RepoImportCandidatesTests
Expected: PASS (3 tests).
- Step 5: Commit
git add src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs
git commit -m "feat(ui): add RepoImportModalViewModel with candidate merge logic"
Task 4: RepoImportModalView
Files:
- Create:
src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml - Create:
src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs
Modeled on AboutModalView.axaml (header/body/footer) and ListSettingsModalView.axaml.cs (folder picker).
- Step 1: Create the view XAML
Create src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml:
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.RepoImportModalView"
x:DataType="vm:RepoImportModalViewModel"
Title="Add repos as lists"
Width="560" Height="480"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
</Window.KeyBindings>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1">
<Grid RowDefinitions="36,Auto,*,52">
<!-- Header -->
<Border Grid.Row="0" Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="ADD REPOS AS LISTS" FontFamily="{DynamicResource MonoFont}" FontSize="11"
LetterSpacing="1.4" Foreground="{DynamicResource TextBrush}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn" Content="✕" FontSize="12"
Command="{Binding CancelCommand}" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Add folder row -->
<Border Grid.Row="1" Padding="16,12,16,4">
<Button Content="Add folder…" Click="AddFolderClicked" HorizontalAlignment="Left"/>
</Border>
<!-- Repo checklist -->
<ScrollViewer Grid.Row="2" Padding="16,4,16,8">
<ItemsControl ItemsSource="{Binding Repos}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:RepoImportItemViewModel">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,4">
<CheckBox Grid.Column="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"
IsVisible="{Binding AlreadyAdded}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Footer -->
<Border Grid.Row="3" Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right"
VerticalAlignment="Center" Margin="16,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="{Binding CreateButtonText}" Command="{Binding CreateCommand}"
IsEnabled="{Binding CanCreate}" MinWidth="120" Classes="accent"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Window>
- Step 2: Create the code-behind with folder picker
Create src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs:
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class RepoImportModalView : Window
{
public RepoImportModalView()
{
InitializeComponent();
}
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
private async void AddFolderClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not RepoImportModalViewModel vm) return;
var top = TopLevel.GetTopLevel(this);
if (top is null) return;
var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Choose folders containing repos",
AllowMultiple = true,
});
if (folders.Count == 0) return;
vm.AddFolders(folders.Select(f => f.Path.LocalPath));
}
}
- Step 3: Build to verify it compiles
Run: dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
Expected: Build succeeded. (TitleBar_PointerPressed is unused for now but kept for parity with other modals; if the build warns as error, leave it — other modals keep the same handler.)
- Step 4: Commit
git add src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs
git commit -m "feat(ui): add RepoImportModalView"
Task 5: DI registration
Files:
-
Modify:
src/ClaudeDo.App/Program.cs:106(afterListSettingsModalViewModelregistration) -
Step 1: Register the modal VM and its factory
In src/ClaudeDo.App/Program.cs, after the line sc.AddTransient<ListSettingsModalViewModel>(); add:
sc.AddTransient<RepoImportModalViewModel>();
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
(RepoImportModalViewModel is in namespace ClaudeDo.Ui.ViewModels.Modals, already imported in Program.cs via the existing modal VM usings — verify the using is present; if not, add using ClaudeDo.Ui.ViewModels.Modals;.)
- Step 2: Build to verify it compiles
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
Expected: Build succeeded.
- Step 3: Commit
git add src/ClaudeDo.App/Program.cs
git commit -m "chore(di): register RepoImportModalViewModel"
Task 6: Lists island entry point
Files:
-
Modify:
src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs -
Modify:
src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml -
Modify:
src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs -
Step 1: Add Func + command to the VM
In src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs, next to the existing ShowListSettingsModal property (around line 30), add:
public Func<RepoImportModalViewModel, System.Threading.Tasks.Task>? ShowRepoImportModal { get; set; }
Then add a command (place it near CreateListAsync, e.g. after the OpenWorktreesOverviewAsync command around line 71):
[RelayCommand]
private async System.Threading.Tasks.Task OpenRepoImportAsync()
{
if (ShowRepoImportModal is null || _services is null) return;
var vm = _services.GetRequiredService<RepoImportModalViewModel>();
await vm.LoadAsync();
await ShowRepoImportModal(vm);
await LoadAsync();
}
(RepoImportModalViewModel is in ClaudeDo.Ui.ViewModels.Modals, already imported at the top of this file.)
- Step 2: Add the folder button in XAML
In src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml, replace the existing + New list button block (lines 171-183) with a row that holds both the new-list button and a folder-scan button:
<!-- New list + import row -->
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="new-list-btn"
Command="{Binding CreateListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Data="{StaticResource Icon.Plus}"
Width="13" Height="13"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="New list" FontSize="12"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Grid.Column="1" Classes="icon-btn" Margin="6,0,0,0"
Command="{Binding OpenRepoImportCommand}"
ToolTip.Tip="Add repos as lists">
<PathIcon Data="{StaticResource Icon.Folder}"
Width="14" Height="14"
Foreground="{DynamicResource TextMuteBrush}"/>
</Button>
</Grid>
- Step 3: Wire the Func in the code-behind
In src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs, inside the DataContextChanged handler (after the vm.ShowWorktreesOverviewModal = ... assignment, before the closing brace of the if block around line 66), add:
vm.ShowRepoImportModal = async modal =>
{
var window = new RepoImportModalView { DataContext = modal };
modal.CloseAction = () => window.Close();
var top = TopLevel.GetTopLevel(this) as Window;
if (top is null) window.Show();
else await window.ShowDialog(top);
};
(RepoImportModalView is in ClaudeDo.Ui.Views.Modals, already imported in this file.)
- Step 4: Build to verify it compiles
Run: dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
Expected: Build succeeded.
- Step 5: Commit
git add src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs
git commit -m "feat(ui): add repo import button to Lists island"
Task 7: Help-menu entry point
Files:
-
Modify:
src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs -
Modify:
src/ClaudeDo.App/Program.cs(pass the Func into the shell VM) -
Modify:
src/ClaudeDo.Ui/Views/MainWindow.axaml -
Modify:
src/ClaudeDo.Ui/Views/MainWindow.axaml.cs -
Step 1: Add Func, factory field, and command to the shell VM
In src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs:
(a) Near the ShowAboutModal property (line 44), add:
public Func<RepoImportModalViewModel, Task>? ShowRepoImportModal { get; set; }
(b) Add a backing field for the factory next to _worktreesOverviewVmFactory (declared as a private readonly field elsewhere in the class). Add:
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
(c) Add a parameter to the public constructor (line 162-171) — append after mergeVmFactory:
Func<MergeModalViewModel> mergeVmFactory,
Func<RepoImportModalViewModel> repoImportVmFactory)
and in the constructor body assign it (next to _mergeVmFactory = mergeVmFactory;):
_repoImportVmFactory = repoImportVmFactory;
(d) Add the command near OpenAbout (line 256):
[RelayCommand]
private async Task OpenRepoImport()
{
if (ShowRepoImportModal is null || _repoImportVmFactory is null) return;
var vm = _repoImportVmFactory();
await vm.LoadAsync();
await ShowRepoImportModal(vm);
if (Lists is not null) await Lists.LoadAsync();
}
(RepoImportModalViewModel is in ClaudeDo.Ui.ViewModels.Modals, already imported in this file.)
- Step 2: Pass the Func into the shell VM in DI
IslandsShellViewModel is registered with sc.AddSingleton<IslandsShellViewModel>(); (Program.cs:123), which resolves constructor params from the container. Since Task 5 registered Func<RepoImportModalViewModel>, no change to the registration call is required — the new constructor parameter resolves automatically. Verify by building in Step 5.
- Step 3: Add the Help-menu item
In src/ClaudeDo.Ui/Views/MainWindow.axaml, inside the Help MenuItem (after the About… item at line 74), add:
<MenuItem Header="Add repos as lists…" Command="{Binding OpenRepoImportCommand}"/>
- Step 4: Wire the Func in MainWindow code-behind
In src/ClaudeDo.Ui/Views/MainWindow.axaml.cs, inside OnDataContextChanged (after the vm.ShowWorktreesOverviewModal = ... block, before the closing brace of the if at line 65), add:
vm.ShowRepoImportModal = async (modal) =>
{
var dlg = new RepoImportModalView { DataContext = modal };
modal.CloseAction = () => dlg.Close();
await dlg.ShowDialog(this);
};
(RepoImportModalView is in ClaudeDo.Ui.Views.Modals, already imported in this file.)
- Step 5: Build to verify it compiles
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
Expected: Build succeeded (this also builds the Ui project).
- Step 6: Commit
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs src/ClaudeDo.Ui/Views/MainWindow.axaml src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
git commit -m "feat(ui): add 'Add repos as lists' Help-menu entry point"
Task 8: Manual verification + docs
Files:
-
Modify:
src/ClaudeDo.Ui/CLAUDE.md -
Step 1: Run the full Ui test suite
Run: dotnet test tests/ClaudeDo.Ui.Tests
Expected: PASS (all tests, including the new RepoScannerTests and RepoImportCandidatesTests).
- Step 2: Manual smoke test
Launch the app (dotnet run --project src/ClaudeDo.App/ClaudeDo.App.csproj). Verify:
- Lists island shows a folder button next to
+ New list; clicking it opens the modal. - Help menu shows
Add repos as lists…; clicking it opens the same modal. Add folder…→ pick a parent folder containing git repos → repos appear as ticked rows; non-repo subfolders are absent.- A repo that already has a list appears ticked, disabled, with
(already added). - The confirm button reads
Create N list(s)and is disabled when N is 0. - Confirming creates the lists; they appear in the Lists island immediately after the modal closes.
Note: if you cannot run the GUI in this environment, state that explicitly rather than claiming the UI works.
- Step 3: Update CLAUDE.md
In src/ClaudeDo.Ui/CLAUDE.md, under the ## Views section, add a bullet:
- **RepoImportModalView** — bulk-creates lists from git repos discovered under chosen parent folders. Opened via the folder button beside "New list" in the Lists island, or the "Add repos as lists…" Help-menu item. Repos already wired to a list show as disabled/"(already added)".
- Step 4: Commit
git add src/ClaudeDo.Ui/CLAUDE.md
git commit -m "docs(ui): document RepoImportModalView"
Self-Review Notes
- Spec coverage: Entry points (Help menu — Task 7; Lists island button — Task 6);
RepoScannernon-recursive.gitdir/file detection (Task 1);RepoImportModalViewModelload existing dirs + merge + create (Task 3); already-added disabled rows +(already added)label (Tasks 2/3/4); combined multi-folder checklist with path dedupe (Task 3AddFolders); defaults Name/WorkingDir/DefaultCommitType (Task 3CreateAsync); reload Lists island after close (Tasks 6/7); DI registration (Task 5); tests for scanner + merge logic (Tasks 1/3). All spec sections map to a task. - Type consistency:
RepoCandidate(Name, FullPath),RepoScanner.Scan,RepoImportItemViewModel{Name,FullPath,AlreadyAdded,CanToggle,IsChecked},RepoImportModalViewModel{Repos,CreateCount,CanCreate,CreateButtonText,LoadAsync,AddFolders,BuildCandidates,CreateCommand,CancelCommand,ShowRepoImportModal,CloseAction}used consistently across tasks. - YAGNI: No recursive scan, no inline rename, no per-list model/prompt/agent during import — all explicitly out of scope.