From 8141c012a93ec093eb199a8e8f8878d9847c9f78 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Thu, 2 Jul 2026 22:07:52 -0700 Subject: [PATCH 1/2] feat: home display settings, section layout, and configurable recents Add home page section ordering, recent workspace limits, adaptive card form helpers, and settings UI refinements. Includes Codex review fixes for form stepper actions and git repo index invalidation on reload. Co-authored-by: Cursor --- .../QuickShell.Core.Tests.csproj | 3 +- .../SectionListItemsTests.cs | 212 ++++++++++ .../WorkspaceUtilityTests.cs | 384 ++++++++++++++++-- .../Services/AdaptiveCardFormJson.cs | 52 +++ .../Services/QuickShellRecentSettings.cs | 42 ++ .../Services/QuickShellSettingsReader.cs | 40 +- QuickShell.Core/Services/ShortcutRecents.cs | 17 +- QuickShell/Pages/HomeDisplaySettingsForm.cs | 100 +++++ .../Pages/QuickShellExtensionSettingsPage.cs | 27 +- QuickShell/Pages/QuickShellPage.cs | 50 +-- .../Pages/TerminalDefaultsSettingsForm.cs | 36 +- QuickShell/QuickShell.csproj | 8 +- QuickShell/QuickShellCommandsProvider.cs | 1 - QuickShell/QuickShellSettingsManager.cs | 34 +- .../Services/DiscoverGitRepoListItems.cs | 24 +- QuickShell/Services/QuickShellStatus.cs | 42 +- QuickShell/Services/SectionListItems.cs | 40 ++ QuickShell/Services/SettingsCardJson.cs | 109 +++-- QuickShell/Services/SettingsFormHelpers.cs | 105 ++++- QuickShell/Services/ShortcutLayoutDisplay.cs | 79 +++- README.md | 8 +- 21 files changed, 1202 insertions(+), 211 deletions(-) create mode 100644 QuickShell.Core.Tests/SectionListItemsTests.cs create mode 100644 QuickShell.Core/Services/AdaptiveCardFormJson.cs create mode 100644 QuickShell.Core/Services/QuickShellRecentSettings.cs create mode 100644 QuickShell/Pages/HomeDisplaySettingsForm.cs create mode 100644 QuickShell/Services/SectionListItems.cs diff --git a/QuickShell.Core.Tests/QuickShell.Core.Tests.csproj b/QuickShell.Core.Tests/QuickShell.Core.Tests.csproj index 6cd5b5b..b2cc8fc 100644 --- a/QuickShell.Core.Tests/QuickShell.Core.Tests.csproj +++ b/QuickShell.Core.Tests/QuickShell.Core.Tests.csproj @@ -1,7 +1,7 @@ - net9.0-windows + net10.0-windows10.0.26100.0 QuickShell.Core.Tests enable enable @@ -23,6 +23,7 @@ + diff --git a/QuickShell.Core.Tests/SectionListItemsTests.cs b/QuickShell.Core.Tests/SectionListItemsTests.cs new file mode 100644 index 0000000..700932a --- /dev/null +++ b/QuickShell.Core.Tests/SectionListItemsTests.cs @@ -0,0 +1,212 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using QuickShell.Models; +using QuickShell.Services; + +namespace QuickShell.Core.Tests; + +public sealed class SectionListItemsTests +{ + [Fact] + public void InSection_InsertsSeparatorHeaderBeforeItems() + { + var items = SectionListItems.InSection( + "Favorites", + [CreateWorkspaceItem("Pinned")]).ToList(); + + Assert.Equal(2, items.Count); + AssertSeparator(items[0], "Favorites"); + Assert.IsType(items[1]); + } + + [Fact] + public void InSection_EmptyItems_DoesNotInsertHeader() + { + var items = SectionListItems.InSection("Favorites", []).ToList(); + Assert.Empty(items); + } + + [Fact] + public void InSection_BlankTitle_ReturnsItemsWithoutHeader() + { + var items = SectionListItems.InSection( + " ", + [CreateWorkspaceItem("Only")]).ToList(); + + Assert.Single(items); + Assert.IsType(items[0]); + } + + [Fact] + public void CreateHeader_UsesCmdPalSeparatorContract() + { + var header = SectionListItems.CreateHeader("Recent"); + + Assert.Null(header.Command); + Assert.Equal("Recent", header.Section); + Assert.Equal("Recent", header.Title); + } + + private static ListItem CreateWorkspaceItem(string title) => + new(new NoOpCommand()) + { + Title = title, + }; + + private static void AssertSeparator(IListItem item, string expectedTitle) + { + var separator = Assert.IsType(item); + Assert.Equal(expectedTitle, separator.Section); + Assert.Null(separator.Command); + } +} + +public sealed class ShortcutLayoutDisplayTests +{ + [Fact] + public void BuildListItems_WithPinnedAndUnpinned_EmitsFavoritesThenWorkspacesHeaders() + { + var pinned = CreateShortcut("Pinned", isPinned: true, pinOrder: 0); + var workspace = CreateShortcut("Workspace", isPinned: false); + + var layout = new List + { + ShortcutLayoutEntry.FromShortcut(pinned), + ShortcutLayoutEntry.FromShortcut(workspace), + }; + + var items = ShortcutLayoutDisplay.BuildListItems( + layout, + shortcut => CreateWorkspaceItem(shortcut.Name)).ToList(); + + Assert.Equal(4, items.Count); + AssertSeparator(items[0], ShortcutLayoutDisplay.FavoritesSectionTitle); + Assert.Equal("Pinned", ((ListItem)items[1]).Title); + AssertSeparator(items[2], ShortcutLayoutDisplay.ShortcutsSectionTitle); + Assert.Equal("Workspace", ((ListItem)items[3]).Title); + } + + [Fact] + public void BuildListItems_WithLayoutSeparator_EmitsCustomSectionHeader() + { + var alpha = CreateShortcut("Alpha", isPinned: false); + var beta = CreateShortcut("Beta", isPinned: false); + + var layout = new List + { + ShortcutLayoutEntry.FromShortcut(alpha), + ShortcutLayoutEntry.FromSeparator("Client repos"), + ShortcutLayoutEntry.FromShortcut(beta), + }; + + var items = ShortcutLayoutDisplay.BuildListItems( + layout, + shortcut => CreateWorkspaceItem(shortcut.Name)).ToList(); + + Assert.Equal(3, items.Count); + Assert.Equal("Alpha", ((ListItem)items[0]).Title); + AssertSeparator(items[1], "Client repos"); + Assert.Equal("Beta", ((ListItem)items[2]).Title); + } + + [Fact] + public void BuildWorkspaceItems_AfterFavoritesAndRecents_StillShowsWorkspacesHeader() + { + var pinned = CreateShortcut("Pinned", isPinned: true, pinOrder: 0); + var workspace = CreateShortcut("Workspace", isPinned: false); + + var layout = new List + { + ShortcutLayoutEntry.FromShortcut(pinned), + ShortcutLayoutEntry.FromShortcut(workspace), + }; + + var items = ShortcutLayoutDisplay.BuildWorkspaceItems( + layout, + shortcut => CreateWorkspaceItem(shortcut.Name), + excludeShortcutIds: new HashSet(StringComparer.OrdinalIgnoreCase), + showDefaultWorkspacesHeader: true).ToList(); + + Assert.Equal(2, items.Count); + AssertSeparator(items[0], ShortcutLayoutDisplay.ShortcutsSectionTitle); + Assert.Equal("Workspace", ((ListItem)items[1]).Title); + } + + [Fact] + public void HomeSectionOrder_FavoritesBeforeRecentsBeforeWorkspaces() + { + var pinned = CreateShortcut("Pinned", isPinned: true, pinOrder: 0); + var recent = CreateShortcut("Recent", isPinned: false); + recent.LastUsedUtc = DateTime.UtcNow; + var workspace = CreateShortcut("Workspace", isPinned: false); + + var layout = new List + { + ShortcutLayoutEntry.FromShortcut(pinned), + ShortcutLayoutEntry.FromShortcut(recent), + ShortcutLayoutEntry.FromShortcut(workspace), + }; + + var recentIds = new HashSet(StringComparer.OrdinalIgnoreCase) { recent.Id }; + var items = new List(); + items.AddRange(ShortcutLayoutDisplay.BuildFavoriteItems(layout, s => CreateWorkspaceItem(s.Name))); + items.AddRange(SectionListItems.InSection( + ShortcutRecents.SectionTitle, + [CreateWorkspaceItem(recent.Name)])); + items.AddRange(ShortcutLayoutDisplay.BuildWorkspaceItems( + layout, + s => CreateWorkspaceItem(s.Name), + recentIds, + showDefaultWorkspacesHeader: true)); + + Assert.Equal(6, items.Count); + AssertSeparator(items[0], ShortcutLayoutDisplay.FavoritesSectionTitle); + Assert.Equal("Pinned", ((ListItem)items[1]).Title); + AssertSeparator(items[2], ShortcutRecents.SectionTitle); + Assert.Equal("Recent", ((ListItem)items[3]).Title); + AssertSeparator(items[4], ShortcutLayoutDisplay.ShortcutsSectionTitle); + Assert.Equal("Workspace", ((ListItem)items[5]).Title); + } + + [Fact] + public void BuildListItems_ExcludesRecentShortcutIds() + { + var recent = CreateShortcut("Recent", isPinned: false); + recent.Id = "recent-id"; + + var layout = new List + { + ShortcutLayoutEntry.FromShortcut(recent), + }; + + var items = ShortcutLayoutDisplay.BuildListItems( + layout, + shortcut => CreateWorkspaceItem(shortcut.Name), + excludeShortcutIds: new HashSet(StringComparer.OrdinalIgnoreCase) { recent.Id }).ToList(); + + Assert.Empty(items); + } + + private static TerminalShortcut CreateShortcut(string name, bool isPinned, int? pinOrder = null) => + new() + { + Id = Guid.NewGuid().ToString("N"), + Name = name, + Directory = @"C:\Projects\" + name, + IsPinned = isPinned, + PinOrder = pinOrder, + }; + + private static ListItem CreateWorkspaceItem(string title) => + new(new NoOpCommand()) + { + Title = title, + }; + + private static void AssertSeparator(IListItem item, string expectedTitle) + { + var separator = Assert.IsType(item); + Assert.Equal(expectedTitle, separator.Section); + Assert.Null(separator.Command); + } +} diff --git a/QuickShell.Core.Tests/WorkspaceUtilityTests.cs b/QuickShell.Core.Tests/WorkspaceUtilityTests.cs index 6c7e95c..6cf9e78 100644 --- a/QuickShell.Core.Tests/WorkspaceUtilityTests.cs +++ b/QuickShell.Core.Tests/WorkspaceUtilityTests.cs @@ -1,5 +1,6 @@ using QuickShell.Models; using QuickShell.Services; +using System.Text.Json; namespace QuickShell.Core.Tests; @@ -108,6 +109,39 @@ public void GetRecentWorkspaces_OrdersByLastUsedAndSkipsPinned() Assert.Single(recents); Assert.Equal("Recent", recents[0].Name); } + + [Theory] + [InlineData(-5, 0)] + [InlineData(0, 0)] + [InlineData(8, 8)] + [InlineData(100, 100)] + [InlineData(150, 100)] + public void NormalizeCount_ClampsToRange(int input, int expected) => + Assert.Equal(expected, QuickShellRecentSettings.NormalizeCount(input)); + + [Fact] + public void TryParseCount_PrefersInvariantCultureDigits() + { + Assert.True(QuickShellRecentSettings.TryParseCount("12", out var parsed)); + Assert.Equal(12, parsed); + Assert.Equal("12", QuickShellRecentSettings.FormatCount(12)); + } + + [Fact] + public void GetRecentWorkspaces_RespectsMaxCount() + { + var shortcuts = Enumerable.Range(1, 12) + .Select(index => new TerminalShortcut + { + Id = index.ToString(), + Name = $"Workspace {index}", + LastUsedUtc = DateTime.UtcNow.AddMinutes(-index), + }) + .ToList(); + + Assert.Equal(3, ShortcutRecents.GetRecentWorkspaces(shortcuts, maxCount: 3).Count); + Assert.Empty(ShortcutRecents.GetRecentWorkspaces(shortcuts, maxCount: 0)); + } } public sealed class WorkspaceLinkValidationTests @@ -182,7 +216,21 @@ public void TryDetectDevServerUrl_ReturnsNullWhenNoPackageJson() } [Fact] - public void TryDetectDevLaunchCommand_ReturnsPackageManagerCommand() + public void TryDetectDevLaunchCommand_UsesNpmByDefault() + { + WritePackageJson(""" + { + "scripts": { + "dev": "vite" + } + } + """); + + Assert.Equal("npm run dev", DevServerUrlDetection.TryDetectDevLaunchCommand(_root)); + } + + [Fact] + public void TryDetectDevLaunchCommand_UsesPnpmWhenLockfilePresent() { WritePackageJson(""" { @@ -194,7 +242,6 @@ public void TryDetectDevLaunchCommand_ReturnsPackageManagerCommand() File.WriteAllText(Path.Combine(_root, "pnpm-lock.yaml"), string.Empty); Assert.Equal("pnpm dev", DevServerUrlDetection.TryDetectDevLaunchCommand(_root)); - Assert.Equal("pnpm dev", DevServerUrlDetection.FormatPackageScriptCommand(_root, "dev")); } [Fact] @@ -224,30 +271,19 @@ public void TryDetectDevLaunchCommand_FallsBackToStartScript() } [Fact] - public void ApplyDirectoryHints_SyncsDetectedDevCommandToLaunchEntry() + public void TryDetectDevLaunchCommand_ReturnsNullWhenNoScripts() { WritePackageJson(""" { - "scripts": { - "dev": "vite" - } + "scripts": {} } """); - var seed = WorkspaceSeedFactory.ApplyDirectoryHints(new TerminalShortcut - { - Name = "sample", - Directory = _root, - Launches = [], - }); - - Assert.Equal("npm run dev", seed.Command); - Assert.Single(seed.Launches); - Assert.Equal("npm run dev", seed.Launches[0].Command); + Assert.Null(DevServerUrlDetection.TryDetectDevLaunchCommand(_root)); } [Fact] - public void ApplyDirectoryHints_UpdatesExistingBlankLaunchEntry() + public void ApplyDirectoryHints_SyncsDetectedDevCommandToLaunchEntry() { WritePackageJson(""" { @@ -261,20 +297,11 @@ public void ApplyDirectoryHints_UpdatesExistingBlankLaunchEntry() { Name = "sample", Directory = _root, - Launches = - [ - new WorkspaceEntry - { - Id = "launch-1", - Label = "Main", - Terminal = "default", - IsEnabled = true, - Order = 0, - }, - ], + Launches = [], }); Assert.Equal("npm run dev", seed.Command); + Assert.Single(seed.Launches); Assert.Equal("npm run dev", seed.Launches[0].Command); } @@ -408,19 +435,92 @@ public void ExpandArguments_ReplacesFolderTokenAndDot() } [Fact] - public void ExpandArguments_ReplacesSolutionToken() + public void ExpandArguments_ReplacesSolutionTokenWithSlnOrFolder() { var directory = Path.Combine(_root, "sample app"); Directory.CreateDirectory(directory); - var solutionPath = Path.Combine(directory, "Sample App.sln"); + var solutionPath = Path.Combine(directory, "App.sln"); File.WriteAllText(solutionPath, string.Empty); + Assert.Equal($"\"{solutionPath}\"", CompanionAppLauncher.ExpandArguments("{solution}", directory)); + Assert.Equal(_root, CompanionAppLauncher.ExpandArguments("{solution}", _root)); + } + + [Fact] + public void BuildFormChoicesJson_AlwaysIncludesExplorerOnWindows() + { + using var document = JsonDocument.Parse(CompanionAppCatalog.BuildFormChoicesJson()); + var values = document.RootElement + .EnumerateArray() + .Select(choice => choice.GetProperty("value").GetString()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + Assert.Contains(CompanionAppCatalog.PresetExplorer, values); + } + + [Fact] + public void BuildFormChoicesJson_OnlyIncludesInstalledPresets() + { + using var document = JsonDocument.Parse(CompanionAppCatalog.BuildFormChoicesJson()); + var values = document.RootElement + .EnumerateArray() + .Select(choice => choice.GetProperty("value").GetString()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + Assert.Contains(CompanionAppCatalog.PresetNone, values); + Assert.Contains(CompanionAppCatalog.PresetCustom, values); + + if (CompanionAppCatalog.IsPresetInstalled(CompanionAppCatalog.PresetVsCode)) + { + Assert.Contains(CompanionAppCatalog.PresetVsCode, values); + } + else + { + Assert.DoesNotContain(CompanionAppCatalog.PresetVsCode, values); + } + } + + [Fact] + public void NormalizePresetForForm_FallsBackWhenCatalogPresetMissing() + { + if (CompanionAppCatalog.IsPresetInstalled(CompanionAppCatalog.PresetVsCode)) + { + Assert.Equal( + CompanionAppCatalog.PresetVsCode, + CompanionAppCatalog.NormalizePresetForForm( + CompanionAppCatalog.PresetVsCode, + @"C:\Apps\Code.exe")); + return; + } + Assert.Equal( - $"\"{solutionPath}\"", - CompanionAppLauncher.ExpandArguments("{solution}", directory)); + CompanionAppCatalog.PresetCustom, + CompanionAppCatalog.NormalizePresetForForm( + CompanionAppCatalog.PresetVsCode, + @"C:\Apps\Code.exe")); + Assert.Equal( + CompanionAppCatalog.PresetNone, + CompanionAppCatalog.NormalizePresetForForm( + CompanionAppCatalog.PresetVsCode, + executablePath: null)); + } + + [Fact] + public void GetContextMenuIcon_UsesCodeIconForCursor() + { Assert.Equal( - Path.Combine(_root, "no-solution"), - CompanionAppLauncher.ExpandArguments("{solution}", Path.Combine(_root, "no-solution"))); + "\uE90F", + CompanionAppCatalog.GetContextMenuIcon( + @"C:\Users\me\AppData\Local\Programs\cursor\Cursor.exe")); + } + + [Fact] + public void GetContextMenuIcon_UsesOpenWithForUnknownCustomExe() + { + Assert.Equal( + ShortcutGlyphs.OpenCompanionApp, + CompanionAppCatalog.GetContextMenuIcon(@"C:\Tools\MyCustomApp.exe")); + Assert.Equal("\uE7AC", ShortcutGlyphs.OpenCompanionApp); } [Fact] @@ -443,11 +543,211 @@ public void InferPresetFromPath_RecognizesKnownEditors() Assert.Equal( CompanionAppCatalog.PresetVsCode, CompanionAppCatalog.InferPresetFromPath(@"C:\Apps\Microsoft VS Code\Code.exe")); + Assert.Equal( + CompanionAppCatalog.PresetFork, + CompanionAppCatalog.InferPresetFromPath(@"C:\Apps\Fork\Fork.exe")); + Assert.Equal( + CompanionAppCatalog.PresetRider, + CompanionAppCatalog.InferPresetFromPath(@"C:\Apps\JetBrains\Rider\bin\rider64.exe")); + Assert.Equal( + CompanionAppCatalog.PresetIntelliJIdea, + CompanionAppCatalog.InferPresetFromPath(@"C:\Apps\JetBrains\IntelliJ IDEA\bin\idea64.exe")); + Assert.Equal( + CompanionAppCatalog.PresetZed, + CompanionAppCatalog.InferPresetFromPath(@"C:\Apps\Zed\zed.exe")); + Assert.Equal( + CompanionAppCatalog.PresetNotepadPlusPlus, + CompanionAppCatalog.InferPresetFromPath(@"C:\Program Files\Notepad++\notepad++.exe")); + Assert.Equal( + CompanionAppCatalog.PresetVs2022, + CompanionAppCatalog.InferPresetFromPath( + @"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\IDE\devenv.exe")); Assert.Equal( CompanionAppCatalog.PresetCustom, CompanionAppCatalog.InferPresetFromPath(@"C:\Apps\MyEditor.exe")); } + [Fact] + public void TrySuggestFromDirectory_PrefersObsidianWhenVaultMarkerExists() + { + Directory.CreateDirectory(Path.Combine(_root, ".obsidian")); + + var suggestion = CompanionAppDetection.TrySuggestFromDirectory(_root); + + if (CompanionAppCatalog.TryResolveExecutable(CompanionAppCatalog.PresetObsidian) is null) + { + Assert.Null(suggestion); + return; + } + + Assert.Equal(CompanionAppCatalog.PresetObsidian, suggestion!.PresetId); + } + + [Fact] + public void TrySuggestFromDirectory_PrefersGitClientWhenRepositoryExists() + { + Directory.CreateDirectory(Path.Combine(_root, ".git")); + + var suggestion = CompanionAppDetection.TrySuggestFromDirectory(_root); + if (suggestion is null) + { + Assert.False(CompanionAppCatalog.IsPresetInstalled(CompanionAppCatalog.PresetFork) + || CompanionAppCatalog.IsPresetInstalled(CompanionAppCatalog.PresetGitHubDesktop)); + return; + } + + Assert.True( + suggestion.PresetId is CompanionAppCatalog.PresetFork or CompanionAppCatalog.PresetGitHubDesktop); + } + + [Fact] + public void TrySuggestFromDirectory_PrefersVisualStudioWhenSolutionExists() + { + File.WriteAllText(Path.Combine(_root, "App.sln"), string.Empty); + + var suggestion = CompanionAppDetection.TrySuggestFromDirectory(_root); + if (suggestion is null) + { + Assert.False(CompanionAppCatalog.IsPresetInstalled(CompanionAppCatalog.PresetVs2022) + && CompanionAppCatalog.IsPresetInstalled(CompanionAppCatalog.PresetVs2026)); + return; + } + + Assert.True( + suggestion.PresetId is CompanionAppCatalog.PresetVs2022 or CompanionAppCatalog.PresetVs2026); + Assert.Equal("{solution}", suggestion.Arguments); + } + + [Fact] + public void TrySuggestFromDirectory_PrefersRiderForDotNetIdeaProjects() + { + Directory.CreateDirectory(Path.Combine(_root, ".idea")); + File.WriteAllText(Path.Combine(_root, "App.csproj"), ""); + + var suggestion = CompanionAppDetection.TrySuggestFromDirectory(_root); + if (suggestion is null) + { + Assert.False(CompanionAppCatalog.IsPresetInstalled(CompanionAppCatalog.PresetRider)); + return; + } + + Assert.Equal(CompanionAppCatalog.PresetRider, suggestion.PresetId); + } + + [Fact] + public void TrySuggestFromDirectory_PrefersIntelliJForIdeaProjectsWithoutDotNet() + { + Directory.CreateDirectory(Path.Combine(_root, ".idea")); + File.WriteAllText(Path.Combine(_root, "pom.xml"), ""); + + var suggestion = CompanionAppDetection.TrySuggestFromDirectory(_root); + if (suggestion is null) + { + Assert.False(CompanionAppCatalog.IsPresetInstalled(CompanionAppCatalog.PresetIntelliJIdea)); + return; + } + + Assert.Equal(CompanionAppCatalog.PresetIntelliJIdea, suggestion.PresetId); + } + + [Fact] + public void TrySuggestFromDirectory_PrefersZedWhenZedMarkerExists() + { + Directory.CreateDirectory(Path.Combine(_root, ".zed")); + + var suggestion = CompanionAppDetection.TrySuggestFromDirectory(_root); + if (suggestion is null) + { + Assert.False(CompanionAppCatalog.IsPresetInstalled(CompanionAppCatalog.PresetZed)); + return; + } + + Assert.Equal(CompanionAppCatalog.PresetZed, suggestion.PresetId); + } + + [Fact] + public void CreateStateFromPreset_Explorer_UsesFolderArgument() + { + if (!CompanionAppCatalog.IsPresetInstalled(CompanionAppCatalog.PresetExplorer)) + { + return; + } + + var state = CompanionAppCatalog.CreateStateFromPreset(CompanionAppCatalog.PresetExplorer); + + Assert.True(state.LaunchOnWorkspaceOpen); + Assert.Equal("{folder}", state.Arguments); + } + + [Fact] + public void ReconcileStoredShortcut_WhenLaunchDisabled_ReturnsNone() + { + var state = CompanionAppCatalog.ReconcileStoredShortcut( + openOnLaunch: false, + @"C:\Apps\Code.exe", + "."); + + Assert.Equal(CompanionAppCatalog.PresetNone, state.Preset); + Assert.False(state.LaunchOnWorkspaceOpen); + Assert.Equal(string.Empty, state.Path); + } + + [Fact] + public void ReconcileForForm_StaleCustomPath_DisablesLaunch() + { + var state = CompanionAppCatalog.ReconcileForForm( + CompanionAppCatalog.PresetCustom, + @"C:\Missing\MyEditor.exe", + "."); + + 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() + { + var state = CompanionAppCatalog.ReconcileForSave( + CompanionAppCatalog.PresetCustom, + @"C:\Missing\MyEditor.exe", + "."); + + Assert.Equal(CompanionAppCatalog.PresetNone, state.Preset); + Assert.False(state.LaunchOnWorkspaceOpen); + Assert.Equal(string.Empty, state.Path); + } + + [Fact] + public void CreateStateFromPreset_None_ClearsCompanion() + { + var state = CompanionAppCatalog.CreateStateFromPreset(CompanionAppCatalog.PresetNone); + + Assert.False(state.LaunchOnWorkspaceOpen); + Assert.Equal(string.Empty, state.Path); + Assert.Equal(string.Empty, state.Arguments); + } + + [Fact] + public void ReconcileForForm_CatalogPreset_ReResolvesWhenInstalled() + { + if (!CompanionAppCatalog.IsPresetInstalled(CompanionAppCatalog.PresetVsCode)) + { + return; + } + + var state = CompanionAppCatalog.ReconcileForForm( + CompanionAppCatalog.PresetVsCode, + @"C:\Stale\Code.exe", + "--old"); + + Assert.Equal(CompanionAppCatalog.PresetVsCode, state.Preset); + Assert.True(state.LaunchOnWorkspaceOpen); + Assert.True(CompanionAppCatalog.TryResolveExecutablePath(state.Path, out _)); + Assert.Equal(".", state.Arguments); + } + public void Dispose() { try @@ -515,6 +815,22 @@ public void GetListGlyph_UsesAdminIconWhenHealthyAndElevated() Assert.Equal(ShortcutGlyphs.AdminLaunch, ShortcutHealth.GetListGlyph(shortcut)); } + [Fact] + public void BuildListSubtitle_WarnsWhenCompanionAppMissing() + { + var shortcut = new TerminalShortcut + { + Name = "Missing companion", + Directory = _root, + OpenCompanionAppOnLaunch = true, + CompanionAppPath = @"C:\Missing\Code.exe", + Launches = [new WorkspaceEntry { Label = "Main", IsEnabled = true }], + }; + + Assert.False(ShortcutHealth.NeedsRepair(shortcut)); + Assert.Contains("Companion app missing", ShortcutHealth.BuildListSubtitle(shortcut), StringComparison.Ordinal); + } + public void Dispose() { try diff --git a/QuickShell.Core/Services/AdaptiveCardFormJson.cs b/QuickShell.Core/Services/AdaptiveCardFormJson.cs new file mode 100644 index 0000000..fbc9caa --- /dev/null +++ b/QuickShell.Core/Services/AdaptiveCardFormJson.cs @@ -0,0 +1,52 @@ +namespace QuickShell.Services; + +/// +/// Shared Adaptive Card field fragments for Command Palette forms. +/// +internal static class AdaptiveCardFormJson +{ + public static string FieldLabel(string label) => + $$""" + { + "type": "TextBlock", + "text": "{{Escape(label)}}", + "weight": "Bolder", + "wrap": true, + "spacing": "None" + } + """; + + public static string FieldHelp(string text) => + $$""" + { + "type": "TextBlock", + "text": "{{Escape(text)}}", + "wrap": true, + "isSubtle": true, + "size": "Small", + "spacing": "None" + } + """; + + public static string FieldGroup(string label, string help, string inputElementJson) => + $$""" + { + "type": "Container", + "spacing": "Medium", + "items": [ + { + "type": "Container", + "spacing": "Small", + "items": [ + {{FieldLabel(label)}}, + {{FieldHelp(help)}}, + {{inputElementJson}} + ] + } + ] + } + """; + + private static string Escape(string value) => + value.Replace("\\", "\\\\").Replace("\"", "\\\""); +} diff --git a/QuickShell.Core/Services/QuickShellRecentSettings.cs b/QuickShell.Core/Services/QuickShellRecentSettings.cs new file mode 100644 index 0000000..267ade6 --- /dev/null +++ b/QuickShell.Core/Services/QuickShellRecentSettings.cs @@ -0,0 +1,42 @@ +namespace QuickShell.Services; + +using System.Globalization; + +internal static class QuickShellRecentSettings +{ + public const string SettingKey = "recentWorkspaceCount"; + public const int DefaultCount = 8; + public const int MinCount = 0; + public const int MaxCount = 100; + + public static int NormalizeCount(int? value) => + value switch + { + null => DefaultCount, + < MinCount => MinCount, + > MaxCount => MaxCount, + _ => value.Value, + }; + + public static string FormatCount(int count) => + NormalizeCount(count).ToString(CultureInfo.InvariantCulture); + + public static bool TryParseCount(string? raw, out int count) + { + if (string.IsNullOrWhiteSpace(raw)) + { + count = DefaultCount; + return false; + } + + if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out count) + || int.TryParse(raw, NumberStyles.Integer, CultureInfo.CurrentCulture, out count)) + { + count = NormalizeCount(count); + return true; + } + + count = DefaultCount; + return false; + } +} diff --git a/QuickShell.Core/Services/QuickShellSettingsReader.cs b/QuickShell.Core/Services/QuickShellSettingsReader.cs index 02fd2ce..962d8dc 100644 --- a/QuickShell.Core/Services/QuickShellSettingsReader.cs +++ b/QuickShell.Core/Services/QuickShellSettingsReader.cs @@ -28,12 +28,15 @@ public void SaveTerminalDefaults(string terminalApplicationId, string defaultPro var profile = EnsureValidDefaultProfile(app, defaultProfileId); var directory = Path.GetDirectoryName(SettingsPath)!; Directory.CreateDirectory(directory); + var recentCount = ReadRecentWorkspaceCount(); var json = - $$"""{"terminalApplication":"{{EscapeJson(app)}}","defaultProfile":"{{EscapeJson(profile)}}"}"""; + $$"""{"terminalApplication":"{{EscapeJson(app)}}","defaultProfile":"{{EscapeJson(profile)}}","{{QuickShellRecentSettings.SettingKey}}":"{{QuickShellRecentSettings.FormatCount(recentCount)}}"}"""; File.WriteAllText(SettingsPath, json); TerminalCatalog.InvalidateCache(); } + public int ReadRecentWorkspaceCount() => ReadRecentWorkspaceCountFromFile(SettingsPath); + public string ConfigDirectory => Path.GetDirectoryName(SettingsPath)!; @@ -215,4 +218,39 @@ private static string LoadLegacyDefaultTerminal(string legacyPath) return TerminalHostIds.WindowsTerminal; } } + + internal static int ReadRecentWorkspaceCountFromFile(string settingsPath) + { + try + { + if (!File.Exists(settingsPath)) + { + return QuickShellRecentSettings.DefaultCount; + } + + using var stream = File.OpenRead(settingsPath); + using var document = JsonDocument.Parse(stream); + if (!document.RootElement.TryGetProperty(QuickShellRecentSettings.SettingKey, out var value)) + { + return QuickShellRecentSettings.DefaultCount; + } + + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) + { + return QuickShellRecentSettings.NormalizeCount(number); + } + + if (value.ValueKind == JsonValueKind.String + && QuickShellRecentSettings.TryParseCount(value.GetString(), out var parsed)) + { + return parsed; + } + } + catch + { + return QuickShellRecentSettings.DefaultCount; + } + + return QuickShellRecentSettings.DefaultCount; + } } diff --git a/QuickShell.Core/Services/ShortcutRecents.cs b/QuickShell.Core/Services/ShortcutRecents.cs index 12f1145..a298ba4 100644 --- a/QuickShell.Core/Services/ShortcutRecents.cs +++ b/QuickShell.Core/Services/ShortcutRecents.cs @@ -4,13 +4,22 @@ namespace QuickShell.Services; internal static class ShortcutRecents { - public const int MaxCount = 8; public const string SectionTitle = "Recent"; - public static List GetRecentWorkspaces(IReadOnlyList shortcuts) => - shortcuts + public static List GetRecentWorkspaces( + IReadOnlyList shortcuts, + int maxCount = QuickShellRecentSettings.DefaultCount) + { + var limit = QuickShellRecentSettings.NormalizeCount(maxCount); + if (limit == 0) + { + return []; + } + + return shortcuts .Where(shortcut => shortcut.LastUsedUtc is not null && !shortcut.IsPinned) .OrderByDescending(shortcut => shortcut.LastUsedUtc) - .Take(MaxCount) + .Take(limit) .ToList(); + } } diff --git a/QuickShell/Pages/HomeDisplaySettingsForm.cs b/QuickShell/Pages/HomeDisplaySettingsForm.cs new file mode 100644 index 0000000..f708976 --- /dev/null +++ b/QuickShell/Pages/HomeDisplaySettingsForm.cs @@ -0,0 +1,100 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using QuickShell.Services; +using System.Text.Json.Nodes; + +namespace QuickShell.Pages; + +internal sealed partial class HomeDisplaySettingsForm : FormContent +{ + private readonly QuickShellSettingsManager _settingsManager; + private readonly Action? _onReload; + private readonly Action? _onSettingsChanged; + private int _pendingRecentCount; + + public HomeDisplaySettingsForm( + QuickShellSettingsManager settingsManager, + Action? onReload = null, + Action? onSettingsChanged = null) + { + _settingsManager = settingsManager; + _onReload = onReload; + _onSettingsChanged = onSettingsChanged; + _pendingRecentCount = settingsManager.RecentWorkspaceCount; + RebuildTemplate(); + } + + public override CommandResult SubmitForm(string payload) => SubmitForm(payload, string.Empty); + + public override CommandResult SubmitForm(string inputs, string data) + { + var action = TryGetAction(data) ?? TryGetActionFromInputs(inputs); + return action switch + { + "recentDecrement" => AdjustRecentCount(-1), + "recentIncrement" => AdjustRecentCount(1), + _ => CommandResult.KeepOpen(), + }; + } + + private CommandResult AdjustRecentCount(int delta) + { + var next = QuickShellRecentSettings.NormalizeCount(_pendingRecentCount + delta); + if (next == _pendingRecentCount) + { + return CommandResult.KeepOpen(); + } + + _pendingRecentCount = next; + RebuildTemplate(); + ScheduleDebouncedCommit(); + return CommandResult.KeepOpen(); + } + + private void ScheduleDebouncedCommit() + { + SettingsFormHelpers.ScheduleDebouncedReload(CommitPendingRecentCount); + } + + private void CommitPendingRecentCount() + { + if (_pendingRecentCount == _settingsManager.RecentWorkspaceCount) + { + return; + } + + _settingsManager.UpdateRecentWorkspaceCount(_pendingRecentCount); + _onReload?.Invoke(); + _onSettingsChanged?.Invoke(); + QuickShellStatus.ShowToast("Saved"); + } + + private void RebuildTemplate() + { + var bodyParts = new List + { + SettingsCardJson.SectionHeader("Home display"), + SettingsCardJson.RecentCountStepper(_pendingRecentCount), + }; + + var bodyJson = string.Join(",\n ", bodyParts); + + TemplateJson = $$""" + { + "type": "AdaptiveCard", + "version": "1.6", + "body": [ + {{bodyJson}} + ] + } + """; + } + + private static string? TryGetAction(string? data) => + string.IsNullOrWhiteSpace(data) + ? null + : JsonNode.Parse(data)?.AsObject()?["action"]?.ToString(); + + private static string? TryGetActionFromInputs(string inputs) => + JsonNode.Parse(inputs)?.AsObject()?["action"]?.ToString(); +} diff --git a/QuickShell/Pages/QuickShellExtensionSettingsPage.cs b/QuickShell/Pages/QuickShellExtensionSettingsPage.cs index 0c1c9f2..3e13d75 100644 --- a/QuickShell/Pages/QuickShellExtensionSettingsPage.cs +++ b/QuickShell/Pages/QuickShellExtensionSettingsPage.cs @@ -40,10 +40,12 @@ internal sealed partial class QuickShellExtensionSettingsPage : ContentPage private readonly QuickShellSettingsManager _settingsManager; - - private readonly Action _onReload; + private TerminalDefaultsSettingsForm? _terminalDefaultsForm; + private HomeDisplaySettingsForm? _homeDisplayForm; + private ShortcutTransferSettingsForm? _transferForm; + @@ -126,30 +128,15 @@ public override IContent[] GetContent() if (QuickShellRuntimeServices.Drafts.HasPending) - - - { - - - content.Add(new PendingShortcutEditForm(_onReload, refreshSettings)); - - - } + content.Add(_terminalDefaultsForm ??= new TerminalDefaultsSettingsForm(_settingsManager, _onReload, refreshSettings)); + content.Add(_homeDisplayForm ??= new HomeDisplaySettingsForm(_settingsManager, _onReload, refreshSettings)); - - - - - content.Add(new TerminalDefaultsSettingsForm(_settingsManager, _onReload, refreshSettings)); - - - - content.Add(new ShortcutTransferSettingsForm(_onReload, refreshSettings)); + content.Add(_transferForm ??= new ShortcutTransferSettingsForm(_onReload, refreshSettings)); diff --git a/QuickShell/Pages/QuickShellPage.cs b/QuickShell/Pages/QuickShellPage.cs index 05f86e7..41d0154 100644 --- a/QuickShell/Pages/QuickShellPage.cs +++ b/QuickShell/Pages/QuickShellPage.cs @@ -79,7 +79,6 @@ public override void UpdateSearchText(string oldSearch, string newSearch) public void Reload() { - GitRepoIndex.Invalidate(); _searchDebouncer.FlushNow(); RefreshItems(_query); } @@ -185,50 +184,37 @@ private IEnumerable BuildHomeLayoutItems( List pinnedInOrder) { var allShortcuts = QuickShellRuntimeServices.Shortcuts.GetShortcuts(); - var recents = ShortcutRecents.GetRecentWorkspaces(allShortcuts); + var recents = ShortcutRecents.GetRecentWorkspaces(allShortcuts, _settings.RecentWorkspaceCount); var recentIds = recents .Select(shortcut => shortcut.Id) .ToHashSet(StringComparer.OrdinalIgnoreCase); + var hasFavorites = layout.Any(entry => + entry.Kind == ShortcutLayoutEntryKind.Shortcut && entry.Shortcut?.IsPinned == true); - if (recents.Count > 0) + foreach (var item in ShortcutLayoutDisplay.BuildFavoriteItems( + layout, + shortcut => BuildShortcutItem(shortcut, pinnedInOrder))) { - yield return new Separator(ShortcutRecents.SectionTitle); - foreach (var shortcut in recents) - { - yield return BuildShortcutItem(shortcut, pinnedInOrder); - } + yield return item; } - var pinnedShortcuts = layout - .Where(entry => entry.Kind == ShortcutLayoutEntryKind.Shortcut && entry.Shortcut?.IsPinned == true) - .Select(entry => entry.Shortcut!) - .OrderBy(shortcut => shortcut.PinOrder ?? int.MaxValue) - .ThenBy(shortcut => shortcut.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (pinnedShortcuts.Count > 0) + if (recents.Count > 0) { - yield return new Separator(ShortcutLayoutDisplay.FavoritesSectionTitle); - foreach (var shortcut in pinnedShortcuts) + foreach (var item in SectionListItems.InSection( + ShortcutRecents.SectionTitle, + recents.Select(shortcut => BuildShortcutItem(shortcut, pinnedInOrder)))) { - yield return BuildShortcutItem(shortcut, pinnedInOrder); + yield return item; } - - yield return new Separator(ShortcutLayoutDisplay.ShortcutsSectionTitle); } - foreach (var entry in layout) + foreach (var item in ShortcutLayoutDisplay.BuildWorkspaceItems( + layout, + shortcut => BuildShortcutItem(shortcut, pinnedInOrder), + recentIds, + showDefaultWorkspacesHeader: hasFavorites)) { - switch (entry.Kind) - { - case ShortcutLayoutEntryKind.Separator: - yield return new Separator(entry.SeparatorTitle ?? string.Empty); - break; - case ShortcutLayoutEntryKind.Shortcut when entry.Shortcut is { IsPinned: false } shortcut - && !recentIds.Contains(shortcut.Id): - yield return BuildShortcutItem(shortcut, pinnedInOrder); - break; - } + yield return item; } } } diff --git a/QuickShell/Pages/TerminalDefaultsSettingsForm.cs b/QuickShell/Pages/TerminalDefaultsSettingsForm.cs index c9a561a..9b4765e 100644 --- a/QuickShell/Pages/TerminalDefaultsSettingsForm.cs +++ b/QuickShell/Pages/TerminalDefaultsSettingsForm.cs @@ -35,17 +35,24 @@ public override CommandResult SubmitForm(string inputs, string data) return RefreshTerminals(); } + return SaveFromInputs(inputs, data); + } + + private CommandResult SaveFromInputs(string inputs, string data) + { var values = ParseValues(inputs, data); var app = values?[TerminalApplicationField]?.ToString() ?? _settingsManager.TerminalApplicationId; var profile = values?[DefaultProfileField]?.ToString() ?? _settingsManager.DefaultProfileId; if (string.IsNullOrWhiteSpace(app) || string.IsNullOrWhiteSpace(profile)) { - return Finish("Pick a terminal application and profile."); + return QuickShellNavigation.StayOnSettings("Pick a terminal application and profile."); } _settingsManager.UpdateTerminalDefaults(app, profile); - return Finish("Terminal defaults saved."); + RebuildTemplate(); + SettingsFormHelpers.ScheduleRefresh(_onSettingsChanged); + return CommandResult.KeepOpen(); } private CommandResult RefreshTerminals() @@ -53,18 +60,8 @@ private CommandResult RefreshTerminals() TerminalDiscovery.Refresh(_settingsManager); _onReload?.Invoke(); RebuildTemplate(); - return Finish("Terminal list refreshed.", refreshContent: false); - } - - private CommandResult Finish(string message, bool refreshContent = true) - { - RebuildTemplate(); - if (refreshContent) - { - SettingsFormHelpers.ScheduleRefresh(_onSettingsChanged); - } - - return QuickShellNavigation.StayOnSettings(message); + SettingsFormHelpers.ScheduleRefresh(_onSettingsChanged); + return QuickShellNavigation.StayOnSettings("Terminal list refreshed."); } private void RebuildTemplate() @@ -76,7 +73,7 @@ private void RebuildTemplate() var bodyParts = new List { SettingsCardJson.SectionHeader("Terminal defaults"), - SettingsCardJson.SubtleText("Default host and profile for projects set to Default."), + SettingsCardJson.SubtleText("Default host and profile for workspaces set to Default. Changes save when you pick a value."), """ { "type": "ActionSet", @@ -100,6 +97,7 @@ private void RebuildTemplate() "style": "compact", "spacing": "Small", "value": "{{EscapeJson(app)}}", + {{SettingsCardJson.ChangeActionSave("saveTerminalDefaults")}}, "choices": [ {{appChoices}} ] @@ -113,6 +111,7 @@ private void RebuildTemplate() "style": "compact", "spacing": "Small", "value": "{{EscapeJson(profile)}}", + {{SettingsCardJson.ChangeActionSave("saveTerminalDefaults")}}, "choices": [ {{profileChoices}} ] @@ -128,13 +127,6 @@ private void RebuildTemplate() "version": "1.6", "body": [ {{bodyJson}} - ], - "actions": [ - { - "type": "Action.Submit", - "title": "Save", - "associatedInputs": "auto" - } ] } """; diff --git a/QuickShell/QuickShell.csproj b/QuickShell/QuickShell.csproj index 4f265d9..146fc17 100644 --- a/QuickShell/QuickShell.csproj +++ b/QuickShell/QuickShell.csproj @@ -120,7 +120,9 @@ - win-$(Platform).pubxml + <_QuickShellPublishPlatform Condition="'$(Platform)' == 'x64'">x64 + <_QuickShellPublishPlatform Condition="'$(Platform)' == 'ARM64' or '$(Platform)' == 'arm64'">arm64 + win-$(_QuickShellPublishPlatform).pubxml @@ -160,4 +162,8 @@ + + + + diff --git a/QuickShell/QuickShellCommandsProvider.cs b/QuickShell/QuickShellCommandsProvider.cs index 0e5303b..9820d5f 100644 --- a/QuickShell/QuickShellCommandsProvider.cs +++ b/QuickShell/QuickShellCommandsProvider.cs @@ -85,7 +85,6 @@ private void ReloadPages() { GitRepoIndex.Invalidate(); _page.Reload(); - _settingsManager.RefreshSettingsContent(); _fallbackPage.ClearResults(); } diff --git a/QuickShell/QuickShellSettingsManager.cs b/QuickShell/QuickShellSettingsManager.cs index 5975cfa..c5af348 100644 --- a/QuickShell/QuickShellSettingsManager.cs +++ b/QuickShell/QuickShellSettingsManager.cs @@ -1,6 +1,7 @@ using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using QuickShell.Services; +using System.Globalization; using System.Text.Json; namespace QuickShell; @@ -9,11 +10,13 @@ internal sealed class QuickShellSettingsManager { private const string TerminalApplicationSettingId = "terminalApplication"; private const string DefaultProfileSettingId = "defaultProfile"; + private const string RecentWorkspaceCountSettingId = QuickShellRecentSettings.SettingKey; private readonly QuickShellJsonSettingsStore _settingsStore; private readonly Settings _settings; private readonly ChoiceSetSetting _terminalApplicationSetting; private readonly ChoiceSetSetting _defaultProfileSetting; + private readonly TextSetting _recentWorkspaceCountSetting; private readonly Pages.QuickShellExtensionSettingsPage _settingsPage; public QuickShellSettingsManager(Action? onReload = null) @@ -26,7 +29,7 @@ public QuickShellSettingsManager(Action? onReload = null) TerminalCatalogChoices.GetTerminalApplicationChoices()) { Label = "Terminal application", - Description = "The terminal host used for Default projects and profile launches. Matches Windows Terminal's \"Default terminal application\" setting.", + Description = "The terminal host used for Default workspaces and profile launches. Matches Windows Terminal's \"Default terminal application\" setting.", }; _defaultProfileSetting = new ChoiceSetSetting( @@ -34,11 +37,18 @@ public QuickShellSettingsManager(Action? onReload = null) TerminalCatalogChoices.GetDefaultProfileChoices(TerminalHostIds.WindowsTerminal)) { Label = "Default profile", - Description = "Profile used when a project is set to Default. Per-project profile choices stay on each project.", + Description = "Profile used when a workspace is set to Default. Per-workspace profile choices stay on each workspace.", }; + _recentWorkspaceCountSetting = new TextSetting( + RecentWorkspaceCountSettingId, + "Recent workspaces to show", + "How many recently used workspaces to show on the home page (0 hides the section).", + QuickShellRecentSettings.DefaultCount.ToString(CultureInfo.InvariantCulture)); + _settings.Add(_terminalApplicationSetting); _settings.Add(_defaultProfileSetting); + _settings.Add(_recentWorkspaceCountSetting); _settingsStore.LoadSettings(); var usedLegacyDefaults = false; @@ -54,8 +64,9 @@ public QuickShellSettingsManager(Action? onReload = null) initialApp = EnsureValidTerminalApplication(initialApp); _defaultProfileSetting.Choices = TerminalCatalogChoices.GetDefaultProfileChoices(initialApp); initialProfile = EnsureValidDefaultProfile(initialApp, initialProfile); + var initialRecentCount = ReadRecentWorkspaceCount(); - _settings.Update($$"""{"{{TerminalApplicationSettingId}}":"{{initialApp}}","{{DefaultProfileSettingId}}":"{{initialProfile}}"}"""); + _settings.Update($$"""{"{{TerminalApplicationSettingId}}":"{{initialApp}}","{{DefaultProfileSettingId}}":"{{initialProfile}}","{{RecentWorkspaceCountSettingId}}":"{{QuickShellRecentSettings.FormatCount(initialRecentCount)}}"}"""); if (usedLegacyDefaults || !File.Exists(_settingsStore.FilePath)) { @@ -81,6 +92,8 @@ public QuickShellSettingsManager(Action? onReload = null) public string DefaultProfileId => EnsureValidDefaultProfile(TerminalApplicationId, _settings.GetSetting(DefaultProfileSettingId)); + public int RecentWorkspaceCount => ReadRecentWorkspaceCount(); + internal void UpdateTerminalDefaults(string app, string profile) { app = EnsureValidTerminalApplication(app); @@ -90,6 +103,13 @@ internal void UpdateTerminalDefaults(string app, string profile) PersistSettings(); } + internal void UpdateRecentWorkspaceCount(int count) + { + count = QuickShellRecentSettings.NormalizeCount(count); + _settings.Update($$"""{"{{RecentWorkspaceCountSettingId}}":"{{QuickShellRecentSettings.FormatCount(count)}}"}"""); + PersistSettings(); + } + internal void PersistSettings() { _settingsStore.SaveSettings(); @@ -249,4 +269,12 @@ private static string LoadLegacyDefaultTerminal(string legacyPath) private static string EscapeJson(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private int ReadRecentWorkspaceCount() + { + var raw = _settings.GetSetting(RecentWorkspaceCountSettingId); + return QuickShellRecentSettings.TryParseCount(raw, out var parsed) + ? parsed + : QuickShellRecentSettings.DefaultCount; + } } diff --git a/QuickShell/Services/DiscoverGitRepoListItems.cs b/QuickShell/Services/DiscoverGitRepoListItems.cs index 4c70552..c2e617c 100644 --- a/QuickShell/Services/DiscoverGitRepoListItems.cs +++ b/QuickShell/Services/DiscoverGitRepoListItems.cs @@ -36,21 +36,29 @@ public static IEnumerable BuildSectionedItems( if (unsaved.Count > 0) { - yield return new Separator(NotSavedSectionTitle); - foreach (var candidate in unsaved.OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase)) + foreach (var item in SectionListItems.InSection( + NotSavedSectionTitle, + unsaved + .OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase) + .Select(candidate => CreateNew(candidate, onSaved)))) { - yield return CreateNew(candidate, onSaved); + yield return item; } } if (saved.Count > 0) { - yield return new Separator(SavedSectionTitle); - foreach (var (candidate, matchingShortcuts) in saved.OrderBy( - entry => entry.Candidate.Name, - StringComparer.OrdinalIgnoreCase)) + foreach (var item in SectionListItems.InSection( + SavedSectionTitle, + saved + .OrderBy(entry => entry.Candidate.Name, StringComparer.OrdinalIgnoreCase) + .Select(entry => CreateSaved( + entry.Candidate, + onSaved, + entry.Shortcuts, + settings)))) { - yield return CreateSaved(candidate, onSaved, matchingShortcuts, settings); + yield return item; } } } diff --git a/QuickShell/Services/QuickShellStatus.cs b/QuickShell/Services/QuickShellStatus.cs index a598b46..c2a1e48 100644 --- a/QuickShell/Services/QuickShellStatus.cs +++ b/QuickShell/Services/QuickShellStatus.cs @@ -1,52 +1,18 @@ -using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; -using System.Threading.Tasks; namespace QuickShell.Services; internal static class QuickShellStatus { - private static readonly object ShowLock = new(); - private static StatusMessage? _activeMessage; - - public static void ShowToast(string? message, MessageState state = MessageState.Success) + public static void ShowToast(string? message) { if (string.IsNullOrWhiteSpace(message)) { return; } - lock (ShowLock) - { - if (_activeMessage is not null) - { - ExtensionHost.HideStatus(_activeMessage); - } - - _activeMessage = new StatusMessage - { - Message = message, - State = state, - }; - - // Page-scoped status shows on the current CmdPal page (including pinned-home navigation). - ExtensionHost.ShowStatus(_activeMessage, StatusContext.Page); - - _ = Task.Run(async () => - { - await Task.Delay(2500).ConfigureAwait(false); - - lock (ShowLock) - { - if (_activeMessage is null) - { - return; - } - - ExtensionHost.HideStatus(_activeMessage); - _activeMessage = null; - } - }); - } + // Toolkit toasts survive settings form rebuilds. ExtensionHost.ShowStatus(Page) + // was cleared when adaptive cards called RaiseItemsChanged after save/import. + new ToastStatusMessage(message).Show(); } } diff --git a/QuickShell/Services/SectionListItems.cs b/QuickShell/Services/SectionListItems.cs new file mode 100644 index 0000000..2ba19ce --- /dev/null +++ b/QuickShell/Services/SectionListItems.cs @@ -0,0 +1,40 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace QuickShell.Services; + +/// +/// Emits CmdPal section headers per PowerToys PR #43952: a row +/// (no command, non-empty section/title) renders the visible header; stamping +/// on normal items does not. +/// +internal static class SectionListItems +{ + public static IEnumerable InSection(string sectionTitle, IEnumerable items) + { + var materialized = items.ToList(); + if (materialized.Count == 0) + { + return materialized; + } + + if (string.IsNullOrWhiteSpace(sectionTitle)) + { + return materialized; + } + + return PrependHeader(sectionTitle, materialized); + } + + public static IEnumerable PrependHeader(string sectionTitle, IReadOnlyList items) + { + yield return CreateHeader(sectionTitle); + foreach (var item in items) + { + yield return item; + } + } + + public static Separator CreateHeader(string sectionTitle) => new(sectionTitle); +} + \ No newline at end of file diff --git a/QuickShell/Services/SettingsCardJson.cs b/QuickShell/Services/SettingsCardJson.cs index 36c245a..b780e8b 100644 --- a/QuickShell/Services/SettingsCardJson.cs +++ b/QuickShell/Services/SettingsCardJson.cs @@ -45,47 +45,106 @@ public static string StatusText(string text, SettingsFeedbackTone tone = Setting } """; - public static string FieldLabel(string label) => - $$""" - { - "type": "TextBlock", - "text": "{{Escape(label)}}", - "weight": "Bolder", - "wrap": true, - "spacing": "None" - } - """; + public static string FieldLabel(string label) => AdaptiveCardFormJson.FieldLabel(label); + + public static string FieldHelp(string text) => AdaptiveCardFormJson.FieldHelp(text); + + public static string FieldGroup(string label, string help, string inputElementJson) => + AdaptiveCardFormJson.FieldGroup(label, help, inputElementJson); - public static string FieldHelp(string text) => + public static string ChangeActionSave(string action = "save") => $$""" - { - "type": "TextBlock", - "text": "{{Escape(text)}}", - "wrap": true, - "isSubtle": true, - "size": "Small", - "spacing": "None" + "changeAction": { + "type": "Action.Submit", + "associatedInputs": "auto", + "data": { "action": "{{Escape(action)}}" } } """; - public static string FieldGroup(string label, string help, string inputElementJson) => - $$""" + public static string RecentCountStepper(int count) + { + var canDecrement = count > QuickShellRecentSettings.MinCount; + var canIncrement = count < QuickShellRecentSettings.MaxCount; + return $$""" { "type": "Container", - "spacing": "Medium", + "spacing": "Small", "items": [ + {{FieldLabel("Recent workspaces to show")}}, + {{SubtleText("How many recently used workspaces appear on the QuickShell home page. Set to 0 to hide the Recent section.")}}, { "type": "Container", - "spacing": "Small", + "style": "emphasis", + "spacing": "None", "items": [ - {{FieldLabel(label)}}, - {{FieldHelp(help)}}, - {{inputElementJson}} + { + "type": "ColumnSet", + "spacing": "None", + "columns": [ + { + "type": "Column", + "width": "auto", + "verticalContentAlignment": "Center", + "items": [ + { + "type": "ActionSet", + "spacing": "None", + "actions": [ + { + "type": "Action.Submit", + "title": "\u2212", + "tooltip": "Show fewer recent workspaces", + "associatedInputs": "none", + "isEnabled": {{canDecrement.ToString().ToLowerInvariant()}}, + "data": { "action": "recentDecrement" } + } + ] + } + ] + }, + { + "type": "Column", + "width": "stretch", + "verticalContentAlignment": "Center", + "items": [ + { + "type": "TextBlock", + "text": "{{count}}", + "horizontalAlignment": "Center", + "size": "Medium", + "weight": "Bolder" + } + ] + }, + { + "type": "Column", + "width": "auto", + "verticalContentAlignment": "Center", + "items": [ + { + "type": "ActionSet", + "spacing": "None", + "actions": [ + { + "type": "Action.Submit", + "title": "+", + "tooltip": "Show more recent workspaces", + "associatedInputs": "none", + "isEnabled": {{canIncrement.ToString().ToLowerInvariant()}}, + "data": { "action": "recentIncrement" } + } + ] + } + ] + } + ] + } ] } ] } """; + } public static string BuildChoicesJson(IEnumerable choices) => string.Join(",\n", choices.Select(choice => diff --git a/QuickShell/Services/SettingsFormHelpers.cs b/QuickShell/Services/SettingsFormHelpers.cs index 6558b61..3a8a218 100644 --- a/QuickShell/Services/SettingsFormHelpers.cs +++ b/QuickShell/Services/SettingsFormHelpers.cs @@ -1,23 +1,124 @@ +using System.Threading; using System.Threading.Tasks; + + namespace QuickShell.Services; + + internal static class SettingsFormHelpers + { + + private const int DefaultRefreshDelayMs = 50; + + private const int DefaultDebouncedReloadDelayMs = 400; + + + + private static readonly object DebouncedReloadLock = new(); + + private static CancellationTokenSource? _debouncedReloadCts; + + + /// + /// Defers settings UI refresh so CmdPal can show a page-level toast first. + /// - internal static void ScheduleRefresh(Action? refresh) + + internal static void ScheduleRefresh(Action? refresh, int delayMs = DefaultRefreshDelayMs) + { + if (refresh is null) + { + return; + } + + _ = Task.Run(async () => + { - await Task.Delay(350).ConfigureAwait(false); + + await Task.Delay(delayMs).ConfigureAwait(false); + refresh(); + }); + } + + + + /// + + /// Coalesces rapid home reloads (e.g. numeric stepper clicks) into one refresh after the user pauses. + + /// + + internal static void ScheduleDebouncedReload(Action? reload, int delayMs = DefaultDebouncedReloadDelayMs) + + { + + if (reload is null) + + { + + return; + + } + + + + CancellationTokenSource cts; + + lock (DebouncedReloadLock) + + { + + _debouncedReloadCts?.Cancel(); + + _debouncedReloadCts?.Dispose(); + + cts = new CancellationTokenSource(); + + _debouncedReloadCts = cts; + + } + + + + _ = Task.Run(async () => + + { + + try + + { + + await Task.Delay(delayMs, cts.Token).ConfigureAwait(false); + + reload(); + + } + + catch (OperationCanceledException) + + { + + } + + }); + + } + } + + diff --git a/QuickShell/Services/ShortcutLayoutDisplay.cs b/QuickShell/Services/ShortcutLayoutDisplay.cs index f57f348..136afff 100644 --- a/QuickShell/Services/ShortcutLayoutDisplay.cs +++ b/QuickShell/Services/ShortcutLayoutDisplay.cs @@ -1,5 +1,4 @@ using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; using QuickShell.Models; namespace QuickShell.Services; @@ -7,41 +6,87 @@ namespace QuickShell.Services; internal static class ShortcutLayoutDisplay { public const string FavoritesSectionTitle = "Favorites"; + public const string ShortcutsSectionTitle = "Workspaces"; public static IEnumerable BuildListItems( IReadOnlyList layout, - Func buildShortcutItem) + Func buildShortcutItem, + IReadOnlySet? excludeShortcutIds = null) { - var pinned = layout - .Where(entry => entry.Kind == ShortcutLayoutEntryKind.Shortcut && entry.Shortcut?.IsPinned == true) - .Select(entry => entry.Shortcut!) - .OrderBy(shortcut => shortcut.PinOrder ?? int.MaxValue) - .ThenBy(shortcut => shortcut.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); + foreach (var item in BuildFavoriteItems(layout, buildShortcutItem)) + { + yield return item; + } - if (pinned.Count > 0) + foreach (var item in BuildWorkspaceItems( + layout, + buildShortcutItem, + excludeShortcutIds, + showDefaultWorkspacesHeader: GetPinnedShortcuts(layout).Count > 0)) { - yield return new Separator(FavoritesSectionTitle); - foreach (var shortcut in pinned) - { - yield return buildShortcutItem(shortcut); - } + yield return item; + } + } + + public static IEnumerable BuildFavoriteItems( + IReadOnlyList layout, + Func buildShortcutItem) + { + var pinned = GetPinnedShortcuts(layout); + if (pinned.Count == 0) + { + yield break; + } - yield return new Separator(ShortcutsSectionTitle); + foreach (var item in SectionListItems.InSection( + FavoritesSectionTitle, + pinned.Select(buildShortcutItem))) + { + yield return item; } + } + + public static IEnumerable BuildWorkspaceItems( + IReadOnlyList layout, + Func buildShortcutItem, + IReadOnlySet? excludeShortcutIds = null, + bool showDefaultWorkspacesHeader = false) + { + excludeShortcutIds ??= new HashSet(StringComparer.OrdinalIgnoreCase); + + var activeWorkspaceSection = showDefaultWorkspacesHeader ? ShortcutsSectionTitle : null; + var workspaceHeaderEmitted = false; foreach (var entry in layout) { switch (entry.Kind) { case ShortcutLayoutEntryKind.Separator: - yield return new Separator(entry.SeparatorTitle ?? string.Empty); + activeWorkspaceSection = string.IsNullOrWhiteSpace(entry.SeparatorTitle) + ? ShortcutsSectionTitle + : entry.SeparatorTitle; + workspaceHeaderEmitted = false; break; - case ShortcutLayoutEntryKind.Shortcut when entry.Shortcut is { IsPinned: false } shortcut: + case ShortcutLayoutEntryKind.Shortcut when entry.Shortcut is { IsPinned: false } shortcut + && !excludeShortcutIds.Contains(shortcut.Id): + if (!string.IsNullOrWhiteSpace(activeWorkspaceSection) && !workspaceHeaderEmitted) + { + yield return SectionListItems.CreateHeader(activeWorkspaceSection); + workspaceHeaderEmitted = true; + } + yield return buildShortcutItem(shortcut); break; } } } + + private static List GetPinnedShortcuts(IReadOnlyList layout) => + layout + .Where(entry => entry.Kind == ShortcutLayoutEntryKind.Shortcut && entry.Shortcut?.IsPinned == true) + .Select(entry => entry.Shortcut!) + .OrderBy(shortcut => shortcut.PinOrder ?? int.MaxValue) + .ThenBy(shortcut => shortcut.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); } diff --git a/README.md b/README.md index 7520727..2a4dd37 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,13 @@ After you install a new terminal or edit profiles, use **Refresh terminal list** winget install tonythethompson.QuickShell ``` +Includes **Command Palette** and **PowerToys Run** (`qs`). Restart PowerToys after install. + ### Option 3 — Download an installer -Get the latest **x64** or **ARM64** installer from [GitHub Releases](https://github.com/tonythethompson/QuickShell/releases). +Get the latest **x64** or **ARM64** installer from [GitHub Releases](https://github.com/tonythethompson/QuickShell/releases). Same bundle as WinGet (CmdPal + Run). + +**Store** installs CmdPal only; use the [Run plugin ZIP](docs/powertoys-run-plugin.md) if you want Alt+Space as well. ### After installing @@ -147,7 +151,7 @@ Mix **section headers** into the same array with shortcut objects: | `Type` | Yes (for headers) | Set to `"separator"` for a titled section header | | `Title` | No | Section label shown in the list (omit for a blank divider) | -Favorited shortcuts (`IsPinned`) always appear under a **Favorites** header at the top and are not repeated under layout sections. +Favorited shortcuts (`IsPinned`) appear under **Favorites** at the top, then **Recent**, then the rest under **Workspaces** (favorites and recents are not repeated in the workspace list). Example: From d4d9ed681bcdc748e7ed7526678c9270295113e5 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Thu, 2 Jul 2026 22:27:36 -0700 Subject: [PATCH 2/2] fix: filter companion app form choices to installed presets BuildFormChoicesJson now omits catalog presets that are not installed, matching IsPresetInstalled and fixing CI on runners where VS Code is on PATH but not at standard install locations. Co-authored-by: Cursor --- QuickShell.Core/Services/CompanionAppCatalog.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/QuickShell.Core/Services/CompanionAppCatalog.cs b/QuickShell.Core/Services/CompanionAppCatalog.cs index 7ea61fe..fbb1cc2 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 });