Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs
T
mika kuns d451edd367 fix(data): normalize a list working dir's trailing separator on write
A list working_dir stored as "C:\Dev\Tests\StaplerTracking\" broke every consumer
that puts it on a Windows command line: argv rules read \" as an escaped quote, so
the token never closes. "Open in terminal" passed wt.exe a starting directory of
C:\Dev\Tests\StaplerTracking" and it failed with 0x8007010b; the same data had
already corrupted the ConPTY list handler's arg list in August.

Paths.TrimTrailingSeparator is now the single helper (replacing the copies in
InteractiveLaunchSpecService and ClaudeHelpLauncher) and ListRepository applies it
on Add/Update, which covers every writer: UI create, repo import, hub UpdateList,
and MCP CreateList/UpdateList. OpenInTerminal also switches to ArgumentList so its
quoting is correct regardless of what's stored.
2026-08-10 17:12:46 +02:00

437 lines
17 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)) return;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = findings,
UseShellExecute = true,
});
}
catch { /* best-effort */ }
}
[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;
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 += () => _ = RefreshCountsAsync();
}
_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); }
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++;
}
await RefreshCountsAsync(ct);
SelectedList = Items.FirstOrDefault();
}
public async Task RefreshCountsAsync(CancellationToken ct = default)
{
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);
}
}
catch (OperationCanceledException) { throw; }
catch { /* best-effort refresh */ }
}
[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;
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();
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
await lists.ReorderAsync(orderedIds);
}
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
{
var rawId = rowId.StartsWith("user:") ? rowId["user:".Length..] : rowId;
var row = UserLists.FirstOrDefault(r => r.Id == rowId);
if (row is null) return;
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
var entity = await lists.GetByIdAsync(rawId);
if (entity is null) return;
row.Name = entity.Name;
row.WorkingDir = entity.WorkingDir;
row.DefaultCommitType = entity.DefaultCommitType;
row.IsManual = entity.IsManual;
row.FindingsTracked = entity.FindingsTracked;
}
catch { /* best-effort refresh */ }
}
}