diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index f082b2b4..1836da9e 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -740,10 +740,12 @@ "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." }, "sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" }, - "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." }, + "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "listSettings": { "untitled": "Unbenannt" }, "detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" }, - "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste", "findingsNotFound": "Für diese Liste gibt es noch keine Findings.", "findingsOpenFailed": "Findings-Ordner konnte nicht geöffnet werden: {0}" }, + "mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" }, + "about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" }, + "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste", "findingsNotFound": "Für diese Liste gibt es noch keine Findings.", "findingsOpenFailed": "Findings-Ordner konnte nicht geöffnet werden: {0}", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" } }, "ops": { diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index ea4fa38c..f36f6a09 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -740,10 +740,12 @@ "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "resetToDefault": "Reset to the bundled default." }, "sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" }, - "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." }, + "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" }, "listSettings": { "untitled": "Untitled" }, "detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" }, - "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list", "findingsNotFound": "No findings yet for this list.", "findingsOpenFailed": "Couldn't open findings folder: {0}" }, + "mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" }, + "about": { "openPathFailed": "Couldn't open path: {0}" }, + "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list", "findingsNotFound": "No findings yet for this list.", "findingsOpenFailed": "Couldn't open findings folder: {0}", "openInExplorerFailed": "Couldn't open path: {0}" }, "repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" } }, "ops": { diff --git a/src/ClaudeDo.Ui/Services/ShellOpen.cs b/src/ClaudeDo.Ui/Services/ShellOpen.cs new file mode 100644 index 00000000..e4168349 --- /dev/null +++ b/src/ClaudeDo.Ui/Services/ShellOpen.cs @@ -0,0 +1,24 @@ +using System.Diagnostics; +using System.IO; + +namespace ClaudeDo.Ui.Services; + +public static class ShellOpen +{ + // "nothing to open" (blank/missing path) is not an error — callers stay silent, matching the + // existing Directory.Exists/File.Exists guards this helper replaces. + public static (bool Ok, string? Error) Path(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return (false, null); + if (!File.Exists(path) && !Directory.Exists(path)) return (false, null); + try + { + Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true }); + return (true, null); + } + catch (Exception ex) + { + return (false, ex.Message); + } + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 75d6b97e..0d16fb59 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -339,6 +339,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task); Merge = new MergeSectionViewModel(worker, services); Merge.DiffViewed = () => ReviewDiffViewed = true; + Merge.ErrorReported += msg => ErrorReported?.Invoke(msg); Merge.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(MergeSectionViewModel.HasReviewableDiff)) diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index 7e990795..40995ad0 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -80,6 +80,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable { 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); @@ -107,17 +108,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable [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 */ } + var (ok, error) = ShellOpen.Path(row?.WorkingDir); + if (!ok && error is not null) + ErrorReported?.Invoke(Loc.T("vm.lists.openInExplorerFailed", error)); } [RelayCommand] diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs index b3953a81..da08761a 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs @@ -60,6 +60,10 @@ public sealed partial class MergeSectionViewModel : ViewModelBase // review gate can record that the changes were inspected before merging. public Action? DiffViewed { get; set; } + // Mirrors ListsIslandViewModel/TasksIslandViewModel.ErrorReported — DetailsIslandViewModel + // forwards this into its own ErrorReported, which the shell flashes in the footer strip. + public event Action? ErrorReported; + // True when there is something to inspect before merging (a live worktree diff, // a merged commit range, or a planning/children combined diff). When false there // is nothing to read, so the review gate must not block approve. @@ -191,16 +195,9 @@ public sealed partial class MergeSectionViewModel : ViewModelBase [RelayCommand(CanExecute = nameof(CanOpenWorktree))] private void OpenWorktree() { - if (_worktreePath is null) return; - try - { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo - { - FileName = _worktreePath, - UseShellExecute = true, - }); - } - catch { } + var (ok, error) = ShellOpen.Path(_worktreePath); + if (!ok && error is not null) + ErrorReported?.Invoke(Loc.T("vm.mergeSection.openWorktreeFailed", error)); } private bool CanOpenWorktree() => _worktreePath != null; diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 1d1704da..73756c2e 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -1432,16 +1432,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable [RelayCommand] private void OpenTaskWorktree(TaskRowViewModel? row) { - if (row?.WorktreePath is not { } path || string.IsNullOrWhiteSpace(path)) return; - try - { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo - { - FileName = path, - UseShellExecute = true, - }); - } - catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.openWorktreeFailed", ex.Message)); } + var (ok, error) = ShellOpen.Path(row?.WorktreePath); + if (!ok && error is not null) + ErrorReported?.Invoke(Loc.T("vm.tasksIsland.openWorktreeFailed", error)); } [RelayCommand] diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index aa85bccd..b88640fd 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -544,6 +544,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable private async Task OpenAbout() { var vm = new AboutModalViewModel(); + vm.ErrorReported += FlashFooterError; if (Dialogs is not null) await Dialogs.ShowAboutAsync(vm); } @@ -599,6 +600,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable try { var vm = _worktreesOverviewVmFactory(); + vm.ErrorReported += FlashFooterError; vm.Configure(null, null); await vm.LoadAsync(); await Dialogs.ShowWorktreesOverviewAsync(vm); diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/AboutModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/AboutModalViewModel.cs index a3280be1..d3feea3e 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/AboutModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/AboutModalViewModel.cs @@ -1,7 +1,8 @@ -using System.Diagnostics; using System.IO; using System.Reflection; using ClaudeDo.Data; +using ClaudeDo.Ui.Localization; +using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -17,18 +18,17 @@ public sealed partial class AboutModalViewModel : ViewModelBase public Action? CloseAction { get; set; } + // Mirrors UsageMonitorModalViewModel.ErrorReported — this modal's own window sits in front + // of the footer strip, so the caller wires this into FlashFooterError. + public event Action? ErrorReported; + [RelayCommand] private void Close() => CloseAction?.Invoke(); [RelayCommand] private void OpenPath(string? path) { - if (string.IsNullOrWhiteSpace(path)) return; - try - { - var target = File.Exists(path) ? path : (Directory.Exists(path) ? path : null); - if (target is null) return; - Process.Start(new ProcessStartInfo("explorer.exe", $"\"{target}\"") { UseShellExecute = true }); - } - catch { /* ignore */ } + var (ok, error) = ShellOpen.Path(path); + if (!ok && error is not null) + ErrorReported?.Invoke(Loc.T("vm.about.openPathFailed", error)); } } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs index b47f1f3d..b88be5d8 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using System.Diagnostics; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Input.Platform; @@ -94,6 +93,10 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase public ObservableCollection MergeTargets { get; } = new(); public ObservableCollection ConflictRows { get; } = new(); + // Mirrors UsageMonitorModalViewModel.ErrorReported — surfaces modal-owned failures in the + // footer strip once the caller wires it (the modal's own window sits in front of the strip). + public event Action? ErrorReported; + public Action? CloseAction { get; set; } public Action? ShowDiffAction { get; set; } public Action? JumpToTaskAction { get; set; } @@ -262,9 +265,9 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase [RelayCommand] private void OpenInExplorer(WorktreeOverviewRowViewModel? row) { - if (row is null || !row.PathExistsOnDisk) return; - try { Process.Start(new ProcessStartInfo { FileName = row.Path, UseShellExecute = true }); } - catch { } + var (ok, error) = ShellOpen.Path(row?.Path); + if (!ok && error is not null) + ErrorReported?.Invoke(Loc.T("vm.worktreesOverview.openInExplorerFailed", error)); } [RelayCommand] diff --git a/tests/ClaudeDo.Ui.Tests/Services/ShellOpenTests.cs b/tests/ClaudeDo.Ui.Tests/Services/ShellOpenTests.cs new file mode 100644 index 00000000..cb21bc6e --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/Services/ShellOpenTests.cs @@ -0,0 +1,158 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Islands; +using ClaudeDo.Ui.ViewModels.Modals; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.Tests.Services; + +// Dedup: five copy-pasted "open this path in the shell" implementations replaced by ShellOpen. +// Process.Start must never fire in tests, so only the "nothing to open" paths are covered here — +// an existing file/directory would actually launch the OS shell handler. +public class ShellOpenTests +{ + private sealed class NoopWorkerClient : StubWorkerClient { } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Path_BlankInput_ReturnsFalseWithNoError(string? input) + { + var (ok, error) = ShellOpen.Path(input); + + Assert.False(ok); + Assert.Null(error); + } + + [Fact] + public void Path_NonExistentPath_ReturnsFalseWithNoError_DoesNotThrow() + { + var missing = System.IO.Path.Combine(Path.GetTempPath(), $"claudedo_shellopen_missing_{Guid.NewGuid():N}"); + + var (ok, error) = ShellOpen.Path(missing); + + Assert.False(ok); + Assert.Null(error); + } + + [Fact] + public void ListsIslandViewModel_OpenInExplorer_NonExistentDir_StaysSilent() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_shellopen_lists_{Guid.NewGuid():N}.db"); + try + { + var opts = new DbContextOptionsBuilder().UseSqlite($"Data Source={dbPath}").Options; + using (var ctx = new ClaudeDoDbContext(opts)) ctx.Database.EnsureCreated(); + var factory = new TestDbFactory(() => new ClaudeDoDbContext(opts)); + + var vm = new ListsIslandViewModel(factory); + string? reportedError = null; + vm.ErrorReported += msg => reportedError = msg; + var row = new ListNavItemViewModel + { + Id = "L1", + Kind = ListKind.User, + WorkingDir = Path.Combine(Path.GetTempPath(), $"claudedo_shellopen_missing_dir_{Guid.NewGuid():N}"), + }; + + vm.OpenInExplorerCommand.Execute(row); + + Assert.Null(reportedError); + } + finally + { + try { File.Delete(dbPath); } catch { } + try { File.Delete(dbPath + "-wal"); } catch { } + try { File.Delete(dbPath + "-shm"); } catch { } + } + } + + [Fact] + public void TasksIslandViewModel_OpenTaskWorktree_NonExistentPath_StaysSilent() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_shellopen_tasks_{Guid.NewGuid():N}.db"); + try + { + var opts = new DbContextOptionsBuilder().UseSqlite($"Data Source={dbPath}").Options; + using (var ctx = new ClaudeDoDbContext(opts)) ctx.Database.EnsureCreated(); + var factory = new TestDbFactory(() => new ClaudeDoDbContext(opts)); + + var vm = new TasksIslandViewModel(factory, new NoopWorkerClient()); + string? reportedError = null; + vm.ErrorReported += msg => reportedError = msg; + var row = new TaskRowViewModel + { + Id = "task-1", + Status = TaskStatus.Idle, + WorktreePath = Path.Combine(Path.GetTempPath(), $"claudedo_shellopen_missing_wt_{Guid.NewGuid():N}"), + }; + + vm.OpenTaskWorktreeCommand.Execute(row); + + Assert.Null(reportedError); + } + finally + { + try { File.Delete(dbPath); } catch { } + try { File.Delete(dbPath + "-wal"); } catch { } + try { File.Delete(dbPath + "-shm"); } catch { } + } + } + + [Fact] + public void WorktreesOverviewModalViewModel_OpenInExplorer_NonExistentPath_StaysSilent() + { + var vm = new WorktreesOverviewModalViewModel(new NoopWorkerClient(), () => null!, new MergeCoordinator()); + string? reportedError = null; + vm.ErrorReported += msg => reportedError = msg; + var row = new WorktreeOverviewRowViewModel + { + TaskId = "task-1", + TaskTitle = "Task 1", + TaskStatus = TaskStatus.Idle, + State = WorktreeState.Active, + Path = Path.Combine(Path.GetTempPath(), $"claudedo_shellopen_missing_row_{Guid.NewGuid():N}"), + }; + + vm.OpenInExplorerCommand.Execute(row); + + Assert.Null(reportedError); + } + + [Fact] + public void AboutModalViewModel_OpenPath_NonExistentPath_StaysSilent() + { + var vm = new AboutModalViewModel(); + string? reportedError = null; + vm.ErrorReported += msg => reportedError = msg; + var missing = Path.Combine(Path.GetTempPath(), $"claudedo_shellopen_missing_about_{Guid.NewGuid():N}"); + + vm.OpenPathCommand.Execute(missing); + + Assert.Null(reportedError); + } + + [Fact] + public void MergeSectionViewModel_OpenWorktree_NonExistentPath_StaysSilent() + { + var vm = new MergeSectionViewModel(new NoopWorkerClient(), services: null!); + string? reportedError = null; + vm.ErrorReported += msg => reportedError = msg; + var missing = Path.Combine(Path.GetTempPath(), $"claudedo_shellopen_missing_merge_{Guid.NewGuid():N}"); + vm.SyncWorktree(missing, null, null, "Active", null); + + vm.OpenWorktreeCommand.Execute(null); + + Assert.Null(reportedError); + } + + private sealed class TestDbFactory : IDbContextFactory + { + private readonly Func _create; + public TestDbFactory(Func create) => _create = create; + public ClaudeDoDbContext CreateDbContext() => _create(); + } +}