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(