feat(mission-control): custom tiles — open any console program as a tile

Programs are configured in Settings → Custom tiles and stored in ui.config.json.
A tile with a fixed folder starts on click; without one it offers a submenu of
home, the list folders and browse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CubeGameLP
2026-10-05 20:09:19 +02:00
co-authored by Claude Opus 5.5
parent 43b03e4837
commit f37b59d58f
12 changed files with 340 additions and 32 deletions
@@ -321,7 +321,7 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(4, vm.SessionTargets.Count); // home, browse, Alpha, VPS
Assert.Contains("VPS", vm.SessionTargets[3].Label);
vm.SessionTargets[3].StartCommand.Execute(null);
vm.SessionTargets[3].StartCommand!.Execute(null);
var pane = Assert.Single(vm.ConPtySessions);
Assert.Equal("VPS", pane.DisplayTitle);
}
@@ -339,7 +339,7 @@ public class MissionControlViewModelTests : IDisposable
using var vm = BuildVm(new FakeWorker());
var browsed = false;
vm.BrowseForSessionRequested = () => browsed = true;
vm.PickFolderAsync = () => { browsed = true; return Task.FromResult<string?>(null); };
await vm.RefreshSessionTargetsAsync();
@@ -347,10 +347,65 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(4, vm.SessionTargets.Count);
Assert.Equal(new[] { "Beta", "Alpha" }, vm.SessionTargets.Skip(2).Select(t => t.Label).ToArray());
vm.SessionTargets[1].StartCommand.Execute(null);
vm.SessionTargets[1].StartCommand!.Execute(null);
Assert.True(browsed);
}
[Fact]
public async Task RefreshSessionTargetsAsync_CustomTiles_FixedFolderStartsDirectly_EmptyFolderOffersFolderSubmenu()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Alpha", WorkingDir = @"C:\repos\alpha", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var settings = new AppSettings { ConfigPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("n") + ".json") };
settings.CustomTiles.Add(new CustomTile { Name = "Shell", Program = "cmd", Folder = @"C:\" });
settings.CustomTiles.Add(new CustomTile { Name = "Git", Program = "cmd" });
var worker = new FakeWorker();
using var vm = new MissionControlViewModel(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker), settings);
await vm.RefreshSessionTargetsAsync();
// Home, browse, Alpha, then the two custom tiles.
var fixedTile = vm.SessionTargets[3];
var askTile = vm.SessionTargets[4];
Assert.NotNull(fixedTile.StartCommand);
Assert.Null(fixedTile.Children);
Assert.Null(askTile.StartCommand);
// Same folder choice as the top level: home, the list folders, then browse.
Assert.Equal(3, askTile.Children!.Count);
Assert.Equal("Alpha", askTile.Children[1].Label);
askTile.Children[1].StartCommand!.Execute(null);
var pane = Assert.Single(vm.ConPtySessions);
Assert.Equal("Git", pane.DisplayTitle);
}
[Fact]
public void CustomTile_SplitArgs_SplitsOnWhitespace_QuotesGroup()
{
Assert.Equal(new[] { "lenovo-server", "-p", "56789" }, CustomTile.SplitArgs("lenovo-server -p 56789 "));
Assert.Equal(new[] { "-c", "git log --oneline", "" }, CustomTile.SplitArgs("-c \"git log --oneline\" \"\""));
Assert.Empty(CustomTile.SplitArgs(null));
}
[Fact]
public void AppSettings_CustomTiles_RoundTripThroughConfigFile()
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("n") + ".json");
try
{
var settings = new AppSettings { ConfigPath = path };
settings.CustomTiles.Add(new CustomTile { Name = "Server", Program = "ssh", Arguments = "lenovo-server" });
settings.Save();
var tile = Assert.Single(AppSettings.Load(path).CustomTiles);
Assert.Equal(("Server", "ssh", "lenovo-server", ""), (tile.Name, tile.Program, tile.Arguments, tile.Folder));
}
finally { File.Delete(path); }
}
private async Task SeedQueueAsync()
{
await using var db = NewContext();