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
@@ -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();
}
}