Merge feat/repo-import-list-helper: add repos-as-lists import helper
This commit is contained in:
@@ -104,6 +104,8 @@ sealed class Program
|
||||
sc.AddTransient<MergeModalViewModel>();
|
||||
sc.AddTransient<Func<MergeModalViewModel>>(sp => () => sp.GetRequiredService<MergeModalViewModel>());
|
||||
sc.AddTransient<ListSettingsModalViewModel>();
|
||||
sc.AddTransient<RepoImportModalViewModel>();
|
||||
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
|
||||
|
||||
// Islands shell VMs
|
||||
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
||||
|
||||
@@ -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`).
|
||||
|
||||
26
src/ClaudeDo.Ui/Services/RepoScanner.cs
Normal file
26
src/ClaudeDo.Ui/Services/RepoScanner.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
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 (Exception e) when (e is IOException or UnauthorizedAccessException)
|
||||
{ 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;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; }
|
||||
public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; }
|
||||
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
|
||||
public Func<RepoImportModalViewModel, System.Threading.Tasks.Task>? 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<RepoImportModalViewModel>();
|
||||
await vm.LoadAsync();
|
||||
await ShowRepoImportModal(vm);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private bool _worktreesOverviewOpen;
|
||||
|
||||
[RelayCommand]
|
||||
|
||||
@@ -34,6 +34,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
|
||||
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
|
||||
private readonly Func<MergeModalViewModel> _mergeVmFactory = () => null!;
|
||||
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
|
||||
|
||||
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
|
||||
|
||||
@@ -43,6 +44,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
// Set by MainWindow to open the About dialog.
|
||||
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
|
||||
|
||||
// Set by MainWindow to open the repo-import dialog.
|
||||
public Func<RepoImportModalViewModel, Task>? ShowRepoImportModal { get; set; }
|
||||
|
||||
// Set by MainWindow to open the global worktrees overview dialog.
|
||||
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
|
||||
|
||||
@@ -168,7 +172,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
InstallerLocator installerLocator,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
|
||||
Func<MergeModalViewModel> mergeVmFactory)
|
||||
Func<MergeModalViewModel> mergeVmFactory,
|
||||
Func<RepoImportModalViewModel> 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]
|
||||
|
||||
15
src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs
Normal file
15
src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs
Normal file
@@ -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;
|
||||
}
|
||||
121
src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs
Normal file
121
src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs
Normal file
@@ -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<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();
|
||||
}
|
||||
@@ -168,19 +168,28 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- + New list button -->
|
||||
<Button Classes="new-list-btn" Margin="0,4,0,0"
|
||||
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>
|
||||
<!-- 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>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
<MenuItem Header="Worktrees…"
|
||||
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
|
||||
<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>
|
||||
<MenuItem Header="Add repos as lists…" Command="{Binding OpenRepoImportCommand}"/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</StackPanel>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
73
src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml
Normal file
73
src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml
Normal file
@@ -0,0 +1,73 @@
|
||||
<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="primary"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
30
src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs
Normal file
30
src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Avalonia.Controls;
|
||||
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 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));
|
||||
}
|
||||
}
|
||||
49
tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs
Normal file
49
tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
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);
|
||||
Assert.Equal(@"C:\src\repo-a", items[0].FullPath);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
78
tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs
Normal file
78
tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user