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/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 }); 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: