From 4f86998eed2e62f8377029ef1145b48096e0a024 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Thu, 2 Jul 2026 22:01:45 -0700 Subject: [PATCH] feat: WSL terminal profile icons and launch arg fixes Use penguin glyph for WSL profiles when WT PNG icons are unavailable, resolve profile icons once per lookup, parse WSL --distribution in command lines, and add terminal launch test seam. Co-authored-by: Cursor --- .../ShortcutLaunchExecutorTests.cs | 20 +++-- .../TerminalLauncherArgsTests.cs | 23 ++++++ .../TerminalProfileIconResolverTests.cs | 43 ++++++++++ .../Services/CompanionAppCatalog.cs | 6 ++ QuickShell.Core/Services/ShortcutGlyphs.cs | 11 +++ .../Services/TerminalLaunchGlyphs.cs | 79 ++++++++++++++----- QuickShell.Core/Services/TerminalLauncher.cs | 11 ++- .../Services/TerminalProfileIconResolver.cs | 22 ++++++ QuickShell.Core/Services/WslPathResolver.cs | 22 +++++- .../Commands/WorkspaceUtilityCommands.cs | 7 +- .../Services/ShortcutContextCommands.cs | 6 +- 11 files changed, 215 insertions(+), 35 deletions(-) diff --git a/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs b/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs index 664f583..4388b9a 100644 --- a/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs +++ b/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs @@ -109,12 +109,20 @@ public void LaunchEntry_DoesNotOpenDevServerWhenOptedIn() ], }; - var result = ShortcutLaunchExecutor.LaunchEntry( - shortcut, - shortcut.Launches[0], - "wt", - "default"); + TerminalLauncher.StartProcessOverride = _ => true; + try + { + var result = ShortcutLaunchExecutor.LaunchEntry( + shortcut, + shortcut.Launches[0], + "wt", + "default"); - Assert.True(result.Dismiss); + Assert.True(result.Dismiss); + } + finally + { + TerminalLauncher.StartProcessOverride = null; + } } } diff --git a/QuickShell.Core.Tests/TerminalLauncherArgsTests.cs b/QuickShell.Core.Tests/TerminalLauncherArgsTests.cs index 037d288..408bef6 100644 --- a/QuickShell.Core.Tests/TerminalLauncherArgsTests.cs +++ b/QuickShell.Core.Tests/TerminalLauncherArgsTests.cs @@ -122,4 +122,27 @@ public void ToWslArguments_PrefersDistroFromWtCommandLineOverProfileName() Assert.Contains("-d \"Ubuntu\"", args, StringComparison.Ordinal); Assert.DoesNotContain("Dev Shell", args, StringComparison.Ordinal); } + + [Fact] + public void ToWslArguments_ParsesLongDistributionFlagFromWtCommandLine() + { + var shortcut = new TerminalShortcut { Directory = @"C:\Projects\App" }; + var target = new LaunchTarget + { + Id = "wt:dev-shell", + DisplayName = "Dev Shell", + Kind = LaunchTargetKind.WindowsTerminal, + ProfileOrDistro = "Dev Shell", + WtCommandLine = "wsl.exe --distribution Debian", + }; + var location = new WslPathResolver.WslLocation + { + LinuxPath = "/mnt/c/Projects/App", + }; + + var args = TerminalLauncherArgs.ToWslArguments(shortcut, target, location); + + Assert.Contains("-d \"Debian\"", args, StringComparison.Ordinal); + Assert.DoesNotContain("Dev Shell", args, StringComparison.Ordinal); + } } diff --git a/QuickShell.Core.Tests/TerminalProfileIconResolverTests.cs b/QuickShell.Core.Tests/TerminalProfileIconResolverTests.cs index 2fba3a9..0e32612 100644 --- a/QuickShell.Core.Tests/TerminalProfileIconResolverTests.cs +++ b/QuickShell.Core.Tests/TerminalProfileIconResolverTests.cs @@ -1,3 +1,4 @@ +using QuickShell.Models; using QuickShell.Services; namespace QuickShell.Core.Tests; @@ -111,6 +112,48 @@ public void Resolve_ReturnsNullWhenPathDoesNotExist() Assert.Null(resolved); } + [Fact] + public void IsCmdPalGlyphIcon_AcceptsEmojiAndRejectsFilePaths() + { + Assert.True(TerminalProfileIconResolver.IsCmdPalGlyphIcon("🐧")); + Assert.True(TerminalProfileIconResolver.IsCmdPalGlyphIcon("\uE756")); + Assert.False(TerminalProfileIconResolver.IsCmdPalGlyphIcon(@"C:\Apps\wt\ProfileIcons\debian.png")); + Assert.False(TerminalProfileIconResolver.IsCmdPalGlyphIcon("ms-appx:///ProfileIcons/foo.png")); + } + + [Fact] + public void IsWslProfile_DetectsWslCommandLine() + { + var profile = CreateProfile("Debian", "wsl.exe -d Debian"); + Assert.True(TerminalLaunchGlyphs.IsWslProfile(profile)); + } + + [Fact] + public void GetForLaunch_WslProfile_UsesLinuxPenguinWhenOnlyPngIconExists() + { + var launch = new WorkspaceEntry + { + Id = "1", + Label = "Main", + Terminal = "wt", + WtProfile = "Debian", + }; + + var glyph = TerminalLaunchGlyphs.GetForLaunch(launch); + Assert.Equal(ShortcutGlyphs.Linux, glyph); + } + + private static WtProfileInfo CreateProfile(string name, string commandline) => new() + { + Name = name, + Commandline = commandline, + SettingsPath = Path.Combine(Path.GetTempPath(), "settings.json"), + Source = TerminalSettingsSource.WindowsTerminal, + HostExecutable = "wt.exe", + IdPrefix = "wt", + SourceLabel = "Windows Terminal", + }; + public void Dispose() { try diff --git a/QuickShell.Core/Services/CompanionAppCatalog.cs b/QuickShell.Core/Services/CompanionAppCatalog.cs index 50ede86..c9f2bf6 100644 --- a/QuickShell.Core/Services/CompanionAppCatalog.cs +++ b/QuickShell.Core/Services/CompanionAppCatalog.cs @@ -70,6 +70,12 @@ public static string GetDisplayName(string? executablePath) return Definitions.First(definition => definition.Id == preset).Title; } + public static string GetContextMenuIcon(string? executablePath) + { + _ = InferPresetFromPath(executablePath); + return ShortcutGlyphs.OpenCompanionApp; + } + public static bool TryApplyPreset(string presetId, out string? executablePath, out string arguments) { executablePath = null; diff --git a/QuickShell.Core/Services/ShortcutGlyphs.cs b/QuickShell.Core/Services/ShortcutGlyphs.cs index cc8ac44..ece6fcc 100644 --- a/QuickShell.Core/Services/ShortcutGlyphs.cs +++ b/QuickShell.Core/Services/ShortcutGlyphs.cs @@ -26,7 +26,18 @@ internal static class ShortcutGlyphs public const string Add = "\uE710"; + public const string Remove = "\uE738"; + public const string Saved = "\uE73E"; public const string Workspace = "\uE8A7"; + + public const string Duplicate = "\uE8C8"; + + public const string CopyPath = "\uF413"; + + public const string OpenRepository = "\uE8A7"; + + /// Segoe MDL2 OpenWith — generic "launch/open application". + public const string OpenCompanionApp = "\uE7AC"; } diff --git a/QuickShell.Core/Services/TerminalLaunchGlyphs.cs b/QuickShell.Core/Services/TerminalLaunchGlyphs.cs index f94511e..58f48f0 100644 --- a/QuickShell.Core/Services/TerminalLaunchGlyphs.cs +++ b/QuickShell.Core/Services/TerminalLaunchGlyphs.cs @@ -13,38 +13,75 @@ public static string GetForShortcut(TerminalShortcut shortcut) public static string GetForLaunch(WorkspaceEntry launch) { - if (TryGetProfileIcon(launch, out var profileIcon)) + var profile = TerminalProfileResolver.ResolveForLaunch(launch); + + if (TryGetProfileIcon(profile, out var profileIcon)) { return profileIcon; } - return GetFallbackGlyph(launch); + return GetFallbackGlyph(launch, profile); } - private static bool TryGetProfileIcon(WorkspaceEntry launch, out string icon) + private static bool TryGetProfileIcon(WtProfileInfo? profile, out string icon) { icon = string.Empty; - var profile = TerminalProfileResolver.ResolveForLaunch(launch); if (profile is null) { return false; } + if (IsWslProfile(profile)) + { + return false; + } + var resolved = TerminalProfileIconResolver.ResolveEffectiveIcon(profile); - if (string.IsNullOrWhiteSpace(resolved)) + if (!TerminalProfileIconResolver.IsCmdPalGlyphIcon(resolved)) { return false; } - icon = resolved; + icon = resolved!; return true; } - private static string GetFallbackGlyph(WorkspaceEntry launch) + internal static bool IsWslProfile(WtProfileInfo profile) { + if (profile.Commandline?.Contains("wsl.exe", StringComparison.OrdinalIgnoreCase) == true) + { + return true; + } + + if (profile.ProfileSource?.Contains("WSL", StringComparison.OrdinalIgnoreCase) == true + || profile.ProfileSource?.Contains("Windows.Subsystem.Linux", StringComparison.OrdinalIgnoreCase) == true) + { + return true; + } + + return IsLinuxDistroName(profile.Name); + } + + private static bool IsWslProfile(string? terminal, string? profileName) + { + if (terminal?.Equals("wsl", StringComparison.OrdinalIgnoreCase) == true) + { + return true; + } + + return IsLinuxDistroName(profileName); + } + + private static string GetFallbackGlyph(WorkspaceEntry launch, WtProfileInfo? profile) + { + if (profile is not null && IsWslProfile(profile)) + { + return ShortcutGlyphs.Linux; + } + var terminal = (launch.Terminal ?? "default").Trim().ToLowerInvariant(); - if (IsLinuxTarget(terminal, launch.WtProfile)) + if (IsWslProfile(terminal, launch.WtProfile)) { return ShortcutGlyphs.Linux; } @@ -60,22 +97,24 @@ private static string GetFallbackGlyph(WorkspaceEntry launch) }; } - private static bool IsLinuxTarget(string terminal, string? profile) + private static bool IsLinuxDistroName(string? value) { - if (terminal.Equals("wsl", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (string.IsNullOrWhiteSpace(profile)) + if (string.IsNullOrWhiteSpace(value)) { return false; } - return profile.Contains("ubuntu", StringComparison.OrdinalIgnoreCase) - || profile.Contains("debian", StringComparison.OrdinalIgnoreCase) - || profile.Contains("fedora", StringComparison.OrdinalIgnoreCase) - || profile.Contains("linux", StringComparison.OrdinalIgnoreCase) - || profile.Contains("wsl", StringComparison.OrdinalIgnoreCase); + return value.Contains("ubuntu", StringComparison.OrdinalIgnoreCase) + || value.Contains("debian", StringComparison.OrdinalIgnoreCase) + || value.Contains("fedora", StringComparison.OrdinalIgnoreCase) + || value.Contains("linux", StringComparison.OrdinalIgnoreCase) + || value.Contains("wsl", StringComparison.OrdinalIgnoreCase) + || value.Contains("alpine", StringComparison.OrdinalIgnoreCase) + || value.Contains("arch", StringComparison.OrdinalIgnoreCase) + || value.Contains("kali", StringComparison.OrdinalIgnoreCase) + || value.Contains("opensuse", StringComparison.OrdinalIgnoreCase) + || value.Contains("suse", StringComparison.OrdinalIgnoreCase) + || value.Contains("mint", StringComparison.OrdinalIgnoreCase) + || value.Contains("gentoo", StringComparison.OrdinalIgnoreCase); } } diff --git a/QuickShell.Core/Services/TerminalLauncher.cs b/QuickShell.Core/Services/TerminalLauncher.cs index 5d97047..6d44604 100644 --- a/QuickShell.Core/Services/TerminalLauncher.cs +++ b/QuickShell.Core/Services/TerminalLauncher.cs @@ -5,6 +5,8 @@ namespace QuickShell.Services; internal static class TerminalLauncher { + internal static Func? StartProcessOverride { get; set; } + public static void Open( TerminalShortcut shortcut, string terminalApplicationId, @@ -58,7 +60,14 @@ public static void Open( startInfo.Verb = "runas"; } - if (Process.Start(startInfo) is null) + if (StartProcessOverride is { } startOverride) + { + if (!startOverride(startInfo)) + { + throw new InvalidOperationException($"Failed to start {startInfo.FileName}."); + } + } + else if (Process.Start(startInfo) is null) { throw new InvalidOperationException($"Failed to start {startInfo.FileName}."); } diff --git a/QuickShell.Core/Services/TerminalProfileIconResolver.cs b/QuickShell.Core/Services/TerminalProfileIconResolver.cs index 69953d5..2abf720 100644 --- a/QuickShell.Core/Services/TerminalProfileIconResolver.cs +++ b/QuickShell.Core/Services/TerminalProfileIconResolver.cs @@ -71,6 +71,28 @@ internal static class TerminalProfileIconResolver return File.Exists(relativePath) ? relativePath : null; } + /// + /// Command Palette icons render Segoe glyphs and emoji, not PNG paths or packaged URIs + /// resolved from Windows Terminal profile settings. + /// + public static bool IsCmdPalGlyphIcon(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + if (value.StartsWith("ms-", StringComparison.OrdinalIgnoreCase) + || Path.IsPathRooted(value) + || value.Contains('\\', StringComparison.Ordinal) + || value.Contains('/', StringComparison.Ordinal)) + { + return false; + } + + return LooksLikeEmojiOrInlineGlyph(value); + } + private static IEnumerable GetIconCandidates(WtProfileInfo profile) { if (!string.IsNullOrWhiteSpace(profile.Icon)) diff --git a/QuickShell.Core/Services/WslPathResolver.cs b/QuickShell.Core/Services/WslPathResolver.cs index e2cb19e..616d150 100644 --- a/QuickShell.Core/Services/WslPathResolver.cs +++ b/QuickShell.Core/Services/WslPathResolver.cs @@ -131,7 +131,13 @@ private static bool TryParseUncRemainder(string remainder, string fullUnc, out W return null; } - const string marker = "-d "; + return ExtractFlagValue(commandLine, "-d ") + ?? ExtractFlagValue(commandLine, "--distribution ") + ?? ExtractFlagValue(commandLine, "--distribution="); + } + + private static string? ExtractFlagValue(string commandLine, string marker) + { var index = commandLine.IndexOf(marker, StringComparison.OrdinalIgnoreCase); if (index < 0) { @@ -144,8 +150,20 @@ private static bool TryParseUncRemainder(string remainder, string fullUnc, out W return null; } + if (remainder.StartsWith('"')) + { + var endQuote = remainder.IndexOf('"', 1); + return endQuote > 0 ? remainder[1..endQuote] : remainder.Trim('"'); + } + + if (remainder.StartsWith('\'')) + { + var endQuote = remainder.IndexOf('\'', 1); + return endQuote > 0 ? remainder[1..endQuote] : remainder.Trim('\''); + } + var end = remainder.IndexOf(' '); - return (end < 0 ? remainder : remainder[..end]).Trim('"'); + return (end < 0 ? remainder : remainder[..end]).Trim(); } private static string EscapeShell(string value) => value.Replace("\"", "\\\""); diff --git a/QuickShell/Commands/WorkspaceUtilityCommands.cs b/QuickShell/Commands/WorkspaceUtilityCommands.cs index 5581027..6a9029a 100644 --- a/QuickShell/Commands/WorkspaceUtilityCommands.cs +++ b/QuickShell/Commands/WorkspaceUtilityCommands.cs @@ -13,7 +13,7 @@ public CopyShortcutPathCommand(string shortcutId) { _shortcutId = shortcutId; Name = "Copy path"; - Icon = new IconInfo("\uE8C8"); + Icon = new IconInfo(ShortcutGlyphs.CopyPath); } public override CommandResult Invoke() @@ -104,7 +104,8 @@ public OpenWorkspaceLinkCommand(string shortcutId, WorkspaceLinkKind kind) WorkspaceLinkKind.Repo => "Open repository", _ => "Open link", }; - Icon = new IconInfo(kind == WorkspaceLinkKind.Repo ? "\uE737" : "\uE774"); + Icon = new IconInfo( + kind == WorkspaceLinkKind.Repo ? ShortcutGlyphs.OpenRepository : "\uE774"); } public override CommandResult Invoke() @@ -133,7 +134,7 @@ public OpenCompanionAppCommand(TerminalShortcut shortcut) { _shortcutId = shortcut.Id; Name = $"Open {CompanionAppCatalog.GetDisplayName(shortcut.CompanionAppPath)}"; - Icon = new IconInfo("\uE70F"); + Icon = new IconInfo(CompanionAppCatalog.GetContextMenuIcon(shortcut.CompanionAppPath)); } public override CommandResult Invoke() diff --git a/QuickShell/Services/ShortcutContextCommands.cs b/QuickShell/Services/ShortcutContextCommands.cs index 843ec3d..1928202 100644 --- a/QuickShell/Services/ShortcutContextCommands.cs +++ b/QuickShell/Services/ShortcutContextCommands.cs @@ -313,7 +313,7 @@ private static void AddFolderAndLinkCommands(List items, Ter items.Add(new CommandContextItem(new CopyShortcutPathCommand(shortcut.Id)) { Title = "Copy path", - Icon = new IconInfo("\uE8C8"), + Icon = new IconInfo(ShortcutGlyphs.CopyPath), #if CMDPAL_HOVER_ACTIONS ShowInHoverActions = true, HoverOrder = HoverOrderCopyPath, @@ -338,7 +338,7 @@ private static void AddFolderAndLinkCommands(List items, Ter items.Add(new CommandContextItem(new OpenWorkspaceLinkCommand(shortcut.Id, WorkspaceLinkKind.Repo)) { Title = "Open repository", - Icon = new IconInfo("\uE737"), + Icon = new IconInfo(ShortcutGlyphs.OpenRepository), #if CMDPAL_HOVER_ACTIONS ShowInHoverActions = true, HoverOrder = HoverOrderRepo, @@ -351,7 +351,7 @@ private static void AddFolderAndLinkCommands(List items, Ter items.Add(new CommandContextItem(new OpenCompanionAppCommand(shortcut)) { Title = $"Open {CompanionAppCatalog.GetDisplayName(shortcut.CompanionAppPath)}", - Icon = new IconInfo("\uE70F"), + Icon = new IconInfo(CompanionAppCatalog.GetContextMenuIcon(shortcut.CompanionAppPath)), #if CMDPAL_HOVER_ACTIONS ShowInHoverActions = true, HoverOrder = HoverOrderCompanionApp,