Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions QuickShell.Core.Tests/ShortcutLaunchExecutorTests.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,122 @@
using System.Diagnostics;
using QuickShell.Models;
using QuickShell.Services;

namespace QuickShell.Core.Tests;

/// <summary>
/// Serializes tests that mutate the shared static <see cref="TerminalLauncher.StartProcessOverride"/>
/// hook so they can't race with each other under xUnit's default cross-class parallelization.
/// </summary>
[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<ProcessStartInfo>();
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<ProcessStartInfo>();
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<ProcessStartInfo>();
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()
{
Expand Down Expand Up @@ -60,6 +172,7 @@ public void Launch_ReturnsErrorWhenNoEnabledLaunches()
}
}

[Collection("TerminalLauncher StartProcessOverride")]
public sealed class WorkspaceDevServerActionsTests
{
[Fact]
Expand Down
86 changes: 72 additions & 14 deletions QuickShell.Core/Services/ShortcutLaunchExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkspaceEntry> enabledLaunches,
Expand All @@ -159,21 +164,17 @@ private static ShortcutLaunchResult LaunchAll(
bool companionSucceeded,
string? companionError)
{
var opened = 0;
var plans = new List<EntryPlan>();
string? lastFailureLabel = null;

foreach (var launch in enabledLaunches)
{
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)
{
Expand All @@ -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,
Expand All @@ -208,7 +235,38 @@ lastFailureLabel is null
companionSucceeded,
companionError,
successPrefix,
partialLaunch: opened < enabledLaunches.Count);
partialLaunch: openedCommands < enabledLaunches.Count);
}

private static List<List<EntryPlan>> GroupPlans(List<EntryPlan> plans)
{
var groups = new List<List<EntryPlan>>();
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(
Expand Down
Loading