From 03617ee3cdec03cc5ff9a5f57353265d3d4c0a23 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 29 May 2026 15:34:32 +0200 Subject: [PATCH 01/10] feat(ui): add RepoScanner for git repo discovery Co-Authored-By: Claude Sonnet 4.6 --- src/ClaudeDo.Ui/Services/RepoScanner.cs | 25 +++++++ tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs | 78 +++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/ClaudeDo.Ui/Services/RepoScanner.cs create mode 100644 tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs diff --git a/src/ClaudeDo.Ui/Services/RepoScanner.cs b/src/ClaudeDo.Ui/Services/RepoScanner.cs new file mode 100644 index 0000000..bf3a723 --- /dev/null +++ b/src/ClaudeDo.Ui/Services/RepoScanner.cs @@ -0,0 +1,25 @@ +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; + } +} diff --git a/tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs b/tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs new file mode 100644 index 0000000..d3e88aa --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs @@ -0,0 +1,78 @@ +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); + } +} From 4877c11aa28145bea408ab9a454a7d034d75ac25 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 29 May 2026 15:36:12 +0200 Subject: [PATCH 02/10] fix(ui): narrow RepoScanner catch to filesystem exceptions Co-Authored-By: Claude Opus 4.7 --- src/ClaudeDo.Ui/Services/RepoScanner.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ClaudeDo.Ui/Services/RepoScanner.cs b/src/ClaudeDo.Ui/Services/RepoScanner.cs index bf3a723..8ac8785 100644 --- a/src/ClaudeDo.Ui/Services/RepoScanner.cs +++ b/src/ClaudeDo.Ui/Services/RepoScanner.cs @@ -12,7 +12,8 @@ public static class RepoScanner var result = new List(); IEnumerable subdirs; try { subdirs = Directory.EnumerateDirectories(parentFolder); } - catch { return Array.Empty(); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { return Array.Empty(); } foreach (var dir in subdirs) { From 1c689a8472cd7bb1271acd19371f56d72f1471b2 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 29 May 2026 15:37:10 +0200 Subject: [PATCH 03/10] feat(ui): add RepoImportItemViewModel --- .../ViewModels/Modals/RepoImportItemViewModel.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs new file mode 100644 index 0000000..10773a1 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs @@ -0,0 +1,15 @@ +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; +} From 50b1589b23a80c01ed6eccea9b55ec23936179e6 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 29 May 2026 15:39:43 +0200 Subject: [PATCH 04/10] feat(ui): add RepoImportModalViewModel with candidate merge logic --- .../Modals/RepoImportModalViewModel.cs | 121 ++++++++++++++++++ .../RepoImportCandidatesTests.cs | 48 +++++++ 2 files changed, 169 insertions(+) create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs create mode 100644 tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs new file mode 100644 index 0000000..16827dc --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs @@ -0,0 +1,121 @@ +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(); +} diff --git a/tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs b/tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs new file mode 100644 index 0000000..0567c9a --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs @@ -0,0 +1,48 @@ +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); + } +} From 0f41384fa8a8a66d8ca03fd537a539e17f275734 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 29 May 2026 15:42:05 +0200 Subject: [PATCH 05/10] test(ui): assert FullPath in RepoImport candidate test Co-Authored-By: Claude Opus 4.7 --- tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs b/tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs index 0567c9a..86bbb58 100644 --- a/tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs +++ b/tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs @@ -18,6 +18,7 @@ public sealed class RepoImportCandidatesTests Assert.True(items[0].IsChecked); Assert.False(items[0].AlreadyAdded); Assert.Equal("repo-a", items[0].Name); + Assert.Equal(@"C:\src\repo-a", items[0].FullPath); } [Fact] From e4d958dcf3f158bbeb1f434675a019e53afef0bd Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 29 May 2026 15:43:52 +0200 Subject: [PATCH 06/10] feat(ui): add RepoImportModalView --- .../Views/Modals/RepoImportModalView.axaml | 73 +++++++++++++++++++ .../Views/Modals/RepoImportModalView.axaml.cs | 30 ++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml create mode 100644 src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs diff --git a/src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml new file mode 100644 index 0000000..7a31047 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs index 7351071..d14acad 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs @@ -30,6 +30,14 @@ public partial class ListsIslandView : UserControl if (top is null) window.Show(); else await window.ShowDialog(top); }; + 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); + }; vm.ShowWorktreesOverviewModal = async modal => { var top = TopLevel.GetTopLevel(this) as Window; From 9c638e72b1835842ab50e88adfaa914016bd8e64 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 29 May 2026 15:50:52 +0200 Subject: [PATCH 09/10] feat(ui): add 'Add repos as lists' Help-menu entry point Co-Authored-By: Claude Sonnet 4.6 --- .../ViewModels/IslandsShellViewModel.cs | 18 +++++++++++++++++- src/ClaudeDo.Ui/Views/MainWindow.axaml | 1 + src/ClaudeDo.Ui/Views/MainWindow.axaml.cs | 6 ++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 1048584..6516ddb 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -34,6 +34,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase private readonly IDbContextFactory? _dbFactory; private readonly Func _worktreesOverviewVmFactory = () => null!; private readonly Func _mergeVmFactory = () => null!; + private readonly Func? _repoImportVmFactory; public Func ResolveMergeVm => _mergeVmFactory; @@ -43,6 +44,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase // Set by MainWindow to open the About dialog. public Func? ShowAboutModal { get; set; } + // Set by MainWindow to open the repo-import dialog. + public Func? ShowRepoImportModal { get; set; } + // Set by MainWindow to open the global worktrees overview dialog. public Func? ShowWorktreesOverviewModal { get; set; } @@ -168,7 +172,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase InstallerLocator installerLocator, IDbContextFactory dbFactory, Func worktreesOverviewVmFactory, - Func mergeVmFactory) + Func mergeVmFactory, + Func repoImportVmFactory) { Lists = lists; Tasks = tasks; Details = details; Worker = worker; _updateCheck = updateCheck; @@ -176,6 +181,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase _dbFactory = dbFactory; _worktreesOverviewVmFactory = worktreesOverviewVmFactory; _mergeVmFactory = mergeVmFactory; + _repoImportVmFactory = repoImportVmFactory; Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask); Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync(); @@ -260,6 +266,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase if (ShowAboutModal is not null) await ShowAboutModal(vm); } + [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(); + } + private bool _worktreesOverviewOpen; [RelayCommand] diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml b/src/ClaudeDo.Ui/Views/MainWindow.axaml index b572008..629b777 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml @@ -72,6 +72,7 @@ + diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs b/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs index d2697e3..c000c3b 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs @@ -62,6 +62,12 @@ public partial class MainWindow : Window }; await dlg.ShowDialog(this); }; + vm.ShowRepoImportModal = async (modal) => + { + var dlg = new RepoImportModalView { DataContext = modal }; + modal.CloseAction = () => dlg.Close(); + await dlg.ShowDialog(this); + }; } } From e5bce07719effad1fba42bafd88bc6ff87273864 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 29 May 2026 15:52:34 +0200 Subject: [PATCH 10/10] docs(ui): document RepoImportModalView --- src/ClaudeDo.Ui/CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index e4910e6..4713d92 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -18,6 +18,7 @@ MVVM with CommunityToolkit.Mvvm source generators: - **ListEditorView** — Modal dialog for list create/edit - **StatusBarView** — Connection status indicator, active task display - **ListSettingsModalView** — edits list name, working dir, default commit type, and per-list Model/SystemPrompt/AgentPath. Opened via context menu or gear button on a list row. +- **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)". - **DetailsIslandView** — contains an "Agent settings (overrides)" expander with per-task Model/SystemPrompt/AgentPath, showing inherited effective values. Disabled while task is running. All views use compiled bindings (`x:DataType`).