From 75561930d7d88b3dd9926532227bb871b84dafb9 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 6 Aug 2026 11:18:00 +0200 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFchore(claude-do):=20RepoImportFolders?= =?UTF-8?q?=20auf=20den=20WorkerHub-Pfad=20umziehen=20(Ui=20schrei?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Befund (am Code verifiziert 2026-08-06) `AppSettingsEntity.RepoImportFolders` (`src/ClaudeDo.Data/Models/AppSettingsEntity.cs:26`, JSON-Array als TEXT) ist **nicht** Teil des `AppSettingsDto` in `src/ClaudeDo.Worker/Hub/WorkerHub.cs:32-49` und wird weder von `GetAppSettings` (~:375-393) noch von `UpdateAppSettings` (~:396-424) transportiert. Stattdessen liest und schreibt die Ui das Feld dire ClaudeDo-Task: 64fbe15d-ae7e-4d81-b8c1-045baa7e3c87 --- src/ClaudeDo.Localization/locales/de.json | 3 +- src/ClaudeDo.Localization/locales/en.json | 3 +- .../Services/Interfaces/IWorkerClient.cs | 5 + src/ClaudeDo.Ui/Services/WorkerClient.cs | 6 + .../Islands/ListsIslandViewModel.cs | 4 + .../ViewModels/IslandsShellViewModel.cs | 1 + .../Modals/RepoImportModalViewModel.cs | 28 ++++- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 12 ++ tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 2 + .../RepoImportModalViewModelTests.cs | 114 ++++++++++++++++++ .../Hub/RepoImportFoldersHubTests.cs | 73 +++++++++++ .../UiVm/TasksIslandViewModelPlanningTests.cs | 2 + 12 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/RepoImportModalViewModelTests.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Hub/RepoImportFoldersHubTests.cs diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 20ad9248..7ec65160 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -676,7 +676,8 @@ "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." }, "listSettings": { "untitled": "Unbenannt" }, "detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt." }, - "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" } + "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }, + "repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" } }, "usage": { "pill": { diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 5cc91a0d..969c04e9 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -676,7 +676,8 @@ "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." }, "listSettings": { "untitled": "Untitled" }, "detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done." }, - "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" } + "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }, + "repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" } }, "usage": { "pill": { diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index c28b4065..a74a9b8b 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -59,6 +59,11 @@ public interface IWorkerClient : INotifyPropertyChanged Task RestoreDefaultAgentsAsync(); Task GetListConfigAsync(string listId); Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto); + /// Repo-import folders remembered for the "Add repos as lists" dialog. Throws on + /// a failed hub call — the caller surfaces the failure rather than showing an empty list. + Task> GetRepoImportFoldersAsync(); + /// Persists the repo-import folder list. Throws on a failed hub call. + Task SetRepoImportFoldersAsync(List folders); Task> GetSessionSkillsAsync(); Task> InstallSessionSkillAsync(string url); Task UpdateSessionSkillAsync(string sourceUrl); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index ab3e7393..553551d1 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -444,6 +444,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC await _hub.InvokeAsync("UpdateTaskAgentSettings", dto); } + public async Task> GetRepoImportFoldersAsync() + => await _hub.InvokeAsync>("GetRepoImportFolders"); + + public Task SetRepoImportFoldersAsync(List folders) + => _hub.InvokeAsync("SetRepoImportFolders", folders); + public async Task> GetSessionSkillsAsync() => await TryInvokeAsync>("GetSessionSkills") ?? []; diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index 15c643f9..3d43770e 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -30,6 +30,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable public event EventHandler? FocusSearchRequested; public void RequestFocusSearch() => FocusSearchRequested?.Invoke(this, EventArgs.Empty); + // mirrors TasksIslandViewModel.ErrorReported — surfaces modal-owned failures in the footer strip. + public event Action? ErrorReported; + public IDialogService? Dialogs { get; set; } [RelayCommand] @@ -58,6 +61,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable { if (Dialogs is null || _services is null) return; var vm = _services.GetRequiredService(); + vm.ErrorReported += msg => ErrorReported?.Invoke(msg); await vm.LoadAsync(); await Dialogs.ShowRepoImportAsync(vm); await LoadAsync(); diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 651d2b63..a64631e4 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -237,6 +237,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable Tasks.NotesRequested += () => Details.ShowNotes(); Tasks.PrepRequested += () => Details.ShowPrep(); Tasks.ErrorReported += FlashFooterError; + Lists.ErrorReported += FlashFooterError; Tasks.OpenConPtySessionRequested += taskId => { OpenMissionControl(); diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs index e91c4a1d..1ee115d8 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -28,12 +29,14 @@ public sealed partial class RepoImportItemViewModel : ViewModelBase public sealed partial class RepoImportModalViewModel : ViewModelBase { private readonly IDbContextFactory _dbFactory; + private readonly IWorkerClient _workerClient; private readonly HashSet _existingDirs = new(StringComparer.OrdinalIgnoreCase); private readonly List _folders = new(); public ObservableCollection Repos { get; } = new(); public Action? CloseAction { get; set; } + public event Action? ErrorReported; [ObservableProperty] private string _searchText = ""; @@ -42,9 +45,10 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase public string CreateButtonText => $"Create {CreateCount} list(s)"; public bool HasFolders => _folders.Count > 0; - public RepoImportModalViewModel(IDbContextFactory dbFactory) + public RepoImportModalViewModel(IDbContextFactory dbFactory, IWorkerClient workerClient) { _dbFactory = dbFactory; + _workerClient = workerClient; } public async Task LoadAsync(CancellationToken ct = default) @@ -61,9 +65,15 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase _existingDirs.Add(l.WorkingDir!); } - var settings = new AppSettingsRepository(ctx); - foreach (var f in await settings.GetRepoImportFoldersAsync(ct)) - AddFolderToSet(f); + try + { + foreach (var f in await _workerClient.GetRepoImportFoldersAsync()) + AddFolderToSet(f); + } + catch (Exception ex) + { + ErrorReported?.Invoke(Loc.T("vm.repoImport.loadFailed", ex.Message)); + } ScanAndAdd(_folders); OnPropertyChanged(nameof(HasFolders)); @@ -155,8 +165,14 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase private async Task SaveFoldersAsync() { - await using var ctx = await _dbFactory.CreateDbContextAsync(); - await new AppSettingsRepository(ctx).SetRepoImportFoldersAsync(_folders); + try + { + await _workerClient.SetRepoImportFoldersAsync(_folders); + } + catch (Exception ex) + { + ErrorReported?.Invoke(Loc.T("vm.repoImport.saveFailed", ex.Message)); + } } private void ClearRepos() diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 29edcb35..15959702 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -424,6 +424,18 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub }); } + public async Task> GetRepoImportFolders() + { + using var ctx = _dbFactory.CreateDbContext(); + return await new AppSettingsRepository(ctx).GetRepoImportFoldersAsync(Context.ConnectionAborted); + } + + public async Task SetRepoImportFolders(List folders) + { + using var ctx = _dbFactory.CreateDbContext(); + await new AppSettingsRepository(ctx).SetRepoImportFoldersAsync(folders ?? new List(), Context.ConnectionAborted); + } + public async Task> GetSessionSkills() { var rows = await _skillRegistry.ListAsync(Context.ConnectionAborted); diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 18365b68..e00957b3 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -79,6 +79,8 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task RefreshAgentsAsync() => Task.CompletedTask; public virtual Task RestoreDefaultAgentsAsync() => Task.FromResult(null); public virtual Task GetListConfigAsync(string listId) => Task.FromResult(null); + public virtual Task> GetRepoImportFoldersAsync() => Task.FromResult(new List()); + public virtual Task SetRepoImportFoldersAsync(List folders) => Task.CompletedTask; public virtual Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; public virtual Task> GetSessionSkillsAsync() => Task.FromResult(new List()); public virtual Task> InstallSessionSkillAsync(string url) => Task.FromResult(new List()); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/RepoImportModalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/RepoImportModalViewModelTests.cs new file mode 100644 index 00000000..49fbf33f --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/RepoImportModalViewModelTests.cs @@ -0,0 +1,114 @@ +using ClaudeDo.Data; +using ClaudeDo.Ui.ViewModels.Modals; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public sealed class RepoImportModalViewModelTests : IDisposable +{ + private readonly string _dbPath; + + public RepoImportModalViewModelTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_repoimport_{Guid.NewGuid():N}.db"); + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + try { File.Delete(_dbPath); } catch { } + try { File.Delete(_dbPath + "-wal"); } catch { } + try { File.Delete(_dbPath + "-shm"); } catch { } + } + + private ClaudeDoDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + private sealed class TestDbFactory : IDbContextFactory + { + private readonly Func _create; + public TestDbFactory(Func create) => _create = create; + public ClaudeDoDbContext CreateDbContext() => _create(); + } + + private sealed class FakeWorkerClient : StubWorkerClient + { + public List Folders = new(); + public Exception? ThrowOnGet; + public Exception? ThrowOnSet; + public List? LastSaved; + + public override Task> GetRepoImportFoldersAsync() + => ThrowOnGet is not null ? throw ThrowOnGet : Task.FromResult(Folders); + + public override Task SetRepoImportFoldersAsync(List folders) + { + LastSaved = folders; + if (ThrowOnSet is not null) throw ThrowOnSet; + return Task.CompletedTask; + } + } + + [Fact] + public async Task LoadAsync_Success_PopulatesFoldersWithoutReportingError() + { + var worker = new FakeWorkerClient { Folders = new List { @"C:\src" } }; + var vm = new RepoImportModalViewModel(new TestDbFactory(NewContext), worker); + string? reportedError = null; + vm.ErrorReported += msg => reportedError = msg; + + await vm.LoadAsync(); + + Assert.True(vm.HasFolders); + Assert.Null(reportedError); + } + + [Fact] + public async Task LoadAsync_HubFailure_ReportsErrorInsteadOfSilentEmptyList() + { + var worker = new FakeWorkerClient { ThrowOnGet = new InvalidOperationException("worker offline") }; + var vm = new RepoImportModalViewModel(new TestDbFactory(NewContext), worker); + string? reportedError = null; + vm.ErrorReported += msg => reportedError = msg; + + await vm.LoadAsync(); + + Assert.False(vm.HasFolders); + Assert.NotNull(reportedError); + Assert.Contains("worker offline", reportedError); + } + + [Fact] + public async Task AddFoldersAsync_SavesThroughWorkerClient() + { + var worker = new FakeWorkerClient(); + var vm = new RepoImportModalViewModel(new TestDbFactory(NewContext), worker); + await vm.LoadAsync(); + + await vm.AddFoldersAsync(new[] { @"C:\src\new-folder" }); + + Assert.NotNull(worker.LastSaved); + Assert.Contains(@"C:\src\new-folder", worker.LastSaved!); + } + + [Fact] + public async Task AddFoldersAsync_SaveFailure_ReportsError() + { + var worker = new FakeWorkerClient { ThrowOnSet = new InvalidOperationException("write failed") }; + var vm = new RepoImportModalViewModel(new TestDbFactory(NewContext), worker); + await vm.LoadAsync(); + string? reportedError = null; + vm.ErrorReported += msg => reportedError = msg; + + await vm.AddFoldersAsync(new[] { @"C:\src\new-folder" }); + + Assert.NotNull(reportedError); + Assert.Contains("write failed", reportedError); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Hub/RepoImportFoldersHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/RepoImportFoldersHubTests.cs new file mode 100644 index 00000000..9daf46c1 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Hub/RepoImportFoldersHubTests.cs @@ -0,0 +1,73 @@ +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tests.Infrastructure; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Hub; + +public sealed class RepoImportFoldersHubTests : IDisposable +{ + private readonly DbFixture _db = new(); + + public void Dispose() => _db.Dispose(); + + private WorkerHub CreateHub() + { + var broadcaster = new HubBroadcaster(new CapturingHubContext()); + var hub = new WorkerHub( + null!, null!, null!, null!, broadcaster, _db.CreateFactory(), + null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), + new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); + hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); + hub.Context = new FakeHubCallerContext(); + return hub; + } + + [Fact] + public async Task GetRepoImportFolders_NoRow_ReturnsEmptyList() + { + var hub = CreateHub(); + + var folders = await hub.GetRepoImportFolders(); + + Assert.Empty(folders); + } + + [Fact] + public async Task SetRepoImportFolders_then_GetRepoImportFolders_RoundTrips() + { + var hub = CreateHub(); + + await hub.SetRepoImportFolders(new List { @"C:\src\repo-a", @"C:\src\repo-b" }); + var folders = await hub.GetRepoImportFolders(); + + Assert.Equal(new List { @"C:\src\repo-a", @"C:\src\repo-b" }, folders); + } + + [Fact] + public async Task SetRepoImportFolders_PersistsSameJsonShapeAsRepository() + { + var hub = CreateHub(); + + await hub.SetRepoImportFolders(new List { @"C:\src\repo-a" }); + + using var ctx = _db.CreateContext(); + var viaRepository = await new AppSettingsRepository(ctx).GetRepoImportFoldersAsync(); + Assert.Equal(new List { @"C:\src\repo-a" }, viaRepository); + } + + [Fact] + public async Task SetRepoImportFolders_DoesNotClearOtherAppSettings() + { + var hub = CreateHub(); + var before = await hub.GetAppSettings(); + + await hub.SetRepoImportFolders(new List { @"C:\src\repo-a" }); + var after = await hub.GetAppSettings(); + + Assert.Equal(before.DefaultModel, after.DefaultModel); + Assert.Equal(before.DefaultMaxTurns, after.DefaultMaxTurns); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index 3538e11b..818059fa 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -51,6 +51,8 @@ sealed class FakeWorkerClient : IWorkerClient public Task RefreshAgentsAsync() => Task.CompletedTask; public Task RestoreDefaultAgentsAsync() => Task.FromResult(null); public Task GetListConfigAsync(string listId) => Task.FromResult(null); + public Task> GetRepoImportFoldersAsync() => Task.FromResult(new List()); + public Task SetRepoImportFoldersAsync(List folders) => Task.CompletedTask; public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; public Task> GetSessionSkillsAsync() => Task.FromResult(new List()); public Task> InstallSessionSkillAsync(string url) => Task.FromResult(new List());