From 87af08630a2b5594b442b071174e790549392268 Mon Sep 17 00:00:00 2001 From: Mike Griese Date: Thu, 2 Oct 2025 06:36:59 -0500 Subject: [PATCH] CmdPal: collection of Run Commands nits (#42092) * Path items were being treated inconsistently * We shouldn't re-enumerate a directory on every keystroke * A bunch of elements had empty TextToSuggest (which makes it crazier that it ever worked right) Vaguely regressed in #41956 related to #39091 --- .pipelines/verifyCommonProps.ps1 | 3 + .../Services/IRunHistoryService.cs | 11 +- .../cmdpal/Microsoft.CmdPal.UI/App.xaml.cs | 2 +- .../Microsoft.CmdPal.UI/Events/RunEvents.cs | 80 ++++++ .../Helpers/TelemetryForwarder.cs | 17 ++ .../QueryTests.cs | 7 +- .../ShellCommandProviderTests.cs | 6 +- .../Commands/ExecuteItem.cs | 228 ------------------ .../OpenUrlWithHistoryCommand.cs | 25 +- .../FallbackExecuteItem.cs | 21 +- .../Helpers/CommandLineNormalizer.cs | 140 +++++------ .../Helpers/ShellListPageHelpers.cs | 46 +--- .../Microsoft.CmdPal.Ext.Shell.csproj | 7 +- .../NativeMethods.json | 7 + .../NativeMethods.txt | 22 ++ .../{ => Pages}/PathListItem.cs | 48 ++-- .../Pages/RunExeItem.cs | 31 ++- .../Pages/ShellListPage.cs | 148 ++++++++---- .../Properties/ResourceLoaderInstance.cs | 13 + .../ISettingsInterface.cs | 0 .../{Helpers => Settings}/SettingsManager.cs | 0 .../ShellCommandsProvider.cs | 8 +- 22 files changed, 418 insertions(+), 452 deletions(-) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Events/RunEvents.cs delete mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs rename src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/{ => Commands}/OpenUrlWithHistoryCommand.cs (51%) create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.json create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.txt rename src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/{ => Pages}/PathListItem.cs (63%) create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/ResourceLoaderInstance.cs rename src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/{Helpers => Settings}/ISettingsInterface.cs (100%) rename src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/{Helpers => Settings}/SettingsManager.cs (100%) diff --git a/.pipelines/verifyCommonProps.ps1 b/.pipelines/verifyCommonProps.ps1 index 8ea6640383..7ed52f6bf1 100644 --- a/.pipelines/verifyCommonProps.ps1 +++ b/.pipelines/verifyCommonProps.ps1 @@ -44,6 +44,9 @@ foreach ($csprojFile in $csprojFilesArray) { if ($csprojFile -like '*Microsoft.CmdPal.Core.*.csproj') { continue } + if ($csprojFile -like '*Microsoft.CmdPal.Ext.Shell.csproj') { + continue + } $importExists = Test-ImportSharedCsWinRTProps -filePath $csprojFile if (!$importExists) { diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IRunHistoryService.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IRunHistoryService.cs index 09d2046dfe..fd68b6e521 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IRunHistoryService.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IRunHistoryService.cs @@ -2,8 +2,6 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Collections.Generic; - namespace Microsoft.CmdPal.Core.Common.Services; public interface IRunHistoryService @@ -25,3 +23,12 @@ public interface IRunHistoryService /// The run history item to add. void AddRunHistoryItem(string item); } + +public interface ITelemetryService +{ + void LogRunQuery(string query, int resultCount, ulong durationMs); + + void LogRunCommand(string command, bool asAdmin, bool success); + + void LogOpenUri(string uri, bool isWeb, bool success); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index 6bf9ec7c86..9ba41a08fb 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -160,7 +160,7 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(new TelemetryForwarder()); + services.AddSingleton(); // ViewModels services.AddSingleton(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/RunEvents.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/RunEvents.cs new file mode 100644 index 0000000000..f5889ab05c --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/RunEvents.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace Microsoft.CmdPal.UI.Events; + +// Just put all the run events in one file for simplicity. +#pragma warning disable SA1402 // File may only contain a single type +#pragma warning disable SA1649 // File name should match first type name + +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class CmdPalRunQuery : EventBase, IEvent +{ + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public string Query { get; set; } + + public int ResultCount { get; set; } + + public ulong DurationMs { get; set; } + + public CmdPalRunQuery(string query, int resultCount, ulong durationMs) + { + EventName = "CmdPal_RunQuery"; + Query = query; + ResultCount = resultCount; + DurationMs = durationMs; + } +} + +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class CmdPalRunCommand : EventBase, IEvent +{ + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public string Command { get; set; } + + public bool AsAdmin { get; set; } + + public bool Success { get; set; } + + public CmdPalRunCommand(string command, bool asAdmin, bool success) + { + EventName = "CmdPal_RunCommand"; + Command = command; + AsAdmin = asAdmin; + Success = success; + } +} + +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class CmdPalOpenUri : EventBase, IEvent +{ + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public string Uri { get; set; } + + public bool IsWeb { get; set; } + + public bool Success { get; set; } + + public CmdPalOpenUri(string uri, bool isWeb, bool success) + { + EventName = "CmdPal_OpenUri"; + Uri = uri; + IsWeb = isWeb; + Success = success; + } +} + +#pragma warning restore SA1649 // File name should match first type name +#pragma warning restore SA1402 // File may only contain a single type diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TelemetryForwarder.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TelemetryForwarder.cs index 53756da785..e14d1abe3b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TelemetryForwarder.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TelemetryForwarder.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.UI.Events; using Microsoft.PowerToys.Telemetry; @@ -19,6 +20,7 @@ namespace Microsoft.CmdPal.UI; /// or something similar, but this works for now. /// internal sealed class TelemetryForwarder : + ITelemetryService, IRecipient, IRecipient { @@ -37,4 +39,19 @@ internal sealed class TelemetryForwarder : { PowerToysTelemetry.Log.WriteEvent(new BeginInvoke()); } + + public void LogRunQuery(string query, int resultCount, ulong durationMs) + { + PowerToysTelemetry.Log.WriteEvent(new CmdPalRunQuery(query, resultCount, durationMs)); + } + + public void LogRunCommand(string command, bool asAdmin, bool success) + { + PowerToysTelemetry.Log.WriteEvent(new CmdPalRunCommand(command, asAdmin, success)); + } + + public void LogOpenUri(string uri, bool isWeb, bool success) + { + PowerToysTelemetry.Log.WriteEvent(new CmdPalOpenUri(uri, isWeb, success)); + } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs index d1720bb7c4..b6247143cb 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs @@ -2,7 +2,6 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -83,7 +82,7 @@ public class QueryTests : CommandPaletteUnitTestBase var settings = Settings.CreateDefaultSettings(); var mockHistory = CreateMockHistoryService(); - var pages = new ShellListPage(settings, mockHistory.Object); + var pages = new ShellListPage(settings, mockHistory.Object, telemetryService: null); await UpdatePageAndWaitForItems(pages, () => { @@ -115,7 +114,7 @@ public class QueryTests : CommandPaletteUnitTestBase var settings = Settings.CreateDefaultSettings(); var mockHistoryService = CreateMockHistoryServiceWithCommonCommands(); - var pages = new ShellListPage(settings, mockHistoryService.Object); + var pages = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null); await UpdatePageAndWaitForItems(pages, () => { @@ -141,7 +140,7 @@ public class QueryTests : CommandPaletteUnitTestBase var settings = Settings.CreateDefaultSettings(); var mockHistoryService = CreateMockHistoryServiceWithCommonCommands(); - var pages = new ShellListPage(settings, mockHistoryService.Object); + var pages = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null); await UpdatePageAndWaitForItems(pages, () => { diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs index d81a4d9fe6..24a3252255 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs @@ -16,7 +16,7 @@ public class ShellCommandProviderTests { // Setup var mockHistoryService = new Mock(); - var provider = new ShellCommandsProvider(mockHistoryService.Object); + var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null); // Assert Assert.IsNotNull(provider.DisplayName); @@ -28,7 +28,7 @@ public class ShellCommandProviderTests { // Setup var mockHistoryService = new Mock(); - var provider = new ShellCommandsProvider(mockHistoryService.Object); + var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null); // Assert Assert.IsNotNull(provider.Icon); @@ -39,7 +39,7 @@ public class ShellCommandProviderTests { // Setup var mockHistoryService = new Mock(); - var provider = new ShellCommandsProvider(mockHistoryService.Object); + var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null); // Act var commands = provider.TopLevelCommands(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs deleted file mode 100644 index f41f5e0ab7..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs +++ /dev/null @@ -1,228 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.ComponentModel; -using System.Diagnostics; -using System.IO; -using Microsoft.CmdPal.Ext.Shell.Helpers; -using Microsoft.CmdPal.Ext.Shell.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Shell.Commands; - -internal sealed partial class ExecuteItem : InvokableCommand -{ - private readonly ISettingsInterface _settings; - private readonly RunAsType _runas; - - public string Cmd { get; internal set; } = string.Empty; - - private static readonly char[] Separator = [' ']; - - public ExecuteItem(string cmd, ISettingsInterface settings, RunAsType type = RunAsType.None) - { - if (type == RunAsType.Administrator) - { - Name = Properties.Resources.cmd_run_as_administrator; - Icon = Icons.AdminIcon; - } - else if (type == RunAsType.OtherUser) - { - Name = Properties.Resources.cmd_run_as_user; - Icon = Icons.UserIcon; - } - else - { - Name = Properties.Resources.generic_run_command; - Icon = Icons.RunV2Icon; - } - - Cmd = cmd; - _settings = settings; - _runas = type; - } - - private void Execute(Func startProcess, ProcessStartInfo info) - { - if (startProcess is null) - { - return; - } - - try - { - startProcess(info); - } - catch (FileNotFoundException e) - { - var name = "Plugin: " + Properties.Resources.cmd_plugin_name; - var message = $"{Properties.Resources.cmd_command_not_found}: {e.Message}"; - - // GH TODO #138 -- show this message once that's wired up - // _context.API.ShowMsg(name, message); - } - catch (Win32Exception e) - { - var name = "Plugin: " + Properties.Resources.cmd_plugin_name; - var message = $"{Properties.Resources.cmd_command_failed}: {e.Message}"; - ExtensionHost.LogMessage(new LogMessage() { Message = name + message }); - - // GH TODO #138 -- show this message once that's wired up - // _context.API.ShowMsg(name, message); - } - } - - public static ProcessStartInfo SetProcessStartInfo(string fileName, string workingDirectory = "", string arguments = "", string verb = "") - { - var info = new ProcessStartInfo - { - FileName = fileName, - WorkingDirectory = workingDirectory, - Arguments = arguments, - Verb = verb, - }; - - return info; - } - - private ProcessStartInfo PrepareProcessStartInfo(string command, RunAsType runAs = RunAsType.None) - { - command = Environment.ExpandEnvironmentVariables(command); - var workingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - - // Set runAsArg - var runAsVerbArg = string.Empty; - if (runAs == RunAsType.OtherUser) - { - runAsVerbArg = "runAsUser"; - } - else if (runAs == RunAsType.Administrator || _settings.RunAsAdministrator) - { - runAsVerbArg = "runAs"; - } - - if (Enum.TryParse(_settings.ShellCommandExecution, out var executionShell)) - { - ProcessStartInfo info; - if (executionShell == ExecutionShell.Cmd) - { - var arguments = _settings.LeaveShellOpen ? $"/k \"{command}\"" : $"/c \"{command}\" & pause"; - - info = SetProcessStartInfo("cmd.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.Powershell) - { - var arguments = _settings.LeaveShellOpen - ? $"-NoExit \"{command}\"" - : $"\"{command} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\""; - info = SetProcessStartInfo("powershell.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.PowerShellSeven) - { - var arguments = _settings.LeaveShellOpen - ? $"-NoExit -C \"{command}\"" - : $"-C \"{command} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\""; - info = SetProcessStartInfo("pwsh.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.WindowsTerminalCmd) - { - var arguments = _settings.LeaveShellOpen ? $"cmd.exe /k \"{command}\"" : $"cmd.exe /c \"{command}\" & pause"; - info = SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.WindowsTerminalPowerShell) - { - var arguments = _settings.LeaveShellOpen ? $"powershell -NoExit -C \"{command}\"" : $"powershell -C \"{command}\""; - info = SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.WindowsTerminalPowerShellSeven) - { - var arguments = _settings.LeaveShellOpen ? $"pwsh.exe -NoExit -C \"{command}\"" : $"pwsh.exe -C \"{command}\""; - info = SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.RunCommand) - { - // Open explorer if the path is a file or directory - if (Directory.Exists(command) || File.Exists(command)) - { - info = SetProcessStartInfo("explorer.exe", arguments: command, verb: runAsVerbArg); - } - else - { - var parts = command.Split(Separator, 2); - if (parts.Length == 2) - { - var filename = parts[0]; - if (ShellListPageHelpers.FileExistInPath(filename)) - { - var arguments = parts[1]; - if (_settings.LeaveShellOpen) - { - // Wrap the command in a cmd.exe process - info = SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{filename} {arguments}\"", runAsVerbArg); - } - else - { - info = SetProcessStartInfo(filename, workingDirectory, arguments, runAsVerbArg); - } - } - else - { - if (_settings.LeaveShellOpen) - { - // Wrap the command in a cmd.exe process - info = SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{command}\"", runAsVerbArg); - } - else - { - info = SetProcessStartInfo(command, verb: runAsVerbArg); - } - } - } - else - { - if (_settings.LeaveShellOpen) - { - // Wrap the command in a cmd.exe process - info = SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{command}\"", runAsVerbArg); - } - else - { - info = SetProcessStartInfo(command, verb: runAsVerbArg); - } - } - } - } - else - { - throw new NotImplementedException(); - } - - info.UseShellExecute = true; - - _settings.AddCmdHistory(command); - - return info; - } - else - { - ExtensionHost.LogMessage(new LogMessage() { Message = "Error extracting setting" }); - throw new NotImplementedException(); - } - } - - public override CommandResult Invoke() - { - try - { - Execute(Process.Start, PrepareProcessStartInfo(Cmd, _runas)); - } - catch - { - ExtensionHost.LogMessage(new LogMessage() { Message = "Error starting the process " }); - } - - return CommandResult.Dismiss(); - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/OpenUrlWithHistoryCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/OpenUrlWithHistoryCommand.cs similarity index 51% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/OpenUrlWithHistoryCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/OpenUrlWithHistoryCommand.cs index 62b4761a34..f1fc878558 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/OpenUrlWithHistoryCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/OpenUrlWithHistoryCommand.cs @@ -2,9 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.IO; -using Microsoft.CommandPalette.Extensions; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Shell; @@ -13,18 +11,33 @@ internal sealed partial class OpenUrlWithHistoryCommand : OpenUrlCommand { private readonly Action? _addToHistory; private readonly string _url; + private readonly ITelemetryService? _telemetryService; - public OpenUrlWithHistoryCommand(string url, Action? addToHistory = null) + public OpenUrlWithHistoryCommand(string url, Action? addToHistory = null, ITelemetryService? telemetryService = null) : base(url) { _addToHistory = addToHistory; _url = url; + _telemetryService = telemetryService; } public override CommandResult Invoke() { _addToHistory?.Invoke(_url); - var result = base.Invoke(); - return result; + + var success = ShellHelpers.OpenInShell(_url); + var isWebUrl = false; + + if (Uri.TryCreate(_url, UriKind.Absolute, out var uri)) + { + if (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) + { + isWebUrl = true; + } + } + + _telemetryService?.LogOpenUri(_url, isWebUrl, success); + + return CommandResult.Dismiss(); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs index 494ac49648..2e4cac7b16 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs @@ -2,14 +2,9 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Ext.Shell.Helpers; using Microsoft.CmdPal.Ext.Shell.Pages; -using Microsoft.CmdPal.Ext.Shell.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Shell; @@ -19,18 +14,20 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos private static readonly char[] _systemDirectoryRoots = ['\\', '/']; private readonly Action? _addToHistory; + private readonly ITelemetryService _telemetryService; private CancellationTokenSource? _cancellationTokenSource; private Task? _currentUpdateTask; - public FallbackExecuteItem(SettingsManager settings, Action? addToHistory) + public FallbackExecuteItem(SettingsManager settings, Action? addToHistory, ITelemetryService telemetryService) : base( new NoOpCommand() { Id = "com.microsoft.run.fallback" }, - Resources.shell_command_display_title) + ResourceLoaderInstance.GetString("shell_command_display_title")) { Title = string.Empty; - Subtitle = Properties.Resources.generic_run_command; + Subtitle = ResourceLoaderInstance.GetString("generic_run_command"); Icon = Icons.RunV2Icon; // Defined in Icons.cs and contains the execute command icon. _addToHistory = addToHistory; + _telemetryService = telemetryService; } public override void UpdateQuery(string query) @@ -147,7 +144,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos if (exeExists) { // TODO we need to probably get rid of the settings for this provider entirely - var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, _addToHistory); + var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, _addToHistory, telemetryService: _telemetryService); Title = exeItem.Title; Subtitle = exeItem.Subtitle; Icon = exeItem.Icon; @@ -156,7 +153,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos } else if (pathIsDir) { - var pathItem = new PathListItem(exe, query, _addToHistory); + var pathItem = new PathListItem(exe, query, _addToHistory, _telemetryService); Command = pathItem.Command; MoreCommands = pathItem.MoreCommands; Title = pathItem.Title; @@ -165,7 +162,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos } else if (System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri)) { - Command = new OpenUrlWithHistoryCommand(searchText, _addToHistory) { Result = CommandResult.Dismiss() }; + Command = new OpenUrlWithHistoryCommand(searchText, _addToHistory, _telemetryService) { Result = CommandResult.Dismiss() }; Title = searchText; } else diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/CommandLineNormalizer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/CommandLineNormalizer.cs index 7f91397d1b..0ce15174b0 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/CommandLineNormalizer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/CommandLineNormalizer.cs @@ -2,10 +2,10 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.IO; -using System.Runtime.InteropServices; using System.Text; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Storage.FileSystem; namespace Microsoft.CmdPal.Ext.Shell.Helpers; @@ -19,38 +19,11 @@ namespace Microsoft.CmdPal.Ext.Shell.Helpers; public static class CommandLineNormalizer { #pragma warning disable SA1310 // Field names should not contain underscore - private const int MAX_PATH = 260; private const uint INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF; - private const uint FILE_ATTRIBUTE_DIRECTORY = 0x10; + + private const int MAX_PATH = 260; #pragma warning restore SA1310 // Field names should not contain underscore - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern uint ExpandEnvironmentStringsW( - [MarshalAs(UnmanagedType.LPWStr)] string lpSrc, - [MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpDst, - uint nSize); - - [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern IntPtr CommandLineToArgvW( - [MarshalAs(UnmanagedType.LPWStr)] string lpCmdLine, - out int pNumArgs); - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern uint SearchPathW( - [MarshalAs(UnmanagedType.LPWStr)] string? lpPath, - [MarshalAs(UnmanagedType.LPWStr)] string lpFileName, - [MarshalAs(UnmanagedType.LPWStr)] string? lpExtension, - uint nBufferLength, - [MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpBuffer, - out IntPtr lpFilePart); - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern uint GetFileAttributesW( - [MarshalAs(UnmanagedType.LPWStr)] string lpFileName); - - [DllImport("kernel32.dll")] - private static extern IntPtr LocalFree(IntPtr hMem); - /// /// Normalizes a command line string by expanding environment variables, resolving executable paths, /// and standardizing the format for comparison purposes. @@ -129,9 +102,9 @@ public static class CommandLineNormalizer private static string ExpandEnvironmentVariables(string input) { const int initialBufferSize = 1024; - var buffer = new StringBuilder(initialBufferSize); + var buffer = new char[initialBufferSize]; - var result = ExpandEnvironmentStringsW(input, buffer, (uint)buffer.Capacity); + var result = PInvoke.ExpandEnvironmentStrings(input, buffer); if (result == 0) { @@ -139,11 +112,11 @@ public static class CommandLineNormalizer return input; } - if (result > buffer.Capacity) + if (result > buffer.Length) { // Buffer was too small, resize and try again - buffer.Capacity = (int)result; - result = ExpandEnvironmentStringsW(input, buffer, (uint)buffer.Capacity); + buffer = new char[result]; + result = PInvoke.ExpandEnvironmentStrings(input, buffer); if (result == 0) { @@ -151,7 +124,7 @@ public static class CommandLineNormalizer } } - return buffer.ToString(); + return new string(buffer, 0, (int)result - 1); // -1 to exclude null terminator } /// @@ -159,28 +132,30 @@ public static class CommandLineNormalizer /// private static string[] ParseCommandLineToArguments(string commandLine) { - var argv = CommandLineToArgvW(commandLine, out var argc); - - if (argv == IntPtr.Zero || argc == 0) + unsafe { - return Array.Empty(); - } + var argv = PInvoke.CommandLineToArgv(commandLine, out var argc); - try - { - var args = new string[argc]; - - for (var i = 0; i < argc; i++) + if (argv == null || argc == 0) { - var argPtr = Marshal.ReadIntPtr(argv, i * IntPtr.Size); - args[i] = Marshal.PtrToStringUni(argPtr) ?? string.Empty; + return Array.Empty(); } - return args; - } - finally - { - LocalFree(argv); + try + { + var args = new string[argc]; + + for (var i = 0; i < argc; i++) + { + args[i] = new string(argv[i]); + } + + return args; + } + finally + { + PInvoke.LocalFree(new HLOCAL(argv)); + } } } @@ -227,39 +202,46 @@ public static class CommandLineNormalizer /// private static string TryResolveExecutable(string executableName) { - var buffer = new StringBuilder(MAX_PATH); + var buffer = new char[MAX_PATH]; - var result = SearchPathW( - null, // Use default search path - executableName, - ".exe", // Default extension - (uint)buffer.Capacity, - buffer, - out var _); // We don't need the file part - - if (result == 0) + unsafe { - return string.Empty; - } + var outParam = default(PWSTR); // ultimately discarded - if (result > buffer.Capacity) - { - // Buffer was too small, resize and try again - buffer.Capacity = (int)result; - result = SearchPathW(null, executableName, ".exe", (uint)buffer.Capacity, buffer, out var _); + var result = PInvoke.SearchPath( + null, // Use default search path + executableName, + ".exe", // Default extension + buffer, + &outParam); // We don't need the file part if (result == 0) { return string.Empty; } + + if (result > buffer.Length) + { + // Buffer was too small, resize and try again + buffer = new char[result]; + result = PInvoke.SearchPath(null, executableName, ".exe", buffer, &outParam); + + if (result == 0) + { + return string.Empty; + } + } + + var resolvedPath = new string(buffer, 0, (int)result); + + // Verify the resolved path exists and is not a directory + var attributes = PInvoke.GetFileAttributes(resolvedPath); + + return attributes == INVALID_FILE_ATTRIBUTES || + (attributes & (uint)FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY) != 0 ? + string.Empty : + resolvedPath; } - - var resolvedPath = buffer.ToString(); - - // Verify the resolved path exists and is not a directory - var attributes = GetFileAttributesW(resolvedPath); - - return attributes == INVALID_FILE_ATTRIBUTES || (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 ? string.Empty : resolvedPath; } /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs index 14d605f458..3522e32346 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs @@ -2,13 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.Collections.Generic; -using System.IO; using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.CmdPal.Ext.Shell.Commands; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Ext.Shell.Pages; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -16,37 +11,6 @@ namespace Microsoft.CmdPal.Ext.Shell.Helpers; public class ShellListPageHelpers { - private static readonly CompositeFormat CmdHasBeenExecutedTimes = System.Text.CompositeFormat.Parse(Properties.Resources.cmd_has_been_executed_times); - private readonly ISettingsInterface _settings; - - public ShellListPageHelpers(ISettingsInterface settings) - { - _settings = settings; - } - - private ListItem GetCurrentCmd(string cmd) - { - var result = new ListItem(new ExecuteItem(cmd, _settings)) - { - Title = cmd, - Subtitle = Properties.Resources.cmd_plugin_name + ": " + Properties.Resources.cmd_execute_through_shell, - Icon = new IconInfo(string.Empty), - }; - - return result; - } - - public List LoadContextMenus(ListItem listItem) - { - var resultList = new List - { - new(new ExecuteItem(listItem.Title, _settings, RunAsType.Administrator)), - new(new ExecuteItem(listItem.Title, _settings, RunAsType.OtherUser )), - }; - - return resultList; - } - internal static bool FileExistInPath(string filename) { return FileExistInPath(filename, out var _); @@ -58,7 +22,7 @@ public class ShellListPageHelpers return ShellHelpers.FileExistInPath(filename, out fullPath, token ?? CancellationToken.None); } - internal static ListItem? ListItemForCommandString(string query, Action? addToHistory) + internal static ListItem? ListItemForCommandString(string query, Action? addToHistory, ITelemetryService? telemetryService) { var li = new ListItem(); @@ -100,7 +64,7 @@ public class ShellListPageHelpers if (exeExists) { // TODO we need to probably get rid of the settings for this provider entirely - var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, addToHistory); + var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, addToHistory, telemetryService); li.Command = exeItem.Command; li.Title = exeItem.Title; li.Subtitle = exeItem.Subtitle; @@ -109,7 +73,7 @@ public class ShellListPageHelpers } else if (pathIsDir) { - var pathItem = new PathListItem(exe, query, addToHistory); + var pathItem = new PathListItem(exe, query, addToHistory, telemetryService); li.Command = pathItem.Command; li.Title = pathItem.Title; li.Subtitle = pathItem.Subtitle; @@ -118,7 +82,7 @@ public class ShellListPageHelpers } else if (System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri)) { - li.Command = new OpenUrlWithHistoryCommand(searchText) { Result = CommandResult.Dismiss() }; + li.Command = new OpenUrlWithHistoryCommand(searchText, addToHistory, telemetryService) { Result = CommandResult.Dismiss() }; li.Title = searchText; } else diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Microsoft.CmdPal.Ext.Shell.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Microsoft.CmdPal.Ext.Shell.csproj index c52b333300..dcb618ec89 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Microsoft.CmdPal.Ext.Shell.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Microsoft.CmdPal.Ext.Shell.csproj @@ -1,11 +1,7 @@  - - - - + - enable Microsoft.CmdPal.Ext.Shell $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal false @@ -16,7 +12,6 @@ - diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.json b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.json new file mode 100644 index 0000000000..b1156c41b7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "allowMarshaling": false, + "comInterop": { + "preserveSigMethods": [ "*" ] + } +} \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.txt b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.txt new file mode 100644 index 0000000000..ea62d0c662 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.txt @@ -0,0 +1,22 @@ +GetCurrentPackageFullName +SetWindowLong +GetWindowLong +WINDOW_EX_STYLE +SFBS_FLAGS +MAX_PATH +GetDpiForWindow +GetWindowRect +GetMonitorInfo +SetWindowPos +MonitorFromWindow + +SHOW_WINDOW_CMD +ShellExecuteEx +SEE_MASK_INVOKEIDLIST + +ExpandEnvironmentStringsW +CommandLineToArgvW +SearchPathW +GetFileAttributesW +LocalFree +FILE_FLAGS_AND_ATTRIBUTES diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/PathListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/PathListItem.cs similarity index 63% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/PathListItem.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/PathListItem.cs index b4d1252ece..b5463d3b73 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/PathListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/PathListItem.cs @@ -2,9 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.IO; using Microsoft.CmdPal.Core.Common.Commands; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.System; @@ -13,13 +12,18 @@ namespace Microsoft.CmdPal.Ext.Shell; internal sealed partial class PathListItem : ListItem { - private readonly Lazy _icon; - private readonly bool _isDirectory; + private readonly Lazy fetchedIcon; + private readonly bool isDirectory; + private readonly string path; - public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; } + public override IIconInfo? Icon { get => fetchedIcon.Value ? _icon : _icon; set => base.Icon = value; } - public PathListItem(string path, string originalDir, Action? addToHistory) - : base(new OpenUrlWithHistoryCommand(path, addToHistory)) + private IIconInfo? _icon; + + internal bool IsDirectory => isDirectory; + + public PathListItem(string path, string originalDir, Action? addToHistory, ITelemetryService? telemetryService = null) + : base(new OpenUrlWithHistoryCommand(path, addToHistory, telemetryService)) { var fileName = Path.GetFileName(path); if (string.IsNullOrEmpty(fileName)) @@ -27,8 +31,8 @@ internal sealed partial class PathListItem : ListItem fileName = Path.GetFileName(Path.GetDirectoryName(path)) ?? string.Empty; } - _isDirectory = Directory.Exists(path); - if (_isDirectory) + isDirectory = Directory.Exists(path); + if (isDirectory) { if (!path.EndsWith('\\')) { @@ -41,6 +45,8 @@ internal sealed partial class PathListItem : ListItem } } + this.path = path; + Title = fileName; // Just the name of the file is the Title Subtitle = path; // What the user typed is the subtitle @@ -58,23 +64,35 @@ internal sealed partial class PathListItem : ListItem // wrap it in quotes suggestion = string.Concat("\"", suggestion, "\""); } + else + { + suggestion = path; + } TextToSuggest = suggestion; MoreCommands = [ new CommandContextItem(new OpenWithCommand(path)), new CommandContextItem(new ShowFileInFolderCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.E) }, - new CommandContextItem(new CopyPathCommand(path) { Name = Properties.Resources.copy_path_command_name }) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.C) }, + new CommandContextItem(new CopyPathCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.C) }, new CommandContextItem(new OpenInConsoleCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.R) }, new CommandContextItem(new OpenPropertiesCommand(path)), ]; - _icon = new Lazy(() => + fetchedIcon = new Lazy(() => { - var iconStream = ThumbnailHelper.GetThumbnail(path).Result; - var icon = iconStream is not null ? IconInfo.FromStream(iconStream) : - _isDirectory ? Icons.FolderIcon : Icons.RunV2Icon; - return icon; + _ = Task.Run(FetchIconAsync); + return true; }); } + + private async Task FetchIconAsync() + { + var iconStream = await ThumbnailHelper.GetThumbnail(path); + var icon = iconStream != null ? + IconInfo.FromStream(iconStream) : + isDirectory ? Icons.FolderIcon : Icons.RunV2Icon; + _icon = icon; + OnPropertyChanged(nameof(Icon)); + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/RunExeItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/RunExeItem.cs index a8d578939e..1a37093c77 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/RunExeItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/RunExeItem.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.Threading.Tasks; +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.Ext.Shell.Helpers; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Storage.Streams; @@ -15,6 +15,7 @@ internal sealed partial class RunExeItem : ListItem { private readonly Lazy _icon; private readonly Action? _addToHistory; + private readonly ITelemetryService? _telemetryService; public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; } @@ -26,13 +27,18 @@ internal sealed partial class RunExeItem : ListItem private string FullString => string.IsNullOrEmpty(_args) ? Exe : $"{Exe} {_args}"; - public RunExeItem(string exe, string args, string fullExePath, Action? addToHistory) + public RunExeItem( + string exe, + string args, + string fullExePath, + Action? addToHistory, + ITelemetryService? telemetryService = null) { FullExePath = fullExePath; Exe = exe; var command = new AnonymousCommand(Run) { - Name = Properties.Resources.generic_run_command, + Name = ResourceLoaderInstance.GetString("generic_run_command"), Result = CommandResult.Dismiss(), }; Command = command; @@ -46,6 +52,7 @@ internal sealed partial class RunExeItem : ListItem }); _addToHistory = addToHistory; + _telemetryService = telemetryService; UpdateArgs(args); @@ -53,13 +60,13 @@ internal sealed partial class RunExeItem : ListItem new CommandContextItem( new AnonymousCommand(RunAsAdmin) { - Name = Properties.Resources.cmd_run_as_administrator, + Name = ResourceLoaderInstance.GetString("cmd_run_as_administrator"), Icon = Icons.AdminIcon, }) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Enter) }, new CommandContextItem( new AnonymousCommand(RunAsOther) { - Name = Properties.Resources.cmd_run_as_user, + Name = ResourceLoaderInstance.GetString("cmd_run_as_user"), Icon = Icons.UserIcon, }) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.U) }, ]; @@ -97,20 +104,26 @@ internal sealed partial class RunExeItem : ListItem { _addToHistory?.Invoke(FullString); - ShellHelpers.OpenInShell(FullExePath, _args); + var success = ShellHelpers.OpenInShell(FullExePath, _args); + + _telemetryService?.LogRunCommand(FullString, false, success); } public void RunAsAdmin() { _addToHistory?.Invoke(FullString); - ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.Administrator); + var success = ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.Administrator); + + _telemetryService?.LogRunCommand(FullString, true, success); } public void RunAsOther() { _addToHistory?.Invoke(FullString); - ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.OtherUser); + var success = ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.OtherUser); + + _telemetryService?.LogRunCommand(FullString, false, success); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs index f513c7f058..2735e77000 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs @@ -2,15 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Ext.Shell.Helpers; -using Microsoft.CmdPal.Ext.Shell.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -18,13 +11,13 @@ namespace Microsoft.CmdPal.Ext.Shell.Pages; internal sealed partial class ShellListPage : DynamicListPage, IDisposable { - private readonly ShellListPageHelpers _helper; - - private readonly List _topLevelItems = []; private readonly Dictionary _historyItems = []; private readonly List _currentHistoryItems = []; private readonly IRunHistoryService _historyService; + private readonly ITelemetryService? _telemetryService; + + private readonly Dictionary _currentPathItems = new(); private ListItem? _exeItem; private List _pathItems = []; @@ -35,27 +28,26 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable private bool _loadedInitialHistory; - public ShellListPage(ISettingsInterface settingsManager, IRunHistoryService runHistoryService, bool addBuiltins = false) + private string _currentSubdir = string.Empty; + + public ShellListPage( + ISettingsInterface settingsManager, + IRunHistoryService runHistoryService, + ITelemetryService? telemetryService) { Icon = Icons.RunV2Icon; Id = "com.microsoft.cmdpal.shell"; - Name = Resources.cmd_plugin_name; - PlaceholderText = Resources.list_placeholder_text; - _helper = new(settingsManager); + Name = ResourceLoaderInstance.GetString("cmd_plugin_name"); + PlaceholderText = ResourceLoaderInstance.GetString("list_placeholder_text"); _historyService = runHistoryService; + _telemetryService = telemetryService; EmptyContent = new CommandItem() { - Title = Resources.cmd_plugin_name, + Title = ResourceLoaderInstance.GetString("cmd_plugin_name"), Icon = Icons.RunV2Icon, - Subtitle = Resources.list_placeholder_text, + Subtitle = ResourceLoaderInstance.GetString("list_placeholder_text"), }; - - if (addBuiltins) - { - // here, we _could_ add built-in providers if we wanted. links to apps, calc, etc. - // That would be a truly run-first experience - } } public override void UpdateSearchText(string oldSearch, string newSearch) @@ -123,8 +115,13 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable private async Task BuildListItemsForSearchAsync(string newSearch, CancellationToken cancellationToken) { + var timer = System.Diagnostics.Stopwatch.StartNew(); + // Check for cancellation at the start - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } // If the search text is the start of a path to a file (it might be a // UNC path), then we want to list all the files that start with that text: @@ -136,7 +133,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable var expanded = Environment.ExpandEnvironmentVariables(searchText); // Check for cancellation after environment expansion - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } // TODO we can be smarter about only re-reading the filesystem if the // new search is just the oldSearch+some chars @@ -206,7 +206,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable couldResolvePath = false; } - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } _pathItems.Clear(); @@ -221,7 +224,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable } // Check for cancellation before creating exe items - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } if (couldResolvePath && exeExists) { @@ -278,17 +284,31 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable _currentHistoryItems.AddRange(filteredHistory); // Final cancellation check - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } + + timer.Stop(); + _telemetryService?.LogRunQuery(newSearch, GetItems().Length, (ulong)timer.ElapsedMilliseconds); } - private static ListItem PathToListItem(string path, string originalPath, string args = "", Action? addToHistory = null) + private static ListItem PathToListItem(string path, string originalPath, string args = "", Action? addToHistory = null, ITelemetryService? telemetryService = null) { - var pathItem = new PathListItem(path, originalPath, addToHistory); + var pathItem = new PathListItem(path, originalPath, addToHistory, telemetryService); + + if (pathItem.IsDirectory) + { + return pathItem; + } // Is this path an executable? If so, then make a RunExeItem if (IsExecutable(path)) { - var exeItem = new RunExeItem(Path.GetFileName(path), args, path, addToHistory); + var exeItem = new RunExeItem(Path.GetFileName(path), args, path, addToHistory, telemetryService) + { + TextToSuggest = path, + }; exeItem.MoreCommands = [ .. exeItem.MoreCommands, @@ -306,24 +326,22 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable LoadInitialHistory(); } - var filteredTopLevel = ListHelpers.FilterList(_topLevelItems, SearchText); List uriItems = _uriItem is not null ? [_uriItem] : []; List exeItems = _exeItem is not null ? [_exeItem] : []; return exeItems - .Concat(filteredTopLevel) .Concat(_currentHistoryItems) .Concat(_pathItems) .Concat(uriItems) .ToArray(); } - internal static ListItem CreateExeItem(string exe, string args, string fullExePath, Action? addToHistory) + internal static ListItem CreateExeItem(string exe, string args, string fullExePath, Action? addToHistory, ITelemetryService? telemetryService) { // PathToListItem will return a RunExeItem if it can find a executable. // It will ALSO add the file search commands to the RunExeItem. - return PathToListItem(fullExePath, exe, args, addToHistory); + return PathToListItem(fullExePath, exe, args, addToHistory, telemetryService); } private void CreateAndAddExeItems(string exe, string args, string fullExePath) @@ -335,7 +353,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable } else { - _exeItem = CreateExeItem(exe, args, fullExePath, AddToHistory); + _exeItem = CreateExeItem(exe, args, fullExePath, AddToHistory, _telemetryService); } } @@ -389,7 +407,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable } // Check for cancellation before directory operations - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } var dirExists = Directory.Exists(directoryPath); @@ -408,30 +429,71 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable if (dirExists) { // Check for cancellation before file system enumeration - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } + + if (directoryPath == _currentSubdir) + { + // Filter the items we already had + var fuzzyString = searchPattern.TrimEnd('*'); + var newMatchedPathItems = new List(); + + foreach (var kv in _currentPathItems) + { + var score = string.IsNullOrEmpty(fuzzyString) ? + 1 : + FuzzyStringMatcher.ScoreFuzzy(fuzzyString, kv.Key); + if (score > 0) + { + newMatchedPathItems.Add(kv.Value); + } + } + + ListHelpers.InPlaceUpdateList(_pathItems, newMatchedPathItems); + return; + } // Get all the files in the directory that start with the search text // Run this on a background thread to avoid blocking var files = await Task.Run(() => Directory.GetFileSystemEntries(directoryPath, searchPattern), cancellationToken); // Check for cancellation after file enumeration - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } var searchPathTrailer = trimmed.Remove(0, Math.Min(directoryPath.Length, trimmed.Length)); - var originalBeginning = originalPath.Remove(originalPath.Length - searchPathTrailer.Length); + var originalBeginning = originalPath.EndsWith(searchPathTrailer, StringComparison.CurrentCultureIgnoreCase) ? + originalPath.Remove(originalPath.Length - searchPathTrailer.Length) : + originalPath; + if (isDriveRoot) { originalBeginning = string.Concat(originalBeginning, '\\'); } // Create a list of commands for each file - var commands = files.Select(f => PathToListItem(f, originalBeginning)).ToList(); + var newPathItems = files + .Select(f => PathToListItem(f, originalBeginning)) + .ToDictionary(item => item.Title, item => item); // Final cancellation check before updating results - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } // Add the commands to the list - _pathItems = commands; + _pathItems = newPathItems.Values.ToList(); + _currentSubdir = directoryPath; + _currentPathItems.Clear(); + foreach ((var k, IListItem v) in newPathItems) + { + _currentPathItems[k] = (ListItem)v; + } } else { @@ -458,7 +520,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable { var hist = _historyService.GetRunHistory(); var histItems = hist - .Select(h => (h, ShellListPageHelpers.ListItemForCommandString(h, AddToHistory))) + .Select(h => (h, ShellListPageHelpers.ListItemForCommandString(h, AddToHistory, _telemetryService))) .Where(tuple => tuple.Item2 is not null) .Select(tuple => (tuple.h, tuple.Item2!)) .ToList(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/ResourceLoaderInstance.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/ResourceLoaderInstance.cs new file mode 100644 index 0000000000..75e5d64dc3 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/ResourceLoaderInstance.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Shell; + +internal static class ResourceLoaderInstance +{ + public static string GetString(string resourceKey) + { + return Properties.Resources.ResourceManager.GetString(resourceKey, Properties.Resources.Culture) ?? throw new InvalidOperationException($"Resource key '{resourceKey}' not found."); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Settings/ISettingsInterface.cs similarity index 100% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ISettingsInterface.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Settings/ISettingsInterface.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Settings/SettingsManager.cs similarity index 100% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/SettingsManager.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Settings/SettingsManager.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs index 0fba7dfa8a..943a3a1c8f 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs @@ -19,19 +19,21 @@ public partial class ShellCommandsProvider : CommandProvider private readonly ShellListPage _shellListPage; private readonly FallbackCommandItem _fallbackItem; private readonly IRunHistoryService _historyService; + private readonly ITelemetryService _telemetryService; - public ShellCommandsProvider(IRunHistoryService runHistoryService) + public ShellCommandsProvider(IRunHistoryService runHistoryService, ITelemetryService telemetryService) { _historyService = runHistoryService; + _telemetryService = telemetryService; Id = "com.microsoft.cmdpal.builtin.run"; DisplayName = Resources.cmd_plugin_name; Icon = Icons.RunV2Icon; Settings = _settingsManager.Settings; - _shellListPage = new ShellListPage(_settingsManager, _historyService); + _shellListPage = new ShellListPage(_settingsManager, _historyService, _telemetryService); - _fallbackItem = new FallbackExecuteItem(_settingsManager, _shellListPage.AddToHistory); + _fallbackItem = new FallbackExecuteItem(_settingsManager, _shellListPage.AddToHistory, _telemetryService); _shellPageItem = new CommandItem(_shellListPage) {