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: