diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 889ac4d..91e2052 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -104,6 +104,8 @@ sealed class Program sc.AddTransient(); sc.AddTransient>(sp => () => sp.GetRequiredService()); sc.AddTransient(); + sc.AddTransient(); + sc.AddTransient>(sp => () => sp.GetRequiredService()); // Islands shell VMs sc.AddSingleton(sp => 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`). diff --git a/src/ClaudeDo.Ui/Services/RepoScanner.cs b/src/ClaudeDo.Ui/Services/RepoScanner.cs new file mode 100644 index 0000000..8ac8785 --- /dev/null +++ b/src/ClaudeDo.Ui/Services/RepoScanner.cs @@ -0,0 +1,26 @@ +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 (Exception e) when (e is IOException or UnauthorizedAccessException) + { 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/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index b0801a9..77b45ee 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -29,6 +29,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase public Func? ShowSettingsModal { get; set; } public Func? ShowListSettingsModal { get; set; } public Func? ShowWorktreesOverviewModal { get; set; } + public Func? ShowRepoImportModal { get; set; } [RelayCommand] private async Task OpenSettings() @@ -50,6 +51,16 @@ public sealed partial class ListsIslandViewModel : ViewModelBase await RefreshRowAsync(row.Id); } + [RelayCommand] + private async System.Threading.Tasks.Task OpenRepoImportAsync() + { + if (ShowRepoImportModal is null || _services is null) return; + var vm = _services.GetRequiredService(); + await vm.LoadAsync(); + await ShowRepoImportModal(vm); + await LoadAsync(); + } + private bool _worktreesOverviewOpen; [RelayCommand] 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/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; +} 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/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml index 030091d..c681d14 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml @@ -168,19 +168,28 @@ - - + + + + + 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; 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); + }; } } 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 @@ + + + + + + + + + + + +