diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs index 9cf15cb70e..57ae504dff 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs @@ -56,6 +56,8 @@ public partial class ListViewModel : PageViewModel, IDisposable public CommandItemViewModel EmptyContent { get; private set; } + public bool IsMainPage { get; init; } + private bool _isDynamic; private Task? _initializeItemsTask; @@ -370,6 +372,7 @@ public partial class ListViewModel : PageViewModel, IDisposable } TextToSuggest = item.TextToSuggest; + WeakReferenceMessenger.Default.Send(new(item.TextToSuggest)); }); _lastSelectedItem = item; @@ -423,6 +426,8 @@ public partial class ListViewModel : PageViewModel, IDisposable WeakReferenceMessenger.Default.Send(); + WeakReferenceMessenger.Default.Send(new(string.Empty)); + TextToSuggest = string.Empty; }); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateSuggestionMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateSuggestionMessage.cs new file mode 100644 index 0000000000..7e27056c4c --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateSuggestionMessage.cs @@ -0,0 +1,9 @@ +// 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.Core.ViewModels.Messages; + +public record UpdateSuggestionMessage(string TextToSuggest) +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs index 5be7c872c3..b0d0346f51 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -263,7 +263,7 @@ public partial class MainListPage : DynamicListPage, { nameMatch, descriptionMatch, - isFallback ? 1 : 0, // Always give fallbacks a chance... + isFallback ? 1 : 0, // Always give fallbacks a chance }; var max = scores.Max(); @@ -273,8 +273,7 @@ public partial class MainListPage : DynamicListPage, // above "git" from "whatever" max = max + extensionTitleMatch; - // ... but downweight them - var matchSomething = (max / (isFallback ? 3 : 1)) + var matchSomething = max + (isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0)); // If we matched title, subtitle, or alias (something real), then diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index 36c742ba7f..10abdf48fe 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -98,10 +98,12 @@ public partial class App : Application // Built-in Commands. Order matters - this is the order they'll be presented by default. var allApps = new AllAppsCommandProvider(); + var files = new IndexerCommandsProvider(); + files.SuppressFallbackWhen(ShellCommandsProvider.SuppressFileFallbackIf); services.AddSingleton(allApps); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(files); services.AddSingleton(); services.AddSingleton(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs index 761c69d724..415f5075ad 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.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.Diagnostics; using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.WinUI; using Microsoft.CmdPal.Core.ViewModels; @@ -21,6 +20,7 @@ namespace Microsoft.CmdPal.UI.Controls; public sealed partial class SearchBar : UserControl, IRecipient, IRecipient, + IRecipient, ICurrentPageAware { private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread(); @@ -31,6 +31,10 @@ public sealed partial class SearchBar : UserControl, private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); private bool _isBackspaceHeld; + private bool _inSuggestion; + private string? _lastText; + private string? _deletedSuggestion; + public PageViewModel? CurrentPageViewModel { get => (PageViewModel?)GetValue(CurrentPageViewModelProperty); @@ -69,6 +73,7 @@ public sealed partial class SearchBar : UserControl, this.InitializeComponent(); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); } public void ClearSearch() @@ -125,15 +130,6 @@ public sealed partial class SearchBar : UserControl, WeakReferenceMessenger.Default.Send(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom)); e.Handled = true; } - else if (e.Key == VirtualKey.Right) - { - if (CurrentPageViewModel != null && !string.IsNullOrEmpty(CurrentPageViewModel.TextToSuggest)) - { - FilterBox.Text = CurrentPageViewModel.TextToSuggest; - FilterBox.Select(FilterBox.Text.Length, 0); - e.Handled = true; - } - } else if (e.Key == VirtualKey.Escape) { if (string.IsNullOrEmpty(FilterBox.Text)) @@ -200,12 +196,65 @@ public sealed partial class SearchBar : UserControl, e.Handled = true; } + else if (e.Key == VirtualKey.Right) + { + if (_inSuggestion) + { + _inSuggestion = false; + _lastText = null; + DoFilterBoxUpdate(); + } + } else if (e.Key == VirtualKey.Down) { WeakReferenceMessenger.Default.Send(); e.Handled = true; } + + if (_inSuggestion) + { + if ( + e.Key == VirtualKey.Back || + e.Key == VirtualKey.Delete + ) + { + _deletedSuggestion = FilterBox.Text; + + FilterBox.Text = _lastText ?? string.Empty; + FilterBox.Select(FilterBox.Text.Length, 0); + + // Logger.LogInfo("deleting suggestion"); + _inSuggestion = false; + _lastText = null; + + e.Handled = true; + return; + } + + var ignoreLeave = + + e.Key == VirtualKey.Up || + e.Key == VirtualKey.Down || + + e.Key == VirtualKey.RightMenu || + e.Key == VirtualKey.LeftMenu || + e.Key == VirtualKey.Menu || + e.Key == VirtualKey.Shift || + e.Key == VirtualKey.RightShift || + e.Key == VirtualKey.LeftShift || + e.Key == VirtualKey.RightControl || + e.Key == VirtualKey.LeftControl || + e.Key == VirtualKey.Control; + if (ignoreLeave) + { + return; + } + + // Logger.LogInfo("leaving suggestion"); + _inSuggestion = false; + _lastText = null; + } } private void FilterBox_PreviewKeyUp(object sender, KeyRoutedEventArgs e) @@ -219,7 +268,7 @@ public sealed partial class SearchBar : UserControl, private void FilterBox_TextChanged(object sender, TextChangedEventArgs e) { - Debug.WriteLine($"FilterBox_TextChanged: {FilterBox.Text}"); + // Logger.LogInfo($"FilterBox_TextChanged: {FilterBox.Text}"); // TERRIBLE HACK TODO GH #245 // There's weird wacky bugs with debounce currently. We're trying @@ -228,23 +277,22 @@ public sealed partial class SearchBar : UserControl, // (otherwise aliases just stop working) if (FilterBox.Text.Length == 1) { - if (CurrentPageViewModel != null) - { - CurrentPageViewModel.Filter = FilterBox.Text; - } + DoFilterBoxUpdate(); return; } + if (_inSuggestion) + { + // Logger.LogInfo($"-- skipping, in suggestion --"); + return; + } + // TODO: We could encapsulate this in a Behavior if we wanted to bind to the Filter property. _debounceTimer.Debounce( () => { - // Actually plumb Filtering to the view model - if (CurrentPageViewModel != null) - { - CurrentPageViewModel.Filter = FilterBox.Text; - } + DoFilterBoxUpdate(); }, //// Couldn't find a good recommendation/resource for value here. PT uses 50ms as default, so that is a reasonable default //// This seems like a useful testing site for typing times: https://keyboardtester.info/keyboard-latency-test/ @@ -254,6 +302,21 @@ public sealed partial class SearchBar : UserControl, immediate: FilterBox.Text.Length <= 1); } + private void DoFilterBoxUpdate() + { + if (_inSuggestion) + { + // Logger.LogInfo($"--- skipping ---"); + return; + } + + // Actually plumb Filtering to the view model + if (CurrentPageViewModel != null) + { + CurrentPageViewModel.Filter = FilterBox.Text; + } + } + // Used to handle the case when a ListPage's `SearchText` may have changed private void Page_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { @@ -273,6 +336,8 @@ public sealed partial class SearchBar : UserControl, // ... Move the cursor to the end of the input FilterBox.Select(FilterBox.Text.Length, 0); } + + // TODO! deal with suggestion } else if (property == nameof(ListViewModel.InitialSearchText)) { @@ -290,4 +355,96 @@ public sealed partial class SearchBar : UserControl, public void Receive(GoHomeMessage message) => ClearSearch(); public void Receive(FocusSearchBoxMessage message) => FilterBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + + public void Receive(UpdateSuggestionMessage message) + { + var suggestion = message.TextToSuggest; + + _queue.TryEnqueue(new(() => + { + var clearSuggestion = string.IsNullOrEmpty(suggestion); + + if (clearSuggestion && _inSuggestion) + { + // Logger.LogInfo($"Cleared suggestion \"{_lastText}\" to {suggestion}"); + _inSuggestion = false; + FilterBox.Text = _lastText ?? string.Empty; + _lastText = null; + return; + } + + if (clearSuggestion) + { + _deletedSuggestion = null; + return; + } + + if (suggestion == _deletedSuggestion) + { + return; + } + else + { + _deletedSuggestion = null; + } + + var currentText = _lastText ?? FilterBox.Text; + + _lastText = currentText; + + // if (_inSuggestion) + // { + // Logger.LogInfo($"Suggestion from \"{_lastText}\" to {suggestion}"); + // } + // else + // { + // Logger.LogInfo($"Entering suggestion from \"{_lastText}\" to {suggestion}"); + // } + _inSuggestion = true; + + var matchedChars = 0; + var suggestionStartsWithQuote = suggestion.Length > 0 && suggestion[0] == '"'; + var currentStartsWithQuote = currentText.Length > 0 && currentText[0] == '"'; + var skipCheckingFirst = suggestionStartsWithQuote && !currentStartsWithQuote; + for (int i = skipCheckingFirst ? 1 : 0, j = 0; + i < suggestion.Length && j < currentText.Length; + i++, j++) + { + if (string.Equals( + suggestion[i].ToString(), + currentText[j].ToString(), + StringComparison.OrdinalIgnoreCase)) + { + matchedChars++; + } + else + { + break; + } + } + + var first = skipCheckingFirst ? "\"" : string.Empty; + var second = currentText.AsSpan(0, matchedChars); + var third = suggestion.AsSpan(matchedChars + (skipCheckingFirst ? 1 : 0)); + + var newText = string.Concat( + first, + second, + third); + + FilterBox.Text = newText; + + var wrappedInQuotes = suggestionStartsWithQuote && suggestion.Last() == '"'; + if (wrappedInQuotes) + { + FilterBox.Select( + (skipCheckingFirst ? 1 : 0) + matchedChars, + Math.Max(0, suggestion.Length - matchedChars - 1 + (skipCheckingFirst ? -1 : 0))); + } + else + { + FilterBox.Select(matchedChars, suggestion.Length - matchedChars); + } + })); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs index 997054b617..3c9cebcf3e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs @@ -265,6 +265,13 @@ public sealed partial class ListPage : Page, { ItemsList.SelectedIndex = 0; } + + // Always reset the selected item when the top-level list page changes + // its items + if (!sender.IsNested) + { + ItemsList.SelectedIndex = 0; + } } private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/TextBox.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/TextBox.xaml index 55b43a0c37..167636e8ec 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/TextBox.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/TextBox.xaml @@ -186,6 +186,8 @@ x:Load="False" AutomationProperties.AccessibilityView="Raw" CharacterSpacing="15" + FontFamily="{TemplateBinding FontFamily}" + FontSize="{TemplateBinding FontSize}" Foreground="{Binding PlaceholderForeground, RelativeSource={RelativeSource TemplatedParent}, TargetNullValue={ThemeResource TextControlPlaceholderForeground}}" Text="{TemplateBinding Description}" TextWrapping="{TemplateBinding TextWrapping}" /> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ResultHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ResultHelper.cs index e4780e4b62..d22ecc1612 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ResultHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ResultHelper.cs @@ -30,13 +30,14 @@ public static class ResultHelper var copyCommandItem = CreateResult(roundedResult, inputCulture, outputCulture, query); + // No TextToSuggest on the main save command item. We don't want to keep suggesting what the result is, + // as the user is typing it. return new ListItem(saveCommand) { // Using CurrentCulture since this is user facing Icon = Icons.ResultIcon, Title = result, Subtitle = query, - TextToSuggest = result, MoreCommands = [ new CommandContextItem(copyCommandItem.Command) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs index c1f867a4f3..88c4f77cc2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs @@ -23,6 +23,8 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, System private uint _queryCookie = 10; + private Func _suppressCallback; + public FallbackOpenFileItem() : base(_baseCommandWithId, Resources.Indexer_Find_Path_fallback_display_title) { @@ -44,6 +46,17 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, System return; } + if (_suppressCallback != null && _suppressCallback(query)) + { + Command = new NoOpCommand(); + Title = string.Empty; + Subtitle = string.Empty; + Icon = null; + MoreCommands = null; + + return; + } + if (Path.Exists(query)) { // Exit 1: The query is a direct path to a file. Great! Return it. @@ -128,4 +141,9 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, System _searchEngine.Dispose(); GC.SuppressFinalize(this); } + + public void SuppressFallbackWhen(Func callback) + { + _suppressCallback = callback; + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs index ab6584f673..d2ea9b9b0c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs @@ -2,6 +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 Microsoft.CmdPal.Ext.Indexer.Data; using Microsoft.CmdPal.Ext.Indexer.Properties; using Microsoft.CommandPalette.Extensions; @@ -41,4 +42,9 @@ public partial class IndexerCommandsProvider : CommandProvider [ _fallbackFileItem ]; + + public void SuppressFallbackWhen(Func callback) + { + _fallbackFileItem.SuppressFallbackWhen(callback); + } } 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 index 74ef0268de..5058b386b3 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs @@ -36,7 +36,7 @@ internal sealed partial class ExecuteItem : InvokableCommand else { Name = Properties.Resources.generic_run_command; - Icon = Icons.ReturnIcon; + Icon = Icons.RunV2Icon; } Cmd = cmd; @@ -44,36 +44,6 @@ internal sealed partial class ExecuteItem : InvokableCommand _runas = type; } - private static bool ExistInPath(string filename) - { - if (File.Exists(filename)) - { - return true; - } - else - { - var values = Environment.GetEnvironmentVariable("PATH"); - if (values != null) - { - foreach (var path in values.Split(';')) - { - var path1 = Path.Combine(path, filename); - var path2 = Path.Combine(path, filename + ".exe"); - if (File.Exists(path1) || File.Exists(path2)) - { - return true; - } - } - - return false; - } - else - { - return false; - } - } - } - private void Execute(Func startProcess, ProcessStartInfo info) { if (startProcess == null) @@ -184,7 +154,7 @@ internal sealed partial class ExecuteItem : InvokableCommand if (parts.Length == 2) { var filename = parts[0]; - if (ExistInPath(filename)) + if (ShellListPageHelpers.FileExistInPath(filename)) { var arguments = parts[1]; if (_settings.LeaveShellOpen) 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 437fbcbdf6..8e549d3141 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs @@ -2,39 +2,197 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Microsoft.CmdPal.Ext.Shell.Commands; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; 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; -internal sealed partial class FallbackExecuteItem : FallbackCommandItem +internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDisposable { - private readonly ExecuteItem _executeItem; - private readonly SettingsManager _settings; + private CancellationTokenSource? _cancellationTokenSource; + private Task? _currentUpdateTask; public FallbackExecuteItem(SettingsManager settings) : base( - new ExecuteItem(string.Empty, settings) { Id = "com.microsoft.run.fallback" }, + new NoOpCommand() { Id = "com.microsoft.run.fallback" }, Resources.shell_command_display_title) { - _settings = settings; - _executeItem = (ExecuteItem)this.Command!; Title = string.Empty; - _executeItem.Name = string.Empty; Subtitle = Properties.Resources.generic_run_command; Icon = Icons.RunV2Icon; // Defined in Icons.cs and contains the execute command icon. } public override void UpdateQuery(string query) { - _executeItem.Cmd = query; - _executeItem.Name = string.IsNullOrEmpty(query) ? string.Empty : Properties.Resources.generic_run_command; - Title = query; - MoreCommands = [ - new CommandContextItem(new ExecuteItem(query, _settings, RunAsType.Administrator)), - new CommandContextItem(new ExecuteItem(query, _settings, RunAsType.OtherUser)), - ]; + // Cancel any ongoing query processing + _cancellationTokenSource?.Cancel(); + + _cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = _cancellationTokenSource.Token; + + try + { + // Save the latest update task + _currentUpdateTask = DoUpdateQueryAsync(query, cancellationToken); + } + catch (OperationCanceledException) + { + // DO NOTHING HERE + return; + } + catch (Exception) + { + // Handle other exceptions + return; + } + + // Await the task to ensure only the latest one gets processed + _ = ProcessUpdateResultsAsync(_currentUpdateTask); + } + + private async Task ProcessUpdateResultsAsync(Task updateTask) + { + try + { + await updateTask; + } + catch (OperationCanceledException) + { + // Handle cancellation gracefully + } + catch (Exception) + { + // Handle other exceptions + } + } + + private async Task DoUpdateQueryAsync(string query, CancellationToken cancellationToken) + { + // Check for cancellation at the start + cancellationToken.ThrowIfCancellationRequested(); + + var searchText = query.Trim(); + var expanded = Environment.ExpandEnvironmentVariables(searchText); + searchText = expanded; + if (string.IsNullOrEmpty(searchText) || string.IsNullOrWhiteSpace(searchText)) + { + Command = null; + Title = string.Empty; + return; + } + + ShellListPage.ParseExecutableAndArgs(searchText, out var exe, out var args); + + // Check for cancellation before file system operations + cancellationToken.ThrowIfCancellationRequested(); + + var exeExists = false; + var fullExePath = string.Empty; + var pathIsDir = false; + + try + { + // Create a timeout for file system operations (200ms) + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + var timeoutToken = combinedCts.Token; + + // Use Task.Run with timeout for file system operations + var fileSystemTask = Task.Run( + () => + { + exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath); + pathIsDir = Directory.Exists(exe); + }, + CancellationToken.None); + + // Wait for either completion or timeout + await fileSystemTask.WaitAsync(timeoutToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Main cancellation token was cancelled, re-throw + throw; + } + catch (TimeoutException) + { + // Timeout occurred - use defaults + return; + } + catch (OperationCanceledException) + { + // Timeout occurred (from WaitAsync) - use defaults + return; + } + catch (Exception) + { + // Handle any other exceptions that might bubble up + return; + } + + // Check for cancellation before updating UI properties + cancellationToken.ThrowIfCancellationRequested(); + + if (exeExists) + { + // TODO we need to probably get rid of the settings for this provider entirely + var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath); + Title = exeItem.Title; + Subtitle = exeItem.Subtitle; + Icon = exeItem.Icon; + Command = exeItem.Command; + MoreCommands = exeItem.MoreCommands; + } + else if (pathIsDir) + { + var pathItem = new PathListItem(exe, query); + Title = pathItem.Title; + Subtitle = pathItem.Subtitle; + Icon = pathItem.Icon; + Command = pathItem.Command; + MoreCommands = pathItem.MoreCommands; + } + else if (System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri)) + { + Command = new OpenUrlCommand(searchText) { Result = CommandResult.Dismiss() }; + Title = searchText; + } + else + { + Command = null; + Title = string.Empty; + } + + // Final cancellation check + cancellationToken.ThrowIfCancellationRequested(); + } + + public void Dispose() + { + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + } + + internal static bool SuppressFileFallbackIf(string query) + { + var searchText = query.Trim(); + var expanded = Environment.ExpandEnvironmentVariables(searchText); + searchText = expanded; + if (string.IsNullOrEmpty(searchText) || string.IsNullOrWhiteSpace(searchText)) + { + return false; + } + + ShellListPage.ParseExecutableAndArgs(searchText, out var exe, out var args); + var exeExists = ShellListPageHelpers.FileExistInPath(exe, out var fullExePath); + var pathIsDir = Directory.Exists(exe); + + return exeExists || pathIsDir; } } 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 1bb682f6bd..188efebcde 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 @@ -4,11 +4,9 @@ using System; using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Reflection; +using System.IO; using System.Text; -using System.Threading.Tasks; +using System.Threading; using Microsoft.CmdPal.Ext.Shell.Commands; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -26,7 +24,7 @@ public class ShellListPageHelpers private ListItem GetCurrentCmd(string cmd) { - ListItem result = new ListItem(new ExecuteItem(cmd, _settings)) + var result = new ListItem(new ExecuteItem(cmd, _settings)) { Title = cmd, Subtitle = Properties.Resources.cmd_plugin_name + ": " + Properties.Resources.cmd_execute_through_shell, @@ -36,58 +34,6 @@ public class ShellListPageHelpers return result; } - private List GetHistoryCmds(string cmd, ListItem result) - { - IEnumerable history = _settings.Count.Where(o => o.Key.Contains(cmd, StringComparison.CurrentCultureIgnoreCase)) - .OrderByDescending(o => o.Value) - .Select(m => - { - if (m.Key == cmd) - { - // Using CurrentCulture since this is user facing - result.Subtitle = Properties.Resources.cmd_plugin_name + ": " + string.Format(CultureInfo.CurrentCulture, CmdHasBeenExecutedTimes, m.Value); - return null; - } - - var ret = new ListItem(new ExecuteItem(m.Key, _settings)) - { - Title = m.Key, - - // Using CurrentCulture since this is user facing - Subtitle = Properties.Resources.cmd_plugin_name + ": " + string.Format(CultureInfo.CurrentCulture, CmdHasBeenExecutedTimes, m.Value), - Icon = Icons.HistoryIcon, - }; - return ret; - }).Where(o => o != null).Take(4); - return history.Select(o => o!).ToList(); - } - - public List Query(string query) - { - ArgumentNullException.ThrowIfNull(query); - - List results = new List(); - var cmd = query; - if (string.IsNullOrEmpty(cmd)) - { - results = ResultsFromHistory(); - } - else - { - var queryCmd = GetCurrentCmd(cmd); - results.Add(queryCmd); - var history = GetHistoryCmds(cmd, queryCmd); - results.AddRange(history); - } - - foreach (var currItem in results) - { - currItem.MoreCommands = LoadContextMenus(currItem).ToArray(); - } - - return results; - } - public List LoadContextMenus(ListItem listItem) { var resultList = new List @@ -99,18 +45,53 @@ public class ShellListPageHelpers return resultList; } - private List ResultsFromHistory() + internal static bool FileExistInPath(string filename) { - IEnumerable history = _settings.Count.OrderByDescending(o => o.Value) - .Select(m => new ListItem(new ExecuteItem(m.Key, _settings)) + return FileExistInPath(filename, out var _); + } + + internal static bool FileExistInPath(string filename, out string fullPath, CancellationToken? token = null) + { + fullPath = string.Empty; + + if (File.Exists(filename)) + { + token?.ThrowIfCancellationRequested(); + fullPath = Path.GetFullPath(filename); + return true; + } + else + { + var values = Environment.GetEnvironmentVariable("PATH"); + if (values != null) { - Title = m.Key, + foreach (var path in values.Split(';')) + { + var path1 = Path.Combine(path, filename); + if (File.Exists(path1)) + { + fullPath = Path.GetFullPath(path1); + return true; + } - // Using CurrentCulture since this is user facing - Subtitle = Properties.Resources.cmd_plugin_name + ": " + string.Format(CultureInfo.CurrentCulture, CmdHasBeenExecutedTimes, m.Value), - Icon = Icons.HistoryIcon, - }).Take(5); + token?.ThrowIfCancellationRequested(); - return history.ToList(); + var path2 = Path.Combine(path, filename + ".exe"); + if (File.Exists(path2)) + { + fullPath = Path.GetFullPath(path2); + return true; + } + + token?.ThrowIfCancellationRequested(); + } + + return false; + } + else + { + return false; + } + } } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Icons.cs index b25d53a6c5..f83cdb18ae 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Icons.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Icons.cs @@ -10,11 +10,9 @@ internal sealed class Icons { internal static IconInfo RunV2Icon { get; } = IconHelpers.FromRelativePath("Assets\\Run.svg"); - internal static IconInfo HistoryIcon { get; } = new IconInfo("\uE81C"); // History + internal static IconInfo FolderIcon { get; } = new IconInfo("📁"); internal static IconInfo AdminIcon { get; } = new IconInfo("\xE7EF"); // Admin Icon internal static IconInfo UserIcon { get; } = new IconInfo("\xE7EE"); // User Icon - - internal static IconInfo ReturnIcon { get; } = new IconInfo("\uE751"); // Return Key Icon } 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 934e6d264a..bafa6e97d2 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 @@ -15,6 +15,10 @@ + + + + Resources.resx 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 new file mode 100644 index 0000000000..bdac4814a9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/RunExeItem.cs @@ -0,0 +1,104 @@ +// 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.Threading.Tasks; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.Ext.Shell.Pages; + +internal sealed partial class RunExeItem : ListItem +{ + private readonly Lazy _icon; + + public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; } + + internal string FullExePath { get; private set; } + + internal string Exe { get; private set; } + + private string _args = string.Empty; + + public RunExeItem(string exe, string args, string fullExePath) + { + FullExePath = fullExePath; + Exe = exe; + var command = new AnonymousCommand(Run) + { + Name = Properties.Resources.generic_run_command, + Result = CommandResult.Dismiss(), + }; + Command = command; + Subtitle = FullExePath; + + _icon = new Lazy(() => + { + var t = FetchIcon(); + t.Wait(); + return t.Result; + }); + + UpdateArgs(args); + + MoreCommands = [ + new CommandContextItem( + new AnonymousCommand(RunAsAdmin) + { + Name = Properties.Resources.cmd_run_as_administrator, + Icon = Icons.AdminIcon, + }), + new CommandContextItem( + new AnonymousCommand(RunAsOther) + { + Name = Properties.Resources.cmd_run_as_user, + Icon = Icons.UserIcon, + }), + ]; + } + + internal void UpdateArgs(string args) + { + _args = args; + Title = string.IsNullOrEmpty(_args) ? Exe : Exe + " " + _args; // todo! you're smarter than this + } + + public async Task FetchIcon() + { + IconInfo? icon = null; + + try + { + var stream = await ThumbnailHelper.GetThumbnail(FullExePath); + if (stream != null) + { + var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream)); + icon = new IconInfo(data, data); + ((AnonymousCommand?)Command)!.Icon = icon; + } + } + catch + { + } + + icon = icon ?? new IconInfo(FullExePath); + return icon; + } + + public void Run() + { + ShellHelpers.OpenInShell(FullExePath, _args); + } + + public void RunAsAdmin() + { + ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.Administrator); + } + + public void RunAsOther() + { + ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.OtherUser); + } +} 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 d817809e10..54f450f9da 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,6 +2,12 @@ // 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.Ext.Shell.Helpers; using Microsoft.CmdPal.Ext.Shell.Properties; using Microsoft.CommandPalette.Extensions; @@ -9,20 +15,436 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Shell.Pages; -internal sealed partial class ShellListPage : DynamicListPage +internal sealed partial class ShellListPage : DynamicListPage, IDisposable { private readonly ShellListPageHelpers _helper; - public ShellListPage(SettingsManager settingsManager) + private readonly List _topLevelItems = []; + private readonly List _historyItems = []; + private RunExeItem? _exeItem; + private List _pathItems = []; + private ListItem? _uriItem; + + private CancellationTokenSource? _cancellationTokenSource; + private Task? _currentSearchTask; + + public ShellListPage(SettingsManager settingsManager, bool addBuiltins = false) { Icon = Icons.RunV2Icon; Id = "com.microsoft.cmdpal.shell"; Name = Resources.cmd_plugin_name; PlaceholderText = Resources.list_placeholder_text; _helper = new(settingsManager); + + EmptyContent = new CommandItem() + { + Title = Resources.cmd_plugin_name, + Icon = Icons.RunV2Icon, + Subtitle = Resources.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) => RaiseItemsChanged(0); + public override void UpdateSearchText(string oldSearch, string newSearch) + { + if (newSearch == oldSearch) + { + return; + } - public override IListItem[] GetItems() => [.. _helper.Query(SearchText)]; + DoUpdateSearchText(newSearch); + } + + private void DoUpdateSearchText(string newSearch) + { + // Cancel any ongoing search + _cancellationTokenSource?.Cancel(); + + _cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = _cancellationTokenSource.Token; + + IsLoading = true; + + try + { + // Save the latest search task + _currentSearchTask = BuildListItemsForSearchAsync(newSearch, cancellationToken); + } + catch (OperationCanceledException) + { + // DO NOTHING HERE + return; + } + catch (Exception) + { + // Handle other exceptions + return; + } + + // Await the task to ensure only the latest one gets processed + _ = ProcessSearchResultsAsync(_currentSearchTask, newSearch); + } + + private async Task ProcessSearchResultsAsync(Task searchTask, string newSearch) + { + try + { + await searchTask; + + // Ensure this is still the latest task + if (_currentSearchTask == searchTask) + { + // The search results have already been updated in BuildListItemsForSearchAsync + IsLoading = false; + RaiseItemsChanged(); + } + } + catch (OperationCanceledException) + { + // Handle cancellation gracefully + } + catch (Exception) + { + // Handle other exceptions + IsLoading = false; + } + } + + private async Task BuildListItemsForSearchAsync(string newSearch, CancellationToken cancellationToken) + { + // Check for cancellation at the start + cancellationToken.ThrowIfCancellationRequested(); + + // 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: + + // 1. Check if the search text is a valid path + // 2. If it is, then list all the files that start with that text + var searchText = newSearch.Trim(); + + var expanded = Environment.ExpandEnvironmentVariables(searchText); + + // Check for cancellation after environment expansion + cancellationToken.ThrowIfCancellationRequested(); + + // TODO we can be smarter about only re-reading the filesystem if the + // new search is just the oldSearch+some chars + if (string.IsNullOrEmpty(searchText) || string.IsNullOrWhiteSpace(searchText)) + { + _pathItems.Clear(); + _exeItem = null; + _uriItem = null; + return; + } + + ParseExecutableAndArgs(expanded, out var exe, out var args); + + // Check for cancellation before file system operations + cancellationToken.ThrowIfCancellationRequested(); + + // Reset the path resolution flag + var couldResolvePath = false; + + var exeExists = false; + var fullExePath = string.Empty; + var pathIsDir = false; + + try + { + // Create a timeout for file system operations (200ms) + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + var timeoutToken = combinedCts.Token; + + // Use Task.Run with timeout - this will actually timeout even if the sync operations don't respond to cancellation + var pathResolutionTask = Task.Run( + () => + { + // Don't check cancellation token here - let the Task timeout handle it + exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath); + pathIsDir = Directory.Exists(expanded); + couldResolvePath = true; + }, + CancellationToken.None); // Use None here since we're handling timeout differently + + // Wait for either completion or timeout + await pathResolutionTask.WaitAsync(timeoutToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Main cancellation token was cancelled, re-throw + throw; + } + catch (TimeoutException) + { + // Timeout occurred + couldResolvePath = false; + } + catch (OperationCanceledException) + { + // Timeout occurred (from WaitAsync) + couldResolvePath = false; + } + catch (Exception) + { + // Handle any other exceptions that might bubble up + couldResolvePath = false; + } + + cancellationToken.ThrowIfCancellationRequested(); + + _pathItems.Clear(); + + // We want to show path items: + // * If there's no args, AND (the path doesn't exist OR the path is a dir) + if (string.IsNullOrEmpty(args) + && (!exeExists || pathIsDir) + && couldResolvePath) + { + await CreatePathItemsAsync(expanded, searchText, cancellationToken); + } + + // Check for cancellation before creating exe items + cancellationToken.ThrowIfCancellationRequested(); + + if (couldResolvePath && exeExists) + { + CreateAndAddExeItems(exe, args, fullExePath); + } + else + { + _exeItem = null; + } + + // Only create the URI item if we didn't make a file or exe item for it. + if (!exeExists && !pathIsDir) + { + CreateUriItems(searchText); + } + else + { + _uriItem = null; + } + + // Final cancellation check + cancellationToken.ThrowIfCancellationRequested(); + } + + private static ListItem PathToListItem(string path, string originalPath, string args = "") + { + var pathItem = new PathListItem(path, originalPath); + + // Is this path an executable? If so, then make a RunExeItem + if (IsExecutable(path)) + { + var exeItem = new RunExeItem(Path.GetFileName(path), args, path); + + exeItem.MoreCommands = [ + .. exeItem.MoreCommands, + .. pathItem.MoreCommands]; + return exeItem; + } + + return pathItem; + } + + public override IListItem[] GetItems() + { + var filteredTopLevel = ListHelpers.FilterList(_topLevelItems, SearchText); + List uriItems = _uriItem != null ? [_uriItem] : []; + List exeItems = _exeItem != null ? [_exeItem] : []; + return + exeItems + .Concat(filteredTopLevel) + .Concat(_historyItems) + .Concat(_pathItems) + .Concat(uriItems) + .ToArray(); + } + + internal static RunExeItem CreateExeItem(string exe, string args, string fullExePath) + { + // 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) as RunExeItem ?? + new RunExeItem(exe, args, fullExePath); + } + + private void CreateAndAddExeItems(string exe, string args, string fullExePath) + { + // If we already have an exe item, and the exe is the same, we can just update it + if (_exeItem != null && _exeItem.FullExePath.Equals(fullExePath, StringComparison.OrdinalIgnoreCase)) + { + _exeItem.UpdateArgs(args); + } + else + { + _exeItem = CreateExeItem(exe, args, fullExePath); + } + } + + private static bool IsExecutable(string path) + { + // Is this path an executable? + // check all the extensions in PATHEXT + var extensions = Environment.GetEnvironmentVariable("PATHEXT")?.Split(';') ?? Array.Empty(); + return extensions.Any(ext => string.Equals(Path.GetExtension(path), ext, StringComparison.OrdinalIgnoreCase)); + } + + private async Task CreatePathItemsAsync(string searchPath, string originalPath, CancellationToken cancellationToken) + { + var directoryPath = string.Empty; + var searchPattern = string.Empty; + + var startsWithQuote = searchPath.Length > 0 && searchPath[0] == '"'; + var endsWithQuote = searchPath.Last() == '"'; + var trimmed = (startsWithQuote && endsWithQuote) ? searchPath.Substring(1, searchPath.Length - 2) : searchPath; + var isDriveRoot = trimmed.Length == 2 && trimmed[1] == ':'; + + // we should also handle just drive roots, ala c:\ or d:\ + // we need to handle this case first, because "C:" does exist, but we need to append the "\" in that case + if (isDriveRoot) + { + directoryPath = trimmed + "\\"; + searchPattern = $"*"; + } + + // Easiest case: text is literally already a full directory + else if (Directory.Exists(trimmed)) + { + directoryPath = trimmed; + searchPattern = $"*"; + } + + // Check if the search text is a valid path + else if (Path.IsPathRooted(trimmed) && Path.GetDirectoryName(trimmed) is string directoryName) + { + directoryPath = directoryName; + searchPattern = $"{Path.GetFileName(trimmed)}*"; + } + + // Check if the search text is a valid UNC path + else if (trimmed.StartsWith(@"\\", System.StringComparison.CurrentCultureIgnoreCase) && + trimmed.Contains(@"\\")) + { + directoryPath = trimmed; + searchPattern = $"*"; + } + + // Check for cancellation before directory operations + cancellationToken.ThrowIfCancellationRequested(); + + var dirExists = Directory.Exists(directoryPath); + + // searchPath is fully expanded, and originalPath is not. We might get: + // * original: X%Y%Z\partial + // * search: X_foo_Z\partial + // and we want the result `X_foo_Z\partialOne` to use the suggestion `X%Y%Z\partialOne` + // + // To do this: + // * Get the directoryPath + // * trim that out of the beginning of searchPath -> searchPathTrailer + // * everything left from searchPath? remove searchPathTrailer from the end of originalPath + // that gets us the expanded original dir + + // Check if the directory exists + if (dirExists) + { + // Check for cancellation before file system enumeration + cancellationToken.ThrowIfCancellationRequested(); + + // 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(); + + var searchPathTrailer = trimmed.Remove(0, Math.Min(directoryPath.Length, trimmed.Length)); + var originalBeginning = originalPath.Remove(originalPath.Length - searchPathTrailer.Length); + if (isDriveRoot) + { + originalBeginning = string.Concat(originalBeginning, '\\'); + } + + // Create a list of commands for each file + var commands = files.Select(f => PathToListItem(f, originalBeginning)).ToList(); + + // Final cancellation check before updating results + cancellationToken.ThrowIfCancellationRequested(); + + // Add the commands to the list + _pathItems = commands; + } + else + { + _pathItems.Clear(); + } + } + + internal static void ParseExecutableAndArgs(string input, out string executable, out string arguments) + { + input = input.Trim(); + executable = string.Empty; + arguments = string.Empty; + + if (string.IsNullOrEmpty(input)) + { + return; + } + + if (input.StartsWith("\"", System.StringComparison.InvariantCultureIgnoreCase)) + { + // Find the closing quote + var closingQuoteIndex = input.IndexOf('\"', 1); + if (closingQuoteIndex > 0) + { + executable = input.Substring(1, closingQuoteIndex - 1); + if (closingQuoteIndex + 1 < input.Length) + { + arguments = input.Substring(closingQuoteIndex + 1).TrimStart(); + } + } + } + else + { + // Executable ends at first space + var firstSpaceIndex = input.IndexOf(' '); + if (firstSpaceIndex > 0) + { + executable = input.Substring(0, firstSpaceIndex); + arguments = input[(firstSpaceIndex + 1)..].TrimStart(); + } + else + { + executable = input; + } + } + } + + internal void CreateUriItems(string searchText) + { + if (!System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri)) + { + _uriItem = null; + return; + } + + var command = new OpenUrlCommand(searchText) { Result = CommandResult.Dismiss() }; + _uriItem = new ListItem(command) + { + Title = searchText, + }; + } + + public void Dispose() + { + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/PathListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/PathListItem.cs new file mode 100644 index 0000000000..4dc9df9ae3 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/PathListItem.cs @@ -0,0 +1,68 @@ +// 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.IO; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Shell; + +internal sealed partial class PathListItem : ListItem +{ + private readonly Lazy _icon; + private readonly bool _isDirectory; + + public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; } + + public PathListItem(string path, string originalDir) + : base(new OpenUrlCommand(path)) + { + var fileName = Path.GetFileName(path); + _isDirectory = Directory.Exists(path); + if (_isDirectory) + { + path = path + "\\"; + fileName = fileName + "\\"; + } + + Title = fileName; + Subtitle = path; + + // NOTE ME: + // If there are spaces on originalDir, trim them off, BEFORE combining originalDir and fileName. + // THEN add quotes at the end + + // Trim off leading & trailing quote, if there is one + var trimmed = originalDir.Trim('"'); + var originalPath = Path.Combine(trimmed, fileName); + var suggestion = originalPath; + var hasSpace = originalPath.Contains(' '); + if (hasSpace) + { + // wrap it in quotes + suggestion = string.Concat("\"", suggestion, "\""); + } + + TextToSuggest = suggestion; + MoreCommands = [ + new CommandContextItem(new CopyTextCommand(path) { Name = Properties.Resources.copy_path_command_name }) { } + ]; + + // MoreCommands = [ + // new CommandContextItem(new OpenWithCommand(indexerItem)), + // new CommandContextItem(new ShowFileInFolderCommand(indexerItem.FullPath) { Name = Resources.Indexer_Command_ShowInFolder }), + // new CommandContextItem(new CopyPathCommand(indexerItem)), + // new CommandContextItem(new OpenInConsoleCommand(indexerItem)), + // new CommandContextItem(new OpenPropertiesCommand(indexerItem)), + // ]; + _icon = new Lazy(() => + { + var iconStream = ThumbnailHelper.GetThumbnail(path).Result; + var icon = iconStream != null ? IconInfo.FromStream(iconStream) : + _isDirectory ? Icons.FolderIcon : Icons.RunV2Icon; + return icon; + }); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs index a43f2350fc..4200c3050a 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs @@ -132,6 +132,15 @@ namespace Microsoft.CmdPal.Ext.Shell.Properties { } } + /// + /// Looks up a localized string similar to Copy path. + /// + public static string copy_path_command_name { + get { + return ResourceManager.GetString("copy_path_command_name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Find and run the executable file. /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx index 3c31b6d167..a2f4cfb64f 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx @@ -190,4 +190,7 @@ Run commands + + Copy path + \ No newline at end of file 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 1abc3c9d1c..de7076e26c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs @@ -13,6 +13,7 @@ namespace Microsoft.CmdPal.Ext.Shell; public partial class ShellCommandsProvider : CommandProvider { private readonly CommandItem _shellPageItem; + private readonly SettingsManager _settingsManager = new(); private readonly FallbackCommandItem _fallbackItem; @@ -39,4 +40,6 @@ public partial class ShellCommandsProvider : CommandProvider public override ICommandItem[] TopLevelCommands() => [_shellPageItem]; public override IFallbackCommandItem[]? FallbackCommands() => [_fallbackItem]; + + public static bool SuppressFileFallbackIf(string query) => FallbackExecuteItem.SuppressFileFallbackIf(query); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs index bc87227221..c942e668d3 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs @@ -15,21 +15,26 @@ internal sealed partial class FallbackExecuteSearchItem : FallbackCommandItem { private readonly SearchWebCommand _executeItem; private static readonly CompositeFormat PluginOpen = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open); + private static readonly CompositeFormat SubtitleText = System.Text.CompositeFormat.Parse(Properties.Resources.web_search_fallback_subtitle); + private string _title; public FallbackExecuteSearchItem(SettingsManager settings) : base(new SearchWebCommand(string.Empty, settings) { Id = "com.microsoft.websearch.fallback" }, Resources.command_item_title) { _executeItem = (SearchWebCommand)this.Command!; Title = string.Empty; + Subtitle = string.Empty; _executeItem.Name = string.Empty; - Subtitle = string.Format(CultureInfo.CurrentCulture, PluginOpen, BrowserInfo.Name ?? BrowserInfo.MSEdgeName); + _title = string.Format(CultureInfo.CurrentCulture, PluginOpen, BrowserInfo.Name ?? BrowserInfo.MSEdgeName); Icon = Icons.WebSearch; } public override void UpdateQuery(string query) { _executeItem.Arguments = query; - _executeItem.Name = string.IsNullOrEmpty(query) ? string.Empty : Properties.Resources.open_in_default_browser; - Title = query; + var isEmpty = string.IsNullOrEmpty(query); + _executeItem.Name = isEmpty ? string.Empty : Properties.Resources.open_in_default_browser; + Title = isEmpty ? string.Empty : _title; + Subtitle = string.Format(CultureInfo.CurrentCulture, SubtitleText, query); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs index bcbfd8e2bd..6c01f18436 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs @@ -248,5 +248,14 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties { return ResourceManager.GetString("settings_page_name", resourceCulture); } } + + /// + /// Looks up a localized string similar to Search for "{0}". + /// + public static string web_search_fallback_subtitle { + get { + return ResourceManager.GetString("web_search_fallback_subtitle", resourceCulture); + } + } } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx index e0e6ec0769..02096369bc 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx @@ -178,6 +178,9 @@ Settings + + Search for "{0}" + Open URL