Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs
T
Mika Kuns 9578c95074 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.
2026-08-24 09:28:20 +02:00

524 lines
21 KiB
C#

using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Filtering;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Ui.ViewModels.Islands;
public enum ListKind { Smart, Virtual, User }
/// <summary>Confirmed handler run: the scope list and the ordered selected task ids.</summary>
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds);
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IServiceProvider? _services;
private readonly IWorkerClient? _worker;
private static readonly TaskListFilterRegistry _filters = new();
public event EventHandler? SelectionChanged;
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<string>? ErrorReported;
public IDialogService? Dialogs { get; set; }
[RelayCommand]
private async Task OpenSettings()
{
if (Dialogs is null || _services is null) return;
var settingsVm = _services.GetRequiredService<SettingsModalViewModel>();
await settingsVm.LoadAsync();
await Dialogs.ShowSettingsAsync(settingsVm);
}
[RelayCommand]
private async System.Threading.Tasks.Task OpenListSettingsAsync(ListNavItemViewModel? row)
{
if (row is null || Dialogs is null || _services is null) return;
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked);
await Dialogs.ShowListSettingsAsync(vm);
if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(row.Id);
}
[RelayCommand]
private async System.Threading.Tasks.Task OpenRepoImportAsync()
{
if (Dialogs is null || _services is null) return;
var vm = _services.GetRequiredService<RepoImportModalViewModel>();
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
await vm.LoadAsync();
await Dialogs.ShowRepoImportAsync(vm);
await LoadAsync();
}
private bool _worktreesOverviewOpen;
[RelayCommand]
private async Task OpenWorktreesOverviewAsync(ListNavItemViewModel? row)
{
if (row is null || Dialogs is null || _services is null) return;
if (row.Kind != ListKind.User) return;
if (_worktreesOverviewOpen) return;
_worktreesOverviewOpen = true;
try
{
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
vm.Configure(rawId, row.Name);
await vm.LoadAsync();
await Dialogs.ShowWorktreesOverviewAsync(vm);
}
finally { _worktreesOverviewOpen = false; }
}
/// <summary>Raised after the merge-helper selection dialog is confirmed; the shell opens the ConPTY tile.</summary>
public event Action<MergeHelperRequest>? LetClaudeHandleRequested;
[RelayCommand]
private async Task LetClaudeHandleListAsync(ListNavItemViewModel? row)
{
if (row is null || Dialogs is null || _services is null) return;
if (row.Kind != ListKind.User || string.IsNullOrWhiteSpace(row.WorkingDir)) return;
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<MergeHelperSelectionModalViewModel>();
vm.Configure(rawId, row.Name);
await vm.LoadAsync();
var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm);
if (ids is { Count: > 0 })
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids));
}
[RelayCommand]
private void OpenInExplorer(ListNavItemViewModel? row)
{
var dir = row?.WorkingDir;
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = dir,
UseShellExecute = true,
});
}
catch { /* best-effort */ }
}
[RelayCommand]
private void OpenFindings(ListNavItemViewModel? row)
{
var dir = row?.WorkingDir;
if (string.IsNullOrWhiteSpace(dir)) return;
var findings = System.IO.Path.Combine(dir, ".claudedo");
if (!System.IO.Directory.Exists(findings))
{
ErrorReported?.Invoke(Loc.T("vm.lists.findingsNotFound"));
return;
}
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = findings,
UseShellExecute = true,
});
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.lists.findingsOpenFailed", ex.Message)); }
}
[RelayCommand]
private void OpenInTerminal(ListNavItemViewModel? row)
{
var dir = row?.WorkingDir;
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return;
// Trailing separator + ArgumentList, not string interpolation: "C:\repo\" would parse as
// C:\repo" and wt would refuse it as a starting directory (Paths.TrimTrailingSeparator).
// Rows loaded before ListRepository normalized on write can still carry one.
dir = Paths.TrimTrailingSeparator(dir)!;
ForegroundHelper.AllowAny();
try
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "wt.exe",
UseShellExecute = true,
};
psi.ArgumentList.Add("-d");
psi.ArgumentList.Add(dir);
System.Diagnostics.Process.Start(psi);
}
catch
{
// Windows Terminal not installed — fall back to a plain console at the directory.
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "cmd.exe",
WorkingDirectory = dir,
UseShellExecute = true,
});
}
catch { /* best-effort */ }
}
}
public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new();
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private ListNavItemViewModel? _selectedList;
/// <summary>True whenever no User list has a linked <c>WorkingDir</c> — drives the no-repo
/// banner. Smart/Virtual lists never count (scope intentionally narrower than
/// 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;
private void RecomputeHasNoLinkedRepo() =>
HasNoLinkedRepo = UserLists.All(r => !RepoLinkage.IsLinked(r.WorkingDir));
public string UserName { get; } = Environment.UserName;
public string MachineName { get; } = Environment.MachineName;
public string MachineNameLocal => Loc.T("vm.lists.localSuffix", MachineName);
public string UserInitials { get; }
private readonly EventHandler _langChangedHandler;
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, IWorkerClient? worker = null)
{
_dbFactory = dbFactory;
_services = services;
_worker = worker;
var parts = Environment.UserName.Split('.', '_', '-', ' ');
UserInitials = parts.Length >= 2
? $"{parts[0][0]}{parts[1][0]}".ToUpperInvariant()
: Environment.UserName.Length >= 2
? Environment.UserName[..2].ToUpperInvariant()
: Environment.UserName.ToUpperInvariant();
if (_worker is not null)
{
_worker.ListUpdatedEvent += id => _ = RefreshRowAsync(id);
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
_worker.WorktreeUpdatedEvent += _id => _ = RefreshCountsAsync();
_worker.ConnectionRestoredEvent += () => _ = ReloadAfterReconnectAsync();
}
_langChangedHandler = (_, _) => RefreshLocalizedLabels();
Loc.LanguageChanged += _langChangedHandler;
}
public void Dispose()
{
Loc.LanguageChanged -= _langChangedHandler;
}
private static string? SmartListNameKey(string id) => id switch
{
"smart:my-day" => "vm.lists.smartMyDay",
"smart:important" => "vm.lists.smartImportant",
"smart:planned" => "vm.lists.smartPlanned",
"virtual:queued" => "vm.lists.virtualQueue",
"virtual:running" => "vm.lists.virtualRunning",
"virtual:review" => "vm.lists.virtualReview",
_ => null,
};
private void RefreshLocalizedLabels()
{
foreach (var item in SmartLists)
if (SmartListNameKey(item.Id) is { } key) item.Name = Loc.T(key);
OnPropertyChanged(nameof(MachineNameLocal));
}
public async Task LoadAsync(CancellationToken ct = default)
{
Items.Clear();
SmartLists.Clear();
UserLists.Clear();
var smart = new[]
{
new ListNavItemViewModel { Id = "smart:my-day", Name = Loc.T("vm.lists.smartMyDay"), Kind = ListKind.Smart, IconKey = "Sun" },
new ListNavItemViewModel { Id = "smart:important", Name = Loc.T("vm.lists.smartImportant"), Kind = ListKind.Smart, IconKey = "Star" },
new ListNavItemViewModel { Id = "smart:planned", Name = Loc.T("vm.lists.smartPlanned"), Kind = ListKind.Smart, IconKey = "Calendar" },
new ListNavItemViewModel { Id = "virtual:queued", Name = Loc.T("vm.lists.virtualQueue"), Kind = ListKind.Virtual, IconKey = "Inbox" },
new ListNavItemViewModel { Id = "virtual:running", Name = Loc.T("vm.lists.virtualRunning"), Kind = ListKind.Virtual, IconKey = "Activity" },
new ListNavItemViewModel { Id = "virtual:review", Name = Loc.T("vm.lists.virtualReview"), Kind = ListKind.Virtual, IconKey = "Eye" },
};
foreach (var s in smart) { Items.Add(s); SmartLists.Add(s); }
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx);
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
var dotColors = new[] { "Moss", "Peat", "Sage" };
int idx = 0;
foreach (var l in await lists.GetAllAsync(ct))
{
if (seedNames.Contains(l.Name)) continue;
var item = new ListNavItemViewModel
{
Id = $"user:{l.Id}",
Name = l.Name,
Kind = ListKind.User,
IconKey = "Folder",
DotColorKey = dotColors[idx % dotColors.Length],
WorkingDir = l.WorkingDir,
DefaultCommitType = l.DefaultCommitType,
IsManual = l.IsManual,
FindingsTracked = l.FindingsTracked,
};
Items.Add(item);
UserLists.Add(item);
idx++;
}
ok = true;
}
finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); }
RecomputeHasNoLinkedRepo();
await RefreshCountsAsync(ct);
SelectedList = Items.FirstOrDefault();
}
/// <summary>
/// A reconnect can have missed offline list creates/renames/deletes (SignalR was down) — unlike
/// the task/worktree events, which only need a count refresh — so this re-reads the full list
/// set instead, restoring the prior selection if it still exists.
/// </summary>
private async Task ReloadAfterReconnectAsync()
{
var selectedId = SelectedList?.Id;
await LoadAsync();
if (selectedId is not null && Items.FirstOrDefault(i => i.Id == selectedId) is { } restored)
SelectedList = restored;
}
public async Task RefreshCountsAsync(CancellationToken ct = default)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
// Single snapshot; counters and the list loader share the same filter strategies.
var all = await ctx.Tasks.AsNoTracking()
.Include(t => t.Worktree)
.ToListAsync(ct);
foreach (var item in SmartLists)
{
var filter = _filters.Resolve(item.Id);
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
}
foreach (var item in UserLists)
{
var filter = _filters.Resolve(item.Id);
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
}
ok = true;
}
catch (OperationCanceledException) { throw; }
catch { /* best-effort refresh */ }
finally { OperationTiming.Shared.Record("db", "ListsIsland.RefreshCountsAsync", sw.Elapsed, ok); }
}
[RelayCommand]
private void Select(ListNavItemViewModel item) => SelectedList = item;
[RelayCommand]
private async Task CreateListAsync()
{
var entity = new ListEntity
{
Id = Guid.NewGuid().ToString("N"),
Name = Loc.T("vm.lists.newList"),
DefaultCommitType = CommitTypeRegistry.DefaultType,
CreatedAt = DateTime.UtcNow,
};
await using (var ctx = await _dbFactory.CreateDbContextAsync())
{
var lists = new ListRepository(ctx);
await lists.AddAsync(entity);
}
var item = new ListNavItemViewModel
{
Id = $"user:{entity.Id}",
Name = entity.Name,
Kind = ListKind.User,
IconKey = "Folder",
DotColorKey = "Moss",
WorkingDir = entity.WorkingDir,
DefaultCommitType = entity.DefaultCommitType,
};
Items.Add(item);
UserLists.Add(item);
SelectedList = item;
RecomputeHasNoLinkedRepo();
if (Dialogs is not null && _services is not null)
{
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked);
await Dialogs.ShowListSettingsAsync(vm);
if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(item.Id);
}
}
public void ClearDropHints()
{
foreach (var r in UserLists)
{
r.DropHintAbove = false;
r.DropHintBelow = false;
}
}
public void SetDropHint(ListNavItemViewModel target, bool placeBelow)
{
foreach (var r in UserLists)
{
var isTarget = ReferenceEquals(r, target);
r.DropHintAbove = isTarget && !placeBelow;
r.DropHintBelow = isTarget && placeBelow;
}
}
public async Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow)
{
if (source.Kind != ListKind.User || target.Kind != ListKind.User) return;
if (ReferenceEquals(source, target)) return;
MoveWithinCollection(UserLists, source, target, placeBelow);
var orderedIds = UserLists.Select(i => i.Id["user:".Length..]).ToList();
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
await lists.ReorderAsync(orderedIds);
ok = true;
}
finally { OperationTiming.Shared.Record("db", "ListsIsland.ReorderAsync", sw.Elapsed, ok); }
}
private static void MoveWithinCollection(
ObservableCollection<ListNavItemViewModel> coll,
ListNavItemViewModel source,
ListNavItemViewModel target,
bool placeBelow)
{
var srcIdx = coll.IndexOf(source);
var tgtIdx = coll.IndexOf(target);
if (srcIdx < 0 || tgtIdx < 0 || srcIdx == tgtIdx) return;
var finalIdx = placeBelow ? tgtIdx + 1 : tgtIdx;
if (srcIdx < finalIdx) finalIdx--;
if (finalIdx < 0) finalIdx = 0;
if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
if (finalIdx == srcIdx) return;
coll.Move(srcIdx, finalIdx);
}
partial void OnSelectedListChanged(ListNavItemViewModel? value)
{
foreach (var i in Items) i.IsActive = ReferenceEquals(i, value);
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
private async System.Threading.Tasks.Task RefreshRowAsync(string rowId)
{
try
{
// The worker broadcasts the raw list id (ListMcpTools/ConfigMcpTools), never the
// "user:" prefixed row id used in this VM's own collections — match on the raw id
// either way so an MCP-driven create/rename is recognized against the loaded row.
var rawId = rowId.StartsWith("user:") ? rowId["user:".Length..] : rowId;
var row = UserLists.FirstOrDefault(r =>
(r.Id.StartsWith("user:") ? r.Id["user:".Length..] : r.Id) == rawId);
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
var entity = await lists.GetByIdAsync(rawId);
if (entity is null) return;
if (row is null)
{
await AddRowAsync(lists, entity);
return;
}
row.Name = entity.Name;
row.WorkingDir = entity.WorkingDir;
row.DefaultCommitType = entity.DefaultCommitType;
row.IsManual = entity.IsManual;
row.FindingsTracked = entity.FindingsTracked;
RecomputeHasNoLinkedRepo();
}
catch { /* best-effort refresh */ }
}
/// <summary>
/// A `create_list` from a running MCP session has no local row yet — build one from the DB
/// entity and insert it at its DB sort position, instead of waiting for the next full reload.
/// </summary>
private async Task AddRowAsync(ListRepository lists, ListEntity entity)
{
var dotColors = new[] { "Moss", "Peat", "Sage" };
var item = new ListNavItemViewModel
{
Id = $"user:{entity.Id}",
Name = entity.Name,
Kind = ListKind.User,
IconKey = "Folder",
DotColorKey = dotColors[UserLists.Count % dotColors.Length],
WorkingDir = entity.WorkingDir,
DefaultCommitType = entity.DefaultCommitType,
IsManual = entity.IsManual,
FindingsTracked = entity.FindingsTracked,
};
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
var ordered = (await lists.GetAllAsync()).Where(l => !seedNames.Contains(l.Name)).ToList();
var dbIndex = ordered.FindIndex(l => l.Id == entity.Id);
var insertAt = dbIndex < 0 ? UserLists.Count : Math.Min(dbIndex, UserLists.Count);
UserLists.Insert(insertAt, item);
Items.Insert(SmartLists.Count + insertAt, item);
RecomputeHasNoLinkedRepo();
}
}