diff --git a/docs/superpowers/plans/2026-05-29-repo-import-list-helper.md b/docs/superpowers/plans/2026-05-29-repo-import-list-helper.md new file mode 100644 index 0000000..186617d --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-repo-import-list-helper.md @@ -0,0 +1,834 @@ +# 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; `RepoCandidate` record + `RepoScanner.Scan`. +- `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs` — one checklist row. +- `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs` — modal VM (load, merge, create) + static `BuildCandidates`. +- `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` — register `RepoImportModalViewModel` (transient) + a `Func`; pass the Func into `IslandsShellViewModel`. +- `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs` — `ShowRepoImportModal` Func + `OpenRepoImportCommand`. +- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml` — folder button beside `+ New list`. +- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs` — wire `ShowRepoImportModal`. +- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — `ShowRepoImportModal` Func + `OpenRepoImportCommand`; inject `Func`. +- `src/ClaudeDo.Ui/Views/MainWindow.axaml` — Help-menu item `Add repos as lists…`. +- `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` — wire `ShowRepoImportModal`. +- `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`: + +```csharp +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`: + +```csharp +namespace ClaudeDo.Ui.Services; + +public sealed record RepoCandidate(string Name, string FullPath); + +public static class RepoScanner +{ + public static IReadOnlyList Scan(string parentFolder) + { + if (string.IsNullOrWhiteSpace(parentFolder) || !Directory.Exists(parentFolder)) + return Array.Empty(); + + var result = new List(); + IEnumerable subdirs; + try { subdirs = Directory.EnumerateDirectories(parentFolder); } + catch { return Array.Empty(); } + + 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** + +```bash +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`: + +```csharp +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** + +```bash +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`: + +```csharp +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(StringComparer.OrdinalIgnoreCase); + var existing = new HashSet(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(StringComparer.OrdinalIgnoreCase); + var existing = new HashSet(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(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase); + var existing = new HashSet(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`: + +```csharp +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 _dbFactory; + private readonly HashSet _existingDirs = new(StringComparer.OrdinalIgnoreCase); + + public ObservableCollection 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 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 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)) + { + item.PropertyChanged += OnItemChanged; + Repos.Add(item); + current.Add(item.FullPath); + } + } + NotifyCreateState(); + } + + public static List BuildCandidates( + IEnumerable found, + IReadOnlySet currentPaths, + IReadOnlySet existingDirs) + { + var items = new List(); + 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** + +```bash +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`: + +```xml + + + + + + + + + + + + + + +``` + +- [ ] **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: + +```csharp + 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** + +```bash +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: + +```csharp + public Func? 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: + +```csharp + private readonly Func? _repoImportVmFactory; +``` + +(c) Add a parameter to the public constructor (line 162-171) — append after `mergeVmFactory`: + +```csharp + Func mergeVmFactory, + Func repoImportVmFactory) +``` + +and in the constructor body assign it (next to `_mergeVmFactory = mergeVmFactory;`): + +```csharp + _repoImportVmFactory = repoImportVmFactory; +``` + +(d) Add the command near `OpenAbout` (line 256): + +```csharp + [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();` (Program.cs:123), which resolves constructor params from the container. Since Task 5 registered `Func`, 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: + +```xml + +``` + +- [ ] **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: + +```csharp + 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** + +```bash +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: + +```markdown +- **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** + +```bash +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); `RepoScanner` non-recursive `.git` dir/file detection (Task 1); `RepoImportModalViewModel` load 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 3 `AddFolders`); defaults Name/WorkingDir/DefaultCommitType (Task 3 `CreateAsync`); 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.