diff --git a/QuickShell.Core.Tests/JetBrainsInstallDiscoveryTests.cs b/QuickShell.Core.Tests/JetBrainsInstallDiscoveryTests.cs
new file mode 100644
index 0000000..41bced1
--- /dev/null
+++ b/QuickShell.Core.Tests/JetBrainsInstallDiscoveryTests.cs
@@ -0,0 +1,45 @@
+using QuickShell.Services;
+
+namespace QuickShell.Core.Tests;
+
+public sealed class JetBrainsInstallDiscoveryTests : IDisposable
+{
+ private readonly string _channelRoot;
+ private readonly string _executable;
+
+ public JetBrainsInstallDiscoveryTests()
+ {
+ var channelName = "ch-test-" + Guid.NewGuid().ToString("N");
+ _channelRoot = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "JetBrains",
+ "Toolbox",
+ "apps",
+ "Rider",
+ channelName);
+ _executable = Path.Combine(_channelRoot, "241.12345.67", "bin", "rider64.exe");
+ Directory.CreateDirectory(Path.GetDirectoryName(_executable)!);
+ File.WriteAllText(_executable, string.Empty);
+ File.SetLastWriteTimeUtc(_executable, DateTime.UtcNow);
+ }
+
+ [Fact]
+ public void TryResolveRider_FindsToolboxBuildDirectoryExecutable()
+ {
+ var resolved = JetBrainsInstallDiscovery.TryResolveRider();
+
+ Assert.NotNull(resolved);
+ Assert.Equal(_executable, resolved, ignoreCase: true);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(_channelRoot, recursive: true);
+ }
+ catch
+ {
+ }
+ }
+}
diff --git a/QuickShell.Core.Tests/ShortcutDisplayTests.cs b/QuickShell.Core.Tests/ShortcutDisplayTests.cs
index 3bfd1e1..5be58e8 100644
--- a/QuickShell.Core.Tests/ShortcutDisplayTests.cs
+++ b/QuickShell.Core.Tests/ShortcutDisplayTests.cs
@@ -93,4 +93,11 @@ public void GetLaunchContextMenuTitle_UsesOpenFolderWhenCommandAndLabelBlank()
Assert.Equal("Open folder", ShortcutDisplay.GetLaunchContextMenuTitle(entry));
}
+
+ [Fact]
+ public void CopyPath_UsesCopyToGlyph_NotIncomingCall()
+ {
+ Assert.Equal("\uF413", ShortcutGlyphs.CopyPath);
+ Assert.NotEqual("\uE77E", ShortcutGlyphs.CopyPath);
+ }
}
diff --git a/QuickShell.Core.Tests/WorkspaceUtilityTests.cs b/QuickShell.Core.Tests/WorkspaceUtilityTests.cs
index c179d67..6c7e95c 100644
--- a/QuickShell.Core.Tests/WorkspaceUtilityTests.cs
+++ b/QuickShell.Core.Tests/WorkspaceUtilityTests.cs
@@ -181,6 +181,103 @@ public void TryDetectDevServerUrl_ReturnsNullWhenNoPackageJson()
Assert.Null(DevServerUrlDetection.TryDetectDevServerUrl(_root));
}
+ [Fact]
+ public void TryDetectDevLaunchCommand_ReturnsPackageManagerCommand()
+ {
+ WritePackageJson("""
+ {
+ "scripts": {
+ "dev": "vite"
+ }
+ }
+ """);
+ File.WriteAllText(Path.Combine(_root, "pnpm-lock.yaml"), string.Empty);
+
+ Assert.Equal("pnpm dev", DevServerUrlDetection.TryDetectDevLaunchCommand(_root));
+ Assert.Equal("pnpm dev", DevServerUrlDetection.FormatPackageScriptCommand(_root, "dev"));
+ }
+
+ [Fact]
+ public void FormatPackageScriptCommand_UsesYarnWhenYarnLockExists()
+ {
+ File.WriteAllText(Path.Combine(_root, "yarn.lock"), string.Empty);
+
+ Assert.Equal("yarn dev", DevServerUrlDetection.FormatPackageScriptCommand(_root, "dev"));
+ }
+
+ [Fact]
+ public void TryDetectDevLaunchCommand_FallsBackToStartScript()
+ {
+ WritePackageJson("""
+ {
+ "scripts": {
+ "start": "react-scripts start"
+ },
+ "dependencies": {
+ "react-scripts": "5.0.1"
+ }
+ }
+ """);
+
+ Assert.Equal("npm start", DevServerUrlDetection.TryDetectDevLaunchCommand(_root));
+ Assert.Equal("http://localhost:3000", DevServerUrlDetection.TryDetectDevServerUrl(_root));
+ }
+
+ [Fact]
+ public void ApplyDirectoryHints_SyncsDetectedDevCommandToLaunchEntry()
+ {
+ WritePackageJson("""
+ {
+ "scripts": {
+ "dev": "vite"
+ }
+ }
+ """);
+
+ var seed = WorkspaceSeedFactory.ApplyDirectoryHints(new TerminalShortcut
+ {
+ Name = "sample",
+ Directory = _root,
+ Launches = [],
+ });
+
+ Assert.Equal("npm run dev", seed.Command);
+ Assert.Single(seed.Launches);
+ Assert.Equal("npm run dev", seed.Launches[0].Command);
+ }
+
+ [Fact]
+ public void ApplyDirectoryHints_UpdatesExistingBlankLaunchEntry()
+ {
+ WritePackageJson("""
+ {
+ "scripts": {
+ "dev": "vite"
+ }
+ }
+ """);
+
+ var seed = WorkspaceSeedFactory.ApplyDirectoryHints(new TerminalShortcut
+ {
+ Name = "sample",
+ Directory = _root,
+ Launches =
+ [
+ new WorkspaceEntry
+ {
+ Id = "launch-1",
+ Label = "Main",
+ Terminal = "default",
+ IsEnabled = true,
+ Order = 0,
+ },
+ ],
+ });
+
+ Assert.Equal("npm run dev", seed.Command);
+ Assert.Equal("npm run dev", seed.Launches[0].Command);
+ }
+
private void WritePackageJson(string contents) =>
File.WriteAllText(Path.Combine(_root, "package.json"), contents);
@@ -268,6 +365,36 @@ public void TrySuggestFromDirectory_PrefersVsCodeWhenDotVscodeExists()
Assert.True(suggestion.EnableOnLaunch);
}
+ [Fact]
+ public void TrySuggestFromDirectory_FallsThroughWhenHigherPriorityCompanionMissing()
+ {
+ Directory.CreateDirectory(Path.Combine(_root, ".cursor"));
+ Directory.CreateDirectory(Path.Combine(_root, ".vscode"));
+
+ var cursorInstalled = CompanionAppCatalog.TryResolveExecutable(CompanionAppCatalog.PresetCursor) is not null;
+ var vsCodeInstalled = CompanionAppCatalog.TryResolveExecutable(CompanionAppCatalog.PresetVsCode) is not null;
+ var suggestion = CompanionAppDetection.TrySuggestFromDirectory(_root);
+
+ if (!cursorInstalled && vsCodeInstalled)
+ {
+ Assert.NotNull(suggestion);
+ Assert.Equal(CompanionAppCatalog.PresetVsCode, suggestion!.PresetId);
+ return;
+ }
+
+ if (cursorInstalled)
+ {
+ Assert.NotNull(suggestion);
+ Assert.Equal(CompanionAppCatalog.PresetCursor, suggestion!.PresetId);
+ return;
+ }
+
+ if (!vsCodeInstalled)
+ {
+ Assert.Null(suggestion);
+ }
+ }
+
[Fact]
public void ExpandArguments_ReplacesFolderTokenAndDot()
{
@@ -280,6 +407,22 @@ public void ExpandArguments_ReplacesFolderTokenAndDot()
Assert.Equal("C:\\Projects\\sample", CompanionAppLauncher.ExpandArguments(".", @"C:\Projects\sample"));
}
+ [Fact]
+ public void ExpandArguments_ReplacesSolutionToken()
+ {
+ var directory = Path.Combine(_root, "sample app");
+ Directory.CreateDirectory(directory);
+ var solutionPath = Path.Combine(directory, "Sample App.sln");
+ File.WriteAllText(solutionPath, string.Empty);
+
+ Assert.Equal(
+ $"\"{solutionPath}\"",
+ CompanionAppLauncher.ExpandArguments("{solution}", directory));
+ Assert.Equal(
+ Path.Combine(_root, "no-solution"),
+ CompanionAppLauncher.ExpandArguments("{solution}", Path.Combine(_root, "no-solution")));
+ }
+
[Fact]
public void TryValidateCompanionApp_RequiresPathWhenLaunchEnabled()
{
diff --git a/QuickShell.Core/Models/TerminalShortcut.cs b/QuickShell.Core/Models/TerminalShortcut.cs
index 6df35d5..020bf6d 100644
--- a/QuickShell.Core/Models/TerminalShortcut.cs
+++ b/QuickShell.Core/Models/TerminalShortcut.cs
@@ -62,8 +62,7 @@ internal sealed class TerminalShortcut
- /// Optional dev server URL opened from the workspace action menu.
-
+ /// Optional dev server URL opened in the browser when the workspace runs.
public string? DevServerUrl { get; set; }
diff --git a/QuickShell.Core/Services/CompanionAppCatalog.cs b/QuickShell.Core/Services/CompanionAppCatalog.cs
index c9f2bf6..7ea61fe 100644
--- a/QuickShell.Core/Services/CompanionAppCatalog.cs
+++ b/QuickShell.Core/Services/CompanionAppCatalog.cs
@@ -6,17 +6,70 @@ internal static class CompanionAppCatalog
{
public const string PresetNone = "none";
public const string PresetCustom = "custom";
+ public const string PresetExplorer = "explorer";
+ public const string PresetVs2022 = "vs2022";
+ public const string PresetVs2026 = "vs2026";
+ public const string PresetGitHubDesktop = "github-desktop";
+ public const string PresetFork = "fork";
+ public const string PresetAzureDataStudio = "azure-data-studio";
+ public const string PresetObsidian = "obsidian";
+ public const string PresetSublime = "sublime";
+ public const string PresetNeovide = "neovide";
+ public const string PresetGvim = "gvim";
+ public const string PresetRider = "rider";
+ public const string PresetIntelliJIdea = "intellij-idea";
+ public const string PresetZed = "zed";
+ public const string PresetNotepadPlusPlus = "notepad-plus-plus";
public const string PresetVsCode = "vscode";
public const string PresetCursor = "cursor";
- public const string PresetNotepad = "notepad";
private static readonly IReadOnlyList<(string Id, string Title, string DefaultArguments, IReadOnlyList CandidatePaths)> Definitions =
[
+ (PresetExplorer, "Windows Explorer", "{folder}", BuildExplorerCandidates()),
+ (PresetVs2022, "Visual Studio 2022", "{solution}", []),
+ (PresetVs2026, "Visual Studio 2026", "{solution}", []),
+ (PresetGitHubDesktop, "GitHub Desktop", "{folder}", BuildGitHubDesktopCandidates()),
+ (PresetFork, "Fork", "{folder}", BuildForkCandidates()),
+ (PresetAzureDataStudio, "Azure Data Studio", "{folder}", BuildAzureDataStudioCandidates()),
+ (PresetObsidian, "Obsidian", "{folder}", BuildObsidianCandidates()),
+ (PresetSublime, "Sublime Text", ".", BuildSublimeCandidates()),
+ (PresetNeovide, "Neovide", ".", BuildNeovideCandidates()),
+ (PresetGvim, "GVim", ".", BuildGvimCandidates()),
+ (PresetRider, "JetBrains Rider", "{folder}", []),
+ (PresetIntelliJIdea, "IntelliJ IDEA", "{folder}", []),
+ (PresetZed, "Zed", ".", BuildZedCandidates()),
+ (PresetNotepadPlusPlus, "Notepad++", string.Empty, BuildNotepadPlusPlusCandidates()),
(PresetVsCode, "Visual Studio Code", ".", BuildVsCodeCandidates()),
(PresetCursor, "Cursor", ".", BuildCursorCandidates()),
- (PresetNotepad, "Notepad", string.Empty, ["notepad.exe"]),
];
+ public static bool IsCatalogPreset(string presetId) =>
+ !string.Equals(presetId, PresetNone, StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(presetId, PresetCustom, StringComparison.OrdinalIgnoreCase);
+
+ public static bool IsPresetInstalled(string presetId)
+ {
+ if (!IsCatalogPreset(presetId))
+ {
+ return true;
+ }
+
+ return TryResolveExecutable(presetId) is not null;
+ }
+
+ ///
+ /// Maps a stored preset to a value the form dropdown can represent when the app is no longer installed.
+ ///
+ public static string NormalizePresetForForm(string presetId, string? executablePath)
+ {
+ if (!IsCatalogPreset(presetId) || IsPresetInstalled(presetId))
+ {
+ return presetId;
+ }
+
+ return string.IsNullOrWhiteSpace(executablePath) ? PresetNone : PresetCustom;
+ }
+
public static string BuildFormChoicesJson()
{
var choices = new List