diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index ff3b397..9a4bc46 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -111,7 +111,11 @@ jobs: - **x64**: `QuickShell-Setup-${{ steps.version.outputs.VERSION }}-x64.exe` - **ARM64**: `QuickShell-Setup-${{ steps.version.outputs.VERSION }}-arm64.exe` - ### PowerToys Run plugin + Includes **Command Palette** registration and the **PowerToys Run** plugin. Restart PowerToys after install. + + ### PowerToys Run plugin (ZIP only) + + For Store users who want Run without the full EXE: - **x64**: `QuickShell.Run-x64.zip` → extract to `%LOCALAPPDATA%\Microsoft\PowerToys\PowerToys Run\Plugins\QuickShell\` - **ARM64**: `QuickShell.Run-ARM64.zip` diff --git a/QuickShell.Core.Tests/RunQueryScoringTests.cs b/QuickShell.Core.Tests/RunQueryScoringTests.cs new file mode 100644 index 0000000..611b979 --- /dev/null +++ b/QuickShell.Core.Tests/RunQueryScoringTests.cs @@ -0,0 +1,102 @@ +using QuickShell.Models; +using QuickShell.Services; + +namespace QuickShell.Core.Tests; + +public sealed class RunQueryScoringTests +{ + [Fact] + public void BrowseMode_ShortcutsOutrankUtilities() + { + var shortcut = new TerminalShortcut { Name = "Demo", Directory = @"C:\Demo" }; + var shortcutScore = RunQueryScoring.ComputeShortcutScore(shortcut, search: string.Empty, directActivationBrowse: true); + var utilityScore = RunQueryScoring.ComputeUtilityScore(rankedScore: 2000, search: string.Empty, utilityOrder: 0); + + Assert.True(shortcutScore > utilityScore); + } + + [Fact] + public void BrowseMode_PinnedShortcutsRespectPinOrder() + { + var first = new TerminalShortcut { Name = "A", IsPinned = true, PinOrder = 1 }; + var second = new TerminalShortcut { Name = "B", IsPinned = true, PinOrder = 2 }; + + var firstScore = RunQueryScoring.ComputeShortcutScore(first, string.Empty, directActivationBrowse: true); + var secondScore = RunQueryScoring.ComputeShortcutScore(second, string.Empty, directActivationBrowse: true); + + Assert.True(firstScore > secondScore); + } + + [Fact] + public void BrowseMode_PinOrderBeatsRecency() + { + var recentSecond = new TerminalShortcut + { + Name = "B", + IsPinned = true, + PinOrder = 2, + LastUsedUtc = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Utc), + }; + var olderFirst = new TerminalShortcut + { + Name = "A", + IsPinned = true, + PinOrder = 1, + LastUsedUtc = null, + }; + + var now = new DateTime(2024, 6, 1, 13, 0, 0, DateTimeKind.Utc); + var firstScore = RunQueryScoring.ComputeShortcutScore(olderFirst, string.Empty, directActivationBrowse: true, now); + var secondScore = RunQueryScoring.ComputeShortcutScore(recentSecond, string.Empty, directActivationBrowse: true, now); + + Assert.True(firstScore > secondScore); + } + + [Fact] + public void BrowseMode_UnorderedPinnedShortcutsRankBelowExplicitOrder() + { + var ordered = new TerminalShortcut { Name = "A", IsPinned = true, PinOrder = 1 }; + var unordered = new TerminalShortcut { Name = "B", IsPinned = true, PinOrder = null }; + + var orderedScore = RunQueryScoring.ComputeShortcutScore(ordered, string.Empty, directActivationBrowse: true); + var unorderedScore = RunQueryScoring.ComputeShortcutScore(unordered, string.Empty, directActivationBrowse: true); + + Assert.True(orderedScore > unorderedScore); + } + + [Fact] + public void BrowseMode_UnorderedPinnedBeatsRecentUnpinned() + { + var favorite = new TerminalShortcut { Name = "Fav", IsPinned = true, PinOrder = null }; + var recent = new TerminalShortcut + { + Name = "Recent", + LastUsedUtc = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Utc), + }; + + var now = new DateTime(2024, 6, 1, 13, 0, 0, DateTimeKind.Utc); + var favoriteScore = RunQueryScoring.ComputeShortcutScore(favorite, string.Empty, directActivationBrowse: true, now); + var recentScore = RunQueryScoring.ComputeShortcutScore(recent, string.Empty, directActivationBrowse: true, now); + + Assert.True(favoriteScore > recentScore); + } + + [Fact] + public void BrowseMode_UtilitiesPreserveDeclarationOrder() + { + var firstUtility = RunQueryScoring.ComputeUtilityScore(2000, string.Empty, utilityOrder: 0); + var secondUtility = RunQueryScoring.ComputeUtilityScore(2000, string.Empty, utilityOrder: 1); + + Assert.True(firstUtility > secondUtility); + } + + [Fact] + public void SearchMode_UtilitiesKeepHighRankWhenMatched() + { + var firstUtility = RunQueryScoring.ComputeUtilityScore(2000, "export", utilityOrder: 0); + var secondUtility = RunQueryScoring.ComputeUtilityScore(2000, "export", utilityOrder: 1); + + Assert.Equal(2000, firstUtility); + Assert.True(firstUtility > secondUtility); + } +} diff --git a/QuickShell.Core.Tests/ShortcutDraftStoreTests.cs b/QuickShell.Core.Tests/ShortcutDraftStoreTests.cs index 3f78bdc..e2f7647 100644 --- a/QuickShell.Core.Tests/ShortcutDraftStoreTests.cs +++ b/QuickShell.Core.Tests/ShortcutDraftStoreTests.cs @@ -156,16 +156,11 @@ public void Dispose() private static void WaitForDraftFile(ShortcutDraftStore store) { - for (var attempt = 0; attempt < 50; attempt++) - { - if (File.Exists(store.DraftPath)) - { - return; - } + store.FlushPendingFileIoForTests(); - Thread.Sleep(20); + if (!File.Exists(store.DraftPath)) + { + throw new InvalidOperationException("Draft file was not written."); } - - throw new InvalidOperationException("Draft file was not written in time."); } } diff --git a/QuickShell.Core.Tests/ShortcutFormSaveRunEditorTests.cs b/QuickShell.Core.Tests/ShortcutFormSaveRunEditorTests.cs new file mode 100644 index 0000000..8e228ec --- /dev/null +++ b/QuickShell.Core.Tests/ShortcutFormSaveRunEditorTests.cs @@ -0,0 +1,304 @@ +using QuickShell.Models; +using QuickShell.Services; + +namespace QuickShell.Core.Tests; + +public sealed class ShortcutFormSaveRunEditorTests +{ + [Fact] + public void TrySaveRunEditor_Create_UsesSingleLaunch() + { + using var directory = new TempDataDirectory(); + using var repository = new ShortcutRepository(directory.Path); + var folder = Path.Combine(directory.Path, "NewProject"); + Directory.CreateDirectory(folder); + + var result = ShortcutFormSave.TrySaveRunEditor( + existing: null, + originalName: null, + name: "NewProject", + abbreviation: "np", + directory: folder, + command: "npm start", + launchTarget: "default", + runAsAdmin: false, + repository, + onSaved: null); + + Assert.True(result.Success); + var saved = repository.GetByName("NewProject"); + Assert.NotNull(saved); + Assert.Single(saved!.Launches); + Assert.Equal("npm start", saved.Launches[0].Command); + } + + [Fact] + public void TrySaveRunEditor_Edit_PreservesSecondaryLaunches() + { + using var directory = new TempDataDirectory(); + using var repository = new ShortcutRepository(directory.Path); + var folder = Path.Combine(directory.Path, "Multi"); + Directory.CreateDirectory(folder); + + var companionPath = Environment.GetEnvironmentVariable("ComSpec") + ?? @"C:\Windows\System32\cmd.exe"; + + var existing = new TerminalShortcut + { + Id = Guid.NewGuid().ToString("N"), + Name = "Multi", + Directory = folder, + Launches = + [ + new WorkspaceEntry + { + Id = Guid.NewGuid().ToString("N"), + Label = "Primary", + Command = "dotnet run", + Terminal = "default", + IsEnabled = true, + Order = 0, + }, + new WorkspaceEntry + { + Id = Guid.NewGuid().ToString("N"), + Label = "Agents", + Command = "claude", + Terminal = "wt", + IsEnabled = true, + Order = 1, + }, + ], + DevServerUrl = "http://localhost:3000", + OpenDevServerOnLaunch = true, + OpenCompanionAppOnLaunch = true, + CompanionAppPath = companionPath, + }; + ShortcutLaunchNormalization.NormalizeShortcut(existing); + repository.Upsert(existing); + + var secondaryId = existing.Launches[1].Id; + + var result = ShortcutFormSave.TrySaveRunEditor( + existing, + originalName: "Multi", + name: "Multi", + abbreviation: string.Empty, + directory: folder, + command: "npm run dev", + launchTarget: "pwsh", + runAsAdmin: true, + repository, + onSaved: null); + + Assert.True(result.Success); + Assert.Contains("preserved", result.Message, StringComparison.OrdinalIgnoreCase); + + var saved = repository.GetByName("Multi"); + Assert.NotNull(saved); + Assert.Equal(2, saved!.Launches.Count); + Assert.Equal("npm run dev", saved.Launches[0].Command); + Assert.True(saved.Launches[0].RunAsAdmin); + Assert.Equal("pwsh", saved.Launches[0].Terminal); + Assert.Equal("claude", saved.Launches[1].Command); + Assert.Equal(secondaryId, saved.Launches[1].Id); + Assert.StartsWith("http://localhost:3000", saved.DevServerUrl); + Assert.True(saved.OpenDevServerOnLaunch); + Assert.True(saved.OpenCompanionAppOnLaunch); + Assert.Equal(companionPath, saved.CompanionAppPath); + } + + [Fact] + public void TrySaveRunEditor_Edit_UpdatesPrimaryWhenFirstLaunchDisabled() + { + using var directory = new TempDataDirectory(); + using var repository = new ShortcutRepository(directory.Path); + var folder = Path.Combine(directory.Path, "DisabledFirst"); + Directory.CreateDirectory(folder); + + var disabledId = Guid.NewGuid().ToString("N"); + var enabledId = Guid.NewGuid().ToString("N"); + var existing = new TerminalShortcut + { + Id = Guid.NewGuid().ToString("N"), + Name = "DisabledFirst", + Directory = folder, + Launches = + [ + new WorkspaceEntry + { + Id = disabledId, + Label = "Off", + Command = "old", + Terminal = "cmd", + IsEnabled = false, + Order = 0, + }, + new WorkspaceEntry + { + Id = enabledId, + Label = "Active", + Command = "keep-me", + Terminal = "wt", + IsEnabled = true, + Order = 1, + }, + ], + }; + ShortcutLaunchNormalization.NormalizeShortcut(existing); + repository.Upsert(existing); + + var result = ShortcutFormSave.TrySaveRunEditor( + existing, + originalName: "DisabledFirst", + name: "DisabledFirst", + abbreviation: string.Empty, + directory: folder, + command: "updated", + launchTarget: "default", + runAsAdmin: false, + repository, + onSaved: null); + + Assert.True(result.Success); + var saved = repository.GetByName("DisabledFirst"); + Assert.NotNull(saved); + Assert.Equal("old", saved!.Launches.First(e => e.Id == disabledId).Command); + Assert.Equal("updated", saved.Launches.First(e => e.Id == enabledId).Command); + } + + [Fact] + public void TrySaveRunEditor_Edit_RepairsInvalidLaunches() + { + using var directory = new TempDataDirectory(); + using var repository = new ShortcutRepository(directory.Path); + var folder = Path.Combine(directory.Path, "Broken"); + Directory.CreateDirectory(folder); + + var existing = new TerminalShortcut + { + Id = Guid.NewGuid().ToString("N"), + Name = "Broken", + Directory = folder, + DevServerUrl = "http://localhost:5173", + Launches = + [ + new WorkspaceEntry + { + Id = Guid.NewGuid().ToString("N"), + Label = string.Empty, + Command = "old", + Terminal = "cmd", + IsEnabled = false, + Order = 0, + }, + ], + }; + + var result = ShortcutFormSave.TrySaveRunEditor( + existing, + originalName: "Broken", + name: "Broken", + abbreviation: string.Empty, + directory: folder, + command: "npm run dev", + launchTarget: "default", + runAsAdmin: false, + repository, + onSaved: null); + + Assert.True(result.Success); + var saved = repository.GetByName("Broken"); + Assert.NotNull(saved); + Assert.Single(saved!.Launches); + Assert.Equal("Broken", saved.Launches[0].Label); + Assert.Equal("npm run dev", saved.Launches[0].Command); + Assert.True(saved.Launches[0].IsEnabled); + Assert.StartsWith("http://localhost:5173", saved.DevServerUrl); + } + + [Fact] + public void TrySaveRunEditor_Edit_RepairsPrimaryWithoutDroppingSecondaryLaunches() + { + using var directory = new TempDataDirectory(); + using var repository = new ShortcutRepository(directory.Path); + var folder = Path.Combine(directory.Path, "RepairPrimary"); + Directory.CreateDirectory(folder); + + var secondaryId = Guid.NewGuid().ToString("N"); + var existing = new TerminalShortcut + { + Id = Guid.NewGuid().ToString("N"), + Name = "RepairPrimary", + Directory = folder, + Launches = + [ + new WorkspaceEntry + { + Id = Guid.NewGuid().ToString("N"), + Label = string.Empty, + Command = "old", + Terminal = "cmd", + IsEnabled = false, + Order = 0, + }, + new WorkspaceEntry + { + Id = secondaryId, + Label = "Agents", + Command = "claude", + Terminal = "wt", + IsEnabled = true, + Order = 1, + }, + ], + }; + + var result = ShortcutFormSave.TrySaveRunEditor( + existing, + originalName: "RepairPrimary", + name: "RepairPrimary", + abbreviation: string.Empty, + directory: folder, + command: "npm run dev", + launchTarget: "default", + runAsAdmin: false, + repository, + onSaved: null); + + Assert.True(result.Success); + Assert.Contains("Repaired", result.Message, StringComparison.OrdinalIgnoreCase); + + var saved = repository.GetByName("RepairPrimary"); + Assert.NotNull(saved); + Assert.Equal(2, saved!.Launches.Count); + Assert.Equal("npm run dev", saved.Launches.First(e => e.Id == secondaryId).Command); + Assert.Equal("old", saved.Launches.First(e => e.Order == 0).Command); + Assert.Equal("Disabled", saved.Launches.First(e => e.Order == 0).Label); + } + + private sealed class TempDataDirectory : IDisposable + { + public TempDataDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "quickshell-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + catch (IOException) + { + } + } + } +} diff --git a/QuickShell.Core/Services/RunQueryScoring.cs b/QuickShell.Core/Services/RunQueryScoring.cs new file mode 100644 index 0000000..4bd27d5 --- /dev/null +++ b/QuickShell.Core/Services/RunQueryScoring.cs @@ -0,0 +1,95 @@ +using QuickShell.Models; + +namespace QuickShell.Services; + +/// +/// Relevance scoring for the PowerToys Run plugin. Browse mode (bare qs) must rank +/// shortcuts above manage utilities so results are not hidden by Run's max-result cap. +/// +internal static class RunQueryScoring +{ + public const int BrowseShortcutBaseScore = 5000; + public const int BrowseUtilityBaseScore = 100; + private const int BrowseMaxRecencyBonus = 40; + private const int BrowsePinnedMinimumBonus = BrowseMaxRecencyBonus + 1; + private const int BrowseUnorderedPinOrder = 100; + + public static int ComputeShortcutScore( + TerminalShortcut shortcut, + string search, + bool directActivationBrowse, + DateTime? utcNow = null) + { + var now = utcNow ?? DateTime.UtcNow; + + if (directActivationBrowse && string.IsNullOrWhiteSpace(search)) + { + var score = BrowseShortcutBaseScore; + if (shortcut.IsPinned) + { + var pinOrder = shortcut.PinOrder ?? BrowseUnorderedPinOrder; + score += Math.Max( + BrowsePinnedMinimumBonus, + 50 + (100 - Math.Min(pinOrder, 99))); + } + else + { + score += RecencyBonus(shortcut, now); + } + + return score; + } + + var result = shortcut.IsPinned ? 100 : 0; + result += AbbreviationBonus(shortcut, search); + result += RecencyBonus(shortcut, now); + return result; + } + + public static int ComputeUtilityScore(int rankedScore, string search, int utilityOrder) => + string.IsNullOrWhiteSpace(search) + ? BrowseUtilityBaseScore - utilityOrder + : rankedScore - utilityOrder; + + public static bool ShouldIncludeUtility(string search, string[] keywords) + { + if (string.IsNullOrWhiteSpace(search)) + { + return true; + } + + return keywords.Any(keyword => keyword.Contains(search, StringComparison.OrdinalIgnoreCase) + || search.Contains(keyword, StringComparison.OrdinalIgnoreCase)); + } + + private static int AbbreviationBonus(TerminalShortcut shortcut, string search) + { + if (string.IsNullOrWhiteSpace(search) || string.IsNullOrWhiteSpace(shortcut.Abbreviation)) + { + return 0; + } + + if (shortcut.Abbreviation.Equals(search, StringComparison.OrdinalIgnoreCase)) + { + return 200; + } + + if (shortcut.Abbreviation.StartsWith(search, StringComparison.OrdinalIgnoreCase)) + { + return 120; + } + + return 0; + } + + private static int RecencyBonus(TerminalShortcut shortcut, DateTime utcNow) + { + if (shortcut.LastUsedUtc is null) + { + return 0; + } + + var ageHours = Math.Max(0, (utcNow - shortcut.LastUsedUtc.Value).TotalHours); + return (int)Math.Round(Math.Max(0, 40 - ageHours)); + } +} diff --git a/QuickShell.Core/Services/ShortcutDraftStore.cs b/QuickShell.Core/Services/ShortcutDraftStore.cs index d44ab88..50a080e 100644 --- a/QuickShell.Core/Services/ShortcutDraftStore.cs +++ b/QuickShell.Core/Services/ShortcutDraftStore.cs @@ -485,6 +485,11 @@ private T WithLock(Func action) } } + internal void FlushPendingFileIoForTests() + { + WithLock(DrainFileIoQueueLocked); + } + public void Dispose() { if (_disposed) diff --git a/QuickShell.Core/Services/ShortcutFormDraftStore.cs b/QuickShell.Core/Services/ShortcutFormDraftStore.cs index 10496c5..5c0aed8 100644 --- a/QuickShell.Core/Services/ShortcutFormDraftStore.cs +++ b/QuickShell.Core/Services/ShortcutFormDraftStore.cs @@ -212,14 +212,46 @@ public static ShortcutSaveResult TrySaveRunEditor( shortcut.Directory = directory.Trim(); ShortcutLaunchNormalization.EnsureLaunchesFromLegacy(shortcut); - var primary = GetPrimaryLaunch(shortcut); - primary.Command = string.IsNullOrWhiteSpace(command) ? null : command.Trim(); - primary.RunAsAdmin = runAsAdmin; + var repairedLaunches = false; + if (!ShortcutLaunchNormalization.TryValidateLaunches(shortcut, out var launchValidationError)) + { + if (shortcut.Launches.Count == 0) + { + shortcut.Launches = + [ + BuildRunEditorLaunch(resolvedName, command, launchTarget, runAsAdmin, order: 0), + ]; + } + else + { + var primary = GetPrimaryLaunch(shortcut); + ApplyRunEditorFieldsToLaunch(primary, resolvedName, command, launchTarget, runAsAdmin); + if (string.IsNullOrWhiteSpace(primary.Label)) + { + primary.Label = resolvedName; + } - var launchScratch = new TerminalShortcut(); - TerminalCatalog.ApplyLaunchTargetId(launchScratch, launchTarget); - primary.Terminal = launchScratch.Terminal; - primary.WtProfile = launchScratch.WtProfile; + if (!shortcut.Launches.Any(entry => entry.IsEnabled)) + { + primary.IsEnabled = true; + } + + foreach (var entry in shortcut.Launches) + { + if (string.IsNullOrWhiteSpace(entry.Label)) + { + entry.Label = entry.IsEnabled ? "Launch" : "Disabled"; + } + } + } + + repairedLaunches = true; + } + else + { + var primary = GetPrimaryLaunch(shortcut); + ApplyRunEditorFieldsToLaunch(primary, resolvedName, command, launchTarget, runAsAdmin); + } ShortcutLaunchNormalization.NormalizeShortcut(shortcut); @@ -237,9 +269,12 @@ public static ShortcutSaveResult TrySaveRunEditor( var preservedNote = extraLaunches > 0 ? $" ({extraLaunches} other launch{(extraLaunches == 1 ? string.Empty : "es")} preserved)" : string.Empty; + var repairNote = repairedLaunches && !string.IsNullOrWhiteSpace(launchValidationError) + ? " Repaired invalid launch entries." + : string.Empty; var message = renamedForConflict - ? $"Saved workspace as '{resolvedName}' (name was already in use).{preservedNote}" - : $"Saved workspace '{resolvedName}'.{preservedNote}"; + ? $"Saved workspace as '{resolvedName}' (name was already in use).{preservedNote}{repairNote}" + : $"Saved workspace '{resolvedName}'.{preservedNote}{repairNote}"; return ShortcutSaveResult.Ok(message); } catch (IOException) @@ -424,6 +459,48 @@ private static WorkspaceEntry GetPrimaryLaunch(TerminalShortcut shortcut) => .FirstOrDefault() ?? shortcut.Launches.OrderBy(entry => entry.Order).First(); + private static WorkspaceEntry BuildRunEditorLaunch( + string name, + string command, + string launchTarget, + bool runAsAdmin, + int order) + { + var entry = new WorkspaceEntry + { + Id = Guid.NewGuid().ToString("N"), + Label = string.IsNullOrWhiteSpace(name) ? "Main" : name.Trim(), + Command = string.IsNullOrWhiteSpace(command) ? null : command.Trim(), + RunAsAdmin = runAsAdmin, + IsEnabled = true, + Order = order, + }; + + ApplyRunEditorFieldsToLaunch(entry, name, command, launchTarget, runAsAdmin); + return entry; + } + + private static void ApplyRunEditorFieldsToLaunch( + WorkspaceEntry launch, + string name, + string command, + string launchTarget, + bool runAsAdmin) + { + launch.Command = string.IsNullOrWhiteSpace(command) ? null : command.Trim(); + launch.RunAsAdmin = runAsAdmin; + + var launchScratch = new TerminalShortcut(); + TerminalCatalog.ApplyLaunchTargetId(launchScratch, launchTarget); + launch.Terminal = launchScratch.Terminal; + launch.WtProfile = launchScratch.WtProfile; + + if (string.IsNullOrWhiteSpace(launch.Label)) + { + launch.Label = string.IsNullOrWhiteSpace(name) ? "Main" : name.Trim(); + } + } + private static TerminalShortcut CloneShortcut(TerminalShortcut source) => new() { Id = source.Id, @@ -440,6 +517,7 @@ private static WorkspaceEntry GetPrimaryLaunch(TerminalShortcut shortcut) => Launches = source.Launches.Select(WorkspaceMapper.CloneEntry).ToList(), DevServerUrl = source.DevServerUrl, RepoUrl = source.RepoUrl, + OpenDevServerOnLaunch = source.OpenDevServerOnLaunch, OpenCompanionAppOnLaunch = source.OpenCompanionAppOnLaunch, CompanionAppPath = source.CompanionAppPath, CompanionAppArguments = source.CompanionAppArguments, diff --git a/QuickShell.Run/Images/quickshell.dark.png b/QuickShell.Run/Images/quickshell.dark.png index 172723b..6331e28 100644 Binary files a/QuickShell.Run/Images/quickshell.dark.png and b/QuickShell.Run/Images/quickshell.dark.png differ diff --git a/QuickShell.Run/Images/quickshell.light.png b/QuickShell.Run/Images/quickshell.light.png index d597a38..228ac83 100644 Binary files a/QuickShell.Run/Images/quickshell.light.png and b/QuickShell.Run/Images/quickshell.light.png differ diff --git a/QuickShell.Run/Main.cs b/QuickShell.Run/Main.cs index 3725ce4..60c8a23 100644 --- a/QuickShell.Run/Main.cs +++ b/QuickShell.Run/Main.cs @@ -1,875 +1,402 @@ using System.IO; - using System.Reflection; - using System.Runtime.Loader; - using System.Windows.Controls; - using ManagedCommon; - using Microsoft.PowerToys.Settings.UI.Library; - using QuickShell.Models; - using QuickShell.Services; - using Wox.Plugin; - - namespace QuickShell.Run; - - public class Main : IPlugin, IPluginI18n, IContextMenu, ISettingProvider, IReloadable, IDisposable - { - public const string PluginIdValue = "a7c3e891-4b2d-4f6e-9c1a-2d8e5f03b4c6"; - - public static string PluginID => PluginIdValue; - - private const string IconFont = "Segoe MDL2 Assets"; - - static Main() - { - var pluginDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - if (string.IsNullOrEmpty(pluginDir)) - { - return; - } - - AssemblyLoadContext.Default.Resolving += (_, assemblyName) => - { - var candidate = Path.Combine(pluginDir, $"{assemblyName.Name}.dll"); - return File.Exists(candidate) - ? AssemblyLoadContext.Default.LoadFromAssemblyPath(candidate) - : null; - }; - } - - private PluginInitContext? _context; - private string _iconPath = string.Empty; - private ShortcutRepository? _shortcuts; - private QuickShellSettingsReader? _settings; - private QuickShellRunSettingsPanel? _settingsPanel; - private string _lastQuery = string.Empty; - private bool _disposed; - - public string Name => "Quick Shell"; - - public string Description => "Open saved folders in any terminal you use"; - - public string GetTranslatedPluginTitle() => Name; - - public string GetTranslatedPluginDescription() => Description; - - public void Init(PluginInitContext context) - { - _shortcuts = new ShortcutRepository(); - _settings = new QuickShellSettingsReader(); - _context = context; - UpdateIconPath(context.API.GetCurrentTheme()); - context.API.ThemeChanged += OnThemeChanged; - _shortcuts.Reload(); - } - - public void Dispose() - { - if (_disposed) - { - return; - } - - if (_context?.API is not null) - { - _context.API.ThemeChanged -= OnThemeChanged; - } - - _shortcuts?.Dispose(); - _disposed = true; - GC.SuppressFinalize(this); - } - - public void ReloadData() - { - Shortcuts.Reload(); - _settingsPanel?.Reload(); - } - - public Control CreateSettingPanel() - { - _settingsPanel ??= new QuickShellRunSettingsPanel( - Settings, - Shortcuts, - (_, _) => { }); - _settingsPanel.Reload(); - return _settingsPanel; - } - - public IEnumerable AdditionalOptions => []; - - public void UpdateSettings(PowerLauncherPluginSettings settings) => - _settingsPanel?.UpdateSettings(settings); - - private ShortcutRepository Shortcuts => - _shortcuts ?? throw new InvalidOperationException("Quick Shell plugin is not initialized."); - - private QuickShellSettingsReader Settings => - _settings ?? throw new InvalidOperationException("Quick Shell plugin is not initialized."); - - public List Query(Query query) - { - _lastQuery = query.RawQuery; - var search = query.Search?.Trim() ?? string.Empty; - - if (ShouldSuppressGlobalQuery(query, search)) - { - return []; - } - - + var directActivationBrowse = IsActionKeywordQuery(query); var results = new List(); - - - if (IsActionKeywordQuery(query)) - + if (directActivationBrowse) { - results.AddRange(GetManageResults(search)); - } - - var shortcuts = string.IsNullOrWhiteSpace(search) - ? Shortcuts.GetShortcuts() - : MergeSearchResults(search); - - results.AddRange(shortcuts - - .Select(shortcut => CreateShortcutResult(shortcut, search)) - - .OrderByDescending(result => result.Score) - - .ThenBy(result => result.Title, StringComparer.OrdinalIgnoreCase)); - - + .Select(shortcut => CreateShortcutResult(shortcut, search, directActivationBrowse))); return results - .OrderByDescending(result => result.Score) - .ThenBy(result => result.Title, StringComparer.OrdinalIgnoreCase) - .ToList(); - } - - public List LoadContextMenus(Result selectedResult) - { - if (selectedResult.ContextData is not RunContextData contextData) - { - return []; - } - - return contextData.Kind switch - { - RunContextKind.Manage => [], - RunContextKind.Shortcut => BuildShortcutContextMenus(contextData.ShortcutId), - _ => [], - }; - } - - private List BuildShortcutContextMenus(string? shortcutId) - { - if (string.IsNullOrWhiteSpace(shortcutId)) - { - return []; - } - - var shortcut = Shortcuts.GetById(shortcutId); - if (shortcut is null) - { - return []; - } - - if (ShortcutHealth.NeedsRepair(shortcut)) - { - var repairMenus = new List - { - CreateContextMenu("Edit shortcut", "\uE70F", _ => - { - ExecuteManageShortcutEdit(shortcut); - return false; - }), - }; - if (shortcut.IsPinned) - { - repairMenus.Add(CreateContextMenu("Unfavorite", "\uE735", _ => - { - Shortcuts.TogglePinned(shortcut.Name); - NotifyStatus($"Removed '{shortcut.Name}' from favorites."); - RefreshResults(); - return false; - })); - } - repairMenus.Add(CreateContextMenu("Delete shortcut", "\uE74D", _ => - { - if (!Shortcuts.Delete(shortcut.Name)) - { - return false; - } - - NotifyStatus($"Deleted shortcut '{shortcut.Name}'."); - RefreshResults(); - return false; - })); - return repairMenus; - } - - return - [ - CreateContextMenu("Edit shortcut", "\uE70F", _ => - { - ExecuteManageShortcutEdit(shortcut); - return false; - }), - - CreateContextMenu("Duplicate shortcut", "\uE8C8", _ => - + CreateContextMenu("Duplicate shortcut", ShortcutGlyphs.Duplicate, _ => { - var duplicate = Shortcuts.BuildDuplicate(shortcut.Name); - if (duplicate is null) - { - return false; - } - - if (ShortcutEditor.TryShowDialog(duplicate, Shortcuts, out var message)) - { - NotifyStatus(message); - RefreshResults(); - } - - return false; - }), - CreateContextMenu("Delete shortcut", "\uE74D", _ => - { - if (!Shortcuts.Delete(shortcut.Name)) - { - return false; - } - - NotifyStatus($"Deleted shortcut '{shortcut.Name}'."); - RefreshResults(); - return false; - }), - CreateContextMenu("Run as administrator", "\uEA18", _ => - { - Launch(shortcut, runAsAdmin: true); - return true; - }), - CreateContextMenu("Open containing folder", "\uE838", _ => OpenContainingFolder(shortcut.Directory)), - - CreateContextMenu("Copy path", "\uE8C8", _ => - + CreateContextMenu("Copy path", ShortcutGlyphs.CopyPath, _ => { - CopyPath(shortcut.Directory); - return true; - }), - ]; - } - - private IEnumerable GetManageResults(string search) - { - var utilities = new (RunManageAction Action, string Title, string Subtitle, int Score, string[] Keywords)[] - { - (RunManageAction.CreateShortcut, "Create shortcut", "Add a new folder shortcut", 2000, ["new", "create", "add"]), - (RunManageAction.ExportShortcuts, "Export shortcuts", "Save shortcuts to a JSON file", 1900, ["export", "backup"]), - (RunManageAction.ImportMerge, "Import shortcuts (merge)", "Add shortcuts from a JSON file", 1850, ["import", "merge", "restore"]), - (RunManageAction.ImportReplace, "Import shortcuts (replace all)", "Replace all shortcuts from a JSON file", 1840, ["replace"]), - (RunManageAction.OpenShortcutsFile, "Open shortcuts.json", Shortcuts.ConfigPath, 1800, ["json", "shortcuts", "file"]), - (RunManageAction.OpenSettingsFile, "Open Quick Shell settings", Settings.SettingsPath, 1750, ["settings", "config"]), - }; - - + var utilityOrder = 0; foreach (var utility in utilities) - { - - if (!ShouldIncludeUtility(search, utility.Keywords)) - + if (!RunQueryScoring.ShouldIncludeUtility(search, utility.Keywords)) { - continue; - } - - yield return new Result - { - Title = utility.Title, - SubTitle = utility.Subtitle, - IcoPath = _iconPath, - - Score = utility.Score, - + Score = RunQueryScoring.ComputeUtilityScore(utility.Score, search, utilityOrder++), ContextData = new RunContextData(RunContextKind.Manage, ManageAction: utility.Action), - Action = _ => - { - ExecuteManageAction(utility.Action); - return ShouldHideRunAfterManage(utility.Action); - }, - }; - - } - - } - - - - private static bool ShouldIncludeUtility(string search, string[] keywords) - - { - - if (string.IsNullOrWhiteSpace(search)) - - { - - return true; - } - - - - return keywords.Any(keyword => keyword.Contains(search, StringComparison.OrdinalIgnoreCase) - - || search.Contains(keyword, StringComparison.OrdinalIgnoreCase)); - } - - private void ExecuteManageAction(RunManageAction action) - { - switch (action) - { - case RunManageAction.CreateShortcut: - if (ShortcutEditor.TryShowDialog(null, Shortcuts, out var createMessage)) - { - NotifyStatus(createMessage); - RefreshResults(); - } - - break; - case RunManageAction.ExportShortcuts: - if (RunFileDialogs.TryExportShortcuts(Shortcuts, null, out var exportMessage)) - { - NotifyStatus(exportMessage); - } - - break; - case RunManageAction.ImportMerge: - if (RunFileDialogs.TryImportShortcuts(Shortcuts, null, replace: false, out var mergeMessage) - && !string.IsNullOrWhiteSpace(mergeMessage)) - { - NotifyStatus(mergeMessage); - RefreshResults(); - } - - break; - case RunManageAction.ImportReplace: - if (RunFileDialogs.TryImportShortcuts(Shortcuts, null, replace: true, out var replaceMessage) - && !string.IsNullOrWhiteSpace(replaceMessage)) - { - NotifyStatus(replaceMessage); - RefreshResults(); - } - - break; - case RunManageAction.OpenShortcutsFile: - RunFileDialogs.OpenPathInEditor(Shortcuts.ConfigPath); - break; - case RunManageAction.OpenSettingsFile: - RunFileDialogs.OpenPathInEditor(Settings.SettingsPath); - break; - } - } - - private void ExecuteManageShortcutEdit(TerminalShortcut shortcut) - { - if (ShortcutEditor.TryShowDialog(shortcut, Shortcuts, out var message)) - { - NotifyStatus(message); - RefreshResults(); - } - } - - private static bool ShouldHideRunAfterManage(RunManageAction action) => - action is RunManageAction.OpenShortcutsFile or RunManageAction.OpenSettingsFile; - - private void NotifyStatus(string message) - { - if (string.IsNullOrWhiteSpace(message) || _context is null) - { - return; - } - - _context.API.ShowNotification("Quick Shell", message); - } - - private void RefreshResults() - { - if (_context is null || string.IsNullOrEmpty(_lastQuery)) - { - return; - } - - _context.API.ChangeQuery(_lastQuery, requery: true); - } - - - private Result CreateShortcutResult(TerminalShortcut shortcut, string search) - + private Result CreateShortcutResult(TerminalShortcut shortcut, string search, bool directActivationBrowse) { - var needsRepair = ShortcutHealth.NeedsRepair(shortcut); - return new Result - { - Title = shortcut.Name, - SubTitle = ShortcutHealth.BuildListSubtitle(shortcut), - Glyph = ShortcutHealth.GetListGlyph(shortcut), - FontFamily = IconFont, - - Score = ComputeScore(shortcut, search), - + Score = RunQueryScoring.ComputeShortcutScore(shortcut, search, directActivationBrowse), ContextData = new RunContextData(RunContextKind.Shortcut, shortcut.Id), - Action = action => - { - if (needsRepair) - { - ExecuteManageShortcutEdit(shortcut); - return false; - } - - var forceAdmin = action.SpecialKeyState.CtrlPressed && action.SpecialKeyState.ShiftPressed; - Launch(shortcut, runAsAdmin: forceAdmin || shortcut.RunAsAdmin); - return true; - }, - }; - } - - private IEnumerable MergeSearchResults(string search) - { - var rootMatches = Shortcuts.SearchForRootPalette(search).ToArray(); - if (rootMatches.Length > 0) - { - return rootMatches; - } - - return Shortcuts.Search(search); - - } - - - - private static int ComputeScore(TerminalShortcut shortcut, string search) - - { - - var score = shortcut.IsPinned ? 100 : 0; - - - - if (!string.IsNullOrWhiteSpace(search) - - && !string.IsNullOrWhiteSpace(shortcut.Abbreviation) - - && shortcut.Abbreviation.Equals(search, StringComparison.OrdinalIgnoreCase)) - - { - - score += 200; - - } - - else if (!string.IsNullOrWhiteSpace(search) - - && !string.IsNullOrWhiteSpace(shortcut.Abbreviation) - - && shortcut.Abbreviation.StartsWith(search, StringComparison.OrdinalIgnoreCase)) - - { - - score += 120; - - } - - - - if (shortcut.LastUsedUtc is not null) - - { - - var ageHours = Math.Max(0, (DateTime.UtcNow - shortcut.LastUsedUtc.Value).TotalHours); - - score += (int)Math.Max(0, 40 - ageHours); - - } - - - - return score; - } - - private void Launch(TerminalShortcut shortcut, bool runAsAdmin = false, bool runAsStandard = false) { var result = ShortcutLaunchExecutor.Launch( @@ -889,138 +416,69 @@ private void Launch(TerminalShortcut shortcut, bool runAsAdmin = false, bool run } } - - private static bool ShouldSuppressGlobalQuery(Query query, string search) - { - if (!string.IsNullOrEmpty(query.ActionKeyword)) - { - return false; - } - - if (string.IsNullOrWhiteSpace(search)) - { - return true; - } - - return search.Contains("quick shell", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsActionKeywordQuery(Query query) => - !string.IsNullOrEmpty(query.ActionKeyword); - - private void OnThemeChanged(Theme currentTheme, Theme newTheme) => UpdateIconPath(newTheme); - - private void UpdateIconPath(Theme theme) - { - _iconPath = theme is Theme.Light or Theme.HighContrastWhite - ? "Images\\quickshell.light.png" - : "Images\\quickshell.dark.png"; - } - - private static ContextMenuResult CreateContextMenu(string title, string glyph, Func action) => - new() - { - Title = title, - Glyph = glyph, - FontFamily = IconFont, - Action = action, - }; - - private static bool OpenContainingFolder(string directory) - { - if (!ShortcutValidation.TryNormalizeDirectory(directory, out var normalized, out _)) - { - return false; - } - - if (!ShortcutValidation.DirectoryExists(normalized)) - { - return false; - } - - return System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo - { - FileName = "explorer.exe", - Arguments = $"\"{normalized}\"", - UseShellExecute = true, - }) is not null; - } - - private static bool CopyPath(string directory) - { - if (!ShortcutValidation.TryNormalizeDirectory(directory, out var normalized, out _)) - { - return false; - } - - return StaClipboard.TrySetText(normalized); - } - } - - diff --git a/QuickShell.Run/ShortcutEditorWindow.cs b/QuickShell.Run/ShortcutEditorWindow.cs index 5501083..c021793 100644 --- a/QuickShell.Run/ShortcutEditorWindow.cs +++ b/QuickShell.Run/ShortcutEditorWindow.cs @@ -24,6 +24,15 @@ public ShortcutEditorWindow(TerminalShortcut? existing, ShortcutRepository short _existing = existing; _shortcuts = shortcuts; + if (existing is not null) + { + ShortcutLaunchNormalization.EnsureLaunchesFromLegacy(existing); + } + + var primaryLaunch = existing is not null + ? ShortcutFormSave.GetPrimaryLaunchForRunEditor(existing) + : null; + Title = existing is null ? "Create Quick Shell shortcut" : $"Edit {existing.Name}"; Width = 560; MinHeight = 420; @@ -59,7 +68,7 @@ public ShortcutEditorWindow(TerminalShortcut? existing, ShortcutRepository short }; root.Children.Add(browseButton); - _commandBox = AddField(root, "Command (optional)", existing?.Command ?? string.Empty); + _commandBox = AddField(root, "Command (optional)", primaryLaunch?.Command ?? existing?.Command ?? string.Empty); root.Children.Add(new TextBlock { @@ -78,17 +87,32 @@ public ShortcutEditorWindow(TerminalShortcut? existing, ShortcutRepository short _terminalBox.Items.Add(new { choice.Id, choice.Label }); } - _terminalBox.SelectedValue = TerminalCatalog.EncodeLaunchTargetId(existing ?? new TerminalShortcut()); + _terminalBox.SelectedValue = primaryLaunch is not null + ? ShortcutFormSave.EncodeLaunchTargetForEntry(primaryLaunch) + : TerminalCatalog.EncodeLaunchTargetId(existing ?? new TerminalShortcut()); root.Children.Add(_terminalBox); _adminBox = new CheckBox { Content = "Launch elevated", - IsChecked = existing?.RunAsAdmin ?? false, + IsChecked = primaryLaunch?.RunAsAdmin ?? existing?.RunAsAdmin ?? false, Margin = new Thickness(0, 0, 0, 12), }; root.Children.Add(_adminBox); + var preserveNote = BuildPreserveNote(existing); + if (!string.IsNullOrWhiteSpace(preserveNote)) + { + root.Children.Add(new TextBlock + { + Text = preserveNote, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 0, 0, 12), + Foreground = System.Windows.Media.Brushes.Gray, + FontSize = 12, + }); + } + var buttons = new StackPanel { Orientation = Orientation.Horizontal, @@ -109,6 +133,33 @@ public ShortcutEditorWindow(TerminalShortcut? existing, ShortcutRepository short Content = root; } + private static string? BuildPreserveNote(TerminalShortcut? existing) + { + if (existing is null) + { + return null; + } + + var enabledCount = ShortcutLaunchNormalization.GetEnabledLaunches(existing).Count; + var parts = new List(); + + if (enabledCount > 1) + { + parts.Add( + $"Command, terminal, and elevation apply to the primary launch only. {enabledCount - 1} other launch{(enabledCount == 2 ? string.Empty : "es")} are preserved."); + } + + if (existing.OpenCompanionAppOnLaunch + || !string.IsNullOrWhiteSpace(existing.CompanionAppPath) + || !string.IsNullOrWhiteSpace(existing.DevServerUrl) + || !string.IsNullOrWhiteSpace(existing.RepoUrl)) + { + parts.Add("Companion app and link settings are preserved. Edit those in Command Palette."); + } + + return parts.Count == 0 ? null : string.Join(' ', parts); + } + private static TextBox AddField(StackPanel root, string label, string value) { root.Children.Add(new TextBlock @@ -129,7 +180,8 @@ private static TextBox AddField(StackPanel root, string label, string value) private void SaveShortcut() { var launchTarget = _terminalBox.SelectedValue as string ?? "default"; - var result = ShortcutFormSave.TrySave( + var result = ShortcutFormSave.TrySaveRunEditor( + _existing, _existing?.Name, _nameBox.Text, _abbreviationBox.Text, diff --git a/QuickShell/Assets/LockScreenLogo.scale-200.png b/QuickShell/Assets/LockScreenLogo.scale-200.png index 0d29548..d94b45f 100644 Binary files a/QuickShell/Assets/LockScreenLogo.scale-200.png and b/QuickShell/Assets/LockScreenLogo.scale-200.png differ diff --git a/QuickShell/Assets/SplashScreen.png b/QuickShell/Assets/SplashScreen.png index b47fb34..94e556b 100644 Binary files a/QuickShell/Assets/SplashScreen.png and b/QuickShell/Assets/SplashScreen.png differ diff --git a/QuickShell/Assets/SplashScreen.scale-200.png b/QuickShell/Assets/SplashScreen.scale-200.png index b47fb34..94e556b 100644 Binary files a/QuickShell/Assets/SplashScreen.scale-200.png and b/QuickShell/Assets/SplashScreen.scale-200.png differ diff --git a/QuickShell/Assets/Square150x150Logo.png b/QuickShell/Assets/Square150x150Logo.png index 9fe0b77..0895073 100644 Binary files a/QuickShell/Assets/Square150x150Logo.png and b/QuickShell/Assets/Square150x150Logo.png differ diff --git a/QuickShell/Assets/Square150x150Logo.scale-200.png b/QuickShell/Assets/Square150x150Logo.scale-200.png index 9fe0b77..0895073 100644 Binary files a/QuickShell/Assets/Square150x150Logo.scale-200.png and b/QuickShell/Assets/Square150x150Logo.scale-200.png differ diff --git a/QuickShell/Assets/Square44x44Logo.png b/QuickShell/Assets/Square44x44Logo.png index 6b25ab4..424eb11 100644 Binary files a/QuickShell/Assets/Square44x44Logo.png and b/QuickShell/Assets/Square44x44Logo.png differ diff --git a/QuickShell/Assets/Square44x44Logo.scale-200.png b/QuickShell/Assets/Square44x44Logo.scale-200.png index 8bad702..47cff0c 100644 Binary files a/QuickShell/Assets/Square44x44Logo.scale-200.png and b/QuickShell/Assets/Square44x44Logo.scale-200.png differ diff --git a/QuickShell/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/QuickShell/Assets/Square44x44Logo.targetsize-24_altform-unplated.png index 8b1c708..3e2d6ac 100644 Binary files a/QuickShell/Assets/Square44x44Logo.targetsize-24_altform-unplated.png and b/QuickShell/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/QuickShell/Assets/StoreListing/AppTile_150x150.png b/QuickShell/Assets/StoreListing/AppTile_150x150.png index 649b73c..558209a 100644 Binary files a/QuickShell/Assets/StoreListing/AppTile_150x150.png and b/QuickShell/Assets/StoreListing/AppTile_150x150.png differ diff --git a/QuickShell/Assets/StoreListing/AppTile_300x300.png b/QuickShell/Assets/StoreListing/AppTile_300x300.png index 9fe0b77..0895073 100644 Binary files a/QuickShell/Assets/StoreListing/AppTile_300x300.png and b/QuickShell/Assets/StoreListing/AppTile_300x300.png differ diff --git a/QuickShell/Assets/StoreListing/AppTile_71x71.png b/QuickShell/Assets/StoreListing/AppTile_71x71.png index 2be2dbe..25a3140 100644 Binary files a/QuickShell/Assets/StoreListing/AppTile_71x71.png and b/QuickShell/Assets/StoreListing/AppTile_71x71.png differ diff --git a/QuickShell/Assets/StoreListing/BoxArt_1080x1080.png b/QuickShell/Assets/StoreListing/BoxArt_1080x1080.png index 692fa72..e28354a 100644 Binary files a/QuickShell/Assets/StoreListing/BoxArt_1080x1080.png and b/QuickShell/Assets/StoreListing/BoxArt_1080x1080.png differ diff --git a/QuickShell/Assets/StoreListing/BoxArt_2160x2160.png b/QuickShell/Assets/StoreListing/BoxArt_2160x2160.png index b2ae8e1..9facdd0 100644 Binary files a/QuickShell/Assets/StoreListing/BoxArt_2160x2160.png and b/QuickShell/Assets/StoreListing/BoxArt_2160x2160.png differ diff --git a/QuickShell/Assets/StoreListing/PosterArt_1440x2160.png b/QuickShell/Assets/StoreListing/PosterArt_1440x2160.png index 15d6fe2..816983f 100644 Binary files a/QuickShell/Assets/StoreListing/PosterArt_1440x2160.png and b/QuickShell/Assets/StoreListing/PosterArt_1440x2160.png differ diff --git a/QuickShell/Assets/StoreListing/PosterArt_720x1080.png b/QuickShell/Assets/StoreListing/PosterArt_720x1080.png index db39c89..45839d9 100644 Binary files a/QuickShell/Assets/StoreListing/PosterArt_720x1080.png and b/QuickShell/Assets/StoreListing/PosterArt_720x1080.png differ diff --git a/QuickShell/Assets/StoreLogo.png b/QuickShell/Assets/StoreLogo.png index 0be066f..7805841 100644 Binary files a/QuickShell/Assets/StoreLogo.png and b/QuickShell/Assets/StoreLogo.png differ diff --git a/QuickShell/Assets/Wide310x150Logo.png b/QuickShell/Assets/Wide310x150Logo.png index b47fb34..94e556b 100644 Binary files a/QuickShell/Assets/Wide310x150Logo.png and b/QuickShell/Assets/Wide310x150Logo.png differ diff --git a/QuickShell/Assets/Wide310x150Logo.scale-200.png b/QuickShell/Assets/Wide310x150Logo.scale-200.png index b47fb34..94e556b 100644 Binary files a/QuickShell/Assets/Wide310x150Logo.scale-200.png and b/QuickShell/Assets/Wide310x150Logo.scale-200.png differ diff --git a/QuickShell/Assets/logo-micro.svg b/QuickShell/Assets/logo-micro.svg index 1fec6d2..2570a77 100644 --- a/QuickShell/Assets/logo-micro.svg +++ b/QuickShell/Assets/logo-micro.svg @@ -1,5 +1,5 @@ - + Micro Folder Bolt @@ -19,37 +19,43 @@ - - - + + + + + + + + + - + - + + opacity="0.28"/> + opacity="0.82"/> diff --git a/QuickShell/Assets/logo-run.dark.svg b/QuickShell/Assets/logo-run.dark.svg index 5883353..c73c362 100644 --- a/QuickShell/Assets/logo-run.dark.svg +++ b/QuickShell/Assets/logo-run.dark.svg @@ -1,13 +1,13 @@ - + Quick Shell Run Dark - + - + diff --git a/QuickShell/Assets/logo-run.light.svg b/QuickShell/Assets/logo-run.light.svg index e13e1a5..168880b 100644 --- a/QuickShell/Assets/logo-run.light.svg +++ b/QuickShell/Assets/logo-run.light.svg @@ -1,13 +1,13 @@ - + Quick Shell Run Light - + - + diff --git a/QuickShell/build-exe.ps1 b/QuickShell/build-exe.ps1 index 661721f..34fd85b 100644 --- a/QuickShell/build-exe.ps1 +++ b/QuickShell/build-exe.ps1 @@ -46,11 +46,22 @@ foreach ($Platform in $Platforms) { $fileCount = (Get-ChildItem -Path $publishDir -Recurse -File).Count Write-Host "Published $fileCount files to $publishDir" -ForegroundColor Green + $runPlatform = if ($Platform -eq "arm64") { "ARM64" } else { "x64" } + $buildRunPlugin = Join-Path (Split-Path $ProjectDir -Parent) "scripts\build-run-plugin.ps1" + Write-Host "Building PowerToys Run plugin ($runPlatform)..." -ForegroundColor Yellow + & $buildRunPlugin -Configuration $Configuration -Platform $runPlatform + if ($LASTEXITCODE -ne 0) { + throw "build-run-plugin.ps1 failed for $runPlatform with exit code $LASTEXITCODE" + } + + $repoRoot = Split-Path -Parent $ProjectDir + $runPluginSource = Join-Path $repoRoot "QuickShell.Run\bin\$runPlatform\$Configuration\package" $setupTemplate = Get-Content (Join-Path $ProjectDir "setup-template.iss") -Raw $setupScript = $setupTemplate -replace '#define AppVersion ".*"', "#define AppVersion `"$Version`"" + $setupScript = $setupScript -replace '#define RunPluginSource ".*"', "#define RunPluginSource `"$($runPluginSource.Replace('\', '\\'))`"" + $setupScript = $setupScript -replace 'OutputDir=bin\\[^\\]+\\installer', ("OutputDir=bin\{0}\installer" -f $Configuration) $setupScript = $setupScript -replace 'OutputBaseFilename=(.*?)\{#AppVersion\}', "OutputBaseFilename=`$1{#AppVersion}-$Platform" - $setupScript = $setupScript -replace 'Source: "bin\\Release\\win-x64\\publish', "Source: `"bin\Release\win-$Platform\publish" - + $setupScript = $setupScript -replace 'Source: "bin\\Release\\win-x64\\publish', ("Source: `"bin\{0}\win-{1}\publish" -f $Configuration, $Platform) if ($Platform -eq "arm64") { $setupScript = $setupScript -replace '(\[Setup\][^\[]*)(MinVersion=)', "`$1ArchitecturesAllowed=arm64`r`nArchitecturesInstallIn64BitMode=arm64`r`n`$2" } diff --git a/QuickShell/setup-template.iss b/QuickShell/setup-template.iss index 03fa692..f420f22 100644 --- a/QuickShell/setup-template.iss +++ b/QuickShell/setup-template.iss @@ -8,6 +8,8 @@ #define Clsid "528cc766-cbe8-4861-9933-722c7a3f3581" #define InstallAppId "8C4E2F91-6B3D-4A5E-9F1C-2D7E8A0B4C6D" +#define RunPluginSource "__MUST_BE_SET_BY_BUILD_SCRIPT__" +#define RunPluginDest "{localappdata}\Microsoft\PowerToys\PowerToys Run\Plugins\QuickShell" [Setup] AppId={{{#InstallAppId}} @@ -32,6 +34,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl" [Files] Source: "bin\Release\win-x64\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs +Source: "{#RunPluginSource}\*"; DestDir: "{#RunPluginDest}"; Flags: ignoreversion recursesubdirs createallsubdirs [Icons] Name: "{group}\{#DisplayName}"; Filename: "{app}\{#ExtensionName}.exe" @@ -42,3 +45,4 @@ Root: HKCU; Subkey: "SOFTWARE\Classes\CLSID\{{{#Clsid}}}\LocalServer32"; ValueTy [UninstallDelete] Type: filesandordirs; Name: "{app}" +Type: filesandordirs; Name: "{#RunPluginDest}" diff --git a/docs/install.md b/docs/install.md index e09f654..88d3b14 100644 --- a/docs/install.md +++ b/docs/install.md @@ -34,23 +34,32 @@ Install from PowerShell or Command Prompt: winget install tonythethompson.QuickShell ``` +This installer registers the **Command Palette** extension and installs the **PowerToys Run** plugin (`qs` in Alt+Space). Restart PowerToys after install so Run picks up the plugin. + ### GitHub Releases Download the installer directly: 1. Go to [GitHub Releases](https://github.com/tonythethompson/QuickShell/releases){:target="_blank"} -2. Download the latest **x64** or **ARM64** MSI installer +2. Download the latest **x64** or **ARM64** **EXE** installer (`QuickShell-Setup-*-x64.exe` or `*-arm64.exe`) 3. Run the installer +Same as WinGet: includes both Command Palette and PowerToys Run. Restart PowerToys after install. + Choose **x64** for most PCs, **ARM64** only if you're on an ARM-based Windows device. +Standalone Run-only ZIPs (`QuickShell.Run-*.zip`) are also on Releases if you already use the Store build and only want the Run plugin. + ## Complete setup After installation, follow these steps: -1. Open **PowerToys Command Palette** (press **Win + Alt + Space**) -2. Search for **Reload Command Palette Extension** and run it -3. Search for **Quick Shell** — you should see it in the results +1. **Restart PowerToys** (WinGet / GitHub EXE installs the Run plugin; Store installs CmdPal only) +2. Open **PowerToys Command Palette** (press **Win + Alt + Space**) +3. Search for **Reload Command Palette Extension** and run it +4. Search for **Quick Shell** — you should see it in the results + +Optional: open **PowerToys Run** (**Alt+Space**), type **`qs`**, to use the same shortcuts from Run.
Not showing up? Make sure Command Palette is enabled in PowerToys settings (Settings → Command Palette → enabled). diff --git a/docs/powertoys-run-plugin.md b/docs/powertoys-run-plugin.md index d28541b..bc538c6 100644 --- a/docs/powertoys-run-plugin.md +++ b/docs/powertoys-run-plugin.md @@ -4,7 +4,17 @@ Third-party plugin that reads the same shortcuts and settings as the [Quick Shel ## Install -### From GitHub Releases +### WinGet or GitHub EXE (recommended) + +The **WinGet** package and **GitHub EXE** installers include both the Command Palette extension and this Run plugin. Restart PowerToys after install. + +```powershell +winget install tonythethompson.QuickShell +``` + +### Run plugin ZIP only + +Use this if you installed Quick Shell from the **Microsoft Store** (CmdPal only) and want Run without reinstalling: 1. Download `QuickShell.Run-x64.zip` or `QuickShell.Run-ARM64.zip` from [Releases](https://github.com/tonythethompson/QuickShell/releases) (added in releases after v0.1.7.0; older tags are CmdPal installer only). 2. Extract into: @@ -25,9 +35,8 @@ Restart PowerToys after deploy. | Action | How | | --- | --- | -| Browse shortcuts | **Alt+Space** → `qs` (optionally `qs measure`, etc.) | -| Home keyword in Run | **Alt+Space** → `qs` + keyword (e.g. `qs measure`) — not bare `measure` | -| Create / export / import | **Alt+Space** → `qs` → pick a utility row, or use **PowerToys Settings → PowerToys Run → Quick Shell** | +| Browse shortcuts | **Alt+Space** → `qs` — shortcuts appear first; type more to filter (e.g. `qs measure`) | +| Manage actions | **Alt+Space** → `qs create`, `qs export`, etc. (utilities rank high once you type a keyword) | | Edit shortcut | Select shortcut → **→** context menu → **Edit shortcut** | | Run elevated | **Ctrl+Shift+Enter**, or context menu | diff --git a/scripts/build-run-plugin.ps1 b/scripts/build-run-plugin.ps1 index 7586a32..ca51287 100644 --- a/scripts/build-run-plugin.ps1 +++ b/scripts/build-run-plugin.ps1 @@ -16,6 +16,13 @@ $zipPath = Join-Path $repoRoot "QuickShell.Run\bin\$Platform\$Configuration\Quic Write-Host "Building Quick Shell Run plugin ($Configuration | $Platform)..." dotnet build $project -c $Configuration -p:Platform=$Platform +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +if (-not (Test-Path (Join-Path $outputRoot 'plugin.json'))) { + throw "Build output is missing plugin.json. qs will not register until it is deployed." +} if (Test-Path $stagingRoot) { Remove-Item $stagingRoot -Recurse -Force @@ -34,13 +41,72 @@ if (Test-Path $zipPath) { Compress-Archive -Path (Join-Path $stagingRoot '*') -DestinationPath $zipPath Write-Host "Created $zipPath" +function Test-PowerToysRunning { + Get-Process -Name 'PowerToys', 'PowerToys.PowerLauncher' -ErrorAction SilentlyContinue | + Select-Object -First 1 +} + +function Copy-PluginPayload { + param( + [string]$SourceRoot, + [string]$DestinationRoot + ) + + New-Item -ItemType Directory -Force -Path $DestinationRoot | Out-Null + + $lockedFiles = @() + Get-ChildItem $SourceRoot -Recurse -File | ForEach-Object { + $relative = $_.FullName.Substring($SourceRoot.Length).TrimStart('\') + $target = Join-Path $DestinationRoot $relative + $targetDir = Split-Path $target -Parent + if (-not (Test-Path $targetDir)) { + New-Item -ItemType Directory -Force -Path $targetDir | Out-Null + } + + try { + Copy-Item $_.FullName -Destination $target -Force -ErrorAction Stop + } + catch [System.IO.IOException] { + $lockedFiles += $relative + } + catch [System.UnauthorizedAccessException] { + $lockedFiles += $relative + } + } + + return $lockedFiles +} + if ($Deploy) { - if (-not (Test-Path $pluginRoot)) { - New-Item -ItemType Directory -Force -Path $pluginRoot | Out-Null + if (-not (Test-Path (Join-Path $stagingRoot 'plugin.json'))) { + throw "Staging directory is missing plugin.json." + } + + $running = Test-PowerToysRunning + if ($running) { + Write-Host "PowerToys is running. DLLs may be locked; plugin.json and images will still be copied." -ForegroundColor Yellow + Write-Host "Restart PowerToys after deploy so qs and updated DLLs load." -ForegroundColor Yellow + } + + # Do not wipe the plugin folder first. A failed delete while PowerToys holds DLLs + # removes plugin.json/Images and leaves only the locked DLLs — that breaks qs. + $lockedFiles = Copy-PluginPayload -SourceRoot $stagingRoot -DestinationRoot $pluginRoot + + $pluginJson = Join-Path $pluginRoot 'plugin.json' + if (-not (Test-Path $pluginJson)) { + throw "Deploy failed: plugin.json is missing at $pluginRoot" } - Get-ChildItem $pluginRoot -Force | Remove-Item -Recurse -Force - Copy-Item -Path (Join-Path $stagingRoot '*') -Destination $pluginRoot -Recurse -Force - Write-Host "Deployed to $pluginRoot" - Write-Host "Restart PowerToys to load the plugin." + $keyword = (Get-Content $pluginJson -Raw | ConvertFrom-Json).ActionKeyword + Write-Host "Deployed to $pluginRoot (action keyword: $keyword)" + + if ($lockedFiles.Count -gt 0) { + Write-Host "These files were locked and may still be the previous build:" -ForegroundColor Yellow + $lockedFiles | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } + Write-Host "Quit PowerToys, rerun with -Deploy, then start PowerToys again." -ForegroundColor Yellow + exit 1 + } + else { + Write-Host "Restart PowerToys to load the plugin." -ForegroundColor Green + } }