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 ClaudeDo.Ui.ViewModels.Modals.Settings;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Ui.ViewModels.Islands;
public enum ListKind { Smart, Virtual, User }
/// Confirmed handler run: the scope list, the ordered selected task ids, and the model
/// picked for the triage session (null = default; review-mode runs skip triage entirely).
public sealed record MergeHelperRequest(string ListId, IReadOnlyList TaskIds, string? TriageModel = null);
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{
/// The day-log list. A smart list with no tasks in it: selecting it makes the tasks
/// island show the notes editor instead of rows.
public const string NotesListId = "smart:notes";
/// Icon key a folder falls back to when it has none of its own.
public const string ListFolderIcon = "Folder";
/// Icon key a list falls back to — deliberately not the folder icon, so the two stay distinguishable.
public const string ListIcon = ListSettingsModalViewModel.DefaultIcon;
private readonly IDbContextFactory _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? ErrorReported;
// Same strip, but for an action that succeeded and has a result worth showing.
public event Action? SuccessReported;
public IDialogService? Dialogs { get; set; }
[RelayCommand]
private async Task OpenSettings()
{
if (Dialogs is null || _services is null) return;
var settingsVm = _services.GetRequiredService();
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();
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
vm.BeginLoad(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
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();
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();
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
vm.Configure(rawId, row.Name);
await vm.LoadAsync();
await Dialogs.ShowWorktreesOverviewAsync(vm);
}
finally { _worktreesOverviewOpen = false; }
}
/// Raised after the merge-helper selection dialog is confirmed; the shell opens the ConPTY tile.
public event Action? 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();
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, vm.SelectedModel));
}
/// Raised once per list after the review-scoped selection dialog is confirmed;
/// the shell opens one merge-phase ConPTY tile per request.
public event Action? MergeReviewsRequested;
// Review-list variant of LetClaudeHandleListAsync: offers every WaitingForReview task across
// all repo-linked lists and starts one merge-phase handler session per list picked.
[RelayCommand]
private async Task MergeReviewsAsync()
{
if (Dialogs is null || _services is null) return;
var vm = _services.GetRequiredService();
vm.ConfigureForReview();
await vm.LoadAsync();
var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm);
if (ids is not { Count: > 0 }) return;
foreach (var (listId, taskIds) in vm.GroupSelectionByList(ids))
MergeReviewsRequested?.Invoke(new MergeHelperRequest(listId, taskIds));
}
[RelayCommand]
private void OpenInExplorer(ListNavItemViewModel? row)
{
var (ok, error) = ShellOpen.Path(row?.WorkingDir);
if (!ok && error is not null)
ErrorReported?.Invoke(Loc.T("vm.lists.openInExplorerFailed", error));
}
[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 Items { get; } = new();
public ObservableCollection SmartLists { get; } = new();
/// Every user list, flat, regardless of folder — the set the count refresh, the
/// row refresh and the no-repo banner work over.
public ObservableCollection UserLists { get; } = new();
/// The sidebar's top level, in render order: folder rows
/// () and folder-less lists
/// () interleaved, so a list can sit above a folder.
/// This is what the view binds to; stays the flat superset.
public ObservableCollection