From 80e5bb3b617c41ea2128f40063d1a8031039fe85 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Thu, 2 Jul 2026 10:46:13 -0700 Subject: [PATCH 1/4] feat: unify workspace create/edit into a single adaptive card form Replace separate editor and launch-form pages with ShortcutFormPage, shared template JSON with caching, consolidated draft save logic, and browse actions for folder and custom companion apps. Co-authored-by: Cursor --- .../FakeShortcutRepository.cs | 7 +- .../ShortcutDraftStoreTests.cs | 171 +++++ .../ShortcutFormTemplateJsonTests.cs | 225 ++++++ .../ShortcutImportExportTests.cs | 152 ++++ .../ShortcutLaunchFormJsonTests.cs | 4 +- .../Services/ShortcutDraftStore.cs | 43 +- .../Services/ShortcutFormDraftStore.cs | 144 ++++ .../Services/ShortcutFormTemplateCache.cs | 42 ++ .../Services/ShortcutFormTemplateJson.cs | 346 +++++++++ .../Services/ShortcutLaunchFormJson.cs | 3 +- .../Services/WorkspaceValidation.cs | 4 +- QuickShell/Commands/DeleteShortcutCommand.cs | 2 +- .../Commands/DuplicateShortcutCommand.cs | 32 +- QuickShell/Commands/ShortcutLaunchCommands.cs | 146 ---- QuickShell/Pages/ImportConflictPage.cs | 6 +- QuickShell/Pages/PendingShortcutEditPage.cs | 14 +- QuickShell/Pages/ShortcutEditorPage.cs | 181 ----- QuickShell/Pages/ShortcutFormPage.cs | 667 +++++++----------- QuickShell/Pages/ShortcutLaunchFormPage.cs | 369 ---------- .../Services/ShortcutEditorNavigationState.cs | 81 --- QuickShell/Services/ShortcutEditorState.cs | 70 -- 21 files changed, 1414 insertions(+), 1295 deletions(-) create mode 100644 QuickShell.Core.Tests/ShortcutDraftStoreTests.cs create mode 100644 QuickShell.Core.Tests/ShortcutFormTemplateJsonTests.cs create mode 100644 QuickShell.Core.Tests/ShortcutImportExportTests.cs create mode 100644 QuickShell.Core/Services/ShortcutFormTemplateCache.cs create mode 100644 QuickShell.Core/Services/ShortcutFormTemplateJson.cs delete mode 100644 QuickShell/Commands/ShortcutLaunchCommands.cs delete mode 100644 QuickShell/Pages/ShortcutEditorPage.cs delete mode 100644 QuickShell/Pages/ShortcutLaunchFormPage.cs delete mode 100644 QuickShell/Services/ShortcutEditorNavigationState.cs delete mode 100644 QuickShell/Services/ShortcutEditorState.cs diff --git a/QuickShell.Core.Tests/FakeShortcutRepository.cs b/QuickShell.Core.Tests/FakeShortcutRepository.cs index 9fee085..bebe938 100644 --- a/QuickShell.Core.Tests/FakeShortcutRepository.cs +++ b/QuickShell.Core.Tests/FakeShortcutRepository.cs @@ -8,14 +8,15 @@ internal sealed class FakeShortcutRepository : IShortcutRepository private readonly Dictionary _byId; private readonly Dictionary _byName; - public FakeShortcutRepository(IEnumerable shortcuts) + public FakeShortcutRepository(IEnumerable shortcuts, string? configDirectory = null) { var list = shortcuts.ToList(); _byId = list.ToDictionary(shortcut => shortcut.Id, StringComparer.OrdinalIgnoreCase); _byName = list.ToDictionary(shortcut => shortcut.Name, StringComparer.OrdinalIgnoreCase); + ConfigDirectory = configDirectory ?? string.Empty; } - public string ConfigDirectory => string.Empty; + public string ConfigDirectory { get; } public string ConfigPath => string.Empty; @@ -70,7 +71,7 @@ public Task ImportMergeAsync(string path, CancellationTo public Task ImportReplaceAsync(string path, CancellationToken cancellationToken = default) => Task.FromResult(new ShortcutTransferResult()); - public ShortcutTransferResult ResetAll() => new() { Success = true, Message = "No projects to reset." }; + public ShortcutTransferResult ResetAll() => new() { Success = true, Message = "No workspaces to reset." }; public bool CanUndo => false; diff --git a/QuickShell.Core.Tests/ShortcutDraftStoreTests.cs b/QuickShell.Core.Tests/ShortcutDraftStoreTests.cs new file mode 100644 index 0000000..3f78bdc --- /dev/null +++ b/QuickShell.Core.Tests/ShortcutDraftStoreTests.cs @@ -0,0 +1,171 @@ +using QuickShell.Models; +using QuickShell.Services; + +namespace QuickShell.Core.Tests; + +public sealed class ShortcutDraftStoreTests : IDisposable +{ + private readonly string _configDirectory; + + public ShortcutDraftStoreTests() + { + _configDirectory = Path.Combine(Path.GetTempPath(), "quickshell-draft-store-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_configDirectory); + } + + [Fact] + public void Clear_removes_pending_and_deletes_draft_file() + { + var shortcut = CreateSavedShortcut(); + var repository = new FakeShortcutRepository([shortcut], _configDirectory); + var store = new ShortcutDraftStore(repository); + + store.SaveIfDirty( + shortcut.Name, + CreateDirtyDraft(shortcut.Name), + CreateBaseline(shortcut), + nameCustomized: false, + autoFilledName: null); + WaitForDraftFile(store); + + store.Clear(); + + Assert.False(store.HasPending); + Assert.False(File.Exists(store.DraftPath)); + } + + [Fact] + public void Clear_after_save_prevents_stale_persist_from_recreating_draft_file() + { + var shortcut = CreateSavedShortcut(); + var repository = new FakeShortcutRepository([shortcut], _configDirectory); + var store = new ShortcutDraftStore(repository); + + store.SaveIfDirty( + shortcut.Name, + CreateDirtyDraft(shortcut.Name), + CreateBaseline(shortcut), + nameCustomized: false, + autoFilledName: null); + + store.Clear(); + store.Dispose(); + + Assert.False(File.Exists(store.DraftPath)); + } + + [Fact] + public void Reload_after_clear_does_not_restore_discarded_draft() + { + var shortcut = CreateSavedShortcut(); + var repository = new FakeShortcutRepository([shortcut], _configDirectory); + + using (var store = new ShortcutDraftStore(repository)) + { + store.SaveIfDirty( + shortcut.Name, + CreateDirtyDraft(shortcut.Name), + CreateBaseline(shortcut), + nameCustomized: false, + autoFilledName: null); + WaitForDraftFile(store); + store.Clear(); + } + + using var reloaded = new ShortcutDraftStore(repository); + Assert.False(reloaded.HasPending); + Assert.False(reloaded.TryGetForRestore(shortcut.Name, out _)); + } + + [Fact] + public void Clear_raises_Cleared_with_original_name() + { + var shortcut = CreateSavedShortcut(); + var repository = new FakeShortcutRepository([shortcut], _configDirectory); + var store = new ShortcutDraftStore(repository); + string? clearedName = null; + store.Cleared += name => clearedName = name; + + store.SaveIfDirty( + shortcut.Name, + CreateDirtyDraft(shortcut.Name), + CreateBaseline(shortcut), + nameCustomized: false, + autoFilledName: null); + + store.Clear(); + + Assert.Equal(shortcut.Name, clearedName); + } + + [Fact] + public void Clear_without_pending_does_not_raise_Cleared() + { + var shortcut = CreateSavedShortcut(); + var repository = new FakeShortcutRepository([shortcut], _configDirectory); + var store = new ShortcutDraftStore(repository); + var raised = false; + store.Cleared += _ => raised = true; + + store.Clear(); + + Assert.False(raised); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_configDirectory)) + { + Directory.Delete(_configDirectory, recursive: true); + } + } + catch + { + // Best effort cleanup for temp test data. + } + } + + private static TerminalShortcut CreateSavedShortcut() => new() + { + Id = "draft-store-test", + Name = "MyProject", + Directory = @"C:\Projects\MyProject", + Command = "npm start", + Terminal = "pwsh", + }; + + private static ShortcutFormDraftData CreateBaseline(TerminalShortcut shortcut) => new() + { + OriginalName = shortcut.Name, + Name = shortcut.Name, + Directory = shortcut.Directory, + Command = shortcut.Command ?? string.Empty, + LaunchTarget = TerminalCatalog.EncodeLaunchTargetId(shortcut), + }; + + private static ShortcutFormDraftData CreateDirtyDraft(string originalName) => new() + { + OriginalName = originalName, + Name = "MyProject", + Directory = @"C:\Projects\Changed", + Command = "npm run dev", + LaunchTarget = "default", + }; + + private static void WaitForDraftFile(ShortcutDraftStore store) + { + for (var attempt = 0; attempt < 50; attempt++) + { + if (File.Exists(store.DraftPath)) + { + return; + } + + Thread.Sleep(20); + } + + throw new InvalidOperationException("Draft file was not written in time."); + } +} diff --git a/QuickShell.Core.Tests/ShortcutFormTemplateJsonTests.cs b/QuickShell.Core.Tests/ShortcutFormTemplateJsonTests.cs new file mode 100644 index 0000000..e5f9273 --- /dev/null +++ b/QuickShell.Core.Tests/ShortcutFormTemplateJsonTests.cs @@ -0,0 +1,225 @@ +using QuickShell.Services; +using System.Text.Json; + +namespace QuickShell.Core.Tests; + +public sealed class ShortcutFormTemplateJsonTests +{ + private static readonly string[] RequiredInputIds = + [ + "OriginalName", + "Directory", + "Name", + "Abbreviation", + "DevServerUrl", + "RepoUrl", + "CompanionAppPreset", + "LaunchTarget", + "RunAsAdmin", + "LaunchCommand_0", + ]; + + [Fact] + public void BuildTemplate_WithLiveChoiceArrays_ParsesAsJson() + { + var template = BuildDefaultTemplate(); + + using var document = JsonDocument.Parse(template); + Assert.Equal("AdaptiveCard", document.RootElement.GetProperty("type").GetString()); + } + + [Fact] + public void BuildTemplate_DoesNotLeaveUnexpandedBuildTokens() + { + var template = BuildDefaultTemplate(["npm run dev", "dotnet watch"]); + + var exception = Record.Exception(() => ShortcutFormTemplateJson.AssertRenderableTemplate(template)); + Assert.Null(exception); + Assert.DoesNotContain("{{companionChoices}}", template, StringComparison.Ordinal); + Assert.DoesNotContain("{{terminalChoices}}", template, StringComparison.Ordinal); + Assert.DoesNotContain("{{commandRows}}", template, StringComparison.Ordinal); + } + + [Fact] + public void AssertRenderableTemplate_ThrowsWhenCompanionChoicesTokenRemains() + { + var broken = BuildDefaultTemplate().Replace( + "\"value\":\"none\"", + "\"value\":\"none\"}}{{companionChoices}}", + StringComparison.Ordinal); + + Assert.Throws(() => + ShortcutFormTemplateJson.AssertRenderableTemplate(broken)); + } + + [Fact] + public void BuildTemplate_ContainsRequiredInputIds() + { + var template = BuildDefaultTemplate(); + + foreach (var id in RequiredInputIds) + { + Assert.Contains($"\"id\": \"{id}\"", template, StringComparison.Ordinal); + } + } + + [Fact] + public void BuildTemplate_EmbedsCompanionChoicesAsJsonArray() + { + var template = BuildDefaultTemplate(); + using var document = JsonDocument.Parse(template); + + var companionChoices = FindChoiceSetChoices(document.RootElement, "CompanionAppPreset"); + Assert.True(companionChoices.GetArrayLength() >= 2); + Assert.Equal("none", companionChoices[0].GetProperty("value").GetString()); + } + + [Fact] + public void BuildTemplate_EmbedsTerminalChoicesAsJsonArray() + { + var template = BuildDefaultTemplate(); + using var document = JsonDocument.Parse(template); + + var terminalChoices = FindChoiceSetChoices(document.RootElement, "LaunchTarget"); + Assert.True(terminalChoices.GetArrayLength() >= 1); + } + + [Fact] + public void BuildTemplate_IncludesSaveAndCancelActions() + { + var template = BuildDefaultTemplate(); + using var document = JsonDocument.Parse(template); + + var actions = document.RootElement.GetProperty("actions"); + var titles = actions.EnumerateArray() + .Select(action => action.GetProperty("title").GetString()) + .ToList(); + + Assert.Contains("Save workspace", titles); + Assert.Contains("Cancel", titles); + } + + [Fact] + public void BuildDataJson_ParsesAsJson() + { + var dataJson = ShortcutFormTemplateJson.BuildDataJson(new ShortcutFormTemplateJson.DataPayload + { + Name = "My App", + Directory = @"C:\Projects\My App", + CompanionAppPreset = CompanionAppCatalog.PresetCustom, + CompanionAppPath = @"C:\Apps\Code.exe", + ShowRestoredDraftNote = true, + RunAsAdmin = true, + }); + + using var document = JsonDocument.Parse(dataJson); + Assert.Equal("My App", document.RootElement.GetProperty("Name").GetString()); + Assert.True(document.RootElement.GetProperty("ShowRestoredDraftNote").GetBoolean()); + Assert.True(document.RootElement.GetProperty("ShowCompanionExecutablePath").GetBoolean()); + } + + [Fact] + public void BuildDataJson_EscapesBackslashesInDirectory() + { + var dataJson = ShortcutFormTemplateJson.BuildDataJson(new ShortcutFormTemplateJson.DataPayload + { + Directory = @"C:\Projects\demo", + }); + + JsonDocument.Parse(dataJson); + Assert.Contains(@"C:\\Projects\\demo", dataJson, StringComparison.Ordinal); + } + + [Fact] + public void BuildDataJson_IncludesLaunchCommandValues() + { + var dataJson = ShortcutFormTemplateJson.BuildDataJson( + new ShortcutFormTemplateJson.DataPayload { Name = "App" }, + ["npm run dev", "dotnet watch"]); + + using var document = JsonDocument.Parse(dataJson); + Assert.Equal("npm run dev", document.RootElement.GetProperty("LaunchCommand_0").GetString()); + Assert.Equal("dotnet watch", document.RootElement.GetProperty("LaunchCommand_1").GetString()); + } + + [Fact] + public void BuildDiscardPromptTemplate_ParsesAsJson() + { + using var document = JsonDocument.Parse(ShortcutFormTemplateJson.BuildDiscardPromptTemplate()); + var actions = document.RootElement.GetProperty("actions"); + Assert.Equal(2, actions.GetArrayLength()); + } + + [Fact] + public void AdaptiveCardFormJson_FieldGroup_DoesNotExpandNestedChoiceTokens() + { + var fragment = AdaptiveCardFormJson.FieldGroup("App preset", "help", """ + { + "type": "Input.ChoiceSet", + "id": "CompanionAppPreset", + "choices": {{companionChoices}} + } + """); + + Assert.Contains("{{companionChoices}}", fragment, StringComparison.Ordinal); + Assert.ThrowsAny(() => JsonDocument.Parse(fragment)); + } + + private static string BuildDefaultTemplate(IReadOnlyList? commands = null) + { + commands ??= [string.Empty]; + return ShortcutFormTemplateJson.BuildTemplate( + TerminalCatalog.BuildFormChoicesJson(includeDefaultChoice: true), + CompanionAppCatalog.BuildFormChoicesJson(), + commands); + } + + private static JsonElement FindChoiceSetChoices(JsonElement root, string choiceSetId) + { + foreach (var choices in EnumerateChoiceSets(root)) + { + if (string.Equals(choices.Id, choiceSetId, StringComparison.Ordinal)) + { + return choices.Choices; + } + } + + throw new InvalidOperationException($"Choice set '{choiceSetId}' was not found in template JSON."); + } + + private static IEnumerable<(string Id, JsonElement Choices)> EnumerateChoiceSets(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + if (element.TryGetProperty("type", out var type) + && type.GetString() == "Input.ChoiceSet" + && element.TryGetProperty("id", out var id) + && element.TryGetProperty("choices", out var choices)) + { + yield return (id.GetString() ?? string.Empty, choices); + } + + foreach (var property in element.EnumerateObject()) + { + foreach (var nested in EnumerateChoiceSets(property.Value)) + { + yield return nested; + } + } + + break; + + case JsonValueKind.Array: + foreach (var item in element.EnumerateArray()) + { + foreach (var nested in EnumerateChoiceSets(item)) + { + yield return nested; + } + } + + break; + } + } +} diff --git a/QuickShell.Core.Tests/ShortcutImportExportTests.cs b/QuickShell.Core.Tests/ShortcutImportExportTests.cs new file mode 100644 index 0000000..27600ba --- /dev/null +++ b/QuickShell.Core.Tests/ShortcutImportExportTests.cs @@ -0,0 +1,152 @@ +using QuickShell.Models; +using QuickShell.Services; + +namespace QuickShell.Core.Tests; + +public sealed class ShortcutImportExportTests +{ + [Fact] + public void TryExportToFile_RoundTripsLayout() + { + using var directory = new TempDataDirectory(); + using var repository = new ShortcutRepository(directory.Path); + var workspaceDirectory = Path.Combine(directory.Path, "Alpha"); + Directory.CreateDirectory(workspaceDirectory); + repository.Upsert(CreateShortcut("Alpha", workspaceDirectory)); + + var exportPath = Path.Combine(directory.Path, "export.json"); + Assert.True(repository.TryExportToFile(exportPath, out _)); + + using var fresh = new ShortcutRepository(directory.Path); + fresh.ResetAll(); + Assert.Empty(fresh.GetShortcuts()); + + var result = fresh.ImportReplace(exportPath); + Assert.True(result.Success); + Assert.Single(fresh.GetShortcuts()); + Assert.Equal("Alpha", fresh.GetShortcuts()[0].Name); + } + + [Fact] + public void ImportMerge_RenamesConflictingNames() + { + using var directory = new TempDataDirectory(); + using var repository = new ShortcutRepository(directory.Path); + var folder = Path.Combine(directory.Path, "Existing"); + Directory.CreateDirectory(folder); + repository.Upsert(CreateShortcut("Alpha", folder)); + + var importPath = Path.Combine(directory.Path, "incoming.json"); + File.WriteAllText(importPath, """ + [ + { + "Name": "Alpha", + "Directory": "C:\\\\Other" + }, + { + "Name": "Beta", + "Directory": "C:\\\\Other2" + } + ] + """); + + var result = repository.ImportMerge(importPath); + + Assert.True(result.Success); + Assert.Equal(2, result.Imported); + Assert.Equal(1, result.Renamed); + Assert.Contains(repository.GetShortcuts(), s => s.Name.Equals("Alpha Copy", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(repository.GetShortcuts(), s => s.Name.Equals("Beta", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ImportReplace_ReplacesAllShortcuts() + { + using var directory = new TempDataDirectory(); + using var repository = new ShortcutRepository(directory.Path); + var folder = Path.Combine(directory.Path, "Old"); + Directory.CreateDirectory(folder); + repository.Upsert(CreateShortcut("Old", folder)); + + var importPath = Path.Combine(directory.Path, "incoming.json"); + File.WriteAllText(importPath, """ + [ + { + "Name": "NewOnly", + "Directory": "C:\\\\New" + } + ] + """); + + var result = repository.ImportReplace(importPath); + + Assert.True(result.Success); + Assert.Single(repository.GetShortcuts()); + Assert.Equal("NewOnly", repository.GetShortcuts()[0].Name); + } + + [Fact] + public void CountImportNameConflicts_CountsOverlappingNames() + { + using var directory = new TempDataDirectory(); + using var repository = new ShortcutRepository(directory.Path); + var folder = Path.Combine(directory.Path, "Alpha"); + Directory.CreateDirectory(folder); + repository.Upsert(CreateShortcut("Alpha", folder)); + + var imported = + new[] + { + CreateShortcut("Alpha", folder), + CreateShortcut("Beta", folder), + }; + + Assert.Equal(1, repository.CountImportNameConflicts(imported)); + } + + [Fact] + public void TryReadImportFile_RejectsMissingFile() + { + using var directory = new TempDataDirectory(); + using var repository = new ShortcutRepository(directory.Path); + + Assert.False(repository.TryReadImportFile( + Path.Combine(directory.Path, "missing.json"), + out _, + out var error)); + + Assert.Contains("not found", error, StringComparison.OrdinalIgnoreCase); + } + + private static TerminalShortcut CreateShortcut(string name, string directory) => new() + { + Id = Guid.NewGuid().ToString("N"), + Name = name, + Directory = directory, + }; + + private sealed class TempDataDirectory : IDisposable + { + public TempDataDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "quickshell-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + catch + { + } + } + } +} diff --git a/QuickShell.Core.Tests/ShortcutLaunchFormJsonTests.cs b/QuickShell.Core.Tests/ShortcutLaunchFormJsonTests.cs index 97bb9cb..e863c4f 100644 --- a/QuickShell.Core.Tests/ShortcutLaunchFormJsonTests.cs +++ b/QuickShell.Core.Tests/ShortcutLaunchFormJsonTests.cs @@ -16,8 +16,8 @@ public void BuildCommandRowsJson_TwoCommands_UsesDistinctIds() Assert.Contains("LaunchCommand_0", text); Assert.Contains("LaunchCommand_1", text); - Assert.Contains("npm start", text); - Assert.Contains("dotnet watch", text); + Assert.Contains("${LaunchCommand_0}", text); + Assert.Contains("${LaunchCommand_1}", text); Assert.Contains("+ Add command", text); } diff --git a/QuickShell.Core/Services/ShortcutDraftStore.cs b/QuickShell.Core/Services/ShortcutDraftStore.cs index 76bcc3e..d44ab88 100644 --- a/QuickShell.Core/Services/ShortcutDraftStore.cs +++ b/QuickShell.Core/Services/ShortcutDraftStore.cs @@ -14,8 +14,11 @@ internal sealed partial class ShortcutDraftStore(IShortcutRepository shortcuts) private PersistedShortcutEditDraft? _cached; private bool _cacheLoaded; + private int _writeGeneration; private Task _fileIoQueue = Task.CompletedTask; + internal event Action? Cleared; + public string DraftPath => Path.Combine(_shortcuts.ConfigDirectory, "shortcut-edit-draft.json"); public bool HasPending => @@ -120,8 +123,20 @@ public void SaveIfDirty( }); } - public void Clear() => - WithLock(ClearLocked); + public void Clear() + { + string? clearedOriginalName = null; + WithLock(() => + { + clearedOriginalName = _cached?.OriginalName; + ClearLocked(); + }); + + if (!string.IsNullOrWhiteSpace(clearedOriginalName)) + { + Cleared?.Invoke(clearedOriginalName); + } + } public ShortcutSaveResult TryCommitPending(Action? onSaved) { @@ -248,7 +263,8 @@ private void WriteLocked(PersistedShortcutEditDraft draft) try { var json = JsonSerializer.Serialize(draft, ShortcutFormDraftJsonContext.Default.PersistedShortcutEditDraft); - EnqueueFileIoLocked(() => PersistDraftAsync(json)); + var generation = _writeGeneration; + EnqueueFileIoLocked(() => PersistDraftAsync(json, generation)); } catch { @@ -258,8 +274,11 @@ private void WriteLocked(PersistedShortcutEditDraft draft) private void ClearLocked() { + _writeGeneration++; _cached = null; - EnqueueFileIoLocked(DeleteDraftIfPresentAsync); + _cacheLoaded = false; + DrainFileIoQueueLocked(); + DeleteDraftFileSync(); } private void DrainFileIoQueueLocked() @@ -281,12 +300,22 @@ private void EnqueueFileIoLocked(Func operation) .Unwrap(); } - private async Task PersistDraftAsync(string json) + private async Task PersistDraftAsync(string json, int generation) { + if (generation != _writeGeneration) + { + return; + } + try { Directory.CreateDirectory(_shortcuts.ConfigDirectory); await File.WriteAllTextAsync(DraftPath, json).ConfigureAwait(false); + + if (generation != _writeGeneration) + { + DeleteDraftFileSync(); + } } catch { @@ -294,7 +323,7 @@ private async Task PersistDraftAsync(string json) } } - private Task DeleteDraftIfPresentAsync() + private void DeleteDraftFileSync() { try { @@ -307,8 +336,6 @@ private Task DeleteDraftIfPresentAsync() { // Best-effort cleanup. } - - return Task.CompletedTask; } private static bool DraftMatchesShortcut(PersistedShortcutEditDraft draft, TerminalShortcut saved) diff --git a/QuickShell.Core/Services/ShortcutFormDraftStore.cs b/QuickShell.Core/Services/ShortcutFormDraftStore.cs index 093f836..10496c5 100644 --- a/QuickShell.Core/Services/ShortcutFormDraftStore.cs +++ b/QuickShell.Core/Services/ShortcutFormDraftStore.cs @@ -156,6 +156,122 @@ public static ShortcutSaveResult Fail(string message) => internal static class ShortcutFormSave { + /// + /// Saves from the PowerToys Run simple editor. Creates use a single launch; edits update + /// shared fields and the primary (first enabled) launch while preserving other launches + /// and companion/link metadata. + /// + public static ShortcutSaveResult TrySaveRunEditor( + TerminalShortcut? existing, + string? originalName, + string name, + string abbreviation, + string directory, + string command, + string launchTarget, + bool runAsAdmin, + IShortcutRepository shortcuts, + Action? onSaved) + { + if (existing is null) + { + return TrySave( + originalName, + name, + abbreviation, + directory, + command, + launchTarget, + runAsAdmin, + shortcuts, + onSaved); + } + + if (string.IsNullOrWhiteSpace(directory)) + { + return ShortcutSaveResult.Fail("Folder path is required."); + } + + if (string.IsNullOrWhiteSpace(name)) + { + name = DeriveNameFromDirectory(directory); + } + + if (string.IsNullOrWhiteSpace(name)) + { + return ShortcutSaveResult.Fail("Name is required."); + } + + name = name.Trim(); + var resolvedName = shortcuts.ResolveAvailableName(name, originalName); + var renamedForConflict = !string.Equals(resolvedName, name, StringComparison.OrdinalIgnoreCase); + + var shortcut = CloneShortcut(existing); + shortcut.Name = resolvedName; + shortcut.Abbreviation = string.IsNullOrWhiteSpace(abbreviation) ? null : abbreviation.Trim(); + shortcut.Directory = directory.Trim(); + + ShortcutLaunchNormalization.EnsureLaunchesFromLegacy(shortcut); + var primary = GetPrimaryLaunch(shortcut); + primary.Command = string.IsNullOrWhiteSpace(command) ? null : command.Trim(); + primary.RunAsAdmin = runAsAdmin; + + var launchScratch = new TerminalShortcut(); + TerminalCatalog.ApplyLaunchTargetId(launchScratch, launchTarget); + primary.Terminal = launchScratch.Terminal; + primary.WtProfile = launchScratch.WtProfile; + + ShortcutLaunchNormalization.NormalizeShortcut(shortcut); + + if (!ShortcutValidation.TryValidate(shortcut, out var validationError)) + { + return ShortcutSaveResult.Fail(validationError); + } + + try + { + shortcuts.Upsert(shortcut, originalName); + onSaved?.Invoke(); + + var extraLaunches = shortcut.Launches.Count(entry => entry.IsEnabled) - 1; + var preservedNote = extraLaunches > 0 + ? $" ({extraLaunches} other launch{(extraLaunches == 1 ? string.Empty : "es")} preserved)" + : string.Empty; + var message = renamedForConflict + ? $"Saved workspace as '{resolvedName}' (name was already in use).{preservedNote}" + : $"Saved workspace '{resolvedName}'.{preservedNote}"; + return ShortcutSaveResult.Ok(message); + } + catch (IOException) + { + return ShortcutSaveResult.Fail("Failed to save workspace: unable to write workspace data."); + } + catch (UnauthorizedAccessException) + { + return ShortcutSaveResult.Fail("Failed to save workspace: access to workspace storage was denied."); + } + catch (InvalidOperationException) + { + return ShortcutSaveResult.Fail("Failed to save workspace: workspace data is invalid."); + } + } + + public static WorkspaceEntry GetPrimaryLaunchForRunEditor(TerminalShortcut shortcut) + { + ShortcutLaunchNormalization.EnsureLaunchesFromLegacy(shortcut); + return GetPrimaryLaunch(shortcut); + } + + public static string EncodeLaunchTargetForEntry(WorkspaceEntry entry) + { + var scratch = new TerminalShortcut + { + Terminal = entry.Terminal, + WtProfile = entry.WtProfile, + }; + return TerminalCatalog.EncodeLaunchTargetId(scratch); + } + public static ShortcutSaveResult TrySave( string? originalName, string name, @@ -300,6 +416,34 @@ private static string DeriveNameFromDirectory(string directory) var leaf = Path.GetFileName(trimmed); return string.IsNullOrWhiteSpace(leaf) ? trimmed : leaf; } + + private static WorkspaceEntry GetPrimaryLaunch(TerminalShortcut shortcut) => + shortcut.Launches + .Where(entry => entry.IsEnabled) + .OrderBy(entry => entry.Order) + .FirstOrDefault() + ?? shortcut.Launches.OrderBy(entry => entry.Order).First(); + + private static TerminalShortcut CloneShortcut(TerminalShortcut source) => new() + { + Id = source.Id, + Name = source.Name, + Abbreviation = source.Abbreviation, + Directory = source.Directory, + Command = source.Command, + Terminal = source.Terminal, + WtProfile = source.WtProfile, + RunAsAdmin = source.RunAsAdmin, + IsPinned = source.IsPinned, + PinOrder = source.PinOrder, + LastUsedUtc = source.LastUsedUtc, + Launches = source.Launches.Select(WorkspaceMapper.CloneEntry).ToList(), + DevServerUrl = source.DevServerUrl, + RepoUrl = source.RepoUrl, + OpenCompanionAppOnLaunch = source.OpenCompanionAppOnLaunch, + CompanionAppPath = source.CompanionAppPath, + CompanionAppArguments = source.CompanionAppArguments, + }; } internal sealed class ShortcutFormLaunchInput diff --git a/QuickShell.Core/Services/ShortcutFormTemplateCache.cs b/QuickShell.Core/Services/ShortcutFormTemplateCache.cs new file mode 100644 index 0000000..5d23ad5 --- /dev/null +++ b/QuickShell.Core/Services/ShortcutFormTemplateCache.cs @@ -0,0 +1,42 @@ +namespace QuickShell.Services; + +internal static class ShortcutFormTemplateCache +{ + private static readonly object Sync = new(); + + private static string? _templateJson; + private static int _commandCount = -1; + private static string? _terminalApplicationId; + + public static string GetOrBuild( + int commandCount, + string terminalApplicationId, + Func buildTemplate) + { + lock (Sync) + { + if (_templateJson is not null + && _commandCount == commandCount + && string.Equals(_terminalApplicationId, terminalApplicationId, StringComparison.OrdinalIgnoreCase)) + { + return _templateJson; + } + + var built = buildTemplate(); + _commandCount = commandCount; + _terminalApplicationId = terminalApplicationId; + _templateJson = built; + return built; + } + } + + public static void Invalidate() + { + lock (Sync) + { + _templateJson = null; + _commandCount = -1; + _terminalApplicationId = null; + } + } +} diff --git a/QuickShell.Core/Services/ShortcutFormTemplateJson.cs b/QuickShell.Core/Services/ShortcutFormTemplateJson.cs new file mode 100644 index 0000000..8ae8a55 --- /dev/null +++ b/QuickShell.Core/Services/ShortcutFormTemplateJson.cs @@ -0,0 +1,346 @@ +namespace QuickShell.Services; + +internal static class ShortcutFormTemplateJson +{ + public const string DisplayNameDefault = "Quick Shell"; + + internal sealed class DataPayload + { + public string OriginalName { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; + + public string Abbreviation { get; init; } = string.Empty; + + public string Directory { get; init; } = string.Empty; + + public string LaunchTarget { get; init; } = "default"; + + public string DevServerUrl { get; init; } = string.Empty; + + public string RepoUrl { get; init; } = string.Empty; + + public string CompanionAppPreset { get; init; } = CompanionAppCatalog.PresetNone; + + public string CompanionAppPath { get; init; } = string.Empty; + + public bool ShowRestoredDraftNote { get; init; } + + public bool RunAsAdmin { get; init; } + } + + public static string BuildTemplate( + string terminalChoices, + string companionChoices, + IReadOnlyList commands, + string displayName = DisplayNameDefault) + { + var commandRows = ShortcutLaunchFormJson.BuildCommandRowsJson(commands); + return $$""" + { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.6", + "body": [ + { + "type": "Input.Text", + "id": "OriginalName", + "isVisible": false, + "value": "${OriginalName}" + }, + { + "type": "TextBlock", + "text": "Restored unsaved changes from your last edit. Save or Cancel when you are done.", + "wrap": true, + "isSubtle": true, + "spacing": "Small", + "$when": "${ShowRestoredDraftNote}" + }, + { + "type": "Container", + "spacing": "Medium", + "items": [ + {{AdaptiveCardFormJson.FieldLabel("Folder path")}}, + {{AdaptiveCardFormJson.FieldHelp("Folder opened when you run this workspace. Browse or paste to pick a folder.")}}, + { + "type": "Input.Text", + "id": "Directory", + "isRequired": true, + "errorMessage": "Folder path is required", + "placeholder": "Type or paste a path, e.g. C:\\Projects\\MyApp", + "value": "${Directory}" + }, + { + "type": "ActionSet", + "spacing": "Small", + "actions": [ + { + "type": "Action.Submit", + "title": "Browse folder", + "data": { "action": "browse" }, + "associatedInputs": "none" + }, + { + "type": "Action.Submit", + "title": "Paste path", + "data": { "action": "paste" }, + "associatedInputs": "none" + } + ] + } + ] + }, + {{AdaptiveCardFormJson.FieldGroup("Name", $"Shown in your {displayName} list. Filled in from the folder name when you browse or paste—you can edit it.", """ + { + "type": "Input.Text", + "id": "Name", + "value": "${Name}" + } + """)}}, + {{AdaptiveCardFormJson.FieldGroup("Home keyword (optional)", "Type this at Command Palette home to jump straight to this workspace.", """ + { + "type": "Input.Text", + "id": "Abbreviation", + "placeholder": "e.g. api", + "value": "${Abbreviation}" + } + """)}}, + {{AdaptiveCardFormJson.FieldGroup("Dev server URL (optional)", "Opens in your browser when you run this workspace (e.g. http://localhost:3000). Use a launch command such as npm run dev to start the server in a terminal.", """ + { + "type": "Input.Text", + "id": "DevServerUrl", + "value": "${DevServerUrl}" + } + """)}}, + {{AdaptiveCardFormJson.FieldGroup("Repository URL (optional)", "Opens from the workspace action menu, e.g. your GitHub repo page.", """ + { + "type": "Input.Text", + "id": "RepoUrl", + "placeholder": "https://github.com/you/your-repo", + "value": "${RepoUrl}" + } + """)}}, + { + "type": "Container", + "spacing": "Medium", + "items": [ + { + "type": "Container", + "spacing": "Small", + "items": [ + {{AdaptiveCardFormJson.FieldLabel("App preset")}}, + {{AdaptiveCardFormJson.FieldHelp("Optionally open an editor or other app with this workspace folder when you run the workspace.")}}, + { + "type": "Input.ChoiceSet", + "id": "CompanionAppPreset", + "style": "compact", + "value": "${CompanionAppPreset}", + "choices": {{companionChoices}} + }, + { + "type": "ActionSet", + "spacing": "Small", + "actions": [ + { + "type": "Action.Submit", + "title": "Choose custom app…", + "tooltip": "Pick any installed application.", + "data": { "action": "browseCompanionApp" }, + "associatedInputs": "auto" + } + ] + } + ] + } + ] + }, + { + "type": "Container", + "$when": "${ShowCompanionExecutablePath}", + "spacing": "Small", + "items": [ + {{AdaptiveCardFormJson.FieldLabel("Executable")}}, + { + "type": "TextBlock", + "text": "${CompanionAppPathDisplay}", + "wrap": true + } + ] + }, + { + "type": "TextBlock", + "$when": "${ShowCompanionPathWarning}", + "text": "${CompanionPathWarning}", + "color": "Attention", + "wrap": true, + "spacing": "Small" + }, + { + "type": "TextBlock", + "text": "Commands", + "weight": "Bolder", + "spacing": "Medium" + }, + { + "type": "TextBlock", + "text": "Each command uses this workspace's terminal. Leave blank to open the folder only.", + "wrap": true, + "isSubtle": true, + "spacing": "Small" + }, + {{commandRows}}, + { + "type": "Container", + "spacing": "Medium", + "items": [ + {{AdaptiveCardFormJson.FieldLabel("Terminal profile")}}, + {{AdaptiveCardFormJson.FieldHelp("Applies to every command in this workspace.")}}, + { + "type": "Input.ChoiceSet", + "id": "LaunchTarget", + "style": "compact", + "value": "${LaunchTarget}", + "choices": {{terminalChoices}} + }, + { + "type": "ActionSet", + "spacing": "Small", + "actions": [ + { + "type": "Action.Submit", + "title": "Refresh profile list", + "tooltip": "Reload after installing a shell or editing Windows Terminal settings.", + "associatedInputs": "auto", + "data": { "action": "refreshTerminals" } + } + ] + } + ] + }, + {{AdaptiveCardFormJson.FieldGroup("Administrator", "Launch elevated. Windows may show a UAC prompt each time.", """ + { + "type": "Input.Toggle", + "id": "RunAsAdmin", + "title": "Always run as administrator", + "value": "${RunAsAdmin}", + "valueOn": "true", + "valueOff": "false" + } + """)}} + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Save workspace", + "associatedInputs": "auto" + }, + { + "type": "Action.Submit", + "title": "Cancel", + "tooltip": "Unsaved changes prompt you before leaving.", + "data": { "action": "cancel" }, + "associatedInputs": "none" + } + ] + } + """; + } + + public static string BuildDataJson(DataPayload draft, IReadOnlyList? commands = null) + { + commands ??= []; + var commandFields = string.Join( + ",\n", + commands.Select((command, index) => + $"\"LaunchCommand_{index}\": \"{Escape(command)}\"")); + + var commandSection = commandFields.Length > 0 ? ",\n" + commandFields : string.Empty; + + return $$""" + { + "OriginalName": "{{Escape(draft.OriginalName)}}", + "Name": "{{Escape(draft.Name)}}", + "Abbreviation": "{{Escape(draft.Abbreviation)}}", + "Directory": "{{Escape(draft.Directory)}}", + "LaunchTarget": "{{Escape(draft.LaunchTarget)}}", + "DevServerUrl": "{{Escape(draft.DevServerUrl)}}", + "RepoUrl": "{{Escape(draft.RepoUrl)}}", + "CompanionAppPreset": "{{Escape(draft.CompanionAppPreset)}}", + "CompanionAppPathDisplay": "{{Escape(draft.CompanionAppPath)}}", + "ShowCompanionExecutablePath": {{(CompanionAppCatalog.ShouldShowExecutablePath(draft.CompanionAppPreset, draft.CompanionAppPath) ? "true" : "false")}}, + "ShowCompanionPathWarning": {{(CompanionAppCatalog.ShouldShowPathWarning(draft.CompanionAppPreset, draft.CompanionAppPath) ? "true" : "false")}}, + "CompanionPathWarning": "{{Escape(CompanionAppCatalog.BuildPathWarning(draft.CompanionAppPreset, draft.CompanionAppPath))}}", + "RunAsAdmin": "{{(draft.RunAsAdmin ? "true" : "false")}}", + "ShowRestoredDraftNote": {{(draft.ShowRestoredDraftNote ? "true" : "false")}}{{commandSection}} + } + """; + } + + public static string BuildDiscardPromptTemplate() => + """ + { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.6", + "body": [ + { + "type": "TextBlock", + "text": "Unsaved changes", + "weight": "Bolder", + "size": "Medium" + }, + { + "type": "TextBlock", + "text": "Save your changes, or discard them and leave?", + "wrap": true + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Save and close", + "data": { "action": "save" }, + "associatedInputs": "none" + }, + { + "type": "Action.Submit", + "title": "Discard", + "data": { "action": "discard" }, + "associatedInputs": "none" + } + ] + } + """; + + private static string Escape(string? value) => + (value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\""); + + /// + /// Choice arrays and command rows must be interpolated in the outer template scope. + /// Nested raw strings (e.g. FieldGroup input fragments) cannot expand {{tokens}}. + /// + public static void AssertRenderableTemplate(string templateJson) + { + if (string.IsNullOrWhiteSpace(templateJson)) + { + throw new InvalidOperationException("Workspace form template is empty."); + } + + foreach (var token in new[] + { + "{{companionChoices}}", + "{{terminalChoices}}", + "{{commandRows}}", + "{{AdaptiveCardFormJson", + "{{SettingsCardJson", + "{{Escape(", + }) + { + if (templateJson.Contains(token, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Workspace form template contains unexpanded build token '{token}'."); + } + } + } +} diff --git a/QuickShell.Core/Services/ShortcutLaunchFormJson.cs b/QuickShell.Core/Services/ShortcutLaunchFormJson.cs index d5df1db..1bcd22a 100644 --- a/QuickShell.Core/Services/ShortcutLaunchFormJson.cs +++ b/QuickShell.Core/Services/ShortcutLaunchFormJson.cs @@ -25,7 +25,6 @@ public static string BuildCommandRowsJson(IReadOnlyList commands) var blocks = new List(); for (var i = 0; i < commands.Count; i++) { - var escapedCommand = Escape(commands[i]); var removeBlock = commands.Count > 1 ? $$""" ,{ @@ -59,7 +58,7 @@ public static string BuildCommandRowsJson(IReadOnlyList commands) "type": "Input.Text", "id": "LaunchCommand_{{i}}", "placeholder": "Optional command or script", - "value": "{{escapedCommand}}" + "value": "${LaunchCommand_{{i}}}" } {{removeBlock}} ] diff --git a/QuickShell.Core/Services/WorkspaceValidation.cs b/QuickShell.Core/Services/WorkspaceValidation.cs index 3b10652..b0239d9 100644 --- a/QuickShell.Core/Services/WorkspaceValidation.cs +++ b/QuickShell.Core/Services/WorkspaceValidation.cs @@ -85,8 +85,8 @@ public static WorkspaceLoadResult NormalizeForLoad( { needsFolderRepair = true; directoryWarning = shortcut is null - ? "Legacy project shortcut was not found." - : "Legacy project shortcut directory could not be normalized."; + ? "Legacy workspace shortcut was not found." + : "Legacy workspace shortcut directory could not be normalized."; } } else diff --git a/QuickShell/Commands/DeleteShortcutCommand.cs b/QuickShell/Commands/DeleteShortcutCommand.cs index 4fb1d50..635428f 100644 --- a/QuickShell/Commands/DeleteShortcutCommand.cs +++ b/QuickShell/Commands/DeleteShortcutCommand.cs @@ -25,6 +25,6 @@ public override CommandResult Invoke() return QuickShellNavigation.StayOpen($"Deleted workspace '{_name}'."); } - return QuickShellNavigation.StayOpen($"Project '{_name}' was not found."); + return QuickShellNavigation.StayOpen($"Workspace '{_name}' was not found."); } } diff --git a/QuickShell/Commands/DuplicateShortcutCommand.cs b/QuickShell/Commands/DuplicateShortcutCommand.cs index 62e887e..98c91ae 100644 --- a/QuickShell/Commands/DuplicateShortcutCommand.cs +++ b/QuickShell/Commands/DuplicateShortcutCommand.cs @@ -1,31 +1,21 @@ using Microsoft.CommandPalette.Extensions.Toolkit; +using QuickShell.Pages; using QuickShell.Services; namespace QuickShell.Commands; -internal sealed partial class DuplicateShortcutCommand : InvokableCommand +/// +/// Opens the workspace editor prefilled from a duplicate. The copy is not saved until +/// the user confirms in the form (matches PowerToys Run duplicate behavior). +/// +internal sealed partial class DuplicateShortcutCommand : ShortcutFormPage { - private readonly string _sourceName; - private readonly Action _onChanged; - - public DuplicateShortcutCommand(string sourceName, Action onChanged) + public DuplicateShortcutCommand(string sourceName, Action onSaved) + : base(existing: null, onSaved, createSeed: QuickShellRuntimeServices.Shortcuts.BuildDuplicate(sourceName)) { - _sourceName = sourceName; - _onChanged = onChanged; + Id = $"com.quickshell.shortcut-form.duplicate.{Guid.NewGuid():N}"; Name = "Duplicate"; - Icon = new IconInfo("\uE8C8"); - } - - public override CommandResult Invoke() - { - var duplicate = QuickShellRuntimeServices.Shortcuts.BuildDuplicate(_sourceName); - if (duplicate is null) - { - return QuickShellNavigation.StayOpen($"Project '{_sourceName}' was not found."); - } - - QuickShellRuntimeServices.Shortcuts.Upsert(duplicate); - _onChanged(); - return QuickShellNavigation.StayOpen($"Duplicated as '{duplicate.Name}'."); + Icon = new IconInfo(ShortcutGlyphs.Duplicate); + Title = "Duplicate workspace"; } } diff --git a/QuickShell/Commands/ShortcutLaunchCommands.cs b/QuickShell/Commands/ShortcutLaunchCommands.cs deleted file mode 100644 index de7f657..0000000 --- a/QuickShell/Commands/ShortcutLaunchCommands.cs +++ /dev/null @@ -1,146 +0,0 @@ -using Microsoft.CommandPalette.Extensions.Toolkit; -using QuickShell.Models; -using QuickShell.Services; - -namespace QuickShell.Commands; - -internal sealed partial class MoveShortcutLaunchCommand : InvokableCommand -{ - private readonly TerminalShortcut _shortcut; - private readonly string _launchId; - private readonly int _direction; - private readonly Action _onChanged; - - public MoveShortcutLaunchCommand( - TerminalShortcut shortcut, - string launchId, - int direction, - Action onChanged) - { - _shortcut = shortcut; - _launchId = launchId; - _direction = direction; - _onChanged = onChanged; - Name = direction < 0 ? "Move up" : "Move down"; - Icon = new IconInfo(direction < 0 ? "\uE70E" : "\uE70D"); - } - - public override CommandResult Invoke() - { - ShortcutEditorState.MoveLaunch(_shortcut, _launchId, _direction); - _onChanged(_shortcut); - return QuickShellNavigation.StayOpen(); - } -} - -internal sealed partial class RemoveShortcutLaunchCommand : InvokableCommand -{ - private readonly TerminalShortcut _shortcut; - private readonly string _launchId; - private readonly Action _onChanged; - - public RemoveShortcutLaunchCommand( - TerminalShortcut shortcut, - string launchId, - Action onChanged) - { - _shortcut = shortcut; - _launchId = launchId; - _onChanged = onChanged; - Name = "Remove"; - Icon = new IconInfo("\uE74D"); - } - - public override CommandResult Invoke() - { - ShortcutLaunchNormalization.EnsureLaunchesFromLegacy(_shortcut); - _shortcut.Launches.RemoveAll(entry => entry.Id.Equals(_launchId, StringComparison.OrdinalIgnoreCase)); - for (var i = 0; i < _shortcut.Launches.Count; i++) - { - _shortcut.Launches[i].Order = i; - } - - _onChanged(_shortcut); - return QuickShellNavigation.StayOpen(); - } -} - -internal sealed partial class SaveShortcutEditorCommand : InvokableCommand -{ - private readonly TerminalShortcut _shortcut; - private readonly string? _originalName; - private readonly Action _onSaved; - - public SaveShortcutEditorCommand(TerminalShortcut shortcut, string? originalName, Action onSaved) - { - _shortcut = shortcut; - _originalName = originalName; - _onSaved = onSaved; - Name = "Save workspace"; - Icon = new IconInfo("\uE74E"); - } - - public override CommandResult Invoke() - { - ShortcutLaunchNormalization.EnsureLaunchesFromLegacy(_shortcut); - var launchInputs = _shortcut.Launches - .OrderBy(entry => entry.Order) - .Select(entry => new ShortcutFormLaunchInput - { - Id = entry.Id, - Label = entry.Label, - Command = entry.Command ?? string.Empty, - LaunchTarget = TerminalCatalog.EncodeLaunchTargetId(new TerminalShortcut - { - Terminal = entry.Terminal, - WtProfile = entry.WtProfile, - }), - RunAsAdmin = entry.RunAsAdmin, - IsEnabled = entry.IsEnabled, - }) - .ToList(); - - var result = ShortcutFormSave.TrySave( - _originalName, - _shortcut.Name, - _shortcut.Abbreviation ?? string.Empty, - _shortcut.Directory, - launchInputs, - QuickShellRuntimeServices.Shortcuts, - _onSaved); - - if (!result.Success) - { - return QuickShellNavigation.StayOpen(result.Message); - } - - QuickShellRuntimeServices.Drafts.Clear(); - return QuickShellNavigation.ReturnHome(result.Message); - } -} - -internal sealed partial class EditShortcutCommand : InvokableCommand -{ - private readonly TerminalShortcut _shortcut; - private readonly Action _onChanged; - - public EditShortcutCommand(TerminalShortcut shortcut, Action onChanged) - { - _shortcut = shortcut; - _onChanged = onChanged; - Name = "Edit"; - Icon = new IconInfo("\uE70F"); - } - - public override CommandResult Invoke() - { - ShortcutEditorNavigationState.SetEditor( - ShortcutEditorState.CloneShortcut(_shortcut), - _shortcut.Name, - _onChanged); - return CommandResult.GoToPage(new GoToPageArgs - { - PageId = Pages.ShortcutEditorPage.PageId, - }); - } -} diff --git a/QuickShell/Pages/ImportConflictPage.cs b/QuickShell/Pages/ImportConflictPage.cs index 89720ff..1091027 100644 --- a/QuickShell/Pages/ImportConflictPage.cs +++ b/QuickShell/Pages/ImportConflictPage.cs @@ -80,21 +80,21 @@ public ImportConflictForm(Action onReload, Action? onSettingsChanged = null) { "type": "Action.Submit", "title": "Merge (rename duplicates)", - "tooltip": "Keep your projects and add imported ones. Duplicate names become \"Name Copy\", \"Name Copy 2\", and so on.", + "tooltip": "Keep your workspaces and add imported ones. Duplicate names become \"Name Copy\", \"Name Copy 2\", and so on.", "data": { "action": "merge" }, "associatedInputs": "none" }, { "type": "Action.Submit", "title": "Replace all", - "tooltip": "Delete every project you have now (including favorites) and replace them with the imported file only.", + "tooltip": "Delete every workspace you have now (including favorites) and replace them with the imported file only.", "data": { "action": "replace" }, "associatedInputs": "none" }, { "type": "Action.Submit", "title": "Cancel import", - "tooltip": "Discard this import file and keep your projects unchanged.", + "tooltip": "Discard this import file and keep your workspaces unchanged.", "data": { "action": "cancel" }, "associatedInputs": "none" } diff --git a/QuickShell/Pages/PendingShortcutEditPage.cs b/QuickShell/Pages/PendingShortcutEditPage.cs index 3c93463..6e3956d 100644 --- a/QuickShell/Pages/PendingShortcutEditPage.cs +++ b/QuickShell/Pages/PendingShortcutEditPage.cs @@ -16,7 +16,7 @@ public PendingShortcutEditPage(Action onReload) _onReload = onReload; Id = PageId; Icon = new IconInfo("\uE7BA"); - Title = "Unsaved project changes"; + Title = "Unsaved workspace changes"; Name = "Resume edit"; } @@ -41,7 +41,7 @@ public PendingShortcutEditForm(Action onReload, Action? onSettingsChanged = null "body": [ { "type": "TextBlock", - "text": "Unsaved project changes", + "text": "Unsaved workspace changes", "weight": "Bolder", "size": "Large" }, @@ -53,7 +53,7 @@ public PendingShortcutEditForm(Action onReload, Action? onSettingsChanged = null }, { "type": "TextBlock", - "text": "Save applies the same validation as the edit form. If required fields are missing, fix them by editing the project again.", + "text": "Save applies the same validation as the edit form. If required fields are missing, fix them by editing the workspace again.", "wrap": true, "isSubtle": true, "spacing": "Medium" @@ -92,7 +92,7 @@ private CommandResult HandleSubmit(string? action) QuickShellRuntimeServices.Drafts.Clear(); _onReload(); _onSettingsChanged?.Invoke(); - return QuickShellNavigation.StayOnSettings("Discarded unsaved project changes."); + return QuickShellNavigation.StayOnSettings("Discarded unsaved workspace changes."); } if (action != "save") @@ -105,7 +105,7 @@ private CommandResult HandleSubmit(string? action) { _onReload(); _onSettingsChanged?.Invoke(); - return QuickShellNavigation.StayOnSettings("No unsaved project edit is pending."); + return QuickShellNavigation.StayOnSettings("No unsaved workspace edit is pending."); } var result = QuickShellRuntimeServices.Drafts.TryCommitPending(_onReload); @@ -125,7 +125,7 @@ private void ApplyPendingState() { DataJson = """ { - "Description": "No unsaved project edit is waiting for a decision." + "Description": "No unsaved workspace edit is waiting for a decision." } """; return; @@ -133,7 +133,7 @@ private void ApplyPendingState() var description = $"You left editing \"{pending.OriginalName}\" with unsaved changes. " + - "Save them to your projects, or discard them."; + "Save them to your workspaces, or discard them."; DataJson = $$""" { diff --git a/QuickShell/Pages/ShortcutEditorPage.cs b/QuickShell/Pages/ShortcutEditorPage.cs deleted file mode 100644 index 8269013..0000000 --- a/QuickShell/Pages/ShortcutEditorPage.cs +++ /dev/null @@ -1,181 +0,0 @@ -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using QuickShell.Commands; -using QuickShell.Models; -using QuickShell.Services; - -namespace QuickShell.Pages; - -internal sealed partial class ShortcutEditorPage : DynamicListPage -{ - public const string PageId = "com.quickshell.shortcut.editor"; - - private readonly TerminalShortcut _shortcut; - private readonly string? _originalName; - private readonly Action _onSaved; - private IListItem[] _items = []; - - public ShortcutEditorPage() - { - if (!ShortcutEditorNavigationState.TryTakeEditor(out var shortcut, out var originalName, out var onSaved)) - { - shortcut = ShortcutEditorState.CreateNew(); - onSaved = () => { }; - originalName = null; - } - - _shortcut = shortcut; - _originalName = originalName; - _onSaved = onSaved; - - Id = PageId; - Icon = QuickShellBrandIcons.App; - Title = _originalName is null ? "Create workspace" : $"Edit {_shortcut.Name}"; - Name = _originalName is null ? "Create" : "Edit"; - RefreshItems(); - } - - public ShortcutEditorPage(TerminalShortcut shortcut, string? originalName, Action onSaved) - { - _shortcut = ShortcutEditorState.CloneShortcut(shortcut); - _originalName = originalName; - _onSaved = onSaved; - - Id = PageId; - Icon = QuickShellBrandIcons.App; - Title = _originalName is null ? "Create workspace" : $"Edit {_shortcut.Name}"; - Name = _originalName is null ? "Create" : "Edit"; - RefreshItems(); - } - - public static ShortcutEditorPage ForCreate(Action onSaved) - { - var page = new ShortcutEditorPage(ShortcutEditorState.CreateNew(), originalName: null, onSaved); - page.Id = ShortcutCommandIds.CreateShortcut; - page.Title = "Create workspace"; - page.Name = "Create"; - return page; - } - - public override IListItem[] GetItems() => _items; - - public override void UpdateSearchText(string oldSearch, string newSearch) - { - } - - private void RefreshItems() - { - ShortcutLaunchNormalization.EnsureLaunchesFromLegacy(_shortcut); - var folderHint = string.IsNullOrWhiteSpace(_shortcut.Directory) - ? "Choose a folder" - : ShortcutDisplay.ShortenPathForDisplay(_shortcut.Directory); - - var items = new List - { - new ListItem(new ShortcutDetailsFormPage(_shortcut, ApplyShortcutChange)) - { - Title = string.IsNullOrWhiteSpace(_shortcut.Name) ? "Workspace details" : _shortcut.Name, - Subtitle = folderHint, - Icon = new IconInfo("\uE70F"), - }, - new Separator("Terminals"), - }; - - var orderedLaunches = _shortcut.Launches.OrderBy(entry => entry.Order).ToList(); - for (var i = 0; i < orderedLaunches.Count; i++) - { - var launch = orderedLaunches[i]; - var moveUp = i > 0 - ? new MoveShortcutLaunchCommand(_shortcut, launch.Id, -1, ApplyShortcutChange) - : null; - var moveDown = i < orderedLaunches.Count - 1 - ? new MoveShortcutLaunchCommand(_shortcut, launch.Id, 1, ApplyShortcutChange) - : null; - - var moreCommands = new List - { - new(new ShortcutLaunchFormPage(_shortcut, launch, ApplyShortcutChange)) - { - Title = "Edit", - Icon = new IconInfo("\uE70F"), - }, - }; - - if (moveUp is not null) - { - moreCommands.Add(new CommandContextItem(moveUp) { Title = "Move up", Icon = new IconInfo("\uE70E") }); - } - - if (moveDown is not null) - { - moreCommands.Add(new CommandContextItem(moveDown) { Title = "Move down", Icon = new IconInfo("\uE70D") }); - } - - if (orderedLaunches.Count > 1) - { - moreCommands.Add(new CommandContextItem(new RemoveShortcutLaunchCommand(_shortcut, launch.Id, ApplyShortcutChange)) - { - Title = "Remove", - Icon = new IconInfo("\uE74D"), - IsCritical = true, - }); - } - - items.Add(new ListItem(new ShortcutLaunchFormPage(_shortcut, launch, ApplyShortcutChange)) - { - Title = launch.Label, - Subtitle = ShortcutDisplay.BuildLaunchEntrySubtitle(launch), - Icon = new IconInfo(launch.IsEnabled ? TerminalLaunchGlyphs.GetForLaunch(launch) : "\uE7BA"), - MoreCommands = moreCommands.ToArray(), - }); - } - - items.Add(new ListItem(new AddShortcutLaunchCommand(_shortcut, ApplyShortcutChange)) - { - Title = "+ Add terminal", - Subtitle = "Open another terminal when this workspace runs", - Icon = new IconInfo("\uE710"), - }); - - items.Add(new ListItem(new SaveShortcutEditorCommand(_shortcut, _originalName, _onSaved)) - { - Title = "Save workspace", - Subtitle = "Apply changes and return to home", - Icon = new IconInfo("\uE74E"), - }); - - _items = items.ToArray(); - RaiseItemsChanged(); - } - - private void ApplyShortcutChange(TerminalShortcut shortcut) => RefreshItems(); -} - -internal sealed partial class AddShortcutLaunchCommand : InvokableCommand -{ - private readonly TerminalShortcut _shortcut; - private readonly Action _onChanged; - - public AddShortcutLaunchCommand(TerminalShortcut shortcut, Action onChanged) - { - _shortcut = shortcut; - _onChanged = onChanged; - Name = "Add"; - Icon = new IconInfo("\uE710"); - } - - public override CommandResult Invoke() - { - ShortcutLaunchNormalization.EnsureLaunchesFromLegacy(_shortcut); - var nextNumber = _shortcut.Launches.Count + 1; - var nextOrder = _shortcut.Launches.Count == 0 - ? 0 - : _shortcut.Launches.Max(entry => entry.Order) + 1; - var launch = ShortcutEditorState.CreateLaunch($"Terminal {nextNumber}", null, nextOrder); - ShortcutEditorNavigationState.SetLaunchForm(_shortcut, launch, _onChanged, isNew: true); - return CommandResult.GoToPage(new GoToPageArgs - { - PageId = ShortcutLaunchFormPage.PageId, - }); - } -} diff --git a/QuickShell/Pages/ShortcutFormPage.cs b/QuickShell/Pages/ShortcutFormPage.cs index dcccb62..7720d2f 100644 --- a/QuickShell/Pages/ShortcutFormPage.cs +++ b/QuickShell/Pages/ShortcutFormPage.cs @@ -20,7 +20,7 @@ public ShortcutFormPage(TerminalShortcut? existing = null, Action? onSaved = nul var isCreate = _existing is null; Id = isCreate ? $"com.quickshell.shortcut-form.create.{Guid.NewGuid():N}" - : $"com.quickshell.shortcut-form.edit.{Guid.NewGuid():N}"; + : $"com.quickshell.shortcut-form.edit.{_existing!.Id}"; Icon = new IconInfo("\uE70F"); Title = isCreate ? "New workspace" : $"Edit {_existing!.Name}"; Name = isCreate ? "Create" : "Edit"; @@ -53,7 +53,6 @@ public override IContent[] GetContent() => LastUsedUtc = shortcut.LastUsedUtc, Launches = shortcut.Launches.Select(WorkspaceMapper.CloneEntry).ToList(), DevServerUrl = shortcut.DevServerUrl, - OpenDevServerOnLaunch = shortcut.OpenDevServerOnLaunch, RepoUrl = shortcut.RepoUrl, OpenCompanionAppOnLaunch = shortcut.OpenCompanionAppOnLaunch, CompanionAppPath = shortcut.CompanionAppPath, @@ -69,10 +68,13 @@ internal sealed partial class ShortcutForm : FormContent private FormDraft _draft = new(); private FormDraft _baselineDraft = new(); private string? _autoFilledName; + private string? _autoFilledLaunchCommand; private bool _nameCustomized; private bool _showingDiscardPrompt; private bool _baselineReady; private bool _showRestoredDraftNote; + private bool _subscribedToDraftCleared; + private int _templateCommandCount = -1; public ShortcutForm(TerminalShortcut? existing, TerminalShortcut? createSeed, Action? onSaved, Action? releaseForm = null) { @@ -83,7 +85,11 @@ public ShortcutForm(TerminalShortcut? existing, TerminalShortcut? createSeed, Ac var initial = existing ?? createSeed; var launchTarget = TerminalCatalog.EncodeLaunchTargetId(initial ?? new TerminalShortcut()); var commands = ShortcutFormLaunchSection.CommandsFromShortcut(initial); - RebuildTemplate(commands); + + var companion = CompanionAppCatalog.ReconcileStoredShortcut( + initial?.OpenCompanionAppOnLaunch ?? false, + initial?.CompanionAppPath, + initial?.CompanionAppArguments); ApplyDraft(new FormDraft { @@ -93,11 +99,10 @@ public ShortcutForm(TerminalShortcut? existing, TerminalShortcut? createSeed, Ac Directory = initial?.Directory ?? string.Empty, DevServerUrl = initial?.DevServerUrl ?? string.Empty, RepoUrl = initial?.RepoUrl ?? string.Empty, - OpenDevServerOnLaunch = initial?.OpenDevServerOnLaunch ?? false, - OpenCompanionAppOnLaunch = initial?.OpenCompanionAppOnLaunch ?? false, - CompanionAppPreset = CompanionAppCatalog.InferPresetFromPath(initial?.CompanionAppPath), - CompanionAppPath = initial?.CompanionAppPath ?? string.Empty, - CompanionAppArguments = initial?.CompanionAppArguments ?? string.Empty, + OpenCompanionAppOnLaunch = companion.LaunchOnWorkspaceOpen, + CompanionAppPreset = companion.Preset, + CompanionAppPath = companion.Path, + CompanionAppArguments = companion.Arguments, Commands = commands, LaunchTarget = launchTarget, RunAsAdmin = initial?.RunAsAdmin ?? false, @@ -105,6 +110,73 @@ public ShortcutForm(TerminalShortcut? existing, TerminalShortcut? createSeed, Ac _baselineDraft = CloneDraft(_draft); _baselineReady = true; TryRestoreEditDraft(); + + if (_originalName is not null) + { + QuickShellRuntimeServices.Drafts.Cleared += OnDraftStoreCleared; + _subscribedToDraftCleared = true; + } + } + + private void OnDraftStoreCleared(string originalName) + { + if (_originalName is null + || !string.Equals(originalName, _originalName, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + ResetToSavedBaseline(); + } + + private void ResetToSavedBaseline() + { + var saved = QuickShellRuntimeServices.Shortcuts.GetByName(_originalName!); + if (saved is null) + { + return; + } + + _showingDiscardPrompt = false; + _showRestoredDraftNote = false; + _nameCustomized = false; + _autoFilledName = null; + + var launchTarget = TerminalCatalog.EncodeLaunchTargetId(saved); + var commands = ShortcutFormLaunchSection.CommandsFromShortcut(saved); + var companion = CompanionAppCatalog.ReconcileStoredShortcut( + saved.OpenCompanionAppOnLaunch, + saved.CompanionAppPath, + saved.CompanionAppArguments); + + ApplyDraft(new FormDraft + { + OriginalName = saved.Name, + Name = saved.Name, + Abbreviation = saved.Abbreviation ?? string.Empty, + Directory = saved.Directory, + DevServerUrl = saved.DevServerUrl ?? string.Empty, + RepoUrl = saved.RepoUrl ?? string.Empty, + OpenCompanionAppOnLaunch = companion.LaunchOnWorkspaceOpen, + CompanionAppPreset = companion.Preset, + CompanionAppPath = companion.Path, + CompanionAppArguments = companion.Arguments, + Commands = commands, + LaunchTarget = launchTarget, + RunAsAdmin = saved.RunAsAdmin, + }, persist: false); + _baselineDraft = CloneDraft(_draft); + } + + private void UnsubscribeFromDraftCleared() + { + if (!_subscribedToDraftCleared) + { + return; + } + + QuickShellRuntimeServices.Drafts.Cleared -= OnDraftStoreCleared; + _subscribedToDraftCleared = false; } private void CaptureInputs(string payload) @@ -114,8 +186,13 @@ private void CaptureInputs(string payload) return; } - if (MergeDraftFromInputs(payload)) + if (MergeDraftFromInputs(payload, out var refreshForm)) { + if (refreshForm) + { + PublishDataJson(_draft); + } + PersistEditDraftIfNeeded(); } } @@ -147,6 +224,11 @@ private void TryRestoreEditDraft() commands[0].Command = restored.Command; } + var companion = CompanionAppCatalog.ReconcileStoredShortcut( + restored.OpenCompanionAppOnLaunch, + restored.CompanionAppPath, + restored.CompanionAppArguments); + ApplyDraft(new FormDraft { OriginalName = restored.OriginalName, @@ -155,11 +237,10 @@ private void TryRestoreEditDraft() Directory = restored.Directory, DevServerUrl = restored.DevServerUrl, RepoUrl = restored.RepoUrl, - OpenDevServerOnLaunch = restored.OpenDevServerOnLaunch, - OpenCompanionAppOnLaunch = restored.OpenCompanionAppOnLaunch, - CompanionAppPreset = restored.CompanionAppPreset, - CompanionAppPath = restored.CompanionAppPath, - CompanionAppArguments = restored.CompanionAppArguments, + OpenCompanionAppOnLaunch = companion.LaunchOnWorkspaceOpen, + CompanionAppPreset = companion.Preset, + CompanionAppPath = companion.Path, + CompanionAppArguments = companion.Arguments, Commands = commands, LaunchTarget = restored.LaunchTarget, RunAsAdmin = restored.RunAsAdmin, @@ -182,14 +263,14 @@ public override CommandResult SubmitForm(string inputs, string data) return CommandResult.KeepOpen(); } - if (IsBrowseAppAction(inputs, data)) + if (IsBrowseAction(inputs, data)) { - return HandleBrowseApp(inputs); + return HandleBrowse(inputs); } - if (IsBrowseAction(inputs, data)) + if (IsBrowseCompanionAppAction(inputs, data)) { - return HandleBrowse(inputs); + return HandleBrowseCompanionApp(inputs); } if (IsPasteAction(inputs, data)) @@ -234,14 +315,14 @@ public override CommandResult SubmitForm(string payload) return CommandResult.KeepOpen(); } - if (IsBrowseAppAction(payload, null)) + if (IsBrowseAction(payload, null)) { - return HandleBrowseApp(payload); + return HandleBrowse(payload); } - if (IsBrowseAction(payload, null)) + if (IsBrowseCompanionAppAction(payload, null)) { - return HandleBrowse(payload); + return HandleBrowseCompanionApp(payload); } if (IsPasteAction(payload, null)) @@ -274,7 +355,7 @@ public override CommandResult SubmitForm(string payload) private CommandResult HandleAddLaunch(string inputs) { - MergeDraftFromInputs(inputs); + MergeDraftFromInputs(inputs, out _); _draft.Commands.Add(new ShortcutFormLaunchSection.CommandRowDraft()); ApplyDraft(_draft); return QuickShellNavigation.StayOpen("Added command row."); @@ -282,7 +363,7 @@ private CommandResult HandleAddLaunch(string inputs) private CommandResult HandleRemoveLaunch(string inputs, int index) { - MergeDraftFromInputs(inputs); + MergeDraftFromInputs(inputs, out _); if (index >= 0 && index < _draft.Commands.Count && _draft.Commands.Count > 1) { _draft.Commands.RemoveAt(index); @@ -292,54 +373,68 @@ private CommandResult HandleRemoveLaunch(string inputs, int index) return QuickShellNavigation.StayOpen(); } - private void RebuildTemplate(IReadOnlyList commands) => - TemplateJson = BuildTemplateJson( - FormTerminalChoicesJson(), - CompanionAppCatalog.BuildFormChoicesJson(), - commands); - - private CommandResult HandleRefreshTerminals(string inputs) + private void RebuildTemplate(List commands) { - MergeDraftFromInputs(inputs); - - TerminalCatalog.InvalidateCache(); - - var targets = TerminalCatalog.GetLaunchTargets(includeDefaultChoice: true); - if (!targets.Any(t => t.Id.Equals(_draft.LaunchTarget, StringComparison.OrdinalIgnoreCase))) - { - _draft.LaunchTarget = "default"; - } - - ApplyDraft(_draft); - return QuickShellNavigation.StayOpen("Terminal list refreshed."); + var terminalApplicationId = + QuickShellRuntimeServices.Settings?.TerminalApplicationId ?? TerminalHostIds.WindowsTerminal; + var commandCount = Math.Max(1, commands.Count); + TemplateJson = ShortcutFormTemplateCache.GetOrBuild( + commandCount, + terminalApplicationId, + () => ShortcutFormTemplateJson.BuildTemplate( + FormTerminalChoicesJson(), + CompanionAppCatalog.BuildFormChoicesJson(), + commands.Select(command => command.Command).ToList(), + QuickShellBrand.DisplayName)); } - private CommandResult HandleBrowseApp(string inputs) + private CommandResult HandleBrowseCompanionApp(string inputs) { - MergeDraftFromInputs(inputs); + MergeDraftFromInputs(inputs, out _); + return TryBrowseCustomCompanion(_draft.CompanionAppPreset); + } + private CommandResult TryBrowseCustomCompanion(string revertPreset) + { var selected = ShortcutFilePickerService.PickExecutableFile(); if (selected is null) { return CommandResult.KeepOpen(); } - _draft.CompanionAppPath = selected; - _draft.CompanionAppPreset = CompanionAppCatalog.PresetCustom; - if (string.IsNullOrWhiteSpace(_draft.CompanionAppArguments)) + var args = string.IsNullOrWhiteSpace(_draft.CompanionAppArguments) + ? CompanionAppCatalog.GetDefaultArguments(CompanionAppCatalog.InferPresetFromPath(selected)) + : _draft.CompanionAppArguments; + ApplyCompanionFormState(CompanionAppCatalog.ReconcileForForm( + CompanionAppCatalog.PresetCustom, + selected, + args)); + PublishDataJson(_draft); + PersistEditDraftIfNeeded(); + return QuickShellNavigation.StayOpen(); + } + + private CommandResult HandleRefreshTerminals(string inputs) + { + MergeDraftFromInputs(inputs, out _); + + TerminalCatalog.InvalidateCache(); + ShortcutFormTemplateCache.Invalidate(); + + var targets = TerminalCatalog.GetLaunchTargets(includeDefaultChoice: true); + if (!targets.Any(t => t.Id.Equals(_draft.LaunchTarget, StringComparison.OrdinalIgnoreCase))) { - _draft.CompanionAppArguments = CompanionAppCatalog.GetDefaultArguments( - CompanionAppCatalog.InferPresetFromPath(selected)); + _draft.LaunchTarget = "default"; } - ApplyDraft(_draft); - return QuickShellNavigation.StayOpen(); + ApplyDraft(_draft, forceTemplateRebuild: true); + return QuickShellNavigation.StayOpen("Terminal list refreshed."); } private CommandResult HandleBrowse(string inputs) { var initialDirectory = GetFieldFromPayload(inputs, "Directory") ?? _draft.Directory; - MergeDraftFromInputs(inputs, excludeDirectory: true); + MergeDraftFromInputs(inputs, out _, excludeDirectory: true); var selected = FolderPickerService.PickFolder( string.IsNullOrWhiteSpace(initialDirectory) ? null : initialDirectory); @@ -354,7 +449,7 @@ private CommandResult HandleBrowse(string inputs) private CommandResult HandlePaste(string inputs) { - MergeDraftFromInputs(inputs, excludeDirectory: true); + MergeDraftFromInputs(inputs, out _, excludeDirectory: true); if (!TryReadClipboardFolderPath(out var pasted, out var error)) { @@ -390,15 +485,17 @@ private void ApplyDirectorySelection(string directory) _draft.DevServerUrl = DevServerUrlDetection.TryDetectDevServerUrl(normalized) ?? string.Empty; } + TryAutofillLaunchCommand(normalized); + if (string.IsNullOrWhiteSpace(_draft.CompanionAppPath)) { var suggestion = CompanionAppDetection.TrySuggestFromDirectory(normalized); if (suggestion is not null) { - _draft.CompanionAppPreset = suggestion.PresetId; - _draft.CompanionAppPath = suggestion.ExecutablePath ?? string.Empty; - _draft.CompanionAppArguments = suggestion.Arguments; - _draft.OpenCompanionAppOnLaunch = suggestion.EnableOnLaunch; + ApplyCompanionFormState(CompanionAppCatalog.ReconcileForForm( + suggestion.PresetId, + suggestion.ExecutablePath, + suggestion.Arguments)); } } @@ -429,6 +526,43 @@ private bool ShouldAutofillNameFromDirectory() StringComparison.OrdinalIgnoreCase); } + private void TryAutofillLaunchCommand(string directory) + { + if (_draft.Commands.Count == 0) + { + _draft.Commands.Add(new ShortcutFormLaunchSection.CommandRowDraft()); + } + + var firstCommand = _draft.Commands[0].Command; + if (!ShouldAutofillLaunchCommand(firstCommand)) + { + return; + } + + var detected = DevServerUrlDetection.TryDetectDevLaunchCommand(directory); + if (string.IsNullOrWhiteSpace(detected)) + { + return; + } + + _draft.Commands[0].Command = detected; + _autoFilledLaunchCommand = detected; + } + + private bool ShouldAutofillLaunchCommand(string command) + { + if (string.IsNullOrWhiteSpace(command)) + { + return true; + } + + return _autoFilledLaunchCommand is not null + && string.Equals( + Normalize(command), + Normalize(_autoFilledLaunchCommand), + StringComparison.OrdinalIgnoreCase); + } + private static string DeriveNameFromDirectory(string directory) { var trimmed = directory.Trim().TrimEnd('\\', '/'); @@ -490,7 +624,7 @@ private CommandResult HandleCancel(string payload) return LeaveShortcutForm(); } - if (!MergeDraftFromInputs(payload)) + if (!MergeDraftFromInputs(payload, out _)) { return QuickShellNavigation.StayOpen("Unable to read form values."); } @@ -527,46 +661,13 @@ private CommandResult HandleDiscardPromptAction(string inputs, string? data) private void ShowDiscardPrompt() { _showingDiscardPrompt = true; - TemplateJson = """ - { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.6", - "body": [ - { - "type": "TextBlock", - "text": "Unsaved changes", - "weight": "Bolder", - "size": "Medium" - }, - { - "type": "TextBlock", - "text": "Save your changes, or discard them and leave?", - "wrap": true - } - ], - "actions": [ - { - "type": "Action.Submit", - "title": "Save and close", - "data": { "action": "save" }, - "associatedInputs": "none" - }, - { - "type": "Action.Submit", - "title": "Discard", - "data": { "action": "discard" }, - "associatedInputs": "none" - } - ] - } - """; + TemplateJson = ShortcutFormTemplateJson.BuildDiscardPromptTemplate(); DataJson = "{}"; } private CommandResult HandleSave(string payload) { - if (!MergeDraftFromInputs(payload)) + if (!MergeDraftFromInputs(payload, out _)) { return QuickShellNavigation.StayOpen("Unable to read form values."); } @@ -585,6 +686,11 @@ private CommandResult SaveCurrentDraft() _autoFilledName = draft.Name; } + ApplyCompanionFormState(CompanionAppCatalog.ReconcileForSave( + draft.CompanionAppPreset, + draft.CompanionAppPath, + draft.CompanionAppArguments)); + var result = ShortcutFormSave.TrySave( originalName, draft.Name, @@ -599,7 +705,6 @@ private CommandResult SaveCurrentDraft() _onSaved, draft.DevServerUrl, draft.RepoUrl, - draft.OpenDevServerOnLaunch, draft.OpenCompanionAppOnLaunch, draft.CompanionAppPath, draft.CompanionAppArguments); @@ -616,32 +721,22 @@ private CommandResult SaveCurrentDraft() private CommandResult LeaveShortcutForm(string? toastMessage = null) { + UnsubscribeFromDraftCleared(); _releaseForm?.Invoke(); return QuickShellNavigation.PopToShortcutsList(toastMessage); } - private void ApplyDraft(FormDraft draft, bool persist = true) + private void ApplyDraft(FormDraft draft, bool persist = true, bool forceTemplateRebuild = false) { _draft = draft; - RebuildTemplate(draft.Commands); - DataJson = $$""" - { - "OriginalName": "{{Escape(draft.OriginalName)}}", - "Name": "{{Escape(draft.Name)}}", - "Abbreviation": "{{Escape(draft.Abbreviation)}}", - "Directory": "{{Escape(draft.Directory)}}", - "LaunchTarget": "{{Escape(draft.LaunchTarget)}}", - "DevServerUrl": "{{Escape(draft.DevServerUrl)}}", - "RepoUrl": "{{Escape(draft.RepoUrl)}}", - "OpenDevServerOnLaunch": "{{(draft.OpenDevServerOnLaunch ? "true" : "false")}}", - "OpenCompanionAppOnLaunch": "{{(draft.OpenCompanionAppOnLaunch ? "true" : "false")}}", - "CompanionAppPreset": "{{Escape(draft.CompanionAppPreset)}}", - "CompanionAppPath": "{{Escape(draft.CompanionAppPath)}}", - "CompanionAppArguments": "{{Escape(draft.CompanionAppArguments)}}", - "RunAsAdmin": "{{(draft.RunAsAdmin ? "true" : "false")}}", - "ShowRestoredDraftNote": {{(_showRestoredDraftNote ? "true" : "false")}} - } - """; + var commandCount = Math.Max(1, draft.Commands.Count); + if (forceTemplateRebuild || _templateCommandCount != commandCount) + { + RebuildTemplate(draft.Commands); + _templateCommandCount = commandCount; + } + + PublishDataJson(draft); if (persist && _baselineReady) { @@ -649,6 +744,24 @@ private void ApplyDraft(FormDraft draft, bool persist = true) } } + private void PublishDataJson(FormDraft draft) => + DataJson = ShortcutFormTemplateJson.BuildDataJson( + new ShortcutFormTemplateJson.DataPayload + { + OriginalName = draft.OriginalName, + Name = draft.Name, + Abbreviation = draft.Abbreviation, + Directory = draft.Directory, + LaunchTarget = draft.LaunchTarget, + DevServerUrl = draft.DevServerUrl, + RepoUrl = draft.RepoUrl, + CompanionAppPreset = draft.CompanionAppPreset, + CompanionAppPath = draft.CompanionAppPath, + RunAsAdmin = draft.RunAsAdmin, + ShowRestoredDraftNote = _showRestoredDraftNote, + }, + draft.Commands.Select(command => command.Command).ToList()); + private void PersistEditDraftIfNeeded() { if (_originalName is null || _showingDiscardPrompt) @@ -677,7 +790,6 @@ private static ShortcutFormDraftData ToDraftData(FormDraft draft) LaunchTarget = draft.LaunchTarget, DevServerUrl = draft.DevServerUrl, RepoUrl = draft.RepoUrl, - OpenDevServerOnLaunch = draft.OpenDevServerOnLaunch, OpenCompanionAppOnLaunch = draft.OpenCompanionAppOnLaunch, CompanionAppPreset = draft.CompanionAppPreset, CompanionAppPath = draft.CompanionAppPath, @@ -696,8 +808,9 @@ private static ShortcutFormDraftData ToDraftData(FormDraft draft) private bool HasUnsavedChanges() => !DraftEquals(_draft, _baselineDraft); - private bool MergeDraftFromInputs(string payload, bool excludeDirectory = false) + private bool MergeDraftFromInputs(string payload, out bool refreshForm, bool excludeDirectory = false) { + refreshForm = false; var data = JsonNode.Parse(payload)?.AsObject(); if (data is null) { @@ -711,6 +824,7 @@ private bool MergeDraftFromInputs(string payload, bool excludeDirectory = false) var mergedName = data["Name"]?.ToString() ?? _draft.Name; UpdateAutoFilledNameTracking(mergedName); + UpdateAutoFilledLaunchCommandTracking(data["LaunchCommand_0"]?.ToString()); var previousPreset = _draft.CompanionAppPreset; var mergedPreset = data["CompanionAppPreset"]?.ToString() ?? _draft.CompanionAppPreset; @@ -727,55 +841,49 @@ private bool MergeDraftFromInputs(string payload, bool excludeDirectory = false) LaunchTarget = data["LaunchTarget"]?.ToString() ?? _draft.LaunchTarget, DevServerUrl = data["DevServerUrl"]?.ToString() ?? _draft.DevServerUrl, RepoUrl = data["RepoUrl"]?.ToString() ?? _draft.RepoUrl, - OpenDevServerOnLaunch = ParseToggleBool( - data["OpenDevServerOnLaunch"]?.ToString(), - _draft.OpenDevServerOnLaunch), - OpenCompanionAppOnLaunch = ParseToggleBool( - data["OpenCompanionAppOnLaunch"]?.ToString(), - _draft.OpenCompanionAppOnLaunch), + OpenCompanionAppOnLaunch = _draft.OpenCompanionAppOnLaunch, CompanionAppPreset = mergedPreset, - CompanionAppPath = data["CompanionAppPath"]?.ToString() ?? _draft.CompanionAppPath, - CompanionAppArguments = data["CompanionAppArguments"]?.ToString() ?? _draft.CompanionAppArguments, + CompanionAppPath = _draft.CompanionAppPath, + CompanionAppArguments = _draft.CompanionAppArguments, RunAsAdmin = ParseToggleBool(data["RunAsAdmin"]?.ToString(), _draft.RunAsAdmin), }; - ApplyCompanionPresetChange(previousPreset, mergedPreset); - - if (string.IsNullOrWhiteSpace(_draft.DevServerUrl)) - { - _draft.OpenDevServerOnLaunch = false; - } + refreshForm = ApplyCompanionPresetChange(previousPreset, mergedPreset); return true; } - private void ApplyCompanionPresetChange(string previousPreset, string mergedPreset) + private bool ApplyCompanionPresetChange(string previousPreset, string mergedPreset) { if (string.Equals(previousPreset, mergedPreset, StringComparison.OrdinalIgnoreCase)) { - return; - } - - if (string.Equals(mergedPreset, CompanionAppCatalog.PresetNone, StringComparison.OrdinalIgnoreCase)) - { - _draft.CompanionAppPath = string.Empty; - _draft.CompanionAppArguments = string.Empty; - _draft.OpenCompanionAppOnLaunch = false; - return; + return false; } if (string.Equals(mergedPreset, CompanionAppCatalog.PresetCustom, StringComparison.OrdinalIgnoreCase)) { - return; + return false; } - if (CompanionAppCatalog.TryApplyPreset(mergedPreset, out var executablePath, out var arguments)) - { - _draft.CompanionAppPath = executablePath ?? string.Empty; - _draft.CompanionAppArguments = arguments; - } + ApplyCompanionFormState(CompanionAppCatalog.CreateStateFromPreset(mergedPreset)); + return true; } + private static bool IsBrowseCompanionAppAction(string inputs, string? data) => + TryGetAction(data) == "browseCompanionApp" + || TryGetActionFromInputs(inputs) == "browseCompanionApp"; + + private static void ApplyCompanionFormState(FormDraft draft, CompanionAppCatalog.CompanionAppFormState state) + { + draft.CompanionAppPreset = state.Preset; + draft.CompanionAppPath = state.Path; + draft.CompanionAppArguments = state.Arguments; + draft.OpenCompanionAppOnLaunch = state.LaunchOnWorkspaceOpen; + } + + private void ApplyCompanionFormState(CompanionAppCatalog.CompanionAppFormState state) => + ApplyCompanionFormState(_draft, state); + private static List MergeCommandsFromInputs( JsonObject data, List existing) @@ -844,6 +952,19 @@ private void UpdateAutoFilledNameTracking(string mergedName) } } + private void UpdateAutoFilledLaunchCommandTracking(string? mergedCommand) + { + mergedCommand ??= string.Empty; + if (_autoFilledLaunchCommand is not null + && !string.Equals( + Normalize(mergedCommand), + Normalize(_autoFilledLaunchCommand), + StringComparison.OrdinalIgnoreCase)) + { + _autoFilledLaunchCommand = null; + } + } + private static string? GetFieldFromPayload(string payload, string field) => JsonNode.Parse(payload)?.AsObject()?[field]?.ToString(); @@ -858,9 +979,6 @@ private bool IsDiscardPromptAction(string inputs, string? data) return action is "save" or "discard"; } - private static bool IsBrowseAppAction(string inputs, string? data) => - TryGetAction(data) == "browseApp" || TryGetActionFromInputs(inputs) == "browseApp"; - private static bool IsBrowseAction(string inputs, string? data) => TryGetAction(data) == "browse" || TryGetActionFromInputs(inputs) == "browse"; @@ -933,7 +1051,6 @@ private static FormDraft CloneDraft(FormDraft draft) => LaunchTarget = draft.LaunchTarget, DevServerUrl = draft.DevServerUrl, RepoUrl = draft.RepoUrl, - OpenDevServerOnLaunch = draft.OpenDevServerOnLaunch, OpenCompanionAppOnLaunch = draft.OpenCompanionAppOnLaunch, CompanionAppPreset = draft.CompanionAppPreset, CompanionAppPath = draft.CompanionAppPath, @@ -949,7 +1066,6 @@ private static bool DraftEquals(FormDraft left, FormDraft right) || !string.Equals(Normalize(left.LaunchTarget), Normalize(right.LaunchTarget), StringComparison.Ordinal) || !string.Equals(Normalize(left.DevServerUrl), Normalize(right.DevServerUrl), StringComparison.Ordinal) || !string.Equals(Normalize(left.RepoUrl), Normalize(right.RepoUrl), StringComparison.Ordinal) - || left.OpenDevServerOnLaunch != right.OpenDevServerOnLaunch || left.OpenCompanionAppOnLaunch != right.OpenCompanionAppOnLaunch || !string.Equals(Normalize(left.CompanionAppPreset), Normalize(right.CompanionAppPreset), StringComparison.Ordinal) || !string.Equals(Normalize(left.CompanionAppPath), Normalize(right.CompanionAppPath), StringComparison.Ordinal) @@ -977,251 +1093,6 @@ private static bool DraftEquals(FormDraft left, FormDraft right) private static string Normalize(string? value) => (value ?? string.Empty).Trim(); - private static string Escape(string? value) => (value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\""); - - private static string BuildTemplateJson( - string terminalChoices, - string companionChoices, - IReadOnlyList commands) - { - var commandRows = ShortcutFormLaunchSection.BuildCommandRowsJson(commands); - return $$""" - { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.6", - "body": [ - { - "type": "Input.Text", - "id": "OriginalName", - "isVisible": false, - "value": "${OriginalName}" - }, - { - "type": "TextBlock", - "text": "Restored unsaved changes from your last edit. Save or Cancel when you are done.", - "wrap": true, - "isSubtle": true, - "spacing": "Small", - "$when": "${ShowRestoredDraftNote}" - }, - { - "type": "Container", - "spacing": "Medium", - "items": [ - {{SettingsCardJson.FieldLabel("Folder path")}}, - {{SettingsCardJson.FieldHelp("Folder opened when you run this workspace. Browse or paste to pick a folder.")}}, - { - "type": "Input.Text", - "id": "Directory", - "isRequired": true, - "errorMessage": "Folder path is required", - "placeholder": "Type or paste a path, e.g. C:\\Projects\\MyApp", - "value": "${Directory}" - }, - { - "type": "ActionSet", - "spacing": "Small", - "actions": [ - { - "type": "Action.Submit", - "title": "Browse folder", - "data": { "action": "browse" }, - "associatedInputs": "none" - }, - { - "type": "Action.Submit", - "title": "Paste path", - "data": { "action": "paste" }, - "associatedInputs": "none" - } - ] - } - ] - }, - {{SettingsCardJson.FieldGroup("Name", $"Shown in your {QuickShellBrand.DisplayName} list. Filled in from the folder name when you browse or paste—you can edit it.", """ - { - "type": "Input.Text", - "id": "Name", - "value": "${Name}" - } - """)}}, - {{SettingsCardJson.FieldGroup("Home keyword (optional)", "Type this at Command Palette home to jump straight to this workspace.", """ - { - "type": "Input.Text", - "id": "Abbreviation", - "placeholder": "e.g. api", - "value": "${Abbreviation}" - } - """)}}, - {{SettingsCardJson.FieldGroup("Dev server URL (optional)", "Used by the action menu and optional auto-open on workspace launch, e.g. http://localhost:3000.", """ - { - "type": "Input.Text", - "id": "DevServerUrl", - "placeholder": "http://localhost:3000", - "value": "${DevServerUrl}" - } - """)}}, - {{SettingsCardJson.FieldGroup("Open on workspace launch", "Opens the dev server URL in your browser when you run the full workspace.", """ - { - "type": "Input.Toggle", - "id": "OpenDevServerOnLaunch", - "title": "Open dev server when workspace runs", - "value": "${OpenDevServerOnLaunch}", - "valueOn": "true", - "valueOff": "false" - } - """)}}, - {{SettingsCardJson.FieldGroup("Repository URL (optional)", "Opens from the workspace action menu, e.g. your GitHub repo page.", """ - { - "type": "Input.Text", - "id": "RepoUrl", - "placeholder": "https://github.com/you/your-repo", - "value": "${RepoUrl}" - } - """)}}, - { - "type": "TextBlock", - "text": "Companion app", - "weight": "Bolder", - "spacing": "Medium" - }, - { - "type": "TextBlock", - "text": "Optionally open an editor or other app with this workspace folder when you run the workspace.", - "wrap": true, - "isSubtle": true, - "spacing": "Small" - }, - {{SettingsCardJson.FieldGroup("Open on workspace launch", "Runs alongside your terminals when you open the full workspace.", """ - { - "type": "Input.Toggle", - "id": "OpenCompanionAppOnLaunch", - "title": "Open companion app when workspace runs", - "value": "${OpenCompanionAppOnLaunch}", - "valueOn": "true", - "valueOff": "false" - } - """)}}, - { - "type": "Container", - "spacing": "Small", - "items": [ - {{SettingsCardJson.FieldLabel("App preset")}}, - {{SettingsCardJson.FieldHelp("Pick a common editor or choose Custom to browse for any executable.")}}, - { - "type": "Input.ChoiceSet", - "id": "CompanionAppPreset", - "style": "compact", - "value": "${CompanionAppPreset}", - "choices": {{companionChoices}} - } - ] - }, - { - "type": "Container", - "spacing": "Small", - "items": [ - {{SettingsCardJson.FieldLabel("Executable")}}, - { - "type": "Input.Text", - "id": "CompanionAppPath", - "placeholder": "C:\\Users\\you\\AppData\\Local\\Programs\\Microsoft VS Code\\Code.exe", - "value": "${CompanionAppPath}" - }, - { - "type": "ActionSet", - "spacing": "Small", - "actions": [ - { - "type": "Action.Submit", - "title": "Browse app…", - "associatedInputs": "auto", - "data": { "action": "browseApp" } - } - ] - } - ] - }, - {{SettingsCardJson.FieldGroup("Arguments (optional)", "Use . or {folder} for the workspace folder. VS Code and Cursor default to .", """ - { - "type": "Input.Text", - "id": "CompanionAppArguments", - "placeholder": ".", - "value": "${CompanionAppArguments}" - } - """)}}, - { - "type": "TextBlock", - "text": "Commands", - "weight": "Bolder", - "spacing": "Medium" - }, - { - "type": "TextBlock", - "text": "Each command uses this workspace's terminal. Leave blank to open the folder only.", - "wrap": true, - "isSubtle": true, - "spacing": "Small" - }, - {{commandRows}}, - { - "type": "Container", - "spacing": "Medium", - "items": [ - {{SettingsCardJson.FieldLabel("Terminal profile")}}, - {{SettingsCardJson.FieldHelp("Applies to every command in this workspace.")}}, - { - "type": "Input.ChoiceSet", - "id": "LaunchTarget", - "style": "compact", - "value": "${LaunchTarget}", - "choices": {{terminalChoices}} - }, - { - "type": "ActionSet", - "spacing": "Small", - "actions": [ - { - "type": "Action.Submit", - "title": "Refresh profile list", - "tooltip": "Reload after installing a shell or editing Windows Terminal settings.", - "associatedInputs": "auto", - "data": { "action": "refreshTerminals" } - } - ] - } - ] - }, - {{SettingsCardJson.FieldGroup("Administrator", "Launch elevated. Windows may show a UAC prompt each time.", """ - { - "type": "Input.Toggle", - "id": "RunAsAdmin", - "title": "Always run as administrator", - "value": "${RunAsAdmin}", - "valueOn": "true", - "valueOff": "false" - } - """)}} - ], - "actions": [ - { - "type": "Action.Submit", - "title": "Save workspace", - "associatedInputs": "auto" - }, - { - "type": "Action.Submit", - "title": "Cancel", - "tooltip": "Unsaved changes prompt you before leaving.", - "data": { "action": "cancel" }, - "associatedInputs": "none" - } - ] - } - """; - } - private sealed class FormDraft { public string OriginalName { get; set; } = string.Empty; @@ -1234,8 +1105,6 @@ private sealed class FormDraft public string DevServerUrl { get; set; } = string.Empty; - public bool OpenDevServerOnLaunch { get; set; } - public string RepoUrl { get; set; } = string.Empty; public bool OpenCompanionAppOnLaunch { get; set; } diff --git a/QuickShell/Pages/ShortcutLaunchFormPage.cs b/QuickShell/Pages/ShortcutLaunchFormPage.cs deleted file mode 100644 index ddb804a..0000000 --- a/QuickShell/Pages/ShortcutLaunchFormPage.cs +++ /dev/null @@ -1,369 +0,0 @@ -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using QuickShell.Models; -using QuickShell.Services; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace QuickShell.Pages; - -internal sealed partial class ShortcutLaunchFormPage : ContentPage -{ - public const string PageId = "com.quickshell.shortcut.launch-form"; - - private readonly TerminalShortcut _shortcut; - private readonly WorkspaceEntry _launch; - private readonly Action _onChanged; - private readonly bool _isNewLaunch; - - public ShortcutLaunchFormPage() - { - if (!ShortcutEditorNavigationState.TryTakeLaunchForm( - out var shortcut, - out var launch, - out var onChanged, - out var isNew)) - { - shortcut = ShortcutEditorState.CreateNew(); - launch = shortcut.Launches[0]; - onChanged = static _ => { }; - isNew = false; - } - - _shortcut = shortcut; - _launch = launch; - _onChanged = onChanged; - _isNewLaunch = isNew; - Id = PageId; - Icon = new IconInfo(TerminalLaunchGlyphs.GetForLaunch(launch)); - Title = isNew ? "Add terminal" : $"Edit {launch.Label}"; - Name = isNew ? "Add" : "Edit"; - } - - public ShortcutLaunchFormPage( - TerminalShortcut shortcut, - WorkspaceEntry launch, - Action onChanged, - bool isNew = false) - { - _shortcut = shortcut; - _launch = launch; - _onChanged = onChanged; - _isNewLaunch = isNew; - Id = PageId; - Icon = new IconInfo(TerminalLaunchGlyphs.GetForLaunch(launch)); - Title = isNew ? "Add terminal" : $"Edit {launch.Label}"; - Name = isNew ? "Add" : "Edit"; - } - - public override IContent[] GetContent() => - [_form ??= new ShortcutLaunchForm(_shortcut, _launch, _onChanged, _isNewLaunch, () => _form = null)]; - - private ShortcutLaunchForm? _form; -} - -internal sealed partial class ShortcutLaunchForm : FormContent -{ - private readonly TerminalShortcut _shortcut; - private readonly WorkspaceEntry _launch; - private readonly Action _onChanged; - private readonly bool _isNewLaunch; - private readonly Action? _releaseForm; - private FormDraft _draft = new(); - - public ShortcutLaunchForm( - TerminalShortcut shortcut, - WorkspaceEntry launch, - Action onChanged, - bool isNewLaunch = false, - Action? releaseForm = null) - { - _shortcut = shortcut; - _launch = launch; - _onChanged = onChanged; - _isNewLaunch = isNewLaunch; - _releaseForm = releaseForm; - TemplateJson = BuildTemplateJson(FormTerminalChoicesJson()); - ApplyDraft(); - } - - public override CommandResult SubmitForm(string inputs, string data) - { - CaptureInputs(inputs); - - if (IsRefreshTerminalsAction(inputs, data)) - { - return HandleRefreshTerminals(); - } - - if (IsCancelAction(inputs, data)) - { - _releaseForm?.Invoke(); - return QuickShellNavigation.GoBack(); - } - - return HandleSave(); - } - - public override CommandResult SubmitForm(string payload) - { - CaptureInputs(payload); - - if (IsRefreshTerminalsAction(payload, null)) - { - return HandleRefreshTerminals(); - } - - if (IsCancelAction(payload, null)) - { - _releaseForm?.Invoke(); - return QuickShellNavigation.GoBack(); - } - - return HandleSave(); - } - - private CommandResult HandleRefreshTerminals() - { - TerminalCatalog.InvalidateCache(); - TemplateJson = BuildTemplateJson(FormTerminalChoicesJson()); - PublishDraftJson(); - return QuickShellNavigation.StayOpen("Terminal list refreshed."); - } - - private CommandResult HandleSave() - { - var candidate = BuildCandidateLaunchFromDraft(); - if (!WorkspaceValidation.TryValidateEntry(candidate, out var error)) - { - return QuickShellNavigation.StayOpen(error); - } - - ShortcutLaunchNormalization.EnsureLaunchesFromLegacy(_shortcut); - var duplicateLabel = _shortcut.Launches.Any(entry => - !entry.Id.Equals(candidate.Id, StringComparison.OrdinalIgnoreCase) - && entry.Label.Equals(candidate.Label, StringComparison.OrdinalIgnoreCase)); - if (duplicateLabel) - { - return QuickShellNavigation.StayOpen($"Duplicate terminal label '{candidate.Label}'."); - } - - ApplyCandidateToLaunch(candidate); - - if (_isNewLaunch - && !_shortcut.Launches.Any(entry => entry.Id.Equals(_launch.Id, StringComparison.OrdinalIgnoreCase))) - { - _shortcut.Launches.Add(_launch); - } - - ShortcutLaunchNormalization.NormalizeLaunchOrders(_shortcut); - _onChanged(_shortcut); - _releaseForm?.Invoke(); - return QuickShellNavigation.GoBack(_isNewLaunch ? $"Added '{_launch.Label}'." : $"Updated '{_launch.Label}'."); - } - - private WorkspaceEntry BuildCandidateLaunchFromDraft() - { - var launchShortcut = new TerminalShortcut(); - TerminalCatalog.ApplyLaunchTargetId(launchShortcut, _draft.LaunchTarget); - return new WorkspaceEntry - { - Id = _launch.Id, - Label = _draft.Label.Trim(), - Command = string.IsNullOrWhiteSpace(_draft.Command) ? null : _draft.Command.Trim(), - Terminal = launchShortcut.Terminal, - WtProfile = launchShortcut.WtProfile, - RunAsAdmin = _draft.RunAsAdmin, - IsEnabled = _draft.IsEnabled, - Order = _launch.Order, - }; - } - - private void ApplyCandidateToLaunch(WorkspaceEntry candidate) - { - _launch.Label = candidate.Label; - _launch.Command = candidate.Command; - _launch.Terminal = candidate.Terminal; - _launch.WtProfile = candidate.WtProfile; - _launch.RunAsAdmin = candidate.RunAsAdmin; - _launch.IsEnabled = candidate.IsEnabled; - } - - private void CaptureInputs(string payload) - { - _draft.Label = GetField(payload, "Label") ?? _draft.Label; - _draft.Command = GetField(payload, "Command") ?? _draft.Command; - _draft.LaunchTarget = GetField(payload, "LaunchTarget") ?? _draft.LaunchTarget; - _draft.RunAsAdmin = ParseToggleBool(GetField(payload, "RunAsAdmin"), _draft.RunAsAdmin); - _draft.IsEnabled = ParseToggleBool(GetField(payload, "IsEnabled"), _draft.IsEnabled); - } - - private void ApplyDraft() - { - var launchTarget = TerminalCatalog.EncodeLaunchTargetId(new TerminalShortcut - { - Terminal = _launch.Terminal, - WtProfile = _launch.WtProfile, - }); - - _draft = new FormDraft - { - Label = _launch.Label, - Command = _launch.Command ?? string.Empty, - LaunchTarget = launchTarget, - RunAsAdmin = _launch.RunAsAdmin, - IsEnabled = _launch.IsEnabled, - }; - - PublishDraftJson(); - } - - private void PublishDraftJson() - { - DataJson = $$""" - { - "Label": "{{EscapeJsonValue(_draft.Label)}}", - "Command": "{{EscapeJsonValue(_draft.Command)}}", - "LaunchTarget": "{{EscapeJsonValue(_draft.LaunchTarget)}}", - "RunAsAdmin": "{{(_draft.RunAsAdmin ? "true" : "false")}}", - "IsEnabled": "{{(_draft.IsEnabled ? "true" : "false")}}" - } - """; - } - - private static string BuildTemplateJson(string terminalChoices) => $$""" - { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.6", - "body": [ - {{SettingsCardJson.FieldGroup("Label", "Shown when this workspace has multiple terminals.", """ - { - "type": "Input.Text", - "id": "Label", - "isRequired": true, - "value": "${Label}" - } - """)}}, - { - "type": "Container", - "spacing": "Medium", - "items": [ - {{SettingsCardJson.FieldLabel("Terminal profile")}}, - { - "type": "Input.ChoiceSet", - "id": "LaunchTarget", - "style": "compact", - "value": "${LaunchTarget}", - "choices": {{terminalChoices}} - }, - { - "type": "ActionSet", - "spacing": "Small", - "actions": [ - { - "type": "Action.Submit", - "title": "Refresh profile list", - "data": { "action": "refreshTerminals" }, - "associatedInputs": "auto" - } - ] - } - ] - }, - {{SettingsCardJson.FieldGroup("Command (optional)", "Run after the terminal opens.", """ - { - "type": "Input.Text", - "id": "Command", - "value": "${Command}" - } - """)}}, - {{SettingsCardJson.FieldGroup("Administrator", "", """ - { - "type": "Input.Toggle", - "id": "RunAsAdmin", - "title": "Run as administrator", - "value": "${RunAsAdmin}", - "valueOn": "true", - "valueOff": "false" - } - """)}}, - {{SettingsCardJson.FieldGroup("Enabled", "", """ - { - "type": "Input.Toggle", - "id": "IsEnabled", - "title": "Include when opening workspace", - "value": "${IsEnabled}", - "valueOn": "true", - "valueOff": "false" - } - """)}} - ], - "actions": [ - { - "type": "Action.Submit", - "title": "Apply", - "associatedInputs": "auto" - }, - { - "type": "Action.Submit", - "title": "Cancel", - "data": { "action": "cancel" }, - "associatedInputs": "none" - } - ] - } - """; - - private static string FormTerminalChoicesJson() => - TerminalCatalog.BuildFormChoicesJson( - includeDefaultChoice: true, - QuickShellRuntimeServices.Settings?.TerminalApplicationId ?? TerminalHostIds.WindowsTerminal); - - private static bool IsRefreshTerminalsAction(string inputs, string? data) => - data?.Contains("refreshTerminals", StringComparison.Ordinal) == true - || inputs.Contains("\"action\":\"refreshTerminals\"", StringComparison.Ordinal); - - private static bool IsCancelAction(string inputs, string? data) => - data?.Contains("cancel", StringComparison.Ordinal) == true - || inputs.Contains("\"action\":\"cancel\"", StringComparison.Ordinal); - - private static string? GetField(string payload, string fieldName) - { - try - { - return JsonNode.Parse(payload)?[fieldName]?.GetValue(); - } - catch - { - return null; - } - } - - private static string EscapeJsonValue(string? value) - { - var encoded = JsonSerializer.Serialize(value ?? string.Empty, QuickShellJsonContext.Default.String); - return encoded.Length >= 2 ? encoded[1..^1] : string.Empty; - } - - private static bool ParseToggleBool(string? value, bool fallback) => - value switch - { - "true" => true, - "false" => false, - _ => fallback, - }; - - private sealed class FormDraft - { - public string Label { get; set; } = string.Empty; - - public string Command { get; set; } = string.Empty; - - public string LaunchTarget { get; set; } = "default"; - - public bool RunAsAdmin { get; set; } - - public bool IsEnabled { get; set; } = true; - } -} diff --git a/QuickShell/Services/ShortcutEditorNavigationState.cs b/QuickShell/Services/ShortcutEditorNavigationState.cs deleted file mode 100644 index 7e7ce79..0000000 --- a/QuickShell/Services/ShortcutEditorNavigationState.cs +++ /dev/null @@ -1,81 +0,0 @@ -using QuickShell.Models; - -namespace QuickShell.Services; - -internal static class ShortcutEditorNavigationState -{ - private static readonly Action NoOp = () => { }; - - private static TerminalShortcut? _editorShortcut; - private static string? _editorOriginalName; - private static Action? _onSaved; - - private static TerminalShortcut? _entryFormShortcut; - private static WorkspaceEntry? _entryFormLaunch; - private static Action? _entryFormOnChanged; - private static bool _entryFormIsNew; - - public static void SetEditor(TerminalShortcut shortcut, string? originalName, Action onSaved) - { - _editorShortcut = shortcut; - _editorOriginalName = originalName; - _onSaved = onSaved; - } - - public static bool TryTakeEditor(out TerminalShortcut shortcut, out string? originalName, out Action onSaved) - { - if (_editorShortcut is null || _onSaved is null) - { - shortcut = new TerminalShortcut(); - originalName = null; - onSaved = NoOp; - return false; - } - - shortcut = _editorShortcut; - originalName = _editorOriginalName; - onSaved = _onSaved; - _editorShortcut = null; - _editorOriginalName = null; - _onSaved = null; - return true; - } - - public static void SetLaunchForm( - TerminalShortcut shortcut, - WorkspaceEntry launch, - Action onChanged, - bool isNew = false) - { - _entryFormShortcut = shortcut; - _entryFormLaunch = launch; - _entryFormOnChanged = onChanged; - _entryFormIsNew = isNew; - } - - public static bool TryTakeLaunchForm( - out TerminalShortcut shortcut, - out WorkspaceEntry launch, - out Action onChanged, - out bool isNew) - { - if (_entryFormShortcut is null || _entryFormLaunch is null || _entryFormOnChanged is null) - { - shortcut = new TerminalShortcut(); - launch = new WorkspaceEntry(); - onChanged = static _ => { }; - isNew = false; - return false; - } - - shortcut = _entryFormShortcut; - launch = _entryFormLaunch; - onChanged = _entryFormOnChanged; - isNew = _entryFormIsNew; - _entryFormShortcut = null; - _entryFormLaunch = null; - _entryFormOnChanged = null; - _entryFormIsNew = false; - return true; - } -} diff --git a/QuickShell/Services/ShortcutEditorState.cs b/QuickShell/Services/ShortcutEditorState.cs deleted file mode 100644 index db4c6e6..0000000 --- a/QuickShell/Services/ShortcutEditorState.cs +++ /dev/null @@ -1,70 +0,0 @@ -using QuickShell.Models; - -namespace QuickShell.Services; - -internal static class ShortcutEditorState -{ - public static TerminalShortcut CreateNew() => - new() - { - Launches = - [ - CreateLaunch("Main", null, 0), - ], - }; - - public static TerminalShortcut CloneShortcut(TerminalShortcut shortcut) - { - ShortcutLaunchNormalization.EnsureLaunchesFromLegacy(shortcut); - return new TerminalShortcut - { - Id = shortcut.Id, - Name = shortcut.Name, - Abbreviation = shortcut.Abbreviation, - Directory = shortcut.Directory, - Command = shortcut.Command, - Terminal = shortcut.Terminal, - WtProfile = shortcut.WtProfile, - RunAsAdmin = shortcut.RunAsAdmin, - IsPinned = shortcut.IsPinned, - PinOrder = shortcut.PinOrder, - LastUsedUtc = shortcut.LastUsedUtc, - Launches = shortcut.Launches.Select(WorkspaceMapper.CloneEntry).ToList(), - }; - } - - public static WorkspaceEntry CreateLaunch(string label, string? command, int order) => new() - { - Id = Guid.NewGuid().ToString("N"), - Label = label, - Command = command, - Terminal = "default", - IsEnabled = true, - Order = order, - }; - - public static void MoveLaunch(TerminalShortcut shortcut, string launchId, int direction) - { - ShortcutLaunchNormalization.EnsureLaunchesFromLegacy(shortcut); - var ordered = shortcut.Launches.OrderBy(entry => entry.Order).ToList(); - var index = ordered.FindIndex(entry => entry.Id.Equals(launchId, StringComparison.OrdinalIgnoreCase)); - if (index < 0) - { - return; - } - - var target = index + direction; - if (target < 0 || target >= ordered.Count) - { - return; - } - - (ordered[index], ordered[target]) = (ordered[target], ordered[index]); - for (var i = 0; i < ordered.Count; i++) - { - ordered[i].Order = i; - } - - shortcut.Launches = ordered; - } -} From 759dcde3f0278ad34bf7419faed35f2b09f5a23a Mon Sep 17 00:00:00 2001 From: Anthony Thompson Date: Thu, 2 Jul 2026 21:04:35 -0700 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Anthony Thompson --- QuickShell.Core/Services/ShortcutFormTemplateJson.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/QuickShell.Core/Services/ShortcutFormTemplateJson.cs b/QuickShell.Core/Services/ShortcutFormTemplateJson.cs index 8ae8a55..1a932a6 100644 --- a/QuickShell.Core/Services/ShortcutFormTemplateJson.cs +++ b/QuickShell.Core/Services/ShortcutFormTemplateJson.cs @@ -312,8 +312,13 @@ public static string BuildDiscardPromptTemplate() => } """; - private static string Escape(string? value) => - (value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\""); +private static string Escape(string? value) +{ + var encoded = global::System.Text.Json.JsonSerializer.Serialize( + value ?? string.Empty, + global::QuickShell.QuickShellJsonContext.Default.String); + return encoded.Length >= 2 ? encoded[1..^1] : string.Empty; +} /// /// Choice arrays and command rows must be interpolated in the outer template scope. From dccf8ac34e8c4644271a772f2572b580adfe2e1b Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Thu, 2 Jul 2026 22:11:27 -0700 Subject: [PATCH 3/4] feat: unify workspace form and apply review fixes Merge create/edit into ShortcutFormPage, address Copilot and Codex review feedback for companion reconciliation, duplicate command, template cache key, and remove obsolete launch form page routing. Co-authored-by: Cursor --- .../FakeShortcutRepository.cs | 2 ++ .../WorkspaceUtilityTests.cs | 26 +++++++++--------- .../Services/CompanionAppCatalog.cs | 27 ++++++++++++++----- .../Services/IShortcutRepository.cs | 2 ++ .../Services/ShortcutFormTemplateCache.cs | 7 ++++- .../Services/ShortcutRepository.cs | 8 +++--- .../Commands/DuplicateShortcutCommand.cs | 5 ++-- QuickShell/Pages/ShortcutFormPage.cs | 19 ++++++++++--- QuickShell/QuickShellCommandsProvider.cs | 9 ------- .../Services/ShortcutContextCommands.cs | 2 +- 10 files changed, 67 insertions(+), 40 deletions(-) diff --git a/QuickShell.Core.Tests/FakeShortcutRepository.cs b/QuickShell.Core.Tests/FakeShortcutRepository.cs index bebe938..e4286da 100644 --- a/QuickShell.Core.Tests/FakeShortcutRepository.cs +++ b/QuickShell.Core.Tests/FakeShortcutRepository.cs @@ -99,6 +99,8 @@ public void MarkUsed(string shortcutId) public TerminalShortcut? BuildDuplicate(string name) => null; + public TerminalShortcut BuildDuplicateFrom(TerminalShortcut source) => source; + public IEnumerable Search(string query) => GetShortcuts(); public IEnumerable SearchForRootPalette(string query) => []; diff --git a/QuickShell.Core.Tests/WorkspaceUtilityTests.cs b/QuickShell.Core.Tests/WorkspaceUtilityTests.cs index 6cf9e78..92e3310 100644 --- a/QuickShell.Core.Tests/WorkspaceUtilityTests.cs +++ b/QuickShell.Core.Tests/WorkspaceUtilityTests.cs @@ -680,43 +680,45 @@ public void CreateStateFromPreset_Explorer_UsesFolderArgument() } [Fact] - public void ReconcileStoredShortcut_WhenLaunchDisabled_ReturnsNone() + public void ReconcileStoredShortcut_WhenLaunchDisabled_PreservesCompanionPath() { var state = CompanionAppCatalog.ReconcileStoredShortcut( openOnLaunch: false, - @"C:\Apps\Code.exe", + @"C:\Apps\MyEditor.exe", "."); - Assert.Equal(CompanionAppCatalog.PresetNone, state.Preset); + Assert.Equal(CompanionAppCatalog.PresetCustom, state.Preset); Assert.False(state.LaunchOnWorkspaceOpen); - Assert.Equal(string.Empty, state.Path); + Assert.Equal(@"C:\Apps\MyEditor.exe", state.Path); + Assert.Equal(".", state.Arguments); } [Fact] - public void ReconcileForForm_StaleCustomPath_DisablesLaunch() + public void ReconcileForSave_PreservesCustomPathWhenLaunchDisabled() { - var state = CompanionAppCatalog.ReconcileForForm( + var state = CompanionAppCatalog.ReconcileForSave( CompanionAppCatalog.PresetCustom, @"C:\Missing\MyEditor.exe", - "."); + ".", + openOnLaunch: false); Assert.Equal(CompanionAppCatalog.PresetCustom, state.Preset); Assert.False(state.LaunchOnWorkspaceOpen); Assert.Equal(@"C:\Missing\MyEditor.exe", state.Path); - Assert.True(CompanionAppCatalog.ShouldShowPathWarning(state.Preset, state.Path)); } [Fact] - public void ReconcileForSave_ClearsWhenNotLaunchable() + public void ReconcileForForm_StaleCustomPath_DisablesLaunch() { - var state = CompanionAppCatalog.ReconcileForSave( + var state = CompanionAppCatalog.ReconcileForForm( CompanionAppCatalog.PresetCustom, @"C:\Missing\MyEditor.exe", "."); - Assert.Equal(CompanionAppCatalog.PresetNone, state.Preset); + Assert.Equal(CompanionAppCatalog.PresetCustom, state.Preset); Assert.False(state.LaunchOnWorkspaceOpen); - Assert.Equal(string.Empty, state.Path); + Assert.Equal(@"C:\Missing\MyEditor.exe", state.Path); + Assert.True(CompanionAppCatalog.ShouldShowPathWarning(state.Preset, state.Path)); } [Fact] diff --git a/QuickShell.Core/Services/CompanionAppCatalog.cs b/QuickShell.Core/Services/CompanionAppCatalog.cs index 7ea61fe..04622a1 100644 --- a/QuickShell.Core/Services/CompanionAppCatalog.cs +++ b/QuickShell.Core/Services/CompanionAppCatalog.cs @@ -239,12 +239,19 @@ public static CompanionAppFormState ReconcileStoredShortcut( string? executablePath, string? arguments) { - if (!openOnLaunch) + var path = executablePath?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(path)) { return new CompanionAppFormState(PresetNone, string.Empty, string.Empty, false); } - return ReconcileForForm(InferPresetFromPath(executablePath), executablePath, arguments); + var state = ReconcileForForm(InferPresetFromPath(path), executablePath, arguments); + if (string.Equals(state.Preset, PresetNone, StringComparison.OrdinalIgnoreCase)) + { + return state; + } + + return state with { LaunchOnWorkspaceOpen = openOnLaunch }; } public static CompanionAppFormState ReconcileForForm( @@ -316,12 +323,18 @@ public static CompanionAppFormState CreateStateFromPreset(string presetId) public static CompanionAppFormState ReconcileForSave( string? presetId, string? executablePath, - string? arguments) => - ReconcileForForm(presetId, executablePath, arguments) switch + string? arguments, + bool openOnLaunch) + { + var state = ReconcileForForm(presetId, executablePath, arguments); + if (string.Equals(state.Preset, PresetNone, StringComparison.OrdinalIgnoreCase) + || string.IsNullOrWhiteSpace(state.Path)) { - { LaunchOnWorkspaceOpen: true } state => state, - _ => new CompanionAppFormState(PresetNone, string.Empty, string.Empty, false), - }; + return new CompanionAppFormState(PresetNone, string.Empty, string.Empty, false); + } + + return state with { LaunchOnWorkspaceOpen = openOnLaunch }; + } public static bool ShouldShowExecutablePath(string preset, string? path) => string.Equals(preset, PresetCustom, StringComparison.OrdinalIgnoreCase) diff --git a/QuickShell.Core/Services/IShortcutRepository.cs b/QuickShell.Core/Services/IShortcutRepository.cs index 6d3f269..1365da5 100644 --- a/QuickShell.Core/Services/IShortcutRepository.cs +++ b/QuickShell.Core/Services/IShortcutRepository.cs @@ -66,6 +66,8 @@ internal interface IShortcutRepository TerminalShortcut? BuildDuplicate(string name); + TerminalShortcut BuildDuplicateFrom(TerminalShortcut source); + IEnumerable Search(string query); IEnumerable SearchForRootPalette(string query); diff --git a/QuickShell.Core/Services/ShortcutFormTemplateCache.cs b/QuickShell.Core/Services/ShortcutFormTemplateCache.cs index 5d23ad5..2317c82 100644 --- a/QuickShell.Core/Services/ShortcutFormTemplateCache.cs +++ b/QuickShell.Core/Services/ShortcutFormTemplateCache.cs @@ -7,17 +7,20 @@ internal static class ShortcutFormTemplateCache private static string? _templateJson; private static int _commandCount = -1; private static string? _terminalApplicationId; + private static string? _companionChoicesJson; public static string GetOrBuild( int commandCount, string terminalApplicationId, + string companionChoicesJson, Func buildTemplate) { lock (Sync) { if (_templateJson is not null && _commandCount == commandCount - && string.Equals(_terminalApplicationId, terminalApplicationId, StringComparison.OrdinalIgnoreCase)) + && string.Equals(_terminalApplicationId, terminalApplicationId, StringComparison.OrdinalIgnoreCase) + && string.Equals(_companionChoicesJson, companionChoicesJson, StringComparison.Ordinal)) { return _templateJson; } @@ -25,6 +28,7 @@ public static string GetOrBuild( var built = buildTemplate(); _commandCount = commandCount; _terminalApplicationId = terminalApplicationId; + _companionChoicesJson = companionChoicesJson; _templateJson = built; return built; } @@ -37,6 +41,7 @@ public static void Invalidate() _templateJson = null; _commandCount = -1; _terminalApplicationId = null; + _companionChoicesJson = null; } } } diff --git a/QuickShell.Core/Services/ShortcutRepository.cs b/QuickShell.Core/Services/ShortcutRepository.cs index faeb8f6..f435490 100644 --- a/QuickShell.Core/Services/ShortcutRepository.cs +++ b/QuickShell.Core/Services/ShortcutRepository.cs @@ -835,11 +835,11 @@ item.Shortcut is not null && public TerminalShortcut? BuildDuplicate(string name) { var source = GetByName(name); - if (source is null) - { - return null; - } + return source is null ? null : BuildDuplicateFrom(source); + } + public TerminalShortcut BuildDuplicateFrom(TerminalShortcut source) + { var copy = Clone(source); copy.Id = Guid.NewGuid().ToString("N"); copy.Name = GetDuplicateName(copy.Name); diff --git a/QuickShell/Commands/DuplicateShortcutCommand.cs b/QuickShell/Commands/DuplicateShortcutCommand.cs index 98c91ae..8b30283 100644 --- a/QuickShell/Commands/DuplicateShortcutCommand.cs +++ b/QuickShell/Commands/DuplicateShortcutCommand.cs @@ -1,4 +1,5 @@ using Microsoft.CommandPalette.Extensions.Toolkit; +using QuickShell.Models; using QuickShell.Pages; using QuickShell.Services; @@ -10,8 +11,8 @@ namespace QuickShell.Commands; /// internal sealed partial class DuplicateShortcutCommand : ShortcutFormPage { - public DuplicateShortcutCommand(string sourceName, Action onSaved) - : base(existing: null, onSaved, createSeed: QuickShellRuntimeServices.Shortcuts.BuildDuplicate(sourceName)) + public DuplicateShortcutCommand(TerminalShortcut source, Action onSaved) + : base(existing: null, onSaved, createSeed: QuickShellRuntimeServices.Shortcuts.BuildDuplicateFrom(source)) { Id = $"com.quickshell.shortcut-form.duplicate.{Guid.NewGuid():N}"; Name = "Duplicate"; diff --git a/QuickShell/Pages/ShortcutFormPage.cs b/QuickShell/Pages/ShortcutFormPage.cs index 7720d2f..225c328 100644 --- a/QuickShell/Pages/ShortcutFormPage.cs +++ b/QuickShell/Pages/ShortcutFormPage.cs @@ -55,6 +55,7 @@ public override IContent[] GetContent() => DevServerUrl = shortcut.DevServerUrl, RepoUrl = shortcut.RepoUrl, OpenCompanionAppOnLaunch = shortcut.OpenCompanionAppOnLaunch, + OpenDevServerOnLaunch = shortcut.OpenDevServerOnLaunch, CompanionAppPath = shortcut.CompanionAppPath, CompanionAppArguments = shortcut.CompanionAppArguments, }; @@ -99,6 +100,7 @@ public ShortcutForm(TerminalShortcut? existing, TerminalShortcut? createSeed, Ac Directory = initial?.Directory ?? string.Empty, DevServerUrl = initial?.DevServerUrl ?? string.Empty, RepoUrl = initial?.RepoUrl ?? string.Empty, + OpenDevServerOnLaunch = initial?.OpenDevServerOnLaunch ?? false, OpenCompanionAppOnLaunch = companion.LaunchOnWorkspaceOpen, CompanionAppPreset = companion.Preset, CompanionAppPath = companion.Path, @@ -237,6 +239,7 @@ private void TryRestoreEditDraft() Directory = restored.Directory, DevServerUrl = restored.DevServerUrl, RepoUrl = restored.RepoUrl, + OpenDevServerOnLaunch = restored.OpenDevServerOnLaunch, OpenCompanionAppOnLaunch = companion.LaunchOnWorkspaceOpen, CompanionAppPreset = companion.Preset, CompanionAppPath = companion.Path, @@ -378,12 +381,14 @@ private void RebuildTemplate(List com var terminalApplicationId = QuickShellRuntimeServices.Settings?.TerminalApplicationId ?? TerminalHostIds.WindowsTerminal; var commandCount = Math.Max(1, commands.Count); + var companionChoicesJson = CompanionAppCatalog.BuildFormChoicesJson(); TemplateJson = ShortcutFormTemplateCache.GetOrBuild( commandCount, terminalApplicationId, + companionChoicesJson, () => ShortcutFormTemplateJson.BuildTemplate( FormTerminalChoicesJson(), - CompanionAppCatalog.BuildFormChoicesJson(), + companionChoicesJson, commands.Select(command => command.Command).ToList(), QuickShellBrand.DisplayName)); } @@ -391,10 +396,10 @@ private void RebuildTemplate(List com private CommandResult HandleBrowseCompanionApp(string inputs) { MergeDraftFromInputs(inputs, out _); - return TryBrowseCustomCompanion(_draft.CompanionAppPreset); + return TryBrowseCustomCompanion(); } - private CommandResult TryBrowseCustomCompanion(string revertPreset) + private CommandResult TryBrowseCustomCompanion() { var selected = ShortcutFilePickerService.PickExecutableFile(); if (selected is null) @@ -689,7 +694,8 @@ private CommandResult SaveCurrentDraft() ApplyCompanionFormState(CompanionAppCatalog.ReconcileForSave( draft.CompanionAppPreset, draft.CompanionAppPath, - draft.CompanionAppArguments)); + draft.CompanionAppArguments, + draft.OpenCompanionAppOnLaunch)); var result = ShortcutFormSave.TrySave( originalName, @@ -705,6 +711,7 @@ private CommandResult SaveCurrentDraft() _onSaved, draft.DevServerUrl, draft.RepoUrl, + draft.OpenDevServerOnLaunch, draft.OpenCompanionAppOnLaunch, draft.CompanionAppPath, draft.CompanionAppArguments); @@ -790,6 +797,7 @@ private static ShortcutFormDraftData ToDraftData(FormDraft draft) LaunchTarget = draft.LaunchTarget, DevServerUrl = draft.DevServerUrl, RepoUrl = draft.RepoUrl, + OpenDevServerOnLaunch = draft.OpenDevServerOnLaunch, OpenCompanionAppOnLaunch = draft.OpenCompanionAppOnLaunch, CompanionAppPreset = draft.CompanionAppPreset, CompanionAppPath = draft.CompanionAppPath, @@ -1051,6 +1059,7 @@ private static FormDraft CloneDraft(FormDraft draft) => LaunchTarget = draft.LaunchTarget, DevServerUrl = draft.DevServerUrl, RepoUrl = draft.RepoUrl, + OpenDevServerOnLaunch = draft.OpenDevServerOnLaunch, OpenCompanionAppOnLaunch = draft.OpenCompanionAppOnLaunch, CompanionAppPreset = draft.CompanionAppPreset, CompanionAppPath = draft.CompanionAppPath, @@ -1105,6 +1114,8 @@ private sealed class FormDraft public string DevServerUrl { get; set; } = string.Empty; + public bool OpenDevServerOnLaunch { get; set; } + public string RepoUrl { get; set; } = string.Empty; public bool OpenCompanionAppOnLaunch { get; set; } diff --git a/QuickShell/QuickShellCommandsProvider.cs b/QuickShell/QuickShellCommandsProvider.cs index 9820d5f..77b13c3 100644 --- a/QuickShell/QuickShellCommandsProvider.cs +++ b/QuickShell/QuickShellCommandsProvider.cs @@ -120,15 +120,6 @@ private void ReloadPages() }; } - if (string.Equals(id, ShortcutLaunchFormPage.PageId, StringComparison.Ordinal)) - { - return new CommandItem(new ShortcutLaunchFormPage()) - { - Title = "Edit terminal", - Icon = new IconInfo("\uE756"), - }; - } - if (ShortcutCommandIds.TryParseOpen(id, out var openKey)) { var shortcut = QuickShellRuntimeServices.Shortcuts.ResolveForOpenCommand(openKey); diff --git a/QuickShell/Services/ShortcutContextCommands.cs b/QuickShell/Services/ShortcutContextCommands.cs index 1928202..4868936 100644 --- a/QuickShell/Services/ShortcutContextCommands.cs +++ b/QuickShell/Services/ShortcutContextCommands.cs @@ -89,7 +89,7 @@ public static CommandContextItem[] Build( showInHoverActions: true, hoverOrder: HoverOrderFavorite)); - var duplicateCommand = new DuplicateShortcutCommand(shortcut.Name, onChanged); + var duplicateCommand = new DuplicateShortcutCommand(shortcut, onChanged); items.Add(WithShortcut( duplicateCommand, ctrl: true, From 4a12689737967aa41cc669748dceb9cf8283afdb Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Thu, 2 Jul 2026 22:27:36 -0700 Subject: [PATCH 4/4] fix: filter companion app form choices to installed presets BuildFormChoicesJson now omits catalog presets that are not installed, matching IsPresetInstalled and fixing CI on runners where VS Code is on PATH but not at standard install locations. Co-authored-by: Cursor --- QuickShell.Core/Services/CompanionAppCatalog.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/QuickShell.Core/Services/CompanionAppCatalog.cs b/QuickShell.Core/Services/CompanionAppCatalog.cs index 04622a1..a20dd33 100644 --- a/QuickShell.Core/Services/CompanionAppCatalog.cs +++ b/QuickShell.Core/Services/CompanionAppCatalog.cs @@ -79,7 +79,10 @@ public static string BuildFormChoicesJson() foreach (var definition in Definitions) { - choices.Add(new { title = definition.Title, value = definition.Id }); + if (IsPresetInstalled(definition.Id)) + { + choices.Add(new { title = definition.Title, value = definition.Id }); + } } choices.Add(new { title = "Custom…", value = PresetCustom });