diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a6697d..4a9d95a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,9 @@ on: # Stacked PRs whose base branch predates this file may need master merged in # before checks appear on downstream PRs (#5–#9). pull_request: + # Manual escape hatch: lets a run be dispatched via the Actions tab or API + # when a synchronize webhook doesn't fire (e.g. non-standard push paths). + workflow_dispatch: permissions: contents: read diff --git a/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs b/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs index 4388b9a..2acf501 100644 --- a/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs +++ b/QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs @@ -1,10 +1,122 @@ +using System.Diagnostics; using QuickShell.Models; using QuickShell.Services; namespace QuickShell.Core.Tests; +/// +/// Serializes tests that mutate the shared static +/// hook so they can't race with each other under xUnit's default cross-class parallelization. +/// +[CollectionDefinition("TerminalLauncher StartProcessOverride", DisableParallelization = true)] +public sealed class TerminalLauncherOverrideCollection +{ +} + +[Collection("TerminalLauncher StartProcessOverride")] public sealed class ShortcutLaunchExecutorTests { + [Fact] + public void LaunchAll_ThreeWindowsTerminalEntries_OpenAsSingleProcessWithTabs() + { + var directory = Environment.CurrentDirectory; + var shortcut = new TerminalShortcut + { + Id = Guid.NewGuid().ToString("N"), + Name = "Full stack", + Directory = directory, + Launches = + [ + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "API", Terminal = "wt", WtProfile = "Profile1", Command = "dotnet run", IsEnabled = true, Order = 0 }, + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "Web", Terminal = "wt", WtProfile = "Profile2", Command = "npm run dev", IsEnabled = true, Order = 1 }, + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "Worker", Terminal = "wt", WtProfile = "Profile3", Command = "npm run worker", IsEnabled = true, Order = 2 }, + ], + }; + + var captured = new List(); + TerminalLauncher.StartProcessOverride = info => { captured.Add(info); return true; }; + try + { + ShortcutLaunchExecutor.Launch(shortcut, "wt", "default"); + + Assert.Single(captured); + Assert.Equal("wt.exe", captured[0].FileName); + Assert.Equal(2, CountOccurrences(captured[0].Arguments ?? string.Empty, "; new-tab")); + } + finally + { + TerminalLauncher.StartProcessOverride = null; + } + } + + [Fact] + public void LaunchAll_MixedElevation_OpensTwoProcesses() + { + var directory = Environment.CurrentDirectory; + var shortcut = new TerminalShortcut + { + Id = Guid.NewGuid().ToString("N"), + Name = "Mixed elevation", + Directory = directory, + Launches = + [ + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "Admin", Terminal = "wt", WtProfile = "Profile1", Command = "cmd", RunAsAdmin = true, IsEnabled = true, Order = 0 }, + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "Normal", Terminal = "wt", WtProfile = "Profile2", Command = "cmd", RunAsAdmin = false, IsEnabled = true, Order = 1 }, + ], + }; + + var captured = new List(); + TerminalLauncher.StartProcessOverride = info => { captured.Add(info); return true; }; + try + { + ShortcutLaunchExecutor.Launch(shortcut, "wt", "default"); + + Assert.Equal(2, captured.Count); + Assert.Contains(captured, c => c.Verb == "runas"); + Assert.Contains(captured, c => string.IsNullOrEmpty(c.Verb)); + } + finally + { + TerminalLauncher.StartProcessOverride = null; + } + } + + [Fact] + public void LaunchAll_NonWindowsTerminalFallbackMixedWithWt_OpensTwoProcesses() + { + var directory = Environment.CurrentDirectory; + var shortcut = new TerminalShortcut + { + Id = Guid.NewGuid().ToString("N"), + Name = "Mixed hosts", + Directory = directory, + Launches = + [ + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "API", Terminal = "wt", WtProfile = "Profile1", Command = "cmd", IsEnabled = true, Order = 0 }, + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "Web", Terminal = "wt", WtProfile = "Profile2", Command = "cmd", IsEnabled = true, Order = 1 }, + new WorkspaceEntry { Id = Guid.NewGuid().ToString("N"), Label = "Legacy", Terminal = "cmd", Command = "cmd", IsEnabled = true, Order = 2 }, + ], + }; + + var captured = new List(); + TerminalLauncher.StartProcessOverride = info => { captured.Add(info); return true; }; + try + { + ShortcutLaunchExecutor.Launch(shortcut, "wt", "default"); + + Assert.Equal(2, captured.Count); + Assert.Contains(captured, c => c.FileName == "wt.exe" && (c.Arguments ?? string.Empty).Contains("; new-tab", StringComparison.Ordinal)); + Assert.Contains(captured, c => c.FileName == "cmd.exe"); + } + finally + { + TerminalLauncher.StartProcessOverride = null; + } + } + + private static int CountOccurrences(string haystack, string needle) => + needle.Length == 0 ? 0 : (haystack.Length - haystack.Replace(needle, string.Empty).Length) / needle.Length; + [Fact] public void Launch_ReturnsErrorWhenDirectoryMissing() { @@ -60,6 +172,7 @@ public void Launch_ReturnsErrorWhenNoEnabledLaunches() } } +[Collection("TerminalLauncher StartProcessOverride")] public sealed class WorkspaceDevServerActionsTests { [Fact] diff --git a/QuickShell.Core/Services/ShortcutLaunchExecutor.cs b/QuickShell.Core/Services/ShortcutLaunchExecutor.cs index 7df8d52..76c46a1 100644 --- a/QuickShell.Core/Services/ShortcutLaunchExecutor.cs +++ b/QuickShell.Core/Services/ShortcutLaunchExecutor.cs @@ -149,6 +149,11 @@ private static ShortcutLaunchResult LaunchSingle( } } + private readonly record struct EntryPlan( + WorkspaceEntry Entry, + ResolvedLaunch Resolved, + bool EffectiveElevation); + private static ShortcutLaunchResult LaunchAll( TerminalShortcut shortcut, IReadOnlyList enabledLaunches, @@ -159,7 +164,7 @@ private static ShortcutLaunchResult LaunchAll( bool companionSucceeded, string? companionError) { - var opened = 0; + var plans = new List(); string? lastFailureLabel = null; foreach (var launch in enabledLaunches) @@ -167,13 +172,9 @@ private static ShortcutLaunchResult LaunchAll( try { var launchShortcut = ShortcutLaunchNormalization.ToLaunchShortcut(launch, shortcut); - TerminalLauncher.Open( - launchShortcut, - terminalApplicationId, - defaultProfileId, - options.RunAsAdmin, - options.RunAsStandard); - opened++; + var resolved = TerminalLauncher.Resolve(launchShortcut, terminalApplicationId, defaultProfileId); + var effectiveElevation = !options.RunAsStandard && (options.RunAsAdmin || launch.RunAsAdmin); + plans.Add(new EntryPlan(launch, resolved, effectiveElevation)); } catch (DirectoryNotFoundException) { @@ -183,23 +184,49 @@ private static ShortcutLaunchResult LaunchAll( { lastFailureLabel = launch.Label; } + } + + var groups = GroupPlans(plans); + var openedCommands = 0; + + foreach (var group in groups) + { + try + { + if (group.Count == 1) + { + TerminalLauncher.OpenResolved(group[0].Resolved, group[0].EffectiveElevation); + } + else + { + TerminalLauncher.OpenGroup( + group.Select(p => p.Resolved).ToList(), + group[0].EffectiveElevation); + } + + openedCommands += group.Count; + } catch (Win32Exception) { - lastFailureLabel = launch.Label; + lastFailureLabel = group[^1].Entry.Label; + } + catch (InvalidOperationException) + { + lastFailureLabel = group[^1].Entry.Label; } } - if (opened == 0) + if (openedCommands == 0) { return ShortcutLaunchResult.StayOpen( lastFailureLabel is null - ? "Workspace could not launch any terminals." + ? "Workspace could not launch any commands." : $"{lastFailureLabel} could not be launched."); } - var successPrefix = opened == enabledLaunches.Count + var successPrefix = openedCommands == enabledLaunches.Count ? "Workspace launched" - : $"Workspace partially launched: {opened} of {enabledLaunches.Count} terminals opened"; + : $"Workspace partially launched: {openedCommands} of {enabledLaunches.Count} commands launched"; return BuildPostLaunchResult( shortcut, @@ -208,7 +235,38 @@ lastFailureLabel is null companionSucceeded, companionError, successPrefix, - partialLaunch: opened < enabledLaunches.Count); + partialLaunch: openedCommands < enabledLaunches.Count); + } + + private static List> GroupPlans(List plans) + { + var groups = new List>(); + var groupIndexByKey = new Dictionary<(string Host, bool Elevated), int>(); + + foreach (var plan in plans) + { + var isTabCapable = plan.Resolved.Target.Kind is + LaunchTargetKind.WindowsTerminal or LaunchTargetKind.IntelligentTerminal; + + if (!isTabCapable) + { + groups.Add([plan]); + continue; + } + + var key = ((plan.Resolved.Target.HostExecutable ?? string.Empty).ToUpperInvariant(), plan.EffectiveElevation); + if (groupIndexByKey.TryGetValue(key, out var index)) + { + groups[index].Add(plan); + } + else + { + groupIndexByKey[key] = groups.Count; + groups.Add([plan]); + } + } + + return groups; } private static ShortcutLaunchResult BuildPostLaunchResult( diff --git a/QuickShell.Core/Services/TerminalLauncher.cs b/QuickShell.Core/Services/TerminalLauncher.cs index e39b55b..4768440 100644 --- a/QuickShell.Core/Services/TerminalLauncher.cs +++ b/QuickShell.Core/Services/TerminalLauncher.cs @@ -3,16 +3,16 @@ namespace QuickShell.Services; +internal readonly record struct ResolvedLaunch(TerminalShortcut Shortcut, LaunchTarget Target); + internal static class TerminalLauncher { internal static Func? StartProcessOverride { get; set; } - public static void Open( + public static ResolvedLaunch Resolve( TerminalShortcut shortcut, string terminalApplicationId, - string defaultProfileId, - bool runAsAdmin = false, - bool runAsStandard = false) + string defaultProfileId) { if (!ShortcutValidation.TryNormalizeDirectory(shortcut.Directory, out var directory, out var error)) { @@ -44,22 +44,95 @@ public static void Open( }; var target = TerminalCatalog.ResolveForShortcut(launchShortcut, terminalApplicationId, defaultProfileId); - var startInfo = target.Kind switch - { - LaunchTargetKind.WindowsTerminal or LaunchTargetKind.IntelligentTerminal => - CreateWindowsTerminalStartInfo(launchShortcut, target), - LaunchTargetKind.PowerShell => CreatePowerShellStartInfo(launchShortcut, usePwsh: false), - LaunchTargetKind.Pwsh => CreatePowerShellStartInfo(launchShortcut, usePwsh: true), - LaunchTargetKind.Cmd => CreateCmdStartInfo(launchShortcut, target), - LaunchTargetKind.Wsl => CreateWslStartInfo(launchShortcut, target), - _ => CreateWindowsTerminalStartInfo(launchShortcut, target), - }; + return new ResolvedLaunch(launchShortcut, target); + } - if (!runAsStandard && (runAsAdmin || shortcut.RunAsAdmin)) + public static void Open( + TerminalShortcut shortcut, + string terminalApplicationId, + string defaultProfileId, + bool runAsAdmin = false, + bool runAsStandard = false) + { + var resolved = Resolve(shortcut, terminalApplicationId, defaultProfileId); + var effectiveElevation = !runAsStandard && (runAsAdmin || shortcut.RunAsAdmin); + OpenResolved(resolved, effectiveElevation); + } + + public static void OpenResolved(ResolvedLaunch resolved, bool effectiveElevation) + { + var startInfo = BuildStartInfo(resolved.Shortcut, resolved.Target); + + if (effectiveElevation) { startInfo.Verb = "runas"; } + StartProcess(startInfo); + } + + /// + /// Launches multiple Windows Terminal-hosted entries as tabs of a single window/process. + /// Every entry must resolve to the same ; elevation + /// applies to the whole window, so callers must group entries by matching elevation first. + /// + public static void OpenGroup(IReadOnlyList group, bool effectiveElevation) + { + if (group is not { Count: > 0 }) + { + throw new ArgumentException("Group must contain at least one resolved launch.", nameof(group)); + } + + var hostExecutable = group[0].Target.HostExecutable; + if (string.IsNullOrWhiteSpace(hostExecutable)) + { + throw new ArgumentException("Resolved launch target has no host executable.", nameof(group)); + } + + var allArguments = new List(); + + for (var i = 0; i < group.Count; i++) + { + var target = group[i].Target; + if (target.Kind is not (LaunchTargetKind.WindowsTerminal or LaunchTargetKind.IntelligentTerminal) + || !string.Equals(target.HostExecutable, hostExecutable, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + "All entries in a group must be Windows Terminal-hosted and share the same host executable.", + nameof(group)); + } + + if (i > 0) + { + allArguments.Add(";"); + allArguments.Add("new-tab"); + } + + allArguments.AddRange(BuildWindowsTerminalArguments(group[i].Shortcut, target)); + } + + var startInfo = CreateWtStartInfo(allArguments, hostExecutable); + if (effectiveElevation) + { + startInfo.Verb = "runas"; + } + + StartProcess(startInfo); + } + + private static ProcessStartInfo BuildStartInfo(TerminalShortcut shortcut, LaunchTarget target) => target.Kind switch + { + LaunchTargetKind.WindowsTerminal or LaunchTargetKind.IntelligentTerminal => + CreateWtStartInfo(BuildWindowsTerminalArguments(shortcut, target), target.HostExecutable), + LaunchTargetKind.PowerShell => CreatePowerShellStartInfo(shortcut, usePwsh: false), + LaunchTargetKind.Pwsh => CreatePowerShellStartInfo(shortcut, usePwsh: true), + LaunchTargetKind.Cmd => CreateCmdStartInfo(shortcut, target), + LaunchTargetKind.Wsl => CreateWslStartInfo(shortcut, target), + _ => CreateWtStartInfo(BuildWindowsTerminalArguments(shortcut, target), target.HostExecutable), + }; + + private static void StartProcess(ProcessStartInfo startInfo) + { if (StartProcessOverride is { } startOverride) { if (!startOverride(startInfo)) @@ -73,11 +146,11 @@ public static void Open( } } - private static ProcessStartInfo CreateWindowsTerminalStartInfo(TerminalShortcut shortcut, LaunchTarget target) + private static List BuildWindowsTerminalArguments(TerminalShortcut shortcut, LaunchTarget target) { if (WslPathResolver.TryParse(shortcut.Directory, out var wslLocation)) { - return CreateWindowsTerminalForWslDirectory(shortcut, target, wslLocation); + return BuildWindowsTerminalArgumentsForWslDirectory(shortcut, target, wslLocation); } var arguments = new List(); @@ -99,10 +172,10 @@ private static ProcessStartInfo CreateWindowsTerminalStartInfo(TerminalShortcut arguments.Add(BuildWindowsTerminalCommandSuffix(shortcut, target, omitDirectoryChange)); } - return CreateWtStartInfo(arguments, target.HostExecutable); + return arguments; } - private static ProcessStartInfo CreateWindowsTerminalForWslDirectory( + private static List BuildWindowsTerminalArgumentsForWslDirectory( TerminalShortcut shortcut, LaunchTarget target, WslPathResolver.WslLocation wslLocation) @@ -117,18 +190,18 @@ private static ProcessStartInfo CreateWindowsTerminalForWslDirectory( if (IsWslProfile(target)) { arguments.Add(TerminalLauncherArgs.ToWslExecutableCommand(shortcut, target, wslLocation)); - return CreateWtStartInfo(arguments, target.HostExecutable); + return arguments; } if (IsPowerShellProfile(target)) { var directory = wslLocation.UncPath ?? shortcut.Directory; arguments.Add(TerminalLauncherArgs.ToPowerShellExecutableCommand(shortcut, GetPowerShellPathForProfile(target), directory)); - return CreateWtStartInfo(arguments, target.HostExecutable); + return arguments; } arguments.Add(ToWslExecutableCommand(shortcut, target, wslLocation)); - return CreateWtStartInfo(arguments, target.HostExecutable); + return arguments; } private static string BuildWindowsTerminalCommandSuffix(