Skip to content
Merged
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
56 changes: 56 additions & 0 deletions MSIXplainer.Core.Tests/TerminalLauncherTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using MSIXplainer.Services;
using Xunit;

namespace MSIXplainer.Core.Tests;

public class TerminalLauncherTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void GetLaunchOptions_BlankPath_ReturnsEmpty(string? path)
{
Assert.Empty(TerminalLauncher.GetLaunchOptions(path));
}

[Fact]
public void GetLaunchOptions_OrdersWindowsTerminalFirstThenPowerShellThenCmd()
{
var options = TerminalLauncher.GetLaunchOptions(@"C:\apps\myapp");

Assert.Equal(3, options.Count);
Assert.Equal("wt.exe", options[0].FileName);
Assert.Equal("powershell.exe", options[1].FileName);
Assert.Equal("cmd.exe", options[2].FileName);
}

[Fact]
public void GetLaunchOptions_WindowsTerminal_EncodesFolderInQuotedDArgument()
{
var wt = TerminalLauncher.GetLaunchOptions(@"C:\apps\my app").First();

Assert.Equal("-d \"C:\\apps\\my app\"", wt.Arguments);
Assert.Null(wt.WorkingDirectory);
}

[Fact]
public void GetLaunchOptions_PowerShellAndCmd_UseWorkingDirectory()
{
var options = TerminalLauncher.GetLaunchOptions(@"C:\apps\myapp");

Assert.Equal(@"C:\apps\myapp", options[1].WorkingDirectory);
Assert.Equal("-NoExit", options[1].Arguments);

Assert.Equal(@"C:\apps\myapp", options[2].WorkingDirectory);
Assert.Null(options[2].Arguments);
}

[Fact]
public void GetLaunchOptions_TrimsTrailingSeparator()
{
var wt = TerminalLauncher.GetLaunchOptions(@"C:\apps\myapp\").First();

Assert.Equal("-d \"C:\\apps\\myapp\"", wt.Arguments);
}
}
14 changes: 14 additions & 0 deletions MSIXplainer.Core/Models/TerminalLaunchOption.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace MSIXplainer.Models;

/// <summary>
/// A single terminal launch candidate produced by
/// <see cref="Services.TerminalLauncher"/>. The UI layer maps this onto a
/// <c>ProcessStartInfo</c> with <c>UseShellExecute = true</c>.
/// </summary>
/// <param name="FileName">Executable to launch (e.g. <c>wt.exe</c>).</param>
/// <param name="Arguments">Command-line arguments, or <c>null</c> for none.</param>
/// <param name="WorkingDirectory">
/// Working directory to start in, or <c>null</c> when the folder is already
/// encoded in <paramref name="Arguments"/> (as it is for Windows Terminal).
/// </param>
public sealed record TerminalLaunchOption(string FileName, string? Arguments, string? WorkingDirectory);
35 changes: 35 additions & 0 deletions MSIXplainer.Core/Services/TerminalLauncher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using MSIXplainer.Models;

namespace MSIXplainer.Services;

/// <summary>
/// Builds an ordered list of terminal launch candidates for a folder. The UI
/// layer tries each candidate in turn until one starts, so a machine without
/// Windows Terminal still falls back to PowerShell and finally cmd.exe (both
/// of which ship with every supported Windows release).
/// </summary>
public static class TerminalLauncher
{
/// <summary>
/// Returns the terminals to try, most-preferred first, for opening
/// <paramref name="folderPath"/>. Returns an empty list when the path is
/// null/blank so callers can skip launching entirely.
/// </summary>
public static IReadOnlyList<TerminalLaunchOption> GetLaunchOptions(string? folderPath)
{
if (string.IsNullOrWhiteSpace(folderPath))
return [];

var path = folderPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

return
[
// Windows Terminal opens directly in the folder via -d.
new TerminalLaunchOption("wt.exe", $"-d \"{path}\"", WorkingDirectory: null),
// PowerShell always ships with Windows; -NoExit keeps the window open.
new TerminalLaunchOption("powershell.exe", "-NoExit", WorkingDirectory: path),
// cmd.exe is the last-resort fallback.
new TerminalLaunchOption("cmd.exe", Arguments: null, WorkingDirectory: path),
];
}
}
10 changes: 10 additions & 0 deletions MSIXplainer/MainPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:InstalledPackage">
<Grid ColumnSpacing="12" Padding="0,4">
<Grid.ContextFlyout>
<MenuFlyout>
<MenuFlyoutItem Text="Open install folder in Terminal"
Click="OnOpenInstallFolderInTerminal">
<MenuFlyoutItem.Icon>
<FontIcon Glyph="&#xE756;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
</MenuFlyout>
</Grid.ContextFlyout>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
Expand Down
32 changes: 32 additions & 0 deletions MSIXplainer/MainPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,38 @@ private void OnInstalledAppClick(object sender, ItemClickEventArgs e)
}
}

// Opens the selected app's install folder in a terminal. Tries Windows
// Terminal first, then falls back to PowerShell / cmd (see TerminalLauncher).
private void OnOpenInstallFolderInTerminal(object sender, RoutedEventArgs e)
{
if (sender is not FrameworkElement { DataContext: InstalledPackage pkg })
return;

foreach (var option in Services.TerminalLauncher.GetLaunchOptions(pkg.InstallLocation))
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = option.FileName,
UseShellExecute = true,
};
if (!string.IsNullOrEmpty(option.Arguments))
psi.Arguments = option.Arguments;
if (!string.IsNullOrEmpty(option.WorkingDirectory))
psi.WorkingDirectory = option.WorkingDirectory;

System.Diagnostics.Process.Start(psi);
return;
}
catch (System.Exception ex)
{
System.Diagnostics.Debug.WriteLine(
$"[MSIXplainer] Terminal launch '{option.FileName}' failed: {ex.Message}");
}
}
}

private void EnterCompareMode()
{
ViewModel.IsCompareMode = true;
Expand Down
Loading