refactor(ui): unify list-linked-repo predicate into RepoLinkage

ListsIslandViewModel and SettingsModalViewModel each re-implemented "does
this list have a linked WorkingDir" with different whitespace handling.
RepoLinkage.IsLinked/IsLinkedInDb is now the single definition; both
callers derive from it, closing the whitespace-only WorkingDir gap where
the Settings modal disagreed with the ListsIsland banner.
This commit is contained in:
Mika Kuns
2026-08-24 09:28:20 +02:00
parent 29171b104b
commit 9578c95074
4 changed files with 42 additions and 4 deletions
+19
View File
@@ -0,0 +1,19 @@
using System;
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Ui.Services;
// Single definition of "does this list have a linked repo" — a WorkingDir that is set to
// something other than blank/whitespace. ListsIslandViewModel and SettingsModalViewModel each
// answer this over a different scope (UserLists in memory vs. all Lists rows in the DB) and need
// it in different shapes, but both must agree on what "linked" means.
public static class RepoLinkage
{
public static bool IsLinked(string? workingDir) => !string.IsNullOrWhiteSpace(workingDir);
// EF Core can't translate string.IsNullOrWhiteSpace against Sqlite, so the DB-side check is
// spelled out via Trim() instead — same semantics as IsLinked above.
public static readonly Expression<Func<ListEntity, bool>> IsLinkedInDb =
l => l.WorkingDir != null && l.WorkingDir.Trim() != "";
}
@@ -187,12 +187,14 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
[ObservableProperty] private ListNavItemViewModel? _selectedList; [ObservableProperty] private ListNavItemViewModel? _selectedList;
/// <summary>True whenever no User list has a linked <c>WorkingDir</c> — drives the no-repo /// <summary>True whenever no User list has a linked <c>WorkingDir</c> — drives the no-repo
/// banner. Smart/Virtual lists never count. Recomputed after every load and every /// banner. Smart/Virtual lists never count (scope intentionally narrower than
/// list CRUD / list-settings save; never persisted, no dismiss state.</summary> /// SettingsModalViewModel.HasLinkedRepo, which checks all Lists rows in the DB). Recomputed
/// after every load and every list CRUD / list-settings save; never persisted, no dismiss
/// state.</summary>
[ObservableProperty] private bool _hasNoLinkedRepo = true; [ObservableProperty] private bool _hasNoLinkedRepo = true;
private void RecomputeHasNoLinkedRepo() => private void RecomputeHasNoLinkedRepo() =>
HasNoLinkedRepo = UserLists.All(r => string.IsNullOrWhiteSpace(r.WorkingDir)); HasNoLinkedRepo = UserLists.All(r => !RepoLinkage.IsLinked(r.WorkingDir));
public string UserName { get; } = Environment.UserName; public string UserName { get; } = Environment.UserName;
public string MachineName { get; } = Environment.MachineName; public string MachineName { get; } = Environment.MachineName;
@@ -56,6 +56,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
// True once at least one list has a linked repo (WorkingDir). Drives the repo-hint strip on // True once at least one list has a linked repo (WorkingDir). Drives the repo-hint strip on
// Worktrees/Prime Claude/Session Skills/Berichte — nothing is ever hidden, just annotated. // Worktrees/Prime Claude/Session Skills/Berichte — nothing is ever hidden, just annotated.
// Scope intentionally broader than ListsIslandViewModel.HasNoLinkedRepo (all Lists rows in
// the DB, not just UserLists).
[ObservableProperty] private bool _hasLinkedRepo; [ObservableProperty] private bool _hasLinkedRepo;
// Wired by WindowDialogService to close this modal and open the existing repo-import flow. // Wired by WindowDialogService to close this modal and open the existing repo-import flow.
@@ -152,7 +154,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
General.LoadModelPresets(dto?.ModelPresets, General.MaxTurnsCeiling); General.LoadModelPresets(dto?.ModelPresets, General.MaxTurnsCeiling);
await using var ctx = await _dbFactory.CreateDbContextAsync(); await using var ctx = await _dbFactory.CreateDbContextAsync();
HasLinkedRepo = await ctx.Lists.AnyAsync(l => l.WorkingDir != null && l.WorkingDir != ""); HasLinkedRepo = await ctx.Lists.AnyAsync(RepoLinkage.IsLinkedInDb);
} }
finally { IsBusy = false; } finally { IsBusy = false; }
} }
@@ -167,6 +167,21 @@ public class SettingsModalViewModelTests : IDisposable
Assert.True(vm.HasLinkedRepo); Assert.True(vm.HasLinkedRepo);
} }
[Fact]
public async Task HasLinkedRepo_false_when_a_list_working_dir_is_whitespace_only()
{
using (var ctx = NewContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "Blank repo", CreatedAt = DateTime.UtcNow, WorkingDir = " " });
ctx.SaveChanges();
}
var vm = MakeVm(new FakeWorker());
await vm.LoadAsync();
Assert.False(vm.HasLinkedRepo);
}
[Fact] [Fact]
public void SelectedCategory_updates_SelectedIndex_and_ignores_null() public void SelectedCategory_updates_SelectedIndex_and_ignores_null()
{ {