Compare commits

...

12 Commits

Author SHA1 Message Date
Mike Griese
fd9f523d5d Merge branch 'dev/migrie/b/core-run-nits' into dev/migrie/f/run-telem 2025-09-30 13:16:47 -05:00
Mike Griese
de5fee2ca6 Merge remote-tracking branch 'origin/main' into dev/migrie/b/core-run-nits 2025-09-30 13:08:26 -05:00
Mike Griese
5da2e74622 aot compatible 2025-09-30 12:15:11 -05:00
Mike Griese
d43a23c745 throwing considered harmful 2025-09-30 05:49:39 -05:00
Mike Griese
ffae061135 just move your around 2025-09-30 05:39:29 -05:00
Mike Griese
1e7c60ec23 lets move you 2025-09-29 16:47:35 -05:00
Mike Griese
2a5c61ce1f lots of cleanup 2025-09-29 16:41:44 -05:00
Mike Griese
fdfffdc256 sync this up a little 2025-09-29 16:35:47 -05:00
Mike Griese
f249f99694 wire it all up 2025-09-29 16:14:42 -05:00
Mike Griese
28c0d4a420 mock out 2025-09-29 15:36:24 -05:00
Mike Griese
b0f6e0ae87 didn't need this 2025-09-29 10:15:02 -05:00
Mike Griese
8cb518b649 CmdPal: collection of Run Commands nits
* 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)
2025-09-29 10:12:29 -05:00
21 changed files with 415 additions and 453 deletions

View File

@@ -2,8 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System.Collections.Generic;
namespace Microsoft.CmdPal.Core.Common.Services; namespace Microsoft.CmdPal.Core.Common.Services;
public interface IRunHistoryService public interface IRunHistoryService
@@ -25,3 +23,12 @@ public interface IRunHistoryService
/// <param name="item">The run history item to add.</param> /// <param name="item">The run history item to add.</param>
void AddRunHistoryItem(string item); 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);
}

View File

@@ -160,7 +160,7 @@ public partial class App : Application
services.AddSingleton<IRootPageService, PowerToysRootPageService>(); services.AddSingleton<IRootPageService, PowerToysRootPageService>();
services.AddSingleton<IAppHostService, PowerToysAppHostService>(); services.AddSingleton<IAppHostService, PowerToysAppHostService>();
services.AddSingleton(new TelemetryForwarder()); services.AddSingleton<ITelemetryService, TelemetryForwarder>();
// ViewModels // ViewModels
services.AddSingleton<ShellViewModel>(); services.AddSingleton<ShellViewModel>();

View File

@@ -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

View File

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.Events; using Microsoft.CmdPal.UI.Events;
using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry;
@@ -19,6 +20,7 @@ namespace Microsoft.CmdPal.UI;
/// or something similar, but this works for now. /// or something similar, but this works for now.
/// </summary> /// </summary>
internal sealed class TelemetryForwarder : internal sealed class TelemetryForwarder :
ITelemetryService,
IRecipient<BeginInvokeMessage>, IRecipient<BeginInvokeMessage>,
IRecipient<CmdPalInvokeResultMessage> IRecipient<CmdPalInvokeResultMessage>
{ {
@@ -37,4 +39,19 @@ internal sealed class TelemetryForwarder :
{ {
PowerToysTelemetry.Log.WriteEvent(new BeginInvoke()); 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));
}
} }

View File

@@ -2,7 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -83,7 +82,7 @@ public class QueryTests : CommandPaletteUnitTestBase
var settings = Settings.CreateDefaultSettings(); var settings = Settings.CreateDefaultSettings();
var mockHistory = CreateMockHistoryService(); var mockHistory = CreateMockHistoryService();
var pages = new ShellListPage(settings, mockHistory.Object); var pages = new ShellListPage(settings, mockHistory.Object, telemetryService: null);
await UpdatePageAndWaitForItems(pages, () => await UpdatePageAndWaitForItems(pages, () =>
{ {
@@ -115,7 +114,7 @@ public class QueryTests : CommandPaletteUnitTestBase
var settings = Settings.CreateDefaultSettings(); var settings = Settings.CreateDefaultSettings();
var mockHistoryService = CreateMockHistoryServiceWithCommonCommands(); var mockHistoryService = CreateMockHistoryServiceWithCommonCommands();
var pages = new ShellListPage(settings, mockHistoryService.Object); var pages = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
await UpdatePageAndWaitForItems(pages, () => await UpdatePageAndWaitForItems(pages, () =>
{ {
@@ -141,7 +140,7 @@ public class QueryTests : CommandPaletteUnitTestBase
var settings = Settings.CreateDefaultSettings(); var settings = Settings.CreateDefaultSettings();
var mockHistoryService = CreateMockHistoryServiceWithCommonCommands(); var mockHistoryService = CreateMockHistoryServiceWithCommonCommands();
var pages = new ShellListPage(settings, mockHistoryService.Object); var pages = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
await UpdatePageAndWaitForItems(pages, () => await UpdatePageAndWaitForItems(pages, () =>
{ {

View File

@@ -16,7 +16,7 @@ public class ShellCommandProviderTests
{ {
// Setup // Setup
var mockHistoryService = new Mock<IRunHistoryService>(); var mockHistoryService = new Mock<IRunHistoryService>();
var provider = new ShellCommandsProvider(mockHistoryService.Object); var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null);
// Assert // Assert
Assert.IsNotNull(provider.DisplayName); Assert.IsNotNull(provider.DisplayName);
@@ -28,7 +28,7 @@ public class ShellCommandProviderTests
{ {
// Setup // Setup
var mockHistoryService = new Mock<IRunHistoryService>(); var mockHistoryService = new Mock<IRunHistoryService>();
var provider = new ShellCommandsProvider(mockHistoryService.Object); var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null);
// Assert // Assert
Assert.IsNotNull(provider.Icon); Assert.IsNotNull(provider.Icon);
@@ -39,7 +39,7 @@ public class ShellCommandProviderTests
{ {
// Setup // Setup
var mockHistoryService = new Mock<IRunHistoryService>(); var mockHistoryService = new Mock<IRunHistoryService>();
var provider = new ShellCommandsProvider(mockHistoryService.Object); var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null);
// Act // Act
var commands = provider.TopLevelCommands(); var commands = provider.TopLevelCommands();

View File

@@ -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<ProcessStartInfo, Process?> 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<ExecutionShell>(_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();
}
}

View File

@@ -2,9 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System; using Microsoft.CmdPal.Core.Common.Services;
using System.IO;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Shell; namespace Microsoft.CmdPal.Ext.Shell;
@@ -13,18 +11,33 @@ internal sealed partial class OpenUrlWithHistoryCommand : OpenUrlCommand
{ {
private readonly Action<string>? _addToHistory; private readonly Action<string>? _addToHistory;
private readonly string _url; private readonly string _url;
private readonly ITelemetryService? _telemetryService;
public OpenUrlWithHistoryCommand(string url, Action<string>? addToHistory = null) public OpenUrlWithHistoryCommand(string url, Action<string>? addToHistory = null, ITelemetryService? telemetryService = null)
: base(url) : base(url)
{ {
_addToHistory = addToHistory; _addToHistory = addToHistory;
_url = url; _url = url;
_telemetryService = telemetryService;
} }
public override CommandResult Invoke() public override CommandResult Invoke()
{ {
_addToHistory?.Invoke(_url); _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();
} }
} }

View File

@@ -2,14 +2,9 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System; using Microsoft.CmdPal.Core.Common.Services;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Shell.Helpers; using Microsoft.CmdPal.Ext.Shell.Helpers;
using Microsoft.CmdPal.Ext.Shell.Pages; using Microsoft.CmdPal.Ext.Shell.Pages;
using Microsoft.CmdPal.Ext.Shell.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Shell; namespace Microsoft.CmdPal.Ext.Shell;
@@ -19,18 +14,20 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
private static readonly char[] _systemDirectoryRoots = ['\\', '/']; private static readonly char[] _systemDirectoryRoots = ['\\', '/'];
private readonly Action<string>? _addToHistory; private readonly Action<string>? _addToHistory;
private readonly ITelemetryService _telemetryService;
private CancellationTokenSource? _cancellationTokenSource; private CancellationTokenSource? _cancellationTokenSource;
private Task? _currentUpdateTask; private Task? _currentUpdateTask;
public FallbackExecuteItem(SettingsManager settings, Action<string>? addToHistory) public FallbackExecuteItem(SettingsManager settings, Action<string>? addToHistory, ITelemetryService telemetryService)
: base( : base(
new NoOpCommand() { Id = "com.microsoft.run.fallback" }, new NoOpCommand() { Id = "com.microsoft.run.fallback" },
Resources.shell_command_display_title) ResourceLoaderInstance.GetString("shell_command_display_title"))
{ {
Title = string.Empty; 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. Icon = Icons.RunV2Icon; // Defined in Icons.cs and contains the execute command icon.
_addToHistory = addToHistory; _addToHistory = addToHistory;
_telemetryService = telemetryService;
} }
public override void UpdateQuery(string query) public override void UpdateQuery(string query)
@@ -147,7 +144,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
if (exeExists) if (exeExists)
{ {
// TODO we need to probably get rid of the settings for this provider entirely // 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; Title = exeItem.Title;
Subtitle = exeItem.Subtitle; Subtitle = exeItem.Subtitle;
Icon = exeItem.Icon; Icon = exeItem.Icon;
@@ -156,7 +153,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
} }
else if (pathIsDir) else if (pathIsDir)
{ {
var pathItem = new PathListItem(exe, query, _addToHistory); var pathItem = new PathListItem(exe, query, _addToHistory, _telemetryService);
Command = pathItem.Command; Command = pathItem.Command;
MoreCommands = pathItem.MoreCommands; MoreCommands = pathItem.MoreCommands;
Title = pathItem.Title; 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)) 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; Title = searchText;
} }
else else

View File

@@ -2,10 +2,10 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text; using System.Text;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.Storage.FileSystem;
namespace Microsoft.CmdPal.Ext.Shell.Helpers; namespace Microsoft.CmdPal.Ext.Shell.Helpers;
@@ -19,38 +19,11 @@ namespace Microsoft.CmdPal.Ext.Shell.Helpers;
public static class CommandLineNormalizer public static class CommandLineNormalizer
{ {
#pragma warning disable SA1310 // Field names should not contain underscore #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 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 #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);
/// <summary> /// <summary>
/// Normalizes a command line string by expanding environment variables, resolving executable paths, /// Normalizes a command line string by expanding environment variables, resolving executable paths,
/// and standardizing the format for comparison purposes. /// and standardizing the format for comparison purposes.
@@ -129,9 +102,9 @@ public static class CommandLineNormalizer
private static string ExpandEnvironmentVariables(string input) private static string ExpandEnvironmentVariables(string input)
{ {
const int initialBufferSize = 1024; 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) if (result == 0)
{ {
@@ -139,11 +112,11 @@ public static class CommandLineNormalizer
return input; return input;
} }
if (result > buffer.Capacity) if (result > buffer.Length)
{ {
// Buffer was too small, resize and try again // Buffer was too small, resize and try again
buffer.Capacity = (int)result; buffer = new char[result];
result = ExpandEnvironmentStringsW(input, buffer, (uint)buffer.Capacity); result = PInvoke.ExpandEnvironmentStrings(input, buffer);
if (result == 0) 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
} }
/// <summary> /// <summary>
@@ -159,28 +132,30 @@ public static class CommandLineNormalizer
/// </summary> /// </summary>
private static string[] ParseCommandLineToArguments(string commandLine) private static string[] ParseCommandLineToArguments(string commandLine)
{ {
var argv = CommandLineToArgvW(commandLine, out var argc); unsafe
if (argv == IntPtr.Zero || argc == 0)
{ {
return Array.Empty<string>(); var argv = PInvoke.CommandLineToArgv(commandLine, out var argc);
}
try if (argv == null || argc == 0)
{
var args = new string[argc];
for (var i = 0; i < argc; i++)
{ {
var argPtr = Marshal.ReadIntPtr(argv, i * IntPtr.Size); return Array.Empty<string>();
args[i] = Marshal.PtrToStringUni(argPtr) ?? string.Empty;
} }
return args; try
} {
finally var args = new string[argc];
{
LocalFree(argv); 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
/// </summary> /// </summary>
private static string TryResolveExecutable(string executableName) private static string TryResolveExecutable(string executableName)
{ {
var buffer = new StringBuilder(MAX_PATH); var buffer = new char[MAX_PATH];
var result = SearchPathW( unsafe
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)
{ {
return string.Empty; var outParam = default(PWSTR); // ultimately discarded
}
if (result > buffer.Capacity) var result = PInvoke.SearchPath(
{ null, // Use default search path
// Buffer was too small, resize and try again executableName,
buffer.Capacity = (int)result; ".exe", // Default extension
result = SearchPathW(null, executableName, ".exe", (uint)buffer.Capacity, buffer, out var _); buffer,
&outParam); // We don't need the file part
if (result == 0) if (result == 0)
{ {
return string.Empty; 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;
} }
/// <summary> /// <summary>

View File

@@ -2,13 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System; using Microsoft.CmdPal.Core.Common.Services;
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.Ext.Shell.Pages; using Microsoft.CmdPal.Ext.Shell.Pages;
using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -16,37 +10,6 @@ namespace Microsoft.CmdPal.Ext.Shell.Helpers;
public class ShellListPageHelpers 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<CommandContextItem> LoadContextMenus(ListItem listItem)
{
var resultList = new List<CommandContextItem>
{
new(new ExecuteItem(listItem.Title, _settings, RunAsType.Administrator)),
new(new ExecuteItem(listItem.Title, _settings, RunAsType.OtherUser )),
};
return resultList;
}
internal static bool FileExistInPath(string filename) internal static bool FileExistInPath(string filename)
{ {
return FileExistInPath(filename, out var _); return FileExistInPath(filename, out var _);
@@ -58,7 +21,7 @@ public class ShellListPageHelpers
return ShellHelpers.FileExistInPath(filename, out fullPath, token ?? CancellationToken.None); return ShellHelpers.FileExistInPath(filename, out fullPath, token ?? CancellationToken.None);
} }
internal static ListItem? ListItemForCommandString(string query, Action<string>? addToHistory) internal static ListItem? ListItemForCommandString(string query, Action<string>? addToHistory, ITelemetryService? telemetryService)
{ {
var li = new ListItem(); var li = new ListItem();
@@ -100,7 +63,7 @@ public class ShellListPageHelpers
if (exeExists) if (exeExists)
{ {
// TODO we need to probably get rid of the settings for this provider entirely // 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.Command = exeItem.Command;
li.Title = exeItem.Title; li.Title = exeItem.Title;
li.Subtitle = exeItem.Subtitle; li.Subtitle = exeItem.Subtitle;
@@ -109,7 +72,7 @@ public class ShellListPageHelpers
} }
else if (pathIsDir) else if (pathIsDir)
{ {
var pathItem = new PathListItem(exe, query, addToHistory); var pathItem = new PathListItem(exe, query, addToHistory, telemetryService);
li.Command = pathItem.Command; li.Command = pathItem.Command;
li.Title = pathItem.Title; li.Title = pathItem.Title;
li.Subtitle = pathItem.Subtitle; li.Subtitle = pathItem.Subtitle;
@@ -118,7 +81,7 @@ public class ShellListPageHelpers
} }
else if (System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri)) 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; li.Title = searchText;
} }
else else

View File

@@ -1,11 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> <Import Project="..\..\CoreCommonProps.props" />
<Import Project="..\..\..\..\Common.Dotnet.AotCompatibility.props" />
<Import Project="..\Common.ExtDependencies.props" />
<PropertyGroup> <PropertyGroup>
<Nullable>enable</Nullable>
<RootNamespace>Microsoft.CmdPal.Ext.Shell</RootNamespace> <RootNamespace>Microsoft.CmdPal.Ext.Shell</RootNamespace>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
@@ -16,7 +12,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" /> <ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" />
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="System.CommandLine" /> <PackageReference Include="System.CommandLine" />

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://aka.ms/CsWin32.schema.json",
"allowMarshaling": false,
"comInterop": {
"preserveSigMethods": [ "*" ]
}
}

View File

@@ -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

View File

@@ -2,9 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // 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.Commands;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.System; using Windows.System;
@@ -13,13 +12,18 @@ namespace Microsoft.CmdPal.Ext.Shell;
internal sealed partial class PathListItem : ListItem internal sealed partial class PathListItem : ListItem
{ {
private readonly Lazy<IconInfo> _icon; private readonly Lazy<bool> fetchedIcon;
private readonly bool _isDirectory; 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<string>? addToHistory) private IIconInfo? _icon;
: base(new OpenUrlWithHistoryCommand(path, addToHistory))
internal bool IsDirectory => isDirectory;
public PathListItem(string path, string originalDir, Action<string>? addToHistory, ITelemetryService? telemetryService = null)
: base(new OpenUrlWithHistoryCommand(path, addToHistory, telemetryService))
{ {
var fileName = Path.GetFileName(path); var fileName = Path.GetFileName(path);
if (string.IsNullOrEmpty(fileName)) if (string.IsNullOrEmpty(fileName))
@@ -27,8 +31,8 @@ internal sealed partial class PathListItem : ListItem
fileName = Path.GetFileName(Path.GetDirectoryName(path)) ?? string.Empty; fileName = Path.GetFileName(Path.GetDirectoryName(path)) ?? string.Empty;
} }
_isDirectory = Directory.Exists(path); isDirectory = Directory.Exists(path);
if (_isDirectory) if (isDirectory)
{ {
if (!path.EndsWith('\\')) 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 Title = fileName; // Just the name of the file is the Title
Subtitle = path; // What the user typed is the subtitle Subtitle = path; // What the user typed is the subtitle
@@ -58,23 +64,35 @@ internal sealed partial class PathListItem : ListItem
// wrap it in quotes // wrap it in quotes
suggestion = string.Concat("\"", suggestion, "\""); suggestion = string.Concat("\"", suggestion, "\"");
} }
else
{
suggestion = path;
}
TextToSuggest = suggestion; TextToSuggest = suggestion;
MoreCommands = [ MoreCommands = [
new CommandContextItem(new OpenWithCommand(path)), new CommandContextItem(new OpenWithCommand(path)),
new CommandContextItem(new ShowFileInFolderCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.E) }, 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 OpenInConsoleCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.R) },
new CommandContextItem(new OpenPropertiesCommand(path)), new CommandContextItem(new OpenPropertiesCommand(path)),
]; ];
_icon = new Lazy<IconInfo>(() => fetchedIcon = new Lazy<bool>(() =>
{ {
var iconStream = ThumbnailHelper.GetThumbnail(path).Result; _ = Task.Run(FetchIconAsync);
var icon = iconStream is not null ? IconInfo.FromStream(iconStream) : return true;
_isDirectory ? Icons.FolderIcon : Icons.RunV2Icon;
return icon;
}); });
} }
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));
}
} }

View File

@@ -2,8 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System; using Microsoft.CmdPal.Core.Common.Services;
using System.Threading.Tasks; using Microsoft.CmdPal.Ext.Shell.Helpers;
using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Storage.Streams; using Windows.Storage.Streams;
@@ -15,6 +15,7 @@ internal sealed partial class RunExeItem : ListItem
{ {
private readonly Lazy<IconInfo> _icon; private readonly Lazy<IconInfo> _icon;
private readonly Action<string>? _addToHistory; private readonly Action<string>? _addToHistory;
private readonly ITelemetryService? _telemetryService;
public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; } 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}"; private string FullString => string.IsNullOrEmpty(_args) ? Exe : $"{Exe} {_args}";
public RunExeItem(string exe, string args, string fullExePath, Action<string>? addToHistory) public RunExeItem(
string exe,
string args,
string fullExePath,
Action<string>? addToHistory,
ITelemetryService? telemetryService = null)
{ {
FullExePath = fullExePath; FullExePath = fullExePath;
Exe = exe; Exe = exe;
var command = new AnonymousCommand(Run) var command = new AnonymousCommand(Run)
{ {
Name = Properties.Resources.generic_run_command, Name = ResourceLoaderInstance.GetString("generic_run_command"),
Result = CommandResult.Dismiss(), Result = CommandResult.Dismiss(),
}; };
Command = command; Command = command;
@@ -46,6 +52,7 @@ internal sealed partial class RunExeItem : ListItem
}); });
_addToHistory = addToHistory; _addToHistory = addToHistory;
_telemetryService = telemetryService;
UpdateArgs(args); UpdateArgs(args);
@@ -53,13 +60,13 @@ internal sealed partial class RunExeItem : ListItem
new CommandContextItem( new CommandContextItem(
new AnonymousCommand(RunAsAdmin) new AnonymousCommand(RunAsAdmin)
{ {
Name = Properties.Resources.cmd_run_as_administrator, Name = ResourceLoaderInstance.GetString("cmd_run_as_administrator"),
Icon = Icons.AdminIcon, Icon = Icons.AdminIcon,
}) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Enter) }, }) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Enter) },
new CommandContextItem( new CommandContextItem(
new AnonymousCommand(RunAsOther) new AnonymousCommand(RunAsOther)
{ {
Name = Properties.Resources.cmd_run_as_user, Name = ResourceLoaderInstance.GetString("cmd_run_as_user"),
Icon = Icons.UserIcon, Icon = Icons.UserIcon,
}) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.U) }, }) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.U) },
]; ];
@@ -97,20 +104,26 @@ internal sealed partial class RunExeItem : ListItem
{ {
_addToHistory?.Invoke(FullString); _addToHistory?.Invoke(FullString);
ShellHelpers.OpenInShell(FullExePath, _args); var success = ShellHelpers.OpenInShell(FullExePath, _args);
_telemetryService?.LogRunCommand(FullString, false, success);
} }
public void RunAsAdmin() public void RunAsAdmin()
{ {
_addToHistory?.Invoke(FullString); _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() public void RunAsOther()
{ {
_addToHistory?.Invoke(FullString); _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);
} }
} }

View File

@@ -2,15 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // 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.Core.Common.Services;
using Microsoft.CmdPal.Ext.Shell.Helpers; using Microsoft.CmdPal.Ext.Shell.Helpers;
using Microsoft.CmdPal.Ext.Shell.Properties;
using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -18,13 +11,13 @@ namespace Microsoft.CmdPal.Ext.Shell.Pages;
internal sealed partial class ShellListPage : DynamicListPage, IDisposable internal sealed partial class ShellListPage : DynamicListPage, IDisposable
{ {
private readonly ShellListPageHelpers _helper;
private readonly List<ListItem> _topLevelItems = [];
private readonly Dictionary<string, ListItem> _historyItems = []; private readonly Dictionary<string, ListItem> _historyItems = [];
private readonly List<ListItem> _currentHistoryItems = []; private readonly List<ListItem> _currentHistoryItems = [];
private readonly IRunHistoryService _historyService; private readonly IRunHistoryService _historyService;
private readonly ITelemetryService? _telemetryService;
private readonly Dictionary<string, ListItem> _currentPathItems = new();
private ListItem? _exeItem; private ListItem? _exeItem;
private List<ListItem> _pathItems = []; private List<ListItem> _pathItems = [];
@@ -35,27 +28,26 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
private bool _loadedInitialHistory; 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; Icon = Icons.RunV2Icon;
Id = "com.microsoft.cmdpal.shell"; Id = "com.microsoft.cmdpal.shell";
Name = Resources.cmd_plugin_name; Name = ResourceLoaderInstance.GetString("cmd_plugin_name");
PlaceholderText = Resources.list_placeholder_text; PlaceholderText = ResourceLoaderInstance.GetString("list_placeholder_text");
_helper = new(settingsManager);
_historyService = runHistoryService; _historyService = runHistoryService;
_telemetryService = telemetryService;
EmptyContent = new CommandItem() EmptyContent = new CommandItem()
{ {
Title = Resources.cmd_plugin_name, Title = ResourceLoaderInstance.GetString("cmd_plugin_name"),
Icon = Icons.RunV2Icon, 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) 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) private async Task BuildListItemsForSearchAsync(string newSearch, CancellationToken cancellationToken)
{ {
var timer = System.Diagnostics.Stopwatch.StartNew();
// Check for cancellation at the start // 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 // 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: // 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); var expanded = Environment.ExpandEnvironmentVariables(searchText);
// Check for cancellation after environment expansion // 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 // TODO we can be smarter about only re-reading the filesystem if the
// new search is just the oldSearch+some chars // new search is just the oldSearch+some chars
@@ -206,7 +206,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
couldResolvePath = false; couldResolvePath = false;
} }
cancellationToken.ThrowIfCancellationRequested(); if (cancellationToken.IsCancellationRequested)
{
return;
}
_pathItems.Clear(); _pathItems.Clear();
@@ -221,7 +224,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
} }
// Check for cancellation before creating exe items // Check for cancellation before creating exe items
cancellationToken.ThrowIfCancellationRequested(); if (cancellationToken.IsCancellationRequested)
{
return;
}
if (couldResolvePath && exeExists) if (couldResolvePath && exeExists)
{ {
@@ -278,17 +284,31 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
_currentHistoryItems.AddRange(filteredHistory); _currentHistoryItems.AddRange(filteredHistory);
// Final cancellation check // 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<string>? addToHistory = null) private static ListItem PathToListItem(string path, string originalPath, string args = "", Action<string>? 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 // Is this path an executable? If so, then make a RunExeItem
if (IsExecutable(path)) 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 = [
.. exeItem.MoreCommands, .. exeItem.MoreCommands,
@@ -306,24 +326,22 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
LoadInitialHistory(); LoadInitialHistory();
} }
var filteredTopLevel = ListHelpers.FilterList(_topLevelItems, SearchText);
List<ListItem> uriItems = _uriItem is not null ? [_uriItem] : []; List<ListItem> uriItems = _uriItem is not null ? [_uriItem] : [];
List<ListItem> exeItems = _exeItem is not null ? [_exeItem] : []; List<ListItem> exeItems = _exeItem is not null ? [_exeItem] : [];
return return
exeItems exeItems
.Concat(filteredTopLevel)
.Concat(_currentHistoryItems) .Concat(_currentHistoryItems)
.Concat(_pathItems) .Concat(_pathItems)
.Concat(uriItems) .Concat(uriItems)
.ToArray(); .ToArray();
} }
internal static ListItem CreateExeItem(string exe, string args, string fullExePath, Action<string>? addToHistory) internal static ListItem CreateExeItem(string exe, string args, string fullExePath, Action<string>? addToHistory, ITelemetryService? telemetryService)
{ {
// PathToListItem will return a RunExeItem if it can find a executable. // PathToListItem will return a RunExeItem if it can find a executable.
// It will ALSO add the file search commands to the RunExeItem. // 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) private void CreateAndAddExeItems(string exe, string args, string fullExePath)
@@ -335,7 +353,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
} }
else 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 // Check for cancellation before directory operations
cancellationToken.ThrowIfCancellationRequested(); if (cancellationToken.IsCancellationRequested)
{
return;
}
var dirExists = Directory.Exists(directoryPath); var dirExists = Directory.Exists(directoryPath);
@@ -408,30 +429,71 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
if (dirExists) if (dirExists)
{ {
// Check for cancellation before file system enumeration // 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<ListItem>();
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 // Get all the files in the directory that start with the search text
// Run this on a background thread to avoid blocking // Run this on a background thread to avoid blocking
var files = await Task.Run(() => Directory.GetFileSystemEntries(directoryPath, searchPattern), cancellationToken); var files = await Task.Run(() => Directory.GetFileSystemEntries(directoryPath, searchPattern), cancellationToken);
// Check for cancellation after file enumeration // Check for cancellation after file enumeration
cancellationToken.ThrowIfCancellationRequested(); if (cancellationToken.IsCancellationRequested)
{
return;
}
var searchPathTrailer = trimmed.Remove(0, Math.Min(directoryPath.Length, trimmed.Length)); 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) if (isDriveRoot)
{ {
originalBeginning = string.Concat(originalBeginning, '\\'); originalBeginning = string.Concat(originalBeginning, '\\');
} }
// Create a list of commands for each file // 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 // Final cancellation check before updating results
cancellationToken.ThrowIfCancellationRequested(); if (cancellationToken.IsCancellationRequested)
{
return;
}
// Add the commands to the list // 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 else
{ {
@@ -458,7 +520,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
{ {
var hist = _historyService.GetRunHistory(); var hist = _historyService.GetRunHistory();
var histItems = hist 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) .Where(tuple => tuple.Item2 is not null)
.Select(tuple => (tuple.h, tuple.Item2!)) .Select(tuple => (tuple.h, tuple.Item2!))
.ToList(); .ToList();

View File

@@ -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.");
}
}

View File

@@ -19,19 +19,21 @@ public partial class ShellCommandsProvider : CommandProvider
private readonly ShellListPage _shellListPage; private readonly ShellListPage _shellListPage;
private readonly FallbackCommandItem _fallbackItem; private readonly FallbackCommandItem _fallbackItem;
private readonly IRunHistoryService _historyService; private readonly IRunHistoryService _historyService;
private readonly ITelemetryService _telemetryService;
public ShellCommandsProvider(IRunHistoryService runHistoryService) public ShellCommandsProvider(IRunHistoryService runHistoryService, ITelemetryService telemetryService)
{ {
_historyService = runHistoryService; _historyService = runHistoryService;
_telemetryService = telemetryService;
Id = "com.microsoft.cmdpal.builtin.run"; Id = "com.microsoft.cmdpal.builtin.run";
DisplayName = Resources.cmd_plugin_name; DisplayName = Resources.cmd_plugin_name;
Icon = Icons.RunV2Icon; Icon = Icons.RunV2Icon;
Settings = _settingsManager.Settings; 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) _shellPageItem = new CommandItem(_shellListPage)
{ {