From de26342f0a8c5444c7c930136adf774f6d57e57f Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Thu, 2 Jul 2026 22:02:51 -0700 Subject: [PATCH] feat: companion app catalog and workspace signal detection Expand companion app presets, detect workspace signals for IDE and git client suggestions, auto-detect dev server URLs and launch commands, and integrate JetBrains Toolbox and Visual Studio install discovery. Co-authored-by: Cursor --- .../JetBrainsInstallDiscoveryTests.cs | 45 ++ QuickShell.Core.Tests/ShortcutDisplayTests.cs | 7 + .../WorkspaceUtilityTests.cs | 143 ++++++ QuickShell.Core/Models/TerminalShortcut.cs | 3 +- .../Services/CompanionAppCatalog.cs | 406 +++++++++++++++++- .../Services/CompanionAppDetection.cs | 58 ++- .../Services/CompanionAppLauncher.cs | 10 +- .../Services/DevServerUrlDetection.cs | 81 ++++ .../Services/JetBrainsInstallDiscovery.cs | 135 ++++++ QuickShell.Core/Services/ShortcutHealth.cs | 9 +- .../Services/VisualStudioInstallDiscovery.cs | 126 ++++++ .../Services/WorkspaceCompanionSignals.cs | 83 ++++ .../Services/WorkspaceSeedFactory.cs | 30 ++ 13 files changed, 1113 insertions(+), 23 deletions(-) create mode 100644 QuickShell.Core.Tests/JetBrainsInstallDiscoveryTests.cs create mode 100644 QuickShell.Core/Services/JetBrainsInstallDiscovery.cs create mode 100644 QuickShell.Core/Services/VisualStudioInstallDiscovery.cs create mode 100644 QuickShell.Core/Services/WorkspaceCompanionSignals.cs 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 @@ -41,7 +94,33 @@ public static string InferPresetFromPath(string? executablePath) return PresetNone; } - var fileName = Path.GetFileName(executablePath).Trim(); + var path = TryResolveExecutablePath(executablePath, out var resolved) + ? resolved + : executablePath.Trim(); + + var visualStudioPreset = VisualStudioInstallDiscovery.TryInferPresetFromDevenvPath(path); + if (visualStudioPreset is not null) + { + return visualStudioPreset; + } + + var fileName = Path.GetFileName(path); + + foreach (var (presetId, fileNames) in ExecutableNamePresets) + { + if (!fileNames.Any(name => string.Equals(name, fileName, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + if (string.Equals(presetId, PresetExplorer, StringComparison.OrdinalIgnoreCase) + && !IsWindowsExplorerExecutable(path)) + { + continue; + } + + return presetId; + } foreach (var definition in Definitions) { @@ -67,13 +146,25 @@ public static string GetDisplayName(string? executablePath) : Path.GetFileNameWithoutExtension(executablePath); } - return Definitions.First(definition => definition.Id == preset).Title; + return FindDefinition(preset).Title; } public static string GetContextMenuIcon(string? executablePath) { - _ = InferPresetFromPath(executablePath); - return ShortcutGlyphs.OpenCompanionApp; + var preset = InferPresetFromPath(executablePath); + return preset switch + { + PresetExplorer => "\uE838", + PresetVsCode or PresetCursor => "\uE90F", + PresetVs2022 or PresetVs2026 => "\uEB4D", + PresetGitHubDesktop or PresetFork => "\uE8C8", + PresetObsidian or PresetSublime or PresetNotepadPlusPlus => "\uE8A5", + PresetRider or PresetIntelliJIdea => "\uE90F", + PresetZed or PresetNeovide or PresetGvim => "\uE90F", + PresetAzureDataStudio => "\uE943", + PresetNone or PresetCustom => ShortcutGlyphs.OpenCompanionApp, + _ => ShortcutGlyphs.OpenCompanionApp, + }; } public static bool TryApplyPreset(string presetId, out string? executablePath, out string arguments) @@ -91,32 +182,161 @@ public static bool TryApplyPreset(string presetId, out string? executablePath, o return false; } - var definition = Definitions.FirstOrDefault(item => - string.Equals(item.Id, presetId, StringComparison.OrdinalIgnoreCase)); + var definition = FindDefinition(presetId); if (definition.Id is null) { return false; } - executablePath = TryResolveExecutable(definition.CandidatePaths); + executablePath = TryResolveExecutable(presetId); arguments = definition.DefaultArguments; return executablePath is not null; } public static string? TryResolveExecutable(string presetId) { - var definition = Definitions.FirstOrDefault(item => - string.Equals(item.Id, presetId, StringComparison.OrdinalIgnoreCase)); + if (string.Equals(presetId, PresetVs2022, StringComparison.OrdinalIgnoreCase)) + { + return VisualStudioInstallDiscovery.TryResolveDevenv(17, 18); + } + + if (string.Equals(presetId, PresetVs2026, StringComparison.OrdinalIgnoreCase)) + { + return VisualStudioInstallDiscovery.TryResolveDevenv(18, 19); + } + + if (string.Equals(presetId, PresetRider, StringComparison.OrdinalIgnoreCase)) + { + return JetBrainsInstallDiscovery.TryResolveRider(); + } + + if (string.Equals(presetId, PresetIntelliJIdea, StringComparison.OrdinalIgnoreCase)) + { + return JetBrainsInstallDiscovery.TryResolveIntelliJIdea(); + } + + var definition = FindDefinition(presetId); return definition.Id is null ? null : TryResolveExecutable(definition.CandidatePaths); } public static string GetDefaultArguments(string presetId) { - var definition = Definitions.FirstOrDefault(item => - string.Equals(item.Id, presetId, StringComparison.OrdinalIgnoreCase)); + var definition = FindDefinition(presetId); return definition.Id is null ? string.Empty : definition.DefaultArguments; } + public readonly record struct CompanionAppFormState( + string Preset, + string Path, + string Arguments, + bool LaunchOnWorkspaceOpen); + + /// + /// Re-resolves companion fields when opening the workspace form (handles uninstall / moved installs). + /// + public static CompanionAppFormState ReconcileStoredShortcut( + bool openOnLaunch, + string? executablePath, + string? arguments) + { + if (!openOnLaunch) + { + return new CompanionAppFormState(PresetNone, string.Empty, string.Empty, false); + } + + return ReconcileForForm(InferPresetFromPath(executablePath), executablePath, arguments); + } + + public static CompanionAppFormState ReconcileForForm( + string? presetId, + string? executablePath, + string? arguments) + { + var path = executablePath?.Trim() ?? string.Empty; + var args = arguments?.Trim() ?? string.Empty; + var preset = string.IsNullOrWhiteSpace(path) + ? PresetNone + : NormalizePresetForForm(presetId ?? InferPresetFromPath(path), path); + + if (string.Equals(preset, PresetNone, StringComparison.OrdinalIgnoreCase)) + { + return new CompanionAppFormState(PresetNone, string.Empty, string.Empty, false); + } + + if (IsCatalogPreset(preset) && TryApplyPreset(preset, out var catalogPath, out var catalogArgs)) + { + return new CompanionAppFormState(preset, catalogPath!, catalogArgs, true); + } + + if (IsCatalogPreset(preset)) + { + preset = string.IsNullOrWhiteSpace(path) ? PresetNone : PresetCustom; + if (string.Equals(preset, PresetNone, StringComparison.OrdinalIgnoreCase)) + { + return new CompanionAppFormState(PresetNone, string.Empty, string.Empty, false); + } + } + + if (string.Equals(preset, PresetCustom, StringComparison.OrdinalIgnoreCase)) + { + if (TryResolveExecutablePath(path, out var resolvedPath)) + { + var resolvedArgs = string.IsNullOrWhiteSpace(args) + ? GetDefaultArguments(InferPresetFromPath(resolvedPath)) + : args; + return new CompanionAppFormState(PresetCustom, resolvedPath, resolvedArgs, true); + } + + return new CompanionAppFormState(PresetCustom, path, args, false); + } + + return new CompanionAppFormState(PresetNone, string.Empty, string.Empty, false); + } + + public static CompanionAppFormState CreateStateFromPreset(string presetId) + { + if (string.Equals(presetId, PresetNone, StringComparison.OrdinalIgnoreCase)) + { + return new CompanionAppFormState(PresetNone, string.Empty, string.Empty, false); + } + + if (string.Equals(presetId, PresetCustom, StringComparison.OrdinalIgnoreCase)) + { + return new CompanionAppFormState(PresetCustom, string.Empty, string.Empty, false); + } + + if (TryApplyPreset(presetId, out var path, out var args)) + { + return new CompanionAppFormState(presetId, path!, args, true); + } + + return new CompanionAppFormState(PresetNone, string.Empty, string.Empty, false); + } + + public static CompanionAppFormState ReconcileForSave( + string? presetId, + string? executablePath, + string? arguments) => + ReconcileForForm(presetId, executablePath, arguments) switch + { + { LaunchOnWorkspaceOpen: true } state => state, + _ => new CompanionAppFormState(PresetNone, string.Empty, string.Empty, false), + }; + + public static bool ShouldShowExecutablePath(string preset, string? path) => + string.Equals(preset, PresetCustom, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(path); + + public static bool ShouldShowPathWarning(string preset, string? path) => + string.Equals(preset, PresetCustom, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(path) + && !TryResolveExecutablePath(path, out _); + + public static string BuildPathWarning(string preset, string? path) => + ShouldShowPathWarning(preset, path) + ? "Executable not found. Choose another app or set App preset to None." + : string.Empty; + public static bool TryResolveExecutablePath(string? executablePath, out string resolvedPath) { resolvedPath = string.Empty; @@ -147,6 +367,48 @@ public static bool TryResolveExecutablePath(string? executablePath, out string r return false; } + private static (string Id, string Title, string DefaultArguments, IReadOnlyList CandidatePaths) FindDefinition(string presetId) => + Definitions.FirstOrDefault(item => + string.Equals(item.Id, presetId, StringComparison.OrdinalIgnoreCase)); + + private static readonly (string PresetId, string[] FileNames)[] ExecutableNamePresets = + [ + (PresetExplorer, ["explorer.exe"]), + (PresetGitHubDesktop, ["GitHubDesktop.exe"]), + (PresetFork, ["Fork.exe"]), + (PresetAzureDataStudio, ["azuredatastudio.exe"]), + (PresetObsidian, ["Obsidian.exe"]), + (PresetSublime, ["sublime_text.exe", "subl.exe"]), + (PresetNeovide, ["neovide.exe"]), + (PresetGvim, ["gvim.exe"]), + (PresetRider, ["rider64.exe"]), + (PresetIntelliJIdea, ["idea64.exe"]), + (PresetZed, ["zed.exe", "Zed.exe"]), + (PresetNotepadPlusPlus, ["notepad++.exe"]), + (PresetVsCode, ["Code.exe"]), + (PresetCursor, ["Cursor.exe"]), + ]; + + private static bool IsWindowsExplorerExecutable(string path) + { + if (TryResolveExecutablePath(path, out var resolved)) + { + path = resolved; + } + + try + { + var windowsDirectory = Path.GetFullPath(Environment.GetFolderPath(Environment.SpecialFolder.Windows)); + var directory = Path.GetDirectoryName(Path.GetFullPath(path)); + return directory is not null + && string.Equals(directory, windowsDirectory, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + private static string? TryResolveExecutable(IReadOnlyList candidatePaths) { foreach (var candidate in candidatePaths) @@ -189,6 +451,124 @@ private static bool TryFindOnPath(string fileName, out string resolvedPath) return false; } + private static IReadOnlyList BuildExplorerCandidates() => + [ + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe"), + ]; + + private static IReadOnlyList BuildGitHubDesktopCandidates() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + return + [ + Path.Combine(localAppData, "GitHubDesktop", "GitHubDesktop.exe"), + Path.Combine(localAppData, "GitHub Desktop", "GitHubDesktop.exe"), + ]; + } + + private static IReadOnlyList BuildForkCandidates() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + + return + [ + Path.Combine(localAppData, "Fork", "Fork.exe"), + Path.Combine(programFiles, "Fork", "Fork.exe"), + ]; + } + + private static IReadOnlyList BuildAzureDataStudioCandidates() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + + return + [ + Path.Combine(localAppData, "Programs", "Azure Data Studio", "azuredatastudio.exe"), + Path.Combine(localAppData, "Programs", "Azure Data Studio", "bin", "azuredatastudio.exe"), + Path.Combine(programFiles, "Azure Data Studio", "bin", "azuredatastudio.exe"), + ]; + } + + private static IReadOnlyList BuildObsidianCandidates() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + + return + [ + Path.Combine(localAppData, "Obsidian", "Obsidian.exe"), + Path.Combine(programFiles, "Obsidian", "Obsidian.exe"), + ]; + } + + private static IReadOnlyList BuildSublimeCandidates() + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + + return + [ + Path.Combine(programFiles, "Sublime Text", "sublime_text.exe"), + Path.Combine(programFilesX86, "Sublime Text", "sublime_text.exe"), + Path.Combine(programFiles, "Sublime Text 3", "sublime_text.exe"), + "subl.exe", + "sublime_text.exe", + ]; + } + + private static IReadOnlyList BuildNeovideCandidates() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + return + [ + Path.Combine(localAppData, "Programs", "neovide", "neovide.exe"), + Path.Combine(localAppData, "Programs", "Neovide", "neovide.exe"), + "neovide.exe", + ]; + } + + private static IReadOnlyList BuildGvimCandidates() + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + + return + [ + "gvim.exe", + Path.Combine(programFiles, "Vim", "vim91", "gvim.exe"), + Path.Combine(programFiles, "Vim", "vim92", "gvim.exe"), + Path.Combine(programFiles, "Vim", "vim90", "gvim.exe"), + ]; + } + + private static IReadOnlyList BuildZedCandidates() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + return + [ + Path.Combine(localAppData, "Programs", "Zed", "zed.exe"), + Path.Combine(localAppData, "Programs", "Zed", "Zed.exe"), + Path.Combine(localAppData, "Programs", "zed", "zed.exe"), + "zed.exe", + ]; + } + + private static IReadOnlyList BuildNotepadPlusPlusCandidates() + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + + return + [ + Path.Combine(programFiles, "Notepad++", "notepad++.exe"), + Path.Combine(programFilesX86, "Notepad++", "notepad++.exe"), + ]; + } + private static IReadOnlyList BuildVsCodeCandidates() { var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); diff --git a/QuickShell.Core/Services/CompanionAppDetection.cs b/QuickShell.Core/Services/CompanionAppDetection.cs index 23811dc..4b98efd 100644 --- a/QuickShell.Core/Services/CompanionAppDetection.cs +++ b/QuickShell.Core/Services/CompanionAppDetection.cs @@ -13,6 +13,18 @@ internal sealed class CompanionAppSuggestion internal static class CompanionAppDetection { + private static readonly string[] GitClientPresetPriority = + [ + CompanionAppCatalog.PresetFork, + CompanionAppCatalog.PresetGitHubDesktop, + ]; + + private static readonly string[] VisualStudioPresetPriority = + [ + CompanionAppCatalog.PresetVs2026, + CompanionAppCatalog.PresetVs2022, + ]; + public static CompanionAppSuggestion? TrySuggestFromDirectory(string directory) { if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory)) @@ -20,14 +32,48 @@ internal static class CompanionAppDetection return null; } - if (Directory.Exists(Path.Combine(directory, ".vscode"))) - { - return BuildSuggestion(CompanionAppCatalog.PresetVsCode); - } + return TrySuggestFromPreset( + Directory.Exists(Path.Combine(directory, ".cursor")), + CompanionAppCatalog.PresetCursor) + ?? TrySuggestFromPreset( + Directory.Exists(Path.Combine(directory, ".vscode")), + CompanionAppCatalog.PresetVsCode) + ?? TrySuggestFromPreset( + Directory.Exists(Path.Combine(directory, ".obsidian")), + CompanionAppCatalog.PresetObsidian) + ?? TrySuggestFromPreset( + WorkspaceCompanionSignals.HasZedProject(directory), + CompanionAppCatalog.PresetZed) + ?? (WorkspaceCompanionSignals.HasJetBrainsProject(directory) + && WorkspaceCompanionSignals.HasDotNetProject(directory) + ? BuildSuggestion(CompanionAppCatalog.PresetRider) + : null) + ?? (WorkspaceCompanionSignals.HasVisualStudioSolution(directory) + ? BuildFirstSuggestion(VisualStudioPresetPriority) + : null) + ?? TrySuggestFromPreset( + WorkspaceCompanionSignals.HasJetBrainsProject(directory), + CompanionAppCatalog.PresetIntelliJIdea) + ?? TrySuggestFromPreset( + WorkspaceCompanionSignals.HasSublimeProject(directory), + CompanionAppCatalog.PresetSublime) + ?? (WorkspaceCompanionSignals.HasGitRepository(directory) + ? BuildFirstSuggestion(GitClientPresetPriority) + : null); + } + + private static CompanionAppSuggestion? TrySuggestFromPreset(bool condition, string presetId) => + condition ? BuildSuggestion(presetId) : null; - if (Directory.Exists(Path.Combine(directory, ".cursor"))) + private static CompanionAppSuggestion? BuildFirstSuggestion(IEnumerable presetIds) + { + foreach (var presetId in presetIds) { - return BuildSuggestion(CompanionAppCatalog.PresetCursor); + var suggestion = BuildSuggestion(presetId); + if (suggestion is not null) + { + return suggestion; + } } return null; diff --git a/QuickShell.Core/Services/CompanionAppLauncher.cs b/QuickShell.Core/Services/CompanionAppLauncher.cs index 0a46141..f48eaed 100644 --- a/QuickShell.Core/Services/CompanionAppLauncher.cs +++ b/QuickShell.Core/Services/CompanionAppLauncher.cs @@ -74,7 +74,15 @@ internal static string ExpandArguments(string? arguments, string workspaceDirect return QuoteIfNeeded(workspaceDirectory); } - return trimmed.Replace("{folder}", QuoteIfNeeded(workspaceDirectory), StringComparison.OrdinalIgnoreCase); + var solution = WorkspaceCompanionSignals.TryFindSolutionFile(workspaceDirectory); + var expanded = trimmed + .Replace("{folder}", QuoteIfNeeded(workspaceDirectory), StringComparison.OrdinalIgnoreCase) + .Replace( + "{solution}", + QuoteIfNeeded(solution ?? workspaceDirectory), + StringComparison.OrdinalIgnoreCase); + + return expanded; } private static string QuoteIfNeeded(string path) => diff --git a/QuickShell.Core/Services/DevServerUrlDetection.cs b/QuickShell.Core/Services/DevServerUrlDetection.cs index c52fc89..ad86814 100644 --- a/QuickShell.Core/Services/DevServerUrlDetection.cs +++ b/QuickShell.Core/Services/DevServerUrlDetection.cs @@ -46,6 +46,87 @@ internal static partial class DevServerUrlDetection } } + public static string? TryDetectDevLaunchCommand(string directory) + { + if (string.IsNullOrWhiteSpace(directory)) + { + return null; + } + + var packageJsonPath = Path.Combine(directory, "package.json"); + if (!File.Exists(packageJsonPath)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(File.ReadAllText(packageJsonPath)); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return null; + } + + var scriptName = ReadScript(root, "dev") is not null + ? "dev" + : ReadScript(root, "start") is not null + ? "start" + : null; + if (scriptName is null) + { + return null; + } + + return FormatPackageScriptCommand(directory, scriptName); + } + catch + { + return null; + } + } + + internal static string FormatPackageScriptCommand(string directory, string scriptName) + { + return DetectPackageManager(directory) switch + { + PackageManagerKind.Pnpm => $"pnpm {scriptName}", + PackageManagerKind.Yarn => $"yarn {scriptName}", + PackageManagerKind.Bun => $"bun run {scriptName}", + PackageManagerKind.Npm when string.Equals(scriptName, "start", StringComparison.Ordinal) => "npm start", + _ => $"npm run {scriptName}", + }; + } + + private enum PackageManagerKind + { + Npm, + Pnpm, + Yarn, + Bun, + } + + private static PackageManagerKind DetectPackageManager(string directory) + { + if (File.Exists(Path.Combine(directory, "pnpm-lock.yaml"))) + { + return PackageManagerKind.Pnpm; + } + + if (File.Exists(Path.Combine(directory, "bun.lockb")) + || File.Exists(Path.Combine(directory, "bun.lock"))) + { + return PackageManagerKind.Bun; + } + + if (File.Exists(Path.Combine(directory, "yarn.lock"))) + { + return PackageManagerKind.Yarn; + } + + return PackageManagerKind.Npm; + } + private static string? ReadScript(JsonElement root, string scriptName) { if (!root.TryGetProperty("scripts", out var scripts) || scripts.ValueKind != JsonValueKind.Object) diff --git a/QuickShell.Core/Services/JetBrainsInstallDiscovery.cs b/QuickShell.Core/Services/JetBrainsInstallDiscovery.cs new file mode 100644 index 0000000..7b6b266 --- /dev/null +++ b/QuickShell.Core/Services/JetBrainsInstallDiscovery.cs @@ -0,0 +1,135 @@ +namespace QuickShell.Services; + +internal static class JetBrainsInstallDiscovery +{ + public static string? TryResolveRider() => + TryResolveProduct(["Rider"], "rider64.exe", directoryName => directoryName.Contains("Rider", StringComparison.OrdinalIgnoreCase)); + + public static string? TryResolveIntelliJIdea() => + TryResolveProduct( + ["IDEA-U", "IDEA-C", "IntelliJ IDEA"], + "idea64.exe", + directoryName => directoryName.Contains("IntelliJ", StringComparison.OrdinalIgnoreCase) + || directoryName.StartsWith("IDEA", StringComparison.OrdinalIgnoreCase)); + + private static string? TryResolveProduct( + IReadOnlyList toolboxAppFolders, + string executableName, + Func matchesStandaloneFolder) + { + foreach (var toolboxAppFolder in toolboxAppFolders) + { + var fromToolbox = TryResolveFromToolbox(toolboxAppFolder, executableName); + if (fromToolbox is not null) + { + return fromToolbox; + } + } + + return TryResolveFromProgramFiles(executableName, matchesStandaloneFolder); + } + + private static string? TryResolveFromToolbox(string appFolder, string executableName) + { + var toolboxApps = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "JetBrains", + "Toolbox", + "apps", + appFolder); + if (!Directory.Exists(toolboxApps)) + { + return null; + } + + try + { + FileInfo? newest = null; + foreach (var channel in Directory.EnumerateDirectories(toolboxApps, "ch-*", SearchOption.TopDirectoryOnly)) + { + foreach (var executable in FindToolboxExecutables(channel, executableName)) + { + if (newest is null || executable.LastWriteTimeUtc > newest.LastWriteTimeUtc) + { + newest = executable; + } + } + } + + return newest?.FullName; + } + catch + { + return null; + } + } + + private static IEnumerable FindToolboxExecutables(string channelDirectory, string executableName) + { + var direct = Path.Combine(channelDirectory, "bin", executableName); + if (File.Exists(direct)) + { + yield return new FileInfo(direct); + } + + IEnumerable buildDirectories; + try + { + buildDirectories = Directory.EnumerateDirectories(channelDirectory); + } + catch + { + yield break; + } + + foreach (var buildDirectory in buildDirectories) + { + var nested = Path.Combine(buildDirectory, "bin", executableName); + if (File.Exists(nested)) + { + yield return new FileInfo(nested); + } + } + } + + private static string? TryResolveFromProgramFiles(string executableName, Func matchesFolder) + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var jetBrainsRoot = Path.Combine(programFiles, "JetBrains"); + if (!Directory.Exists(jetBrainsRoot)) + { + return null; + } + + try + { + FileInfo? newest = null; + foreach (var productDirectory in Directory.EnumerateDirectories(jetBrainsRoot)) + { + var folderName = Path.GetFileName(productDirectory); + if (!matchesFolder(folderName)) + { + continue; + } + + var executable = Path.Combine(productDirectory, "bin", executableName); + if (!File.Exists(executable)) + { + continue; + } + + var candidate = new FileInfo(executable); + if (newest is null || candidate.LastWriteTimeUtc > newest.LastWriteTimeUtc) + { + newest = candidate; + } + } + + return newest?.FullName; + } + catch + { + return null; + } + } +} diff --git a/QuickShell.Core/Services/ShortcutHealth.cs b/QuickShell.Core/Services/ShortcutHealth.cs index e79439c..d7cbe8b 100644 --- a/QuickShell.Core/Services/ShortcutHealth.cs +++ b/QuickShell.Core/Services/ShortcutHealth.cs @@ -47,7 +47,7 @@ public static string BuildListSubtitle(TerminalShortcut shortcut) if (string.IsNullOrWhiteSpace(shortcut.Directory)) { - return "Choose project folder · fix in edit"; + return "Choose workspace folder · fix in edit"; } if (!ShortcutValidation.TryNormalizeDirectory(shortcut.Directory, out _, out _)) @@ -66,6 +66,13 @@ public static string BuildListSubtitle(TerminalShortcut shortcut) return $"Invalid workspace · {launchError}"; } + if (shortcut.OpenCompanionAppOnLaunch + && !string.IsNullOrWhiteSpace(shortcut.CompanionAppPath) + && !CompanionAppCatalog.TryResolveExecutablePath(shortcut.CompanionAppPath, out _)) + { + return $"Companion app missing · {ShortcutDisplay.BuildSubtitle(shortcut)}"; + } + return ShortcutDisplay.BuildSubtitle(shortcut); } } diff --git a/QuickShell.Core/Services/VisualStudioInstallDiscovery.cs b/QuickShell.Core/Services/VisualStudioInstallDiscovery.cs new file mode 100644 index 0000000..f92a735 --- /dev/null +++ b/QuickShell.Core/Services/VisualStudioInstallDiscovery.cs @@ -0,0 +1,126 @@ +using System.Diagnostics; + +namespace QuickShell.Services; + +internal static class VisualStudioInstallDiscovery +{ + public static string? TryResolveDevenv(int minVersionInclusive, int maxVersionExclusive) + { + var installationPath = TryQueryInstallationPath(minVersionInclusive, maxVersionExclusive); + if (string.IsNullOrWhiteSpace(installationPath)) + { + return null; + } + + var devenv = Path.Combine(installationPath, "Common7", "IDE", "devenv.exe"); + return File.Exists(devenv) ? Path.GetFullPath(devenv) : null; + } + + public static string? TryInferPresetFromDevenvPath(string? executablePath) + { + if (string.IsNullOrWhiteSpace(executablePath)) + { + return null; + } + + var path = executablePath.Trim(); + if (!string.Equals(Path.GetFileName(path), "devenv.exe", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (CompanionAppCatalog.TryResolveExecutablePath(path, out var resolved)) + { + foreach (var (presetId, min, max) in PresetVersionRanges) + { + var devenv = TryResolveDevenv(min, max); + if (devenv is not null + && string.Equals(devenv, resolved, StringComparison.OrdinalIgnoreCase)) + { + return presetId; + } + } + } + + var normalized = path.Replace('/', '\\'); + if (normalized.Contains(@"\2022\", StringComparison.OrdinalIgnoreCase) + || normalized.Contains("Visual Studio 2022", StringComparison.OrdinalIgnoreCase)) + { + return CompanionAppCatalog.PresetVs2022; + } + + if (normalized.Contains(@"\2026\", StringComparison.OrdinalIgnoreCase) + || normalized.Contains("Visual Studio 2026", StringComparison.OrdinalIgnoreCase) + || normalized.Contains(@"\18.", StringComparison.OrdinalIgnoreCase)) + { + return CompanionAppCatalog.PresetVs2026; + } + + return CompanionAppCatalog.PresetCustom; + } + + private static IEnumerable<(string PresetId, int Min, int Max)> PresetVersionRanges => + [ + (CompanionAppCatalog.PresetVs2026, 18, 19), + (CompanionAppCatalog.PresetVs2022, 17, 18), + ]; + + private static string? TryQueryInstallationPath(int minVersionInclusive, int maxVersionExclusive) + { + var vswhere = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + "Microsoft Visual Studio", + "Installer", + "vswhere.exe"); + if (!File.Exists(vswhere)) + { + return null; + } + + var versionRange = $"[{minVersionInclusive}.0,{maxVersionExclusive}.0)"; + try + { + var startInfo = new ProcessStartInfo + { + FileName = vswhere, + Arguments = $"-version {versionRange} -latest -property installationPath", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(startInfo); + if (process is null) + { + return null; + } + + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(5000)) + { + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // Best effort. + } + + return null; + } + + var output = outputTask.GetAwaiter().GetResult().Trim(); + _ = errorTask.GetAwaiter().GetResult(); + return process.ExitCode != 0 || string.IsNullOrWhiteSpace(output) || !Directory.Exists(output) + ? null + : output; + } + catch + { + return null; + } + } +} diff --git a/QuickShell.Core/Services/WorkspaceCompanionSignals.cs b/QuickShell.Core/Services/WorkspaceCompanionSignals.cs new file mode 100644 index 0000000..b8f0daf --- /dev/null +++ b/QuickShell.Core/Services/WorkspaceCompanionSignals.cs @@ -0,0 +1,83 @@ +namespace QuickShell.Services; + +internal static class WorkspaceCompanionSignals +{ + public static bool HasGitRepository(string directory) => + Directory.Exists(Path.Combine(directory, ".git")); + + public static bool HasVisualStudioSolution(string directory) + { + if (Directory.Exists(Path.Combine(directory, ".vs"))) + { + return true; + } + + return TryFindSolutionFile(directory) is not null; + } + + public static string? TryFindSolutionFile(string directory) + { + if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory)) + { + return null; + } + + try + { + return Directory + .EnumerateFiles(directory, "*.sln", SearchOption.TopDirectoryOnly) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + } + catch + { + return null; + } + } + + public static bool HasSublimeProject(string directory) + { + try + { + return Directory.EnumerateFiles(directory, "*.sublime-project", SearchOption.TopDirectoryOnly).Any(); + } + catch + { + return false; + } + } + + public static bool HasJetBrainsProject(string directory) => + Directory.Exists(Path.Combine(directory, ".idea")); + + public static bool HasZedProject(string directory) => + Directory.Exists(Path.Combine(directory, ".zed")); + + public static bool HasDotNetProject(string directory) + { + if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory)) + { + return false; + } + + if (TryFindSolutionFile(directory) is not null) + { + return true; + } + + if (File.Exists(Path.Combine(directory, "global.json"))) + { + return true; + } + + try + { + return Directory.EnumerateFiles(directory, "*.csproj", SearchOption.TopDirectoryOnly).Any() + || Directory.EnumerateFiles(directory, "*.fsproj", SearchOption.TopDirectoryOnly).Any(); + } + catch + { + return false; + } + } +} diff --git a/QuickShell.Core/Services/WorkspaceSeedFactory.cs b/QuickShell.Core/Services/WorkspaceSeedFactory.cs index e10df23..480657e 100644 --- a/QuickShell.Core/Services/WorkspaceSeedFactory.cs +++ b/QuickShell.Core/Services/WorkspaceSeedFactory.cs @@ -29,6 +29,16 @@ public static TerminalShortcut ApplyDirectoryHints(TerminalShortcut seed) seed.DevServerUrl = DevServerUrlDetection.TryDetectDevServerUrl(seed.Directory); } + if (!HasNonemptyLaunchCommand(seed)) + { + var detected = DevServerUrlDetection.TryDetectDevLaunchCommand(seed.Directory); + if (!string.IsNullOrWhiteSpace(detected)) + { + seed.Command = detected; + ApplyDetectedCommandToLaunches(seed, detected); + } + } + if (string.IsNullOrWhiteSpace(seed.CompanionAppPath)) { var suggestion = CompanionAppDetection.TrySuggestFromDirectory(seed.Directory); @@ -42,4 +52,24 @@ public static TerminalShortcut ApplyDirectoryHints(TerminalShortcut seed) return seed; } + + private static bool HasNonemptyLaunchCommand(TerminalShortcut seed) => + seed.Launches.Any(launch => !string.IsNullOrWhiteSpace(launch.Command)) + || !string.IsNullOrWhiteSpace(seed.Command); + + private static void ApplyDetectedCommandToLaunches(TerminalShortcut seed, string command) + { + if (seed.Launches is { Count: > 0 }) + { + var first = seed.Launches.OrderBy(launch => launch.Order).First(); + if (string.IsNullOrWhiteSpace(first.Command)) + { + first.Command = command; + } + + return; + } + + ShortcutLaunchNormalization.EnsureLaunchesFromLegacy(seed); + } }