refactor(ui): dedupe path-open helper across five view models

ListsIslandViewModel.OpenInExplorer, MergeSectionViewModel.OpenWorktree,
WorktreesOverviewModalViewModel.OpenInExplorer, AboutModalViewModel.OpenPath and
TasksIslandViewModel.OpenTaskWorktree each reimplemented "open this path in the
shell" with their own existence check, launch mechanism and error handling.
Replace all five with the new ShellOpen.Path helper and route failures through
the existing ErrorReported -> footer-strip convention instead of bare catch {}.
This commit is contained in:
Mika Kuns
2026-08-24 09:38:41 +02:00
parent d309302043
commit b2a1b79029
11 changed files with 223 additions and 48 deletions
+4 -2
View File
@@ -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." }, "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}" }, "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}" }, "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" }, "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}" }, "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}" } "repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" }
}, },
"ops": { "ops": {
+4 -2
View File
@@ -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." }, "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}" }, "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}" }, "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" }, "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}" }, "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}" } "repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" }
}, },
"ops": { "ops": {
+24
View File
@@ -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);
}
}
}
@@ -339,6 +339,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task); AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task);
Merge = new MergeSectionViewModel(worker, services); Merge = new MergeSectionViewModel(worker, services);
Merge.DiffViewed = () => ReviewDiffViewed = true; Merge.DiffViewed = () => ReviewDiffViewed = true;
Merge.ErrorReported += msg => ErrorReported?.Invoke(msg);
Merge.PropertyChanged += (_, e) => Merge.PropertyChanged += (_, e) =>
{ {
if (e.PropertyName == nameof(MergeSectionViewModel.HasReviewableDiff)) if (e.PropertyName == nameof(MergeSectionViewModel.HasReviewableDiff))
@@ -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 rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>(); var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
vm.Configure(rawId, row.Name); vm.Configure(rawId, row.Name);
await vm.LoadAsync(); await vm.LoadAsync();
await Dialogs.ShowWorktreesOverviewAsync(vm); await Dialogs.ShowWorktreesOverviewAsync(vm);
@@ -107,17 +108,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
[RelayCommand] [RelayCommand]
private void OpenInExplorer(ListNavItemViewModel? row) private void OpenInExplorer(ListNavItemViewModel? row)
{ {
var dir = row?.WorkingDir; var (ok, error) = ShellOpen.Path(row?.WorkingDir);
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return; if (!ok && error is not null)
try ErrorReported?.Invoke(Loc.T("vm.lists.openInExplorerFailed", error));
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = dir,
UseShellExecute = true,
});
}
catch { /* best-effort */ }
} }
[RelayCommand] [RelayCommand]
@@ -60,6 +60,10 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
// review gate can record that the changes were inspected before merging. // review gate can record that the changes were inspected before merging.
public Action? DiffViewed { get; set; } 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<string>? ErrorReported;
// True when there is something to inspect before merging (a live worktree diff, // 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 // 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. // 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))] [RelayCommand(CanExecute = nameof(CanOpenWorktree))]
private void OpenWorktree() private void OpenWorktree()
{ {
if (_worktreePath is null) return; var (ok, error) = ShellOpen.Path(_worktreePath);
try if (!ok && error is not null)
{ ErrorReported?.Invoke(Loc.T("vm.mergeSection.openWorktreeFailed", error));
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = _worktreePath,
UseShellExecute = true,
});
}
catch { }
} }
private bool CanOpenWorktree() => _worktreePath != null; private bool CanOpenWorktree() => _worktreePath != null;
@@ -1432,16 +1432,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
[RelayCommand] [RelayCommand]
private void OpenTaskWorktree(TaskRowViewModel? row) private void OpenTaskWorktree(TaskRowViewModel? row)
{ {
if (row?.WorktreePath is not { } path || string.IsNullOrWhiteSpace(path)) return; var (ok, error) = ShellOpen.Path(row?.WorktreePath);
try if (!ok && error is not null)
{ ErrorReported?.Invoke(Loc.T("vm.tasksIsland.openWorktreeFailed", error));
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = path,
UseShellExecute = true,
});
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.openWorktreeFailed", ex.Message)); }
} }
[RelayCommand] [RelayCommand]
@@ -544,6 +544,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
private async Task OpenAbout() private async Task OpenAbout()
{ {
var vm = new AboutModalViewModel(); var vm = new AboutModalViewModel();
vm.ErrorReported += FlashFooterError;
if (Dialogs is not null) await Dialogs.ShowAboutAsync(vm); if (Dialogs is not null) await Dialogs.ShowAboutAsync(vm);
} }
@@ -599,6 +600,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
try try
{ {
var vm = _worktreesOverviewVmFactory(); var vm = _worktreesOverviewVmFactory();
vm.ErrorReported += FlashFooterError;
vm.Configure(null, null); vm.Configure(null, null);
await vm.LoadAsync(); await vm.LoadAsync();
await Dialogs.ShowWorktreesOverviewAsync(vm); await Dialogs.ShowWorktreesOverviewAsync(vm);
@@ -1,7 +1,8 @@
using System.Diagnostics;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -17,18 +18,17 @@ public sealed partial class AboutModalViewModel : ViewModelBase
public Action? CloseAction { get; set; } 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<string>? ErrorReported;
[RelayCommand] private void Close() => CloseAction?.Invoke(); [RelayCommand] private void Close() => CloseAction?.Invoke();
[RelayCommand] [RelayCommand]
private void OpenPath(string? path) private void OpenPath(string? path)
{ {
if (string.IsNullOrWhiteSpace(path)) return; var (ok, error) = ShellOpen.Path(path);
try if (!ok && error is not null)
{ ErrorReported?.Invoke(Loc.T("vm.about.openPathFailed", error));
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 */ }
} }
} }
@@ -1,5 +1,4 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics;
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input.Platform; using Avalonia.Input.Platform;
@@ -94,6 +93,10 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
public ObservableCollection<string> MergeTargets { get; } = new(); public ObservableCollection<string> MergeTargets { get; } = new();
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new(); public ObservableCollection<WorktreeOverviewRowViewModel> 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<string>? ErrorReported;
public Action? CloseAction { get; set; } public Action? CloseAction { get; set; }
public Action<DiffViewerViewModel>? ShowDiffAction { get; set; } public Action<DiffViewerViewModel>? ShowDiffAction { get; set; }
public Action<string, string>? JumpToTaskAction { get; set; } public Action<string, string>? JumpToTaskAction { get; set; }
@@ -262,9 +265,9 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void OpenInExplorer(WorktreeOverviewRowViewModel? row) private void OpenInExplorer(WorktreeOverviewRowViewModel? row)
{ {
if (row is null || !row.PathExistsOnDisk) return; var (ok, error) = ShellOpen.Path(row?.Path);
try { Process.Start(new ProcessStartInfo { FileName = row.Path, UseShellExecute = true }); } if (!ok && error is not null)
catch { } ErrorReported?.Invoke(Loc.T("vm.worktreesOverview.openInExplorerFailed", error));
} }
[RelayCommand] [RelayCommand]
@@ -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<ClaudeDoDbContext>().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<ClaudeDoDbContext>().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<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
}