diff --git a/QuickShell.Core.Tests/FakeShortcutRepository.cs b/QuickShell.Core.Tests/FakeShortcutRepository.cs index 9fee085..e4286da 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; @@ -98,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/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.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..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 }); @@ -239,12 +242,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 +326,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/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..2317c82 --- /dev/null +++ b/QuickShell.Core/Services/ShortcutFormTemplateCache.cs @@ -0,0 +1,47 @@ +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; + 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(_companionChoicesJson, companionChoicesJson, StringComparison.Ordinal)) + { + return _templateJson; + } + + var built = buildTemplate(); + _commandCount = commandCount; + _terminalApplicationId = terminalApplicationId; + _companionChoicesJson = companionChoicesJson; + _templateJson = built; + return built; + } + } + + public static void Invalidate() + { + lock (Sync) + { + _templateJson = null; + _commandCount = -1; + _terminalApplicationId = null; + _companionChoicesJson = null; + } + } +} diff --git a/QuickShell.Core/Services/ShortcutFormTemplateJson.cs b/QuickShell.Core/Services/ShortcutFormTemplateJson.cs new file mode 100644 index 0000000..1a932a6 --- /dev/null +++ b/QuickShell.Core/Services/ShortcutFormTemplateJson.cs @@ -0,0 +1,351 @@ +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) +{ + 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. + /// 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/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.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..8b30283 100644 --- a/QuickShell/Commands/DuplicateShortcutCommand.cs +++ b/QuickShell/Commands/DuplicateShortcutCommand.cs @@ -1,31 +1,22 @@ using Microsoft.CommandPalette.Extensions.Toolkit; +using QuickShell.Models; +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(TerminalShortcut source, Action onSaved) + : base(existing: null, onSaved, createSeed: QuickShellRuntimeServices.Shortcuts.BuildDuplicateFrom(source)) { - _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..225c328 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,9 +53,9 @@ 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, + OpenDevServerOnLaunch = shortcut.OpenDevServerOnLaunch, CompanionAppPath = shortcut.CompanionAppPath, CompanionAppArguments = shortcut.CompanionAppArguments, }; @@ -69,10 +69,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 +86,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 { @@ -94,10 +101,10 @@ public ShortcutForm(TerminalShortcut? existing, TerminalShortcut? createSeed, Ac 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 +112,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 +188,13 @@ private void CaptureInputs(string payload) return; } - if (MergeDraftFromInputs(payload)) + if (MergeDraftFromInputs(payload, out var refreshForm)) { + if (refreshForm) + { + PublishDataJson(_draft); + } + PersistEditDraftIfNeeded(); } } @@ -147,6 +226,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, @@ -156,10 +240,10 @@ private void TryRestoreEditDraft() 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 +266,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 +318,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 +358,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 +366,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 +376,70 @@ 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); + var companionChoicesJson = CompanionAppCatalog.BuildFormChoicesJson(); + TemplateJson = ShortcutFormTemplateCache.GetOrBuild( + commandCount, + terminalApplicationId, + companionChoicesJson, + () => ShortcutFormTemplateJson.BuildTemplate( + FormTerminalChoicesJson(), + companionChoicesJson, + 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(); + } + private CommandResult TryBrowseCustomCompanion() + { 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 +454,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 +490,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 +531,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 +629,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 +666,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 +691,12 @@ private CommandResult SaveCurrentDraft() _autoFilledName = draft.Name; } + ApplyCompanionFormState(CompanionAppCatalog.ReconcileForSave( + draft.CompanionAppPreset, + draft.CompanionAppPath, + draft.CompanionAppArguments, + draft.OpenCompanionAppOnLaunch)); + var result = ShortcutFormSave.TrySave( originalName, draft.Name, @@ -616,32 +728,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 +751,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) @@ -696,8 +816,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 +832,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 +849,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 +960,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 +987,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"; @@ -949,7 +1075,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 +1102,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; 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/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, 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; - } -}