Compare commits

...

88 Commits

Author SHA1 Message Date
Mike Griese
752ec2deb0 Merge branch 'dev/migrie/b/fix-clipboard' into dev/migrie/rel/fun 2025-07-09 14:57:16 -05:00
Mike Griese
d9c168c3fe Merge branch 'dev/migrie/f/run-page-2-with-history' into dev/migrie/f/bookmark-exes 2025-07-09 14:56:46 -05:00
Mike Griese
210febe270 Merge branch 'dev/migrie/f/run-page-2-with-suggestions' into dev/migrie/f/run-page-2-with-history 2025-07-09 14:56:22 -05:00
Mike Griese
e53cb409b4 Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-page-2-with-suggestions 2025-07-09 14:56:07 -05:00
Mike Griese
98c771788b Merge branch 'dev/migrie/f/run-page-2-with-history' into dev/migrie/f/bookmark-exes 2025-07-09 14:55:30 -05:00
Mike Griese
5dee7c9d89 Merge branch 'dev/migrie/f/run-page-2-with-suggestions' into dev/migrie/f/run-page-2-with-history 2025-07-09 14:55:02 -05:00
Mike Griese
c2ba9af144 Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-page-2-with-suggestions 2025-07-09 14:47:13 -05:00
Mike Griese
1368f62aed missed an icon 2025-07-08 09:59:03 -05:00
Mike Griese
1cf28270c2 Loc, icons, cleanup 2025-07-08 09:52:36 -05:00
Mike Griese
0562415044 Merge branch 'dev/migrie/f/foreground-sucks' into dev/migrie/b/fix-clipboard 2025-07-08 09:34:45 -05:00
Mike Griese
cfcaafca03 _NA 2025-07-08 09:24:39 -05:00
Mike Griese
a748f653ac dead code 2025-07-08 06:58:02 -05:00
Mike Griese
b56309efbc NOW THIS works well 2025-07-08 06:55:29 -05:00
Mike Griese
dc439540c3 NOW THIS works well 2025-07-08 06:53:17 -05:00
Mike Griese
f403e97a0d CmdPal: Give FG back to the previous window
this is a port of ce15032 onto main, for just the FG change.

When we cloak our window, we want to make sure to _manually_ give FG
back to another window. Because apparently cloaked windows can have FG.
beacause that makes sense 🤦

Closes #39638
2025-07-07 10:17:59 -05:00
Mike Griese
01a6cec411 re-enable the thing 2025-07-07 10:12:12 -05:00
Mike Griese
1fafcb06dc Merge remote-tracking branch 'origin/main' into dev/migrie/b/fix-clipboard 2025-07-07 09:33:57 -05:00
Mike Griese
29c15601f0 Merge branch 'dev/migrie/f/run-page-2-with-history' into dev/migrie/f/bookmark-exes 2025-07-06 14:14:53 -05:00
Mike Griese
558e2af6cb uhg 2025-07-06 14:14:33 -05:00
Mike Griese
70c5e3c9d5 Merge branch 'dev/migrie/f/run-page-2-with-history' into dev/migrie/f/bookmark-exes 2025-07-06 14:11:30 -05:00
Mike Griese
986ffbeede Merge branch 'dev/migrie/f/run-page-2-with-suggestions' into dev/migrie/f/run-page-2-with-history 2025-07-06 14:07:16 -05:00
Mike Griese
8466c47059 delete all this logging we don't need 2025-07-06 10:36:14 -05:00
Mike Griese
229d3b4991 loc, dead code removal 2025-07-06 09:50:49 -05:00
Mike Griese
7ca9798df5 Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-page-2-with-suggestions 2025-07-06 09:24:35 -05:00
Mike Griese
a6e39d5535 PARENT: omg 2025-07-06 09:24:20 -05:00
Mike Griese
edf02497af tiny note 2025-07-06 09:24:06 -05:00
Mike Griese
02b583267d move it all out 2025-07-06 09:16:04 -05:00
Mike Griese
e2569ec4ee Start pulling this out of IconPathConverter 2025-07-06 09:07:56 -05:00
Mike Griese
c760962573 minor nits 2025-07-05 22:55:53 -05:00
Mike Griese
ec2480385b Tidy up the Type property, which we no longer need 2025-07-05 22:52:44 -05:00
Mike Griese
458d3a2699 I think placeholders should have subtitles too 2025-07-05 10:22:44 -05:00
Mike Griese
a58b802cb9 i'm so good at this 2025-07-05 07:03:57 -05:00
Mike Griese
a72bf5aed7 Bookmarking exes or files or urls is that easy 2025-07-05 06:26:05 -05:00
Mike Griese
fb49f6a5e5 you should be with your friends 2025-07-05 05:47:25 -05:00
Mike Griese
b4a7bb4a7a MAIN: this is dumb 2025-07-04 06:58:50 -05:00
Mike Griese
8615c48c5c this works better 2025-07-03 20:16:03 -05:00
Mike Griese
1aa78e1b96 PARENT: omg 2025-07-03 15:49:42 -05:00
Mike Griese
b0c862dd67 add items to history 2025-07-03 15:27:50 -05:00
Mike Griese
b6e3b8a3ee rename 2025-07-03 12:46:29 -05:00
Mike Griese
a2d0d3b262 start adding callbacks to commands 2025-07-03 12:46:10 -05:00
Mike Griese
ee53a6d138 Use a dict for string->listitem for history 2025-07-02 06:57:02 -05:00
Mike Griese
126a3c0de8 load history items with a timeout 2025-07-02 06:20:07 -05:00
Mike Griese
a94bd91dba Merge branch 'dev/migrie/f/run-page-2-with-suggestions' into dev/migrie/f/run-page-2-with-history 2025-07-01 16:32:14 -05:00
Mike Griese
b2f2462ad6 The fallback command should also be cancellable 2025-07-01 16:13:28 -05:00
Mike Griese
b6f0ced53e Exes that are found in a dir should be RunExeItems 2025-07-01 14:46:58 -05:00
Mike Griese
a405f27d19 reuse existing exe item when we can 2025-07-01 14:30:19 -05:00
Mike Griese
86d04cc3bd timeout resolving network paths 2025-07-01 13:57:14 -05:00
Mike Griese
381482e9a0 Revert "try to async the File.Exists, but get sad"
This reverts commit 9e7d212c31.
2025-07-01 13:34:08 -05:00
Mike Griese
9e7d212c31 try to async the File.Exists, but get sad 2025-07-01 13:34:04 -05:00
Mike Griese
11c9d913cc Make run searches async, so subsequent ones cancel the previous.
thanks copilot, this actually worked
2025-07-01 12:36:01 -05:00
Mike Griese
31f5af7e14 URI only if the file doesn't exist 2025-07-01 12:17:38 -05:00
Mike Griese
568c2ca388 Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-page-2-with-suggestions 2025-07-01 10:04:59 -05:00
Mike Griese
27124972cd Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-page-2-with-suggestions 2025-06-30 08:39:19 -05:00
Mike Griese
ca9488c875 Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-page-2-with-suggestions 2025-06-30 05:53:59 -05:00
Mike Griese
68461ab553 This only sometimes didn't work, and never while debugged 2025-06-19 08:59:32 -05:00
Mike Griese
ce150322ed holy shit it worked
I'm POSITIVE there's a lot of places where this won't.
But if we just exclude TOOLWINDOW's from the EnumWindows call, we can
toss FG back pretty consistently?
2025-06-19 08:43:30 -05:00
Mike Griese
d73a8a0a2c Load the run history immediately 2025-06-18 09:37:00 -05:00
Mike Griese
c953ce7eca Add support for using the RunDlg history to initialize the run history 2025-06-18 08:43:48 -05:00
Mike Griese
9fc7a180d4 xamlformat(?) 2025-06-10 06:20:09 -05:00
Mike Griese
ccca007562 Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-page-2-with-suggestions 2025-06-10 06:15:59 -05:00
Mike Griese
07c85065f2 Add support for URIs too 2025-06-09 20:42:35 -05:00
Mike Griese
c06767b73e Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-page-2-with-suggestions 2025-06-08 12:46:12 -05:00
Mike Griese
4c639a085c Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-page-2-with-suggestions 2025-06-08 06:32:20 -05:00
Mike Griese
8aa7349f32 handle backspacing suggestions cleaner 2025-06-08 05:56:26 -05:00
Mike Griese
fa86f2223c PARENT: deal with drives and quotes better 2025-06-08 05:56:03 -05:00
Mike Griese
30215e8b4f PARENT: much better env var handling 2025-06-06 17:58:29 -05:00
Mike Griese
ee386e7be4 PARENT: Suppress file fallback when run finds something 2025-06-05 04:52:25 -05:00
Mike Griese
9ba2030b5e OWN BRANCH: Fix the weighting for the web search fallbacks
Closes #39419
This can be moved to its own branch.
2025-06-04 06:34:26 -05:00
Mike Griese
1ba832b732 Make the calc command play better with TextToSuggest 2025-06-02 14:11:05 -05:00
Mike Griese
46f27c1612 make it build 2025-05-30 07:00:29 -05:00
Mike Griese
1d81ef8935 PARENT: Start working on making run fallback hide the file search one 2025-05-23 18:16:47 -07:00
Mike Griese
e19cdba074 PARENT: Update the fallback item too 2025-05-23 11:28:28 -07:00
Mike Griese
4223286061 PARENT: Expand env vars 2025-05-23 09:39:19 -07:00
Mike Griese
8e6bd141ca PARENT: Better deal with spaces 2025-05-23 09:37:29 -07:00
Mike Griese
d29121c3fd PARENT: logging that I don't think I needed 2025-05-18 19:00:54 -05:00
Mike Griese
58e0530980 Accept suggestions better 2025-05-18 19:00:41 -05:00
Mike Griese
9272e3112b This feels pretty great tbh 2025-05-18 18:47:10 -05:00
Mike Griese
a64095c3d3 try messing with selection to indicate text suggestion 2025-05-18 17:00:32 -05:00
Mike Griese
49480041cd this is near perfect 2025-05-18 15:43:28 -05:00
Mike Griese
547b664a8c Better handle removing the path item if the thing is an exe 2025-05-18 15:07:11 -05:00
Mike Griese
72320bea79 Add a single command for running the commandline. Remove history 2025-05-18 14:29:54 -05:00
Mike Griese
910de53a0a Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-page-2 2025-05-18 10:18:20 -05:00
Mike Griese
11f60de543 stash: start moving into the main run page 2025-05-08 06:13:57 -05:00
Mike Griese
e7eb2d0239 Add the TextToSuggest back, just for demo purposes 2025-04-30 20:36:24 -05:00
Mike Griese
aefae2935e heck just use the original shell provider for history 2025-04-30 10:05:16 -05:00
Mike Griese
727367960e Start adding history 2025-04-30 06:01:45 -05:00
Mike Griese
32700658fd Deduplicate a bunch of code 2025-04-30 04:25:59 -05:00
Mike Griese
a7cb535515 What if the run page had the same typeahead that Rundlg had? 2025-04-29 17:01:49 -05:00
58 changed files with 2137 additions and 237 deletions

View File

@@ -0,0 +1,27 @@
// 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.Collections.Generic;
namespace Microsoft.CmdPal.Common.Services;
public interface IRunHistoryService
{
/// <summary>
/// Gets the run history.
/// </summary>
/// <returns>A list of run history items.</returns>
IReadOnlyList<string> GetRunHistory();
/// <summary>
/// Clears the run history.
/// </summary>
void ClearRunHistory();
/// <summary>
/// Adds a run history item.
/// </summary>
/// <param name="item">The run history item to add.</param>
void AddRunHistoryItem(string item);
}

View File

@@ -21,8 +21,12 @@ public partial class AppStateModel : ObservableObject
///////////////////////////////////////////////////////////////////////////
// STATE HERE
// Make sure that you make the setters public (JsonSerializer.Deserialize will fail silently otherwise)!
// Make sure that any new types you add are added to JsonSerializationContext!
public RecentCommandsManager RecentCommands { get; set; } = new();
public List<string> RunHistory { get; set; } = [];
// END SETTINGS
///////////////////////////////////////////////////////////////////////////
@@ -86,7 +90,7 @@ public partial class AppStateModel : ObservableObject
{
foreach (var item in newSettings)
{
savedSettings[item.Key] = item.Value != null ? item.Value.DeepClone() : null;
savedSettings[item.Key] = item.Value?.DeepClone();
}
var serialized = savedSettings.ToJsonString(JsonSerializationContext.Default.AppStateModel.Options);
@@ -121,20 +125,4 @@ public partial class AppStateModel : ObservableObject
// now, the settings is just next to the exe
return Path.Combine(directory, "state.json");
}
// [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "<Pending>")]
// private static readonly JsonSerializerOptions _serializerOptions = new()
// {
// WriteIndented = true,
// Converters = { new JsonStringEnumConverter() },
// };
// private static readonly JsonSerializerOptions _deserializerOptions = new()
// {
// PropertyNameCaseInsensitive = true,
// IncludeFields = true,
// AllowTrailingCommas = true,
// PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate,
// ReadCommentHandling = JsonCommentHandling.Skip,
// };
}

View File

@@ -222,7 +222,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();
@@ -232,8 +232,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

View File

@@ -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;
@@ -158,10 +160,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
}
// Cancel any ongoing search
if (_cancellationTokenSource != null)
{
_cancellationTokenSource.Cancel();
}
_cancellationTokenSource?.Cancel();
lock (_listLock)
{
@@ -373,6 +372,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
}
TextToSuggest = item.TextToSuggest;
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(item.TextToSuggest));
});
_lastSelectedItem = item;
@@ -426,6 +426,8 @@ public partial class ListViewModel : PageViewModel, IDisposable
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty));
TextToSuggest = string.Empty;
});
}

View File

@@ -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.UI.ViewModels.Messages;
public record UpdateSuggestionMessage(string TextToSuggest)
{
}

View File

@@ -8,6 +8,7 @@ using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Bookmarks;
using Microsoft.CmdPal.Ext.Calc;
using Microsoft.CmdPal.Ext.ClipboardHistory;
using Microsoft.CmdPal.Ext.Indexer;
using Microsoft.CmdPal.Ext.Registry;
using Microsoft.CmdPal.Ext.Shell;
@@ -96,14 +97,18 @@ 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<ICommandProvider>(allApps);
services.AddSingleton<ICommandProvider, ShellCommandsProvider>();
services.AddSingleton<ICommandProvider, CalculatorCommandProvider>();
services.AddSingleton<ICommandProvider, IndexerCommandsProvider>();
services.AddSingleton<ICommandProvider>(files);
services.AddSingleton<ICommandProvider, BookmarksCommandProvider>();
services.AddSingleton<ICommandProvider, WindowWalkerCommandsProvider>();
services.AddSingleton<ICommandProvider, WebSearchCommandsProvider>();
services.AddSingleton<ICommandProvider, ClipboardHistoryCommandsProvider>();
// GH #38440: Users might not have WinGet installed! Or they might have
// a ridiculously old version. Or might be running as admin.
@@ -141,6 +146,7 @@ public partial class App : Application
services.AddSingleton(state);
services.AddSingleton<IExtensionService, ExtensionService>();
services.AddSingleton<TrayIconService>();
services.AddSingleton<IRunHistoryService, RunHistoryService>();
// ViewModels
services.AddSingleton<ShellViewModel>();

View File

@@ -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.UI.ViewModels;
@@ -21,6 +20,7 @@ namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class SearchBar : UserControl,
IRecipient<GoHomeMessage>,
IRecipient<FocusSearchBoxMessage>,
IRecipient<UpdateSuggestionMessage>,
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<GoHomeMessage>(this);
WeakReferenceMessenger.Default.Register<FocusSearchBoxMessage>(this);
WeakReferenceMessenger.Default.Register<UpdateSuggestionMessage>(this);
}
public void ClearSearch()
@@ -125,15 +130,6 @@ public sealed partial class SearchBar : UserControl,
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(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))
@@ -204,12 +200,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<NavigateNextCommand>();
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)
@@ -223,7 +272,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
@@ -232,23 +281,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/
@@ -258,6 +306,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)
{
@@ -277,6 +340,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))
{
@@ -294,4 +359,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);
}
}));
}
}

View File

@@ -264,6 +264,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)

View File

@@ -107,6 +107,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
<ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.ClipboardHistory\Microsoft.CmdPal.Ext.ClipboardHistory.csproj" />
<ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.System\Microsoft.CmdPal.Ext.System.csproj" />
<ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.WebSearch\Microsoft.CmdPal.Ext.WebSearch.csproj" />
<ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.Indexer\Microsoft.CmdPal.Ext.Indexer.csproj" />

View File

@@ -0,0 +1,50 @@
// 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 Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels;
namespace Microsoft.CmdPal.UI;
internal sealed class RunHistoryService : IRunHistoryService
{
private readonly AppStateModel _appStateModel;
public RunHistoryService(AppStateModel appStateModel)
{
_appStateModel = appStateModel;
}
public IReadOnlyList<string> GetRunHistory()
{
if (_appStateModel.RunHistory.Count == 0)
{
var history = Microsoft.Terminal.UI.RunHistory.CreateRunHistory();
_appStateModel.RunHistory.AddRange(history);
}
return _appStateModel.RunHistory;
}
public void ClearRunHistory()
{
_appStateModel.RunHistory.Clear();
}
public void AddRunHistoryItem(string item)
{
// insert at the beginning of the list
if (string.IsNullOrWhiteSpace(item))
{
return; // Do not add empty or whitespace items
}
_appStateModel.RunHistory.Remove(item);
// Add the item to the front of the history
_appStateModel.RunHistory.Insert(0, item);
AppStateModel.SaveState(_appStateModel);
}
}

View File

@@ -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}" />

View File

@@ -383,4 +383,5 @@ namespace winrt::Microsoft::Terminal::UI::implementation
icon.Height(targetSize);
return icon;
}
}

View File

@@ -13,6 +13,7 @@ namespace winrt::Microsoft::Terminal::UI::implementation
static Microsoft::UI::Xaml::Controls::IconSource IconSourceMUX(const winrt::hstring& iconPath, bool convertToGrayscale, const int targetSize=24);
static Microsoft::UI::Xaml::Controls::IconElement IconMUX(const winrt::hstring& iconPath);
static Microsoft::UI::Xaml::Controls::IconElement IconMUX(const winrt::hstring& iconPath, const int targetSize);
};
}

View File

@@ -153,6 +153,9 @@
<ClInclude Include="IconPathConverter.h">
<DependentUpon>IconPathConverter.idl</DependentUpon>
</ClInclude>
<ClInclude Include="RunHistory.h">
<DependentUpon>RunHistory.idl</DependentUpon>
</ClInclude>
<ClInclude Include="ResourceString.h">
<DependentUpon>ResourceString.idl</DependentUpon>
</ClInclude>
@@ -168,6 +171,9 @@
<ClCompile Include="IconPathConverter.cpp">
<DependentUpon>IconPathConverter.idl</DependentUpon>
</ClCompile>
<ClCompile Include="RunHistory.cpp">
<DependentUpon>RunHistory.idl</DependentUpon>
</ClCompile>
<ClCompile Include="ResourceString.cpp">
<DependentUpon>ResourceString.idl</DependentUpon>
</ClCompile>
@@ -176,6 +182,7 @@
<ItemGroup>
<Midl Include="Converters.idl" />
<Midl Include="IconPathConverter.idl" />
<Midl Include="RunHistory.idl" />
<Midl Include="IDirectKeyListener.idl" />
<Midl Include="ResourceString.idl" />
</ItemGroup>

View File

@@ -0,0 +1,87 @@
#include "pch.h"
#include "RunHistory.h"
#include "RunHistory.g.cpp"
using namespace winrt::Windows;
namespace winrt::Microsoft::Terminal::UI::implementation
{
// Run history
// Largely copied from the Run work circa 2022.
winrt::Windows::Foundation::Collections::IVector<hstring> RunHistory::CreateRunHistory()
{
// Load MRU history
std::vector<hstring> history;
wil::unique_hmodule _comctl;
HANDLE(WINAPI* _createMRUList)(MRUINFO* lpmi);
int(WINAPI* _enumMRUList)(HANDLE hMRU,int nItem,void* lpData,UINT uLen);
void(WINAPI *_freeMRUList)(HANDLE hMRU);
int(WINAPI *_addMRUString)(HANDLE hMRU, LPCWSTR szString);
// Lazy load comctl32.dll
// Theoretically, we could cache this into a magic static, but we shouldn't need to actually do this more than once in CmdPal
_comctl.reset(LoadLibraryExW(L"comctl32.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32));
_createMRUList = reinterpret_cast<decltype(_createMRUList)>(GetProcAddress(_comctl.get(), "CreateMRUListW"));
FAIL_FAST_LAST_ERROR_IF(!_createMRUList);
_enumMRUList = reinterpret_cast<decltype(_enumMRUList)>(GetProcAddress(_comctl.get(), "EnumMRUListW"));
FAIL_FAST_LAST_ERROR_IF(!_enumMRUList);
_freeMRUList = reinterpret_cast<decltype(_freeMRUList)>(GetProcAddress(_comctl.get(), "FreeMRUList"));
FAIL_FAST_LAST_ERROR_IF(!_freeMRUList);
_addMRUString = reinterpret_cast<decltype(_addMRUString)>(GetProcAddress(_comctl.get(), "AddMRUStringW"));
FAIL_FAST_LAST_ERROR_IF(!_addMRUString);
static const WCHAR c_szRunMRU[] = REGSTR_PATH_EXPLORER L"\\RunMRU";
MRUINFO mi = {
sizeof(mi),
26,
MRU_CACHEWRITE,
HKEY_CURRENT_USER,
c_szRunMRU,
NULL // NOTE: use default string compare
// since this is a GLOBAL MRU
};
if (const auto hmru = _createMRUList(&mi))
{
auto freeMRUList = wil::scope_exit([=]() {
_freeMRUList(hmru);
});
for (int nMax = _enumMRUList(hmru, -1, NULL, 0), i = 0; i < nMax; ++i)
{
WCHAR szCommand[MAX_PATH + 2];
const auto length = _enumMRUList(hmru, i, szCommand, ARRAYSIZE(szCommand));
if (length > 1)
{
// clip off the null-terminator
std::wstring_view text{ szCommand, wil::safe_cast<size_t>(length - 1) };
//#pragma disable warning(C26493)
#pragma warning( push )
#pragma warning( disable : 26493 )
if (text.back() == L'\\')
{
// old MRU format has a slash at the end with the show cmd
text = { szCommand, wil::safe_cast<size_t>(length - 2) };
#pragma warning( pop )
if (text.empty())
{
continue;
}
}
history.emplace_back(text);
}
}
}
// Update dropdown & initial value
return winrt::single_threaded_observable_vector<winrt::hstring>(std::move(history));
}
}

View File

@@ -0,0 +1,21 @@
#pragma once
#include "RunHistory.g.h"
#include "types.h"
namespace winrt::Microsoft::Terminal::UI::implementation
{
struct RunHistory
{
RunHistory() = default;
static winrt::Windows::Foundation::Collections::IVector<hstring> CreateRunHistory();
private:
winrt::Windows::Foundation::Collections::IVector<hstring> _mruHistory;
};
}
namespace winrt::Microsoft::Terminal::UI::factory_implementation
{
BASIC_FACTORY(RunHistory);
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
namespace Microsoft.Terminal.UI
{
static runtimeclass RunHistory
{
static Windows.Foundation.Collections.IVector<String> CreateRunHistory();
};
}

View File

@@ -64,6 +64,8 @@
// WIL
#include <wil/com.h>
#include <wil/resource.h>
#include <wil/safecast.h>
#include <wil/stl.h>
#include <wil/filesystem.h>
// Due to the use of RESOURCE_SUPPRESS_STL in result.h, we need to include resource.h first, which happens
@@ -90,6 +92,7 @@
#include <winrt/Windows.ApplicationModel.Resources.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.Graphics.Imaging.h>
#include <Windows.Graphics.Imaging.Interop.h>

View File

@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
#pragma once
#define MRU_CACHEWRITE 0x0002
#define REGSTR_PATH_EXPLORER TEXT("Software\\Microsoft\\Windows\\CurrentVersion\\Explorer")
// https://learn.microsoft.com/en-us/windows/win32/shell/mrucmpproc
typedef int(CALLBACK* MRUCMPPROC)(
LPCTSTR pString1,
LPCTSTR pString2);
// https://learn.microsoft.com/en-us/windows/win32/shell/mruinfo
struct MRUINFO
{
DWORD cbSize;
UINT uMax;
UINT fFlags;
HKEY hKey;
LPCTSTR lpszSubKey;
MRUCMPPROC lpfnCompare;
};

View File

@@ -2,8 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.CmdPal.Ext.Bookmarks.Properties;
@@ -75,31 +73,9 @@ internal sealed partial class AddBookmarkForm : FormContent
var formBookmark = formInput["bookmark"] ?? string.Empty;
var hasPlaceholder = formBookmark.ToString().Contains('{') && formBookmark.ToString().Contains('}');
// Determine the type of the bookmark
string bookmarkType;
if (formBookmark.ToString().StartsWith("http://", StringComparison.OrdinalIgnoreCase) || formBookmark.ToString().StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
bookmarkType = "web";
}
else if (File.Exists(formBookmark.ToString()))
{
bookmarkType = "file";
}
else if (Directory.Exists(formBookmark.ToString()))
{
bookmarkType = "folder";
}
else
{
// Default to web if we can't determine the type
bookmarkType = "web";
}
var updated = _bookmark ?? new BookmarkData();
updated.Name = formName.ToString();
updated.Bookmark = formBookmark.ToString();
updated.Type = bookmarkType;
AddedCommand?.Invoke(this, updated);
return CommandResult.GoHome();

View File

@@ -2,7 +2,9 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Text.Json.Serialization;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Bookmarks;
@@ -12,8 +14,38 @@ public class BookmarkData
public string Bookmark { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
// public string Type { get; set; } = string.Empty;
[JsonIgnore]
public bool IsPlaceholder => Bookmark.Contains('{') && Bookmark.Contains('}');
internal void GetExeAndArgs(out string exe, out string args)
{
ShellHelpers.ParseExecutableAndArgs(Bookmark, out exe, out args);
}
internal bool IsWebUrl()
{
GetExeAndArgs(out var exe, out var args);
if (string.IsNullOrEmpty(exe))
{
return false;
}
if (Uri.TryCreate(exe, UriKind.Absolute, out var uri))
{
if (uri.Scheme == Uri.UriSchemeFile)
{
return false;
}
// return true if the scheme is http or https, or if there's no scheme (e.g., "www.example.com") but there is a dot in the host
return
uri.Scheme == Uri.UriSchemeHttp ||
uri.Scheme == Uri.UriSchemeHttps ||
(string.IsNullOrEmpty(uri.Scheme) && uri.Host.Contains('.'));
}
// If we can't parse it as a URI, we assume it's not a web URL
return false;
}
}

View File

@@ -2,17 +2,14 @@
// 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.Globalization;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using ManagedCommon;
using Microsoft.CmdPal.Ext.Bookmarks.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.System;
namespace Microsoft.CmdPal.Ext.Bookmarks;
@@ -25,7 +22,7 @@ internal sealed partial class BookmarkPlaceholderForm : FormContent
private readonly string _bookmark = string.Empty;
// TODO pass in an array of placeholders
public BookmarkPlaceholderForm(string name, string url, string type)
public BookmarkPlaceholderForm(string name, string url)
{
_bookmark = url;
var r = new Regex(Regex.Escape("{") + "(.*?)" + Regex.Escape("}"));
@@ -88,23 +85,8 @@ internal sealed partial class BookmarkPlaceholderForm : FormContent
target = target.Replace(placeholderString, placeholderData);
}
try
{
var uri = UrlCommand.GetUri(target);
if (uri != null)
{
_ = Launcher.LaunchUriAsync(uri);
}
else
{
// throw new UriFormatException("The provided URL is not valid.");
}
}
catch (Exception ex)
{
Logger.LogError(ex.Message);
}
var success = UrlCommand.LaunchCommand(target);
return CommandResult.GoHome();
return success ? CommandResult.Dismiss() : CommandResult.KeepOpen();
}
}

View File

@@ -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.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -9,19 +10,30 @@ namespace Microsoft.CmdPal.Ext.Bookmarks;
internal sealed partial class BookmarkPlaceholderPage : ContentPage
{
private readonly Lazy<IconInfo> _icon;
private readonly FormContent _bookmarkPlaceholder;
public override IContent[] GetContent() => [_bookmarkPlaceholder];
public override IconInfo Icon { get => _icon.Value; set => base.Icon = value; }
public BookmarkPlaceholderPage(BookmarkData data)
: this(data.Name, data.Bookmark, data.Type)
: this(data.Name, data.Bookmark)
{
}
public BookmarkPlaceholderPage(string name, string url, string type)
public BookmarkPlaceholderPage(string name, string url)
{
Name = name;
Icon = new IconInfo(UrlCommand.IconFromUrl(url, type));
_bookmarkPlaceholder = new BookmarkPlaceholderForm(name, url, type);
Name = Properties.Resources.bookmarks_command_name_open;
_bookmarkPlaceholder = new BookmarkPlaceholderForm(name, url);
_icon = new Lazy<IconInfo>(() =>
{
ShellHelpers.ParseExecutableAndArgs(url, out var exe, out var args);
var t = UrlCommand.GetIconForPath(exe);
t.Wait();
return t.Result;
});
}
}

View File

@@ -4,7 +4,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using ManagedCommon;
@@ -39,10 +38,7 @@ public partial class BookmarksCommandProvider : CommandProvider
private void AddNewCommand_AddedCommand(object sender, BookmarkData args)
{
ExtensionHost.LogMessage($"Adding bookmark ({args.Name},{args.Bookmark})");
if (_bookmarks != null)
{
_bookmarks.Data.Add(args);
}
_bookmarks?.Data.Add(args);
SaveAndUpdateCommands();
}
@@ -120,7 +116,7 @@ public partial class BookmarksCommandProvider : CommandProvider
// Add commands for folder types
if (command is UrlCommand urlCommand)
{
if (urlCommand.Type == "folder")
if (!bookmark.IsWebUrl())
{
contextMenu.Add(
new CommandContextItem(new DirectoryPage(urlCommand.Url)));
@@ -128,10 +124,11 @@ public partial class BookmarksCommandProvider : CommandProvider
contextMenu.Add(
new CommandContextItem(new OpenInTerminalCommand(urlCommand.Url)));
}
listItem.Subtitle = urlCommand.Url;
}
listItem.Title = bookmark.Name;
listItem.Subtitle = bookmark.Bookmark;
var edit = new AddBookmarkPage(bookmark) { Icon = EditIcon };
edit.AddedCommand += Edit_AddedCommand;
contextMenu.Add(new CommandContextItem(edit));

View File

@@ -78,6 +78,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Open.
/// </summary>
public static string bookmarks_command_name_open {
get {
return ResourceManager.GetString("bookmarks_command_name_open", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete.
/// </summary>

View File

@@ -148,6 +148,9 @@
<data name="bookmarks_form_open" xml:space="preserve">
<value>Open</value>
</data>
<data name="bookmarks_command_name_open" xml:space="preserve">
<value>Open</value>
</data>
<data name="bookmarks_form_name_required" xml:space="preserve">
<value>Name is required</value>
</data>

View File

@@ -3,52 +3,89 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Storage.Streams;
using Windows.System;
namespace Microsoft.CmdPal.Ext.Bookmarks;
public partial class UrlCommand : InvokableCommand
{
public string Type { get; }
private readonly Lazy<IconInfo> _icon;
public string Url { get; }
public override IconInfo Icon { get => _icon.Value; set => base.Icon = value; }
public UrlCommand(BookmarkData data)
: this(data.Name, data.Bookmark, data.Type)
: this(data.Name, data.Bookmark)
{
}
public UrlCommand(string name, string url, string type)
public UrlCommand(string name, string url)
{
Name = name;
Type = type;
Name = Properties.Resources.bookmarks_command_name_open;
Url = url;
Icon = new IconInfo(IconFromUrl(Url, type));
_icon = new Lazy<IconInfo>(() =>
{
ShellHelpers.ParseExecutableAndArgs(Url, out var exe, out var args);
var t = GetIconForPath(exe);
t.Wait();
return t.Result;
});
}
public override CommandResult Invoke()
{
var target = Url;
try
var success = LaunchCommand(Url);
return success ? CommandResult.Dismiss() : CommandResult.KeepOpen();
}
internal static bool LaunchCommand(string target)
{
ShellHelpers.ParseExecutableAndArgs(target, out var exe, out var args);
return LaunchCommand(exe, args);
}
internal static bool LaunchCommand(string exe, string args)
{
if (string.IsNullOrEmpty(exe))
{
var uri = GetUri(target);
var message = "No executable found in the command.";
Logger.LogError(message);
return false;
}
if (ShellHelpers.OpenInShell(exe, args))
{
return true;
}
// If we reach here, it means the command could not be executed
// If there aren't args, then try again as a https: uri
if (string.IsNullOrEmpty(args))
{
var uri = GetUri(exe);
if (uri != null)
{
_ = Launcher.LaunchUriAsync(uri);
}
else
{
// throw new UriFormatException("The provided URL is not valid.");
Logger.LogError("The provided URL is not valid.");
}
}
catch (Exception ex)
{
Logger.LogError(ex.Message);
return true;
}
return CommandResult.Dismiss();
return false;
}
internal static Uri? GetUri(string url)
@@ -65,35 +102,90 @@ public partial class UrlCommand : InvokableCommand
return uri;
}
internal static string IconFromUrl(string url, string type)
public static async Task<IconInfo> GetIconForPath(string target)
{
switch (type)
{
case "file":
return "📄";
case "folder":
return "📁";
case "web":
default:
// Get the base url up to the first placeholder
var placeholderIndex = url.IndexOf('{');
var baseString = placeholderIndex > 0 ? url.Substring(0, placeholderIndex) : url;
try
{
var uri = GetUri(baseString);
if (uri != null)
{
var hostname = uri.Host;
var faviconUrl = $"{uri.Scheme}://{hostname}/favicon.ico";
return faviconUrl;
}
}
catch (UriFormatException ex)
{
Logger.LogError(ex.Message);
}
IconInfo? icon = null;
return "🔗";
// First, try to get the icon from the thumbnail helper
// This works for local files and folders
icon = await MaybeGetIconForPath(target);
if (icon != null)
{
return icon;
}
// Okay, that failed. Try to resolve the full path of the executable
var exeExists = false;
var fullExePath = string.Empty;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
// 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 = ShellHelpers.FileExistInPath(target, out fullExePath);
},
CancellationToken.None);
// Wait for either completion or timeout
pathResolutionTask.Wait(cts.Token);
}
catch (OperationCanceledException)
{
// Debug.WriteLine("Operation was canceled.");
}
if (exeExists)
{
// If the executable exists, try to get the icon from the file
icon = await MaybeGetIconForPath(fullExePath);
if (icon != null)
{
return icon;
}
}
// Get the base url up to the first placeholder
var placeholderIndex = target.IndexOf('{');
var baseString = placeholderIndex > 0 ? target.Substring(0, placeholderIndex) : target;
try
{
var uri = GetUri(baseString);
if (uri != null)
{
var hostname = uri.Host;
var faviconUrl = $"{uri.Scheme}://{hostname}/favicon.ico";
icon = new IconInfo(faviconUrl);
}
}
catch (UriFormatException)
{
}
// If we still don't have an icon, use the target as the icon
icon = icon ?? new IconInfo(target);
return icon;
}
private static async Task<IconInfo?> MaybeGetIconForPath(string target)
{
try
{
var stream = await ThumbnailHelper.GetThumbnail(target);
if (stream != null)
{
var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream));
return new IconInfo(data, data);
}
}
catch
{
}
return null;
}
}

View File

@@ -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 = CalculatorIcons.ResultIcon,
Title = result,
Subtitle = query,
TextToSuggest = result,
MoreCommands = [
new CommandContextItem(copyCommandItem.Command)
{

View File

@@ -16,12 +16,13 @@ public partial class ClipboardHistoryCommandsProvider : CommandProvider
{
_clipboardHistoryListItem = new ListItem(new ClipboardHistoryListPage())
{
Title = "Search Clipboard History",
Icon = new IconInfo("\xE8C8"), // Copy icon
Title = Properties.Resources.list_item_title,
Subtitle = Properties.Resources.list_item_subtitle,
Icon = Icons.ClipboardList,
};
DisplayName = $"Clipboard History";
Icon = new IconInfo("\xE8C8"); // Copy icon
DisplayName = Properties.Resources.provider_display_name;
Icon = Icons.ClipboardList;
Id = "Windows.ClipboardHistory";
}

View File

@@ -16,20 +16,20 @@ internal sealed partial class CopyCommand : InvokableCommand
{
_clipboardItem = clipboardItem;
_clipboardFormat = clipboardFormat;
Name = "Copy";
Name = Properties.Resources.copy_command_name;
if (clipboardFormat == ClipboardFormat.Text)
{
Icon = new("\xE8C8"); // Copy icon
Icon = Icons.Copy;
}
else
{
Icon = new("\xE8B9"); // Picture icon
Icon = Icons.Picture;
}
}
public override CommandResult Invoke()
{
ClipboardHelper.SetClipboardContent(_clipboardItem, _clipboardFormat);
return CommandResult.ShowToast("Copied to clipboard");
return CommandResult.ShowToast(Properties.Resources.copied_toast_text);
}
}

View File

@@ -6,7 +6,6 @@ using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.Ext.ClipboardHistory.Models;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.ApplicationModel.DataTransfer;
namespace Microsoft.CmdPal.Ext.ClipboardHistory.Commands;
@@ -20,8 +19,8 @@ internal sealed partial class PasteCommand : InvokableCommand
{
_clipboardItem = clipboardItem;
_clipboardFormat = clipboardFormat;
Name = "Paste";
Icon = new("\xE8C8"); // Copy icon
Name = Properties.Resources.paste_command_name;
Icon = Icons.Paste;
}
private void HideWindow()
@@ -37,8 +36,10 @@ internal sealed partial class PasteCommand : InvokableCommand
{
ClipboardHelper.SetClipboardContent(_clipboardItem, _clipboardFormat);
HideWindow();
ClipboardHelper.SendPasteKeyCombination();
Clipboard.DeleteItemFromHistory(_clipboardItem.Item);
return CommandResult.ShowToast("Pasting");
return CommandResult.ShowToast(Properties.Resources.paste_toast_text);
}
}

View File

@@ -59,10 +59,10 @@ internal static class ClipboardHelper
output.SetText(text);
try
{
// Clipboard.SetContentWithOptions(output, null);
ClipboardThreadQueue.EnqueueTask(() =>
{
Clipboard.SetContent(output);
Flush();
ExtensionHost.LogMessage(new LogMessage() { Message = "Copied text to clipboard" });
});
@@ -87,7 +87,7 @@ internal static class ClipboardHelper
{
try
{
Task.Run(Clipboard.Flush).Wait();
Clipboard.Flush();
return;
}
catch (Exception ex)

View File

@@ -0,0 +1,18 @@
// 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 Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.ClipboardHistory;
internal sealed class Icons
{
internal static IconInfo Copy { get; } = new("\xE8C8");
internal static IconInfo Picture { get; } = new("\xE8B9");
internal static IconInfo Paste { get; } = new("\uE77F");
internal static IconInfo ClipboardList { get; } = new("\uF0E3");
}

View File

@@ -15,4 +15,19 @@
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" />
</ItemGroup>
<!-- String resources -->
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DependentUpon>Resources.resx</DependentUpon>
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
<Generator>PublicResXFileCodeGenerator</Generator>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@@ -24,8 +24,8 @@ internal sealed partial class ClipboardHistoryListPage : ListPage
{
clipboardHistory = [];
_defaultIconPath = string.Empty;
Icon = new("\uF0E3"); // ClipboardList icon
Name = "Clipboard History";
Icon = Icons.ClipboardList;
Name = Properties.Resources.clipboard_history_page_name;
Id = "com.microsoft.cmdpal.clipboardHistory";
ShowDetails = true;
@@ -113,7 +113,7 @@ internal sealed partial class ClipboardHistoryListPage : ListPage
{
// TODO GH #108 We need to figure out some logging
// Logger.LogError("Loading clipboard history failed", ex);
ExtensionHost.ShowStatus(new StatusMessage() { Message = "Loading clipboard history failed", State = MessageState.Error }, StatusContext.Page);
ExtensionHost.ShowStatus(new StatusMessage() { Message = Properties.Resources.clipboard_failed_to_load, State = MessageState.Error }, StatusContext.Page);
ExtensionHost.LogMessage(ex.ToString());
}
}

View File

@@ -0,0 +1,144 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace Microsoft.CmdPal.Ext.ClipboardHistory.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Ext.ClipboardHistory.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to Loading clipboard history failed.
/// </summary>
public static string clipboard_failed_to_load {
get {
return ResourceManager.GetString("clipboard_failed_to_load", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Open.
/// </summary>
public static string clipboard_history_page_name {
get {
return ResourceManager.GetString("clipboard_history_page_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Copied to clipboard.
/// </summary>
public static string copied_toast_text {
get {
return ResourceManager.GetString("copied_toast_text", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Copy.
/// </summary>
public static string copy_command_name {
get {
return ResourceManager.GetString("copy_command_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Copy, paste, and search items on the clipboard.
/// </summary>
public static string list_item_subtitle {
get {
return ResourceManager.GetString("list_item_subtitle", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Clipboard History.
/// </summary>
public static string list_item_title {
get {
return ResourceManager.GetString("list_item_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Paste.
/// </summary>
public static string paste_command_name {
get {
return ResourceManager.GetString("paste_command_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Pasting.
/// </summary>
public static string paste_toast_text {
get {
return ResourceManager.GetString("paste_toast_text", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Clipboard History.
/// </summary>
public static string provider_display_name {
get {
return ResourceManager.GetString("provider_display_name", resourceCulture);
}
}
}
}

View File

@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="copy_command_name" xml:space="preserve">
<value>Copy</value>
</data>
<data name="paste_command_name" xml:space="preserve">
<value>Paste</value>
</data>
<data name="paste_toast_text" xml:space="preserve">
<value>Pasting</value>
</data>
<data name="copied_toast_text" xml:space="preserve">
<value>Copied to clipboard</value>
</data>
<data name="list_item_title" xml:space="preserve">
<value>Clipboard History</value>
</data>
<data name="list_item_subtitle" xml:space="preserve">
<value>Copy, paste, and search items on the clipboard</value>
</data>
<data name="provider_display_name" xml:space="preserve">
<value>Clipboard History</value>
</data>
<data name="clipboard_history_page_name" xml:space="preserve">
<value>Open</value>
</data>
<data name="clipboard_failed_to_load" xml:space="preserve">
<value>Loading clipboard history failed</value>
</data>
</root>

View File

@@ -21,6 +21,8 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, System
private uint _queryCookie = 10;
private Func<string, bool> _suppressCallback;
public FallbackOpenFileItem()
: base(new NoOpCommand(), Resources.Indexer_Find_Path_fallback_display_title)
{
@@ -41,6 +43,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.
@@ -125,4 +138,9 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, System
_searchEngine.Dispose();
GC.SuppressFinalize(this);
}
public void SuppressFallbackWhen(Func<string, bool> callback)
{
_suppressCallback = callback;
}
}

View File

@@ -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<string, bool> callback)
{
_fallbackFileItem.SuppressFallbackWhen(callback);
}
}

View File

@@ -26,17 +26,17 @@ internal sealed partial class ExecuteItem : InvokableCommand
if (type == RunAsType.Administrator)
{
Name = Properties.Resources.cmd_run_as_administrator;
Icon = new IconInfo("\xE7EF"); // Admin Icon
Icon = Icons.RunAsAdmin;
}
else if (type == RunAsType.OtherUser)
{
Name = Properties.Resources.cmd_run_as_user;
Icon = new IconInfo("\xE7EE"); // User Icon
Icon = Icons.RunAsUser;
}
else
{
Name = Properties.Resources.generic_run_command;
Icon = new IconInfo("\uE751"); // Return Key Icon
Icon = Icons.RunV2;
}
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<ProcessStartInfo, Process?> 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)

View File

@@ -2,37 +2,196 @@
// 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 readonly Action<string>? _addToHistory;
private CancellationTokenSource? _cancellationTokenSource;
private Task? _currentUpdateTask;
public FallbackExecuteItem(SettingsManager settings)
: base(new ExecuteItem(string.Empty, settings), Resources.shell_command_display_title)
public FallbackExecuteItem(SettingsManager settings, Action<string>? addToHistory)
: base(new NoOpCommand(), 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.RunV2; // Defined in Icons.cs and contains the execute command icon.
Icon = Icons.RunV2;
_addToHistory = addToHistory;
}
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;
}
ShellHelpers.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, _addToHistory);
Title = exeItem.Title;
Subtitle = exeItem.Subtitle;
Icon = exeItem.Icon;
Command = exeItem.Command;
MoreCommands = exeItem.MoreCommands;
}
else if (pathIsDir)
{
var pathItem = new PathListItem(exe, query, _addToHistory);
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 OpenUrlWithHistoryCommand(searchText, _addToHistory) { 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;
}
ShellHelpers.ParseExecutableAndArgs(searchText, out var exe, out var args);
var exeExists = ShellListPageHelpers.FileExistInPath(exe, out var fullExePath);
var pathIsDir = Directory.Exists(exe);
return exeExists || pathIsDir;
}
}

View File

@@ -4,12 +4,15 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Shell.Commands;
using Microsoft.CmdPal.Ext.Shell.Pages;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Shell.Helpers;
@@ -26,7 +29,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,
@@ -38,7 +41,7 @@ public class ShellListPageHelpers
private List<ListItem> GetHistoryCmds(string cmd, ListItem result)
{
IEnumerable<ListItem?> history = _settings.Count.Where(o => o.Key.Contains(cmd, StringComparison.CurrentCultureIgnoreCase))
var history = _settings.Count.Where(o => o.Key.Contains(cmd, StringComparison.CurrentCultureIgnoreCase))
.OrderByDescending(o => o.Value)
.Select(m =>
{
@@ -66,7 +69,7 @@ public class ShellListPageHelpers
{
ArgumentNullException.ThrowIfNull(query);
List<ListItem> results = new List<ListItem>();
var results = new List<ListItem>();
var cmd = query;
if (string.IsNullOrEmpty(cmd))
{
@@ -101,7 +104,7 @@ public class ShellListPageHelpers
private List<ListItem> ResultsFromHistory()
{
IEnumerable<ListItem> history = _settings.Count.OrderByDescending(o => o.Value)
var history = _settings.Count.OrderByDescending(o => o.Value)
.Select(m => new ListItem(new ExecuteItem(m.Key, _settings))
{
Title = m.Key,
@@ -113,4 +116,89 @@ public class ShellListPageHelpers
return history.ToList();
}
internal static bool FileExistInPath(string filename)
{
return FileExistInPath(filename, out var _);
}
internal static bool FileExistInPath(string filename, out string fullPath, CancellationToken? token = null)
{
// TODO! remove this method and just use ShellHelpers.FileExistInPath directly
return ShellHelpers.FileExistInPath(filename, out fullPath, token ?? CancellationToken.None);
}
internal static ListItem? ListItemForCommandString(string query, Action<string>? addToHistory)
{
var li = new ListItem();
var searchText = query.Trim();
var expanded = Environment.ExpandEnvironmentVariables(searchText);
searchText = expanded;
if (string.IsNullOrEmpty(searchText) || string.IsNullOrWhiteSpace(searchText))
{
return null;
}
ShellHelpers.ParseExecutableAndArgs(searchText, out var exe, out var args);
var exeExists = false;
var pathIsDir = false;
var fullExePath = string.Empty;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
// 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);
},
CancellationToken.None); // Use None here since we're handling timeout differently
// Wait for either completion or timeout
pathResolutionTask.Wait(cts.Token);
}
catch (OperationCanceledException)
{
Debug.WriteLine("Operation was canceled.");
}
Debug.WriteLine($"Run: exeExists={exeExists}, pathIsDir={pathIsDir}");
if (exeExists)
{
// TODO we need to probably get rid of the settings for this provider entirely
var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, addToHistory);
li.Command = exeItem.Command;
li.Title = exeItem.Title;
li.Subtitle = exeItem.Subtitle;
li.Icon = exeItem.Icon;
li.MoreCommands = exeItem.MoreCommands;
}
else if (pathIsDir)
{
var pathItem = new PathListItem(exe, query, addToHistory);
li.Command = pathItem.Command;
li.Title = pathItem.Title;
li.Subtitle = pathItem.Subtitle;
li.Icon = pathItem.Icon;
li.MoreCommands = pathItem.MoreCommands;
}
else if (System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri))
{
li.Command = new OpenUrlWithHistoryCommand(searchText) { Result = CommandResult.Dismiss() };
li.Title = searchText;
}
else
{
return null;
}
return li;
}
}

View File

@@ -9,4 +9,10 @@ namespace Microsoft.CmdPal.Ext.Shell;
internal sealed class Icons
{
internal static IconInfo RunV2 { get; } = IconHelpers.FromRelativePath("Assets\\Run.svg");
internal static IconInfo Folder { get; } = new("📁");
internal static IconInfo RunAsAdmin { get; } = new("\xE7EF"); // admin
internal static IconInfo RunAsUser { get; } = new("\uE7EE"); // user
}

View File

@@ -14,7 +14,12 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
<ProjectReference Include="..\..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DependentUpon>Resources.resx</DependentUpon>

View File

@@ -0,0 +1,30 @@
// 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 OpenUrlWithHistoryCommand : OpenUrlCommand
{
private readonly Action<string>? _addToHistory;
private readonly string _url;
public OpenUrlWithHistoryCommand(string url, Action<string>? addToHistory = null)
: base(url)
{
_addToHistory = addToHistory;
_url = url;
}
public override CommandResult Invoke()
{
_addToHistory?.Invoke(_url);
var result = base.Invoke();
return result;
}
}

View File

@@ -0,0 +1,115 @@
// 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<IconInfo> _icon;
private readonly Action<string>? _addToHistory;
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;
private string FullString => string.IsNullOrEmpty(_args) ? Exe : $"{Exe} {_args}";
public RunExeItem(string exe, string args, string fullExePath, Action<string>? addToHistory)
{
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<IconInfo>(() =>
{
var t = FetchIcon();
t.Wait();
return t.Result;
});
_addToHistory = addToHistory;
UpdateArgs(args);
MoreCommands = [
new CommandContextItem(
new AnonymousCommand(RunAsAdmin)
{
Name = Properties.Resources.cmd_run_as_administrator,
Icon = Icons.RunAsAdmin,
}),
new CommandContextItem(
new AnonymousCommand(RunAsOther)
{
Name = Properties.Resources.cmd_run_as_user,
Icon = Icons.RunAsUser,
}),
];
}
internal void UpdateArgs(string args)
{
_args = args;
Title = string.IsNullOrEmpty(_args) ? Exe : Exe + " " + _args; // todo! you're smarter than this
}
public async Task<IconInfo> 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()
{
_addToHistory?.Invoke(FullString);
ShellHelpers.OpenInShell(FullExePath, _args);
}
public void RunAsAdmin()
{
_addToHistory?.Invoke(FullString);
ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.Administrator);
}
public void RunAsOther()
{
_addToHistory?.Invoke(FullString);
ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.OtherUser);
}
}

View File

@@ -2,6 +2,13 @@
// 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.Common.Services;
using Microsoft.CmdPal.Ext.Shell.Helpers;
using Microsoft.CmdPal.Ext.Shell.Properties;
using Microsoft.CommandPalette.Extensions;
@@ -9,20 +16,480 @@ 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<ListItem> _topLevelItems = [];
private readonly Dictionary<string, ListItem> _historyItems = [];
private readonly List<ListItem> _currentHistoryItems = [];
private readonly IRunHistoryService _historyService;
private RunExeItem? _exeItem;
private List<ListItem> _pathItems = [];
private ListItem? _uriItem;
private CancellationTokenSource? _cancellationTokenSource;
private Task? _currentSearchTask;
private bool _loadedInitialHistory;
public ShellListPage(SettingsManager settingsManager, IRunHistoryService runHistoryService, bool addBuiltins = false)
{
Icon = Icons.RunV2;
Id = "com.microsoft.cmdpal.shell";
Name = Resources.cmd_plugin_name;
PlaceholderText = Resources.list_placeholder_text;
_helper = new(settingsManager);
_historyService = runHistoryService;
EmptyContent = new CommandItem()
{
Title = Resources.cmd_plugin_name,
Icon = Icons.RunV2,
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;
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;
_currentHistoryItems.Clear();
_currentHistoryItems.AddRange(_historyItems.Values);
return;
}
ShellHelpers.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)
{
IsLoading = true;
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;
}
var histItemsNotInSearch =
_historyItems
.Where(kv => !kv.Key.Equals(newSearch, StringComparison.OrdinalIgnoreCase));
if (_exeItem != null)
{
// If we have an exe item, we want to remove it from the history items
histItemsNotInSearch = histItemsNotInSearch
.Where(kv => !kv.Value.Title.Equals(_exeItem.Title, StringComparison.OrdinalIgnoreCase));
}
if (_uriItem != null)
{
// If we have an uri item, we want to remove it from the history items
histItemsNotInSearch = histItemsNotInSearch
.Where(kv => !kv.Value.Title.Equals(_uriItem.Title, StringComparison.OrdinalIgnoreCase));
}
// Filter the history items based on the search text
var filterHistory = (string query, KeyValuePair<string, ListItem> pair) =>
{
// Fuzzy search on the key (command string)
var score = StringMatcher.FuzzySearch(query, pair.Key).Score;
return score;
};
var filteredHistory =
ListHelpers.FilterList<KeyValuePair<string, ListItem>>(
histItemsNotInSearch,
searchText,
filterHistory)
.Select(p => p.Value);
_currentHistoryItems.Clear();
_currentHistoryItems.AddRange(filteredHistory);
// Final cancellation check
cancellationToken.ThrowIfCancellationRequested();
}
private static ListItem PathToListItem(string path, string originalPath, string args = "", Action<string>? addToHistory = null)
{
var pathItem = new PathListItem(path, originalPath, addToHistory);
// Is this path an executable? If so, then make a RunExeItem
if (IsExecutable(path))
{
var exeItem = new RunExeItem(Path.GetFileName(path), args, path, addToHistory);
exeItem.MoreCommands = [
.. exeItem.MoreCommands,
.. pathItem.MoreCommands];
return exeItem;
}
return pathItem;
}
public override IListItem[] GetItems()
{
if (!_loadedInitialHistory)
{
LoadInitialHistory();
}
var filteredTopLevel = ListHelpers.FilterList(_topLevelItems, SearchText);
List<ListItem> uriItems = _uriItem != null ? [_uriItem] : [];
List<ListItem> exeItems = _exeItem != null ? [_exeItem] : [];
return
exeItems
.Concat(filteredTopLevel)
.Concat(_currentHistoryItems)
.Concat(_pathItems)
.Concat(uriItems)
.ToArray();
}
internal static RunExeItem CreateExeItem(string exe, string args, string fullExePath, Action<string>? addToHistory)
{
// PathToListItem will return a RunExeItem if it can find a executable.
// It will ALSO add the file search commands to the RunExeItem.
return PathToListItem(fullExePath, exe, args, addToHistory) as RunExeItem ??
new RunExeItem(exe, args, fullExePath, addToHistory);
}
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, AddToHistory);
}
}
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<string>();
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 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,
};
}
private void LoadInitialHistory()
{
var hist = _historyService.GetRunHistory();
var histItems = hist
.Select(h => (h, ShellListPageHelpers.ListItemForCommandString(h, AddToHistory)))
.Where(tuple => tuple.Item2 != null)
.Select(tuple => (tuple.h, tuple.Item2!))
.ToList();
// Add all the history items to the _historyItems dictionary
foreach (var (h, item) in histItems)
{
_historyItems[h] = item;
}
_currentHistoryItems.Clear();
_currentHistoryItems.AddRange(histItems.Select(tuple => tuple.Item2));
_loadedInitialHistory = true;
}
internal void AddToHistory(string commandString)
{
if (string.IsNullOrWhiteSpace(commandString))
{
return; // Do not add empty or whitespace items
}
_historyService.AddRunHistoryItem(commandString);
LoadInitialHistory();
DoUpdateSearchText(SearchText);
}
public void Dispose()
{
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
}
}

View File

@@ -0,0 +1,69 @@
// 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<IconInfo> _icon;
private readonly bool _isDirectory;
public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; }
public PathListItem(string path, string originalDir, Action<string>? addToHistory)
: base(new OpenUrlWithHistoryCommand(path, addToHistory))
{
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 }) { }
];
// TODO: Follow-up during 0.4. Add the indexer commands here.
// 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<IconInfo>(() =>
{
var iconStream = ThumbnailHelper.GetThumbnail(path).Result;
var icon = iconStream != null ? IconInfo.FromStream(iconStream) :
_isDirectory ? Icons.Folder : Icons.RunV2;
return icon;
});
}
}

View File

@@ -132,6 +132,15 @@ namespace Microsoft.CmdPal.Ext.Shell.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Copy path.
/// </summary>
public static string copy_path_command_name {
get {
return ResourceManager.GetString("copy_path_command_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Find and run the executable file.
/// </summary>

View File

@@ -190,4 +190,7 @@
<data name="shell_command_display_title" xml:space="preserve">
<value>Run commands</value>
</data>
<data name="copy_path_command_name" xml:space="preserve">
<value>Copy path</value>
</data>
</root>

View File

@@ -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 Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Ext.Shell.Helpers;
using Microsoft.CmdPal.Ext.Shell.Pages;
using Microsoft.CmdPal.Ext.Shell.Properties;
@@ -13,19 +14,26 @@ namespace Microsoft.CmdPal.Ext.Shell;
public partial class ShellCommandsProvider : CommandProvider
{
private readonly CommandItem _shellPageItem;
private readonly SettingsManager _settingsManager = new();
private readonly FallbackCommandItem _fallbackItem;
public ShellCommandsProvider()
private readonly SettingsManager _settingsManager = new();
private readonly ShellListPage _shellListPage;
private readonly FallbackCommandItem _fallbackItem;
private readonly IRunHistoryService _historyService;
public ShellCommandsProvider(IRunHistoryService runHistoryService)
{
_historyService = runHistoryService;
Id = "Run";
DisplayName = Resources.cmd_plugin_name;
Icon = Icons.RunV2;
Settings = _settingsManager.Settings;
_fallbackItem = new FallbackExecuteItem(_settingsManager);
_shellListPage = new ShellListPage(_settingsManager, _historyService);
_shellPageItem = new CommandItem(new ShellListPage(_settingsManager))
_fallbackItem = new FallbackExecuteItem(_settingsManager, _shellListPage.AddToHistory);
_shellPageItem = new CommandItem(_shellListPage)
{
Icon = Icons.RunV2,
Title = Resources.shell_command_name,
@@ -39,4 +47,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);
}

View File

@@ -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), 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 = IconHelpers.FromRelativePath("Assets\\WebSearch.png");
}
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);
}
}

View File

@@ -7,7 +7,6 @@ using System.Globalization;
using System.Text;
using Microsoft.CmdPal.Ext.WebSearch.Commands;
using Microsoft.CmdPal.Ext.WebSearch.Helpers;
using Microsoft.CmdPal.Ext.WebSearch.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo;
@@ -33,6 +32,7 @@ internal sealed partial class FallbackOpenURLItem : FallbackCommandItem
{
if (!IsValidUrl(query))
{
_executeItem.Name = string.Empty;
Title = string.Empty;
Subtitle = string.Empty;
return;

View File

@@ -239,5 +239,14 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties {
return ResourceManager.GetString("settings_page_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Search for &quot;{0}&quot;.
/// </summary>
public static string web_search_fallback_subtitle {
get {
return ResourceManager.GetString("web_search_fallback_subtitle", resourceCulture);
}
}
}
}

View File

@@ -178,4 +178,7 @@
<data name="settings_page_name" xml:space="preserve">
<value>Settings</value>
</data>
<data name="web_search_fallback_subtitle" xml:space="preserve">
<value>Search for "{0}"</value>
</data>
</root>

View File

@@ -43,7 +43,6 @@ public partial class ListHelpers
}
public static IEnumerable<T> FilterList<T>(IEnumerable<T> items, string query, Func<string, T, int> scoreFunction)
where T : class
{
var scores = items
.Select(li => new Scored<T>() { Item = li, Score = scoreFunction(query, li) })

View File

@@ -4,7 +4,7 @@
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public sealed partial class OpenUrlCommand : InvokableCommand
public partial class OpenUrlCommand : InvokableCommand
{
private readonly string _target;

View File

@@ -59,4 +59,101 @@ public static class ShellHelpers
Administrator,
OtherUser,
}
/// <summary>
/// Parses the input string to extract the executable and its arguments.
/// </summary>
public 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;
}
}
}
/// <summary>
/// Checks if a file exists somewhere in the PATH.
/// If it exists, returns the full path to the file in the out parameter.
/// If it does not exist, returns false and the out parameter is set to an empty string.
/// <param name="filename">The name of the file to check.</param>
/// <param name="fullPath">The full path to the file if it exists, otherwise an empty string.</param>
/// <param name="token">An optional cancellation token to cancel the operation.</param>
/// <returns>True if the file exists in the PATH, otherwise false.</returns>
/// </summary>
public 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)
{
foreach (var path in values.Split(';'))
{
var path1 = Path.Combine(path, filename);
if (File.Exists(path1))
{
fullPath = Path.GetFullPath(path1);
return true;
}
token?.ThrowIfCancellationRequested();
var path2 = Path.Combine(path, filename + ".exe");
if (File.Exists(path2))
{
fullPath = Path.GetFullPath(path2);
return true;
}
token?.ThrowIfCancellationRequested();
}
return false;
}
else
{
return false;
}
}
}
}