Reminders written down as todos had no home: every task looked like Claude work. A manual task now shows a MANUAL badge and hides send-to-queue, refine and the planning session; the queue picker, daily prep and the list handler all skip it, with a TaskStateService guard so the MCP surface and hub cannot start one either. Opening a hand-driven ConPTY session stays available on purpose. A list can be marked manual in its settings, which makes tasks created there (UI and MCP add_task) start out manual. Toggle per task from its context menu.
406 lines
15 KiB
C#
406 lines
15 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);
|
|
|
|
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);
|
|
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>();
|
|
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 OpenInTerminal(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 = "wt.exe",
|
|
Arguments = $"-d \"{dir}\"",
|
|
UseShellExecute = true,
|
|
});
|
|
}
|
|
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,
|
|
};
|
|
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);
|
|
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;
|
|
}
|
|
catch { /* best-effort refresh */ }
|
|
}
|
|
}
|